castle-web-cli 0.4.72 → 0.4.74
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 +703 -222
- package/kits/basic-2d/editors/SelectionOverlay.jsx +818 -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 +169 -13
- 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/playConsole.js +66 -0
- 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 +1385 -332
- 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-vmdKwUE1.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,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
|
-
|
|
5
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
<
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
}
|