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.
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-BY21Og40.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,506 @@
1
+ /* Layers x frames timeline panel for the PxArt editor. Sits below the artboard
2
+ in the editor's left column: a toolbar row, a horizontally-scrolling
3
+ layers(rows) x frames(columns) grid, then the tags panel. Colors reuse the
4
+ shared inspector CSS variables from engine/ui.module.css. */
5
+
6
+ /* The editor's left column: artboard region stacked above the timeline. Takes
7
+ grid column 1 of the shared .drawingEditor grid (the inspector is column 2). */
8
+ .editorLeft {
9
+ grid-column: 1;
10
+ grid-row: 1;
11
+ display: flex;
12
+ flex-direction: column;
13
+ min-width: 0;
14
+ min-height: 0;
15
+ }
16
+
17
+ .artboardRegion {
18
+ flex: 1 1 0;
19
+ min-height: 0;
20
+ display: flex;
21
+ overflow: hidden;
22
+ }
23
+
24
+ @container editor (max-width: 620px) {
25
+ .barLabel {
26
+ display: none;
27
+ }
28
+ }
29
+
30
+ /* Height is driven by an inline style from the resize handle (see
31
+ pxArtTimeline.jsx); flex-shrink stays 0 so the artboard region above keeps
32
+ the remainder. */
33
+ .timeline {
34
+ position: relative;
35
+ flex: 0 0 auto;
36
+ display: flex;
37
+ flex-direction: column;
38
+ min-height: 0;
39
+ border-top: 1px solid var(--castle-inspector-divider);
40
+ background: var(--castle-workspace-bg);
41
+ color: var(--castle-inspector-text);
42
+ font-size: 13px;
43
+ overflow: hidden;
44
+ }
45
+
46
+ /* Drag target sitting over the top divider. Absolute so it consumes no layout.
47
+ Neutral hover/active treatment (no accent/purple highlight) to stay
48
+ consistent with the editor's existing chrome. */
49
+ .resizeHandle {
50
+ position: absolute;
51
+ top: -3px;
52
+ left: 0;
53
+ right: 0;
54
+ height: 7px;
55
+ cursor: row-resize;
56
+ background: transparent;
57
+ z-index: 5;
58
+ touch-action: none;
59
+ }
60
+
61
+ .resizeHandle:hover {
62
+ background: var(--castle-inspector-border);
63
+ }
64
+
65
+ .resizeHandleActive,
66
+ .resizeHandleActive:hover {
67
+ background: var(--castle-inspector-muted);
68
+ }
69
+
70
+ .bar {
71
+ display: flex;
72
+ align-items: center;
73
+ flex-wrap: wrap;
74
+ gap: 14px;
75
+ padding: 8px;
76
+ border-bottom: 1px solid var(--castle-inspector-divider);
77
+ }
78
+
79
+ .barGroup {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .barLabel {
86
+ color: var(--castle-inspector-muted);
87
+ font-size: 12px;
88
+ text-transform: uppercase;
89
+ letter-spacing: 0.04em;
90
+ }
91
+
92
+ .barSpacer {
93
+ flex: 1 1 auto;
94
+ }
95
+
96
+ .barUnit {
97
+ color: var(--castle-inspector-muted);
98
+ font-size: 11px;
99
+ }
100
+
101
+ .globalDuration {
102
+ width: 56px;
103
+ border: 1px solid var(--castle-inspector-border);
104
+ border-radius: var(--castle-radius);
105
+ background: var(--castle-inspector-input-bg);
106
+ color: var(--castle-inspector-text);
107
+ padding: 4px 6px;
108
+ text-align: center;
109
+ }
110
+
111
+ /* Icon-only label (stopwatch) sitting next to the global duration input, in
112
+ place of the old "Duration … ms · all frames" text. */
113
+ .durationIcon {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ color: var(--castle-inspector-muted);
117
+ }
118
+
119
+ .durationGlyph {
120
+ width: 14px;
121
+ height: 14px;
122
+ fill: currentColor;
123
+ }
124
+
125
+ .miniInput {
126
+ width: 64px;
127
+ border: 1px solid var(--castle-inspector-border);
128
+ border-radius: var(--castle-radius);
129
+ background: var(--castle-inspector-input-bg);
130
+ color: var(--castle-inspector-text);
131
+ padding: 4px 6px;
132
+ }
133
+
134
+ .scroll {
135
+ flex: 1 1 0;
136
+ min-height: 0;
137
+ overflow: auto;
138
+ }
139
+
140
+ .grid {
141
+ display: inline-flex;
142
+ flex-direction: column;
143
+ min-width: 100%;
144
+ }
145
+
146
+ .row {
147
+ display: flex;
148
+ align-items: stretch;
149
+ }
150
+
151
+ /* Grid separators: every grid element (corner, frame headers, layer headers,
152
+ and art cells) draws a SINGLE 1px right+bottom divider. Interior edges are
153
+ each owned by exactly one element (no two borders meet), so there are no
154
+ doubled/thicker seams. The grid's outer top is the toolbar's existing bottom
155
+ divider; outer right/bottom come from the last cells. */
156
+ .corner {
157
+ flex: 0 0 168px;
158
+ border-right: 1px solid var(--castle-inspector-divider);
159
+ border-bottom: 1px solid var(--castle-inspector-divider);
160
+ background: var(--castle-workspace-bg);
161
+ position: sticky;
162
+ left: 0;
163
+ z-index: 2;
164
+ }
165
+
166
+ .frameHead {
167
+ flex: 0 0 52px;
168
+ display: flex;
169
+ flex-direction: column;
170
+ align-items: center;
171
+ gap: 2px;
172
+ padding: 4px 3px;
173
+ border-right: 1px solid var(--castle-inspector-divider);
174
+ border-bottom: 1px solid var(--castle-inspector-divider);
175
+ background: transparent;
176
+ color: var(--castle-inspector-muted);
177
+ cursor: pointer;
178
+ }
179
+
180
+ .frameHeadActive {
181
+ background: var(--castle-black);
182
+ color: var(--castle-text);
183
+ }
184
+
185
+ .frameNum {
186
+ font-variant-numeric: tabular-nums;
187
+ font-size: 12px;
188
+ }
189
+
190
+ .durationInput {
191
+ width: 44px;
192
+ border: 1px solid var(--castle-inspector-border);
193
+ border-radius: 3px;
194
+ background: var(--castle-inspector-input-bg);
195
+ color: var(--castle-inspector-text);
196
+ padding: 1px 3px;
197
+ font-size: 11px;
198
+ text-align: center;
199
+ }
200
+
201
+ /* An OVERRIDDEN frame duration: accented + bold so it reads distinctly from the
202
+ greyed placeholder shown by frames that inherit the global. */
203
+ .durationOverride {
204
+ color: var(--castle-text);
205
+ font-weight: 600;
206
+ border-color: var(--castle-blue);
207
+ }
208
+
209
+ .layerHead {
210
+ flex: 0 0 168px;
211
+ display: flex;
212
+ flex-direction: column;
213
+ justify-content: center;
214
+ gap: 4px;
215
+ padding: 4px 8px;
216
+ border-right: 1px solid var(--castle-inspector-divider);
217
+ border-bottom: 1px solid var(--castle-inspector-divider);
218
+ background: var(--castle-workspace-bg);
219
+ position: sticky;
220
+ left: 0;
221
+ z-index: 1;
222
+ cursor: pointer;
223
+ }
224
+
225
+ .layerHeadActive {
226
+ background: var(--castle-black);
227
+ }
228
+
229
+ .layerTopRow {
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 6px;
233
+ }
234
+
235
+ .visBtn {
236
+ flex: 0 0 auto;
237
+ width: 22px;
238
+ height: 22px;
239
+ display: inline-flex;
240
+ align-items: center;
241
+ justify-content: center;
242
+ border: 1px solid var(--castle-inspector-border);
243
+ border-radius: var(--castle-radius);
244
+ background: var(--castle-inspector-button-bg);
245
+ color: var(--castle-inspector-text);
246
+ cursor: pointer;
247
+ padding: 0;
248
+ }
249
+
250
+ .visBtn:hover {
251
+ background: var(--castle-inspector-input-bg);
252
+ }
253
+
254
+ .visBtn svg {
255
+ width: 12px;
256
+ height: 12px;
257
+ }
258
+
259
+ /* Inline FA eye / eye-slash glyph for the visibility toggle (see FaGlyph). */
260
+ .visGlyph {
261
+ width: 12px;
262
+ height: 12px;
263
+ fill: currentColor;
264
+ }
265
+
266
+ /* Hidden layer: the visibility toggle borrows the standard disabled LOOK
267
+ (0.35 dimming, matching the undo button) without being disabled -- this
268
+ button stays interactive and toggles default <-> disabled-look. */
269
+ .visOff {
270
+ opacity: 0.35;
271
+ }
272
+
273
+ /* Static layer-name label (rename was removed from the UI). */
274
+ .name {
275
+ flex: 1 1 auto;
276
+ min-width: 0;
277
+ overflow: hidden;
278
+ text-overflow: ellipsis;
279
+ white-space: nowrap;
280
+ background: none;
281
+ border: 0;
282
+ color: inherit;
283
+ text-align: left;
284
+ padding: 0;
285
+ }
286
+
287
+ /* Slim opacity slider: a thin custom track + small thumb so the layer header
288
+ cell stays no taller than the art (thumbnail) cells in its row. */
289
+ .opacity {
290
+ width: 100%;
291
+ height: 12px;
292
+ margin: 0;
293
+ cursor: pointer;
294
+ appearance: none;
295
+ -webkit-appearance: none;
296
+ background: transparent;
297
+ accent-color: var(--castle-selected);
298
+ }
299
+
300
+ .opacity::-webkit-slider-runnable-track {
301
+ height: 3px;
302
+ border-radius: 2px;
303
+ background: var(--castle-inspector-border);
304
+ }
305
+
306
+ .opacity::-webkit-slider-thumb {
307
+ -webkit-appearance: none;
308
+ appearance: none;
309
+ width: 10px;
310
+ height: 10px;
311
+ margin-top: -3.5px;
312
+ border-radius: 50%;
313
+ background: var(--castle-selected);
314
+ }
315
+
316
+ .opacity::-moz-range-track {
317
+ height: 3px;
318
+ border-radius: 2px;
319
+ background: var(--castle-inspector-border);
320
+ }
321
+
322
+ .opacity::-moz-range-thumb {
323
+ width: 10px;
324
+ height: 10px;
325
+ border: 0;
326
+ border-radius: 50%;
327
+ background: var(--castle-selected);
328
+ }
329
+
330
+ /* The cell is a real <button>, so wipe the UA button chrome first. Crucially
331
+ `border: 0` clears the default TOP/LEFT borders the UA otherwise keeps (we only
332
+ override right/bottom below) — those surviving defaults were the "heavier /
333
+ doubled" top-left edge. The reset comes BEFORE the divider longhands so our
334
+ right+bottom separators still apply; the selected-cell inset stroke lives on
335
+ .cellActive (box-shadow) and is unaffected by this border reset. */
336
+ .cell {
337
+ appearance: none;
338
+ -webkit-appearance: none;
339
+ margin: 0;
340
+ border: 0;
341
+ background: none;
342
+ font: inherit;
343
+ color: inherit;
344
+ text-align: center;
345
+ box-sizing: border-box;
346
+ flex: 0 0 52px;
347
+ height: 52px;
348
+ display: flex;
349
+ align-items: center;
350
+ justify-content: center;
351
+ padding: 4px;
352
+ border-right: 1px solid var(--castle-inspector-divider);
353
+ border-bottom: 1px solid var(--castle-inspector-divider);
354
+ cursor: pointer;
355
+ position: relative;
356
+ }
357
+
358
+ .cellActive {
359
+ background: rgb(141 183 255 / 0.16);
360
+ box-shadow: inset 0 0 0 2px var(--castle-blue);
361
+ }
362
+
363
+ .thumb {
364
+ width: 100%;
365
+ height: 100%;
366
+ }
367
+
368
+ .thumbCanvas {
369
+ width: 100%;
370
+ height: 100%;
371
+ image-rendering: pixelated;
372
+ display: block;
373
+ }
374
+
375
+ /* Empty/absent cell: plain and empty. The cell's own grid separators (see the
376
+ .corner/.cell border rules) still bound it; there's no dashed interior. */
377
+ .empty {
378
+ width: 100%;
379
+ height: 100%;
380
+ background: transparent;
381
+ }
382
+
383
+ .linkBadge {
384
+ position: absolute;
385
+ left: 5px;
386
+ top: 5px;
387
+ width: 14px;
388
+ height: 14px;
389
+ display: inline-flex;
390
+ align-items: center;
391
+ justify-content: center;
392
+ /* Match the thumb/.linkRing corner rounding (2px). A larger radius left a
393
+ sliver of the parent's rounded corner peeking behind the badge. */
394
+ border-radius: 2px;
395
+ background: var(--castle-blue);
396
+ color: var(--castle-selected-ink);
397
+ }
398
+
399
+ .linkBadge svg {
400
+ width: 9px;
401
+ height: 9px;
402
+ }
403
+
404
+ .linkRing {
405
+ box-shadow: inset 0 0 0 1px var(--castle-blue);
406
+ border-radius: 2px;
407
+ }
408
+
409
+ /* Fade only the linked cell's thumbnail IMAGE (the canvas) to signal a shared
410
+ image. Scoped to the canvas inside a linked thumb, so the blue ring
411
+ (.linkRing box-shadow) and the link badge (.linkBadge) stay full opacity. */
412
+ .linkRing .thumbCanvas {
413
+ opacity: 0.5;
414
+ }
415
+
416
+ .tags {
417
+ flex: 0 0 auto;
418
+ max-height: 38%;
419
+ overflow: auto;
420
+ border-top: 1px solid var(--castle-inspector-divider);
421
+ padding: 8px 12px;
422
+ }
423
+
424
+ .tagsHead {
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 10px;
428
+ margin-bottom: 8px;
429
+ }
430
+
431
+ .tagRow {
432
+ display: flex;
433
+ align-items: center;
434
+ gap: 6px;
435
+ margin-bottom: 6px;
436
+ }
437
+
438
+ .tagName {
439
+ flex: 1 1 90px;
440
+ min-width: 0;
441
+ border: 1px solid var(--castle-inspector-border);
442
+ border-radius: 3px;
443
+ background: var(--castle-inspector-input-bg);
444
+ color: var(--castle-inspector-text);
445
+ padding: 3px 6px;
446
+ }
447
+
448
+ .tagNum {
449
+ width: 46px;
450
+ border: 1px solid var(--castle-inspector-border);
451
+ border-radius: 3px;
452
+ background: var(--castle-inspector-input-bg);
453
+ color: var(--castle-inspector-text);
454
+ padding: 3px 4px;
455
+ text-align: center;
456
+ }
457
+
458
+ .tagSelect {
459
+ border: 1px solid var(--castle-inspector-border);
460
+ border-radius: 3px;
461
+ background: var(--castle-inspector-input-bg);
462
+ color: var(--castle-inspector-text);
463
+ padding: 3px 4px;
464
+ }
465
+
466
+ .tagDefault {
467
+ flex: 0 0 auto;
468
+ width: 24px;
469
+ height: 24px;
470
+ display: inline-flex;
471
+ align-items: center;
472
+ justify-content: center;
473
+ border: 1px solid var(--castle-inspector-border);
474
+ border-radius: var(--castle-radius);
475
+ background: var(--castle-inspector-button-bg);
476
+ color: var(--castle-inspector-muted);
477
+ cursor: pointer;
478
+ padding: 0;
479
+ }
480
+
481
+ .tagDefault:hover {
482
+ background: var(--castle-inspector-input-bg);
483
+ }
484
+
485
+ .tagDefaultOn,
486
+ .tagDefaultOn:hover {
487
+ background: var(--castle-selected);
488
+ border-color: var(--castle-selected);
489
+ color: var(--castle-selected-ink);
490
+ }
491
+
492
+ .tagDefault svg {
493
+ width: 12px;
494
+ height: 12px;
495
+ }
496
+
497
+ .muted {
498
+ color: var(--castle-inspector-muted);
499
+ font-size: 12px;
500
+ }
501
+
502
+ /* Disabled right-click menu item (reuses the shared selArrangeItem chrome). */
503
+ .menuItemDisabled {
504
+ opacity: 0.4;
505
+ cursor: default;
506
+ }
@@ -0,0 +1,124 @@
1
+ import { EDG64, KEY_ALPHABET, normalizeHex, TRANSPARENT } from '../engine/pxart';
2
+ import { forEachDab, forEachLinePoint } from './pixelGeometry';
3
+
4
+ // The PxArt editor paints over the FIXED full Endesga-64 palette (no free color
5
+ // picking). Each palette color gets a stable single-char key (KEY_ALPHABET, 64
6
+ // distinct keys); the transparent cell is the reserved "." (TRANSPARENT).
7
+ export const EDITOR_KEYS = EDG64.map((_, index) => KEY_ALPHABET[index]);
8
+
9
+ // key -> "#rrggbb" record, in palette order. Passed to the SDK's fromCells so
10
+ // serialized files carry only the palette entries actually used.
11
+ export const EDITOR_PALETTE = Object.fromEntries(
12
+ EDG64.map((hex, index) => [EDITOR_KEYS[index], hex])
13
+ );
14
+
15
+ // hex -> palette key, for mapping a loaded sprite's colors onto the swatch.
16
+ const KEY_BY_HEX = new Map(EDG64.map((hex, index) => [hex, EDITOR_KEYS[index]]));
17
+
18
+ // Build a `height x width` matrix of "." (transparent) cells.
19
+ export function blankCells(width, height) {
20
+ return Array.from({ length: height }, () => Array.from({ length: width }, () => TRANSPARENT));
21
+ }
22
+
23
+ // Snap an arbitrary hex color to the nearest palette key by RGB distance, so a
24
+ // loaded sprite whose palette isn't exactly the Endesga-64 set still maps onto
25
+ // the fixed swatch. Returns "." for invalid/transparent input.
26
+ export function editorKeyForHex(hex) {
27
+ const norm = typeof hex === 'string' ? normalizeHex(hex) : null;
28
+ if (!norm) return TRANSPARENT;
29
+ const exact = KEY_BY_HEX.get('#' + norm.slice(1, 7));
30
+ if (exact) return exact;
31
+ const r = parseInt(norm.slice(1, 3), 16);
32
+ const g = parseInt(norm.slice(3, 5), 16);
33
+ const b = parseInt(norm.slice(5, 7), 16);
34
+ let bestKey = EDITOR_KEYS[0];
35
+ let bestDist = Infinity;
36
+ EDG64.forEach((swatch, index) => {
37
+ const dist =
38
+ (r - parseInt(swatch.slice(1, 3), 16)) ** 2 +
39
+ (g - parseInt(swatch.slice(3, 5), 16)) ** 2 +
40
+ (b - parseInt(swatch.slice(5, 7), 16)) ** 2;
41
+ if (dist < bestDist) {
42
+ bestDist = dist;
43
+ bestKey = EDITOR_KEYS[index];
44
+ }
45
+ });
46
+ return bestKey;
47
+ }
48
+
49
+ function isInside(cells, x, y) {
50
+ return y >= 0 && y < cells.length && x >= 0 && x < (cells[0]?.length ?? 0);
51
+ }
52
+
53
+ function copyCells(cells) {
54
+ return cells.map((row) => row.slice());
55
+ }
56
+
57
+ // Paint a single cell in a working matrix; returns true when it changed.
58
+ function writeCell(cells, x, y, key) {
59
+ if (!isInside(cells, x, y) || cells[y][x] === key) return false;
60
+ cells[y][x] = key;
61
+ return true;
62
+ }
63
+
64
+ // Paint `key` along the segment from `from` to `to` with a brush of `size`
65
+ // cells (1 = single cell). Returns a new matrix when anything changed, else the
66
+ // original (so callers can skip no-op commits).
67
+ export function paintLine(cells, from, to, key, size = 1) {
68
+ const next = copyCells(cells);
69
+ let changed = false;
70
+ forEachLinePoint(from, to, (cx, cy) => {
71
+ forEachDab(size, (dx, dy) => {
72
+ changed = writeCell(next, cx + dx, cy + dy, key) || changed;
73
+ });
74
+ });
75
+ return changed ? next : cells;
76
+ }
77
+
78
+ // Both region tools share the same guard: bail when the start is out of bounds
79
+ // or already the target key, else return a working copy plus the key to replace.
80
+ function beginKeyReplace(cells, start, key) {
81
+ if (!isInside(cells, start.x, start.y)) return null;
82
+ const target = cells[start.y][start.x];
83
+ if (target === key) return null;
84
+ return { next: copyCells(cells), target };
85
+ }
86
+
87
+ // Flood-fill the contiguous region matching the start cell's key with `key`.
88
+ // Passing TRANSPARENT erases the contiguous region (the "fill mode" eraser).
89
+ export function fillRegion(cells, start, key) {
90
+ const begin = beginKeyReplace(cells, start, key);
91
+ if (!begin) return cells;
92
+ const { next, target } = begin;
93
+ const queue = [start];
94
+ while (queue.length) {
95
+ const { x, y } = queue.pop();
96
+ if (!isInside(next, x, y) || next[y][x] !== target) continue;
97
+ next[y][x] = key;
98
+ queue.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 });
99
+ }
100
+ return next;
101
+ }
102
+
103
+ // Recolor EVERY cell matching the start cell's key to `key` (global swap): a
104
+ // fixed-palette palette-swap fill mode.
105
+ export function swapKey(cells, start, key) {
106
+ const begin = beginKeyReplace(cells, start, key);
107
+ if (!begin) return cells;
108
+ const { next, target } = begin;
109
+ let changed = false;
110
+ for (let y = 0; y < next.length; y++) {
111
+ const row = next[y];
112
+ for (let x = 0; x < row.length; x++) {
113
+ if (row[x] !== target) continue;
114
+ row[x] = key;
115
+ changed = true;
116
+ }
117
+ }
118
+ return changed ? next : cells;
119
+ }
120
+
121
+ // The palette key under a cell (for the eyedropper), or "." when transparent.
122
+ export function sampleKey(cells, point) {
123
+ return isInside(cells, point.x, point.y) ? cells[point.y][point.x] : TRANSPARENT;
124
+ }