castle-web-cli 0.4.82 → 0.4.83
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.d.ts +1 -0
- package/dist/agent.js +323 -9
- package/dist/ide.js +150 -1
- package/dist/native/loop.js +5 -1
- package/dist/native/openrouter.d.ts +5 -1
- package/dist/native/openrouter.js +20 -1
- package/dist/native/types.d.ts +4 -0
- package/dist/shell/assets/index-BMkQt27u.css +1 -0
- package/dist/shell/assets/index-C9Zhmien.js +142 -0
- package/dist/shell/index.html +2 -2
- package/dist/shell/operator.png +0 -0
- package/kits/basic-2d/CLAUDE.md +27 -23
- package/kits/basic-2d/behaviors/Collider.jsx +24 -30
- package/kits/basic-2d/behaviors/Layout.jsx +9 -6
- package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
- package/kits/basic-2d/blueprints/cauldron.scene +3 -5
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
- package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
- package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
- package/kits/basic-2d/editors/inspectorSheet.js +5 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
- package/kits/basic-2d/engine/autoInspector.jsx +26 -7
- package/kits/basic-2d/engine/blueprint.js +35 -8
- package/kits/basic-2d/engine/collider.js +146 -0
- package/kits/basic-2d/engine/scene.js +53 -30
- package/kits/basic-2d/engine/spriteGeometry.js +32 -0
- package/kits/basic-2d/engine/ui.jsx +89 -30
- package/kits/basic-2d/engine/ui.module.css +157 -53
- package/kits/basic-2d/scenes/main.scene +3 -3
- package/package.json +2 -1
- package/dist/shell/assets/index-ByhgiJoP.js +0 -141
- package/dist/shell/assets/index-D6hM_VlW.css +0 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Single source of truth for the Collider rect. Lives in engine/ (not
|
|
2
|
+
// behaviors/) so SceneRuntime can use it directly, with behaviors/Collider.jsx
|
|
3
|
+
// (its `draw`, and its `getColliderRect`/`intersects` re-exports) delegating
|
|
4
|
+
// here instead of keeping a second, drifting copy.
|
|
5
|
+
import { frameCount, renderSpriteFrame } from './pxart';
|
|
6
|
+
import { spriteDestRect } from './spriteGeometry';
|
|
7
|
+
|
|
8
|
+
// Union bounding box (fraction of native resolution, 0..1) of every opaque
|
|
9
|
+
// (alpha > 0) pixel across ALL animation frames of a sprite, or null when the
|
|
10
|
+
// art is fully transparent. Unioning across frames keeps an animated sprite's
|
|
11
|
+
// collider from pulsing frame to frame. Cached in a WeakMap keyed by the
|
|
12
|
+
// sprite object -- a sprite edit produces a fresh object (same invalidation
|
|
13
|
+
// lifecycle as Sprite.jsx's rendered-canvas cache), so a stale entry never
|
|
14
|
+
// outlives the art it was computed from.
|
|
15
|
+
const opaqueBoundsCache = new WeakMap();
|
|
16
|
+
|
|
17
|
+
// Expand `acc` (in pixel units) to cover every opaque pixel of the canvas's
|
|
18
|
+
// current contents.
|
|
19
|
+
function accumulateOpaquePixels(acc, ctx, canvas) {
|
|
20
|
+
if (!ctx || canvas.width === 0 || canvas.height === 0) return;
|
|
21
|
+
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
22
|
+
for (let y = 0; y < canvas.height; y++) {
|
|
23
|
+
for (let x = 0; x < canvas.width; x++) {
|
|
24
|
+
if (data[(y * canvas.width + x) * 4 + 3] === 0) continue;
|
|
25
|
+
if (x < acc.minX) acc.minX = x;
|
|
26
|
+
if (y < acc.minY) acc.minY = y;
|
|
27
|
+
if (x + 1 > acc.maxX) acc.maxX = x + 1;
|
|
28
|
+
if (y + 1 > acc.maxY) acc.maxY = y + 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function opaqueBoundsFraction(sprite) {
|
|
34
|
+
if (opaqueBoundsCache.has(sprite)) return opaqueBoundsCache.get(sprite);
|
|
35
|
+
|
|
36
|
+
const { width, height } = sprite.resolution;
|
|
37
|
+
const canvas = document.createElement('canvas');
|
|
38
|
+
const ctx = canvas.getContext('2d');
|
|
39
|
+
const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
40
|
+
const total = frameCount(sprite);
|
|
41
|
+
for (let frame = 0; frame < total; frame++) {
|
|
42
|
+
renderSpriteFrame(sprite, frame, canvas);
|
|
43
|
+
accumulateOpaquePixels(acc, ctx, canvas);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bounds =
|
|
47
|
+
acc.minX <= acc.maxX && acc.minY <= acc.maxY && width > 0 && height > 0
|
|
48
|
+
? {
|
|
49
|
+
x: acc.minX / width,
|
|
50
|
+
y: acc.minY / height,
|
|
51
|
+
width: (acc.maxX - acc.minX) / width,
|
|
52
|
+
height: (acc.maxY - acc.minY) / height,
|
|
53
|
+
}
|
|
54
|
+
: null;
|
|
55
|
+
opaqueBoundsCache.set(sprite, bounds);
|
|
56
|
+
return bounds;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// True when a resolved sprite has no opaque pixels across any frame -- fully
|
|
60
|
+
// transparent art (e.g. a brand-new blank drawing). The editor draws an
|
|
61
|
+
// empty-actor placeholder for these so a blank actor isn't invisible. Shares
|
|
62
|
+
// `opaqueBoundsFraction`'s per-sprite cache (a sprite edit yields a fresh
|
|
63
|
+
// object, which invalidates it).
|
|
64
|
+
export function spriteIsEmpty(sprite) {
|
|
65
|
+
return !sprite || opaqueBoundsFraction(sprite) === null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The `mode: 'auto'` rect (before offsets), in world units, or null when auto
|
|
69
|
+
// falls back to the full Layout box: no Sprite, an unresolved sprite file,
|
|
70
|
+
// `Sprite.mode === 'tile'` (tiled art fills its cell by definition, so a
|
|
71
|
+
// per-cell bbox doesn't compose), or fully transparent art. Deliberately does
|
|
72
|
+
// NOT follow Sprite.jsx's FALLBACK_FILE placeholder-art fallback -- an
|
|
73
|
+
// unresolvable file means the Layout box, period.
|
|
74
|
+
function autoRect(layout, spriteProps, sprite) {
|
|
75
|
+
if (!sprite || spriteProps.mode === 'tile') return null;
|
|
76
|
+
const bounds = opaqueBoundsFraction(sprite);
|
|
77
|
+
if (!bounds) return null;
|
|
78
|
+
const dest = spriteDestRect(spriteProps.mode, layout, sprite.resolution);
|
|
79
|
+
const rect = {
|
|
80
|
+
x: dest.x + bounds.x * dest.width,
|
|
81
|
+
y: dest.y + bounds.y * dest.height,
|
|
82
|
+
width: bounds.width * dest.width,
|
|
83
|
+
height: bounds.height * dest.height,
|
|
84
|
+
};
|
|
85
|
+
// `cover`'s dest overflows the Layout box and the draw clips to it, so the
|
|
86
|
+
// opaque-bounds rect can spill outside the actor -- clamp to the visible box.
|
|
87
|
+
// A no-op for `stretch`/`fit`, whose dest already sits inside the box.
|
|
88
|
+
if (spriteProps.mode === 'cover') return intersectRect(rect, layout);
|
|
89
|
+
return rect;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rect intersection in world units; zero-size (never negative) when disjoint.
|
|
93
|
+
function intersectRect(rect, box) {
|
|
94
|
+
const x0 = Math.max(rect.x, box.x);
|
|
95
|
+
const y0 = Math.max(rect.y, box.y);
|
|
96
|
+
const x1 = Math.min(rect.x + rect.width, box.x + box.width);
|
|
97
|
+
const y1 = Math.min(rect.y + rect.height, box.y + box.height);
|
|
98
|
+
return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `mode: 'manual'` rect (before offsets): `width`/`height`, centered in the
|
|
102
|
+
// Layout box.
|
|
103
|
+
function manualRect(layout, collider) {
|
|
104
|
+
const width = collider.width ?? layout.width;
|
|
105
|
+
const height = collider.height ?? layout.height;
|
|
106
|
+
return {
|
|
107
|
+
x: layout.x + (layout.width - width) / 2,
|
|
108
|
+
y: layout.y + (layout.height - height) / 2,
|
|
109
|
+
width,
|
|
110
|
+
height,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
|
|
115
|
+
|
|
116
|
+
// The un-offset rect for whichever mode is active: `manualRect` for
|
|
117
|
+
// `mode: 'manual'`, otherwise the auto sprite-bbox rect (or its Layout-box
|
|
118
|
+
// fallback).
|
|
119
|
+
function modeRect(layout, collider, spriteProps, sprites) {
|
|
120
|
+
if ((collider.mode ?? 'auto') === 'manual') return manualRect(layout, collider);
|
|
121
|
+
const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
|
|
122
|
+
return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
|
|
126
|
+
// `mode: 'auto'`), or null if it lacks a Layout or Collider. `sprites` is the
|
|
127
|
+
// scene's sprite map (file path -> parsed sprite); omit it (or pass a map
|
|
128
|
+
// missing the actor's file) to force the Layout-box fallback, e.g. from a
|
|
129
|
+
// caller that only has raw scene data and no loaded sprites.
|
|
130
|
+
export function getColliderRect(actor, sprites) {
|
|
131
|
+
const layout = actor?.components?.Layout;
|
|
132
|
+
const collider = actor?.components?.Collider;
|
|
133
|
+
if (!layout || !collider) return null;
|
|
134
|
+
|
|
135
|
+
const rect = modeRect(layout, collider, actor.components.Sprite, sprites);
|
|
136
|
+
return {
|
|
137
|
+
x: rect.x + (collider.offsetX ?? 0),
|
|
138
|
+
y: rect.y + (collider.offsetY ?? 0),
|
|
139
|
+
width: rect.width,
|
|
140
|
+
height: rect.height,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function intersects(a, b) {
|
|
145
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
146
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { initialFiles, parseJsonFile } from './files';
|
|
2
2
|
import { getBlueprintTemplate, mergeComponents } from './blueprint';
|
|
3
|
+
import { getColliderRect, intersects, spriteIsEmpty } from './collider';
|
|
3
4
|
|
|
4
5
|
const CARD_WIDTH = 500;
|
|
5
6
|
const CARD_HEIGHT = 700;
|
|
@@ -127,7 +128,7 @@ export class SceneRuntime {
|
|
|
127
128
|
|
|
128
129
|
colliderRect(actorOrId) {
|
|
129
130
|
const actor = typeof actorOrId === 'string' ? this.getActor(actorOrId) : actorOrId;
|
|
130
|
-
return getColliderRect(actor);
|
|
131
|
+
return getColliderRect(actor, this.sprites);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
overlaps(first, second) {
|
|
@@ -204,12 +205,24 @@ export class SceneRuntime {
|
|
|
204
205
|
const viewport = options.viewport ?? DEFAULT_VIEWPORT;
|
|
205
206
|
ctx.clearRect(0, 0, viewport.width, viewport.height);
|
|
206
207
|
const camera = options.useCamera && this.camera ? this.camera : { x: 0, y: 0 };
|
|
208
|
+
// When a blueprint is selected in the editor, its instances stay at full
|
|
209
|
+
// opacity and everything else -- the background and every other actor --
|
|
210
|
+
// dims, so the selection stands out.
|
|
211
|
+
const dimBlueprintPath = options.dimBlueprintPath ?? null;
|
|
207
212
|
if (options.showCropOutline) {
|
|
208
213
|
ctx.fillStyle = this.data.background ?? '#0f2a1d';
|
|
209
214
|
ctx.fillRect(0, 0, viewport.width, viewport.height);
|
|
215
|
+
if (dimBlueprintPath) {
|
|
216
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
217
|
+
ctx.fillRect(0, 0, viewport.width, viewport.height);
|
|
218
|
+
}
|
|
210
219
|
} else {
|
|
211
220
|
ctx.fillStyle = this.data.background ?? '#0f2a1d';
|
|
212
221
|
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
222
|
+
if (dimBlueprintPath) {
|
|
223
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
224
|
+
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
225
|
+
}
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
ctx.save();
|
|
@@ -220,15 +233,15 @@ export class SceneRuntime {
|
|
|
220
233
|
);
|
|
221
234
|
}
|
|
222
235
|
if (options.showGrid) drawDotGrid(ctx, options.gridSize, viewport, camera);
|
|
223
|
-
const editSelectedIds = options.editPlaceholders
|
|
224
|
-
? new Set(options.editSelectedActorIds ?? [])
|
|
225
|
-
: null;
|
|
226
236
|
for (const actor of this.getActors()) {
|
|
227
237
|
// Per-actor transform: Layout's `rotation` (degrees) rotates every draw
|
|
228
238
|
// behavior of the actor about its center.
|
|
229
239
|
ctx.save();
|
|
240
|
+
if (dimBlueprintPath && actor.blueprint !== dimBlueprintPath) {
|
|
241
|
+
ctx.globalAlpha = 0.2;
|
|
242
|
+
}
|
|
230
243
|
applyLayoutRotation(ctx, getLayout(actor));
|
|
231
|
-
if (options.editPlaceholders && shouldDrawEditPlaceholder(actor,
|
|
244
|
+
if (options.editPlaceholders && shouldDrawEditPlaceholder(actor, this.sprites)) {
|
|
232
245
|
drawEditPlaceholder(ctx, actor);
|
|
233
246
|
}
|
|
234
247
|
this.forEachBehavior(actor, (instance) => instance.draw?.(actor, this, ctx, options));
|
|
@@ -344,7 +357,7 @@ export function duplicateActors(sceneData, actorIds) {
|
|
|
344
357
|
const source = next.actors.find((candidate) => candidate.id === id);
|
|
345
358
|
if (!source) continue;
|
|
346
359
|
const copy = structuredClone(source);
|
|
347
|
-
copy.id =
|
|
360
|
+
copy.id = dedupeActorId(existingIds, stripDupSuffix(source.id));
|
|
348
361
|
existingIds.add(copy.id);
|
|
349
362
|
const layout = copy.components.Layout;
|
|
350
363
|
if (layout) {
|
|
@@ -471,6 +484,28 @@ export function mintActorId(existing) {
|
|
|
471
484
|
return `${Date.now().toString(16)}${Math.floor(Math.random() * 0xffff).toString(16)}`;
|
|
472
485
|
}
|
|
473
486
|
|
|
487
|
+
// Slug-suffix dedup: return `base`, else `base-2`, `base-3`, ... -- the first
|
|
488
|
+
// not already in `existing`. Callers pass an already-formed base (a blueprint
|
|
489
|
+
// name's slug for fresh placements, an existing id for clones) so newly created
|
|
490
|
+
// actors read as `cauldron` / `cauldron-2` instead of opaque hex. Falls back to
|
|
491
|
+
// a random hex id in the pathological case (100k straight collisions) so the
|
|
492
|
+
// caller always gets a unique id.
|
|
493
|
+
export function dedupeActorId(existing, base) {
|
|
494
|
+
const safe = typeof base === 'string' && base.length ? base : 'actor';
|
|
495
|
+
if (!existing.has(safe)) return safe;
|
|
496
|
+
for (let attempt = 2; attempt < 100000; attempt += 1) {
|
|
497
|
+
const candidate = `${safe}-${attempt}`;
|
|
498
|
+
if (!existing.has(candidate)) return candidate;
|
|
499
|
+
}
|
|
500
|
+
return mintActorId(existing);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Strip a trailing `-<n>` dedup suffix so a clone of `brick-3` bases off
|
|
504
|
+
// `brick` (yielding the next free `brick-N`) instead of nesting `brick-3-2`.
|
|
505
|
+
function stripDupSuffix(id) {
|
|
506
|
+
return typeof id === 'string' ? id.replace(/-\d+$/, '') : id;
|
|
507
|
+
}
|
|
508
|
+
|
|
474
509
|
export function screenToCard(canvas, clientX, clientY) {
|
|
475
510
|
const rect = canvas.getBoundingClientRect();
|
|
476
511
|
const viewport = readCanvasViewport(canvas);
|
|
@@ -529,13 +564,19 @@ function drawDotGrid(ctx, gridSize = 25, viewport = DEFAULT_VIEWPORT, camera = {
|
|
|
529
564
|
}
|
|
530
565
|
|
|
531
566
|
// Per-actor edit bounding box visibility: show it only when the actor lacks a
|
|
532
|
-
// real sprite to look at (no Sprite behavior, or a Sprite with no .pxart file)
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
567
|
+
// real sprite to look at (no Sprite behavior, or a Sprite with no .pxart file).
|
|
568
|
+
// This gray box is purely the "sprite-less actor" indicator -- selection is
|
|
569
|
+
// represented separately by the DOM SelectionOverlay's solid white box, so a
|
|
570
|
+
// Sprite-backed actor never shows this box, selected or not.
|
|
571
|
+
function shouldDrawEditPlaceholder(actor, sprites) {
|
|
537
572
|
const sprite = actor?.components?.Sprite;
|
|
538
|
-
|
|
573
|
+
if (!sprite || !sprite.file) return true;
|
|
574
|
+
const resolved = sprites?.[sprite.file];
|
|
575
|
+
// An unresolved file falls back to Sprite.jsx's own placeholder art, so it's
|
|
576
|
+
// not invisible -- only draw the editor placeholder when the file resolves
|
|
577
|
+
// to fully-transparent (empty) art.
|
|
578
|
+
if (!resolved) return false;
|
|
579
|
+
return spriteIsEmpty(resolved);
|
|
539
580
|
}
|
|
540
581
|
|
|
541
582
|
function drawEditPlaceholder(ctx, actor) {
|
|
@@ -616,21 +657,3 @@ function applyLayoutRotation(ctx, layout) {
|
|
|
616
657
|
function getLayout(actor) {
|
|
617
658
|
return actor?.components?.Layout;
|
|
618
659
|
}
|
|
619
|
-
|
|
620
|
-
function getColliderRect(actor) {
|
|
621
|
-
const layout = getLayout(actor);
|
|
622
|
-
const collider = actor?.components?.Collider;
|
|
623
|
-
if (!layout || !collider) return null;
|
|
624
|
-
const width = collider.width ?? layout.width;
|
|
625
|
-
const height = collider.height ?? layout.height;
|
|
626
|
-
return {
|
|
627
|
-
x: layout.x + (layout.width - width) / 2 + (collider.offsetX ?? 0),
|
|
628
|
-
y: layout.y + (layout.height - height) / 2 + (collider.offsetY ?? 0),
|
|
629
|
-
width,
|
|
630
|
-
height,
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function intersects(a, b) {
|
|
635
|
-
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
636
|
-
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Shared sprite-to-Layout geometry. Lives in engine/ (not behaviors/) so both
|
|
2
|
+
// Sprite.jsx (rendering) and the Collider rect logic (engine/collider.js) can
|
|
3
|
+
// use the same math without behaviors/ <-> engine/ import cycles.
|
|
4
|
+
|
|
5
|
+
// Destination rect for a draw mode: the Layout box for `stretch`/`tile`, or an
|
|
6
|
+
// aspect-ratio-preserving centered rect for `fit` and `cover`:
|
|
7
|
+
// - `fit` (CSS object-fit: contain): the largest rect with the art's aspect
|
|
8
|
+
// ratio that fits INSIDE the box -- letterboxed sides just aren't drawn.
|
|
9
|
+
// - `cover` (CSS object-fit: cover): the smallest rect with the art's aspect
|
|
10
|
+
// ratio that fully COVERS the box -- the overflow is cropped by the caller
|
|
11
|
+
// clipping to the Layout box (see Sprite.draw).
|
|
12
|
+
// `artSize` only needs `.width`/`.height` -- a rendered canvas or a sprite's
|
|
13
|
+
// `.resolution` both work.
|
|
14
|
+
export function spriteDestRect(mode, layout, artSize) {
|
|
15
|
+
if (mode !== 'fit' && mode !== 'cover') return layout;
|
|
16
|
+
const aspect = artSize.width / artSize.height;
|
|
17
|
+
let width = layout.width;
|
|
18
|
+
let height = width / aspect;
|
|
19
|
+
// `fit` shrinks until the art fits inside the box; `cover` grows until it
|
|
20
|
+
// fills the box. The comparison flips between the two.
|
|
21
|
+
const overflows = mode === 'fit' ? height > layout.height : height < layout.height;
|
|
22
|
+
if (overflows) {
|
|
23
|
+
height = layout.height;
|
|
24
|
+
width = height * aspect;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
x: layout.x + (layout.width - width) / 2,
|
|
28
|
+
y: layout.y + (layout.height - height) / 2,
|
|
29
|
+
width,
|
|
30
|
+
height,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -223,12 +223,12 @@ export function Icon({ name }) {
|
|
|
223
223
|
</svg>
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
|
-
export function Panel({ title, action, children }) {
|
|
226
|
+
export function Panel({ title, action, children, overridden = false }) {
|
|
227
227
|
return (
|
|
228
228
|
<section className={styles.panel}>
|
|
229
229
|
{title ? (
|
|
230
230
|
<div className={styles.panelHeader}>
|
|
231
|
-
<span>{title}</span>
|
|
231
|
+
<span className={cx(overridden && styles.panelTitleOverridden)}>{title}</span>
|
|
232
232
|
{action}
|
|
233
233
|
</div>
|
|
234
234
|
) : null}
|
|
@@ -236,17 +236,39 @@ export function Panel({ title, action, children }) {
|
|
|
236
236
|
</section>
|
|
237
237
|
);
|
|
238
238
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
239
|
+
// Render the "Default: X" value in an instance override's sub-line.
|
|
240
|
+
function formatFieldDefault(value) {
|
|
241
|
+
if (typeof value === 'boolean') return value ? 'On' : 'Off';
|
|
242
|
+
if (value == null || value === '') return 'none';
|
|
243
|
+
return String(value);
|
|
244
|
+
}
|
|
245
|
+
export function FieldRow({ label, overridden, defaultValue, onReset, children }) {
|
|
246
|
+
const row = (
|
|
247
|
+
<label className={cx(styles.fieldRow, overridden && styles.fieldRowOverridden)}>
|
|
248
|
+
<span className={cx(styles.fieldLabel, overridden && styles.fieldLabelOverridden)}>{label}</span>
|
|
243
249
|
{children}
|
|
244
250
|
</label>
|
|
245
251
|
);
|
|
252
|
+
if (!overridden) return row;
|
|
253
|
+
return (
|
|
254
|
+
<div className={styles.fieldOverride}>
|
|
255
|
+
{row}
|
|
256
|
+
<div className={styles.fieldDefault}>
|
|
257
|
+
<div className={styles.fieldDefaultInner}>
|
|
258
|
+
<span>Default: {formatFieldDefault(defaultValue)}</span>
|
|
259
|
+
{onReset ? (
|
|
260
|
+
<button type="button" className={styles.fieldResetBtn} onClick={onReset}>
|
|
261
|
+
Reset
|
|
262
|
+
</button>
|
|
263
|
+
) : null}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
246
268
|
}
|
|
247
|
-
export function TextField({ label, value, onChange }) {
|
|
269
|
+
export function TextField({ label, value, onChange, overridden, defaultValue, onReset }) {
|
|
248
270
|
return (
|
|
249
|
-
<FieldRow label={label}>
|
|
271
|
+
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
250
272
|
<input
|
|
251
273
|
className={styles.input}
|
|
252
274
|
value={value ?? ''}
|
|
@@ -255,7 +277,7 @@ export function TextField({ label, value, onChange }) {
|
|
|
255
277
|
</FieldRow>
|
|
256
278
|
);
|
|
257
279
|
}
|
|
258
|
-
export function NumberField({ label, value, onChange, min, max, step = 1 }) {
|
|
280
|
+
export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
|
|
259
281
|
const current = Number.isFinite(value) ? (value ?? 0) : 0;
|
|
260
282
|
const [draft, setDraft] = useState(String(current));
|
|
261
283
|
const editingRef = useRef(false);
|
|
@@ -280,7 +302,7 @@ export function NumberField({ label, value, onChange, min, max, step = 1 }) {
|
|
|
280
302
|
onChange(next);
|
|
281
303
|
}
|
|
282
304
|
return (
|
|
283
|
-
<FieldRow label={label}>
|
|
305
|
+
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
284
306
|
<div className={styles.numberField}>
|
|
285
307
|
<input
|
|
286
308
|
className={cx(styles.input, styles.numberInput)}
|
|
@@ -339,9 +361,9 @@ export function NumberField({ label, value, onChange, min, max, step = 1 }) {
|
|
|
339
361
|
</FieldRow>
|
|
340
362
|
);
|
|
341
363
|
}
|
|
342
|
-
export function CheckboxField({ label, checked, onChange }) {
|
|
364
|
+
export function CheckboxField({ label, checked, onChange, overridden, defaultValue, onReset }) {
|
|
343
365
|
return (
|
|
344
|
-
<FieldRow label={label}>
|
|
366
|
+
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
345
367
|
<button
|
|
346
368
|
type="button"
|
|
347
369
|
className={cx(styles.toggle, checked && styles.toggleOn)}
|
|
@@ -353,12 +375,12 @@ export function CheckboxField({ label, checked, onChange }) {
|
|
|
353
375
|
</FieldRow>
|
|
354
376
|
);
|
|
355
377
|
}
|
|
356
|
-
export function ColorField({ label, value, onChange }) {
|
|
378
|
+
export function ColorField({ label, value, onChange, overridden, defaultValue, onReset }) {
|
|
357
379
|
const hex = normalizeHex(value);
|
|
358
380
|
const alpha = hex.length === 9 ? hex.slice(7) : '';
|
|
359
381
|
const rgb = hex.slice(0, 7);
|
|
360
382
|
return (
|
|
361
|
-
<FieldRow label={label}>
|
|
383
|
+
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
362
384
|
<input
|
|
363
385
|
className={styles.colorInput}
|
|
364
386
|
type="color"
|
|
@@ -437,12 +459,43 @@ function settleSheet(drag, setHeight, expandedRef) {
|
|
|
437
459
|
const settled = setHeight(drag.startHeight - (drag.lastY - drag.startY));
|
|
438
460
|
if (settled > peek + 2) expandedRef.current = settled;
|
|
439
461
|
}
|
|
440
|
-
|
|
441
|
-
|
|
462
|
+
// Persist a sheet's resting height across reloads (an agent's CODE edit
|
|
463
|
+
// triggers requestReload, which would otherwise reset every sheet to the
|
|
464
|
+
// default size). Keyed per sheet via `storageKey`; sessionStorage-scoped and
|
|
465
|
+
// best-effort, the same degrade-to-memory-only contract as editorHistory.
|
|
466
|
+
function sheetHeightStorageKey(key) {
|
|
467
|
+
return `castle-sheet-height:${key}`;
|
|
468
|
+
}
|
|
469
|
+
function loadStoredSheetHeight(key) {
|
|
470
|
+
if (!key || typeof sessionStorage === 'undefined') return null;
|
|
471
|
+
try {
|
|
472
|
+
const raw = sessionStorage.getItem(sheetHeightStorageKey(key));
|
|
473
|
+
if (raw === null) return null;
|
|
474
|
+
const px = Number(raw);
|
|
475
|
+
return Number.isFinite(px) && px > 0 ? px : null;
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function saveStoredSheetHeight(key, px) {
|
|
481
|
+
if (!key || typeof sessionStorage === 'undefined') return;
|
|
482
|
+
try {
|
|
483
|
+
sessionStorage.setItem(sheetHeightStorageKey(key), String(Math.round(px)));
|
|
484
|
+
} catch {
|
|
485
|
+
// storage full/unavailable -- height still works in-memory this session
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export function useMobileSheet({ open = true, baseClassName, storageKey }) {
|
|
489
|
+
const [height, setHeightState] = useState(() =>
|
|
490
|
+
clampSheetHeight(loadStoredSheetHeight(storageKey) ?? sheetSizes().expanded)
|
|
491
|
+
);
|
|
442
492
|
const [dragging, setDragging] = useState(false);
|
|
443
493
|
const heightRef = useRef(height);
|
|
444
|
-
|
|
494
|
+
// Last height above the peek, for tap-restore: seed from the restored height
|
|
495
|
+
// when it isn't collapsed, else the default expanded size.
|
|
496
|
+
const expandedRef = useRef(height > sheetSizes().peek + 2 ? height : sheetSizes().expanded);
|
|
445
497
|
const dragRef = useRef(null);
|
|
498
|
+
const didOpenMountRef = useRef(false);
|
|
446
499
|
const setHeight = (px) => {
|
|
447
500
|
const next = clampSheetHeight(px);
|
|
448
501
|
heightRef.current = next;
|
|
@@ -450,8 +503,14 @@ export function useMobileSheet({ open = true, baseClassName }) {
|
|
|
450
503
|
return next;
|
|
451
504
|
};
|
|
452
505
|
// Restore the last expanded height whenever the sheet (re)opens, and keep the
|
|
453
|
-
// height in range across viewport resize / rotation.
|
|
506
|
+
// height in range across viewport resize / rotation. Skip the initial mount
|
|
507
|
+
// so a height restored from storage (including a collapsed peek) is kept
|
|
508
|
+
// rather than immediately overwritten by the default expanded size.
|
|
454
509
|
useEffect(() => {
|
|
510
|
+
if (!didOpenMountRef.current) {
|
|
511
|
+
didOpenMountRef.current = true;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
455
514
|
if (open) setHeight(expandedRef.current);
|
|
456
515
|
}, [open]);
|
|
457
516
|
useEffect(() => {
|
|
@@ -459,6 +518,11 @@ export function useMobileSheet({ open = true, baseClassName }) {
|
|
|
459
518
|
window.addEventListener('resize', onResize);
|
|
460
519
|
return () => window.removeEventListener('resize', onResize);
|
|
461
520
|
}, []);
|
|
521
|
+
// Persist the resting height (never mid-drag, so a drag doesn't spam storage)
|
|
522
|
+
// so it survives a reload.
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
if (!dragging) saveStoredSheetHeight(storageKey, height);
|
|
525
|
+
}, [dragging, height, storageKey]);
|
|
462
526
|
function onPointerDown(event) {
|
|
463
527
|
dragRef.current = {
|
|
464
528
|
pointerId: event.pointerId,
|
|
@@ -510,16 +574,11 @@ export function useMobileSheet({ open = true, baseClassName }) {
|
|
|
510
574
|
},
|
|
511
575
|
};
|
|
512
576
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
<span className={styles.sheetGrabLabel}>{label}</span>
|
|
519
|
-
{hint ? <span className={styles.sheetGrabHint}>{hint}</span> : null}
|
|
520
|
-
</div>
|
|
521
|
-
</>
|
|
522
|
-
);
|
|
577
|
+
// Just the drag-affordance pill for the mobile sheet. The label/hint header
|
|
578
|
+
// this used to render was dropped -- it duplicated each inspector's own header
|
|
579
|
+
// (blueprint name input, "<name> Actor", scene settings, etc.).
|
|
580
|
+
export function SheetGrabHandle() {
|
|
581
|
+
return <div className={styles.sheetGrabBar} />;
|
|
523
582
|
}
|
|
524
583
|
// Cursor-anchored right-click menu reusing the scene editor's "Arrange" menu
|
|
525
584
|
// chrome (the shared `selArrangeMenu`/`selArrangeItem` classes). Opens at the
|
|
@@ -605,9 +664,9 @@ export function ConfirmDialog({ title, message, confirmLabel = 'Delete', danger
|
|
|
605
664
|
</div>
|
|
606
665
|
);
|
|
607
666
|
}
|
|
608
|
-
export function SelectField({ label, value, onChange, options }) {
|
|
667
|
+
export function SelectField({ label, value, onChange, options, overridden, defaultValue, onReset }) {
|
|
609
668
|
return (
|
|
610
|
-
<FieldRow label={label}>
|
|
669
|
+
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
611
670
|
<select
|
|
612
671
|
className={styles.select}
|
|
613
672
|
value={value ?? ''}
|