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.
- 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 +98 -34
- package/dist/deckLocatorShape.d.ts +16 -0
- package/dist/deckLocatorShape.js +28 -0
- package/dist/editorConfig.d.ts +1 -0
- package/dist/serve.js +3 -1
- package/dist/shell/assets/index-C890YbXX.css +1 -0
- package/dist/shell/assets/index-z6shfW4S.js +447 -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 +7 -3
- package/dist/shell/assets/index-BWOEraUy.js +0 -447
- 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
|
-
|
|
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 (
|
|
@@ -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:
|
|
2330
|
-
font-family:
|
|
2337
|
+
resize: vertical;
|
|
2338
|
+
font-family: inherit;
|
|
2331
2339
|
font-size: 14px;
|
|
2332
2340
|
line-height: 1.45;
|
|
2333
2341
|
}
|