@pix3/cli 1.6.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.
Files changed (155) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +754 -0
  3. package/dist/index.js +494 -0
  4. package/dist/runtime-types.json +1 -0
  5. package/dist/smoke/prebuilt/build-7MIXIMSZ.mjs +948 -0
  6. package/dist/smoke/prebuilt/chunk-J2SJVMDO.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +239 -0
  8. package/dist/smoke/prebuilt/chunk-P2CBRXPJ.mjs +4050 -0
  9. package/dist/smoke/prebuilt/runtime.mjs +2 -0
  10. package/dist/smoke/prebuilt/smoke-worker.mjs +3 -0
  11. package/dist/smoke/prebuilt/three.mjs +2 -0
  12. package/dist/smoke/prebuilt/tree-defaults.mjs +2 -0
  13. package/dist/validate/prebuilt/build-AUGKXER3.js +948 -0
  14. package/dist/validate/prebuilt/bundle-main.js +24 -0
  15. package/dist/validate/prebuilt/chunk-6ACT6FNV.js +4050 -0
  16. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +239 -0
  17. package/dist/validate/prebuilt/runtime.js +2 -0
  18. package/dist/validate/prebuilt/three.js +2 -0
  19. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -0
  20. package/kit/files/.claude/skills/pix3-nodes/reference.md +1331 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +235 -0
  22. package/kit/files/.claude/skills/pix3-scene-format/reference.md +176 -0
  23. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +281 -0
  24. package/kit/files/.claude/skills/pix3-scripts/reference.md +638 -0
  25. package/kit/files/.claude/skills/pix3-verify/SKILL.md +386 -0
  26. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -0
  27. package/kit/files/AGENTS.md +203 -0
  28. package/kit/files/CLAUDE.md +1 -0
  29. package/kit/kit.json +24 -0
  30. package/package.json +54 -0
  31. package/templates/empty-2d/files/README.md +20 -0
  32. package/templates/empty-2d/files/scenes/main.pix3scene +68 -0
  33. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  34. package/templates/empty-2d/template.yaml +7 -0
  35. package/templates/empty-3d/files/README.md +20 -0
  36. package/templates/empty-3d/files/scenes/main.pix3scene +108 -0
  37. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  38. package/templates/empty-3d/template.yaml +7 -0
  39. package/templates/idea-blank/files/README.md +22 -0
  40. package/templates/idea-blank/files/scenes/main.pix3scene +37 -0
  41. package/templates/idea-blank/template.yaml +10 -0
  42. package/templates/minigame-2d/files/README.md +44 -0
  43. package/templates/minigame-2d/files/design/tests/reachability.json +1 -0
  44. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -0
  45. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -0
  46. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -0
  47. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -0
  48. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -0
  49. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -0
  50. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -0
  51. package/templates/minigame-2d/template.yaml +10 -0
  52. package/templates/playable-2d/files/README.md +30 -0
  53. package/templates/playable-2d/files/design/recipe.md +83 -0
  54. package/templates/playable-2d/files/design/tests/reachability.json +4 -0
  55. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -0
  56. package/templates/playable-2d/files/scenes/main.pix3scene +111 -0
  57. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -0
  58. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -0
  59. package/templates/playable-2d/files/scripts/CtaButton.ts +55 -0
  60. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -0
  61. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  62. package/templates/playable-2d/template.yaml +9 -0
  63. package/templates/playable-3d/files/README.md +32 -0
  64. package/templates/playable-3d/files/design/recipe.md +127 -0
  65. package/templates/playable-3d/files/design/tests/reachability.json +4 -0
  66. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -0
  67. package/templates/playable-3d/files/scenes/main.pix3scene +230 -0
  68. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -0
  69. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -0
  70. package/templates/playable-3d/files/scripts/CtaButton.ts +55 -0
  71. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -0
  72. package/templates/playable-3d/files/scripts/GameRules.ts +290 -0
  73. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -0
  74. package/templates/playable-3d/template.yaml +9 -0
  75. package/templates/recipe-arena-2d/files/README.md +48 -0
  76. package/templates/recipe-arena-2d/files/design/recipe.md +127 -0
  77. package/templates/recipe-arena-2d/files/design/tests/reachability.json +4 -0
  78. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +23 -0
  79. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -0
  80. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -0
  81. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  82. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -0
  83. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -0
  84. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -0
  85. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -0
  86. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -0
  87. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -0
  88. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -0
  89. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -0
  90. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  91. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  92. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  93. package/templates/recipe-arena-2d/template.yaml +10 -0
  94. package/templates/recipe-blank-2d/files/README.md +61 -0
  95. package/templates/recipe-blank-2d/files/design/recipe.md +148 -0
  96. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -0
  97. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -0
  98. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +23 -0
  99. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -0
  100. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -0
  101. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -0
  102. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -0
  103. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  104. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  105. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  106. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  107. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  108. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  109. package/templates/recipe-blank-2d/template.yaml +10 -0
  110. package/templates/recipe-bouncer-2d/files/README.md +81 -0
  111. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -0
  112. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +4 -0
  113. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +23 -0
  114. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -0
  115. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -0
  116. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -0
  117. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -0
  118. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -0
  119. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -0
  120. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -0
  121. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -0
  122. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -0
  123. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -0
  124. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  125. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  126. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  127. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  128. package/templates/recipe-bouncer-2d/template.yaml +10 -0
  129. package/templates/recipe-grid-3d/files/README.md +22 -0
  130. package/templates/recipe-grid-3d/files/design/recipe.md +110 -0
  131. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -0
  132. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -0
  133. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -0
  134. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -0
  135. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -0
  136. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -0
  137. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -0
  138. package/templates/recipe-grid-3d/template.yaml +10 -0
  139. package/templates/recipe-tapper-2d/files/README.md +39 -0
  140. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -0
  141. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -0
  142. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -0
  143. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -0
  144. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -0
  145. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  146. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -0
  147. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -0
  148. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -0
  149. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -0
  150. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -0
  151. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -0
  152. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -0
  153. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  154. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  155. package/templates/recipe-tapper-2d/template.yaml +10 -0
