@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,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-targets` + `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/target.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
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * TouchRules — turns a tap into meaning.
3
+ *
4
+ * Same role (and the same `rules` format) as in the other recipes; only the
5
+ * source of the contact differs. Here a pointer-down is hit-tested against the
6
+ * `core:Hitbox2D` groups listed in `rules` and the topmost match resolves.
7
+ *
8
+ * It emits the same semantic signals every recipe uses, on `game-root`:
9
+ * `touch-scored` (amount, x, y)
10
+ * `touch-damaged` (amount, x, y)
11
+ *
12
+ * `rules` format: `group:outcome:amount`, entries separated by `;`.
13
+ * A tap that hits nothing can be punished too — see `missPenalty`.
14
+ */
15
+ import { Script, type PropertySchema } from '@pix3/runtime';
16
+
17
+ type TouchOutcome = 'score' | 'damage';
18
+
19
+ interface TouchRule {
20
+ group: string;
21
+ outcome: TouchOutcome;
22
+ amount: number;
23
+ }
24
+
25
+ function parseRules(text: string): TouchRule[] {
26
+ const rules: TouchRule[] = [];
27
+ for (const entry of text.split(';')) {
28
+ const parts = entry.trim().split(':');
29
+ if (parts.length < 2 || !parts[0].trim()) {
30
+ continue;
31
+ }
32
+ const amount = Number(parts[2] ?? 1);
33
+ rules.push({
34
+ group: parts[0].trim(),
35
+ outcome: parts[1].trim() === 'damage' ? 'damage' : 'score',
36
+ amount: Number.isFinite(amount) && amount > 0 ? amount : 1,
37
+ });
38
+ }
39
+ return rules;
40
+ }
41
+
42
+ export class TouchRules extends Script {
43
+ constructor(id: string, type: string) {
44
+ super(id, type);
45
+ this.config = {
46
+ // group:outcome:amount pairs, separated by ';'.
47
+ rules: 'target:score:1;hazard:damage:1',
48
+ // 0 = exact point test; larger = a more forgiving finger.
49
+ tapRadius: 40,
50
+ // Free the tapped node after resolving it.
51
+ consume: true,
52
+ // Damage dealt by a tap that hits nothing (0 = free misses).
53
+ missPenalty: 0,
54
+ scoreSound: '',
55
+ damageSound: '',
56
+ };
57
+ }
58
+
59
+ static getPropertySchema(): PropertySchema {
60
+ const num = (name: string, label: string, min: number, max: number, step: number) => ({
61
+ name,
62
+ type: 'number' as const,
63
+ ui: { label, group: 'Contact', min, max, step, slider: true },
64
+ getValue: (s: unknown) => (s as TouchRules).config[name],
65
+ setValue: (s: unknown, v: unknown) => {
66
+ const n = Number(v);
67
+ (s as TouchRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
68
+ },
69
+ });
70
+ const str = (name: string, label: string, group: string, editor?: 'audio-resource') => ({
71
+ name,
72
+ type: 'string' as const,
73
+ ui: { label, group, editor },
74
+ getValue: (s: unknown) => (s as TouchRules).config[name],
75
+ setValue: (s: unknown, v: unknown) => {
76
+ (s as TouchRules).config[name] = typeof v === 'string' ? v : '';
77
+ },
78
+ });
79
+
80
+ return {
81
+ nodeType: 'TouchRules',
82
+ properties: [
83
+ str('rules', 'Rules', 'Contact'),
84
+ num('tapRadius', 'Tap Radius', 0, 400, 1),
85
+ num('missPenalty', 'Miss Penalty', 0, 10, 1),
86
+ {
87
+ name: 'consume',
88
+ type: 'boolean',
89
+ ui: { label: 'Consume On Tap', group: 'Contact' },
90
+ getValue: s => (s as TouchRules).config.consume,
91
+ setValue: (s, v) => {
92
+ (s as TouchRules).config.consume = Boolean(v);
93
+ },
94
+ },
95
+ str('scoreSound', 'Score Sound', 'Feedback', 'audio-resource'),
96
+ str('damageSound', 'Damage Sound', 'Feedback', 'audio-resource'),
97
+ ],
98
+ groups: {
99
+ Contact: { label: 'Contact', expanded: true },
100
+ Feedback: { label: 'Feedback', expanded: false },
101
+ },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Every finger that landed this frame resolves on its own.
107
+ *
108
+ * Both halves of that are addressed on purpose. The UI gate asks whether **this** finger is over
109
+ * a control, not whether *any* control is hovered — the aggregate meant a thumb parked on a HUD
110
+ * button silently swallowed taps made with the other hand. And the contact point comes from the
111
+ * finger that produced the event, not from the shared pointer position, which under multi-touch
112
+ * describes only the oldest finger.
113
+ */
114
+ onUpdate(): void {
115
+ const input = this.input;
116
+ if (!this.scene || !this.node || !input) {
117
+ return;
118
+ }
119
+ for (const event of input.pointerEvents) {
120
+ if (event.type !== 'down') {
121
+ continue;
122
+ }
123
+ // With nothing in the addressed map — a harness driving the game by assigning input fields —
124
+ // there is no per-pointer hover to read, so the aggregate is all there is.
125
+ const overUI =
126
+ input.pointerDownCount > 0 ? input.isPointerOverUI(event.pointerId) : input.isHoveringUI;
127
+ if (overUI) {
128
+ continue;
129
+ }
130
+ this.resolveTap(event.pointerId);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Resolve one contact: the topmost rule match wins, and a tap that hits nothing may be punished.
136
+ */
137
+ private resolveTap(pointerId: number): void {
138
+ const scene = this.scene;
139
+ const owner = this.node;
140
+ if (!scene || !owner) {
141
+ return;
142
+ }
143
+ // A tap that went down and up inside one frame is already out of the pointer map by the time
144
+ // scripts run, so the addressed lookup misses; the shared position still holds where it landed.
145
+ const pointer =
146
+ scene.getPointer2DWorldPosition(pointerId) ?? scene.getPointer2DWorldPosition();
147
+ if (!pointer) {
148
+ return;
149
+ }
150
+
151
+ const radius = Math.max(0, Number(this.config.tapRadius) || 0);
152
+ const consume = Boolean(this.config.consume);
153
+
154
+ for (const rule of parseRules(String(this.config.rules ?? ''))) {
155
+ const hits =
156
+ radius > 0
157
+ ? scene.collision2d.overlapCircle(pointer.x, pointer.y, radius, rule.group)
158
+ : scene.collision2d.overlapPoint(pointer.x, pointer.y, rule.group);
159
+ // Only the first match resolves: one tap is one action.
160
+ const hit = hits[0];
161
+ if (!hit) {
162
+ continue;
163
+ }
164
+ if (rule.outcome === 'damage') {
165
+ owner.emit('touch-damaged', rule.amount, hit.x, hit.y);
166
+ scene.juice.flash({ color: '#ff4d6d', intensity: 0.4, durationSec: 0.22 });
167
+ this.playSound(String(this.config.damageSound ?? ''));
168
+ } else {
169
+ owner.emit('touch-scored', rule.amount, hit.x, hit.y);
170
+ scene.juice.punchScale(hit.node, { amount: 0.3, duration: 0.18 });
171
+ this.playSound(String(this.config.scoreSound ?? ''));
172
+ }
173
+ if (consume) {
174
+ hit.node.queueFree();
175
+ }
176
+ return;
177
+ }
178
+
179
+ const penalty = Math.max(0, Number(this.config.missPenalty) || 0);
180
+ if (penalty > 0) {
181
+ owner.emit('touch-damaged', penalty, pointer.x, pointer.y);
182
+ this.playSound(String(this.config.damageSound ?? ''));
183
+ }
184
+ }
185
+
186
+ private playSound(path: string): void {
187
+ if (!path) {
188
+ return;
189
+ }
190
+ void this.scene?.audio.play(path, { bus: 'sfx', pitchVariation: 0.08 });
191
+ }
192
+ }
@@ -0,0 +1,10 @@
1
+ id: recipe-tapper-2d
2
+ title: Tapper 2D
3
+ description: Objects fall into the field and tapping them is the whole game — good ones score, one bad type costs a life, and a countdown ends the run. Whack-a-mole, catch-the-falling, clickers.
4
+ projectType: 2d
5
+ targetPlatform: universal
6
+ viewport: { width: 1080, height: 1920 }
7
+ # The build/full-run entry scene (Project Settings → Default Export Scene Path).
8
+ # The menu boots first; PLAY transitions to main.pix3scene (the game).
9
+ entryScene: scenes/menu.pix3scene
10
+ order: 62