@pix3/cli 1.6.0 → 1.6.3
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 -201
- package/README.md +788 -754
- package/dist/index.js +208 -203
- package/dist/runtime-types.json +1 -1
- package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-CBFYKDS7.mjs} +1 -1
- package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +239 -0
- package/dist/smoke/prebuilt/runtime.mjs +1 -1
- package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
- package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
- package/dist/validate/prebuilt/bundle-main.js +13 -13
- package/dist/validate/prebuilt/chunk-CFQFEI52.js +239 -0
- package/dist/validate/prebuilt/runtime.js +1 -1
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
- package/kit/files/.claude/skills/pix3-nodes/reference.md +39 -24
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +43 -28
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
- package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
- package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
- package/kit/files/AGENTS.md +203 -203
- package/kit/files/CLAUDE.md +1 -1
- package/kit/kit.json +2 -2
- package/package.json +54 -54
- package/templates/empty-2d/files/README.md +20 -20
- package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
- package/templates/empty-2d/template.yaml +7 -7
- package/templates/empty-3d/files/README.md +20 -20
- package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
- package/templates/empty-3d/template.yaml +7 -7
- package/templates/idea-blank/files/README.md +22 -22
- package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
- package/templates/idea-blank/template.yaml +10 -10
- package/templates/minigame-2d/files/README.md +44 -44
- package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
- package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
- package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
- package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
- package/templates/minigame-2d/template.yaml +10 -10
- package/templates/playable-2d/files/README.md +30 -30
- package/templates/playable-2d/files/design/recipe.md +83 -83
- package/templates/playable-2d/files/design/tests/reachability.json +4 -4
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
- package/templates/playable-2d/template.yaml +9 -9
- package/templates/playable-3d/files/README.md +32 -32
- package/templates/playable-3d/files/design/recipe.md +127 -127
- package/templates/playable-3d/files/design/tests/reachability.json +4 -4
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
- package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
- package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
- package/templates/playable-3d/template.yaml +9 -9
- package/templates/recipe-arena-2d/files/README.md +48 -48
- package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
- package/templates/recipe-arena-2d/template.yaml +10 -10
- package/templates/recipe-blank-2d/files/README.md +61 -61
- package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
- package/templates/recipe-blank-2d/template.yaml +10 -10
- package/templates/recipe-bouncer-2d/files/README.md +81 -81
- package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
- package/templates/recipe-bouncer-2d/template.yaml +10 -10
- package/templates/recipe-grid-3d/files/README.md +22 -22
- package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-grid-3d/template.yaml +10 -10
- package/templates/recipe-tapper-2d/files/README.md +39 -39
- package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
- package/templates/recipe-tapper-2d/template.yaml +10 -10
- package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
- package/dist/validate/prebuilt/chunk-BS2OZM7U.js +0 -239
|
@@ -1,278 +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
|
-
}
|
|
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
|
+
}
|