castle-web-cli 0.4.2 → 0.4.4
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/index.js +8 -2
- package/dist/init.js +9 -6
- package/package.json +1 -1
- package/kits/basic-2d-frozen/.prettierrc +0 -8
- package/kits/basic-2d-frozen/CLAUDE.md +0 -131
- package/kits/basic-2d-frozen/behaviors/Camera.jsx +0 -43
- package/kits/basic-2d-frozen/behaviors/Collider.jsx +0 -71
- package/kits/basic-2d-frozen/behaviors/Drawing.jsx +0 -139
- package/kits/basic-2d-frozen/behaviors/Layout.jsx +0 -16
- package/kits/basic-2d-frozen/drawings/floor.drawing +0 -70
- package/kits/basic-2d-frozen/editors/App.jsx +0 -152
- package/kits/basic-2d-frozen/editors/CodeEditor.jsx +0 -112
- package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +0 -222
- package/kits/basic-2d-frozen/editors/FileBrowser.jsx +0 -143
- package/kits/basic-2d-frozen/editors/PlayOnly.jsx +0 -21
- package/kits/basic-2d-frozen/editors/SceneEditor.jsx +0 -1012
- package/kits/basic-2d-frozen/editors/behaviorRegistry.js +0 -24
- package/kits/basic-2d-frozen/editors/editorHistory.js +0 -52
- package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +0 -83
- package/kits/basic-2d-frozen/engine/SceneUI.jsx +0 -67
- package/kits/basic-2d-frozen/engine/TouchControls.jsx +0 -136
- package/kits/basic-2d-frozen/engine/autoInspector.jsx +0 -51
- package/kits/basic-2d-frozen/engine/files.js +0 -62
- package/kits/basic-2d-frozen/engine/scene.js +0 -420
- package/kits/basic-2d-frozen/engine/ui.jsx +0 -344
- package/kits/basic-2d-frozen/engine/ui.module.css +0 -928
- package/kits/basic-2d-frozen/eslint.config.js +0 -50
- package/kits/basic-2d-frozen/index.html +0 -11
- package/kits/basic-2d-frozen/main.jsx +0 -10
- package/kits/basic-2d-frozen/package-lock.json +0 -2706
- package/kits/basic-2d-frozen/package.json +0 -41
- package/kits/basic-2d-frozen/scenes/main.scene +0 -108
- package/kits/basic-2d-frozen/vite.config.js +0 -1
- package/kits/rpg-2d/.prettierrc +0 -8
- package/kits/rpg-2d/behaviors/Camera.tsx +0 -52
- package/kits/rpg-2d/behaviors/Collider.tsx +0 -98
- package/kits/rpg-2d/behaviors/Dialog.tsx +0 -184
- package/kits/rpg-2d/behaviors/Drawing.tsx +0 -161
- package/kits/rpg-2d/behaviors/Friend.tsx +0 -45
- package/kits/rpg-2d/behaviors/Layout.tsx +0 -29
- package/kits/rpg-2d/behaviors/PlayerController.tsx +0 -255
- package/kits/rpg-2d/behaviors/Portal.tsx +0 -60
- package/kits/rpg-2d/behaviors/QuestLog.tsx +0 -90
- package/kits/rpg-2d/behaviors/SaveMenu.tsx +0 -123
- package/kits/rpg-2d/behaviors/Tilemap.tsx +0 -90
- package/kits/rpg-2d/drawings/bld-home.drawing +0 -8136
- package/kits/rpg-2d/drawings/env-crate.drawing +0 -509
- package/kits/rpg-2d/drawings/env-fence.drawing +0 -536
- package/kits/rpg-2d/drawings/env-flower-bed.drawing +0 -607
- package/kits/rpg-2d/drawings/env-fountain.drawing +0 -2622
- package/kits/rpg-2d/drawings/env-hedge.drawing +0 -601
- package/kits/rpg-2d/drawings/env-house-blue.drawing +0 -1
- package/kits/rpg-2d/drawings/env-house-green.drawing +0 -1
- package/kits/rpg-2d/drawings/env-tree-oak.drawing +0 -1540
- package/kits/rpg-2d/drawings/env-tree-pine.drawing +0 -1315
- package/kits/rpg-2d/drawings/floor.drawing +0 -70
- package/kits/rpg-2d/drawings/fx-sparkle.drawing +0 -926
- package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +0 -1099
- package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +0 -4177
- package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +0 -1099
- package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +0 -4177
- package/kits/rpg-2d/drawings/player-idle-down.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-idle-left.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-idle-right.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-idle-up.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-walk-down.drawing +0 -4148
- package/kits/rpg-2d/drawings/player-walk-left.drawing +0 -4148
- package/kits/rpg-2d/drawings/player-walk-right.drawing +0 -4148
- package/kits/rpg-2d/drawings/player-walk-up.drawing +0 -4148
- package/kits/rpg-2d/editors/App.tsx +0 -163
- package/kits/rpg-2d/editors/CodeEditor.tsx +0 -120
- package/kits/rpg-2d/editors/DrawingEditor.tsx +0 -278
- package/kits/rpg-2d/editors/FileBrowser.tsx +0 -191
- package/kits/rpg-2d/editors/PlayOnly.tsx +0 -26
- package/kits/rpg-2d/editors/SceneEditor.tsx +0 -1093
- package/kits/rpg-2d/editors/behaviorRegistry.ts +0 -33
- package/kits/rpg-2d/editors/editorHistory.ts +0 -75
- package/kits/rpg-2d/editors/editorProps.ts +0 -10
- package/kits/rpg-2d/engine/ScenePlayer.tsx +0 -130
- package/kits/rpg-2d/engine/SceneUI.tsx +0 -74
- package/kits/rpg-2d/engine/TouchControls.tsx +0 -157
- package/kits/rpg-2d/engine/autoInspector.tsx +0 -111
- package/kits/rpg-2d/engine/drawing.ts +0 -81
- package/kits/rpg-2d/engine/files.ts +0 -215
- package/kits/rpg-2d/engine/scene.ts +0 -484
- package/kits/rpg-2d/engine/ui.module.css +0 -928
- package/kits/rpg-2d/engine/ui.tsx +0 -483
- package/kits/rpg-2d/eslint.config.js +0 -46
- package/kits/rpg-2d/index.html +0 -11
- package/kits/rpg-2d/main.tsx +0 -14
- package/kits/rpg-2d/package-lock.json +0 -3149
- package/kits/rpg-2d/package.json +0 -46
- package/kits/rpg-2d/scenes/main.scene +0 -203
- package/kits/rpg-2d/tsconfig.json +0 -17
- package/kits/rpg-2d/vite-env.d.ts +0 -7
- package/kits/rpg-2d/vite.config.js +0 -1
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { BehaviorClass } from '../engine/scene';
|
|
2
|
-
|
|
3
|
-
// Discover every behavior class from `behaviors/*.tsx`. Vite HMR is off, so a
|
|
4
|
-
// newly-added behavior file is picked up on the next reload/restart.
|
|
5
|
-
const modules = import.meta.glob<Record<string, unknown>>('../behaviors/*.tsx', { eager: true });
|
|
6
|
-
|
|
7
|
-
function isBehaviorClass(value: unknown): value is BehaviorClass {
|
|
8
|
-
return (
|
|
9
|
-
typeof value === 'function' &&
|
|
10
|
-
typeof (value as { behaviorName?: unknown }).behaviorName === 'string'
|
|
11
|
-
);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function collectBehaviors(): BehaviorClass[] {
|
|
15
|
-
const found = new Map<string, BehaviorClass>();
|
|
16
|
-
for (const mod of Object.values(modules)) {
|
|
17
|
-
for (const exported of Object.values(mod)) {
|
|
18
|
-
if (isBehaviorClass(exported)) found.set(exported.behaviorName, exported);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
// Layout first (every actor needs it), then alphabetical.
|
|
22
|
-
return [...found.values()].sort((a, b) => {
|
|
23
|
-
if (a.behaviorName === 'Layout') return -1;
|
|
24
|
-
if (b.behaviorName === 'Layout') return 1;
|
|
25
|
-
return a.behaviorName.localeCompare(b.behaviorName);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const behaviorClasses: BehaviorClass[] = collectBehaviors();
|
|
30
|
-
|
|
31
|
-
export function findBehaviorClass(behaviorName: string): BehaviorClass | undefined {
|
|
32
|
-
return behaviorClasses.find((candidate) => candidate.behaviorName === behaviorName);
|
|
33
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
|
|
3
|
-
interface History {
|
|
4
|
-
undo: string[];
|
|
5
|
-
redo: string[];
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const HISTORY_LIMIT = 50;
|
|
9
|
-
|
|
10
|
-
export interface EditHistory {
|
|
11
|
-
commit: (nextText: string) => void;
|
|
12
|
-
undo: () => void;
|
|
13
|
-
redo: () => void;
|
|
14
|
-
canUndo: boolean;
|
|
15
|
-
canRedo: boolean;
|
|
16
|
-
// For mid-stroke records: stash current text into the undo stack without
|
|
17
|
-
// touching `onChange` (the caller will emit the new text itself).
|
|
18
|
-
recordSnapshot: () => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Text-undo/redo for file-backed editors. `text` is the canonical current
|
|
22
|
-
// value; `onChange` writes the new value back. The hook owns the undo/redo
|
|
23
|
-
// stacks; it never mutates `text` directly.
|
|
24
|
-
export function useEditHistory(text: string, onChange: (next: string) => void): EditHistory {
|
|
25
|
-
const [history, setHistory] = useState<History>({ undo: [], redo: [] });
|
|
26
|
-
|
|
27
|
-
function commit(nextText: string): void {
|
|
28
|
-
if (nextText === text) return;
|
|
29
|
-
setHistory((current) => ({
|
|
30
|
-
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
31
|
-
redo: [],
|
|
32
|
-
}));
|
|
33
|
-
onChange(nextText);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function recordSnapshot(): void {
|
|
37
|
-
setHistory((current) => ({
|
|
38
|
-
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
39
|
-
redo: [],
|
|
40
|
-
}));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function undo(): void {
|
|
44
|
-
setHistory((current) => {
|
|
45
|
-
const previous = current.undo.at(-1);
|
|
46
|
-
if (!previous) return current;
|
|
47
|
-
onChange(previous);
|
|
48
|
-
return {
|
|
49
|
-
undo: current.undo.slice(0, -1),
|
|
50
|
-
redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
|
|
51
|
-
};
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function redo(): void {
|
|
56
|
-
setHistory((current) => {
|
|
57
|
-
const next = current.redo[0];
|
|
58
|
-
if (!next) return current;
|
|
59
|
-
onChange(next);
|
|
60
|
-
return {
|
|
61
|
-
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
62
|
-
redo: current.redo.slice(1),
|
|
63
|
-
};
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
commit,
|
|
69
|
-
undo,
|
|
70
|
-
redo,
|
|
71
|
-
canUndo: history.undo.length > 0,
|
|
72
|
-
canRedo: history.redo.length > 0,
|
|
73
|
-
recordSnapshot,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// Shared prop shape for file-backed editors (Code / Drawing / Scene). Each
|
|
2
|
-
// editor receives the current file's `path`, its `text`, an `onChange` to
|
|
3
|
-
// write back, and the file-browser toggle/state for the editor header.
|
|
4
|
-
export interface FileEditorProps {
|
|
5
|
-
path: string;
|
|
6
|
-
text: string;
|
|
7
|
-
onChange: (text: string) => void;
|
|
8
|
-
onToggleFiles?: () => void;
|
|
9
|
-
filesOpen?: boolean;
|
|
10
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { initialFiles, parseJsonFile } from './files';
|
|
3
|
-
import type { DrawingData } from './files';
|
|
4
|
-
import { configureSceneCanvas, makeScene } from './scene';
|
|
5
|
-
import type { BehaviorClass, SceneData, SceneRuntime } from './scene';
|
|
6
|
-
import { SceneUI } from './SceneUI';
|
|
7
|
-
import { TouchControls } from './TouchControls';
|
|
8
|
-
|
|
9
|
-
// Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
|
|
10
|
-
// keyboard / pointer input, run the update+draw loop, and render the
|
|
11
|
-
// behavior-driven UI overlay. No game logic lives here -- behaviors and
|
|
12
|
-
// scenes are the place for that.
|
|
13
|
-
export function ScenePlayer({
|
|
14
|
-
sceneData,
|
|
15
|
-
drawings,
|
|
16
|
-
behaviorClasses,
|
|
17
|
-
}: {
|
|
18
|
-
sceneData: SceneData;
|
|
19
|
-
drawings: Record<string, DrawingData>;
|
|
20
|
-
behaviorClasses: BehaviorClass[];
|
|
21
|
-
}) {
|
|
22
|
-
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
23
|
-
const runtimeRef = useRef<SceneRuntime | null>(null);
|
|
24
|
-
const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
|
|
25
|
-
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
26
|
-
// `loadedScene` lets Portal swap scenes: when a behavior sets
|
|
27
|
-
// `runtime.requestedScene = 'scenes/foo.scene'` the player loop notices,
|
|
28
|
-
// bumps this state, the effect re-runs, and a fresh runtime mounts.
|
|
29
|
-
const [loadedScene, setLoadedScene] = useState<SceneData>(sceneData);
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
const canvas = canvasRef.current;
|
|
33
|
-
if (!canvas) return undefined;
|
|
34
|
-
const ctx = canvas.getContext('2d');
|
|
35
|
-
if (!ctx) return undefined;
|
|
36
|
-
configureSceneCanvas(canvas, ctx);
|
|
37
|
-
|
|
38
|
-
const runtime = makeScene(loadedScene, behaviorClasses, drawings).clone();
|
|
39
|
-
runtimeRef.current = runtime;
|
|
40
|
-
|
|
41
|
-
const onKeyDown = (event: KeyboardEvent): void => {
|
|
42
|
-
runtime.keys.add(event.key);
|
|
43
|
-
};
|
|
44
|
-
const onKeyUp = (event: KeyboardEvent): void => {
|
|
45
|
-
runtime.keys.delete(event.key);
|
|
46
|
-
};
|
|
47
|
-
const onPointerDown = (event: PointerEvent): void => {
|
|
48
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, true);
|
|
49
|
-
canvas.setPointerCapture(event.pointerId);
|
|
50
|
-
};
|
|
51
|
-
const onPointerMove = (event: PointerEvent): void => {
|
|
52
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY);
|
|
53
|
-
};
|
|
54
|
-
const onPointerUp = (event: PointerEvent): void => {
|
|
55
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, false);
|
|
56
|
-
try {
|
|
57
|
-
canvas.releasePointerCapture(event.pointerId);
|
|
58
|
-
} catch {
|
|
59
|
-
// Pointer capture may already be gone after cancel/blur.
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
window.addEventListener('keydown', onKeyDown);
|
|
64
|
-
window.addEventListener('keyup', onKeyUp);
|
|
65
|
-
canvas.addEventListener('pointerdown', onPointerDown);
|
|
66
|
-
canvas.addEventListener('pointermove', onPointerMove);
|
|
67
|
-
canvas.addEventListener('pointerup', onPointerUp);
|
|
68
|
-
canvas.addEventListener('pointercancel', onPointerUp);
|
|
69
|
-
|
|
70
|
-
const stopLoop = startPlayerLoop(canvas, ctx, runtime, (next) => setLoadedScene(next));
|
|
71
|
-
|
|
72
|
-
return () => {
|
|
73
|
-
stopLoop();
|
|
74
|
-
window.removeEventListener('keydown', onKeyDown);
|
|
75
|
-
window.removeEventListener('keyup', onKeyUp);
|
|
76
|
-
canvas.removeEventListener('pointerdown', onPointerDown);
|
|
77
|
-
canvas.removeEventListener('pointermove', onPointerMove);
|
|
78
|
-
canvas.removeEventListener('pointerup', onPointerUp);
|
|
79
|
-
canvas.removeEventListener('pointercancel', onPointerUp);
|
|
80
|
-
runtimeRef.current = null;
|
|
81
|
-
};
|
|
82
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
-
}, [loadedScene]);
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
|
|
87
|
-
<canvas
|
|
88
|
-
ref={canvasRef}
|
|
89
|
-
style={{ width: '100%', height: '100%', display: 'block' }}
|
|
90
|
-
/>
|
|
91
|
-
<SceneUI getRuntime={getRuntime} />
|
|
92
|
-
<TouchControls getKeys={getKeys} />
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function startPlayerLoop(
|
|
98
|
-
canvas: HTMLCanvasElement,
|
|
99
|
-
ctx: CanvasRenderingContext2D,
|
|
100
|
-
runtime: SceneRuntime,
|
|
101
|
-
onSceneSwap: (next: SceneData) => void
|
|
102
|
-
): () => void {
|
|
103
|
-
let raf = 0;
|
|
104
|
-
let previousTime = performance.now();
|
|
105
|
-
let swapped = false;
|
|
106
|
-
const tick = (now: number): void => {
|
|
107
|
-
const dt = Math.min(0.033, (now - previousTime) / 1000);
|
|
108
|
-
previousTime = now;
|
|
109
|
-
runtime.update(dt);
|
|
110
|
-
configureSceneCanvas(canvas, ctx);
|
|
111
|
-
runtime.draw(ctx, { useCamera: true });
|
|
112
|
-
const requested = runtime.requestedScene;
|
|
113
|
-
if (requested && !swapped) {
|
|
114
|
-
const text = initialFiles[requested];
|
|
115
|
-
if (text) {
|
|
116
|
-
const { value } = parseJsonFile<SceneData>(requested, text);
|
|
117
|
-
if (value) {
|
|
118
|
-
swapped = true;
|
|
119
|
-
runtime.requestedScene = undefined;
|
|
120
|
-
onSceneSwap(value);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
runtime.requestedScene = undefined;
|
|
125
|
-
}
|
|
126
|
-
raf = requestAnimationFrame(tick);
|
|
127
|
-
};
|
|
128
|
-
raf = requestAnimationFrame(tick);
|
|
129
|
-
return () => cancelAnimationFrame(raf);
|
|
130
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import styles from './ui.module.css';
|
|
3
|
-
import { cardSize } from './scene';
|
|
4
|
-
import type { SceneRuntime } from './scene';
|
|
5
|
-
|
|
6
|
-
// Game-time UI overlay.
|
|
7
|
-
//
|
|
8
|
-
// Behaviors expose a `ui(actor, scene)` handler -- the React counterpart of
|
|
9
|
-
// `draw`. `SceneUI` collects every behavior's UI output each frame and renders
|
|
10
|
-
// it into a deck-sized layer that sits exactly over the canvas. The layer is
|
|
11
|
-
// `cardSize` units wide/tall and scaled onto the canvas box, so behaviors lay
|
|
12
|
-
// out in the same card units as `Layout`. The root clips with `overflow:
|
|
13
|
-
// hidden`, so deck UI can never render outside the card.
|
|
14
|
-
|
|
15
|
-
export function SceneUI({ getRuntime }: { getRuntime: () => SceneRuntime | null }) {
|
|
16
|
-
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
17
|
-
const [box, setBox] = useState({ width: 0, height: 0 });
|
|
18
|
-
const [, forceRender] = useState(0);
|
|
19
|
-
|
|
20
|
-
// Track the canvas-sized overlay box so the card-unit layer scales onto it.
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const root = rootRef.current;
|
|
23
|
-
if (!root) return undefined;
|
|
24
|
-
const measure = (): void => setBox({ width: root.clientWidth, height: root.clientHeight });
|
|
25
|
-
const observer = new ResizeObserver(measure);
|
|
26
|
-
observer.observe(root);
|
|
27
|
-
measure();
|
|
28
|
-
return () => observer.disconnect();
|
|
29
|
-
}, []);
|
|
30
|
-
|
|
31
|
-
// Re-render every frame so behavior UI reflects live runtime state.
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
let raf = 0;
|
|
34
|
-
const frame = (): void => {
|
|
35
|
-
forceRender((tick) => tick + 1);
|
|
36
|
-
raf = requestAnimationFrame(frame);
|
|
37
|
-
};
|
|
38
|
-
raf = requestAnimationFrame(frame);
|
|
39
|
-
return () => cancelAnimationFrame(raf);
|
|
40
|
-
}, []);
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<div ref={rootRef} className={styles.sceneUiRoot}>
|
|
44
|
-
<div
|
|
45
|
-
className={styles.sceneUiLayer}
|
|
46
|
-
style={{
|
|
47
|
-
width: cardSize.width,
|
|
48
|
-
height: cardSize.height,
|
|
49
|
-
transform: `scale(${box.width / cardSize.width}, ${box.height / cardSize.height})`,
|
|
50
|
-
}}>
|
|
51
|
-
{collectUiNodes(getRuntime())}
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Build one keyed React node per behavior that defines a `ui` handler.
|
|
58
|
-
function collectUiNodes(runtime: SceneRuntime | null): React.ReactNode[] {
|
|
59
|
-
if (!runtime) return [];
|
|
60
|
-
const nodes: React.ReactNode[] = [];
|
|
61
|
-
for (const actor of runtime.getActors()) {
|
|
62
|
-
for (const [behaviorName, props] of Object.entries(actor.components)) {
|
|
63
|
-
if (!props) continue;
|
|
64
|
-
const Behavior = runtime.behaviors.get(behaviorName);
|
|
65
|
-
if (!Behavior) continue;
|
|
66
|
-
const instance = new Behavior(props as never);
|
|
67
|
-
if (!instance.ui) continue;
|
|
68
|
-
const node = instance.ui(actor, runtime);
|
|
69
|
-
if (node == null) continue;
|
|
70
|
-
nodes.push(<React.Fragment key={`${actor.id}:${behaviorName}`}>{node}</React.Fragment>);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return nodes;
|
|
74
|
-
}
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
// On-screen movement controls: a 4-way d-pad feeds arrow keys onto
|
|
4
|
-
// `scene.keys` -- the exact same keyboard keys behaviors consume, no separate
|
|
5
|
-
// input path.
|
|
6
|
-
const KEYS = {
|
|
7
|
-
up: 'ArrowUp',
|
|
8
|
-
down: 'ArrowDown',
|
|
9
|
-
left: 'ArrowLeft',
|
|
10
|
-
right: 'ArrowRight',
|
|
11
|
-
} as const;
|
|
12
|
-
|
|
13
|
-
type ButtonId = keyof typeof KEYS;
|
|
14
|
-
|
|
15
|
-
export function TouchControls({
|
|
16
|
-
getKeys,
|
|
17
|
-
visible = true,
|
|
18
|
-
}: {
|
|
19
|
-
getKeys: () => Set<string> | null | undefined;
|
|
20
|
-
visible?: boolean;
|
|
21
|
-
}) {
|
|
22
|
-
const [isTouch, setIsTouch] = useState(false);
|
|
23
|
-
const heldRef = useRef<Set<ButtonId>>(new Set());
|
|
24
|
-
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
const params = new URLSearchParams(window.location.search);
|
|
27
|
-
const override = params.get('touch');
|
|
28
|
-
if (override === '1' || override === 'true') {
|
|
29
|
-
setIsTouch(true);
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
if (override === '0' || override === 'false') {
|
|
33
|
-
setIsTouch(false);
|
|
34
|
-
return undefined;
|
|
35
|
-
}
|
|
36
|
-
const mq = window.matchMedia('(pointer: coarse)');
|
|
37
|
-
const update = (): void => {
|
|
38
|
-
setIsTouch(mq.matches || 'ontouchstart' in window || navigator.maxTouchPoints > 0);
|
|
39
|
-
};
|
|
40
|
-
update();
|
|
41
|
-
mq.addEventListener?.('change', update);
|
|
42
|
-
return () => mq.removeEventListener?.('change', update);
|
|
43
|
-
}, []);
|
|
44
|
-
|
|
45
|
-
const release = useCallback(
|
|
46
|
-
(id: ButtonId): void => {
|
|
47
|
-
if (!heldRef.current.has(id)) return;
|
|
48
|
-
heldRef.current.delete(id);
|
|
49
|
-
getKeys()?.delete(KEYS[id]);
|
|
50
|
-
},
|
|
51
|
-
[getKeys]
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!visible || !isTouch) {
|
|
56
|
-
// Drop any keys we were holding when controls hide.
|
|
57
|
-
const keys = getKeys();
|
|
58
|
-
if (keys) for (const id of heldRef.current) keys.delete(KEYS[id]);
|
|
59
|
-
heldRef.current.clear();
|
|
60
|
-
}
|
|
61
|
-
}, [visible, isTouch, getKeys]);
|
|
62
|
-
|
|
63
|
-
if (!visible || !isTouch) return null;
|
|
64
|
-
|
|
65
|
-
const press = (id: ButtonId): void => {
|
|
66
|
-
heldRef.current.add(id);
|
|
67
|
-
getKeys()?.add(KEYS[id]);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const button = (id: ButtonId, label: string, extra?: React.CSSProperties) => (
|
|
71
|
-
<button
|
|
72
|
-
key={id}
|
|
73
|
-
type="button"
|
|
74
|
-
tabIndex={-1}
|
|
75
|
-
aria-label={id}
|
|
76
|
-
onTouchStart={(event) => {
|
|
77
|
-
event.preventDefault();
|
|
78
|
-
press(id);
|
|
79
|
-
}}
|
|
80
|
-
onTouchEnd={(event) => {
|
|
81
|
-
event.preventDefault();
|
|
82
|
-
release(id);
|
|
83
|
-
}}
|
|
84
|
-
onTouchCancel={(event) => {
|
|
85
|
-
event.preventDefault();
|
|
86
|
-
release(id);
|
|
87
|
-
}}
|
|
88
|
-
onMouseDown={(event) => {
|
|
89
|
-
event.preventDefault();
|
|
90
|
-
press(id);
|
|
91
|
-
}}
|
|
92
|
-
onMouseUp={() => release(id)}
|
|
93
|
-
onMouseLeave={() => release(id)}
|
|
94
|
-
onContextMenu={(event) => event.preventDefault()}
|
|
95
|
-
style={{ ...buttonStyle, ...extra }}>
|
|
96
|
-
{label}
|
|
97
|
-
</button>
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<div style={containerStyle}>
|
|
102
|
-
<div style={dpadStyle}>
|
|
103
|
-
{button('up', '▲', dpadUpStyle)}
|
|
104
|
-
{button('left', '◀', dpadLeftStyle)}
|
|
105
|
-
{button('right', '▶', dpadRightStyle)}
|
|
106
|
-
{button('down', '▼', dpadDownStyle)}
|
|
107
|
-
</div>
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const containerStyle: React.CSSProperties = {
|
|
113
|
-
position: 'fixed',
|
|
114
|
-
left: 0,
|
|
115
|
-
right: 0,
|
|
116
|
-
bottom: 0,
|
|
117
|
-
display: 'flex',
|
|
118
|
-
justifyContent: 'flex-start',
|
|
119
|
-
alignItems: 'flex-end',
|
|
120
|
-
padding: '12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px)',
|
|
121
|
-
pointerEvents: 'none',
|
|
122
|
-
zIndex: 50,
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
// 3x3 grid -- the d-pad arms occupy the cross cells, corners stay empty.
|
|
126
|
-
const dpadStyle: React.CSSProperties = {
|
|
127
|
-
display: 'grid',
|
|
128
|
-
gridTemplateColumns: 'repeat(3, 64px)',
|
|
129
|
-
gridTemplateRows: 'repeat(3, 64px)',
|
|
130
|
-
gap: 6,
|
|
131
|
-
pointerEvents: 'auto',
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const dpadUpStyle: React.CSSProperties = { gridColumn: 2, gridRow: 1 };
|
|
135
|
-
const dpadLeftStyle: React.CSSProperties = { gridColumn: 1, gridRow: 2 };
|
|
136
|
-
const dpadRightStyle: React.CSSProperties = { gridColumn: 3, gridRow: 2 };
|
|
137
|
-
const dpadDownStyle: React.CSSProperties = { gridColumn: 2, gridRow: 3 };
|
|
138
|
-
|
|
139
|
-
const buttonStyle: React.CSSProperties = {
|
|
140
|
-
width: 64,
|
|
141
|
-
height: 64,
|
|
142
|
-
borderRadius: 12,
|
|
143
|
-
border: '1px solid rgba(255, 255, 255, 0.35)',
|
|
144
|
-
background: 'rgba(0, 0, 0, 0.35)',
|
|
145
|
-
color: 'rgba(255, 255, 255, 0.92)',
|
|
146
|
-
fontSize: 24,
|
|
147
|
-
fontWeight: 600,
|
|
148
|
-
display: 'flex',
|
|
149
|
-
alignItems: 'center',
|
|
150
|
-
justifyContent: 'center',
|
|
151
|
-
userSelect: 'none',
|
|
152
|
-
WebkitUserSelect: 'none',
|
|
153
|
-
WebkitTouchCallout: 'none',
|
|
154
|
-
WebkitTapHighlightColor: 'transparent',
|
|
155
|
-
touchAction: 'none',
|
|
156
|
-
cursor: 'pointer',
|
|
157
|
-
};
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
|
|
3
|
-
|
|
4
|
-
// Auto-generated inspector fields.
|
|
5
|
-
//
|
|
6
|
-
// `AutoFields` derives a control per prop from a behavior's `defaultProps`:
|
|
7
|
-
// the field SET and the inferred type come from `defaultProps`, the current
|
|
8
|
-
// value comes from the actor's component (falling back to the default).
|
|
9
|
-
//
|
|
10
|
-
// A behavior that needs custom UI for a prop auto-gen cannot express
|
|
11
|
-
// (enum/select, file refs) defines a `static Inspector` and renders the
|
|
12
|
-
// standard props with `<AutoFields only={...} />` / `exclude={...}` while
|
|
13
|
-
// hand-writing only the special ones.
|
|
14
|
-
|
|
15
|
-
type FieldKey<T> = Extract<keyof T, string>;
|
|
16
|
-
|
|
17
|
-
export function AutoFields<T extends object>({
|
|
18
|
-
defaultProps,
|
|
19
|
-
component,
|
|
20
|
-
setComponent,
|
|
21
|
-
only,
|
|
22
|
-
exclude,
|
|
23
|
-
}: {
|
|
24
|
-
defaultProps: T;
|
|
25
|
-
component: Partial<T>;
|
|
26
|
-
setComponent: (next: Partial<T>) => void;
|
|
27
|
-
only?: FieldKey<T>[];
|
|
28
|
-
exclude?: FieldKey<T>[];
|
|
29
|
-
}) {
|
|
30
|
-
const keys = (Object.keys(defaultProps) as FieldKey<T>[]).filter((key) => {
|
|
31
|
-
if (only) return only.includes(key);
|
|
32
|
-
if (exclude) return !exclude.includes(key);
|
|
33
|
-
return true;
|
|
34
|
-
});
|
|
35
|
-
return (
|
|
36
|
-
<>
|
|
37
|
-
{keys.map((key) => {
|
|
38
|
-
const fallback = defaultProps[key];
|
|
39
|
-
const current = key in component ? (component as T)[key] : fallback;
|
|
40
|
-
const set = (value: unknown) => setComponent({ [key]: value } as Partial<T>);
|
|
41
|
-
const label = humanizeKey(key);
|
|
42
|
-
const sample = fallback ?? current;
|
|
43
|
-
|
|
44
|
-
if (typeof sample === 'number') {
|
|
45
|
-
return (
|
|
46
|
-
<NumberField
|
|
47
|
-
key={key}
|
|
48
|
-
label={label}
|
|
49
|
-
value={current as number | null | undefined}
|
|
50
|
-
onChange={set}
|
|
51
|
-
/>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
if (typeof sample === 'boolean') {
|
|
55
|
-
return (
|
|
56
|
-
<CheckboxField
|
|
57
|
-
key={key}
|
|
58
|
-
label={label}
|
|
59
|
-
checked={current as boolean | null | undefined}
|
|
60
|
-
onChange={set}
|
|
61
|
-
/>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
if (isHexColor(sample)) {
|
|
65
|
-
return (
|
|
66
|
-
<ColorField
|
|
67
|
-
key={key}
|
|
68
|
-
label={label}
|
|
69
|
-
value={current as string | null | undefined}
|
|
70
|
-
onChange={set}
|
|
71
|
-
/>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
return (
|
|
75
|
-
<TextField
|
|
76
|
-
key={key}
|
|
77
|
-
label={label}
|
|
78
|
-
value={current == null ? '' : String(current)}
|
|
79
|
-
onChange={set}
|
|
80
|
-
/>
|
|
81
|
-
);
|
|
82
|
-
})}
|
|
83
|
-
</>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function AutoInspector<T extends object>({
|
|
88
|
-
behaviorName,
|
|
89
|
-
defaultProps,
|
|
90
|
-
component,
|
|
91
|
-
setComponent,
|
|
92
|
-
}: {
|
|
93
|
-
behaviorName: string;
|
|
94
|
-
defaultProps: T;
|
|
95
|
-
component: Partial<T>;
|
|
96
|
-
setComponent: (next: Partial<T>) => void;
|
|
97
|
-
}) {
|
|
98
|
-
return (
|
|
99
|
-
<Panel title={humanizeKey(behaviorName)}>
|
|
100
|
-
<AutoFields defaultProps={defaultProps} component={component} setComponent={setComponent} />
|
|
101
|
-
</Panel>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// `deadZoneY` -> `Dead Zone Y`, `PlatformerCharacter` -> `Platformer Character`.
|
|
106
|
-
export function humanizeKey(key: string): string {
|
|
107
|
-
return key
|
|
108
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
109
|
-
.replace(/^./, (c) => c.toUpperCase())
|
|
110
|
-
.trim();
|
|
111
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { TRANSPARENT, blankPixelGrid, frameCount } from './files';
|
|
2
|
-
import type { DrawingData } from './files';
|
|
3
|
-
|
|
4
|
-
// Render-time helpers for layered+animated drawings. Pulled from the bloom-run
|
|
5
|
-
// engine; only the bits the runtime Drawing behavior + DrawingEditor need are
|
|
6
|
-
// kept. Pixel grids are row-major `string[]` of hex-RGBA strings.
|
|
7
|
-
|
|
8
|
-
// Parse a hex color into [r, g, b, a] (0-255), or null when not a hex color.
|
|
9
|
-
export function parseHexColor(color: string): [number, number, number, number] | null {
|
|
10
|
-
const hex = color.trim().replace(/^#/, '');
|
|
11
|
-
if (hex.length !== 6 && hex.length !== 8) return null;
|
|
12
|
-
if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
|
|
13
|
-
const r = parseInt(hex.slice(0, 2), 16);
|
|
14
|
-
const g = parseInt(hex.slice(2, 4), 16);
|
|
15
|
-
const b = parseInt(hex.slice(4, 6), 16);
|
|
16
|
-
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255;
|
|
17
|
-
return [r, g, b, a];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function toHexColor(rgba: [number, number, number, number]): string {
|
|
21
|
-
return '#' + rgba.map((c) => clamp255(c).toString(16).padStart(2, '0')).join('');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function clamp255(value: number): number {
|
|
25
|
-
return Math.max(0, Math.min(255, Math.round(value)));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function isTransparent(color: string | undefined): boolean {
|
|
29
|
-
if (!color) return true;
|
|
30
|
-
const rgba = parseHexColor(color);
|
|
31
|
-
return !rgba || rgba[3] === 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Source-over alpha composite of `src` onto `dst`, both hex-RGBA.
|
|
35
|
-
export function blendColors(dst: string, src: string): string {
|
|
36
|
-
const d = parseHexColor(dst);
|
|
37
|
-
const s = parseHexColor(src);
|
|
38
|
-
if (!s) return dst;
|
|
39
|
-
if (!d || s[3] === 255) return src;
|
|
40
|
-
const sa = s[3] / 255;
|
|
41
|
-
const da = d[3] / 255;
|
|
42
|
-
const outA = sa + da * (1 - sa);
|
|
43
|
-
if (outA <= 0) return TRANSPARENT;
|
|
44
|
-
const mix = (sc: number, dc: number): number => (sc * sa + dc * da * (1 - sa)) / outA;
|
|
45
|
-
return toHexColor([mix(s[0], d[0]), mix(s[1], d[1]), mix(s[2], d[2]), outA * 255]);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Flatten the visible layers of a frame into one pixel grid (bottom-to-top).
|
|
49
|
-
export function compositeFrame(drawing: DrawingData, frameIndex: number): string[] {
|
|
50
|
-
const { width, height } = drawing;
|
|
51
|
-
const out = blankPixelGrid(width, height);
|
|
52
|
-
for (const layer of drawing.layers) {
|
|
53
|
-
if (!layer.visible) continue;
|
|
54
|
-
const frame = layer.frames[frameIndex];
|
|
55
|
-
if (!frame) continue;
|
|
56
|
-
for (let i = 0; i < out.length; i++) {
|
|
57
|
-
const src = frame[i];
|
|
58
|
-
if (isTransparent(src)) continue;
|
|
59
|
-
out[i] = blendColors(out[i], src);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Resolve which 0-based frame to show at scene time `time` (seconds), given
|
|
66
|
-
// the drawing's fps + play mode. `still` always shows initialFrame.
|
|
67
|
-
export function frameIndexAt(drawing: DrawingData, time: number): number {
|
|
68
|
-
const count = frameCount(drawing);
|
|
69
|
-
if (count <= 1) return 0;
|
|
70
|
-
const initial = clampInt(drawing.initialFrame - 1, 0, count - 1);
|
|
71
|
-
if (drawing.playMode === 'still' || drawing.fps === 0) return initial;
|
|
72
|
-
const advanced = Math.floor(Math.abs(time) * Math.abs(drawing.fps));
|
|
73
|
-
if (drawing.playMode === 'once') {
|
|
74
|
-
return clampInt(initial + advanced, 0, count - 1);
|
|
75
|
-
}
|
|
76
|
-
return (initial + (advanced % count)) % count;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function clampInt(value: number, min: number, max: number): number {
|
|
80
|
-
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
81
|
-
}
|