@pix3/cli 1.6.0
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/LICENSE +201 -0
- package/README.md +754 -0
- package/dist/index.js +494 -0
- package/dist/runtime-types.json +1 -0
- package/dist/smoke/prebuilt/build-7MIXIMSZ.mjs +948 -0
- package/dist/smoke/prebuilt/chunk-J2SJVMDO.mjs +2 -0
- package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +239 -0
- package/dist/smoke/prebuilt/chunk-P2CBRXPJ.mjs +4050 -0
- package/dist/smoke/prebuilt/runtime.mjs +2 -0
- package/dist/smoke/prebuilt/smoke-worker.mjs +3 -0
- package/dist/smoke/prebuilt/three.mjs +2 -0
- package/dist/smoke/prebuilt/tree-defaults.mjs +2 -0
- package/dist/validate/prebuilt/build-AUGKXER3.js +948 -0
- package/dist/validate/prebuilt/bundle-main.js +24 -0
- package/dist/validate/prebuilt/chunk-6ACT6FNV.js +4050 -0
- package/dist/validate/prebuilt/chunk-BS2OZM7U.js +239 -0
- package/dist/validate/prebuilt/runtime.js +2 -0
- package/dist/validate/prebuilt/three.js +2 -0
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -0
- package/kit/files/.claude/skills/pix3-nodes/reference.md +1331 -0
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +235 -0
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +176 -0
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +281 -0
- package/kit/files/.claude/skills/pix3-scripts/reference.md +638 -0
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +386 -0
- package/kit/files/.claude/skills/pix3-verify/reference.md +21 -0
- package/kit/files/AGENTS.md +203 -0
- package/kit/files/CLAUDE.md +1 -0
- package/kit/kit.json +24 -0
- package/package.json +54 -0
- package/templates/empty-2d/files/README.md +20 -0
- package/templates/empty-2d/files/scenes/main.pix3scene +68 -0
- package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
- package/templates/empty-2d/template.yaml +7 -0
- package/templates/empty-3d/files/README.md +20 -0
- package/templates/empty-3d/files/scenes/main.pix3scene +108 -0
- package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
- package/templates/empty-3d/template.yaml +7 -0
- package/templates/idea-blank/files/README.md +22 -0
- package/templates/idea-blank/files/scenes/main.pix3scene +37 -0
- package/templates/idea-blank/template.yaml +10 -0
- package/templates/minigame-2d/files/README.md +44 -0
- package/templates/minigame-2d/files/design/tests/reachability.json +1 -0
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -0
- package/templates/minigame-2d/files/scenes/main.pix3scene +84 -0
- package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -0
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -0
- package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -0
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -0
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -0
- package/templates/minigame-2d/template.yaml +10 -0
- package/templates/playable-2d/files/README.md +30 -0
- package/templates/playable-2d/files/design/recipe.md +83 -0
- package/templates/playable-2d/files/design/tests/reachability.json +4 -0
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -0
- package/templates/playable-2d/files/scenes/main.pix3scene +111 -0
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -0
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -0
- package/templates/playable-2d/files/scripts/CtaButton.ts +55 -0
- package/templates/playable-2d/files/scripts/GameFlow.ts +256 -0
- package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
- package/templates/playable-2d/template.yaml +9 -0
- package/templates/playable-3d/files/README.md +32 -0
- package/templates/playable-3d/files/design/recipe.md +127 -0
- package/templates/playable-3d/files/design/tests/reachability.json +4 -0
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -0
- package/templates/playable-3d/files/scenes/main.pix3scene +230 -0
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -0
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -0
- package/templates/playable-3d/files/scripts/CtaButton.ts +55 -0
- package/templates/playable-3d/files/scripts/GameFlow.ts +309 -0
- package/templates/playable-3d/files/scripts/GameRules.ts +290 -0
- package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -0
- package/templates/playable-3d/template.yaml +9 -0
- package/templates/recipe-arena-2d/files/README.md +48 -0
- package/templates/recipe-arena-2d/files/design/recipe.md +127 -0
- package/templates/recipe-arena-2d/files/design/tests/reachability.json +4 -0
- package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +23 -0
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -0
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -0
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -0
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -0
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -0
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -0
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -0
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -0
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -0
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -0
- package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
- package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
- package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
- package/templates/recipe-arena-2d/template.yaml +10 -0
- package/templates/recipe-blank-2d/files/README.md +61 -0
- package/templates/recipe-blank-2d/files/design/recipe.md +148 -0
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -0
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -0
- package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +23 -0
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -0
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -0
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -0
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -0
- package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
- package/templates/recipe-blank-2d/template.yaml +10 -0
- package/templates/recipe-bouncer-2d/files/README.md +81 -0
- package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -0
- package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +4 -0
- package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +23 -0
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -0
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -0
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -0
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -0
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -0
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -0
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -0
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -0
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -0
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
- package/templates/recipe-bouncer-2d/template.yaml +10 -0
- package/templates/recipe-grid-3d/files/README.md +22 -0
- package/templates/recipe-grid-3d/files/design/recipe.md +110 -0
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -0
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -0
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -0
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -0
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -0
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -0
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -0
- package/templates/recipe-grid-3d/template.yaml +10 -0
- package/templates/recipe-tapper-2d/files/README.md +39 -0
- package/templates/recipe-tapper-2d/files/design/recipe.md +132 -0
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -0
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -0
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -0
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -0
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -0
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -0
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -0
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -0
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -0
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -0
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -0
- package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
- package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
- package/templates/recipe-tapper-2d/template.yaml +10 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GridBoard — the block you carve.
|
|
3
|
+
*
|
|
4
|
+
* Builds a solid `sizeX × sizeY × sizeZ` grid of cubes under `boardAnchor` at start, then turns a
|
|
5
|
+
* tap into the removal of one cube. Some cubes are secretly CORE: hitting one costs a life instead
|
|
6
|
+
* of scoring. Clear every non-core cube to finish the board.
|
|
7
|
+
*
|
|
8
|
+
* It emits the semantic signals the rules script listens for, on `game-root`:
|
|
9
|
+
* `cell-cleared` (remaining)
|
|
10
|
+
* `core-hit` (remaining)
|
|
11
|
+
* `board-cleared` ()
|
|
12
|
+
*
|
|
13
|
+
* **Picking is a 3D raycast, not a 2D hit test.** 2D nodes are hit-tested against `Hitbox2D`
|
|
14
|
+
* groups; a mesh in a perspective frustum has no such thing, so the contact comes from
|
|
15
|
+
* `scene.raycastViewport(nx, ny)` with the pointer converted to normalized device coordinates. That
|
|
16
|
+
* conversion is the part everyone gets wrong — Y is flipped, and the divisor is the INPUT surface
|
|
17
|
+
* (`input.width/height`), not the window.
|
|
18
|
+
*
|
|
19
|
+
* **Mobile budget.** Every cube is a `GeometryMesh` with `material.type: 'lambert'`: this recipe
|
|
20
|
+
* ships hundreds of them, and PBR on a phone at this count is the difference between a smooth board
|
|
21
|
+
* and a slideshow. A 4×4×4 board is 64 draw calls — if you grow it past ~8³, move to an
|
|
22
|
+
* `InstancedMesh3D` before you move to prettier materials.
|
|
23
|
+
*/
|
|
24
|
+
import { GeometryMesh, Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
|
|
25
|
+
|
|
26
|
+
/** Cube metadata, kept in a map rather than on the node so nothing depends on node naming. */
|
|
27
|
+
interface Cell {
|
|
28
|
+
node: GeometryMesh;
|
|
29
|
+
isCore: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class GridBoard extends Script {
|
|
33
|
+
private cells = new Map<string, Cell>();
|
|
34
|
+
private remaining = 0;
|
|
35
|
+
private cleared = false;
|
|
36
|
+
|
|
37
|
+
constructor(id: string, type: string) {
|
|
38
|
+
super(id, type);
|
|
39
|
+
this.config = {
|
|
40
|
+
// Node the cubes are parented to (kept separate from game-root so clearing is one subtree).
|
|
41
|
+
boardAnchor: 'board-anchor',
|
|
42
|
+
sizeX: 4,
|
|
43
|
+
sizeY: 4,
|
|
44
|
+
sizeZ: 4,
|
|
45
|
+
cellSize: 0.9,
|
|
46
|
+
// Centre-to-centre spacing; > cellSize leaves visible seams between cubes.
|
|
47
|
+
spacing: 1,
|
|
48
|
+
// How many cubes are core (a life each). Never more than a third of the board.
|
|
49
|
+
coreCount: 6,
|
|
50
|
+
cellColor: '#3ee6c1',
|
|
51
|
+
coreColor: '#ff6b6b',
|
|
52
|
+
// Reveal core cubes in their own colour instead of hiding them (debug / tutorial).
|
|
53
|
+
revealCores: false,
|
|
54
|
+
clearSound: '',
|
|
55
|
+
coreSound: '',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static getPropertySchema(): PropertySchema {
|
|
60
|
+
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
61
|
+
name,
|
|
62
|
+
type: 'number' as const,
|
|
63
|
+
ui: { label, group: 'Board', min, max, step, slider: true },
|
|
64
|
+
getValue: (s: unknown) => (s as GridBoard).config[name],
|
|
65
|
+
setValue: (s: unknown, v: unknown) => {
|
|
66
|
+
const n = Number(v);
|
|
67
|
+
(s as GridBoard).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const str = (name: string, label: string, group: string, editor?: 'audio-resource') => ({
|
|
71
|
+
name,
|
|
72
|
+
type: 'string' as const,
|
|
73
|
+
ui: { label, group, ...(editor ? { editor } : {}) },
|
|
74
|
+
getValue: (s: unknown) => (s as GridBoard).config[name],
|
|
75
|
+
setValue: (s: unknown, v: unknown) => {
|
|
76
|
+
(s as GridBoard).config[name] = typeof v === 'string' ? v : '';
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const color = (name: string, label: string) => ({
|
|
80
|
+
name,
|
|
81
|
+
type: 'color' as const,
|
|
82
|
+
ui: { label, group: 'Look' },
|
|
83
|
+
getValue: (s: unknown) => (s as GridBoard).config[name],
|
|
84
|
+
setValue: (s: unknown, v: unknown) => {
|
|
85
|
+
(s as GridBoard).config[name] = typeof v === 'string' ? v : '#ffffff';
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
nodeType: 'GridBoard',
|
|
91
|
+
properties: [
|
|
92
|
+
str('boardAnchor', 'Board Anchor', 'Board'),
|
|
93
|
+
num('sizeX', 'Size X', 1, 10, 1),
|
|
94
|
+
num('sizeY', 'Size Y', 1, 10, 1),
|
|
95
|
+
num('sizeZ', 'Size Z', 1, 10, 1),
|
|
96
|
+
num('cellSize', 'Cell Size', 0.1, 3, 0.05),
|
|
97
|
+
num('spacing', 'Spacing', 0.1, 4, 0.05),
|
|
98
|
+
num('coreCount', 'Core Cubes', 0, 40, 1),
|
|
99
|
+
color('cellColor', 'Cell Colour'),
|
|
100
|
+
color('coreColor', 'Core Colour'),
|
|
101
|
+
{
|
|
102
|
+
name: 'revealCores',
|
|
103
|
+
type: 'boolean' as const,
|
|
104
|
+
ui: { label: 'Reveal Cores', group: 'Look' },
|
|
105
|
+
getValue: (s: unknown) => (s as GridBoard).config.revealCores,
|
|
106
|
+
setValue: (s: unknown, v: unknown) => {
|
|
107
|
+
(s as GridBoard).config.revealCores = Boolean(v);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
str('clearSound', 'Clear Sound', 'Feedback', 'audio-resource'),
|
|
111
|
+
str('coreSound', 'Core Sound', 'Feedback', 'audio-resource'),
|
|
112
|
+
],
|
|
113
|
+
groups: {
|
|
114
|
+
Board: { label: 'Board', expanded: true },
|
|
115
|
+
Look: { label: 'Look', expanded: true },
|
|
116
|
+
Feedback: { label: 'Feedback', expanded: false },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onStart(): void {
|
|
122
|
+
this.build();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One removal per finger that landed this frame.
|
|
127
|
+
*
|
|
128
|
+
* Taps over the HUD are ignored the same way every recipe does it, so a thumb parked on a button
|
|
129
|
+
* cannot carve the board underneath it.
|
|
130
|
+
*/
|
|
131
|
+
onUpdate(): void {
|
|
132
|
+
const input = this.input;
|
|
133
|
+
if (this.cleared || !input || !this.scene) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const event of input.pointerEvents) {
|
|
137
|
+
if (event.type !== 'down') {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const overUI =
|
|
141
|
+
input.pointerDownCount > 0 ? input.isPointerOverUI(event.pointerId) : input.isHoveringUI;
|
|
142
|
+
if (overUI) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
this.resolveTap(event.x, event.y);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Live board state, for the rules script and for tests. */
|
|
150
|
+
getRemaining(): number {
|
|
151
|
+
return this.remaining;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private build(): void {
|
|
155
|
+
const anchor = this.findAnchor();
|
|
156
|
+
if (!anchor) {
|
|
157
|
+
console.warn('[GridBoard] No board anchor node — nothing was built.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const sizeX = this.intConfig('sizeX', 1, 10);
|
|
161
|
+
const sizeY = this.intConfig('sizeY', 1, 10);
|
|
162
|
+
const sizeZ = this.intConfig('sizeZ', 1, 10);
|
|
163
|
+
const cellSize = Math.max(0.1, Number(this.config.cellSize) || 0.9);
|
|
164
|
+
const spacing = Math.max(cellSize, Number(this.config.spacing) || 1);
|
|
165
|
+
const total = sizeX * sizeY * sizeZ;
|
|
166
|
+
// A board that is mostly core is not a puzzle, it is a minefield.
|
|
167
|
+
const coreCount = Math.min(this.intConfig('coreCount', 0, 40), Math.floor(total / 3));
|
|
168
|
+
const coreKeys = this.pickCoreKeys(sizeX, sizeY, sizeZ, coreCount);
|
|
169
|
+
const reveal = Boolean(this.config.revealCores);
|
|
170
|
+
const cellColor = String(this.config.cellColor ?? '#3ee6c1');
|
|
171
|
+
const coreColor = String(this.config.coreColor ?? '#ff6b6b');
|
|
172
|
+
|
|
173
|
+
// Centre the board on the anchor so the camera framing survives a size change.
|
|
174
|
+
const offset = (n: number) => ((n - 1) * spacing) / 2;
|
|
175
|
+
|
|
176
|
+
for (let x = 0; x < sizeX; x++) {
|
|
177
|
+
for (let y = 0; y < sizeY; y++) {
|
|
178
|
+
for (let z = 0; z < sizeZ; z++) {
|
|
179
|
+
const key = `${x},${y},${z}`;
|
|
180
|
+
const isCore = coreKeys.has(key);
|
|
181
|
+
const cube = new GeometryMesh({
|
|
182
|
+
id: `cell-${key}`,
|
|
183
|
+
name: `Cell ${key}`,
|
|
184
|
+
geometry: 'box',
|
|
185
|
+
size: [cellSize, cellSize, cellSize],
|
|
186
|
+
material: {
|
|
187
|
+
type: 'lambert',
|
|
188
|
+
color: isCore && reveal ? coreColor : cellColor,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
cube.position.set(
|
|
192
|
+
x * spacing - offset(sizeX),
|
|
193
|
+
y * spacing - offset(sizeY),
|
|
194
|
+
z * spacing - offset(sizeZ)
|
|
195
|
+
);
|
|
196
|
+
anchor.add(cube);
|
|
197
|
+
this.cells.set(key, { node: cube, isCore });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.remaining = total - coreCount;
|
|
202
|
+
this.node?.emit('board-built', this.remaining, coreCount);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve one contact against the board.
|
|
207
|
+
*
|
|
208
|
+
* The hit object is a three.js `Mesh` inside the cube node, so walk up to the `GeometryMesh` the
|
|
209
|
+
* cell map is keyed by — the raycaster reports what it struck, not what owns it.
|
|
210
|
+
*/
|
|
211
|
+
private resolveTap(screenX: number, screenY: number): void {
|
|
212
|
+
const scene = this.scene;
|
|
213
|
+
const input = this.input;
|
|
214
|
+
if (!scene || !input) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const width = input.width;
|
|
218
|
+
const height = input.height;
|
|
219
|
+
if (!(width > 0) || !(height > 0)) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const hit = scene.raycastViewport((screenX / width) * 2 - 1, 1 - (screenY / height) * 2);
|
|
223
|
+
if (!hit) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const entry = this.findCell(hit.node);
|
|
227
|
+
if (!entry) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.removeCell(entry[0], entry[1]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private removeCell(key: string, cell: Cell): void {
|
|
234
|
+
this.cells.delete(key);
|
|
235
|
+
const owner = this.node;
|
|
236
|
+
if (cell.isCore) {
|
|
237
|
+
this.scene?.juice.flash({ color: '#ff4d6d', intensity: 0.45, durationSec: 0.22 });
|
|
238
|
+
this.playSound(String(this.config.coreSound ?? ''));
|
|
239
|
+
cell.node.queueFree();
|
|
240
|
+
owner?.emit('core-hit', this.remaining);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.remaining = Math.max(0, this.remaining - 1);
|
|
244
|
+
this.scene?.juice.punchScale(cell.node, { amount: 0.35, duration: 0.16 });
|
|
245
|
+
this.playSound(String(this.config.clearSound ?? ''));
|
|
246
|
+
cell.node.queueFree();
|
|
247
|
+
owner?.emit('cell-cleared', this.remaining);
|
|
248
|
+
if (this.remaining === 0 && !this.cleared) {
|
|
249
|
+
this.cleared = true;
|
|
250
|
+
owner?.emit('board-cleared');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Walk from the raycast hit up to the cube node this board owns. */
|
|
255
|
+
private findCell(hitNode: NodeBase): [string, Cell] | null {
|
|
256
|
+
let current: NodeBase | null = hitNode;
|
|
257
|
+
while (current) {
|
|
258
|
+
for (const [key, cell] of this.cells) {
|
|
259
|
+
if (cell.node === current) {
|
|
260
|
+
return [key, cell];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
current = (current.parent as NodeBase | null) ?? null;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private findAnchor(): NodeBase | null {
|
|
269
|
+
const name = String(this.config.boardAnchor ?? '').trim();
|
|
270
|
+
if (!name) {
|
|
271
|
+
return this.node ?? null;
|
|
272
|
+
}
|
|
273
|
+
// `findNode` is the Script helper (scene-global by name/id), the same one Spawner uses.
|
|
274
|
+
return this.findNode(name) ?? this.node ?? null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Choose which cubes are core.
|
|
279
|
+
*
|
|
280
|
+
* Cores are drawn from the INTERIOR when the board has one: a core on the surface can be tapped
|
|
281
|
+
* before the player has learned anything, which reads as an unfair loss rather than a puzzle.
|
|
282
|
+
* Small boards have no interior, so they fall back to the whole set.
|
|
283
|
+
*/
|
|
284
|
+
private pickCoreKeys(sizeX: number, sizeY: number, sizeZ: number, count: number): Set<string> {
|
|
285
|
+
const interior: string[] = [];
|
|
286
|
+
const all: string[] = [];
|
|
287
|
+
for (let x = 0; x < sizeX; x++) {
|
|
288
|
+
for (let y = 0; y < sizeY; y++) {
|
|
289
|
+
for (let z = 0; z < sizeZ; z++) {
|
|
290
|
+
const key = `${x},${y},${z}`;
|
|
291
|
+
all.push(key);
|
|
292
|
+
const inner =
|
|
293
|
+
x > 0 && x < sizeX - 1 && y > 0 && y < sizeY - 1 && z > 0 && z < sizeZ - 1;
|
|
294
|
+
if (inner) {
|
|
295
|
+
interior.push(key);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const pool = interior.length >= count ? interior : all;
|
|
301
|
+
const chosen = new Set<string>();
|
|
302
|
+
while (chosen.size < count && chosen.size < pool.length) {
|
|
303
|
+
chosen.add(pool[Math.floor(Math.random() * pool.length)]);
|
|
304
|
+
}
|
|
305
|
+
return chosen;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private intConfig(name: string, min: number, max: number): number {
|
|
309
|
+
const value = Math.round(Number(this.config[name]));
|
|
310
|
+
return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private playSound(path: string): void {
|
|
314
|
+
if (!path) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
void this.scene?.audio.play(path, { bus: 'sfx', pitchVariation: 0.08 });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GridRules — score, lives and the end of the run for a carving board.
|
|
3
|
+
*
|
|
4
|
+
* Same shape as the other recipes' `GameRules`, with the win condition the board dictates: you win
|
|
5
|
+
* by clearing every non-core cube (`board-cleared`), you lose by running out of lives. The optional
|
|
6
|
+
* clock is a deadline, not a mode of its own.
|
|
7
|
+
*
|
|
8
|
+
* Listens on its own node (`game-root`) for what `GridBoard` emits:
|
|
9
|
+
* `cell-cleared` (remaining) → +1 score
|
|
10
|
+
* `core-hit` (remaining) → −1 life
|
|
11
|
+
* `board-cleared` () → win
|
|
12
|
+
* `board-built` (clearable, cores) → the target for the HUD
|
|
13
|
+
*
|
|
14
|
+
* The end-screen buttons dispatch the commands `restart` and `return-to-menu` (`scene.commands`)
|
|
15
|
+
* rather than calling methods, so a test can replay the end flow without hunting for buttons.
|
|
16
|
+
*
|
|
17
|
+
* It publishes the run through `registerGameDebug`: `remaining` is the number that matters here —
|
|
18
|
+
* an agent proving "the tap removed a cube" reads it instead of comparing screenshots.
|
|
19
|
+
*/
|
|
20
|
+
import { Button2D, Label2D, Script, registerGameDebug, type PropertySchema } from '@pix3/runtime';
|
|
21
|
+
|
|
22
|
+
function roundHundredths(value: number): number {
|
|
23
|
+
return Math.round(value * 100) / 100;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class GridRules extends Script {
|
|
27
|
+
private score = 0;
|
|
28
|
+
private lives = 3;
|
|
29
|
+
private remaining = 0;
|
|
30
|
+
private clearable = 0;
|
|
31
|
+
private elapsed = 0;
|
|
32
|
+
private over = false;
|
|
33
|
+
private won = false;
|
|
34
|
+
private disposeDebug: (() => void) | null = null;
|
|
35
|
+
private disposeCommands: (() => void)[] = [];
|
|
36
|
+
|
|
37
|
+
constructor(id: string, type: string) {
|
|
38
|
+
super(id, type);
|
|
39
|
+
this.config = {
|
|
40
|
+
startingLives: 3,
|
|
41
|
+
// 0 = no clock. With a limit, running it out loses.
|
|
42
|
+
timeLimitSec: 0,
|
|
43
|
+
resultNode: 'result-overlay',
|
|
44
|
+
resultLabel: 'result-label',
|
|
45
|
+
retryButton: 'retry-button',
|
|
46
|
+
menuButton: 'menu-button',
|
|
47
|
+
winText: 'SHAPE REVEALED',
|
|
48
|
+
loseText: 'CORE DESTROYED',
|
|
49
|
+
gameScene: 'res://scenes/main.pix3scene',
|
|
50
|
+
menuScene: 'res://scenes/menu.pix3scene',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static getPropertySchema(): PropertySchema {
|
|
55
|
+
const num = (name: string, label: string, min: number, max: number) => ({
|
|
56
|
+
name,
|
|
57
|
+
type: 'number' as const,
|
|
58
|
+
ui: { label, group: 'Rules', min, max, step: 1, slider: true },
|
|
59
|
+
getValue: (s: unknown) => (s as GridRules).config[name],
|
|
60
|
+
setValue: (s: unknown, v: unknown) => {
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
(s as GridRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const str = (name: string, label: string, group: string) => ({
|
|
66
|
+
name,
|
|
67
|
+
type: 'string' as const,
|
|
68
|
+
ui: { label, group },
|
|
69
|
+
getValue: (s: unknown) => (s as GridRules).config[name],
|
|
70
|
+
setValue: (s: unknown, v: unknown) => {
|
|
71
|
+
(s as GridRules).config[name] = typeof v === 'string' ? v : '';
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
nodeType: 'GridRules',
|
|
77
|
+
properties: [
|
|
78
|
+
num('startingLives', 'Starting Lives', 1, 9),
|
|
79
|
+
num('timeLimitSec', 'Time Limit (s)', 0, 600),
|
|
80
|
+
str('resultNode', 'Result Overlay', 'End'),
|
|
81
|
+
str('resultLabel', 'Result Label', 'End'),
|
|
82
|
+
str('retryButton', 'Retry Button', 'End'),
|
|
83
|
+
str('menuButton', 'Menu Button', 'End'),
|
|
84
|
+
str('winText', 'Win Text', 'End'),
|
|
85
|
+
str('loseText', 'Lose Text', 'End'),
|
|
86
|
+
str('gameScene', 'Game Scene', 'Scenes'),
|
|
87
|
+
str('menuScene', 'Menu Scene', 'Scenes'),
|
|
88
|
+
],
|
|
89
|
+
groups: {
|
|
90
|
+
Rules: { label: 'Rules', expanded: true },
|
|
91
|
+
End: { label: 'End Screen', expanded: false },
|
|
92
|
+
Scenes: { label: 'Scenes', expanded: false },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onStart(): void {
|
|
98
|
+
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
99
|
+
const commands = this.scene?.commands;
|
|
100
|
+
this.disposeCommands = [
|
|
101
|
+
commands?.register('restart', () => this.restart(), {
|
|
102
|
+
description: 'Reload the game scene for a fresh board.',
|
|
103
|
+
}),
|
|
104
|
+
commands?.register('return-to-menu', () => this.toMenu(), {
|
|
105
|
+
description: 'Leave the run and go back to the menu scene.',
|
|
106
|
+
}),
|
|
107
|
+
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
108
|
+
|
|
109
|
+
this.disposeDebug = registerGameDebug({
|
|
110
|
+
name: 'recipe-grid-3d',
|
|
111
|
+
version: 1,
|
|
112
|
+
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
113
|
+
snapshot: () => ({
|
|
114
|
+
scene: 'game',
|
|
115
|
+
phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
|
|
116
|
+
score: this.score,
|
|
117
|
+
lives: this.lives,
|
|
118
|
+
// The board's own number: cubes still to clear. This is what proves a tap landed.
|
|
119
|
+
remaining: this.remaining,
|
|
120
|
+
clearable: this.clearable,
|
|
121
|
+
elapsedSec: roundHundredths(this.elapsed),
|
|
122
|
+
timeLeftSec: this.timeLeftSec(),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const owner = this.node;
|
|
127
|
+
owner?.connect('board-built', this, (...args: unknown[]) => {
|
|
128
|
+
this.clearable = Number(args[0]) || 0;
|
|
129
|
+
this.remaining = this.clearable;
|
|
130
|
+
this.broadcast();
|
|
131
|
+
});
|
|
132
|
+
owner?.connect('cell-cleared', this, (...args: unknown[]) => this.onCellCleared(Number(args[0]) || 0));
|
|
133
|
+
owner?.connect('core-hit', this, () => this.takeDamage(1));
|
|
134
|
+
owner?.connect('board-cleared', this, () => this.finish(true));
|
|
135
|
+
owner?.connect('hud-ready', this, () => this.broadcast());
|
|
136
|
+
|
|
137
|
+
// Components start in scene order, and the board is listed first — so by now it has already
|
|
138
|
+
// built and already emitted `board-built`, into a signal nobody was connected to yet. Pull the
|
|
139
|
+
// number as well as listening for it: the connect above covers a later rebuild (or a scene that
|
|
140
|
+
// lists the components the other way round), this covers the first one.
|
|
141
|
+
this.adoptBoardCount();
|
|
142
|
+
|
|
143
|
+
this.setNodeVisible(String(this.config.resultNode ?? ''), false);
|
|
144
|
+
// Both end-screen buttons start disabled, because in THIS recipe the menu button lives inside
|
|
145
|
+
// the hidden `result-overlay` (the 2D recipes park theirs in the always-visible HUD, where
|
|
146
|
+
// enabling it is right). Leaving it enabled read as "you can leave mid-run" while the control
|
|
147
|
+
// was invisible and sitting over the middle of the board.
|
|
148
|
+
this.bindButton(String(this.config.retryButton ?? ''), () => this.scene?.commands.dispatch('restart'), false);
|
|
149
|
+
this.bindButton(String(this.config.menuButton ?? ''), () => this.scene?.commands.dispatch('return-to-menu'), false);
|
|
150
|
+
this.broadcast();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onUpdate(dt: number): void {
|
|
154
|
+
if (this.over) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.elapsed += dt;
|
|
158
|
+
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
159
|
+
this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
160
|
+
if (this.lives <= 0) {
|
|
161
|
+
this.finish(false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (limit > 0 && this.elapsed >= limit) {
|
|
165
|
+
this.finish(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
onDetach(): void {
|
|
170
|
+
this.disposeDebug?.();
|
|
171
|
+
this.disposeDebug = null;
|
|
172
|
+
for (const dispose of this.disposeCommands) {
|
|
173
|
+
dispose();
|
|
174
|
+
}
|
|
175
|
+
this.disposeCommands = [];
|
|
176
|
+
super.onDetach();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** End the run. Public so a custom rule can call it. */
|
|
180
|
+
finish(won: boolean): void {
|
|
181
|
+
if (this.over) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.over = true;
|
|
185
|
+
this.won = won;
|
|
186
|
+
const label = this.findNode(String(this.config.resultLabel ?? ''));
|
|
187
|
+
if (label instanceof Label2D) {
|
|
188
|
+
label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
|
|
189
|
+
}
|
|
190
|
+
this.setNodeVisible(String(this.config.resultNode ?? ''), true);
|
|
191
|
+
this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
|
|
192
|
+
this.setButtonEnabled(String(this.config.menuButton ?? ''), true);
|
|
193
|
+
this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read the live cube count off whichever sibling component owns the board.
|
|
198
|
+
*
|
|
199
|
+
* Duck-typed rather than importing `GridBoard`: a project that swaps in its own board keeps
|
|
200
|
+
* working as long as it answers `getRemaining()`.
|
|
201
|
+
*/
|
|
202
|
+
private adoptBoardCount(): void {
|
|
203
|
+
if (this.clearable > 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const component of this.node?.components ?? []) {
|
|
207
|
+
const getRemaining = (component as { getRemaining?: unknown }).getRemaining;
|
|
208
|
+
if (typeof getRemaining === 'function') {
|
|
209
|
+
const value = Number((getRemaining as () => number).call(component));
|
|
210
|
+
if (Number.isFinite(value) && value > 0) {
|
|
211
|
+
this.clearable = value;
|
|
212
|
+
this.remaining = value;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private onCellCleared(remaining: number): void {
|
|
220
|
+
if (this.over) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.remaining = remaining;
|
|
224
|
+
this.score += 1;
|
|
225
|
+
this.node?.emit('score-changed', this.score);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private takeDamage(amount: number): void {
|
|
229
|
+
if (this.over) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.lives = Math.max(0, this.lives - Math.max(1, amount));
|
|
233
|
+
this.node?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private broadcast(): void {
|
|
237
|
+
const owner = this.node;
|
|
238
|
+
owner?.emit('score-changed', this.score);
|
|
239
|
+
owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
|
|
240
|
+
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
241
|
+
owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private timeLeftSec(): number | null {
|
|
245
|
+
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
246
|
+
return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private restart(): void {
|
|
250
|
+
void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private toMenu(): void {
|
|
254
|
+
void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private bindButton(query: string, handler: () => void, enabled: boolean): void {
|
|
258
|
+
const button = this.findNode(query);
|
|
259
|
+
if (button instanceof Button2D) {
|
|
260
|
+
button.enabled = enabled;
|
|
261
|
+
button.connect('pressed', this, handler);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private setButtonEnabled(query: string, enabled: boolean): void {
|
|
266
|
+
const button = this.findNode(query);
|
|
267
|
+
if (button instanceof Button2D) {
|
|
268
|
+
button.enabled = enabled;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private setNodeVisible(query: string, visible: boolean): void {
|
|
273
|
+
const node = this.findNode(query);
|
|
274
|
+
if (node) {
|
|
275
|
+
node.visible = visible;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MenuFlow — drives the menu scene (the project's entry/export scene).
|
|
3
|
+
*
|
|
4
|
+
* PLAY transitions to the game scene with a fade. That is all it does: keep the
|
|
5
|
+
* menu dumb so the game scene stays directly playable on its own (agents and
|
|
6
|
+
* the editor open `main.pix3scene` and press play without going through here).
|
|
7
|
+
*
|
|
8
|
+
* PLAY does not call `startGame()` directly: it dispatches the command
|
|
9
|
+
* `start-game` (`scene.commands`). A test can then leave the menu without a tap,
|
|
10
|
+
* every raised intent lands in `scene.commands.log` with its frame, and one real
|
|
11
|
+
* tap is enough to prove the button is still wired to the intent it claims.
|
|
12
|
+
*
|
|
13
|
+
* It registers a debug provider too. `registerGameDebug` is global and
|
|
14
|
+
* last-wins, and only the running scene's flow script registers one — so
|
|
15
|
+
* without this, everything a tool reads while the menu is up would come back
|
|
16
|
+
* empty (or, worse, stale from the previous scene).
|
|
17
|
+
*/
|
|
18
|
+
import { Script, registerGameDebug, type PropertySchema } from '@pix3/runtime';
|
|
19
|
+
|
|
20
|
+
export class MenuFlow extends Script {
|
|
21
|
+
private playButtonReady = false;
|
|
22
|
+
private disposeDebug: (() => void) | null = null;
|
|
23
|
+
private disposeCommands: (() => void)[] = [];
|
|
24
|
+
|
|
25
|
+
constructor(id: string, type: string) {
|
|
26
|
+
super(id, type);
|
|
27
|
+
this.config = {
|
|
28
|
+
gameScene: 'res://scenes/main.pix3scene',
|
|
29
|
+
playButton: 'play-button',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static getPropertySchema(): PropertySchema {
|
|
34
|
+
const str = (name: string, label: string) => ({
|
|
35
|
+
name,
|
|
36
|
+
type: 'string' as const,
|
|
37
|
+
ui: { label, group: 'Menu' },
|
|
38
|
+
getValue: (s: unknown) => (s as MenuFlow).config[name],
|
|
39
|
+
setValue: (s: unknown, v: unknown) => {
|
|
40
|
+
(s as MenuFlow).config[name] = typeof v === 'string' ? v : '';
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
nodeType: 'MenuFlow',
|
|
46
|
+
properties: [str('gameScene', 'Game Scene'), str('playButton', 'Play Button')],
|
|
47
|
+
groups: { Menu: { label: 'Menu Flow', expanded: true } },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onStart(): void {
|
|
52
|
+
const commands = this.scene?.commands;
|
|
53
|
+
this.disposeCommands = [
|
|
54
|
+
commands?.register('start-game', () => this.startGame(), {
|
|
55
|
+
description: 'Leave the menu and transition into the game scene.',
|
|
56
|
+
}),
|
|
57
|
+
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
58
|
+
|
|
59
|
+
this.disposeDebug = registerGameDebug({
|
|
60
|
+
name: 'recipe-grid-3d',
|
|
61
|
+
version: 1,
|
|
62
|
+
// The registry IS the action list — never a second, hand-kept copy.
|
|
63
|
+
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
64
|
+
// `scene` is what tells a reader which flow answered: the game scene's
|
|
65
|
+
// GameRules publishes under the same name.
|
|
66
|
+
snapshot: () => ({
|
|
67
|
+
scene: 'menu',
|
|
68
|
+
gameScene: String(this.config.gameScene ?? ''),
|
|
69
|
+
playButtonReady: this.playButtonReady,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const button = this.findNode(String(this.config.playButton ?? ''));
|
|
74
|
+
if (!button) {
|
|
75
|
+
console.warn(`[MenuFlow] Button "${this.config.playButton}" not found.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.playButtonReady = true;
|
|
79
|
+
button.connect('pressed', this, () => {
|
|
80
|
+
this.scene?.commands.dispatch('start-game');
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onDetach(): void {
|
|
85
|
+
// Safe in any order: the disposer clears the global only while it still
|
|
86
|
+
// holds *this* provider, so the next scene's registration is never wiped.
|
|
87
|
+
this.disposeDebug?.();
|
|
88
|
+
this.disposeDebug = null;
|
|
89
|
+
// The scene's registry is cleared on stop anyway; this covers the other
|
|
90
|
+
// case — this script being detached while the scene keeps running.
|
|
91
|
+
for (const dispose of this.disposeCommands) {
|
|
92
|
+
dispose();
|
|
93
|
+
}
|
|
94
|
+
this.disposeCommands = [];
|
|
95
|
+
this.playButtonReady = false;
|
|
96
|
+
super.onDetach();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Transition into the game scene. */
|
|
100
|
+
startGame(): void {
|
|
101
|
+
void this.scene?.changeScene(String(this.config.gameScene ?? ''), {
|
|
102
|
+
transition: 'fade',
|
|
103
|
+
durationSec: 0.3,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|