/* 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, "'"); } 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(); }); }