@pix3/cli 1.6.3 → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +722 -788
  3. package/dist/index.js +557 -421
  4. package/dist/runtime-types.json +1 -1
  5. package/dist/smoke/prebuilt/{build-7MIXIMSZ.mjs → build-7REMSA2S.mjs} +52 -52
  6. package/dist/smoke/prebuilt/chunk-4DGG2BKR.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-YLBMUJ2H.mjs +238 -0
  8. package/dist/smoke/prebuilt/runtime.mjs +1 -1
  9. package/dist/smoke/prebuilt/smoke-worker.mjs +211 -2
  10. package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
  11. package/dist/validate/prebuilt/{build-AUGKXER3.js → build-ADF5EEE2.js} +52 -52
  12. package/dist/validate/prebuilt/bundle-main.js +223 -18
  13. package/dist/validate/prebuilt/chunk-VIHIF4G2.js +238 -0
  14. package/dist/validate/prebuilt/runtime.js +1 -1
  15. package/kit/files/.claude/skills/pix3-editor/SKILL.md +200 -0
  16. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +13 -15
  17. package/kit/files/.claude/skills/pix3-nodes/reference.md +77 -71
  18. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +38 -41
  19. package/kit/files/.claude/skills/pix3-scene-format/pix3anim.md +185 -0
  20. package/kit/files/.claude/skills/pix3-scene-format/project-files.md +244 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -16
  22. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +34 -24
  23. package/kit/files/.claude/skills/pix3-scripts/reference.md +68 -141
  24. package/kit/files/.claude/skills/pix3-verify/SKILL.md +65 -326
  25. package/kit/files/.claude/skills/pix3-verify/reference.md +2 -14
  26. package/kit/files/AGENTS.md +76 -84
  27. package/kit/files/design/tests/bots/pix3-test-bot.d.ts +89 -0
  28. package/kit/kit.json +8 -5
  29. package/package.json +62 -54
  30. package/templates/2d/files/scenes/main.pix3scene +31 -0
  31. package/templates/2d/template.yaml +11 -0
  32. package/templates/3d/files/scenes/main.pix3scene +50 -0
  33. package/templates/3d/template.yaml +11 -0
  34. package/templates/base/files/README.md +39 -0
  35. package/templates/base/files/gitignore +11 -0
  36. package/templates/base/files/index.html +23 -0
  37. package/templates/base/files/package.json +28 -0
  38. package/templates/base/files/src/main.ts +3 -0
  39. package/templates/base/files/tsconfig.json +17 -0
  40. package/templates/base/files/vite.config.ts +10 -0
  41. package/dist/smoke/prebuilt/chunk-CBFYKDS7.mjs +0 -2
  42. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +0 -239
  43. package/dist/validate/prebuilt/chunk-CFQFEI52.js +0 -239
  44. package/templates/empty-2d/files/README.md +0 -20
  45. package/templates/empty-2d/files/scenes/main.pix3scene +0 -68
  46. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  47. package/templates/empty-2d/template.yaml +0 -7
  48. package/templates/empty-3d/files/README.md +0 -20
  49. package/templates/empty-3d/files/scenes/main.pix3scene +0 -108
  50. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  51. package/templates/empty-3d/template.yaml +0 -7
  52. package/templates/idea-blank/files/README.md +0 -22
  53. package/templates/idea-blank/files/scenes/main.pix3scene +0 -37
  54. package/templates/idea-blank/template.yaml +0 -10
  55. package/templates/minigame-2d/files/README.md +0 -44
  56. package/templates/minigame-2d/files/design/tests/reachability.json +0 -1
  57. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +0 -44
  58. package/templates/minigame-2d/files/scenes/main.pix3scene +0 -84
  59. package/templates/minigame-2d/files/scenes/menu.pix3scene +0 -118
  60. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +0 -138
  61. package/templates/minigame-2d/files/scripts/GameFlow.ts +0 -133
  62. package/templates/minigame-2d/files/scripts/MenuFlow.ts +0 -217
  63. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +0 -198
  64. package/templates/minigame-2d/template.yaml +0 -10
  65. package/templates/playable-2d/files/README.md +0 -30
  66. package/templates/playable-2d/files/design/recipe.md +0 -83
  67. package/templates/playable-2d/files/design/tests/reachability.json +0 -4
  68. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +0 -32
  69. package/templates/playable-2d/files/scenes/main.pix3scene +0 -111
  70. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +0 -91
  71. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +0 -65
  72. package/templates/playable-2d/files/scripts/CtaButton.ts +0 -55
  73. package/templates/playable-2d/files/scripts/GameFlow.ts +0 -256
  74. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  75. package/templates/playable-2d/template.yaml +0 -9
  76. package/templates/playable-3d/files/README.md +0 -32
  77. package/templates/playable-3d/files/design/recipe.md +0 -127
  78. package/templates/playable-3d/files/design/tests/reachability.json +0 -4
  79. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +0 -32
  80. package/templates/playable-3d/files/scenes/main.pix3scene +0 -230
  81. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +0 -91
  82. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +0 -65
  83. package/templates/playable-3d/files/scripts/CtaButton.ts +0 -55
  84. package/templates/playable-3d/files/scripts/GameFlow.ts +0 -309
  85. package/templates/playable-3d/files/scripts/GameRules.ts +0 -290
  86. package/templates/playable-3d/files/scripts/ScoreHud.ts +0 -113
  87. package/templates/playable-3d/template.yaml +0 -9
  88. package/templates/recipe-arena-2d/files/README.md +0 -48
  89. package/templates/recipe-arena-2d/files/design/recipe.md +0 -127
  90. package/templates/recipe-arena-2d/files/design/tests/reachability.json +0 -4
  91. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +0 -23
  92. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +0 -292
  93. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +0 -100
  94. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
  95. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +0 -45
  96. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +0 -85
  97. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +0 -368
  98. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +0 -106
  99. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +0 -151
  100. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +0 -94
  101. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +0 -214
  102. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +0 -174
  103. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  104. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  105. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  106. package/templates/recipe-arena-2d/template.yaml +0 -10
  107. package/templates/recipe-blank-2d/files/README.md +0 -61
  108. package/templates/recipe-blank-2d/files/design/recipe.md +0 -148
  109. package/templates/recipe-blank-2d/files/design/tests/reachability.json +0 -4
  110. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +0 -27
  111. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +0 -23
  112. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +0 -224
  113. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +0 -85
  114. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +0 -350
  115. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +0 -110
  116. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  117. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  118. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  119. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  120. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  121. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  122. package/templates/recipe-blank-2d/template.yaml +0 -10
  123. package/templates/recipe-bouncer-2d/files/README.md +0 -81
  124. package/templates/recipe-bouncer-2d/files/design/recipe.md +0 -146
  125. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +0 -4
  126. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +0 -23
  127. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +0 -615
  128. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +0 -191
  129. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +0 -87
  130. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +0 -298
  131. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +0 -377
  132. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +0 -109
  133. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +0 -112
  134. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +0 -94
  135. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +0 -263
  136. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +0 -307
  137. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  138. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  139. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  140. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  141. package/templates/recipe-bouncer-2d/template.yaml +0 -10
  142. package/templates/recipe-grid-3d/files/README.md +0 -22
  143. package/templates/recipe-grid-3d/files/design/recipe.md +0 -110
  144. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +0 -169
  145. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +0 -100
  146. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +0 -105
  147. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +0 -319
  148. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +0 -278
  149. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +0 -106
  150. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +0 -94
  151. package/templates/recipe-grid-3d/template.yaml +0 -10
  152. package/templates/recipe-tapper-2d/files/README.md +0 -39
  153. package/templates/recipe-tapper-2d/files/design/recipe.md +0 -132
  154. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +0 -4
  155. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +0 -23
  156. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +0 -269
  157. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +0 -100
  158. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
  159. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +0 -45
  160. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +0 -85
  161. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +0 -368
  162. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +0 -106
  163. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +0 -94
  164. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +0 -214
  165. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +0 -192
  166. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  167. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  168. package/templates/recipe-tapper-2d/template.yaml +0 -10
@@ -1,100 +0,0 @@
1
- # Pix3 Scene File (YAML)
2
- # Arena 2D — the MENU scene. This is the project's ENTRY / export scene
3
- # (Project Settings → Default Export Scene Path): a full build and "Play Game (Entry Scene)"
4
- # boot here. PLAY transitions to the game scene (main.pix3scene) via a fade.
5
- # Wired by scripts/MenuFlow.ts. Iterate on the GAME in main.pix3scene.
6
- version: 1.0.0
7
- metadata:
8
- author: Pix3 Recipe
9
- description: Menu scene — entry point; PLAY transitions to the game
10
- root:
11
- - id: menu-root
12
- type: Group2D
13
- name: Menu Root
14
- properties:
15
- width: 1080
16
- height: 1920
17
- layout:
18
- enabled: true
19
- horizontalAlign: stretch
20
- verticalAlign: stretch
21
- components:
22
- - id: menu-flow
23
- type: user:MenuFlow
24
- enabled: true
25
- config:
26
- gameScene: res://scenes/main.pix3scene
27
- playButton: play-button
28
- children:
29
- - id: menu-background
30
- type: ColorRect2D
31
- name: Menu Background
32
- properties:
33
- width: 1080
34
- height: 1920
35
- color: "#141a2e"
36
- transform:
37
- position: [0, 0]
38
- scale: [1, 1]
39
- rotation: 0
40
- layout:
41
- enabled: true
42
- horizontalAlign: stretch
43
- verticalAlign: stretch
44
- children: []
45
- - id: title-label
46
- type: Label2D
47
- name: Title Label
48
- properties:
49
- label: "{{PROJECT_NAME}}"
50
- labelFontSize: 96
51
- labelColor: "#ffffff"
52
- labelAlign: center
53
- transform:
54
- position: [0, 420]
55
- scale: [1, 1]
56
- rotation: 0
57
- layout:
58
- enabled: true
59
- horizontalAlign: center
60
- verticalAlign: center
61
- children: []
62
- - id: subtitle-label
63
- type: Label2D
64
- name: Subtitle Label
65
- properties:
66
- label: "COLLECT. DODGE. SURVIVE."
67
- labelFontSize: 40
68
- labelColor: "#93a3c9"
69
- labelAlign: center
70
- transform:
71
- position: [0, 300]
72
- scale: [1, 1]
73
- rotation: 0
74
- layout:
75
- enabled: true
76
- horizontalAlign: center
77
- verticalAlign: center
78
- children: []
79
- - id: play-button
80
- type: Button2D
81
- name: Play Button
82
- properties:
83
- width: 480
84
- height: 140
85
- label: "PLAY"
86
- labelFontSize: 56
87
- labelColor: "#141a2e"
88
- backgroundColor: "#f5ae39"
89
- hoverColor: "#ffc862"
90
- pressedColor: "#d4922a"
91
- buttonAction: play
92
- transform:
93
- position: [0, -60]
94
- scale: [1, 1]
95
- rotation: 0
96
- layout:
97
- enabled: true
98
- horizontalAlign: center
99
- verticalAlign: center
100
- children: []
@@ -1,42 +0,0 @@
1
- # Pix3 Prefab (a .pix3scene with exactly ONE root node).
2
- # Spawned by scripts/Spawner.ts. Same shape as pickup.pix3scene — only the
3
- # hitbox GROUP (and the art) differ, and that group is what turns contact into
4
- # damage instead of score (see TouchRules' `rules`).
5
- version: 1.0.0
6
- metadata:
7
- author: Pix3 Recipe
8
- description: Hazard — touching it costs a life
9
- root:
10
- - id: hazard
11
- type: Sprite2D
12
- name: Hazard
13
- properties:
14
- texture: { type: 'texture', url: 'res://sprites/ph-hazard.png' }
15
- width: 104
16
- height: 104
17
- effects:
18
- - type: core:tint
19
- params: { color: "#ff4d6d", amount: 1 }
20
- transform:
21
- position: [0, 0]
22
- scale: [1, 1]
23
- rotation: 0
24
- components:
25
- - id: hazard-hitbox
26
- type: core:Hitbox2D
27
- enabled: true
28
- config:
29
- shape: circle
30
- radius: 44
31
- group: hazard
32
- debugDraw: false
33
- - id: hazard-pop
34
- type: core:PopIn
35
- enabled: true
36
- config:
37
- from: 0.2
38
- duration: 0.25
39
- easing: backOut
40
- playOnStart: true
41
- triggerEvent: ""
42
- children: []
@@ -1,45 +0,0 @@
1
- # Pix3 Prefab (a .pix3scene with exactly ONE root node).
2
- # Spawned by scripts/Spawner.ts via scene.instantiate().
3
- #
4
- # It carries only FIXED defaults — above all its `core:Hitbox2D` GROUP, which is
5
- # what TouchRules matches on. Component config on a prefab INSTANCE cannot be
6
- # edited (the editor locks it), so every tunable lives on a plain node in
7
- # main.pix3scene instead. Change the group here and in TouchRules' `rules`.
8
- version: 1.0.0
9
- metadata:
10
- author: Pix3 Recipe
11
- description: Pickup — touching it scores
12
- root:
13
- - id: pickup
14
- type: Sprite2D
15
- name: Pickup
16
- properties:
17
- texture: { type: 'texture', url: 'res://sprites/ph-pickup.png' }
18
- width: 96
19
- height: 96
20
- effects:
21
- - type: core:tint
22
- params: { color: "#7ee787", amount: 1 }
23
- transform:
24
- position: [0, 0]
25
- scale: [1, 1]
26
- rotation: 0
27
- components:
28
- - id: pickup-hitbox
29
- type: core:Hitbox2D
30
- enabled: true
31
- config:
32
- shape: circle
33
- radius: 46
34
- group: pickup
35
- debugDraw: false
36
- - id: pickup-pop
37
- type: core:PopIn
38
- enabled: true
39
- config:
40
- from: 0.2
41
- duration: 0.25
42
- easing: backOut
43
- playOnStart: true
44
- triggerEvent: ""
45
- children: []
@@ -1,85 +0,0 @@
1
- # Pix3 Scene File (YAML)
2
- # Arena 2D — the RESULT overlay (win/lose), in its own file so the gameplay
3
- # scene opens clean. `main.pix3scene` carries it as a one-line
4
- # `instance:` reference marked `visible: false`, which hides it in the EDITOR
5
- # only; `user:GameRules` reveals it at run time by setting `visible`.
6
- #
7
- # Edit it here, full-screen, in its own tab. To preview it composited over the
8
- # game, tick the eye on `result-overlay` in main.pix3scene — just do not commit
9
- # that tick, or every iteration starts behind a GAME OVER screen again.
10
- #
11
- # Stable node ids (design/recipe.md is the contract — do not rename these):
12
- # result-overlay, result-label, retry-button
13
- version: 1.0.0
14
- metadata:
15
- author: Pix3 Recipe
16
- description: Result overlay — dim, win/lose headline and a RETRY button
17
- root:
18
- - id: result-overlay
19
- type: Group2D
20
- name: Result Overlay
21
- properties:
22
- width: 1080
23
- height: 1920
24
- initiallyVisible: false
25
- layout:
26
- enabled: true
27
- horizontalAlign: stretch
28
- verticalAlign: stretch
29
- children:
30
- - id: result-dim
31
- type: ColorRect2D
32
- name: Result Dim
33
- properties:
34
- width: 1080
35
- height: 1920
36
- color: "#000000"
37
- opacity: 0.7
38
- transform:
39
- position: [0, 0]
40
- scale: [1, 1]
41
- rotation: 0
42
- layout:
43
- enabled: true
44
- horizontalAlign: stretch
45
- verticalAlign: stretch
46
- children: []
47
- - id: result-label
48
- type: Label2D
49
- name: Result Label
50
- properties:
51
- label: "GAME OVER"
52
- labelFontSize: 88
53
- labelColor: "#ffffff"
54
- labelAlign: center
55
- transform:
56
- position: [0, 160]
57
- scale: [1, 1]
58
- rotation: 0
59
- layout:
60
- enabled: true
61
- horizontalAlign: center
62
- verticalAlign: center
63
- children: []
64
- - id: retry-button
65
- type: Button2D
66
- name: Retry Button
67
- properties:
68
- width: 420
69
- height: 130
70
- label: "RETRY"
71
- labelFontSize: 50
72
- labelColor: "#141a2e"
73
- backgroundColor: "#f5ae39"
74
- hoverColor: "#ffc862"
75
- pressedColor: "#d4922a"
76
- buttonAction: retry
77
- transform:
78
- position: [0, -60]
79
- scale: [1, 1]
80
- rotation: 0
81
- layout:
82
- enabled: true
83
- horizontalAlign: center
84
- verticalAlign: center
85
- children: []
@@ -1,368 +0,0 @@
1
- /**
2
- * GameRules — score, lives, timer, win/lose and the end flow.
3
- *
4
- * Listens on its own node for the semantic signals `TouchRules` emits
5
- * (`touch-scored` / `touch-damaged`), keeps the run state, and re-broadcasts
6
- * `score-changed` / `lives-changed` / `time-changed` for the HUD. It is the only
7
- * script that decides when the run is over.
8
- *
9
- * Win modes:
10
- * `score` — reach `targetScore`; the time limit (if any) is a deadline.
11
- * `time` — play until the timer ends, then win if `targetScore` was reached.
12
- * `survive` — stay alive until the timer ends; reaching it is the win.
13
- * In every mode, running out of lives loses.
14
- *
15
- * The end-screen buttons dispatch the commands `restart` and `return-to-menu`
16
- * (`scene.commands`) instead of calling their methods: a test replays the end
17
- * flow without hunting for buttons, the run's intents are journalled with the
18
- * frame they happened on, and one real tap proves each binding once.
19
- *
20
- * It also publishes the run's state through `registerGameDebug`: this script
21
- * owns everything a test needs to read (score, lives, clock, outcome), so tools
22
- * and agents can assert on values instead of guessing from pixels. Add your own
23
- * state to `snapshot()` as you extend the rules — keep every value JSON-safe.
24
- */
25
- import {
26
- Button2D,
27
- Label2D,
28
- NodeBase,
29
- Script,
30
- registerGameDebug,
31
- type PropertySchema,
32
- } from '@pix3/runtime';
33
-
34
- type WinMode = 'score' | 'time' | 'survive';
35
-
36
- /** Snapshot numbers are read by humans and diffed by tools — keep them short. */
37
- function roundHundredths(value: number): number {
38
- return Math.round(value * 100) / 100;
39
- }
40
-
41
- export class GameRules extends Script {
42
- private score = 0;
43
- private lives = 3;
44
- private elapsed = 0;
45
- private over = false;
46
- private won = false;
47
- private disposeDebug: (() => void) | null = null;
48
- private disposeCommands: (() => void)[] = [];
49
- /** Components `finish()` switched off, so `resetRun()` revives exactly those. */
50
- private frozen: { enabled: boolean }[] = [];
51
-
52
- constructor(id: string, type: string) {
53
- super(id, type);
54
- this.config = {
55
- winMode: 'score',
56
- targetScore: 12,
57
- timeLimitSec: 45,
58
- startingLives: 3,
59
- resultNode: 'result-overlay',
60
- resultLabel: 'result-label',
61
- winText: 'YOU WIN!',
62
- loseText: 'GAME OVER',
63
- retryButton: 'retry-button',
64
- menuButton: 'menu-button',
65
- menuScene: 'res://scenes/menu.pix3scene',
66
- gameScene: 'res://scenes/main.pix3scene',
67
- // Comma-separated node ids frozen on game over: every component on the node and
68
- // on everything under it (spawned instances included) is disabled.
69
- freezeNodes: 'spawner-pickups,spawner-hazards,player',
70
- };
71
- }
72
-
73
- static getPropertySchema(): PropertySchema {
74
- const num = (name: string, label: string, min: number, max: number, step: number) => ({
75
- name,
76
- type: 'number' as const,
77
- ui: { label, group: 'Rules', min, max, step, slider: true },
78
- getValue: (s: unknown) => (s as GameRules).config[name],
79
- setValue: (s: unknown, v: unknown) => {
80
- const n = Number(v);
81
- (s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
82
- },
83
- });
84
- const str = (name: string, label: string, group: string) => ({
85
- name,
86
- type: 'string' as const,
87
- ui: { label, group },
88
- getValue: (s: unknown) => (s as GameRules).config[name],
89
- setValue: (s: unknown, v: unknown) => {
90
- (s as GameRules).config[name] = typeof v === 'string' ? v : '';
91
- },
92
- });
93
-
94
- return {
95
- nodeType: 'GameRules',
96
- properties: [
97
- {
98
- name: 'winMode',
99
- type: 'select',
100
- ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
101
- getValue: s => (s as GameRules).config.winMode,
102
- setValue: (s, v) => {
103
- (s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
104
- },
105
- },
106
- num('targetScore', 'Target Score', 1, 99999, 1),
107
- num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
108
- num('startingLives', 'Starting Lives', 1, 20, 1),
109
- str('winText', 'Win Text', 'Result'),
110
- str('loseText', 'Lose Text', 'Result'),
111
- str('resultNode', 'Result Overlay', 'Result'),
112
- str('resultLabel', 'Result Label', 'Result'),
113
- str('retryButton', 'Retry Button', 'Flow'),
114
- str('menuButton', 'Menu Button', 'Flow'),
115
- str('menuScene', 'Menu Scene', 'Flow'),
116
- str('gameScene', 'Game Scene', 'Flow'),
117
- str('freezeNodes', 'Freeze Nodes', 'Flow'),
118
- ],
119
- groups: {
120
- Rules: { label: 'Rules', expanded: true },
121
- Result: { label: 'Result Screen', expanded: true },
122
- Flow: { label: 'Flow', expanded: false },
123
- },
124
- };
125
- }
126
-
127
- onStart(): void {
128
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
129
- const commands = this.scene?.commands;
130
- this.disposeCommands = [
131
- commands?.register('restart', () => this.restart(), {
132
- description: 'Reload the game scene for a fresh run.',
133
- }),
134
- commands?.register('return-to-menu', () => this.toMenu(), {
135
- description: 'Leave the run and transition back to the menu scene.',
136
- }),
137
- ].filter((dispose): dispose is () => void => dispose !== undefined);
138
-
139
- this.disposeDebug = registerGameDebug({
140
- name: 'recipe-arena-2d',
141
- version: 1,
142
- // The registry IS the action list — never a second, hand-kept copy.
143
- actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
144
- // What "the start" means for a run, so a test can put the game back without
145
- // reloading the scene — a reload restores the scene graph but not what a
146
- // script parked outside it, which is why tooling has to label the two
147
- // differently. `seed` is accepted and ignored here: the recipe's spawners
148
- // draw from `Math.random`, so this restores the same starting STATE, not the
149
- // same SEQUENCE. Wire it to a seeded RNG if you need replayable runs.
150
- reset: () => this.resetRun(),
151
- snapshot: () => ({
152
- scene: 'game',
153
- phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
154
- score: this.score,
155
- lives: this.lives,
156
- winMode: String(this.config.winMode ?? 'score'),
157
- targetScore: Math.max(1, Number(this.config.targetScore) || 1),
158
- elapsedSec: roundHundredths(this.elapsed),
159
- // null when the mode has no clock at all.
160
- timeLeftSec: this.timeLeftSec(),
161
- }),
162
- });
163
-
164
- const owner = this.node;
165
- owner?.connect('touch-scored', this, (...args: unknown[]) =>
166
- this.addScore(Number(args[0]) || 0)
167
- );
168
- owner?.connect('touch-damaged', this, (...args: unknown[]) =>
169
- this.takeDamage(Number(args[0]) || 0)
170
- );
171
- // The HUD announces itself once it is connected, so it never misses the
172
- // opening values (its onStart runs after this one).
173
- owner?.connect('hud-ready', this, () => this.broadcast());
174
-
175
- this.setNodeVisible(String(this.config.resultNode ?? ''), false);
176
- this.bindButton(
177
- String(this.config.retryButton ?? ''),
178
- () => this.scene?.commands.dispatch('restart'),
179
- false
180
- );
181
- this.bindButton(
182
- String(this.config.menuButton ?? ''),
183
- () => this.scene?.commands.dispatch('return-to-menu'),
184
- true
185
- );
186
- this.broadcast();
187
- }
188
-
189
- onUpdate(dt: number): void {
190
- if (this.over) {
191
- return;
192
- }
193
- this.elapsed += dt;
194
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
195
- this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
196
-
197
- if (this.lives <= 0) {
198
- this.finish(false);
199
- return;
200
- }
201
- const raw = String(this.config.winMode ?? 'score');
202
- const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
203
- const target = Math.max(1, Number(this.config.targetScore) || 1);
204
- const timeUp = limit > 0 && this.elapsed >= limit;
205
-
206
- if (mode === 'score') {
207
- if (this.score >= target) this.finish(true);
208
- else if (timeUp) this.finish(false);
209
- } else if (mode === 'time') {
210
- if (timeUp) this.finish(this.score >= target);
211
- } else if (timeUp) {
212
- this.finish(true);
213
- }
214
- }
215
-
216
- onDetach(): void {
217
- // Clearing is safe in any order: the disposer only clears the global when it
218
- // still holds *this* provider, so a scene that already registered its own wins.
219
- this.disposeDebug?.();
220
- this.disposeDebug = null;
221
- // The scene's registry is cleared on stop anyway; this covers the other
222
- // case — this script being detached while the scene keeps running.
223
- for (const dispose of this.disposeCommands) {
224
- dispose();
225
- }
226
- this.disposeCommands = [];
227
- super.onDetach();
228
- }
229
-
230
- /** Seconds left on the clock, or `null` when this run has no time limit. */
231
- private timeLeftSec(): number | null {
232
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
233
- return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
234
- }
235
-
236
- private addScore(amount: number): void {
237
- if (this.over) return;
238
- this.score += amount;
239
- this.node?.emit('score-changed', this.score);
240
- }
241
-
242
- private takeDamage(amount: number): void {
243
- if (this.over) return;
244
- this.lives = Math.max(0, this.lives - Math.max(1, amount));
245
- this.node?.emit(
246
- 'lives-changed',
247
- this.lives,
248
- Math.max(1, Number(this.config.startingLives) || 1)
249
- );
250
- }
251
-
252
- private broadcast(): void {
253
- const owner = this.node;
254
- owner?.emit('score-changed', this.score);
255
- owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
256
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
257
- owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
258
- }
259
-
260
- /** End the run. Call this from your own code for a custom win/lose condition. */
261
- finish(won: boolean): void {
262
- if (this.over) return;
263
- this.over = true;
264
- this.won = won;
265
-
266
- const label = this.findNode(String(this.config.resultLabel ?? ''));
267
- if (label instanceof Label2D) {
268
- label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
269
- }
270
- this.setNodeVisible(String(this.config.resultNode ?? ''), true);
271
- this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
272
-
273
- // A freeze node freezes its whole subtree: the spawners' own components AND
274
- // every instance they spawned under themselves (a chaser's script, a pickup's
275
- // pop-in), so nothing keeps moving over the result card.
276
- for (const id of String(this.config.freezeNodes ?? '').split(',')) {
277
- const root = this.findNode(id.trim());
278
- if (!root) continue;
279
- root.traverse(object => {
280
- if (!(object instanceof NodeBase)) return;
281
- for (const component of object.components) {
282
- // Only remember what WE switched off: reviving everything on reset would
283
- // silently enable a component the scene author had left disabled.
284
- if (!component.enabled) continue;
285
- component.enabled = false;
286
- this.frozen.push(component);
287
- }
288
- });
289
- }
290
- this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
291
- }
292
-
293
- private restart(): void {
294
- void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
295
- }
296
-
297
- private toMenu(): void {
298
- void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
299
- }
300
-
301
- /**
302
- * Back to the state `onStart` left behind: score, lives, clock and outcome, the
303
- * result overlay hidden, the freeze `finish()` applied lifted, and anything a
304
- * spawner still has on the field despawned. Those leftovers are state too — a
305
- * run that begins amid the previous run's targets did not begin from the start.
306
- *
307
- * Public because it is useful on its own (an in-run "try again" that skips the
308
- * scene reload); the debug provider's `reset` is this method.
309
- */
310
- resetRun(): void {
311
- for (const component of this.frozen) {
312
- component.enabled = true;
313
- }
314
- this.frozen = [];
315
- this.clearSpawned();
316
-
317
- this.score = 0;
318
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
319
- this.elapsed = 0;
320
- this.over = false;
321
- this.won = false;
322
-
323
- this.setNodeVisible(String(this.config.resultNode ?? ''), false);
324
- this.setButtonEnabled(String(this.config.retryButton ?? ''), false);
325
- this.broadcast();
326
- }
327
-
328
- /**
329
- * Ask every component on a `freezeNodes` node that owns spawned instances to
330
- * drop them. Duck-typed instead of importing `Spawner`: the freeze list is
331
- * whatever the scene author wrote there, and a project may put its own spawner
332
- * in it — one that also happens to offer `clear()`.
333
- */
334
- private clearSpawned(): void {
335
- for (const id of String(this.config.freezeNodes ?? '').split(',')) {
336
- const node = this.findNode(id.trim());
337
- if (!node) continue;
338
- for (const component of node.components) {
339
- const clear = (component as { clear?: unknown }).clear;
340
- if (typeof clear === 'function') {
341
- (clear as () => void).call(component);
342
- }
343
- }
344
- }
345
- }
346
-
347
- private bindButton(query: string, handler: () => void, enabled: boolean): void {
348
- const button = this.findNode(query);
349
- if (button instanceof Button2D) {
350
- button.enabled = enabled;
351
- button.connect('pressed', this, handler);
352
- }
353
- }
354
-
355
- private setButtonEnabled(query: string, enabled: boolean): void {
356
- const button = this.findNode(query);
357
- if (button instanceof Button2D) {
358
- button.enabled = enabled;
359
- }
360
- }
361
-
362
- private setNodeVisible(query: string, visible: boolean): void {
363
- const node = this.findNode(query);
364
- if (node) {
365
- node.visible = visible;
366
- }
367
- }
368
- }