castle-web-cli 0.4.177 → 0.4.179

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 (40) 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 +125 -40
  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/shell/assets/index-C5DS_7UM.js +447 -0
  9. package/dist/shell/assets/index-CoU3ETYM.css +1 -0
  10. package/dist/shell/index.html +2 -2
  11. package/kits/base/CLAUDE.md +3 -0
  12. package/kits/base/castle.json +21 -11
  13. package/kits/physics-2d/CLAUDE.md +41 -8
  14. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  15. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  16. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  17. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  18. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  19. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  20. package/kits/physics-2d/blueprints/text.scene +12 -0
  21. package/kits/physics-2d/castle.json +10 -6
  22. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  23. package/kits/physics-2d/editors/deckFont.js +65 -55
  24. package/kits/physics-2d/editors/fontPreview.js +6 -38
  25. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  26. package/kits/physics-2d/engine/blueprint.js +28 -0
  27. package/kits/physics-2d/engine/fonts.js +125 -23
  28. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  29. package/kits/physics-2d/engine/physics/controls.js +5 -81
  30. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  31. package/kits/physics-2d/engine/scene.js +12 -3
  32. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  33. package/kits/physics-2d/engine/tap.js +90 -0
  34. package/kits/physics-2d/engine/text.js +94 -0
  35. package/kits/physics-2d/engine/ui.jsx +20 -0
  36. package/kits/physics-2d/engine/ui.module.css +10 -2
  37. package/kits/physics-3d/castle.json +4 -2
  38. package/package.json +1 -1
  39. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  40. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -1,20 +1,18 @@
1
- // The deck's font.
1
+ // The deck's fonts.
2
2
  //
3
- // Two files hold it, and the split is the whole point:
3
+ // Two files hold them, and the split is the whole point:
4
4
  //
5
- // theme.style { "font": "Piazzolla" } the RECORD -- readable, hand-editable
6
- // fonts.generated.js a one-line re-export the BYTES
5
+ // theme.style { "font": "Piazzolla" } the RECORD -- primary face
6
+ // fonts.generated.js static re-exports the BYTES
7
7
  //
8
8
  // `theme.style` is data the engine reads at RUNTIME, so a font name in it is
9
9
  // just a string, and turning a runtime string into font bytes means a lookup
10
10
  // keyed by that name -- which no bundler can narrow, so it would inline all
11
- // nine Castle faces (495 KB) into every physics-2d deck, whose whole bundle is
12
- // under 900 KB. The generated module names its face in a static import
13
- // instead, so exactly one face ships. See castle-web-fonts' README.
11
+ // nine Castle faces (495 KB) into every physics-2d deck. The generated module
12
+ // names each face in a static import instead, so only the faces in use ship.
14
13
  //
15
- // The two can drift when someone edits `theme.style` by hand. Edit mode heals
16
- // that (editors/deckFont.js); a published deck is always consistent, because
17
- // the module is on disk when `save-deck` bundles it.
14
+ // Faces in use = the theme primary plus every `Text.font` on actors in the
15
+ // deck's scenes/blueprints. Edit mode keeps the module honest (editors/deckFont.js).
18
16
 
19
17
  import { FACES, faceInfo } from 'castle-web-fonts';
20
18
 
@@ -34,11 +32,19 @@ export const DEFAULT_FONT_FAMILY = 'sans-serif';
34
32
  const generated = import.meta.glob('/fonts.generated.js', { eager: true });
35
33
  const deckFontModule = generated['/fonts.generated.js'];
36
34
 
37
- /** The face the generated module actually carries, or null when there is none.
38
- * This is what the deck RENDERS in, as against what `theme.style` asks for. */
35
+ /** The face the generated module actually carries as primary, or null. */
39
36
  export const generatedFontName =
40
37
  typeof deckFontModule?.deckFont === 'string' ? deckFontModule.deckFont : null;
41
38
 
39
+ /** Every face the generated module ships (primary + Text extras), ordered. */
40
+ export const generatedFontNames = (() => {
41
+ const listed = deckFontModule?.deckFonts;
42
+ if (Array.isArray(listed) && listed.length) {
43
+ return listed.filter((name) => typeof name === 'string' && faceInfo(name));
44
+ }
45
+ return generatedFontName ? [generatedFontName] : [];
46
+ })();
47
+
42
48
  /** A CSS `font-family` value that is always safe to use: the deck's face when
43
49
  * it has one, a plain sans stack when it doesn't. */
44
50
  export const deckFontFamily = generatedFontName ?? DEFAULT_FONT_FAMILY;
@@ -50,30 +56,126 @@ export function themeFontName(themeData) {
50
56
  return typeof raw === 'string' && faceInfo(raw) ? raw : null;
51
57
  }
52
58
 
