@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,290 +1,290 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GameRules — score, lives, timer and the win/lose decision for the 3D recipe.
|
|
3
|
-
*
|
|
4
|
-
* The same bookkeeping the 2D blank recipe ships, so a 3D idea starts with the *outcome*
|
|
5
|
-
* of a run already built and spends its first increment on the mechanic. It owns:
|
|
6
|
-
* - the run state (score, lives, clock, outcome),
|
|
7
|
-
* - the HUD signals (`score-changed`, `lives-changed`, `time-changed`),
|
|
8
|
-
* - the win/lose decision.
|
|
9
|
-
*
|
|
10
|
-
* Your mechanic talks to it in either of two ways, whichever suits the script:
|
|
11
|
-
* 1. call the methods — `rules.addScore(1)`, `rules.loseLife()`, `rules.finish(true)`;
|
|
12
|
-
* 2. emit on the node that carries this component — `node.emit('score-added', 1)`
|
|
13
|
-
* or `node.emit('life-lost', 1)` — which needs no reference at all.
|
|
14
|
-
*
|
|
15
|
-
* Win modes:
|
|
16
|
-
* `score` — reach `targetScore`; a non-zero time limit is a deadline.
|
|
17
|
-
* `time` — play until the clock ends, then win if `targetScore` was reached.
|
|
18
|
-
* `survive` — stay alive until the clock ends; reaching it is the win.
|
|
19
|
-
* In every mode, running out of lives loses. `timeLimitSec: 0` means no clock,
|
|
20
|
-
* so the HUD's time label counts elapsed seconds up instead of down.
|
|
21
|
-
*
|
|
22
|
-
* **Two things it deliberately does NOT own**, because `GameFlow` on the same node already
|
|
23
|
-
* does, and a playable may have exactly one of each:
|
|
24
|
-
*
|
|
25
|
-
* - **The end screen.** `finish(won)` writes the outcome text and dispatches GameFlow's
|
|
26
|
-
* `finish` command. One result surface, one phase machine — a second overlay would give
|
|
27
|
-
* the playable two ways to be over and no way to agree which one happened.
|
|
28
|
-
* - **The debug provider.** `registerGameDebug` keeps ONE provider, so a second call here
|
|
29
|
-
* would silently displace GameFlow's and take every intro/end verification down with it.
|
|
30
|
-
* Instead {@link GameRules.bookkeeping} is read by GameFlow's snapshot, and the numbers
|
|
31
|
-
* below arrive in the same JSON as the phase.
|
|
32
|
-
*
|
|
33
|
-
* The wiring in both directions is duck-typed on purpose: delete this component and
|
|
34
|
-
* GameFlow still runs the gate, the timer and the CTA exactly as it did before. Delete
|
|
35
|
-
* GameFlow instead and set `startWithFlow: false`, or nothing ever starts the run.
|
|
36
|
-
*/
|
|
37
|
-
import { Label2D, Script, type PropertySchema } from '@pix3/runtime';
|
|
38
|
-
|
|
39
|
-
type WinMode = 'score' | 'time' | 'survive';
|
|
40
|
-
|
|
41
|
-
/** Snapshot numbers are read by humans and diffed by tools — keep them short. */
|
|
42
|
-
function roundHundredths(value: number): number {
|
|
43
|
-
return Math.round(value * 100) / 100;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export class GameRules extends Script {
|
|
47
|
-
private score = 0;
|
|
48
|
-
private lives = 3;
|
|
49
|
-
private elapsed = 0;
|
|
50
|
-
private over = false;
|
|
51
|
-
private won = false;
|
|
52
|
-
/** False until the run actually begins — see `startWithFlow`. */
|
|
53
|
-
private running = false;
|
|
54
|
-
|
|
55
|
-
constructor(id: string, type: string) {
|
|
56
|
-
super(id, type);
|
|
57
|
-
this.config = {
|
|
58
|
-
winMode: 'score',
|
|
59
|
-
targetScore: 10,
|
|
60
|
-
// No clock by default: a recipe that ended runs on a deadline nobody asked for
|
|
61
|
-
// would look like a bug in the mechanic being built.
|
|
62
|
-
timeLimitSec: 0,
|
|
63
|
-
startingLives: 3,
|
|
64
|
-
resultLabel: 'end-label',
|
|
65
|
-
winText: 'YOU WIN!',
|
|
66
|
-
loseText: 'GAME OVER',
|
|
67
|
-
// Wait for GameFlow to leave the tap gate before the clock starts and the win
|
|
68
|
-
// condition is evaluated. Without it a `survive` run with a time limit is WON while
|
|
69
|
-
// the intro overlay is still up, having survived nothing. Set it false if you delete
|
|
70
|
-
// GameFlow — then the run starts with the scene.
|
|
71
|
-
startWithFlow: true,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
static getPropertySchema(): PropertySchema {
|
|
76
|
-
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
77
|
-
name,
|
|
78
|
-
type: 'number' as const,
|
|
79
|
-
ui: { label, group: 'Rules', min, max, step, slider: true },
|
|
80
|
-
getValue: (s: unknown) => (s as GameRules).config[name],
|
|
81
|
-
setValue: (s: unknown, v: unknown) => {
|
|
82
|
-
const n = Number(v);
|
|
83
|
-
(s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
const str = (name: string, label: string, group: string) => ({
|
|
87
|
-
name,
|
|
88
|
-
type: 'string' as const,
|
|
89
|
-
ui: { label, group },
|
|
90
|
-
getValue: (s: unknown) => (s as GameRules).config[name],
|
|
91
|
-
setValue: (s: unknown, v: unknown) => {
|
|
92
|
-
(s as GameRules).config[name] = typeof v === 'string' ? v : '';
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
nodeType: 'GameRules',
|
|
98
|
-
properties: [
|
|
99
|
-
{
|
|
100
|
-
name: 'winMode',
|
|
101
|
-
type: 'select',
|
|
102
|
-
ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
|
|
103
|
-
getValue: s => (s as GameRules).config.winMode,
|
|
104
|
-
setValue: (s, v) => {
|
|
105
|
-
(s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
num('targetScore', 'Target Score', 1, 99999, 1),
|
|
109
|
-
num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
|
|
110
|
-
num('startingLives', 'Starting Lives', 1, 20, 1),
|
|
111
|
-
str('winText', 'Win Text', 'Result'),
|
|
112
|
-
str('loseText', 'Lose Text', 'Result'),
|
|
113
|
-
str('resultLabel', 'Result Label', 'Result'),
|
|
114
|
-
{
|
|
115
|
-
name: 'startWithFlow',
|
|
116
|
-
type: 'boolean',
|
|
117
|
-
ui: {
|
|
118
|
-
label: 'Start With Flow',
|
|
119
|
-
description: 'Wait for GameFlow to leave the tap gate before the run begins',
|
|
120
|
-
group: 'Rules',
|
|
121
|
-
},
|
|
122
|
-
getValue: s => (s as GameRules).config.startWithFlow !== false,
|
|
123
|
-
setValue: (s, v) => {
|
|
124
|
-
(s as GameRules).config.startWithFlow = v !== false;
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
groups: {
|
|
129
|
-
Rules: { label: 'Rules', expanded: true },
|
|
130
|
-
Result: { label: 'Result Text', expanded: true },
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
onStart(): void {
|
|
136
|
-
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
137
|
-
this.running = this.config.startWithFlow === false;
|
|
138
|
-
|
|
139
|
-
const owner = this.node;
|
|
140
|
-
// The signal channel: a mechanic script can score without holding a reference
|
|
141
|
-
// to this component. Same effect as calling the methods below.
|
|
142
|
-
owner?.connect('score-added', this, (...args: unknown[]) =>
|
|
143
|
-
this.addScore(Number(args[0]) || 0)
|
|
144
|
-
);
|
|
145
|
-
owner?.connect('life-lost', this, (...args: unknown[]) => this.loseLife(Number(args[0]) || 1));
|
|
146
|
-
// The HUD announces itself once it is connected, so it never misses the
|
|
147
|
-
// opening values (its onStart runs after this one).
|
|
148
|
-
owner?.connect('hud-ready', this, () => this.broadcast());
|
|
149
|
-
this.broadcast();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
onUpdate(dt: number): void {
|
|
153
|
-
if (this.over || !this.running) {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
this.elapsed += dt;
|
|
157
|
-
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
158
|
-
this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
159
|
-
|
|
160
|
-
if (this.lives <= 0) {
|
|
161
|
-
this.finish(false);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const raw = String(this.config.winMode ?? 'score');
|
|
165
|
-
const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
|
|
166
|
-
const target = Math.max(1, Number(this.config.targetScore) || 1);
|
|
167
|
-
const timeUp = limit > 0 && this.elapsed >= limit;
|
|
168
|
-
|
|
169
|
-
if (mode === 'score') {
|
|
170
|
-
if (this.score >= target) this.finish(true);
|
|
171
|
-
else if (timeUp) this.finish(false);
|
|
172
|
-
} else if (mode === 'time') {
|
|
173
|
-
if (timeUp) this.finish(this.score >= target);
|
|
174
|
-
} else if (timeUp) {
|
|
175
|
-
this.finish(true);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* The run has begun: start the clock and start judging the win condition. Called by
|
|
181
|
-
* `GameFlow` when the tap gate opens, so the timer measures play and not the time the
|
|
182
|
-
* intro overlay spent on screen.
|
|
183
|
-
*/
|
|
184
|
-
startRun(): void {
|
|
185
|
-
if (this.over) return;
|
|
186
|
-
this.running = true;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* The run ended somewhere else — `GameFlow`'s placeholder timer, or a `finish` command
|
|
191
|
-
* dispatched by a test. Without this the phase said `ended` while the rules kept ticking,
|
|
192
|
-
* reported `outcome: null`, and (with a time limit) fired a SECOND ending that rewrote the
|
|
193
|
-
* result label behind the end screen.
|
|
194
|
-
*
|
|
195
|
-
* With no verdict passed, the rules read their own win condition — the run is over, and
|
|
196
|
-
* "was it won?" is exactly the question this component exists to answer.
|
|
197
|
-
*/
|
|
198
|
-
endRun(won?: boolean): void {
|
|
199
|
-
if (this.over) return;
|
|
200
|
-
this.finish(won ?? this.score >= Math.max(1, Number(this.config.targetScore) || 1));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Score. Call it from your mechanic, or emit `score-added` on this node. */
|
|
204
|
-
addScore(amount: number): void {
|
|
205
|
-
if (this.over) return;
|
|
206
|
-
this.score += amount;
|
|
207
|
-
this.node?.emit('score-changed', this.score);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Lose lives. Call it from your mechanic, or emit `life-lost` on this node. */
|
|
211
|
-
loseLife(amount = 1): void {
|
|
212
|
-
if (this.over) return;
|
|
213
|
-
this.lives = Math.max(0, this.lives - Math.max(1, amount));
|
|
214
|
-
this.node?.emit(
|
|
215
|
-
'lives-changed',
|
|
216
|
-
this.lives,
|
|
217
|
-
Math.max(1, Number(this.config.startingLives) || 1)
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* End the run: write the outcome text, then hand the end screen to `GameFlow`.
|
|
223
|
-
*
|
|
224
|
-
* Dispatching its `finish` command rather than reaching for the component keeps the
|
|
225
|
-
* ending in ONE place and journals it, so a routine can watch the run end by intent.
|
|
226
|
-
*/
|
|
227
|
-
finish(won: boolean): void {
|
|
228
|
-
if (this.over) return;
|
|
229
|
-
this.over = true;
|
|
230
|
-
this.running = false;
|
|
231
|
-
this.won = won;
|
|
232
|
-
|
|
233
|
-
const label = this.findNode(String(this.config.resultLabel ?? ''));
|
|
234
|
-
if (label instanceof Label2D) {
|
|
235
|
-
label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
|
|
236
|
-
}
|
|
237
|
-
this.scene?.commands.dispatch('finish');
|
|
238
|
-
this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* The run's numbers, for GameFlow's debug snapshot. Kept flat and JSON-serialisable —
|
|
243
|
-
* it is merged into that snapshot, so it is what every verification tool reads.
|
|
244
|
-
*/
|
|
245
|
-
bookkeeping(): Record<string, unknown> {
|
|
246
|
-
return {
|
|
247
|
-
score: this.score,
|
|
248
|
-
lives: this.lives,
|
|
249
|
-
winMode: String(this.config.winMode ?? 'score'),
|
|
250
|
-
targetScore: Math.max(1, Number(this.config.targetScore) || 1),
|
|
251
|
-
// null when the run has no clock at all.
|
|
252
|
-
timeLeftSec: this.timeLeftSec(),
|
|
253
|
-
outcome: this.over ? (this.won ? 'won' : 'lost') : null,
|
|
254
|
-
// Whether the clock and the win condition are live. A run that reads `phase: playing`
|
|
255
|
-
// while this is false is a run stuck behind the gate.
|
|
256
|
-
running: this.running,
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Back to the state `onStart` left behind. Called by GameFlow's `restart`, so one intent
|
|
262
|
-
* arms a fresh run everywhere — a restart that put the gate back but kept last run's score
|
|
263
|
-
* is the kind of half-reset that makes a routine's second pass meaningless.
|
|
264
|
-
*/
|
|
265
|
-
resetRun(): void {
|
|
266
|
-
this.score = 0;
|
|
267
|
-
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
268
|
-
this.elapsed = 0;
|
|
269
|
-
this.over = false;
|
|
270
|
-
this.won = false;
|
|
271
|
-
// Back behind the gate, exactly as at `onStart` — a reset that left the clock running
|
|
272
|
-
// would spend the next intro overlay burning the new run's time limit.
|
|
273
|
-
this.running = this.config.startWithFlow === false;
|
|
274
|
-
this.broadcast();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Seconds left on the clock, or `null` when this run has no time limit. */
|
|
278
|
-
private timeLeftSec(): number | null {
|
|
279
|
-
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
280
|
-
return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
private broadcast(): void {
|
|
284
|
-
const owner = this.node;
|
|
285
|
-
owner?.emit('score-changed', this.score);
|
|
286
|
-
owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
|
|
287
|
-
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
288
|
-
owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* GameRules — score, lives, timer and the win/lose decision for the 3D recipe.
|
|
3
|
+
*
|
|
4
|
+
* The same bookkeeping the 2D blank recipe ships, so a 3D idea starts with the *outcome*
|
|
5
|
+
* of a run already built and spends its first increment on the mechanic. It owns:
|
|
6
|
+
* - the run state (score, lives, clock, outcome),
|
|
7
|
+
* - the HUD signals (`score-changed`, `lives-changed`, `time-changed`),
|
|
8
|
+
* - the win/lose decision.
|
|
9
|
+
*
|
|
10
|
+
* Your mechanic talks to it in either of two ways, whichever suits the script:
|
|
11
|
+
* 1. call the methods — `rules.addScore(1)`, `rules.loseLife()`, `rules.finish(true)`;
|
|
12
|
+
* 2. emit on the node that carries this component — `node.emit('score-added', 1)`
|
|
13
|
+
* or `node.emit('life-lost', 1)` — which needs no reference at all.
|
|
14
|
+
*
|
|
15
|
+
* Win modes:
|
|
16
|
+
* `score` — reach `targetScore`; a non-zero time limit is a deadline.
|
|
17
|
+
* `time` — play until the clock ends, then win if `targetScore` was reached.
|
|
18
|
+
* `survive` — stay alive until the clock ends; reaching it is the win.
|
|
19
|
+
* In every mode, running out of lives loses. `timeLimitSec: 0` means no clock,
|
|
20
|
+
* so the HUD's time label counts elapsed seconds up instead of down.
|
|
21
|
+
*
|
|
22
|
+
* **Two things it deliberately does NOT own**, because `GameFlow` on the same node already
|
|
23
|
+
* does, and a playable may have exactly one of each:
|
|
24
|
+
*
|
|
25
|
+
* - **The end screen.** `finish(won)` writes the outcome text and dispatches GameFlow's
|
|
26
|
+
* `finish` command. One result surface, one phase machine — a second overlay would give
|
|
27
|
+
* the playable two ways to be over and no way to agree which one happened.
|
|
28
|
+
* - **The debug provider.** `registerGameDebug` keeps ONE provider, so a second call here
|
|
29
|
+
* would silently displace GameFlow's and take every intro/end verification down with it.
|
|
30
|
+
* Instead {@link GameRules.bookkeeping} is read by GameFlow's snapshot, and the numbers
|
|
31
|
+
* below arrive in the same JSON as the phase.
|
|
32
|
+
*
|
|
33
|
+
* The wiring in both directions is duck-typed on purpose: delete this component and
|
|
34
|
+
* GameFlow still runs the gate, the timer and the CTA exactly as it did before. Delete
|
|
35
|
+
* GameFlow instead and set `startWithFlow: false`, or nothing ever starts the run.
|
|
36
|
+
*/
|
|
37
|
+
import { Label2D, Script, type PropertySchema } from '@pix3/runtime';
|
|
38
|
+
|
|
39
|
+
type WinMode = 'score' | 'time' | 'survive';
|
|
40
|
+
|
|
41
|
+
/** Snapshot numbers are read by humans and diffed by tools — keep them short. */
|
|
42
|
+
function roundHundredths(value: number): number {
|
|
43
|
+
return Math.round(value * 100) / 100;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class GameRules extends Script {
|
|
47
|
+
private score = 0;
|
|
48
|
+
private lives = 3;
|
|
49
|
+
private elapsed = 0;
|
|
50
|
+
private over = false;
|
|
51
|
+
private won = false;
|
|
52
|
+
/** False until the run actually begins — see `startWithFlow`. */
|
|
53
|
+
private running = false;
|
|
54
|
+
|
|
55
|
+
constructor(id: string, type: string) {
|
|
56
|
+
super(id, type);
|
|
57
|
+
this.config = {
|
|
58
|
+
winMode: 'score',
|
|
59
|
+
targetScore: 10,
|
|
60
|
+
// No clock by default: a recipe that ended runs on a deadline nobody asked for
|
|
61
|
+
// would look like a bug in the mechanic being built.
|
|
62
|
+
timeLimitSec: 0,
|
|
63
|
+
startingLives: 3,
|
|
64
|
+
resultLabel: 'end-label',
|
|
65
|
+
winText: 'YOU WIN!',
|
|
66
|
+
loseText: 'GAME OVER',
|
|
67
|
+
// Wait for GameFlow to leave the tap gate before the clock starts and the win
|
|
68
|
+
// condition is evaluated. Without it a `survive` run with a time limit is WON while
|
|
69
|
+
// the intro overlay is still up, having survived nothing. Set it false if you delete
|
|
70
|
+
// GameFlow — then the run starts with the scene.
|
|
71
|
+
startWithFlow: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static getPropertySchema(): PropertySchema {
|
|
76
|
+
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
77
|
+
name,
|
|
78
|
+
type: 'number' as const,
|
|
79
|
+
ui: { label, group: 'Rules', min, max, step, slider: true },
|
|
80
|
+
getValue: (s: unknown) => (s as GameRules).config[name],
|
|
81
|
+
setValue: (s: unknown, v: unknown) => {
|
|
82
|
+
const n = Number(v);
|
|
83
|
+
(s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const str = (name: string, label: string, group: string) => ({
|
|
87
|
+
name,
|
|
88
|
+
type: 'string' as const,
|
|
89
|
+
ui: { label, group },
|
|
90
|
+
getValue: (s: unknown) => (s as GameRules).config[name],
|
|
91
|
+
setValue: (s: unknown, v: unknown) => {
|
|
92
|
+
(s as GameRules).config[name] = typeof v === 'string' ? v : '';
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
nodeType: 'GameRules',
|
|
98
|
+
properties: [
|
|
99
|
+
{
|
|
100
|
+
name: 'winMode',
|
|
101
|
+
type: 'select',
|
|
102
|
+
ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
|
|
103
|
+
getValue: s => (s as GameRules).config.winMode,
|
|
104
|
+
setValue: (s, v) => {
|
|
105
|
+
(s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
num('targetScore', 'Target Score', 1, 99999, 1),
|
|
109
|
+
num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
|
|
110
|
+
num('startingLives', 'Starting Lives', 1, 20, 1),
|
|
111
|
+
str('winText', 'Win Text', 'Result'),
|
|
112
|
+
str('loseText', 'Lose Text', 'Result'),
|
|
113
|
+
str('resultLabel', 'Result Label', 'Result'),
|
|
114
|
+
{
|
|
115
|
+
name: 'startWithFlow',
|
|
116
|
+
type: 'boolean',
|
|
117
|
+
ui: {
|
|
118
|
+
label: 'Start With Flow',
|
|
119
|
+
description: 'Wait for GameFlow to leave the tap gate before the run begins',
|
|
120
|
+
group: 'Rules',
|
|
121
|
+
},
|
|
122
|
+
getValue: s => (s as GameRules).config.startWithFlow !== false,
|
|
123
|
+
setValue: (s, v) => {
|
|
124
|
+
(s as GameRules).config.startWithFlow = v !== false;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
groups: {
|
|
129
|
+
Rules: { label: 'Rules', expanded: true },
|
|
130
|
+
Result: { label: 'Result Text', expanded: true },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onStart(): void {
|
|
136
|
+
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
137
|
+
this.running = this.config.startWithFlow === false;
|
|
138
|
+
|
|
139
|
+
const owner = this.node;
|
|
140
|
+
// The signal channel: a mechanic script can score without holding a reference
|
|
141
|
+
// to this component. Same effect as calling the methods below.
|
|
142
|
+
owner?.connect('score-added', this, (...args: unknown[]) =>
|
|
143
|
+
this.addScore(Number(args[0]) || 0)
|
|
144
|
+
);
|
|
145
|
+
owner?.connect('life-lost', this, (...args: unknown[]) => this.loseLife(Number(args[0]) || 1));
|
|
146
|
+
// The HUD announces itself once it is connected, so it never misses the
|
|
147
|
+
// opening values (its onStart runs after this one).
|
|
148
|
+
owner?.connect('hud-ready', this, () => this.broadcast());
|
|
149
|
+
this.broadcast();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onUpdate(dt: number): void {
|
|
153
|
+
if (this.over || !this.running) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.elapsed += dt;
|
|
157
|
+
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
158
|
+
this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
159
|
+
|
|
160
|
+
if (this.lives <= 0) {
|
|
161
|
+
this.finish(false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const raw = String(this.config.winMode ?? 'score');
|
|
165
|
+
const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
|
|
166
|
+
const target = Math.max(1, Number(this.config.targetScore) || 1);
|
|
167
|
+
const timeUp = limit > 0 && this.elapsed >= limit;
|
|
168
|
+
|
|
169
|
+
if (mode === 'score') {
|
|
170
|
+
if (this.score >= target) this.finish(true);
|
|
171
|
+
else if (timeUp) this.finish(false);
|
|
172
|
+
} else if (mode === 'time') {
|
|
173
|
+
if (timeUp) this.finish(this.score >= target);
|
|
174
|
+
} else if (timeUp) {
|
|
175
|
+
this.finish(true);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The run has begun: start the clock and start judging the win condition. Called by
|
|
181
|
+
* `GameFlow` when the tap gate opens, so the timer measures play and not the time the
|
|
182
|
+
* intro overlay spent on screen.
|
|
183
|
+
*/
|
|
184
|
+
startRun(): void {
|
|
185
|
+
if (this.over) return;
|
|
186
|
+
this.running = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The run ended somewhere else — `GameFlow`'s placeholder timer, or a `finish` command
|
|
191
|
+
* dispatched by a test. Without this the phase said `ended` while the rules kept ticking,
|
|
192
|
+
* reported `outcome: null`, and (with a time limit) fired a SECOND ending that rewrote the
|
|
193
|
+
* result label behind the end screen.
|
|
194
|
+
*
|
|
195
|
+
* With no verdict passed, the rules read their own win condition — the run is over, and
|
|
196
|
+
* "was it won?" is exactly the question this component exists to answer.
|
|
197
|
+
*/
|
|
198
|
+
endRun(won?: boolean): void {
|
|
199
|
+
if (this.over) return;
|
|
200
|
+
this.finish(won ?? this.score >= Math.max(1, Number(this.config.targetScore) || 1));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Score. Call it from your mechanic, or emit `score-added` on this node. */
|
|
204
|
+
addScore(amount: number): void {
|
|
205
|
+
if (this.over) return;
|
|
206
|
+
this.score += amount;
|
|
207
|
+
this.node?.emit('score-changed', this.score);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Lose lives. Call it from your mechanic, or emit `life-lost` on this node. */
|
|
211
|
+
loseLife(amount = 1): void {
|
|
212
|
+
if (this.over) return;
|
|
213
|
+
this.lives = Math.max(0, this.lives - Math.max(1, amount));
|
|
214
|
+
this.node?.emit(
|
|
215
|
+
'lives-changed',
|
|
216
|
+
this.lives,
|
|
217
|
+
Math.max(1, Number(this.config.startingLives) || 1)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* End the run: write the outcome text, then hand the end screen to `GameFlow`.
|
|
223
|
+
*
|
|
224
|
+
* Dispatching its `finish` command rather than reaching for the component keeps the
|
|
225
|
+
* ending in ONE place and journals it, so a routine can watch the run end by intent.
|
|
226
|
+
*/
|
|
227
|
+
finish(won: boolean): void {
|
|
228
|
+
if (this.over) return;
|
|
229
|
+
this.over = true;
|
|
230
|
+
this.running = false;
|
|
231
|
+
this.won = won;
|
|
232
|
+
|
|
233
|
+
const label = this.findNode(String(this.config.resultLabel ?? ''));
|
|
234
|
+
if (label instanceof Label2D) {
|
|
235
|
+
label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
|
|
236
|
+
}
|
|
237
|
+
this.scene?.commands.dispatch('finish');
|
|
238
|
+
this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The run's numbers, for GameFlow's debug snapshot. Kept flat and JSON-serialisable —
|
|
243
|
+
* it is merged into that snapshot, so it is what every verification tool reads.
|
|
244
|
+
*/
|
|
245
|
+
bookkeeping(): Record<string, unknown> {
|
|
246
|
+
return {
|
|
247
|
+
score: this.score,
|
|
248
|
+
lives: this.lives,
|
|
249
|
+
winMode: String(this.config.winMode ?? 'score'),
|
|
250
|
+
targetScore: Math.max(1, Number(this.config.targetScore) || 1),
|
|
251
|
+
// null when the run has no clock at all.
|
|
252
|
+
timeLeftSec: this.timeLeftSec(),
|
|
253
|
+
outcome: this.over ? (this.won ? 'won' : 'lost') : null,
|
|
254
|
+
// Whether the clock and the win condition are live. A run that reads `phase: playing`
|
|
255
|
+
// while this is false is a run stuck behind the gate.
|
|
256
|
+
running: this.running,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Back to the state `onStart` left behind. Called by GameFlow's `restart`, so one intent
|
|
262
|
+
* arms a fresh run everywhere — a restart that put the gate back but kept last run's score
|
|
263
|
+
* is the kind of half-reset that makes a routine's second pass meaningless.
|
|
264
|
+
*/
|
|
265
|
+
resetRun(): void {
|
|
266
|
+
this.score = 0;
|
|
267
|
+
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
268
|
+
this.elapsed = 0;
|
|
269
|
+
this.over = false;
|
|
270
|
+
this.won = false;
|
|
271
|
+
// Back behind the gate, exactly as at `onStart` — a reset that left the clock running
|
|
272
|
+
// would spend the next intro overlay burning the new run's time limit.
|
|
273
|
+
this.running = this.config.startWithFlow === false;
|
|
274
|
+
this.broadcast();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Seconds left on the clock, or `null` when this run has no time limit. */
|
|
278
|
+
private timeLeftSec(): number | null {
|
|
279
|
+
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
280
|
+
return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private broadcast(): void {
|
|
284
|
+
const owner = this.node;
|
|
285
|
+
owner?.emit('score-changed', this.score);
|
|
286
|
+
owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
|
|
287
|
+
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
288
|
+
owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
289
|
+
}
|
|
290
|
+
}
|