castle-web-cli 0.4.76 → 0.4.78
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.d.ts +9 -0
- package/dist/agent-prompts.js +27 -9
- package/dist/agent.d.ts +29 -0
- package/dist/agent.js +718 -244
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +7 -1
- package/dist/filesChanged.d.ts +25 -0
- package/dist/filesChanged.js +140 -0
- package/dist/ide.js +2 -0
- package/dist/init.js +1 -1
- package/dist/serve.js +18 -2
- package/dist/shell/assets/{index-DuKq-Grp.css → index-WE24qX3d.css} +1 -1
- package/dist/shell/assets/{index-DNWEQd4R.js → index-yGdKhgfZ.js} +22 -22
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +15 -3
- package/kits/basic-2d/docs/pxart-format.md +79 -1
- package/kits/basic-2d/editors/PlayOnly.jsx +8 -3
- package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +30 -12
- package/kits/basic-2d/editors/SingleEditor.jsx +24 -8
- package/kits/basic-2d/editors/editorHistory.js +87 -15
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
- package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
- package/kits/basic-2d/engine/liveReload.js +88 -0
- package/kits/basic-2d/engine/pxart.js +49 -2
- package/kits/basic-2d/engine/pxartSmooth.js +222 -0
- package/kits/basic-2d/engine/ui.module.css +108 -14
- package/package.json +1 -1
- package/kits/basic-2d/pnpm-workspace.yaml +0 -3
package/dist/shell/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Castle Editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/__castle/ide/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-yGdKhgfZ.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-WE24qX3d.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { frameCount, renderSpriteFrame } from '../engine/pxart';
|
|
3
|
+
import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
|
|
3
4
|
import { Panel, SelectField } from '../engine/ui';
|
|
4
5
|
import { AutoFields } from '../engine/autoInspector';
|
|
5
6
|
import { parseTint, tintImageData } from './tint';
|
|
@@ -7,7 +8,10 @@ import { parseTint, tintImageData } from './tint';
|
|
|
7
8
|
// Runtime behavior for the pixel-art Sprite format (.pxart). It looks up the
|
|
8
9
|
// parsed Sprite from the scene's sprite map, composites the current animation
|
|
9
10
|
// frame into a cached offscreen canvas (tinted), and blits it into the actor's
|
|
10
|
-
// Layout box with smoothing disabled
|
|
11
|
+
// Layout box with smoothing disabled — unless the FILE itself opts into a
|
|
12
|
+
// `cornerRadius` (a property of the .pxart asset, not a Sprite prop) greater
|
|
13
|
+
// than 0, in which case the cached canvas holds a corner-rounded vector
|
|
14
|
+
// render instead and gets blitted with smoothing on.
|
|
11
15
|
//
|
|
12
16
|
// Animation plays in `update` and is stored on `actor.runtime.spriteAnim` (the
|
|
13
17
|
// behavior instance is recreated every frame, but the actor object persists in
|
|
@@ -58,7 +62,11 @@ export class Sprite {
|
|
|
58
62
|
const frameIndex = Math.min(Math.max(actor.runtime?.spriteAnim?.frameIndex ?? 0, 0), total - 1);
|
|
59
63
|
const canvas = getSpriteCanvas(sprite, frameIndex, this.props.tint);
|
|
60
64
|
const prevSmoothing = ctx.imageSmoothingEnabled;
|
|
61
|
-
|
|
65
|
+
// File-level `cornerRadius > 0` sprites are pre-rendered as supersampled,
|
|
66
|
+
// corner-rounded vector fills (see engine/pxartSmooth.js); blit those with
|
|
67
|
+
// smoothing on so downscaling to the Layout box stays antialiased. Plain
|
|
68
|
+
// pixel sprites (`cornerRadius === 0`) keep nearest-neighbor blitting.
|
|
69
|
+
ctx.imageSmoothingEnabled = sprite.cornerRadius > 0;
|
|
62
70
|
ctx.drawImage(canvas, layout.x, layout.y, layout.width, layout.height);
|
|
63
71
|
ctx.imageSmoothingEnabled = prevSmoothing;
|
|
64
72
|
}
|
|
@@ -100,7 +108,11 @@ function getSpriteCanvas(sprite, frameIndex, tint) {
|
|
|
100
108
|
if (cached) return cached;
|
|
101
109
|
|
|
102
110
|
const canvas = document.createElement('canvas');
|
|
103
|
-
|
|
111
|
+
if (sprite.cornerRadius > 0) {
|
|
112
|
+
renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
|
|
113
|
+
} else {
|
|
114
|
+
renderSpriteFrame(sprite, frameIndex, canvas);
|
|
115
|
+
}
|
|
104
116
|
const tintRgba = parseTint(tint);
|
|
105
117
|
if (tintRgba) {
|
|
106
118
|
const octx = canvas.getContext('2d');
|
|
@@ -282,7 +282,85 @@ case).
|
|
|
282
282
|
|
|
283
283
|
---
|
|
284
284
|
|
|
285
|
-
## 10.
|
|
285
|
+
## 10. Corner rounding
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
"cornerRadius": 0.25
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- A top-level **number**: a corner-rounding radius, in native-pixel units.
|
|
292
|
+
`0` (the default) renders sharp/nearest-neighbor, same as before this field
|
|
293
|
+
existed. Any value `> 0` renders rounded, by that amount — see "Rendering"
|
|
294
|
+
below. **File-level** — a property of the sprite asset itself, not a
|
|
295
|
+
per-actor/per-placement override. There is no equivalent `Sprite` behavior
|
|
296
|
+
prop.
|
|
297
|
+
- **A value, not a flag**, deliberately: the amount of rounding is itself
|
|
298
|
+
part of the portable file format, not a fixed code-side constant every
|
|
299
|
+
smooth sprite would otherwise be stuck with. `MAX_CORNER_RADIUS` (0.5, in
|
|
300
|
+
`engine/pxart.js`) is the ceiling — two corner cuts on the same
|
|
301
|
+
1-native-pixel edge must not overlap — and any value is clamped to
|
|
302
|
+
`[0, MAX_CORNER_RADIUS]` on read. The `PxArtEditor` UI curates this down to
|
|
303
|
+
a 3-way segmented control (`0` / `¼` / `½`, see `CornerRadiusBar` in
|
|
304
|
+
`editors/pixelInspector.jsx`) so picking a value never means hunting a
|
|
305
|
+
slider, but the format itself isn't limited to those three — any in-range
|
|
306
|
+
number is valid, e.g. from hand-edited JSON or a future finer-grained UI.
|
|
307
|
+
- **`0` is the default** and is never written to disk: `serializeFull` and
|
|
308
|
+
`serializeCompact` both OMIT the field when it's `0`, so existing (and
|
|
309
|
+
newly authored, unsmoothed) files stay byte-identical to their
|
|
310
|
+
pre-`cornerRadius`-field shape.
|
|
311
|
+
- **Present in BOTH on-disk forms.** `parseCompact` reads a top-level
|
|
312
|
+
`cornerRadius` alongside `palette`/`grid`; `parseFull`'s native-full branch
|
|
313
|
+
reads it alongside `resolution`/`layers`; `upgradeCompactToFull` carries a
|
|
314
|
+
compact file's `cornerRadius` through into the upgraded Sprite. This means
|
|
315
|
+
the kit's save path — which picks compact vs. full by a serialize →
|
|
316
|
+
re-parse round-trip (`serializeModel` in `editors/pxArtEditorModel.js`, see
|
|
317
|
+
[§9](#9-compact-shorthand)) — never drops the field: a single-layer/frame
|
|
318
|
+
rounded sprite still serializes compact, with `cornerRadius: 0.25` alongside
|
|
319
|
+
`palette`/`grid`.
|
|
320
|
+
- **Parser tolerance:** any non-finite-number value (missing field, `null`, a
|
|
321
|
+
typo) defaults to `0`. Numbers are clamped to `[0, MAX_CORNER_RADIUS]`.
|
|
322
|
+
**Legacy back-compat:** this field used to be called `render` — first a
|
|
323
|
+
`"pixel"` | `"smooth"` string enum, then (briefly) a bare numeric radius
|
|
324
|
+
under that same key. Both migrate on read: a legacy `render: "smooth"`
|
|
325
|
+
becomes a fixed radius (`0.25`); a legacy numeric `render` value is read
|
|
326
|
+
as-is; anything else (including the original `render: "pixel"`) defaults to
|
|
327
|
+
`0`. This keeps old files rendering rounded rather than silently reverting
|
|
328
|
+
to sharp. Detection of compact vs. full form ([§1](#1-form-discriminator))
|
|
329
|
+
is unaffected — neither key is ever used as a structural signal.
|
|
330
|
+
- **Rendering.** `cornerRadius === 0` renders as before: 1px/cell,
|
|
331
|
+
nearest-neighbor (`renderSpriteFrame`, `imageSmoothingEnabled = false`).
|
|
332
|
+
`cornerRadius > 0` renders through `engine/pxartSmooth.js`'s
|
|
333
|
+
`renderSmoothSpriteFrame`, using a LOCAL per-pixel kernel — the same shape
|
|
334
|
+
of algorithm as Animal Crossing's actual smoothing (xBRZ-style template
|
|
335
|
+
matching), not a global vectorization pass: it composites the frame
|
|
336
|
+
normally (so layer visibility/opacity/blend keep working), then for each
|
|
337
|
+
pixel independently, classifies each of its 4 corners against only the 3
|
|
338
|
+
pixels touching that corner (its two edge-adjacent neighbors and their
|
|
339
|
+
shared diagonal neighbor). A corner rounds only when it's a genuine convex
|
|
340
|
+
corner of that pixel's own color region — a diagonal touch between two
|
|
341
|
+
same-colored pixels is deliberately left sharp so it doesn't get visually
|
|
342
|
+
pinched off, and a corner already matched by an adjacent same-color
|
|
343
|
+
neighbor is left for that neighbor's own (independent) classification to
|
|
344
|
+
handle. Because the kernel never looks past a pixel's immediate
|
|
345
|
+
neighborhood, there's no notion of a "run" or "line" to (mis)detect across
|
|
346
|
+
a whole shape — every pixel supersamples into its own fixed block of the
|
|
347
|
+
output canvas (by default 8x the sprite's native resolution), so rounding
|
|
348
|
+
a corner is always a same-block recoloring, never a shape that could leave
|
|
349
|
+
a gap against its neighbors. This suits small pixel-art sprites, not
|
|
350
|
+
general raster upscaling.
|
|
351
|
+
- `behaviors/Sprite.jsx` blits a `cornerRadius > 0` sprite's cached canvas
|
|
352
|
+
with `imageSmoothingEnabled = true` (vs. `false` for `cornerRadius === 0`),
|
|
353
|
+
passing the sprite's own `cornerRadius` value through as
|
|
354
|
+
`renderSmoothSpriteFrame`'s `cornerRadius` option; the offscreen canvas
|
|
355
|
+
cache itself is unaffected (still one WeakMap entry per sprite object —
|
|
356
|
+
smoothing is a property of the sprite, not an extra cache dimension). The
|
|
357
|
+
`PxArtEditor` artboard previews rounded sprites live (a display-resolution
|
|
358
|
+
canvas behind the pixel-grid editing surface) while keeping normal
|
|
359
|
+
pixel-grid editing/selection interactions on the grid underneath.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## 11. Reserved / out of scope
|
|
286
364
|
|
|
287
365
|
Named so the format can grow without a breaking change, but **not built**:
|
|
288
366
|
tilemap/tileset layers (a future `kind`), slices, layer groups, vector/avatar
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Lifecycle } from 'castle-web-sdk';
|
|
3
|
-
import {
|
|
3
|
+
import { parseJsonFile } from '../engine/files';
|
|
4
|
+
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
4
5
|
import { collectAssets } from '../engine/assets';
|
|
5
6
|
import { ScenePlayer } from '../engine/ScenePlayer';
|
|
6
7
|
import { behaviorClasses } from './behaviorRegistry';
|
|
7
8
|
// Play-mode entry point. Intentionally thin: it locates the start scene and
|
|
8
9
|
// hands it to the engine's `ScenePlayer`, which owns the runtime and input.
|
|
9
10
|
// Deck/game logic belongs in `scenes/` and `behaviors/`, not here.
|
|
11
|
+
// Files are live: scene/drawing edits re-key the player against fresh data;
|
|
12
|
+
// code changes reload this context (see engine/liveReload.js).
|
|
10
13
|
export function PlayOnly() {
|
|
11
|
-
const
|
|
14
|
+
const { files, dataVersion } = useLiveDeckFiles();
|
|
15
|
+
const sceneText = files['scenes/main.scene'] ?? '';
|
|
12
16
|
const { value: sceneData } = parseJsonFile('scenes/main.scene', sceneText);
|
|
13
17
|
if (!sceneData) return null;
|
|
14
|
-
const { sprites } = collectAssets(
|
|
18
|
+
const { sprites } = collectAssets(files);
|
|
15
19
|
return (
|
|
16
20
|
<ScenePlayer
|
|
21
|
+
key={dataVersion}
|
|
17
22
|
sceneData={sceneData}
|
|
18
23
|
sprites={sprites}
|
|
19
24
|
behaviorClasses={behaviorClasses}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
|
|
3
|
+
import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
|
|
3
4
|
import { basename } from '../engine/files';
|
|
4
5
|
import { EditorBody, styles } from '../engine/ui';
|
|
5
6
|
import { eventToCell } from './pixelCanvas';
|
|
@@ -8,6 +9,7 @@ import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEd
|
|
|
8
9
|
import {
|
|
9
10
|
BRUSH_SIZES,
|
|
10
11
|
CanvasSizeBar,
|
|
12
|
+
CornerRadiusBar,
|
|
11
13
|
PaintStrip,
|
|
12
14
|
PIXEL_TOOLS,
|
|
13
15
|
PixelToolStrip,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
setCellOffset,
|
|
54
56
|
setDefaultDuration,
|
|
55
57
|
setDefaultTag,
|
|
58
|
+
setCornerRadius,
|
|
56
59
|
setFrameDuration,
|
|
57
60
|
setupSpriteCanvas,
|
|
58
61
|
unlinkCell,
|
|
@@ -85,6 +88,7 @@ const TOOL_CURSORS = {
|
|
|
85
88
|
|
|
86
89
|
export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
87
90
|
const canvasRef = useRef(null);
|
|
91
|
+
const smoothRef = useRef(null);
|
|
88
92
|
const overlayRef = useRef(null);
|
|
89
93
|
const strokeRef = useRef(null);
|
|
90
94
|
// The serialized text this editor last wrote itself. Lets us tell our own
|
|
@@ -116,7 +120,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
116
120
|
eraseSize,
|
|
117
121
|
setEraseSize,
|
|
118
122
|
} = toolState;
|
|
119
|
-
const { history, headerShell } = usePixelEditorShell(text, onChange);
|
|
123
|
+
const { history, headerShell } = usePixelEditorShell(text, onChange, path);
|
|
120
124
|
const canvasWrapRef = useRef(null);
|
|
121
125
|
const canvasSizeBarRef = useRef(null);
|
|
122
126
|
const colorButtonRef = useRef(null);
|
|
@@ -143,12 +147,20 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
143
147
|
eraseSize,
|
|
144
148
|
activeKey,
|
|
145
149
|
});
|
|
146
|
-
useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
|
|
147
150
|
const { canvasStyle } = useArtboardFit({
|
|
148
151
|
wrapRef: canvasWrapRef,
|
|
149
152
|
sizeBarRef: canvasSizeBarRef,
|
|
150
153
|
resolution: sprite?.resolution ?? { width: 16, height: 16 },
|
|
151
154
|
});
|
|
155
|
+
// `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
|
|
156
|
+
// by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
|
|
157
|
+
// dockview panel drag). Feeding it in here is what keeps the supersampled
|
|
158
|
+
// canvas's resolution in sync with that display size: without it, this
|
|
159
|
+
// effect has no way to know the wrap resized (nothing about `text`,
|
|
160
|
+
// `frameIndex`, or `cornerRadius` changes on a pure container resize), so
|
|
161
|
+
// it would keep rendering at whatever scale was last measured.
|
|
162
|
+
useSmoothPreviewRender(smoothRef, text, sprite, previewIndex, canvasStyle?.width);
|
|
163
|
+
useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
|
|
152
164
|
const isCompactLayout = useEditorCompactLayout(editorBodyRef);
|
|
153
165
|
useEffect(() => {
|
|
154
166
|
if (!isCompactLayout) setPaletteOpen(false);
|
|
@@ -272,6 +284,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
272
284
|
deletePixels: deleteMarqueePixels,
|
|
273
285
|
};
|
|
274
286
|
function startTool(event) {
|
|
287
|
+
// Pull keyboard focus into this editor iframe so Cmd+Z reaches our undo
|
|
288
|
+
// handler (bound on this iframe's window) instead of falling through to the
|
|
289
|
+
// browser. Safari in particular won't keep keyboard focus in a subframe
|
|
290
|
+
// after clicking a non-focusable canvas, so we focus it explicitly. Done
|
|
291
|
+
// before the early-return so even a click outside a cell claims focus.
|
|
292
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
275
293
|
const point = eventToCell(event, width, height);
|
|
276
294
|
if (!point) return;
|
|
277
295
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -433,15 +451,22 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
433
451
|
</div>
|
|
434
452
|
<div ref={canvasWrapRef} className={styles.drawingCanvasWrap}>
|
|
435
453
|
<div className={styles.artboardStack}>
|
|
436
|
-
<div ref={canvasSizeBarRef}>
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
454
|
+
<div ref={canvasSizeBarRef} className={styles.canvasTopStack}>
|
|
455
|
+
<div className={styles.canvasTopBar}>
|
|
456
|
+
<CanvasSizeBar
|
|
457
|
+
width={width}
|
|
458
|
+
height={height}
|
|
459
|
+
onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
|
|
460
|
+
/>
|
|
461
|
+
<CornerRadiusBar
|
|
462
|
+
radius={sprite.cornerRadius}
|
|
463
|
+
onChange={(radius) => history.commit(serializeModel(setCornerRadius(sprite, radius)))}
|
|
464
|
+
/>
|
|
465
|
+
</div>
|
|
442
466
|
</div>
|
|
443
467
|
<PixelArtboard
|
|
444
468
|
canvasRef={canvasRef}
|
|
469
|
+
smoothRef={smoothRef}
|
|
445
470
|
overlayRef={overlayRef}
|
|
446
471
|
style={{ ...canvasStyle, cursor }}
|
|
447
472
|
handlers={{
|
|
@@ -592,6 +617,34 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
|
|
|
592
617
|
]);
|
|
593
618
|
}
|
|
594
619
|
|
|
620
|
+
// Render the "smooth" preview into its own display-resolution canvas, behind
|
|
621
|
+
// the interactive artboard (see .drawingSmooth). Left empty when
|
|
622
|
+
// `cornerRadius` is 0 (sharp/pixel) — the interactive canvas's own unrounded
|
|
623
|
+
// frame draw is what shows then. Runs off `text`/`previewIndex` rather than
|
|
624
|
+
// the `sprite` object (a fresh object every render) so it only redraws on an
|
|
625
|
+
// actual content/frame/radius change, not on every hover-driven re-render of
|
|
626
|
+
// the tool preview — PLUS `displayWidth` (the artboard's current CSS width
|
|
627
|
+
// from useArtboardFit), so a wrap resize (e.g. a dockview panel drag) also
|
|
628
|
+
// triggers a re-render at the new supersample scale, not just a content
|
|
629
|
+
// change. The supersample factor auto-fits to the displayed size (capped
|
|
630
|
+
// between 4x and 16x).
|
|
631
|
+
function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth) {
|
|
632
|
+
const cornerRadius = sprite?.cornerRadius ?? 0;
|
|
633
|
+
useEffect(() => {
|
|
634
|
+
const canvas = smoothRef.current;
|
|
635
|
+
if (!canvas) return;
|
|
636
|
+
if (!sprite || cornerRadius <= 0) {
|
|
637
|
+
canvas.width = 0;
|
|
638
|
+
canvas.height = 0;
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const cssWidth = canvas.clientWidth || sprite.resolution.width;
|
|
642
|
+
const dpr = window.devicePixelRatio || 1;
|
|
643
|
+
const autoScale = Math.min(16, Math.max(4, Math.round((cssWidth * dpr) / sprite.resolution.width)));
|
|
644
|
+
renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
|
|
645
|
+
}, [text, frameIndex, smoothRef, cornerRadius, displayWidth]);
|
|
646
|
+
}
|
|
647
|
+
|
|
595
648
|
// Window-scoped tool keyboard shortcuts, mounted only while the editor is.
|
|
596
649
|
function useToolShortcuts(ctx) {
|
|
597
650
|
const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
|
|
@@ -614,7 +667,13 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
|
|
|
614
667
|
}
|
|
615
668
|
}
|
|
616
669
|
ctx.globalAlpha = 1;
|
|
617
|
-
|
|
670
|
+
// "Smooth" sprites are drawn by the display-resolution .drawingSmooth canvas
|
|
671
|
+
// BEHIND this one (see useSmoothPreviewRender); leaving this canvas
|
|
672
|
+
// transparent here lets that show through while pointer math, the marquee,
|
|
673
|
+
// and the tool-preview ghost below all keep working in native grid units.
|
|
674
|
+
if (sprite.cornerRadius <= 0) {
|
|
675
|
+
ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
|
|
676
|
+
}
|
|
618
677
|
if (preview) drawToolPreview(ctx, canvas, preview);
|
|
619
678
|
}
|
|
620
679
|
|
|
@@ -84,8 +84,11 @@ export function SceneEditor({
|
|
|
84
84
|
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
85
85
|
selectedActorIdsRef.current = selectedActorIds;
|
|
86
86
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
87
|
-
const history = useEditHistory(text, onChange);
|
|
88
|
-
|
|
87
|
+
const history = useEditHistory(text, onChange, path);
|
|
88
|
+
// Disabled during play: the header undo/redo buttons are already disabled
|
|
89
|
+
// then, but without this the keyboard shortcut could still rewrite the
|
|
90
|
+
// scene file out from under the running play-mode runtime.
|
|
91
|
+
useUndoRedoShortcuts(history, !isPlaying);
|
|
89
92
|
const showMulti = selectedActorIds.length > 1 || multiSelectMode;
|
|
90
93
|
const inspectorSheet = useSelectionInspectorSheet(true);
|
|
91
94
|
const { value: sceneData, error } = parseJsonFile(path, text);
|
|
@@ -206,13 +209,18 @@ export function SceneEditor({
|
|
|
206
209
|
<canvas
|
|
207
210
|
ref={canvasRef}
|
|
208
211
|
className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
// Focusable so pointer-down pulls keyboard focus into this
|
|
213
|
+
// editor iframe and Cmd+Z reaches our undo handler instead of
|
|
214
|
+
// the browser's (see PxArtEditor startTool for the full
|
|
215
|
+
// rationale -- Safari won't keep focus on a clicked
|
|
216
|
+
// non-focusable canvas).
|
|
217
|
+
tabIndex={-1}
|
|
218
|
+
onPointerDown={(event) => {
|
|
219
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
220
|
+
if (isPlaying) return playPointer.onPointerDown(event);
|
|
221
|
+
if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
|
|
222
|
+
return gesture.onPointerDown(event);
|
|
223
|
+
}}
|
|
216
224
|
onPointerMove={(event) =>
|
|
217
225
|
isPlaying
|
|
218
226
|
? playPointer.onPointerMove(event)
|
|
@@ -660,12 +668,17 @@ function makeActorId(existingIds) {
|
|
|
660
668
|
return `actor-${Date.now()}`;
|
|
661
669
|
}
|
|
662
670
|
function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
|
|
663
|
-
function commitScene(next) {
|
|
664
|
-
commit(formatJson(next));
|
|
671
|
+
function commitScene(next, options) {
|
|
672
|
+
commit(formatJson(next), options);
|
|
665
673
|
}
|
|
666
674
|
return {
|
|
675
|
+
// Keyed by the changed field so consecutive edits to the SAME field (e.g.
|
|
676
|
+
// dragging a ColorField or clicking a NumberField stepper) coalesce into
|
|
677
|
+
// one undo entry, while edits to a different field stay separate steps.
|
|
667
678
|
updateComponent: (actorId, behaviorName, nextProps) =>
|
|
668
|
-
commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps)
|
|
679
|
+
commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps), {
|
|
680
|
+
coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
|
|
681
|
+
}),
|
|
669
682
|
addBehavior: (actorId, behaviorName) => {
|
|
670
683
|
const Behavior = findBehaviorClass(behaviorName);
|
|
671
684
|
if (!Behavior) return;
|
|
@@ -693,6 +706,11 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
|
|
|
693
706
|
},
|
|
694
707
|
};
|
|
695
708
|
}
|
|
709
|
+
// setActorComponent merges nextProps into the actor's existing component, so
|
|
710
|
+
// its keys ARE the fields this call actually changed.
|
|
711
|
+
function componentCoalesceKey(actorId, behaviorName, nextProps) {
|
|
712
|
+
return `${actorId}:${behaviorName}:${Object.keys(nextProps).sort().join(',')}`;
|
|
713
|
+
}
|
|
696
714
|
function setSceneSettings(sceneData, nextScene) {
|
|
697
715
|
return {
|
|
698
716
|
...sceneData,
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// browsing and the code/text editor are now builtin shell panels.
|
|
6
6
|
|
|
7
7
|
import React, { useEffect, useRef, useState } from 'react';
|
|
8
|
-
import { onBeforeRestart, writeFile } from 'castle-web-sdk';
|
|
9
|
-
import { getFileKind
|
|
8
|
+
import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
|
|
9
|
+
import { getFileKind } from '../engine/files';
|
|
10
|
+
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
10
11
|
import { collectAssets } from '../engine/assets';
|
|
11
12
|
import { MainEditor, styles } from '../engine/ui';
|
|
12
13
|
import { PxArtEditor } from './PxArtEditor';
|
|
@@ -36,10 +37,12 @@ function useFileSaver() {
|
|
|
36
37
|
versions.current[path] = version;
|
|
37
38
|
pending.current[path] = text;
|
|
38
39
|
if (timers.current[path]) window.clearTimeout(timers.current[path]);
|
|
40
|
+
// Short-ish debounce: writes now drive the live play/panel updates, so a
|
|
41
|
+
// drag should reflect soon after the user pauses.
|
|
39
42
|
timers.current[path] = window.setTimeout(() => {
|
|
40
43
|
delete timers.current[path];
|
|
41
44
|
void commit(path, text, version);
|
|
42
|
-
},
|
|
45
|
+
}, 800);
|
|
43
46
|
}
|
|
44
47
|
useEffect(
|
|
45
48
|
() =>
|
|
@@ -54,14 +57,27 @@ function useFileSaver() {
|
|
|
54
57
|
}),
|
|
55
58
|
[]
|
|
56
59
|
);
|
|
57
|
-
|
|
60
|
+
function hasPending(path) {
|
|
61
|
+
return pending.current[path] !== undefined;
|
|
62
|
+
}
|
|
63
|
+
return { schedule, hasPending };
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
export function SingleEditor({ path, editor }) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
67
|
+
// Selection survives a code-change reload: stashed via the SDK's save-state
|
|
68
|
+
// hook right before the reload, picked back up here on boot.
|
|
69
|
+
const stashKey = `single-editor:${path}`;
|
|
70
|
+
const [stash] = useState(() => takeReloadState(stashKey));
|
|
71
|
+
const { schedule, hasPending } = useFileSaver();
|
|
72
|
+
// Live deck files; our own in-flight (debounced, unsaved) edits win over the
|
|
73
|
+
// fs echo of a stale write, so a drag in progress isn't clobbered.
|
|
74
|
+
const { files, setFiles } = useLiveDeckFiles({ shouldSkipPath: hasPending });
|
|
75
|
+
const [selectedActorIds, setSelectedActorIds] = useState(stash?.selectedActorIds ?? []);
|
|
76
|
+
const [multiSelectMode, setMultiSelectMode] = useState(stash?.multiSelectMode ?? false);
|
|
77
|
+
useEffect(
|
|
78
|
+
() => onSaveReloadState(stashKey, () => ({ selectedActorIds, multiSelectMode })),
|
|
79
|
+
[stashKey, selectedActorIds, multiSelectMode]
|
|
80
|
+
);
|
|
65
81
|
const { sprites } = collectAssets(files);
|
|
66
82
|
function onChange(nextText) {
|
|
67
83
|
setFiles((current) => ({ ...current, [path]: nextText }));
|
|
@@ -1,47 +1,116 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
const HISTORY_LIMIT = 50;
|
|
3
|
+
// Time window (ms) within which consecutive commits sharing the same
|
|
4
|
+
// coalesceKey collapse into a single undo entry (e.g. dragging a color
|
|
5
|
+
// picker or repeatedly clicking a number stepper). Sliding: each coalesced
|
|
6
|
+
// commit refreshes the window.
|
|
7
|
+
const COALESCE_WINDOW_MS = 800;
|
|
8
|
+
|
|
9
|
+
function historyStorageKey(path) {
|
|
10
|
+
return `castle-edit-history:${path}`;
|
|
11
|
+
}
|
|
12
|
+
// Best-effort sessionStorage read/write so undo/redo survives the editor
|
|
13
|
+
// iframe reloading (e.g. after `npm run restart`). Quota errors or disabled
|
|
14
|
+
// storage (private browsing, etc.) degrade silently to in-memory-only history.
|
|
15
|
+
function loadStoredHistory(path) {
|
|
16
|
+
if (!path) return null;
|
|
17
|
+
try {
|
|
18
|
+
const raw = sessionStorage.getItem(historyStorageKey(path));
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (!parsed || !Array.isArray(parsed.undo) || !Array.isArray(parsed.redo)) return null;
|
|
22
|
+
return {
|
|
23
|
+
undo: parsed.undo.slice(-HISTORY_LIMIT),
|
|
24
|
+
redo: parsed.redo.slice(0, HISTORY_LIMIT),
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function saveStoredHistory(path, history) {
|
|
31
|
+
if (!path) return;
|
|
32
|
+
try {
|
|
33
|
+
sessionStorage.setItem(historyStorageKey(path), JSON.stringify(history));
|
|
34
|
+
} catch {
|
|
35
|
+
// Storage full or unavailable -- history still works in-memory this session.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Drop entries at the end of `stack` equal to `text`: snapshots recorded at
|
|
39
|
+
// gesture start (recordSnapshot) that the gesture never actually changed.
|
|
40
|
+
function trimTrailingNoOps(stack, text) {
|
|
41
|
+
let end = stack.length;
|
|
42
|
+
while (end > 0 && stack[end - 1] === text) end--;
|
|
43
|
+
return end === stack.length ? stack : stack.slice(0, end);
|
|
44
|
+
}
|
|
45
|
+
// Same idea for the redo stack, which is read from the front.
|
|
46
|
+
function trimLeadingNoOps(stack, text) {
|
|
47
|
+
let start = 0;
|
|
48
|
+
while (start < stack.length && stack[start] === text) start++;
|
|
49
|
+
return start === 0 ? stack : stack.slice(start);
|
|
50
|
+
}
|
|
3
51
|
// Text-undo/redo for file-backed editors. `text` is the canonical current
|
|
4
52
|
// value; `onChange` writes the new value back. The hook owns the undo/redo
|
|
5
|
-
// stacks; it never mutates `text` directly.
|
|
6
|
-
|
|
7
|
-
|
|
53
|
+
// stacks; it never mutates `text` directly. `path`, when given, persists the
|
|
54
|
+
// stacks to sessionStorage keyed by file path.
|
|
55
|
+
export function useEditHistory(text, onChange, path) {
|
|
56
|
+
const [history, setHistory] = useState(() => loadStoredHistory(path) ?? { undo: [], redo: [] });
|
|
8
57
|
// Mirror the latest stacks so undo/redo can read them synchronously in the
|
|
9
58
|
// event handler. onChange writes the PARENT's state, so it must never run
|
|
10
59
|
// inside a setHistory updater -- updaters execute in React's render phase,
|
|
11
60
|
// which would update the parent while this editor renders (setState-in-render).
|
|
12
61
|
const historyRef = useRef(history);
|
|
13
62
|
historyRef.current = history;
|
|
14
|
-
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
saveStoredHistory(path, history);
|
|
65
|
+
}, [path, history]);
|
|
66
|
+
// { key, time } of the most recent coalescable commit. Undo/redo/
|
|
67
|
+
// recordSnapshot null this out so a later commit never coalesces across them.
|
|
68
|
+
const coalesceRef = useRef({ key: null, time: 0 });
|
|
69
|
+
function commit(nextText, { coalesceKey } = {}) {
|
|
15
70
|
if (nextText === text) return;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const last = coalesceRef.current;
|
|
73
|
+
const coalescing =
|
|
74
|
+
coalesceKey != null && last.key === coalesceKey && now - last.time <= COALESCE_WINDOW_MS;
|
|
75
|
+
coalesceRef.current = { key: coalesceKey ?? null, time: now };
|
|
76
|
+
if (coalescing) {
|
|
77
|
+
setHistory((current) => ({ ...current, redo: [] }));
|
|
78
|
+
} else {
|
|
79
|
+
setHistory((current) => ({
|
|
80
|
+
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
81
|
+
redo: [],
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
20
84
|
onChange(nextText);
|
|
21
85
|
}
|
|
22
86
|
function recordSnapshot() {
|
|
87
|
+
coalesceRef.current = { key: null, time: 0 };
|
|
23
88
|
setHistory((current) => ({
|
|
24
89
|
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
25
90
|
redo: [],
|
|
26
91
|
}));
|
|
27
92
|
}
|
|
28
93
|
function undo() {
|
|
94
|
+
coalesceRef.current = { key: null, time: 0 };
|
|
29
95
|
const current = historyRef.current;
|
|
30
|
-
const
|
|
96
|
+
const stack = trimTrailingNoOps(current.undo, text);
|
|
97
|
+
const previous = stack.at(-1);
|
|
31
98
|
if (previous === undefined) return;
|
|
32
99
|
setHistory({
|
|
33
|
-
undo:
|
|
100
|
+
undo: stack.slice(0, -1),
|
|
34
101
|
redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
|
|
35
102
|
});
|
|
36
103
|
onChange(previous);
|
|
37
104
|
}
|
|
38
105
|
function redo() {
|
|
106
|
+
coalesceRef.current = { key: null, time: 0 };
|
|
39
107
|
const current = historyRef.current;
|
|
40
|
-
const
|
|
108
|
+
const stack = trimLeadingNoOps(current.redo, text);
|
|
109
|
+
const next = stack[0];
|
|
41
110
|
if (next === undefined) return;
|
|
42
111
|
setHistory({
|
|
43
112
|
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
44
|
-
redo:
|
|
113
|
+
redo: stack.slice(1),
|
|
45
114
|
});
|
|
46
115
|
onChange(next);
|
|
47
116
|
}
|
|
@@ -49,17 +118,20 @@ export function useEditHistory(text, onChange) {
|
|
|
49
118
|
commit,
|
|
50
119
|
undo,
|
|
51
120
|
redo,
|
|
52
|
-
canUndo: history.undo.length > 0,
|
|
53
|
-
canRedo: history.redo.length > 0,
|
|
121
|
+
canUndo: trimTrailingNoOps(history.undo, text).length > 0,
|
|
122
|
+
canRedo: trimLeadingNoOps(history.redo, text).length > 0,
|
|
54
123
|
recordSnapshot,
|
|
55
124
|
};
|
|
56
125
|
}
|
|
57
126
|
|
|
58
|
-
export function useUndoRedoShortcuts(history) {
|
|
127
|
+
export function useUndoRedoShortcuts(history, enabled = true) {
|
|
59
128
|
const historyRef = useRef(history);
|
|
60
129
|
historyRef.current = history;
|
|
130
|
+
const enabledRef = useRef(enabled);
|
|
131
|
+
enabledRef.current = enabled;
|
|
61
132
|
useEffect(() => {
|
|
62
133
|
function onKeyDown(event) {
|
|
134
|
+
if (!enabledRef.current) return;
|
|
63
135
|
if (event.defaultPrevented || isEditableTarget(event.target)) return;
|
|
64
136
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
|
|
65
137
|
event.preventDefault();
|
|
@@ -5,8 +5,8 @@ import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
|
|
|
5
5
|
// Shared shell state for the pixel editors: text undo/redo history and undo/redo
|
|
6
6
|
// keyboard shortcuts. Returns the `history` controller and `headerShell` for the
|
|
7
7
|
// header (undo/redo only — paint controls live in the artboard layout).
|
|
8
|
-
export function usePixelEditorShell(text, onChange) {
|
|
9
|
-
const history = useEditHistory(text, onChange);
|
|
8
|
+
export function usePixelEditorShell(text, onChange, path) {
|
|
9
|
+
const history = useEditHistory(text, onChange, path);
|
|
10
10
|
useUndoRedoShortcuts(history);
|
|
11
11
|
const headerShell = { history };
|
|
12
12
|
return { history, headerShell };
|
|
@@ -36,18 +36,31 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// The native-resolution canvas inside its artboard frame. `style` sets the fitted
|
|
39
|
-
// display size
|
|
40
|
-
//
|
|
41
|
-
// (
|
|
42
|
-
//
|
|
43
|
-
|
|
39
|
+
// display size, and is applied ONLY to the frame: all three canvases inside are
|
|
40
|
+
// absolutely positioned with `width/height: 100%`, so they fill the frame's
|
|
41
|
+
// content box identically. (Passing `style` to a canvas too would size it to the
|
|
42
|
+
// frame's BORDER box — the frame is border-box with a 1px border — leaving the
|
|
43
|
+
// canvas ~2px larger than its siblings and anchored top-left, which shifts the
|
|
44
|
+
// smooth-preview vs. pixel render out of alignment.) `handlers` are the pointer
|
|
45
|
+
// callbacks the editor wires for tools. `overlayRef` is a sibling canvas at
|
|
46
|
+
// display resolution used for crisp overlays (marching-ants selection) that would
|
|
47
|
+
// otherwise render sub-pixel on the upscaled native canvas. `smoothRef` is a
|
|
48
|
+
// sibling canvas BEHIND the main one, holding the corner-rounded render for
|
|
49
|
+
// "smooth"-mode sprites (empty/unused in "pixel" mode) so the interactive canvas
|
|
50
|
+
// above it can stay at native resolution — pointer math and tool-preview overlays
|
|
51
|
+
// are unaffected by the render mode.
|
|
52
|
+
export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers }) {
|
|
44
53
|
return (
|
|
45
54
|
<div className={styles.drawingArtboard}>
|
|
46
55
|
<div className={styles.drawingArtboardFrame} style={style}>
|
|
56
|
+
<canvas ref={smoothRef} className={styles.drawingSmooth} aria-hidden="true" />
|
|
47
57
|
<canvas
|
|
48
58
|
ref={canvasRef}
|
|
49
59
|
className={styles.drawingCanvas}
|
|
50
|
-
|
|
60
|
+
// Focusable so a pointer-down can pull keyboard focus into this editor
|
|
61
|
+
// iframe (see startTool). Without it, Safari often leaves keyboard focus
|
|
62
|
+
// on the top document and Cmd+Z falls through to the browser's own undo.
|
|
63
|
+
tabIndex={-1}
|
|
51
64
|
onPointerDown={handlers.onPointerDown}
|
|
52
65
|
onPointerMove={handlers.onPointerMove}
|
|
53
66
|
onPointerUp={handlers.onPointerUp}
|