59
+ /** Resolve a Text.font prop to a CSS family: empty/unknown → deck primary. */
60
+ export function resolveTextFont(font) {
61
+ if (typeof font === 'string' && font && faceInfo(font)) return font;
62
+ return deckFontFamily;
63
+ }
64
+
65
+ /** Unique official face names from every Text actor in scene/blueprint files. */
66
+ export function collectTextFontsFromFiles(files) {
67
+ const found = new Set();
68
+ for (const [path, text] of Object.entries(files ?? {})) {
69
+ if (!path.endsWith('.scene') || typeof text !== 'string') continue;
70
+ let data;
71
+ try {
72
+ data = JSON.parse(text);
73
+ } catch {
74
+ continue;
75
+ }
76
+ for (const actor of data?.actors ?? []) {
77
+ const name = actor?.components?.Text?.font;
78
+ if (typeof name === 'string' && faceInfo(name)) found.add(name);
79
+ }
80
+ }
81
+ return [...found];
82
+ }
83
+
84
+ /** Ordered unique official faces: primary first, then extras. */
85
+ export function orderedDeckFonts(primary, extras = []) {
86
+ const out = [];
87
+ const seen = new Set();
88
+ for (const name of [primary, ...extras]) {
89
+ if (!name || !faceInfo(name) || seen.has(name)) continue;
90
+ seen.add(name);
91
+ out.push(name);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Source for `fonts.generated.js` carrying exactly `fonts` (primary = deckFont). */
97
+ export function fontsGeneratedSource(primary, fonts) {
98
+ const list = orderedDeckFonts(primary, fonts);
99
+ if (!list.length) {
100
+ return `// Generated by the Theme editor. Do not edit.
101
+ //
102
+ // This deck has no font of its own; text renders in the default sans stack.
103
+ export const deckFont = null;
104
+ export const deckFonts = [];
105
+ export const installDeckFonts = [];
106
+ `;
107
+ }
108
+ const primaryName = list.includes(primary) ? primary : list[0];
109
+ const imports = list.map((name) => `install${name}`).join(', ');
110
+ const installers = list.map((name) => `install${name}`).join(', ');
111
+ const namesLit = list.map((name) => `'${name}'`).join(', ');
112
+ return `// Generated by the Theme editor / Text font picker. Do not edit.
113
+ //
114
+ // \`theme.style\` records the deck's primary font as ${primaryName}; Text actors
115
+ // may use additional faces. This module is what puts their bytes in the
116
+ // published bundle — each face must be named in a static import so the bundler
117
+ // can tree-shake the rest.
118
+ import { ${imports} } from 'castle-web-fonts';
119
+ export { install${primaryName} as installDeckFont };
120
+ export const deckFont = '${primaryName}';
121
+ export const deckFonts = [${namesLit}];
122
+ export const installDeckFonts = [${installers}];
123
+ `;
124
+ }
125
+
53
126
  let ready = null;
54
127
 
55
128
  /** Resolve once every face this deck registered can be drawn with -- the deck
56
- * font, and any a behavior imported for itself.
129
+ * font(s) in fonts.generated.js, and any a behavior imported for itself.
57
130
  *
58
131
  * Wait for this before the first frame. Canvas 2D does not trigger a font load,
59
132
  * so a `draw` hook setting `ctx.font` before the face is ready silently falls
60
- * back to the default -- and never repaints when the font lands, so the deck
61
- * looks right after a warm reload and wrong on a cold first paint.
62
- *
63
- * `document.fonts.ready` is what covers the extras, and it is the only thing
64
- * that can: a deck wanting a display face beside the deck font imports it
65
- * itself, and nothing here knows its name. A behavior module's `installBore()`
66
- * runs while the module graph loads, which is before the player mounts, so that
67
- * load is already pending by the time this is first called.
133
+ * back to the default -- and never repaints when the font lands.
68
134
  *
69
135
  * Never rejects: a deck whose font failed to load should still play. */
70
136
  export function fontsReady() {
71
137
  if (!ready) {
72
- const install = deckFontModule?.installDeckFont;
138
+ const list = deckFontModule?.installDeckFonts;
139
+ const installers = Array.isArray(list) && list.length
140
+ ? list
141
+ : deckFontModule?.installDeckFont
142
+ ? [deckFontModule.installDeckFont]
143
+ : [];
73
144
  ready = Promise.all([
74
- install ? install() : null,
145
+ ...installers.map((install) => (typeof install === 'function' ? install() : null)),
75
146
  typeof document === 'undefined' ? null : document.fonts?.ready,
76
147
  ]).catch(() => null);
77
148
  }
78
149
  return ready;
79
150
  }
151
+
152
+ // Edit-time loads for faces not yet in the eager `fonts.generated.js` module
153
+ // (e.g. the author just picked Text.font). Same woff2 URLs the font picker
154
+ // uses -- served from node_modules while editing, never inlined into a publish.
155
+ const faceLoads = new Map();
156
+
157
+ /** Install one Castle face into `document.fonts` so canvas text can use it
158
+ * without a panel reload. No-op for unknown / empty names. Idempotent. */
159
+ export function ensureOfficialFace(name) {
160
+ if (!name || !faceInfo(name)) return Promise.resolve(false);
161
+ if (typeof FontFace === 'undefined' || typeof document === 'undefined') {
162
+ return Promise.resolve(false);
163
+ }
164
+ const pending = faceLoads.get(name);
165
+ if (pending) return pending;
166
+ const load = (async () => {
167
+ try {
168
+ const face = new FontFace(
169
+ name,
170
+ `url("/node_modules/castle-web-fonts/woff2/${name}.woff2") format("woff2")`
171
+ );
172
+ await face.load();
173
+ document.fonts.add(face);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ })();
179
+ faceLoads.set(name, load);
180
+ return load;
181
+ }
@@ -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 (