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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// "Smooth" (Animal-Crossing-style) rendering for `.pxart` sprites.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Sprites with a file-level `cornerRadius` > 0 (see pxart.js /
|
|
6
|
+
// docs/pxart-format.md) render through `renderSmoothSpriteFrame` instead of
|
|
7
|
+
// the normal 1px/cell `renderSpriteFrame`.
|
|
8
|
+
//
|
|
9
|
+
// This is a LOCAL, per-pixel kernel filter — the same shape of algorithm as
|
|
10
|
+
// Animal Crossing's actual smoothing (xBRZ-style template matching), not the
|
|
11
|
+
// global boundary-trace-and-round approach this file used to implement. That
|
|
12
|
+
// approach traced each region's FULL pixel-boundary loop and could detect
|
|
13
|
+
// long straight/staircase runs across the whole loop; it looked fine on
|
|
14
|
+
// blocky shapes but flattened organic curves it decided were "too regular",
|
|
15
|
+
// which isn't fixable by tuning since the run detection itself is the
|
|
16
|
+
// problem. This version never looks past a pixel's immediate 3x3
|
|
17
|
+
// neighborhood, so there is no run/regularity detection to mis-fire — it
|
|
18
|
+
// physically cannot flatten a curve, because it has no notion of a curve at
|
|
19
|
+
// all, only of each corner in isolation.
|
|
20
|
+
//
|
|
21
|
+
// Approach, per source pixel P:
|
|
22
|
+
// 1. Composite the frame normally (`renderSpriteFrame` — this already
|
|
23
|
+
// handles layer visibility/opacity/blend), then read back the native
|
|
24
|
+
// resolution raster. Regions are exact-RGBA-equality color runs, same as
|
|
25
|
+
// before.
|
|
26
|
+
// 2. Supersample: P gets its own `scale` x `scale` block of the output
|
|
27
|
+
// canvas (plain grid subdivision — every output pixel belongs to
|
|
28
|
+
// exactly one source pixel's block, so there is no possibility of a
|
|
29
|
+
// gap or overlap between neighboring pixels' rendering).
|
|
30
|
+
// 3. Fill P's whole block with its own color, then independently classify
|
|
31
|
+
// each of P's 4 corners against ONLY the 3 pixels touching that corner
|
|
32
|
+
// (2 edge-adjacent neighbors + 1 diagonal) and, for genuine convex
|
|
33
|
+
// corners, paint a `cornerRadius`-sized quarter-circle "cut" over that
|
|
34
|
+
// corner revealing the relevant neighbor's color (see `cornerFill`).
|
|
35
|
+
// Because every pixel only ever paints within its OWN block, this recoloring
|
|
36
|
+
// can never create a gap: it's the same guarantee a supersampled nearest-
|
|
37
|
+
// neighbor render already has, just with a softened corner instead of a hard
|
|
38
|
+
// one.
|
|
39
|
+
//
|
|
40
|
+
// Kept as a sibling of pxart.js (rather than inside it) so the format
|
|
41
|
+
// parser/serializer stays focused on the on-disk shape.
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
import { MAX_CORNER_RADIUS, renderSpriteFrame } from './pxart';
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_SMOOTH_SCALE = 8;
|
|
47
|
+
|
|
48
|
+
// Fallback only for a caller that omits `cornerRadius` entirely; every real
|
|
49
|
+
// caller passes the sprite's own file-level `cornerRadius` value (see
|
|
50
|
+
// docs/pxart-format.md and Sprite.jsx/PxArtEditor.jsx), which is how the
|
|
51
|
+
// corner radius ends up being a portable, per-sprite part of the format
|
|
52
|
+
// rather than a fixed constant every smooth sprite is stuck with.
|
|
53
|
+
const FALLBACK_CORNER_RADIUS = 0.25;
|
|
54
|
+
|
|
55
|
+
/** Render one frame of a Sprite with locally corner-rounded fills,
|
|
56
|
+
* supersampled into `canvas` (sized to width*scale x height*scale). Reuses
|
|
57
|
+
* `renderSpriteFrame` for compositing (layers, opacity, visibility, blend),
|
|
58
|
+
* so those keep working unchanged. `cornerRadius` is in native-pixel units,
|
|
59
|
+
* clamped to `MAX_CORNER_RADIUS` (two cuts on the same edge must not
|
|
60
|
+
* overlap). */
|
|
61
|
+
export function renderSmoothSpriteFrame(
|
|
62
|
+
sprite,
|
|
63
|
+
frameIndex,
|
|
64
|
+
canvas,
|
|
65
|
+
{ scale = DEFAULT_SMOOTH_SCALE, cornerRadius = FALLBACK_CORNER_RADIUS } = {}
|
|
66
|
+
) {
|
|
67
|
+
const clampedRadius = Math.min(MAX_CORNER_RADIUS, Math.max(0, cornerRadius));
|
|
68
|
+
const { width, height } = sprite.resolution;
|
|
69
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
70
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
71
|
+
const ctx = canvas.getContext('2d');
|
|
72
|
+
if (!ctx) return;
|
|
73
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
74
|
+
if (width <= 0 || height <= 0) return;
|
|
75
|
+
|
|
76
|
+
const native = document.createElement('canvas');
|
|
77
|
+
renderSpriteFrame(sprite, frameIndex, native);
|
|
78
|
+
const nctx = native.getContext('2d');
|
|
79
|
+
const data = nctx?.getImageData(0, 0, width, height).data;
|
|
80
|
+
if (!data) return;
|
|
81
|
+
|
|
82
|
+
// null = out of canvas OR fully transparent; both read as "not this pixel's
|
|
83
|
+
// color" to every classification below, same as the old mask's treatment
|
|
84
|
+
// of out-of-canvas/transparent as background.
|
|
85
|
+
const colorAt = (x, y) => {
|
|
86
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return null;
|
|
87
|
+
const i = (y * width + x) * 4;
|
|
88
|
+
const a = data[i + 3];
|
|
89
|
+
return a === 0 ? null : `${data[i]},${data[i + 1]},${data[i + 2]},${a}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
ctx.save();
|
|
93
|
+
ctx.scale(scale, scale);
|
|
94
|
+
// Every pixel is processed, including transparent ones: a fully-enclosed
|
|
95
|
+
// transparent "hole" is, from its own corners' point of view, exactly the
|
|
96
|
+
// same kind of convex corner as an opaque pixel poking out of a
|
|
97
|
+
// background — it needs to cut (and round) its OWN corners the same way,
|
|
98
|
+
// or an enclosed 1px hole would stay a hard square forever (its opaque
|
|
99
|
+
// neighbors never round toward it, by the same-neighbor rule below).
|
|
100
|
+
for (let y = 0; y < height; y++) {
|
|
101
|
+
for (let x = 0; x < width; x++) drawPixelBlock(ctx, colorAt, x, y, colorAt(x, y), clampedRadius);
|
|
102
|
+
}
|
|
103
|
+
ctx.restore();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// per-pixel local corner kernel
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const NO_CUT = undefined;
|
|
111
|
+
|
|
112
|
+
// Fill pixel (x, y)'s own unit square [x, x+1] x [y, y+1] with `color` (a
|
|
113
|
+
// no-op when `color` is null — transparent, nothing to fill), then paint a
|
|
114
|
+
// small rounded cut over each of its 4 corners that qualifies (see
|
|
115
|
+
// `cornerFill`). Uses ONLY the 3x3 neighborhood of (x, y) — genuinely local,
|
|
116
|
+
// unlike the old global loop trace.
|
|
117
|
+
function drawPixelBlock(ctx, colorAt, x, y, color, cornerRadius) {
|
|
118
|
+
const north = colorAt(x, y - 1);
|
|
119
|
+
const south = colorAt(x, y + 1);
|
|
120
|
+
const west = colorAt(x - 1, y);
|
|
121
|
+
const east = colorAt(x + 1, y);
|
|
122
|
+
|
|
123
|
+
const cuts = [
|
|
124
|
+
['TL', cornerFill(color, west, north, colorAt(x - 1, y - 1))],
|
|
125
|
+
['TR', cornerFill(color, east, north, colorAt(x + 1, y - 1))],
|
|
126
|
+
['BL', cornerFill(color, west, south, colorAt(x - 1, y + 1))],
|
|
127
|
+
['BR', cornerFill(color, east, south, colorAt(x + 1, y + 1))],
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
if (color) {
|
|
131
|
+
ctx.fillStyle = rgbaFillStyle(color);
|
|
132
|
+
ctx.fillRect(x, y, 1, 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (cornerRadius <= 0) return;
|
|
136
|
+
for (const [corner, reveal] of cuts) {
|
|
137
|
+
if (reveal === NO_CUT) continue;
|
|
138
|
+
ctx.beginPath();
|
|
139
|
+
tracePixelWedge(ctx, corner, x, y, cornerRadius);
|
|
140
|
+
if (reveal === null) {
|
|
141
|
+
// Revealing transparency: clip to the wedge and clear it, rather than
|
|
142
|
+
// fill it, since there's no color to paint.
|
|
143
|
+
ctx.save();
|
|
144
|
+
ctx.clip();
|
|
145
|
+
ctx.clearRect(x, y, 1, 1);
|
|
146
|
+
ctx.restore();
|
|
147
|
+
} else {
|
|
148
|
+
ctx.fillStyle = rgbaFillStyle(reveal);
|
|
149
|
+
ctx.fill();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Classify one corner of pixel `color`, given its two edge-adjacent
|
|
155
|
+
// neighbors (`a`, `b`) and its diagonal neighbor (`g`). Returns the neighbor
|
|
156
|
+
// color to reveal at that corner's rounded cut, or `NO_CUT` to leave the
|
|
157
|
+
// corner sharp:
|
|
158
|
+
// - `a` or `b` matches `color`: either a flat/interior corner, or (when
|
|
159
|
+
// they don't BOTH match) a straight edge passing by rather than a real
|
|
160
|
+
// corner. Either way, nothing to round from P's side — a matching
|
|
161
|
+
// neighbor's OWN corner classification (evaluated independently, when
|
|
162
|
+
// IT is P) is what rounds the opposite case; the physical wedge that
|
|
163
|
+
// gets cut always lives entirely inside whichever pixel's corner is
|
|
164
|
+
// convex, so there's no double-handling.
|
|
165
|
+
// - neither `a` nor `b` matches, but `g` DOES: a diagonal touch between
|
|
166
|
+
// two same-colored pixels (the "checkerboard" case). Left uncut, so a
|
|
167
|
+
// smoothed diagonal stroke doesn't get visually pinched off at every
|
|
168
|
+
// step.
|
|
169
|
+
// - neither `a`, `b`, nor `g` matches, and `a` and `b` are THE SAME color:
|
|
170
|
+
// a genuine, unambiguous convex corner of P's own region touching one
|
|
171
|
+
// other region. Cut it, revealing that color.
|
|
172
|
+
// - neither `a`, `b`, nor `g` matches, and `a` and `b` DIFFER: three (or
|
|
173
|
+
// four, counting `g`) distinct colors meet at this exact point — e.g. a
|
|
174
|
+
// "T" where one region's straight edge is crossed by the boundary
|
|
175
|
+
// between two others. Left uncut. Rounding here would have to guess
|
|
176
|
+
// which of `a`/`b` "wins", and since the pixel on the OTHER side of
|
|
177
|
+
// that guess is classifying this same point independently — and would
|
|
178
|
+
// guess differently — a pair of pixels each revealing the OTHER's
|
|
179
|
+
// color produces a little criss-crossed notch instead of one clean
|
|
180
|
+
// curve. Leaving every pixel at a 3+-way point sharp keeps it a single
|
|
181
|
+
// consistent (if unrounded) vertex.
|
|
182
|
+
function cornerFill(color, a, b, g) {
|
|
183
|
+
if (a === color || b === color) return NO_CUT;
|
|
184
|
+
if (g === color) return NO_CUT;
|
|
185
|
+
return a === b ? a : NO_CUT;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `color` is always a `"r,g,b,a"` string (0-255 channels, alpha 0-255) —
|
|
189
|
+
// see `colorAt` — never the transparent sentinel (only opaque colors ever
|
|
190
|
+
// reach this function as `color`, only ever as `reveal`).
|
|
191
|
+
function rgbaFillStyle(color) {
|
|
192
|
+
const [r, g, b, a] = color.split(',').map(Number);
|
|
193
|
+
return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Trace the small corner wedge that gets cut from pixel (x, y)'s unit square
|
|
197
|
+
// at `corner` (one of 'TL' | 'TR' | 'BL' | 'BR'): the sliver between the
|
|
198
|
+
// exact corner point and the quarter-circle of radius `r` tangent to both
|
|
199
|
+
// adjacent edges at distance `r` from the corner — i.e. exactly the piece a
|
|
200
|
+
// standard rounded-rect corner removes from a sharp one. `ctx.arcTo`'s
|
|
201
|
+
// corner-point-as-control-point form draws that same tangent arc without
|
|
202
|
+
// hand-computed sweep angles.
|
|
203
|
+
function tracePixelWedge(ctx, corner, x, y, r) {
|
|
204
|
+
if (corner === 'TL') {
|
|
205
|
+
ctx.moveTo(x, y);
|
|
206
|
+
ctx.lineTo(x + r, y);
|
|
207
|
+
ctx.arcTo(x, y, x, y + r, r);
|
|
208
|
+
} else if (corner === 'TR') {
|
|
209
|
+
ctx.moveTo(x + 1, y);
|
|
210
|
+
ctx.lineTo(x + 1, y + r);
|
|
211
|
+
ctx.arcTo(x + 1, y, x + 1 - r, y, r);
|
|
212
|
+
} else if (corner === 'BL') {
|
|
213
|
+
ctx.moveTo(x, y + 1);
|
|
214
|
+
ctx.lineTo(x, y + 1 - r);
|
|
215
|
+
ctx.arcTo(x, y + 1, x + r, y + 1, r);
|
|
216
|
+
} else {
|
|
217
|
+
ctx.moveTo(x + 1, y + 1);
|
|
218
|
+
ctx.lineTo(x + 1 - r, y + 1);
|
|
219
|
+
ctx.arcTo(x + 1, y + 1, x + 1, y + 1 - r, r);
|
|
220
|
+
}
|
|
221
|
+
ctx.closePath();
|
|
222
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { initialFiles, parseJsonFile } from './files';
|
|
2
|
+
import { getBlueprintTemplate, mergeComponents } from './blueprint';
|
|
2
3
|
|
|
3
4
|
const CARD_WIDTH = 500;
|
|
4
5
|
const CARD_HEIGHT = 700;
|
|
@@ -25,9 +26,14 @@ function resolveSceneFileKey(name) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export class SceneRuntime {
|
|
28
|
-
|
|
29
|
+
// `files` is the live deck files map (path -> text), used to resolve every
|
|
30
|
+
// actor's `blueprint` ref against the CURRENT blueprint file content -- not
|
|
31
|
+
// a load-time snapshot -- so a blueprint edit propagates to every scene that
|
|
32
|
+
// places it, in both the editor and play mode / `PlayOnly`.
|
|
33
|
+
constructor(sceneData, behaviors, sprites, files) {
|
|
29
34
|
this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
|
|
30
35
|
this.sprites = sprites ?? {};
|
|
36
|
+
this.files = files ?? {};
|
|
31
37
|
this.time = 0;
|
|
32
38
|
this.keys = new Set();
|
|
33
39
|
this.pointer = { x: 0, y: 0, down: false };
|
|
@@ -43,6 +49,10 @@ export class SceneRuntime {
|
|
|
43
49
|
this.actors = new Map();
|
|
44
50
|
for (const actor of this.data.actors ?? []) {
|
|
45
51
|
actor.runtime = {};
|
|
52
|
+
if (actor.blueprint) {
|
|
53
|
+
const template = getBlueprintTemplate(this.files, actor.blueprint);
|
|
54
|
+
actor.components = mergeComponents(template?.components, actor.components);
|
|
55
|
+
}
|
|
46
56
|
this.actors.set(actor.id, actor);
|
|
47
57
|
for (const [behaviorName, Behavior] of this.behaviors) {
|
|
48
58
|
const component = actor.components[behaviorName];
|
|
@@ -77,7 +87,7 @@ export class SceneRuntime {
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
clone() {
|
|
80
|
-
return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.sprites);
|
|
90
|
+
return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
serialize() {
|
|
@@ -126,6 +136,10 @@ export class SceneRuntime {
|
|
|
126
136
|
return Boolean(a && b && intersects(a, b));
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
// Raw spawn: `actor.components` is used as-is (already-resolved values),
|
|
140
|
+
// just filled out with each behavior's defaultProps. Internal use -- prefer
|
|
141
|
+
// `spawnFromBlueprint` so a runtime-spawned actor is a blueprint instance
|
|
142
|
+
// like every other actor.
|
|
129
143
|
spawnActor(actor) {
|
|
130
144
|
const id = actor.id ?? mintActorId(new Set(this.actors.keys()));
|
|
131
145
|
const components = {};
|
|
@@ -139,6 +153,16 @@ export class SceneRuntime {
|
|
|
139
153
|
return next;
|
|
140
154
|
}
|
|
141
155
|
|
|
156
|
+
// The blessed spawn path: resolve `blueprintPath` from the live `files`,
|
|
157
|
+
// merge with `overrides.components` (sparse, same shape as a placed
|
|
158
|
+
// instance's file overrides), and spawn the result. `overrides.id`, when
|
|
159
|
+
// given, is used as the new actor's id.
|
|
160
|
+
spawnFromBlueprint(blueprintPath, overrides = {}) {
|
|
161
|
+
const template = getBlueprintTemplate(this.files, blueprintPath);
|
|
162
|
+
const components = mergeComponents(template?.components, overrides.components);
|
|
163
|
+
return this.spawnActor({ id: overrides.id, blueprint: blueprintPath, components });
|
|
164
|
+
}
|
|
165
|
+
|
|
142
166
|
despawnActor(actorId) {
|
|
143
167
|
const actor = this.actors.get(actorId);
|
|
144
168
|
if (!actor) return false;
|
|
@@ -261,8 +285,8 @@ export class SceneRuntime {
|
|
|
261
285
|
}
|
|
262
286
|
}
|
|
263
287
|
|
|
264
|
-
export function makeScene(sceneData, behaviors, sprites) {
|
|
265
|
-
return new SceneRuntime(sceneData, behaviors, sprites);
|
|
288
|
+
export function makeScene(sceneData, behaviors, sprites, files) {
|
|
289
|
+
return new SceneRuntime(sceneData, behaviors, sprites, files);
|
|
266
290
|
}
|
|
267
291
|
|
|
268
292
|
export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
|
|
@@ -437,31 +461,7 @@ export function getSelectionBounds(sceneData, actorIds) {
|
|
|
437
461
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
438
462
|
}
|
|
439
463
|
|
|
440
|
-
export function
|
|
441
|
-
const next = structuredClone(sceneData);
|
|
442
|
-
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
443
|
-
const id = mintActorId(existingIds);
|
|
444
|
-
const width = 64;
|
|
445
|
-
const height = 64;
|
|
446
|
-
const layout = {
|
|
447
|
-
x: Math.round((CARD_WIDTH - width) / 2),
|
|
448
|
-
y: Math.round((CARD_HEIGHT - height) / 2),
|
|
449
|
-
width,
|
|
450
|
-
height,
|
|
451
|
-
z: 0,
|
|
452
|
-
rotation: 0,
|
|
453
|
-
};
|
|
454
|
-
const components = { Layout: layout };
|
|
455
|
-
// Give a fresh actor the first available .pxart sprite so it shows art.
|
|
456
|
-
const spritePath = Object.keys(sprites ?? {}).sort()[0];
|
|
457
|
-
if (spritePath) {
|
|
458
|
-
components.Sprite = { file: spritePath };
|
|
459
|
-
}
|
|
460
|
-
next.actors.push({ id, components });
|
|
461
|
-
return { sceneData: next, newId: id };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function mintActorId(existing) {
|
|
464
|
+
export function mintActorId(existing) {
|
|
465
465
|
for (let attempt = 0; attempt < 64; attempt++) {
|
|
466
466
|
const candidate = Math.floor(Math.random() * 0xffffffff)
|
|
467
467
|
.toString(16)
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
faCircle,
|
|
12
12
|
faClone,
|
|
13
13
|
faCode,
|
|
14
|
+
faCodeBranch,
|
|
14
15
|
faEraser,
|
|
15
16
|
faEyeDropper,
|
|
16
17
|
faFile,
|
|
@@ -115,9 +116,26 @@ export function EditorHeader({ title, subtitle, right, onToggleFiles, filesOpen,
|
|
|
115
116
|
if (!headerHost) return null;
|
|
116
117
|
return createPortal(header, headerHost);
|
|
117
118
|
}
|
|
119
|
+
// Safari only moves keyboard focus into an iframe -- at the browser level,
|
|
120
|
+
// not just document.activeElement -- when the click lands on something
|
|
121
|
+
// Safari itself focuses on click (a text input; NOT a button, div, or
|
|
122
|
+
// canvas). PxArtEditor/SceneEditor each already focus their own canvas on
|
|
123
|
+
// pointer-down for exactly this reason, but that only covers clicks that
|
|
124
|
+
// land on the canvas: clicking a tool button, a palette swatch, the
|
|
125
|
+
// timeline, etc. does nothing, so Safari's frame focus can stay wherever it
|
|
126
|
+
// was and the NEXT keyboard shortcut or Cmd+Z falls through to the browser
|
|
127
|
+
// -- reproducing right after the user just clicked in the editor. Claiming
|
|
128
|
+
// focus here, once, for every pointer-down anywhere in the editor body
|
|
129
|
+
// closes that gap for both editors without duplicating the fix. Real text
|
|
130
|
+
// inputs still win focus normally right after: the browser's own
|
|
131
|
+
// click-focuses-inputs behavior runs as mousedown's default action, which
|
|
132
|
+
// fires AFTER this pointerdown handler.
|
|
133
|
+
function claimEditorFocus(event) {
|
|
134
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
135
|
+
}
|
|
118
136
|
export const EditorBody = React.forwardRef(function EditorBody({ children }, ref) {
|
|
119
137
|
return (
|
|
120
|
-
<div ref={ref} className={styles.editorBody}>
|
|
138
|
+
<div ref={ref} className={styles.editorBody} tabIndex={-1} onPointerDown={claimEditorFocus}>
|
|
121
139
|
{children}
|
|
122
140
|
</div>
|
|
123
141
|
);
|
|
@@ -158,6 +176,8 @@ const icons = {
|
|
|
158
176
|
circle: faCircle,
|
|
159
177
|
clone: faClone,
|
|
160
178
|
code: faCode,
|
|
179
|
+
// FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
|
|
180
|
+
'code-fork': faCodeBranch,
|
|
161
181
|
eraser: faEraser,
|
|
162
182
|
eyedropper: faEyeDropper,
|
|
163
183
|
fill: faFillDrip,
|
|
@@ -361,33 +381,125 @@ export function isHexColor(value) {
|
|
|
361
381
|
typeof value === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/.test(value)
|
|
362
382
|
);
|
|
363
383
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
384
|
+
// --- Bottom-sheet sizing (mobile only) -------------------------------------
|
|
385
|
+
// The inspector / file sheets are docked panels on desktop; below the CSS
|
|
386
|
+
// breakpoint they become drag-resizable bottom sheets. `useMobileSheet` owns a
|
|
387
|
+
// continuous pixel height so the sheet tracks the finger 1:1 and can rest at
|
|
388
|
+
// any height (the old version only read the drag delta at release and jumped
|
|
389
|
+
// between two fixed snaps, which felt dead mid-drag).
|
|
390
|
+
const SHEET_PEEK_FRACTION = 0.18; // collapsed "peek" height, fraction of viewport
|
|
391
|
+
const SHEET_DEFAULT_FRACTION = 0.62; // expanded default height
|
|
392
|
+
const SHEET_MAX_FRACTION = 0.92; // never quite cover the whole screen
|
|
393
|
+
const SHEET_MIN_PX = 96; // floor so the grab handle always fits
|
|
394
|
+
const SHEET_TAP_PX = 6; // release movement under this reads as a tap, not a drag
|
|
395
|
+
const SHEET_FLICK_VELOCITY = 0.6; // px/ms; a faster release flings to an end
|
|
396
|
+
function sheetViewportHeight() {
|
|
397
|
+
return typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
398
|
+
}
|
|
399
|
+
function sheetSizes() {
|
|
400
|
+
const vh = sheetViewportHeight();
|
|
401
|
+
const peek = Math.max(SHEET_MIN_PX, vh * SHEET_PEEK_FRACTION);
|
|
402
|
+
const max = vh * SHEET_MAX_FRACTION;
|
|
403
|
+
const expanded = Math.min(max, Math.max(peek, vh * SHEET_DEFAULT_FRACTION));
|
|
404
|
+
return { peek, expanded, max };
|
|
405
|
+
}
|
|
406
|
+
function clampSheetHeight(px) {
|
|
407
|
+
const { peek, max } = sheetSizes();
|
|
408
|
+
return Math.min(max, Math.max(peek, px));
|
|
409
|
+
}
|
|
410
|
+
function releasePointer(event, pointerId) {
|
|
411
|
+
try {
|
|
412
|
+
event.currentTarget.releasePointerCapture(pointerId);
|
|
413
|
+
} catch {
|
|
414
|
+
// Capture may already be gone after a cancel / blur.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Decide where the sheet rests when the finger lifts: a stationary press toggles
|
|
418
|
+
// between the peek and the last expanded height; a fast flick flings to the
|
|
419
|
+
// nearest end; otherwise it stays exactly where it was dragged (free resize).
|
|
420
|
+
function settleSheet(drag, setHeight, expandedRef) {
|
|
421
|
+
const { peek, expanded, max } = sheetSizes();
|
|
422
|
+
if (drag.moved < SHEET_TAP_PX) {
|
|
423
|
+
const wasCollapsed = drag.startHeight <= peek + 2;
|
|
424
|
+
if (!wasCollapsed) expandedRef.current = drag.startHeight;
|
|
425
|
+
setHeight(wasCollapsed ? expandedRef.current || expanded : peek);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (drag.velocity > SHEET_FLICK_VELOCITY) {
|
|
429
|
+
setHeight(peek);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (drag.velocity < -SHEET_FLICK_VELOCITY) {
|
|
433
|
+
expandedRef.current = max;
|
|
434
|
+
setHeight(max);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const settled = setHeight(drag.startHeight - (drag.lastY - drag.startY));
|
|
438
|
+
if (settled > peek + 2) expandedRef.current = settled;
|
|
439
|
+
}
|
|
440
|
+
export function useMobileSheet({ open = true, baseClassName }) {
|
|
441
|
+
const [height, setHeightState] = useState(() => sheetSizes().expanded);
|
|
442
|
+
const [dragging, setDragging] = useState(false);
|
|
443
|
+
const heightRef = useRef(height);
|
|
444
|
+
const expandedRef = useRef(height); // last height above the peek, for tap-restore
|
|
445
|
+
const dragRef = useRef(null);
|
|
446
|
+
const setHeight = (px) => {
|
|
447
|
+
const next = clampSheetHeight(px);
|
|
448
|
+
heightRef.current = next;
|
|
449
|
+
setHeightState(next);
|
|
450
|
+
return next;
|
|
451
|
+
};
|
|
452
|
+
// Restore the last expanded height whenever the sheet (re)opens, and keep the
|
|
453
|
+
// height in range across viewport resize / rotation.
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
if (open) setHeight(expandedRef.current);
|
|
456
|
+
}, [open]);
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const onResize = () => setHeight(heightRef.current);
|
|
459
|
+
window.addEventListener('resize', onResize);
|
|
460
|
+
return () => window.removeEventListener('resize', onResize);
|
|
461
|
+
}, []);
|
|
368
462
|
function onPointerDown(event) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
463
|
+
dragRef.current = {
|
|
464
|
+
pointerId: event.pointerId,
|
|
465
|
+
startY: event.clientY,
|
|
466
|
+
startHeight: heightRef.current,
|
|
467
|
+
lastY: event.clientY,
|
|
468
|
+
lastT: event.timeStamp,
|
|
469
|
+
velocity: 0,
|
|
470
|
+
moved: 0,
|
|
471
|
+
};
|
|
472
|
+
setDragging(true);
|
|
473
|
+
try {
|
|
474
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
475
|
+
} catch {
|
|
476
|
+
// No active pointer to capture (e.g. synthetic events) -- drag still works.
|
|
477
|
+
}
|
|
373
478
|
}
|
|
374
479
|
function onPointerMove(event) {
|
|
375
|
-
|
|
376
|
-
|
|
480
|
+
const drag = dragRef.current;
|
|
481
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
482
|
+
const dy = event.clientY - drag.startY; // down is positive
|
|
483
|
+
drag.moved = Math.max(drag.moved, Math.abs(dy));
|
|
484
|
+
const dt = event.timeStamp - drag.lastT;
|
|
485
|
+
if (dt > 0) drag.velocity = (event.clientY - drag.lastY) / dt;
|
|
486
|
+
drag.lastY = event.clientY;
|
|
487
|
+
drag.lastT = event.timeStamp;
|
|
488
|
+
setHeight(drag.startHeight - dy); // dragging up grows the sheet
|
|
377
489
|
}
|
|
378
|
-
function endDrag() {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
else if (dy < -30) onTransition('up');
|
|
490
|
+
function endDrag(event) {
|
|
491
|
+
const drag = dragRef.current;
|
|
492
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
493
|
+
dragRef.current = null;
|
|
494
|
+
setDragging(false);
|
|
495
|
+
releasePointer(event, drag.pointerId);
|
|
496
|
+
settleSheet(drag, setHeight, expandedRef);
|
|
386
497
|
}
|
|
387
498
|
return {
|
|
388
499
|
rootProps: {
|
|
389
|
-
className: baseClassName,
|
|
390
|
-
'data-sheet-
|
|
500
|
+
className: cx(baseClassName, dragging && styles.sheetDragging),
|
|
501
|
+
'data-sheet-open': open ? 'true' : 'false',
|
|
502
|
+
style: { '--sheet-h': `${Math.round(height)}px` },
|
|
391
503
|
},
|
|
392
504
|
grabProps: {
|
|
393
505
|
className: cx(styles.sheetGrab, styles.mobileOnly),
|
|
@@ -466,6 +578,33 @@ export function ContextMenu({ x, y, items, onClose }) {
|
|
|
466
578
|
</div>
|
|
467
579
|
);
|
|
468
580
|
}
|
|
581
|
+
// Cursor-independent modal confirm (unlike `ContextMenu`, which anchors to a
|
|
582
|
+
// click point) -- for actions with real consequences, like the blueprint
|
|
583
|
+
// library's cascade delete. Dismisses on Escape or a backdrop click, same as
|
|
584
|
+
// cancelling.
|
|
585
|
+
export function ConfirmDialog({ title, message, confirmLabel = 'Delete', danger = true, onConfirm, onCancel }) {
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
const onKeyDown = (event) => {
|
|
588
|
+
if (event.key === 'Escape') onCancel();
|
|
589
|
+
};
|
|
590
|
+
window.addEventListener('keydown', onKeyDown);
|
|
591
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
592
|
+
}, [onCancel]);
|
|
593
|
+
return (
|
|
594
|
+
<div className={styles.confirmBackdrop} onPointerDown={onCancel}>
|
|
595
|
+
<div className={styles.confirmDialog} role="alertdialog" onPointerDown={(event) => event.stopPropagation()}>
|
|
596
|
+
<div className={styles.confirmTitle}>{title}</div>
|
|
597
|
+
<div className={styles.confirmMessage}>{message}</div>
|
|
598
|
+
<div className={styles.confirmActions}>
|
|
599
|
+
<Button onClick={onCancel}>Cancel</Button>
|
|
600
|
+
<Button variant={danger ? 'danger' : ''} onClick={onConfirm}>
|
|
601
|
+
{confirmLabel}
|
|
602
|
+
</Button>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
469
608
|
export function SelectField({ label, value, onChange, options }) {
|
|
470
609
|
return (
|
|
471
610
|
<FieldRow label={label}>
|