Compare commits
1 commit
refactor/a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c64e1fbe |
43 changed files with 909 additions and 3427 deletions
|
|
@ -1,75 +0,0 @@
|
|||
# Arc 1 Smoke Checklist
|
||||
|
||||
This checklist is the manual checkpoint for Arc 1.
|
||||
|
||||
It is intentionally short and focused on the editor flows that must remain usable while the refactor branch is in motion.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- start the Vite client with `npm run dev`
|
||||
- start the API server with `npm run dev:api`
|
||||
- open the launcher at the local dev URL
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Launcher Opens
|
||||
|
||||
- verify the launcher renders
|
||||
- verify the primary launch actions are visible
|
||||
- verify the `Content Editor` action is available
|
||||
|
||||
### 2. Content Editor Loads
|
||||
|
||||
- open the content editor from the launcher
|
||||
- verify the content editor window loads
|
||||
- verify the main content domains are listed:
|
||||
- NPCs
|
||||
- Dialogues
|
||||
- Monsters
|
||||
- Items
|
||||
- Abilities
|
||||
- Loot Tables
|
||||
- Quests
|
||||
- Graphics
|
||||
- Factions
|
||||
|
||||
### 3. Content Record Edit Round Trip
|
||||
|
||||
- open any existing content record
|
||||
- change one small field value
|
||||
- use `Commit`
|
||||
- use `Save`
|
||||
- verify the save status updates
|
||||
- revert the field to its original value
|
||||
- save again so the repository returns to its original content state
|
||||
|
||||
### 4. Studio Window Opens
|
||||
|
||||
- open the studio from the launcher
|
||||
- verify the studio bootstraps into a world without console-blocking errors
|
||||
|
||||
### 5. World Loads And A Tile Edit Still Works
|
||||
|
||||
- verify the current world name and dimensions are visible
|
||||
- select a tile
|
||||
- paint one tile on the map
|
||||
- verify undo/save state updates
|
||||
- save the world
|
||||
- revert the tile change before finishing the checkpoint
|
||||
|
||||
### 6. Graphics Painter Opens And Saves
|
||||
|
||||
- open the graphics browser from the studio
|
||||
- open a sprite or tile asset in the painter
|
||||
- change one pixel
|
||||
- save the asset
|
||||
- revert the pixel change before finishing the checkpoint
|
||||
|
||||
## Verification Notes
|
||||
|
||||
Checkpoint commits for Arc 1 should only be considered shareable when:
|
||||
|
||||
- `npm test` passes
|
||||
- `npm run build` passes
|
||||
- this checklist passes
|
||||
- temporary smoke-test content edits have been reverted before commit
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
# World And Chunk Semantics
|
||||
|
||||
This document describes the **current** `WorldShaper` world/chunk behavior as of Arc 1.
|
||||
|
||||
It is a compatibility and refactor aid, not an endorsement of the long-term model.
|
||||
|
||||
## Scope
|
||||
|
||||
These notes summarize the behavior currently implemented in:
|
||||
|
||||
- `src/worldChunking.ts`
|
||||
- `src/components/worldshaperShared.ts`
|
||||
- regression tests in:
|
||||
- `src/worldChunking.test.ts`
|
||||
- `src/components/worldshaperShared.test.ts`
|
||||
|
||||
## Coordinate Model
|
||||
|
||||
### Chunk Dimensions
|
||||
|
||||
- chunk width and height are normalized with `normalizeChunkDimension`
|
||||
- values are floored to integers
|
||||
- invalid values fall back to `DEFAULT_WORLD_CHUNK_SIZE`
|
||||
- normalized dimensions are clamped to a minimum of `1`
|
||||
|
||||
### World To Chunk Coordinates
|
||||
|
||||
- chunk coordinates use floor division
|
||||
- this applies on both positive and negative world coordinates
|
||||
- examples with chunk size `32`:
|
||||
- `0 -> chunk 0`
|
||||
- `31 -> chunk 0`
|
||||
- `32 -> chunk 1`
|
||||
- `-1 -> chunk -1`
|
||||
- `-33 -> chunk -2`
|
||||
|
||||
This means negative coordinates behave as mathematical grid cells, not truncation toward zero.
|
||||
|
||||
### World To Local Coordinates
|
||||
|
||||
- local coordinates are derived from the resolved chunk coordinate
|
||||
- formula: `world - (chunk * chunkSize)`
|
||||
- result stays in the half-open range `[0, chunkSize)`
|
||||
- examples with chunk size `32`:
|
||||
- `31 -> local 31`
|
||||
- `32 -> local 0`
|
||||
- `-1 -> local 31`
|
||||
- `-33 -> local 31`
|
||||
|
||||
### Local To World Coordinates
|
||||
|
||||
- formula: `(chunkCoord * chunkSize) + localCoord`
|
||||
- examples with chunk size `32`:
|
||||
- `chunk -2, local 31 -> world -33`
|
||||
|
||||
### Address Resolution
|
||||
|
||||
`resolveWorldChunkAddress` returns:
|
||||
|
||||
- `chunkX`
|
||||
- `chunkY`
|
||||
- `localX`
|
||||
- `localY`
|
||||
- `chunkKey` in `x:y` form
|
||||
- `fileName` in `x_y.json` form
|
||||
|
||||
## Chunk Identity And Storage
|
||||
|
||||
- chunk keys use `buildChunkKey(chunkX, chunkY)`
|
||||
- chunk filenames use `buildChunkFileName(chunkX, chunkY)`
|
||||
- filenames preserve negative signs, for example `-3_4.json`
|
||||
|
||||
## Empty Chunk Defaults
|
||||
|
||||
`createEmptyChunk` currently creates:
|
||||
|
||||
- schema version `1`
|
||||
- top-level `backgroundTileId`
|
||||
- `roomLayers[0]` filled with `.` characters
|
||||
- `roomLayers[1]` filled with spaces
|
||||
- empty `heightLayers`
|
||||
- empty `instances`
|
||||
|
||||
This establishes the current meaning that:
|
||||
|
||||
- `.` in layer `0` is the default empty background cell encoding
|
||||
- spaces in non-background layers represent empty overlay cells
|
||||
|
||||
## Background Tile Behavior
|
||||
|
||||
`getMapBackgroundTileId` currently resolves background tiles in this order:
|
||||
|
||||
1. top-level `backgroundTileId`
|
||||
2. legacy nested `tiles.backgroundTileId`
|
||||
3. empty string fallback
|
||||
|
||||
Arc 1 should treat the nested `tiles.backgroundTileId` shape as compatibility baggage.
|
||||
|
||||
## Room Layer Semantics
|
||||
|
||||
`parseRoomLayers` currently does the following:
|
||||
|
||||
- parses `record.roomLayers` if present
|
||||
- ignores malformed entries
|
||||
- requires a numeric `layer`
|
||||
- sorts output by ascending `layer`
|
||||
- normalizes row sizes to map bounds
|
||||
- uses `.` fill for layer `0`
|
||||
- uses space fill for non-zero layers
|
||||
- filters blank `instanceIds`
|
||||
|
||||
If no explicit layer `0` exists:
|
||||
|
||||
- a synthetic layer `0` is created from top-level `record.rows`
|
||||
|
||||
If there are no usable layers at all:
|
||||
|
||||
- a single synthetic layer `0` is returned from top-level `record.rows`
|
||||
|
||||
### Current `zIndex` Behavior
|
||||
|
||||
- layer `0` always gets `zIndex: 0`
|
||||
- non-zero layers preserve provided `zIndex` if present
|
||||
- otherwise non-zero layers default to `0`
|
||||
- non-zero `zIndex` values are clamped into `[0, 5]`
|
||||
|
||||
This is an important current behavior to preserve during Arc 1, but it looks at least partly accidental because non-zero layers do **not** derive `zIndex` from layer number.
|
||||
|
||||
## Height Patch Semantics
|
||||
|
||||
`parseHeightLayers` currently treats height patches as sparse row-based overlays.
|
||||
|
||||
### Input Interpretation
|
||||
|
||||
- `rows` are string arrays
|
||||
- `.` is interpreted as empty space
|
||||
- empty margins are trimmed away
|
||||
- patches are clipped to map bounds
|
||||
|
||||
### Normalization Rules
|
||||
|
||||
- duplicate patch ids are dropped after the first occurrence
|
||||
- `z` is clamped to a minimum of `1`
|
||||
- `x` and `y` are floored to integers
|
||||
- rows completely outside bounds become empty
|
||||
- leading/trailing empty rows are removed
|
||||
- leading/trailing empty columns are removed by cropping to occupied content
|
||||
- trailing whitespace inside retained rows is stripped
|
||||
|
||||
### Resulting Meaning
|
||||
|
||||
The current height patch encoding behaves more like a cropped sparse stamp than a fixed-size tile layer.
|
||||
|
||||
That is useful to document now because any future redesign needs to decide whether this sparse behavior is intentional or just a side effect of the current editor implementation.
|
||||
|
||||
## Chunk Instance Semantics
|
||||
|
||||
Arc 1 has not redesigned chunk instances yet, but the current shape is:
|
||||
|
||||
- `id`
|
||||
- optional `templateId`
|
||||
- `layer`
|
||||
- `x`
|
||||
- `y`
|
||||
- `record`
|
||||
|
||||
The semantics of `templateId + record` are still under-specified and should be treated as a known redesign target for later arcs.
|
||||
|
||||
## Likely Accidental Or Under-Specified Behavior
|
||||
|
||||
These behaviors are currently preserved, but should not be treated as settled architecture:
|
||||
|
||||
- non-background layers default to `zIndex: 0` instead of deriving depth from layer number
|
||||
- duplicate height patch ids are silently dropped after the first occurrence
|
||||
- top-level `rows` still act as a fallback source for synthesized background layers
|
||||
- nested `tiles.backgroundTileId` is still accepted
|
||||
- layer `0` empties use `.` while non-zero layer empties use spaces
|
||||
- chunk instance meaning is still implicit rather than explicitly modeled
|
||||
|
||||
## Arc 2+ Questions
|
||||
|
||||
- Should negative-coordinate behavior remain floor-based, or should world addressing be modeled differently at a higher level?
|
||||
- Should layer depth be derived from `layer`, `zIndex`, or a clearer world-space model?
|
||||
- Should height data remain sparse text rows, or become a more explicit numeric structure?
|
||||
- Should background tiles stay top-level, or belong to a clearer terrain/base-layer contract?
|
||||
- What is the correct long-term meaning of chunk instances, templates, and per-instance overrides?
|
||||
1213
package-lock.json
generated
1213
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -12,12 +12,11 @@
|
|||
"analyze:requests": "node scripts/request-analysis-worker.mjs",
|
||||
"validate:content": "node scripts/validate-content-schemas.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.19.2",
|
||||
"pixi.js": "^8.19.0",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -25,9 +24,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -36,10 +32,8 @@
|
|||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
224
server.js
224
server.js
|
|
@ -1,25 +1,9 @@
|
|||
import express from "express";
|
||||
import compression from "compression";
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
areRowsOnlyFillChar,
|
||||
normalizeBackgroundTileId,
|
||||
} from "./server/contentTransforms.js";
|
||||
import {
|
||||
buildWorldChunkFileName,
|
||||
defaultWorldDirRel,
|
||||
getWorldStoragePaths as buildWorldStoragePaths,
|
||||
normalizeWorldBookmark,
|
||||
normalizeWorldIndexEntry,
|
||||
normalizeWorldIndexPayload,
|
||||
sanitizeWorldId,
|
||||
} from "./server/worldTransforms.js";
|
||||
import {
|
||||
validateCatalogMetaPayload as validateCatalogMetaPayloadShape,
|
||||
validatePayload as validatePayloadShape,
|
||||
} from "./server/validation.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
|
@ -978,6 +962,27 @@ function writeLauncherRequestsPayload(payload) {
|
|||
});
|
||||
}
|
||||
|
||||
function normalizeBackgroundTileId(value, idToSymbol = null) {
|
||||
const normalizedId = String(value || "").trim();
|
||||
if (!normalizedId) {
|
||||
return "";
|
||||
}
|
||||
if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) {
|
||||
return "";
|
||||
}
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
function areRowsOnlyFillChar(rows, fillChar = ".") {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return rows.every((row) => {
|
||||
const normalizedRow = String(row || "");
|
||||
return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar);
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultColorCatalogEntries() {
|
||||
return DEFAULT_COLOR_HEXES_ORDERED.map((hex, index) => {
|
||||
const symbol = DEFAULT_COLOR_SYMBOLS_ORDERED[index] || `X${index}`;
|
||||
|
|
@ -995,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];
|
||||
|
|
@ -1026,14 +1048,70 @@ function readJsonSafe(fullPath, fallback) {
|
|||
}
|
||||
}
|
||||
|
||||
function toContentAbs(relPath) {
|
||||
const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return path.resolve(contentRoot, normalized);
|
||||
}
|
||||
|
||||
function sanitizeWorldId(worldId) {
|
||||
const raw = String(worldId || "").trim();
|
||||
if (!raw) {
|
||||
return "world";
|
||||
}
|
||||
return raw.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
function defaultWorldDirRel(worldId) {
|
||||
return `worlds/${sanitizeWorldId(worldId)}`;
|
||||
}
|
||||
|
||||
function buildWorldChunkFileName(chunkX, chunkY) {
|
||||
return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`;
|
||||
}
|
||||
|
||||
function getWorldStoragePaths(worldEntryOrId) {
|
||||
return buildWorldStoragePaths(contentRoot, worldEntryOrId);
|
||||
const worldId = typeof worldEntryOrId === "string"
|
||||
? String(worldEntryOrId || "").trim()
|
||||
: String(worldEntryOrId?.id || "").trim();
|
||||
const worldDirRel = typeof worldEntryOrId === "string"
|
||||
? defaultWorldDirRel(worldId)
|
||||
: String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId));
|
||||
const worldDirAbs = toContentAbs(worldDirRel);
|
||||
const chunksDirRel = `${worldDirRel}/chunks`;
|
||||
return {
|
||||
worldId,
|
||||
worldDirRel,
|
||||
worldDirAbs,
|
||||
worldJsonRel: `${worldDirRel}/world.json`,
|
||||
worldJsonAbs: path.join(worldDirAbs, "world.json"),
|
||||
bookmarksRel: `${worldDirRel}/bookmarks.json`,
|
||||
bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"),
|
||||
chunksDirRel,
|
||||
chunksDirAbs: path.join(worldDirAbs, "chunks"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorldIndexEntry(entry) {
|
||||
const id = sanitizeWorldId(entry?.id || "");
|
||||
return {
|
||||
id,
|
||||
name: String(entry?.name || id || "World"),
|
||||
worldDir: String(entry?.worldDir || defaultWorldDirRel(id)),
|
||||
};
|
||||
}
|
||||
|
||||
function readWorldIndexPayload() {
|
||||
const fallback = { schemaVersion: 1, worlds: [] };
|
||||
const payload = readJsonSafe(worldsIndexPath, fallback);
|
||||
return normalizeWorldIndexPayload(payload);
|
||||
const worlds = Array.isArray(payload?.worlds)
|
||||
? payload.worlds
|
||||
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
|
||||
.map((entry) => normalizeWorldIndexEntry(entry))
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
|
||||
worlds,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorldDefinitionPayload(payload, fallbackId = "") {
|
||||
|
|
@ -1095,6 +1173,16 @@ function readWorldDefinitionPayload(worldId) {
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeWorldBookmark(entry, index = 0) {
|
||||
const fallbackId = `bookmark_${index + 1}`;
|
||||
return {
|
||||
id: String(entry?.id || fallbackId).trim() || fallbackId,
|
||||
label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId,
|
||||
x: Math.floor(Number(entry?.x) || 0),
|
||||
y: Math.floor(Number(entry?.y) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function readWorldBookmarksPayload(worldId) {
|
||||
const normalizedId = sanitizeWorldId(worldId);
|
||||
const storage = getWorldStoragePaths(normalizedId);
|
||||
|
|
@ -1255,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) {
|
||||
|
|
@ -2094,11 +2209,59 @@ function injectNpcNodeDescriptions(payload, meta) {
|
|||
}
|
||||
|
||||
function validatePayload(payload, type, rootKey) {
|
||||
return validatePayloadShape(payload, type, rootKey, REQUIRED_ID_KEY_BY_TYPE);
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
||||
return "Payload must be an object";
|
||||
}
|
||||
if (typeof payload.schemaVersion !== "number") {
|
||||
return "schemaVersion must be a number";
|
||||
}
|
||||
const allowedTopLevel = new Set(["schemaVersion", rootKey]);
|
||||
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
|
||||
if (unknownTopLevel.length > 0) {
|
||||
return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`;
|
||||
}
|
||||
if (!Array.isArray(payload[rootKey])) {
|
||||
return `Missing array root: ${rootKey}`;
|
||||
}
|
||||
|
||||
const idKey = REQUIRED_ID_KEY_BY_TYPE[type];
|
||||
if (!idKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = payload[rootKey];
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
const entry = list[index];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return `${rootKey}[${index}] must be an object`;
|
||||
}
|
||||
const idValue = String(entry[idKey] ?? "").trim();
|
||||
if (!idValue) {
|
||||
return `${rootKey}[${index}] is missing required key: ${idKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCatalogMetaPayload(payload) {
|
||||
return validateCatalogMetaPayloadShape(payload, FROZEN_CATALOG_KEYS);
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
||||
return "Catalog payload must be an object";
|
||||
}
|
||||
if (typeof payload.schemaVersion !== "number") {
|
||||
return "schemaVersion must be a number";
|
||||
}
|
||||
const allowedTopLevel = new Set(["schemaVersion", ...FROZEN_CATALOG_KEYS]);
|
||||
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
|
||||
if (unknownTopLevel.length > 0) {
|
||||
return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`;
|
||||
}
|
||||
for (const key of FROZEN_CATALOG_KEYS) {
|
||||
if (!Array.isArray(payload[key])) {
|
||||
return `${key} must be an array`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeJsonAtomic(fullPath, data) {
|
||||
|
|
@ -3387,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;
|
||||
|
|
@ -3406,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({
|
||||
|
|
|
|||
3
server/contentTransforms.d.ts
vendored
3
server/contentTransforms.d.ts
vendored
|
|
@ -1,3 +0,0 @@
|
|||
export function normalizeBackgroundTileId(value: unknown, idToSymbol?: Map<string, unknown> | null): string;
|
||||
export function areRowsOnlyFillChar(rows: unknown, fillChar?: string): boolean;
|
||||
export function resolveContentPath(contentRoot: string, relativePath: string): string;
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import path from "path";
|
||||
|
||||
export function normalizeBackgroundTileId(value, idToSymbol = null) {
|
||||
const normalizedId = String(value || "").trim();
|
||||
if (!normalizedId) {
|
||||
return "";
|
||||
}
|
||||
if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) {
|
||||
return "";
|
||||
}
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
export function areRowsOnlyFillChar(rows, fillChar = ".") {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return rows.every((row) => {
|
||||
const normalizedRow = String(row || "");
|
||||
return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveContentPath(contentRoot, relativePath) {
|
||||
const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return path.resolve(contentRoot, normalized);
|
||||
}
|
||||
10
server/validation.d.ts
vendored
10
server/validation.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
|||
export function validatePayload(
|
||||
payload: unknown,
|
||||
type: string,
|
||||
rootKey: string,
|
||||
requiredIdKeyByType: Record<string, string>,
|
||||
): string | null;
|
||||
export function validateCatalogMetaPayload(
|
||||
payload: unknown,
|
||||
frozenCatalogKeys: string[],
|
||||
): string | null;
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
export function validatePayload(payload, type, rootKey, requiredIdKeyByType) {
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
||||
return "Payload must be an object";
|
||||
}
|
||||
if (typeof payload.schemaVersion !== "number") {
|
||||
return "schemaVersion must be a number";
|
||||
}
|
||||
const allowedTopLevel = new Set(["schemaVersion", rootKey]);
|
||||
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
|
||||
if (unknownTopLevel.length > 0) {
|
||||
return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`;
|
||||
}
|
||||
if (!Array.isArray(payload[rootKey])) {
|
||||
return `Missing array root: ${rootKey}`;
|
||||
}
|
||||
|
||||
const idKey = requiredIdKeyByType[type];
|
||||
if (!idKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = payload[rootKey];
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
const entry = list[index];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return `${rootKey}[${index}] must be an object`;
|
||||
}
|
||||
const idValue = String(entry[idKey] ?? "").trim();
|
||||
if (!idValue) {
|
||||
return `${rootKey}[${index}] is missing required key: ${idKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCatalogMetaPayload(payload, frozenCatalogKeys) {
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
||||
return "Catalog payload must be an object";
|
||||
}
|
||||
if (typeof payload.schemaVersion !== "number") {
|
||||
return "schemaVersion must be a number";
|
||||
}
|
||||
const allowedTopLevel = new Set(["schemaVersion", ...frozenCatalogKeys]);
|
||||
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
|
||||
if (unknownTopLevel.length > 0) {
|
||||
return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`;
|
||||
}
|
||||
for (const key of frozenCatalogKeys) {
|
||||
if (!Array.isArray(payload[key])) {
|
||||
return `${key} must be an array`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
36
server/worldTransforms.d.ts
vendored
36
server/worldTransforms.d.ts
vendored
|
|
@ -1,36 +0,0 @@
|
|||
export function sanitizeWorldId(worldId: unknown): string;
|
||||
export function defaultWorldDirRel(worldId: unknown): string;
|
||||
export function buildWorldChunkFileName(chunkX: unknown, chunkY: unknown): string;
|
||||
export function getWorldStoragePaths(
|
||||
contentRoot: string,
|
||||
worldEntryOrId: string | { id?: unknown; worldDir?: unknown },
|
||||
): {
|
||||
worldId: string;
|
||||
worldDirRel: string;
|
||||
worldDirAbs: string;
|
||||
worldJsonRel: string;
|
||||
worldJsonAbs: string;
|
||||
bookmarksRel: string;
|
||||
bookmarksAbs: string;
|
||||
chunksDirRel: string;
|
||||
chunksDirAbs: string;
|
||||
};
|
||||
export function normalizeWorldIndexEntry(entry: { id?: unknown; name?: unknown; worldDir?: unknown } | null | undefined): {
|
||||
id: string;
|
||||
name: string;
|
||||
worldDir: string;
|
||||
};
|
||||
export function normalizeWorldIndexPayload(payload: unknown): {
|
||||
schemaVersion: number;
|
||||
worlds: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
worldDir: string;
|
||||
}>;
|
||||
};
|
||||
export function normalizeWorldBookmark(entry: { id?: unknown; label?: unknown; x?: unknown; y?: unknown } | null | undefined, index?: number): {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import path from "path";
|
||||
import { resolveContentPath } from "./contentTransforms.js";
|
||||
|
||||
export function sanitizeWorldId(worldId) {
|
||||
const raw = String(worldId || "").trim();
|
||||
if (!raw) {
|
||||
return "world";
|
||||
}
|
||||
return raw.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
export function defaultWorldDirRel(worldId) {
|
||||
return `worlds/${sanitizeWorldId(worldId)}`;
|
||||
}
|
||||
|
||||
export function buildWorldChunkFileName(chunkX, chunkY) {
|
||||
return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`;
|
||||
}
|
||||
|
||||
export function getWorldStoragePaths(contentRoot, worldEntryOrId) {
|
||||
const worldId = typeof worldEntryOrId === "string"
|
||||
? String(worldEntryOrId || "").trim()
|
||||
: String(worldEntryOrId?.id || "").trim();
|
||||
const worldDirRel = typeof worldEntryOrId === "string"
|
||||
? defaultWorldDirRel(worldId)
|
||||
: String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId));
|
||||
const worldDirAbs = resolveContentPath(contentRoot, worldDirRel);
|
||||
const chunksDirRel = `${worldDirRel}/chunks`;
|
||||
return {
|
||||
worldId,
|
||||
worldDirRel,
|
||||
worldDirAbs,
|
||||
worldJsonRel: `${worldDirRel}/world.json`,
|
||||
worldJsonAbs: path.join(worldDirAbs, "world.json"),
|
||||
bookmarksRel: `${worldDirRel}/bookmarks.json`,
|
||||
bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"),
|
||||
chunksDirRel,
|
||||
chunksDirAbs: path.join(worldDirAbs, "chunks"),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorldIndexEntry(entry) {
|
||||
const id = sanitizeWorldId(entry?.id || "");
|
||||
return {
|
||||
id,
|
||||
name: String(entry?.name || id || "World"),
|
||||
worldDir: String(entry?.worldDir || defaultWorldDirRel(id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorldIndexPayload(payload) {
|
||||
const worlds = Array.isArray(payload?.worlds)
|
||||
? payload.worlds
|
||||
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
|
||||
.map((entry) => normalizeWorldIndexEntry(entry))
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
|
||||
worlds,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorldBookmark(entry, index = 0) {
|
||||
const fallbackId = `bookmark_${index + 1}`;
|
||||
return {
|
||||
id: String(entry?.id || fallbackId).trim() || fallbackId,
|
||||
label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId,
|
||||
x: Math.floor(Number(entry?.x) || 0),
|
||||
y: Math.floor(Number(entry?.y) || 0),
|
||||
};
|
||||
}
|
||||
|
|
@ -683,16 +683,6 @@ function openSharedContractNote(): void {
|
|||
window.location.assign(noteUrl.toString());
|
||||
}
|
||||
|
||||
function openContentEditor(): void {
|
||||
const editorUrl = new URL("./worldshaper-content.html", window.location.href);
|
||||
const popup = window.open(editorUrl.toString(), "worldshaper-content-editor", "popup=yes,width=1600,height=980,resizable=yes,scrollbars=yes");
|
||||
if (popup) {
|
||||
popup.focus();
|
||||
return;
|
||||
}
|
||||
window.location.assign(editorUrl.toString());
|
||||
}
|
||||
|
||||
function openAdminPanelWindow(): boolean {
|
||||
const nextUrl = new URL(window.location.href);
|
||||
nextUrl.searchParams.set("admin", "requests");
|
||||
|
|
@ -1374,9 +1364,6 @@ function WorldshaperLauncher() {
|
|||
<button type="button" className="launcher-primary-btn" onClick={() => void handleLaunch()} disabled={isBusy}>
|
||||
Launch
|
||||
</button>
|
||||
<button type="button" className="launcher-secondary-btn" onClick={openContentEditor} disabled={isBusy}>
|
||||
Content Editor
|
||||
</button>
|
||||
<button type="button" className="launcher-secondary-btn" onClick={openSharedContractNote} disabled={isBusy}>
|
||||
Shared Contract
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import {
|
||||
getMapBackgroundTileId,
|
||||
parseHeightLayers,
|
||||
parseRoomLayers,
|
||||
} from "./worldshaperShared";
|
||||
import type { JsonObject } from "../editorCore";
|
||||
|
||||
describe("worldshaperShared", () => {
|
||||
it("prefers top-level background tile ids and falls back to the legacy nested shape", () => {
|
||||
expect(getMapBackgroundTileId({ backgroundTileId: "grass" })).toBe("grass");
|
||||
expect(getMapBackgroundTileId({ tiles: { backgroundTileId: "water" } as unknown as JsonObject })).toBe("water");
|
||||
expect(getMapBackgroundTileId({})).toBe("");
|
||||
});
|
||||
|
||||
it("synthesizes and resizes room layers from the current map payload", () => {
|
||||
const result = parseRoomLayers({
|
||||
rows: ["##", "#"],
|
||||
roomLayers: [
|
||||
{
|
||||
layer: 2,
|
||||
name: "Objects",
|
||||
rows: ["A"],
|
||||
instanceIds: ["npc_1", ""],
|
||||
},
|
||||
],
|
||||
}, 3, 2);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
layer: 0,
|
||||
name: undefined,
|
||||
zIndex: 0,
|
||||
rows: ["##.", "#.."],
|
||||
instanceIds: [],
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
name: "Objects",
|
||||
zIndex: 0,
|
||||
rows: ["A ", " "],
|
||||
instanceIds: ["npc_1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes, trims, and bounds height patches while dropping duplicate ids", () => {
|
||||
const result = parseHeightLayers({
|
||||
heightLayers: [
|
||||
{
|
||||
id: "ridge",
|
||||
z: 3,
|
||||
x: -1,
|
||||
y: -1,
|
||||
rows: [
|
||||
"...",
|
||||
".9.",
|
||||
"..8",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ridge",
|
||||
z: 9,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rows: ["1"],
|
||||
},
|
||||
],
|
||||
}, 4, 4);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "ridge",
|
||||
name: undefined,
|
||||
z: 3,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rows: ["9", " 8"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { resolveUnifiedColorSymbol } from "../editorCore";
|
||||
import type { JsonObject } from "../contracts/json";
|
||||
import type { JsonObject } from "../editorCore";
|
||||
|
||||
export const TILE_COLORS: Record<string, string> = {
|
||||
"#": resolveUnifiedColorSymbol("L", "#3d4f6a"),
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import "./App.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
mergeImagesPayloadWithTilesPayload,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeImagesPayloadForSave,
|
||||
normalizeTileRecordForSave,
|
||||
} from "./graphicsPayload";
|
||||
import type { JsonObject, JsonValue } from "../contracts/json";
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
|
||||
describe("graphicsPayload", () => {
|
||||
it("normalizes tile records with padded rows and compatibility symbols", () => {
|
||||
const result = normalizeTileRecordForSave({
|
||||
id: "tile_grass",
|
||||
rows: ["A", "BC", "DROP"],
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
id: "tile_grass",
|
||||
symbol: "t",
|
||||
rows: ["A.", "BC"],
|
||||
width: 2,
|
||||
height: 2,
|
||||
}));
|
||||
});
|
||||
|
||||
it("normalizes image records into frame-based storage while preserving tile compatibility", () => {
|
||||
const result = normalizeImageRecordForSave({
|
||||
id: "tile_tree",
|
||||
roles: ["tile", "sprite", "other", "tile"],
|
||||
rows: ["A", "BC"],
|
||||
width: 2,
|
||||
height: 2,
|
||||
speed: -10,
|
||||
playback: "invalid",
|
||||
tileSymbol: "$",
|
||||
});
|
||||
|
||||
expect(result.rows).toBeUndefined();
|
||||
expect(result.symbol).toBeUndefined();
|
||||
expect(result.graphicRole).toBeUndefined();
|
||||
expect(result.roles).toEqual(["tile", "sprite"]);
|
||||
expect(result.defaultFrame).toBe("frame_0");
|
||||
expect(result.speed).toBe(0);
|
||||
expect(result.playback).toBe("normal");
|
||||
expect(result.tileSymbol).toBe("$");
|
||||
expect(result.frames).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "frame_0",
|
||||
rows: ["A.", "BC"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves sprite rows from the default enabled frame", () => {
|
||||
const record: JsonObject = {
|
||||
defaultFrame: "walk_1",
|
||||
frames: [
|
||||
{ id: "idle", rows: ["II"], enabled: true },
|
||||
{ id: "walk_1", rows: ["WW"], enabled: true },
|
||||
{ id: "disabled", rows: ["DD"], enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSpriteRows(record)).toEqual(["WW"]);
|
||||
});
|
||||
|
||||
it("normalizes image payload arrays without changing non-record entries", () => {
|
||||
const payload: JsonValue = {
|
||||
schemaVersion: 1,
|
||||
images: [
|
||||
{
|
||||
id: "sprite_hero",
|
||||
roles: ["sprite"],
|
||||
rows: ["X"],
|
||||
},
|
||||
"leave-me-alone",
|
||||
],
|
||||
};
|
||||
|
||||
expect(normalizeImagesPayloadForSave(payload)).toEqual({
|
||||
schemaVersion: 1,
|
||||
images: [
|
||||
expect.objectContaining({
|
||||
id: "sprite_hero",
|
||||
roles: ["sprite"],
|
||||
defaultFrame: "frame_0",
|
||||
}),
|
||||
"leave-me-alone",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("projects tiles and sprites from images while preserving current compatibility behavior", () => {
|
||||
const imagesPayload: JsonValue = {
|
||||
schemaVersion: 1,
|
||||
images: [
|
||||
{
|
||||
id: "tile_grass",
|
||||
roles: ["tile"],
|
||||
tileSymbol: "G",
|
||||
width: 2,
|
||||
height: 2,
|
||||
frames: [{ id: "frame_0", rows: ["AA", "BB"] }],
|
||||
},
|
||||
{
|
||||
id: "sprite_hero",
|
||||
roles: ["sprite"],
|
||||
width: 1,
|
||||
height: 1,
|
||||
frames: [{ id: "frame_0", rows: ["H"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(buildTilesPayloadFromImagesPayload(imagesPayload)).toEqual({
|
||||
schemaVersion: 1,
|
||||
tiles: [expect.objectContaining({ id: "tile_grass", symbol: "G", rows: ["AA", "BB"] })],
|
||||
});
|
||||
expect(buildSpritesPayloadFromImagesPayload(imagesPayload)).toEqual({
|
||||
schemaVersion: 1,
|
||||
sprites: [expect.objectContaining({ id: "sprite_hero", rows: ["H"] })],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges tile payloads into images and removes tile role when a tile disappears", () => {
|
||||
const merged = mergeImagesPayloadWithTilesPayload(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
images: [
|
||||
{
|
||||
id: "shared",
|
||||
roles: ["tile", "sprite"],
|
||||
tileSymbol: "S",
|
||||
frames: [{ id: "frame_0", rows: ["A"] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
tiles: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged).toEqual({
|
||||
schemaVersion: 1,
|
||||
images: [
|
||||
expect.objectContaining({
|
||||
id: "shared",
|
||||
roles: ["sprite"],
|
||||
tileSymbol: "",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
import { isPlainObject, type JsonObject, type JsonValue } from "../contracts/json";
|
||||
import { getDirectSpriteRows, getRawImageFrames, getSpriteRows, normalizeRowsToSize } from "../graphics/rowEncoding";
|
||||
import { normalizeStringList } from "../shared/normalization";
|
||||
|
||||
const IMAGES_ROOT = "images";
|
||||
const SPRITES_ROOT = "sprites";
|
||||
const TILES_ROOT = "tiles";
|
||||
|
||||
function createRandomIdFragment(): string {
|
||||
try {
|
||||
const bytes = new Uint8Array(5);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes).map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
} catch {
|
||||
return Math.random().toString(16).slice(2, 12);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageRoles(value: JsonValue | undefined): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((entry) => String(entry || "").trim().toLowerCase())
|
||||
.filter((entry) => entry === "tile" || entry === "sprite"),
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeSpriteLikeRecord(record: JsonObject, defaultIdPrefix: string): JsonObject {
|
||||
const nextRecord: JsonObject = { ...record };
|
||||
const id = String(nextRecord.id ?? "").trim();
|
||||
nextRecord.id = id || `${defaultIdPrefix}_${createRandomIdFragment()}`;
|
||||
|
||||
if (typeof nextRecord.name !== "string") {
|
||||
nextRecord.name = "";
|
||||
}
|
||||
|
||||
const width = Number(nextRecord.width);
|
||||
const height = Number(nextRecord.height);
|
||||
const pixelScale = Number(nextRecord.pixelScale);
|
||||
const opacity = Number(nextRecord.opacity);
|
||||
nextRecord.width = Number.isFinite(width) && width > 0 ? Math.floor(width) : 1;
|
||||
nextRecord.height = Number.isFinite(height) && height > 0 ? Math.floor(height) : 1;
|
||||
nextRecord.pixelScale = Number.isFinite(pixelScale) && pixelScale > 0 ? Math.floor(pixelScale) : 1;
|
||||
nextRecord.opacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;
|
||||
|
||||
delete nextRecord.palette;
|
||||
|
||||
nextRecord.rows = normalizeRowsToSize(
|
||||
getDirectSpriteRows(nextRecord),
|
||||
Number(nextRecord.width) || 1,
|
||||
Number(nextRecord.height) || 1,
|
||||
) as unknown as JsonValue;
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
function normalizeImageFrameRecord(
|
||||
frame: JsonObject,
|
||||
fallbackRecord: JsonObject,
|
||||
index: number,
|
||||
): JsonObject {
|
||||
const nextFrameId = String(frame.id || "").trim() || `frame_${index}`;
|
||||
const normalizedFrame = normalizeSpriteLikeRecord({
|
||||
...frame,
|
||||
id: nextFrameId,
|
||||
width: Number(frame.width) || Number(fallbackRecord.width) || 1,
|
||||
height: Number(frame.height) || Number(fallbackRecord.height) || 1,
|
||||
pixelScale: Number(frame.pixelScale) || Number(fallbackRecord.pixelScale) || 1,
|
||||
opacity: Number(frame.opacity ?? fallbackRecord.opacity ?? 1),
|
||||
rows: Array.isArray(frame.rows) ? frame.rows : getDirectSpriteRows(fallbackRecord),
|
||||
}, "frame");
|
||||
return {
|
||||
...normalizedFrame,
|
||||
id: nextFrameId,
|
||||
enabled: frame.enabled !== false,
|
||||
index: Number.isFinite(Number(frame.index)) ? Math.max(0, Math.floor(Number(frame.index))) : index,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSpritePayloadForSave(payload: JsonValue): JsonValue {
|
||||
if (!isPlainObject(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const records = payload[SPRITES_ROOT];
|
||||
if (!Array.isArray(records)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
[SPRITES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeSpriteLikeRecord(entry, "sprite") : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTileRecordForSave(record: JsonObject): JsonObject {
|
||||
const nextRecord = normalizeSpriteLikeRecord(record, "tile");
|
||||
const symbol = String(nextRecord.symbol ?? "").trim().charAt(0);
|
||||
nextRecord.symbol = symbol || String(nextRecord.id || "T").charAt(0) || "T";
|
||||
if (typeof nextRecord.description !== "string") {
|
||||
nextRecord.description = "";
|
||||
}
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
export function normalizeTilesPayloadForSave(payload: JsonValue): JsonValue {
|
||||
if (!isPlainObject(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const records = payload[TILES_ROOT];
|
||||
if (!Array.isArray(records)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
[TILES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeTileRecordForSave(entry) : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeImagePlayback(value: JsonValue | undefined): "normal" | "rewind" | "stop" {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "rewind" || normalized === "stop") {
|
||||
return normalized;
|
||||
}
|
||||
return "normal";
|
||||
}
|
||||
|
||||
export function normalizeImageRecordForSave(record: JsonObject): JsonObject {
|
||||
const nextRecord = normalizeSpriteLikeRecord(record, "image");
|
||||
const roles = normalizeImageRoles(nextRecord.roles);
|
||||
const inputFrames = getRawImageFrames(record);
|
||||
const explicitRows = getDirectSpriteRows(record);
|
||||
nextRecord.description = typeof nextRecord.description === "string" ? nextRecord.description : "";
|
||||
nextRecord.tags = normalizeStringList(nextRecord.tags);
|
||||
nextRecord.roles = roles as unknown as JsonValue;
|
||||
let normalizedFrames = inputFrames.map((entry, index) => normalizeImageFrameRecord(entry, nextRecord, index));
|
||||
if (normalizedFrames.length <= 0) {
|
||||
normalizedFrames = [normalizeImageFrameRecord({
|
||||
id: "frame_0",
|
||||
rows: explicitRows.length > 0 ? explicitRows : getDirectSpriteRows(nextRecord),
|
||||
}, nextRecord, 0)];
|
||||
}
|
||||
const requestedDefaultFrameId = String(record.defaultFrame || nextRecord.defaultFrame || "").trim();
|
||||
const resolvedDefaultFrameId = String(
|
||||
normalizedFrames.find((entry) => String(entry.id || "").trim() === requestedDefaultFrameId)?.id
|
||||
|| normalizedFrames[0]?.id
|
||||
|| "frame_0",
|
||||
).trim() || "frame_0";
|
||||
if (explicitRows.length > 0) {
|
||||
normalizedFrames = normalizedFrames.map((entry) => (
|
||||
String(entry.id || "").trim() !== resolvedDefaultFrameId
|
||||
? entry
|
||||
: normalizeImageFrameRecord({
|
||||
...entry,
|
||||
id: resolvedDefaultFrameId,
|
||||
rows: explicitRows,
|
||||
width: Number(nextRecord.width) || 1,
|
||||
height: Number(nextRecord.height) || 1,
|
||||
pixelScale: Number(nextRecord.pixelScale) || 1,
|
||||
opacity: Number(nextRecord.opacity ?? 1),
|
||||
}, nextRecord, normalizedFrames.findIndex((candidate) => String(candidate.id || "").trim() === resolvedDefaultFrameId))
|
||||
));
|
||||
}
|
||||
nextRecord.defaultFrame = resolvedDefaultFrameId;
|
||||
nextRecord.speed = Number.isFinite(Number(record.speed)) && Number(record.speed) >= 0 ? Number(record.speed) : 0;
|
||||
nextRecord.playback = normalizeImagePlayback(record.playback);
|
||||
nextRecord.frames = normalizedFrames as unknown as JsonValue;
|
||||
nextRecord.tileSymbol = roles.includes("tile")
|
||||
? (String(nextRecord.tileSymbol ?? nextRecord.symbol ?? "").trim().charAt(0) || String(nextRecord.id || "T").charAt(0) || "T")
|
||||
: "";
|
||||
delete nextRecord.rows;
|
||||
delete nextRecord.symbol;
|
||||
delete nextRecord.graphicRole;
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
export function normalizeImagesPayloadForSave(payload: JsonValue): JsonValue {
|
||||
if (!isPlainObject(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const records = payload[IMAGES_ROOT];
|
||||
if (!Array.isArray(records)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
[IMAGES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeImageRecordForSave(entry) : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTilesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
|
||||
const normalizedPayload = normalizeImagesPayloadForSave(payload);
|
||||
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
|
||||
? normalizedPayload.images
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
|
||||
tiles: records
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry))
|
||||
.filter((entry) => normalizeImageRoles(entry.roles).includes("tile"))
|
||||
.map((entry) => normalizeTileRecordForSave({
|
||||
id: String(entry.id || "").trim(),
|
||||
symbol: String(entry.tileSymbol || entry.symbol || "").trim().charAt(0),
|
||||
name: String(entry.name || "").trim(),
|
||||
description: String(entry.description || "").trim(),
|
||||
width: Number(entry.width) || 16,
|
||||
height: Number(entry.height) || 16,
|
||||
pixelScale: Number(entry.pixelScale) || 1,
|
||||
opacity: Number(entry.opacity ?? 1),
|
||||
rows: getSpriteRows(entry),
|
||||
tags: normalizeStringList(entry.tags),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSpritesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
|
||||
const normalizedPayload = normalizeImagesPayloadForSave(payload);
|
||||
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
|
||||
? normalizedPayload.images
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
|
||||
sprites: records
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry))
|
||||
.filter((entry) => {
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
return roles.includes("sprite") || roles.length === 0;
|
||||
})
|
||||
.map((entry) => {
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
return normalizeSpriteLikeRecord({
|
||||
id: String(entry.id || "").trim(),
|
||||
name: String(entry.name || "").trim(),
|
||||
description: String(entry.description || "").trim(),
|
||||
width: Number(entry.width) || 16,
|
||||
height: Number(entry.height) || 16,
|
||||
pixelScale: Number(entry.pixelScale) || 1,
|
||||
opacity: Number(entry.opacity ?? 1),
|
||||
rows: getSpriteRows(entry),
|
||||
tags: normalizeStringList(entry.tags),
|
||||
graphicRole: roles.includes("sprite") ? "sprite" : "other",
|
||||
}, "sprite");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImagesPayloadWithTilesPayload(imagesPayload: JsonValue, tilesPayload: JsonValue): JsonObject {
|
||||
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
|
||||
const normalizedTilesPayload = normalizeTilesPayloadForSave(tilesPayload);
|
||||
const nextImagesById = new Map<string, JsonObject>();
|
||||
const nextOrder: string[] = [];
|
||||
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
|
||||
? normalizedImagesPayload.images
|
||||
: [];
|
||||
existingImages.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedEntry = normalizeImageRecordForSave(entry);
|
||||
const id = String(normalizedEntry.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizedEntry);
|
||||
nextOrder.push(id);
|
||||
});
|
||||
const incomingTiles = isPlainObject(normalizedTilesPayload) && Array.isArray(normalizedTilesPayload.tiles)
|
||||
? normalizedTilesPayload.tiles
|
||||
: [];
|
||||
const seenTileIds = new Set<string>();
|
||||
incomingTiles.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedTile = normalizeTileRecordForSave(entry);
|
||||
const id = String(normalizedTile.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
seenTileIds.add(id);
|
||||
const existing = nextImagesById.get(id);
|
||||
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
|
||||
const nextImage = normalizeImageRecordForSave({
|
||||
...(existing || {}),
|
||||
id,
|
||||
name: String(normalizedTile.name || existing?.name || "").trim(),
|
||||
description: String(normalizedTile.description || existing?.description || "").trim(),
|
||||
width: Number(normalizedTile.width) || Number(existing?.width) || 16,
|
||||
height: Number(normalizedTile.height) || Number(existing?.height) || 16,
|
||||
pixelScale: Number(normalizedTile.pixelScale) || Number(existing?.pixelScale) || 1,
|
||||
opacity: Number(normalizedTile.opacity ?? existing?.opacity ?? 1),
|
||||
rows: getSpriteRows(normalizedTile),
|
||||
tags: normalizeStringList(normalizedTile.tags ?? existing?.tags),
|
||||
roles: Array.from(new Set([...existingRoles, "tile"])),
|
||||
tileSymbol: String(normalizedTile.symbol || existing?.tileSymbol || "").trim().charAt(0),
|
||||
});
|
||||
if (!nextImagesById.has(id)) {
|
||||
nextOrder.push(id);
|
||||
}
|
||||
nextImagesById.set(id, nextImage);
|
||||
});
|
||||
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
if (!roles.includes("tile") || seenTileIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
const nextRoles = roles.filter((role) => role !== "tile");
|
||||
if (nextRoles.length === 0) {
|
||||
nextImagesById.delete(id);
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizeImageRecordForSave({
|
||||
...entry,
|
||||
roles: nextRoles,
|
||||
tileSymbol: "",
|
||||
}));
|
||||
});
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
|
||||
images: nextOrder
|
||||
.map((id) => nextImagesById.get(id))
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImagesPayloadWithSpritesPayload(imagesPayload: JsonValue, spritesPayload: JsonValue): JsonObject {
|
||||
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
|
||||
const normalizedSpritesPayload = normalizeSpritePayloadForSave(spritesPayload);
|
||||
const nextImagesById = new Map<string, JsonObject>();
|
||||
const nextOrder: string[] = [];
|
||||
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
|
||||
? normalizedImagesPayload.images
|
||||
: [];
|
||||
existingImages.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedEntry = normalizeImageRecordForSave(entry);
|
||||
const id = String(normalizedEntry.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizedEntry);
|
||||
nextOrder.push(id);
|
||||
});
|
||||
const incomingSprites = isPlainObject(normalizedSpritesPayload) && Array.isArray(normalizedSpritesPayload.sprites)
|
||||
? normalizedSpritesPayload.sprites
|
||||
: [];
|
||||
const seenSpriteIds = new Set<string>();
|
||||
incomingSprites.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedSprite = normalizeSpriteLikeRecord(entry, "sprite");
|
||||
const id = String(normalizedSprite.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
seenSpriteIds.add(id);
|
||||
const existing = nextImagesById.get(id);
|
||||
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
|
||||
const wantsSpriteRole = String(normalizedSprite.graphicRole || "sprite").trim().toLowerCase() !== "other";
|
||||
const nextRoles = wantsSpriteRole
|
||||
? Array.from(new Set([...existingRoles, "sprite"]))
|
||||
: existingRoles.filter((role) => role !== "sprite");
|
||||
const nextImage = normalizeImageRecordForSave({
|
||||
...(existing || {}),
|
||||
id,
|
||||
name: String(normalizedSprite.name || existing?.name || "").trim(),
|
||||
description: String(normalizedSprite.description || existing?.description || "").trim(),
|
||||
width: Number(normalizedSprite.width) || Number(existing?.width) || 16,
|
||||
height: Number(normalizedSprite.height) || Number(existing?.height) || 16,
|
||||
pixelScale: Number(normalizedSprite.pixelScale) || Number(existing?.pixelScale) || 1,
|
||||
opacity: Number(normalizedSprite.opacity ?? existing?.opacity ?? 1),
|
||||
rows: getSpriteRows(normalizedSprite),
|
||||
tags: normalizeStringList(normalizedSprite.tags ?? existing?.tags),
|
||||
roles: nextRoles,
|
||||
tileSymbol: String(existing?.tileSymbol || "").trim().charAt(0),
|
||||
});
|
||||
if (!nextImagesById.has(id)) {
|
||||
nextOrder.push(id);
|
||||
}
|
||||
if (nextRoles.length === 0 && !normalizeImageRoles(existing?.roles).includes("tile")) {
|
||||
nextImagesById.delete(id);
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, nextImage);
|
||||
});
|
||||
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
|
||||
if (seenSpriteIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
if (!roles.includes("sprite")) {
|
||||
return;
|
||||
}
|
||||
const nextRoles = roles.filter((role) => role !== "sprite");
|
||||
if (nextRoles.length === 0) {
|
||||
nextImagesById.delete(id);
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizeImageRecordForSave({
|
||||
...entry,
|
||||
roles: nextRoles,
|
||||
}));
|
||||
});
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
|
||||
images: nextOrder
|
||||
.map((id) => nextImagesById.get(id))
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry)),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
export function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import {
|
||||
resolveUnifiedColorSymbol,
|
||||
setUnifiedColorEntries,
|
||||
} from "./editorCore";
|
||||
|
||||
describe("editorCore", () => {
|
||||
it("updates the unified color lookup from catalog entries", () => {
|
||||
setUnifiedColorEntries([
|
||||
{ key: "A", color: "#123456" },
|
||||
{ key: "!", color: "#FFFFFF" },
|
||||
]);
|
||||
|
||||
expect(resolveUnifiedColorSymbol("A")).toBe("#123456");
|
||||
expect(resolveUnifiedColorSymbol(".")).toBe("#00000000");
|
||||
expect(resolveUnifiedColorSymbol("Z", "#ABCDEF")).toBe("#ABCDEF");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,24 +1,6 @@
|
|||
|
||||
import { getSpriteRows } from "./graphics/rowEncoding";
|
||||
import { isPlainObject, type JsonObject, type JsonValue } from "./contracts/json";
|
||||
|
||||
export type { JsonObject, JsonValue } from "./contracts/json";
|
||||
export { isPlainObject } from "./contracts/json";
|
||||
export { normalizeHexColor, normalizeStringList, parseCsv } from "./shared/normalization";
|
||||
export { getSpriteRows } from "./graphics/rowEncoding";
|
||||
export {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
mergeImagesPayloadWithSpritesPayload,
|
||||
mergeImagesPayloadWithTilesPayload,
|
||||
normalizeImagePlayback,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeImagesPayloadForSave,
|
||||
normalizeSpritePayloadForSave,
|
||||
normalizeTileRecordForSave,
|
||||
normalizeTilesPayloadForSave,
|
||||
} from "./contentTransforms/graphicsPayload";
|
||||
|
||||
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
export type CatalogEntry = {
|
||||
entryId?: string;
|
||||
sourceKey?: string;
|
||||
|
|
@ -332,6 +314,10 @@ export function formatTypeLabel(type: string): string {
|
|||
return TYPE_LABELS[type] || type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export function isPlainObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function buildDefaultRecord(activeType: string, records: JsonObject[]): JsonObject {
|
||||
if (activeType === "quests") {
|
||||
const maxQuestId = records.reduce((acc, entry) => {
|
||||
|
|
@ -454,6 +440,455 @@ export function getRecordLabel(record: JsonObject, index: number): string {
|
|||
return `Record ${index + 1}`;
|
||||
}
|
||||
|
||||
export function normalizeHexColor(value: JsonValue | undefined, fallback = "#7aa2ff"): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw)) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getDirectSpriteRows(record: JsonObject): string[] {
|
||||
const rawRows = record.rows;
|
||||
if (!Array.isArray(rawRows)) {
|
||||
return [];
|
||||
}
|
||||
return rawRows.map((row) => String(row || ""));
|
||||
}
|
||||
|
||||
function getRawImageFrames(record: JsonObject): JsonObject[] {
|
||||
if (!Array.isArray(record.frames)) {
|
||||
return [];
|
||||
}
|
||||
return record.frames.filter((entry): entry is JsonObject => isPlainObject(entry));
|
||||
}
|
||||
|
||||
function getSortedImageFrames(record: JsonObject): JsonObject[] {
|
||||
return getRawImageFrames(record)
|
||||
.map((entry, index) => ({
|
||||
entry,
|
||||
sortIndex: Number.isFinite(Number(entry.index)) ? Number(entry.index) : index,
|
||||
sourceIndex: index,
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.sortIndex !== right.sortIndex
|
||||
? left.sortIndex - right.sortIndex
|
||||
: left.sourceIndex - right.sourceIndex
|
||||
))
|
||||
.map((entry) => entry.entry);
|
||||
}
|
||||
|
||||
export function getSpriteRows(record: JsonObject): string[] {
|
||||
const frames = getSortedImageFrames(record);
|
||||
if (frames.length > 0) {
|
||||
const defaultFrameId = String(record.defaultFrame || "").trim();
|
||||
const enabledFrames = frames.filter((entry) => entry.enabled !== false);
|
||||
const renderFrames = enabledFrames.length > 0 ? enabledFrames : frames;
|
||||
const resolvedFrame = renderFrames.find((entry) => String(entry.id || "").trim() === defaultFrameId) || renderFrames[0];
|
||||
return getDirectSpriteRows(resolvedFrame);
|
||||
}
|
||||
return getDirectSpriteRows(record);
|
||||
}
|
||||
|
||||
function normalizeImageFrameRecord(
|
||||
frame: JsonObject,
|
||||
fallbackRecord: JsonObject,
|
||||
index: number,
|
||||
): JsonObject {
|
||||
const nextFrameId = String(frame.id || "").trim() || `frame_${index}`;
|
||||
const normalizedFrame = normalizeSpriteLikeRecord({
|
||||
...frame,
|
||||
id: nextFrameId,
|
||||
width: Number(frame.width) || Number(fallbackRecord.width) || 1,
|
||||
height: Number(frame.height) || Number(fallbackRecord.height) || 1,
|
||||
pixelScale: Number(frame.pixelScale) || Number(fallbackRecord.pixelScale) || 1,
|
||||
opacity: Number(frame.opacity ?? fallbackRecord.opacity ?? 1),
|
||||
rows: Array.isArray(frame.rows) ? frame.rows : getDirectSpriteRows(fallbackRecord),
|
||||
}, "frame");
|
||||
return {
|
||||
...normalizedFrame,
|
||||
id: nextFrameId,
|
||||
enabled: frame.enabled !== false,
|
||||
index: Number.isFinite(Number(frame.index)) ? Math.max(0, Math.floor(Number(frame.index))) : index,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpriteLikeRecord(record: JsonObject, defaultIdPrefix: string): JsonObject {
|
||||
const nextRecord: JsonObject = { ...record };
|
||||
const id = String(nextRecord.id ?? "").trim();
|
||||
nextRecord.id = id || `${defaultIdPrefix}_${genRandomId()}`;
|
||||
|
||||
if (typeof nextRecord.name !== "string") {
|
||||
nextRecord.name = "";
|
||||
}
|
||||
|
||||
const width = Number(nextRecord.width);
|
||||
const height = Number(nextRecord.height);
|
||||
const pixelScale = Number(nextRecord.pixelScale);
|
||||
const opacity = Number(nextRecord.opacity);
|
||||
nextRecord.width = Number.isFinite(width) && width > 0 ? Math.floor(width) : 1;
|
||||
nextRecord.height = Number.isFinite(height) && height > 0 ? Math.floor(height) : 1;
|
||||
nextRecord.pixelScale = Number.isFinite(pixelScale) && pixelScale > 0 ? Math.floor(pixelScale) : 1;
|
||||
nextRecord.opacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;
|
||||
|
||||
delete nextRecord.palette;
|
||||
|
||||
const rows = getDirectSpriteRows(nextRecord);
|
||||
const normalizedRows = Array.from({ length: Number(nextRecord.height) || 1 }, (_, rowIndex) => {
|
||||
const base = rows[rowIndex] || "";
|
||||
return base.padEnd(Number(nextRecord.width) || 1, ".").slice(0, Number(nextRecord.width) || 1);
|
||||
});
|
||||
nextRecord.rows = normalizedRows as unknown as JsonValue;
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
export function normalizeSpritePayloadForSave(payload: JsonValue): JsonValue {
|
||||
if (!isPlainObject(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const root = ROOT_KEY_BY_TYPE.sprites;
|
||||
const records = payload[root];
|
||||
if (!Array.isArray(records)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
[root]: records.map((entry) => (isPlainObject(entry) ? normalizeSpriteLikeRecord(entry, "sprite") : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTileRecordForSave(record: JsonObject): JsonObject {
|
||||
const nextRecord = normalizeSpriteLikeRecord(record, "tile");
|
||||
const symbol = String(nextRecord.symbol ?? "").trim().charAt(0);
|
||||
nextRecord.symbol = symbol || String(nextRecord.id || "T").charAt(0) || "T";
|
||||
if (typeof nextRecord.description !== "string") {
|
||||
nextRecord.description = "";
|
||||
}
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
export function normalizeTilesPayloadForSave(payload: JsonValue): JsonValue {
|
||||
if (!isPlainObject(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const root = ROOT_KEY_BY_TYPE.tiles;
|
||||
const records = payload[root];
|
||||
if (!Array.isArray(records)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
[root]: records.map((entry) => (isPlainObject(entry) ? normalizeTileRecordForSave(entry) : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImageRoles(value: JsonValue | undefined): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((entry) => String(entry || "").trim().toLowerCase())
|
||||
.filter((entry) => entry === "tile" || entry === "sprite"),
|
||||
));
|
||||
}
|
||||
|
||||
export function normalizeImagePlayback(value: JsonValue | undefined): "normal" | "rewind" | "stop" {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "rewind" || normalized === "stop") {
|
||||
return normalized;
|
||||
}
|
||||
return "normal";
|
||||
}
|
||||
|
||||
export function normalizeImageRecordForSave(record: JsonObject): JsonObject {
|
||||
const nextRecord = normalizeSpriteLikeRecord(record, "image");
|
||||
const roles = normalizeImageRoles(nextRecord.roles);
|
||||
const inputFrames = getRawImageFrames(record);
|
||||
const explicitRows = getDirectSpriteRows(record);
|
||||
nextRecord.description = typeof nextRecord.description === "string" ? nextRecord.description : "";
|
||||
nextRecord.tags = normalizeStringList(nextRecord.tags);
|
||||
nextRecord.roles = roles as unknown as JsonValue;
|
||||
let normalizedFrames = inputFrames.map((entry, index) => normalizeImageFrameRecord(entry, nextRecord, index));
|
||||
if (normalizedFrames.length <= 0) {
|
||||
normalizedFrames = [normalizeImageFrameRecord({
|
||||
id: "frame_0",
|
||||
rows: explicitRows.length > 0 ? explicitRows : getDirectSpriteRows(nextRecord),
|
||||
}, nextRecord, 0)];
|
||||
}
|
||||
const requestedDefaultFrameId = String(record.defaultFrame || nextRecord.defaultFrame || "").trim();
|
||||
const resolvedDefaultFrameId = String(
|
||||
normalizedFrames.find((entry) => String(entry.id || "").trim() === requestedDefaultFrameId)?.id
|
||||
|| normalizedFrames[0]?.id
|
||||
|| "frame_0",
|
||||
).trim() || "frame_0";
|
||||
if (explicitRows.length > 0) {
|
||||
normalizedFrames = normalizedFrames.map((entry) => (
|
||||
String(entry.id || "").trim() !== resolvedDefaultFrameId
|
||||
? entry
|
||||
: normalizeImageFrameRecord({
|
||||
...entry,
|
||||
id: resolvedDefaultFrameId,
|
||||
rows: explicitRows,
|
||||
width: Number(nextRecord.width) || 1,
|
||||
height: Number(nextRecord.height) || 1,
|
||||
pixelScale: Number(nextRecord.pixelScale) || 1,
|
||||
opacity: Number(nextRecord.opacity ?? 1),
|
||||
}, nextRecord, normalizedFrames.findIndex((candidate) => String(candidate.id || "").trim() === resolvedDefaultFrameId))
|
||||
));
|
||||
}
|
||||
nextRecord.defaultFrame = resolvedDefaultFrameId;
|
||||
nextRecord.speed = Number.isFinite(Number(record.speed)) && Number(record.speed) >= 0 ? Number(record.speed) : 0;
|
||||
nextRecord.playback = normalizeImagePlayback(record.playback);
|
||||
nextRecord.frames = normalizedFrames as unknown as JsonValue;
|
||||
nextRecord.tileSymbol = roles.includes("tile")
|
||||
? (String(nextRecord.tileSymbol ?? nextRecord.symbol ?? "").trim().charAt(0) || String(nextRecord.id || "T").charAt(0) || "T")
|
||||
: "";
|
||||
delete nextRecord.rows;
|
||||
delete nextRecord.symbol;
|
||||
delete nextRecord.graphicRole;
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
export function normalizeImagesPayloadForSave(payload: JsonValue): JsonValue {
|
||||
if (!isPlainObject(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const records = payload.images;
|
||||
if (!Array.isArray(records)) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
images: records.map((entry) => (isPlainObject(entry) ? normalizeImageRecordForSave(entry) : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTilesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
|
||||
const normalizedPayload = normalizeImagesPayloadForSave(payload);
|
||||
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
|
||||
? normalizedPayload.images
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
|
||||
tiles: records
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry))
|
||||
.filter((entry) => normalizeImageRoles(entry.roles).includes("tile"))
|
||||
.map((entry) => normalizeTileRecordForSave({
|
||||
id: String(entry.id || "").trim(),
|
||||
symbol: String(entry.tileSymbol || entry.symbol || "").trim().charAt(0),
|
||||
name: String(entry.name || "").trim(),
|
||||
description: String(entry.description || "").trim(),
|
||||
width: Number(entry.width) || 16,
|
||||
height: Number(entry.height) || 16,
|
||||
pixelScale: Number(entry.pixelScale) || 1,
|
||||
opacity: Number(entry.opacity ?? 1),
|
||||
rows: getSpriteRows(entry),
|
||||
tags: normalizeStringList(entry.tags),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSpritesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
|
||||
const normalizedPayload = normalizeImagesPayloadForSave(payload);
|
||||
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
|
||||
? normalizedPayload.images
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
|
||||
sprites: records
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry))
|
||||
.filter((entry) => {
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
return roles.includes("sprite") || roles.length === 0;
|
||||
})
|
||||
.map((entry) => {
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
return normalizeSpriteLikeRecord({
|
||||
id: String(entry.id || "").trim(),
|
||||
name: String(entry.name || "").trim(),
|
||||
description: String(entry.description || "").trim(),
|
||||
width: Number(entry.width) || 16,
|
||||
height: Number(entry.height) || 16,
|
||||
pixelScale: Number(entry.pixelScale) || 1,
|
||||
opacity: Number(entry.opacity ?? 1),
|
||||
rows: getSpriteRows(entry),
|
||||
tags: normalizeStringList(entry.tags),
|
||||
graphicRole: roles.includes("sprite") ? "sprite" : "other",
|
||||
}, "sprite");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImagesPayloadWithTilesPayload(imagesPayload: JsonValue, tilesPayload: JsonValue): JsonObject {
|
||||
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
|
||||
const normalizedTilesPayload = normalizeTilesPayloadForSave(tilesPayload);
|
||||
const nextImagesById = new Map<string, JsonObject>();
|
||||
const nextOrder: string[] = [];
|
||||
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
|
||||
? normalizedImagesPayload.images
|
||||
: [];
|
||||
existingImages.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedEntry = normalizeImageRecordForSave(entry);
|
||||
const id = String(normalizedEntry.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizedEntry);
|
||||
nextOrder.push(id);
|
||||
});
|
||||
const incomingTiles = isPlainObject(normalizedTilesPayload) && Array.isArray(normalizedTilesPayload.tiles)
|
||||
? normalizedTilesPayload.tiles
|
||||
: [];
|
||||
const seenTileIds = new Set<string>();
|
||||
incomingTiles.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedTile = normalizeTileRecordForSave(entry);
|
||||
const id = String(normalizedTile.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
seenTileIds.add(id);
|
||||
const existing = nextImagesById.get(id);
|
||||
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
|
||||
const nextImage = normalizeImageRecordForSave({
|
||||
...(existing || {}),
|
||||
id,
|
||||
name: String(normalizedTile.name || existing?.name || "").trim(),
|
||||
description: String(normalizedTile.description || existing?.description || "").trim(),
|
||||
width: Number(normalizedTile.width) || Number(existing?.width) || 16,
|
||||
height: Number(normalizedTile.height) || Number(existing?.height) || 16,
|
||||
pixelScale: Number(normalizedTile.pixelScale) || Number(existing?.pixelScale) || 1,
|
||||
opacity: Number(normalizedTile.opacity ?? existing?.opacity ?? 1),
|
||||
rows: getSpriteRows(normalizedTile),
|
||||
tags: normalizeStringList(normalizedTile.tags ?? existing?.tags),
|
||||
roles: Array.from(new Set([...existingRoles, "tile"])),
|
||||
tileSymbol: String(normalizedTile.symbol || existing?.tileSymbol || "").trim().charAt(0),
|
||||
});
|
||||
if (!nextImagesById.has(id)) {
|
||||
nextOrder.push(id);
|
||||
}
|
||||
nextImagesById.set(id, nextImage);
|
||||
});
|
||||
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
if (!roles.includes("tile") || seenTileIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
const nextRoles = roles.filter((role) => role !== "tile");
|
||||
if (nextRoles.length === 0) {
|
||||
nextImagesById.delete(id);
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizeImageRecordForSave({
|
||||
...entry,
|
||||
roles: nextRoles,
|
||||
tileSymbol: "",
|
||||
}));
|
||||
});
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
|
||||
images: nextOrder
|
||||
.map((id) => nextImagesById.get(id))
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImagesPayloadWithSpritesPayload(imagesPayload: JsonValue, spritesPayload: JsonValue): JsonObject {
|
||||
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
|
||||
const normalizedSpritesPayload = normalizeSpritePayloadForSave(spritesPayload);
|
||||
const nextImagesById = new Map<string, JsonObject>();
|
||||
const nextOrder: string[] = [];
|
||||
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
|
||||
? normalizedImagesPayload.images
|
||||
: [];
|
||||
existingImages.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedEntry = normalizeImageRecordForSave(entry);
|
||||
const id = String(normalizedEntry.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizedEntry);
|
||||
nextOrder.push(id);
|
||||
});
|
||||
const incomingSprites = isPlainObject(normalizedSpritesPayload) && Array.isArray(normalizedSpritesPayload.sprites)
|
||||
? normalizedSpritesPayload.sprites
|
||||
: [];
|
||||
const seenSpriteIds = new Set<string>();
|
||||
incomingSprites.forEach((entry) => {
|
||||
if (!isPlainObject(entry)) {
|
||||
return;
|
||||
}
|
||||
const normalizedSprite = normalizeSpriteLikeRecord(entry, "sprite");
|
||||
const id = String(normalizedSprite.id || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
seenSpriteIds.add(id);
|
||||
const existing = nextImagesById.get(id);
|
||||
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
|
||||
const wantsSpriteRole = String(normalizedSprite.graphicRole || "sprite").trim().toLowerCase() !== "other";
|
||||
const nextRoles = wantsSpriteRole
|
||||
? Array.from(new Set([...existingRoles, "sprite"]))
|
||||
: existingRoles.filter((role) => role !== "sprite");
|
||||
const nextImage = normalizeImageRecordForSave({
|
||||
...(existing || {}),
|
||||
id,
|
||||
name: String(normalizedSprite.name || existing?.name || "").trim(),
|
||||
description: String(normalizedSprite.description || existing?.description || "").trim(),
|
||||
width: Number(normalizedSprite.width) || Number(existing?.width) || 16,
|
||||
height: Number(normalizedSprite.height) || Number(existing?.height) || 16,
|
||||
pixelScale: Number(normalizedSprite.pixelScale) || Number(existing?.pixelScale) || 1,
|
||||
opacity: Number(normalizedSprite.opacity ?? existing?.opacity ?? 1),
|
||||
rows: getSpriteRows(normalizedSprite),
|
||||
tags: normalizeStringList(normalizedSprite.tags ?? existing?.tags),
|
||||
roles: nextRoles,
|
||||
tileSymbol: String(existing?.tileSymbol || "").trim().charAt(0),
|
||||
});
|
||||
if (!nextImagesById.has(id)) {
|
||||
nextOrder.push(id);
|
||||
}
|
||||
if (nextRoles.length === 0 && !normalizeImageRoles(existing?.roles).includes("tile")) {
|
||||
nextImagesById.delete(id);
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, nextImage);
|
||||
});
|
||||
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
|
||||
if (seenSpriteIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
const roles = normalizeImageRoles(entry.roles);
|
||||
if (!roles.includes("sprite")) {
|
||||
return;
|
||||
}
|
||||
const nextRoles = roles.filter((role) => role !== "sprite");
|
||||
if (nextRoles.length === 0) {
|
||||
nextImagesById.delete(id);
|
||||
return;
|
||||
}
|
||||
nextImagesById.set(id, normalizeImageRecordForSave({
|
||||
...entry,
|
||||
roles: nextRoles,
|
||||
}));
|
||||
});
|
||||
return {
|
||||
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
|
||||
images: nextOrder
|
||||
.map((id) => nextImagesById.get(id))
|
||||
.filter((entry): entry is JsonObject => isPlainObject(entry)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSpritePalette(record?: JsonObject): Record<string, string> {
|
||||
void record;
|
||||
const palette: Record<string, string> = {
|
||||
|
|
@ -586,6 +1021,20 @@ export function toFieldLabel(rawKey: string): string {
|
|||
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
|
||||
}
|
||||
|
||||
export function normalizeStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
export function parseCsv(value: string): string[] {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getCatalogEntryIdValue(entry: CatalogEntry | null | undefined, fallback = ""): string {
|
||||
return String(entry?.key || entry?.sourceKey || entry?.originalName || fallback).trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import { isPlainObject, type JsonObject } from "../contracts/json";
|
||||
|
||||
export function getDirectSpriteRows(record: JsonObject): string[] {
|
||||
const rawRows = record.rows;
|
||||
if (!Array.isArray(rawRows)) {
|
||||
return [];
|
||||
}
|
||||
return rawRows.map((row) => String(row || ""));
|
||||
}
|
||||
|
||||
export function normalizeRowsToSize(rows: string[], width: number, height: number, fillChar = "."): string[] {
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
return Array.from({ length: safeHeight }, (_, rowIndex) => {
|
||||
const base = String(rows[rowIndex] || "");
|
||||
return base.padEnd(safeWidth, fillChar).slice(0, safeWidth);
|
||||
});
|
||||
}
|
||||
|
||||
export function getRawImageFrames(record: JsonObject): JsonObject[] {
|
||||
if (!Array.isArray(record.frames)) {
|
||||
return [];
|
||||
}
|
||||
return record.frames.filter((entry): entry is JsonObject => isPlainObject(entry));
|
||||
}
|
||||
|
||||
export function getSortedImageFrames(record: JsonObject): JsonObject[] {
|
||||
return getRawImageFrames(record)
|
||||
.map((entry, index) => ({
|
||||
entry,
|
||||
sortIndex: Number.isFinite(Number(entry.index)) ? Number(entry.index) : index,
|
||||
sourceIndex: index,
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.sortIndex !== right.sortIndex
|
||||
? left.sortIndex - right.sortIndex
|
||||
: left.sourceIndex - right.sourceIndex
|
||||
))
|
||||
.map((entry) => entry.entry);
|
||||
}
|
||||
|
||||
export function getSpriteRows(record: JsonObject): string[] {
|
||||
const frames = getSortedImageFrames(record);
|
||||
if (frames.length > 0) {
|
||||
const defaultFrameId = String(record.defaultFrame || "").trim();
|
||||
const enabledFrames = frames.filter((entry) => entry.enabled !== false);
|
||||
const renderFrames = enabledFrames.length > 0 ? enabledFrames : frames;
|
||||
const resolvedFrame = renderFrames.find((entry) => String(entry.id || "").trim() === defaultFrameId) || renderFrames[0];
|
||||
return getDirectSpriteRows(resolvedFrame);
|
||||
}
|
||||
return getDirectSpriteRows(record);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { areRowsOnlyFillChar, normalizeBackgroundTileId, resolveContentPath } from "../../server/contentTransforms.js";
|
||||
|
||||
describe("server/contentTransforms", () => {
|
||||
it("normalizes background tile ids and optionally validates them against a known id map", () => {
|
||||
expect(normalizeBackgroundTileId(" grass ")).toBe("grass");
|
||||
expect(normalizeBackgroundTileId("", new Map([["grass", "#"]]))).toBe("");
|
||||
expect(normalizeBackgroundTileId("stone", new Map([["grass", "#"]]))).toBe("");
|
||||
expect(normalizeBackgroundTileId("grass", new Map([["grass", "#"]]))).toBe("grass");
|
||||
});
|
||||
|
||||
it("detects fill-only row payloads using the provided fill character", () => {
|
||||
expect(areRowsOnlyFillChar([], ".")).toBe(true);
|
||||
expect(areRowsOnlyFillChar(["...", ""], ".")).toBe(true);
|
||||
expect(areRowsOnlyFillChar([" "], " ")).toBe(true);
|
||||
expect(areRowsOnlyFillChar(["..x"], ".")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves content-relative paths without allowing leading slashes to escape intent", () => {
|
||||
expect(resolveContentPath("/workspace/content", "/worlds/overworld/world.json")).toBe("/workspace/content/worlds/overworld/world.json");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { validateCatalogMetaPayload, validatePayload } from "../../server/validation.js";
|
||||
|
||||
const REQUIRED_ID_KEY_BY_TYPE = {
|
||||
images: "id",
|
||||
quests: "questId",
|
||||
};
|
||||
|
||||
const FROZEN_CATALOG_KEYS = ["conditions", "itemActions", "systemActions", "effects", "colors"];
|
||||
|
||||
describe("server/validation", () => {
|
||||
it("validates standard catalog payload roots and required id keys", () => {
|
||||
expect(validatePayload({
|
||||
schemaVersion: 1,
|
||||
images: [{ id: "grass" }],
|
||||
}, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBeNull();
|
||||
|
||||
expect(validatePayload([], "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("Payload must be an object");
|
||||
expect(validatePayload({ images: [] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("schemaVersion must be a number");
|
||||
expect(validatePayload({ schemaVersion: 1, wrong: [] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("Unsupported top-level keys for images: wrong");
|
||||
expect(validatePayload({ schemaVersion: 1, images: [{}] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("images[0] is missing required key: id");
|
||||
});
|
||||
|
||||
it("skips per-entry id checks for types without configured required ids", () => {
|
||||
expect(validatePayload({
|
||||
schemaVersion: 1,
|
||||
custom: [{ anything: true }],
|
||||
}, "custom", "custom", REQUIRED_ID_KEY_BY_TYPE)).toBeNull();
|
||||
});
|
||||
|
||||
it("validates frozen catalog payload shape", () => {
|
||||
expect(validateCatalogMetaPayload({
|
||||
schemaVersion: 1,
|
||||
conditions: [],
|
||||
itemActions: [],
|
||||
systemActions: [],
|
||||
effects: [],
|
||||
colors: [],
|
||||
}, FROZEN_CATALOG_KEYS)).toBeNull();
|
||||
|
||||
expect(validateCatalogMetaPayload({
|
||||
schemaVersion: 1,
|
||||
conditions: [],
|
||||
itemActions: [],
|
||||
systemActions: [],
|
||||
effects: [],
|
||||
colors: [],
|
||||
extra: [],
|
||||
}, FROZEN_CATALOG_KEYS)).toBe("Unsupported catalog keys: extra");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import {
|
||||
buildWorldChunkFileName,
|
||||
defaultWorldDirRel,
|
||||
getWorldStoragePaths,
|
||||
normalizeWorldBookmark,
|
||||
normalizeWorldIndexEntry,
|
||||
normalizeWorldIndexPayload,
|
||||
sanitizeWorldId,
|
||||
} from "../../server/worldTransforms.js";
|
||||
|
||||
describe("server/worldTransforms", () => {
|
||||
it("sanitizes world ids and builds default world directories", () => {
|
||||
expect(sanitizeWorldId(" Over world!? ")).toBe("Over_world__");
|
||||
expect(sanitizeWorldId("")).toBe("world");
|
||||
expect(defaultWorldDirRel("overworld")).toBe("worlds/overworld");
|
||||
});
|
||||
|
||||
it("builds stable chunk file names", () => {
|
||||
expect(buildWorldChunkFileName(-3, 4)).toBe("-3_4.json");
|
||||
expect(buildWorldChunkFileName("2.9", "-1.1")).toBe("2_-2.json");
|
||||
});
|
||||
|
||||
it("resolves world storage paths from either a world id or index entry", () => {
|
||||
expect(getWorldStoragePaths("/repo/content", "overworld")).toEqual({
|
||||
worldId: "overworld",
|
||||
worldDirRel: "worlds/overworld",
|
||||
worldDirAbs: "/repo/content/worlds/overworld",
|
||||
worldJsonRel: "worlds/overworld/world.json",
|
||||
worldJsonAbs: "/repo/content/worlds/overworld/world.json",
|
||||
bookmarksRel: "worlds/overworld/bookmarks.json",
|
||||
bookmarksAbs: "/repo/content/worlds/overworld/bookmarks.json",
|
||||
chunksDirRel: "worlds/overworld/chunks",
|
||||
chunksDirAbs: "/repo/content/worlds/overworld/chunks",
|
||||
});
|
||||
|
||||
expect(getWorldStoragePaths("/repo/content", { id: "city", worldDir: "custom/worlds/city" })).toEqual(
|
||||
expect.objectContaining({
|
||||
worldId: "city",
|
||||
worldDirRel: "custom/worlds/city",
|
||||
chunksDirRel: "custom/worlds/city/chunks",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes world index entries and payloads", () => {
|
||||
expect(normalizeWorldIndexEntry({ id: "My World!", name: "", worldDir: "" })).toEqual({
|
||||
id: "My_World_",
|
||||
name: "My_World_",
|
||||
worldDir: "worlds/My_World_",
|
||||
});
|
||||
|
||||
expect(normalizeWorldIndexPayload({
|
||||
schemaVersion: 3,
|
||||
worlds: [
|
||||
{ id: "overworld", name: "Overworld" },
|
||||
null,
|
||||
[],
|
||||
],
|
||||
})).toEqual({
|
||||
schemaVersion: 3,
|
||||
worlds: [
|
||||
{
|
||||
id: "overworld",
|
||||
name: "Overworld",
|
||||
worldDir: "worlds/overworld",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes bookmark payload entries", () => {
|
||||
expect(normalizeWorldBookmark({ id: "", label: "", x: 2.9, y: -3.1 }, 1)).toEqual({
|
||||
id: "bookmark_2",
|
||||
label: "bookmark_2",
|
||||
x: 2,
|
||||
y: -4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import type { JsonValue } from "../contracts/json";
|
||||
|
||||
export function normalizeHexColor(value: JsonValue | undefined, fallback = "#7aa2ff"): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw)) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function normalizeStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
export function parseCsv(value: string): string[] {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import {
|
||||
DEFAULT_WORLD_CHUNK_SIZE,
|
||||
buildChunkFileName,
|
||||
buildChunkKey,
|
||||
createEmptyChunk,
|
||||
localToWorldCoord,
|
||||
normalizeChunkDimension,
|
||||
resolveWorldChunkAddress,
|
||||
worldToChunkCoord,
|
||||
worldToLocalCoord,
|
||||
} from "./worldChunking";
|
||||
|
||||
describe("worldChunking", () => {
|
||||
it("normalizes chunk dimensions with flooring and fallback behavior", () => {
|
||||
expect(normalizeChunkDimension(12.9)).toBe(12);
|
||||
expect(normalizeChunkDimension(0, 24)).toBe(24);
|
||||
expect(normalizeChunkDimension(-5, 24)).toBe(1);
|
||||
expect(normalizeChunkDimension("bad", 18)).toBe(18);
|
||||
});
|
||||
|
||||
it("converts world coordinates into chunk and local coordinates across the origin", () => {
|
||||
expect(worldToChunkCoord(0, 32)).toBe(0);
|
||||
expect(worldToChunkCoord(31, 32)).toBe(0);
|
||||
expect(worldToChunkCoord(32, 32)).toBe(1);
|
||||
expect(worldToChunkCoord(-1, 32)).toBe(-1);
|
||||
expect(worldToChunkCoord(-33, 32)).toBe(-2);
|
||||
|
||||
expect(worldToLocalCoord(31, 32)).toBe(31);
|
||||
expect(worldToLocalCoord(32, 32)).toBe(0);
|
||||
expect(worldToLocalCoord(-1, 32)).toBe(31);
|
||||
expect(worldToLocalCoord(-33, 32)).toBe(31);
|
||||
|
||||
expect(localToWorldCoord(-2, 31, 32)).toBe(-33);
|
||||
});
|
||||
|
||||
it("resolves stable chunk addressing metadata", () => {
|
||||
expect(buildChunkKey(-3, 4)).toBe("-3:4");
|
||||
expect(buildChunkFileName(-3, 4)).toBe("-3_4.json");
|
||||
expect(resolveWorldChunkAddress(-33, 64, 32, 16)).toEqual({
|
||||
chunkX: -2,
|
||||
chunkY: 4,
|
||||
localX: 31,
|
||||
localY: 0,
|
||||
chunkKey: "-2:4",
|
||||
fileName: "-2_4.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates empty chunks with the current compatibility layer defaults", () => {
|
||||
const chunk = createEmptyChunk("overworld", 2, -1, "grass", 4, 3);
|
||||
|
||||
expect(chunk).toEqual({
|
||||
schemaVersion: 1,
|
||||
worldId: "overworld",
|
||||
chunkX: 2,
|
||||
chunkY: -1,
|
||||
width: 4,
|
||||
height: 3,
|
||||
backgroundTileId: "grass",
|
||||
roomLayers: [
|
||||
{
|
||||
layer: 0,
|
||||
rows: ["....", "....", "...."],
|
||||
instanceIds: [],
|
||||
},
|
||||
{
|
||||
layer: 1,
|
||||
rows: [" ", " ", " "],
|
||||
instanceIds: [],
|
||||
},
|
||||
],
|
||||
heightLayers: [],
|
||||
instances: [],
|
||||
});
|
||||
expect(createEmptyChunk("world", 0, 0).width).toBe(DEFAULT_WORLD_CHUNK_SIZE);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JsonObject } from "./contracts/json";
|
||||
import type { JsonObject } from "./editorCore";
|
||||
|
||||
export const WORLD_INDEX_SCHEMA_VERSION = 1;
|
||||
export const WORLD_SCHEMA_VERSION = 1;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
buildDefaultRecord,
|
||||
buildSpritePreviewDataUrl,
|
||||
fetchJsonOrThrow,
|
||||
normalizeNpcRecordForLoad,
|
||||
type JsonObject,
|
||||
} from "../editorCore";
|
||||
import type { JsonObject } from "../contracts/json";
|
||||
import { buildSpritesPayloadFromImagesPayload, buildTilesPayloadFromImagesPayload } from "../contentTransforms/graphicsPayload";
|
||||
import type {
|
||||
HeightLayerPatchPayload,
|
||||
NpcOverlay,
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
import {
|
||||
buildImageRecordFromSpriteRecord,
|
||||
buildImageRecordFromTileRecord,
|
||||
buildTileRecordFromImageRecord,
|
||||
getImageRecordFromPayload,
|
||||
normalizeGraphicRoles,
|
||||
normalizeImagesPayloadSnapshot,
|
||||
} from "./graphicsDocumentHelpers";
|
||||
import type { JsonObject } from "../editorCore";
|
||||
|
||||
describe("graphicsDocumentHelpers", () => {
|
||||
it("keeps only supported graphic roles and de-duplicates them", () => {
|
||||
expect(normalizeGraphicRoles(["tile", "sprite", "tile", "other", "", null])).toEqual(["tile", "sprite"]);
|
||||
expect(normalizeGraphicRoles("tile")).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds tile-backed image records without inventing new storage rules", () => {
|
||||
const existingRecord: JsonObject = {
|
||||
id: "tile_grass",
|
||||
roles: ["sprite"],
|
||||
tileSymbol: "g",
|
||||
tags: ["existing"],
|
||||
};
|
||||
|
||||
const result = buildImageRecordFromTileRecord({
|
||||
id: "tile_grass",
|
||||
name: "Grass",
|
||||
symbol: "#",
|
||||
rows: ["AB", "C"],
|
||||
width: 2,
|
||||
height: 2,
|
||||
pixelScale: 3,
|
||||
opacity: 2,
|
||||
tags: ["terrain", "terrain"],
|
||||
}, existingRecord);
|
||||
|
||||
expect(result.roles).toEqual(["sprite", "tile"]);
|
||||
expect(result.tileSymbol).toBe("#");
|
||||
expect(result.rows).toBeUndefined();
|
||||
expect(result.defaultFrame).toBe("frame_0");
|
||||
expect(result.frames).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "frame_0",
|
||||
rows: ["AB", "C."],
|
||||
width: 2,
|
||||
height: 2,
|
||||
pixelScale: 3,
|
||||
opacity: 1,
|
||||
}),
|
||||
]);
|
||||
expect(result.tags).toEqual(["terrain"]);
|
||||
});
|
||||
|
||||
it("updates sprite roles while preserving compatibility fields from existing images", () => {
|
||||
const existingRecord: JsonObject = {
|
||||
id: "hero",
|
||||
roles: ["tile", "sprite"],
|
||||
tileSymbol: "@",
|
||||
};
|
||||
|
||||
const result = buildImageRecordFromSpriteRecord({
|
||||
id: "hero",
|
||||
rows: ["X"],
|
||||
width: 1,
|
||||
height: 1,
|
||||
}, "other", existingRecord);
|
||||
|
||||
expect(result.roles).toEqual(["tile"]);
|
||||
expect(result.tileSymbol).toBe("@");
|
||||
});
|
||||
|
||||
it("hydrates image rows from the resolved default frame", () => {
|
||||
const payload: JsonObject = {
|
||||
schemaVersion: 1,
|
||||
images: [
|
||||
{
|
||||
id: "tile_grass",
|
||||
roles: ["tile"],
|
||||
tileSymbol: "G",
|
||||
width: 2,
|
||||
height: 2,
|
||||
defaultFrame: "alt",
|
||||
frames: [
|
||||
{ id: "base", rows: ["AA", "AA"], enabled: true },
|
||||
{ id: "alt", rows: ["BB", "BB"], enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = normalizeImagesPayloadSnapshot(payload);
|
||||
const imageRecord = getImageRecordFromPayload(snapshot, "tile_grass");
|
||||
|
||||
expect(imageRecord).toEqual(expect.objectContaining({
|
||||
id: "tile_grass",
|
||||
rows: ["BB", "BB"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("projects tile records from image records through the current compatibility adapter", () => {
|
||||
const tileRecord = buildTileRecordFromImageRecord({
|
||||
id: "tile_grass",
|
||||
tileSymbol: "G",
|
||||
name: "Grass",
|
||||
description: "Ground",
|
||||
width: 2,
|
||||
height: 2,
|
||||
frames: [
|
||||
{ id: "frame_0", rows: ["AB", "CD"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(tileRecord).toEqual(expect.objectContaining({
|
||||
id: "tile_grass",
|
||||
symbol: "G",
|
||||
rows: ["AB", "CD"],
|
||||
width: 2,
|
||||
height: 2,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import {
|
||||
getSpriteRows,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeImagesPayloadForSave,
|
||||
normalizeTileRecordForSave,
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
import type { JsonObject, JsonValue } from "../contracts/json";
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "../editorCore";
|
||||
|
||||
export type GraphicRole = "tile" | "sprite" | "other";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '<h4>Change Preview</h4><div class="history-preview-empty">Select a history entry to inspect it.</div>';
|
||||
if (!scope.historyPreviewEl) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : [];
|
||||
const detailHtml = details.length > 0
|
||||
? "<ul>" + details.map((detail) => "<li>" + detail + "</li>").join("") + "</ul>"
|
||||
: '<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 =
|
||||
"<h4>" + currentText + "</h4>" +
|
||||
"<h4>" + title + "</h4>" +
|
||||
'<div style="margin-bottom:6px;">' + formatHistoryLabel(selectedEntry) + "</div>" +
|
||||
detailHtml +
|
||||
'<button class="mini-btn" id="jumpHistoryBtn" type="button" style="margin-top:8px;">Restore To Selected</button>';
|
||||
|
||||
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);
|
||||
});
|
||||
'<div class="history-preview-empty" style="margin-top:8px;">Only one undo / redo step is kept in memory.</div>';
|
||||
}
|
||||
|
||||
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) {
|
||||
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
|
||||
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>';
|
||||
}
|
||||
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) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../contentTransforms/graphicsPayload";
|
||||
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../editorCore";
|
||||
|
||||
const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
buildTilesPayloadFromImagesPayload,
|
||||
mergeImagesPayloadWithSpritesPayload,
|
||||
mergeImagesPayloadWithTilesPayload,
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
} from "../editorCore";
|
||||
import { resizeRows } from "../components/worldshaperShared";
|
||||
import { moveItemRelative } from "./reorderableListController";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
import { resolveUnifiedColorSymbol, getSpritePalette } from "../editorCore";
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
|
||||
import { getSpritePalette, getSpriteRows, resolveUnifiedColorSymbol } from "../editorCore";
|
||||
|
||||
export function parseHexColor(value, fallback = 0x060A14) {
|
||||
const raw = String(value || "").trim();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
|
||||
import { getSpriteRows } from "../editorCore";
|
||||
import { Application, Container, Sprite, Texture } from "pixi.js";
|
||||
import {
|
||||
applyPixelArtTexture,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,14 @@
|
|||
// @ts-nocheck
|
||||
import {
|
||||
buildSpritePreviewDataUrl,
|
||||
fetchJsonOrThrow,
|
||||
} from "../editorCore";
|
||||
import {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
fetchJsonOrThrow,
|
||||
mergeImagesPayloadWithSpritesPayload,
|
||||
mergeImagesPayloadWithTilesPayload,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeTileRecordForSave,
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
} from "../editorCore";
|
||||
import {
|
||||
buildSpriteCatalog,
|
||||
buildTileCatalogById,
|
||||
|
|
@ -4515,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() {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@
|
|||
|
||||
import {
|
||||
buildSpritePreviewDataUrl,
|
||||
getSpritePalette,
|
||||
} from "../editorCore";
|
||||
import {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
normalizeImagePlayback,
|
||||
normalizeImageRecordForSave,
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
getSpritePalette,
|
||||
} from "../editorCore";
|
||||
import {
|
||||
normalizeEditorTagValue,
|
||||
normalizeEditorTags,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ export default defineConfig({
|
|||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
worldshaperContent: resolve(__dirname, "worldshaper-content.html"),
|
||||
futureSharedContract: resolve(__dirname, "Future - Shared Contract.html"),
|
||||
worldshaperStudio: resolve(__dirname, "worldshaper-studio.html"),
|
||||
worldshaperHeightViewer: resolve(__dirname, "worldshaper-height-viewer.html"),
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Worldshaper Content Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/contentMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue