castle-web-cli 0.4.71 → 0.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.js +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BY21Og40.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Castle pixel-art format — COMPACT form ("flat" sprite)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// `.pxart` files come in two COEXISTING forms (capability tiers, not temporal
|
|
6
|
+
// versions). The on-disk discriminator is a string `format` field:
|
|
7
|
+
// `"compact"` or `"full"`. The COMPACT form is intentionally minimal: one
|
|
8
|
+
// shared palette + one grid of single-character keys.
|
|
9
|
+
//
|
|
10
|
+
// {
|
|
11
|
+
// "format": "compact",
|
|
12
|
+
// "palette": { "y": "#ffe11a", "o": "#e8b800", ".": null },
|
|
13
|
+
// "grid": ["..ooyyoo..", ".oyyyyyyo.", "oyyyyyyyyo"]
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// Rules:
|
|
17
|
+
// - `palette` maps a SINGLE-CHARACTER key -> "#rrggbb" (or "#rrggbbaa"), or
|
|
18
|
+
// null for transparent.
|
|
19
|
+
// - The char "." is reserved for transparency. It is transparent whether or
|
|
20
|
+
// not it appears in `palette` (a `"." : null` entry is allowed but optional).
|
|
21
|
+
// - `grid` is an array of strings, one per row. Each character is a palette
|
|
22
|
+
// key. Width = the longest row; ragged rows are padded with "." (transparent).
|
|
23
|
+
// Height = grid.length.
|
|
24
|
+
// - On serialize we always stamp `format: "compact"`. The parser detects the
|
|
25
|
+
// form by STRUCTURE (object `palette` + `grid`), TOLERATES a missing
|
|
26
|
+
// `format` discriminator, and tolerates ragged rows.
|
|
27
|
+
//
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// The FULL form (layers + frames + tags) lives in the second half of this file.
|
|
30
|
+
// A compact file is equivalent to a full sprite with a single pixel layer whose
|
|
31
|
+
// single frame's cel grid === `grid`. In memory the kit ALWAYS works on the full
|
|
32
|
+
// model: `parseFull` upgrades a compact file into the full model on read.
|
|
33
|
+
//
|
|
34
|
+
// This kit is plain JavaScript: the format is described in comments and the
|
|
35
|
+
// runtime behavior below is the source of truth. Do NOT read/write
|
|
36
|
+
// layers/frames/animation in compact-form code.
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
export const COMPACT_FORM = 'compact';
|
|
40
|
+
export const FULL_FORM = 'full';
|
|
41
|
+
export const TRANSPARENT = '.';
|
|
42
|
+
|
|
43
|
+
/** Parse a .pxart string into a compact PxArt record, or null if it isn't the
|
|
44
|
+
* compact shape. Detects by STRUCTURE (object `palette` + `grid`); the on-disk
|
|
45
|
+
* `format` discriminator is optional. Tolerant of ragged rows and a `"."`
|
|
46
|
+
* palette entry. */
|
|
47
|
+
export function parseCompact(content) {
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse(content);
|
|
50
|
+
if (
|
|
51
|
+
data &&
|
|
52
|
+
typeof data.palette === 'object' &&
|
|
53
|
+
data.palette !== null &&
|
|
54
|
+
!Array.isArray(data.palette) &&
|
|
55
|
+
Array.isArray(data.grid) &&
|
|
56
|
+
data.grid.every((r) => typeof r === 'string')
|
|
57
|
+
) {
|
|
58
|
+
const palette = {};
|
|
59
|
+
for (const [k, v] of Object.entries(data.palette)) {
|
|
60
|
+
// Accept string hex or explicit null; ignore other junk values.
|
|
61
|
+
if (typeof v === 'string') palette[k] = v;
|
|
62
|
+
else if (v === null) palette[k] = null;
|
|
63
|
+
}
|
|
64
|
+
return { palette, grid: data.grid };
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
/* not valid pxart json */
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Serialize a compact PxArt record to a .pxart string. Always stamps
|
|
73
|
+
* `format: "compact"`. */
|
|
74
|
+
export function serializeCompact(art) {
|
|
75
|
+
const out = {
|
|
76
|
+
format: COMPACT_FORM,
|
|
77
|
+
palette: art.palette,
|
|
78
|
+
grid: art.grid,
|
|
79
|
+
};
|
|
80
|
+
return JSON.stringify(out, null, 2) + '\n';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// cells <-> grid
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Rectangularize: ragged rows pad with transparent; empty art gets 1×1. */
|
|
88
|
+
export function toCells(art) {
|
|
89
|
+
const rows = Math.max(art.grid.length, 1);
|
|
90
|
+
const cols = Math.max(...art.grid.map((r) => r.length), 1);
|
|
91
|
+
const cells = [];
|
|
92
|
+
for (let y = 0; y < rows; y++) {
|
|
93
|
+
const row = art.grid[y] ?? '';
|
|
94
|
+
cells.push(Array.from({ length: cols }, (_, x) => row[x] ?? TRANSPARENT));
|
|
95
|
+
}
|
|
96
|
+
return cells;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Build a PxArt from a palette + cell matrix, dropping unused palette keys. */
|
|
100
|
+
export function fromCells(palette, cells) {
|
|
101
|
+
const used = new Set();
|
|
102
|
+
for (const row of cells) for (const ch of row) used.add(ch);
|
|
103
|
+
const gc = {};
|
|
104
|
+
for (const [k, v] of Object.entries(palette)) {
|
|
105
|
+
if (used.has(k)) gc[k] = v;
|
|
106
|
+
}
|
|
107
|
+
return { palette: gc, grid: cells.map((r) => r.join('')) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// dimensions / colors
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export function dimsOf(art) {
|
|
115
|
+
const cells = toCells(art);
|
|
116
|
+
const height = cells.length;
|
|
117
|
+
const width = cells[0]?.length ?? 0;
|
|
118
|
+
const used = new Set();
|
|
119
|
+
for (const row of cells) {
|
|
120
|
+
for (const ch of row) {
|
|
121
|
+
if (ch !== TRANSPARENT && colorForKey(art.palette, ch)) used.add(ch);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { width, height, colors: used.size };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve a grid char to a CSS color, or null when transparent. "." is always
|
|
128
|
+
* transparent; an absent key or a null palette value is transparent too. */
|
|
129
|
+
export function colorForKey(palette, key) {
|
|
130
|
+
if (key === TRANSPARENT) return null;
|
|
131
|
+
const v = palette[key];
|
|
132
|
+
return typeof v === 'string' ? v : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** '#abc' | 'abc' | '#aabbcc' | 'aabbccdd' -> normalized lowercase '#...' , or
|
|
136
|
+
* null if not a valid 3/6/8-digit hex color. */
|
|
137
|
+
export function normalizeHex(input) {
|
|
138
|
+
let s = input.trim().toLowerCase();
|
|
139
|
+
if (s.startsWith('#')) s = s.slice(1);
|
|
140
|
+
if (/^[0-9a-f]{3}$/.test(s)) s = [...s].map((c) => c + c).join('');
|
|
141
|
+
if (!/^([0-9a-f]{6}|[0-9a-f]{8})$/.test(s)) return null;
|
|
142
|
+
return '#' + s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Single-char keys usable in a packed grid, excluding "." (reserved for
|
|
146
|
+
* transparency). Letters + digits give 62 slots — far more than a small sprite
|
|
147
|
+
* ever needs — and the few trailing symbols are JSON-safe fallbacks. Used by
|
|
148
|
+
* representation normalizers that arrive with raw hex colors (svg-rect,
|
|
149
|
+
* index-array) and must mint keys for our packed compact grid. */
|
|
150
|
+
export const KEY_ALPHABET =
|
|
151
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&*+=?@';
|
|
152
|
+
|
|
153
|
+
/** Mint stable single-char palette keys for a list of (possibly repeated, or
|
|
154
|
+
* un-normalized) hex colors. Returns the deduped `palette` (key -> normalized
|
|
155
|
+
* hex) plus `keyOf`, a map from normalized hex -> assigned key. Invalid hex and
|
|
156
|
+
* duplicates are skipped. If colors exceed the alphabet they collapse onto the
|
|
157
|
+
* last key (acceptable for the tiny sprites this harness deals with). */
|
|
158
|
+
export function allocatePaletteKeys(colors) {
|
|
159
|
+
const keyOf = new Map();
|
|
160
|
+
const palette = {};
|
|
161
|
+
let i = 0;
|
|
162
|
+
for (const raw of colors) {
|
|
163
|
+
const hex = normalizeHex(raw);
|
|
164
|
+
if (!hex || keyOf.has(hex)) continue;
|
|
165
|
+
const key = KEY_ALPHABET[Math.min(i, KEY_ALPHABET.length - 1)];
|
|
166
|
+
i++;
|
|
167
|
+
keyOf.set(hex, key);
|
|
168
|
+
palette[key] = hex;
|
|
169
|
+
}
|
|
170
|
+
return { palette, keyOf };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** lospec.com/palette-list/aap-64 — the recommended default palette. */
|
|
174
|
+
export const AAP64 = [
|
|
175
|
+
'#060608', '#141013', '#3b1725', '#73172d', '#b4202a', '#df3e23', '#fa6a0a', '#f9a31b',
|
|
176
|
+
'#ffd541', '#fffc40', '#d6f264', '#9cdb43', '#59c135', '#14a02e', '#1a7a3e', '#24523b',
|
|
177
|
+
'#122020', '#143464', '#285cc4', '#249fde', '#20d6c7', '#a6fcdb', '#ffffff', '#fef3c0',
|
|
178
|
+
'#fad6b8', '#f5a097', '#e86a73', '#bc4a9b', '#793a80', '#403353', '#242234', '#221c1a',
|
|
179
|
+
'#322b28', '#71413b', '#bb7547', '#dba463', '#f4d29c', '#dae0ea', '#b3b9d1', '#8b93af',
|
|
180
|
+
'#6d758d', '#4a5462', '#333941', '#422433', '#5b3138', '#8e5252', '#ba756a', '#e9b5a3',
|
|
181
|
+
'#e3e6ff', '#b9bffb', '#849be4', '#588dbe', '#477d85', '#23674e', '#328464', '#5daf8d',
|
|
182
|
+
'#92dcba', '#cdf7e2', '#e4d2aa', '#c7b08b', '#a08662', '#796755', '#5a4e44', '#423934',
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// rendering
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/** Draw the sprite at 1 device-pixel per cell with smoothing disabled. The
|
|
190
|
+
* canvas is sized to the sprite's exact pixel dimensions; scale it up for
|
|
191
|
+
* display with CSS `image-rendering: pixelated` (see PxSprite in the UI).
|
|
192
|
+
* Transparent cells are left clear so a backdrop/checker can show through. */
|
|
193
|
+
export function renderToCanvas(art, canvas) {
|
|
194
|
+
const cells = toCells(art);
|
|
195
|
+
const rows = cells.length;
|
|
196
|
+
const cols = cells[0]?.length ?? 1;
|
|
197
|
+
canvas.width = cols;
|
|
198
|
+
canvas.height = rows;
|
|
199
|
+
const ctx = canvas.getContext('2d');
|
|
200
|
+
if (!ctx) return;
|
|
201
|
+
ctx.imageSmoothingEnabled = false;
|
|
202
|
+
ctx.clearRect(0, 0, cols, rows);
|
|
203
|
+
for (let y = 0; y < rows; y++) {
|
|
204
|
+
for (let x = 0; x < cols; x++) {
|
|
205
|
+
const color = colorForKey(art.palette, cells[y][x]);
|
|
206
|
+
if (color) {
|
|
207
|
+
ctx.fillStyle = color;
|
|
208
|
+
ctx.fillRect(x, y, 1, 1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Castle pixel-art format — FULL form (layered + animated "Sprite")
|
|
216
|
+
// ============================================================================
|
|
217
|
+
//
|
|
218
|
+
// The FULL form is a SUPERSET of the compact form. Everything above
|
|
219
|
+
// (parseCompact, serializeCompact, renderToCanvas, dimsOf, toCells, fromCells,
|
|
220
|
+
// normalizeHex, allocatePaletteKeys, AAP64, ...) keeps its exact compact-form
|
|
221
|
+
// signatures/behavior. The full surface below introduces a separate in-memory
|
|
222
|
+
// model (`Sprite`) plus its own parse/serialize/render functions. A compact file
|
|
223
|
+
// upgrades into a Sprite in memory (one layer, one frame).
|
|
224
|
+
//
|
|
225
|
+
// Canonical schema is documented in docs/pxart-format.md. The model here mirrors
|
|
226
|
+
// it: one resolution per sprite, an ORDERED palette of { key, hex }, a frames[]
|
|
227
|
+
// timeline, named tags, and layers (bottom->top) whose cells row is
|
|
228
|
+
// dense-with-null (`{ grid }` | `{ link }` | null), Aseprite-style.
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
/** Default per-frame duration when none is supplied (ms). */
|
|
232
|
+
export const DEFAULT_DURATION_MS = 100;
|
|
233
|
+
|
|
234
|
+
/** Valid sprite resolutions: power-of-two from 16 to 512, inclusive. */
|
|
235
|
+
export const RESOLUTION_MIN = 16;
|
|
236
|
+
export const RESOLUTION_MAX = 512;
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// resolution helpers
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** The valid resolution dimensions (powers of two, 16..512). */
|
|
243
|
+
export const RESOLUTION_STEPS = (() => {
|
|
244
|
+
const out = [];
|
|
245
|
+
for (let v = RESOLUTION_MIN; v <= RESOLUTION_MAX; v *= 2) out.push(v);
|
|
246
|
+
return out;
|
|
247
|
+
})();
|
|
248
|
+
|
|
249
|
+
/** Snap an arbitrary dimension to the nearest valid power-of-two in 16..512
|
|
250
|
+
* (ties round up). Non-finite/<=0 input snaps to the minimum. Used for
|
|
251
|
+
* EXPLICIT full-form resolutions; compact-shorthand upgrade preserves native
|
|
252
|
+
* dims. */
|
|
253
|
+
export function snapResolutionDim(n) {
|
|
254
|
+
if (!Number.isFinite(n) || n <= 0) return RESOLUTION_MIN;
|
|
255
|
+
const clamped = Math.min(RESOLUTION_MAX, Math.max(RESOLUTION_MIN, n));
|
|
256
|
+
let best = RESOLUTION_STEPS[0];
|
|
257
|
+
let bestDist = Infinity;
|
|
258
|
+
for (const step of RESOLUTION_STEPS) {
|
|
259
|
+
const dist = Math.abs(step - clamped);
|
|
260
|
+
// strict `<` keeps the smaller step on an exact tie distance; to round ties
|
|
261
|
+
// UP we accept an equal distance only when the candidate is larger.
|
|
262
|
+
if (dist < bestDist || (dist === bestDist && step > best)) {
|
|
263
|
+
best = step;
|
|
264
|
+
bestDist = dist;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return best;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// format defaults + curated palette
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/** Default sprite resolution for new/generated art (16×16). */
|
|
275
|
+
export const DEFAULT_RESOLUTION = { width: 16, height: 16 };
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Two-tier Endesga-64 palette system
|
|
279
|
+
//
|
|
280
|
+
// EDG64 is the FULL painting palette (Lospec "Endesga 64", canonical order) the
|
|
281
|
+
// editor offers to USERS. AGENT_PALETTE_16 is a fixed 16-color SUBSET of EDG64
|
|
282
|
+
// — the constrained set the LLM generation path is told to use and that the
|
|
283
|
+
// `draw` svg-rect quantizer snaps to. Every AGENT_PALETTE_16 color is a member
|
|
284
|
+
// of EDG64.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/** lospec.com/palette-list/endesga-64 — the full 64-color painting palette.
|
|
288
|
+
* Lospec order, except the lead bright red (#ff0040) is moved down among the
|
|
289
|
+
* other reds (4th from last) so the swatch grid opens on the grayscale ramp.
|
|
290
|
+
* The editor exposes ALL of these as swatches. */
|
|
291
|
+
export const EDG64 = [
|
|
292
|
+
'#131313', '#1b1b1b', '#272727', '#3d3d3d', '#5d5d5d', '#858585', '#b4b4b4', '#ffffff',
|
|
293
|
+
'#c7cfdd', '#92a1b9', '#657392', '#424c6e', '#2a2f4e', '#1a1932', '#0e071b', '#1c121c',
|
|
294
|
+
'#391f21', '#5d2c28', '#8a4836', '#bf6f4a', '#e69c69', '#f6ca9f', '#f9e6cf', '#edab50',
|
|
295
|
+
'#e07438', '#c64524', '#8e251d', '#ff5000', '#ed7614', '#ffa214', '#ffc825', '#ffeb57',
|
|
296
|
+
'#d3fc7e', '#99e65f', '#5ac54f', '#33984b', '#1e6f50', '#134c4c', '#0c2e44', '#00396d',
|
|
297
|
+
'#0069aa', '#0098dc', '#00cdf9', '#0cf1ff', '#94fdff', '#fdd2ed', '#f389f5', '#db3ffd',
|
|
298
|
+
'#7a09fa', '#3003d9', '#0c0293', '#03193f', '#3b1443', '#622461', '#93388f', '#ca52c9',
|
|
299
|
+
'#c85086', '#f68187', '#f5555d', '#ea323c', '#ff0040', '#c42430', '#891e2b', '#571c27',
|
|
300
|
+
].map((hex) => normalizeHex(hex) ?? hex);
|
|
301
|
+
|
|
302
|
+
/** A fixed 16-color SUBSET of EDG64 — the constrained palette the agent
|
|
303
|
+
* generation path is told to use, and the target the svg-rect quantizer snaps
|
|
304
|
+
* to. Order is the agent-facing order (warm skins → reds → warm → greens →
|
|
305
|
+
* teals → neutrals → light → cyans → blue). Every entry is a member of EDG64. */
|
|
306
|
+
export const AGENT_PALETTE_16 = [
|
|
307
|
+
'#e69c69', '#bf6f4a', '#8a4836', '#391f21',
|
|
308
|
+
'#891e2b', '#ea323c', '#ffa214', '#ffeb57',
|
|
309
|
+
'#5ac54f', '#1e6f50', '#134c4c', '#657392',
|
|
310
|
+
'#c7cfdd', '#ffffff', '#0cf1ff', '#0098dc',
|
|
311
|
+
].map((hex) => normalizeHex(hex) ?? hex);
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// full-form parse
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
function isPlainObject(v) {
|
|
318
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Normalize a loosely-typed palette array into ordered { key, hex } entries.
|
|
322
|
+
* Drops "." entries, invalid hex, non-single-char keys, and duplicate keys
|
|
323
|
+
* (first wins). Order is preserved. */
|
|
324
|
+
function parsePaletteArray(raw) {
|
|
325
|
+
const out = [];
|
|
326
|
+
if (!Array.isArray(raw)) return out;
|
|
327
|
+
const seen = new Set();
|
|
328
|
+
for (const entry of raw) {
|
|
329
|
+
if (!isPlainObject(entry)) continue;
|
|
330
|
+
const key = entry.key;
|
|
331
|
+
if (typeof key !== 'string' || key.length !== 1 || key === TRANSPARENT) continue;
|
|
332
|
+
if (seen.has(key)) continue;
|
|
333
|
+
const hex = typeof entry.hex === 'string' ? normalizeHex(entry.hex) : null;
|
|
334
|
+
if (!hex) continue;
|
|
335
|
+
seen.add(key);
|
|
336
|
+
out.push({ key, hex });
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseDirection(raw) {
|
|
342
|
+
return raw === 'reverse' || raw === 'pingpong' ? raw : 'forward';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Coerce a loosely-typed offset component to a finite integer, defaulting 0. */
|
|
346
|
+
function coerceOffset(raw) {
|
|
347
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Crop a grid so neither axis exceeds RESOLUTION_MAX (512). A cel image may be
|
|
351
|
+
* larger than the canvas (off-canvas content), but it is hard-capped at the
|
|
352
|
+
* same 512/axis ceiling as the canvas resolution. Returns the input untouched
|
|
353
|
+
* when already within the cap. */
|
|
354
|
+
function capGridDims(grid) {
|
|
355
|
+
const tooTall = grid.length > RESOLUTION_MAX;
|
|
356
|
+
const tooWide = grid.some((r) => r.length > RESOLUTION_MAX);
|
|
357
|
+
if (!tooTall && !tooWide) return grid;
|
|
358
|
+
const rows = tooTall ? grid.slice(0, RESOLUTION_MAX) : grid;
|
|
359
|
+
return rows.map((r) => (r.length > RESOLUTION_MAX ? r.slice(0, RESOLUTION_MAX) : r));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Build a { grid } cell, attaching x/y only when nonzero (additive: a zero
|
|
363
|
+
* offset serializes as a bare { grid }, identical to pre-offset files). */
|
|
364
|
+
function makeGridCell(grid, rawX, rawY) {
|
|
365
|
+
const capped = capGridDims(grid);
|
|
366
|
+
const x = coerceOffset(rawX);
|
|
367
|
+
const y = coerceOffset(rawY);
|
|
368
|
+
return x || y ? { grid: capped, x, y } : { grid: capped };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Coerce a loosely-typed cell into a SpriteCell. Reads an optional integer
|
|
372
|
+
* (x, y) offset on grid cells (default 0,0) and caps grid dims at 512/axis. */
|
|
373
|
+
function parseCell(raw) {
|
|
374
|
+
if (raw == null) return null;
|
|
375
|
+
if (isPlainObject(raw)) {
|
|
376
|
+
if (Array.isArray(raw.grid) && raw.grid.every((r) => typeof r === 'string')) {
|
|
377
|
+
return makeGridCell(raw.grid, raw.x, raw.y);
|
|
378
|
+
}
|
|
379
|
+
if (typeof raw.link === 'number' && Number.isInteger(raw.link)) {
|
|
380
|
+
return { link: raw.link };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Tolerate a bare frame-index number as a link, or a bare row array as a grid.
|
|
384
|
+
if (typeof raw === 'number' && Number.isInteger(raw)) return { link: raw };
|
|
385
|
+
if (Array.isArray(raw) && raw.every((r) => typeof r === 'string')) {
|
|
386
|
+
return makeGridCell(raw, undefined, undefined);
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Infer canvas dimensions from a set of layers, used ONLY as a fallback when a
|
|
392
|
+
* full file omits an explicit `resolution`. The canvas is independent of cel
|
|
393
|
+
* image size, so an oversized cel grid (off-canvas content) must NOT inflate
|
|
394
|
+
* the canvas past the 512 ceiling — clamp each axis to RESOLUTION_MAX. */
|
|
395
|
+
function inferDimsFromLayers(layers) {
|
|
396
|
+
let width = 0;
|
|
397
|
+
let height = 0;
|
|
398
|
+
for (const layer of layers) {
|
|
399
|
+
for (const cell of layer.cells) {
|
|
400
|
+
if (cell && 'grid' in cell) {
|
|
401
|
+
height = Math.max(height, cell.grid.length);
|
|
402
|
+
for (const row of cell.grid) width = Math.max(width, row.length);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
width: Math.min(Math.max(width, 1), RESOLUTION_MAX),
|
|
408
|
+
height: Math.min(Math.max(height, 1), RESOLUTION_MAX),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Decide whether a parsed JSON object is the COMPACT form. Detection is by
|
|
413
|
+
* STRUCTURE — full = layered/animated shape (layers / array palette / frames /
|
|
414
|
+
* resolution); compact = flat `grid` over an object palette. The optional
|
|
415
|
+
* `format` discriminator only breaks a tie when structure is ambiguous, so a
|
|
416
|
+
* missing or mislabeled field never causes a silent mis-detection. */
|
|
417
|
+
function detectCompactForm(data) {
|
|
418
|
+
const looksFull =
|
|
419
|
+
Array.isArray(data.layers) ||
|
|
420
|
+
Array.isArray(data.palette) ||
|
|
421
|
+
Array.isArray(data.frames) ||
|
|
422
|
+
isPlainObject(data.resolution);
|
|
423
|
+
const looksCompact = isPlainObject(data.palette) && Array.isArray(data.grid);
|
|
424
|
+
if (looksCompact && !looksFull) return true;
|
|
425
|
+
if (looksFull && !looksCompact) return false;
|
|
426
|
+
const declared = typeof data.format === 'string' ? data.format : null;
|
|
427
|
+
if (declared === COMPACT_FORM) return true;
|
|
428
|
+
if (declared === FULL_FORM) return false;
|
|
429
|
+
return looksCompact; // last resort: a bare grid upgrades
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Parse a .pxart string into the full Sprite model, or null if it isn't valid
|
|
434
|
+
* pixel art. Handles BOTH on-disk forms, detecting by STRUCTURE:
|
|
435
|
+
* - the FULL form (resolution / array palette / frames / layers), parsed
|
|
436
|
+
* natively, and
|
|
437
|
+
* - the COMPACT shorthand ({ palette: {...}, grid: [...] }), upgraded in
|
|
438
|
+
* memory.
|
|
439
|
+
*
|
|
440
|
+
* The optional `format` discriminator ("compact" | "full") is used as a hint to
|
|
441
|
+
* disambiguate genuinely ambiguous shapes, but clear structure always wins so a
|
|
442
|
+
* mislabeled or undiscriminated file still parses without silent mis-detection.
|
|
443
|
+
*
|
|
444
|
+
* Tolerant: missing `format`, missing/short cells arrays, ragged rows, null
|
|
445
|
+
* cells, bare link numbers, and out-of-range explicit resolutions (snapped).
|
|
446
|
+
*/
|
|
447
|
+
export function parseFull(content) {
|
|
448
|
+
let data;
|
|
449
|
+
try {
|
|
450
|
+
data = JSON.parse(content);
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (!isPlainObject(data)) return null;
|
|
455
|
+
|
|
456
|
+
if (detectCompactForm(data)) {
|
|
457
|
+
// Compact shorthand path: reuse the compact parser then upgrade.
|
|
458
|
+
const compact = parseCompact(content);
|
|
459
|
+
return compact ? upgradeCompactToFull(compact) : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const palette = parsePaletteArray(data.palette);
|
|
463
|
+
|
|
464
|
+
// Layers (tolerant). A full file with no layers but a top-level grid still
|
|
465
|
+
// works via the compact path above; here we require a layers array to proceed.
|
|
466
|
+
const rawLayers = Array.isArray(data.layers) ? data.layers : [];
|
|
467
|
+
const layers = rawLayers.filter(isPlainObject).map((l, i) => {
|
|
468
|
+
const rawCells = Array.isArray(l.cells) ? l.cells : [];
|
|
469
|
+
return {
|
|
470
|
+
id: typeof l.id === 'string' && l.id ? l.id : `layer-${i}`,
|
|
471
|
+
name: typeof l.name === 'string' && l.name ? l.name : `Layer ${i + 1}`,
|
|
472
|
+
visible: l.visible !== false,
|
|
473
|
+
opacity:
|
|
474
|
+
typeof l.opacity === 'number' && Number.isFinite(l.opacity)
|
|
475
|
+
? Math.min(1, Math.max(0, l.opacity))
|
|
476
|
+
: 1,
|
|
477
|
+
blendMode: 'normal',
|
|
478
|
+
kind: 'pixel',
|
|
479
|
+
cells: rawCells.map(parseCell),
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Frame count: max of explicit frames[] and the longest cells row.
|
|
484
|
+
const rawFrames = Array.isArray(data.frames) ? data.frames : [];
|
|
485
|
+
let frameCount = rawFrames.length;
|
|
486
|
+
for (const layer of layers) frameCount = Math.max(frameCount, layer.cells.length);
|
|
487
|
+
frameCount = Math.max(frameCount, 1);
|
|
488
|
+
|
|
489
|
+
const defaultDurationMs =
|
|
490
|
+
typeof data.defaultDurationMs === 'number' && Number.isFinite(data.defaultDurationMs)
|
|
491
|
+
? data.defaultDurationMs
|
|
492
|
+
: DEFAULT_DURATION_MS;
|
|
493
|
+
|
|
494
|
+
// A frame's `durationMs` is an OPTIONAL override: keep it only when the raw
|
|
495
|
+
// frame supplies a finite value, otherwise leave it undefined so the frame
|
|
496
|
+
// inherits the global `defaultDurationMs`. Padded frames (beyond rawFrames)
|
|
497
|
+
// inherit too.
|
|
498
|
+
const frames = [];
|
|
499
|
+
for (let f = 0; f < frameCount; f++) {
|
|
500
|
+
const raw = rawFrames[f];
|
|
501
|
+
if (
|
|
502
|
+
isPlainObject(raw) &&
|
|
503
|
+
typeof raw.durationMs === 'number' &&
|
|
504
|
+
Number.isFinite(raw.durationMs)
|
|
505
|
+
) {
|
|
506
|
+
frames.push({ durationMs: raw.durationMs });
|
|
507
|
+
} else {
|
|
508
|
+
frames.push({});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Pad/truncate every layer's cells to exactly frameCount (dense-with-null).
|
|
513
|
+
for (const layer of layers) {
|
|
514
|
+
if (layer.cells.length < frameCount) {
|
|
515
|
+
while (layer.cells.length < frameCount) layer.cells.push(null);
|
|
516
|
+
} else if (layer.cells.length > frameCount) {
|
|
517
|
+
layer.cells.length = frameCount;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Resolution: snap an explicit value; otherwise infer from the largest grid.
|
|
522
|
+
let resolution;
|
|
523
|
+
if (isPlainObject(data.resolution)) {
|
|
524
|
+
resolution = {
|
|
525
|
+
width: snapResolutionDim(Number(data.resolution.width)),
|
|
526
|
+
height: snapResolutionDim(Number(data.resolution.height)),
|
|
527
|
+
};
|
|
528
|
+
} else {
|
|
529
|
+
resolution = inferDimsFromLayers(layers);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const tags = (Array.isArray(data.tags) ? data.tags : [])
|
|
533
|
+
.filter(isPlainObject)
|
|
534
|
+
.map((t) => ({
|
|
535
|
+
name: typeof t.name === 'string' ? t.name : '',
|
|
536
|
+
from: typeof t.from === 'number' && Number.isInteger(t.from) ? t.from : 0,
|
|
537
|
+
to: typeof t.to === 'number' && Number.isInteger(t.to) ? t.to : 0,
|
|
538
|
+
direction: parseDirection(t.direction),
|
|
539
|
+
repeat: typeof t.repeat === 'number' && Number.isInteger(t.repeat) ? t.repeat : 0,
|
|
540
|
+
}));
|
|
541
|
+
|
|
542
|
+
const defaultTag = typeof data.defaultTag === 'string' ? data.defaultTag : undefined;
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
resolution,
|
|
546
|
+
palette,
|
|
547
|
+
frames,
|
|
548
|
+
defaultDurationMs,
|
|
549
|
+
tags,
|
|
550
|
+
defaultTag,
|
|
551
|
+
layers,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Upgrade a compact PxArt record (flat palette+grid) into the full Sprite
|
|
556
|
+
* model: one pixel layer, one frame, resolution = the grid's NATIVE dims (not
|
|
557
|
+
* snapped). */
|
|
558
|
+
export function upgradeCompactToFull(art) {
|
|
559
|
+
const cells = toCells(art);
|
|
560
|
+
const height = cells.length;
|
|
561
|
+
const width = cells[0]?.length ?? 1;
|
|
562
|
+
|
|
563
|
+
// Ordered palette from the compact record, in insertion order, dropping
|
|
564
|
+
// transparent/null/invalid entries and "." (reserved).
|
|
565
|
+
const palette = [];
|
|
566
|
+
const seen = new Set();
|
|
567
|
+
for (const [key, value] of Object.entries(art.palette)) {
|
|
568
|
+
if (key.length !== 1 || key === TRANSPARENT || seen.has(key)) continue;
|
|
569
|
+
if (typeof value !== 'string') continue;
|
|
570
|
+
const hex = normalizeHex(value);
|
|
571
|
+
if (!hex) continue;
|
|
572
|
+
seen.add(key);
|
|
573
|
+
palette.push({ key, hex });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
resolution: { width, height },
|
|
578
|
+
palette,
|
|
579
|
+
// The single upgraded frame INHERITS the global (no override).
|
|
580
|
+
frames: [{}],
|
|
581
|
+
defaultDurationMs: DEFAULT_DURATION_MS,
|
|
582
|
+
tags: [],
|
|
583
|
+
defaultTag: undefined,
|
|
584
|
+
layers: [
|
|
585
|
+
{
|
|
586
|
+
id: 'layer-0',
|
|
587
|
+
name: 'Layer 1',
|
|
588
|
+
visible: true,
|
|
589
|
+
opacity: 1,
|
|
590
|
+
blendMode: 'normal',
|
|
591
|
+
kind: 'pixel',
|
|
592
|
+
cells: [{ grid: [...art.grid] }],
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// full-form serialize
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
/** Collect the set of palette keys actually used by any { grid } cell. */
|
|
603
|
+
function usedKeysOf(sprite) {
|
|
604
|
+
const used = new Set();
|
|
605
|
+
for (const layer of sprite.layers) {
|
|
606
|
+
for (const cell of layer.cells) {
|
|
607
|
+
if (cell && 'grid' in cell) {
|
|
608
|
+
for (const row of cell.grid) for (const ch of row) used.add(ch);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return used;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Serialize a Sprite to a canonical .pxart string. Always stamps
|
|
616
|
+
* `format: "full"`, drops unused palette entries, and emits packed-string
|
|
617
|
+
* grids. Palette order is preserved for the entries that survive. */
|
|
618
|
+
export function serializeFull(sprite) {
|
|
619
|
+
const used = usedKeysOf(sprite);
|
|
620
|
+
const palette = sprite.palette
|
|
621
|
+
.filter((e) => used.has(e.key))
|
|
622
|
+
.map((e) => ({ key: e.key, hex: e.hex }));
|
|
623
|
+
|
|
624
|
+
const out = {
|
|
625
|
+
format: FULL_FORM,
|
|
626
|
+
resolution: { width: sprite.resolution.width, height: sprite.resolution.height },
|
|
627
|
+
palette,
|
|
628
|
+
// Write `durationMs` only for OVERRIDDEN frames; inherited frames (undefined)
|
|
629
|
+
// serialize as a bare `{}` and re-parse as inheriting the global.
|
|
630
|
+
frames: sprite.frames.map((f) =>
|
|
631
|
+
typeof f.durationMs === 'number' && Number.isFinite(f.durationMs)
|
|
632
|
+
? { durationMs: f.durationMs }
|
|
633
|
+
: {},
|
|
634
|
+
),
|
|
635
|
+
defaultDurationMs: sprite.defaultDurationMs,
|
|
636
|
+
tags: sprite.tags.map((t) => ({
|
|
637
|
+
name: t.name,
|
|
638
|
+
from: t.from,
|
|
639
|
+
to: t.to,
|
|
640
|
+
direction: t.direction,
|
|
641
|
+
repeat: t.repeat,
|
|
642
|
+
})),
|
|
643
|
+
...(sprite.defaultTag !== undefined ? { defaultTag: sprite.defaultTag } : {}),
|
|
644
|
+
layers: sprite.layers.map((l) => ({
|
|
645
|
+
id: l.id,
|
|
646
|
+
name: l.name,
|
|
647
|
+
visible: l.visible,
|
|
648
|
+
opacity: l.opacity,
|
|
649
|
+
blendMode: l.blendMode,
|
|
650
|
+
kind: l.kind,
|
|
651
|
+
cells: l.cells.map((c) => {
|
|
652
|
+
if (c == null) return null;
|
|
653
|
+
if ('grid' in c) {
|
|
654
|
+
// Emit x/y only when nonzero so a zero-offset cel round-trips to the
|
|
655
|
+
// exact pre-offset shape (forward/backward compatible).
|
|
656
|
+
const cell = { grid: c.grid };
|
|
657
|
+
if (c.x) cell.x = c.x;
|
|
658
|
+
if (c.y) cell.y = c.y;
|
|
659
|
+
return cell;
|
|
660
|
+
}
|
|
661
|
+
return { link: c.link };
|
|
662
|
+
}),
|
|
663
|
+
})),
|
|
664
|
+
};
|
|
665
|
+
return JSON.stringify(out, null, 2) + '\n';
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
// linked-cell resolution & helpers
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
/** Number of frames in the sprite. */
|
|
673
|
+
export function frameCount(sprite) {
|
|
674
|
+
return sprite.frames.length;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Effective pixel dimensions of the sprite (its resolution). */
|
|
678
|
+
export function spriteDims(sprite) {
|
|
679
|
+
return { width: sprite.resolution.width, height: sprite.resolution.height };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Build a key -> hex lookup from the ordered palette (first key wins). */
|
|
683
|
+
export function paletteLookup(palette) {
|
|
684
|
+
const map = new Map();
|
|
685
|
+
for (const { key, hex } of palette) if (!map.has(key)) map.set(key, hex);
|
|
686
|
+
return map;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Resolve a grid char to a CSS color using an ordered palette, or null when
|
|
690
|
+
* transparent. "." and unknown keys are transparent. */
|
|
691
|
+
export function colorForKeyV2(lookup, key) {
|
|
692
|
+
if (key === TRANSPARENT) return null;
|
|
693
|
+
return lookup.get(key) ?? null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Resolve the effective cel (image grid + offset) for a (layer, frameIndex)
|
|
698
|
+
* cell, following same-layer `link` references to the underlying `{ grid }`.
|
|
699
|
+
* A link shares the SOURCE cel's image AND offset (a fully shared cel). Returns
|
|
700
|
+
* null if the cell is empty / unresolvable. Guards against cycles and invalid
|
|
701
|
+
* indices (returns null rather than looping).
|
|
702
|
+
*/
|
|
703
|
+
export function resolveCell(layer, frameIndex) {
|
|
704
|
+
const visited = new Set();
|
|
705
|
+
let idx = frameIndex;
|
|
706
|
+
// Bound the walk by cells.length as a hard backstop in addition to `visited`.
|
|
707
|
+
for (let steps = 0; steps <= layer.cells.length; steps++) {
|
|
708
|
+
if (idx < 0 || idx >= layer.cells.length) return null;
|
|
709
|
+
if (visited.has(idx)) return null;
|
|
710
|
+
visited.add(idx);
|
|
711
|
+
const cell = layer.cells[idx];
|
|
712
|
+
if (cell == null) return null;
|
|
713
|
+
if ('grid' in cell) return { grid: cell.grid, x: cell.x ?? 0, y: cell.y ?? 0 };
|
|
714
|
+
idx = cell.link;
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Resolve just the effective image grid for a (layer, frameIndex) cell (offset
|
|
721
|
+
* dropped). Thin wrapper over `resolveCell` kept for call sites that only need
|
|
722
|
+
* the pixels (e.g. linkability checks, timeline thumbnails).
|
|
723
|
+
*/
|
|
724
|
+
export function resolveCellGrid(layer, frameIndex) {
|
|
725
|
+
return resolveCell(layer, frameIndex)?.grid ?? null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Visit each pixel of a resolved cel (image grid + x/y offset) that lands inside
|
|
730
|
+
* the [0,width) x [0,height) canvas window. `visit(cx, cy, key)` receives the
|
|
731
|
+
* canvas coordinates and the grid char at that cel pixel. The single home for
|
|
732
|
+
* the cel-offset clip math, shared by the frame renderer and the editor's
|
|
733
|
+
* canvas-window readback so the two never drift.
|
|
734
|
+
*/
|
|
735
|
+
export function forEachCelPixel(resolved, width, height, visit) {
|
|
736
|
+
const { grid, x: ox, y: oy } = resolved;
|
|
737
|
+
for (let gy = 0; gy < grid.length; gy++) {
|
|
738
|
+
const cy = gy + oy;
|
|
739
|
+
if (cy < 0 || cy >= height) continue;
|
|
740
|
+
const row = grid[gy];
|
|
741
|
+
for (let gx = 0; gx < row.length; gx++) {
|
|
742
|
+
const cx = gx + ox;
|
|
743
|
+
if (cx < 0 || cx >= width) continue;
|
|
744
|
+
visit(cx, cy, row[gx]);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// full-form rendering — composite a single frame
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Render ONE frame of a Sprite to a canvas, compositing layers bottom->top.
|
|
755
|
+
* Sizes the canvas to the sprite resolution, respects per-layer `visible` and
|
|
756
|
+
* `opacity`, treats blendMode "normal", resolves linked cells, and leaves
|
|
757
|
+
* `null` cells / transparent keys clear. Uses the same 1px-per-cell,
|
|
758
|
+
* imageSmoothingEnabled=false approach as the compact renderer; scale up with CSS
|
|
759
|
+
* `image-rendering: pixelated` for display.
|
|
760
|
+
*/
|
|
761
|
+
export function renderSpriteFrame(sprite, frameIndex, canvas) {
|
|
762
|
+
const { width, height } = sprite.resolution;
|
|
763
|
+
canvas.width = width;
|
|
764
|
+
canvas.height = height;
|
|
765
|
+
const ctx = canvas.getContext('2d');
|
|
766
|
+
if (!ctx) return;
|
|
767
|
+
ctx.imageSmoothingEnabled = false;
|
|
768
|
+
ctx.clearRect(0, 0, width, height);
|
|
769
|
+
|
|
770
|
+
const lookup = paletteLookup(sprite.palette);
|
|
771
|
+
const prevAlpha = ctx.globalAlpha;
|
|
772
|
+
for (const layer of sprite.layers) {
|
|
773
|
+
if (!layer.visible || layer.opacity <= 0) continue;
|
|
774
|
+
const resolved = resolveCell(layer, frameIndex);
|
|
775
|
+
if (!resolved) continue;
|
|
776
|
+
ctx.globalAlpha = Math.min(1, Math.max(0, layer.opacity));
|
|
777
|
+
// Blit the cel image at its (x, y) offset, CLIPPING to the canvas window.
|
|
778
|
+
// The cel image may be larger than / offset outside the canvas; only the
|
|
779
|
+
// portion that lands inside [0,width) x [0,height) is drawn.
|
|
780
|
+
forEachCelPixel(resolved, width, height, (cx, cy, key) => {
|
|
781
|
+
const color = colorForKeyV2(lookup, key);
|
|
782
|
+
if (color) {
|
|
783
|
+
ctx.fillStyle = color;
|
|
784
|
+
ctx.fillRect(cx, cy, 1, 1);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
ctx.globalAlpha = prevAlpha;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ===========================================================================
|
|
792
|
+
// svg-rect decode (generation path)
|
|
793
|
+
// ===========================================================================
|
|
794
|
+
//
|
|
795
|
+
// The CLI generation worker prompts an LLM to emit pixel art as a tiny <svg>
|
|
796
|
+
// with one <rect> per pixel. We snap those rects onto a cell grid by (x,y) and
|
|
797
|
+
// cell size, mint a palette from the distinct fills, and emit PxArt. Direct
|
|
798
|
+
// rect->cell mapping; if the rects don't land on a clean grid we still snap
|
|
799
|
+
// (round) onto the inferred grid, which degrades gracefully without pulling in a
|
|
800
|
+
// rasterizer. Ported verbatim from castle-px-eval/src/representations.ts.
|
|
801
|
+
// ===========================================================================
|
|
802
|
+
|
|
803
|
+
function parseRects(svg) {
|
|
804
|
+
const rects = [];
|
|
805
|
+
const rectRe = /<rect\b([^>]*?)\/?>/gi;
|
|
806
|
+
const attrRe = /([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)'/g;
|
|
807
|
+
let m;
|
|
808
|
+
while ((m = rectRe.exec(svg))) {
|
|
809
|
+
const attrs = {};
|
|
810
|
+
let a;
|
|
811
|
+
attrRe.lastIndex = 0;
|
|
812
|
+
while ((a = attrRe.exec(m[1]))) {
|
|
813
|
+
const key = (a[1] ?? a[3]).toLowerCase();
|
|
814
|
+
attrs[key] = a[2] ?? a[4];
|
|
815
|
+
}
|
|
816
|
+
const x = Number(attrs.x ?? '0');
|
|
817
|
+
const y = Number(attrs.y ?? '0');
|
|
818
|
+
const w = Number(attrs.width ?? '1');
|
|
819
|
+
const h = Number(attrs.height ?? '1');
|
|
820
|
+
let fill = null;
|
|
821
|
+
const rawFill = attrs.fill ?? styleFill(attrs.style);
|
|
822
|
+
if (rawFill && rawFill !== 'none' && rawFill !== 'transparent') fill = normalizeHex(rawFill);
|
|
823
|
+
if (![x, y, w, h].every(Number.isFinite)) continue;
|
|
824
|
+
rects.push({ x, y, w, h, fill });
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Canvas size: prefer viewBox, then width/height attrs on <svg>.
|
|
828
|
+
let canvas = null;
|
|
829
|
+
const vb = svg.match(/viewBox\s*=\s*["']\s*([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s*["']/i);
|
|
830
|
+
if (vb) {
|
|
831
|
+
const w = Number(vb[3]);
|
|
832
|
+
const h = Number(vb[4]);
|
|
833
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) canvas = { w, h };
|
|
834
|
+
}
|
|
835
|
+
if (!canvas) {
|
|
836
|
+
const svgTag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? '';
|
|
837
|
+
const w = Number(svgTag.match(/\bwidth\s*=\s*["']?([\d.]+)/i)?.[1]);
|
|
838
|
+
const h = Number(svgTag.match(/\bheight\s*=\s*["']?([\d.]+)/i)?.[1]);
|
|
839
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) canvas = { w, h };
|
|
840
|
+
}
|
|
841
|
+
return { rects, canvas };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function styleFill(style) {
|
|
845
|
+
if (!style) return undefined;
|
|
846
|
+
return style.match(/fill\s*:\s*([^;]+)/i)?.[1]?.trim();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function normalizeSvgRect(raw, ctx) {
|
|
850
|
+
const svgStart = raw.indexOf('<svg');
|
|
851
|
+
const src = svgStart >= 0 ? raw.slice(svgStart) : raw;
|
|
852
|
+
const { rects, canvas } = parseRects(src);
|
|
853
|
+
const filled = rects.filter((r) => r.fill && r.w > 0 && r.h > 0);
|
|
854
|
+
if (!filled.length) return null;
|
|
855
|
+
|
|
856
|
+
// Infer cell size: the smallest rect side is one cell (rects are per-pixel, so
|
|
857
|
+
// this is almost always 1, but models sometimes scale up, e.g. 10px cells).
|
|
858
|
+
let cell = Infinity;
|
|
859
|
+
for (const r of filled) cell = Math.min(cell, r.w, r.h);
|
|
860
|
+
if (!Number.isFinite(cell) || cell <= 0) cell = 1;
|
|
861
|
+
|
|
862
|
+
// Grid extent: from canvas if given, else from the rects' bounding box.
|
|
863
|
+
let cols;
|
|
864
|
+
let rows;
|
|
865
|
+
if (canvas) {
|
|
866
|
+
cols = Math.max(1, Math.round(canvas.w / cell));
|
|
867
|
+
rows = Math.max(1, Math.round(canvas.h / cell));
|
|
868
|
+
} else {
|
|
869
|
+
let maxX = 0;
|
|
870
|
+
let maxY = 0;
|
|
871
|
+
for (const r of filled) {
|
|
872
|
+
maxX = Math.max(maxX, r.x + r.w);
|
|
873
|
+
maxY = Math.max(maxY, r.y + r.h);
|
|
874
|
+
}
|
|
875
|
+
cols = Math.max(1, Math.round(maxX / cell));
|
|
876
|
+
rows = Math.max(1, Math.round(maxY / cell));
|
|
877
|
+
}
|
|
878
|
+
// Guard against absurd canvases (typos like width="1000"); fall back to the
|
|
879
|
+
// target size when available, else clamp.
|
|
880
|
+
const MAX = 256;
|
|
881
|
+
if (cols > MAX || rows > MAX) {
|
|
882
|
+
if (ctx.targetSize) {
|
|
883
|
+
cols = ctx.targetSize.width;
|
|
884
|
+
rows = ctx.targetSize.height;
|
|
885
|
+
} else {
|
|
886
|
+
cols = Math.min(cols, MAX);
|
|
887
|
+
rows = Math.min(rows, MAX);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const { palette, keyOf } = allocatePaletteKeys(filled.map((r) => r.fill));
|
|
892
|
+
const cells = Array.from({ length: rows }, () =>
|
|
893
|
+
Array.from({ length: cols }, () => TRANSPARENT),
|
|
894
|
+
);
|
|
895
|
+
for (const r of filled) {
|
|
896
|
+
const hex = normalizeHex(r.fill);
|
|
897
|
+
const key = hex ? keyOf.get(hex) : undefined;
|
|
898
|
+
if (!key) continue;
|
|
899
|
+
const c0 = Math.round(r.x / cell);
|
|
900
|
+
const r0 = Math.round(r.y / cell);
|
|
901
|
+
const cspan = Math.max(1, Math.round(r.w / cell));
|
|
902
|
+
const rspan = Math.max(1, Math.round(r.h / cell));
|
|
903
|
+
for (let dy = 0; dy < rspan; dy++) {
|
|
904
|
+
for (let dx = 0; dx < cspan; dx++) {
|
|
905
|
+
const cx = c0 + dx;
|
|
906
|
+
const cy = r0 + dy;
|
|
907
|
+
if (cy >= 0 && cy < rows && cx >= 0 && cx < cols) cells[cy][cx] = key;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return fromCells(palette, cells);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// svg-rect -> palette-quantized PxArt (higher-level generation helper)
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
/** Quantize a single hex color to the nearest entry in `palette` by RGB
|
|
919
|
+
* Euclidean distance. Alpha is ignored for distance. Returns the chosen
|
|
920
|
+
* "#rrggbb" (lowercase), or null when the input is invalid or fully
|
|
921
|
+
* transparent (alpha === 00). */
|
|
922
|
+
function quantizeToPalette(hex, palette) {
|
|
923
|
+
const norm = normalizeHex(hex);
|
|
924
|
+
if (!norm) return null;
|
|
925
|
+
const body = norm.slice(1);
|
|
926
|
+
// Preserve full transparency: an 8-digit color with alpha 00 stays transparent.
|
|
927
|
+
if (body.length === 8 && body.slice(6, 8) === '00') return null;
|
|
928
|
+
const r = parseInt(body.slice(0, 2), 16);
|
|
929
|
+
const g = parseInt(body.slice(2, 4), 16);
|
|
930
|
+
const b = parseInt(body.slice(4, 6), 16);
|
|
931
|
+
let best = null;
|
|
932
|
+
let bestDist = Infinity;
|
|
933
|
+
for (const entry of palette) {
|
|
934
|
+
const pn = normalizeHex(entry);
|
|
935
|
+
if (!pn) continue;
|
|
936
|
+
const pr = parseInt(pn.slice(1, 3), 16);
|
|
937
|
+
const pg = parseInt(pn.slice(3, 5), 16);
|
|
938
|
+
const pb = parseInt(pn.slice(5, 7), 16);
|
|
939
|
+
const dist = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
|
940
|
+
if (dist < bestDist) {
|
|
941
|
+
bestDist = dist;
|
|
942
|
+
best = '#' + pn.slice(1, 7);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return best;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Decode a terse svg-rect emission into a compact PxArt, quantized onto a fixed
|
|
950
|
+
* palette. Runs the svg-rect decode at the target resolution (default
|
|
951
|
+
* DEFAULT_RESOLUTION, 16×16), then maps every resulting color to the nearest
|
|
952
|
+
* color in `palette` (default AGENT_PALETTE_16, the 16-color agent subset) via
|
|
953
|
+
* RGB Euclidean distance. The returned palette contains only the quantized
|
|
954
|
+
* colors actually used; transparent cells stay transparent ("."). Returns null
|
|
955
|
+
* if the svg has no usable rects.
|
|
956
|
+
*/
|
|
957
|
+
export function svgRectToPxArt(svg, opts) {
|
|
958
|
+
const resolution = opts?.resolution ?? DEFAULT_RESOLUTION;
|
|
959
|
+
const palette = opts?.palette ?? AGENT_PALETTE_16;
|
|
960
|
+
|
|
961
|
+
const decoded = normalizeSvgRect(svg, { targetSize: resolution });
|
|
962
|
+
if (!decoded) return null;
|
|
963
|
+
|
|
964
|
+
// Map each original palette key to its quantized "#rrggbb" (or drop it).
|
|
965
|
+
const quantHexByKey = new Map();
|
|
966
|
+
for (const [key, hex] of Object.entries(decoded.palette)) {
|
|
967
|
+
if (typeof hex !== 'string') continue;
|
|
968
|
+
const q = quantizeToPalette(hex, palette);
|
|
969
|
+
if (q) quantHexByKey.set(key, q);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Allocate fresh single-char keys for the distinct quantized colors used.
|
|
973
|
+
const { palette: outPalette, keyOf } = allocatePaletteKeys(quantHexByKey.values());
|
|
974
|
+
|
|
975
|
+
const cells = toCells(decoded).map((row) =>
|
|
976
|
+
row.map((ch) => {
|
|
977
|
+
if (ch === TRANSPARENT) return TRANSPARENT;
|
|
978
|
+
const q = quantHexByKey.get(ch);
|
|
979
|
+
if (!q) return TRANSPARENT;
|
|
980
|
+
return keyOf.get(q) ?? TRANSPARENT;
|
|
981
|
+
}),
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
return fromCells(outPalette, cells);
|
|
985
|
+
}
|