@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,377 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GameRules — score, lives, timer, win/lose and the end flow.
|
|
3
|
-
*
|
|
4
|
-
* Listens on its own node for the semantic signals `TouchRules` emits
|
|
5
|
-
* (`touch-scored` / `touch-damaged`), keeps the run state, and re-broadcasts
|
|
6
|
-
* `score-changed` / `lives-changed` / `time-changed` for the HUD. It is the only
|
|
7
|
-
* script that decides when the run is over.
|
|
8
|
-
*
|
|
9
|
-
* Win modes:
|
|
10
|
-
* `score` — reach `targetScore`; the time limit (if any) is a deadline.
|
|
11
|
-
* `time` — play until the timer ends, then win if `targetScore` was reached.
|
|
12
|
-
* `survive` — stay alive until the timer ends; reaching it is the win.
|
|
13
|
-
* In every mode, running out of lives loses.
|
|
14
|
-
*
|
|
15
|
-
* The end-screen buttons dispatch the commands `restart` and `return-to-menu`
|
|
16
|
-
* (`scene.commands`) instead of calling their methods: a test replays the end
|
|
17
|
-
* flow without hunting for buttons, the run's intents are journalled with the
|
|
18
|
-
* frame they happened on, and one real tap proves each binding once.
|
|
19
|
-
*
|
|
20
|
-
* It also publishes the run's state through `registerGameDebug`: this script
|
|
21
|
-
* owns everything a test needs to read (score, lives, clock, outcome), so tools
|
|
22
|
-
* and agents can assert on values instead of guessing from pixels. Add your own
|
|
23
|
-
* state to `snapshot()` as you extend the rules — keep every value JSON-safe.
|
|
24
|
-
*/
|
|
25
|
-
import {
|
|
26
|
-
Button2D,
|
|
27
|
-
Label2D,
|
|
28
|
-
NodeBase,
|
|
29
|
-
Script,
|
|
30
|
-
registerGameDebug,
|
|
31
|
-
type PropertySchema,
|
|
32
|
-
} from '@pix3/runtime';
|
|
33
|
-
|
|
34
|
-
type WinMode = 'score' | 'time' | 'survive';
|
|
35
|
-
|
|
36
|
-
/** Snapshot numbers are read by humans and diffed by tools — keep them short. */
|
|
37
|
-
function roundHundredths(value: number): number {
|
|
38
|
-
return Math.round(value * 100) / 100;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class GameRules extends Script {
|
|
42
|
-
private score = 0;
|
|
43
|
-
private lives = 3;
|
|
44
|
-
private elapsed = 0;
|
|
45
|
-
private over = false;
|
|
46
|
-
private won = false;
|
|
47
|
-
private disposeDebug: (() => void) | null = null;
|
|
48
|
-
private disposeCommands: (() => void)[] = [];
|
|
49
|
-
/** Components `finish()` switched off, so `resetRun()` revives exactly those. */
|
|
50
|
-
private frozen: { enabled: boolean }[] = [];
|
|
51
|
-
|
|
52
|
-
constructor(id: string, type: string) {
|
|
53
|
-
super(id, type);
|
|
54
|
-
this.config = {
|
|
55
|
-
winMode: 'score',
|
|
56
|
-
targetScore: 1500,
|
|
57
|
-
timeLimitSec: 0,
|
|
58
|
-
startingLives: 3,
|
|
59
|
-
resultNode: 'result-overlay',
|
|
60
|
-
resultLabel: 'result-label',
|
|
61
|
-
winText: 'YOU WIN!',
|
|
62
|
-
loseText: 'GAME OVER',
|
|
63
|
-
retryButton: 'retry-button',
|
|
64
|
-
menuButton: 'menu-button',
|
|
65
|
-
menuScene: 'res://scenes/menu.pix3scene',
|
|
66
|
-
gameScene: 'res://scenes/main.pix3scene',
|
|
67
|
-
// Comma-separated node ids frozen on game over: every component on the node and
|
|
68
|
-
// on everything under it (spawned instances included) is disabled.
|
|
69
|
-
freezeNodes: 'ball,paddle',
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
static getPropertySchema(): PropertySchema {
|
|
74
|
-
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
75
|
-
name,
|
|
76
|
-
type: 'number' as const,
|
|
77
|
-
ui: { label, group: 'Rules', min, max, step, slider: true },
|
|
78
|
-
getValue: (s: unknown) => (s as GameRules).config[name],
|
|
79
|
-
setValue: (s: unknown, v: unknown) => {
|
|
80
|
-
const n = Number(v);
|
|
81
|
-
(s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
const str = (name: string, label: string, group: string) => ({
|
|
85
|
-
name,
|
|
86
|
-
type: 'string' as const,
|
|
87
|
-
ui: { label, group },
|
|
88
|
-
getValue: (s: unknown) => (s as GameRules).config[name],
|
|
89
|
-
setValue: (s: unknown, v: unknown) => {
|
|
90
|
-
(s as GameRules).config[name] = typeof v === 'string' ? v : '';
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
nodeType: 'GameRules',
|
|
96
|
-
properties: [
|
|
97
|
-
{
|
|
98
|
-
name: 'winMode',
|
|
99
|
-
type: 'select',
|
|
100
|
-
ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
|
|
101
|
-
getValue: s => (s as GameRules).config.winMode,
|
|
102
|
-
setValue: (s, v) => {
|
|
103
|
-
(s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
num('targetScore', 'Target Score', 1, 99999, 1),
|
|
107
|
-
num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
|
|
108
|
-
num('startingLives', 'Starting Lives', 1, 20, 1),
|
|
109
|
-
str('winText', 'Win Text', 'Result'),
|
|
110
|
-
str('loseText', 'Lose Text', 'Result'),
|
|
111
|
-
str('resultNode', 'Result Overlay', 'Result'),
|
|
112
|
-
str('resultLabel', 'Result Label', 'Result'),
|
|
113
|
-
str('retryButton', 'Retry Button', 'Flow'),
|
|
114
|
-
str('menuButton', 'Menu Button', 'Flow'),
|
|
115
|
-
str('menuScene', 'Menu Scene', 'Flow'),
|
|
116
|
-
str('gameScene', 'Game Scene', 'Flow'),
|
|
117
|
-
str('freezeNodes', 'Freeze Nodes', 'Flow'),
|
|
118
|
-
],
|
|
119
|
-
groups: {
|
|
120
|
-
Rules: { label: 'Rules', expanded: true },
|
|
121
|
-
Result: { label: 'Result Screen', expanded: true },
|
|
122
|
-
Flow: { label: 'Flow', expanded: false },
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
onStart(): void {
|
|
128
|
-
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
129
|
-
const commands = this.scene?.commands;
|
|
130
|
-
this.disposeCommands = [
|
|
131
|
-
commands?.register('restart', () => this.restart(), {
|
|
132
|
-
description: 'Reload the game scene for a fresh run.',
|
|
133
|
-
}),
|
|
134
|
-
commands?.register('return-to-menu', () => this.toMenu(), {
|
|
135
|
-
description: 'Leave the run and transition back to the menu scene.',
|
|
136
|
-
}),
|
|
137
|
-
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
138
|
-
|
|
139
|
-
this.disposeDebug = registerGameDebug({
|
|
140
|
-
name: 'recipe-bouncer-2d',
|
|
141
|
-
version: 1,
|
|
142
|
-
// The registry IS the action list — never a second, hand-kept copy.
|
|
143
|
-
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
144
|
-
// What "the start" means for a run, so a test can put the game back without
|
|
145
|
-
// reloading the scene — a reload restores the scene graph but not what a
|
|
146
|
-
// script parked outside it, which is why tooling has to label the two
|
|
147
|
-
// differently. `seed` is accepted and ignored here: the recipe's spawners
|
|
148
|
-
// draw from `Math.random`, so this restores the same starting STATE, not the
|
|
149
|
-
// same SEQUENCE. Wire it to a seeded RNG if you need replayable runs.
|
|
150
|
-
reset: () => this.resetRun(),
|
|
151
|
-
snapshot: () => ({
|
|
152
|
-
scene: 'game',
|
|
153
|
-
phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
|
|
154
|
-
score: this.score,
|
|
155
|
-
lives: this.lives,
|
|
156
|
-
winMode: String(this.config.winMode ?? 'score'),
|
|
157
|
-
targetScore: Math.max(1, Number(this.config.targetScore) || 1),
|
|
158
|
-
elapsedSec: roundHundredths(this.elapsed),
|
|
159
|
-
// null when the mode has no clock at all.
|
|
160
|
-
timeLeftSec: this.timeLeftSec(),
|
|
161
|
-
}),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
const owner = this.node;
|
|
165
|
-
owner?.connect('touch-scored', this, (...args: unknown[]) =>
|
|
166
|
-
this.addScore(Number(args[0]) || 0)
|
|
167
|
-
);
|
|
168
|
-
owner?.connect('touch-damaged', this, (...args: unknown[]) =>
|
|
169
|
-
this.takeDamage(Number(args[0]) || 0)
|
|
170
|
-
);
|
|
171
|
-
// The HUD announces itself once it is connected, so it never misses the
|
|
172
|
-
// opening values (its onStart runs after this one).
|
|
173
|
-
owner?.connect('hud-ready', this, () => this.broadcast());
|
|
174
|
-
|
|
175
|
-
this.setNodeVisible(String(this.config.resultNode ?? ''), false);
|
|
176
|
-
this.bindButton(
|
|
177
|
-
String(this.config.retryButton ?? ''),
|
|
178
|
-
() => this.scene?.commands.dispatch('restart'),
|
|
179
|
-
false
|
|
180
|
-
);
|
|
181
|
-
this.bindButton(
|
|
182
|
-
String(this.config.menuButton ?? ''),
|
|
183
|
-
() => this.scene?.commands.dispatch('return-to-menu'),
|
|
184
|
-
true
|
|
185
|
-
);
|
|
186
|
-
this.broadcast();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
onUpdate(dt: number): void {
|
|
190
|
-
if (this.over) {
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
this.elapsed += dt;
|
|
194
|
-
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
195
|
-
this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
196
|
-
|
|
197
|
-
if (this.lives <= 0) {
|
|
198
|
-
this.finish(false);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const raw = String(this.config.winMode ?? 'score');
|
|
202
|
-
const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
|
|
203
|
-
const target = Math.max(1, Number(this.config.targetScore) || 1);
|
|
204
|
-
const timeUp = limit > 0 && this.elapsed >= limit;
|
|
205
|
-
|
|
206
|
-
if (mode === 'score') {
|
|
207
|
-
if (this.score >= target) this.finish(true);
|
|
208
|
-
else if (timeUp) this.finish(false);
|
|
209
|
-
} else if (mode === 'time') {
|
|
210
|
-
if (timeUp) this.finish(this.score >= target);
|
|
211
|
-
} else if (timeUp) {
|
|
212
|
-
this.finish(true);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
onDetach(): void {
|
|
217
|
-
// Clearing is safe in any order: the disposer only clears the global when it
|
|
218
|
-
// still holds *this* provider, so a scene that already registered its own wins.
|
|
219
|
-
this.disposeDebug?.();
|
|
220
|
-
this.disposeDebug = null;
|
|
221
|
-
// The scene's registry is cleared on stop anyway; this covers the other
|
|
222
|
-
// case — this script being detached while the scene keeps running.
|
|
223
|
-
for (const dispose of this.disposeCommands) {
|
|
224
|
-
dispose();
|
|
225
|
-
}
|
|
226
|
-
this.disposeCommands = [];
|
|
227
|
-
super.onDetach();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Seconds left on the clock, or `null` when this run has no time limit. */
|
|
231
|
-
private timeLeftSec(): number | null {
|
|
232
|
-
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
233
|
-
return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private addScore(amount: number): void {
|
|
237
|
-
if (this.over) return;
|
|
238
|
-
this.score += amount;
|
|
239
|
-
this.node?.emit('score-changed', this.score);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private takeDamage(amount: number): void {
|
|
243
|
-
if (this.over) return;
|
|
244
|
-
this.lives = Math.max(0, this.lives - Math.max(1, amount));
|
|
245
|
-
this.node?.emit(
|
|
246
|
-
'lives-changed',
|
|
247
|
-
this.lives,
|
|
248
|
-
Math.max(1, Number(this.config.startingLives) || 1)
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
private broadcast(): void {
|
|
253
|
-
const owner = this.node;
|
|
254
|
-
owner?.emit('score-changed', this.score);
|
|
255
|
-
owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
|
|
256
|
-
const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
|
|
257
|
-
owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** End the run. Call this from your own code for a custom win/lose condition. */
|
|
261
|
-
finish(won: boolean): void {
|
|
262
|
-
if (this.over) return;
|
|
263
|
-
this.over = true;
|
|
264
|
-
this.won = won;
|
|
265
|
-
|
|
266
|
-
const label = this.findNode(String(this.config.resultLabel ?? ''));
|
|
267
|
-
if (label instanceof Label2D) {
|
|
268
|
-
label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
|
|
269
|
-
}
|
|
270
|
-
this.setNodeVisible(String(this.config.resultNode ?? ''), true);
|
|
271
|
-
this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
|
|
272
|
-
|
|
273
|
-
// A freeze node freezes its whole subtree: the spawners' own components AND
|
|
274
|
-
// every instance they spawned under themselves (a chaser's script, a pickup's
|
|
275
|
-
// pop-in), so nothing keeps moving over the result card.
|
|
276
|
-
for (const id of String(this.config.freezeNodes ?? '').split(',')) {
|
|
277
|
-
const root = this.findNode(id.trim());
|
|
278
|
-
if (!root) continue;
|
|
279
|
-
root.traverse(object => {
|
|
280
|
-
if (!(object instanceof NodeBase)) return;
|
|
281
|
-
for (const component of object.components) {
|
|
282
|
-
// Only remember what WE switched off: reviving everything on reset would
|
|
283
|
-
// silently enable a component the scene author had left disabled.
|
|
284
|
-
if (!component.enabled) continue;
|
|
285
|
-
component.enabled = false;
|
|
286
|
-
this.frozen.push(component);
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// The run's last impression: a procedural jingle (no asset needed) and, on a
|
|
292
|
-
// win, a warm screen flash. Both are one-liners — `scene.audio.sfx` and
|
|
293
|
-
// `scene.juice.flash`.
|
|
294
|
-
const scene = this.scene;
|
|
295
|
-
scene?.audio.sfx(won ? 'win' : 'lose');
|
|
296
|
-
if (won) {
|
|
297
|
-
scene?.juice.flash({ color: '#ffe066', intensity: 0.5, durationSec: 0.35 });
|
|
298
|
-
}
|
|
299
|
-
this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
private restart(): void {
|
|
303
|
-
void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
private toMenu(): void {
|
|
307
|
-
void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Back to the state `onStart` left behind: score, lives, clock and outcome, the
|
|
312
|
-
* result overlay hidden, the freeze `finish()` applied lifted, and anything a
|
|
313
|
-
* spawner still has on the field despawned. Those leftovers are state too — a
|
|
314
|
-
* run that begins amid the previous run's targets did not begin from the start.
|
|
315
|
-
*
|
|
316
|
-
* Public because it is useful on its own (an in-run "try again" that skips the
|
|
317
|
-
* scene reload); the debug provider's `reset` is this method.
|
|
318
|
-
*/
|
|
319
|
-
resetRun(): void {
|
|
320
|
-
for (const component of this.frozen) {
|
|
321
|
-
component.enabled = true;
|
|
322
|
-
}
|
|
323
|
-
this.frozen = [];
|
|
324
|
-
this.clearSpawned();
|
|
325
|
-
|
|
326
|
-
this.score = 0;
|
|
327
|
-
this.lives = Math.max(1, Number(this.config.startingLives) || 1);
|
|
328
|
-
this.elapsed = 0;
|
|
329
|
-
this.over = false;
|
|
330
|
-
this.won = false;
|
|
331
|
-
|
|
332
|
-
this.setNodeVisible(String(this.config.resultNode ?? ''), false);
|
|
333
|
-
this.setButtonEnabled(String(this.config.retryButton ?? ''), false);
|
|
334
|
-
this.broadcast();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Ask every component on a `freezeNodes` node that owns spawned instances to
|
|
339
|
-
* drop them. Duck-typed instead of importing `Spawner`: the freeze list is
|
|
340
|
-
* whatever the scene author wrote there, and a project may put its own spawner
|
|
341
|
-
* in it — one that also happens to offer `clear()`.
|
|
342
|
-
*/
|
|
343
|
-
private clearSpawned(): void {
|
|
344
|
-
for (const id of String(this.config.freezeNodes ?? '').split(',')) {
|
|
345
|
-
const node = this.findNode(id.trim());
|
|
346
|
-
if (!node) continue;
|
|
347
|
-
for (const component of node.components) {
|
|
348
|
-
const clear = (component as { clear?: unknown }).clear;
|
|
349
|
-
if (typeof clear === 'function') {
|
|
350
|
-
(clear as () => void).call(component);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
private bindButton(query: string, handler: () => void, enabled: boolean): void {
|
|
357
|
-
const button = this.findNode(query);
|
|
358
|
-
if (button instanceof Button2D) {
|
|
359
|
-
button.enabled = enabled;
|
|
360
|
-
button.connect('pressed', this, handler);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
private setButtonEnabled(query: string, enabled: boolean): void {
|
|
365
|
-
const button = this.findNode(query);
|
|
366
|
-
if (button instanceof Button2D) {
|
|
367
|
-
button.enabled = enabled;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private setNodeVisible(query: string, visible: boolean): void {
|
|
372
|
-
const node = this.findNode(query);
|
|
373
|
-
if (node) {
|
|
374
|
-
node.visible = visible;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MenuFlow — drives the menu scene (the project's entry/export scene).
|
|
3
|
-
*
|
|
4
|
-
* PLAY transitions to the game scene with a fade. That is all it does: keep the
|
|
5
|
-
* menu dumb so the game scene stays directly playable on its own (agents and
|
|
6
|
-
* the editor open `main.pix3scene` and press play without going through here).
|
|
7
|
-
*
|
|
8
|
-
* PLAY does not call `startGame()` directly: it dispatches the command
|
|
9
|
-
* `start-game` (`scene.commands`). A test can then leave the menu without a tap,
|
|
10
|
-
* every raised intent lands in `scene.commands.log` with its frame, and one real
|
|
11
|
-
* tap is enough to prove the button is still wired to the intent it claims.
|
|
12
|
-
*
|
|
13
|
-
* It registers a debug provider too. `registerGameDebug` is global and
|
|
14
|
-
* last-wins, and only the running scene's flow script registers one — so
|
|
15
|
-
* without this, everything a tool reads while the menu is up would come back
|
|
16
|
-
* empty (or, worse, stale from the previous scene).
|
|
17
|
-
*/
|
|
18
|
-
import { Script, registerGameDebug, type PropertySchema } from '@pix3/runtime';
|
|
19
|
-
|
|
20
|
-
export class MenuFlow extends Script {
|
|
21
|
-
private playButtonReady = false;
|
|
22
|
-
private disposeDebug: (() => void) | null = null;
|
|
23
|
-
private disposeCommands: (() => void)[] = [];
|
|
24
|
-
|
|
25
|
-
constructor(id: string, type: string) {
|
|
26
|
-
super(id, type);
|
|
27
|
-
this.config = {
|
|
28
|
-
gameScene: 'res://scenes/main.pix3scene',
|
|
29
|
-
playButton: 'play-button',
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
static getPropertySchema(): PropertySchema {
|
|
34
|
-
const str = (name: string, label: string) => ({
|
|
35
|
-
name,
|
|
36
|
-
type: 'string' as const,
|
|
37
|
-
ui: { label, group: 'Menu' },
|
|
38
|
-
getValue: (s: unknown) => (s as MenuFlow).config[name],
|
|
39
|
-
setValue: (s: unknown, v: unknown) => {
|
|
40
|
-
(s as MenuFlow).config[name] = typeof v === 'string' ? v : '';
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
nodeType: 'MenuFlow',
|
|
46
|
-
properties: [str('gameScene', 'Game Scene'), str('playButton', 'Play Button')],
|
|
47
|
-
groups: { Menu: { label: 'Menu Flow', expanded: true } },
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
onStart(): void {
|
|
52
|
-
const commands = this.scene?.commands;
|
|
53
|
-
this.disposeCommands = [
|
|
54
|
-
commands?.register('start-game', () => this.startGame(), {
|
|
55
|
-
description: 'Leave the menu and transition into the game scene.',
|
|
56
|
-
}),
|
|
57
|
-
].filter((dispose): dispose is () => void => dispose !== undefined);
|
|
58
|
-
|
|
59
|
-
this.disposeDebug = registerGameDebug({
|
|
60
|
-
name: 'recipe-bouncer-2d',
|
|
61
|
-
version: 1,
|
|
62
|
-
// The registry IS the action list — never a second, hand-kept copy.
|
|
63
|
-
actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
|
|
64
|
-
// `scene` is what tells a reader which flow answered: the game scene's
|
|
65
|
-
// GameRules publishes under the same name.
|
|
66
|
-
snapshot: () => ({
|
|
67
|
-
scene: 'menu',
|
|
68
|
-
gameScene: String(this.config.gameScene ?? ''),
|
|
69
|
-
playButtonReady: this.playButtonReady,
|
|
70
|
-
}),
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const button = this.findNode(String(this.config.playButton ?? ''));
|
|
74
|
-
if (!button) {
|
|
75
|
-
console.warn(`[MenuFlow] Button "${this.config.playButton}" not found.`);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
this.playButtonReady = true;
|
|
79
|
-
button.connect('pressed', this, () => {
|
|
80
|
-
this.scene?.commands.dispatch('start-game');
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
onDetach(): void {
|
|
85
|
-
// Safe in any order: the disposer clears the global only while it still
|
|
86
|
-
// holds *this* provider, so the next scene's registration is never wiped.
|
|
87
|
-
this.disposeDebug?.();
|
|
88
|
-
this.disposeDebug = null;
|
|
89
|
-
// The scene's registry is cleared on stop anyway; this covers the other
|
|
90
|
-
// case — this script being detached while the scene keeps running.
|
|
91
|
-
for (const dispose of this.disposeCommands) {
|
|
92
|
-
dispose();
|
|
93
|
-
}
|
|
94
|
-
this.disposeCommands = [];
|
|
95
|
-
this.playButtonReady = false;
|
|
96
|
-
super.onDetach();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Transition into the game scene. */
|
|
100
|
-
startGame(): void {
|
|
101
|
-
// The press is the user gesture Web Audio waits for, so this is also what
|
|
102
|
-
// unlocks sound for the run. `sfx` is procedural — no asset to author.
|
|
103
|
-
this.scene?.audio.sfx('powerup');
|
|
104
|
-
void this.scene?.changeScene(String(this.config.gameScene ?? ''), {
|
|
105
|
-
transition: 'fade',
|
|
106
|
-
durationSec: 0.3,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PaddleController — the only thing the player directly drives.
|
|
3
|
-
*
|
|
4
|
-
* Slides the paddle along its local X axis, clamped to `boundsNode`, either by
|
|
5
|
-
* following the pointer or with the arrow/A-D keys. `BallBody` re-reads the
|
|
6
|
-
* paddle's world transform every frame, so anything this script does to the
|
|
7
|
-
* node — including rotating it — is picked up by the physics for free.
|
|
8
|
-
*
|
|
9
|
-
* This is the blessed swap point for flippers: keep the file's job ("turn input
|
|
10
|
-
* into paddle transforms") and change what a paddle IS. See design/recipe.md.
|
|
11
|
-
*/
|
|
12
|
-
import { Group2D, Script, type PropertySchema } from '@pix3/runtime';
|
|
13
|
-
|
|
14
|
-
export class PaddleController extends Script {
|
|
15
|
-
constructor(id: string, type: string) {
|
|
16
|
-
super(id, type);
|
|
17
|
-
this.config = {
|
|
18
|
-
// 'pointer' = follow the finger/mouse, 'keys' = arrows / A-D.
|
|
19
|
-
mode: 'pointer',
|
|
20
|
-
speed: 1600,
|
|
21
|
-
// Half the paddle's width — keeps it inside the field.
|
|
22
|
-
halfWidth: 130,
|
|
23
|
-
boundsNode: 'board',
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
static getPropertySchema(): PropertySchema {
|
|
28
|
-
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
29
|
-
name,
|
|
30
|
-
type: 'number' as const,
|
|
31
|
-
ui: { label, group: 'Paddle', min, max, step, slider: true },
|
|
32
|
-
getValue: (s: unknown) => (s as PaddleController).config[name],
|
|
33
|
-
setValue: (s: unknown, v: unknown) => {
|
|
34
|
-
const n = Number(v);
|
|
35
|
-
(s as PaddleController).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
nodeType: 'PaddleController',
|
|
41
|
-
properties: [
|
|
42
|
-
{
|
|
43
|
-
name: 'mode',
|
|
44
|
-
type: 'select',
|
|
45
|
-
ui: { label: 'Mode', group: 'Paddle', options: ['pointer', 'keys'] },
|
|
46
|
-
getValue: s => (s as PaddleController).config.mode,
|
|
47
|
-
setValue: (s, v) => {
|
|
48
|
-
(s as PaddleController).config.mode = v === 'keys' ? 'keys' : 'pointer';
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
num('speed', 'Speed (px/s)', 100, 6000, 25),
|
|
52
|
-
num('halfWidth', 'Half Width', 10, 800, 5),
|
|
53
|
-
{
|
|
54
|
-
name: 'boundsNode',
|
|
55
|
-
type: 'string',
|
|
56
|
-
ui: { label: 'Bounds Node', group: 'Paddle' },
|
|
57
|
-
getValue: s => (s as PaddleController).config.boundsNode,
|
|
58
|
-
setValue: (s, v) => {
|
|
59
|
-
(s as PaddleController).config.boundsNode = typeof v === 'string' ? v : '';
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
|
-
groups: { Paddle: { label: 'Paddle', expanded: true } },
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
onUpdate(dt: number): void {
|
|
68
|
-
const node = this.node;
|
|
69
|
-
if (!node || dt <= 0) {
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
const speed = Math.max(0, Number(this.config.speed) || 0);
|
|
73
|
-
|
|
74
|
-
if (this.config.mode === 'keys') {
|
|
75
|
-
const dir =
|
|
76
|
-
(this.held('Key_ArrowRight', 'Key_D') ? 1 : 0) - (this.held('Key_ArrowLeft', 'Key_A') ? 1 : 0);
|
|
77
|
-
node.position.x += dir * speed * dt;
|
|
78
|
-
} else {
|
|
79
|
-
const pointer = this.scene?.getPointer2DWorldPosition() ?? null;
|
|
80
|
-
if (pointer) {
|
|
81
|
-
const target = this.worldToParentX(pointer.x, pointer.y);
|
|
82
|
-
const delta = target - node.position.x;
|
|
83
|
-
const step = Math.min(Math.abs(delta), speed * dt);
|
|
84
|
-
node.position.x += Math.sign(delta) * step;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const bounds = this.findNode(String(this.config.boundsNode ?? ''));
|
|
89
|
-
if (bounds instanceof Group2D) {
|
|
90
|
-
const limit = Math.max(0, bounds.width / 2 - Math.max(0, Number(this.config.halfWidth) || 0));
|
|
91
|
-
node.position.x = Math.min(limit, Math.max(-limit, node.position.x));
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
private held(...buttons: string[]): boolean {
|
|
96
|
-
return buttons.some(name => this.input?.getButton(name) ?? false);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** World X → the X of this node's parent space (exact 2×2 inverse). */
|
|
100
|
-
private worldToParentX(wx: number, wy: number): number {
|
|
101
|
-
const parent = this.node?.parentNode ?? null;
|
|
102
|
-
if (!parent) {
|
|
103
|
-
return wx;
|
|
104
|
-
}
|
|
105
|
-
parent.updateWorldMatrix(true, false);
|
|
106
|
-
const e = parent.matrixWorld.elements;
|
|
107
|
-
const dx = wx - e[12];
|
|
108
|
-
const dy = wy - e[13];
|
|
109
|
-
const det = e[0] * e[5] - e[1] * e[4];
|
|
110
|
-
return Math.abs(det) < 1e-8 ? dx : (e[5] * dx - e[4] * dy) / det;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ScoreHud — signal-driven HUD. Display only, no game logic.
|
|
3
|
-
*
|
|
4
|
-
* Listens on `sourceNode` (the node carrying `GameRules`) for:
|
|
5
|
-
* `score-changed` (score)
|
|
6
|
-
* `lives-changed` (lives, maxLives)
|
|
7
|
-
* `time-changed` (seconds)
|
|
8
|
-
*
|
|
9
|
-
* If a HUD widget is missing from the scene it is quietly skipped, so you can
|
|
10
|
-
* delete the timer or the lives bar without touching any script.
|
|
11
|
-
*/
|
|
12
|
-
import { Bar2D, Label2D, Script, type PropertySchema } from '@pix3/runtime';
|
|
13
|
-
|
|
14
|
-
export class ScoreHud extends Script {
|
|
15
|
-
constructor(id: string, type: string) {
|
|
16
|
-
super(id, type);
|
|
17
|
-
this.config = {
|
|
18
|
-
// Node that emits the HUD signals (the GameRules host).
|
|
19
|
-
sourceNode: 'game-root',
|
|
20
|
-
scoreLabel: 'score-label',
|
|
21
|
-
scoreFormat: 'SCORE {value}',
|
|
22
|
-
timeLabel: 'time-label',
|
|
23
|
-
timeFormat: '{value}',
|
|
24
|
-
livesBar: 'lives-bar',
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
static getPropertySchema(): PropertySchema {
|
|
29
|
-
const str = (name: string, label: string, description?: string) => ({
|
|
30
|
-
name,
|
|
31
|
-
type: 'string' as const,
|
|
32
|
-
ui: { label, group: 'HUD', description },
|
|
33
|
-
getValue: (s: unknown) => (s as ScoreHud).config[name],
|
|
34
|
-
setValue: (s: unknown, v: unknown) => {
|
|
35
|
-
(s as ScoreHud).config[name] = typeof v === 'string' ? v : '';
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
nodeType: 'ScoreHud',
|
|
41
|
-
properties: [
|
|
42
|
-
str('sourceNode', 'Source Node', 'Node that emits score-changed / lives-changed / time-changed'),
|
|
43
|
-
str('scoreLabel', 'Score Label'),
|
|
44
|
-
str('scoreFormat', 'Score Format', '{value} is replaced by the score'),
|
|
45
|
-
str('timeLabel', 'Time Label'),
|
|
46
|
-
str('timeFormat', 'Time Format', '{value} is replaced by whole seconds'),
|
|
47
|
-
str('livesBar', 'Lives Bar'),
|
|
48
|
-
],
|
|
49
|
-
groups: { HUD: { label: 'HUD', expanded: true } },
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
onStart(): void {
|
|
54
|
-
const source = this.findNode(String(this.config.sourceNode ?? ''));
|
|
55
|
-
if (!source) {
|
|
56
|
-
console.warn(`[ScoreHud] Source node "${this.config.sourceNode}" not found.`);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
source.connect('score-changed', this, (...args: unknown[]) => this.setScore(Number(args[0]) || 0));
|
|
60
|
-
source.connect('time-changed', this, (...args: unknown[]) => this.setTime(Number(args[0]) || 0));
|
|
61
|
-
source.connect('lives-changed', this, (...args: unknown[]) =>
|
|
62
|
-
this.setLives(Number(args[0]) || 0, Number(args[1]) || 0)
|
|
63
|
-
);
|
|
64
|
-
// Ask for the opening values now that the listeners are live: GameRules
|
|
65
|
-
// starts before this HUD (parent components tick before children).
|
|
66
|
-
source.emit('hud-ready');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private setScore(score: number): void {
|
|
70
|
-
const label = this.findNode(String(this.config.scoreLabel ?? ''));
|
|
71
|
-
if (label instanceof Label2D) {
|
|
72
|
-
label.setText(String(this.config.scoreFormat ?? '{value}').replace('{value}', String(Math.round(score))));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private setTime(seconds: number): void {
|
|
77
|
-
const label = this.findNode(String(this.config.timeLabel ?? ''));
|
|
78
|
-
if (label instanceof Label2D) {
|
|
79
|
-
label.setText(
|
|
80
|
-
String(this.config.timeFormat ?? '{value}').replace('{value}', String(Math.max(0, Math.ceil(seconds))))
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private setLives(lives: number, maxLives: number): void {
|
|
86
|
-
const bar = this.findNode(String(this.config.livesBar ?? ''));
|
|
87
|
-
if (bar instanceof Bar2D) {
|
|
88
|
-
if (maxLives > 0) {
|
|
89
|
-
bar.maxValue = maxLives;
|
|
90
|
-
}
|
|
91
|
-
bar.setValue(lives);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|