Worldshaper/src/worldshaperStudio/runtime.ts

4759 lines
198 KiB
TypeScript
Raw Normal View History

2026-06-27 04:36:26 -04:00
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
2026-06-26 18:18:14 -04:00
// @ts-nocheck
import {
buildSpritePreviewDataUrl,
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
fetchJsonOrThrow,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
normalizeImageRecordForSave,
normalizeTileRecordForSave,
} from "../editorCore";
2026-06-27 04:36:26 -04:00
import { DEFAULT_TILE_COLOR } from "../components/worldshaperShared";
2026-06-26 20:30:30 -04:00
import type { WorldshaperStudioBootstrap } from "./bootstrap";
2026-06-26 18:18:14 -04:00
import {
2026-06-26 20:30:30 -04:00
cacheStandaloneWorldshaperBootstrap,
clearWorldshaperStudioBootstrap,
createWorldshaperStudioToken,
loadStandaloneWorldshaperBootstrap,
registerWorldshaperStudioBootstrap,
2026-06-26 18:18:14 -04:00
} from "./bootstrap";
import { buildChunkKey, worldToChunkCoord, worldToLocalCoord } from "../worldChunking";
import {
2026-06-26 20:30:30 -04:00
getCenteredWorldshaperStudioBounds,
WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY,
openWorldshaperHeightViewerWindow,
persistWorldshaperStudioBounds,
2026-06-26 18:18:14 -04:00
} from "./windowing";
import { createHistoryStateStore } from "./historyStateStore";
import { createMapDocumentController } from "./mapDocumentController";
import { createMapDocumentStore } from "./mapDocumentStore";
import {
createPanelFolderLayoutFolder,
deletePanelFolderLayoutFolder,
movePanelFolderLayoutNode,
renamePanelFolderLayoutFolder,
togglePanelFolderLayoutFolder,
} from "./panelFolders";
import { createEditorUiStore } from "./editorUiStore";
import {
getEngineOverrideValue,
normalizeEngineOverrideEntries,
} from "./engineOverrides";
import { createPopupSessionStore } from "./popupSessionStore";
import {
buildImageRecordFromSpriteRecord,
buildImageRecordFromTileRecord,
buildTileRecordFromImageRecord,
getImageRecordFromPayload,
normalizeGraphicRoles,
normalizeImagesPayloadSnapshot,
} from "./graphicsDocumentHelpers";
import {
2026-06-26 20:30:30 -04:00
DEFAULT_WORLDSHAPER_THEME_PRESET,
applyWorldshaperThemePreset,
getWorldshaperThemeLabel,
2026-06-26 18:18:14 -04:00
getDefaultEditorSettings,
normalizeEditorSettings,
2026-06-26 20:30:30 -04:00
normalizeWorldshaperThemePreset,
2026-06-26 18:18:14 -04:00
persistEditorSettings,
} from "./themePresets";
import { createAtTooltip } from "./tooltip";
2026-06-27 04:36:26 -04:00
import { initializeRuntimeControllers } from "./runtimeControllerBootstrap";
import {
buildNpcOverlaysFromWorldChunks,
buildSpriteCatalogFromBootstrap,
buildTileCatalogByIdFromBootstrap,
cloneRuntimeValue,
createInitialWorldRuntimeState,
MAX_DYNAMIC_WORLD_CHUNK_RADIUS,
MAX_WORLD_CHUNK_CACHE_ENTRIES,
normalizeMapBackgroundColor,
TILE_SYMBOL_POOL,
} from "./runtimeBootstrapHelpers";
import { createRuntimeLogging } from "./runtimeLogging";
import {
buildChunkHeightLayersFromDocument as buildChunkHeightLayersFromDocumentHelper,
buildChunkInstancesFromDocument as buildChunkInstancesFromDocumentHelper,
buildWorldChunkLayerInstanceIds,
buildWorldLayerMetadata,
cloneWorldChunkHeightLayers,
composeWorldHeightLayers,
composeWorldRoomLayers,
createEmptyWorldChunkPayload as createEmptyWorldChunkPayloadHelper,
createFilledRows,
isChunkFillSymbol,
isWorldChunkPayloadEmpty as isWorldChunkPayloadEmptyHelper,
normalizeCachedWorldChunkPayload as normalizeCachedWorldChunkPayloadHelper,
normalizeWorldChunkInstances as normalizeWorldChunkInstancesHelper,
normalizeWorldChunkRows,
sliceNormalizedRows,
transformChunkHeightPatch,
transformChunkLocalCoord,
transformChunkRows,
transformWorldChunkPayload as transformWorldChunkPayloadHelper,
} from "./worldChunkRuntimeHelpers";
2026-06-26 18:18:14 -04:00
2026-06-26 20:30:30 -04:00
export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, initialEditorSettings: unknown = getDefaultEditorSettings()): void {
2026-06-26 18:18:14 -04:00
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 ?? "")) : [];
2026-06-27 04:36:26 -04:00
const worldRuntimeState = createInitialWorldRuntimeState(bootstrap);
2026-06-26 18:18:14 -04:00
function isWorldModeActive() {
return worldRuntimeState.enabled && !!worldRuntimeState.worldId;
}
const defaultTileColor = DEFAULT_TILE_COLOR;
2026-06-27 04:36:26 -04:00
const tileColors = cloneRuntimeValue(bootstrap.tileColors) || {};
2026-06-26 18:18:14 -04:00
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();
2026-06-27 04:36:26 -04:00
const contentByType = cloneRuntimeValue(bootstrap.contentByType) || {};
2026-06-26 18:18:14 -04:00
const spriteCatalog = applySpriteCatalogVisualRevision(buildSpriteCatalogFromBootstrap(bootstrap));
2026-06-27 04:36:26 -04:00
const defaultNpcTemplate = cloneRuntimeValue(bootstrap.defaultNpcTemplate) || {};
2026-06-26 18:18:14 -04:00
const apiBase = String(bootstrap.apiBase || "").replace(/\/+$/, "");
function deriveHistoryStorageKey(mapIdValue) {
2026-06-26 20:30:30 -04:00
return "worldshaper:world-history:v2:" + String(mapIdValue || "").trim();
2026-06-26 18:18:14 -04:00
}
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() {
2026-06-26 20:30:30 -04:00
return cacheStandaloneWorldshaperBootstrap(buildCurrentBootstrapSnapshot(), window);
2026-06-26 18:18:14 -04:00
}
function syncDocumentTitle() {
const titleName = String(mapDocument.mapName || currentMapId || "Untitled").trim() || "Untitled";
2026-06-26 20:30:30 -04:00
document.title = "Worldshaper Studio - " + titleName;
2026-06-26 18:18:14 -04:00
}
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
2026-06-27 04:36:26 -04:00
? cloneRuntimeValue(existingChunk)
2026-06-26 18:18:14 -04:00
: (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(), {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(existingChunk),
2026-06-26 18:18:14 -04:00
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)
2026-06-27 04:36:26 -04:00
.map((entry) => cloneRuntimeValue(entry));
2026-06-26 18:18:14 -04:00
}
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 buildChunkHeightLayersFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) {
2026-06-27 04:36:26 -04:00
return buildChunkHeightLayersFromDocumentHelper({
mapDocument,
cloneHeightLayers,
baseTileX,
baseTileY,
chunkWidth,
chunkHeight,
});
2026-06-26 18:18:14 -04:00
}
function buildChunkInstancesFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) {
2026-06-27 04:36:26 -04:00
return buildChunkInstancesFromDocumentHelper({
mapDocument,
cloneValue,
baseTileX,
baseTileY,
chunkWidth,
chunkHeight,
tileOffsetX: worldRuntimeState.tileOffsetX,
tileOffsetY: worldRuntimeState.tileOffsetY,
2026-06-26 18:18:14 -04:00
});
}
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 {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(entry),
2026-06-26 18:18:14 -04:00
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 {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(entry),
2026-06-26 18:18:14 -04:00
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, {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(chunkValue),
2026-06-26 18:18:14 -04:00
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);
let statusLogWindowController = null;
2026-06-27 04:36:26 -04:00
const runtimeLogging = createRuntimeLogging({
windowRef: window,
runtimeUniqueId,
2026-06-26 18:18:14 -04:00
});
2026-06-27 04:36:26 -04:00
const { appendEditorLogEntry, getEditorLogEntries, clearEditorLogEntries } = runtimeLogging;
2026-06-26 18:18:14 -04:00
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);
2026-06-27 04:36:26 -04:00
// ── AtTooltip: reusable anchored floating context menu ──────────────
2026-06-26 18:18:14 -04:00
const atTooltip = createAtTooltip();
const initialEditorUiState = bootstrap.editorUi;
const editorUiStore = createEditorUiStore(initialEditorUiState);
const historyState = createHistoryStateStore();
let currentHistoryStorageKey = deriveHistoryStorageKey(currentMapId);
2026-06-26 20:30:30 -04:00
const popupBoundsStorageKey = WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY;
2026-06-26 18:18:14 -04:00
function runtimeEscapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function getActiveThemePreset() {
2026-06-26 20:30:30 -04:00
return normalizeWorldshaperThemePreset(editorSettingsState?.worldshaperStudio?.themePreset || DEFAULT_WORLDSHAPER_THEME_PRESET);
2026-06-26 18:18:14 -04:00
}
function getEditorEngineOverrides() {
2026-06-26 20:30:30 -04:00
return normalizeEngineOverrideEntries(editorSettingsState?.worldshaperStudio?.engineOverrides);
2026-06-26 18:18:14 -04:00
}
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,
2026-06-26 20:30:30 -04:00
worldshaperStudio: {
...editorSettingsState.worldshaperStudio,
2026-06-26 18:18:14 -04:00
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) => {
2026-06-26 20:30:30 -04:00
const presetId = normalizeWorldshaperThemePreset(button.getAttribute("data-theme-preset") || "");
2026-06-26 18:18:14 -04:00
button.classList.toggle("active", presetId === activePreset);
button.setAttribute("aria-pressed", presetId === activePreset ? "true" : "false");
});
}
async function saveThemePreset(nextPreset) {
editorSettingsState = await persistEditorSettings(apiBase, {
...editorSettingsState,
2026-06-26 20:30:30 -04:00
worldshaperStudio: {
...editorSettingsState.worldshaperStudio,
2026-06-26 18:18:14 -04:00
themePreset: nextPreset,
},
});
refreshThemePresetButtons();
}
function applyThemePreset(nextPreset, options) {
2026-06-26 20:30:30 -04:00
const normalizedPreset = normalizeWorldshaperThemePreset(nextPreset);
2026-06-26 18:18:14 -04:00
editorSettingsState = normalizeEditorSettings({
...editorSettingsState,
2026-06-26 20:30:30 -04:00
worldshaperStudio: {
...editorSettingsState.worldshaperStudio,
2026-06-26 18:18:14 -04:00
themePreset: normalizedPreset,
},
});
2026-06-26 20:30:30 -04:00
applyWorldshaperThemePreset(normalizedPreset);
2026-06-26 18:18:14 -04:00
refreshThemePresetButtons();
if (!(options && options.silent)) {
2026-06-26 20:30:30 -04:00
setStatus("Theme switched to " + getWorldshaperThemeLabel(normalizedPreset) + ".", false);
2026-06-26 18:18:14 -04:00
}
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 {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(entry),
2026-06-26 18:18:14 -04:00
id: metadata.id,
name: metadata.name,
z: metadata.z,
};
})
.filter(Boolean);
nextEntries.push([chunkKey, {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(chunkValue),
2026-06-26 18:18:14 -04:00
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 normalizeWorldChunkInstances(sourceInstances, chunkX, chunkY, width, height, options) {
2026-06-27 04:36:26 -04:00
return normalizeWorldChunkInstancesHelper({
sourceInstances,
chunkX,
chunkY,
width,
height,
options,
cloneValue,
runtimeUniqueId,
});
2026-06-26 18:18:14 -04:00
}
function createEmptyWorldChunkPayload(chunkX, chunkY) {
2026-06-27 04:36:26 -04:00
return createEmptyWorldChunkPayloadHelper({
chunkX,
chunkY,
chunkWidth: Math.max(1, Number(worldRuntimeState.chunkWidth) || 32),
chunkHeight: Math.max(1, Number(worldRuntimeState.chunkHeight) || 32),
2026-06-26 18:18:14 -04:00
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
2026-06-27 04:36:26 -04:00
});
2026-06-26 18:18:14 -04:00
}
function normalizeCachedWorldChunkPayload(chunkPayload, chunkX, chunkY, options) {
2026-06-27 04:36:26 -04:00
return normalizeCachedWorldChunkPayloadHelper({
chunkPayload,
chunkX,
chunkY,
chunkWidth: Number(worldRuntimeState.chunkWidth) || 32,
chunkHeight: Number(worldRuntimeState.chunkHeight) || 32,
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
cloneValue,
runtimeUniqueId,
options,
2026-06-26 18:18:14 -04:00
});
}
2026-06-27 04:36:26 -04:00
function isWorldChunkPayloadEmpty(chunkPayload) {
return isWorldChunkPayloadEmptyHelper({
chunkPayload,
chunkWidth: Number(worldRuntimeState.chunkWidth) || 32,
chunkHeight: Number(worldRuntimeState.chunkHeight) || 32,
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
cloneValue,
runtimeUniqueId,
2026-06-26 18:18:14 -04:00
});
}
function transformWorldChunkPayload(chunkPayload, operation, options) {
2026-06-27 04:36:26 -04:00
return transformWorldChunkPayloadHelper({
chunkPayload,
operation,
chunkWidth: Number(worldRuntimeState.chunkWidth) || 32,
chunkHeight: Number(worldRuntimeState.chunkHeight) || 32,
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
cloneValue,
runtimeUniqueId,
options,
});
2026-06-26 18:18:14 -04:00
}
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(
2026-06-27 04:36:26 -04:00
cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
2026-06-26 18:18:14 -04:00
safeSourceChunkX,
safeSourceChunkY,
);
if (isWorldChunkPayloadEmpty(sourceChunk)) {
setStatus("Move failed: source chunk is empty.", true);
return { ok: false, reason: "source-empty" };
}
const destinationChunk = normalizeCachedWorldChunkPayload(
2026-06-27 04:36:26 -04:00
cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
2026-06-26 18:18:14 -04:00
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(
2026-06-27 04:36:26 -04:00
cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
2026-06-26 18:18:14 -04:00
safeSourceChunkX,
safeSourceChunkY,
);
if (isWorldChunkPayloadEmpty(sourceChunk)) {
setStatus("Duplicate failed: source chunk is empty.", true);
return { ok: false, reason: "source-empty" };
}
const destinationChunk = normalizeCachedWorldChunkPayload(
2026-06-27 04:36:26 -04:00
cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
2026-06-26 18:18:14 -04:00
safeTargetChunkX,
safeTargetChunkY,
);
if (!isWorldChunkPayloadEmpty(destinationChunk)) {
setStatus("Duplicate failed: destination chunk is not empty.", true);
return { ok: false, reason: "destination-occupied" };
}
const duplicatedChunk = normalizeCachedWorldChunkPayload({
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(sourceChunk),
2026-06-26 18:18:14 -04:00
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(
2026-06-27 04:36:26 -04:00
cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
2026-06-26 18:18:14 -04:00
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(
2026-06-27 04:36:26 -04:00
cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
2026-06-26 18:18:14 -04:00
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);
2026-06-27 04:36:26 -04:00
const existingChunk = cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || {
2026-06-26 18:18:14 -04:00
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;
}
2026-06-26 20:30:30 -04:00
function buildCurrentBootstrapSnapshot(): WorldshaperStudioBootstrap {
2026-06-26 18:18:14 -04:00
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),
2026-06-27 04:36:26 -04:00
tileColors: cloneRuntimeValue(tileColors),
2026-06-26 18:18:14 -04:00
baseRows,
npcOverlays: cloneNpcOverlays(mapDocument.npcOverlays),
2026-06-27 04:36:26 -04:00
contentByType: cloneRuntimeValue(mapDocument.contentBundle),
spriteCatalog: cloneRuntimeValue(spriteCatalog),
tileCatalogById: cloneRuntimeValue(tileCatalogById),
defaultNpcTemplate: cloneRuntimeValue(defaultNpcTemplate),
2026-06-26 18:18:14 -04:00
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,
2026-06-27 04:36:26 -04:00
sourceChunks: isWorldModeActive() ? cloneRuntimeValue(worldRuntimeState.sourceChunks) : undefined,
2026-06-26 18:18:14 -04:00
};
}
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) {}
2026-06-26 20:30:30 -04:00
const nextBounds = getCenteredWorldshaperStudioBounds(window);
2026-06-26 18:18:14 -04:00
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() {
2026-06-27 04:36:26 -04:00
return cloneRuntimeValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] };
2026-06-26 18:18:14 -04:00
}
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;
2026-06-27 04:36:26 -04:00
setDocumentContentPayload("images", cloneRuntimeValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] });
setDocumentContentPayload("tiles", cloneRuntimeValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] });
setDocumentContentPayload("sprites", cloneRuntimeValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] });
2026-06-26 18:18:14 -04:00
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({
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(sourceRecord),
2026-06-26 18:18:14 -04:00
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 || "")) : [],
2026-06-27 04:36:26 -04:00
frames: Array.isArray(sourceRecord.frames) ? cloneRuntimeValue(sourceRecord.frames) : [],
tags: Array.isArray(sourceRecord.tags) ? cloneRuntimeValue(sourceRecord.tags) : [],
roles: Array.isArray(sourceRecord.roles) ? cloneRuntimeValue(sourceRecord.roles) : [],
2026-06-26 18:18:14 -04:00
});
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({
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(record),
2026-06-26 18:18:14 -04:00
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({
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(sourceRecord),
2026-06-26 18:18:14 -04:00
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, {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(chunkValue),
2026-06-26 18:18:14 -04:00
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;
}
2026-06-27 04:36:26 -04:00
const nextEntry = cloneRuntimeValue(entry) || {};
2026-06-26 18:18:14 -04:00
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, {
2026-06-27 04:36:26 -04:00
...cloneRuntimeValue(chunkValue),
2026-06-26 18:18:14 -04:00
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() {
2026-06-26 20:30:30 -04:00
const token = createWorldshaperStudioToken();
registerWorldshaperStudioBootstrap(token, buildCurrentBootstrapSnapshot(), window);
const popup = openWorldshaperHeightViewerWindow(currentMapId, token, window);
2026-06-26 18:18:14 -04:00
if (!popup) {
2026-06-26 20:30:30 -04:00
setStatus("Worldshaper Height Viewer unavailable: viewer window was blocked.", true);
2026-06-26 18:18:14 -04:00
return null;
}
window.setTimeout(() => {
2026-06-26 20:30:30 -04:00
clearWorldshaperStudioBootstrap(token, window);
2026-06-26 18:18:14 -04:00
}, 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];
});
2026-06-27 04:36:26 -04:00
Object.assign(target, cloneRuntimeValue(nextValue) || {});
2026-06-26 18:18:14 -04:00
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 {
2026-06-27 04:36:26 -04:00
setDocumentContentPayload(normalizedType, cloneRuntimeValue(payload) || {});
2026-06-26 18:18:14 -04:00
}
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,
2026-06-27 04:36:26 -04:00
getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneRuntimeValue(entry)),
2026-06-26 18:18:14 -04:00
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 persistPopupBounds = () => {
2026-06-26 20:30:30 -04:00
persistWorldshaperStudioBounds(window);
2026-06-26 18:18:14 -04:00
};
2026-06-27 04:36:26 -04:00
const runtimeControllerBootstrap = initializeRuntimeControllers({
scope,
uiScope,
resetWorkspaceLayout,
setStatus,
createNewTile,
createNewSpriteGraphic,
duplicateGraphicRecord,
openTilePaletteContextMenu,
openPlacedEntityContextMenu,
applyNpcEditorChange,
getEditorEngineOverrides,
saveEditorEngineOverrides,
getEffectiveHeightBlurStep,
isRendererDebugEnabled,
reloadGraphicsContentFromApi,
syncDocumentTitle,
syncCanvasDimensionsToTileSize,
refreshEditorEngineOverridesUi,
cacheStandaloneMapBootstrap,
currentMapId,
persistPopupBounds,
popupSessionStore,
windowRef: window,
isWorldModeActive,
getInitialWorldViewTile,
centerViewportOnWorldTile,
prefetchAdjacentWorldNeighborhoods,
worldRuntimeState,
syncWorldNeighborhoodForViewport,
drawNow,
2026-06-26 18:18:14 -04:00
});
2026-06-27 04:36:26 -04:00
renderController = runtimeControllerBootstrap.renderController;
statusLogWindowController = runtimeControllerBootstrap.statusLogWindowController;
runtimeLogging.setStatusLogWindowController(statusLogWindowController);
2026-06-26 18:18:14 -04:00
}
2026-06-26 20:30:30 -04:00
2026-06-27 04:36:26 -04:00