@@ -0,0 +1,106 @@
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-arena-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
+ void this.scene?.changeScene(String(this.config.gameScene ?? ''), {
102
+ transition: 'fade',
103
+ durationSec: 0.3,
104
+ });
105
+ }
106
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * PlayerController — steers the avatar inside a bounded field.
3
+ *
4
+ * Two locomotion modes, chosen in the inspector:
5
+ * - `pointer` — the avatar chases the pointer/finger at `speed` px/s.
6
+ * - `keys` — 8-direction WASD / arrow movement at `speed` px/s.
7
+ *
8
+ * The avatar is clamped to `boundsNode` (a Group2D — normally the node it is
9
+ * parented to) inset by `radius`, so it can never leave the field.
10
+ *
11
+ * This file is deliberately the ONLY thing that decides how the avatar moves:
12
+ * swap it wholesale for a grid stepper, a jumper or an auto-runner and the rest
13
+ * of the recipe (spawners, touch rules, HUD, win/lose) keeps working unchanged.
14
+ */
15
+ import { Group2D, Script, type PropertySchema } from '@pix3/runtime';
16
+
17
+ type LocomotionMode = 'pointer' | 'keys';
18
+
19
+ export class PlayerController extends Script {
20
+ constructor(id: string, type: string) {
21
+ super(id, type);
22
+ this.config = {
23
+ // 'pointer' = follow the finger/mouse, 'keys' = 8-direction WASD/arrows.
24
+ mode: 'pointer',
25
+ // Movement speed in logical pixels per second.
26
+ speed: 620,
27
+ // Collision/inset radius: keeps the avatar fully inside the field.
28
+ radius: 56,
29
+ // Node id/name of the Group2D whose rect bounds the avatar.
30
+ boundsNode: 'board',
31
+ // Pointer mode only: stop chasing once this close (px) to avoid jitter.
32
+ deadZone: 4,
33
+ };
34
+ }
35
+
36
+ static getPropertySchema(): PropertySchema {
37
+ const numberProp = (name: string, label: string, min: number, max: number, step = 1) => ({
38
+ name,
39
+ type: 'number' as const,
40
+ ui: { label, group: 'Movement', min, max, step, slider: true },
41
+ getValue: (s: unknown) => (s as PlayerController).config[name],
42
+ setValue: (s: unknown, v: unknown) => {
43
+ const n = Number(v);
44
+ (s as PlayerController).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
45
+ },
46
+ });
47
+
48
+ return {
49
+ nodeType: 'PlayerController',
50
+ properties: [
51
+ {
52
+ name: 'mode',
53
+ type: 'select',
54
+ ui: {
55
+ label: 'Mode',
56
+ description: 'pointer = follow the finger, keys = 8-direction WASD/arrows',
57
+ group: 'Movement',
58
+ options: ['pointer', 'keys'],
59
+ },
60
+ getValue: s => (s as PlayerController).config.mode,
61
+ setValue: (s, v) => {
62
+ (s as PlayerController).config.mode = v === 'keys' ? 'keys' : 'pointer';
63
+ },
64
+ },
65
+ numberProp('speed', 'Speed (px/s)', 60, 2000, 10),
66
+ numberProp('radius', 'Radius', 4, 400, 1),
67
+ numberProp('deadZone', 'Dead Zone', 0, 60, 1),
68
+ {
69
+ name: 'boundsNode',
70
+ type: 'string',
71
+ ui: { label: 'Bounds Node', group: 'Movement' },
72
+ getValue: s => (s as PlayerController).config.boundsNode,
73
+ setValue: (s, v) => {
74
+ (s as PlayerController).config.boundsNode = typeof v === 'string' ? v : '';
75
+ },
76
+ },
77
+ ],
78
+ groups: { Movement: { label: 'Movement', expanded: true } },
79
+ };
80
+ }
81
+
82
+ onUpdate(dt: number): void {
83
+ const node = this.node;
84
+ if (!node || dt <= 0) {
85
+ return;
86
+ }
87
+ const speed = Math.max(0, Number(this.config.speed) || 0);
88
+ const mode: LocomotionMode = this.config.mode === 'keys' ? 'keys' : 'pointer';
89
+
90
+ if (mode === 'keys') {
91
+ const ax = (this.held('Key_ArrowRight', 'Key_D') ? 1 : 0) - (this.held('Key_ArrowLeft', 'Key_A') ? 1 : 0);
92
+ const ay = (this.held('Key_ArrowUp', 'Key_W') ? 1 : 0) - (this.held('Key_ArrowDown', 'Key_S') ? 1 : 0);
93
+ const len = Math.hypot(ax, ay);
94
+ if (len > 0) {
95
+ node.position.x += (ax / len) * speed * dt;
96
+ node.position.y += (ay / len) * speed * dt;
97
+ }
98
+ } else {
99
+ const pointer = this.scene?.getPointer2DWorldPosition() ?? null;
100
+ if (pointer) {
101
+ const target = this.worldToParent(pointer.x, pointer.y);
102
+ const dx = target.x - node.position.x;
103
+ const dy = target.y - node.position.y;
104
+ const dist = Math.hypot(dx, dy);
105
+ const deadZone = Math.max(0, Number(this.config.deadZone) || 0);
106
+ if (dist > deadZone) {
107
+ const step = Math.min(dist, speed * dt);
108
+ node.position.x += (dx / dist) * step;
109
+ node.position.y += (dy / dist) * step;
110
+ }
111
+ }
112
+ }
113
+
114
+ this.clampToBounds();
115
+ }
116
+
117
+ private held(...buttons: string[]): boolean {
118
+ return buttons.some(name => this.input?.getButton(name) ?? false);
119
+ }
120
+
121
+ /** Convert a 2D world point into the coordinate space of this node's parent. */
122
+ private worldToParent(wx: number, wy: number): { x: number; y: number } {
123
+ const parent = this.node?.parentNode ?? null;
124
+ if (!parent) {
125
+ return { x: wx, y: wy };
126
+ }
127
+ parent.updateWorldMatrix(true, false);
128
+ const e = parent.matrixWorld.elements;
129
+ const dx = wx - e[12];
130
+ const dy = wy - e[13];
131
+ const det = e[0] * e[5] - e[1] * e[4];
132
+ if (Math.abs(det) < 1e-8) {
133
+ return { x: dx, y: dy };
134
+ }
135
+ return { x: (e[5] * dx - e[4] * dy) / det, y: (e[0] * dy - e[1] * dx) / det };
136
+ }
137
+
138
+ /** Keep the avatar inside the bounds node's rect, inset by `radius`. */
139
+ private clampToBounds(): void {
140
+ const node = this.node;
141
+ const bounds = this.findNode(String(this.config.boundsNode ?? ''));
142
+ if (!node || !(bounds instanceof Group2D)) {
143
+ return;
144
+ }
145
+ const radius = Math.max(0, Number(this.config.radius) || 0);
146
+ const halfW = Math.max(0, bounds.width / 2 - radius);
147
+ const halfH = Math.max(0, bounds.height / 2 - radius);
148
+ node.position.x = Math.min(halfW, Math.max(-halfW, node.position.x));
149
+ node.position.y = Math.min(halfH, Math.max(-halfH, node.position.y));
150
+ }
151
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Spawner — drops prefab instances into the field on a timer.
3
+ *
4
+ * One spawner = one kind of thing. Put a second Spawner on a second node for a
5
+ * second kind (the recipe ships `spawner-pickups` + `spawner-hazards`).
6
+ *
7
+ * It owns the whole life of what it spawns: pick a random point in the spawn
8
+ * rect (optionally grid-snapped), instantiate the prefab under `parentNode`,
9
+ * drift it every frame, and `queueFree` it when its lifetime runs out or it
10
+ * leaves `despawnRadius`. What a spawned thing DOES on contact is not its
11
+ * business — that is `TouchRules` reading the prefab's `core:Hitbox2D` group.
12
+ */
13
+ import { Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
14
+
15
+ interface SpawnedEntry {
16
+ node: NodeBase;
17
+ ttl: number;
18
+ }
19
+
20
+ /** World point → the coordinate space of `node` (2×2 inverse of its world basis). */
21
+ function worldToLocal2D(node: NodeBase, wx: number, wy: number): { x: number; y: number } {
22
+ node.updateWorldMatrix(true, false);
23
+ const e = node.matrixWorld.elements;
24
+ const dx = wx - e[12];
25
+ const dy = wy - e[13];
26
+ const det = e[0] * e[5] - e[1] * e[4];
27
+ if (Math.abs(det) < 1e-8) {
28
+ return { x: dx, y: dy };
29
+ }
30
+ return { x: (e[5] * dx - e[4] * dy) / det, y: (e[0] * dy - e[1] * dx) / det };
31
+ }
32
+
33
+ /** Local point in `node`'s space → 2D world. */
34
+ function localToWorld2D(node: NodeBase, lx: number, ly: number): { x: number; y: number } {
35
+ node.updateWorldMatrix(true, false);
36
+ const e = node.matrixWorld.elements;
37
+ return { x: e[0] * lx + e[4] * ly + e[12], y: e[1] * lx + e[5] * ly + e[13] };
38
+ }
39
+
40
+ export class Spawner extends Script {
41
+ private alive: SpawnedEntry[] = [];
42
+ private cooldown = 0;
43
+ private pending = 0;
44
+
45
+ constructor(id: string, type: string) {
46
+ super(id, type);
47
+ this.config = {
48
+ // Prefab (.pix3scene with exactly one root node) to instantiate.
49
+ prefab: 'res://scenes/prefabs/pickup.pix3scene',
50
+ // Node id/name the instances are parented to. Empty = this spawner node.
51
+ parentNode: '',
52
+ // Seconds between spawns, plus a random 0..jitter extra.
53
+ intervalSec: 1.1,
54
+ intervalJitter: 0.35,
55
+ // Hard cap on simultaneously alive instances from THIS spawner.
56
+ maxAlive: 10,
57
+ // Spawn rect (centred on this node), in logical pixels.
58
+ spawnWidth: 860,
59
+ spawnHeight: 60,
60
+ // Snap spawn coordinates to a grid of this size (0 = free placement).
61
+ gridSnap: 0,
62
+ // Constant per-second drift applied to every live instance.
63
+ driftX: 0,
64
+ driftY: -300,
65
+ // Seconds an instance lives (0 = until it leaves despawnRadius).
66
+ lifetimeSec: 7,
67
+ // Distance from this node beyond which an instance is freed.
68
+ despawnRadius: 1800,
69
+ // Delay before the first spawn.
70
+ startDelaySec: 0.4,
71
+ };
72
+ }
73
+
74
+ static getPropertySchema(): PropertySchema {
75
+ const num = (name: string, label: string, min: number, max: number, step: number, group: string) => ({
76
+ name,
77
+ type: 'number' as const,
78
+ ui: { label, group, min, max, step, slider: true },
79
+ getValue: (s: unknown) => (s as Spawner).config[name],
80
+ setValue: (s: unknown, v: unknown) => {
81
+ const n = Number(v);
82
+ (s as Spawner).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
83
+ },
84
+ });
85
+ const str = (name: string, label: string) => ({
86
+ name,
87
+ type: 'string' as const,
88
+ ui: { label, group: 'Source' },
89
+ getValue: (s: unknown) => (s as Spawner).config[name],
90
+ setValue: (s: unknown, v: unknown) => {
91
+ (s as Spawner).config[name] = typeof v === 'string' ? v : '';
92
+ },
93
+ });
94
+
95
+ return {
96
+ nodeType: 'Spawner',
97
+ properties: [
98
+ str('prefab', 'Prefab'),
99
+ str('parentNode', 'Parent Node'),
100
+ num('intervalSec', 'Interval (s)', 0.05, 20, 0.05, 'Rate'),
101
+ num('intervalJitter', 'Interval Jitter (s)', 0, 10, 0.05, 'Rate'),
102
+ num('maxAlive', 'Max Alive', 1, 200, 1, 'Rate'),
103
+ num('startDelaySec', 'Start Delay (s)', 0, 20, 0.1, 'Rate'),
104
+ num('spawnWidth', 'Spawn Width', 0, 4000, 10, 'Placement'),
105
+ num('spawnHeight', 'Spawn Height', 0, 4000, 10, 'Placement'),
106
+ num('gridSnap', 'Grid Snap', 0, 500, 1, 'Placement'),
107
+ num('driftX', 'Drift X (px/s)', -3000, 3000, 10, 'Motion'),
108
+ num('driftY', 'Drift Y (px/s)', -3000, 3000, 10, 'Motion'),
109
+ num('lifetimeSec', 'Lifetime (s)', 0, 120, 0.5, 'Motion'),
110
+ num('despawnRadius', 'Despawn Radius', 100, 8000, 50, 'Motion'),
111
+ ],
112
+ groups: {
113
+ Source: { label: 'Source', expanded: true },
114
+ Rate: { label: 'Rate', expanded: true },
115
+ Placement: { label: 'Placement', expanded: true },
116
+ Motion: { label: 'Motion', expanded: true },
117
+ },
118
+ };
119
+ }
120
+
121
+ onStart(): void {
122
+ this.cooldown = Math.max(0, Number(this.config.startDelaySec) || 0);
123
+ }
124
+
125
+ onUpdate(dt: number): void {
126
+ this.driftAndExpire(dt);
127
+
128
+ this.cooldown -= dt;
129
+ if (this.cooldown > 0) {
130
+ return;
131
+ }
132
+ const interval = Math.max(0.05, Number(this.config.intervalSec) || 0.05);
133
+ const jitter = Math.max(0, Number(this.config.intervalJitter) || 0);
134
+ this.cooldown = interval + Math.random() * jitter;
135
+
136
+ const maxAlive = Math.max(1, Number(this.config.maxAlive) || 1);
137
+ if (this.alive.length + this.pending < maxAlive) {
138
+ void this.spawnOne();
139
+ }
140
+ }
141
+
142
+ /** Free everything this spawner currently owns (e.g. on a restart). */
143
+ clear(): void {
144
+ for (const entry of this.alive) {
145
+ entry.node.queueFree();
146
+ }
147
+ this.alive = [];
148
+ }
149
+
150
+ private driftAndExpire(dt: number): void {
151
+ const dx = (Number(this.config.driftX) || 0) * dt;
152
+ const dy = (Number(this.config.driftY) || 0) * dt;
153
+ const lifetime = Math.max(0, Number(this.config.lifetimeSec) || 0);
154
+ const despawn = Math.max(1, Number(this.config.despawnRadius) || 1);
155
+ const survivors: SpawnedEntry[] = [];
156
+
157
+ for (const entry of this.alive) {
158
+ if (!entry.node.parent) {
159
+ continue; // already freed elsewhere (TouchRules consumed it)
160
+ }
161
+ entry.node.position.x += dx;
162
+ entry.node.position.y += dy;
163
+ entry.ttl -= dt;
164
+
165
+ const world = localToWorld2D(entry.node, 0, 0);
166
+ const here = worldToLocal2D(this.node ?? entry.node, world.x, world.y);
167
+ const outOfRange = Math.abs(here.x) > despawn || Math.abs(here.y) > despawn;
168
+ if (outOfRange || (lifetime > 0 && entry.ttl <= 0)) {
169
+ entry.node.queueFree();
170
+ continue;
171
+ }
172
+ survivors.push(entry);
173
+ }
174
+ this.alive = survivors;
175
+ }
176
+
177
+ private async spawnOne(): Promise<void> {
178
+ const scene = this.scene;
179
+ const owner = this.node;
180
+ const prefab = String(this.config.prefab ?? '');
181
+ if (!scene || !owner || !prefab) {
182
+ return;
183
+ }
184
+ const parentQuery = String(this.config.parentNode ?? '');
185
+ const parent = parentQuery ? this.findNode(parentQuery) : owner;
186
+ if (!parent) {
187
+ console.warn(`[Spawner] Parent "${parentQuery}" not found.`);
188
+ return;
189
+ }
190
+
191
+ const snap = Math.max(0, Number(this.config.gridSnap) || 0);
192
+ const halfW = Math.max(0, Number(this.config.spawnWidth) || 0) / 2;
193
+ const halfH = Math.max(0, Number(this.config.spawnHeight) || 0) / 2;
194
+ let localX = (Math.random() * 2 - 1) * halfW;
195
+ let localY = (Math.random() * 2 - 1) * halfH;
196
+ if (snap > 0) {
197
+ localX = Math.round(localX / snap) * snap;
198
+ localY = Math.round(localY / snap) * snap;
199
+ }
200
+ const world = localToWorld2D(owner, localX, localY);
201
+
202
+ this.pending += 1;
203
+ try {
204
+ const node = await scene.instantiate(prefab, { parent });
205
+ const local = worldToLocal2D(parent, world.x, world.y);
206
+ node.position.set(local.x, local.y, node.position.z);
207
+ this.alive.push({ node, ttl: Math.max(0, Number(this.config.lifetimeSec) || 0) });
208
+ } catch (error) {
209
+ console.warn(`[Spawner] Failed to spawn "${prefab}":`, error);
210
+ } finally {
211
+ this.pending -= 1;
212
+ }
213
+ }
214
+ }