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.
- package/dist/agent-failures.js +4 -4
- package/dist/agent-prompts.d.ts +9 -1
- package/dist/agent-prompts.js +15 -2
- package/dist/agent.js +125 -40
- package/dist/deckLocatorShape.d.ts +16 -0
- package/dist/deckLocatorShape.js +28 -0
- package/dist/editorConfig.d.ts +1 -0
- package/dist/shell/assets/index-C5DS_7UM.js +447 -0
- package/dist/shell/assets/index-CoU3ETYM.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/base/CLAUDE.md +3 -0
- package/kits/base/castle.json +21 -11
- package/kits/physics-2d/CLAUDE.md +41 -8
- package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
- package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
- package/kits/physics-2d/behaviors/Style.jsx +270 -0
- package/kits/physics-2d/behaviors/Text.jsx +213 -0
- package/kits/physics-2d/behaviors/Video.jsx +8 -4
- package/kits/physics-2d/blueprints/text.scene +12 -0
- package/kits/physics-2d/castle.json +10 -6
- package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
- package/kits/physics-2d/editors/deckFont.js +65 -55
- package/kits/physics-2d/editors/fontPreview.js +6 -38
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
- package/kits/physics-2d/engine/blueprint.js +28 -0
- package/kits/physics-2d/engine/fonts.js +125 -23
- package/kits/physics-2d/engine/paletteField.jsx +235 -0
- package/kits/physics-2d/engine/physics/controls.js +5 -81
- package/kits/physics-2d/engine/popoverDismiss.js +17 -0
- package/kits/physics-2d/engine/scene.js +12 -3
- package/kits/physics-2d/engine/spriteField.jsx +2 -15
- package/kits/physics-2d/engine/tap.js +90 -0
- package/kits/physics-2d/engine/text.js +94 -0
- package/kits/physics-2d/engine/ui.jsx +20 -0
- package/kits/physics-2d/engine/ui.module.css +10 -2
- package/kits/physics-3d/castle.json +4 -2
- package/package.json +1 -1
- package/dist/shell/assets/index-BWOEraUy.js +0 -447
- package/dist/shell/assets/index-BkVF1OXc.css +0 -1
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
// The deck's
|
|
1
|
+
// The deck's fonts.
|
|
2
2
|
//
|
|
3
|
-
// Two files hold
|
|
3
|
+
// Two files hold them, and the split is the whole point:
|
|
4
4
|
//
|
|
5
|
-
// theme.style { "font": "Piazzolla" } the RECORD --
|
|
6
|
-
// fonts.generated.js
|
|
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
|
|
12
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|