castle-web-cli 0.4.176 → 0.4.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/agent-failures.js +4 -4
  2. package/dist/agent-prompts.d.ts +9 -1
  3. package/dist/agent-prompts.js +15 -2
  4. package/dist/agent.js +98 -34
  5. package/dist/deckLocatorShape.d.ts +16 -0
  6. package/dist/deckLocatorShape.js +28 -0
  7. package/dist/editorConfig.d.ts +1 -0
  8. package/dist/serve.js +3 -1
  9. package/dist/shell/assets/index-C890YbXX.css +1 -0
  10. package/dist/shell/assets/index-z6shfW4S.js +447 -0
  11. package/dist/shell/index.html +2 -2
  12. package/kits/base/CLAUDE.md +3 -0
  13. package/kits/base/castle.json +21 -11
  14. package/kits/physics-2d/CLAUDE.md +41 -8
  15. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  16. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  17. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  18. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  19. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  20. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  21. package/kits/physics-2d/blueprints/text.scene +12 -0
  22. package/kits/physics-2d/castle.json +10 -6
  23. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  24. package/kits/physics-2d/editors/deckFont.js +65 -55
  25. package/kits/physics-2d/editors/fontPreview.js +6 -38
  26. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  27. package/kits/physics-2d/engine/blueprint.js +28 -0
  28. package/kits/physics-2d/engine/fonts.js +125 -23
  29. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  30. package/kits/physics-2d/engine/physics/controls.js +5 -81
  31. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  32. package/kits/physics-2d/engine/scene.js +12 -3
  33. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  34. package/kits/physics-2d/engine/tap.js +90 -0
  35. package/kits/physics-2d/engine/text.js +94 -0
  36. package/kits/physics-2d/engine/ui.jsx +20 -0
  37. package/kits/physics-2d/engine/ui.module.css +10 -2
  38. package/kits/physics-3d/castle.json +4 -2
  39. package/package.json +7 -3
  40. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  41. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -0,0 +1,235 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import {
4
+ deckPaletteFromFiles,
5
+ pickerPageIndexFor,
6
+ pickerPagesFor,
7
+ } from './palettes';
8
+ import { cx, FieldRow, IconButton, styles } from './ui';
9
+ import { attachDismissListeners } from './popoverDismiss';
10
+
11
+ export function sameHex(a, b) {
12
+ return !!a && !!b && a.toLowerCase() === b.toLowerCase();
13
+ }
14
+
15
+ export function NoneSwatch({ selected, onSelect }) {
16
+ return (
17
+ <button
18
+ type="button"
19
+ className={cx(styles.swatch, selected && styles.swatchSelected)}
20
+ title="None"
21
+ aria-label="None"
22
+ style={{ position: 'relative', background: 'transparent' }}
23
+ onClick={onSelect}>
24
+ <span
25
+ aria-hidden
26
+ style={{
27
+ position: 'absolute',
28
+ inset: 4,
29
+ background:
30
+ 'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
31
+ }}
32
+ />
33
+ </button>
34
+ );
35
+ }
36
+
37
+ // Swatch strip shared by the docked sidebar and the compact popover.
38
+ // `keys` paints a sprite's working palette; `hexes` paints a picker list.
39
+ // `wrap` is the unlabeled sprite row; the named official page is an 8-col grid.
40
+ export function PaletteGrid({
41
+ keys,
42
+ hexes,
43
+ palette,
44
+ activeKey,
45
+ activeHex,
46
+ onSelectKey,
47
+ onSelectHex,
48
+ allowNone = false,
49
+ noneSelected = false,
50
+ onSelectNone,
51
+ wrap = false,
52
+ }) {
53
+ const items = hexes
54
+ ? hexes.map((hex) => ({ id: hex, hex, selected: sameHex(activeHex, hex), onClick: () => onSelectHex?.(hex) }))
55
+ : (keys ?? []).map((key) => ({
56
+ id: key,
57
+ hex: palette?.[key],
58
+ selected: activeKey === key || sameHex(activeHex, palette?.[key]),
59
+ onClick: () => onSelectKey?.(key),
60
+ }));
61
+ if (!allowNone && !items.length) return null;
62
+ return (
63
+ <div className={wrap ? styles.paletteSpriteRow : styles.palette}>
64
+ {allowNone ? <NoneSwatch selected={noneSelected} onSelect={onSelectNone} /> : null}
65
+ {items.map((item) => (
66
+ <button
67
+ key={item.id}
68
+ type="button"
69
+ className={cx(styles.swatch, item.selected && styles.swatchSelected)}
70
+ title={item.hex}
71
+ style={{ background: item.hex }}
72
+ onClick={item.onClick}
73
+ />
74
+ ))}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export function PalettePager({ name, onPage }) {
80
+ return (
81
+ <div className={styles.palettePager}>
82
+ <span className={styles.palettePagerName}>{name}</span>
83
+ <div className={styles.palettePagerBtns}>
84
+ <IconButton icon="chevron-left" label="Previous palette" onClick={() => onPage(-1)} />
85
+ <IconButton icon="chevron-right" label="Next palette" onClick={() => onPage(1)} />
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ // Anchored palette popover for inspector / paint-strip color taps.
92
+ export function PalettePopover({ open, anchorRef, onClose, children }) {
93
+ const popoverRef = useRef(null);
94
+ const [position, setPosition] = useState({ top: 0, left: 0 });
95
+
96
+ useLayoutEffect(() => {
97
+ if (!open || !anchorRef.current) return;
98
+
99
+ function positionPopover() {
100
+ const anchor = anchorRef.current.getBoundingClientRect();
101
+ const rect = popoverRef.current?.getBoundingClientRect();
102
+ const margin = 8;
103
+ const width = rect?.width ?? 231;
104
+ const height = rect?.height ?? 320;
105
+ const maxLeft = Math.max(margin, window.innerWidth - width - margin);
106
+ const maxTop = Math.max(margin, window.innerHeight - height - margin);
107
+ const leftSide = anchor.left - width - margin;
108
+ const rightSide = anchor.right + margin;
109
+ // Prefer the side with room, then clamp a too-wide picker inside this
110
+ // iframe rather than sending it beyond either viewport edge.
111
+ const left =
112
+ leftSide >= margin
113
+ ? leftSide
114
+ : rightSide <= maxLeft
115
+ ? rightSide
116
+ : Math.min(maxLeft, Math.max(margin, anchor.left + anchor.width / 2 - width / 2));
117
+ const top = Math.min(maxTop, Math.max(margin, anchor.top));
118
+ setPosition({ top, left });
119
+ }
120
+
121
+ positionPopover();
122
+ window.addEventListener('resize', positionPopover);
123
+ window.addEventListener('scroll', positionPopover, true);
124
+ return () => {
125
+ window.removeEventListener('resize', positionPopover);
126
+ window.removeEventListener('scroll', positionPopover, true);
127
+ };
128
+ }, [open, anchorRef]);
129
+
130
+ useEffect(() => {
131
+ if (!open) return undefined;
132
+ return attachDismissListeners(onClose, [popoverRef, anchorRef]);
133
+ }, [open, onClose, anchorRef]);
134
+
135
+ if (!open) return null;
136
+ return createPortal(
137
+ <div
138
+ ref={popoverRef}
139
+ className={styles.palettePopover}
140
+ style={{ top: position.top, left: position.left }}
141
+ role="dialog"
142
+ aria-label="Palette">
143
+ {children}
144
+ </div>,
145
+ document.body
146
+ );
147
+ }
148
+
149
+ // Inspector field: one swatch + hex, opening the same paged official-palette
150
+ // browser the sprite editor uses. Paging never writes theme.style. `allowNone`
151
+ // adds a None swatch that commits `''`.
152
+ export function PaletteColorField({
153
+ label,
154
+ value,
155
+ onChange,
156
+ files,
157
+ allowNone = false,
158
+ overridden,
159
+ defaultValue,
160
+ onReset,
161
+ }) {
162
+ const current = value ?? '';
163
+ const [open, setOpen] = useState(false);
164
+ const buttonRef = useRef(null);
165
+ const deckPalette = deckPaletteFromFiles(files);
166
+ const pages = pickerPagesFor(deckPalette);
167
+ const [pageIndex, setPageIndex] = useState(() => pickerPageIndexFor(pages, deckPalette));
168
+ const page = pages[pageIndex] ?? pages[0];
169
+
170
+ // Snap to the deck's palette only when the popover opens. `pages` /
171
+ // `deckPalette` are new references every render, so listing them as deps
172
+ // would re-run this after every pager click and bounce back to page 0.
173
+ useEffect(() => {
174
+ if (!open) return;
175
+ setPageIndex(pickerPageIndexFor(pages, deckPalette));
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- open edge only
177
+ }, [open]);
178
+
179
+ function pick(hex) {
180
+ onChange(hex);
181
+ setOpen(false);
182
+ }
183
+
184
+ return (
185
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
186
+ <button
187
+ ref={buttonRef}
188
+ type="button"
189
+ className={cx(styles.select, styles.fileField, styles.paletteColorField)}
190
+ aria-haspopup="dialog"
191
+ aria-expanded={open}
192
+ onClick={() => setOpen((next) => !next)}>
193
+ <span
194
+ className={styles.swatch}
195
+ aria-hidden
196
+ style={
197
+ current
198
+ ? { background: current }
199
+ : {
200
+ position: 'relative',
201
+ background: 'transparent',
202
+ backgroundImage:
203
+ 'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
204
+ backgroundOrigin: 'content-box',
205
+ padding: 4,
206
+ }
207
+ }
208
+ />
209
+ <span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
210
+ {current || 'None'}
211
+ </span>
212
+ </button>
213
+ <PalettePopover open={open} anchorRef={buttonRef} onClose={() => setOpen(false)}>
214
+ {page ? (
215
+ <>
216
+ <PalettePager
217
+ name={page.name}
218
+ onPage={(delta) =>
219
+ setPageIndex((i) => (i + delta + pages.length) % pages.length)
220
+ }
221
+ />
222
+ <PaletteGrid
223
+ hexes={page.colors}
224
+ activeHex={current}
225
+ onSelectHex={pick}
226
+ allowNone={allowNone}
227
+ noneSelected={!current}
228
+ onSelectNone={() => pick('')}
229
+ />
230
+ </>
231
+ ) : null}
232
+ </PalettePopover>
233
+ </FieldRow>
234
+ );
235
+ }
@@ -4,11 +4,7 @@
4
4
  // thin wrappers: read input + runtime state, call these, push the result
5
5
  // through scene.physics. (Controls are touch/pointer-first; keyboard, when
6
6
  // added, should only DUPLICATE an on-screen control, never be the only input.)
7
- //
8
- // AnalogStick force mode: stickDrive (held) and stickBrake (idle) return
9
- // velocity deltas applied via applyImpulse — mass-independent, composable,
10
- // and restricted to the stick's driven axes so gravity on an undriven axis
11
- // is never clamped or braked.
7
+ // Stick force-mode math lives in AnalogStick.jsx — it has no other consumer.
12
8
 
13
9
  // Authored Draggable `stiffness` (0..1, 0 = floppy .. 1 = rigid) -> the matter
14
10
  // constraint values that hold the object. Geometric, like joints.js's rope map
@@ -57,6 +53,10 @@ export function clampLength(v, max) {
57
53
  // name into shared code and had no answer at all once two fingers were down.
58
54
  export const TARGETED_CLAIM = 10;
59
55
  export const GREEDY_CLAIM = 0;
56
+ // An actor whose behaviors implement `onTap`. Beats greedy (stick, grab-anywhere
57
+ // sling) so the finger isn't also those, and yields to a targeted control that
58
+ // claims later the same frame.
59
+ export const TAP_CLAIM = 5;
60
60
 
61
61
  // Take the first fresh press this frame that `actorId` is allowed to have, and
62
62
  // claim it. `priorityFor(pointer)` returns the claim priority to bid, or null to
@@ -103,79 +103,3 @@ export function slingLaunch(anchor, pointer, props = {}) {
103
103
  const pull = slingPull(anchor, pointer, props.maxDrag ?? 160);
104
104
  return { x: -pull.x * speed, y: -pull.y * speed };
105
105
  }
106
-
107
- // AnalogStick: stick displacement from its origin, clamped to maxRadius.
108
- export function stickVector(pointer, origin, maxRadius = 60) {
109
- return clampLength({ x: pointer.x - origin.x, y: pointer.y - origin.y }, maxRadius);
110
- }
111
-
112
- // Card coords before the camera -- for screen-fixed controls reading a pointer.
113
- export function screenPoint(pointer) {
114
- return { x: pointer.screenX, y: pointer.screenY };
115
- }
116
-
117
- // Keep only the axes the stick is allowed to drive. An undriven axis (gravity
118
- // in a platformer) must never be clamped or braked by the stick.
119
- function drivenAxes(v, axes) {
120
- return {
121
- x: axes === 'vertical' ? 0 : v.x,
122
- y: axes === 'horizontal' ? 0 : v.y,
123
- };
124
- }
125
-
126
- // Smallest signed angle from `from` to `to`, wrapped into [−π, π].
127
- function angleDelta(from, to) {
128
- let d = Math.atan2(to.y, to.x) - Math.atan2(from.y, from.x);
129
- if (d > Math.PI) d -= 2 * Math.PI;
130
- if (d < -Math.PI) d += 2 * Math.PI;
131
- return d;
132
- }
133
-
134
- // Velocity delta while the stick is held: accelerate toward deflection × speed
135
- // with OG turnFriction boost on reversals, then clamp the driven axes to `speed`.
136
- // Returns an impulse (add to velocity); never touches an undriven axis.
137
- export function stickDrive(velocity, deflection, props = {}, dt) {
138
- const speed = props.speed ?? 6;
139
- const rampTime = props.rampTime ?? 0.2;
140
- const turnBoost = props.turnBoost ?? 3;
141
- const axes = props.axes ?? 'both';
142
- const vDriven = drivenAxes(velocity, axes);
143
-
144
- let boost = 1;
145
- if (turnBoost > 0 && length(velocity) > 0 && length(deflection) > 0) {
146
- boost = 1 + turnBoost * (Math.abs(angleDelta(velocity, deflection)) / Math.PI);
147
- }
148
-
149
- let delta;
150
- if (rampTime <= 0) {
151
- // Instant: one-frame snap toward the capped target on the driven axes.
152
- const target = drivenAxes(
153
- { x: deflection.x * speed, y: deflection.y * speed },
154
- axes,
155
- );
156
- delta = { x: target.x - vDriven.x, y: target.y - vDriven.y };
157
- } else {
158
- const accel = speed / rampTime;
159
- delta = drivenAxes(
160
- { x: deflection.x * accel * boost * dt, y: deflection.y * accel * boost * dt },
161
- axes,
162
- );
163
- const capped = clampLength({ x: vDriven.x + delta.x, y: vDriven.y + delta.y }, speed);
164
- delta = { x: capped.x - vDriven.x, y: capped.y - vDriven.y };
165
- }
166
- return delta;
167
- }
168
-
169
- // Velocity delta while the stick is idle: Coulomb brake on the driven axes only.
170
- // `slowDown` is speed units lost per second (px/step per second). 0 = coast.
171
- export function stickBrake(velocity, props = {}, dt) {
172
- const slowDown = props.slowDown ?? 20;
173
- if (slowDown <= 0) return { x: 0, y: 0 };
174
- const axes = props.axes ?? 'both';
175
- const vDriven = drivenAxes(velocity, axes);
176
- const len = length(vDriven);
177
- if (len === 0) return { x: 0, y: 0 };
178
- const reduce = Math.min(len, slowDown * dt);
179
- const s = -reduce / len;
180
- return { x: vDriven.x * s, y: vDriven.y * s };
181
- }
@@ -0,0 +1,17 @@
1
+ // Shared Escape / outside-pointer dismiss for anchored popovers.
2
+ // `insideRefs` are refs whose nodes count as "inside" (popover + anchor).
3
+ export function attachDismissListeners(onClose, insideRefs) {
4
+ function onKeyDown(event) {
5
+ if (event.key === 'Escape') onClose();
6
+ }
7
+ function onPointerDown(event) {
8
+ if (insideRefs.some((ref) => ref.current?.contains(event.target))) return;
9
+ onClose();
10
+ }
11
+ window.addEventListener('keydown', onKeyDown);
12
+ window.addEventListener('pointerdown', onPointerDown);
13
+ return () => {
14
+ window.removeEventListener('keydown', onKeyDown);
15
+ window.removeEventListener('pointerdown', onPointerDown);
16
+ };
17
+ }
@@ -4,6 +4,8 @@ import { actorUsesBlueprint, getBlueprintTemplate, mergeComponents } from './blu
4
4
  import { getColliderRect, intersects, spriteIsEmpty } from './collider';
5
5
  import { systemInstallers } from './systemRegistry';
6
6
  import { deckFontFamily } from './fonts';
7
+ import { wrapText } from './text';
8
+ import { updateTaps } from './tap';
7
9
 
8
10
  const CARD_WIDTH = 500;
9
11
  const CARD_HEIGHT = 700;
@@ -393,6 +395,9 @@ export class SceneRuntime {
393
395
 
394
396
  update(dt) {
395
397
  this.time += dt;
398
+ // Before controls, so a tap on an actor that implements `onTap` claims the
399
+ // finger first. Targeted controls (Draggable) can still take it afterward.
400
+ updateTaps(this);
396
401
  for (const actor of this.getActors()) {
397
402
  this.#syncEnabled(actor);
398
403
  this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
@@ -408,7 +413,12 @@ export class SceneRuntime {
408
413
  }
409
414
 
410
415
  forEachBehavior(actor, callback) {
411
- for (const [behaviorName, props] of Object.entries(actor.components)) {
416
+ // Style paints chrome behind Text (and Style-only actors); run it first
417
+ // regardless of key insertion order. Sprite/Video paint Style themselves
418
+ // before their blit — see paintActorStyle in those behaviors.
419
+ const entries = Object.entries(actor.components ?? {});
420
+ entries.sort(([a], [b]) => (a === 'Style' ? -1 : b === 'Style' ? 1 : 0));
421
+ for (const [behaviorName, props] of entries) {
412
422
  if (!isBehaviorEnabled(props)) continue;
413
423
  const Behavior = this.behaviors.get(behaviorName);
414
424
  if (!Behavior) continue;
@@ -844,8 +854,7 @@ function drawDisabledSpriteLabel(ctx, actor) {
844
854
  const padY = fontSize * 0.4;
845
855
 
846
856
  const single = 'Sprite disabled';
847
- const lines =
848
- ctx.measureText(single).width + padX * 2 <= layout.width ? [single] : ['Sprite', 'disabled'];
857
+ const lines = wrapText(ctx, single, Math.max(0, layout.width - padX * 2));
849
858
  const lineHeight = fontSize * 1.15;
850
859
  const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
851
860
  const width = textWidth + padX * 2;
@@ -2,6 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { ArtThumbnail } from './artThumbnail';
4
4
  import { cx, FieldRow, Icon, styles } from './ui';
5
+ import { attachDismissListeners } from './popoverDismiss';
5
6
 
6
7
  // Visual sprite / image picker for inspector fields that point at art files.
7
8
  // Anchored popover (same family as FileField), not a modal: thumbnails only,
@@ -68,21 +69,7 @@ function SpritePickerPopover({ paths, current, sprites, anchorRef, onPick, onClo
68
69
  }, [anchorRef, paths.length]);
69
70
 
70
71
  useEffect(() => {
71
- function onKeyDown(event) {
72
- if (event.key === 'Escape') onClose();
73
- }
74
- function onPointerDown(event) {
75
- if (listRef.current?.contains(event.target) || anchorRef.current?.contains(event.target)) {
76
- return;
77
- }
78
- onClose();
79
- }
80
- window.addEventListener('keydown', onKeyDown);
81
- window.addEventListener('pointerdown', onPointerDown);
82
- return () => {
83
- window.removeEventListener('keydown', onKeyDown);
84
- window.removeEventListener('pointerdown', onPointerDown);
85
- };
72
+ return attachDismissListeners(onClose, [listRef, anchorRef]);
86
73
  }, [anchorRef, onClose]);
87
74
 
88
75
  return (
@@ -0,0 +1,90 @@
1
+ import { TAP_CLAIM } from './physics/controls';
2
+
3
+ // Built-in tap. Any enabled behavior may implement `onTap(actor, scene)`; the
4
+ // actor is then a tap target with no extra component. Fires on release inside
5
+ // the hit zone (Collider when present, else the axis-aligned Layout box).
6
+ // Screen-space Text uses screen coords so a HUD label hits where it draws.
7
+ //
8
+ // Claims at TAP_CLAIM: beats a greedy control (stick, grab-anywhere sling) so
9
+ // the finger isn't also those, and yields to a targeted control (Draggable, a
10
+ // sling pressed on its own body) that claims later the same frame.
11
+
12
+ export function updateTaps(scene) {
13
+ for (const actor of scene.actors.values()) {
14
+ const id = actor.runtime?._tapPointerId;
15
+ if (id == null) continue;
16
+ const held = scene.pointers.get(id);
17
+ if (held?.down) {
18
+ if (!scene.ownsPointer(id, actor.id)) clearTap(actor);
19
+ else actor.runtime._tapInside = hits(actor, scene, held);
20
+ continue;
21
+ }
22
+ const stillOwned = held ? scene.ownsPointer(id, actor.id) : true;
23
+ const inside = held ? hits(actor, scene, held) : Boolean(actor.runtime._tapInside);
24
+ if (stillOwned && inside && hasOnTap(scene, actor)) fireTap(scene, actor);
25
+ clearTap(actor);
26
+ }
27
+
28
+ for (const pointer of scene.pointers.values()) {
29
+ if (!pointer.justPressed) continue;
30
+ const actor = topTapActor(scene, pointer);
31
+ if (!actor) continue;
32
+ if (!scene.claimPointer(pointer.id, actor.id, TAP_CLAIM)) continue;
33
+ actor.runtime._tapPointerId = pointer.id;
34
+ actor.runtime._tapInside = true;
35
+ // A click that ends before the next frame is justPressed with down:false.
36
+ if (!pointer.down) {
37
+ if (scene.ownsPointer(pointer.id, actor.id)) fireTap(scene, actor);
38
+ clearTap(actor);
39
+ }
40
+ }
41
+ }
42
+
43
+ function fireTap(scene, actor) {
44
+ scene.forEachBehavior(actor, (inst) => inst.onTap?.(actor, scene));
45
+ }
46
+
47
+ function clearTap(actor) {
48
+ actor.runtime._tapPointerId = null;
49
+ actor.runtime._tapInside = false;
50
+ }
51
+
52
+ function hasOnTap(scene, actor) {
53
+ for (const [name, props] of Object.entries(actor.components ?? {})) {
54
+ if (!props || props.$enabled === false) continue;
55
+ const Behavior = scene.behaviors.get(name);
56
+ if (typeof Behavior?.prototype?.onTap === 'function') return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function topTapActor(scene, pointer) {
62
+ let best = null;
63
+ let bestZ = -Infinity;
64
+ for (const actor of scene.actors.values()) {
65
+ if (!hasOnTap(scene, actor)) continue;
66
+ const z = actor.components?.Layout?.z ?? 0;
67
+ if (z < bestZ) continue;
68
+ if (!hits(actor, scene, pointer)) continue;
69
+ best = actor;
70
+ bestZ = z;
71
+ }
72
+ return best;
73
+ }
74
+
75
+ function pointerCoords(actor, pointer) {
76
+ if (actor.components?.Text?.space === 'screen') {
77
+ return { x: pointer.screenX, y: pointer.screenY };
78
+ }
79
+ return { x: pointer.x, y: pointer.y };
80
+ }
81
+
82
+ function hits(actor, scene, pointer) {
83
+ const { x, y } = pointerCoords(actor, pointer);
84
+ if (actor.components?.Collider && scene.physics?.containsPoint) {
85
+ return scene.physics.containsPoint(actor, x, y);
86
+ }
87
+ const layout = actor.components?.Layout;
88
+ if (!layout) return false;
89
+ return x >= layout.x && x <= layout.x + layout.width && y >= layout.y && y <= layout.y + layout.height;
90
+ }
@@ -0,0 +1,94 @@
1
+ // Canvas text layout helpers shared by the Text behavior and editor chrome.
2
+ // Pure: no imports. Callers set `ctx.font` before wrapText / pass size+font into
3
+ // drawTextBlock.
4
+
5
+ // Greedy word-wrap against the ctx's current font. Paragraphs split on `\n`
6
+ // (after normalizing `\r\n`); an empty paragraph yields an empty line. A single
7
+ // word wider than maxWidth stays on its own line and overflows.
8
+ export function wrapText(ctx, text, maxWidth) {
9
+ const source = String(text ?? '').replace(/\r\n/g, '\n');
10
+ const paragraphs = source.split('\n');
11
+ const lines = [];
12
+ for (const paragraph of paragraphs) {
13
+ if (!paragraph) {
14
+ lines.push('');
15
+ continue;
16
+ }
17
+ const words = paragraph.split(/\s+/);
18
+ let line = '';
19
+ for (const word of words) {
20
+ if (!word) continue;
21
+ const next = line ? `${line} ${word}` : word;
22
+ if (line && ctx.measureText(next).width > maxWidth) {
23
+ lines.push(line);
24
+ line = word;
25
+ } else {
26
+ line = next;
27
+ }
28
+ }
29
+ lines.push(line);
30
+ }
31
+ return lines;
32
+ }
33
+
34
+ const ALIGN_X = { left: 0, center: 0.5, right: 1 };
35
+ const ALIGN_Y = { top: 0, middle: 0.5, bottom: 1 };
36
+
37
+ // Draw wrapped, aligned text into a box. No vertical clipping — lines that
38
+ // overrun the box still paint (OG Text parity). Returns the laid-out lines
39
+ // and the resolved line height in card units.
40
+ export function drawTextBlock(
41
+ ctx,
42
+ {
43
+ x,
44
+ y,
45
+ width,
46
+ height,
47
+ text,
48
+ size = 24,
49
+ font = 'sans-serif',
50
+ color = '#131313',
51
+ align = 'left',
52
+ verticalAlign = 'top',
53
+ padding = 0,
54
+ lineHeight = 1.25,
55
+ outlineWidth = 0,
56
+ outlineColor = '#ffffff',
57
+ }
58
+ ) {
59
+ const pad = Math.max(0, Number(padding) || 0);
60
+ const innerW = Math.max(0, width - pad * 2);
61
+ const innerH = Math.max(0, height - pad * 2);
62
+ const fontSize = Number.isFinite(size) ? size : 24;
63
+ const lineH = fontSize * (Number.isFinite(lineHeight) ? lineHeight : 1.25);
64
+
65
+ ctx.save();
66
+ ctx.font = `${fontSize}px ${font}`;
67
+ ctx.textAlign = align === 'center' || align === 'right' ? align : 'left';
68
+ ctx.textBaseline = 'middle';
69
+
70
+ const lines = wrapText(ctx, text, innerW);
71
+ const blockH = lines.length * lineH;
72
+ const yFactor = ALIGN_Y[verticalAlign] ?? 0;
73
+ const blockTop = y + pad + (innerH - blockH) * yFactor;
74
+ const xFactor = ALIGN_X[align] ?? 0;
75
+ const anchorX = x + pad + innerW * xFactor;
76
+
77
+ const stroke = Number(outlineWidth) > 0 && Boolean(outlineColor);
78
+ const fill = Boolean(color);
79
+ if (stroke) {
80
+ ctx.lineWidth = outlineWidth;
81
+ ctx.strokeStyle = outlineColor;
82
+ ctx.lineJoin = 'round';
83
+ ctx.miterLimit = 2;
84
+ }
85
+ if (fill) ctx.fillStyle = color;
86
+
87
+ for (let i = 0; i < lines.length; i++) {
88
+ const ly = blockTop + lineH * (i + 0.5);
89
+ if (stroke) ctx.strokeText(lines[i], anchorX, ly);
90
+ if (fill) ctx.fillText(lines[i], anchorX, ly);
91
+ }
92
+ ctx.restore();
93
+ return { lines, lineHeight: lineH };
94
+ }
@@ -419,6 +419,26 @@ export function TextField({ label, value, onChange, overridden, defaultValue, on
419
419
  </FieldRow>
420
420
  );
421
421
  }
422
+ export function TextAreaField({
423
+ label,
424
+ value,
425
+ onChange,
426
+ rows = 3,
427
+ overridden,
428
+ defaultValue,
429
+ onReset,
430
+ }) {
431
+ return (
432
+ <FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
433
+ <textarea
434
+ className={styles.textarea}
435
+ rows={rows}
436
+ value={value ?? ''}
437
+ onChange={(event) => onChange(event.target.value)}
438
+ />
439
+ </FieldRow>
440
+ );
441
+ }
422
442
  function numberText(v) {
423
443
  return Number.isFinite(v) ? String(v) : '0';
424
444
  }
@@ -1773,6 +1773,7 @@
1773
1773
 
1774
1774
  .fieldRowOverridden .input,
1775
1775
  .fieldRowOverridden .select,
1776
+ .fieldRowOverridden .textarea,
1776
1777
  .fieldRowOverridden .colorInput,
1777
1778
  .fieldRowOverridden .checkbox {
1778
1779
  border-color: var(--castle-override-border);
@@ -1883,6 +1884,13 @@
1883
1884
  color: var(--castle-inspector-muted);
1884
1885
  }
1885
1886
 
1887
+ /* PaletteColorField: swatch + hex in the closed chrome. */
1888
+ .paletteColorField {
1889
+ gap: 8px;
1890
+ padding-left: 6px;
1891
+ min-height: 32px;
1892
+ }
1893
+
1886
1894
  /* SpriteField: closed chrome carries a tiny thumb beside the basename so you
1887
1895
  can see which art is selected without opening the picker. */
1888
1896
  .spriteField {
@@ -2326,8 +2334,8 @@
2326
2334
 
2327
2335
  .textarea {
2328
2336
  min-height: 0;
2329
- resize: none;
2330
- font-family: var(--castle-font-mono);
2337
+ resize: vertical;
2338
+ font-family: inherit;
2331
2339
  font-size: 14px;
2332
2340
  line-height: 1.45;
2333
2341
  }