@pix3/cli 1.6.0 → 1.6.2
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-7MZGPAUK.mjs} +1 -1
- package/dist/smoke/prebuilt/chunk-7WOFGLW3.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-HKG6YI6Y.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 +32 -24
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -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,256 +1,256 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GameFlow — the playable's phase driver: intro → playing → ended.
|
|
3
|
-
*
|
|
4
|
-
* Attach to `game-root`. While the intro overlay is visible, the first tap
|
|
5
|
-
* hides it and starts the game (the tap also unlocks browser audio — the
|
|
6
|
-
* engine's AudioService resumes its context on the first gesture). The end
|
|
7
|
-
* screen is revealed either by calling `finish()` from game code or, as a
|
|
8
|
-
* placeholder, after `autoWinAfterSec` seconds. Replace the timer with your
|
|
9
|
-
* real win/lose condition.
|
|
10
|
-
*
|
|
11
|
-
* `skipIntro` starts the run already playing, gate hidden — a prototyping vent, so
|
|
12
|
-
* that iterating on the game does not cost a tap per reload. Turn it back off before
|
|
13
|
-
* shipping: in an ad container the first tap is what unlocks browser audio, so a
|
|
14
|
-
* playable that never gates on one is a playable that plays silently.
|
|
15
|
-
*
|
|
16
|
-
* Two conventions here are worth keeping as you replace the placeholder:
|
|
17
|
-
*
|
|
18
|
-
* - **Intent-first handlers.** Every reaction to the player lives in a named
|
|
19
|
-
* public method marked `Intent:` (`start`, `finish`, `restart`), and the ones
|
|
20
|
-
* a test drives are registered as commands (`scene.commands`) that the event
|
|
21
|
-
* handler dispatches. Game code and automated tests then drive the game by
|
|
22
|
-
* intent instead of by simulated pixels — `scene.commands.dispatch('restart')`
|
|
23
|
-
* — the flow survives any relayout of the UI, and every raised intent is
|
|
24
|
-
* journalled with the frame it happened on.
|
|
25
|
-
* - **A debug provider.** `registerGameDebug` publishes a JSON snapshot of the
|
|
26
|
-
* run so external tooling (the editor's debug bridge, DevTools, an agent) can
|
|
27
|
-
* read the phase instead of guessing it from the screen. Add your own fields
|
|
28
|
-
* as you add state — keep everything JSON-serialisable.
|
|
29
|
-
*/
|
|
30
|
-
import { Script, playable, registerGameDebug, type PropertySchema } from '@pix3/runtime';
|
|
31
|
-
|
|
32
|
-
export class GameFlow extends Script {
|
|
33
|
-
private phase: 'intro' | 'playing' | 'ended' = 'intro';
|
|
34
|
-
private elapsed = 0;
|
|
35
|
-
private disposeDebug: (() => void) | null = null;
|
|
36
|
-
private disposeCommands: (() => void)[] = [];
|
|
37
|
-
|
|
38
|
-
constructor(id: string, type: string) {
|
|
39
|
-
super(id, type);
|
|
40
|
-
this.config = {
|
|
41
|
-
// Node id/name of the tap-to-start overlay.
|
|
42
|
-
introNode: 'intro-overlay',
|
|
43
|
-
// Node id/name of the end screen with the CTA button.
|
|
44
|
-
endNode: 'end-screen',
|
|
45
|
-
// Placeholder auto-win timer in seconds (0 = never; call finish() instead).
|
|
46
|
-
autoWinAfterSec: 15,
|
|
47
|
-
// Prototyping vent: start in `playing` with the gate already gone.
|
|
48
|
-
skipIntro: false,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
static getPropertySchema(): PropertySchema {
|
|
53
|
-
return {
|
|
54
|
-
nodeType: 'GameFlow',
|
|
55
|
-
properties: [
|
|
56
|
-
{
|
|
57
|
-
name: 'introNode',
|
|
58
|
-
type: 'string',
|
|
59
|
-
ui: { label: 'Intro Overlay', group: 'Flow' },
|
|
60
|
-
getValue: s => (s as GameFlow).config.introNode,
|
|
61
|
-
setValue: (s, v) => {
|
|
62
|
-
(s as GameFlow).config.introNode = typeof v === 'string' ? v : '';
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: 'endNode',
|
|
67
|
-
type: 'string',
|
|
68
|
-
ui: { label: 'End Screen', group: 'Flow' },
|
|
69
|
-
getValue: s => (s as GameFlow).config.endNode,
|
|
70
|
-
setValue: (s, v) => {
|
|
71
|
-
(s as GameFlow).config.endNode = typeof v === 'string' ? v : '';
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
name: 'autoWinAfterSec',
|
|
76
|
-
type: 'number',
|
|
77
|
-
ui: {
|
|
78
|
-
label: 'Auto Win After (s)',
|
|
79
|
-
description: 'Placeholder timer that reveals the end screen (0 = disabled)',
|
|
80
|
-
group: 'Flow',
|
|
81
|
-
min: 0,
|
|
82
|
-
step: 1,
|
|
83
|
-
},
|
|
84
|
-
getValue: s => (s as GameFlow).config.autoWinAfterSec,
|
|
85
|
-
setValue: (s, v) => {
|
|
86
|
-
(s as GameFlow).config.autoWinAfterSec = Math.max(0, Number(v) || 0);
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
name: 'skipIntro',
|
|
91
|
-
type: 'boolean',
|
|
92
|
-
ui: {
|
|
93
|
-
label: 'Skip Intro',
|
|
94
|
-
description:
|
|
95
|
-
'Start already playing, with the tap gate hidden. For prototyping only — a shipped playable needs the tap to unlock audio.',
|
|
96
|
-
group: 'Flow',
|
|
97
|
-
},
|
|
98
|
-
getValue: s => (s as GameFlow).config.skipIntro === true,
|
|
99
|
-
setValue: (s, v) => {
|
|
100
|
-
(s as GameFlow).config.skipIntro = v === true;
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
],
|
|
104
|
-
groups: { Flow: { label: 'Game Flow', expanded: true } },
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
onStart(): void {
|
|
109
|
-
const commands = this.scene?.commands;
|
|
110
|
-
this.disposeCommands = [
|
|
111
|
-
commands?.register('start-game', () => this.start(), {
|
|
112
|
-
description: 'Leave the intro overlay and begin play.',
|
|
113
|
-
}),
|
|
114
|
-
// `finish` is registered for the same reason `start-game` is: a test (and the
|
|
115
|
-
// rest of the game) must be able to END the run by intent instead of waiting
|
|
116
|
-
// out the placeholder timer — and a routine that reached in and called
|
|
117
|
-
// `finish()` directly would prove the method works while skipping the wire
|
|
118
|
-
// from the player to it. Replace the timer with your win/lose condition and
|
|
119
|
-
// dispatch this from there.
|
|
120
|
-
commands?.register('finish', () => this.finish(), {
|
|
121
|
-
description: 'End the run and reveal the end screen.',
|
|
122
|
-
}),
|
|
123
|
-
commands?.register('restart', () => this.restart(), {
|
|
124
|
-
description: 'Return to the tap-to-start gate and arm a fresh run.',
|
|
125
|
-
}),
|
|
126
|
-
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
127
|
-
|
|
128
|
-
this.disposeDebug = registerGameDebug({
|
|
129
|
-
name: 'playable-2d',
|
|
130
|
-
version: 1,
|
|
131
|
-
// The registry IS the action list — never a second, hand-kept copy.
|
|
132
|
-
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
133
|
-
// What "the start" means for this playable, so a test can put the game back
|
|
134
|
-
// without restarting the scene (a restart keeps whatever a script parked in
|
|
135
|
-
// module state; this does not). It is `restart()` plus the one thing that
|
|
136
|
-
// lives OUTSIDE the scene and would otherwise leak into the next run: the
|
|
137
|
-
// SDK's session `gameEnded` flag, which `playable.gameEnd()` latched.
|
|
138
|
-
// `seed` is accepted and ignored — the placeholder flow has no randomness,
|
|
139
|
-
// so this restores the same starting state, not the same sequence. Seed
|
|
140
|
-
// your own RNG here once you add one.
|
|
141
|
-
reset: () => {
|
|
142
|
-
this.restart();
|
|
143
|
-
playable.reset();
|
|
144
|
-
},
|
|
145
|
-
snapshot: () => ({
|
|
146
|
-
phase: this.phase,
|
|
147
|
-
elapsedSec: Math.round(this.elapsed * 100) / 100,
|
|
148
|
-
autoWinAfterSec: Number(this.config.autoWinAfterSec) || 0,
|
|
149
|
-
skipIntro: this.config.skipIntro === true,
|
|
150
|
-
introVisible: this.isNodeVisible(String(this.config.introNode ?? '')),
|
|
151
|
-
endVisible: this.isNodeVisible(String(this.config.endNode ?? '')),
|
|
152
|
-
// Set by playable.gameEnd() — the CTA button reports the session as over.
|
|
153
|
-
gameEnded: playable.hasGameEnded(),
|
|
154
|
-
}),
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
if (this.config.skipIntro === true) {
|
|
158
|
-
this.openTheGate();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
onDetach(): void {
|
|
163
|
-
this.disposeDebug?.();
|
|
164
|
-
this.disposeDebug = null;
|
|
165
|
-
// The scene's registry is cleared on stop anyway; this covers the other
|
|
166
|
-
// case — this script being detached while the scene keeps running.
|
|
167
|
-
for (const dispose of this.disposeCommands) {
|
|
168
|
-
dispose();
|
|
169
|
-
}
|
|
170
|
-
this.disposeCommands = [];
|
|
171
|
-
super.onDetach();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
onUpdate(dt: number): void {
|
|
175
|
-
if (this.phase === 'intro') {
|
|
176
|
-
const tapped = this.input?.pointerEvents.some(e => e.type === 'down') ?? false;
|
|
177
|
-
if (tapped) {
|
|
178
|
-
// Raised through the registry, so the tap-to-start gate shows up in the
|
|
179
|
-
// command journal exactly like a dispatched `start-game` would.
|
|
180
|
-
this.scene?.commands.dispatch('start-game');
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (this.phase === 'playing') {
|
|
186
|
-
const autoWin = Number(this.config.autoWinAfterSec) || 0;
|
|
187
|
-
if (autoWin > 0) {
|
|
188
|
-
this.elapsed += dt;
|
|
189
|
-
if (this.elapsed >= autoWin) {
|
|
190
|
-
this.finish();
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Intent: leave the intro overlay and begin play. Raised by the first tap. */
|
|
197
|
-
start(): void {
|
|
198
|
-
if (this.phase !== 'intro') {
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
this.openTheGate();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Hide the gate and go to `playing`. Shared by the tap and by `skipIntro`, so the two
|
|
206
|
-
* cannot drift into two slightly different notions of "the run has begun".
|
|
207
|
-
*/
|
|
208
|
-
private openTheGate(): void {
|
|
209
|
-
this.setNodeVisible(String(this.config.introNode ?? ''), false);
|
|
210
|
-
this.phase = 'playing';
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Intent: end the run and reveal the end screen. Call this from game code on win/lose. */
|
|
214
|
-
finish(): void {
|
|
215
|
-
if (this.phase === 'ended') {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
this.phase = 'ended';
|
|
219
|
-
this.setNodeVisible(String(this.config.endNode ?? ''), true);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Intent: arm a fresh run — back at the tap-to-start gate, or straight into play when
|
|
224
|
-
* `skipIntro` is on. A restart that re-raised a gate the project has turned off would
|
|
225
|
-
* strand every routine that restarts between checks behind a tap nobody is going to make.
|
|
226
|
-
*/
|
|
227
|
-
restart(): void {
|
|
228
|
-
this.phase = 'intro';
|
|
229
|
-
this.elapsed = 0;
|
|
230
|
-
this.setNodeVisible(String(this.config.endNode ?? ''), false);
|
|
231
|
-
this.setNodeVisible(String(this.config.introNode ?? ''), true);
|
|
232
|
-
if (this.config.skipIntro === true) {
|
|
233
|
-
this.openTheGate();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
private setNodeVisible(query: string, visible: boolean): void {
|
|
238
|
-
if (!query) {
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
const node = this.findNode(query);
|
|
242
|
-
if (node) {
|
|
243
|
-
node.visible = visible;
|
|
244
|
-
} else {
|
|
245
|
-
console.warn(`[GameFlow] Node "${query}" not found.`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Own `visible` flag of a configured node, or null when it is not in the scene. */
|
|
250
|
-
private isNodeVisible(query: string): boolean | null {
|
|
251
|
-
if (!query) {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
return this.findNode(query)?.visible ?? null;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* GameFlow — the playable's phase driver: intro → playing → ended.
|
|
3
|
+
*
|
|
4
|
+
* Attach to `game-root`. While the intro overlay is visible, the first tap
|
|
5
|
+
* hides it and starts the game (the tap also unlocks browser audio — the
|
|
6
|
+
* engine's AudioService resumes its context on the first gesture). The end
|
|
7
|
+
* screen is revealed either by calling `finish()` from game code or, as a
|
|
8
|
+
* placeholder, after `autoWinAfterSec` seconds. Replace the timer with your
|
|
9
|
+
* real win/lose condition.
|
|
10
|
+
*
|
|
11
|
+
* `skipIntro` starts the run already playing, gate hidden — a prototyping vent, so
|
|
12
|
+
* that iterating on the game does not cost a tap per reload. Turn it back off before
|
|
13
|
+
* shipping: in an ad container the first tap is what unlocks browser audio, so a
|
|
14
|
+
* playable that never gates on one is a playable that plays silently.
|
|
15
|
+
*
|
|
16
|
+
* Two conventions here are worth keeping as you replace the placeholder:
|
|
17
|
+
*
|
|
18
|
+
* - **Intent-first handlers.** Every reaction to the player lives in a named
|
|
19
|
+
* public method marked `Intent:` (`start`, `finish`, `restart`), and the ones
|
|
20
|
+
* a test drives are registered as commands (`scene.commands`) that the event
|
|
21
|
+
* handler dispatches. Game code and automated tests then drive the game by
|
|
22
|
+
* intent instead of by simulated pixels — `scene.commands.dispatch('restart')`
|
|
23
|
+
* — the flow survives any relayout of the UI, and every raised intent is
|
|
24
|
+
* journalled with the frame it happened on.
|
|
25
|
+
* - **A debug provider.** `registerGameDebug` publishes a JSON snapshot of the
|
|
26
|
+
* run so external tooling (the editor's debug bridge, DevTools, an agent) can
|
|
27
|
+
* read the phase instead of guessing it from the screen. Add your own fields
|
|
28
|
+
* as you add state — keep everything JSON-serialisable.
|
|
29
|
+
*/
|
|
30
|
+
import { Script, playable, registerGameDebug, type PropertySchema } from '@pix3/runtime';
|
|
31
|
+
|
|
32
|
+
export class GameFlow extends Script {
|
|
33
|
+
private phase: 'intro' | 'playing' | 'ended' = 'intro';
|
|
34
|
+
private elapsed = 0;
|
|
35
|
+
private disposeDebug: (() => void) | null = null;
|
|
36
|
+
private disposeCommands: (() => void)[] = [];
|
|
37
|
+
|
|
38
|
+
constructor(id: string, type: string) {
|
|
39
|
+
super(id, type);
|
|
40
|
+
this.config = {
|
|
41
|
+
// Node id/name of the tap-to-start overlay.
|
|
42
|
+
introNode: 'intro-overlay',
|
|
43
|
+
// Node id/name of the end screen with the CTA button.
|
|
44
|
+
endNode: 'end-screen',
|
|
45
|
+
// Placeholder auto-win timer in seconds (0 = never; call finish() instead).
|
|
46
|
+
autoWinAfterSec: 15,
|
|
47
|
+
// Prototyping vent: start in `playing` with the gate already gone.
|
|
48
|
+
skipIntro: false,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static getPropertySchema(): PropertySchema {
|
|
53
|
+
return {
|
|
54
|
+
nodeType: 'GameFlow',
|
|
55
|
+
properties: [
|
|
56
|
+
{
|
|
57
|
+
name: 'introNode',
|
|
58
|
+
type: 'string',
|
|
59
|
+
ui: { label: 'Intro Overlay', group: 'Flow' },
|
|
60
|
+
getValue: s => (s as GameFlow).config.introNode,
|
|
61
|
+
setValue: (s, v) => {
|
|
62
|
+
(s as GameFlow).config.introNode = typeof v === 'string' ? v : '';
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'endNode',
|
|
67
|
+
type: 'string',
|
|
68
|
+
ui: { label: 'End Screen', group: 'Flow' },
|
|
69
|
+
getValue: s => (s as GameFlow).config.endNode,
|
|
70
|
+
setValue: (s, v) => {
|
|
71
|
+
(s as GameFlow).config.endNode = typeof v === 'string' ? v : '';
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'autoWinAfterSec',
|
|
76
|
+
type: 'number',
|
|
77
|
+
ui: {
|
|
78
|
+
label: 'Auto Win After (s)',
|
|
79
|
+
description: 'Placeholder timer that reveals the end screen (0 = disabled)',
|
|
80
|
+
group: 'Flow',
|
|
81
|
+
min: 0,
|
|
82
|
+
step: 1,
|
|
83
|
+
},
|
|
84
|
+
getValue: s => (s as GameFlow).config.autoWinAfterSec,
|
|
85
|
+
setValue: (s, v) => {
|
|
86
|
+
(s as GameFlow).config.autoWinAfterSec = Math.max(0, Number(v) || 0);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'skipIntro',
|
|
91
|
+
type: 'boolean',
|
|
92
|
+
ui: {
|
|
93
|
+
label: 'Skip Intro',
|
|
94
|
+
description:
|
|
95
|
+
'Start already playing, with the tap gate hidden. For prototyping only — a shipped playable needs the tap to unlock audio.',
|
|
96
|
+
group: 'Flow',
|
|
97
|
+
},
|
|
98
|
+
getValue: s => (s as GameFlow).config.skipIntro === true,
|
|
99
|
+
setValue: (s, v) => {
|
|
100
|
+
(s as GameFlow).config.skipIntro = v === true;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
groups: { Flow: { label: 'Game Flow', expanded: true } },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onStart(): void {
|
|
109
|
+
const commands = this.scene?.commands;
|
|
110
|
+
this.disposeCommands = [
|
|
111
|
+
commands?.register('start-game', () => this.start(), {
|
|
112
|
+
description: 'Leave the intro overlay and begin play.',
|
|
113
|
+
}),
|
|
114
|
+
// `finish` is registered for the same reason `start-game` is: a test (and the
|
|
115
|
+
// rest of the game) must be able to END the run by intent instead of waiting
|
|
116
|
+
// out the placeholder timer — and a routine that reached in and called
|
|
117
|
+
// `finish()` directly would prove the method works while skipping the wire
|
|
118
|
+
// from the player to it. Replace the timer with your win/lose condition and
|
|
119
|
+
// dispatch this from there.
|
|
120
|
+
commands?.register('finish', () => this.finish(), {
|
|
121
|
+
description: 'End the run and reveal the end screen.',
|
|
122
|
+
}),
|
|
123
|
+
commands?.register('restart', () => this.restart(), {
|
|
124
|
+
description: 'Return to the tap-to-start gate and arm a fresh run.',
|
|
125
|
+
}),
|
|
126
|
+
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
127
|
+
|
|
128
|
+
this.disposeDebug = registerGameDebug({
|
|
129
|
+
name: 'playable-2d',
|
|
130
|
+
version: 1,
|
|
131
|
+
// The registry IS the action list — never a second, hand-kept copy.
|
|
132
|
+
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
133
|
+
// What "the start" means for this playable, so a test can put the game back
|
|
134
|
+
// without restarting the scene (a restart keeps whatever a script parked in
|
|
135
|
+
// module state; this does not). It is `restart()` plus the one thing that
|
|
136
|
+
// lives OUTSIDE the scene and would otherwise leak into the next run: the
|
|
137
|
+
// SDK's session `gameEnded` flag, which `playable.gameEnd()` latched.
|
|
138
|
+
// `seed` is accepted and ignored — the placeholder flow has no randomness,
|
|
139
|
+
// so this restores the same starting state, not the same sequence. Seed
|
|
140
|
+
// your own RNG here once you add one.
|
|
141
|
+
reset: () => {
|
|
142
|
+
this.restart();
|
|
143
|
+
playable.reset();
|
|
144
|
+
},
|
|
145
|
+
snapshot: () => ({
|
|
146
|
+
phase: this.phase,
|
|
147
|
+
elapsedSec: Math.round(this.elapsed * 100) / 100,
|
|
148
|
+
autoWinAfterSec: Number(this.config.autoWinAfterSec) || 0,
|
|
149
|
+
skipIntro: this.config.skipIntro === true,
|
|
150
|
+
introVisible: this.isNodeVisible(String(this.config.introNode ?? '')),
|
|
151
|
+
endVisible: this.isNodeVisible(String(this.config.endNode ?? '')),
|
|
152
|
+
// Set by playable.gameEnd() — the CTA button reports the session as over.
|
|
153
|
+
gameEnded: playable.hasGameEnded(),
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (this.config.skipIntro === true) {
|
|
158
|
+
this.openTheGate();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
onDetach(): void {
|
|
163
|
+
this.disposeDebug?.();
|
|
164
|
+
this.disposeDebug = null;
|
|
165
|
+
// The scene's registry is cleared on stop anyway; this covers the other
|
|
166
|
+
// case — this script being detached while the scene keeps running.
|
|
167
|
+
for (const dispose of this.disposeCommands) {
|
|
168
|
+
dispose();
|
|
169
|
+
}
|
|
170
|
+
this.disposeCommands = [];
|
|
171
|
+
super.onDetach();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onUpdate(dt: number): void {
|
|
175
|
+
if (this.phase === 'intro') {
|
|
176
|
+
const tapped = this.input?.pointerEvents.some(e => e.type === 'down') ?? false;
|
|
177
|
+
if (tapped) {
|
|
178
|
+
// Raised through the registry, so the tap-to-start gate shows up in the
|
|
179
|
+
// command journal exactly like a dispatched `start-game` would.
|
|
180
|
+
this.scene?.commands.dispatch('start-game');
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.phase === 'playing') {
|
|
186
|
+
const autoWin = Number(this.config.autoWinAfterSec) || 0;
|
|
187
|
+
if (autoWin > 0) {
|
|
188
|
+
this.elapsed += dt;
|
|
189
|
+
if (this.elapsed >= autoWin) {
|
|
190
|
+
this.finish();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Intent: leave the intro overlay and begin play. Raised by the first tap. */
|
|
197
|
+
start(): void {
|
|
198
|
+
if (this.phase !== 'intro') {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.openTheGate();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Hide the gate and go to `playing`. Shared by the tap and by `skipIntro`, so the two
|
|
206
|
+
* cannot drift into two slightly different notions of "the run has begun".
|
|
207
|
+
*/
|
|
208
|
+
private openTheGate(): void {
|
|
209
|
+
this.setNodeVisible(String(this.config.introNode ?? ''), false);
|
|
210
|
+
this.phase = 'playing';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Intent: end the run and reveal the end screen. Call this from game code on win/lose. */
|
|
214
|
+
finish(): void {
|
|
215
|
+
if (this.phase === 'ended') {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.phase = 'ended';
|
|
219
|
+
this.setNodeVisible(String(this.config.endNode ?? ''), true);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Intent: arm a fresh run — back at the tap-to-start gate, or straight into play when
|
|
224
|
+
* `skipIntro` is on. A restart that re-raised a gate the project has turned off would
|
|
225
|
+
* strand every routine that restarts between checks behind a tap nobody is going to make.
|
|
226
|
+
*/
|
|
227
|
+
restart(): void {
|
|
228
|
+
this.phase = 'intro';
|
|
229
|
+
this.elapsed = 0;
|
|
230
|
+
this.setNodeVisible(String(this.config.endNode ?? ''), false);
|
|
231
|
+
this.setNodeVisible(String(this.config.introNode ?? ''), true);
|
|
232
|
+
if (this.config.skipIntro === true) {
|
|
233
|
+
this.openTheGate();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private setNodeVisible(query: string, visible: boolean): void {
|
|
238
|
+
if (!query) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const node = this.findNode(query);
|
|
242
|
+
if (node) {
|
|
243
|
+
node.visible = visible;
|
|
244
|
+
} else {
|
|
245
|
+
console.warn(`[GameFlow] Node "${query}" not found.`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Own `visible` flag of a configured node, or null when it is not in the scene. */
|
|
250
|
+
private isNodeVisible(query: string): boolean | null {
|
|
251
|
+
if (!query) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return this.findNode(query)?.visible ?? null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
id: playable-2d
|
|
2
|
-
title: Playable 2D
|
|
3
|
-
description: Portrait 2D playable-ad blank — tap-to-start intro (unlocks audio), gameplay placeholder, end screen with a store CTA button.
|
|
4
|
-
# Flow recipe this template serves (see design/recipe.md).
|
|
5
|
-
recipeId: recipe-playable-ad
|
|
6
|
-
projectType: 2d
|
|
7
|
-
targetPlatform: mobile
|
|
8
|
-
viewport: { width: 1080, height: 1920 }
|
|
9
|
-
order: 40
|
|
1
|
+
id: playable-2d
|
|
2
|
+
title: Playable 2D
|
|
3
|
+
description: Portrait 2D playable-ad blank — tap-to-start intro (unlocks audio), gameplay placeholder, end screen with a store CTA button.
|
|
4
|
+
# Flow recipe this template serves (see design/recipe.md).
|
|
5
|
+
recipeId: recipe-playable-ad
|
|
6
|
+
projectType: 2d
|
|
7
|
+
targetPlatform: mobile
|
|
8
|
+
viewport: { width: 1080, height: 1920 }
|
|
9
|
+
order: 40
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
# {{PROJECT_NAME}}
|
|
2
|
-
|
|
3
|
-
A Pix3 project created from the **Playable 3D** template — a portrait playable-ad blank.
|
|
4
|
-
|
|
5
|
-
## What's included
|
|
6
|
-
|
|
7
|
-
- **Tap-to-start intro** (`intro-overlay`, `scenes/ui/intro.pix3scene`) — the first tap unlocks browser audio and starts the game (`scripts/GameFlow.ts`).
|
|
8
|
-
- **Gameplay placeholder** — replace the demo box and ground in `main.pix3scene` with your game.
|
|
9
|
-
- **End screen with CTA** (`end-screen`, `scenes/ui/end-screen.pix3scene`) — the PLAY NOW button reports game end (`playable.gameEnd()`) and logs the click. It opens nothing: the ad network decides the store target at delivery time (`mraid.open` / `dapi.openStoreUrl`), so wire its SDK in `CtaButton.ctaClick()` when you package the ad.
|
|
10
|
-
- `GameFlow` shows the end screen automatically after `autoWinAfterSec` seconds — replace that with your real win/lose condition. `skipIntro: true` starts already playing while you iterate; turn it off before shipping, since that first tap is what unlocks audio.
|
|
11
|
-
- **Bookkeeping, already built** (`scripts/GameRules.ts` + `scripts/ScoreHud.ts`) — score, lives, an optional clock, the win/lose decision and the HUD that shows them. Your mechanic calls `addScore(1)` / `loseLife()` / `finish(true)`, or emits `score-added` / `life-lost` on `hud-root`; don't write a second score counter or result screen.
|
|
12
|
-
- **Readable state** — `GameFlow` publishes the one `registerGameDebug` snapshot (phase, timer, overlay visibility, session end, plus the rules' score/lives/time), so tooling reads the run's state instead of judging it from a screenshot. Add your own fields there — a second provider would silently replace this one.
|
|
13
|
-
- **Intent-first handlers** — every reaction to the player is a named method (`GameFlow.start/finish/restart`, `CtaButton.ctaClick`) that the event handler merely calls; keep that shape and the game stays drivable by intent.
|
|
14
|
-
- **Canonical roots** — `game-root` holds the 3D scene, `hud-root` the 2D overlay layer (intro gate, HUD, end screen); routines and assertions address them by those ids.
|
|
15
|
-
|
|
16
|
-
## Project structure
|
|
17
|
-
|
|
18
|
-
- `design/` — game design document and reference images
|
|
19
|
-
- `design/tests/` — agent-testing material: `routines/*.json` (named step +
|
|
20
|
-
assertion scripts, written on the highest channel the game offers) and
|
|
21
|
-
`reachability.json` (controls proven reachable on a live run). The harness
|
|
22
|
-
that runs them is still in development; format and location are fixed now.
|
|
23
|
-
- `scenes/` — scene files; `main.pix3scene` is the startup scene
|
|
24
|
-
- `scenes/ui/` — the full-screen overlays (`intro.pix3scene`, `end-screen.pix3scene`),
|
|
25
|
-
instanced into `main.pix3scene` with `visible: false` so the editor opens on the
|
|
26
|
-
game. That flag is editor-only; `initiallyVisible` on each overlay's root is what
|
|
27
|
-
play mode reads. Edit an overlay in its own tab, or tick the eye on its instance
|
|
28
|
-
node to preview it composited — just don't save that tick.
|
|
29
|
-
- `sprites/` — images and textures
|
|
30
|
-
- `scripts/` — game scripts (`export class X extends Script`, used as `user:X`)
|
|
31
|
-
- `audio/` — music and sound effects
|
|
32
|
-
- `AGENTS.md` — rules and pointers for AI agents working on this project
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A Pix3 project created from the **Playable 3D** template — a portrait playable-ad blank.
|
|
4
|
+
|
|
5
|
+
## What's included
|
|
6
|
+
|
|
7
|
+
- **Tap-to-start intro** (`intro-overlay`, `scenes/ui/intro.pix3scene`) — the first tap unlocks browser audio and starts the game (`scripts/GameFlow.ts`).
|
|
8
|
+
- **Gameplay placeholder** — replace the demo box and ground in `main.pix3scene` with your game.
|
|
9
|
+
- **End screen with CTA** (`end-screen`, `scenes/ui/end-screen.pix3scene`) — the PLAY NOW button reports game end (`playable.gameEnd()`) and logs the click. It opens nothing: the ad network decides the store target at delivery time (`mraid.open` / `dapi.openStoreUrl`), so wire its SDK in `CtaButton.ctaClick()` when you package the ad.
|
|
10
|
+
- `GameFlow` shows the end screen automatically after `autoWinAfterSec` seconds — replace that with your real win/lose condition. `skipIntro: true` starts already playing while you iterate; turn it off before shipping, since that first tap is what unlocks audio.
|
|
11
|
+
- **Bookkeeping, already built** (`scripts/GameRules.ts` + `scripts/ScoreHud.ts`) — score, lives, an optional clock, the win/lose decision and the HUD that shows them. Your mechanic calls `addScore(1)` / `loseLife()` / `finish(true)`, or emits `score-added` / `life-lost` on `hud-root`; don't write a second score counter or result screen.
|
|
12
|
+
- **Readable state** — `GameFlow` publishes the one `registerGameDebug` snapshot (phase, timer, overlay visibility, session end, plus the rules' score/lives/time), so tooling reads the run's state instead of judging it from a screenshot. Add your own fields there — a second provider would silently replace this one.
|
|
13
|
+
- **Intent-first handlers** — every reaction to the player is a named method (`GameFlow.start/finish/restart`, `CtaButton.ctaClick`) that the event handler merely calls; keep that shape and the game stays drivable by intent.
|
|
14
|
+
- **Canonical roots** — `game-root` holds the 3D scene, `hud-root` the 2D overlay layer (intro gate, HUD, end screen); routines and assertions address them by those ids.
|
|
15
|
+
|
|
16
|
+
## Project structure
|
|
17
|
+
|
|
18
|
+
- `design/` — game design document and reference images
|
|
19
|
+
- `design/tests/` — agent-testing material: `routines/*.json` (named step +
|
|
20
|
+
assertion scripts, written on the highest channel the game offers) and
|
|
21
|
+
`reachability.json` (controls proven reachable on a live run). The harness
|
|
22
|
+
that runs them is still in development; format and location are fixed now.
|
|
23
|
+
- `scenes/` — scene files; `main.pix3scene` is the startup scene
|
|
24
|
+
- `scenes/ui/` — the full-screen overlays (`intro.pix3scene`, `end-screen.pix3scene`),
|
|
25
|
+
instanced into `main.pix3scene` with `visible: false` so the editor opens on the
|
|
26
|
+
game. That flag is editor-only; `initiallyVisible` on each overlay's root is what
|
|
27
|
+
play mode reads. Edit an overlay in its own tab, or tick the eye on its instance
|
|
28
|
+
node to preview it composited — just don't save that tick.
|
|
29
|
+
- `sprites/` — images and textures
|
|
30
|
+
- `scripts/` — game scripts (`export class X extends Script`, used as `user:X`)
|
|
31
|
+
- `audio/` — music and sound effects
|
|
32
|
+
- `AGENTS.md` — rules and pointers for AI agents working on this project
|