extract graphics transform and row encoding modules
This commit is contained in:
parent
9f538b1543
commit
5a9723440c
17 changed files with 693 additions and 577 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { resolveUnifiedColorSymbol } from "../editorCore";
|
||||
import type { JsonObject } from "../editorCore";
|
||||
import type { JsonObject } from "../contracts/json";
|
||||
|
||||
export const TILE_COLORS: Record<string, string> = {
|
||||
"#": resolveUnifiedColorSymbol("L", "#3d4f6a"),
|
||||
|
|
|
|||
159
src/contentTransforms/graphicsPayload.test.ts
Normal file
159
src/contentTransforms/graphicsPayload.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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: "",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
413
src/contentTransforms/graphicsPayload.ts
Normal file
413
src/contentTransforms/graphicsPayload.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
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)),
|
||||
};
|
||||
}
|
||||
6
src/contracts/json.ts
Normal file
6
src/contracts/json.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
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,98 +1,9 @@
|
|||
import {
|
||||
getSpriteRows,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeImagesPayloadForSave,
|
||||
normalizeTileRecordForSave,
|
||||
resolveUnifiedColorSymbol,
|
||||
setUnifiedColorEntries,
|
||||
} from "./editorCore";
|
||||
import type { JsonObject, JsonValue } from "./editorCore";
|
||||
|
||||
describe("editorCore", () => {
|
||||
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("updates the unified color lookup from catalog entries", () => {
|
||||
setUnifiedColorEntries([
|
||||
{ key: "A", color: "#123456" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
|
||||
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
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 CatalogEntry = {
|
||||
entryId?: string;
|
||||
sourceKey?: string;
|
||||
|
|
@ -314,10 +332,6 @@ 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) => {
|
||||
|
|
@ -440,455 +454,6 @@ 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> = {
|
||||
|
|
@ -1021,20 +586,6 @@ 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();
|
||||
}
|
||||
|
|
|
|||
52
src/graphics/rowEncoding.ts
Normal file
52
src/graphics/rowEncoding.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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);
|
||||
}
|
||||
23
src/shared/normalization.ts
Normal file
23
src/shared/normalization.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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,4 +1,4 @@
|
|||
import type { JsonObject } from "./editorCore";
|
||||
import type { JsonObject } from "./contracts/json";
|
||||
|
||||
export const WORLD_INDEX_SCHEMA_VERSION = 1;
|
||||
export const WORLD_SCHEMA_VERSION = 1;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
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,11 +1,10 @@
|
|||
import {
|
||||
getSpriteRows,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeImagesPayloadForSave,
|
||||
normalizeTileRecordForSave,
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "../editorCore";
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
import type { JsonObject, JsonValue } from "../contracts/json";
|
||||
|
||||
export type GraphicRole = "tile" | "sprite" | "other";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../editorCore";
|
||||
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../contentTransforms/graphicsPayload";
|
||||
|
||||
const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
buildTilesPayloadFromImagesPayload,
|
||||
mergeImagesPayloadWithSpritesPayload,
|
||||
mergeImagesPayloadWithTilesPayload,
|
||||
} from "../editorCore";
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
import { resizeRows } from "../components/worldshaperShared";
|
||||
import { moveItemRelative } from "./reorderableListController";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
|
||||
import { getSpritePalette, getSpriteRows, resolveUnifiedColorSymbol } from "../editorCore";
|
||||
import { resolveUnifiedColorSymbol, getSpritePalette } from "../editorCore";
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
|
||||
export function parseHexColor(value, fallback = 0x060A14) {
|
||||
const raw = String(value || "").trim();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
|
||||
import { getSpriteRows } from "../editorCore";
|
||||
import { getSpriteRows } from "../graphics/rowEncoding";
|
||||
import { Application, Container, Sprite, Texture } from "pixi.js";
|
||||
import {
|
||||
applyPixelArtTexture,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@
|
|||
// @ts-nocheck
|
||||
import {
|
||||
buildSpritePreviewDataUrl,
|
||||
fetchJsonOrThrow,
|
||||
} from "../editorCore";
|
||||
import {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
fetchJsonOrThrow,
|
||||
mergeImagesPayloadWithSpritesPayload,
|
||||
mergeImagesPayloadWithTilesPayload,
|
||||
normalizeImageRecordForSave,
|
||||
normalizeTileRecordForSave,
|
||||
} from "../editorCore";
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
import {
|
||||
buildSpriteCatalog,
|
||||
buildTileCatalogById,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
|
||||
import {
|
||||
buildSpritePreviewDataUrl,
|
||||
getSpritePalette,
|
||||
} from "../editorCore";
|
||||
import {
|
||||
buildSpritesPayloadFromImagesPayload,
|
||||
buildTilesPayloadFromImagesPayload,
|
||||
normalizeImagePlayback,
|
||||
normalizeImageRecordForSave,
|
||||
getSpritePalette,
|
||||
} from "../editorCore";
|
||||
} from "../contentTransforms/graphicsPayload";
|
||||
import {
|
||||
normalizeEditorTagValue,
|
||||
normalizeEditorTags,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue