@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.
Files changed (126) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +788 -754
  3. package/dist/index.js +208 -203
  4. package/dist/runtime-types.json +1 -1
  5. package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-7MZGPAUK.mjs} +1 -1
  6. package/dist/smoke/prebuilt/chunk-7WOFGLW3.mjs +239 -0
  7. package/dist/smoke/prebuilt/runtime.mjs +1 -1
  8. package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
  9. package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
  10. package/dist/validate/prebuilt/bundle-main.js +13 -13
  11. package/dist/validate/prebuilt/chunk-HKG6YI6Y.js +239 -0
  12. package/dist/validate/prebuilt/runtime.js +1 -1
  13. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
  14. package/kit/files/.claude/skills/pix3-nodes/reference.md +32 -24
  15. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
  16. package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -28
  17. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
  18. package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
  19. package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
  20. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
  21. package/kit/files/AGENTS.md +203 -203
  22. package/kit/files/CLAUDE.md +1 -1
  23. package/kit/kit.json +2 -2
  24. package/package.json +54 -54
  25. package/templates/empty-2d/files/README.md +20 -20
  26. package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
  27. package/templates/empty-2d/template.yaml +7 -7
  28. package/templates/empty-3d/files/README.md +20 -20
  29. package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
  30. package/templates/empty-3d/template.yaml +7 -7
  31. package/templates/idea-blank/files/README.md +22 -22
  32. package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
  33. package/templates/idea-blank/template.yaml +10 -10
  34. package/templates/minigame-2d/files/README.md +44 -44
  35. package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
  36. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
  37. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
  38. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
  39. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
  40. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
  41. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
  42. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
  43. package/templates/minigame-2d/template.yaml +10 -10
  44. package/templates/playable-2d/files/README.md +30 -30
  45. package/templates/playable-2d/files/design/recipe.md +83 -83
  46. package/templates/playable-2d/files/design/tests/reachability.json +4 -4
  47. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
  48. package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
  49. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
  50. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
  51. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
  52. package/templates/playable-2d/template.yaml +9 -9
  53. package/templates/playable-3d/files/README.md +32 -32
  54. package/templates/playable-3d/files/design/recipe.md +127 -127
  55. package/templates/playable-3d/files/design/tests/reachability.json +4 -4
  56. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
  57. package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
  58. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
  59. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
  60. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
  61. package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
  62. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
  63. package/templates/playable-3d/template.yaml +9 -9
  64. package/templates/recipe-arena-2d/files/README.md +48 -48
  65. package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
  66. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
  67. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
  68. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
  69. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
  70. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
  71. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
  72. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
  73. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
  74. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
  75. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
  76. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
  77. package/templates/recipe-arena-2d/template.yaml +10 -10
  78. package/templates/recipe-blank-2d/files/README.md +61 -61
  79. package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
  80. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
  81. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
  82. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
  83. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
  84. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
  85. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
  86. package/templates/recipe-blank-2d/template.yaml +10 -10
  87. package/templates/recipe-bouncer-2d/files/README.md +81 -81
  88. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
  89. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
  90. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
  91. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
  92. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
  93. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
  94. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
  95. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
  96. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
  97. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
  98. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
  99. package/templates/recipe-bouncer-2d/template.yaml +10 -10
  100. package/templates/recipe-grid-3d/files/README.md +22 -22
  101. package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
  102. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
  103. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
  104. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
  105. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
  106. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
  107. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
  108. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
  109. package/templates/recipe-grid-3d/template.yaml +10 -10
  110. package/templates/recipe-tapper-2d/files/README.md +39 -39
  111. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
  112. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
  113. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
  114. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
  115. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
  116. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
  117. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
  118. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
  119. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
  120. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
  121. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
  122. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
  123. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
  124. package/templates/recipe-tapper-2d/template.yaml +10 -10
  125. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
  126. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +0 -239
@@ -1,113 +1,113 @@
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
- * With `GameRules.timeLimitSec` at 0 there is no clock, and `time-changed` carries
13
- * ELAPSED seconds instead — the label counts up. Set a limit and it counts down.
14
- *
15
- * Identical to the one the 2D blank recipe ships, down to the signal names: the HUD is
16
- * 2D in both, because a 3D game's score is still read on a flat overlay.
17
- */
18
- import { Bar2D, Label2D, Script, type PropertySchema } from '@pix3/runtime';
19
-
20
- export class ScoreHud extends Script {
21
- constructor(id: string, type: string) {
22
- super(id, type);
23
- this.config = {
24
- // Node that emits the HUD signals (the GameRules host).
25
- sourceNode: 'hud-root',
26
- scoreLabel: 'score-label',
27
- scoreFormat: 'SCORE {value}',
28
- timeLabel: 'time-label',
29
- timeFormat: '{value}',
30
- livesBar: 'lives-bar',
31
- };
32
- }
33
-
34
- static getPropertySchema(): PropertySchema {
35
- const str = (name: string, label: string, description?: string) => ({
36
- name,
37
- type: 'string' as const,
38
- ui: { label, group: 'HUD', description },
39
- getValue: (s: unknown) => (s as ScoreHud).config[name],
40
- setValue: (s: unknown, v: unknown) => {
41
- (s as ScoreHud).config[name] = typeof v === 'string' ? v : '';
42
- },
43
- });
44
-
45
- return {
46
- nodeType: 'ScoreHud',
47
- properties: [
48
- str(
49
- 'sourceNode',
50
- 'Source Node',
51
- 'Node that emits score-changed / lives-changed / time-changed'
52
- ),
53
- str('scoreLabel', 'Score Label'),
54
- str('scoreFormat', 'Score Format', '{value} is replaced by the score'),
55
- str('timeLabel', 'Time Label'),
56
- str('timeFormat', 'Time Format', '{value} is replaced by whole seconds'),
57
- str('livesBar', 'Lives Bar'),
58
- ],
59
- groups: { HUD: { label: 'HUD', expanded: true } },
60
- };
61
- }
62
-
63
- onStart(): void {
64
- const source = this.findNode(String(this.config.sourceNode ?? ''));
65
- if (!source) {
66
- console.warn(`[ScoreHud] Source node "${this.config.sourceNode}" not found.`);
67
- return;
68
- }
69
- source.connect('score-changed', this, (...args: unknown[]) =>
70
- this.setScore(Number(args[0]) || 0)
71
- );
72
- source.connect('time-changed', this, (...args: unknown[]) =>
73
- this.setTime(Number(args[0]) || 0)
74
- );
75
- source.connect('lives-changed', this, (...args: unknown[]) =>
76
- this.setLives(Number(args[0]) || 0, Number(args[1]) || 0)
77
- );
78
- // Ask for the opening values now that the listeners are live: GameRules
79
- // starts before this HUD (parent components tick before children).
80
- source.emit('hud-ready');
81
- }
82
-
83
- private setScore(score: number): void {
84
- const label = this.findNode(String(this.config.scoreLabel ?? ''));
85
- if (label instanceof Label2D) {
86
- label.setText(
87
- String(this.config.scoreFormat ?? '{value}').replace('{value}', String(Math.round(score)))
88
- );
89
- }
90
- }
91
-
92
- private setTime(seconds: number): void {
93
- const label = this.findNode(String(this.config.timeLabel ?? ''));
94
- if (label instanceof Label2D) {
95
- label.setText(
96
- String(this.config.timeFormat ?? '{value}').replace(
97
- '{value}',
98
- String(Math.max(0, Math.ceil(seconds)))
99
- )
100
- );
101
- }
102
- }
103
-
104
- private setLives(lives: number, maxLives: number): void {
105
- const bar = this.findNode(String(this.config.livesBar ?? ''));
106
- if (bar instanceof Bar2D) {
107
- if (maxLives > 0) {
108
- bar.maxValue = maxLives;
109
- }
110
- bar.setValue(lives);
111
- }
112
- }
113
- }
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
+ * With `GameRules.timeLimitSec` at 0 there is no clock, and `time-changed` carries
13
+ * ELAPSED seconds instead — the label counts up. Set a limit and it counts down.
14
+ *
15
+ * Identical to the one the 2D blank recipe ships, down to the signal names: the HUD is
16
+ * 2D in both, because a 3D game's score is still read on a flat overlay.
17
+ */
18
+ import { Bar2D, Label2D, Script, type PropertySchema } from '@pix3/runtime';
19
+
20
+ export class ScoreHud extends Script {
21
+ constructor(id: string, type: string) {
22
+ super(id, type);
23
+ this.config = {
24
+ // Node that emits the HUD signals (the GameRules host).
25
+ sourceNode: 'hud-root',
26
+ scoreLabel: 'score-label',
27
+ scoreFormat: 'SCORE {value}',
28
+ timeLabel: 'time-label',
29
+ timeFormat: '{value}',
30
+ livesBar: 'lives-bar',
31
+ };
32
+ }
33
+
34
+ static getPropertySchema(): PropertySchema {
35
+ const str = (name: string, label: string, description?: string) => ({
36
+ name,
37
+ type: 'string' as const,
38
+ ui: { label, group: 'HUD', description },
39
+ getValue: (s: unknown) => (s as ScoreHud).config[name],
40
+ setValue: (s: unknown, v: unknown) => {
41
+ (s as ScoreHud).config[name] = typeof v === 'string' ? v : '';
42
+ },
43
+ });
44
+
45
+ return {
46
+ nodeType: 'ScoreHud',
47
+ properties: [
48
+ str(
49
+ 'sourceNode',
50
+ 'Source Node',
51
+ 'Node that emits score-changed / lives-changed / time-changed'
52
+ ),
53
+ str('scoreLabel', 'Score Label'),
54
+ str('scoreFormat', 'Score Format', '{value} is replaced by the score'),
55
+ str('timeLabel', 'Time Label'),
56
+ str('timeFormat', 'Time Format', '{value} is replaced by whole seconds'),
57
+ str('livesBar', 'Lives Bar'),
58
+ ],
59
+ groups: { HUD: { label: 'HUD', expanded: true } },
60
+ };
61
+ }
62
+
63
+ onStart(): void {
64
+ const source = this.findNode(String(this.config.sourceNode ?? ''));
65
+ if (!source) {
66
+ console.warn(`[ScoreHud] Source node "${this.config.sourceNode}" not found.`);
67
+ return;
68
+ }
69
+ source.connect('score-changed', this, (...args: unknown[]) =>
70
+ this.setScore(Number(args[0]) || 0)
71
+ );
72
+ source.connect('time-changed', this, (...args: unknown[]) =>
73
+ this.setTime(Number(args[0]) || 0)
74
+ );
75
+ source.connect('lives-changed', this, (...args: unknown[]) =>
76
+ this.setLives(Number(args[0]) || 0, Number(args[1]) || 0)
77
+ );
78
+ // Ask for the opening values now that the listeners are live: GameRules
79
+ // starts before this HUD (parent components tick before children).
80
+ source.emit('hud-ready');
81
+ }
82
+
83
+ private setScore(score: number): void {
84
+ const label = this.findNode(String(this.config.scoreLabel ?? ''));
85
+ if (label instanceof Label2D) {
86
+ label.setText(
87
+ String(this.config.scoreFormat ?? '{value}').replace('{value}', String(Math.round(score)))
88
+ );
89
+ }
90
+ }
91
+
92
+ private setTime(seconds: number): void {
93
+ const label = this.findNode(String(this.config.timeLabel ?? ''));
94
+ if (label instanceof Label2D) {
95
+ label.setText(
96
+ String(this.config.timeFormat ?? '{value}').replace(
97
+ '{value}',
98
+ String(Math.max(0, Math.ceil(seconds)))
99
+ )
100
+ );
101
+ }
102
+ }
103
+
104
+ private setLives(lives: number, maxLives: number): void {
105
+ const bar = this.findNode(String(this.config.livesBar ?? ''));
106
+ if (bar instanceof Bar2D) {
107
+ if (maxLives > 0) {
108
+ bar.maxValue = maxLives;
109
+ }
110
+ bar.setValue(lives);
111
+ }
112
+ }
113
+ }
@@ -1,9 +1,9 @@
1
- id: playable-3d
2
- title: Playable 3D
3
- description: Portrait 3D playable-ad blank — tap-to-start intro (unlocks audio), gameplay placeholder, end screen with a store CTA button.
4
- # Flow recipe this template serves (see design/recipe.md).
5
- recipeId: recipe-scene-3d
6
- projectType: 3d
7
- targetPlatform: mobile
8
- viewport: { width: 1080, height: 1920 }
9
- order: 30
1
+ id: playable-3d
2
+ title: Playable 3D
3
+ description: Portrait 3D playable-ad blank — tap-to-start intro (unlocks audio), gameplay placeholder, end screen with a store CTA button.
4
+ # Flow recipe this template serves (see design/recipe.md).
5
+ recipeId: recipe-scene-3d
6
+ projectType: 3d
7
+ targetPlatform: mobile
8
+ viewport: { width: 1080, height: 1920 }
9
+ order: 30
@@ -1,48 +1,48 @@
1
- # Arena 2D
2
-
3
- A playable skeleton: an avatar in a bounded field, two spawners dripping
4
- pickups and hazards, touch resolved into score or damage, lives + a timer, and a
5
- win/lose screen with RETRY / MENU. It plays as shipped — press play in
6
- `scenes/main.pix3scene` before changing anything.
7
-
8
- **`design/recipe.md` is the contract.** It lists the stable node ids, the
9
- tunables (with their ranges), the extension points and what must not be renamed.
10
- Read it first; tools and agents grep it.
11
-
12
- ## Layout
13
-
14
- ```
15
- scenes/menu.pix3scene entry / export scene — PLAY transitions to the game
16
- scenes/main.pix3scene the GAME (editor startup scene — iterate here)
17
- scenes/ui/result.pix3scene win/lose overlay — instanced into main hidden
18
- scenes/prefabs/*.pix3scene what the spawners instantiate
19
- scripts/PlayerController.ts how the avatar moves (swap this file whole)
20
- scripts/Spawner.ts timed instantiate + drift + despawn
21
- scripts/TouchRules.ts contact → semantic signals
22
- scripts/ScoreHud.ts signals → HUD widgets
23
- scripts/GameRules.ts score / lives / timer / win / lose / end flow
24
- scripts/MenuFlow.ts menu PLAY button
25
- sprites/ph-*.png near-white placeholders (tinted via core:tint)
26
- ```
27
-
28
- Full-screen UI lives in its own scene file, never inline in the game scene.
29
- `main.pix3scene` carries the end screen as a one-line `instance:` marked
30
- `visible: false` — that hides it in the **editor** only, so opening the project
31
- shows the game instead of a GAME OVER card. What play mode reads is
32
- `initiallyVisible`, authored in `scenes/ui/result.pix3scene`. Edit the overlay
33
- full-screen in its own tab (right-click → Open Prefab); tick the eye in
34
- `main.pix3scene` to preview it composited, and untick it before saving.
35
-
36
- ## The rule that keeps it workable
37
-
38
- One file, one responsibility. Each script is 70–140 lines on purpose: small
39
- files can be edited surgically; monoliths get rewritten wholesale and lose the
40
- changes that were already working. When you add a mechanic, add a script.
41
-
42
- Signals are the seam between them:
43
-
44
- ```
45
- TouchRules --touch-scored/touch-damaged--> GameRules --score-changed--> ScoreHud
46
- --lives-changed-->
47
- --time-changed-->
48
- ```
1
+ # Arena 2D
2
+
3
+ A playable skeleton: an avatar in a bounded field, two spawners dripping
4
+ pickups and hazards, touch resolved into score or damage, lives + a timer, and a
5
+ win/lose screen with RETRY / MENU. It plays as shipped — press play in
6
+ `scenes/main.pix3scene` before changing anything.
7
+
8
+ **`design/recipe.md` is the contract.** It lists the stable node ids, the
9
+ tunables (with their ranges), the extension points and what must not be renamed.
10
+ Read it first; tools and agents grep it.
11
+
12
+ ## Layout
13
+
14
+ ```
15
+ scenes/menu.pix3scene entry / export scene — PLAY transitions to the game
16
+ scenes/main.pix3scene the GAME (editor startup scene — iterate here)
17
+ scenes/ui/result.pix3scene win/lose overlay — instanced into main hidden
18
+ scenes/prefabs/*.pix3scene what the spawners instantiate
19
+ scripts/PlayerController.ts how the avatar moves (swap this file whole)
20
+ scripts/Spawner.ts timed instantiate + drift + despawn
21
+ scripts/TouchRules.ts contact → semantic signals
22
+ scripts/ScoreHud.ts signals → HUD widgets
23
+ scripts/GameRules.ts score / lives / timer / win / lose / end flow
24
+ scripts/MenuFlow.ts menu PLAY button
25
+ sprites/ph-*.png near-white placeholders (tinted via core:tint)
26
+ ```
27
+
28
+ Full-screen UI lives in its own scene file, never inline in the game scene.
29
+ `main.pix3scene` carries the end screen as a one-line `instance:` marked
30
+ `visible: false` — that hides it in the **editor** only, so opening the project
31
+ shows the game instead of a GAME OVER card. What play mode reads is
32
+ `initiallyVisible`, authored in `scenes/ui/result.pix3scene`. Edit the overlay
33
+ full-screen in its own tab (right-click → Open Prefab); tick the eye in
34
+ `main.pix3scene` to preview it composited, and untick it before saving.
35
+
36
+ ## The rule that keeps it workable
37
+
38
+ One file, one responsibility. Each script is 70–140 lines on purpose: small
39
+ files can be edited surgically; monoliths get rewritten wholesale and lose the
40
+ changes that were already working. When you add a mechanic, add a script.
41
+
42
+ Signals are the seam between them:
43
+
44
+ ```
45
+ TouchRules --touch-scored/touch-damaged--> GameRules --score-changed--> ScoreHud
46
+ --lives-changed-->
47
+ --time-changed-->
48
+ ```
@@ -1,127 +1,127 @@
1
- # Recipe: recipe-arena-2d
2
-
3
- ## What this is
4
-
5
- An avatar in a bounded field; two spawners drip things in; touching a thing
6
- resolves through a group→outcome map into score or damage. Lives, timer,
7
- win/lose screen with RETRY / MENU. Playable before any edit — start from what
8
- runs. This is the **fallback recipe**: anything shaped like "steer something
9
- around a space while stuff comes at you" starts here.
10
-
11
- ## Node map
12
-
13
- | id | what |
14
- | --- | --- |
15
- | `game-root` | root; hosts `GameRules` + `TouchRules`; all HUD signals are emitted here |
16
- | `game-background` | full-screen `ColorRect2D` (palette background) |
17
- | `board` | the field (`Group2D`); its rect clamps the avatar |
18
- | `board-floor` | the field's visible plate |
19
- | `player` | avatar (`Sprite2D`), hosts `PlayerController` |
20
- | `spawner-pickups` / `spawner-hazards` | spawn bands, one `Spawner` each |
21
- | `hud` | `CanvasLayer2D` overlay, hosts `ScoreHud` |
22
- | `score-label` / `time-label` / `lives-bar` | HUD widgets, signal-driven only |
23
- | `menu-button` | back to the menu scene |
24
- | `result-overlay` / `result-label` / `retry-button` | end screen — authored in `scenes/ui/result.pix3scene`, instanced into `hud` with `visible: false` so the editor opens on the game; `initiallyVisible` and `GameRules` own it at run time |
25
-
26
- Signals: `TouchRules` emits `touch-scored` / `touch-damaged` on `game-root`;
27
- `GameRules` answers `score-changed`, `lives-changed`, `time-changed`,
28
- `game-won` / `game-lost`.
29
-
30
- ## Placeholders
31
-
32
- Near-white PNGs carrying a `core:tint` effect: set `effects[0].params.color` and
33
- the multiply gives you that palette colour.
34
-
35
- | role | file | node/prefab |
36
- | --- | --- | --- |
37
- | avatar | `sprites/ph-avatar.png` | `player` |
38
- | collectible | `sprites/ph-pickup.png` | `prefabs/pickup.pix3scene` |
39
- | threat | `sprites/ph-hazard.png` | `prefabs/hazard.pix3scene` |
40
-
41
- ## Tunables
42
-
43
- ```yaml
44
- tunables:
45
- playerMode: { node: player, component: "user:PlayerController", property: mode, default: pointer }
46
- playerSpeed: { node: player, component: "user:PlayerController", property: speed, min: 60, max: 2000, default: 620 }
47
- playerRadius: { node: player, component: "user:PlayerController", property: radius, min: 4, max: 400, default: 60 }
48
- pickupRate: { node: spawner-pickups, component: "user:Spawner", property: intervalSec, min: 0.05, max: 20, default: 1.0 }
49
- pickupMaxAlive: { node: spawner-pickups, component: "user:Spawner", property: maxAlive, min: 1, max: 200, default: 10 }
50
- pickupFallSpeed: { node: spawner-pickups, component: "user:Spawner", property: driftY, min: -3000, max: 3000, default: -300 }
51
- hazardRate: { node: spawner-hazards, component: "user:Spawner", property: intervalSec, min: 0.05, max: 20, default: 1.8 }
52
- hazardFallSpeed: { node: spawner-hazards, component: "user:Spawner", property: driftY, min: -3000, max: 3000, default: -380 }
53
- touchRadius: { node: game-root, component: "user:TouchRules", property: touchRadius, min: 4, max: 600, default: 62 }
54
- touchRules: { node: game-root, component: "user:TouchRules", property: rules, default: "pickup:score:1;hazard:damage:1" }
55
- winMode: { node: game-root, component: "user:GameRules", property: winMode, default: score }
56
- targetScore: { node: game-root, component: "user:GameRules", property: targetScore, min: 1, max: 99999, default: 12 }
57
- timeLimitSec: { node: game-root, component: "user:GameRules", property: timeLimitSec, min: 0, max: 600, default: 45 }
58
- startingLives: { node: game-root, component: "user:GameRules", property: startingLives, min: 1, max: 20, default: 3 }
59
- bgColor: { node: game-background, property: color, default: "#141a2e" }
60
- boardColor: { node: board-floor, property: color, default: "#1d2743" }
61
- ```
62
-
63
- `component` present → `set_component_property`; absent → `set_property`. The
64
- schema clamps out-of-range values rather than rejecting them.
65
-
66
- ## Extension points
67
-
68
- - **Different locomotion → replace `scripts/PlayerController.ts` wholesale.** It
69
- is the only file that reads input and writes `player.position`. A grid stepper,
70
- a jumper (velocity + gravity) or an auto-runner all drop in as long as the file
71
- still exports a `PlayerController` class and keeps the node inside `board`.
72
- Write it in ONE call — `fs_write` with `overwrite: true` and a `reason` (the
73
- size guard refuses a silent wholesale rewrite; this one is sanctioned).
74
- - **Trail / body behind the avatar.** Add a `segment` prefab (`Sprite2D` +
75
- `core:Hitbox2D` in a hazard group). In the locomotion script, on each completed
76
- step `scene.instantiate` a segment at the vacated position into a `segments`
77
- node and `queueFree` the tail once the trail exceeds its length. Self-collision
78
- then falls out of the existing rules — the segment's group is already in
79
- `touchRules`, so hitting your own trail costs a life with no new code. Grow the
80
- trail by skipping the tail free on a `touch-scored` frame.
81
- - **A third kind of thing.** Copy a prefab, give its `core:Hitbox2D` a new group,
82
- `create_node` + `add_component` a third `Spawner`, append `<group>:score:5` to
83
- `touchRules`. Chasers: a script on the prefab that steers toward `player`'s
84
- world position — contact is already punished. Spawn it under a spawner (the
85
- default `parentNode`) listed in `freezeNodes`: `finish()` freezes that whole
86
- subtree, so the chaser stops on game over. Parented elsewhere, it keeps
87
- crawling over the result card — list that parent in `freezeNodes` too.
88
- - **More juice.** `scene.juice.punchScale/shake/flash` + `scene.time.hitstop` are
89
- already called in `TouchRules`; raise the numbers there.
90
-
91
- ## Ending a run belongs to GameRules
92
-
93
- The result overlay and RETRY are **owned by `GameRules`**: it hides the overlay on
94
- start and keeps `retry-button` **disabled**, then `finish(won)` shows the overlay,
95
- writes the result text, enables RETRY and freezes the `freezeNodes` subtrees
96
- (every component on them and on what they spawned). For a custom
97
- win/lose condition call `finish(true|false)` on that component — do NOT show the
98
- overlay yourself. A hand-rolled ending leaves RETRY on screen with its handler bound
99
- and `enabled: false`, i.e. a button that can never be pressed (`game_observe` reports
100
- `control: { enabled: false }`; that is the tell).
101
-
102
- ## Do not touch
103
-
104
- - The node ids above (rename `name`, never `id`), and the signal names.
105
- - `visible: false` on the `result-overlay` instance in `main.pix3scene`. Tick the
106
- eye to preview the end screen over the field, but never save it on: it is what
107
- keeps every iteration from starting behind a GAME OVER card. Run-time
108
- visibility is `initiallyVisible` in `scenes/ui/result.pix3scene`, not this.
109
- - Component config on a **prefab instance** — the editor locks it. Prefabs carry
110
- fixed defaults only (their hitbox group); tunables live on plain nodes in
111
- `main.pix3scene`.
112
- - Do not import rapier: there is no rigidbody solver here and none is needed.
113
-
114
- ## Playtest contract
115
-
116
- terminalVisual: static
117
- terminalRestartRoutine: terminal-retry
118
-
119
- ## Verify
120
-
121
- 1. `play_start` on `scenes/main.pix3scene`.
122
- 2. `game_input` a pointer drag across the lower board — `player`'s position
123
- changes between two `game_observe` calls.
124
- 3. After ~3 s: children exist under `spawner-pickups`, and `score-label` climbs
125
- above `SCORE 0` once the avatar meets one.
126
- 4. Set `targetScore: 1`, replay, collect one → `result-overlay` visible with
127
- `YOU WIN!`; `retry-button` restarts the scene.
1
+ # Recipe: recipe-arena-2d
2
+
3
+ ## What this is
4
+
5
+ An avatar in a bounded field; two spawners drip things in; touching a thing
6
+ resolves through a group→outcome map into score or damage. Lives, timer,
7
+ win/lose screen with RETRY / MENU. Playable before any edit — start from what
8
+ runs. This is the **fallback recipe**: anything shaped like "steer something
9
+ around a space while stuff comes at you" starts here.
10
+
11
+ ## Node map
12
+
13
+ | id | what |
14
+ | --- | --- |
15
+ | `game-root` | root; hosts `GameRules` + `TouchRules`; all HUD signals are emitted here |
16
+ | `game-background` | full-screen `ColorRect2D` (palette background) |
17
+ | `board` | the field (`Group2D`); its rect clamps the avatar |
18
+ | `board-floor` | the field's visible plate |
19
+ | `player` | avatar (`Sprite2D`), hosts `PlayerController` |
20
+ | `spawner-pickups` / `spawner-hazards` | spawn bands, one `Spawner` each |
21
+ | `hud` | `CanvasLayer2D` overlay, hosts `ScoreHud` |
22
+ | `score-label` / `time-label` / `lives-bar` | HUD widgets, signal-driven only |
23
+ | `menu-button` | back to the menu scene |
24
+ | `result-overlay` / `result-label` / `retry-button` | end screen — authored in `scenes/ui/result.pix3scene`, instanced into `hud` with `visible: false` so the editor opens on the game; `initiallyVisible` and `GameRules` own it at run time |
25
+
26
+ Signals: `TouchRules` emits `touch-scored` / `touch-damaged` on `game-root`;
27
+ `GameRules` answers `score-changed`, `lives-changed`, `time-changed`,
28
+ `game-won` / `game-lost`.
29
+
30
+ ## Placeholders
31
+
32
+ Near-white PNGs carrying a `core:tint` effect: set `effects[0].params.color` and
33
+ the multiply gives you that palette colour.
34
+
35
+ | role | file | node/prefab |
36
+ | --- | --- | --- |
37
+ | avatar | `sprites/ph-avatar.png` | `player` |
38
+ | collectible | `sprites/ph-pickup.png` | `prefabs/pickup.pix3scene` |
39
+ | threat | `sprites/ph-hazard.png` | `prefabs/hazard.pix3scene` |
40
+
41
+ ## Tunables
42
+
43
+ ```yaml
44
+ tunables:
45
+ playerMode: { node: player, component: "user:PlayerController", property: mode, default: pointer }
46
+ playerSpeed: { node: player, component: "user:PlayerController", property: speed, min: 60, max: 2000, default: 620 }
47
+ playerRadius: { node: player, component: "user:PlayerController", property: radius, min: 4, max: 400, default: 60 }
48
+ pickupRate: { node: spawner-pickups, component: "user:Spawner", property: intervalSec, min: 0.05, max: 20, default: 1.0 }
49
+ pickupMaxAlive: { node: spawner-pickups, component: "user:Spawner", property: maxAlive, min: 1, max: 200, default: 10 }
50
+ pickupFallSpeed: { node: spawner-pickups, component: "user:Spawner", property: driftY, min: -3000, max: 3000, default: -300 }
51
+ hazardRate: { node: spawner-hazards, component: "user:Spawner", property: intervalSec, min: 0.05, max: 20, default: 1.8 }
52
+ hazardFallSpeed: { node: spawner-hazards, component: "user:Spawner", property: driftY, min: -3000, max: 3000, default: -380 }
53
+ touchRadius: { node: game-root, component: "user:TouchRules", property: touchRadius, min: 4, max: 600, default: 62 }
54
+ touchRules: { node: game-root, component: "user:TouchRules", property: rules, default: "pickup:score:1;hazard:damage:1" }
55
+ winMode: { node: game-root, component: "user:GameRules", property: winMode, default: score }
56
+ targetScore: { node: game-root, component: "user:GameRules", property: targetScore, min: 1, max: 99999, default: 12 }
57
+ timeLimitSec: { node: game-root, component: "user:GameRules", property: timeLimitSec, min: 0, max: 600, default: 45 }
58
+ startingLives: { node: game-root, component: "user:GameRules", property: startingLives, min: 1, max: 20, default: 3 }
59
+ bgColor: { node: game-background, property: color, default: "#141a2e" }
60
+ boardColor: { node: board-floor, property: color, default: "#1d2743" }
61
+ ```
62
+
63
+ `component` present → `set_component_property`; absent → `set_property`. The
64
+ schema clamps out-of-range values rather than rejecting them.
65
+
66
+ ## Extension points
67
+
68
+ - **Different locomotion → replace `scripts/PlayerController.ts` wholesale.** It
69
+ is the only file that reads input and writes `player.position`. A grid stepper,
70
+ a jumper (velocity + gravity) or an auto-runner all drop in as long as the file
71
+ still exports a `PlayerController` class and keeps the node inside `board`.
72
+ Write it in ONE call — `fs_write` with `overwrite: true` and a `reason` (the
73
+ size guard refuses a silent wholesale rewrite; this one is sanctioned).
74
+ - **Trail / body behind the avatar.** Add a `segment` prefab (`Sprite2D` +
75
+ `core:Hitbox2D` in a hazard group). In the locomotion script, on each completed
76
+ step `scene.instantiate` a segment at the vacated position into a `segments`
77
+ node and `queueFree` the tail once the trail exceeds its length. Self-collision
78
+ then falls out of the existing rules — the segment's group is already in
79
+ `touchRules`, so hitting your own trail costs a life with no new code. Grow the
80
+ trail by skipping the tail free on a `touch-scored` frame.
81
+ - **A third kind of thing.** Copy a prefab, give its `core:Hitbox2D` a new group,
82
+ `create_node` + `add_component` a third `Spawner`, append `<group>:score:5` to
83
+ `touchRules`. Chasers: a script on the prefab that steers toward `player`'s
84
+ world position — contact is already punished. Spawn it under a spawner (the
85
+ default `parentNode`) listed in `freezeNodes`: `finish()` freezes that whole
86
+ subtree, so the chaser stops on game over. Parented elsewhere, it keeps
87
+ crawling over the result card — list that parent in `freezeNodes` too.
88
+ - **More juice.** `scene.juice.punchScale/shake/flash` + `scene.time.hitstop` are
89
+ already called in `TouchRules`; raise the numbers there.
90
+
91
+ ## Ending a run belongs to GameRules
92
+
93
+ The result overlay and RETRY are **owned by `GameRules`**: it hides the overlay on
94
+ start and keeps `retry-button` **disabled**, then `finish(won)` shows the overlay,
95
+ writes the result text, enables RETRY and freezes the `freezeNodes` subtrees
96
+ (every component on them and on what they spawned). For a custom
97
+ win/lose condition call `finish(true|false)` on that component — do NOT show the
98
+ overlay yourself. A hand-rolled ending leaves RETRY on screen with its handler bound
99
+ and `enabled: false`, i.e. a button that can never be pressed (`game_observe` reports
100
+ `control: { enabled: false }`; that is the tell).
101
+
102
+ ## Do not touch
103
+
104
+ - The node ids above (rename `name`, never `id`), and the signal names.
105
+ - `visible: false` on the `result-overlay` instance in `main.pix3scene`. Tick the
106
+ eye to preview the end screen over the field, but never save it on: it is what
107
+ keeps every iteration from starting behind a GAME OVER card. Run-time
108
+ visibility is `initiallyVisible` in `scenes/ui/result.pix3scene`, not this.
109
+ - Component config on a **prefab instance** — the editor locks it. Prefabs carry
110
+ fixed defaults only (their hitbox group); tunables live on plain nodes in
111
+ `main.pix3scene`.
112
+ - Do not import rapier: there is no rigidbody solver here and none is needed.
113
+
114
+ ## Playtest contract
115
+
116
+ terminalVisual: static
117
+ terminalRestartRoutine: terminal-retry
118
+
119
+ ## Verify
120
+
121
+ 1. `play_start` on `scenes/main.pix3scene`.
122
+ 2. `game_input` a pointer drag across the lower board — `player`'s position
123
+ changes between two `game_observe` calls.
124
+ 3. After ~3 s: children exist under `spawner-pickups`, and `score-label` climbs
125
+ above `SCORE 0` once the avatar meets one.
126
+ 4. Set `targetScore: 1`, replay, collect one → `result-overlay` visible with
127
+ `YOU WIN!`; `retry-button` restarts the scene.