Improve VPS delivery and simplify editor history
This commit is contained in:
parent
0074f0e10c
commit
17c64e1fbe
6 changed files with 283 additions and 595 deletions
64
package-lock.json
generated
64
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "worldshaper",
|
"name": "worldshaper",
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"compression": "^1.8.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"pixi.js": "^8.19.0",
|
"pixi.js": "^8.19.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|
@ -1442,6 +1443,60 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
|
@ -2869,6 +2924,15 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"compression": "^1.8.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"pixi.js": "^8.19.0",
|
"pixi.js": "^8.19.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|
@ -36,4 +37,3 @@
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
64
server.js
64
server.js
|
|
@ -1,4 +1,5 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import compression from "compression";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
@ -999,8 +1000,25 @@ function createDefaultColorCatalogEntries() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.use(compression({ threshold: 1024 }));
|
||||||
app.use(express.json({ limit: "10mb" }));
|
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) {
|
function resolveContent(type) {
|
||||||
const entry = contentMap[type];
|
const entry = contentMap[type];
|
||||||
|
|
@ -1325,6 +1343,33 @@ function listWorldChunkFiles(worldId) {
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.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) {
|
function countSymbolOccurrencesInRows(rows, targetSymbol) {
|
||||||
const normalizedTarget = String(targetSymbol || "").charAt(0);
|
const normalizedTarget = String(targetSymbol || "").charAt(0);
|
||||||
if (!normalizedTarget) {
|
if (!normalizedTarget) {
|
||||||
|
|
@ -3505,6 +3550,19 @@ app.get("/api/world/:worldId/overview", (req, res) => {
|
||||||
const chunks = chunkCoords
|
const chunks = chunkCoords
|
||||||
.map((coord) => readWorldChunkPayload(worldId, coord.chunkX, coord.chunkY, { createIfMissing: false }))
|
.map((coord) => readWorldChunkPayload(worldId, coord.chunkX, coord.chunkY, { createIfMissing: false }))
|
||||||
.filter(Boolean);
|
.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 chunkWidth = Math.max(1, Number(worldDefinition.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE);
|
||||||
const chunkHeight = Math.max(1, Number(worldDefinition.chunkHeight) || 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;
|
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,
|
maxTileX: ((maxChunkX + 1) * chunkWidth) - 1,
|
||||||
maxTileY: ((maxChunkY + 1) * chunkHeight) - 1,
|
maxTileY: ((maxChunkY + 1) * chunkHeight) - 1,
|
||||||
},
|
},
|
||||||
chunkCount: chunks.length,
|
chunkCount: overviewChunks.length,
|
||||||
chunks,
|
chunks: overviewChunks,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
export function createHistoryController(scope) {
|
export function createHistoryController(scope) {
|
||||||
const documentScope = scope.documentScope || scope;
|
const documentScope = scope.documentScope || scope;
|
||||||
const renderScope = scope.renderScope || scope;
|
const renderScope = scope.renderScope || scope;
|
||||||
const historyScope = scope.historyScope || scope;
|
|
||||||
const uiScope = scope.uiScope || scope;
|
const uiScope = scope.uiScope || scope;
|
||||||
const sessionScope = scope.sessionScope || scope;
|
const sessionScope = scope.sessionScope || scope;
|
||||||
const MAX_HISTORY_ENTRIES = 40;
|
const MAX_HISTORY_ENTRIES = 2;
|
||||||
const MAX_PERSISTED_HISTORY_CHARS = 1_500_000;
|
|
||||||
const OPERATION_CHECKPOINT_INTERVAL = 12;
|
|
||||||
let pendingPersistTimer = 0;
|
let pendingPersistTimer = 0;
|
||||||
|
|
||||||
function cloneValue(value) {
|
|
||||||
if (typeof structuredClone === "function") {
|
|
||||||
return structuredClone(value);
|
|
||||||
}
|
|
||||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPendingPersistTimer() {
|
function clearPendingPersistTimer() {
|
||||||
if (!pendingPersistTimer) {
|
if (!pendingPersistTimer) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -29,102 +19,26 @@ export function createHistoryController(scope) {
|
||||||
|
|
||||||
function persistHistoryState() {
|
function persistHistoryState() {
|
||||||
clearPendingPersistTimer();
|
clearPendingPersistTimer();
|
||||||
try {
|
return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePersistHistoryState() {
|
function schedulePersistHistoryState() {
|
||||||
clearPendingPersistTimer();
|
clearPendingPersistTimer();
|
||||||
pendingPersistTimer = window.setTimeout(() => {
|
|
||||||
pendingPersistTimer = 0;
|
|
||||||
persistHistoryState();
|
|
||||||
}, 120);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreHistoryState() {
|
function restoreHistoryState() {
|
||||||
try {
|
return null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHistorySnapshot(snapshot) {
|
function applyHistorySnapshot() {
|
||||||
if (!snapshot || typeof snapshot !== "object") {
|
return false;
|
||||||
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 captureState() {
|
function captureState() {
|
||||||
return {
|
return {
|
||||||
width: Number(documentScope.width) || 1,
|
width: Math.max(1, Number(documentScope.width) || 1),
|
||||||
height: Number(documentScope.height) || 1,
|
height: Math.max(1, Number(documentScope.height) || 1),
|
||||||
mapName: String(documentScope.mapName || scope.mapId || ""),
|
mapName: String(documentScope.mapName || scope.mapId || ""),
|
||||||
backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor),
|
backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor),
|
||||||
backgroundTileId: String(documentScope.backgroundTileId || "").trim(),
|
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() {
|
function refreshUiAfterHistoryMutation() {
|
||||||
documentScope.ensureBaseLayer();
|
documentScope.ensureBaseLayer();
|
||||||
sessionScope.activeLayer = documentScope.roomLayers.some((layer) => layer.layer === sessionScope.activeLayer) ? sessionScope.activeLayer : 0;
|
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.backgroundColor = documentScope.normalizeMapBackgroundColor(state?.backgroundColor || documentScope.backgroundColor);
|
||||||
documentScope.backgroundTileId = documentScope.normalizeBackgroundTileId(state?.backgroundTileId);
|
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.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.roomLayers = documentScope.cloneLayers(Array.isArray(state?.layers) ? state.layers : []);
|
||||||
documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []);
|
documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state?.heightLayers) ? state.heightLayers : []);
|
||||||
const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []);
|
const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : []);
|
||||||
sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} };
|
sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} };
|
||||||
if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) {
|
if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) {
|
||||||
sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim();
|
sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim();
|
||||||
|
|
@ -202,7 +145,7 @@ export function createHistoryController(scope) {
|
||||||
scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {});
|
scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {});
|
||||||
}
|
}
|
||||||
if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) {
|
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()) {
|
if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
|
||||||
scope.rebuildVisibleWorldChunksFromDocument();
|
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) {
|
function restoreToHistoryIndex(targetIndex) {
|
||||||
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
|
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
|
||||||
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
|
const targetEntry = scope.historyEntries[normalizedTargetIndex] || null;
|
||||||
if (snapshotIndex < 0) {
|
if (!targetEntry?.state) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
applyState(scope.historyEntries[snapshotIndex].state, { deferRefresh: true });
|
applyState(targetEntry.state);
|
||||||
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();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStateSignature(state) {
|
function getStateSignature(state) {
|
||||||
const layerSig = scope.cloneLayers(state.layers)
|
const layerSig = scope.cloneLayers(Array.isArray(state?.layers) ? state.layers : [])
|
||||||
.sort((a, b) => a.layer - b.layer)
|
.sort((a, b) => Number(a.layer) - Number(b.layer))
|
||||||
.map((layer) => ({
|
.map((layer) => ({
|
||||||
layer: layer.layer,
|
layer: Number(layer.layer) || 0,
|
||||||
name: typeof layer.name === "string" ? layer.name : "",
|
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 || "")))
|
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")))
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: String(entry.id || ""),
|
id: String(entry.id || ""),
|
||||||
|
|
@ -603,30 +183,38 @@ export function createHistoryController(scope) {
|
||||||
y: Number(entry.y) || 0,
|
y: Number(entry.y) || 0,
|
||||||
rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
||||||
}));
|
}));
|
||||||
const npcSig = scope.cloneNpcOverlays(state.npcs)
|
const npcSig = scope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : [])
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")))
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry.id,
|
id: String(entry.id || ""),
|
||||||
layer: Number(entry.layer) || 0,
|
layer: Number(entry.layer) || 0,
|
||||||
name: entry.name,
|
name: String(entry.name || ""),
|
||||||
spriteId: entry.spriteId,
|
spriteId: String(entry.spriteId || ""),
|
||||||
x: entry.x,
|
x: Number(entry.x) || 0,
|
||||||
y: entry.y,
|
y: Number(entry.y) || 0,
|
||||||
}));
|
}));
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
width: Number(state.width) || 1,
|
width: Math.max(1, Number(state?.width) || 1),
|
||||||
height: Number(state.height) || 1,
|
height: Math.max(1, Number(state?.height) || 1),
|
||||||
mapName: String(state.mapName || ""),
|
mapName: String(state?.mapName || ""),
|
||||||
backgroundColor: scope.normalizeMapBackgroundColor(state.backgroundColor),
|
backgroundColor: scope.normalizeMapBackgroundColor(state?.backgroundColor),
|
||||||
backgroundTileId: scope.normalizeBackgroundTileId(state.backgroundTileId),
|
backgroundTileId: scope.normalizeBackgroundTileId(state?.backgroundTileId),
|
||||||
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
|
heightBlurStep: Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || 0.1)),
|
||||||
layerSig,
|
layerSig,
|
||||||
heightLayerSig,
|
heightLayerSig,
|
||||||
npcSig,
|
npcSig,
|
||||||
worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds)
|
worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds)
|
||||||
? 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() {
|
function renderHistoryPreview() {
|
||||||
const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null;
|
if (!scope.historyPreviewEl) {
|
||||||
if (!selectedEntry) {
|
return;
|
||||||
scope.historyPreviewEl.innerHTML = '<h4>Change Preview</h4><div class="history-preview-empty">Select a history entry to inspect it.</div>';
|
}
|
||||||
|
const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null;
|
||||||
|
if (!selectedEntry) {
|
||||||
|
scope.historyPreviewEl.innerHTML = '<h4>Undo / Redo</h4><div class="history-preview-empty">Single-step history is empty.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : [];
|
const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : [];
|
||||||
const detailHtml = details.length > 0
|
const detailHtml = details.length > 0
|
||||||
? "<ul>" + details.map((detail) => "<li>" + detail + "</li>").join("") + "</ul>"
|
? "<ul>" + details.map((detail) => "<li>" + detail + "</li>").join("") + "</ul>"
|
||||||
: '<div class="history-preview-empty">No additional details recorded.</div>';
|
: '<div class="history-preview-empty">No additional details recorded.</div>';
|
||||||
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 =
|
scope.historyPreviewEl.innerHTML =
|
||||||
"<h4>" + currentText + "</h4>" +
|
"<h4>" + title + "</h4>" +
|
||||||
'<div style="margin-bottom:6px;">' + formatHistoryLabel(selectedEntry) + "</div>" +
|
'<div style="margin-bottom:6px;">' + formatHistoryLabel(selectedEntry) + "</div>" +
|
||||||
detailHtml +
|
detailHtml +
|
||||||
'<button class="mini-btn" id="jumpHistoryBtn" type="button" style="margin-top:8px;">Restore To Selected</button>';
|
'<div class="history-preview-empty" style="margin-top:8px;">Only one undo / redo step is kept in memory.</div>';
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHistoryList() {
|
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 =
|
||||||
|
"<span>" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "</span>" +
|
||||||
|
'<span class="history-meta">' + timeText + "</span>";
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
if (index === scope.historySelectionIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope.historySelectionIndex = index;
|
||||||
|
renderHistoryList();
|
||||||
|
renderHistoryPreview();
|
||||||
|
});
|
||||||
|
scope.historyListEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (scope.historyCurrentEl) {
|
if (scope.historyCurrentEl) {
|
||||||
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
||||||
scope.historyCurrentEl.innerHTML = currentEntry
|
scope.historyCurrentEl.innerHTML = currentEntry
|
||||||
|
|
@ -686,34 +287,13 @@ export function createHistoryController(scope) {
|
||||||
)
|
)
|
||||||
: '<div class="history-current-label">Current State</div><div class="history-current-empty">No history yet.</div>';
|
: '<div class="history-current-label">Current State</div><div class="history-current-empty">No history yet.</div>';
|
||||||
}
|
}
|
||||||
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 =
|
|
||||||
"<span>" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "</span>" +
|
|
||||||
'<span class="history-meta">' + timeText + "</span>";
|
|
||||||
row.addEventListener("click", () => {
|
|
||||||
if (index === scope.historySelectionIndex) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scope.historySelectionIndex = index;
|
|
||||||
renderHistoryList();
|
|
||||||
renderHistoryPreview();
|
|
||||||
});
|
|
||||||
scope.historyListEl.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshToolbarState(preserveCurrentStatus) {
|
function refreshToolbarState(preserveCurrentStatus) {
|
||||||
const canUndo = scope.historyIndex > 0;
|
const canUndo = scope.historyIndex > 0;
|
||||||
const canRedo = scope.historyIndex < scope.historyEntries.length - 1;
|
const canRedo = scope.historyIndex < scope.historyEntries.length - 1;
|
||||||
const currentHistoryId = scope.historyEntries[scope.historyIndex] ? scope.historyEntries[scope.historyIndex].id : 0;
|
const currentHistoryId = scope.historyEntries[scope.historyIndex] ? Number(scope.historyEntries[scope.historyIndex].id) || 0 : 0;
|
||||||
const isDirtyFromSaved = currentHistoryId !== scope.lastSavedHistoryId;
|
const isDirtyFromSaved = currentHistoryId !== (Number(scope.lastSavedHistoryId) || 0);
|
||||||
|
|
||||||
scope.undoBtn.disabled = scope.isSaving || !canUndo;
|
scope.undoBtn.disabled = scope.isSaving || !canUndo;
|
||||||
scope.redoBtn.disabled = scope.isSaving || !canRedo;
|
scope.redoBtn.disabled = scope.isSaving || !canRedo;
|
||||||
|
|
@ -725,13 +305,12 @@ export function createHistoryController(scope) {
|
||||||
if (preserveCurrentStatus) {
|
if (preserveCurrentStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope.isSaving) {
|
if (scope.isSaving) {
|
||||||
scope.setStatus("Saving...", false);
|
scope.setStatus("Saving...", false);
|
||||||
} else if (canRedo) {
|
} 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) {
|
} else if (isDirtyFromSaved) {
|
||||||
scope.setStatus("Unsaved history changes.", false);
|
scope.setStatus("Unsaved changes.", false);
|
||||||
} else {
|
} else {
|
||||||
scope.setStatus("All changes saved.", false);
|
scope.setStatus("All changes saved.", false);
|
||||||
}
|
}
|
||||||
|
|
@ -739,20 +318,10 @@ export function createHistoryController(scope) {
|
||||||
|
|
||||||
function registerHistory(label, before, after, details, options) {
|
function registerHistory(label, before, after, details, options) {
|
||||||
const config = options && typeof options === "object" ? options : {};
|
const config = options && typeof options === "object" ? options : {};
|
||||||
const operation = config.operation ? cloneValue(config.operation) : null;
|
const nextState = cloneHistoryState(config.nextState || captureState());
|
||||||
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 currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
||||||
const currentState = currentEntry && currentEntry.state
|
const currentState = currentEntry?.state ? cloneHistoryState(currentEntry.state) : captureState();
|
||||||
? currentEntry.state
|
if (!config.skipStateCheck && getStateSignature(nextState) === getStateSignature(currentState)) {
|
||||||
: captureHistoryStateAtIndex(scope.historyIndex);
|
|
||||||
if (!config.skipStateCheck && nextState && currentState && getStateSignature(nextState) === getStateSignature(currentState)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -760,25 +329,6 @@ export function createHistoryController(scope) {
|
||||||
scope.historyEntries = scope.historyEntries.slice(0, scope.historyIndex + 1);
|
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 = {
|
const entry = {
|
||||||
id: scope.nextHistoryId,
|
id: scope.nextHistoryId,
|
||||||
seq: scope.nextHistoryId,
|
seq: scope.nextHistoryId,
|
||||||
|
|
@ -787,47 +337,47 @@ export function createHistoryController(scope) {
|
||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
details: Array.isArray(details) ? details : [],
|
details: Array.isArray(details) ? details : [],
|
||||||
state: nextState || checkpointState,
|
state: nextState,
|
||||||
operation,
|
|
||||||
};
|
};
|
||||||
scope.nextHistoryId += 1;
|
scope.nextHistoryId += 1;
|
||||||
|
|
||||||
scope.historyEntries.push(entry);
|
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.historyIndex = scope.historyEntries.length - 1;
|
||||||
scope.historySelectionIndex = scope.historyIndex;
|
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();
|
schedulePersistHistoryState();
|
||||||
refreshToolbarState();
|
refreshToolbarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
if (scope.historyIndex <= 0) {
|
if (scope.historyIndex <= 0) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
scope.historyIndex -= 1;
|
scope.historyIndex -= 1;
|
||||||
scope.historySelectionIndex = scope.historyIndex;
|
scope.historySelectionIndex = scope.historyIndex;
|
||||||
restoreToHistoryIndex(scope.historyIndex);
|
const restored = restoreToHistoryIndex(scope.historyIndex);
|
||||||
schedulePersistHistoryState();
|
schedulePersistHistoryState();
|
||||||
refreshToolbarState();
|
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() {
|
function redo() {
|
||||||
if (scope.historyIndex >= scope.historyEntries.length - 1) {
|
if (scope.historyIndex >= scope.historyEntries.length - 1) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
scope.historyIndex += 1;
|
scope.historyIndex += 1;
|
||||||
scope.historySelectionIndex = scope.historyIndex;
|
scope.historySelectionIndex = scope.historyIndex;
|
||||||
restoreToHistoryIndex(scope.historyIndex);
|
const restored = restoreToHistoryIndex(scope.historyIndex);
|
||||||
schedulePersistHistoryState();
|
schedulePersistHistoryState();
|
||||||
refreshToolbarState();
|
refreshToolbarState();
|
||||||
scope.setStatus("Redo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
|
if (restored) {
|
||||||
|
scope.setStatus("Redid the last change.", false);
|
||||||
|
}
|
||||||
|
return restored;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -837,7 +387,7 @@ export function createHistoryController(scope) {
|
||||||
applyHistorySnapshot,
|
applyHistorySnapshot,
|
||||||
captureState,
|
captureState,
|
||||||
applyState,
|
applyState,
|
||||||
applyOperation,
|
applyOperation: () => false,
|
||||||
restoreToHistoryIndex,
|
restoreToHistoryIndex,
|
||||||
getStateSignature,
|
getStateSignature,
|
||||||
formatCellCoord,
|
formatCellCoord,
|
||||||
|
|
|
||||||
|
|
@ -4513,11 +4513,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
|
||||||
scope.historySelectionIndex = 0;
|
scope.historySelectionIndex = 0;
|
||||||
scope.lastSavedHistoryId = initialEntry.id;
|
scope.lastSavedHistoryId = initialEntry.id;
|
||||||
scope.nextHistoryId = initialEntry.id + 1;
|
scope.nextHistoryId = initialEntry.id + 1;
|
||||||
|
try {
|
||||||
const restoredHistory = scope.restoreHistoryState();
|
if (currentHistoryStorageKey) {
|
||||||
if (!scope.applyHistorySnapshot(restoredHistory)) {
|
window.localStorage.removeItem(currentHistoryStorageKey);
|
||||||
scope.persistHistoryState();
|
}
|
||||||
}
|
} catch {}
|
||||||
|
scope.persistHistoryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshUiForLoadedMap() {
|
function refreshUiForLoadedMap() {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@ type OverviewChunk = {
|
||||||
height?: number;
|
height?: number;
|
||||||
backgroundTileId?: string;
|
backgroundTileId?: string;
|
||||||
roomLayers?: OverviewRoomLayer[];
|
roomLayers?: OverviewRoomLayer[];
|
||||||
|
surfaceRows?: string[];
|
||||||
instances?: unknown[];
|
instances?: unknown[];
|
||||||
|
instanceCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OverviewPayload = {
|
type OverviewPayload = {
|
||||||
|
|
@ -860,7 +862,9 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginPendingChunkAction(type: "move" | "duplicate", chunkRect: ChunkWorldRect, chunkKey: string, chunk?: OverviewChunk | null) {
|
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) {
|
if (type === "move" && entityCount > 0) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
"Move chunk " + chunkRect.chunkX + "," + chunkRect.chunkY + "?\n\nThis will also move " + entityCount + " placed entit" + (entityCount === 1 ? "y" : "ies") + ".",
|
"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) || ".";
|
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 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 || "";
|
let resolvedSymbol = backgroundSymbol || "";
|
||||||
roomLayers.forEach((layer: OverviewRoomLayer) => {
|
sortedRoomLayers.forEach((layer: OverviewRoomLayer) => {
|
||||||
const layerNumber = Number(layer?.layer) || 0;
|
const layerNumber = Number(layer?.layer) || 0;
|
||||||
const fillChar = layerNumber === 0 ? "." : " ";
|
const fillChar = layerNumber === 0 ? "." : " ";
|
||||||
const row = Array.isArray(layer?.rows) ? String(layer.rows[localY] || "") : "";
|
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) {
|
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("|") : ""))
|
.map((layer: OverviewRoomLayer) => String(Number(layer?.layer) || 0) + ":" + (Array.isArray(layer?.rows) ? layer.rows.join("|") : ""))
|
||||||
.join("~");
|
.join("~");
|
||||||
return [
|
return [
|
||||||
|
|
@ -1459,10 +1466,18 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) {
|
||||||
canvas.height = chunkHeight;
|
canvas.height = chunkHeight;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (ctx) {
|
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);
|
const imageData = ctx.createImageData(chunkWidth, chunkHeight);
|
||||||
for (let localY = 0; localY < chunkHeight; localY += 1) {
|
for (let localY = 0; localY < chunkHeight; localY += 1) {
|
||||||
|
const surfaceRow = surfaceRows ? String(surfaceRows[localY] || "") : "";
|
||||||
for (let localX = 0; localX < chunkWidth; localX += 1) {
|
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;
|
const pixelIndex = (localY * chunkWidth + localX) * 4;
|
||||||
imageData.data[pixelIndex] = color[0];
|
imageData.data[pixelIndex] = color[0];
|
||||||
imageData.data[pixelIndex + 1] = color[1];
|
imageData.data[pixelIndex + 1] = color[1];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue