castle-web-cli 0.4.72 → 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.
Files changed (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-vmdKwUE1.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -0,0 +1,47 @@
1
+ // Tint helpers for the pixel-art Sprite behavior. A tint is a hex color
2
+ // (#rrggbb / #rrggbbaa) multiplied channel-wise into each source pixel. White
3
+ // (#ffffffff) means "no change".
4
+
5
+ // Parse a hex color into [r, g, b, a] channels (0-255). Returns null when the
6
+ // string is not a hex color so callers can fall back to the raw color.
7
+ export function parseHexColor(color) {
8
+ const hex = String(color).trim().replace(/^#/, '');
9
+ if (hex.length !== 6 && hex.length !== 8) return null;
10
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
11
+ const r = parseInt(hex.slice(0, 2), 16);
12
+ const g = parseInt(hex.slice(2, 4), 16);
13
+ const b = parseInt(hex.slice(4, 6), 16);
14
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255;
15
+ return [r, g, b, a];
16
+ }
17
+
18
+ // Returns the tint as [r, g, b, a] channels, or null when there is no tint or
19
+ // the tint is white (#ffffffff) -- both meaning "no change".
20
+ export function parseTint(tint) {
21
+ if (!tint) return null;
22
+ const rgba = parseHexColor(tint);
23
+ if (!rgba) return null;
24
+ if (rgba[0] === 255 && rgba[1] === 255 && rgba[2] === 255 && rgba[3] === 255) {
25
+ return null;
26
+ }
27
+ return rgba;
28
+ }
29
+
30
+ // Multiply each channel of a pixel color (hex string) by the tint (0-255).
31
+ export function applyTint(color, tint) {
32
+ const rgba = parseHexColor(color);
33
+ if (!rgba) return color;
34
+ const out = rgba.map((channel, i) => Math.round((channel * tint[i]) / 255));
35
+ return '#' + out.map((channel) => channel.toString(16).padStart(2, '0')).join('');
36
+ }
37
+
38
+ // Multiply an ImageData buffer's RGBA channels in place by the tint (0-255).
39
+ // Used to tint an already-rendered offscreen canvas (the Sprite path).
40
+ export function tintImageData(data, tint) {
41
+ for (let i = 0; i < data.length; i += 4) {
42
+ data[i] = Math.round((data[i] * tint[0]) / 255);
43
+ data[i + 1] = Math.round((data[i + 1] * tint[1]) / 255);
44
+ data[i + 2] = Math.round((data[i + 2] * tint[2]) / 255);
45
+ data[i + 3] = Math.round((data[i + 3] * tint[3]) / 255);
46
+ }
47
+ }
@@ -0,0 +1,298 @@
1
+ # `.pxart` Pixel-Art Format (basic-2d kit)
2
+
3
+ This documents the `.pxart` sprite format as integrated into the `basic-2d`
4
+ kit. The format and its parser/serializer/renderer live in the kit itself, in
5
+ `engine/pxart.js` (`parseFull`, `serializeFull`, `renderSpriteFrame`, plus
6
+ the compact-form helpers `parseCompact` / `serializeCompact` / `renderToCanvas`).
7
+ `.pxart` is a pure kit-layer concern — the harness (`castle-web-sdk`, the CLI)
8
+ has no knowledge of it.
9
+
10
+ A sprite is modeled on Aseprite conventions: a `layers × frames` spreadsheet of
11
+ cells, one resolution per sprite, an ordered indexed palette, named animation
12
+ tags, and Aseprite-style linked cells. A `.pxart` file is a single JSON object.
13
+
14
+ The format has two **coexisting on-disk forms** — capability tiers, *not*
15
+ temporal versions — that the kit treats as one in-memory model:
16
+
17
+ - **full** — the full layered/animated `Sprite` (`"format": "full"`).
18
+ - **compact** — a flat `{ palette, grid }` object (`"format": "compact"`, or
19
+ absent). The parser upgrades it into a degenerate one-layer / one-frame
20
+ Sprite in memory.
21
+
22
+ > Files in this kit use the extension `.pxart`. They live under `drawings/`
23
+ > and are discovered by `engine/files.js`.
24
+
25
+ ---
26
+
27
+ ## 1. Form discriminator
28
+
29
+ - Every canonical file carries a top-level string `format`.
30
+ - `"format": "full"` — the layered/animated sprite.
31
+ - `"format": "compact"` (or absent) — the flat-grid shorthand (see
32
+ [§9](#9-compact-shorthand)). The parser upgrades it in memory.
33
+ - `serializeFull` always stamps `"format": "full"`. `serializeCompact` (the
34
+ compact path the kit's `PxArtEditor` and the `draw` generator use) always
35
+ stamps `"format": "compact"`.
36
+ - **The two forms are capability tiers, not versions** — `full` is a superset of
37
+ `compact`. Files of both kinds coexist; a sprite is written in whichever form
38
+ loses nothing (see [§9](#9-compact-shorthand)).
39
+ - **Detection is by STRUCTURE, not the field.** The parser recognizes the
40
+ compact form by an object `palette` + a `grid`, and the full form by a
41
+ `resolution` / array `palette` / `frames` / `layers`. The `format` field is a
42
+ hint used only to disambiguate genuinely ambiguous shapes; clear structure
43
+ always wins, so a file with a missing or even mislabeled discriminator still
44
+ parses (without silent mis-detection). Unknown top-level fields are ignored
45
+ (forward compatibility).
46
+
47
+ ---
48
+
49
+ ## 2. Resolution
50
+
51
+ ```json
52
+ "resolution": { "width": 16, "height": 16 }
53
+ ```
54
+
55
+ - **Default resolution is 16×16** (`DEFAULT_RESOLUTION`). New/empty sprites and
56
+ generated art start at 16×16, and the kit's `PxArtEditor` opens a blank canvas
57
+ at this size.
58
+ - **Power-of-two only**, from **16 to 512**: one of `16, 32, 64, 128, 256, 512`.
59
+ - **One resolution per sprite.** There is no per-layer or per-frame resolution.
60
+ - **Parser policy:**
61
+ - **Explicit full-form `resolution`:** each dimension is **snapped** to the
62
+ valid set (clamped to `[16, 512]`, then rounded to the nearest power of two,
63
+ ties round up). Out-of-range values snap rather than reject, keeping the
64
+ parser tolerant.
65
+ - **compact shorthand upgrade:** resolution is inferred from the grid's
66
+ **native dimensions verbatim** and is **not** snapped, so arbitrary-size
67
+ compact grids round-trip faithfully.
68
+
69
+ ---
70
+
71
+ ## 3. Palette
72
+
73
+ ```json
74
+ "palette": [
75
+ { "key": "o", "hex": "#e8b800" },
76
+ { "key": "y", "hex": "#ffe11a" }
77
+ ]
78
+ ```
79
+
80
+ - An **ORDERED array** of `{ key, hex }` entries (indexed-with-hex color mode).
81
+ - `key`: a **single character** used in packed grid strings.
82
+ - `hex`: a normalized `#rrggbb` or `#rrggbbaa`.
83
+ - **Ordering is meaningful** — it is the stable index, preserved on parse and
84
+ serialize.
85
+ - **`.` is reserved for transparent** and is **never** a palette entry. A grid
86
+ character of `.` (or any char with no matching palette key) renders
87
+ transparent.
88
+ - Indexed-with-hex only: **not** RGBA-per-pixel, **not** grayscale.
89
+
90
+ ### Two-tier palette system (Endesga-64)
91
+
92
+ The kit uses a **two-tier** fixed palette, both tiers drawn from Lospec's
93
+ [Endesga 64](https://lospec.com/palette-list/endesga-64). There is no free color
94
+ picking at either tier.
95
+
96
+ **Full palette — Endesga 64 (`EDG64`, all 64 colors): what USERS paint with.**
97
+ The kit's `PxArtEditor` exposes the **full 64-color swatch** (canonical Lospec
98
+ order). The editor assigns each color a stable single-char key (`KEY_ALPHABET`,
99
+ 64 distinct keys) plus `.` for transparent. When an existing sprite is opened for
100
+ editing, each cell's resolved color is **snapped to the nearest Endesga-64
101
+ color** by RGB distance, so editing always stays on the fixed swatch. (Sprites
102
+ still *render* with their own stored palette via `renderSpriteFrame`; the snap
103
+ only affects what the editor writes back.) The swatch UI renders as an 8-column
104
+ grid (8 rows for the 64 colors) that scrolls within the inspector.
105
+
106
+ **Agent subset — 16 colors (`AGENT_PALETTE_16`): what the LLM generates with.**
107
+ The agent generation path (the `draw` svg-rect → `.pxart` quantizer and the
108
+ prompt the model receives) is constrained to a **fixed 16-color subset of
109
+ Endesga-64**, so generated art stays coherent. Every color below is a member of
110
+ `EDG64`. In order:
111
+
112
+ ```
113
+ #e69c69 #bf6f4a #8a4836 #391f21 #891e2b #ea323c #ffa214 #ffeb57
114
+ #5ac54f #1e6f50 #134c4c #657392 #c7cfdd #ffffff #0cf1ff #0098dc
115
+ ```
116
+
117
+ So: the editor offers all 64 to people; the agent generation path snaps to these
118
+ 16. Both constants (`EDG64`, `AGENT_PALETTE_16`) live in the kit's
119
+ `engine/pxart.js`.
120
+
121
+ ---
122
+
123
+ ## 4. Frames & timing
124
+
125
+ ```json
126
+ "frames": [{ "durationMs": 120 }, { "durationMs": 120 }, { "durationMs": 240 }],
127
+ "defaultDurationMs": 120
128
+ ```
129
+
130
+ - `frames`: an array of `{ durationMs }`. Its length is the **frame count**.
131
+ Frame indices are 0-based.
132
+ - `defaultDurationMs`: applied to any frame missing an explicit `durationMs`
133
+ (the kit default is 100ms).
134
+ - **No global `fps` field** (matches Aseprite). Holding a frame longer is just a
135
+ larger `durationMs`.
136
+
137
+ The kit's `Sprite` behavior advances frames in its `update(dt)` using each
138
+ frame's `durationMs`.
139
+
140
+ ---
141
+
142
+ ## 5. Tags
143
+
144
+ ```json
145
+ "tags": [
146
+ { "name": "idle", "from": 0, "to": 1, "direction": "pingpong", "repeat": 0 },
147
+ { "name": "blink", "from": 2, "to": 2, "direction": "forward", "repeat": 1 }
148
+ ],
149
+ "defaultTag": "idle"
150
+ ```
151
+
152
+ - `tags`: named, inclusive 0-based frame ranges. Each tag has:
153
+ - `name`: stable handle.
154
+ - `from`, `to`: inclusive frame indices.
155
+ - `direction`: `"forward" | "reverse" | "pingpong"`.
156
+ - `repeat`: play count; **`0` = loop forever**.
157
+ - `defaultTag`: name of the tag that plays by default.
158
+
159
+ **Animation lives in the file** — ranges and timing are the asset's intrinsic
160
+ property. The placement/runtime layer may override *which* tag plays:
161
+
162
+ ### Actor-side tag override (this kit)
163
+
164
+ The `Sprite` behavior exposes a `tag` prop. Resolution order:
165
+
166
+ 1. If `tag` is a non-empty string naming a tag in the file, that tag plays.
167
+ 2. Otherwise the file's `defaultTag` plays.
168
+ 3. Otherwise the whole timeline plays forward, looping.
169
+
170
+ `direction` and `repeat` come from the tag (a whole-timeline fallback loops
171
+ forever, forward). When `repeat` is exhausted the behavior holds the last frame.
172
+ When the `playing` prop is `false`, the behavior holds frame 0.
173
+
174
+ ---
175
+
176
+ ## 6. Layers
177
+
178
+ ```json
179
+ "layers": [
180
+ {
181
+ "id": "body", "name": "Body", "visible": true, "opacity": 1.0,
182
+ "blendMode": "normal", "kind": "pixel",
183
+ "cells": [ ... ]
184
+ }
185
+ ]
186
+ ```
187
+
188
+ - `layers`: an **ordered** array, **bottom → top** (`layers[0]` drawn first).
189
+ - Each layer: `id`, `name`, `visible` (skipped when hidden), `opacity`
190
+ (`0.0`–`1.0`), `blendMode` (**`"normal"` only** for now), `kind`
191
+ (**`"pixel"` only** for now), and `cells` (its row of the spreadsheet).
192
+
193
+ `renderSpriteFrame` composites visible layers bottom→top, applying per-layer
194
+ opacity, at 1px/cell with `imageSmoothingEnabled = false`.
195
+
196
+ ---
197
+
198
+ ## 7. Cells
199
+
200
+ `cells` is **dense-with-`null`**: `layer.cells.length == frames.length`, and
201
+ cell index `f` is the layer's content for frame `f`. A cell is one of:
202
+
203
+ | Form | Meaning |
204
+ | --- | --- |
205
+ | `{ "grid": ["...", "..."] }` | An image: packed strings using palette keys, one per row. `.` = transparent. |
206
+ | `{ "link": <frameIndex> }` | Aseprite-style **same-layer** reference; shares the image of `cells[frameIndex]`. Edits to the source propagate. |
207
+ | `null` | Empty / fully transparent for this (layer, frame). |
208
+
209
+ - A `link` must point at a cell index on the **same layer**; the resolver
210
+ (`resolveCellGrid`) follows the chain to the underlying `{ grid }`, guarding
211
+ against cycles and invalid indices (resolving to `null` if it can't reach a
212
+ grid).
213
+ - **Faithful storage:** grids are stored as **packed strings** of palette keys.
214
+ Generation-only emission shapes (svg-rect, etc.) normalize into this packed
215
+ form and are never the thing of record.
216
+
217
+ ---
218
+
219
+ ## 8. Coordinates
220
+
221
+ - **Origin is top-left.** `grid[0]` is the top row; char index 0 is the leftmost
222
+ column.
223
+ - Units are **pixels** within the sprite's `resolution`. Scaling/placement
224
+ happen **outside** the file — in this kit, the actor's `Layout` box. The
225
+ `Sprite` behavior blits the native-resolution frame into the Layout rectangle
226
+ with smoothing disabled and an optional `tint` multiply.
227
+
228
+ ---
229
+
230
+ ## 9. compact shorthand
231
+
232
+ A file with a top-level `palette` (an **object** map) + `grid` (no `layers` /
233
+ `frames`) is the **compact flat-grid shorthand**:
234
+
235
+ ```json
236
+ {
237
+ "format": "compact",
238
+ "palette": { "y": "#ffe11a", "o": "#e8b800", ".": null },
239
+ "grid": ["..ooyyoo..", ".oyyyyyyo.", "oyyyyyyyyo"]
240
+ }
241
+ ```
242
+
243
+ It is **sugar for one layer / one frame**, upgraded in memory by `parseFull`
244
+ (via `upgradeCompactToFull`):
245
+
246
+ - `resolution` ← the grid's native `{ width, height }` (not snapped).
247
+ - `palette` ← the compact record as an ordered array (insertion order), dropping
248
+ the `.`/null transparent entry.
249
+ - one `frame` using `defaultDurationMs`.
250
+ - one `pixel` layer whose single cell is `{ grid: <the grid> }`.
251
+
252
+ This keeps cold-start generation tiny: emit just `{ palette, grid }` and the
253
+ system treats it as a full (degenerate) Sprite.
254
+
255
+ ### Single-layer / single-frame generation contract
256
+
257
+ The **generation contract** for this kit is to emit the **compact shorthand**: a
258
+ single 16×16 (default) `{ palette, grid }` sprite over the agent 16-color subset
259
+ (`AGENT_PALETTE_16`).
260
+ The CLI generation worker and the kit's `PxArtEditor` both produce/store this
261
+ compact form when nothing is lost by it. Multi-layer / multi-frame structure is
262
+ authored by tools later, not hand-generated — and the minimal `PxArtEditor`
263
+ opens such files **read-only** (it only edits the single-layer / single-frame
264
+ case).
265
+
266
+ ### compact ↔ full relationship
267
+
268
+ - `full` is a **superset** of `compact`; the compact functions keep their exact
269
+ behavior.
270
+ - A compact file is equivalent to a full sprite with one `pixel` layer and one
271
+ frame.
272
+ - `parseFull` accepts **both** forms and always yields a full `Sprite`.
273
+ - On save, the kit picks the form **structurally**: it serializes the working
274
+ sprite to the candidate compact form, re-parses it, and keeps the compact
275
+ output only when that round-trip reproduces the sprite exactly (`serializeModel`
276
+ in `editors/pxArtEditorModel.js`). A single fully-opaque visible layer / single
277
+ inheriting frame / no tags / all on-canvas content round-trips losslessly, so
278
+ it is stored compact. Anything the compact form can't carry — extra
279
+ layers/frames/tags, per-layer opacity or visibility, cel offsets, or off-canvas
280
+ content — forces `serializeFull`. This is a derived fact, not a hand-maintained
281
+ checklist, so it stays correct as new full-form features are added.
282
+
283
+ ---
284
+
285
+ ## 10. Reserved / out of scope
286
+
287
+ Named so the format can grow without a breaking change, but **not built**:
288
+ tilemap/tileset layers (a future `kind`), slices, layer groups, vector/avatar
289
+ layers, and rich blend modes (beyond `"normal"`). Reserving the `kind` and
290
+ `blendMode` discriminators keeps these additive later, not breaking.
291
+
292
+ ---
293
+
294
+ ## Appendix — sample
295
+
296
+ `drawings/pig.pxart` is a 16×16 single-layer sprite referenced by the
297
+ `pig_sprite` actor in `scenes/main.scene` through the `Sprite` behavior — a
298
+ working rendered example of the format in this kit.
@@ -0,0 +1,59 @@
1
+ {
2
+ "format": "full",
3
+ "resolution": {
4
+ "width": 16,
5
+ "height": 16
6
+ },
7
+ "palette": [
8
+ {
9
+ "key": "q",
10
+ "hex": "#391f21"
11
+ },
12
+ {
13
+ "key": "v",
14
+ "hex": "#f6ca9f"
15
+ },
16
+ {
17
+ "key": "6",
18
+ "hex": "#f5555d"
19
+ }
20
+ ],
21
+ "frames": [
22
+ {}
23
+ ],
24
+ "defaultDurationMs": 100,
25
+ "tags": [],
26
+ "layers": [
27
+ {
28
+ "id": "layer-0",
29
+ "name": "Layer 1",
30
+ "visible": true,
31
+ "opacity": 1,
32
+ "blendMode": "normal",
33
+ "kind": "pixel",
34
+ "cells": [
35
+ {
36
+ "grid": [
37
+ "................",
38
+ "................",
39
+ "........q..q....",
40
+ "....qqqqvqqvq...",
41
+ "...qvvvvvvvvq...",
42
+ "..qvvvvvvvvvvq..",
43
+ ".qvvvvvvvqvvqvq.",
44
+ ".qvvvvvvvqvvqvq.",
45
+ "q6vvvvvvvvv666q.",
46
+ "q6vvvvvvvv6q6q6q",
47
+ ".qvvvvvvvv66666q",
48
+ ".qvvvvvvvvv666q.",
49
+ "..qvvvvvvvvvqq..",
50
+ "..q66qqqq6q6q...",
51
+ "..q6q...q6q.....",
52
+ "................",
53
+ "................"
54
+ ]
55
+ }
56
+ ]
57
+ }
58
+ ]
59
+ }
@@ -1,32 +1,44 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { onBeforeRestart, writeFile } from 'castle-web-sdk';
3
- import {
4
- flatFileOrder,
5
- formatJson,
6
- getFileKind,
7
- initialFiles,
8
- parseJsonFile,
9
- } from '../engine/files';
10
- import { AppShell, cx, MainEditor, styles } from '../engine/ui';
3
+ import { flatFileOrder, formatJson, getFileKind, initialFiles } from '../engine/files';
4
+ import { collectAssets } from '../engine/assets';
5
+ import { AppShell, cx, MainEditor, styles, useCompactViewport } from '../engine/ui';
11
6
  import { CodeEditor } from './CodeEditor';
12
- import { DrawingEditor } from './DrawingEditor';
7
+ import { PxArtEditor } from './PxArtEditor';
13
8
  import { FileBrowser } from './FileBrowser';
14
9
  import { SceneEditor } from './SceneEditor';
10
+ // Desktop file-list open/closed state persists across reloads; the bottom
11
+ // sheet (compact) is always session-only.
12
+ const FILES_OPEN_KEY = 'castle-basic2d-files-open';
13
+ const SELECTED_PATH_KEY = 'castle-basic2d-selected-path';
14
+ function readFilesOpen() {
15
+ try {
16
+ return localStorage.getItem(FILES_OPEN_KEY) !== '0';
17
+ } catch {
18
+ return true;
19
+ }
20
+ }
21
+ function readSelectedPath() {
22
+ try {
23
+ const saved = localStorage.getItem(SELECTED_PATH_KEY);
24
+ return saved && initialFiles[saved] !== undefined ? saved : 'scenes/main.scene';
25
+ } catch {
26
+ return 'scenes/main.scene';
27
+ }
28
+ }
15
29
  export function App() {
16
30
  const [files, setFiles] = useState(initialFiles);
17
- const [selectedPath, setSelectedPath] = useState('scenes/main.scene');
31
+ const [selectedPath, setSelectedPath] = useState(readSelectedPath);
18
32
  const [filesSheetOpen, setFilesSheetOpen] = useState(false);
33
+ const [filesOpenDesktop, setFilesOpenDesktop] = useState(readFilesOpen);
34
+ const [headerHost, setHeaderHost] = useState(null);
35
+ const isCompact = useCompactViewport();
19
36
  const [selectedActorIds, setSelectedActorIds] = useState([]);
20
37
  const [multiSelectMode, setMultiSelectMode] = useState(false);
21
38
  const saveTimersRef = useRef({});
22
39
  const saveVersionsRef = useRef({});
23
40
  const pendingWritesRef = useRef({});
24
- const drawings = {};
25
- for (const [path, text] of Object.entries(files)) {
26
- if (!path.endsWith('.drawing')) continue;
27
- const parsed = parseJsonFile(path, text);
28
- if (parsed.value) drawings[path] = parsed.value;
29
- }
41
+ const { sprites } = collectAssets(files);
30
42
  function updateSelectedFile(nextText) {
31
43
  const path = selectedPath;
32
44
  setFiles((current) => ({
@@ -75,17 +87,47 @@ export function App() {
75
87
  }, []);
76
88
  const kind = getFileKind(selectedPath);
77
89
  const text = files[selectedPath] ?? '';
78
- function selectFile(path) {
90
+ // The PxArt pixel editor handles `.pxart` files; pick it by kind and render a
91
+ // single block.
92
+ const PixelEditor = kind === 'pxart' ? PxArtEditor : null;
93
+ // Move selection to `path` and persist it, without the extra resets
94
+ // selectFile applies. Used by file ops that should keep the same item
95
+ // selected across a rename or pick a neighbor after a delete.
96
+ function rememberSelected(path) {
79
97
  setSelectedPath(path);
98
+ try {
99
+ localStorage.setItem(SELECTED_PATH_KEY, path);
100
+ } catch {
101
+ // Debug convenience only; selecting files still works in-session.
102
+ }
103
+ }
104
+ function selectFile(path) {
105
+ rememberSelected(path);
80
106
  setFilesSheetOpen(false);
81
107
  setSelectedActorIds([]);
82
108
  setMultiSelectMode(false);
83
109
  }
110
+ // One toggle, two behaviors: compact opens/closes the file-list bottom
111
+ // sheet (and deselects, since the sheet covers the canvas); desktop collapses
112
+ // the 236px file-list column and persists that to localStorage.
84
113
  const onToggleFiles = () => {
85
- setFilesSheetOpen((previous) => !previous);
86
- setSelectedActorIds([]);
87
- setMultiSelectMode(false);
114
+ if (isCompact) {
115
+ setFilesSheetOpen((previous) => !previous);
116
+ setSelectedActorIds([]);
117
+ setMultiSelectMode(false);
118
+ return;
119
+ }
120
+ setFilesOpenDesktop((previous) => {
121
+ const next = !previous;
122
+ try {
123
+ localStorage.setItem(FILES_OPEN_KEY, next ? '1' : '0');
124
+ } catch {
125
+ // localStorage may be unavailable; the toggle still works in-session.
126
+ }
127
+ return next;
128
+ });
88
129
  };
130
+ const filesShown = isCompact ? filesSheetOpen : filesOpenDesktop;
89
131
  // Alt+Up / Alt+Down steps through files in file-browser sidebar order.
90
132
  useEffect(() => {
91
133
  function onKeyDown(event) {
@@ -114,64 +156,71 @@ export function App() {
114
156
  return () => window.removeEventListener('keydown', onKeyDown);
115
157
  }, [files, selectedPath]);
116
158
  return (
117
- <AppShell>
118
- <FileBrowser
119
- files={files}
120
- selectedPath={selectedPath}
121
- onSelect={selectFile}
122
- sheetOpen={filesSheetOpen}
123
- onSheetOpenChange={setFilesSheetOpen}
124
- />
125
- {filesSheetOpen ? (
126
- <div
127
- className={cx(styles.sheetBackdrop, styles.mobileOnly)}
128
- onClick={() => setFilesSheetOpen(false)}
159
+ <div className={cx(styles.editorRoot, !isCompact && !filesOpenDesktop && styles.filesHidden)}>
160
+ <div ref={setHeaderHost} className={styles.editorHeaderHost} />
161
+ <AppShell>
162
+ <FileBrowser
163
+ files={files}
164
+ selectedPath={selectedPath}
165
+ onSelect={selectFile}
166
+ sheetOpen={filesSheetOpen}
167
+ onSheetOpenChange={setFilesSheetOpen}
129
168
  />
130
- ) : null}
131
- <MainEditor>
132
- {kind === 'scene' ? (
133
- <SceneEditor
134
- path={selectedPath}
135
- text={text}
136
- files={files}
137
- drawings={drawings}
138
- onChange={updateSelectedFile}
139
- onToggleFiles={onToggleFiles}
140
- filesOpen={filesSheetOpen}
141
- selectedActorIds={selectedActorIds}
142
- onSelectActorIds={setSelectedActorIds}
143
- multiSelectMode={multiSelectMode}
144
- onSetMultiSelectMode={setMultiSelectMode}
145
- />
146
- ) : null}
147
- {kind === 'drawing' ? (
148
- <DrawingEditor
149
- path={selectedPath}
150
- text={text}
151
- onChange={updateSelectedFile}
152
- onToggleFiles={onToggleFiles}
153
- filesOpen={filesSheetOpen}
154
- />
155
- ) : null}
156
- {kind === 'code' ? (
157
- <CodeEditor
158
- path={selectedPath}
159
- text={text}
160
- onChange={updateSelectedFile}
161
- onToggleFiles={onToggleFiles}
162
- filesOpen={filesSheetOpen}
163
- />
164
- ) : null}
165
- {kind === 'text' ? (
166
- <CodeEditor
167
- path={selectedPath}
168
- text={formatJson(text)}
169
- onChange={updateSelectedFile}
170
- onToggleFiles={onToggleFiles}
171
- filesOpen={filesSheetOpen}
169
+ {filesSheetOpen ? (
170
+ <div
171
+ className={cx(styles.sheetBackdrop, styles.mobileOnly)}
172
+ onClick={() => setFilesSheetOpen(false)}
172
173
  />
173
174
  ) : null}
174
- </MainEditor>
175
- </AppShell>
175
+ <MainEditor>
176
+ {kind === 'scene' ? (
177
+ <SceneEditor
178
+ path={selectedPath}
179
+ text={text}
180
+ files={files}
181
+ sprites={sprites}
182
+ onChange={updateSelectedFile}
183
+ onToggleFiles={onToggleFiles}
184
+ filesOpen={filesShown}
185
+ headerHost={headerHost}
186
+ selectedActorIds={selectedActorIds}
187
+ onSelectActorIds={setSelectedActorIds}
188
+ multiSelectMode={multiSelectMode}
189
+ onSetMultiSelectMode={setMultiSelectMode}
190
+ />
191
+ ) : null}
192
+ {PixelEditor ? (
193
+ <PixelEditor
194
+ path={selectedPath}
195
+ text={text}
196
+ onChange={updateSelectedFile}
197
+ onToggleFiles={onToggleFiles}
198
+ filesOpen={filesShown}
199
+ headerHost={headerHost}
200
+ />
201
+ ) : null}
202
+ {kind === 'code' ? (
203
+ <CodeEditor
204
+ path={selectedPath}
205
+ text={text}
206
+ onChange={updateSelectedFile}
207
+ onToggleFiles={onToggleFiles}
208
+ filesOpen={filesShown}
209
+ headerHost={headerHost}
210
+ />
211
+ ) : null}
212
+ {kind === 'text' ? (
213
+ <CodeEditor
214
+ path={selectedPath}
215
+ text={formatJson(text)}
216
+ onChange={updateSelectedFile}
217
+ onToggleFiles={onToggleFiles}
218
+ filesOpen={filesShown}
219
+ headerHost={headerHost}
220
+ />
221
+ ) : null}
222
+ </MainEditor>
223
+ </AppShell>
224
+ </div>
176
225
  );
177
226
  }