@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,109 +1,109 @@
|
|
|
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
|
+
/**
|
|
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 +1,112 @@
|
|
|
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
|
+
/**
|
|
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 +1,94 @@
|
|
|
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
|
-
}
|
|
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
|
+
}
|