5493 lines
234 KiB
TypeScript
5493 lines
234 KiB
TypeScript
|
|
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
|
||
|
|
// @ts-nocheck
|
||
|
|
import {
|
||
|
|
buildSpritePreviewDataUrl,
|
||
|
|
buildSpritesPayloadFromImagesPayload,
|
||
|
|
buildTilesPayloadFromImagesPayload,
|
||
|
|
fetchJsonOrThrow,
|
||
|
|
mergeImagesPayloadWithSpritesPayload,
|
||
|
|
mergeImagesPayloadWithTilesPayload,
|
||
|
|
normalizeImageRecordForSave,
|
||
|
|
normalizeTileRecordForSave,
|
||
|
|
} from "../editorCore";
|
||
|
|
import {
|
||
|
|
buildSpriteCatalog,
|
||
|
|
buildTileCatalogById,
|
||
|
|
DEFAULT_MAP_BACKGROUND_COLOR,
|
||
|
|
DEFAULT_TILE_COLOR,
|
||
|
|
} from "../components/mapEditorShared";
|
||
|
|
import type { MapEditorPopupBootstrap } from "./bootstrap";
|
||
|
|
import {
|
||
|
|
cacheStandaloneWorldEditorPopupBootstrap,
|
||
|
|
clearMapEditorPopupBootstrap,
|
||
|
|
createMapEditorPopupToken,
|
||
|
|
loadStandaloneWorldEditorPopupBootstrap,
|
||
|
|
registerMapEditorPopupBootstrap,
|
||
|
|
} from "./bootstrap";
|
||
|
|
import { buildChunkKey, worldToChunkCoord, worldToLocalCoord } from "../worldChunking";
|
||
|
|
import {
|
||
|
|
getCenteredMapEditorPopupBounds,
|
||
|
|
MAP_EDITOR_POPUP_BOUNDS_STORAGE_KEY,
|
||
|
|
openStandaloneMapHeightViewer,
|
||
|
|
persistMapEditorPopupBounds,
|
||
|
|
} from "./windowing";
|
||
|
|
import { createHistoryController } from "./historyController";
|
||
|
|
import { createHistoryStateStore } from "./historyStateStore";
|
||
|
|
import { createInteractionController } from "./interactionController";
|
||
|
|
import { createImportController } from "./importController";
|
||
|
|
import { createMapDocumentController } from "./mapDocumentController";
|
||
|
|
import { createMapDocumentStore } from "./mapDocumentStore";
|
||
|
|
import { createNpcController } from "./npcController";
|
||
|
|
import {
|
||
|
|
createPanelFolderLayoutFolder,
|
||
|
|
deletePanelFolderLayoutFolder,
|
||
|
|
movePanelFolderLayoutNode,
|
||
|
|
renamePanelFolderLayoutFolder,
|
||
|
|
togglePanelFolderLayoutFolder,
|
||
|
|
} from "./panelFolders";
|
||
|
|
import { createEditorUiStore } from "./editorUiStore";
|
||
|
|
import { createChangelogSplashWindowController } from "./changelogSplashWindowController";
|
||
|
|
import { createEntityEditorWindowController } from "./entityEditorWindowController";
|
||
|
|
import { createEngineOverrideWindowController } from "./engineOverrideWindowController";
|
||
|
|
import {
|
||
|
|
getEngineOverrideValue,
|
||
|
|
normalizeEngineOverrideEntries,
|
||
|
|
} from "./engineOverrides";
|
||
|
|
import { createPersistenceController } from "./persistenceController";
|
||
|
|
import { createPopupSessionStore } from "./popupSessionStore";
|
||
|
|
import { createRenderController } from "./renderController";
|
||
|
|
import { createSidebarController } from "./sidebarController";
|
||
|
|
import { createStatusLogWindowController } from "./statusLogWindowController";
|
||
|
|
import { createTileArtEditorWindowController } from "./tileArtEditorWindowController";
|
||
|
|
import { createToolWindowController } from "./toolWindowController";
|
||
|
|
import { createWorldOverviewWindowController } from "./worldOverviewWindowController";
|
||
|
|
import { createDebouncedCallback } from "./debounce";
|
||
|
|
import {
|
||
|
|
buildImageRecordFromSpriteRecord,
|
||
|
|
buildImageRecordFromTileRecord,
|
||
|
|
buildTileRecordFromImageRecord,
|
||
|
|
getImageRecordFromPayload,
|
||
|
|
normalizeGraphicRoles,
|
||
|
|
normalizeImagesPayloadSnapshot,
|
||
|
|
} from "./graphicsDocumentHelpers";
|
||
|
|
import {
|
||
|
|
DEFAULT_MAP_EDITOR_THEME_PRESET,
|
||
|
|
applyMapEditorThemePreset,
|
||
|
|
getMapEditorThemeLabel,
|
||
|
|
getDefaultEditorSettings,
|
||
|
|
normalizeEditorSettings,
|
||
|
|
normalizeMapEditorThemePreset,
|
||
|
|
persistEditorSettings,
|
||
|
|
} from "./themePresets";
|
||
|
|
import { createAtTooltip } from "./tooltip";
|
||
|
|
|
||
|
|
function cloneValue(value) {
|
||
|
|
if (typeof structuredClone === "function") {
|
||
|
|
return structuredClone(value);
|
||
|
|
}
|
||
|
|
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
function createFilledRows(width, height, fillChar) {
|
||
|
|
return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1)));
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeRowSegment(rows, y, x, segment) {
|
||
|
|
if (!Array.isArray(rows) || !segment) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const targetY = Math.floor(Number(y) || 0);
|
||
|
|
if (targetY < 0 || targetY >= rows.length) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const safeX = Math.max(0, Math.floor(Number(x) || 0));
|
||
|
|
const sourceRow = String(rows[targetY] || "");
|
||
|
|
const paddedRow = sourceRow.length >= safeX
|
||
|
|
? sourceRow
|
||
|
|
: (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length)));
|
||
|
|
const before = paddedRow.slice(0, safeX);
|
||
|
|
const afterStart = safeX + segment.length;
|
||
|
|
const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : "";
|
||
|
|
rows[targetY] = before + segment + after;
|
||
|
|
}
|
||
|
|
|
||
|
|
function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) {
|
||
|
|
const layerMap = new Map();
|
||
|
|
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||
|
|
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||
|
|
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||
|
|
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
|
||
|
|
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
|
||
|
|
const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
|
||
|
|
rawLayers.forEach((rawLayer) => {
|
||
|
|
const layerNumber = Number(rawLayer?.layer) || 0;
|
||
|
|
const fillChar = layerNumber === 0 ? "." : " ";
|
||
|
|
if (!layerMap.has(layerNumber)) {
|
||
|
|
layerMap.set(layerNumber, {
|
||
|
|
layer: layerNumber,
|
||
|
|
name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
|
||
|
|
rows: createFilledRows(worldWidth, worldHeight, fillChar),
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const targetLayer = layerMap.get(layerNumber);
|
||
|
|
const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : [];
|
||
|
|
sourceRows.forEach((row, localY) => {
|
||
|
|
const targetY = offsetY + localY;
|
||
|
|
if (targetY < 0 || targetY >= targetLayer.rows.length) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const maxWidth = Math.max(0, worldWidth - offsetX);
|
||
|
|
writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth));
|
||
|
|
});
|
||
|
|
const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds)
|
||
|
|
? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean)
|
||
|
|
: [];
|
||
|
|
targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds]));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (!layerMap.has(0)) {
|
||
|
|
layerMap.set(0, {
|
||
|
|
layer: 0,
|
||
|
|
rows: createFilledRows(worldWidth, worldHeight, "."),
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) {
|
||
|
|
const patches = [];
|
||
|
|
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||
|
|
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||
|
|
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||
|
|
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
|
||
|
|
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
|
||
|
|
const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : [];
|
||
|
|
rawHeightLayers.forEach((entry, index) => {
|
||
|
|
const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`;
|
||
|
|
patches.push({
|
||
|
|
id: String(entry?.id || fallbackId).trim() || fallbackId,
|
||
|
|
name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined,
|
||
|
|
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
|
||
|
|
x: offsetX + Math.max(0, Number(entry?.x) || 0),
|
||
|
|
y: offsetY + Math.max(0, Number(entry?.y) || 0),
|
||
|
|
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
return patches.sort((a, b) => {
|
||
|
|
if (a.z !== b.z) {
|
||
|
|
return a.z - b.z;
|
||
|
|
}
|
||
|
|
return String(a.name || a.id).localeCompare(String(b.name || b.id));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildWorldLayerMetadata(chunks) {
|
||
|
|
const layerMap = new Map();
|
||
|
|
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||
|
|
const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
|
||
|
|
rawLayers.forEach((rawLayer) => {
|
||
|
|
const layerNumber = Number(rawLayer?.layer) || 0;
|
||
|
|
if (layerMap.has(layerNumber)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
layerMap.set(layerNumber, {
|
||
|
|
layer: layerNumber,
|
||
|
|
name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
|
||
|
|
rows: [],
|
||
|
|
instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (!layerMap.has(0)) {
|
||
|
|
layerMap.set(0, {
|
||
|
|
layer: 0,
|
||
|
|
rows: [],
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) {
|
||
|
|
layerMap.set(1, {
|
||
|
|
layer: 1,
|
||
|
|
rows: [],
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
function getContentRecords(payload, key) {
|
||
|
|
const records = payload && Array.isArray(payload[key]) ? payload[key] : [];
|
||
|
|
return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildSpriteCatalogFromBootstrap(bootstrap) {
|
||
|
|
const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites");
|
||
|
|
if (spriteRecords.length > 0) {
|
||
|
|
return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl);
|
||
|
|
}
|
||
|
|
return cloneValue(bootstrap?.spriteCatalog) || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildTileCatalogByIdFromBootstrap(bootstrap) {
|
||
|
|
const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles");
|
||
|
|
if (tileRecords.length > 0) {
|
||
|
|
return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl);
|
||
|
|
}
|
||
|
|
return cloneValue(bootstrap?.tileCatalogById) || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildNpcOverlaysFromWorldChunks(chunks, spriteCatalog, chunkWidth, chunkHeight, originChunkX, originChunkY) {
|
||
|
|
return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => {
|
||
|
|
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||
|
|
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||
|
|
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
|
||
|
|
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
|
||
|
|
const instances = Array.isArray(chunk?.instances) ? chunk.instances : [];
|
||
|
|
return instances
|
||
|
|
.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
|
||
|
|
.map((entry) => {
|
||
|
|
const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
|
||
|
|
? cloneValue(entry.record)
|
||
|
|
: {};
|
||
|
|
const spriteId = String(record.spriteId || entry.spriteId || "").trim();
|
||
|
|
const spriteEntry = spriteCatalog[spriteId] || null;
|
||
|
|
const overlayX = offsetX + Math.max(0, Number(entry.x) || 0);
|
||
|
|
const overlayY = offsetY + Math.max(0, Number(entry.y) || 0);
|
||
|
|
record.position = {
|
||
|
|
x: overlayX,
|
||
|
|
y: overlayY,
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
id: String(entry.id || "").trim(),
|
||
|
|
layer: Number(entry.layer) || 0,
|
||
|
|
name: String(record.name || entry.id || "NPC"),
|
||
|
|
spriteId,
|
||
|
|
x: overlayX,
|
||
|
|
y: overlayY,
|
||
|
|
dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
|
||
|
|
spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28,
|
||
|
|
spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28,
|
||
|
|
opacity: spriteEntry ? spriteEntry.opacity : 1,
|
||
|
|
record,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((entry) => entry.id);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256;
|
||
|
|
const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4;
|
||
|
|
const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
|
||
|
|
|
||
|
|
export function startMapEditorPopup(bootstrap: MapEditorPopupBootstrap, initialEditorSettings: unknown = getDefaultEditorSettings()): void {
|
||
|
|
function normalizeMapBackgroundColor(value, fallback) {
|
||
|
|
const f = fallback || DEFAULT_MAP_BACKGROUND_COLOR;
|
||
|
|
const raw = String(value || "").trim();
|
||
|
|
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : f;
|
||
|
|
}
|
||
|
|
|
||
|
|
const baseTileSize = Math.max(8, Number(bootstrap.tileSize) || 32);
|
||
|
|
const minZoomLevel = 0.5;
|
||
|
|
const maxZoomLevel = 4;
|
||
|
|
let zoomLevel = 1;
|
||
|
|
let tileSize = baseTileSize;
|
||
|
|
let currentMapId = String(bootstrap.mapId || "").trim();
|
||
|
|
let currentBaseRows = Array.isArray(bootstrap.baseRows) ? bootstrap.baseRows.map((row) => String(row ?? "")) : [];
|
||
|
|
const worldRuntimeState = {
|
||
|
|
enabled: !!String(bootstrap.worldId || bootstrap.mapId || "").trim(),
|
||
|
|
worldId: String(bootstrap.worldId || bootstrap.mapId || "").trim(),
|
||
|
|
worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World",
|
||
|
|
defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(),
|
||
|
|
heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)),
|
||
|
|
chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32),
|
||
|
|
chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32),
|
||
|
|
chunkRadius: Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)),
|
||
|
|
originChunkX: Math.floor(Number(bootstrap.worldOriginChunkX) || 0),
|
||
|
|
originChunkY: Math.floor(Number(bootstrap.worldOriginChunkY) || 0),
|
||
|
|
tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0),
|
||
|
|
tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0),
|
||
|
|
spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0),
|
||
|
|
spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0),
|
||
|
|
centerChunkX: Math.floor(Number(bootstrap.worldOriginChunkX) || 0) + Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)),
|
||
|
|
centerChunkY: Math.floor(Number(bootstrap.worldOriginChunkY) || 0) + Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)),
|
||
|
|
sourceChunks: Array.isArray(bootstrap.sourceChunks)
|
||
|
|
? bootstrap.sourceChunks.map((entry) => ({
|
||
|
|
chunkX: Math.floor(Number(entry?.chunkX) || 0),
|
||
|
|
chunkY: Math.floor(Number(entry?.chunkY) || 0),
|
||
|
|
}))
|
||
|
|
: [],
|
||
|
|
bookmarks: Array.isArray(bootstrap.worldBookmarks)
|
||
|
|
? bootstrap.worldBookmarks.map((entry, index) => ({
|
||
|
|
id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`,
|
||
|
|
label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`,
|
||
|
|
x: Math.floor(Number(entry?.x) || 0),
|
||
|
|
y: Math.floor(Number(entry?.y) || 0),
|
||
|
|
}))
|
||
|
|
: [],
|
||
|
|
chunkCache: new Map(),
|
||
|
|
dirtyChunkKeys: new Set(),
|
||
|
|
pendingNeighborhoodFetches: new Map(),
|
||
|
|
prefetchedNeighborhoodKeys: new Set(),
|
||
|
|
pendingLoadKey: "",
|
||
|
|
pendingLoadPromise: null,
|
||
|
|
requestSerial: 0,
|
||
|
|
documentDirty: false,
|
||
|
|
};
|
||
|
|
function isWorldModeActive() {
|
||
|
|
return worldRuntimeState.enabled && !!worldRuntimeState.worldId;
|
||
|
|
}
|
||
|
|
const defaultTileColor = DEFAULT_TILE_COLOR;
|
||
|
|
const tileColors = cloneValue(bootstrap.tileColors) || {};
|
||
|
|
let graphicsVisualRevision = 0;
|
||
|
|
function applyGraphicsVisualRevision(dataUrl, revision = graphicsVisualRevision) {
|
||
|
|
const normalizedDataUrl = String(dataUrl || "").trim();
|
||
|
|
if (!normalizedDataUrl) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const safeRevision = Math.max(0, Math.floor(Number(revision) || 0));
|
||
|
|
const baseUrl = normalizedDataUrl.replace(/#gfxrev=\d+$/, "");
|
||
|
|
return `${baseUrl}#gfxrev=${safeRevision}`;
|
||
|
|
}
|
||
|
|
function applyTileCatalogVisualRevision(catalog, revision = graphicsVisualRevision) {
|
||
|
|
const nextCatalog = {};
|
||
|
|
Object.entries(catalog || {}).forEach(([entryId, entry]) => {
|
||
|
|
nextCatalog[entryId] = {
|
||
|
|
...(entry || {}),
|
||
|
|
dataUrl: applyGraphicsVisualRevision(entry?.dataUrl, revision),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return nextCatalog;
|
||
|
|
}
|
||
|
|
function applySpriteCatalogVisualRevision(catalog, revision = graphicsVisualRevision) {
|
||
|
|
const nextCatalog = {};
|
||
|
|
Object.entries(catalog || {}).forEach(([entryId, entry]) => {
|
||
|
|
nextCatalog[entryId] = {
|
||
|
|
...(entry || {}),
|
||
|
|
dataUrl: applyGraphicsVisualRevision(entry?.dataUrl, revision),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return nextCatalog;
|
||
|
|
}
|
||
|
|
const tileCatalogById = applyTileCatalogVisualRevision(buildTileCatalogByIdFromBootstrap(bootstrap));
|
||
|
|
function normalizeBackgroundTileId(value) {
|
||
|
|
const normalizedId = String(value || "").trim();
|
||
|
|
return normalizedId && tileCatalogById[normalizedId] ? normalizedId : "";
|
||
|
|
}
|
||
|
|
function normalizeWorldBookmarkEntry(entry, fallbackIndex) {
|
||
|
|
const fallbackId = `poi_${Math.max(1, Number(fallbackIndex) || 1)}`;
|
||
|
|
const fallbackLabel = `POI ${Math.max(1, Number(fallbackIndex) || 1)}`;
|
||
|
|
return {
|
||
|
|
id: String(entry?.id || fallbackId).trim() || fallbackId,
|
||
|
|
label: String(entry?.label || entry?.id || fallbackLabel).trim() || fallbackLabel,
|
||
|
|
x: Math.floor(Number(entry?.x) || 0),
|
||
|
|
y: Math.floor(Number(entry?.y) || 0),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function cloneWorldBookmarks(source) {
|
||
|
|
return (Array.isArray(source) ? source : worldRuntimeState.bookmarks)
|
||
|
|
.map((entry, index) => normalizeWorldBookmarkEntry(entry, index + 1));
|
||
|
|
}
|
||
|
|
function applyWorldBookmarkState(bookmarks) {
|
||
|
|
worldRuntimeState.bookmarks = cloneWorldBookmarks(bookmarks);
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return cloneWorldBookmarks();
|
||
|
|
}
|
||
|
|
function captureWorldBookmarkState() {
|
||
|
|
return cloneWorldBookmarks();
|
||
|
|
}
|
||
|
|
function getWorldBookmarks() {
|
||
|
|
return cloneWorldBookmarks();
|
||
|
|
}
|
||
|
|
function getInitialWorldViewTile() {
|
||
|
|
const firstBookmark = cloneWorldBookmarks()[0] || null;
|
||
|
|
if (firstBookmark) {
|
||
|
|
return {
|
||
|
|
worldTileX: Math.floor(Number(firstBookmark.x) || 0),
|
||
|
|
worldTileY: Math.floor(Number(firstBookmark.y) || 0),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
worldTileX: worldRuntimeState.spawnX,
|
||
|
|
worldTileY: worldRuntimeState.spawnY,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function buildNextWorldBookmarkId() {
|
||
|
|
const existingIds = new Set(cloneWorldBookmarks().map((entry) => String(entry.id || "").trim()).filter(Boolean));
|
||
|
|
let nextIndex = existingIds.size + 1;
|
||
|
|
let nextId = `poi_${nextIndex}`;
|
||
|
|
while (existingIds.has(nextId)) {
|
||
|
|
nextIndex += 1;
|
||
|
|
nextId = `poi_${nextIndex}`;
|
||
|
|
}
|
||
|
|
return nextId;
|
||
|
|
}
|
||
|
|
function createWorldBookmark(worldTileX, worldTileY, label) {
|
||
|
|
const nextEntry = normalizeWorldBookmarkEntry({
|
||
|
|
id: buildNextWorldBookmarkId(),
|
||
|
|
label,
|
||
|
|
x: worldTileX,
|
||
|
|
y: worldTileY,
|
||
|
|
}, worldRuntimeState.bookmarks.length + 1);
|
||
|
|
worldRuntimeState.bookmarks = [...cloneWorldBookmarks(), nextEntry];
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return nextEntry;
|
||
|
|
}
|
||
|
|
function renameWorldBookmark(bookmarkId, nextLabel) {
|
||
|
|
const normalizedId = String(bookmarkId || "").trim();
|
||
|
|
if (!normalizedId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const safeLabel = String(nextLabel || "").trim();
|
||
|
|
let nextEntry = null;
|
||
|
|
worldRuntimeState.bookmarks = cloneWorldBookmarks().map((entry) => {
|
||
|
|
if (String(entry.id || "").trim() !== normalizedId) {
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
nextEntry = {
|
||
|
|
...entry,
|
||
|
|
label: safeLabel || entry.label,
|
||
|
|
};
|
||
|
|
return nextEntry;
|
||
|
|
});
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return nextEntry;
|
||
|
|
}
|
||
|
|
function deleteWorldBookmark(bookmarkId) {
|
||
|
|
const normalizedId = String(bookmarkId || "").trim();
|
||
|
|
if (!normalizedId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const existing = cloneWorldBookmarks();
|
||
|
|
const nextEntry = existing.find((entry) => String(entry.id || "").trim() === normalizedId) || null;
|
||
|
|
if (!nextEntry) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
worldRuntimeState.bookmarks = existing.filter((entry) => String(entry.id || "").trim() !== normalizedId);
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return nextEntry;
|
||
|
|
}
|
||
|
|
function buildMergedTileCatalog() {
|
||
|
|
const merged = {};
|
||
|
|
Object.entries(tileColors).forEach(([symbol, color]) => {
|
||
|
|
merged[symbol] = {
|
||
|
|
id: symbol,
|
||
|
|
symbol,
|
||
|
|
name: symbol === " " ? "Space" : symbol,
|
||
|
|
color,
|
||
|
|
dataUrl: null,
|
||
|
|
width: 1,
|
||
|
|
height: 1,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
Object.values(tileCatalogById || {}).forEach((entry) => {
|
||
|
|
if (!entry || typeof entry !== "object") {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const symbol = String((entry && entry.symbol) || "").charAt(0);
|
||
|
|
if (!symbol) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const existing = merged[symbol] || {
|
||
|
|
id: symbol,
|
||
|
|
symbol,
|
||
|
|
name: symbol === " " ? "Space" : symbol,
|
||
|
|
color: defaultTileColor,
|
||
|
|
dataUrl: null,
|
||
|
|
width: 1,
|
||
|
|
height: 1,
|
||
|
|
};
|
||
|
|
merged[symbol] = {
|
||
|
|
...existing,
|
||
|
|
...entry,
|
||
|
|
symbol,
|
||
|
|
color: String((entry && entry.color) || existing.color || defaultTileColor),
|
||
|
|
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : existing.rows,
|
||
|
|
pixelScale: Math.max(1, Number(entry?.pixelScale) || Number(existing.pixelScale) || 1),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return merged;
|
||
|
|
}
|
||
|
|
const tileCatalog = buildMergedTileCatalog();
|
||
|
|
const contentByType = cloneValue(bootstrap.contentByType) || {};
|
||
|
|
const spriteCatalog = applySpriteCatalogVisualRevision(buildSpriteCatalogFromBootstrap(bootstrap));
|
||
|
|
const defaultNpcTemplate = cloneValue(bootstrap.defaultNpcTemplate) || {};
|
||
|
|
const apiBase = String(bootstrap.apiBase || "").replace(/\/+$/, "");
|
||
|
|
function deriveHistoryStorageKey(mapIdValue) {
|
||
|
|
return "content-editor-v2:map-history:v2:" + String(mapIdValue || "").trim();
|
||
|
|
}
|
||
|
|
const layerListEl = document.getElementById("layerList");
|
||
|
|
const paintPaletteEl = document.getElementById("paintPalette");
|
||
|
|
const instancePaletteEl = document.getElementById("instancePalette");
|
||
|
|
const npcListEl = document.getElementById("npcList");
|
||
|
|
const newNpcBtn = document.getElementById("newNpcBtn");
|
||
|
|
const newTileFolderBtn = document.getElementById("newTileFolderBtn");
|
||
|
|
const newTileBtn = document.getElementById("newTileBtn");
|
||
|
|
const tileSearchModeBtn = document.getElementById("tileSearchModeBtn");
|
||
|
|
const graphicsTilesBtn = document.getElementById("graphicsTilesBtn");
|
||
|
|
const graphicsSpritesBtn = document.getElementById("graphicsSpritesBtn");
|
||
|
|
const graphicsOtherBtn = document.getElementById("graphicsOtherBtn");
|
||
|
|
const newTemplateFolderBtn = document.getElementById("newTemplateFolderBtn");
|
||
|
|
const newPlacedFolderBtn = document.getElementById("newPlacedFolderBtn");
|
||
|
|
const entitySearchModeBtn = document.getElementById("entitySearchModeBtn");
|
||
|
|
const entitySearchModeHost = document.getElementById("entitySearchModeHost");
|
||
|
|
const entityTypeFriendlyBtn = document.getElementById("entityTypeFriendlyBtn");
|
||
|
|
const entityTypeHostileBtn = document.getElementById("entityTypeHostileBtn");
|
||
|
|
const entityTypePropBtn = document.getElementById("entityTypePropBtn");
|
||
|
|
const newMonsterFolderBtn = document.getElementById("newMonsterFolderBtn");
|
||
|
|
const newTriggerFolderBtn = document.getElementById("newTriggerFolderBtn");
|
||
|
|
const newPathFolderBtn = document.getElementById("newPathFolderBtn");
|
||
|
|
const newTransitionFolderBtn = document.getElementById("newTransitionFolderBtn");
|
||
|
|
const toggleTemplateSectionBtn = document.getElementById("toggleTemplateSectionBtn");
|
||
|
|
const togglePlacedSectionBtn = document.getElementById("togglePlacedSectionBtn");
|
||
|
|
const instanceTemplateSectionBody = document.getElementById("instanceTemplateSectionBody");
|
||
|
|
const placedInstanceSectionBody = document.getElementById("placedInstanceSectionBody");
|
||
|
|
const entityCatalogSection = document.getElementById("entityCatalogSection");
|
||
|
|
const placedEntitiesSection = document.getElementById("placedEntitiesSection");
|
||
|
|
const monsterListEl = document.getElementById("monsterList");
|
||
|
|
const triggerListEl = document.getElementById("triggerList");
|
||
|
|
const pathListEl = document.getElementById("pathList");
|
||
|
|
const transitionListEl = document.getElementById("transitionList");
|
||
|
|
const metaEl = document.getElementById("meta");
|
||
|
|
const metaMainEl = document.getElementById("metaMain");
|
||
|
|
const metaStatsEl = document.getElementById("metaStats");
|
||
|
|
const historyListEl = document.getElementById("historyList");
|
||
|
|
const historyPreviewEl = document.getElementById("historyPreview");
|
||
|
|
const historyCurrentEl = document.getElementById("historyCurrent");
|
||
|
|
const undoBtn = document.getElementById("undoBtn");
|
||
|
|
const redoBtn = document.getElementById("redoBtn");
|
||
|
|
const saveBtn = document.getElementById("saveBtn");
|
||
|
|
const testHeightBtn = document.getElementById("testHeightBtn");
|
||
|
|
const menuLayerSelectEl = document.getElementById("menuLayerSelect");
|
||
|
|
const saveStatusEl = document.getElementById("saveStatus");
|
||
|
|
const themePresetButtons = Array.from(document.querySelectorAll("[data-theme-preset]"));
|
||
|
|
const informationTabBtn = document.getElementById("informationTabBtn");
|
||
|
|
const layersTabBtn = document.getElementById("layersTabBtn");
|
||
|
|
const tilesTabBtn = document.getElementById("tilesTabBtn");
|
||
|
|
const instancesTabBtn = document.getElementById("instancesTabBtn");
|
||
|
|
const triggersTabBtn = document.getElementById("triggersTabBtn");
|
||
|
|
const pathsTabBtn = document.getElementById("pathsTabBtn");
|
||
|
|
const transitionsTabBtn = document.getElementById("transitionsTabBtn");
|
||
|
|
const historyTabBtn = document.getElementById("historyTabBtn");
|
||
|
|
const newsTabBtn = document.getElementById("newsTabBtn");
|
||
|
|
const editorBodyEl = document.getElementById("editorBody");
|
||
|
|
const sidebarEl = document.getElementById("sidebar");
|
||
|
|
const sidebarTabsEl = document.getElementById("sidebarTabs");
|
||
|
|
const sidebarPanelsHostEl = document.getElementById("sidebarPanelsHost");
|
||
|
|
const informationPanel = document.getElementById("informationPanel");
|
||
|
|
const layersPanel = document.getElementById("layersPanel");
|
||
|
|
const tilesPanel = document.getElementById("tilesPanel");
|
||
|
|
const instancesPanel = document.getElementById("instancesPanel");
|
||
|
|
const triggersPanel = document.getElementById("triggersPanel");
|
||
|
|
const pathsPanel = document.getElementById("pathsPanel");
|
||
|
|
const transitionsPanel = document.getElementById("transitionsPanel");
|
||
|
|
const historyPanel = document.getElementById("historyPanel");
|
||
|
|
const drawLayerSectionBody = document.getElementById("drawLayerSectionBody");
|
||
|
|
const heightLayerSectionBody = document.getElementById("heightLayerSectionBody");
|
||
|
|
const toggleDrawLayerSectionBtn = document.getElementById("toggleDrawLayerSectionBtn");
|
||
|
|
const toggleHeightLayerSectionBtn = document.getElementById("toggleHeightLayerSectionBtn");
|
||
|
|
const informationSettingsSectionBody = document.getElementById("informationSettingsSectionBody");
|
||
|
|
const informationConfigurationSectionBody = document.getElementById("informationConfigurationSectionBody");
|
||
|
|
const informationHotkeysSectionBody = document.getElementById("informationHotkeysSectionBody");
|
||
|
|
const toggleInformationSettingsSectionBtn = document.getElementById("toggleInformationSettingsSectionBtn");
|
||
|
|
const toggleInformationConfigurationSectionBtn = document.getElementById("toggleInformationConfigurationSectionBtn");
|
||
|
|
const toggleInformationHotkeysSectionBtn = document.getElementById("toggleInformationHotkeysSectionBtn");
|
||
|
|
const mapIdLockedEl = document.getElementById("mapIdLocked");
|
||
|
|
const mapNameInputEl = document.getElementById("mapNameInput");
|
||
|
|
const mapWidthInputEl = document.getElementById("mapWidthInput");
|
||
|
|
const mapHeightInputEl = document.getElementById("mapHeightInput");
|
||
|
|
const mapBackgroundColorInputEl = document.getElementById("mapBackgroundColorInput");
|
||
|
|
const engineOverridesBtn = document.getElementById("engineOverridesBtn");
|
||
|
|
const engineOverridesSummaryEl = document.getElementById("engineOverridesSummary");
|
||
|
|
const backgroundModeBtn = document.getElementById("backgroundModeBtn");
|
||
|
|
const backgroundModePreviewEl = document.getElementById("backgroundModePreview");
|
||
|
|
const backgroundModeTitleEl = document.getElementById("backgroundModeTitle");
|
||
|
|
const backgroundModeMetaEl = document.getElementById("backgroundModeMeta");
|
||
|
|
const experimentalImportToggleBtn = document.getElementById("experimentalImportToggleBtn");
|
||
|
|
const experimentalImportCheckEl = document.getElementById("experimentalImportCheck");
|
||
|
|
const experimentalImportBodyEl = document.getElementById("experimentalImportBody");
|
||
|
|
const importSpritesBtn = document.getElementById("importSpritesBtn");
|
||
|
|
const importTilesBtn = document.getElementById("importTilesBtn");
|
||
|
|
const importJsonBtn = document.getElementById("importJsonBtn");
|
||
|
|
const importSpritesInputEl = document.getElementById("importSpritesInput");
|
||
|
|
const importTilesInputEl = document.getElementById("importTilesInput");
|
||
|
|
const importJsonModal = document.getElementById("importJsonModal");
|
||
|
|
const importJsonTypeSelect = document.getElementById("importJsonTypeSelect");
|
||
|
|
const importJsonTextarea = document.getElementById("importJsonTextarea");
|
||
|
|
const importJsonConfirmBtn = document.getElementById("importJsonConfirmBtn");
|
||
|
|
const importJsonCancelBtn = document.getElementById("importJsonCancelBtn");
|
||
|
|
const mapWidthValueEl = document.getElementById("mapWidthValue");
|
||
|
|
const mapHeightValueEl = document.getElementById("mapHeightValue");
|
||
|
|
const mapWidthControlsEl = document.getElementById("mapWidthControls");
|
||
|
|
const mapHeightControlsEl = document.getElementById("mapHeightControls");
|
||
|
|
const confirmWidthBtn = document.getElementById("confirmWidthBtn");
|
||
|
|
const cancelWidthBtn = document.getElementById("cancelWidthBtn");
|
||
|
|
const confirmHeightBtn = document.getElementById("confirmHeightBtn");
|
||
|
|
const cancelHeightBtn = document.getElementById("cancelHeightBtn");
|
||
|
|
const restoreToolWindowsBtn = document.getElementById("restoreToolWindowsBtn");
|
||
|
|
const resetWorkspaceLayoutBtn = document.getElementById("resetWorkspaceLayoutBtn");
|
||
|
|
const addHeightLayerBtn = document.getElementById("addHeightLayerBtn");
|
||
|
|
const heightLayerListEl = document.getElementById("heightLayerList");
|
||
|
|
const stageEl = document.getElementById("stage");
|
||
|
|
const canvasSelectToolBtn = document.getElementById("canvasSelectToolBtn");
|
||
|
|
const toolWindowLayerEl = document.getElementById("toolWindowLayer");
|
||
|
|
const viewport = document.getElementById("viewport");
|
||
|
|
const viewportSpacer = document.getElementById("viewportSpacer");
|
||
|
|
const pixiHost = document.getElementById("pixiHost");
|
||
|
|
const canvas = document.getElementById("roomCanvas");
|
||
|
|
const ctx = canvas.getContext("2d");
|
||
|
|
|
||
|
|
const initialBackgroundColor = normalizeMapBackgroundColor(bootstrap.backgroundColor);
|
||
|
|
const initialBackgroundTileId = normalizeBackgroundTileId(bootstrap.backgroundTileId);
|
||
|
|
const initialHeightBlurStep = Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1));
|
||
|
|
const mapDocumentStore = createMapDocumentStore({
|
||
|
|
mapId: currentMapId,
|
||
|
|
mapName: bootstrap.mapName || bootstrap.mapId || "Untitled",
|
||
|
|
width: bootstrap.width,
|
||
|
|
height: bootstrap.height,
|
||
|
|
backgroundColor: initialBackgroundColor,
|
||
|
|
backgroundTileId: initialBackgroundTileId,
|
||
|
|
heightBlurStep: initialHeightBlurStep,
|
||
|
|
backgroundCellMode: initialBackgroundTileId ? "tile" : "inherit",
|
||
|
|
mapInfoDraft: {
|
||
|
|
width: Number(bootstrap.width) || 1,
|
||
|
|
height: Number(bootstrap.height) || 1,
|
||
|
|
backgroundColor: initialBackgroundColor,
|
||
|
|
heightBlurStep: initialHeightBlurStep,
|
||
|
|
},
|
||
|
|
roomLayers: bootstrap.roomLayers,
|
||
|
|
heightLayers: bootstrap.heightLayers,
|
||
|
|
npcOverlays: bootstrap.npcOverlays,
|
||
|
|
contentBundle: contentByType,
|
||
|
|
});
|
||
|
|
const mapDocument = mapDocumentStore.state;
|
||
|
|
function cacheStandaloneMapBootstrap() {
|
||
|
|
return cacheStandaloneWorldEditorPopupBootstrap(buildCurrentBootstrapSnapshot(), window);
|
||
|
|
}
|
||
|
|
function syncDocumentTitle() {
|
||
|
|
const titleName = String(mapDocument.mapName || currentMapId || "Untitled").trim() || "Untitled";
|
||
|
|
document.title = "TES:VIII The Elder " + titleName;
|
||
|
|
}
|
||
|
|
function clampZoomLevel(value) {
|
||
|
|
const normalized = Number(value);
|
||
|
|
if (!Number.isFinite(normalized)) {
|
||
|
|
return zoomLevel;
|
||
|
|
}
|
||
|
|
return Math.max(minZoomLevel, Math.min(maxZoomLevel, normalized));
|
||
|
|
}
|
||
|
|
function getZoomPercent() {
|
||
|
|
return Math.round(clampZoomLevel(zoomLevel) * 100);
|
||
|
|
}
|
||
|
|
function getScaledSize(value, fallback) {
|
||
|
|
const baseSize = Math.max(1, Number(fallback) || baseTileSize);
|
||
|
|
const rawSize = Math.max(1, Number(value) || baseSize);
|
||
|
|
return Math.max(1, Math.round(rawSize * (tileSize / baseTileSize)));
|
||
|
|
}
|
||
|
|
function syncCanvasDimensionsToTileSize() {
|
||
|
|
const nextCanvasWidth = Math.max(1, Math.ceil(Number(viewport?.clientWidth) || 0));
|
||
|
|
const nextCanvasHeight = Math.max(1, Math.ceil(Number(viewport?.clientHeight) || 0));
|
||
|
|
if (canvas.width !== nextCanvasWidth || canvas.height !== nextCanvasHeight) {
|
||
|
|
canvas.width = nextCanvasWidth;
|
||
|
|
canvas.height = nextCanvasHeight;
|
||
|
|
}
|
||
|
|
canvas.style.width = nextCanvasWidth + "px";
|
||
|
|
canvas.style.height = nextCanvasHeight + "px";
|
||
|
|
if (pixiHost) {
|
||
|
|
pixiHost.style.width = nextCanvasWidth + "px";
|
||
|
|
pixiHost.style.height = nextCanvasHeight + "px";
|
||
|
|
}
|
||
|
|
const viewportLayer = canvas.parentElement;
|
||
|
|
if (viewportLayer) {
|
||
|
|
viewportLayer.style.width = nextCanvasWidth + "px";
|
||
|
|
viewportLayer.style.height = nextCanvasHeight + "px";
|
||
|
|
}
|
||
|
|
if (viewportSpacer) {
|
||
|
|
viewportSpacer.style.width = Math.max(nextCanvasWidth, mapDocument.width * tileSize) + "px";
|
||
|
|
viewportSpacer.style.height = Math.max(nextCanvasHeight, mapDocument.height * tileSize) + "px";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function applyZoomLevel(nextZoomLevel, anchorClientX, anchorClientY) {
|
||
|
|
const clampedZoom = clampZoomLevel(nextZoomLevel);
|
||
|
|
if (Math.abs(clampedZoom - zoomLevel) < 0.001) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const previousTileSize = tileSize;
|
||
|
|
const viewportRect = viewport.getBoundingClientRect();
|
||
|
|
const anchorOffsetX = Number.isFinite(anchorClientX)
|
||
|
|
? anchorClientX - viewportRect.left
|
||
|
|
: viewport.clientWidth / 2;
|
||
|
|
const anchorOffsetY = Number.isFinite(anchorClientY)
|
||
|
|
? anchorClientY - viewportRect.top
|
||
|
|
: viewport.clientHeight / 2;
|
||
|
|
const worldTileX = (viewport.scrollLeft + anchorOffsetX) / Math.max(1, previousTileSize);
|
||
|
|
const worldTileY = (viewport.scrollTop + anchorOffsetY) / Math.max(1, previousTileSize);
|
||
|
|
zoomLevel = clampedZoom;
|
||
|
|
tileSize = Math.max(8, Math.round(baseTileSize * zoomLevel));
|
||
|
|
syncCanvasDimensionsToTileSize();
|
||
|
|
const nextScrollLeft = Math.round((worldTileX * tileSize) - anchorOffsetX);
|
||
|
|
const nextScrollTop = Math.round((worldTileY * tileSize) - anchorOffsetY);
|
||
|
|
const worldPixelWidth = Math.max(1, mapDocument.width * tileSize);
|
||
|
|
const worldPixelHeight = Math.max(1, mapDocument.height * tileSize);
|
||
|
|
const maxScrollLeft = Math.max(0, worldPixelWidth - viewport.clientWidth);
|
||
|
|
const maxScrollTop = Math.max(0, worldPixelHeight - viewport.clientHeight);
|
||
|
|
viewport.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
|
||
|
|
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
|
||
|
|
if (isWorldModeActive()) {
|
||
|
|
syncWorldNeighborhoodForViewport();
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
function startZoomPreview(durationMs) {
|
||
|
|
popupSessionStore.state.zoomPreviewUntil = Date.now() + Math.max(16, Number(durationMs) || 120);
|
||
|
|
}
|
||
|
|
function isZoomPreviewActive() {
|
||
|
|
return Date.now() < popupSessionStore.state.zoomPreviewUntil;
|
||
|
|
}
|
||
|
|
function startScrollPreview(durationMs) {
|
||
|
|
popupSessionStore.state.scrollPreviewUntil = Date.now() + Math.max(16, Number(durationMs) || 90);
|
||
|
|
}
|
||
|
|
function isScrollPreviewActive() {
|
||
|
|
return Date.now() < popupSessionStore.state.scrollPreviewUntil;
|
||
|
|
}
|
||
|
|
function centerViewportOnWorldPoint(worldX, worldY) {
|
||
|
|
const targetX = Number(worldX);
|
||
|
|
const targetY = Number(worldY);
|
||
|
|
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !viewport) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const worldPixelWidth = Math.max(1, mapDocument.width * tileSize);
|
||
|
|
const worldPixelHeight = Math.max(1, mapDocument.height * tileSize);
|
||
|
|
const maxScrollLeft = Math.max(0, worldPixelWidth - viewport.clientWidth);
|
||
|
|
const maxScrollTop = Math.max(0, worldPixelHeight - viewport.clientHeight);
|
||
|
|
viewport.scrollLeft = Math.max(0, Math.min(maxScrollLeft, Math.round(targetX - (viewport.clientWidth / 2))));
|
||
|
|
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, Math.round(targetY - (viewport.clientHeight / 2))));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
function getViewportCenterWorldTile() {
|
||
|
|
if (!viewport) {
|
||
|
|
return { worldTileX: 0, worldTileY: 0 };
|
||
|
|
}
|
||
|
|
const localTileX = (Number(viewport.scrollLeft) + (Number(viewport.clientWidth) / 2)) / Math.max(1, tileSize);
|
||
|
|
const localTileY = (Number(viewport.scrollTop) + (Number(viewport.clientHeight) / 2)) / Math.max(1, tileSize);
|
||
|
|
return {
|
||
|
|
worldTileX: worldRuntimeState.tileOffsetX + localTileX,
|
||
|
|
worldTileY: worldRuntimeState.tileOffsetY + localTileY,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function centerViewportOnWorldTile(worldTileX, worldTileY) {
|
||
|
|
const localTileX = Number(worldTileX) - worldRuntimeState.tileOffsetX;
|
||
|
|
const localTileY = Number(worldTileY) - worldRuntimeState.tileOffsetY;
|
||
|
|
return centerViewportOnWorldPoint(
|
||
|
|
localTileX * tileSize,
|
||
|
|
localTileY * tileSize,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getVisibleWorldChunkPayloads() {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
return worldRuntimeState.sourceChunks
|
||
|
|
.map((entry) => worldRuntimeState.chunkCache.get(buildChunkKey(entry.chunkX, entry.chunkY)) || null)
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getWorldChunkCoordForLocalTile(localTileX, localTileY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const safeLocalTileX = Math.floor(Number(localTileX) || 0);
|
||
|
|
const safeLocalTileY = Math.floor(Number(localTileY) || 0);
|
||
|
|
return {
|
||
|
|
chunkX: worldToChunkCoord(worldRuntimeState.tileOffsetX + safeLocalTileX, worldRuntimeState.chunkWidth),
|
||
|
|
chunkY: worldToChunkCoord(worldRuntimeState.tileOffsetY + safeLocalTileY, worldRuntimeState.chunkHeight),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function getCachedWorldChunk(chunkX, chunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return worldRuntimeState.chunkCache.get(buildChunkKey(chunkX, chunkY)) || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getWorldChunkBackgroundTileId(chunkX, chunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return normalizeBackgroundTileId(mapDocument.backgroundTileId);
|
||
|
|
}
|
||
|
|
const chunk = getCachedWorldChunk(chunkX, chunkY);
|
||
|
|
const chunkBackgroundTileId = chunk && Object.prototype.hasOwnProperty.call(chunk, "backgroundTileId")
|
||
|
|
? String(chunk.backgroundTileId || "").trim()
|
||
|
|
: "";
|
||
|
|
return chunkBackgroundTileId || normalizeBackgroundTileId(mapDocument.backgroundTileId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getBackgroundTileIdForLocalTile(localTileX, localTileY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return normalizeBackgroundTileId(mapDocument.backgroundTileId);
|
||
|
|
}
|
||
|
|
const chunkCoord = getWorldChunkCoordForLocalTile(localTileX, localTileY);
|
||
|
|
if (!chunkCoord) {
|
||
|
|
return normalizeBackgroundTileId(mapDocument.backgroundTileId);
|
||
|
|
}
|
||
|
|
return getWorldChunkBackgroundTileId(chunkCoord.chunkX, chunkCoord.chunkY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setWorldChunkBackgroundTileId(chunkX, chunkY, backgroundTileId) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const chunkKey = buildChunkKey(safeChunkX, safeChunkY);
|
||
|
|
const normalizedBackgroundTileId = normalizeBackgroundTileId(backgroundTileId);
|
||
|
|
const storedBackgroundTileId = normalizedBackgroundTileId === normalizeBackgroundTileId(mapDocument.backgroundTileId)
|
||
|
|
? ""
|
||
|
|
: normalizedBackgroundTileId;
|
||
|
|
const existingChunk = worldRuntimeState.chunkCache.get(chunkKey);
|
||
|
|
const chunkValue = existingChunk
|
||
|
|
? cloneValue(existingChunk)
|
||
|
|
: (rebuildWorldChunkPayloadFromDocument(safeChunkX, safeChunkY) || {
|
||
|
|
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
|
||
|
|
chunkX: safeChunkX,
|
||
|
|
chunkY: safeChunkY,
|
||
|
|
width: worldRuntimeState.chunkWidth,
|
||
|
|
height: worldRuntimeState.chunkHeight,
|
||
|
|
roomLayers: [],
|
||
|
|
heightLayers: [],
|
||
|
|
instances: [],
|
||
|
|
});
|
||
|
|
chunkValue.backgroundTileId = storedBackgroundTileId;
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, chunkValue);
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.([chunkKey]);
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return chunkValue;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getPreferredWorldChunkCoord() {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (Number.isFinite(popupSessionStore.state.hoverTileX) && Number.isFinite(popupSessionStore.state.hoverTileY) && popupSessionStore.state.hoverTileX >= 0 && popupSessionStore.state.hoverTileY >= 0) {
|
||
|
|
return getWorldChunkCoordForLocalTile(popupSessionStore.state.hoverTileX, popupSessionStore.state.hoverTileY);
|
||
|
|
}
|
||
|
|
const selectedTile = popupSessionStore.state.selectedTile && typeof popupSessionStore.state.selectedTile === "object"
|
||
|
|
? popupSessionStore.state.selectedTile
|
||
|
|
: null;
|
||
|
|
if (selectedTile && Number.isFinite(selectedTile.x) && Number.isFinite(selectedTile.y) && selectedTile.x >= 0 && selectedTile.y >= 0) {
|
||
|
|
return getWorldChunkCoordForLocalTile(selectedTile.x, selectedTile.y);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSelectedWorldChunkCoord() {
|
||
|
|
if (!isWorldModeActive() || popupSessionStore.state.canvasToolMode !== "select") {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const selectedTile = popupSessionStore.state.selectedTile && typeof popupSessionStore.state.selectedTile === "object"
|
||
|
|
? popupSessionStore.state.selectedTile
|
||
|
|
: null;
|
||
|
|
if (selectedTile && Number.isFinite(selectedTile.x) && Number.isFinite(selectedTile.y) && selectedTile.x >= 0 && selectedTile.y >= 0) {
|
||
|
|
return getWorldChunkCoordForLocalTile(selectedTile.x, selectedTile.y);
|
||
|
|
}
|
||
|
|
const selectedNpcId = String(popupSessionStore.state.selectedNpcId || "").trim();
|
||
|
|
if (!selectedNpcId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const npc = mapDocument.npcOverlays.find((entry) => String(entry?.id || "").trim() === selectedNpcId) || null;
|
||
|
|
if (!npc) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const npcX = Math.floor(Number(npc.x));
|
||
|
|
const npcY = Math.floor(Number(npc.y));
|
||
|
|
if (!Number.isFinite(npcX) || !Number.isFinite(npcY) || npcX < 0 || npcY < 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return getWorldChunkCoordForLocalTile(npcX, npcY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function captureWorldChunkBackgroundState() {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
const payload = {};
|
||
|
|
Array.from(worldRuntimeState.chunkCache.entries())
|
||
|
|
.sort((left, right) => String(left[0] || "").localeCompare(String(right[0] || "")))
|
||
|
|
.forEach(([chunkKey, chunkValue]) => {
|
||
|
|
payload[chunkKey] = chunkValue && Object.prototype.hasOwnProperty.call(chunkValue, "backgroundTileId")
|
||
|
|
? String(chunkValue.backgroundTileId || "").trim()
|
||
|
|
: "";
|
||
|
|
});
|
||
|
|
return payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyWorldChunkBackgroundState(backgrounds) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const entries = backgrounds && typeof backgrounds === "object" && !Array.isArray(backgrounds)
|
||
|
|
? Object.entries(backgrounds)
|
||
|
|
: [];
|
||
|
|
let changed = false;
|
||
|
|
entries.forEach(([chunkKey, backgroundTileId]) => {
|
||
|
|
const existingChunk = worldRuntimeState.chunkCache.get(String(chunkKey || "").trim());
|
||
|
|
if (!existingChunk) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const nextBackgroundTileId = normalizeBackgroundTileId(backgroundTileId);
|
||
|
|
if (String(existingChunk.backgroundTileId || "").trim() === nextBackgroundTileId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
touchWorldChunkCacheEntry(String(chunkKey || "").trim(), {
|
||
|
|
...cloneValue(existingChunk),
|
||
|
|
backgroundTileId: nextBackgroundTileId,
|
||
|
|
});
|
||
|
|
changed = true;
|
||
|
|
});
|
||
|
|
return changed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureWorldDocumentCurrent() {
|
||
|
|
if (!isWorldModeActive() || worldRuntimeState.documentDirty !== true) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const visibleChunks = getVisibleWorldChunkPayloads();
|
||
|
|
const composedWidth = ((worldRuntimeState.chunkRadius * 2) + 1) * worldRuntimeState.chunkWidth;
|
||
|
|
const composedHeight = ((worldRuntimeState.chunkRadius * 2) + 1) * worldRuntimeState.chunkHeight;
|
||
|
|
const roomLayers = composeWorldRoomLayers(
|
||
|
|
visibleChunks,
|
||
|
|
worldRuntimeState.chunkWidth,
|
||
|
|
worldRuntimeState.chunkHeight,
|
||
|
|
worldRuntimeState.originChunkX,
|
||
|
|
worldRuntimeState.originChunkY,
|
||
|
|
composedWidth,
|
||
|
|
composedHeight,
|
||
|
|
);
|
||
|
|
const heightLayers = composeWorldHeightLayers(
|
||
|
|
visibleChunks,
|
||
|
|
worldRuntimeState.chunkWidth,
|
||
|
|
worldRuntimeState.chunkHeight,
|
||
|
|
worldRuntimeState.originChunkX,
|
||
|
|
worldRuntimeState.originChunkY,
|
||
|
|
);
|
||
|
|
currentBaseRows = Array.isArray(roomLayers.find((layer) => Number(layer.layer) === 0)?.rows)
|
||
|
|
? roomLayers.find((layer) => Number(layer.layer) === 0).rows.map((row) => String(row || ""))
|
||
|
|
: createFilledRows(composedWidth, composedHeight, ".");
|
||
|
|
mapDocument.roomLayers = cloneLayers(roomLayers);
|
||
|
|
mapDocument.heightLayers = cloneHeightLayers(heightLayers);
|
||
|
|
popupSessionStore.syncLayerVisibility(mapDocument.roomLayers);
|
||
|
|
ensureActiveHeightLayerSelection();
|
||
|
|
worldRuntimeState.documentDirty = false;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function touchWorldChunkCacheEntry(chunkKey, chunkValue) {
|
||
|
|
const normalizedKey = String(chunkKey || "").trim();
|
||
|
|
if (!normalizedKey) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (worldRuntimeState.chunkCache.has(normalizedKey)) {
|
||
|
|
worldRuntimeState.chunkCache.delete(normalizedKey);
|
||
|
|
}
|
||
|
|
worldRuntimeState.chunkCache.set(normalizedKey, chunkValue);
|
||
|
|
}
|
||
|
|
|
||
|
|
function markWorldChunkDirty(chunkX, chunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const chunkKey = buildChunkKey(chunkX, chunkY);
|
||
|
|
if (!chunkKey) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function markWorldChunkDirtyByLocalTile(localTileX, localTileY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const safeLocalTileX = Math.floor(Number(localTileX) || 0);
|
||
|
|
const safeLocalTileY = Math.floor(Number(localTileY) || 0);
|
||
|
|
const worldTileX = worldRuntimeState.tileOffsetX + safeLocalTileX;
|
||
|
|
const worldTileY = worldRuntimeState.tileOffsetY + safeLocalTileY;
|
||
|
|
return markWorldChunkDirty(
|
||
|
|
worldToChunkCoord(worldTileX, worldRuntimeState.chunkWidth),
|
||
|
|
worldToChunkCoord(worldTileY, worldRuntimeState.chunkHeight),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function markWorldChunksDirtyByLocalBounds(bounds) {
|
||
|
|
if (!isWorldModeActive() || !bounds || typeof bounds !== "object") {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const minLocalX = Math.floor(Number(bounds.minX));
|
||
|
|
const minLocalY = Math.floor(Number(bounds.minY));
|
||
|
|
const maxLocalX = Math.floor(Number(bounds.maxX));
|
||
|
|
const maxLocalY = Math.floor(Number(bounds.maxY));
|
||
|
|
if (![minLocalX, minLocalY, maxLocalX, maxLocalY].every(Number.isFinite)) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const startChunkX = worldToChunkCoord(worldRuntimeState.tileOffsetX + Math.min(minLocalX, maxLocalX), worldRuntimeState.chunkWidth);
|
||
|
|
const endChunkX = worldToChunkCoord(worldRuntimeState.tileOffsetX + Math.max(minLocalX, maxLocalX), worldRuntimeState.chunkWidth);
|
||
|
|
const startChunkY = worldToChunkCoord(worldRuntimeState.tileOffsetY + Math.min(minLocalY, maxLocalY), worldRuntimeState.chunkHeight);
|
||
|
|
const endChunkY = worldToChunkCoord(worldRuntimeState.tileOffsetY + Math.max(minLocalY, maxLocalY), worldRuntimeState.chunkHeight);
|
||
|
|
const touchedKeys = [];
|
||
|
|
for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY += 1) {
|
||
|
|
for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX += 1) {
|
||
|
|
const chunkKey = buildChunkKey(chunkX, chunkY);
|
||
|
|
if (!chunkKey) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
touchedKeys.push(chunkKey);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return touchedKeys;
|
||
|
|
}
|
||
|
|
|
||
|
|
function markVisibleWorldChunksDirty() {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
return (Array.isArray(worldRuntimeState.sourceChunks) ? worldRuntimeState.sourceChunks : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const chunkKey = buildChunkKey(entry?.chunkX, entry?.chunkY);
|
||
|
|
if (chunkKey) {
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
}
|
||
|
|
return chunkKey;
|
||
|
|
})
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDirtyWorldChunkKeys() {
|
||
|
|
return Array.from(worldRuntimeState.dirtyChunkKeys.values());
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearDirtyWorldChunks(chunkKeys) {
|
||
|
|
const keys = Array.isArray(chunkKeys) ? chunkKeys : Array.from(worldRuntimeState.dirtyChunkKeys.values());
|
||
|
|
keys.forEach((chunkKey) => {
|
||
|
|
worldRuntimeState.dirtyChunkKeys.delete(String(chunkKey || "").trim());
|
||
|
|
});
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDirtyWorldChunkPayloads() {
|
||
|
|
return Array.from(worldRuntimeState.dirtyChunkKeys.values())
|
||
|
|
.map((chunkKey) => worldRuntimeState.chunkCache.get(chunkKey) || null)
|
||
|
|
.filter(Boolean)
|
||
|
|
.map((entry) => cloneValue(entry));
|
||
|
|
}
|
||
|
|
|
||
|
|
function pruneWorldChunkCache() {
|
||
|
|
if (worldRuntimeState.chunkCache.size <= MAX_WORLD_CHUNK_CACHE_ENTRIES) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const preserveRadius = Math.max(0, worldRuntimeState.chunkRadius) + 2;
|
||
|
|
const preservedKeys = new Set();
|
||
|
|
for (let chunkY = worldRuntimeState.centerChunkY - preserveRadius; chunkY <= worldRuntimeState.centerChunkY + preserveRadius; chunkY += 1) {
|
||
|
|
for (let chunkX = worldRuntimeState.centerChunkX - preserveRadius; chunkX <= worldRuntimeState.centerChunkX + preserveRadius; chunkX += 1) {
|
||
|
|
preservedKeys.add(buildChunkKey(chunkX, chunkY));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const sourceChunk of worldRuntimeState.sourceChunks) {
|
||
|
|
preservedKeys.add(buildChunkKey(sourceChunk?.chunkX, sourceChunk?.chunkY));
|
||
|
|
}
|
||
|
|
for (const dirtyChunkKey of worldRuntimeState.dirtyChunkKeys) {
|
||
|
|
preservedKeys.add(dirtyChunkKey);
|
||
|
|
}
|
||
|
|
for (const [chunkKey] of worldRuntimeState.chunkCache) {
|
||
|
|
if (worldRuntimeState.chunkCache.size <= MAX_WORLD_CHUNK_CACHE_ENTRIES) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
if (preservedKeys.has(chunkKey)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
worldRuntimeState.chunkCache.delete(chunkKey);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDesiredWorldChunkRadius() {
|
||
|
|
if (!isWorldModeActive() || !viewport) {
|
||
|
|
return Math.max(0, worldRuntimeState.chunkRadius);
|
||
|
|
}
|
||
|
|
const visibleTilesX = Math.max(1, (Number(viewport.clientWidth) || 0) / Math.max(1, tileSize));
|
||
|
|
const visibleTilesY = Math.max(1, (Number(viewport.clientHeight) || 0) / Math.max(1, tileSize));
|
||
|
|
const horizontalBufferTiles = Math.max(4, Math.ceil(worldRuntimeState.chunkWidth * 0.35));
|
||
|
|
const verticalBufferTiles = Math.max(4, Math.ceil(worldRuntimeState.chunkHeight * 0.35));
|
||
|
|
const horizontalRadius = Math.ceil(((visibleTilesX * 0.5) + horizontalBufferTiles) / Math.max(1, worldRuntimeState.chunkWidth));
|
||
|
|
const verticalRadius = Math.ceil(((visibleTilesY * 0.5) + verticalBufferTiles) / Math.max(1, worldRuntimeState.chunkHeight));
|
||
|
|
return Math.max(
|
||
|
|
1,
|
||
|
|
Math.min(
|
||
|
|
MAX_DYNAMIC_WORLD_CHUNK_RADIUS,
|
||
|
|
Math.max(horizontalRadius, verticalRadius),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) {
|
||
|
|
return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => {
|
||
|
|
const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || "");
|
||
|
|
const paddedRow = sourceRow.length >= startX + width
|
||
|
|
? sourceRow
|
||
|
|
: sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length));
|
||
|
|
return paddedRow.slice(startX, startX + width);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildChunkHeightLayersFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) {
|
||
|
|
return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const patchX = Math.max(0, Number(entry?.x) || 0);
|
||
|
|
const patchY = Math.max(0, Number(entry?.y) || 0);
|
||
|
|
const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [];
|
||
|
|
const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||
|
|
const patchHeight = rows.length;
|
||
|
|
const patchRight = patchX + patchWidth;
|
||
|
|
const patchBottom = patchY + patchHeight;
|
||
|
|
const chunkRight = baseTileX + chunkWidth;
|
||
|
|
const chunkBottom = baseTileY + chunkHeight;
|
||
|
|
const overlapLeft = Math.max(baseTileX, patchX);
|
||
|
|
const overlapTop = Math.max(baseTileY, patchY);
|
||
|
|
const overlapRight = Math.min(chunkRight, patchRight);
|
||
|
|
const overlapBottom = Math.min(chunkBottom, patchBottom);
|
||
|
|
if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const localRows = [];
|
||
|
|
for (let y = overlapTop; y < overlapBottom; y += 1) {
|
||
|
|
const sourceRow = String(rows[y - patchY] || "");
|
||
|
|
localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, ""));
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
id: String(entry?.id || "").trim(),
|
||
|
|
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||
|
|
z: Math.max(1, Number(entry?.z) || 1),
|
||
|
|
x: overlapLeft - baseTileX,
|
||
|
|
y: overlapTop - baseTileY,
|
||
|
|
rows: localRows,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((entry) => entry && entry.id);
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildChunkInstancesFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) {
|
||
|
|
const chunkInstances = cloneValue(mapDocument.npcOverlays)
|
||
|
|
.filter((npc) => {
|
||
|
|
const localX = Math.floor(Number(npc?.x));
|
||
|
|
const localY = Math.floor(Number(npc?.y));
|
||
|
|
return Number.isFinite(localX)
|
||
|
|
&& Number.isFinite(localY)
|
||
|
|
&& localX >= baseTileX
|
||
|
|
&& localX < baseTileX + chunkWidth
|
||
|
|
&& localY >= baseTileY
|
||
|
|
&& localY < baseTileY + chunkHeight;
|
||
|
|
})
|
||
|
|
.map((npc) => ({
|
||
|
|
id: String(npc.id || "").trim(),
|
||
|
|
templateId: String(npc?.record?.templateId || "").trim(),
|
||
|
|
layer: Number(npc.layer) || 0,
|
||
|
|
x: Math.floor(Number(npc.x) || 0) - baseTileX,
|
||
|
|
y: Math.floor(Number(npc.y) || 0) - baseTileY,
|
||
|
|
record: {
|
||
|
|
...cloneValue(npc.record || {}),
|
||
|
|
id: String(npc.id || "").trim(),
|
||
|
|
layer: Number(npc.layer) || 0,
|
||
|
|
templateId: String(npc?.record?.templateId || "").trim(),
|
||
|
|
name: String(npc.name || npc?.record?.name || ""),
|
||
|
|
entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"),
|
||
|
|
faction: String(npc.faction || npc?.record?.faction || ""),
|
||
|
|
spriteId: String(npc.spriteId || npc?.record?.spriteId || ""),
|
||
|
|
dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""),
|
||
|
|
description: String(npc.description || npc?.record?.description || ""),
|
||
|
|
tags: cloneValue(npc?.record?.tags) || [],
|
||
|
|
enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true,
|
||
|
|
position: {
|
||
|
|
x: Math.floor(Number(npc.x) || 0) + worldRuntimeState.tileOffsetX,
|
||
|
|
y: Math.floor(Number(npc.y) || 0) + worldRuntimeState.tileOffsetY,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}))
|
||
|
|
.filter((entry) => entry.id);
|
||
|
|
const npcIdsByLayer = new Map();
|
||
|
|
chunkInstances.forEach((entry) => {
|
||
|
|
const layerNumber = Number(entry.layer) || 0;
|
||
|
|
if (!npcIdsByLayer.has(layerNumber)) {
|
||
|
|
npcIdsByLayer.set(layerNumber, []);
|
||
|
|
}
|
||
|
|
npcIdsByLayer.get(layerNumber).push(entry.id);
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
chunkInstances,
|
||
|
|
npcIdsByLayer,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function rebuildWorldChunkPayloadFromDocument(chunkX, chunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const chunkWidth = Math.max(1, worldRuntimeState.chunkWidth);
|
||
|
|
const chunkHeight = Math.max(1, worldRuntimeState.chunkHeight);
|
||
|
|
const baseTileX = (safeChunkX - worldRuntimeState.originChunkX) * chunkWidth;
|
||
|
|
const baseTileY = (safeChunkY - worldRuntimeState.originChunkY) * chunkHeight;
|
||
|
|
const cachedChunk = worldRuntimeState.chunkCache.get(buildChunkKey(safeChunkX, safeChunkY)) || null;
|
||
|
|
const chunkBackgroundTileId = cachedChunk && Object.prototype.hasOwnProperty.call(cachedChunk, "backgroundTileId")
|
||
|
|
? normalizeBackgroundTileId(cachedChunk.backgroundTileId)
|
||
|
|
: "";
|
||
|
|
const normalizedLayers = cloneLayers(mapDocument.roomLayers)
|
||
|
|
.map((layer) => ({
|
||
|
|
layer: Number(layer.layer) || 0,
|
||
|
|
name: typeof layer.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
|
||
|
|
rows: documentController.normalizeRows(layer.rows, (Number(layer.layer) || 0) === 0 ? "." : " "),
|
||
|
|
}))
|
||
|
|
.sort((left, right) => left.layer - right.layer);
|
||
|
|
const { chunkInstances, npcIdsByLayer } = buildChunkInstancesFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight);
|
||
|
|
const roomLayers = normalizedLayers.map((layer) => ({
|
||
|
|
layer: layer.layer,
|
||
|
|
name: layer.name,
|
||
|
|
rows: sliceNormalizedRows(layer.rows, baseTileX, baseTileY, chunkWidth, chunkHeight, layer.layer === 0 ? "." : " "),
|
||
|
|
instanceIds: npcIdsByLayer.get(layer.layer) || [],
|
||
|
|
}));
|
||
|
|
return {
|
||
|
|
...(cachedChunk && typeof cachedChunk === "object" && !Array.isArray(cachedChunk) ? cachedChunk : {}),
|
||
|
|
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
|
||
|
|
chunkX: safeChunkX,
|
||
|
|
chunkY: safeChunkY,
|
||
|
|
width: chunkWidth,
|
||
|
|
height: chunkHeight,
|
||
|
|
backgroundTileId: chunkBackgroundTileId,
|
||
|
|
roomLayers,
|
||
|
|
heightLayers: buildChunkHeightLayersFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight),
|
||
|
|
instances: chunkInstances,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function rebuildCachedWorldChunkFromDocument(chunkX, chunkY, options) {
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const payload = rebuildWorldChunkPayloadFromDocument(chunkX, chunkY);
|
||
|
|
if (!payload) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const chunkKey = buildChunkKey(payload.chunkX, payload.chunkY);
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, payload);
|
||
|
|
if (config.markDirty !== false) {
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
}
|
||
|
|
return payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
function rebuildVisibleWorldChunksFromDocument(targetChunkKeys) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const requestedKeys = new Set(
|
||
|
|
(Array.isArray(targetChunkKeys) ? targetChunkKeys : [])
|
||
|
|
.map((chunkKey) => String(chunkKey || "").trim())
|
||
|
|
.filter(Boolean),
|
||
|
|
);
|
||
|
|
const nextPayloads = [];
|
||
|
|
(Array.isArray(worldRuntimeState.sourceChunks) ? worldRuntimeState.sourceChunks : []).forEach((entry) => {
|
||
|
|
const chunkKey = buildChunkKey(entry?.chunkX, entry?.chunkY);
|
||
|
|
if (requestedKeys.size > 0 && !requestedKeys.has(chunkKey)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const payload = rebuildCachedWorldChunkFromDocument(entry?.chunkX, entry?.chunkY, { markDirty: true });
|
||
|
|
if (payload) {
|
||
|
|
nextPayloads.push(payload);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
pruneWorldChunkCache();
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.(nextPayloads.map((entry) => buildChunkKey(entry?.chunkX, entry?.chunkY)));
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return nextPayloads;
|
||
|
|
}
|
||
|
|
|
||
|
|
function rebuildWorldChunksForLocalBounds(bounds) {
|
||
|
|
if (!isWorldModeActive() || !bounds || typeof bounds !== "object") {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const touchedKeys = markWorldChunksDirtyByLocalBounds(bounds);
|
||
|
|
const rebuiltPayloads = [];
|
||
|
|
touchedKeys.forEach((chunkKey) => {
|
||
|
|
const [chunkXPart, chunkYPart] = String(chunkKey || "").split(":");
|
||
|
|
const payload = rebuildCachedWorldChunkFromDocument(Number(chunkXPart), Number(chunkYPart), { markDirty: true });
|
||
|
|
if (payload) {
|
||
|
|
rebuiltPayloads.push(payload);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
pruneWorldChunkCache();
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.(rebuiltPayloads.map((entry) => buildChunkKey(entry?.chunkX, entry?.chunkY)));
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return rebuiltPayloads;
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncCachedWorldRoomLayerMetadata(options) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const layerNumberMap = config.layerNumberMap && typeof config.layerNumberMap === "object"
|
||
|
|
? config.layerNumberMap
|
||
|
|
: null;
|
||
|
|
const fallbackLayerNumber = Number(config.fallbackLayerNumber) || 0;
|
||
|
|
const removedLayerNumbers = new Set(
|
||
|
|
(Array.isArray(config.removedLayerNumbers) ? config.removedLayerNumbers : [])
|
||
|
|
.map((entry) => Number(entry) || 0),
|
||
|
|
);
|
||
|
|
const metadataByLayer = new Map(
|
||
|
|
cloneLayers(mapDocument.roomLayers).map((layer) => [
|
||
|
|
Number(layer.layer) || 0,
|
||
|
|
{
|
||
|
|
layer: Number(layer.layer) || 0,
|
||
|
|
name: typeof layer.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
|
||
|
|
},
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
let changed = false;
|
||
|
|
for (const [chunkKey, chunkValue] of worldRuntimeState.chunkCache.entries()) {
|
||
|
|
if (!chunkValue || typeof chunkValue !== "object" || Array.isArray(chunkValue)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const safeWidth = Math.max(1, Number(chunkValue.width) || worldRuntimeState.chunkWidth);
|
||
|
|
const safeHeight = Math.max(1, Number(chunkValue.height) || worldRuntimeState.chunkHeight);
|
||
|
|
const nextInstances = (Array.isArray(chunkValue.instances) ? chunkValue.instances : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const previousLayer = Number(entry?.layer) || 0;
|
||
|
|
const nextLayer = removedLayerNumbers.has(previousLayer)
|
||
|
|
? fallbackLayerNumber
|
||
|
|
: (layerNumberMap ? (layerNumberMap[String(previousLayer)] ?? previousLayer) : previousLayer);
|
||
|
|
return {
|
||
|
|
...cloneValue(entry),
|
||
|
|
layer: nextLayer,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
const instanceIdsByLayer = new Map();
|
||
|
|
nextInstances.forEach((entry) => {
|
||
|
|
const layerNumber = Number(entry?.layer) || 0;
|
||
|
|
if (!instanceIdsByLayer.has(layerNumber)) {
|
||
|
|
instanceIdsByLayer.set(layerNumber, []);
|
||
|
|
}
|
||
|
|
instanceIdsByLayer.get(layerNumber).push(String(entry?.id || "").trim());
|
||
|
|
});
|
||
|
|
const nextRoomLayers = (Array.isArray(chunkValue.roomLayers) ? chunkValue.roomLayers : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const previousLayer = Number(entry?.layer) || 0;
|
||
|
|
if (removedLayerNumbers.has(previousLayer)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const nextLayer = layerNumberMap
|
||
|
|
? (layerNumberMap[String(previousLayer)] ?? previousLayer)
|
||
|
|
: previousLayer;
|
||
|
|
const metadata = metadataByLayer.get(nextLayer) || { layer: nextLayer, name: undefined };
|
||
|
|
const fillChar = nextLayer === 0 ? "." : " ";
|
||
|
|
return {
|
||
|
|
...cloneValue(entry),
|
||
|
|
layer: nextLayer,
|
||
|
|
name: metadata.name,
|
||
|
|
rows: Array.isArray(entry?.rows)
|
||
|
|
? entry.rows.map((row) => String(row || ""))
|
||
|
|
: createFilledRows(safeWidth, safeHeight, fillChar),
|
||
|
|
instanceIds: instanceIdsByLayer.get(nextLayer) || [],
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter(Boolean)
|
||
|
|
.sort((left, right) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0));
|
||
|
|
const previousSignature = JSON.stringify({
|
||
|
|
roomLayers: chunkValue.roomLayers || [],
|
||
|
|
instances: chunkValue.instances || [],
|
||
|
|
});
|
||
|
|
const nextSignature = JSON.stringify({
|
||
|
|
roomLayers: nextRoomLayers,
|
||
|
|
instances: nextInstances,
|
||
|
|
});
|
||
|
|
if (previousSignature === nextSignature) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, {
|
||
|
|
...cloneValue(chunkValue),
|
||
|
|
roomLayers: nextRoomLayers,
|
||
|
|
instances: nextInstances,
|
||
|
|
});
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
if (changed) {
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
}
|
||
|
|
return changed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncWorldChunkCellFromLocalTile(layerNumber, localTileX, localTileY, storedChar) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const worldTileX = worldRuntimeState.tileOffsetX + Math.floor(Number(localTileX) || 0);
|
||
|
|
const worldTileY = worldRuntimeState.tileOffsetY + Math.floor(Number(localTileY) || 0);
|
||
|
|
const chunkX = worldToChunkCoord(worldTileX, worldRuntimeState.chunkWidth);
|
||
|
|
const chunkY = worldToChunkCoord(worldTileY, worldRuntimeState.chunkHeight);
|
||
|
|
const chunkKey = buildChunkKey(chunkX, chunkY);
|
||
|
|
const cachedChunk = worldRuntimeState.chunkCache.get(chunkKey);
|
||
|
|
if (!cachedChunk) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const localX = worldToLocalCoord(worldTileX, worldRuntimeState.chunkWidth);
|
||
|
|
const localY = worldToLocalCoord(worldTileY, worldRuntimeState.chunkHeight);
|
||
|
|
const safeLayerNumber = Number(layerNumber) || 0;
|
||
|
|
const fillChar = safeLayerNumber === 0 ? "." : " ";
|
||
|
|
let layerEntry = Array.isArray(cachedChunk.roomLayers)
|
||
|
|
? cachedChunk.roomLayers.find((layer) => Number(layer?.layer) === safeLayerNumber)
|
||
|
|
: null;
|
||
|
|
if (!layerEntry) {
|
||
|
|
layerEntry = {
|
||
|
|
layer: safeLayerNumber,
|
||
|
|
rows: createFilledRows(cachedChunk.width || worldRuntimeState.chunkWidth, cachedChunk.height || worldRuntimeState.chunkHeight, fillChar),
|
||
|
|
instanceIds: [],
|
||
|
|
};
|
||
|
|
if (!Array.isArray(cachedChunk.roomLayers)) {
|
||
|
|
cachedChunk.roomLayers = [];
|
||
|
|
}
|
||
|
|
cachedChunk.roomLayers.push(layerEntry);
|
||
|
|
cachedChunk.roomLayers.sort((left, right) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0));
|
||
|
|
}
|
||
|
|
const targetRows = Array.isArray(layerEntry.rows)
|
||
|
|
? layerEntry.rows.map((row) => String(row || ""))
|
||
|
|
: createFilledRows(cachedChunk.width || worldRuntimeState.chunkWidth, cachedChunk.height || worldRuntimeState.chunkHeight, fillChar);
|
||
|
|
const safeWidth = Math.max(1, Number(cachedChunk.width) || worldRuntimeState.chunkWidth);
|
||
|
|
while (targetRows.length <= localY) {
|
||
|
|
targetRows.push(String(fillChar || " ").repeat(safeWidth));
|
||
|
|
}
|
||
|
|
const existingRow = String(targetRows[localY] || "").padEnd(safeWidth, fillChar).slice(0, safeWidth);
|
||
|
|
targetRows[localY] = existingRow.slice(0, localX) + String(storedChar || fillChar).charAt(0) + existingRow.slice(localX + 1);
|
||
|
|
layerEntry.rows = targetRows;
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, cachedChunk);
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.([chunkKey]);
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
const popupSessionStore = createPopupSessionStore({
|
||
|
|
activeLayer: 1,
|
||
|
|
viewingAllLayers: false,
|
||
|
|
visibleLayersById: {},
|
||
|
|
activeSidebarTab: "layers",
|
||
|
|
pan: { isPanning: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 },
|
||
|
|
draggingNpc: null,
|
||
|
|
pointerCandidate: null,
|
||
|
|
paintingStroke: null,
|
||
|
|
dragDrawX: 0,
|
||
|
|
dragDrawY: 0,
|
||
|
|
isSaving: false,
|
||
|
|
activeBrushTileId: "",
|
||
|
|
activeGraphicsTab: "tiles",
|
||
|
|
activeGraphicsRecordId: "",
|
||
|
|
canvasToolMode: "paint",
|
||
|
|
activeInstanceBrushId: "",
|
||
|
|
activeEntityCategory: "friendly",
|
||
|
|
hoverTileX: -1,
|
||
|
|
hoverTileY: -1,
|
||
|
|
selectedNpcId: mapDocument.npcOverlays[0] ? String(mapDocument.npcOverlays[0].id || "") : "",
|
||
|
|
selectedTile: null,
|
||
|
|
spritePickerOpenNpcId: "",
|
||
|
|
hoveredNpcId: "",
|
||
|
|
templateSectionCollapsed: false,
|
||
|
|
placedSectionCollapsed: false,
|
||
|
|
organizedListDrag: null,
|
||
|
|
tileMutationBatchDepth: 0,
|
||
|
|
hideTileGrid: false,
|
||
|
|
showChunkBounds: false,
|
||
|
|
zoomPreviewUntil: 0,
|
||
|
|
scrollPreviewUntil: 0,
|
||
|
|
hoverCanvasX: 0,
|
||
|
|
hoverCanvasY: 0,
|
||
|
|
editingTargetKind: "room",
|
||
|
|
activeHeightLayerId: "",
|
||
|
|
});
|
||
|
|
popupSessionStore.restorePersistedLayout(window);
|
||
|
|
const editorLogEntries = [];
|
||
|
|
const EDITOR_LOG_LIMIT = 500;
|
||
|
|
let statusLogWindowController = null;
|
||
|
|
|
||
|
|
function formatEditorLogTimestamp(timestamp) {
|
||
|
|
try {
|
||
|
|
return new Date(timestamp).toLocaleString();
|
||
|
|
} catch {
|
||
|
|
return String(timestamp || "");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function appendEditorLogEntry(level, message) {
|
||
|
|
const normalizedMessage = String(message || "").trim();
|
||
|
|
if (!normalizedMessage) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const timestamp = Date.now();
|
||
|
|
const entry = {
|
||
|
|
id: runtimeUniqueId(),
|
||
|
|
timestamp,
|
||
|
|
timestampLabel: formatEditorLogTimestamp(timestamp),
|
||
|
|
level: String(level || "Information").trim() || "Information",
|
||
|
|
message: normalizedMessage,
|
||
|
|
};
|
||
|
|
editorLogEntries.push(entry);
|
||
|
|
while (editorLogEntries.length > EDITOR_LOG_LIMIT) {
|
||
|
|
editorLogEntries.shift();
|
||
|
|
}
|
||
|
|
statusLogWindowController?.refresh?.();
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEditorLogEntries() {
|
||
|
|
return editorLogEntries.slice();
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearEditorLogEntries() {
|
||
|
|
editorLogEntries.splice(0, editorLogEntries.length);
|
||
|
|
statusLogWindowController?.refresh?.();
|
||
|
|
}
|
||
|
|
|
||
|
|
window.addEventListener("error", (event) => {
|
||
|
|
const message = String(event?.message || event?.error?.message || "Unknown runtime error");
|
||
|
|
appendEditorLogEntry("Error", message);
|
||
|
|
});
|
||
|
|
window.addEventListener("unhandledrejection", (event) => {
|
||
|
|
const reason = event?.reason;
|
||
|
|
const message = typeof reason === "string"
|
||
|
|
? reason
|
||
|
|
: String(reason?.message || reason || "Unhandled promise rejection");
|
||
|
|
appendEditorLogEntry("Error", message);
|
||
|
|
});
|
||
|
|
let renderController = null;
|
||
|
|
const documentController = createMapDocumentController({
|
||
|
|
mapId: currentMapId,
|
||
|
|
getMapId: () => currentMapId,
|
||
|
|
mapDocument,
|
||
|
|
popupSessionStore,
|
||
|
|
baseRows: currentBaseRows,
|
||
|
|
getBaseRows: () => currentBaseRows,
|
||
|
|
baseTileSize,
|
||
|
|
normalizeBackgroundTileId,
|
||
|
|
normalizeMapBackgroundColor,
|
||
|
|
onMapNameUpdated: syncDocumentTitle,
|
||
|
|
invalidateTileSurface: () => {
|
||
|
|
if (renderController && typeof renderController.invalidateTileSurface === "function") {
|
||
|
|
renderController.invalidateTileSurface("document-controller");
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
let editorSettingsState = normalizeEditorSettings(initialEditorSettings);
|
||
|
|
|
||
|
|
// ── AtTooltip: reusable anchored floating context menu ──────────────
|
||
|
|
const atTooltip = createAtTooltip();
|
||
|
|
|
||
|
|
const initialEditorUiState = bootstrap.editorUi;
|
||
|
|
const editorUiStore = createEditorUiStore(initialEditorUiState);
|
||
|
|
const historyState = createHistoryStateStore();
|
||
|
|
let currentHistoryStorageKey = deriveHistoryStorageKey(currentMapId);
|
||
|
|
const popupBoundsStorageKey = MAP_EDITOR_POPUP_BOUNDS_STORAGE_KEY;
|
||
|
|
|
||
|
|
function runtimeEscapeHtml(value) {
|
||
|
|
return String(value || "")
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">")
|
||
|
|
.replace(/\"/g, """)
|
||
|
|
.replace(/'/g, "'");
|
||
|
|
}
|
||
|
|
|
||
|
|
function getActiveThemePreset() {
|
||
|
|
return normalizeMapEditorThemePreset(editorSettingsState?.mapEditor?.themePreset || DEFAULT_MAP_EDITOR_THEME_PRESET);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEditorEngineOverrides() {
|
||
|
|
return normalizeEngineOverrideEntries(editorSettingsState?.mapEditor?.engineOverrides);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEffectiveHeightBlurStep() {
|
||
|
|
const overrideValue = getEngineOverrideValue(getEditorEngineOverrides(), "heightBlurStep", null);
|
||
|
|
if (typeof overrideValue === "number" && Number.isFinite(overrideValue)) {
|
||
|
|
return Math.max(0, Math.min(1, Number(overrideValue) || 0.1));
|
||
|
|
}
|
||
|
|
return Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isRendererDebugEnabled() {
|
||
|
|
const overrideValue = getEngineOverrideValue(getEditorEngineOverrides(), "rendererDebug", null);
|
||
|
|
if (typeof overrideValue === "boolean") {
|
||
|
|
return overrideValue;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshEditorEngineOverridesUi() {
|
||
|
|
scope.refreshEngineOverrideSummary?.();
|
||
|
|
scope.refreshEngineOverrideWindow?.();
|
||
|
|
renderController?.refreshRendererDebugState?.();
|
||
|
|
drawNow();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveEditorEngineOverrides(nextEntries) {
|
||
|
|
const requestedEntries = normalizeEngineOverrideEntries(nextEntries);
|
||
|
|
editorSettingsState = await persistEditorSettings(apiBase, {
|
||
|
|
...editorSettingsState,
|
||
|
|
mapEditor: {
|
||
|
|
...editorSettingsState.mapEditor,
|
||
|
|
engineOverrides: requestedEntries,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const persistedEntries = getEditorEngineOverrides();
|
||
|
|
const requestedSignature = JSON.stringify(requestedEntries);
|
||
|
|
const persistedSignature = JSON.stringify(persistedEntries);
|
||
|
|
if (requestedSignature !== persistedSignature) {
|
||
|
|
throw new Error("Engine overrides were not persisted by the API. Restart the API server and try again.");
|
||
|
|
}
|
||
|
|
refreshEditorEngineOverridesUi();
|
||
|
|
return persistedEntries;
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshThemePresetButtons() {
|
||
|
|
const activePreset = getActiveThemePreset();
|
||
|
|
themePresetButtons.forEach((button) => {
|
||
|
|
const presetId = normalizeMapEditorThemePreset(button.getAttribute("data-theme-preset") || "");
|
||
|
|
button.classList.toggle("active", presetId === activePreset);
|
||
|
|
button.setAttribute("aria-pressed", presetId === activePreset ? "true" : "false");
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveThemePreset(nextPreset) {
|
||
|
|
editorSettingsState = await persistEditorSettings(apiBase, {
|
||
|
|
...editorSettingsState,
|
||
|
|
mapEditor: {
|
||
|
|
...editorSettingsState.mapEditor,
|
||
|
|
themePreset: nextPreset,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
refreshThemePresetButtons();
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyThemePreset(nextPreset, options) {
|
||
|
|
const normalizedPreset = normalizeMapEditorThemePreset(nextPreset);
|
||
|
|
editorSettingsState = normalizeEditorSettings({
|
||
|
|
...editorSettingsState,
|
||
|
|
mapEditor: {
|
||
|
|
...editorSettingsState.mapEditor,
|
||
|
|
themePreset: normalizedPreset,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
applyMapEditorThemePreset(normalizedPreset);
|
||
|
|
refreshThemePresetButtons();
|
||
|
|
if (!(options && options.silent)) {
|
||
|
|
setStatus("Theme switched to " + getMapEditorThemeLabel(normalizedPreset) + ".", false);
|
||
|
|
}
|
||
|
|
if (!(options && options.persist === false)) {
|
||
|
|
void saveThemePreset(normalizedPreset).catch((error) => {
|
||
|
|
setStatus(String(error), true);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return normalizedPreset;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneLayers(source) {
|
||
|
|
return source.map((layer) => ({
|
||
|
|
layer: Number(layer.layer) || 0,
|
||
|
|
name: typeof layer.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
|
||
|
|
rows: Array.isArray(layer.rows) ? layer.rows.map((row) => String(row || "")) : [],
|
||
|
|
instanceIds: Array.isArray(layer.instanceIds) ? layer.instanceIds.map((id) => String(id || "").trim()).filter(Boolean) : [],
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneHeightLayers(source) {
|
||
|
|
return documentController.cloneHeightLayers(source);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getNeighborhoodCacheKey(centerChunkX, centerChunkY, radiusOverride) {
|
||
|
|
const radius = Math.max(0, Math.floor(Number(radiusOverride) || worldRuntimeState.chunkRadius));
|
||
|
|
return buildChunkKey(centerChunkX, centerChunkY) + ":r" + radius;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildNeighborhoodChunkCoords(centerChunkX, centerChunkY, radiusOverride) {
|
||
|
|
const coords = [];
|
||
|
|
const radius = Math.max(0, Math.floor(Number(radiusOverride) || worldRuntimeState.chunkRadius));
|
||
|
|
for (let chunkY = centerChunkY - radius; chunkY <= centerChunkY + radius; chunkY += 1) {
|
||
|
|
for (let chunkX = centerChunkX - radius; chunkX <= centerChunkX + radius; chunkX += 1) {
|
||
|
|
coords.push({
|
||
|
|
chunkX: Math.floor(Number(chunkX) || 0),
|
||
|
|
chunkY: Math.floor(Number(chunkY) || 0),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return coords;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cacheWorldChunks(chunks) {
|
||
|
|
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||
|
|
if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const chunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||
|
|
const chunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||
|
|
const chunkKey = buildChunkKey(chunkX, chunkY);
|
||
|
|
if (worldRuntimeState.dirtyChunkKeys.has(chunkKey) && worldRuntimeState.chunkCache.has(chunkKey)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, normalizeCachedWorldChunkPayload(chunk, chunkX, chunkY));
|
||
|
|
});
|
||
|
|
pruneWorldChunkCache();
|
||
|
|
}
|
||
|
|
|
||
|
|
function completeWorldNeighborhoodChunks(centerChunkX, centerChunkY, radiusOverride, sourceChunks) {
|
||
|
|
const chunkMap = new Map();
|
||
|
|
(Array.isArray(sourceChunks) ? sourceChunks : []).forEach((chunk) => {
|
||
|
|
if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const safeChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||
|
|
chunkMap.set(
|
||
|
|
buildChunkKey(safeChunkX, safeChunkY),
|
||
|
|
normalizeCachedWorldChunkPayload(chunk, safeChunkX, safeChunkY),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
return buildNeighborhoodChunkCoords(centerChunkX, centerChunkY, radiusOverride)
|
||
|
|
.map((coord) => {
|
||
|
|
const chunkKey = buildChunkKey(coord.chunkX, coord.chunkY);
|
||
|
|
const payload = chunkMap.get(chunkKey)
|
||
|
|
|| worldRuntimeState.chunkCache.get(chunkKey)
|
||
|
|
|| createEmptyWorldChunkPayload(coord.chunkX, coord.chunkY);
|
||
|
|
return normalizeCachedWorldChunkPayload(payload, coord.chunkX, coord.chunkY);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncCachedWorldHeightLayerMetadata() {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const metadataById = new Map(
|
||
|
|
(Array.isArray(mapDocument.heightLayers) ? mapDocument.heightLayers : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const heightLayerId = String(entry?.id || "").trim();
|
||
|
|
if (!heightLayerId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return [heightLayerId, {
|
||
|
|
id: heightLayerId,
|
||
|
|
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||
|
|
z: Math.max(1, Number(entry?.z) || 1),
|
||
|
|
}];
|
||
|
|
})
|
||
|
|
.filter(Boolean),
|
||
|
|
);
|
||
|
|
const nextEntries = [];
|
||
|
|
for (const [chunkKey, chunkValue] of worldRuntimeState.chunkCache.entries()) {
|
||
|
|
if (!chunkValue || typeof chunkValue !== "object" || Array.isArray(chunkValue)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const nextHeightLayers = (Array.isArray(chunkValue.heightLayers) ? chunkValue.heightLayers : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const heightLayerId = String(entry?.id || "").trim();
|
||
|
|
const metadata = metadataById.get(heightLayerId);
|
||
|
|
if (!metadata) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...cloneValue(entry),
|
||
|
|
id: metadata.id,
|
||
|
|
name: metadata.name,
|
||
|
|
z: metadata.z,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter(Boolean);
|
||
|
|
nextEntries.push([chunkKey, {
|
||
|
|
...cloneValue(chunkValue),
|
||
|
|
heightLayers: nextHeightLayers,
|
||
|
|
}]);
|
||
|
|
}
|
||
|
|
nextEntries.forEach(([chunkKey, chunkValue]) => {
|
||
|
|
const previousValue = worldRuntimeState.chunkCache.get(chunkKey);
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, chunkValue);
|
||
|
|
if (JSON.stringify(previousValue?.heightLayers || []) !== JSON.stringify(chunkValue?.heightLayers || [])) {
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getCachedNeighborhoodChunks(centerChunkX, centerChunkY, radiusOverride) {
|
||
|
|
const coords = buildNeighborhoodChunkCoords(centerChunkX, centerChunkY, radiusOverride);
|
||
|
|
const chunks = [];
|
||
|
|
for (const coord of coords) {
|
||
|
|
const chunk = worldRuntimeState.chunkCache.get(buildChunkKey(coord.chunkX, coord.chunkY));
|
||
|
|
if (!chunk) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
touchWorldChunkCacheEntry(buildChunkKey(coord.chunkX, coord.chunkY), chunk);
|
||
|
|
chunks.push(chunk);
|
||
|
|
}
|
||
|
|
return chunks;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function requestWorldNeighborhood(centerChunkX, centerChunkY, options) {
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const safeCenterChunkX = Math.floor(Number(centerChunkX) || 0);
|
||
|
|
const safeCenterChunkY = Math.floor(Number(centerChunkY) || 0);
|
||
|
|
const requestedRadius = Math.max(0, Math.floor(Number(config.radius) || worldRuntimeState.chunkRadius));
|
||
|
|
const requestKey = getNeighborhoodCacheKey(safeCenterChunkX, safeCenterChunkY, requestedRadius);
|
||
|
|
if (worldRuntimeState.pendingNeighborhoodFetches.has(requestKey)) {
|
||
|
|
return worldRuntimeState.pendingNeighborhoodFetches.get(requestKey);
|
||
|
|
}
|
||
|
|
const requestUrl = new URL(`/api/world/${encodeURIComponent(worldRuntimeState.worldId)}/chunks`, apiBase || window.location.origin);
|
||
|
|
requestUrl.searchParams.set("chunkX", String(safeCenterChunkX));
|
||
|
|
requestUrl.searchParams.set("chunkY", String(safeCenterChunkY));
|
||
|
|
requestUrl.searchParams.set("radius", String(requestedRadius));
|
||
|
|
requestUrl.searchParams.set("createIfMissing", config.createIfMissing === true ? "1" : "0");
|
||
|
|
const requestPromise = fetchJsonOrThrow(requestUrl.toString())
|
||
|
|
.then((payload) => {
|
||
|
|
const nextDefaultBackgroundTileId = String(payload?.world?.defaultBackgroundTileId || "").trim();
|
||
|
|
if (nextDefaultBackgroundTileId) {
|
||
|
|
worldRuntimeState.defaultBackgroundTileId = nextDefaultBackgroundTileId;
|
||
|
|
}
|
||
|
|
if (payload?.world) {
|
||
|
|
worldRuntimeState.heightBlurStep = Math.max(0, Math.min(1, Number(payload.world.heightBlurStep ?? payload.world.heightDetailStep) || worldRuntimeState.heightBlurStep || 0.1));
|
||
|
|
}
|
||
|
|
const completedChunks = completeWorldNeighborhoodChunks(
|
||
|
|
safeCenterChunkX,
|
||
|
|
safeCenterChunkY,
|
||
|
|
requestedRadius,
|
||
|
|
payload?.chunks,
|
||
|
|
);
|
||
|
|
cacheWorldChunks(completedChunks);
|
||
|
|
return {
|
||
|
|
...payload,
|
||
|
|
chunks: completedChunks,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.finally(() => {
|
||
|
|
worldRuntimeState.pendingNeighborhoodFetches.delete(requestKey);
|
||
|
|
});
|
||
|
|
worldRuntimeState.pendingNeighborhoodFetches.set(requestKey, requestPromise);
|
||
|
|
return requestPromise;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function ensureWorldChunkCachedForEdit(chunkX, chunkY) {
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const existingChunk = getCachedWorldChunk(safeChunkX, safeChunkY);
|
||
|
|
if (existingChunk) {
|
||
|
|
return existingChunk;
|
||
|
|
}
|
||
|
|
await requestWorldNeighborhood(safeChunkX, safeChunkY, {
|
||
|
|
createIfMissing: false,
|
||
|
|
radius: 0,
|
||
|
|
});
|
||
|
|
const hydratedChunk = getCachedWorldChunk(safeChunkX, safeChunkY);
|
||
|
|
if (hydratedChunk) {
|
||
|
|
return hydratedChunk;
|
||
|
|
}
|
||
|
|
const emptyChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
|
||
|
|
safeChunkX,
|
||
|
|
safeChunkY,
|
||
|
|
);
|
||
|
|
touchWorldChunkCacheEntry(buildChunkKey(safeChunkX, safeChunkY), emptyChunk);
|
||
|
|
return emptyChunk;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeWorldChunkRows(rows, width, height, fillChar) {
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||
|
|
return Array.from({ length: safeHeight }, (_entry, rowIndex) => {
|
||
|
|
const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || "");
|
||
|
|
return sourceRow.length >= safeWidth
|
||
|
|
? sourceRow.slice(0, safeWidth)
|
||
|
|
: (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length)));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneWorldChunkHeightLayers(source) {
|
||
|
|
return (Array.isArray(source) ? source : [])
|
||
|
|
.map((entry, index) => ({
|
||
|
|
id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`,
|
||
|
|
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||
|
|
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
|
||
|
|
x: Math.max(0, Math.floor(Number(entry?.x) || 0)),
|
||
|
|
y: Math.max(0, Math.floor(Number(entry?.y) || 0)),
|
||
|
|
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
||
|
|
}))
|
||
|
|
.filter((entry) => entry.id);
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) {
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||
|
|
const nextLayers = new Map();
|
||
|
|
(Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => {
|
||
|
|
const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0));
|
||
|
|
nextLayers.set(layerNumber, {
|
||
|
|
layer: layerNumber,
|
||
|
|
name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
|
||
|
|
rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (!nextLayers.has(0)) {
|
||
|
|
nextLayers.set(0, {
|
||
|
|
layer: 0,
|
||
|
|
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."),
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) {
|
||
|
|
nextLayers.set(1, {
|
||
|
|
layer: 1,
|
||
|
|
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "),
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
(Array.isArray(instances) ? instances : []).forEach((entry) => {
|
||
|
|
const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0));
|
||
|
|
const instanceId = String(entry?.id || "").trim();
|
||
|
|
if (!instanceId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!nextLayers.has(layerNumber)) {
|
||
|
|
nextLayers.set(layerNumber, {
|
||
|
|
layer: layerNumber,
|
||
|
|
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
|
||
|
|
instanceIds: [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
nextLayers.get(layerNumber).instanceIds.push(instanceId);
|
||
|
|
});
|
||
|
|
return Array.from(nextLayers.values())
|
||
|
|
.map((entry) => ({
|
||
|
|
...entry,
|
||
|
|
instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))),
|
||
|
|
}))
|
||
|
|
.sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeWorldChunkInstances(sourceInstances, chunkX, chunkY, width, height, options) {
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const duplicateIds = config.duplicateIds === true;
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||
|
|
return (Array.isArray(sourceInstances) ? sourceInstances : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record)
|
||
|
|
? cloneValue(entry.record)
|
||
|
|
: {};
|
||
|
|
const nextId = duplicateIds
|
||
|
|
? runtimeUniqueId()
|
||
|
|
: (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId());
|
||
|
|
const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0));
|
||
|
|
const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0)));
|
||
|
|
const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0)));
|
||
|
|
const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim();
|
||
|
|
record.id = nextId;
|
||
|
|
record.layer = nextLayer;
|
||
|
|
record.templateId = nextTemplateId;
|
||
|
|
record.position = {
|
||
|
|
x: (safeChunkX * safeWidth) + nextX,
|
||
|
|
y: (safeChunkY * safeHeight) + nextY,
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
id: nextId,
|
||
|
|
templateId: nextTemplateId,
|
||
|
|
layer: nextLayer,
|
||
|
|
x: nextX,
|
||
|
|
y: nextY,
|
||
|
|
record,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((entry) => entry.id);
|
||
|
|
}
|
||
|
|
|
||
|
|
function createEmptyWorldChunkPayload(chunkX, chunkY) {
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const chunkWidth = Math.max(1, Number(worldRuntimeState.chunkWidth) || 32);
|
||
|
|
const chunkHeight = Math.max(1, Number(worldRuntimeState.chunkHeight) || 32);
|
||
|
|
return {
|
||
|
|
schemaVersion: 1,
|
||
|
|
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
|
||
|
|
chunkX: safeChunkX,
|
||
|
|
chunkY: safeChunkY,
|
||
|
|
width: chunkWidth,
|
||
|
|
height: chunkHeight,
|
||
|
|
backgroundTileId: "",
|
||
|
|
roomLayers: [
|
||
|
|
{
|
||
|
|
layer: 0,
|
||
|
|
rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)),
|
||
|
|
instanceIds: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
layer: 1,
|
||
|
|
rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)),
|
||
|
|
instanceIds: [],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
heightLayers: [],
|
||
|
|
instances: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeCachedWorldChunkPayload(chunkPayload, chunkX, chunkY, options) {
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0);
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(worldRuntimeState.chunkWidth) || 32));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(worldRuntimeState.chunkHeight) || 32));
|
||
|
|
const instances = normalizeWorldChunkInstances(chunkPayload?.instances, safeChunkX, safeChunkY, safeWidth, safeHeight, options);
|
||
|
|
const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight);
|
||
|
|
return {
|
||
|
|
schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)),
|
||
|
|
worldId: String(chunkPayload?.worldId || worldRuntimeState.worldId || currentMapId || "").trim(),
|
||
|
|
chunkX: safeChunkX,
|
||
|
|
chunkY: safeChunkY,
|
||
|
|
width: safeWidth,
|
||
|
|
height: safeHeight,
|
||
|
|
backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(),
|
||
|
|
roomLayers,
|
||
|
|
heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers),
|
||
|
|
instances,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function isChunkFillSymbol(ch, fillChar) {
|
||
|
|
const symbol = String(ch || "").charAt(0);
|
||
|
|
return !symbol || symbol === fillChar || symbol === "." || symbol === " ";
|
||
|
|
}
|
||
|
|
|
||
|
|
function isWorldChunkPayloadEmpty(chunkPayload) {
|
||
|
|
const normalized = normalizeCachedWorldChunkPayload(chunkPayload, chunkPayload?.chunkX, chunkPayload?.chunkY);
|
||
|
|
if (String(normalized?.backgroundTileId || "").trim()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => (
|
||
|
|
Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || "")))
|
||
|
|
))) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => {
|
||
|
|
const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " ";
|
||
|
|
return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => {
|
||
|
|
const sourceRow = String(row || "");
|
||
|
|
for (let index = 0; index < sourceRow.length; index += 1) {
|
||
|
|
if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformChunkLocalCoord(localX, localY, width, height, operation) {
|
||
|
|
const safeX = Math.floor(Number(localX) || 0);
|
||
|
|
const safeY = Math.floor(Number(localY) || 0);
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||
|
|
switch (String(operation || "").trim()) {
|
||
|
|
case "flipHorizontal":
|
||
|
|
return { x: (safeWidth - 1) - safeX, y: safeY };
|
||
|
|
case "flipVertical":
|
||
|
|
return { x: safeX, y: (safeHeight - 1) - safeY };
|
||
|
|
case "rotate180":
|
||
|
|
return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY };
|
||
|
|
case "rotate90cw":
|
||
|
|
if (safeWidth !== safeHeight) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return { x: (safeWidth - 1) - safeY, y: safeX };
|
||
|
|
case "rotate90ccw":
|
||
|
|
if (safeWidth !== safeHeight) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return { x: safeY, y: (safeHeight - 1) - safeX };
|
||
|
|
default:
|
||
|
|
return { x: safeX, y: safeY };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformChunkRows(rows, width, height, fillChar, operation) {
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||
|
|
const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar);
|
||
|
|
const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " "));
|
||
|
|
for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) {
|
||
|
|
const sourceRow = sourceRows[rowIndex];
|
||
|
|
for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) {
|
||
|
|
const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " ";
|
||
|
|
if (isChunkFillSymbol(char, fillChar)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation);
|
||
|
|
if (!nextCoord) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
nextRows[nextCoord.y][nextCoord.x] = char;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nextRows.map((row) => row.join(""));
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformChunkHeightPatch(patch, width, height, operation) {
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||
|
|
const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : [];
|
||
|
|
const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0);
|
||
|
|
const patchHeight = sourceRows.length;
|
||
|
|
const transformedCells = [];
|
||
|
|
for (let localY = 0; localY < patchHeight; localY += 1) {
|
||
|
|
const row = sourceRows[localY] || "";
|
||
|
|
for (let localX = 0; localX < patchWidth; localX += 1) {
|
||
|
|
const char = String(row.charAt(localX) || " ").charAt(0) || " ";
|
||
|
|
if (char === " " || char === ".") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX;
|
||
|
|
const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY;
|
||
|
|
if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation);
|
||
|
|
if (!nextCoord) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
transformedCells.push({
|
||
|
|
x: nextCoord.x,
|
||
|
|
y: nextCoord.y,
|
||
|
|
char,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (transformedCells.length <= 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x);
|
||
|
|
const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x);
|
||
|
|
const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y);
|
||
|
|
const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y);
|
||
|
|
const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " "));
|
||
|
|
transformedCells.forEach((entry) => {
|
||
|
|
nextRows[entry.y - minY][entry.x - minX] = entry.char;
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
id: String(patch?.id || "").trim(),
|
||
|
|
name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined,
|
||
|
|
z: Math.max(1, Math.floor(Number(patch?.z) || 1)),
|
||
|
|
x: minX,
|
||
|
|
y: minY,
|
||
|
|
rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformWorldChunkPayload(chunkPayload, operation, options) {
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const normalized = normalizeCachedWorldChunkPayload(chunkPayload, chunkPayload?.chunkX, chunkPayload?.chunkY, config);
|
||
|
|
const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1));
|
||
|
|
const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1));
|
||
|
|
const normalizedOperation = String(operation || "").trim();
|
||
|
|
if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) {
|
||
|
|
throw new Error("Chunk rotation requires square chunks.");
|
||
|
|
}
|
||
|
|
const instances = normalizeWorldChunkInstances(
|
||
|
|
(Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => {
|
||
|
|
const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation);
|
||
|
|
return {
|
||
|
|
...cloneValue(entry),
|
||
|
|
x: nextCoord?.x ?? entry.x,
|
||
|
|
y: nextCoord?.y ?? entry.y,
|
||
|
|
};
|
||
|
|
}),
|
||
|
|
normalized.chunkX,
|
||
|
|
normalized.chunkY,
|
||
|
|
safeWidth,
|
||
|
|
safeHeight,
|
||
|
|
config,
|
||
|
|
);
|
||
|
|
const roomLayers = buildWorldChunkLayerInstanceIds(
|
||
|
|
(Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({
|
||
|
|
...cloneValue(layer),
|
||
|
|
rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation),
|
||
|
|
})),
|
||
|
|
instances,
|
||
|
|
safeWidth,
|
||
|
|
safeHeight,
|
||
|
|
);
|
||
|
|
const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers)
|
||
|
|
.map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation))
|
||
|
|
.filter(Boolean)
|
||
|
|
.sort((left, right) => {
|
||
|
|
if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) {
|
||
|
|
return (Number(left?.z) || 0) - (Number(right?.z) || 0);
|
||
|
|
}
|
||
|
|
return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || ""));
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
...normalized,
|
||
|
|
roomLayers,
|
||
|
|
heightLayers,
|
||
|
|
instances,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function commitWorldChunkPayloads(nextChunks, reason) {
|
||
|
|
const entries = Array.isArray(nextChunks) ? nextChunks : [];
|
||
|
|
if (entries.length <= 0) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const touchedChunkKeys = [];
|
||
|
|
entries.forEach((entry) => {
|
||
|
|
const normalized = normalizeCachedWorldChunkPayload(entry, entry?.chunkX, entry?.chunkY);
|
||
|
|
const chunkKey = buildChunkKey(normalized.chunkX, normalized.chunkY);
|
||
|
|
touchedChunkKeys.push(chunkKey);
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, normalized);
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
});
|
||
|
|
worldRuntimeState.documentDirty = true;
|
||
|
|
ensureWorldDocumentCurrent();
|
||
|
|
invalidateTileSurface(reason || "world-chunk-mutation");
|
||
|
|
refreshInformationPanel();
|
||
|
|
draw();
|
||
|
|
scope.refreshToolbarState?.();
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.(touchedChunkKeys);
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function moveWorldChunkContent(sourceChunkX, sourceChunkY, targetChunkX, targetChunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return { ok: false, reason: "world-mode-inactive" };
|
||
|
|
}
|
||
|
|
const safeSourceChunkX = Math.floor(Number(sourceChunkX) || 0);
|
||
|
|
const safeSourceChunkY = Math.floor(Number(sourceChunkY) || 0);
|
||
|
|
const safeTargetChunkX = Math.floor(Number(targetChunkX) || 0);
|
||
|
|
const safeTargetChunkY = Math.floor(Number(targetChunkY) || 0);
|
||
|
|
if (safeSourceChunkX === safeTargetChunkX && safeSourceChunkY === safeTargetChunkY) {
|
||
|
|
setStatus("Move cancelled: choose a different destination chunk.", true);
|
||
|
|
return { ok: false, reason: "same-chunk" };
|
||
|
|
}
|
||
|
|
const sourceChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
|
||
|
|
safeSourceChunkX,
|
||
|
|
safeSourceChunkY,
|
||
|
|
);
|
||
|
|
if (isWorldChunkPayloadEmpty(sourceChunk)) {
|
||
|
|
setStatus("Move failed: source chunk is empty.", true);
|
||
|
|
return { ok: false, reason: "source-empty" };
|
||
|
|
}
|
||
|
|
const destinationChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
|
||
|
|
safeTargetChunkX,
|
||
|
|
safeTargetChunkY,
|
||
|
|
);
|
||
|
|
if (!isWorldChunkPayloadEmpty(destinationChunk)) {
|
||
|
|
setStatus("Move failed: destination chunk is not empty.", true);
|
||
|
|
return { ok: false, reason: "destination-occupied" };
|
||
|
|
}
|
||
|
|
const movedChunk = normalizeCachedWorldChunkPayload(sourceChunk, safeTargetChunkX, safeTargetChunkY);
|
||
|
|
const clearedSource = createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY);
|
||
|
|
commitWorldChunkPayloads([movedChunk, clearedSource], "world-chunk-move");
|
||
|
|
registerHistory(
|
||
|
|
"Chunk moved",
|
||
|
|
safeSourceChunkX + "," + safeSourceChunkY,
|
||
|
|
safeTargetChunkX + "," + safeTargetChunkY,
|
||
|
|
[
|
||
|
|
"Source: " + safeSourceChunkX + "," + safeSourceChunkY,
|
||
|
|
"Destination: " + safeTargetChunkX + "," + safeTargetChunkY,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
setStatus("Moved chunk " + safeSourceChunkX + "," + safeSourceChunkY + " to " + safeTargetChunkX + "," + safeTargetChunkY + ".", false);
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
sourceChunkKey: buildChunkKey(safeSourceChunkX, safeSourceChunkY),
|
||
|
|
targetChunkKey: buildChunkKey(safeTargetChunkX, safeTargetChunkY),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function duplicateWorldChunkContent(sourceChunkX, sourceChunkY, targetChunkX, targetChunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return { ok: false, reason: "world-mode-inactive" };
|
||
|
|
}
|
||
|
|
const safeSourceChunkX = Math.floor(Number(sourceChunkX) || 0);
|
||
|
|
const safeSourceChunkY = Math.floor(Number(sourceChunkY) || 0);
|
||
|
|
const safeTargetChunkX = Math.floor(Number(targetChunkX) || 0);
|
||
|
|
const safeTargetChunkY = Math.floor(Number(targetChunkY) || 0);
|
||
|
|
if (safeSourceChunkX === safeTargetChunkX && safeSourceChunkY === safeTargetChunkY) {
|
||
|
|
setStatus("Duplicate cancelled: choose a different destination chunk.", true);
|
||
|
|
return { ok: false, reason: "same-chunk" };
|
||
|
|
}
|
||
|
|
const sourceChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
|
||
|
|
safeSourceChunkX,
|
||
|
|
safeSourceChunkY,
|
||
|
|
);
|
||
|
|
if (isWorldChunkPayloadEmpty(sourceChunk)) {
|
||
|
|
setStatus("Duplicate failed: source chunk is empty.", true);
|
||
|
|
return { ok: false, reason: "source-empty" };
|
||
|
|
}
|
||
|
|
const destinationChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
|
||
|
|
safeTargetChunkX,
|
||
|
|
safeTargetChunkY,
|
||
|
|
);
|
||
|
|
if (!isWorldChunkPayloadEmpty(destinationChunk)) {
|
||
|
|
setStatus("Duplicate failed: destination chunk is not empty.", true);
|
||
|
|
return { ok: false, reason: "destination-occupied" };
|
||
|
|
}
|
||
|
|
const duplicatedChunk = normalizeCachedWorldChunkPayload({
|
||
|
|
...cloneValue(sourceChunk),
|
||
|
|
instances: [],
|
||
|
|
}, safeTargetChunkX, safeTargetChunkY);
|
||
|
|
commitWorldChunkPayloads([duplicatedChunk], "world-chunk-duplicate");
|
||
|
|
registerHistory(
|
||
|
|
"Chunk duplicated",
|
||
|
|
safeSourceChunkX + "," + safeSourceChunkY,
|
||
|
|
safeTargetChunkX + "," + safeTargetChunkY,
|
||
|
|
[
|
||
|
|
"Source: " + safeSourceChunkX + "," + safeSourceChunkY,
|
||
|
|
"Destination: " + safeTargetChunkX + "," + safeTargetChunkY,
|
||
|
|
"Placed entities copied: no",
|
||
|
|
],
|
||
|
|
);
|
||
|
|
setStatus("Duplicated chunk " + safeSourceChunkX + "," + safeSourceChunkY + " into " + safeTargetChunkX + "," + safeTargetChunkY + ".", false);
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
sourceChunkKey: buildChunkKey(safeSourceChunkX, safeSourceChunkY),
|
||
|
|
targetChunkKey: buildChunkKey(safeTargetChunkX, safeTargetChunkY),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function transformWorldChunkAt(chunkX, chunkY, operation) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return { ok: false, reason: "world-mode-inactive" };
|
||
|
|
}
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const normalizedOperation = String(operation || "").trim();
|
||
|
|
const sourceChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
|
||
|
|
safeChunkX,
|
||
|
|
safeChunkY,
|
||
|
|
);
|
||
|
|
if (isWorldChunkPayloadEmpty(sourceChunk)) {
|
||
|
|
setStatus("Transform failed: chunk is empty.", true);
|
||
|
|
return { ok: false, reason: "source-empty" };
|
||
|
|
}
|
||
|
|
const transformedChunk = transformWorldChunkPayload(sourceChunk, normalizedOperation);
|
||
|
|
commitWorldChunkPayloads([transformedChunk], "world-chunk-transform");
|
||
|
|
registerHistory(
|
||
|
|
"Chunk transformed",
|
||
|
|
safeChunkX + "," + safeChunkY,
|
||
|
|
normalizedOperation,
|
||
|
|
[
|
||
|
|
"Chunk: " + safeChunkX + "," + safeChunkY,
|
||
|
|
"Operation: " + normalizedOperation,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
setStatus("Transformed chunk " + safeChunkX + "," + safeChunkY + ".", false);
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
chunkKey: buildChunkKey(safeChunkX, safeChunkY),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function clearWorldChunkAt(chunkX, chunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return { ok: false, reason: "world-mode-inactive" };
|
||
|
|
}
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const existingChunk = normalizeCachedWorldChunkPayload(
|
||
|
|
cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
|
||
|
|
safeChunkX,
|
||
|
|
safeChunkY,
|
||
|
|
);
|
||
|
|
if (isWorldChunkPayloadEmpty(existingChunk)) {
|
||
|
|
setStatus("Chunk " + safeChunkX + "," + safeChunkY + " is already empty.", false);
|
||
|
|
return { ok: false, reason: "already-empty" };
|
||
|
|
}
|
||
|
|
commitWorldChunkPayloads([createEmptyWorldChunkPayload(safeChunkX, safeChunkY)], "world-chunk-clear");
|
||
|
|
registerHistory(
|
||
|
|
"Chunk cleared",
|
||
|
|
safeChunkX + "," + safeChunkY,
|
||
|
|
"empty",
|
||
|
|
[
|
||
|
|
"Chunk: " + safeChunkX + "," + safeChunkY,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
setStatus("Cleared chunk " + safeChunkX + "," + safeChunkY + ".", false);
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
chunkKey: buildChunkKey(safeChunkX, safeChunkY),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function applyWorldChunkBackgroundTileAt(chunkX, chunkY, nextBackgroundTileId) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||
|
|
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||
|
|
const chunkWidth = Math.max(1, Number(worldRuntimeState.chunkWidth) || 32);
|
||
|
|
const chunkHeight = Math.max(1, Number(worldRuntimeState.chunkHeight) || 32);
|
||
|
|
const worldBackgroundTileId = String(worldRuntimeState.defaultBackgroundTileId || mapDocument.backgroundTileId || "").trim();
|
||
|
|
const normalizedBackgroundTileId = String(nextBackgroundTileId == null ? "" : normalizeBackgroundTileId(nextBackgroundTileId)).trim();
|
||
|
|
const storedBackgroundTileId = normalizedBackgroundTileId && normalizedBackgroundTileId !== worldBackgroundTileId
|
||
|
|
? normalizedBackgroundTileId
|
||
|
|
: "";
|
||
|
|
const chunkKey = buildChunkKey(safeChunkX, safeChunkY);
|
||
|
|
const existingChunk = cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || {
|
||
|
|
chunkX: safeChunkX,
|
||
|
|
chunkY: safeChunkY,
|
||
|
|
width: chunkWidth,
|
||
|
|
height: chunkHeight,
|
||
|
|
backgroundTileId: "",
|
||
|
|
roomLayers: [],
|
||
|
|
heightLayers: [],
|
||
|
|
instances: [],
|
||
|
|
};
|
||
|
|
const baseLayerRows = Array.isArray(existingChunk?.roomLayers)
|
||
|
|
? ((existingChunk.roomLayers.find((entry) => Number(entry?.layer) === 0) || {}).rows || [])
|
||
|
|
: [];
|
||
|
|
const normalizedBaseRows = Array.from({ length: chunkHeight }, (_entry, rowIndex) => {
|
||
|
|
const sourceRow = String(baseLayerRows[rowIndex] || "");
|
||
|
|
return sourceRow.length >= chunkWidth
|
||
|
|
? sourceRow.slice(0, chunkWidth)
|
||
|
|
: (sourceRow + ".".repeat(Math.max(0, chunkWidth - sourceRow.length)));
|
||
|
|
});
|
||
|
|
const hadExplicitBaseCells = normalizedBaseRows.some((row) => /[^.]/.test(String(row || "")));
|
||
|
|
const currentStoredBackgroundTileId = String(existingChunk?.backgroundTileId || "").trim();
|
||
|
|
if (currentStoredBackgroundTileId === storedBackgroundTileId && !hadExplicitBaseCells) {
|
||
|
|
setStatus(
|
||
|
|
storedBackgroundTileId
|
||
|
|
? ("Chunk " + safeChunkX + "," + safeChunkY + " already uses " + describeBrushTileId(storedBackgroundTileId) + ".")
|
||
|
|
: ("Chunk " + safeChunkX + "," + safeChunkY + " already inherits the world background."),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const nextRoomLayers = Array.isArray(existingChunk?.roomLayers)
|
||
|
|
? cloneLayers(existingChunk.roomLayers)
|
||
|
|
: [];
|
||
|
|
let baseLayer = nextRoomLayers.find((entry) => Number(entry?.layer) === 0) || null;
|
||
|
|
if (!baseLayer) {
|
||
|
|
baseLayer = {
|
||
|
|
layer: 0,
|
||
|
|
rows: [],
|
||
|
|
instanceIds: [],
|
||
|
|
};
|
||
|
|
nextRoomLayers.push(baseLayer);
|
||
|
|
}
|
||
|
|
baseLayer.rows = Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth));
|
||
|
|
const nextChunk = {
|
||
|
|
...existingChunk,
|
||
|
|
chunkX: safeChunkX,
|
||
|
|
chunkY: safeChunkY,
|
||
|
|
width: chunkWidth,
|
||
|
|
height: chunkHeight,
|
||
|
|
backgroundTileId: storedBackgroundTileId,
|
||
|
|
roomLayers: nextRoomLayers,
|
||
|
|
};
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, nextChunk);
|
||
|
|
worldRuntimeState.dirtyChunkKeys.add(chunkKey);
|
||
|
|
worldRuntimeState.documentDirty = true;
|
||
|
|
ensureWorldDocumentCurrent();
|
||
|
|
invalidateTileSurface("world-overview-chunk-fill");
|
||
|
|
refreshInformationPanel();
|
||
|
|
draw();
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.([chunkKey]);
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
registerHistory(
|
||
|
|
storedBackgroundTileId ? "Chunk background filled" : "Chunk background restored",
|
||
|
|
safeChunkX + "," + safeChunkY,
|
||
|
|
storedBackgroundTileId ? describeBrushTileId(storedBackgroundTileId) : "world background",
|
||
|
|
[
|
||
|
|
"Chunk: " + safeChunkX + "," + safeChunkY,
|
||
|
|
"Target: " + (storedBackgroundTileId ? describeBrushTileId(storedBackgroundTileId) : "world background"),
|
||
|
|
"Cleared explicit base cells: " + (hadExplicitBaseCells ? "yes" : "no"),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
setStatus(
|
||
|
|
storedBackgroundTileId
|
||
|
|
? ("Filled chunk " + safeChunkX + "," + safeChunkY + " with " + describeBrushTileId(storedBackgroundTileId) + ".")
|
||
|
|
: ("Restored chunk " + safeChunkX + "," + safeChunkY + " to the world background."),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyWorldNeighborhoodFromChunks(centerChunkX, centerChunkY, chunks, preservedWorldCenter, sourceReason, radiusOverride) {
|
||
|
|
const safeCenterChunkX = Math.floor(Number(centerChunkX) || 0);
|
||
|
|
const safeCenterChunkY = Math.floor(Number(centerChunkY) || 0);
|
||
|
|
const safeRadius = Math.max(0, Math.floor(Number(radiusOverride) || worldRuntimeState.chunkRadius));
|
||
|
|
const originChunkX = safeCenterChunkX - safeRadius;
|
||
|
|
const originChunkY = safeCenterChunkY - safeRadius;
|
||
|
|
const composedWidth = ((safeRadius * 2) + 1) * worldRuntimeState.chunkWidth;
|
||
|
|
const composedHeight = ((safeRadius * 2) + 1) * worldRuntimeState.chunkHeight;
|
||
|
|
const previousTileOffsetX = Math.floor(Number(worldRuntimeState.tileOffsetX) || 0);
|
||
|
|
const previousTileOffsetY = Math.floor(Number(worldRuntimeState.tileOffsetY) || 0);
|
||
|
|
const previousHoverTileX = Math.floor(Number(popupSessionStore.state.hoverTileX) || -1);
|
||
|
|
const previousHoverTileY = Math.floor(Number(popupSessionStore.state.hoverTileY) || -1);
|
||
|
|
const previousSelectedTile = popupSessionStore.state.selectedTile
|
||
|
|
? {
|
||
|
|
...popupSessionStore.state.selectedTile,
|
||
|
|
x: Math.floor(Number(popupSessionStore.state.selectedTile.x) || 0),
|
||
|
|
y: Math.floor(Number(popupSessionStore.state.selectedTile.y) || 0),
|
||
|
|
}
|
||
|
|
: null;
|
||
|
|
const roomLayers = composeWorldRoomLayers(
|
||
|
|
chunks,
|
||
|
|
worldRuntimeState.chunkWidth,
|
||
|
|
worldRuntimeState.chunkHeight,
|
||
|
|
originChunkX,
|
||
|
|
originChunkY,
|
||
|
|
composedWidth,
|
||
|
|
composedHeight,
|
||
|
|
);
|
||
|
|
const heightLayers = composeWorldHeightLayers(
|
||
|
|
chunks,
|
||
|
|
worldRuntimeState.chunkWidth,
|
||
|
|
worldRuntimeState.chunkHeight,
|
||
|
|
originChunkX,
|
||
|
|
originChunkY,
|
||
|
|
);
|
||
|
|
const npcOverlays = buildNpcOverlaysFromWorldChunks(
|
||
|
|
chunks,
|
||
|
|
spriteCatalog,
|
||
|
|
worldRuntimeState.chunkWidth,
|
||
|
|
worldRuntimeState.chunkHeight,
|
||
|
|
originChunkX,
|
||
|
|
originChunkY,
|
||
|
|
);
|
||
|
|
worldRuntimeState.chunkRadius = safeRadius;
|
||
|
|
worldRuntimeState.centerChunkX = safeCenterChunkX;
|
||
|
|
worldRuntimeState.centerChunkY = safeCenterChunkY;
|
||
|
|
worldRuntimeState.originChunkX = originChunkX;
|
||
|
|
worldRuntimeState.originChunkY = originChunkY;
|
||
|
|
worldRuntimeState.tileOffsetX = originChunkX * worldRuntimeState.chunkWidth;
|
||
|
|
worldRuntimeState.tileOffsetY = originChunkY * worldRuntimeState.chunkHeight;
|
||
|
|
worldRuntimeState.sourceChunks = chunks.map((chunk) => ({
|
||
|
|
chunkX: Math.floor(Number(chunk?.chunkX) || 0),
|
||
|
|
chunkY: Math.floor(Number(chunk?.chunkY) || 0),
|
||
|
|
}));
|
||
|
|
|
||
|
|
mapDocument.width = Math.max(1, composedWidth);
|
||
|
|
mapDocument.height = Math.max(1, composedHeight);
|
||
|
|
mapDocumentStore.setMapName(worldRuntimeState.worldName, currentMapId);
|
||
|
|
mapDocumentStore.setBackgroundTileId(
|
||
|
|
worldRuntimeState.defaultBackgroundTileId || mapDocument.backgroundTileId,
|
||
|
|
normalizeBackgroundTileId,
|
||
|
|
);
|
||
|
|
mapDocument.heightBlurStep = Math.max(0, Math.min(1, Number(worldRuntimeState.heightBlurStep) || mapDocument.heightBlurStep || mapDocument.heightDetailStep || 0.1));
|
||
|
|
mapDocumentStore.setBackgroundCellMode(mapDocument.backgroundTileId ? "tile" : "inherit", ["tile", "hole", "inherit"]);
|
||
|
|
mapDocument.mapInfoDraft = {
|
||
|
|
width: mapDocument.width,
|
||
|
|
height: mapDocument.height,
|
||
|
|
name: String(mapDocument.mapName || currentMapId || ""),
|
||
|
|
backgroundColor: normalizeMapBackgroundColor(mapDocument.backgroundColor),
|
||
|
|
heightBlurStep: Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1)),
|
||
|
|
};
|
||
|
|
currentBaseRows = Array.isArray(roomLayers.find((layer) => Number(layer.layer) === 0)?.rows)
|
||
|
|
? roomLayers.find((layer) => Number(layer.layer) === 0).rows.map((row) => String(row || ""))
|
||
|
|
: createFilledRows(composedWidth, composedHeight, ".");
|
||
|
|
mapDocument.roomLayers = cloneLayers(roomLayers);
|
||
|
|
mapDocument.heightLayers = cloneHeightLayers(heightLayers);
|
||
|
|
mapDocument.npcOverlays.length = 0;
|
||
|
|
npcOverlays.forEach((npc) => mapDocument.npcOverlays.push(npc));
|
||
|
|
const rebaseLocalTileToCurrentNeighborhood = (localTileX, localTileY) => {
|
||
|
|
const safeLocalTileX = Math.floor(Number(localTileX));
|
||
|
|
const safeLocalTileY = Math.floor(Number(localTileY));
|
||
|
|
if (!Number.isFinite(safeLocalTileX) || !Number.isFinite(safeLocalTileY)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const worldTileX = previousTileOffsetX + safeLocalTileX;
|
||
|
|
const worldTileY = previousTileOffsetY + safeLocalTileY;
|
||
|
|
const nextLocalTileX = worldTileX - worldRuntimeState.tileOffsetX;
|
||
|
|
const nextLocalTileY = worldTileY - worldRuntimeState.tileOffsetY;
|
||
|
|
if (
|
||
|
|
nextLocalTileX < 0 || nextLocalTileX >= composedWidth
|
||
|
|
|| nextLocalTileY < 0 || nextLocalTileY >= composedHeight
|
||
|
|
) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
x: nextLocalTileX,
|
||
|
|
y: nextLocalTileY,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const rebasedHoverTile = rebaseLocalTileToCurrentNeighborhood(previousHoverTileX, previousHoverTileY);
|
||
|
|
popupSessionStore.state.hoverTileX = rebasedHoverTile ? rebasedHoverTile.x : -1;
|
||
|
|
popupSessionStore.state.hoverTileY = rebasedHoverTile ? rebasedHoverTile.y : -1;
|
||
|
|
if (previousSelectedTile) {
|
||
|
|
const rebasedSelectedTile = rebaseLocalTileToCurrentNeighborhood(previousSelectedTile.x, previousSelectedTile.y);
|
||
|
|
popupSessionStore.state.selectedTile = rebasedSelectedTile
|
||
|
|
? {
|
||
|
|
...previousSelectedTile,
|
||
|
|
x: rebasedSelectedTile.x,
|
||
|
|
y: rebasedSelectedTile.y,
|
||
|
|
}
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
popupSessionStore.syncLayerVisibility(mapDocument.roomLayers);
|
||
|
|
syncCanvasDimensionsToTileSize();
|
||
|
|
invalidateTileSurface(sourceReason || "world-neighborhood-apply");
|
||
|
|
syncDocumentTitle();
|
||
|
|
ensureActiveHeightLayerSelection();
|
||
|
|
worldRuntimeState.documentDirty = false;
|
||
|
|
centerViewportOnWorldTile(preservedWorldCenter.worldTileX, preservedWorldCenter.worldTileY);
|
||
|
|
draw();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function prefetchAdjacentWorldNeighborhoods(centerChunkX, centerChunkY) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const prefetchRadius = Math.max(0, worldRuntimeState.chunkRadius);
|
||
|
|
const viewportWidthTiles = (Number(viewport?.clientWidth) || 0) / Math.max(1, tileSize);
|
||
|
|
const viewportHeightTiles = (Number(viewport?.clientHeight) || 0) / Math.max(1, tileSize);
|
||
|
|
const horizontalThresholdTiles = Math.max(
|
||
|
|
4,
|
||
|
|
Math.floor(worldRuntimeState.chunkWidth * 0.45),
|
||
|
|
Math.ceil(viewportWidthTiles * 0.35),
|
||
|
|
);
|
||
|
|
const verticalThresholdTiles = Math.max(
|
||
|
|
4,
|
||
|
|
Math.floor(worldRuntimeState.chunkHeight * 0.45),
|
||
|
|
Math.ceil(viewportHeightTiles * 0.35),
|
||
|
|
);
|
||
|
|
const horizontalThresholdPixels = horizontalThresholdTiles * Math.max(1, tileSize);
|
||
|
|
const verticalThresholdPixels = verticalThresholdTiles * Math.max(1, tileSize);
|
||
|
|
const localWorldPixelWidth = Math.max(1, mapDocument.width * tileSize);
|
||
|
|
const localWorldPixelHeight = Math.max(1, mapDocument.height * tileSize);
|
||
|
|
const remainingRight = localWorldPixelWidth - ((Number(viewport?.scrollLeft) || 0) + (Number(viewport?.clientWidth) || 0));
|
||
|
|
const remainingBottom = localWorldPixelHeight - ((Number(viewport?.scrollTop) || 0) + (Number(viewport?.clientHeight) || 0));
|
||
|
|
const directions = [];
|
||
|
|
if ((Number(viewport?.scrollLeft) || 0) <= horizontalThresholdPixels) {
|
||
|
|
directions.push({ dx: -1, dy: 0 });
|
||
|
|
} else if (remainingRight <= horizontalThresholdPixels) {
|
||
|
|
directions.push({ dx: 1, dy: 0 });
|
||
|
|
}
|
||
|
|
if ((Number(viewport?.scrollTop) || 0) <= verticalThresholdPixels) {
|
||
|
|
directions.push({ dx: 0, dy: -1 });
|
||
|
|
} else if (remainingBottom <= verticalThresholdPixels) {
|
||
|
|
directions.push({ dx: 0, dy: 1 });
|
||
|
|
}
|
||
|
|
directions.forEach(({ dx, dy }) => {
|
||
|
|
const nextCenterChunkX = centerChunkX + dx;
|
||
|
|
const nextCenterChunkY = centerChunkY + dy;
|
||
|
|
const neighborhoodKey = getNeighborhoodCacheKey(nextCenterChunkX, nextCenterChunkY, prefetchRadius);
|
||
|
|
if (getCachedNeighborhoodChunks(nextCenterChunkX, nextCenterChunkY, prefetchRadius)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (worldRuntimeState.prefetchedNeighborhoodKeys.has(neighborhoodKey)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
worldRuntimeState.prefetchedNeighborhoodKeys.add(neighborhoodKey);
|
||
|
|
void requestWorldNeighborhood(nextCenterChunkX, nextCenterChunkY, { createIfMissing: false, radius: prefetchRadius })
|
||
|
|
.catch(() => {})
|
||
|
|
.finally(() => {
|
||
|
|
worldRuntimeState.prefetchedNeighborhoodKeys.delete(neighborhoodKey);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadWorldNeighborhoodAtChunk(centerChunkX, centerChunkY, options) {
|
||
|
|
if (!isWorldModeActive()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const safeCenterChunkX = Math.floor(Number(centerChunkX) || 0);
|
||
|
|
const safeCenterChunkY = Math.floor(Number(centerChunkY) || 0);
|
||
|
|
const requestedRadius = Math.max(0, Math.floor(Number(config.radius) || worldRuntimeState.chunkRadius));
|
||
|
|
const loadKey = getNeighborhoodCacheKey(safeCenterChunkX, safeCenterChunkY, requestedRadius);
|
||
|
|
const forceReload = config.force === true;
|
||
|
|
if (!forceReload && worldRuntimeState.pendingLoadKey === loadKey && worldRuntimeState.pendingLoadPromise) {
|
||
|
|
return worldRuntimeState.pendingLoadPromise;
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
!forceReload
|
||
|
|
&& safeCenterChunkX === worldRuntimeState.centerChunkX
|
||
|
|
&& safeCenterChunkY === worldRuntimeState.centerChunkY
|
||
|
|
&& requestedRadius === worldRuntimeState.chunkRadius
|
||
|
|
) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const preservedWorldCenter = config.preserveWorldCenter
|
||
|
|
? {
|
||
|
|
worldTileX: Number(config.preserveWorldCenter.worldTileX) || 0,
|
||
|
|
worldTileY: Number(config.preserveWorldCenter.worldTileY) || 0,
|
||
|
|
}
|
||
|
|
: getViewportCenterWorldTile();
|
||
|
|
const requestId = worldRuntimeState.requestSerial + 1;
|
||
|
|
worldRuntimeState.requestSerial = requestId;
|
||
|
|
worldRuntimeState.pendingLoadKey = loadKey;
|
||
|
|
const loadPromise = Promise.resolve()
|
||
|
|
.then(async () => {
|
||
|
|
if (!forceReload) {
|
||
|
|
const cachedChunks = getCachedNeighborhoodChunks(safeCenterChunkX, safeCenterChunkY, requestedRadius);
|
||
|
|
if (cachedChunks) {
|
||
|
|
return {
|
||
|
|
chunks: cachedChunks,
|
||
|
|
sourceReason: "world-neighborhood-cache",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const payload = await requestWorldNeighborhood(safeCenterChunkX, safeCenterChunkY, {
|
||
|
|
createIfMissing: false,
|
||
|
|
radius: requestedRadius,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
chunks: Array.isArray(payload?.chunks) ? payload.chunks : [],
|
||
|
|
sourceReason: "world-neighborhood-network",
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.then((result) => {
|
||
|
|
if (requestId !== worldRuntimeState.requestSerial) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (popupSessionStore.state.pan.isPanning) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const chunks = Array.isArray(result?.chunks) ? result.chunks : [];
|
||
|
|
const applied = applyWorldNeighborhoodFromChunks(
|
||
|
|
safeCenterChunkX,
|
||
|
|
safeCenterChunkY,
|
||
|
|
chunks,
|
||
|
|
preservedWorldCenter,
|
||
|
|
result?.sourceReason,
|
||
|
|
requestedRadius,
|
||
|
|
);
|
||
|
|
prefetchAdjacentWorldNeighborhoods(safeCenterChunkX, safeCenterChunkY);
|
||
|
|
return applied;
|
||
|
|
})
|
||
|
|
.catch((error) => {
|
||
|
|
if (requestId === worldRuntimeState.requestSerial) {
|
||
|
|
setStatus("World chunk load failed: " + String(error), true);
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
})
|
||
|
|
.finally(() => {
|
||
|
|
if (requestId === worldRuntimeState.requestSerial) {
|
||
|
|
worldRuntimeState.pendingLoadKey = "";
|
||
|
|
worldRuntimeState.pendingLoadPromise = null;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
worldRuntimeState.pendingLoadPromise = loadPromise;
|
||
|
|
return loadPromise;
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncWorldNeighborhoodForViewport() {
|
||
|
|
if (!isWorldModeActive() || !viewport) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (popupSessionStore.state.pan.isPanning || popupSessionStore.state.draggingNpc || popupSessionStore.state.paintingStroke) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const desiredRadius = getDesiredWorldChunkRadius();
|
||
|
|
const { worldTileX, worldTileY } = getViewportCenterWorldTile();
|
||
|
|
if (desiredRadius !== worldRuntimeState.chunkRadius) {
|
||
|
|
void loadWorldNeighborhoodAtChunk(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY, {
|
||
|
|
radius: desiredRadius,
|
||
|
|
preserveWorldCenter: { worldTileX, worldTileY },
|
||
|
|
}).catch(() => {});
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
const viewportWidthTiles = (Number(viewport.clientWidth) || 0) / Math.max(1, tileSize);
|
||
|
|
const viewportHeightTiles = (Number(viewport.clientHeight) || 0) / Math.max(1, tileSize);
|
||
|
|
const horizontalThresholdTiles = Math.max(
|
||
|
|
4,
|
||
|
|
Math.floor(worldRuntimeState.chunkWidth * 0.45),
|
||
|
|
Math.ceil(viewportWidthTiles * 0.35),
|
||
|
|
);
|
||
|
|
const verticalThresholdTiles = Math.max(
|
||
|
|
4,
|
||
|
|
Math.floor(worldRuntimeState.chunkHeight * 0.45),
|
||
|
|
Math.ceil(viewportHeightTiles * 0.35),
|
||
|
|
);
|
||
|
|
const horizontalThresholdPixels = horizontalThresholdTiles * Math.max(1, tileSize);
|
||
|
|
const verticalThresholdPixels = verticalThresholdTiles * Math.max(1, tileSize);
|
||
|
|
const localWorldPixelWidth = Math.max(1, mapDocument.width * tileSize);
|
||
|
|
const localWorldPixelHeight = Math.max(1, mapDocument.height * tileSize);
|
||
|
|
const remainingRight = localWorldPixelWidth - ((Number(viewport.scrollLeft) || 0) + (Number(viewport.clientWidth) || 0));
|
||
|
|
const remainingBottom = localWorldPixelHeight - ((Number(viewport.scrollTop) || 0) + (Number(viewport.clientHeight) || 0));
|
||
|
|
let nextChunkX = worldRuntimeState.centerChunkX;
|
||
|
|
let nextChunkY = worldRuntimeState.centerChunkY;
|
||
|
|
const canShiftHorizontally = localWorldPixelWidth > (Number(viewport.clientWidth) || 0) + horizontalThresholdPixels;
|
||
|
|
const canShiftVertically = localWorldPixelHeight > (Number(viewport.clientHeight) || 0) + verticalThresholdPixels;
|
||
|
|
if (canShiftHorizontally && (Number(viewport.scrollLeft) || 0) <= horizontalThresholdPixels) {
|
||
|
|
nextChunkX -= 1;
|
||
|
|
} else if (canShiftHorizontally && remainingRight <= horizontalThresholdPixels) {
|
||
|
|
nextChunkX += 1;
|
||
|
|
}
|
||
|
|
if (canShiftVertically && (Number(viewport.scrollTop) || 0) <= verticalThresholdPixels) {
|
||
|
|
nextChunkY -= 1;
|
||
|
|
} else if (canShiftVertically && remainingBottom <= verticalThresholdPixels) {
|
||
|
|
nextChunkY += 1;
|
||
|
|
}
|
||
|
|
if (nextChunkX === worldRuntimeState.centerChunkX && nextChunkY === worldRuntimeState.centerChunkY) {
|
||
|
|
nextChunkX = worldToChunkCoord(worldTileX, worldRuntimeState.chunkWidth);
|
||
|
|
nextChunkY = worldToChunkCoord(worldTileY, worldRuntimeState.chunkHeight);
|
||
|
|
}
|
||
|
|
if (nextChunkX === worldRuntimeState.centerChunkX && nextChunkY === worldRuntimeState.centerChunkY) {
|
||
|
|
prefetchAdjacentWorldNeighborhoods(nextChunkX, nextChunkY);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
void loadWorldNeighborhoodAtChunk(nextChunkX, nextChunkY, {
|
||
|
|
radius: desiredRadius,
|
||
|
|
preserveWorldCenter: { worldTileX, worldTileY },
|
||
|
|
}).catch(() => {});
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildCurrentBootstrapSnapshot(): MapEditorPopupBootstrap {
|
||
|
|
ensureWorldDocumentCurrent();
|
||
|
|
const baseLayer = cloneLayers(mapDocument.roomLayers).find((layer) => Number(layer.layer) === 0) || null;
|
||
|
|
const baseRows = Array.isArray(baseLayer?.rows)
|
||
|
|
? baseLayer.rows.map((row) => String(row || ""))
|
||
|
|
: Array.from({ length: Math.max(1, Number(mapDocument.height) || 1) }, () => ".".repeat(Math.max(1, Number(mapDocument.width) || 1)));
|
||
|
|
return {
|
||
|
|
mapId: currentMapId,
|
||
|
|
mapName: String(mapDocument.mapName || currentMapId || "Untitled"),
|
||
|
|
width: Math.max(1, Number(mapDocument.width) || 1),
|
||
|
|
height: Math.max(1, Number(mapDocument.height) || 1),
|
||
|
|
tileSize: baseTileSize,
|
||
|
|
backgroundTileId: normalizeBackgroundTileId(mapDocument.backgroundTileId),
|
||
|
|
roomLayers: cloneLayers(mapDocument.roomLayers),
|
||
|
|
heightLayers: cloneHeightLayers(mapDocument.heightLayers),
|
||
|
|
tileColors: cloneValue(tileColors),
|
||
|
|
baseRows,
|
||
|
|
npcOverlays: cloneNpcOverlays(mapDocument.npcOverlays),
|
||
|
|
contentByType: cloneValue(mapDocument.contentBundle),
|
||
|
|
spriteCatalog: cloneValue(spriteCatalog),
|
||
|
|
tileCatalogById: cloneValue(tileCatalogById),
|
||
|
|
defaultNpcTemplate: cloneValue(defaultNpcTemplate),
|
||
|
|
apiBase,
|
||
|
|
backgroundColor: normalizeMapBackgroundColor(mapDocument.backgroundColor),
|
||
|
|
heightBlurStep: Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1)),
|
||
|
|
editorUi: cloneEditorUiState(),
|
||
|
|
sourceMode: "world",
|
||
|
|
worldId: isWorldModeActive() ? worldRuntimeState.worldId : undefined,
|
||
|
|
worldName: isWorldModeActive() ? worldRuntimeState.worldName : undefined,
|
||
|
|
worldChunkWidth: isWorldModeActive() ? worldRuntimeState.chunkWidth : undefined,
|
||
|
|
worldChunkHeight: isWorldModeActive() ? worldRuntimeState.chunkHeight : undefined,
|
||
|
|
worldOriginChunkX: isWorldModeActive() ? worldRuntimeState.originChunkX : undefined,
|
||
|
|
worldOriginChunkY: isWorldModeActive() ? worldRuntimeState.originChunkY : undefined,
|
||
|
|
worldChunkRadius: isWorldModeActive() ? worldRuntimeState.chunkRadius : undefined,
|
||
|
|
worldTileOffsetX: isWorldModeActive() ? worldRuntimeState.tileOffsetX : undefined,
|
||
|
|
worldTileOffsetY: isWorldModeActive() ? worldRuntimeState.tileOffsetY : undefined,
|
||
|
|
worldSpawnX: isWorldModeActive() ? worldRuntimeState.spawnX : undefined,
|
||
|
|
worldSpawnY: isWorldModeActive() ? worldRuntimeState.spawnY : undefined,
|
||
|
|
worldBookmarks: isWorldModeActive() ? cloneWorldBookmarks() : undefined,
|
||
|
|
sourceChunks: isWorldModeActive() ? cloneValue(worldRuntimeState.sourceChunks) : undefined,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function persistHistoryState() {
|
||
|
|
return historyController.persistHistoryState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function restoreHistoryState() {
|
||
|
|
return historyController.restoreHistoryState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyHistorySnapshot(snapshot) {
|
||
|
|
return historyController.applyHistorySnapshot(snapshot);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetWindowPosition() {
|
||
|
|
try {
|
||
|
|
window.localStorage.removeItem(popupBoundsStorageKey);
|
||
|
|
} catch (_err) {}
|
||
|
|
const nextBounds = getCenteredMapEditorPopupBounds(window);
|
||
|
|
try {
|
||
|
|
window.resizeTo(nextBounds.width, nextBounds.height);
|
||
|
|
window.moveTo(nextBounds.left, nextBounds.top);
|
||
|
|
} catch (_err) {}
|
||
|
|
setStatus("Window position reset.", false);
|
||
|
|
}
|
||
|
|
function resetWorkspaceLayout() {
|
||
|
|
popupSessionStore.clearPersistedLayout(window);
|
||
|
|
resetWindowPosition();
|
||
|
|
setStatus("Workspace layout reset.", false);
|
||
|
|
}
|
||
|
|
function runtimeUniqueId() {
|
||
|
|
try {
|
||
|
|
const bytes = new Uint8Array(5);
|
||
|
|
crypto.getRandomValues(bytes);
|
||
|
|
return 'inst_' + Array.from(bytes, function(b) { return b.toString(16).padStart(2, '0'); }).join('');
|
||
|
|
} catch (_e) {
|
||
|
|
return 'inst_' + Math.random().toString(16).slice(2, 12);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function toFiniteNumber(value, fallback) {
|
||
|
|
const nextValue = Number(value);
|
||
|
|
return Number.isFinite(nextValue) ? nextValue : fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneEditorUiState(sourceState) {
|
||
|
|
return editorUiStore.cloneState(sourceState);
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshStandaloneBootstrapCache() {
|
||
|
|
try {
|
||
|
|
cacheStandaloneMapBootstrap(currentMapId);
|
||
|
|
} catch (_error) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getPanelLayout(panelKey, itemIds) {
|
||
|
|
return editorUiStore.getPanelLayout(panelKey, itemIds);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setPanelLayout(panelKey, nextLayout, itemIds) {
|
||
|
|
const nextState = editorUiStore.setPanelLayout(panelKey, nextLayout, itemIds);
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
return nextState;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updatePanelLayout(panelKey, itemIds, updater) {
|
||
|
|
const nextState = editorUiStore.updatePanelLayout(panelKey, itemIds, updater);
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
return nextState;
|
||
|
|
}
|
||
|
|
|
||
|
|
function takeNextAvailableTileSymbol() {
|
||
|
|
const usedSymbols = new Set(
|
||
|
|
Object.values(tileCatalogById)
|
||
|
|
.map((entry) => String(entry?.symbol || "").charAt(0))
|
||
|
|
.filter(Boolean),
|
||
|
|
);
|
||
|
|
for (const symbol of TILE_SYMBOL_POOL) {
|
||
|
|
if (!usedSymbols.has(symbol)) {
|
||
|
|
return symbol;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function getImagesPayload() {
|
||
|
|
return cloneValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildDuplicateGraphicName(baseName, imagesPayload) {
|
||
|
|
const normalizedBase = String(baseName || "Graphic").trim() || "Graphic";
|
||
|
|
const existingNames = new Set(
|
||
|
|
(Array.isArray(imagesPayload?.images) ? imagesPayload.images : [])
|
||
|
|
.map((entry) => String(entry?.name || "").trim().toLowerCase())
|
||
|
|
.filter(Boolean),
|
||
|
|
);
|
||
|
|
const firstCandidate = `${normalizedBase} Copy`;
|
||
|
|
if (!existingNames.has(firstCandidate.toLowerCase())) {
|
||
|
|
return firstCandidate;
|
||
|
|
}
|
||
|
|
let suffix = 2;
|
||
|
|
while (suffix < 10000) {
|
||
|
|
const nextCandidate = `${normalizedBase} Copy ${suffix}`;
|
||
|
|
if (!existingNames.has(nextCandidate.toLowerCase())) {
|
||
|
|
return nextCandidate;
|
||
|
|
}
|
||
|
|
suffix += 1;
|
||
|
|
}
|
||
|
|
return `${normalizedBase} Copy ${Date.now()}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function persistImagesPayloadDirect(imagesPayload, options = {}) {
|
||
|
|
const normalizedImagesPayload = normalizeImagesPayloadSnapshot(imagesPayload, cloneValue);
|
||
|
|
await persistContentPayload("images", normalizedImagesPayload);
|
||
|
|
applyContentPayloadToRuntime("images", normalizedImagesPayload, options);
|
||
|
|
return normalizedImagesPayload;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function persistGraphicsDerivedPayload(type, payload, options = {}) {
|
||
|
|
const normalizedType = String(type || "").trim() === "sprites" ? "sprites" : "tiles";
|
||
|
|
const nextImagesPayload = normalizedType === "sprites"
|
||
|
|
? mergeImagesPayloadWithSpritesPayload(getImagesPayload(), payload)
|
||
|
|
: mergeImagesPayloadWithTilesPayload(getImagesPayload(), payload);
|
||
|
|
return persistImagesPayloadDirect(nextImagesPayload, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function reloadGraphicsContentFromApi(options = {}) {
|
||
|
|
const requestUrl = `${apiBase}/api/content/images?ts=${Date.now()}`;
|
||
|
|
const payload = await fetchJsonOrThrow(requestUrl);
|
||
|
|
applyContentPayloadToRuntime("images", payload, options);
|
||
|
|
return payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncRuntimeGraphicsFromImagesPayload(imagesPayload, options = {}) {
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const normalizedImagesPayload = normalizeImagesPayloadSnapshot(imagesPayload, cloneValue);
|
||
|
|
const nextTilesPayload = buildTilesPayloadFromImagesPayload(normalizedImagesPayload);
|
||
|
|
const nextSpritesPayload = buildSpritesPayloadFromImagesPayload(normalizedImagesPayload);
|
||
|
|
graphicsVisualRevision += 1;
|
||
|
|
setDocumentContentPayload("images", cloneValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] });
|
||
|
|
setDocumentContentPayload("tiles", cloneValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] });
|
||
|
|
setDocumentContentPayload("sprites", cloneValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] });
|
||
|
|
replaceObjectContents(
|
||
|
|
tileCatalogById,
|
||
|
|
applyTileCatalogVisualRevision(
|
||
|
|
buildTileCatalogById(Array.isArray(nextTilesPayload?.tiles) ? nextTilesPayload.tiles : [], buildSpritePreviewDataUrl),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
replaceObjectContents(
|
||
|
|
spriteCatalog,
|
||
|
|
applySpriteCatalogVisualRevision(
|
||
|
|
buildSpriteCatalog(Array.isArray(nextSpritesPayload?.sprites) ? nextSpritesPayload.sprites : [], buildSpritePreviewDataUrl),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
replaceObjectContents(tileCatalog, buildMergedTileCatalog());
|
||
|
|
mapDocument.backgroundTileId = normalizeBackgroundTileId(mapDocument.backgroundTileId);
|
||
|
|
const paintableTileIds = getPaintableTileIds();
|
||
|
|
if (!paintableTileIds.includes(String(popupSessionStore.state.activeBrushTileId || "").trim())) {
|
||
|
|
popupSessionStore.state.activeBrushTileId = "";
|
||
|
|
}
|
||
|
|
Object.keys(npcImages).forEach((npcId) => {
|
||
|
|
delete npcImages[npcId];
|
||
|
|
});
|
||
|
|
mapDocument.npcOverlays.forEach((npc) => {
|
||
|
|
syncNpcOverlayFromRecord(npc);
|
||
|
|
});
|
||
|
|
invalidateTileSurface("graphics-catalog-updated", { refreshTileImages: true });
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces?.(null, { refreshTileVisuals: true });
|
||
|
|
uiScope.refreshWorldOverviewWindow?.();
|
||
|
|
if (!config.deferRefresh) {
|
||
|
|
uiScope.renderNpcList();
|
||
|
|
uiScope.renderInstancePalette();
|
||
|
|
uiScope.renderPaintPalette();
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
images: mapDocument.contentBundle.images,
|
||
|
|
tiles: nextTilesPayload,
|
||
|
|
sprites: nextSpritesPayload,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildSpriteLikeGraphicRecord(recordId, graphicRole) {
|
||
|
|
return {
|
||
|
|
id: String(recordId || "sprite_" + runtimeUniqueId().replace(/^inst_/, "")),
|
||
|
|
name: "New " + (graphicRole === "other" ? "Graphic" : "Sprite"),
|
||
|
|
width: 16,
|
||
|
|
height: 16,
|
||
|
|
pixelScale: 2,
|
||
|
|
graphicRole: graphicRole === "other" ? "other" : "sprite",
|
||
|
|
tags: [],
|
||
|
|
rows: Array.from({ length: 16 }, () => ".".repeat(16)),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createNewTile() {
|
||
|
|
const nextSymbol = takeNextAvailableTileSymbol();
|
||
|
|
if (!nextSymbol) {
|
||
|
|
setStatus("No free tile symbols remain for new tiles.", true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const nextRecord = normalizeTileRecordForSave({
|
||
|
|
id: "tile_" + runtimeUniqueId().replace(/^inst_/, ""),
|
||
|
|
symbol: nextSymbol,
|
||
|
|
name: "New Tile",
|
||
|
|
description: "",
|
||
|
|
width: 16,
|
||
|
|
height: 16,
|
||
|
|
pixelScale: 2,
|
||
|
|
rows: Array.from({ length: 16 }, () => ".".repeat(16)),
|
||
|
|
});
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
nextImages.push(buildImageRecordFromTileRecord(nextRecord, null, cloneValue));
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
popupSessionStore.state.activeBrushTileId = String(nextRecord.id || "");
|
||
|
|
popupSessionStore.state.activeGraphicsRecordId = String(nextRecord.id || "");
|
||
|
|
popupSessionStore.state.activeGraphicsTab = "tiles";
|
||
|
|
setSidebarTab("tiles");
|
||
|
|
renderPaintPalette();
|
||
|
|
draw();
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
setStatus("Created " + describeBrushTileId(nextRecord.id) + ".", false);
|
||
|
|
scope.openTileArtEditorWindow?.(nextRecord.id);
|
||
|
|
return nextRecord;
|
||
|
|
} catch (error) {
|
||
|
|
setStatus(String(error || "Failed to create tile."), true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createNewSpriteGraphic(graphicRole = "sprite") {
|
||
|
|
const nextRecord = buildSpriteLikeGraphicRecord("sprite_" + runtimeUniqueId().replace(/^inst_/, ""), graphicRole);
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
nextImages.push(buildImageRecordFromSpriteRecord(nextRecord, graphicRole, null, cloneValue));
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
popupSessionStore.state.activeGraphicsRecordId = String(nextRecord.id || "");
|
||
|
|
popupSessionStore.state.activeGraphicsTab = graphicRole === "other" ? "other" : "sprites";
|
||
|
|
setSidebarTab("tiles");
|
||
|
|
renderPaintPalette();
|
||
|
|
draw();
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
setStatus("Created " + (nextRecord.name || nextRecord.id) + ".", false);
|
||
|
|
scope.openTileArtEditorWindow?.(graphicRole === "other" ? "other" : "sprite", nextRecord.id);
|
||
|
|
return nextRecord;
|
||
|
|
} catch (error) {
|
||
|
|
setStatus(String(error || "Failed to create graphic."), true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function duplicateGraphicRecord(recordType, graphicId) {
|
||
|
|
const normalizedType = recordType === "other" ? "other" : (recordType === "sprite" ? "sprite" : "tile");
|
||
|
|
const normalizedId = String(graphicId || "").trim();
|
||
|
|
if (!normalizedId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const sourceRecord = getImageRecordFromPayload(imagesPayload, normalizedId);
|
||
|
|
if (!sourceRecord) {
|
||
|
|
setStatus("Graphic not found.", true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
let nextTileSymbol = String(sourceRecord.tileSymbol || "").trim().charAt(0);
|
||
|
|
if (normalizedType === "tile") {
|
||
|
|
nextTileSymbol = takeNextAvailableTileSymbol();
|
||
|
|
if (!nextTileSymbol) {
|
||
|
|
setStatus("No free tile symbols remain for duplicated tiles.", true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const nextRecord = normalizeImageRecordForSave({
|
||
|
|
...cloneValue(sourceRecord),
|
||
|
|
id: (normalizedType === "tile" ? "tile_" : "sprite_") + runtimeUniqueId().replace(/^inst_/, ""),
|
||
|
|
name: buildDuplicateGraphicName(String(sourceRecord.name || normalizedId || "Graphic"), imagesPayload),
|
||
|
|
tileSymbol: normalizedType === "tile" ? nextTileSymbol : String(sourceRecord.tileSymbol || "").trim().charAt(0),
|
||
|
|
rows: Array.isArray(sourceRecord.rows) ? sourceRecord.rows.map((row) => String(row || "")) : [],
|
||
|
|
frames: Array.isArray(sourceRecord.frames) ? cloneValue(sourceRecord.frames) : [],
|
||
|
|
tags: Array.isArray(sourceRecord.tags) ? cloneValue(sourceRecord.tags) : [],
|
||
|
|
roles: Array.isArray(sourceRecord.roles) ? cloneValue(sourceRecord.roles) : [],
|
||
|
|
});
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
nextImages.push(nextRecord);
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
popupSessionStore.state.activeGraphicsRecordId = String(nextRecord.id || "");
|
||
|
|
popupSessionStore.state.activeGraphicsTab = normalizedType === "other" ? "other" : (normalizedType === "sprite" ? "sprites" : "tiles");
|
||
|
|
if (normalizedType === "tile") {
|
||
|
|
popupSessionStore.state.activeBrushTileId = String(nextRecord.id || "");
|
||
|
|
}
|
||
|
|
setSidebarTab("tiles");
|
||
|
|
renderPaintPalette();
|
||
|
|
draw();
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
setStatus(
|
||
|
|
"Duplicated "
|
||
|
|
+ (normalizedType === "tile" ? "tile" : (normalizedType === "sprite" ? "sprite" : "graphic"))
|
||
|
|
+ " as "
|
||
|
|
+ (nextRecord.name || nextRecord.id)
|
||
|
|
+ ".",
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
return nextRecord;
|
||
|
|
} catch (error) {
|
||
|
|
setStatus(String(error || "Failed to duplicate graphic."), true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function syncGraphicCounterpartFromRecord(recordType, record) {
|
||
|
|
const normalizedType = recordType === "other" ? "other" : (recordType === "sprite" ? "sprite" : "tile");
|
||
|
|
const normalizedId = String(record?.id || "").trim();
|
||
|
|
if (!normalizedId) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (normalizedType === "tile") {
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
const existingIndex = nextImages.findIndex((entry) => String(entry?.id || "").trim() === normalizedId);
|
||
|
|
if (existingIndex < 0) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const existing = nextImages[existingIndex] || {};
|
||
|
|
nextImages[existingIndex] = buildImageRecordFromTileRecord({
|
||
|
|
...cloneValue(record),
|
||
|
|
symbol: String(record?.symbol || existing?.tileSymbol || "").charAt(0) || takeNextAvailableTileSymbol() || "T",
|
||
|
|
}, existing, cloneValue);
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
const existingIndex = nextImages.findIndex((entry) => String(entry?.id || "").trim() === normalizedId);
|
||
|
|
if (existingIndex < 0) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const existing = nextImages[existingIndex] || {};
|
||
|
|
nextImages[existingIndex] = buildImageRecordFromSpriteRecord(record, normalizedType, existing, cloneValue);
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function assignGraphicToCategory(sourceType, graphicId, targetCategory) {
|
||
|
|
const normalizedId = String(graphicId || "").trim();
|
||
|
|
const normalizedSourceType = sourceType === "sprite" || sourceType === "other" ? "sprite" : "tile";
|
||
|
|
const normalizedTargetCategory = targetCategory === "other" ? "other" : (targetCategory === "sprites" ? "sprites" : "tiles");
|
||
|
|
if (!normalizedId) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (normalizedTargetCategory === "tiles") {
|
||
|
|
const sourceRecord = getImageRecordFromPayload(getImagesPayload(), normalizedId);
|
||
|
|
if (!sourceRecord) {
|
||
|
|
setStatus("Graphic not found.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
const existingIndex = nextImages.findIndex((entry) => String(entry?.id || "").trim() === normalizedId);
|
||
|
|
const existing = existingIndex >= 0 ? nextImages[existingIndex] : sourceRecord;
|
||
|
|
const nextSymbol = String(existing?.tileSymbol || sourceRecord?.tileSymbol || "").charAt(0) || takeNextAvailableTileSymbol();
|
||
|
|
if (!nextSymbol) {
|
||
|
|
setStatus("No free tile symbols remain.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const nextRecord = buildImageRecordFromTileRecord({
|
||
|
|
...cloneValue(sourceRecord),
|
||
|
|
symbol: nextSymbol,
|
||
|
|
}, existing, cloneValue);
|
||
|
|
if (existingIndex >= 0) {
|
||
|
|
nextImages[existingIndex] = nextRecord;
|
||
|
|
} else {
|
||
|
|
nextImages.push(nextRecord);
|
||
|
|
}
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
setStatus("Assigned graphic to Tiles.", false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
const sourceRecord = getImageRecordFromPayload(getImagesPayload(), normalizedId);
|
||
|
|
if (!sourceRecord) {
|
||
|
|
setStatus("Graphic not found.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const imagesPayload = getImagesPayload();
|
||
|
|
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
|
||
|
|
const existingIndex = nextImages.findIndex((entry) => String(entry?.id || "").trim() === normalizedId);
|
||
|
|
const existing = existingIndex >= 0 ? nextImages[existingIndex] : sourceRecord;
|
||
|
|
const nextRecord = buildImageRecordFromSpriteRecord(
|
||
|
|
sourceRecord,
|
||
|
|
normalizedTargetCategory === "other" ? "other" : "sprite",
|
||
|
|
existing,
|
||
|
|
cloneValue,
|
||
|
|
);
|
||
|
|
if (existingIndex >= 0) {
|
||
|
|
nextImages[existingIndex] = nextRecord;
|
||
|
|
} else {
|
||
|
|
nextImages.push(nextRecord);
|
||
|
|
}
|
||
|
|
const nextPayload = {
|
||
|
|
schemaVersion: typeof imagesPayload.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
|
||
|
|
images: nextImages,
|
||
|
|
};
|
||
|
|
await persistImagesPayloadDirect(nextPayload);
|
||
|
|
setStatus("Assigned graphic to " + (normalizedTargetCategory === "other" ? "Other" : "Sprites") + ".", false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getRecordFromTilePayload(tileId) {
|
||
|
|
const entry = getImageRecordFromPayload(getImagesPayload(), tileId);
|
||
|
|
if (!entry || !normalizeGraphicRoles(entry.roles).includes("tile")) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return buildTileRecordFromImageRecord(entry, cloneValue);
|
||
|
|
}
|
||
|
|
|
||
|
|
function replaceTileSymbolInRows(rows, targetSymbol, replacementSymbol) {
|
||
|
|
const normalizedTarget = String(targetSymbol || "").charAt(0);
|
||
|
|
const normalizedReplacement = String(replacementSymbol || "").charAt(0) || " ";
|
||
|
|
let changedCells = 0;
|
||
|
|
const nextRows = (Array.isArray(rows) ? rows : []).map((row) => Array.from(String(row || "")).map((ch) => {
|
||
|
|
if (!normalizedTarget || ch !== normalizedTarget) {
|
||
|
|
return ch;
|
||
|
|
}
|
||
|
|
changedCells += 1;
|
||
|
|
return normalizedReplacement;
|
||
|
|
}).join(""));
|
||
|
|
return {
|
||
|
|
rows: nextRows,
|
||
|
|
changedCells,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrubTileReferencesFromRoomLayersLocal(sourceLayers, targetSymbol) {
|
||
|
|
let changedCells = 0;
|
||
|
|
const nextLayers = cloneLayers(sourceLayers).map((layer) => {
|
||
|
|
const fillChar = (Number(layer.layer) || 0) === 0 ? "." : " ";
|
||
|
|
const scrubbed = replaceTileSymbolInRows(layer.rows, targetSymbol, fillChar);
|
||
|
|
changedCells += scrubbed.changedCells;
|
||
|
|
return {
|
||
|
|
...layer,
|
||
|
|
rows: scrubbed.rows,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
roomLayers: nextLayers,
|
||
|
|
changedCells,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrubTileReferencesFromHeightLayersLocal(sourceLayers, targetSymbol) {
|
||
|
|
let changedCells = 0;
|
||
|
|
const nextLayers = (Array.isArray(sourceLayers) ? sourceLayers : [])
|
||
|
|
.map((entry) => {
|
||
|
|
const scrubbed = replaceTileSymbolInRows(entry?.rows, targetSymbol, " ");
|
||
|
|
changedCells += scrubbed.changedCells;
|
||
|
|
return {
|
||
|
|
...entry,
|
||
|
|
rows: scrubbed.rows,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((entry) => Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || ""))));
|
||
|
|
return {
|
||
|
|
heightLayers: cloneHeightLayers(nextLayers),
|
||
|
|
changedCells,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrubTileReferencesFromCachedWorldChunks(targetTileId, targetSymbol) {
|
||
|
|
let updatedChunks = 0;
|
||
|
|
let changedRoomCells = 0;
|
||
|
|
let changedHeightCells = 0;
|
||
|
|
let clearedBackgrounds = 0;
|
||
|
|
for (const [chunkKey, chunkValue] of worldRuntimeState.chunkCache.entries()) {
|
||
|
|
if (!chunkValue || typeof chunkValue !== "object") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const scrubbedLayers = scrubTileReferencesFromRoomLayersLocal(chunkValue.roomLayers, targetSymbol);
|
||
|
|
const scrubbedHeightLayers = scrubTileReferencesFromHeightLayersLocal(chunkValue.heightLayers, targetSymbol);
|
||
|
|
const clearsBackground = String(chunkValue.backgroundTileId || "").trim() === targetTileId;
|
||
|
|
const changed = scrubbedLayers.changedCells > 0 || scrubbedHeightLayers.changedCells > 0 || clearsBackground;
|
||
|
|
if (!changed) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
updatedChunks += 1;
|
||
|
|
changedRoomCells += scrubbedLayers.changedCells;
|
||
|
|
changedHeightCells += scrubbedHeightLayers.changedCells;
|
||
|
|
if (clearsBackground) {
|
||
|
|
clearedBackgrounds += 1;
|
||
|
|
}
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, {
|
||
|
|
...cloneValue(chunkValue),
|
||
|
|
backgroundTileId: clearsBackground ? "" : String(chunkValue.backgroundTileId || "").trim(),
|
||
|
|
roomLayers: scrubbedLayers.roomLayers,
|
||
|
|
heightLayers: scrubbedHeightLayers.heightLayers,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
updatedChunks,
|
||
|
|
changedRoomCells,
|
||
|
|
changedHeightCells,
|
||
|
|
clearedBackgrounds,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrubSpriteReferencesFromCachedWorldChunks(targetSpriteId) {
|
||
|
|
const normalizedSpriteId = String(targetSpriteId || "").trim();
|
||
|
|
let updatedChunks = 0;
|
||
|
|
let scrubbedEntities = 0;
|
||
|
|
for (const [chunkKey, chunkValue] of worldRuntimeState.chunkCache.entries()) {
|
||
|
|
if (!chunkValue || typeof chunkValue !== "object") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let changedEntities = 0;
|
||
|
|
const nextInstances = (Array.isArray(chunkValue.instances) ? chunkValue.instances : []).map((entry) => {
|
||
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
const nextEntry = cloneValue(entry) || {};
|
||
|
|
const nextRecord = nextEntry.record && typeof nextEntry.record === "object" && !Array.isArray(nextEntry.record)
|
||
|
|
? { ...nextEntry.record }
|
||
|
|
: {};
|
||
|
|
const clearsSpriteId = String(nextRecord.spriteId || "").trim() === normalizedSpriteId;
|
||
|
|
const clearsSpriteIdOverride = String(nextRecord.spriteIdOverride || "").trim() === normalizedSpriteId;
|
||
|
|
const clearsTopLevelSpriteId = String(nextEntry.spriteId || "").trim() === normalizedSpriteId;
|
||
|
|
if (clearsSpriteId) {
|
||
|
|
nextRecord.spriteId = "";
|
||
|
|
}
|
||
|
|
if (clearsSpriteIdOverride) {
|
||
|
|
nextRecord.spriteIdOverride = "";
|
||
|
|
}
|
||
|
|
if (clearsTopLevelSpriteId) {
|
||
|
|
nextEntry.spriteId = "";
|
||
|
|
}
|
||
|
|
if (clearsSpriteId || clearsSpriteIdOverride || clearsTopLevelSpriteId) {
|
||
|
|
changedEntities += 1;
|
||
|
|
}
|
||
|
|
nextEntry.record = nextRecord;
|
||
|
|
return nextEntry;
|
||
|
|
});
|
||
|
|
if (changedEntities <= 0) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
updatedChunks += 1;
|
||
|
|
scrubbedEntities += changedEntities;
|
||
|
|
touchWorldChunkCacheEntry(chunkKey, {
|
||
|
|
...cloneValue(chunkValue),
|
||
|
|
instances: nextInstances,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
updatedChunks,
|
||
|
|
scrubbedEntities,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function deleteTileEverywhere(tileId) {
|
||
|
|
const normalizedTileId = String(tileId || "").trim();
|
||
|
|
if (!normalizedTileId) {
|
||
|
|
setStatus("Tile id missing. Cannot delete tile.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const tileEntry = getTileEntryById(normalizedTileId);
|
||
|
|
const tileSymbol = String(tileEntry?.symbol || "").charAt(0);
|
||
|
|
if (!tileSymbol || tileSymbol === "." || tileSymbol === " ") {
|
||
|
|
setStatus("That tile cannot be deleted.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const tileLabel = describeBrushTileId(normalizedTileId);
|
||
|
|
const confirmed = window.confirm(
|
||
|
|
`Delete ${tileLabel}?\n\nThis will remove the tile from the tile catalog and scrub its saved references from world/map data.`,
|
||
|
|
);
|
||
|
|
if (!confirmed) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (hasUnsavedChanges()) {
|
||
|
|
const saved = await saveCurrentState();
|
||
|
|
if (!saved) {
|
||
|
|
setStatus("Save before delete failed. Tile delete cancelled.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let response;
|
||
|
|
try {
|
||
|
|
response = await fetch(`${apiBase}/api/content/tiles/${encodeURIComponent(normalizedTileId)}/delete`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
setStatus(String(error || "Tile delete failed."), true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (!response.ok) {
|
||
|
|
let errorMessage = "";
|
||
|
|
try {
|
||
|
|
const rawText = await response.text();
|
||
|
|
if (rawText) {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(rawText);
|
||
|
|
errorMessage = String(parsed?.error || rawText).trim();
|
||
|
|
} catch (_parseError) {
|
||
|
|
errorMessage = String(rawText || "").trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (_error) {}
|
||
|
|
setStatus(`Tile delete failed (${response.status})${errorMessage ? `: ${errorMessage}` : ""}`, true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const payload = await response.json().catch(() => ({}));
|
||
|
|
const scrubbedRoomLayers = scrubTileReferencesFromRoomLayersLocal(mapDocument.roomLayers, tileSymbol);
|
||
|
|
const scrubbedHeightLayers = scrubTileReferencesFromHeightLayersLocal(mapDocument.heightLayers, tileSymbol);
|
||
|
|
const mapBackgroundCleared = String(mapDocument.backgroundTileId || "").trim() === normalizedTileId;
|
||
|
|
mapDocument.roomLayers = scrubbedRoomLayers.roomLayers;
|
||
|
|
mapDocument.heightLayers = scrubbedHeightLayers.heightLayers;
|
||
|
|
if (mapBackgroundCleared) {
|
||
|
|
mapDocumentStore.setBackgroundTileId("", normalizeBackgroundTileId);
|
||
|
|
}
|
||
|
|
if (isWorldModeActive()) {
|
||
|
|
if (String(worldRuntimeState.defaultBackgroundTileId || "").trim() === normalizedTileId) {
|
||
|
|
worldRuntimeState.defaultBackgroundTileId = "";
|
||
|
|
}
|
||
|
|
const cachedChunkStats = scrubTileReferencesFromCachedWorldChunks(normalizedTileId, tileSymbol);
|
||
|
|
void cachedChunkStats;
|
||
|
|
}
|
||
|
|
applyContentPayloadToRuntime("tiles", payload?.tiles || { schemaVersion: 1, tiles: [] }, { deferRefresh: true });
|
||
|
|
renderPaintPalette();
|
||
|
|
refreshInformationPanel();
|
||
|
|
refreshToolbarState(true);
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
scope.refreshWorldOverviewWindow?.();
|
||
|
|
invalidateTileSurface("tile-deleted", { refreshTileImages: true });
|
||
|
|
draw();
|
||
|
|
const nextState = captureState();
|
||
|
|
registerHistory("Tile deleted", tileLabel, "removed", [
|
||
|
|
"Tile: " + tileLabel,
|
||
|
|
"Removed from tile catalog and scrubbed from saved world/map data.",
|
||
|
|
], {
|
||
|
|
nextState,
|
||
|
|
});
|
||
|
|
if (scope.historyEntries[scope.historyIndex]) {
|
||
|
|
scope.lastSavedHistoryId = scope.historyEntries[scope.historyIndex].id;
|
||
|
|
persistHistoryState();
|
||
|
|
}
|
||
|
|
refreshToolbarState(true);
|
||
|
|
const stats = payload?.stats && typeof payload.stats === "object" ? payload.stats : {};
|
||
|
|
const summary = [
|
||
|
|
`Deleted ${tileLabel}.`,
|
||
|
|
Number(stats.updatedChunks) > 0 ? (`Chunks scrubbed: ${Number(stats.updatedChunks) || 0}.`) : "",
|
||
|
|
Number(stats.updatedMaps) > 0 ? (`Maps scrubbed: ${Number(stats.updatedMaps) || 0}.`) : "",
|
||
|
|
Number(stats.updatedWorlds) > 0 ? (`Worlds updated: ${Number(stats.updatedWorlds) || 0}.`) : "",
|
||
|
|
].filter(Boolean).join(" ");
|
||
|
|
setStatus(summary || (`Deleted ${tileLabel}.`), false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function deleteSpriteEverywhere(spriteId) {
|
||
|
|
const normalizedSpriteId = String(spriteId || "").trim();
|
||
|
|
if (!normalizedSpriteId) {
|
||
|
|
setStatus("Sprite id missing. Cannot delete sprite.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const spriteEntry = spriteCatalog[normalizedSpriteId] || null;
|
||
|
|
const spriteLabel = String(spriteEntry?.name || normalizedSpriteId).trim() || normalizedSpriteId;
|
||
|
|
const confirmed = window.confirm(
|
||
|
|
`Delete ${spriteLabel}?\n\nThis will remove the sprite from graphics and scrub entity references from saved world data.`,
|
||
|
|
);
|
||
|
|
if (!confirmed) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (hasUnsavedChanges()) {
|
||
|
|
const saved = await saveCurrentState();
|
||
|
|
if (!saved) {
|
||
|
|
setStatus("Save before delete failed. Sprite delete cancelled.", true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let response;
|
||
|
|
try {
|
||
|
|
response = await fetch(`${apiBase}/api/content/sprites/${encodeURIComponent(normalizedSpriteId)}/delete`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
setStatus(String(error || "Sprite delete failed."), true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (!response.ok) {
|
||
|
|
let errorMessage = "";
|
||
|
|
try {
|
||
|
|
const rawText = await response.text();
|
||
|
|
if (rawText) {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(rawText);
|
||
|
|
errorMessage = String(parsed?.error || rawText).trim();
|
||
|
|
} catch (_parseError) {
|
||
|
|
errorMessage = String(rawText || "").trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (_error) {}
|
||
|
|
setStatus(`Sprite delete failed (${response.status})${errorMessage ? `: ${errorMessage}` : ""}`, true);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const payload = await response.json().catch(() => ({}));
|
||
|
|
const templatePayload = ensureDocumentContentPayload("npc_templates", { schemaVersion: 1, npcTemplates: [] }) || { schemaVersion: 1, npcTemplates: [] };
|
||
|
|
if (Array.isArray(templatePayload.npcTemplates)) {
|
||
|
|
templatePayload.npcTemplates = templatePayload.npcTemplates.map((entry) => {
|
||
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
const nextEntry = { ...entry };
|
||
|
|
if (String(nextEntry.spriteId || "").trim() === normalizedSpriteId) {
|
||
|
|
nextEntry.spriteId = "";
|
||
|
|
}
|
||
|
|
if (String(nextEntry.spriteIdOverride || "").trim() === normalizedSpriteId) {
|
||
|
|
nextEntry.spriteIdOverride = "";
|
||
|
|
}
|
||
|
|
return nextEntry;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
mapDocument.npcOverlays.forEach((npc) => {
|
||
|
|
if (!npc || !npc.record || typeof npc.record !== "object" || Array.isArray(npc.record)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (String(npc.record.spriteId || "").trim() === normalizedSpriteId) {
|
||
|
|
npc.record.spriteId = "";
|
||
|
|
}
|
||
|
|
if (String(npc.record.spriteIdOverride || "").trim() === normalizedSpriteId) {
|
||
|
|
npc.record.spriteIdOverride = "";
|
||
|
|
}
|
||
|
|
syncNpcOverlayFromRecord(npc);
|
||
|
|
});
|
||
|
|
if (isWorldModeActive()) {
|
||
|
|
void scrubSpriteReferencesFromCachedWorldChunks(normalizedSpriteId);
|
||
|
|
}
|
||
|
|
applyContentPayloadToRuntime("images", payload?.images || { schemaVersion: 1, images: [] }, { deferRefresh: true });
|
||
|
|
if (String(popupSessionStore.state.activeGraphicsRecordId || "").trim() === normalizedSpriteId) {
|
||
|
|
popupSessionStore.state.activeGraphicsRecordId = "";
|
||
|
|
}
|
||
|
|
refreshStandaloneBootstrapCache();
|
||
|
|
renderPaintPalette();
|
||
|
|
renderNpcList();
|
||
|
|
renderInstancePalette();
|
||
|
|
draw();
|
||
|
|
const nextState = captureState();
|
||
|
|
registerHistory("Sprite deleted", spriteLabel, "removed", [
|
||
|
|
"Sprite: " + spriteLabel,
|
||
|
|
"Removed from graphics and scrubbed from saved entity references.",
|
||
|
|
], {
|
||
|
|
nextState,
|
||
|
|
});
|
||
|
|
if (scope.historyEntries[scope.historyIndex]) {
|
||
|
|
scope.lastSavedHistoryId = scope.historyEntries[scope.historyIndex].id;
|
||
|
|
persistHistoryState();
|
||
|
|
}
|
||
|
|
const stats = payload?.stats && typeof payload.stats === "object" ? payload.stats : {};
|
||
|
|
const summary = [
|
||
|
|
`Deleted ${spriteLabel}.`,
|
||
|
|
Number(stats.updatedChunks) > 0 ? (`Chunks updated: ${Number(stats.updatedChunks) || 0}.`) : "",
|
||
|
|
Number(stats.scrubbedPlacedEntities) > 0 ? (`Placed entities scrubbed: ${Number(stats.scrubbedPlacedEntities) || 0}.`) : "",
|
||
|
|
Number(stats.updatedNpcTemplateRecords) > 0 ? (`Templates scrubbed: ${Number(stats.updatedNpcTemplateRecords) || 0}.`) : "",
|
||
|
|
].filter(Boolean).join(" ");
|
||
|
|
setStatus(summary || (`Deleted ${spriteLabel}.`), false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function createPanelFolder(panelKey, itemIds, panelLabel) {
|
||
|
|
const currentLayout = getPanelLayout(panelKey, itemIds);
|
||
|
|
const folderCount = Object.keys(currentLayout.folders || {}).length;
|
||
|
|
const folderId = "folder_" + runtimeUniqueId().replace(/^inst_/, "");
|
||
|
|
const folderName = "New Folder" + (folderCount > 0 ? " " + (folderCount + 1) : "");
|
||
|
|
setPanelLayout(panelKey, createPanelFolderLayoutFolder(currentLayout, itemIds, folderId, folderName), itemIds);
|
||
|
|
registerHistory(panelLabel + " folder created", "folders:" + folderCount, "folders:" + (folderCount + 1), [
|
||
|
|
"Panel: " + panelLabel,
|
||
|
|
"Folder: " + folderName,
|
||
|
|
]);
|
||
|
|
setStatus("Created " + panelLabel.toLowerCase() + " folder " + folderName + ".", false);
|
||
|
|
return folderId;
|
||
|
|
}
|
||
|
|
|
||
|
|
function togglePanelFolder(panelKey, itemIds, folderId, panelLabel) {
|
||
|
|
const currentLayout = getPanelLayout(panelKey, itemIds);
|
||
|
|
const folder = currentLayout.folders[String(folderId || "").trim()];
|
||
|
|
if (!folder) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const nextCollapsed = !folder.collapsed;
|
||
|
|
setPanelLayout(panelKey, togglePanelFolderLayoutFolder(currentLayout, itemIds, folderId), itemIds);
|
||
|
|
registerHistory(panelLabel + " folder " + (nextCollapsed ? "collapsed" : "expanded"), nextCollapsed ? "open" : "closed", nextCollapsed ? "closed" : "open", [
|
||
|
|
"Panel: " + panelLabel,
|
||
|
|
"Folder: " + (folder.name || folder.id),
|
||
|
|
]);
|
||
|
|
setStatus((nextCollapsed ? "Collapsed " : "Expanded ") + (folder.name || "folder") + ".", false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renamePanelFolder(panelKey, itemIds, folderId, panelLabel) {
|
||
|
|
const currentLayout = getPanelLayout(panelKey, itemIds);
|
||
|
|
const folder = currentLayout.folders[String(folderId || "").trim()];
|
||
|
|
if (!folder) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const nextName = String(window.prompt("Folder name", folder.name || "New Folder") || "").trim();
|
||
|
|
if (!nextName || nextName === folder.name) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
setPanelLayout(panelKey, renamePanelFolderLayoutFolder(currentLayout, itemIds, folderId, nextName), itemIds);
|
||
|
|
registerHistory(panelLabel + " folder renamed", folder.name || folder.id, nextName, [
|
||
|
|
"Panel: " + panelLabel,
|
||
|
|
"Folder: " + (folder.name || folder.id) + " -> " + nextName,
|
||
|
|
]);
|
||
|
|
setStatus("Renamed folder to " + nextName + ".", false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function deletePanelFolder(panelKey, itemIds, folderId, panelLabel) {
|
||
|
|
const currentLayout = getPanelLayout(panelKey, itemIds);
|
||
|
|
const folder = currentLayout.folders[String(folderId || "").trim()];
|
||
|
|
if (!folder) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const itemCount = Array.isArray(folder.itemOrder) ? folder.itemOrder.length : 0;
|
||
|
|
setPanelLayout(panelKey, deletePanelFolderLayoutFolder(currentLayout, itemIds, folderId), itemIds);
|
||
|
|
registerHistory(panelLabel + " folder removed", folder.name || folder.id, "base panel", [
|
||
|
|
"Panel: " + panelLabel,
|
||
|
|
"Folder: " + (folder.name || folder.id),
|
||
|
|
"Moved back to base panel: " + itemCount + " item" + (itemCount === 1 ? "" : "s"),
|
||
|
|
]);
|
||
|
|
setStatus("Removed folder " + (folder.name || folder.id) + ".", false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function movePanelNode(panelKey, itemIds, panelLabel, dragging, dropTarget) {
|
||
|
|
if (!dragging || !dropTarget) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const currentLayout = getPanelLayout(panelKey, itemIds);
|
||
|
|
const nextLayout = movePanelFolderLayoutNode(currentLayout, itemIds, dragging, dropTarget);
|
||
|
|
const beforeSignature = JSON.stringify(currentLayout);
|
||
|
|
const afterSignature = JSON.stringify(nextLayout);
|
||
|
|
if (beforeSignature === afterSignature) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
setPanelLayout(panelKey, nextLayout, itemIds);
|
||
|
|
registerHistory(panelLabel + " order changed", dragging.kind + ":" + dragging.id, dropTarget.kind + ":" + (dropTarget.id || "root"), [
|
||
|
|
"Panel: " + panelLabel,
|
||
|
|
"Moved " + dragging.kind + " " + dragging.id + ".",
|
||
|
|
]);
|
||
|
|
setStatus("Updated " + panelLabel.toLowerCase() + " organization.", false);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneNpcPositions(source) {
|
||
|
|
return source.map((npc) => ({
|
||
|
|
id: String(npc.id || ""),
|
||
|
|
name: String(npc.name || npc.id || "NPC"),
|
||
|
|
x: toFiniteNumber(npc.x, 0),
|
||
|
|
y: toFiniteNumber(npc.y, 0),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function toCellKey(layerNumber, tileX, tileY) {
|
||
|
|
return String(Number(layerNumber) || 0) + ":" + String(Number(tileX) || 0) + ":" + String(Number(tileY) || 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function beginTileInstanceMutationBatch() {
|
||
|
|
popupSessionStore.beginTileMutationBatch();
|
||
|
|
}
|
||
|
|
|
||
|
|
function endTileInstanceMutationBatch() {
|
||
|
|
popupSessionStore.endTileMutationBatch();
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneNpcOverlays(source) {
|
||
|
|
return source.map((src) => {
|
||
|
|
const record = JSON.parse(JSON.stringify(src.record || {}));
|
||
|
|
record.id = String(src.id || "");
|
||
|
|
record.position = { x: toFiniteNumber(src.x, 0), y: toFiniteNumber(src.y, 0) };
|
||
|
|
return {
|
||
|
|
id: String(src.id || ""),
|
||
|
|
layer: Number(src.layer ?? record.layer ?? 0) || 0,
|
||
|
|
name: String(src.name || src.id || "NPC"),
|
||
|
|
spriteId: String(src.spriteId || ""),
|
||
|
|
isPlacementSlot: Boolean(src.isPlacementSlot),
|
||
|
|
x: toFiniteNumber(src.x, 0),
|
||
|
|
y: toFiniteNumber(src.y, 0),
|
||
|
|
dataUrl: src.dataUrl || null,
|
||
|
|
spriteWidth: Number(src.spriteWidth) || 28,
|
||
|
|
spriteHeight: Number(src.spriteHeight) || 28,
|
||
|
|
record,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncNpcOverlayFromRecord(npc) {
|
||
|
|
const record = npc.record && typeof npc.record === "object" && !Array.isArray(npc.record) ? npc.record : {};
|
||
|
|
const pos = record.position && typeof record.position === "object" && !Array.isArray(record.position)
|
||
|
|
? record.position
|
||
|
|
: {};
|
||
|
|
npc.id = String(record.id || npc.id || "");
|
||
|
|
npc.layer = Number(record.layer ?? npc.layer ?? 0) || 0;
|
||
|
|
npc.name = typeof record.name === "string"
|
||
|
|
? record.name
|
||
|
|
: String(record.nameOverride || npc.name || npc.id || "NPC");
|
||
|
|
npc.faction = String(record.faction || record.factionOverride || npc.faction || "");
|
||
|
|
npc.spriteId = typeof record.spriteId === "string"
|
||
|
|
? record.spriteId
|
||
|
|
: String(record.spriteIdOverride || npc.spriteId || "");
|
||
|
|
npc.dialogueId = String(record.dialogueId || record.dialogueIdOverride || npc.dialogueId || "");
|
||
|
|
npc.description = String(record.description || npc.description || "");
|
||
|
|
npc.x = toFiniteNumber(pos.x ?? npc.x, 0);
|
||
|
|
npc.y = toFiniteNumber(pos.y ?? npc.y, 0);
|
||
|
|
const spriteEntry = spriteCatalog[npc.spriteId] || null;
|
||
|
|
npc.dataUrl = spriteEntry ? spriteEntry.dataUrl : null;
|
||
|
|
npc.spriteWidth = spriteEntry ? spriteEntry.spriteWidth : 28;
|
||
|
|
npc.spriteHeight = spriteEntry ? spriteEntry.spriteHeight : 28;
|
||
|
|
}
|
||
|
|
|
||
|
|
function captureState() {
|
||
|
|
return historyController.captureState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyState(state) {
|
||
|
|
return historyController.applyState(state);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getStateSignature(state) {
|
||
|
|
return historyController.getStateSignature(state);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getVisibleNpcOverlays() {
|
||
|
|
return npcController.getVisibleNpcOverlays();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setStatus(message, isError, options) {
|
||
|
|
return sidebarController.setStatus(message, isError, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setSidebarTab(tab) {
|
||
|
|
return sidebarController.setSidebarTab(tab);
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshInformationPanel() {
|
||
|
|
return sidebarController.refreshInformationPanel();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshBackgroundModeButton() {
|
||
|
|
return sidebarController.refreshBackgroundModeButton();
|
||
|
|
}
|
||
|
|
|
||
|
|
function cycleBackgroundCellMode() {
|
||
|
|
return sidebarController.cycleBackgroundCellMode();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshInformationDraftState() {
|
||
|
|
return sidebarController.refreshInformationDraftState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function cancelDimensionEdit(field) {
|
||
|
|
return sidebarController.cancelDimensionEdit(field);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDimensionKeydown(field, event) {
|
||
|
|
return sidebarController.handleDimensionKeydown(field, event);
|
||
|
|
}
|
||
|
|
|
||
|
|
function cancelInformationEdits() {
|
||
|
|
return sidebarController.cancelInformationEdits();
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyInformationEdits(options) {
|
||
|
|
return sidebarController.applyInformationEdits(options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openTilePaletteContextMenu(tileId, tile, event, options) {
|
||
|
|
return sidebarController.openTilePaletteContextMenu(tileId, tile, event, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getNpcCatalogRecords() {
|
||
|
|
return npcController.getNpcCatalogRecords();
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasUnsavedChanges() {
|
||
|
|
return sidebarController.hasUnsavedChanges();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDialogueCatalogRecords() {
|
||
|
|
return npcController.getDialogueCatalogRecords();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getFactionRecords() {
|
||
|
|
return npcController.getFactionRecords();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSpriteCatalogRecords() {
|
||
|
|
return npcController.getSpriteCatalogRecords();
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyNpcEditorChange(npc, mutator, statusLabel) {
|
||
|
|
return npcController.applyNpcEditorChange(npc, mutator, statusLabel);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureNpcImageLoaded(npc) {
|
||
|
|
return npcController.ensureNpcImageLoaded(npc);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getCachedImage(cacheKey, dataUrl) {
|
||
|
|
return npcController.getCachedImage(cacheKey, dataUrl);
|
||
|
|
}
|
||
|
|
|
||
|
|
function assignNpcToSlot(slotId, assignedTemplateId) {
|
||
|
|
return npcController.assignNpcToSlot(slotId, assignedTemplateId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openPlacedEntityContextMenu(npc, event, options) {
|
||
|
|
return npcController.openPlacedEntityContextMenu(npc, event, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderNpcList() {
|
||
|
|
return npcController.renderNpcList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function centerViewportOnNpc(npc) {
|
||
|
|
return npcController.centerViewportOnNpc(npc);
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectTile(tileX, tileY) {
|
||
|
|
return interactionController.selectTile(tileX, tileY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectNpc(npc) {
|
||
|
|
return npcController.selectNpc(npc);
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeNpcInstanceById(instanceId, sourceLabel) {
|
||
|
|
return npcController.removeNpcInstanceById(instanceId, sourceLabel);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findPlacedNpcByTemplateId(templateId) {
|
||
|
|
return npcController.findPlacedNpcByTemplateId(templateId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getTileEntry(ch) {
|
||
|
|
return tileCatalog[ch] || {
|
||
|
|
id: ch,
|
||
|
|
symbol: ch,
|
||
|
|
name: ch === " " ? "Space" : ch,
|
||
|
|
color: tileColors[ch] || defaultTileColor,
|
||
|
|
dataUrl: null,
|
||
|
|
width: 1,
|
||
|
|
height: 1,
|
||
|
|
pixelScale: 1,
|
||
|
|
opacity: 1,
|
||
|
|
rows: [],
|
||
|
|
tags: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function getPaintableTileIds() {
|
||
|
|
const ids = Object.keys(tileCatalogById).filter((id) => id !== " ");
|
||
|
|
if (ids.length > 0) {
|
||
|
|
return ids;
|
||
|
|
}
|
||
|
|
return ["."];
|
||
|
|
}
|
||
|
|
|
||
|
|
function getTileEntryById(tileId) {
|
||
|
|
const normalizedId = String(tileId || "").trim();
|
||
|
|
return tileCatalogById[normalizedId] || tileCatalogById["."] || {
|
||
|
|
id: normalizedId || ".",
|
||
|
|
symbol: ".",
|
||
|
|
name: "Empty",
|
||
|
|
color: defaultTileColor,
|
||
|
|
dataUrl: null,
|
||
|
|
width: 1,
|
||
|
|
height: 1,
|
||
|
|
pixelScale: 1,
|
||
|
|
opacity: 1,
|
||
|
|
rows: [],
|
||
|
|
tags: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function getBackgroundTileEntry() {
|
||
|
|
return mapDocument.backgroundTileId ? getTileEntryById(mapDocument.backgroundTileId) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getBackgroundTileSymbol() {
|
||
|
|
const entry = getBackgroundTileEntry();
|
||
|
|
return entry ? (String(entry.symbol || ".").charAt(0) || ".") : "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function getBrushSymbol() {
|
||
|
|
const entry = getTileEntryById(popupSessionStore.state.activeBrushTileId);
|
||
|
|
const symbol = String(entry.symbol || ".").charAt(0);
|
||
|
|
return symbol || ".";
|
||
|
|
}
|
||
|
|
|
||
|
|
function describeBrushTileId(tileId) {
|
||
|
|
const entry = getTileEntryById(tileId);
|
||
|
|
const symbol = String(entry.symbol || ".").charAt(0) || ".";
|
||
|
|
const name = String(entry.name || "").trim();
|
||
|
|
if (name && name !== symbol) {
|
||
|
|
return tileId + " (" + symbol + " / " + name + ")";
|
||
|
|
}
|
||
|
|
return tileId + " (" + symbol + ")";
|
||
|
|
}
|
||
|
|
|
||
|
|
function describeTileSymbol(ch) {
|
||
|
|
const tile = getTileEntry(ch);
|
||
|
|
const symbolLabel = ch === " " ? "space" : ch;
|
||
|
|
if (tile && tile.name && tile.name !== symbolLabel) {
|
||
|
|
return symbolLabel + " (" + tile.name + ")";
|
||
|
|
}
|
||
|
|
return symbolLabel;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatCellCoord(cell) {
|
||
|
|
return historyController.formatCellCoord(cell);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getLayerByNumber(layerNumber) {
|
||
|
|
return documentController.getLayerByNumber(layerNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDefaultEditableLayerNumber() {
|
||
|
|
return documentController.getDefaultEditableLayerNumber();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getLayerDefaultName(layerNumber) {
|
||
|
|
return documentController.getLayerDefaultName(layerNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getLayerDisplayName(layerOrNumber) {
|
||
|
|
return documentController.getLayerDisplayName(layerOrNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isBackgroundLayer(layerNumber) {
|
||
|
|
return documentController.isBackgroundLayer(layerNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneHeightLayers(source) {
|
||
|
|
return documentController.cloneHeightLayers(source);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getHeightLayerById(heightLayerId) {
|
||
|
|
return documentController.getHeightLayerById(heightLayerId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getHeightLayerDisplayName(heightLayerOrId) {
|
||
|
|
return documentController.getHeightLayerDisplayName(heightLayerOrId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatHistoryLabel(entry) {
|
||
|
|
return historyController.formatHistoryLabel(entry);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderHistoryPreview() {
|
||
|
|
return historyController.renderHistoryPreview();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderHistoryList() {
|
||
|
|
return historyController.renderHistoryList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshToolbarState(preserveCurrentStatus) {
|
||
|
|
return historyController.refreshToolbarState(preserveCurrentStatus);
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerHistory(label, before, after, details, options) {
|
||
|
|
return historyController.registerHistory(label, before, after, details, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function undo() {
|
||
|
|
return historyController.undo();
|
||
|
|
}
|
||
|
|
|
||
|
|
function redo() {
|
||
|
|
return historyController.redo();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveCurrentState() {
|
||
|
|
return persistenceController.saveCurrentState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function openHeightViewerWindow() {
|
||
|
|
const token = createMapEditorPopupToken();
|
||
|
|
registerMapEditorPopupBootstrap(token, buildCurrentBootstrapSnapshot(), window);
|
||
|
|
const popup = openStandaloneMapHeightViewer(currentMapId, token, window);
|
||
|
|
if (!popup) {
|
||
|
|
setStatus("Height viewer unavailable: popup was blocked.", true);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
window.setTimeout(() => {
|
||
|
|
clearMapEditorPopupBootstrap(token, window);
|
||
|
|
}, 60_000);
|
||
|
|
setStatus("Opened height viewer for " + currentMapId + ".", false);
|
||
|
|
return popup;
|
||
|
|
}
|
||
|
|
|
||
|
|
const uiImageCache = {};
|
||
|
|
const npcImages = {};
|
||
|
|
|
||
|
|
function getCanvasPoint(event) {
|
||
|
|
return renderController.getCanvasPoint(event);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findTopNpcAtCanvas(canvasX, canvasY) {
|
||
|
|
return renderController.findTopNpcAtCanvas(canvasX, canvasY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function uiIconEl(slug, fallbackText, size) {
|
||
|
|
return renderController.uiIconEl(slug, fallbackText, size);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeRows(rows, fillChar) {
|
||
|
|
return documentController.normalizeRows(rows, fillChar);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureBaseLayer() {
|
||
|
|
return documentController.ensureBaseLayer();
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncLayerVisibilityState() {
|
||
|
|
popupSessionStore.syncLayerVisibility(mapDocument.roomLayers);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setLayerVisibility(layerNumber, isVisible) {
|
||
|
|
popupSessionStore.setLayerVisibility(layerNumber, isVisible, mapDocument.roomLayers);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isLayerVisible(layerNumber) {
|
||
|
|
return popupSessionStore.isLayerVisible(layerNumber, mapDocument.roomLayers);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isLayerRendered(layerNumber) {
|
||
|
|
const normalizedLayer = Number(layerNumber) || 0;
|
||
|
|
if (!popupSessionStore.state.viewingAllLayers && normalizedLayer === (Number(popupSessionStore.state.activeLayer) || 0)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return isLayerVisible(normalizedLayer);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEditableLayerNumber() {
|
||
|
|
const currentLayer = Number(popupSessionStore.state.activeLayer) || 0;
|
||
|
|
const hasCurrentLayer = mapDocument.roomLayers.some((layer) => (Number(layer.layer) || 0) === currentLayer);
|
||
|
|
if (popupSessionStore.state.viewingAllLayers) {
|
||
|
|
return hasCurrentLayer ? currentLayer : getDefaultEditableLayerNumber();
|
||
|
|
}
|
||
|
|
return hasCurrentLayer ? currentLayer : getDefaultEditableLayerNumber();
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureActiveHeightLayerSelection() {
|
||
|
|
const currentId = String(popupSessionStore.state.activeHeightLayerId || "").trim();
|
||
|
|
if (currentId && mapDocument.heightLayers.some((entry) => String(entry?.id || "").trim() === currentId)) {
|
||
|
|
return currentId;
|
||
|
|
}
|
||
|
|
const firstId = String(mapDocument.heightLayers[0]?.id || "").trim();
|
||
|
|
popupSessionStore.state.activeHeightLayerId = firstId;
|
||
|
|
if (!firstId && popupSessionStore.state.editingTargetKind === "height") {
|
||
|
|
popupSessionStore.state.editingTargetKind = "room";
|
||
|
|
}
|
||
|
|
return firstId;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getActiveHeightLayer() {
|
||
|
|
const activeId = ensureActiveHeightLayerSelection();
|
||
|
|
return activeId ? getHeightLayerById(activeId) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isEditingHeightLayer() {
|
||
|
|
return popupSessionStore.state.editingTargetKind === "height" && !!getActiveHeightLayer();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setEditingTargetKind(nextKind, heightLayerId) {
|
||
|
|
const normalizedKind = nextKind === "height" ? "height" : "room";
|
||
|
|
if (normalizedKind === "height") {
|
||
|
|
const requestedId = String(heightLayerId || popupSessionStore.state.activeHeightLayerId || "").trim();
|
||
|
|
const resolvedEntry = requestedId ? getHeightLayerById(requestedId) : null;
|
||
|
|
const fallbackEntry = resolvedEntry || mapDocument.heightLayers[0] || null;
|
||
|
|
if (!fallbackEntry) {
|
||
|
|
popupSessionStore.state.editingTargetKind = "room";
|
||
|
|
popupSessionStore.state.activeHeightLayerId = "";
|
||
|
|
return "room";
|
||
|
|
}
|
||
|
|
popupSessionStore.state.activeHeightLayerId = String(fallbackEntry.id || "").trim();
|
||
|
|
popupSessionStore.state.editingTargetKind = "height";
|
||
|
|
return "height";
|
||
|
|
}
|
||
|
|
popupSessionStore.state.editingTargetKind = "room";
|
||
|
|
return "room";
|
||
|
|
}
|
||
|
|
|
||
|
|
function moveLayerToDepth(sourceLayerNumber, targetLayerNumber, position) {
|
||
|
|
return documentController.moveLayerToDepth(sourceLayerNumber, targetLayerNumber, position);
|
||
|
|
}
|
||
|
|
|
||
|
|
function moveHeightLayerToDepth(sourceHeightLayerId, targetHeightLayerId, position) {
|
||
|
|
return documentController.moveHeightLayerToDepth(sourceHeightLayerId, targetHeightLayerId, position);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPaintPalette() {
|
||
|
|
return sidebarController.renderPaintPalette();
|
||
|
|
}
|
||
|
|
|
||
|
|
function addHeightLayer() {
|
||
|
|
return sidebarController.addHeightLayer();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderHeightLayerList() {
|
||
|
|
return sidebarController.renderHeightLayerList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderInstancePalette() {
|
||
|
|
return sidebarController.renderInstancePalette();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshEntityTypeTabs() {
|
||
|
|
return sidebarController.refreshEntityTypeTabs();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setActiveEntityCategory(nextType, options) {
|
||
|
|
return sidebarController.setActiveEntityCategory(nextType, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getTileIdForCell(layerNumber, tileX, tileY) {
|
||
|
|
return sidebarController.getTileIdForCell(layerNumber, tileX, tileY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getRowFillChar(layerNumber) {
|
||
|
|
return interactionController.getRowFillChar(layerNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setTileCharAt(layerNumber, tileX, tileY, ch, tileIdOverride) {
|
||
|
|
return interactionController.setTileCharAt(layerNumber, tileX, tileY, ch, tileIdOverride);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findOpenNpcSpawnTile() {
|
||
|
|
return npcController.findOpenNpcSpawnTile();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getNpcEntityType(npc) {
|
||
|
|
return npcController.getNpcEntityType(npc);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeEntityType(value, fallback) {
|
||
|
|
return npcController.normalizeEntityType(value, fallback);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEntityTypeLabel(value) {
|
||
|
|
return npcController.getEntityTypeLabel(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setNpcCatalogEntityType(templateId, nextType) {
|
||
|
|
return npcController.setNpcCatalogEntityType(templateId, nextType);
|
||
|
|
}
|
||
|
|
|
||
|
|
function createNewNpc() {
|
||
|
|
return npcController.createNewNpc();
|
||
|
|
}
|
||
|
|
|
||
|
|
function beginPaintStroke(tileX, tileY) {
|
||
|
|
return interactionController.beginPaintStroke(tileX, tileY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function paintStrokeAt(tileX, tileY) {
|
||
|
|
return interactionController.paintStrokeAt(tileX, tileY);
|
||
|
|
}
|
||
|
|
|
||
|
|
function finalizePaintStroke() {
|
||
|
|
return interactionController.finalizePaintStroke();
|
||
|
|
}
|
||
|
|
|
||
|
|
function draw() {
|
||
|
|
return renderController.draw();
|
||
|
|
}
|
||
|
|
|
||
|
|
function drawNow() {
|
||
|
|
return renderController.drawNow();
|
||
|
|
}
|
||
|
|
|
||
|
|
function invalidateTileSurface(reason, options) {
|
||
|
|
return renderController.invalidateTileSurface(reason, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function patchTileSurfaceCell(tileX, tileY, reason) {
|
||
|
|
return renderController.patchTileSurfaceCell(tileX, tileY, reason);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderLayerList() {
|
||
|
|
return sidebarController.renderLayerList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshMenuLayerSelect() {
|
||
|
|
return sidebarController.refreshMenuLayerSelect();
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleMenuLayerSelectionChange() {
|
||
|
|
return sidebarController.handleMenuLayerSelectionChange();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderTriggerList() {
|
||
|
|
return sidebarController.renderTriggerList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderMonsterList() {
|
||
|
|
return sidebarController.renderMonsterList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPathList() {
|
||
|
|
return sidebarController.renderPathList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderTransitionList() {
|
||
|
|
return sidebarController.renderTransitionList();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleTemplateSection() {
|
||
|
|
return sidebarController.toggleTemplateSection();
|
||
|
|
}
|
||
|
|
|
||
|
|
function togglePlacedSection() {
|
||
|
|
return sidebarController.togglePlacedSection();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleDrawLayerSection() {
|
||
|
|
return sidebarController.toggleDrawLayerSection();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleHeightLayerSection() {
|
||
|
|
return sidebarController.toggleHeightLayerSection();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshLayerSectionState() {
|
||
|
|
return sidebarController.refreshLayerSectionState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshInstanceSectionState() {
|
||
|
|
return sidebarController.refreshInstanceSectionState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function collapseActiveSidebarTabSections() {
|
||
|
|
return sidebarController.collapseActiveSidebarTabSections();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function persistContentPayload(type, payload) {
|
||
|
|
return persistenceController.persistContentPayload(type, payload);
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshImportControls() {
|
||
|
|
return importController.refreshImportControls();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleExperimentalImportPanel() {
|
||
|
|
return importController.toggleExperimentalImportPanel();
|
||
|
|
}
|
||
|
|
|
||
|
|
function openImportDialog(type) {
|
||
|
|
return importController.openImportDialog(type);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleImportSelection(type) {
|
||
|
|
return importController.handleImportSelection(type);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openJsonImportModal() {
|
||
|
|
return importController.openJsonImportModal();
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeJsonImportModal() {
|
||
|
|
return importController.closeJsonImportModal();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submitJsonImport() {
|
||
|
|
return importController.submitJsonImport();
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyMapInformationEditsToDocument(nextState) {
|
||
|
|
return documentController.applyMapInformationEdits(nextState);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureDocumentContentPayload(type, fallback) {
|
||
|
|
return documentController.ensureContentPayload(type, fallback);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setDocumentContentPayload(type, payload) {
|
||
|
|
return documentController.setContentPayload(type, payload);
|
||
|
|
}
|
||
|
|
|
||
|
|
function replaceObjectContents(target, nextValue) {
|
||
|
|
Object.keys(target || {}).forEach((key) => {
|
||
|
|
delete target[key];
|
||
|
|
});
|
||
|
|
Object.assign(target, cloneValue(nextValue) || {});
|
||
|
|
return target;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyContentPayloadToRuntime(type, payload, options) {
|
||
|
|
const normalizedType = String(type || "").trim();
|
||
|
|
const config = options && typeof options === "object" ? options : {};
|
||
|
|
const isGraphicsPayload = normalizedType === "images" || normalizedType === "tiles" || normalizedType === "sprites";
|
||
|
|
if (normalizedType === "images") {
|
||
|
|
syncRuntimeGraphicsFromImagesPayload(payload, config);
|
||
|
|
} else if (normalizedType === "tiles") {
|
||
|
|
const nextImagesPayload = mergeImagesPayloadWithTilesPayload(getImagesPayload(), payload);
|
||
|
|
syncRuntimeGraphicsFromImagesPayload(nextImagesPayload, config);
|
||
|
|
} else if (normalizedType === "sprites") {
|
||
|
|
const nextImagesPayload = mergeImagesPayloadWithSpritesPayload(getImagesPayload(), payload);
|
||
|
|
syncRuntimeGraphicsFromImagesPayload(nextImagesPayload, config);
|
||
|
|
} else {
|
||
|
|
setDocumentContentPayload(normalizedType, cloneValue(payload) || {});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!config.deferRefresh) {
|
||
|
|
if (isGraphicsPayload) {
|
||
|
|
renderController.drawNow();
|
||
|
|
} else {
|
||
|
|
renderController.draw();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return mapDocument.contentBundle[normalizedType];
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncRuntimeCatalogs(nextBootstrap) {
|
||
|
|
replaceObjectContents(tileCatalogById, applyTileCatalogVisualRevision(buildTileCatalogByIdFromBootstrap(nextBootstrap)));
|
||
|
|
replaceObjectContents(spriteCatalog, applySpriteCatalogVisualRevision(buildSpriteCatalogFromBootstrap(nextBootstrap)));
|
||
|
|
replaceObjectContents(defaultNpcTemplate, nextBootstrap.defaultNpcTemplate || {});
|
||
|
|
replaceObjectContents(tileCatalog, buildMergedTileCatalog());
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetSessionStateForLoadedMap() {
|
||
|
|
popupSessionStore.state.activeLayer = 1;
|
||
|
|
popupSessionStore.state.viewingAllLayers = false;
|
||
|
|
popupSessionStore.state.visibleLayersById = {};
|
||
|
|
popupSessionStore.state.pan.isPanning = false;
|
||
|
|
popupSessionStore.state.pan.startX = 0;
|
||
|
|
popupSessionStore.state.pan.startY = 0;
|
||
|
|
popupSessionStore.state.pan.scrollLeft = 0;
|
||
|
|
popupSessionStore.state.pan.scrollTop = 0;
|
||
|
|
popupSessionStore.state.draggingNpc = null;
|
||
|
|
popupSessionStore.state.pointerCandidate = null;
|
||
|
|
popupSessionStore.state.paintingStroke = null;
|
||
|
|
popupSessionStore.state.dragDrawX = 0;
|
||
|
|
popupSessionStore.state.dragDrawY = 0;
|
||
|
|
popupSessionStore.state.isSaving = false;
|
||
|
|
popupSessionStore.state.activeInstanceBrushId = "";
|
||
|
|
popupSessionStore.state.activeGraphicsTab = "tiles";
|
||
|
|
popupSessionStore.state.activeGraphicsRecordId = "";
|
||
|
|
popupSessionStore.state.hoverTileX = -1;
|
||
|
|
popupSessionStore.state.hoverTileY = -1;
|
||
|
|
popupSessionStore.state.selectedNpcId = mapDocument.npcOverlays[0] ? String(mapDocument.npcOverlays[0].id || "") : "";
|
||
|
|
popupSessionStore.state.selectedTile = null;
|
||
|
|
popupSessionStore.state.spritePickerOpenNpcId = "";
|
||
|
|
popupSessionStore.state.hoveredNpcId = "";
|
||
|
|
popupSessionStore.state.organizedListDrag = null;
|
||
|
|
popupSessionStore.state.tileMutationBatchDepth = 0;
|
||
|
|
popupSessionStore.state.zoomPreviewUntil = 0;
|
||
|
|
popupSessionStore.state.scrollPreviewUntil = 0;
|
||
|
|
popupSessionStore.state.hoverCanvasX = 0;
|
||
|
|
popupSessionStore.state.hoverCanvasY = 0;
|
||
|
|
popupSessionStore.state.editingTargetKind = "room";
|
||
|
|
popupSessionStore.state.activeHeightLayerId = String(mapDocument.heightLayers[0]?.id || "").trim();
|
||
|
|
mapDocument.heightBlurStep = Math.max(0, Math.min(1, Number(nextBootstrap.heightBlurStep ?? nextBootstrap.heightDetailStep) || 0.1));
|
||
|
|
const paintableTileIds = getPaintableTileIds();
|
||
|
|
if (!paintableTileIds.includes(String(popupSessionStore.state.activeBrushTileId || "").trim())) {
|
||
|
|
popupSessionStore.state.activeBrushTileId = "";
|
||
|
|
}
|
||
|
|
popupSessionStore.syncLayerVisibility(mapDocument.roomLayers);
|
||
|
|
}
|
||
|
|
|
||
|
|
function initializeHistoryForCurrentMap() {
|
||
|
|
scope.ensureBaseLayer();
|
||
|
|
scope.historyEntries = [];
|
||
|
|
scope.historyIndex = 0;
|
||
|
|
scope.historySelectionIndex = 0;
|
||
|
|
scope.nextHistoryId = 1;
|
||
|
|
scope.lastSavedHistoryId = 1;
|
||
|
|
const initialEntry = {
|
||
|
|
id: scope.nextHistoryId,
|
||
|
|
seq: scope.nextHistoryId,
|
||
|
|
createdAt: Date.now(),
|
||
|
|
label: "Initial state",
|
||
|
|
before: "-",
|
||
|
|
after: "-",
|
||
|
|
details: ["Loaded initial map state."],
|
||
|
|
state: scope.captureState(),
|
||
|
|
};
|
||
|
|
scope.historyEntries = [initialEntry];
|
||
|
|
scope.historyIndex = 0;
|
||
|
|
scope.historySelectionIndex = 0;
|
||
|
|
scope.lastSavedHistoryId = initialEntry.id;
|
||
|
|
scope.nextHistoryId = initialEntry.id + 1;
|
||
|
|
|
||
|
|
const restoredHistory = scope.restoreHistoryState();
|
||
|
|
if (!scope.applyHistorySnapshot(restoredHistory)) {
|
||
|
|
scope.persistHistoryState();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshUiForLoadedMap() {
|
||
|
|
scope.refreshInformationPanel();
|
||
|
|
scope.refreshImportControls();
|
||
|
|
scope.refreshInstanceSectionState();
|
||
|
|
scope.renderPaintPalette();
|
||
|
|
scope.renderInstancePalette();
|
||
|
|
scope.renderLayerList();
|
||
|
|
scope.renderNpcList();
|
||
|
|
scope.renderMonsterList();
|
||
|
|
scope.renderTriggerList();
|
||
|
|
scope.renderPathList();
|
||
|
|
scope.renderTransitionList();
|
||
|
|
scope.setSidebarTab(scope.activeSidebarTab || "information");
|
||
|
|
scope.refreshToolbarState();
|
||
|
|
if (viewport) {
|
||
|
|
viewport.scrollLeft = 0;
|
||
|
|
viewport.scrollTop = 0;
|
||
|
|
}
|
||
|
|
scope.drawNow();
|
||
|
|
}
|
||
|
|
|
||
|
|
const scope = {
|
||
|
|
mapId: currentMapId,
|
||
|
|
tileCatalogById,
|
||
|
|
tileCatalog,
|
||
|
|
tileColors,
|
||
|
|
contentBundle: mapDocument.contentBundle,
|
||
|
|
spriteCatalog,
|
||
|
|
defaultNpcTemplate,
|
||
|
|
apiBase,
|
||
|
|
npcOverlays: mapDocument.npcOverlays,
|
||
|
|
atTooltip,
|
||
|
|
pan: popupSessionStore.state.pan,
|
||
|
|
uiImageCache,
|
||
|
|
npcImages,
|
||
|
|
historyStorageKey: currentHistoryStorageKey,
|
||
|
|
popupBoundsStorageKey,
|
||
|
|
layerListEl,
|
||
|
|
paintPaletteEl,
|
||
|
|
instancePaletteEl,
|
||
|
|
npcListEl,
|
||
|
|
newNpcBtn,
|
||
|
|
newTileFolderBtn,
|
||
|
|
newTileBtn,
|
||
|
|
tileSearchModeBtn,
|
||
|
|
graphicsTilesBtn,
|
||
|
|
graphicsSpritesBtn,
|
||
|
|
graphicsOtherBtn,
|
||
|
|
newTemplateFolderBtn,
|
||
|
|
newPlacedFolderBtn,
|
||
|
|
entitySearchModeBtn,
|
||
|
|
entitySearchModeHost,
|
||
|
|
entityTypeFriendlyBtn,
|
||
|
|
entityTypeHostileBtn,
|
||
|
|
entityTypePropBtn,
|
||
|
|
newMonsterFolderBtn,
|
||
|
|
newTriggerFolderBtn,
|
||
|
|
newPathFolderBtn,
|
||
|
|
newTransitionFolderBtn,
|
||
|
|
toggleTemplateSectionBtn,
|
||
|
|
togglePlacedSectionBtn,
|
||
|
|
instanceTemplateSectionBody,
|
||
|
|
placedInstanceSectionBody,
|
||
|
|
entityCatalogSection,
|
||
|
|
placedEntitiesSection,
|
||
|
|
monsterListEl,
|
||
|
|
triggerListEl,
|
||
|
|
pathListEl,
|
||
|
|
transitionListEl,
|
||
|
|
metaEl,
|
||
|
|
metaMainEl,
|
||
|
|
metaStatsEl,
|
||
|
|
historyListEl,
|
||
|
|
historyCurrentEl,
|
||
|
|
historyPreviewEl,
|
||
|
|
undoBtn,
|
||
|
|
redoBtn,
|
||
|
|
saveBtn,
|
||
|
|
testHeightBtn,
|
||
|
|
menuLayerSelectEl,
|
||
|
|
saveStatusEl,
|
||
|
|
themePresetButtons,
|
||
|
|
informationTabBtn,
|
||
|
|
layersTabBtn,
|
||
|
|
tilesTabBtn,
|
||
|
|
instancesTabBtn,
|
||
|
|
triggersTabBtn,
|
||
|
|
pathsTabBtn,
|
||
|
|
transitionsTabBtn,
|
||
|
|
historyTabBtn,
|
||
|
|
newsTabBtn,
|
||
|
|
editorBodyEl,
|
||
|
|
sidebarEl,
|
||
|
|
sidebarTabsEl,
|
||
|
|
sidebarPanelsHostEl,
|
||
|
|
informationPanel,
|
||
|
|
layersPanel,
|
||
|
|
tilesPanel,
|
||
|
|
instancesPanel,
|
||
|
|
triggersPanel,
|
||
|
|
pathsPanel,
|
||
|
|
transitionsPanel,
|
||
|
|
historyPanel,
|
||
|
|
drawLayerSectionBody,
|
||
|
|
heightLayerSectionBody,
|
||
|
|
toggleDrawLayerSectionBtn,
|
||
|
|
toggleHeightLayerSectionBtn,
|
||
|
|
informationSettingsSectionBody,
|
||
|
|
informationConfigurationSectionBody,
|
||
|
|
informationHotkeysSectionBody,
|
||
|
|
toggleInformationSettingsSectionBtn,
|
||
|
|
toggleInformationConfigurationSectionBtn,
|
||
|
|
toggleInformationHotkeysSectionBtn,
|
||
|
|
mapIdLockedEl,
|
||
|
|
mapNameInputEl,
|
||
|
|
mapWidthInputEl,
|
||
|
|
mapHeightInputEl,
|
||
|
|
mapBackgroundColorInputEl,
|
||
|
|
engineOverridesBtn,
|
||
|
|
engineOverridesSummaryEl,
|
||
|
|
backgroundModeBtn,
|
||
|
|
backgroundModePreviewEl,
|
||
|
|
backgroundModeTitleEl,
|
||
|
|
backgroundModeMetaEl,
|
||
|
|
experimentalImportToggleBtn,
|
||
|
|
experimentalImportCheckEl,
|
||
|
|
experimentalImportBodyEl,
|
||
|
|
importSpritesBtn,
|
||
|
|
importTilesBtn,
|
||
|
|
importJsonBtn,
|
||
|
|
importSpritesInputEl,
|
||
|
|
importTilesInputEl,
|
||
|
|
importJsonModal,
|
||
|
|
importJsonTypeSelect,
|
||
|
|
importJsonTextarea,
|
||
|
|
importJsonConfirmBtn,
|
||
|
|
importJsonCancelBtn,
|
||
|
|
mapWidthValueEl,
|
||
|
|
mapHeightValueEl,
|
||
|
|
mapWidthControlsEl,
|
||
|
|
mapHeightControlsEl,
|
||
|
|
confirmWidthBtn,
|
||
|
|
cancelWidthBtn,
|
||
|
|
confirmHeightBtn,
|
||
|
|
cancelHeightBtn,
|
||
|
|
restoreToolWindowsBtn,
|
||
|
|
resetWorkspaceLayoutBtn,
|
||
|
|
addHeightLayerBtn,
|
||
|
|
heightLayerListEl,
|
||
|
|
stageEl,
|
||
|
|
canvasSelectToolBtn,
|
||
|
|
toolWindowLayerEl,
|
||
|
|
viewport,
|
||
|
|
viewportSpacer,
|
||
|
|
pixiHost,
|
||
|
|
canvas,
|
||
|
|
ctx,
|
||
|
|
runtimeEscapeHtml,
|
||
|
|
persistHistoryState,
|
||
|
|
restoreHistoryState,
|
||
|
|
applyHistorySnapshot,
|
||
|
|
resetWindowPosition,
|
||
|
|
resetWorkspaceLayout,
|
||
|
|
persistPopupSessionLayout: () => popupSessionStore.persistPersistedLayoutDeferred(window),
|
||
|
|
flushPersistedPopupSessionLayout: () => popupSessionStore.flushPersistedLayout(window),
|
||
|
|
clearPersistedPopupSessionLayout: () => popupSessionStore.clearPersistedLayout(window),
|
||
|
|
getPersistedToolWindowState: (key) => popupSessionStore.getToolWindowState(key),
|
||
|
|
setPersistedToolWindowState: (key, value) => popupSessionStore.setToolWindowState(key, value),
|
||
|
|
normalizeMapBackgroundColor,
|
||
|
|
cloneLayers,
|
||
|
|
cloneHeightLayers,
|
||
|
|
cloneNpcOverlays,
|
||
|
|
cloneEditorUiState,
|
||
|
|
normalizeRows,
|
||
|
|
toCellKey,
|
||
|
|
getLayerByNumber,
|
||
|
|
getLayerDefaultName,
|
||
|
|
getLayerDisplayName,
|
||
|
|
isBackgroundLayer,
|
||
|
|
getHeightLayerById,
|
||
|
|
getHeightLayerDisplayName,
|
||
|
|
getActiveHeightLayer,
|
||
|
|
isEditingHeightLayer,
|
||
|
|
setEditingTargetKind,
|
||
|
|
syncNpcOverlayFromRecord,
|
||
|
|
ensureBaseLayer,
|
||
|
|
syncLayerVisibilityState,
|
||
|
|
setLayerVisibility,
|
||
|
|
isLayerVisible,
|
||
|
|
isLayerRendered,
|
||
|
|
getEditableLayerNumber,
|
||
|
|
moveLayerToDepth,
|
||
|
|
moveHeightLayerToDepth,
|
||
|
|
beginTileInstanceMutationBatch,
|
||
|
|
endTileInstanceMutationBatch,
|
||
|
|
runtimeUniqueId,
|
||
|
|
uiIconEl,
|
||
|
|
draw,
|
||
|
|
drawNow,
|
||
|
|
invalidateTileSurface,
|
||
|
|
patchTileSurfaceCell,
|
||
|
|
setStatus,
|
||
|
|
setSidebarTab,
|
||
|
|
refreshInformationPanel,
|
||
|
|
refreshBackgroundModeButton,
|
||
|
|
cycleBackgroundCellMode,
|
||
|
|
resetWorkspaceLayout: null,
|
||
|
|
restoreAllToolWindows: null,
|
||
|
|
openTileArtEditorWindow: null,
|
||
|
|
closeTileArtEditorWindow: null,
|
||
|
|
openEntityEditorWindow: null,
|
||
|
|
closeEntityEditorWindow: null,
|
||
|
|
openWorldOverviewWindow: null,
|
||
|
|
closeWorldOverviewWindow: null,
|
||
|
|
refreshWorldOverviewWindow: null,
|
||
|
|
openStatusLogWindow: null,
|
||
|
|
closeStatusLogWindow: null,
|
||
|
|
openTilePaletteContextMenu: null,
|
||
|
|
openPlacedEntityContextMenu: null,
|
||
|
|
addHeightLayer,
|
||
|
|
renderPaintPalette,
|
||
|
|
renderHeightLayerList,
|
||
|
|
renderInstancePalette,
|
||
|
|
refreshEntityTypeTabs,
|
||
|
|
setActiveEntityCategory,
|
||
|
|
renderLayerList,
|
||
|
|
refreshMenuLayerSelect,
|
||
|
|
handleMenuLayerSelectionChange,
|
||
|
|
renderTriggerList,
|
||
|
|
renderMonsterList,
|
||
|
|
renderPathList,
|
||
|
|
renderTransitionList,
|
||
|
|
collapseActiveSidebarTabSections,
|
||
|
|
toggleDrawLayerSection,
|
||
|
|
toggleHeightLayerSection,
|
||
|
|
toggleTemplateSection,
|
||
|
|
togglePlacedSection,
|
||
|
|
refreshLayerSectionState,
|
||
|
|
refreshInstanceSectionState,
|
||
|
|
renderNpcList,
|
||
|
|
getPanelLayout,
|
||
|
|
setPanelLayout,
|
||
|
|
createPanelFolder,
|
||
|
|
togglePanelFolder,
|
||
|
|
renamePanelFolder,
|
||
|
|
deletePanelFolder,
|
||
|
|
movePanelNode,
|
||
|
|
refreshInformationDraftState,
|
||
|
|
getVisibleNpcOverlays,
|
||
|
|
getNpcCatalogRecords,
|
||
|
|
getDialogueCatalogRecords,
|
||
|
|
getFactionRecords,
|
||
|
|
getSpriteCatalogRecords,
|
||
|
|
getNpcEntityType,
|
||
|
|
normalizeEntityType,
|
||
|
|
getEntityTypeLabel,
|
||
|
|
hasUnsavedChanges,
|
||
|
|
cacheStandaloneMapBootstrap,
|
||
|
|
getCachedImage,
|
||
|
|
assignNpcToSlot,
|
||
|
|
setNpcCatalogEntityType,
|
||
|
|
getTileIdForCell,
|
||
|
|
getTileEntry,
|
||
|
|
getBackgroundTileEntry,
|
||
|
|
getBackgroundTileSymbol,
|
||
|
|
getCanvasPoint,
|
||
|
|
findTopNpcAtCanvas,
|
||
|
|
centerViewportOnNpc,
|
||
|
|
setTileCharAt,
|
||
|
|
selectTile,
|
||
|
|
selectNpc,
|
||
|
|
removeNpcInstanceById,
|
||
|
|
ensureNpcImageLoaded,
|
||
|
|
findPlacedNpcByTemplateId,
|
||
|
|
saveCurrentState,
|
||
|
|
openHeightViewerWindow,
|
||
|
|
persistContentPayload,
|
||
|
|
undo,
|
||
|
|
redo,
|
||
|
|
cancelDimensionEdit,
|
||
|
|
applyInformationEdits,
|
||
|
|
handleDimensionKeydown,
|
||
|
|
createNewNpc,
|
||
|
|
deleteTileEverywhere,
|
||
|
|
deleteSpriteEverywhere,
|
||
|
|
createNewSpriteGraphic,
|
||
|
|
duplicateGraphicRecord,
|
||
|
|
assignGraphicToCategory,
|
||
|
|
syncGraphicCounterpartFromRecord,
|
||
|
|
appendEditorLogEntry,
|
||
|
|
getEditorLogEntries,
|
||
|
|
clearEditorLogEntries,
|
||
|
|
getBrushSymbol,
|
||
|
|
captureState,
|
||
|
|
registerHistory,
|
||
|
|
refreshToolbarState,
|
||
|
|
refreshImportControls,
|
||
|
|
toggleExperimentalImportPanel,
|
||
|
|
openImportDialog,
|
||
|
|
handleImportSelection,
|
||
|
|
openJsonImportModal,
|
||
|
|
closeJsonImportModal,
|
||
|
|
submitJsonImport,
|
||
|
|
applyMapInformationEditsToDocument,
|
||
|
|
ensureDocumentContentPayload,
|
||
|
|
setDocumentContentPayload,
|
||
|
|
applyThemePreset,
|
||
|
|
refreshThemePresetButtons,
|
||
|
|
getPaintableTileIds,
|
||
|
|
getTileEntryById,
|
||
|
|
normalizeBackgroundTileId,
|
||
|
|
backgroundCellModeOrder: ["tile", "hole", "inherit"],
|
||
|
|
getRowFillChar,
|
||
|
|
describeBrushTileId,
|
||
|
|
describeTileSymbol,
|
||
|
|
formatCellCoord,
|
||
|
|
defaultTileColor,
|
||
|
|
getScaledSize,
|
||
|
|
getZoomPercent,
|
||
|
|
applyZoomLevel,
|
||
|
|
startZoomPreview,
|
||
|
|
isZoomPreviewActive,
|
||
|
|
startScrollPreview,
|
||
|
|
isScrollPreviewActive,
|
||
|
|
centerViewportOnWorldPoint,
|
||
|
|
getViewportCenterWorldTile,
|
||
|
|
centerViewportOnWorldTile,
|
||
|
|
isWorldModeActive,
|
||
|
|
ensureWorldDocumentCurrent,
|
||
|
|
getVisibleWorldChunkPayloads,
|
||
|
|
getWorldChunkCoordForLocalTile,
|
||
|
|
getCachedWorldChunk,
|
||
|
|
getWorldChunkBackgroundTileId,
|
||
|
|
getBackgroundTileIdForLocalTile,
|
||
|
|
setWorldChunkBackgroundTileId,
|
||
|
|
getPreferredWorldChunkCoord,
|
||
|
|
getSelectedWorldChunkCoord,
|
||
|
|
captureWorldChunkBackgroundState,
|
||
|
|
applyWorldChunkBackgroundState,
|
||
|
|
captureWorldBookmarkState,
|
||
|
|
applyWorldBookmarkState,
|
||
|
|
getWorldBookmarks,
|
||
|
|
createWorldBookmark,
|
||
|
|
renameWorldBookmark,
|
||
|
|
deleteWorldBookmark,
|
||
|
|
applyWorldChunkBackgroundTileAt,
|
||
|
|
moveWorldChunkContent,
|
||
|
|
duplicateWorldChunkContent,
|
||
|
|
transformWorldChunkAt,
|
||
|
|
clearWorldChunkAt,
|
||
|
|
getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneValue(entry)),
|
||
|
|
getDirtyWorldChunkKeys,
|
||
|
|
getDirtyWorldChunkPayloads,
|
||
|
|
clearDirtyWorldChunks,
|
||
|
|
markWorldChunkDirty,
|
||
|
|
markWorldChunkDirtyByLocalTile,
|
||
|
|
markWorldChunksDirtyByLocalBounds,
|
||
|
|
markVisibleWorldChunksDirty,
|
||
|
|
cacheWorldChunks,
|
||
|
|
syncCachedWorldHeightLayerMetadata,
|
||
|
|
syncCachedWorldRoomLayerMetadata,
|
||
|
|
syncWorldChunkCellFromLocalTile,
|
||
|
|
rebuildVisibleWorldChunksFromDocument,
|
||
|
|
rebuildWorldChunksForLocalBounds,
|
||
|
|
syncWorldNeighborhoodForViewport,
|
||
|
|
loadWorldNeighborhoodAtChunk,
|
||
|
|
syncViewportDimensions: syncCanvasDimensionsToTileSize,
|
||
|
|
};
|
||
|
|
Object.defineProperties(scope, {
|
||
|
|
width: { get: () => mapDocument.width, set: (value) => { mapDocument.width = Math.max(1, Number(value) || 1); } },
|
||
|
|
height: { get: () => mapDocument.height, set: (value) => { mapDocument.height = Math.max(1, Number(value) || 1); } },
|
||
|
|
baseTileSize: { get: () => baseTileSize },
|
||
|
|
tileSize: { get: () => tileSize, set: (value) => { tileSize = Math.max(8, Number(value) || baseTileSize); } },
|
||
|
|
zoomLevel: { get: () => zoomLevel, set: (value) => { zoomLevel = clampZoomLevel(value); tileSize = Math.max(8, Math.round(baseTileSize * zoomLevel)); } },
|
||
|
|
minZoomLevel: { get: () => minZoomLevel },
|
||
|
|
maxZoomLevel: { get: () => maxZoomLevel },
|
||
|
|
worldTileOffsetX: { get: () => worldRuntimeState.tileOffsetX },
|
||
|
|
worldTileOffsetY: { get: () => worldRuntimeState.tileOffsetY },
|
||
|
|
worldChunkWidth: { get: () => worldRuntimeState.chunkWidth },
|
||
|
|
worldChunkHeight: { get: () => worldRuntimeState.chunkHeight },
|
||
|
|
worldOriginChunkX: { get: () => worldRuntimeState.originChunkX },
|
||
|
|
worldOriginChunkY: { get: () => worldRuntimeState.originChunkY },
|
||
|
|
worldSpawnX: { get: () => worldRuntimeState.spawnX, set: (value) => { worldRuntimeState.spawnX = Math.floor(Number(value) || 0); } },
|
||
|
|
worldSpawnY: { get: () => worldRuntimeState.spawnY, set: (value) => { worldRuntimeState.spawnY = Math.floor(Number(value) || 0); } },
|
||
|
|
worldId: { get: () => worldRuntimeState.worldId, set: (value) => { worldRuntimeState.worldId = String(value || "").trim(); } },
|
||
|
|
worldName: { get: () => worldRuntimeState.worldName, set: (value) => { worldRuntimeState.worldName = String(value || "").trim() || "World"; } },
|
||
|
|
roomLayers: { get: () => mapDocument.roomLayers, set: (value) => { mapDocument.roomLayers = value; invalidateTileSurface("room-layers-replaced"); } },
|
||
|
|
heightLayers: { get: () => mapDocument.heightLayers, set: (value) => { mapDocument.heightLayers = cloneHeightLayers(value); draw(); } },
|
||
|
|
activeLayer: { get: () => popupSessionStore.state.activeLayer, set: (value) => { popupSessionStore.state.activeLayer = value; } },
|
||
|
|
viewingAllLayers: { get: () => popupSessionStore.state.viewingAllLayers, set: (value) => { popupSessionStore.state.viewingAllLayers = value; } },
|
||
|
|
activeSidebarTab: { get: () => popupSessionStore.state.activeSidebarTab, set: (value) => { popupSessionStore.state.activeSidebarTab = value; } },
|
||
|
|
mapId: { get: () => currentMapId, set: (value) => { currentMapId = String(value || "").trim(); } },
|
||
|
|
historyStorageKey: { get: () => currentHistoryStorageKey, set: (value) => { currentHistoryStorageKey = String(value || "").trim(); } },
|
||
|
|
mapName: { get: () => mapDocument.mapName, set: (value) => { mapDocumentStore.setMapName(value, currentMapId); syncDocumentTitle(); } },
|
||
|
|
backgroundColor: { get: () => mapDocument.backgroundColor, set: (value) => { mapDocument.backgroundColor = value; } },
|
||
|
|
backgroundTileId: { get: () => mapDocument.backgroundTileId, set: (value) => { mapDocumentStore.setBackgroundTileId(value, normalizeBackgroundTileId); invalidateTileSurface("background-tile-changed"); } },
|
||
|
|
heightBlurStep: {
|
||
|
|
get: () => Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1)),
|
||
|
|
set: (value) => { mapDocument.heightBlurStep = Math.max(0, Math.min(1, Number(value) || 0.1)); },
|
||
|
|
},
|
||
|
|
backgroundCellMode: { get: () => mapDocument.backgroundCellMode, set: (value) => { mapDocumentStore.setBackgroundCellMode(value, ["tile", "hole", "inherit"]); } },
|
||
|
|
mapInfoDraft: { get: () => mapDocument.mapInfoDraft, set: (value) => { mapDocument.mapInfoDraft = value; } },
|
||
|
|
activeBrushTileId: { get: () => popupSessionStore.state.activeBrushTileId, set: (value) => { popupSessionStore.state.activeBrushTileId = value; } },
|
||
|
|
activeGraphicsTab: { get: () => popupSessionStore.state.activeGraphicsTab, set: (value) => { popupSessionStore.state.activeGraphicsTab = String(value || "").trim() || "tiles"; } },
|
||
|
|
activeGraphicsRecordId: { get: () => popupSessionStore.state.activeGraphicsRecordId, set: (value) => { popupSessionStore.state.activeGraphicsRecordId = String(value || "").trim(); } },
|
||
|
|
canvasToolMode: {
|
||
|
|
get: () => popupSessionStore.state.canvasToolMode === "select" ? "select" : "paint",
|
||
|
|
set: (value) => { popupSessionStore.state.canvasToolMode = value === "select" ? "select" : "paint"; },
|
||
|
|
},
|
||
|
|
editingTargetKind: { get: () => popupSessionStore.state.editingTargetKind, set: (value) => { setEditingTargetKind(value, popupSessionStore.state.activeHeightLayerId); } },
|
||
|
|
activeHeightLayerId: {
|
||
|
|
get: () => popupSessionStore.state.activeHeightLayerId,
|
||
|
|
set: (value) => {
|
||
|
|
const normalized = String(value || "").trim();
|
||
|
|
popupSessionStore.state.activeHeightLayerId = normalized;
|
||
|
|
ensureActiveHeightLayerSelection();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
activeInstanceBrushId: { get: () => popupSessionStore.state.activeInstanceBrushId, set: (value) => { popupSessionStore.state.activeInstanceBrushId = value; } },
|
||
|
|
activeEntityCategory: { get: () => popupSessionStore.state.activeEntityCategory, set: (value) => { popupSessionStore.state.activeEntityCategory = String(value || "").trim() || "friendly"; } },
|
||
|
|
hoverTileX: { get: () => popupSessionStore.state.hoverTileX, set: (value) => { popupSessionStore.state.hoverTileX = value; } },
|
||
|
|
hoverTileY: { get: () => popupSessionStore.state.hoverTileY, set: (value) => { popupSessionStore.state.hoverTileY = value; } },
|
||
|
|
pointerCandidate: { get: () => popupSessionStore.state.pointerCandidate, set: (value) => { popupSessionStore.state.pointerCandidate = value; } },
|
||
|
|
draggingNpc: { get: () => popupSessionStore.state.draggingNpc, set: (value) => { popupSessionStore.state.draggingNpc = value; } },
|
||
|
|
dragDrawX: { get: () => popupSessionStore.state.dragDrawX, set: (value) => { popupSessionStore.state.dragDrawX = value; } },
|
||
|
|
dragDrawY: { get: () => popupSessionStore.state.dragDrawY, set: (value) => { popupSessionStore.state.dragDrawY = value; } },
|
||
|
|
paintingStroke: { get: () => popupSessionStore.state.paintingStroke, set: (value) => { popupSessionStore.state.paintingStroke = value; } },
|
||
|
|
tileMutationBatchDepth: { get: () => popupSessionStore.state.tileMutationBatchDepth },
|
||
|
|
selectedNpcId: { get: () => popupSessionStore.state.selectedNpcId, set: (value) => { popupSessionStore.state.selectedNpcId = value; } },
|
||
|
|
selectedTile: { get: () => popupSessionStore.state.selectedTile, set: (value) => { popupSessionStore.state.selectedTile = value; } },
|
||
|
|
hideTileGrid: {
|
||
|
|
get: () => popupSessionStore.state.hideTileGrid,
|
||
|
|
set: (value) => {
|
||
|
|
const nextValue = value === true;
|
||
|
|
if (popupSessionStore.state.hideTileGrid === nextValue) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
popupSessionStore.state.hideTileGrid = nextValue;
|
||
|
|
draw();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
showChunkBounds: {
|
||
|
|
get: () => popupSessionStore.state.showChunkBounds,
|
||
|
|
set: (value) => {
|
||
|
|
const nextValue = value === true;
|
||
|
|
if (popupSessionStore.state.showChunkBounds === nextValue) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
popupSessionStore.state.showChunkBounds = nextValue;
|
||
|
|
draw();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
zoomPreviewUntil: { get: () => popupSessionStore.state.zoomPreviewUntil, set: (value) => { popupSessionStore.state.zoomPreviewUntil = Math.max(0, Number(value) || 0); } },
|
||
|
|
scrollPreviewUntil: { get: () => popupSessionStore.state.scrollPreviewUntil, set: (value) => { popupSessionStore.state.scrollPreviewUntil = Math.max(0, Number(value) || 0); } },
|
||
|
|
spritePickerOpenNpcId: { get: () => popupSessionStore.state.spritePickerOpenNpcId, set: (value) => { popupSessionStore.state.spritePickerOpenNpcId = value; } },
|
||
|
|
hoveredNpcId: { get: () => popupSessionStore.state.hoveredNpcId, set: (value) => { popupSessionStore.state.hoveredNpcId = value; } },
|
||
|
|
hoverCanvasX: { get: () => popupSessionStore.state.hoverCanvasX, set: (value) => { popupSessionStore.state.hoverCanvasX = value; } },
|
||
|
|
hoverCanvasY: { get: () => popupSessionStore.state.hoverCanvasY, set: (value) => { popupSessionStore.state.hoverCanvasY = value; } },
|
||
|
|
templateSectionCollapsed: { get: () => popupSessionStore.state.templateSectionCollapsed, set: (value) => { popupSessionStore.state.templateSectionCollapsed = value === true; } },
|
||
|
|
placedSectionCollapsed: { get: () => popupSessionStore.state.placedSectionCollapsed, set: (value) => { popupSessionStore.state.placedSectionCollapsed = value === true; } },
|
||
|
|
drawLayerSectionCollapsed: { get: () => popupSessionStore.state.drawLayerSectionCollapsed, set: (value) => { popupSessionStore.state.drawLayerSectionCollapsed = value === true; } },
|
||
|
|
heightLayerSectionCollapsed: { get: () => popupSessionStore.state.heightLayerSectionCollapsed, set: (value) => { popupSessionStore.state.heightLayerSectionCollapsed = value === true; } },
|
||
|
|
organizedListDrag: { get: () => popupSessionStore.state.organizedListDrag, set: (value) => { popupSessionStore.state.organizedListDrag = value; } },
|
||
|
|
editorUiState: { get: () => editorUiStore.getState(), set: (value) => { editorUiStore.setState(value); } },
|
||
|
|
isSaving: { get: () => popupSessionStore.state.isSaving, set: (value) => { popupSessionStore.state.isSaving = value; } },
|
||
|
|
historyEntries: { get: () => historyState.entries, set: (value) => { historyState.entries = value; } },
|
||
|
|
historyIndex: { get: () => historyState.index, set: (value) => { historyState.index = value; } },
|
||
|
|
historySelectionIndex: { get: () => historyState.selectionIndex, set: (value) => { historyState.selectionIndex = value; } },
|
||
|
|
nextHistoryId: { get: () => historyState.nextId, set: (value) => { historyState.nextId = value; } },
|
||
|
|
lastSavedHistoryId: { get: () => historyState.lastSavedId, set: (value) => { historyState.lastSavedId = value; } },
|
||
|
|
});
|
||
|
|
|
||
|
|
const documentScope = {
|
||
|
|
mapId: currentMapId,
|
||
|
|
tileCatalogById,
|
||
|
|
tileCatalog,
|
||
|
|
tileColors,
|
||
|
|
contentBundle: mapDocument.contentBundle,
|
||
|
|
spriteCatalog,
|
||
|
|
defaultNpcTemplate,
|
||
|
|
apiBase,
|
||
|
|
npcOverlays: mapDocument.npcOverlays,
|
||
|
|
cloneLayers,
|
||
|
|
cloneHeightLayers,
|
||
|
|
cloneNpcOverlays,
|
||
|
|
cloneEditorUiState,
|
||
|
|
normalizeMapBackgroundColor,
|
||
|
|
normalizeBackgroundTileId,
|
||
|
|
normalizeRows,
|
||
|
|
getLayerByNumber,
|
||
|
|
getLayerDefaultName,
|
||
|
|
getLayerDisplayName,
|
||
|
|
isBackgroundLayer,
|
||
|
|
getHeightLayerById,
|
||
|
|
getHeightLayerDisplayName,
|
||
|
|
getActiveHeightLayer,
|
||
|
|
isEditingHeightLayer,
|
||
|
|
setEditingTargetKind,
|
||
|
|
syncNpcOverlayFromRecord,
|
||
|
|
ensureBaseLayer,
|
||
|
|
syncLayerVisibilityState,
|
||
|
|
setLayerVisibility,
|
||
|
|
isLayerVisible,
|
||
|
|
isLayerRendered,
|
||
|
|
getEditableLayerNumber,
|
||
|
|
moveLayerToDepth,
|
||
|
|
moveHeightLayerToDepth,
|
||
|
|
applyMapInformationEditsToDocument,
|
||
|
|
ensureDocumentContentPayload,
|
||
|
|
setDocumentContentPayload,
|
||
|
|
applyContentPayloadToRuntime,
|
||
|
|
getTileEntry,
|
||
|
|
getBackgroundTileEntry,
|
||
|
|
getBackgroundTileSymbol,
|
||
|
|
getPaintableTileIds,
|
||
|
|
getTileEntryById,
|
||
|
|
getRowFillChar,
|
||
|
|
getBrushSymbol,
|
||
|
|
describeBrushTileId,
|
||
|
|
describeTileSymbol,
|
||
|
|
setTileCharAt,
|
||
|
|
toCellKey,
|
||
|
|
runtimeUniqueId,
|
||
|
|
selectTile,
|
||
|
|
selectNpc,
|
||
|
|
findPlacedNpcByTemplateId,
|
||
|
|
assignNpcToSlot,
|
||
|
|
removeNpcInstanceById,
|
||
|
|
createNewNpc,
|
||
|
|
deleteTileEverywhere,
|
||
|
|
deleteSpriteEverywhere,
|
||
|
|
createNewSpriteGraphic,
|
||
|
|
assignGraphicToCategory,
|
||
|
|
syncGraphicCounterpartFromRecord,
|
||
|
|
ensureNpcImageLoaded,
|
||
|
|
findOpenNpcSpawnTile,
|
||
|
|
getVisibleNpcOverlays,
|
||
|
|
getNpcCatalogRecords,
|
||
|
|
ensureWorldDocumentCurrent,
|
||
|
|
cacheStandaloneMapBootstrap,
|
||
|
|
saveCurrentState,
|
||
|
|
persistContentPayload,
|
||
|
|
backgroundCellModeOrder: ["tile", "hole", "inherit"],
|
||
|
|
};
|
||
|
|
Object.defineProperties(documentScope, {
|
||
|
|
mapId: { get: () => scope.mapId, set: (value) => { scope.mapId = value; } },
|
||
|
|
width: { get: () => scope.width, set: (value) => { scope.width = value; } },
|
||
|
|
height: { get: () => scope.height, set: (value) => { scope.height = value; } },
|
||
|
|
roomLayers: { get: () => scope.roomLayers, set: (value) => { scope.roomLayers = value; } },
|
||
|
|
heightLayers: { get: () => scope.heightLayers, set: (value) => { scope.heightLayers = value; } },
|
||
|
|
mapName: { get: () => scope.mapName, set: (value) => { scope.mapName = value; } },
|
||
|
|
backgroundColor: { get: () => scope.backgroundColor, set: (value) => { scope.backgroundColor = value; } },
|
||
|
|
backgroundTileId: { get: () => scope.backgroundTileId, set: (value) => { scope.backgroundTileId = value; } },
|
||
|
|
heightBlurStep: { get: () => scope.heightBlurStep, set: (value) => { scope.heightBlurStep = value; } },
|
||
|
|
backgroundCellMode: { get: () => scope.backgroundCellMode, set: (value) => { scope.backgroundCellMode = value; } },
|
||
|
|
mapInfoDraft: { get: () => scope.mapInfoDraft, set: (value) => { scope.mapInfoDraft = value; } },
|
||
|
|
editingTargetKind: { get: () => scope.editingTargetKind, set: (value) => { scope.editingTargetKind = value; } },
|
||
|
|
activeHeightLayerId: { get: () => scope.activeHeightLayerId, set: (value) => { scope.activeHeightLayerId = value; } },
|
||
|
|
baseTileSize: { get: () => scope.baseTileSize },
|
||
|
|
});
|
||
|
|
|
||
|
|
const renderScope = {
|
||
|
|
pixiHost,
|
||
|
|
canvas,
|
||
|
|
ctx,
|
||
|
|
viewport,
|
||
|
|
viewportSpacer,
|
||
|
|
getCanvasPoint,
|
||
|
|
findTopNpcAtCanvas,
|
||
|
|
uiIconEl,
|
||
|
|
draw,
|
||
|
|
drawNow,
|
||
|
|
invalidateTileSurface,
|
||
|
|
patchTileSurfaceCell,
|
||
|
|
getCachedImage,
|
||
|
|
getScaledSize,
|
||
|
|
getZoomPercent,
|
||
|
|
applyZoomLevel,
|
||
|
|
startZoomPreview,
|
||
|
|
isZoomPreviewActive,
|
||
|
|
startScrollPreview,
|
||
|
|
isScrollPreviewActive,
|
||
|
|
centerViewportOnWorldPoint,
|
||
|
|
centerViewportOnNpc,
|
||
|
|
syncViewportDimensions: syncCanvasDimensionsToTileSize,
|
||
|
|
};
|
||
|
|
|
||
|
|
const historyScope = {
|
||
|
|
historyStorageKey: currentHistoryStorageKey,
|
||
|
|
persistHistoryState,
|
||
|
|
restoreHistoryState,
|
||
|
|
applyHistorySnapshot,
|
||
|
|
captureState,
|
||
|
|
registerHistory,
|
||
|
|
refreshToolbarState,
|
||
|
|
undo,
|
||
|
|
redo,
|
||
|
|
formatCellCoord,
|
||
|
|
formatHistoryLabel,
|
||
|
|
renderHistoryPreview,
|
||
|
|
renderHistoryList,
|
||
|
|
};
|
||
|
|
Object.defineProperties(historyScope, {
|
||
|
|
historyStorageKey: { get: () => currentHistoryStorageKey, set: (value) => { currentHistoryStorageKey = String(value || "").trim(); } },
|
||
|
|
historyEntries: { get: () => scope.historyEntries, set: (value) => { scope.historyEntries = value; } },
|
||
|
|
historyIndex: { get: () => scope.historyIndex, set: (value) => { scope.historyIndex = value; } },
|
||
|
|
historySelectionIndex: { get: () => scope.historySelectionIndex, set: (value) => { scope.historySelectionIndex = value; } },
|
||
|
|
nextHistoryId: { get: () => scope.nextHistoryId, set: (value) => { scope.nextHistoryId = value; } },
|
||
|
|
lastSavedHistoryId: { get: () => scope.lastSavedHistoryId, set: (value) => { scope.lastSavedHistoryId = value; } },
|
||
|
|
isSaving: { get: () => scope.isSaving, set: (value) => { scope.isSaving = value; } },
|
||
|
|
});
|
||
|
|
|
||
|
|
const uiScope = {
|
||
|
|
layerListEl,
|
||
|
|
paintPaletteEl,
|
||
|
|
instancePaletteEl,
|
||
|
|
npcListEl,
|
||
|
|
newNpcBtn,
|
||
|
|
newTileFolderBtn,
|
||
|
|
newTileBtn,
|
||
|
|
tileSearchModeBtn,
|
||
|
|
graphicsTilesBtn,
|
||
|
|
graphicsSpritesBtn,
|
||
|
|
graphicsOtherBtn,
|
||
|
|
newTemplateFolderBtn,
|
||
|
|
newPlacedFolderBtn,
|
||
|
|
entitySearchModeBtn,
|
||
|
|
entitySearchModeHost,
|
||
|
|
entityTypeFriendlyBtn,
|
||
|
|
entityTypeHostileBtn,
|
||
|
|
entityTypePropBtn,
|
||
|
|
newMonsterFolderBtn,
|
||
|
|
newTriggerFolderBtn,
|
||
|
|
newPathFolderBtn,
|
||
|
|
newTransitionFolderBtn,
|
||
|
|
toggleTemplateSectionBtn,
|
||
|
|
togglePlacedSectionBtn,
|
||
|
|
instanceTemplateSectionBody,
|
||
|
|
placedInstanceSectionBody,
|
||
|
|
entityCatalogSection,
|
||
|
|
placedEntitiesSection,
|
||
|
|
monsterListEl,
|
||
|
|
triggerListEl,
|
||
|
|
pathListEl,
|
||
|
|
transitionListEl,
|
||
|
|
metaEl,
|
||
|
|
metaMainEl,
|
||
|
|
metaStatsEl,
|
||
|
|
historyListEl,
|
||
|
|
historyCurrentEl,
|
||
|
|
historyPreviewEl,
|
||
|
|
undoBtn,
|
||
|
|
redoBtn,
|
||
|
|
saveBtn,
|
||
|
|
testHeightBtn,
|
||
|
|
menuLayerSelectEl,
|
||
|
|
saveStatusEl,
|
||
|
|
themePresetButtons,
|
||
|
|
informationTabBtn,
|
||
|
|
layersTabBtn,
|
||
|
|
tilesTabBtn,
|
||
|
|
instancesTabBtn,
|
||
|
|
triggersTabBtn,
|
||
|
|
pathsTabBtn,
|
||
|
|
transitionsTabBtn,
|
||
|
|
historyTabBtn,
|
||
|
|
newsTabBtn,
|
||
|
|
editorBodyEl,
|
||
|
|
sidebarEl,
|
||
|
|
sidebarTabsEl,
|
||
|
|
sidebarPanelsHostEl,
|
||
|
|
informationPanel,
|
||
|
|
layersPanel,
|
||
|
|
tilesPanel,
|
||
|
|
instancesPanel,
|
||
|
|
triggersPanel,
|
||
|
|
pathsPanel,
|
||
|
|
transitionsPanel,
|
||
|
|
historyPanel,
|
||
|
|
drawLayerSectionBody,
|
||
|
|
heightLayerSectionBody,
|
||
|
|
toggleDrawLayerSectionBtn,
|
||
|
|
toggleHeightLayerSectionBtn,
|
||
|
|
mapIdLockedEl,
|
||
|
|
mapNameInputEl,
|
||
|
|
mapWidthInputEl,
|
||
|
|
mapHeightInputEl,
|
||
|
|
mapBackgroundColorInputEl,
|
||
|
|
engineOverridesBtn,
|
||
|
|
engineOverridesSummaryEl,
|
||
|
|
backgroundModeBtn,
|
||
|
|
backgroundModePreviewEl,
|
||
|
|
backgroundModeTitleEl,
|
||
|
|
backgroundModeMetaEl,
|
||
|
|
experimentalImportToggleBtn,
|
||
|
|
experimentalImportCheckEl,
|
||
|
|
experimentalImportBodyEl,
|
||
|
|
importSpritesBtn,
|
||
|
|
importTilesBtn,
|
||
|
|
importJsonBtn,
|
||
|
|
importSpritesInputEl,
|
||
|
|
importTilesInputEl,
|
||
|
|
importJsonModal,
|
||
|
|
importJsonTypeSelect,
|
||
|
|
importJsonTextarea,
|
||
|
|
importJsonConfirmBtn,
|
||
|
|
importJsonCancelBtn,
|
||
|
|
mapWidthValueEl,
|
||
|
|
mapHeightValueEl,
|
||
|
|
mapWidthControlsEl,
|
||
|
|
mapHeightControlsEl,
|
||
|
|
confirmWidthBtn,
|
||
|
|
cancelWidthBtn,
|
||
|
|
confirmHeightBtn,
|
||
|
|
cancelHeightBtn,
|
||
|
|
addHeightLayerBtn,
|
||
|
|
heightLayerListEl,
|
||
|
|
stageEl,
|
||
|
|
canvasSelectToolBtn,
|
||
|
|
toolWindowLayerEl,
|
||
|
|
atTooltip,
|
||
|
|
runtimeEscapeHtml,
|
||
|
|
setStatus,
|
||
|
|
setSidebarTab,
|
||
|
|
refreshInformationPanel,
|
||
|
|
refreshBackgroundModeButton,
|
||
|
|
cycleBackgroundCellMode,
|
||
|
|
addHeightLayer,
|
||
|
|
renderPaintPalette,
|
||
|
|
renderHeightLayerList,
|
||
|
|
renderInstancePalette,
|
||
|
|
refreshEntityTypeTabs,
|
||
|
|
setActiveEntityCategory,
|
||
|
|
renderLayerList,
|
||
|
|
refreshMenuLayerSelect,
|
||
|
|
handleMenuLayerSelectionChange,
|
||
|
|
renderTriggerList,
|
||
|
|
renderMonsterList,
|
||
|
|
renderPathList,
|
||
|
|
renderTransitionList,
|
||
|
|
collapseActiveSidebarTabSections,
|
||
|
|
toggleDrawLayerSection,
|
||
|
|
toggleHeightLayerSection,
|
||
|
|
toggleTemplateSection,
|
||
|
|
togglePlacedSection,
|
||
|
|
refreshLayerSectionState,
|
||
|
|
refreshInstanceSectionState,
|
||
|
|
renderNpcList,
|
||
|
|
getPanelLayout,
|
||
|
|
setPanelLayout,
|
||
|
|
createPanelFolder,
|
||
|
|
togglePanelFolder,
|
||
|
|
renamePanelFolder,
|
||
|
|
deletePanelFolder,
|
||
|
|
movePanelNode,
|
||
|
|
refreshInformationDraftState,
|
||
|
|
refreshImportControls,
|
||
|
|
openHeightViewerWindow,
|
||
|
|
openEntityEditorWindow: null,
|
||
|
|
closeEntityEditorWindow: null,
|
||
|
|
toggleExperimentalImportPanel,
|
||
|
|
openImportDialog,
|
||
|
|
handleImportSelection,
|
||
|
|
openJsonImportModal,
|
||
|
|
closeJsonImportModal,
|
||
|
|
submitJsonImport,
|
||
|
|
applyThemePreset,
|
||
|
|
refreshThemePresetButtons,
|
||
|
|
getEditorEngineOverrides,
|
||
|
|
saveEditorEngineOverrides,
|
||
|
|
getEffectiveHeightBlurStep,
|
||
|
|
isRendererDebugEnabled,
|
||
|
|
};
|
||
|
|
|
||
|
|
const sessionScope = {
|
||
|
|
pan: popupSessionStore.state.pan,
|
||
|
|
uiImageCache,
|
||
|
|
npcImages,
|
||
|
|
popupBoundsStorageKey,
|
||
|
|
resetWindowPosition,
|
||
|
|
resetWorkspaceLayout,
|
||
|
|
beginTileInstanceMutationBatch,
|
||
|
|
endTileInstanceMutationBatch,
|
||
|
|
hasUnsavedChanges,
|
||
|
|
getVisibleNpcOverlays,
|
||
|
|
persistPopupSessionLayout: () => popupSessionStore.persistPersistedLayoutDeferred(window),
|
||
|
|
flushPersistedPopupSessionLayout: () => popupSessionStore.flushPersistedLayout(window),
|
||
|
|
clearPersistedPopupSessionLayout: () => popupSessionStore.clearPersistedLayout(window),
|
||
|
|
getPersistedToolWindowState: (key) => popupSessionStore.getToolWindowState(key),
|
||
|
|
setPersistedToolWindowState: (key, value) => popupSessionStore.setToolWindowState(key, value),
|
||
|
|
};
|
||
|
|
Object.defineProperties(sessionScope, {
|
||
|
|
activeLayer: { get: () => scope.activeLayer, set: (value) => { scope.activeLayer = value; } },
|
||
|
|
viewingAllLayers: { get: () => scope.viewingAllLayers, set: (value) => { scope.viewingAllLayers = value; } },
|
||
|
|
activeSidebarTab: { get: () => scope.activeSidebarTab, set: (value) => { scope.activeSidebarTab = value; } },
|
||
|
|
activeBrushTileId: { get: () => scope.activeBrushTileId, set: (value) => { scope.activeBrushTileId = value; } },
|
||
|
|
canvasToolMode: { get: () => scope.canvasToolMode, set: (value) => { scope.canvasToolMode = value; } },
|
||
|
|
editingTargetKind: { get: () => scope.editingTargetKind, set: (value) => { scope.editingTargetKind = value; } },
|
||
|
|
activeHeightLayerId: { get: () => scope.activeHeightLayerId, set: (value) => { scope.activeHeightLayerId = value; } },
|
||
|
|
activeInstanceBrushId: { get: () => scope.activeInstanceBrushId, set: (value) => { scope.activeInstanceBrushId = value; } },
|
||
|
|
activeEntityCategory: { get: () => scope.activeEntityCategory, set: (value) => { scope.activeEntityCategory = value; } },
|
||
|
|
hoverTileX: { get: () => scope.hoverTileX, set: (value) => { scope.hoverTileX = value; } },
|
||
|
|
hoverTileY: { get: () => scope.hoverTileY, set: (value) => { scope.hoverTileY = value; } },
|
||
|
|
pointerCandidate: { get: () => scope.pointerCandidate, set: (value) => { scope.pointerCandidate = value; } },
|
||
|
|
draggingNpc: { get: () => scope.draggingNpc, set: (value) => { scope.draggingNpc = value; } },
|
||
|
|
dragDrawX: { get: () => scope.dragDrawX, set: (value) => { scope.dragDrawX = value; } },
|
||
|
|
dragDrawY: { get: () => scope.dragDrawY, set: (value) => { scope.dragDrawY = value; } },
|
||
|
|
paintingStroke: { get: () => scope.paintingStroke, set: (value) => { scope.paintingStroke = value; } },
|
||
|
|
tileMutationBatchDepth: { get: () => scope.tileMutationBatchDepth },
|
||
|
|
selectedNpcId: { get: () => scope.selectedNpcId, set: (value) => { scope.selectedNpcId = value; } },
|
||
|
|
selectedTile: { get: () => scope.selectedTile, set: (value) => { scope.selectedTile = value; } },
|
||
|
|
hideTileGrid: { get: () => scope.hideTileGrid, set: (value) => { scope.hideTileGrid = value; } },
|
||
|
|
showChunkBounds: { get: () => scope.showChunkBounds, set: (value) => { scope.showChunkBounds = value; } },
|
||
|
|
zoomPreviewUntil: { get: () => scope.zoomPreviewUntil, set: (value) => { scope.zoomPreviewUntil = value; } },
|
||
|
|
scrollPreviewUntil: { get: () => scope.scrollPreviewUntil, set: (value) => { scope.scrollPreviewUntil = value; } },
|
||
|
|
spritePickerOpenNpcId: { get: () => scope.spritePickerOpenNpcId, set: (value) => { scope.spritePickerOpenNpcId = value; } },
|
||
|
|
hoveredNpcId: { get: () => scope.hoveredNpcId, set: (value) => { scope.hoveredNpcId = value; } },
|
||
|
|
hoverCanvasX: { get: () => scope.hoverCanvasX, set: (value) => { scope.hoverCanvasX = value; } },
|
||
|
|
hoverCanvasY: { get: () => scope.hoverCanvasY, set: (value) => { scope.hoverCanvasY = value; } },
|
||
|
|
templateSectionCollapsed: { get: () => scope.templateSectionCollapsed, set: (value) => { scope.templateSectionCollapsed = value; } },
|
||
|
|
placedSectionCollapsed: { get: () => scope.placedSectionCollapsed, set: (value) => { scope.placedSectionCollapsed = value; } },
|
||
|
|
drawLayerSectionCollapsed: { get: () => scope.drawLayerSectionCollapsed, set: (value) => { scope.drawLayerSectionCollapsed = value; } },
|
||
|
|
heightLayerSectionCollapsed: { get: () => scope.heightLayerSectionCollapsed, set: (value) => { scope.heightLayerSectionCollapsed = value; } },
|
||
|
|
organizedListDrag: { get: () => scope.organizedListDrag, set: (value) => { scope.organizedListDrag = value; } },
|
||
|
|
editorUiState: { get: () => scope.editorUiState, set: (value) => { scope.editorUiState = value; } },
|
||
|
|
});
|
||
|
|
|
||
|
|
scope.documentScope = documentScope;
|
||
|
|
scope.renderScope = renderScope;
|
||
|
|
scope.historyScope = historyScope;
|
||
|
|
scope.uiScope = uiScope;
|
||
|
|
scope.sessionScope = sessionScope;
|
||
|
|
const toolWindowController = createToolWindowController(scope);
|
||
|
|
const tileArtEditorWindowController = createTileArtEditorWindowController(scope);
|
||
|
|
const entityEditorWindowController = createEntityEditorWindowController(scope);
|
||
|
|
const engineOverrideWindowController = createEngineOverrideWindowController(scope);
|
||
|
|
const worldOverviewWindowController = createWorldOverviewWindowController(scope);
|
||
|
|
const changelogSplashWindowController = createChangelogSplashWindowController(scope);
|
||
|
|
statusLogWindowController = createStatusLogWindowController(scope);
|
||
|
|
const syncToolPanels = () => toolWindowController.syncPanels();
|
||
|
|
const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab);
|
||
|
|
const restoreAllToolWindows = () => toolWindowController.restoreAllWindows();
|
||
|
|
const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId);
|
||
|
|
const closeTileArtEditorWindow = () => tileArtEditorWindowController.close();
|
||
|
|
const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId);
|
||
|
|
const closeEntityEditorWindow = () => entityEditorWindowController.close();
|
||
|
|
const openEngineOverrideWindow = () => engineOverrideWindowController.open();
|
||
|
|
const closeEngineOverrideWindow = () => engineOverrideWindowController.close();
|
||
|
|
const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh();
|
||
|
|
const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary();
|
||
|
|
const openWorldOverviewWindow = () => worldOverviewWindowController.open();
|
||
|
|
const closeWorldOverviewWindow = () => worldOverviewWindowController.close();
|
||
|
|
const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh();
|
||
|
|
const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options);
|
||
|
|
const openStatusLogWindow = () => statusLogWindowController.open();
|
||
|
|
const closeStatusLogWindow = () => statusLogWindowController.close();
|
||
|
|
const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options });
|
||
|
|
const resetWorkspaceLayoutFlow = () => {
|
||
|
|
resetWorkspaceLayout();
|
||
|
|
toolWindowController.restoreAllWindows();
|
||
|
|
setStatus("Workspace layout reset.", false);
|
||
|
|
};
|
||
|
|
scope.syncToolPanels = syncToolPanels;
|
||
|
|
scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
|
||
|
|
scope.restoreAllToolWindows = restoreAllToolWindows;
|
||
|
|
scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
|
||
|
|
scope.createNewTile = createNewTile;
|
||
|
|
scope.createNewSpriteGraphic = createNewSpriteGraphic;
|
||
|
|
scope.duplicateGraphicRecord = duplicateGraphicRecord;
|
||
|
|
scope.openTileArtEditorWindow = openTileArtEditorWindow;
|
||
|
|
scope.closeTileArtEditorWindow = closeTileArtEditorWindow;
|
||
|
|
scope.openEntityEditorWindow = openEntityEditorWindow;
|
||
|
|
scope.closeEntityEditorWindow = closeEntityEditorWindow;
|
||
|
|
scope.openEngineOverrideWindow = openEngineOverrideWindow;
|
||
|
|
scope.closeEngineOverrideWindow = closeEngineOverrideWindow;
|
||
|
|
scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
|
||
|
|
scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
|
||
|
|
scope.openWorldOverviewWindow = openWorldOverviewWindow;
|
||
|
|
scope.closeWorldOverviewWindow = closeWorldOverviewWindow;
|
||
|
|
scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
|
||
|
|
scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
|
||
|
|
scope.openStatusLogWindow = openStatusLogWindow;
|
||
|
|
scope.closeStatusLogWindow = closeStatusLogWindow;
|
||
|
|
scope.openNewsWindow = openNewsWindow;
|
||
|
|
scope.openTilePaletteContextMenu = openTilePaletteContextMenu;
|
||
|
|
scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
|
||
|
|
scope.applyNpcEditorChange = applyNpcEditorChange;
|
||
|
|
scope.getEditorEngineOverrides = getEditorEngineOverrides;
|
||
|
|
scope.saveEditorEngineOverrides = saveEditorEngineOverrides;
|
||
|
|
scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
|
||
|
|
scope.isRendererDebugEnabled = isRendererDebugEnabled;
|
||
|
|
scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
|
||
|
|
uiScope.syncToolPanels = syncToolPanels;
|
||
|
|
uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
|
||
|
|
uiScope.restoreAllToolWindows = restoreAllToolWindows;
|
||
|
|
uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
|
||
|
|
uiScope.createNewTile = createNewTile;
|
||
|
|
uiScope.createNewSpriteGraphic = createNewSpriteGraphic;
|
||
|
|
uiScope.duplicateGraphicRecord = duplicateGraphicRecord;
|
||
|
|
uiScope.openEntityEditorWindow = openEntityEditorWindow;
|
||
|
|
uiScope.closeEntityEditorWindow = closeEntityEditorWindow;
|
||
|
|
uiScope.openEngineOverrideWindow = openEngineOverrideWindow;
|
||
|
|
uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow;
|
||
|
|
uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
|
||
|
|
uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
|
||
|
|
uiScope.openWorldOverviewWindow = openWorldOverviewWindow;
|
||
|
|
uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow;
|
||
|
|
uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
|
||
|
|
uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
|
||
|
|
uiScope.openStatusLogWindow = openStatusLogWindow;
|
||
|
|
uiScope.closeStatusLogWindow = closeStatusLogWindow;
|
||
|
|
uiScope.openNewsWindow = openNewsWindow;
|
||
|
|
uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu;
|
||
|
|
uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
|
||
|
|
uiScope.applyNpcEditorChange = applyNpcEditorChange;
|
||
|
|
uiScope.getEditorEngineOverrides = getEditorEngineOverrides;
|
||
|
|
uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides;
|
||
|
|
uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
|
||
|
|
uiScope.isRendererDebugEnabled = isRendererDebugEnabled;
|
||
|
|
uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
|
||
|
|
|
||
|
|
syncDocumentTitle();
|
||
|
|
const historyController = createHistoryController(scope);
|
||
|
|
const npcController = createNpcController(scope);
|
||
|
|
const sidebarController = createSidebarController(scope);
|
||
|
|
renderController = createRenderController(scope);
|
||
|
|
const persistenceController = createPersistenceController(scope);
|
||
|
|
const importController = createImportController(scope);
|
||
|
|
const interactionController = createInteractionController(scope);
|
||
|
|
const persistPopupBounds = () => {
|
||
|
|
persistMapEditorPopupBounds(window);
|
||
|
|
};
|
||
|
|
const persistPopupBoundsDeferred = createDebouncedCallback(() => {
|
||
|
|
persistPopupBounds();
|
||
|
|
}, 160);
|
||
|
|
syncCanvasDimensionsToTileSize();
|
||
|
|
toolWindowController.initialize();
|
||
|
|
tileArtEditorWindowController.initialize();
|
||
|
|
entityEditorWindowController.initialize();
|
||
|
|
engineOverrideWindowController.initialize();
|
||
|
|
worldOverviewWindowController.initialize();
|
||
|
|
changelogSplashWindowController.initialize();
|
||
|
|
statusLogWindowController.initialize();
|
||
|
|
renderController.initializeRenderAssets();
|
||
|
|
interactionController.initializeEditorState();
|
||
|
|
interactionController.bindDomEvents();
|
||
|
|
interactionController.initializeUi();
|
||
|
|
refreshEditorEngineOverridesUi();
|
||
|
|
cacheStandaloneMapBootstrap(currentMapId);
|
||
|
|
if (isWorldModeActive()) {
|
||
|
|
window.requestAnimationFrame(() => {
|
||
|
|
const initialWorldView = getInitialWorldViewTile();
|
||
|
|
centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY);
|
||
|
|
prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY);
|
||
|
|
syncWorldNeighborhoodForViewport();
|
||
|
|
drawNow();
|
||
|
|
setStatus("World mode loaded. Endless navigation is active.", false);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
window.requestAnimationFrame(() => {
|
||
|
|
changelogSplashWindowController.maybeOpenForCurrentVersion();
|
||
|
|
});
|
||
|
|
window.addEventListener("resize", () => {
|
||
|
|
persistPopupBoundsDeferred();
|
||
|
|
});
|
||
|
|
window.addEventListener("beforeunload", () => {
|
||
|
|
popupSessionStore.flushPersistedLayout(window);
|
||
|
|
persistPopupBounds();
|
||
|
|
});
|
||
|
|
}
|