castle-web-cli 0.4.77 → 0.4.79
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 +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/ide.js +2 -0
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +83 -4
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +95 -17
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- 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/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/pxart.js +49 -2
- package/kits/basic-2d/engine/pxartSmooth.js +222 -0
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +263 -27
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -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
|
|
@@ -293,6 +371,7 @@ layers, and rich blend modes (beyond `"normal"`). Reserving the `kind` and
|
|
|
293
371
|
|
|
294
372
|
## Appendix — sample
|
|
295
373
|
|
|
296
|
-
`drawings/
|
|
297
|
-
`
|
|
298
|
-
working rendered example of the format in
|
|
374
|
+
`drawings/cauldron.pxart` is a 16×16 multi-layer animated sprite referenced by
|
|
375
|
+
the `pig` actor in `scenes/main.scene` (via `blueprints/cauldron.scene`)
|
|
376
|
+
through the `Sprite` behavior — a working rendered example of the format in
|
|
377
|
+
this kit.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "full",
|
|
3
|
+
"resolution": {
|
|
4
|
+
"width": 16,
|
|
5
|
+
"height": 16
|
|
6
|
+
},
|
|
7
|
+
"palette": [
|
|
8
|
+
{
|
|
9
|
+
"key": "I",
|
|
10
|
+
"hex": "#5ac54f"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"key": "1",
|
|
14
|
+
"hex": "#622461"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "2",
|
|
18
|
+
"hex": "#93388f"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"frames": [
|
|
22
|
+
{},
|
|
23
|
+
{}
|
|
24
|
+
],
|
|
25
|
+
"defaultDurationMs": 500,
|
|
26
|
+
"tags": [],
|
|
27
|
+
"cornerRadius": 0.25,
|
|
28
|
+
"layers": [
|
|
29
|
+
{
|
|
30
|
+
"id": "layer-0",
|
|
31
|
+
"name": "Layer 1",
|
|
32
|
+
"visible": true,
|
|
33
|
+
"opacity": 1,
|
|
34
|
+
"blendMode": "normal",
|
|
35
|
+
"kind": "pixel",
|
|
36
|
+
"cells": [
|
|
37
|
+
{
|
|
38
|
+
"grid": [
|
|
39
|
+
"................",
|
|
40
|
+
"................",
|
|
41
|
+
"................",
|
|
42
|
+
"...11111111111..",
|
|
43
|
+
".211IIIIIIIII11.",
|
|
44
|
+
".22IIIIIIIII221.",
|
|
45
|
+
"..22222222222...",
|
|
46
|
+
"...1111111111...",
|
|
47
|
+
"..222222221111..",
|
|
48
|
+
".22222222221111.",
|
|
49
|
+
".22222222221111.",
|
|
50
|
+
".22222222221111.",
|
|
51
|
+
".22222222211111.",
|
|
52
|
+
"..222222211111..",
|
|
53
|
+
".11222211111111.",
|
|
54
|
+
".11.11111111.11."
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"link": 0
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "layer-1",
|
|
64
|
+
"name": "Layer 1 copy",
|
|
65
|
+
"visible": true,
|
|
66
|
+
"opacity": 1,
|
|
67
|
+
"blendMode": "normal",
|
|
68
|
+
"kind": "pixel",
|
|
69
|
+
"cells": [
|
|
70
|
+
{
|
|
71
|
+
"grid": [
|
|
72
|
+
"...........I....",
|
|
73
|
+
"....I...........",
|
|
74
|
+
".......I........",
|
|
75
|
+
"................",
|
|
76
|
+
"................",
|
|
77
|
+
"................",
|
|
78
|
+
"................",
|
|
79
|
+
"................",
|
|
80
|
+
"................",
|
|
81
|
+
"................",
|
|
82
|
+
"................",
|
|
83
|
+
"................",
|
|
84
|
+
"................",
|
|
85
|
+
"................",
|
|
86
|
+
"................",
|
|
87
|
+
"................"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"grid": [
|
|
92
|
+
"...I............",
|
|
93
|
+
"........I.......",
|
|
94
|
+
"............I...",
|
|
95
|
+
"................",
|
|
96
|
+
"................",
|
|
97
|
+
"................",
|
|
98
|
+
"................",
|
|
99
|
+
"................",
|
|
100
|
+
"................",
|
|
101
|
+
"................",
|
|
102
|
+
"................",
|
|
103
|
+
"................",
|
|
104
|
+
"................",
|
|
105
|
+
"................",
|
|
106
|
+
"................",
|
|
107
|
+
"................"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { renderSpriteFrame } from '../engine/pxart';
|
|
3
|
+
import { screenToCard } from '../engine/scene';
|
|
4
|
+
import { countBlueprintInstances, listBlueprints } from '../engine/blueprint';
|
|
5
|
+
import { ConfirmDialog, ContextMenu, cx, Icon, styles } from '../engine/ui';
|
|
6
|
+
|
|
7
|
+
const DRAG_THRESHOLD = 4;
|
|
8
|
+
|
|
9
|
+
// Deck-level blueprint library / hotbar: lists `blueprints/*.scene` (replacing
|
|
10
|
+
// the old scene-local stamp system). Mirrors castle-client's belt: TAP a slot
|
|
11
|
+
// to select the blueprint for editing in the sidebar; DRAG a slot onto the
|
|
12
|
+
// canvas to place a new instance; right-click for a delete option.
|
|
13
|
+
export function BlueprintLibrary({
|
|
14
|
+
files,
|
|
15
|
+
sprites,
|
|
16
|
+
canvasRef,
|
|
17
|
+
editCameraRef,
|
|
18
|
+
isPlaying,
|
|
19
|
+
selectedBlueprintPath,
|
|
20
|
+
onSelectBlueprint,
|
|
21
|
+
onAddActor,
|
|
22
|
+
dragPlace,
|
|
23
|
+
onDeleteBlueprint,
|
|
24
|
+
}) {
|
|
25
|
+
const blueprints = listBlueprints(files);
|
|
26
|
+
const dragRef = useRef(null);
|
|
27
|
+
const suppressClickRef = useRef(false);
|
|
28
|
+
const [dragPreview, setDragPreview] = useState(null);
|
|
29
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
30
|
+
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
31
|
+
|
|
32
|
+
const onSlotContextMenu = (event, blueprint) => {
|
|
33
|
+
if (isPlaying) return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
event.stopPropagation();
|
|
36
|
+
setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const onSlotPointerDown = (event, blueprint) => {
|
|
40
|
+
if (isPlaying || event.button !== 0) return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
const drag = {
|
|
43
|
+
blueprint,
|
|
44
|
+
pointerId: event.pointerId,
|
|
45
|
+
startX: event.clientX,
|
|
46
|
+
startY: event.clientY,
|
|
47
|
+
moved: false,
|
|
48
|
+
onStage: false,
|
|
49
|
+
};
|
|
50
|
+
dragRef.current = drag;
|
|
51
|
+
try {
|
|
52
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
53
|
+
} catch {
|
|
54
|
+
// Window listeners still finish the drag if capture is unavailable.
|
|
55
|
+
}
|
|
56
|
+
const onMove = (moveEvent) => {
|
|
57
|
+
if (moveEvent.pointerId !== drag.pointerId) return;
|
|
58
|
+
if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
|
|
59
|
+
drag.moved = true;
|
|
60
|
+
}
|
|
61
|
+
if (!drag.moved) return;
|
|
62
|
+
const position = eventToStagePoint(moveEvent, canvasRef, editCameraRef);
|
|
63
|
+
if (position) {
|
|
64
|
+
// Over the stage: the drag becomes a REAL placed instance immediately,
|
|
65
|
+
// so it grid-snaps and renders exactly like the actor it is. The
|
|
66
|
+
// floating thumbnail only exists off-stage.
|
|
67
|
+
if (drag.onStage) {
|
|
68
|
+
dragPlace.move(position);
|
|
69
|
+
} else {
|
|
70
|
+
drag.onStage = true;
|
|
71
|
+
setDragPreview(null);
|
|
72
|
+
dragPlace.enter(blueprint.path, position);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
if (drag.onStage) {
|
|
76
|
+
drag.onStage = false;
|
|
77
|
+
dragPlace.leave();
|
|
78
|
+
}
|
|
79
|
+
setDragPreview({ blueprint, x: moveEvent.clientX, y: moveEvent.clientY, overStage: false });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const onUp = (upEvent) => {
|
|
83
|
+
if (upEvent.pointerId !== drag.pointerId) return;
|
|
84
|
+
window.removeEventListener('pointermove', onMove);
|
|
85
|
+
window.removeEventListener('pointerup', onUp);
|
|
86
|
+
window.removeEventListener('pointercancel', onUp);
|
|
87
|
+
dragRef.current = null;
|
|
88
|
+
setDragPreview(null);
|
|
89
|
+
if (!drag.moved) return;
|
|
90
|
+
suppressClickRef.current = true;
|
|
91
|
+
// Cancelled drags (pointercancel) count as a release off-stage.
|
|
92
|
+
if (drag.onStage && upEvent.type !== 'pointercancel') dragPlace.drop();
|
|
93
|
+
else if (drag.onStage) dragPlace.leave();
|
|
94
|
+
};
|
|
95
|
+
window.addEventListener('pointermove', onMove);
|
|
96
|
+
window.addEventListener('pointerup', onUp);
|
|
97
|
+
window.addEventListener('pointercancel', onUp);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={styles.bpHotbar} aria-label="Blueprints">
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className={styles.bpActionSlot}
|
|
105
|
+
aria-label="Add actor"
|
|
106
|
+
title="Add actor (creates a new blueprint)"
|
|
107
|
+
onClick={onAddActor}
|
|
108
|
+
disabled={isPlaying}>
|
|
109
|
+
<span className={styles.dashedSquareIcon} aria-hidden="true" />
|
|
110
|
+
</button>
|
|
111
|
+
{blueprints.map((blueprint) => (
|
|
112
|
+
<button
|
|
113
|
+
key={blueprint.path}
|
|
114
|
+
type="button"
|
|
115
|
+
className={cx(styles.bpSlot, selectedBlueprintPath === blueprint.path && styles.bpSlotSelected)}
|
|
116
|
+
title={`${blueprint.name} -- click to edit, drag to place`}
|
|
117
|
+
disabled={isPlaying}
|
|
118
|
+
onPointerDown={(event) => onSlotPointerDown(event, blueprint)}
|
|
119
|
+
onContextMenu={(event) => onSlotContextMenu(event, blueprint)}
|
|
120
|
+
onClick={() => {
|
|
121
|
+
if (suppressClickRef.current) {
|
|
122
|
+
suppressClickRef.current = false;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
onSelectBlueprint(blueprint.path);
|
|
126
|
+
}}>
|
|
127
|
+
<BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
|
|
128
|
+
<InstanceCountBadge files={files} blueprintPath={blueprint.path} />
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
{dragPreview ? <BlueprintDragPreview preview={dragPreview} sprites={sprites} /> : null}
|
|
132
|
+
{contextMenu ? (
|
|
133
|
+
<ContextMenu
|
|
134
|
+
x={contextMenu.x}
|
|
135
|
+
y={contextMenu.y}
|
|
136
|
+
onClose={() => setContextMenu(null)}
|
|
137
|
+
items={[
|
|
138
|
+
{
|
|
139
|
+
key: 'delete',
|
|
140
|
+
label: `Delete ${contextMenu.blueprint.name}...`,
|
|
141
|
+
onClick: () => setConfirmDelete(contextMenu.blueprint),
|
|
142
|
+
},
|
|
143
|
+
]}
|
|
144
|
+
/>
|
|
145
|
+
) : null}
|
|
146
|
+
{confirmDelete ? (
|
|
147
|
+
<DeleteBlueprintConfirm
|
|
148
|
+
files={files}
|
|
149
|
+
blueprint={confirmDelete}
|
|
150
|
+
onCancel={() => setConfirmDelete(null)}
|
|
151
|
+
onConfirm={() => {
|
|
152
|
+
onDeleteBlueprint(confirmDelete.path);
|
|
153
|
+
setConfirmDelete(null);
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function DeleteBlueprintConfirm({ files, blueprint, onCancel, onConfirm }) {
|
|
162
|
+
const count = countBlueprintInstances(files, blueprint.path);
|
|
163
|
+
const instanceText = count === 1 ? '1 placed actor' : `${count} placed actors`;
|
|
164
|
+
return (
|
|
165
|
+
<ConfirmDialog
|
|
166
|
+
title={`Delete ${blueprint.name}?`}
|
|
167
|
+
message={
|
|
168
|
+
count > 0
|
|
169
|
+
? `This removes ${blueprint.name} and ${instanceText} across every scene in the deck. This can't be undone.`
|
|
170
|
+
: `${blueprint.name} has no placed actors. This can't be undone.`
|
|
171
|
+
}
|
|
172
|
+
confirmLabel="Delete"
|
|
173
|
+
onCancel={onCancel}
|
|
174
|
+
onConfirm={onConfirm}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function InstanceCountBadge({ files, blueprintPath }) {
|
|
180
|
+
const count = countBlueprintInstances(files, blueprintPath);
|
|
181
|
+
if (count === 0) return null;
|
|
182
|
+
return <span className={styles.bpSlotBadge}>{count > 99 ? '99+' : count}</span>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function BlueprintDragPreview({ preview, sprites }) {
|
|
186
|
+
const layout = preview.blueprint.components?.Layout;
|
|
187
|
+
const width = Math.max(24, Math.min(80, layout?.width ?? 48));
|
|
188
|
+
const height = Math.max(24, Math.min(80, layout?.height ?? 48));
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
|
|
192
|
+
style={{
|
|
193
|
+
left: preview.x,
|
|
194
|
+
top: preview.y,
|
|
195
|
+
width,
|
|
196
|
+
height,
|
|
197
|
+
transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
|
|
198
|
+
}}>
|
|
199
|
+
<BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
|
|
205
|
+
const canvasRef = useRef(null);
|
|
206
|
+
const spritePath = blueprint.components?.Sprite?.file;
|
|
207
|
+
const sprite = spritePath ? sprites?.[spritePath] : null;
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const canvas = canvasRef.current;
|
|
210
|
+
if (!canvas || !sprite) return;
|
|
211
|
+
renderSpriteFrame(sprite, 0, canvas);
|
|
212
|
+
}, [sprite]);
|
|
213
|
+
if (!sprite) {
|
|
214
|
+
return (
|
|
215
|
+
<span className={styles.bpSlotFallback}>
|
|
216
|
+
<Icon name="clone" />
|
|
217
|
+
</span>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
<canvas
|
|
222
|
+
ref={canvasRef}
|
|
223
|
+
className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function eventToStagePoint(event, canvasRef, editCameraRef) {
|
|
230
|
+
const canvas = canvasRef.current;
|
|
231
|
+
if (!canvas) return null;
|
|
232
|
+
const rect = canvas.getBoundingClientRect();
|
|
233
|
+
if (
|
|
234
|
+
event.clientX < rect.left ||
|
|
235
|
+
event.clientX > rect.right ||
|
|
236
|
+
event.clientY < rect.top ||
|
|
237
|
+
event.clientY > rect.bottom
|
|
238
|
+
) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const raw = screenToCard(canvas, event.clientX, event.clientY);
|
|
242
|
+
const camera = editCameraRef.current ?? { x: 0, y: 0 };
|
|
243
|
+
return {
|
|
244
|
+
x: raw.x + camera.x,
|
|
245
|
+
y: raw.y + camera.y,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -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
|
|