Worldshaper/src/worldshaperStudio/runtime.ts

5494 lines
234 KiB
TypeScript
Raw Normal View History

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