diff --git a/package-lock.json b/package-lock.json index 5f18269..a2709c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "worldshaper", "version": "0.0.3", "dependencies": { + "compression": "^1.8.1", "express": "^4.19.2", "pixi.js": "^8.19.0", "react": "^19.2.6", @@ -1442,6 +1443,60 @@ ], "license": "CC-BY-4.0" }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2869,6 +2924,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index b2444b2..560d895 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "preview": "vite preview" }, "dependencies": { + "compression": "^1.8.1", "express": "^4.19.2", "pixi.js": "^8.19.0", "react": "^19.2.6", @@ -36,4 +37,3 @@ "vite": "^8.0.12" } } - diff --git a/server.js b/server.js index caf42fc..ad59e8a 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ import express from "express"; +import compression from "compression"; import { spawn } from "child_process"; import fs from "fs"; import path from "path"; @@ -999,8 +1000,25 @@ function createDefaultColorCatalogEntries() { }); } +app.use(compression({ threshold: 1024 })); app.use(express.json({ limit: "10mb" })); -app.use(express.static(path.join(__dirname, "dist"))); +app.use(express.static(path.join(__dirname, "dist"), { + etag: true, + setHeaders(res, filePath) { + const normalizedPath = String(filePath || "").replace(/\\/g, "/"); + if (normalizedPath.includes("/dist/assets/")) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return; + } + if (/\.(png|svg|jpg|jpeg|webp|gif|ico)$/i.test(normalizedPath)) { + res.setHeader("Cache-Control", "public, max-age=604800"); + return; + } + if (/\.html$/i.test(normalizedPath)) { + res.setHeader("Cache-Control", "no-cache"); + } + }, +})); function resolveContent(type) { const entry = contentMap[type]; @@ -1325,6 +1343,33 @@ function listWorldChunkFiles(worldId) { .sort((a, b) => a.localeCompare(b)); } +function buildWorldChunkOverviewSurfaceRows(chunkPayload, fallbackWidth, fallbackHeight) { + const width = Math.max(1, Number(chunkPayload?.width) || Number(fallbackWidth) || DEFAULT_WORLD_CHUNK_SIZE); + const height = Math.max(1, Number(chunkPayload?.height) || Number(fallbackHeight) || DEFAULT_WORLD_CHUNK_SIZE); + const sortedRoomLayers = Array.isArray(chunkPayload?.roomLayers) + ? chunkPayload.roomLayers + .slice() + .sort((left, right) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0)) + : []; + return Array.from({ length: height }, (_entry, rowIndex) => { + let row = ""; + for (let columnIndex = 0; columnIndex < width; columnIndex += 1) { + let resolvedSymbol = "."; + sortedRoomLayers.forEach((layer) => { + const layerNumber = Number(layer?.layer) || 0; + const fillChar = layerNumber === 0 ? "." : " "; + const sourceRow = Array.isArray(layer?.rows) ? String(layer.rows[rowIndex] || "") : ""; + const symbol = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || fillChar; + if (symbol !== "." && symbol !== " ") { + resolvedSymbol = symbol; + } + }); + row += resolvedSymbol; + } + return row; + }); +} + function countSymbolOccurrencesInRows(rows, targetSymbol) { const normalizedTarget = String(targetSymbol || "").charAt(0); if (!normalizedTarget) { @@ -3505,6 +3550,19 @@ app.get("/api/world/:worldId/overview", (req, res) => { const chunks = chunkCoords .map((coord) => readWorldChunkPayload(worldId, coord.chunkX, coord.chunkY, { createIfMissing: false })) .filter(Boolean); + const overviewChunks = chunks.map((chunk) => { + const width = Math.max(1, Number(chunk.width) || Number(worldDefinition.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE); + const height = Math.max(1, Number(chunk.height) || Number(worldDefinition.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE); + return { + chunkX: Math.floor(Number(chunk.chunkX) || 0), + chunkY: Math.floor(Number(chunk.chunkY) || 0), + width, + height, + backgroundTileId: String(chunk.backgroundTileId || "").trim(), + surfaceRows: buildWorldChunkOverviewSurfaceRows(chunk, width, height), + instanceCount: Array.isArray(chunk.instances) ? chunk.instances.length : 0, + }; + }); const chunkWidth = Math.max(1, Number(worldDefinition.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE); const chunkHeight = Math.max(1, Number(worldDefinition.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE); const minChunkX = chunks.length > 0 ? Math.min(...chunks.map((chunk) => Math.floor(Number(chunk.chunkX) || 0))) : 0; @@ -3524,8 +3582,8 @@ app.get("/api/world/:worldId/overview", (req, res) => { maxTileX: ((maxChunkX + 1) * chunkWidth) - 1, maxTileY: ((maxChunkY + 1) * chunkHeight) - 1, }, - chunkCount: chunks.length, - chunks, + chunkCount: overviewChunks.length, + chunks: overviewChunks, }); } catch (err) { res.status(500).json({ diff --git a/src/worldshaperStudio/historyController.ts b/src/worldshaperStudio/historyController.ts index 1be408c..7c1f101 100644 --- a/src/worldshaperStudio/historyController.ts +++ b/src/worldshaperStudio/historyController.ts @@ -1,24 +1,14 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-nocheck export function createHistoryController(scope) { const documentScope = scope.documentScope || scope; const renderScope = scope.renderScope || scope; - const historyScope = scope.historyScope || scope; const uiScope = scope.uiScope || scope; const sessionScope = scope.sessionScope || scope; - const MAX_HISTORY_ENTRIES = 40; - const MAX_PERSISTED_HISTORY_CHARS = 1_500_000; - const OPERATION_CHECKPOINT_INTERVAL = 12; + const MAX_HISTORY_ENTRIES = 2; let pendingPersistTimer = 0; - function cloneValue(value) { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - return value == null ? value : JSON.parse(JSON.stringify(value)); - } - function clearPendingPersistTimer() { if (!pendingPersistTimer) { return; @@ -29,102 +19,26 @@ export function createHistoryController(scope) { function persistHistoryState() { clearPendingPersistTimer(); - try { - const savedIndex = Math.max(0, Math.min( - scope.historyEntries.findIndex((entry) => Number(entry?.id) === Number(historyScope.lastSavedHistoryId)), - scope.historyEntries.length - 1, - )); - const savedState = scope.historyEntries.length > 0 - ? captureHistoryStateAtIndex(savedIndex >= 0 ? savedIndex : scope.historyIndex) - : captureState(); - const payload = { - mapId: String(documentScope.mapId || scope.mapId || ""), - savedStateSignature: getStateSignature(savedState), - historyEntries: historyScope.historyEntries, - historyIndex: historyScope.historyIndex, - historySelectionIndex: historyScope.historySelectionIndex, - nextHistoryId: historyScope.nextHistoryId, - lastSavedHistoryId: historyScope.lastSavedHistoryId, - }; - const serialized = JSON.stringify(payload); - if (serialized.length > MAX_PERSISTED_HISTORY_CHARS) { - window.localStorage.removeItem(historyScope.historyStorageKey); - return false; - } - window.localStorage.setItem(historyScope.historyStorageKey, serialized); - return true; - } catch {} - return false; + return true; } function schedulePersistHistoryState() { clearPendingPersistTimer(); - pendingPersistTimer = window.setTimeout(() => { - pendingPersistTimer = 0; - persistHistoryState(); - }, 120); return true; } function restoreHistoryState() { - try { - const raw = window.localStorage.getItem(historyScope.historyStorageKey); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - return null; - } - return parsed; - } catch { - return null; - } + return null; } - function applyHistorySnapshot(snapshot) { - if (!snapshot || typeof snapshot !== "object") { - return false; - } - const snapshotMapId = String(snapshot.mapId || "").trim(); - const currentMapId = String(documentScope.mapId || scope.mapId || "").trim(); - if (snapshotMapId && currentMapId && snapshotMapId !== currentMapId) { - return false; - } - const entries = Array.isArray(snapshot.historyEntries) ? snapshot.historyEntries : null; - if (!entries || entries.length === 0) { - return false; - } - const savedId = Number(snapshot.lastSavedHistoryId) || 0; - const currentId = Number(snapshot.historyEntries?.[Number(snapshot.historyIndex) || 0]?.id) || 0; - if (!savedId || !currentId || savedId !== currentId) { - return false; - } - const savedStateSignature = String(snapshot.savedStateSignature || "").trim(); - if (savedStateSignature) { - const currentLoadedSignature = getStateSignature(captureState()); - if (savedStateSignature !== currentLoadedSignature) { - return false; - } - } - historyScope.historyEntries = entries; - historyScope.historyIndex = Math.max(0, Math.min(Number(snapshot.historyIndex) || 0, historyScope.historyEntries.length - 1)); - historyScope.historySelectionIndex = Math.max(0, Math.min(Number(snapshot.historySelectionIndex) || historyScope.historyIndex, historyScope.historyEntries.length - 1)); - historyScope.nextHistoryId = Math.max(1, Number(snapshot.nextHistoryId) || (historyScope.historyEntries[historyScope.historyEntries.length - 1]?.seq || 0) + 1); - historyScope.lastSavedHistoryId = Math.max(1, Number(snapshot.lastSavedHistoryId) || historyScope.historyEntries[historyScope.historyIndex]?.id || 1); - if (!restoreToHistoryIndex(historyScope.historyIndex)) { - const currentState = historyScope.historyEntries[historyScope.historyIndex] && historyScope.historyEntries[historyScope.historyIndex].state ? historyScope.historyEntries[historyScope.historyIndex].state : null; - if (currentState) { - applyState(currentState); - } - } - return true; + function applyHistorySnapshot() { + return false; } function captureState() { return { - width: Number(documentScope.width) || 1, - height: Number(documentScope.height) || 1, + width: Math.max(1, Number(documentScope.width) || 1), + height: Math.max(1, Number(documentScope.height) || 1), mapName: String(documentScope.mapName || scope.mapId || ""), backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor), backgroundTileId: String(documentScope.backgroundTileId || "").trim(), @@ -142,6 +56,35 @@ export function createHistoryController(scope) { }; } + function cloneHistoryState(state) { + if (!state || typeof state !== "object") { + return captureState(); + } + return { + width: Math.max(1, Number(state.width) || 1), + height: Math.max(1, Number(state.height) || 1), + mapName: String(state.mapName || scope.mapId || ""), + backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor), + backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId), + heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)), + layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []), + heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []), + npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []), + worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds) + ? { ...state.worldChunkBackgrounds } + : {}, + worldBookmarks: typeof scope.applyWorldBookmarkState === "function" && Array.isArray(state?.worldBookmarks) + ? state.worldBookmarks.map((entry) => ({ + id: String(entry?.id || "").trim(), + label: String(entry?.label || entry?.id || "").trim(), + x: Math.floor(Number(entry?.x) || 0), + y: Math.floor(Number(entry?.y) || 0), + })) + : [], + editorUi: documentScope.cloneEditorUiState(state.editorUi || {}), + }; + } + function refreshUiAfterHistoryMutation() { documentScope.ensureBaseLayer(); sessionScope.activeLayer = documentScope.roomLayers.some((layer) => layer.layer === sessionScope.activeLayer) ? sessionScope.activeLayer : 0; @@ -185,9 +128,9 @@ export function createHistoryController(scope) { documentScope.backgroundColor = documentScope.normalizeMapBackgroundColor(state?.backgroundColor || documentScope.backgroundColor); documentScope.backgroundTileId = documentScope.normalizeBackgroundTileId(state?.backgroundTileId); documentScope.heightBlurStep = Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || documentScope.heightBlurStep || documentScope.heightDetailStep || 0.1)); - documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []); - documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []); - const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []); + documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state?.layers) ? state.layers : []); + documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state?.heightLayers) ? state.heightLayers : []); + const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : []); sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} }; if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) { sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim(); @@ -202,7 +145,7 @@ export function createHistoryController(scope) { scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {}); } if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) { - scope.applyWorldBookmarkState(state?.worldBookmarks || []); + scope.applyWorldBookmarkState(Array.isArray(state?.worldBookmarks) ? state.worldBookmarks : []); } if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { scope.rebuildVisibleWorldChunksFromDocument(); @@ -212,388 +155,25 @@ export function createHistoryController(scope) { } } - function ensureLayerForOperation(layerNumber) { - const normalizedLayer = Number(layerNumber) || 0; - let layerEntry = scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || null; - if (layerEntry) { - return layerEntry; - } - layerEntry = { - layer: normalizedLayer, - name: undefined, - zIndex: 0, - rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "), - instanceIds: [], - }; - scope.roomLayers.push(layerEntry); - scope.roomLayers = scope.roomLayers - .slice() - .sort((left, right) => Number(left.layer) - Number(right.layer)); - return scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry; - } - - function setStoredTileCharAt(layerNumber, tileX, tileY, nextStoredChar) { - if (tileX < 0 || tileX >= scope.width || tileY < 0 || tileY >= scope.height) { - return false; - } - const normalizedLayer = Number(layerNumber) || 0; - const layerEntry = ensureLayerForOperation(normalizedLayer); - const fillChar = normalizedLayer === 0 ? "." : " "; - const rows = scope.normalizeRows(layerEntry.rows, fillChar); - const row = rows[tileY] || fillChar.repeat(scope.width); - const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar; - if ((row.charAt(tileX) || fillChar) === safeChar) { - return false; - } - rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1); - layerEntry.rows = rows; - if (typeof scope.syncWorldChunkCellFromLocalTile === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { - scope.syncWorldChunkCellFromLocalTile(normalizedLayer, tileX, tileY, safeChar); - } - return true; - } - - function resolveTileOperationCellCoord(cell) { - if (typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { - const worldX = Number(cell?.worldX); - const worldY = Number(cell?.worldY); - if (Number.isFinite(worldX) && Number.isFinite(worldY)) { - return { - x: Math.floor(worldX - (Number(scope.worldTileOffsetX) || 0)), - y: Math.floor(worldY - (Number(scope.worldTileOffsetY) || 0)), - }; - } - } - return { - x: Math.floor(Number(cell?.x) || 0), - y: Math.floor(Number(cell?.y) || 0), - }; - } - - function applyTileCellsOperation(operation, direction) { - const isRedo = direction !== "undo"; - const nextBackgroundTileId = isRedo - ? operation.afterBackgroundTileId - : operation.beforeBackgroundTileId; - if (nextBackgroundTileId !== undefined) { - scope.backgroundTileId = scope.normalizeBackgroundTileId(nextBackgroundTileId); - } - const cells = Array.isArray(operation.cells) ? operation.cells : []; - cells.forEach((cell) => { - const resolvedCoord = resolveTileOperationCellCoord(cell, scope.width, scope.height); - const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar; - setStoredTileCharAt(cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar); - }); - if (nextBackgroundTileId !== undefined && typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { - scope.rebuildVisibleWorldChunksFromDocument(); - } - scope.invalidateTileSurface(); - } - - function buildNpcTargetEntries(operation, direction) { - const useAfter = direction !== "undo"; - const rawEntries = Array.isArray(operation.entries) ? operation.entries : []; - return rawEntries - .map((entry) => { - const snapshot = useAfter ? entry.after : entry.before; - const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex; - if (!snapshot || typeof snapshot !== "object") { - return null; - } - const cloned = scope.cloneNpcOverlays([cloneValue(snapshot)])[0]; - if (!cloned) { - return null; - } - scope.syncNpcOverlayFromRecord(cloned); - return { - npc: cloned, - index: Math.max(0, Number(targetIndex) || 0), - }; - }) - .filter((entry) => entry !== null) - .sort((left, right) => left.index - right.index); - } - - function applyNpcEntriesOperation(operation, direction) { - const rawEntries = Array.isArray(operation.entries) ? operation.entries : []; - const touchedPositions = []; - rawEntries.forEach((entry) => { - const beforePos = entry?.before && typeof entry.before === "object" ? entry.before : null; - const afterPos = entry?.after && typeof entry.after === "object" ? entry.after : null; - if (beforePos) { - const x = Math.floor(Number(beforePos.x)); - const y = Math.floor(Number(beforePos.y)); - if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) { - touchedPositions.push({ x, y }); - } - } - if (afterPos) { - const x = Math.floor(Number(afterPos.x)); - const y = Math.floor(Number(afterPos.y)); - if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) { - touchedPositions.push({ x, y }); - } - } - }); - const affectedIds = new Set( - rawEntries.flatMap((entry) => { - const ids = []; - const beforeId = String(entry?.before?.id || "").trim(); - const afterId = String(entry?.after?.id || "").trim(); - if (beforeId) { - ids.push(beforeId); - } - if (afterId) { - ids.push(afterId); - } - return ids; - }), - ); - if (affectedIds.size === 0) { - return; - } - const remainingNpcs = scope.npcOverlays.filter((npc) => !affectedIds.has(String(npc.id || "").trim())); - scope.npcOverlays.length = 0; - remainingNpcs.forEach((npc) => scope.npcOverlays.push(npc)); - affectedIds.forEach((npcId) => { - delete scope.npcImages[npcId]; - }); - const targetEntries = buildNpcTargetEntries(operation, direction); - targetEntries.forEach((entry) => { - const nextIndex = Math.max(0, Math.min(scope.npcOverlays.length, entry.index)); - scope.ensureNpcImageLoaded(entry.npc); - scope.npcOverlays.splice(nextIndex, 0, entry.npc); - }); - if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive() && touchedPositions.length > 0) { - const xs = touchedPositions.map((entry) => entry.x); - const ys = touchedPositions.map((entry) => entry.y); - scope.rebuildWorldChunksForLocalBounds({ - minX: Math.min(...xs), - minY: Math.min(...ys), - maxX: Math.max(...xs), - maxY: Math.max(...ys), - }); - } - } - - function applyOperation(operation, direction, options) { - const config = options && typeof options === "object" ? options : {}; - if (!operation || typeof operation !== "object") { - return false; - } - if (operation.type === "tile_cells") { - applyTileCellsOperation(operation, direction); - } else if (operation.type === "npc_entries") { - applyNpcEntriesOperation(operation, direction); - } else { - return false; - } - if (!config.deferRefresh) { - refreshUiAfterHistoryMutation(); - } - return true; - } - - function cloneHistoryState(state) { - if (!state || typeof state !== "object") { - return captureState(); - } - return { - width: Math.max(1, Number(state.width) || 1), - height: Math.max(1, Number(state.height) || 1), - mapName: String(state.mapName || scope.mapId || ""), - backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor), - backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId), - heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)), - layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []), - heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []), - npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []), - editorUi: documentScope.cloneEditorUiState(state.editorUi || {}), - }; - } - - function ensureLayerForStateOperation(state, layerNumber) { - const normalizedLayer = Number(layerNumber) || 0; - let layerEntry = state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || null; - if (layerEntry) { - return layerEntry; - } - layerEntry = { - layer: normalizedLayer, - name: undefined, - zIndex: 0, - rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "), - instanceIds: [], - }; - state.layers.push(layerEntry); - state.layers = state.layers - .slice() - .sort((left, right) => Number(left.layer) - Number(right.layer)); - return state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry; - } - - function setStoredTileCharAtInState(state, layerNumber, tileX, tileY, nextStoredChar) { - if (tileX < 0 || tileX >= state.width || tileY < 0 || tileY >= state.height) { - return false; - } - const normalizedLayer = Number(layerNumber) || 0; - const layerEntry = ensureLayerForStateOperation(state, normalizedLayer); - const fillChar = normalizedLayer === 0 ? "." : " "; - const rows = scope.normalizeRows(layerEntry.rows, fillChar); - const row = rows[tileY] || fillChar.repeat(state.width); - const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar; - if ((row.charAt(tileX) || fillChar) === safeChar) { - return false; - } - rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1); - layerEntry.rows = rows; - return true; - } - - function applyTileCellsOperationToState(state, operation, direction) { - const nextState = cloneHistoryState(state); - const isRedo = direction !== "undo"; - const nextBackgroundTileId = isRedo - ? operation.afterBackgroundTileId - : operation.beforeBackgroundTileId; - if (nextBackgroundTileId !== undefined) { - nextState.backgroundTileId = documentScope.normalizeBackgroundTileId(nextBackgroundTileId); - } - const cells = Array.isArray(operation.cells) ? operation.cells : []; - cells.forEach((cell) => { - const resolvedCoord = resolveTileOperationCellCoord(cell, nextState.width, nextState.height); - const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar; - setStoredTileCharAtInState(nextState, cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar); - }); - return nextState; - } - - function applyNpcEntriesOperationToState(state, operation, direction) { - const nextState = cloneHistoryState(state); - const rawEntries = Array.isArray(operation.entries) ? operation.entries : []; - const affectedIds = new Set( - rawEntries.flatMap((entry) => { - const ids = []; - const beforeId = String(entry?.before?.id || "").trim(); - const afterId = String(entry?.after?.id || "").trim(); - if (beforeId) { - ids.push(beforeId); - } - if (afterId) { - ids.push(afterId); - } - return ids; - }), - ); - if (affectedIds.size === 0) { - return nextState; - } - const useAfter = direction !== "undo"; - const remainingNpcs = nextState.npcs.filter((npc) => !affectedIds.has(String(npc.id || "").trim())); - const targetEntries = rawEntries - .map((entry) => { - const snapshot = useAfter ? entry.after : entry.before; - const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex; - if (!snapshot || typeof snapshot !== "object") { - return null; - } - const cloned = documentScope.cloneNpcOverlays([cloneValue(snapshot)])[0]; - if (!cloned) { - return null; - } - documentScope.syncNpcOverlayFromRecord(cloned); - return { - npc: cloned, - index: Math.max(0, Number(targetIndex) || 0), - }; - }) - .filter((entry) => entry !== null) - .sort((left, right) => left.index - right.index); - nextState.npcs = remainingNpcs; - targetEntries.forEach((entry) => { - const nextIndex = Math.max(0, Math.min(nextState.npcs.length, entry.index)); - nextState.npcs.splice(nextIndex, 0, entry.npc); - }); - return nextState; - } - - function captureHistoryStateAtIndex(targetIndex) { - const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1)); - const targetEntry = scope.historyEntries[normalizedTargetIndex] || null; - if (!targetEntry) { - return captureState(); - } - if (targetEntry.state) { - return cloneHistoryState(targetEntry.state); - } - const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex); - if (snapshotIndex < 0) { - return captureState(); - } - let nextState = cloneHistoryState(scope.historyEntries[snapshotIndex].state); - for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) { - const entry = scope.historyEntries[index]; - if (!entry) { - continue; - } - if (entry.state) { - nextState = cloneHistoryState(entry.state); - continue; - } - if (!entry.operation || typeof entry.operation !== "object") { - continue; - } - if (entry.operation.type === "tile_cells") { - nextState = applyTileCellsOperationToState(nextState, entry.operation, "redo"); - } else if (entry.operation.type === "npc_entries") { - nextState = applyNpcEntriesOperationToState(nextState, entry.operation, "redo"); - } - } - return nextState; - } - - function findNearestSnapshotIndex(targetIndex) { - for (let index = Math.max(0, Number(targetIndex) || 0); index >= 0; index -= 1) { - if (scope.historyEntries[index] && scope.historyEntries[index].state) { - return index; - } - } - return -1; - } - function restoreToHistoryIndex(targetIndex) { const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1)); - const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex); - if (snapshotIndex < 0) { + const targetEntry = scope.historyEntries[normalizedTargetIndex] || null; + if (!targetEntry?.state) { return false; } - applyState(scope.historyEntries[snapshotIndex].state, { deferRefresh: true }); - for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) { - const entry = scope.historyEntries[index]; - if (!entry) { - continue; - } - if (entry.state) { - applyState(entry.state, { deferRefresh: true }); - continue; - } - if (entry.operation) { - applyOperation(entry.operation, "redo", { deferRefresh: true }); - } - } - refreshUiAfterHistoryMutation(); + applyState(targetEntry.state); return true; } function getStateSignature(state) { - const layerSig = scope.cloneLayers(state.layers) - .sort((a, b) => a.layer - b.layer) + const layerSig = scope.cloneLayers(Array.isArray(state?.layers) ? state.layers : []) + .sort((a, b) => Number(a.layer) - Number(b.layer)) .map((layer) => ({ - layer: layer.layer, + layer: Number(layer.layer) || 0, name: typeof layer.name === "string" ? layer.name : "", - rows: scope.normalizeRows(layer.rows, layer.layer === 0 ? "." : " "), + rows: scope.normalizeRows(layer.rows, Number(layer.layer) === 0 ? "." : " "), })); - const heightLayerSig = scope.cloneHeightLayers(state.heightLayers) + const heightLayerSig = scope.cloneHeightLayers(Array.isArray(state?.heightLayers) ? state.heightLayers : []) .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))) .map((entry) => ({ id: String(entry.id || ""), @@ -603,30 +183,38 @@ export function createHistoryController(scope) { y: Number(entry.y) || 0, rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [], })); - const npcSig = scope.cloneNpcOverlays(state.npcs) - .sort((a, b) => a.id.localeCompare(b.id)) + const npcSig = scope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : []) + .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))) .map((entry) => ({ - id: entry.id, + id: String(entry.id || ""), layer: Number(entry.layer) || 0, - name: entry.name, - spriteId: entry.spriteId, - x: entry.x, - y: entry.y, + name: String(entry.name || ""), + spriteId: String(entry.spriteId || ""), + x: Number(entry.x) || 0, + y: Number(entry.y) || 0, })); return JSON.stringify({ - width: Number(state.width) || 1, - height: Number(state.height) || 1, - mapName: String(state.mapName || ""), - backgroundColor: scope.normalizeMapBackgroundColor(state.backgroundColor), - backgroundTileId: scope.normalizeBackgroundTileId(state.backgroundTileId), - heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)), + width: Math.max(1, Number(state?.width) || 1), + height: Math.max(1, Number(state?.height) || 1), + mapName: String(state?.mapName || ""), + backgroundColor: scope.normalizeMapBackgroundColor(state?.backgroundColor), + backgroundTileId: scope.normalizeBackgroundTileId(state?.backgroundTileId), + heightBlurStep: Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || 0.1)), layerSig, heightLayerSig, npcSig, worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds) ? state.worldChunkBackgrounds : {}, - editorUi: scope.cloneEditorUiState(state.editorUi || {}), + worldBookmarks: Array.isArray(state?.worldBookmarks) + ? state.worldBookmarks.map((entry) => ({ + id: String(entry?.id || ""), + label: String(entry?.label || ""), + x: Number(entry?.x) || 0, + y: Number(entry?.y) || 0, + })) + : [], + editorUi: scope.cloneEditorUiState(state?.editorUi || {}), }); } @@ -642,38 +230,51 @@ export function createHistoryController(scope) { } function renderHistoryPreview() { - const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null; - if (!selectedEntry) { - scope.historyPreviewEl.innerHTML = '

Change Preview

Select a history entry to inspect it.
'; + if (!scope.historyPreviewEl) { + return; + } + const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null; + if (!selectedEntry) { + scope.historyPreviewEl.innerHTML = '

Undo / Redo

Single-step history is empty.
'; return; } - const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : []; const detailHtml = details.length > 0 ? "" : '
No additional details recorded.
'; - const currentText = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Selected step " + selectedEntry.seq; + const title = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Single undo step"; scope.historyPreviewEl.innerHTML = - "

" + currentText + "

" + + "

" + title + "

" + '
' + formatHistoryLabel(selectedEntry) + "
" + detailHtml + - ''; - - const nextJumpBtn = document.getElementById("jumpHistoryBtn"); - nextJumpBtn.disabled = scope.isSaving || scope.historySelectionIndex === scope.historyIndex; - nextJumpBtn.addEventListener("click", () => { - if (scope.historySelectionIndex === scope.historyIndex) { - return; - } - scope.historyIndex = scope.historySelectionIndex; - restoreToHistoryIndex(scope.historyIndex); - refreshToolbarState(); - scope.setStatus("Restored to history step " + scope.historyEntries[scope.historyIndex].seq + ".", false); - }); + '
Only one undo / redo step is kept in memory.
'; } function renderHistoryList() { - scope.historyListEl.innerHTML = ""; + if (scope.historyListEl) { + scope.historyListEl.innerHTML = ""; + scope.historyEntries.forEach((entry, index) => { + if (index === scope.historyIndex) { + return; + } + const row = document.createElement("button"); + row.type = "button"; + row.className = "history-row" + (index === scope.historySelectionIndex ? " active" : ""); + const timeText = new Date(entry.createdAt).toLocaleTimeString(); + row.innerHTML = + "" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "" + + '' + timeText + ""; + row.addEventListener("click", () => { + if (index === scope.historySelectionIndex) { + return; + } + scope.historySelectionIndex = index; + renderHistoryList(); + renderHistoryPreview(); + }); + scope.historyListEl.appendChild(row); + }); + } if (scope.historyCurrentEl) { const currentEntry = scope.historyEntries[scope.historyIndex] || null; scope.historyCurrentEl.innerHTML = currentEntry @@ -686,34 +287,13 @@ export function createHistoryController(scope) { ) : '
Current State
No history yet.
'; } - scope.historyEntries.forEach((entry, index) => { - if (index === scope.historyIndex) { - return; - } - const row = document.createElement("button"); - row.type = "button"; - row.className = "history-row" + (index === scope.historySelectionIndex ? " active" : ""); - const timeText = new Date(entry.createdAt).toLocaleTimeString(); - row.innerHTML = - "" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "" + - '' + timeText + ""; - row.addEventListener("click", () => { - if (index === scope.historySelectionIndex) { - return; - } - scope.historySelectionIndex = index; - renderHistoryList(); - renderHistoryPreview(); - }); - scope.historyListEl.appendChild(row); - }); } function refreshToolbarState(preserveCurrentStatus) { const canUndo = scope.historyIndex > 0; const canRedo = scope.historyIndex < scope.historyEntries.length - 1; - const currentHistoryId = scope.historyEntries[scope.historyIndex] ? scope.historyEntries[scope.historyIndex].id : 0; - const isDirtyFromSaved = currentHistoryId !== scope.lastSavedHistoryId; + const currentHistoryId = scope.historyEntries[scope.historyIndex] ? Number(scope.historyEntries[scope.historyIndex].id) || 0 : 0; + const isDirtyFromSaved = currentHistoryId !== (Number(scope.lastSavedHistoryId) || 0); scope.undoBtn.disabled = scope.isSaving || !canUndo; scope.redoBtn.disabled = scope.isSaving || !canRedo; @@ -725,13 +305,12 @@ export function createHistoryController(scope) { if (preserveCurrentStatus) { return; } - if (scope.isSaving) { scope.setStatus("Saving...", false); } else if (canRedo) { - scope.setStatus("History branch active. New edits will replace future steps.", false); + scope.setStatus("Redo is available. A new edit will replace it.", false); } else if (isDirtyFromSaved) { - scope.setStatus("Unsaved history changes.", false); + scope.setStatus("Unsaved changes.", false); } else { scope.setStatus("All changes saved.", false); } @@ -739,20 +318,10 @@ export function createHistoryController(scope) { function registerHistory(label, before, after, details, options) { const config = options && typeof options === "object" ? options : {}; - const operation = config.operation ? cloneValue(config.operation) : null; - if (operation && operation.type === "tile_cells" && (!Array.isArray(operation.cells) || operation.cells.length === 0)) { - return; - } - if (operation && operation.type === "npc_entries" && (!Array.isArray(operation.entries) || operation.entries.length === 0)) { - return; - } - const shouldStoreOperationOnly = Boolean(operation); - const nextState = shouldStoreOperationOnly ? null : (config.nextState || captureState()); + const nextState = cloneHistoryState(config.nextState || captureState()); const currentEntry = scope.historyEntries[scope.historyIndex] || null; - const currentState = currentEntry && currentEntry.state - ? currentEntry.state - : captureHistoryStateAtIndex(scope.historyIndex); - if (!config.skipStateCheck && nextState && currentState && getStateSignature(nextState) === getStateSignature(currentState)) { + const currentState = currentEntry?.state ? cloneHistoryState(currentEntry.state) : captureState(); + if (!config.skipStateCheck && getStateSignature(nextState) === getStateSignature(currentState)) { return; } @@ -760,25 +329,6 @@ export function createHistoryController(scope) { scope.historyEntries = scope.historyEntries.slice(0, scope.historyIndex + 1); } - let operationEntriesSinceSnapshot = 0; - if (shouldStoreOperationOnly) { - for (let index = scope.historyEntries.length - 1; index >= 0; index -= 1) { - const entry = scope.historyEntries[index]; - if (!entry) { - continue; - } - if (entry.state) { - break; - } - if (entry.operation) { - operationEntriesSinceSnapshot += 1; - } - } - } - const checkpointState = shouldStoreOperationOnly && operationEntriesSinceSnapshot + 1 >= OPERATION_CHECKPOINT_INTERVAL - ? captureState() - : null; - const entry = { id: scope.nextHistoryId, seq: scope.nextHistoryId, @@ -787,47 +337,47 @@ export function createHistoryController(scope) { before, after, details: Array.isArray(details) ? details : [], - state: nextState || checkpointState, - operation, + state: nextState, }; scope.nextHistoryId += 1; - scope.historyEntries.push(entry); + if (scope.historyEntries.length > MAX_HISTORY_ENTRIES) { + scope.historyEntries = scope.historyEntries.slice(scope.historyEntries.length - MAX_HISTORY_ENTRIES); + } scope.historyIndex = scope.historyEntries.length - 1; scope.historySelectionIndex = scope.historyIndex; - if (scope.historyEntries.length > MAX_HISTORY_ENTRIES) { - const trimmedCount = scope.historyEntries.length - MAX_HISTORY_ENTRIES; - scope.historyEntries = scope.historyEntries.slice(trimmedCount); - scope.historyIndex = Math.max(0, scope.historyIndex - trimmedCount); - scope.historySelectionIndex = Math.max(0, scope.historySelectionIndex - trimmedCount); - } - schedulePersistHistoryState(); refreshToolbarState(); } function undo() { if (scope.historyIndex <= 0) { - return; + return false; } scope.historyIndex -= 1; scope.historySelectionIndex = scope.historyIndex; - restoreToHistoryIndex(scope.historyIndex); + const restored = restoreToHistoryIndex(scope.historyIndex); schedulePersistHistoryState(); refreshToolbarState(); - scope.setStatus("Undo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false); + if (restored) { + scope.setStatus("Undid the last change.", false); + } + return restored; } function redo() { if (scope.historyIndex >= scope.historyEntries.length - 1) { - return; + return false; } scope.historyIndex += 1; scope.historySelectionIndex = scope.historyIndex; - restoreToHistoryIndex(scope.historyIndex); + const restored = restoreToHistoryIndex(scope.historyIndex); schedulePersistHistoryState(); refreshToolbarState(); - scope.setStatus("Redo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false); + if (restored) { + scope.setStatus("Redid the last change.", false); + } + return restored; } return { @@ -837,7 +387,7 @@ export function createHistoryController(scope) { applyHistorySnapshot, captureState, applyState, - applyOperation, + applyOperation: () => false, restoreToHistoryIndex, getStateSignature, formatCellCoord, diff --git a/src/worldshaperStudio/runtime.ts b/src/worldshaperStudio/runtime.ts index 3c6c8d0..3105e4d 100644 --- a/src/worldshaperStudio/runtime.ts +++ b/src/worldshaperStudio/runtime.ts @@ -4513,11 +4513,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in scope.historySelectionIndex = 0; scope.lastSavedHistoryId = initialEntry.id; scope.nextHistoryId = initialEntry.id + 1; - - const restoredHistory = scope.restoreHistoryState(); - if (!scope.applyHistorySnapshot(restoredHistory)) { - scope.persistHistoryState(); - } + try { + if (currentHistoryStorageKey) { + window.localStorage.removeItem(currentHistoryStorageKey); + } + } catch {} + scope.persistHistoryState(); } function refreshUiForLoadedMap() { diff --git a/src/worldshaperStudio/worldOverviewWindowController.ts b/src/worldshaperStudio/worldOverviewWindowController.ts index 7cd468a..7346af1 100644 --- a/src/worldshaperStudio/worldOverviewWindowController.ts +++ b/src/worldshaperStudio/worldOverviewWindowController.ts @@ -46,7 +46,9 @@ type OverviewChunk = { height?: number; backgroundTileId?: string; roomLayers?: OverviewRoomLayer[]; + surfaceRows?: string[]; instances?: unknown[]; + instanceCount?: number; }; type OverviewPayload = { @@ -860,7 +862,9 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) { } function beginPendingChunkAction(type: "move" | "duplicate", chunkRect: ChunkWorldRect, chunkKey: string, chunk?: OverviewChunk | null) { - const entityCount = Array.isArray(chunk?.instances) ? chunk.instances.length : 0; + const entityCount = Array.isArray(chunk?.instances) + ? chunk.instances.length + : Math.max(0, Math.floor(Number(chunk?.instanceCount) || 0)); if (type === "move" && entityCount > 0) { const confirmed = window.confirm( "Move chunk " + chunkRect.chunkX + "," + chunkRect.chunkY + "?\n\nThis will also move " + entityCount + " placed entit" + (entityCount === 1 ? "y" : "ies") + ".", @@ -1395,13 +1399,15 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) { return String(entry?.symbol || ".").charAt(0) || "."; } - function getTopVisibleSymbol(chunk: OverviewChunk | null | undefined, localX: number, localY: number) { + function getTopVisibleSymbol( + chunk: OverviewChunk | null | undefined, + sortedRoomLayers: OverviewRoomLayer[], + localX: number, + localY: number, + ) { const backgroundSymbol = getBackgroundSymbol(chunk); - const roomLayers = Array.isArray(chunk?.roomLayers) - ? chunk.roomLayers.slice().sort((left: OverviewRoomLayer, right: OverviewRoomLayer) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0)) - : []; let resolvedSymbol = backgroundSymbol || ""; - roomLayers.forEach((layer: OverviewRoomLayer) => { + sortedRoomLayers.forEach((layer: OverviewRoomLayer) => { const layerNumber = Number(layer?.layer) || 0; const fillChar = layerNumber === 0 ? "." : " "; const row = Array.isArray(layer?.rows) ? String(layer.rows[localY] || "") : ""; @@ -1433,7 +1439,8 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) { } function buildChunkSurfaceSignature(chunk: OverviewChunk | null | undefined) { - const roomLayerSig = (Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []) + const surfaceSig = Array.isArray(chunk?.surfaceRows) ? chunk.surfaceRows.join("|") : ""; + const roomLayerSig = surfaceSig || (Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []) .map((layer: OverviewRoomLayer) => String(Number(layer?.layer) || 0) + ":" + (Array.isArray(layer?.rows) ? layer.rows.join("|") : "")) .join("~"); return [ @@ -1459,10 +1466,18 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) { canvas.height = chunkHeight; const ctx = canvas.getContext("2d"); if (ctx) { + const surfaceRows = Array.isArray(chunk?.surfaceRows) ? chunk.surfaceRows : null; + const sortedRoomLayers = Array.isArray(chunk?.roomLayers) + ? chunk.roomLayers.slice().sort((left: OverviewRoomLayer, right: OverviewRoomLayer) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0)) + : []; const imageData = ctx.createImageData(chunkWidth, chunkHeight); for (let localY = 0; localY < chunkHeight; localY += 1) { + const surfaceRow = surfaceRows ? String(surfaceRows[localY] || "") : ""; for (let localX = 0; localX < chunkWidth; localX += 1) { - const color = getSymbolColor(getTopVisibleSymbol(chunk, localX, localY), chunk); + const visibleSymbol = surfaceRow + ? (String(surfaceRow.charAt(localX) || ".").charAt(0) || ".") + : getTopVisibleSymbol(chunk, sortedRoomLayers, localX, localY); + const color = getSymbolColor(visibleSymbol, chunk); const pixelIndex = (localY * chunkWidth + localX) * 4; imageData.data[pixelIndex] = color[0]; imageData.data[pixelIndex + 1] = color[1];