@pix3/cli 1.6.3 → 2.0.0-alpha.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 -201
- package/README.md +722 -788
- package/dist/index.js +557 -421
- package/dist/runtime-types.json +1 -1
- package/dist/smoke/prebuilt/{build-7MIXIMSZ.mjs → build-7REMSA2S.mjs} +52 -52
- package/dist/smoke/prebuilt/chunk-4DGG2BKR.mjs +2 -0
- package/dist/smoke/prebuilt/chunk-YLBMUJ2H.mjs +238 -0
- package/dist/smoke/prebuilt/runtime.mjs +1 -1
- package/dist/smoke/prebuilt/smoke-worker.mjs +211 -2
- package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
- package/dist/validate/prebuilt/{build-AUGKXER3.js → build-ADF5EEE2.js} +52 -52
- package/dist/validate/prebuilt/bundle-main.js +223 -18
- package/dist/validate/prebuilt/chunk-VIHIF4G2.js +238 -0
- package/dist/validate/prebuilt/runtime.js +1 -1
- package/kit/files/.claude/skills/pix3-editor/SKILL.md +200 -0
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +13 -15
- package/kit/files/.claude/skills/pix3-nodes/reference.md +77 -71
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +38 -41
- package/kit/files/.claude/skills/pix3-scene-format/pix3anim.md +185 -0
- package/kit/files/.claude/skills/pix3-scene-format/project-files.md +244 -0
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -16
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +34 -24
- package/kit/files/.claude/skills/pix3-scripts/reference.md +68 -141
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +65 -326
- package/kit/files/.claude/skills/pix3-verify/reference.md +2 -14
- package/kit/files/AGENTS.md +76 -84
- package/kit/files/design/tests/bots/pix3-test-bot.d.ts +89 -0
- package/kit/kit.json +8 -5
- package/package.json +62 -54
- package/templates/2d/files/scenes/main.pix3scene +31 -0
- package/templates/2d/template.yaml +11 -0
- package/templates/3d/files/scenes/main.pix3scene +50 -0
- package/templates/3d/template.yaml +11 -0
- package/templates/base/files/README.md +39 -0
- package/templates/base/files/gitignore +11 -0
- package/templates/base/files/index.html +23 -0
- package/templates/base/files/package.json +28 -0
- package/templates/base/files/src/main.ts +3 -0
- package/templates/base/files/tsconfig.json +17 -0
- package/templates/base/files/vite.config.ts +10 -0
- package/dist/smoke/prebuilt/chunk-CBFYKDS7.mjs +0 -2
- package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +0 -239
- package/dist/validate/prebuilt/chunk-CFQFEI52.js +0 -239
- package/templates/empty-2d/files/README.md +0 -20
- package/templates/empty-2d/files/scenes/main.pix3scene +0 -68
- package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
- package/templates/empty-2d/template.yaml +0 -7
- package/templates/empty-3d/files/README.md +0 -20
- package/templates/empty-3d/files/scenes/main.pix3scene +0 -108
- package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
- package/templates/empty-3d/template.yaml +0 -7
- package/templates/idea-blank/files/README.md +0 -22
- package/templates/idea-blank/files/scenes/main.pix3scene +0 -37
- package/templates/idea-blank/template.yaml +0 -10
- package/templates/minigame-2d/files/README.md +0 -44
- package/templates/minigame-2d/files/design/tests/reachability.json +0 -1
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +0 -44
- package/templates/minigame-2d/files/scenes/main.pix3scene +0 -84
- package/templates/minigame-2d/files/scenes/menu.pix3scene +0 -118
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +0 -138
- package/templates/minigame-2d/files/scripts/GameFlow.ts +0 -133
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +0 -217
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +0 -198
- package/templates/minigame-2d/template.yaml +0 -10
- package/templates/playable-2d/files/README.md +0 -30
- package/templates/playable-2d/files/design/recipe.md +0 -83
- package/templates/playable-2d/files/design/tests/reachability.json +0 -4
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +0 -32
- package/templates/playable-2d/files/scenes/main.pix3scene +0 -111
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +0 -91
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +0 -65
- package/templates/playable-2d/files/scripts/CtaButton.ts +0 -55
- package/templates/playable-2d/files/scripts/GameFlow.ts +0 -256
- package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
- package/templates/playable-2d/template.yaml +0 -9
- package/templates/playable-3d/files/README.md +0 -32
- package/templates/playable-3d/files/design/recipe.md +0 -127
- package/templates/playable-3d/files/design/tests/reachability.json +0 -4
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +0 -32
- package/templates/playable-3d/files/scenes/main.pix3scene +0 -230
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +0 -91
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +0 -65
- package/templates/playable-3d/files/scripts/CtaButton.ts +0 -55
- package/templates/playable-3d/files/scripts/GameFlow.ts +0 -309
- package/templates/playable-3d/files/scripts/GameRules.ts +0 -290
- package/templates/playable-3d/files/scripts/ScoreHud.ts +0 -113
- package/templates/playable-3d/template.yaml +0 -9
- package/templates/recipe-arena-2d/files/README.md +0 -48
- package/templates/recipe-arena-2d/files/design/recipe.md +0 -127
- package/templates/recipe-arena-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +0 -292
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +0 -100
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +0 -45
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +0 -85
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +0 -368
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +0 -106
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +0 -151
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +0 -214
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +0 -174
- 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 +0 -10
- package/templates/recipe-blank-2d/files/README.md +0 -61
- package/templates/recipe-blank-2d/files/design/recipe.md +0 -148
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +0 -27
- package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +0 -224
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +0 -85
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +0 -350
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +0 -110
- 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 +0 -10
- package/templates/recipe-bouncer-2d/files/README.md +0 -81
- package/templates/recipe-bouncer-2d/files/design/recipe.md +0 -146
- package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +0 -615
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +0 -191
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +0 -87
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +0 -298
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +0 -377
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +0 -109
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +0 -112
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +0 -263
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +0 -307
- 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 +0 -10
- package/templates/recipe-grid-3d/files/README.md +0 -22
- package/templates/recipe-grid-3d/files/design/recipe.md +0 -110
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +0 -169
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +0 -100
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +0 -105
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +0 -319
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +0 -278
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +0 -106
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-grid-3d/template.yaml +0 -10
- package/templates/recipe-tapper-2d/files/README.md +0 -39
- package/templates/recipe-tapper-2d/files/design/recipe.md +0 -132
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +0 -269
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +0 -100
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +0 -45
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +0 -85
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +0 -368
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +0 -106
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +0 -214
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +0 -192
- 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 +0 -10
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GameFlow — the playable's phase driver: intro → playing → ended.
|
|
3
|
-
*
|
|
4
|
-
* Attach to `hud-root` (the 2D overlay layer above the 3D `game-root`). While
|
|
5
|
-
* the intro overlay is visible, the first tap
|
|
6
|
-
* hides it and starts the game (the tap also unlocks browser audio — the
|
|
7
|
-
* engine's AudioService resumes its context on the first gesture). The end
|
|
8
|
-
* screen is revealed either by calling `finish()` from game code or, as a
|
|
9
|
-
* placeholder, after `autoWinAfterSec` seconds. Replace the timer with your
|
|
10
|
-
* real win/lose condition.
|
|
11
|
-
*
|
|
12
|
-
* `skipIntro` starts the run already playing, gate hidden — a prototyping vent, so
|
|
13
|
-
* that iterating on the game does not cost a tap per reload. Turn it back off before
|
|
14
|
-
* shipping: in an ad container the first tap is what unlocks browser audio, so a
|
|
15
|
-
* playable that never gates on one is a playable that plays silently.
|
|
16
|
-
*
|
|
17
|
-
* `GameRules` on the same node keeps score, lives and the clock, and ends the run through
|
|
18
|
-
* this script's `finish` command — one phase machine, one end screen, one debug provider.
|
|
19
|
-
* Its numbers ride along in the snapshot below.
|
|
20
|
-
*
|
|
21
|
-
* Two conventions here are worth keeping as you replace the placeholder:
|
|
22
|
-
*
|
|
23
|
-
* - **Intent-first handlers.** Every reaction to the player lives in a named
|
|
24
|
-
* public method marked `Intent:` (`start`, `finish`, `restart`), and the ones
|
|
25
|
-
* a test drives are registered as commands (`scene.commands`) that the event
|
|
26
|
-
* handler dispatches. Game code and automated tests then drive the game by
|
|
27
|
-
* intent instead of by simulated pixels — `scene.commands.dispatch('restart')`
|
|
28
|
-
* — the flow survives any relayout of the UI, and every raised intent is
|
|
29
|
-
* journalled with the frame it happened on.
|
|
30
|
-
* - **A debug provider.** `registerGameDebug` publishes a JSON snapshot of the
|
|
31
|
-
* run so external tooling (the editor's debug bridge, DevTools, an agent) can
|
|
32
|
-
* read the phase instead of guessing it from the screen. Add your own fields
|
|
33
|
-
* as you add state — keep everything JSON-serialisable.
|
|
34
|
-
*/
|
|
35
|
-
import { Script, playable, registerGameDebug, type PropertySchema } from '@pix3/runtime';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* What this script needs from a bookkeeping component, and the whole of what the two know
|
|
39
|
-
* about each other. `bookkeeping`/`resetRun` are required (they are how the component is
|
|
40
|
-
* recognised); the lifecycle pair is optional so a hand-written replacement can implement
|
|
41
|
-
* only what it uses.
|
|
42
|
-
*/
|
|
43
|
-
interface RunBookkeeping {
|
|
44
|
-
bookkeeping(): Record<string, unknown>;
|
|
45
|
-
resetRun(): void;
|
|
46
|
-
startRun?(): void;
|
|
47
|
-
endRun?(won?: boolean): void;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export class GameFlow extends Script {
|
|
51
|
-
private phase: 'intro' | 'playing' | 'ended' = 'intro';
|
|
52
|
-
private elapsed = 0;
|
|
53
|
-
private disposeDebug: (() => void) | null = null;
|
|
54
|
-
private disposeCommands: (() => void)[] = [];
|
|
55
|
-
|
|
56
|
-
constructor(id: string, type: string) {
|
|
57
|
-
super(id, type);
|
|
58
|
-
this.config = {
|
|
59
|
-
// Node id/name of the tap-to-start overlay.
|
|
60
|
-
introNode: 'intro-overlay',
|
|
61
|
-
// Node id/name of the end screen with the CTA button.
|
|
62
|
-
endNode: 'end-screen',
|
|
63
|
-
// Placeholder auto-win timer in seconds (0 = never; call finish() instead).
|
|
64
|
-
autoWinAfterSec: 15,
|
|
65
|
-
// Prototyping vent: start in `playing` with the gate already gone.
|
|
66
|
-
skipIntro: false,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
static getPropertySchema(): PropertySchema {
|
|
71
|
-
return {
|
|
72
|
-
nodeType: 'GameFlow',
|
|
73
|
-
properties: [
|
|
74
|
-
{
|
|
75
|
-
name: 'introNode',
|
|
76
|
-
type: 'string',
|
|
77
|
-
ui: { label: 'Intro Overlay', group: 'Flow' },
|
|
78
|
-
getValue: s => (s as GameFlow).config.introNode,
|
|
79
|
-
setValue: (s, v) => {
|
|
80
|
-
(s as GameFlow).config.introNode = typeof v === 'string' ? v : '';
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
name: 'endNode',
|
|
85
|
-
type: 'string',
|
|
86
|
-
ui: { label: 'End Screen', group: 'Flow' },
|
|
87
|
-
getValue: s => (s as GameFlow).config.endNode,
|
|
88
|
-
setValue: (s, v) => {
|
|
89
|
-
(s as GameFlow).config.endNode = typeof v === 'string' ? v : '';
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
name: 'autoWinAfterSec',
|
|
94
|
-
type: 'number',
|
|
95
|
-
ui: {
|
|
96
|
-
label: 'Auto Win After (s)',
|
|
97
|
-
description: 'Placeholder timer that reveals the end screen (0 = disabled)',
|
|
98
|
-
group: 'Flow',
|
|
99
|
-
min: 0,
|
|
100
|
-
step: 1,
|
|
101
|
-
},
|
|
102
|
-
getValue: s => (s as GameFlow).config.autoWinAfterSec,
|
|
103
|
-
setValue: (s, v) => {
|
|
104
|
-
(s as GameFlow).config.autoWinAfterSec = Math.max(0, Number(v) || 0);
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
name: 'skipIntro',
|
|
109
|
-
type: 'boolean',
|
|
110
|
-
ui: {
|
|
111
|
-
label: 'Skip Intro',
|
|
112
|
-
description:
|
|
113
|
-
'Start already playing, with the tap gate hidden. For prototyping only — a shipped playable needs the tap to unlock audio.',
|
|
114
|
-
group: 'Flow',
|
|
115
|
-
},
|
|
116
|
-
getValue: s => (s as GameFlow).config.skipIntro === true,
|
|
117
|
-
setValue: (s, v) => {
|
|
118
|
-
(s as GameFlow).config.skipIntro = v === true;
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
],
|
|
122
|
-
groups: { Flow: { label: 'Game Flow', expanded: true } },
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
onStart(): void {
|
|
127
|
-
const commands = this.scene?.commands;
|
|
128
|
-
this.disposeCommands = [
|
|
129
|
-
commands?.register('start-game', () => this.start(), {
|
|
130
|
-
description: 'Leave the intro overlay and begin play.',
|
|
131
|
-
}),
|
|
132
|
-
// `finish` is registered for the same reason `start-game` is: a test (and the
|
|
133
|
-
// rest of the game) must be able to END the run by intent instead of waiting
|
|
134
|
-
// out the placeholder timer — and a routine that reached in and called
|
|
135
|
-
// `finish()` directly would prove the method works while skipping the wire
|
|
136
|
-
// from the player to it. Replace the timer with your win/lose condition and
|
|
137
|
-
// dispatch this from there.
|
|
138
|
-
commands?.register('finish', () => this.finish(), {
|
|
139
|
-
description: 'End the run and reveal the end screen.',
|
|
140
|
-
}),
|
|
141
|
-
commands?.register('restart', () => this.restart(), {
|
|
142
|
-
description: 'Return to the tap-to-start gate and arm a fresh run.',
|
|
143
|
-
}),
|
|
144
|
-
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
145
|
-
|
|
146
|
-
this.disposeDebug = registerGameDebug({
|
|
147
|
-
name: 'playable-3d',
|
|
148
|
-
version: 1,
|
|
149
|
-
// The registry IS the action list — never a second, hand-kept copy.
|
|
150
|
-
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
151
|
-
// What "the start" means for this playable, so a test can put the game back
|
|
152
|
-
// without restarting the scene (a restart keeps whatever a script parked in
|
|
153
|
-
// module state; this does not). It is `restart()` plus the one thing that
|
|
154
|
-
// lives OUTSIDE the scene and would otherwise leak into the next run: the
|
|
155
|
-
// SDK's session `gameEnded` flag, which `playable.gameEnd()` latched.
|
|
156
|
-
// `seed` is accepted and ignored — the placeholder flow has no randomness,
|
|
157
|
-
// so this restores the same starting state, not the same sequence. Seed
|
|
158
|
-
// your own RNG here once you add one.
|
|
159
|
-
reset: () => {
|
|
160
|
-
this.restart();
|
|
161
|
-
playable.reset();
|
|
162
|
-
},
|
|
163
|
-
snapshot: () => ({
|
|
164
|
-
phase: this.phase,
|
|
165
|
-
elapsedSec: Math.round(this.elapsed * 100) / 100,
|
|
166
|
-
autoWinAfterSec: Number(this.config.autoWinAfterSec) || 0,
|
|
167
|
-
skipIntro: this.config.skipIntro === true,
|
|
168
|
-
introVisible: this.isNodeVisible(String(this.config.introNode ?? '')),
|
|
169
|
-
endVisible: this.isNodeVisible(String(this.config.endNode ?? '')),
|
|
170
|
-
// Set by playable.gameEnd() — the CTA button reports the session as over.
|
|
171
|
-
gameEnded: playable.hasGameEnded(),
|
|
172
|
-
// Score/lives/clock, when the scene carries the bookkeeping component. Merged into
|
|
173
|
-
// THIS snapshot rather than published as a second provider: `registerGameDebug` keeps
|
|
174
|
-
// one, so a second call would silently displace this one.
|
|
175
|
-
...(this.rules()?.bookkeeping() ?? {}),
|
|
176
|
-
}),
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
if (this.config.skipIntro === true) {
|
|
180
|
-
this.openTheGate();
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
onDetach(): void {
|
|
185
|
-
this.disposeDebug?.();
|
|
186
|
-
this.disposeDebug = null;
|
|
187
|
-
// The scene's registry is cleared on stop anyway; this covers the other
|
|
188
|
-
// case — this script being detached while the scene keeps running.
|
|
189
|
-
for (const dispose of this.disposeCommands) {
|
|
190
|
-
dispose();
|
|
191
|
-
}
|
|
192
|
-
this.disposeCommands = [];
|
|
193
|
-
super.onDetach();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
onUpdate(dt: number): void {
|
|
197
|
-
if (this.phase === 'intro') {
|
|
198
|
-
const tapped = this.input?.pointerEvents.some(e => e.type === 'down') ?? false;
|
|
199
|
-
if (tapped) {
|
|
200
|
-
// Raised through the registry, so the tap-to-start gate shows up in the
|
|
201
|
-
// command journal exactly like a dispatched `start-game` would.
|
|
202
|
-
this.scene?.commands.dispatch('start-game');
|
|
203
|
-
}
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (this.phase === 'playing') {
|
|
208
|
-
const autoWin = Number(this.config.autoWinAfterSec) || 0;
|
|
209
|
-
if (autoWin > 0) {
|
|
210
|
-
this.elapsed += dt;
|
|
211
|
-
if (this.elapsed >= autoWin) {
|
|
212
|
-
this.finish();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Intent: leave the intro overlay and begin play. Raised by the first tap. */
|
|
219
|
-
start(): void {
|
|
220
|
-
if (this.phase !== 'intro') {
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
this.openTheGate();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Hide the gate and go to `playing`. Shared by the tap and by `skipIntro`, so the two
|
|
228
|
-
* cannot drift into two slightly different notions of "the run has begun".
|
|
229
|
-
*/
|
|
230
|
-
private openTheGate(): void {
|
|
231
|
-
this.setNodeVisible(String(this.config.introNode ?? ''), false);
|
|
232
|
-
this.phase = 'playing';
|
|
233
|
-
// The run begins HERE, not when the scene loads: a clock started behind the tap gate
|
|
234
|
-
// measures how long the overlay was on screen.
|
|
235
|
-
this.rules()?.startRun?.();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Intent: end the run and reveal the end screen. Call this from game code on win/lose. */
|
|
239
|
-
finish(): void {
|
|
240
|
-
if (this.phase === 'ended') {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
this.phase = 'ended';
|
|
244
|
-
// Tell the rules before revealing the screen: they write the outcome text into the end
|
|
245
|
-
// label, and they must stop ticking — otherwise the run reads as `ended` with no
|
|
246
|
-
// outcome, and a time limit fires a second ending behind the end screen.
|
|
247
|
-
this.rules()?.endRun?.();
|
|
248
|
-
this.setNodeVisible(String(this.config.endNode ?? ''), true);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Intent: arm a fresh run — back at the tap-to-start gate, or straight into play when
|
|
253
|
-
* `skipIntro` is on. A restart that re-raised a gate the project has turned off would
|
|
254
|
-
* strand every routine that restarts between checks behind a tap nobody is going to make.
|
|
255
|
-
*/
|
|
256
|
-
restart(): void {
|
|
257
|
-
this.phase = 'intro';
|
|
258
|
-
this.elapsed = 0;
|
|
259
|
-
this.setNodeVisible(String(this.config.endNode ?? ''), false);
|
|
260
|
-
this.setNodeVisible(String(this.config.introNode ?? ''), true);
|
|
261
|
-
// One intent arms a fresh run everywhere: a restart that puts the gate back but keeps
|
|
262
|
-
// last run's score is the kind of half-reset that makes a routine's second pass lie.
|
|
263
|
-
this.rules()?.resetRun();
|
|
264
|
-
if (this.config.skipIntro === true) {
|
|
265
|
-
this.openTheGate();
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* The bookkeeping component on this node, if the scene has one (`GameRules`).
|
|
271
|
-
*
|
|
272
|
-
* Duck-typed rather than imported: delete `GameRules` and this template still runs the
|
|
273
|
-
* gate, the timer and the CTA exactly as it did before — the two scripts know about each
|
|
274
|
-
* other only through the two methods named here.
|
|
275
|
-
*/
|
|
276
|
-
private rules(): RunBookkeeping | null {
|
|
277
|
-
for (const component of this.node?.components ?? []) {
|
|
278
|
-
const candidate = component as unknown as Partial<RunBookkeeping>;
|
|
279
|
-
if (
|
|
280
|
-
component !== this &&
|
|
281
|
-
typeof candidate.bookkeeping === 'function' &&
|
|
282
|
-
typeof candidate.resetRun === 'function'
|
|
283
|
-
) {
|
|
284
|
-
return candidate as RunBookkeeping;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private setNodeVisible(query: string, visible: boolean): void {
|
|
291
|
-
if (!query) {
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
const node = this.findNode(query);
|
|
295
|
-
if (node) {
|
|
296
|
-
node.visible = visible;
|
|
297
|
-
} else {
|
|
298
|
-
console.warn(`[GameFlow] Node "${query}" not found.`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Own `visible` flag of a configured node, or null when it is not in the scene. */
|
|
303
|
-
private isNodeVisible(query: string): boolean | null {
|
|
304
|
-
if (!query) {
|
|
305
|
-
return null;
|
|
306
|
-
}
|
|
307
|
-
return this.findNode(query)?.visible ?? null;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
@@ -1,290 +0,0 @@
|
|
|
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
|
-
}
|