@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,319 +1,319 @@
1
- /**
2
- * GridBoard — the block you carve.
3
- *
4
- * Builds a solid `sizeX × sizeY × sizeZ` grid of cubes under `boardAnchor` at start, then turns a
5
- * tap into the removal of one cube. Some cubes are secretly CORE: hitting one costs a life instead
6
- * of scoring. Clear every non-core cube to finish the board.
7
- *
8
- * It emits the semantic signals the rules script listens for, on `game-root`:
9
- * `cell-cleared` (remaining)
10
- * `core-hit` (remaining)
11
- * `board-cleared` ()
12
- *
13
- * **Picking is a 3D raycast, not a 2D hit test.** 2D nodes are hit-tested against `Hitbox2D`
14
- * groups; a mesh in a perspective frustum has no such thing, so the contact comes from
15
- * `scene.raycastViewport(nx, ny)` with the pointer converted to normalized device coordinates. That
16
- * conversion is the part everyone gets wrong — Y is flipped, and the divisor is the INPUT surface
17
- * (`input.width/height`), not the window.
18
- *
19
- * **Mobile budget.** Every cube is a `GeometryMesh` with `material.type: 'lambert'`: this recipe
20
- * ships hundreds of them, and PBR on a phone at this count is the difference between a smooth board
21
- * and a slideshow. A 4×4×4 board is 64 draw calls — if you grow it past ~8³, move to an
22
- * `InstancedMesh3D` before you move to prettier materials.
23
- */
24
- import { GeometryMesh, Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
25
-
26
- /** Cube metadata, kept in a map rather than on the node so nothing depends on node naming. */
27
- interface Cell {
28
- node: GeometryMesh;
29
- isCore: boolean;
30
- }
31
-
32
- export class GridBoard extends Script {
33
- private cells = new Map<string, Cell>();
34
- private remaining = 0;
35
- private cleared = false;
36
-
37
- constructor(id: string, type: string) {
38
- super(id, type);
39
- this.config = {
40
- // Node the cubes are parented to (kept separate from game-root so clearing is one subtree).
41
- boardAnchor: 'board-anchor',
42
- sizeX: 4,
43
- sizeY: 4,
44
- sizeZ: 4,
45
- cellSize: 0.9,
46
- // Centre-to-centre spacing; > cellSize leaves visible seams between cubes.
47
- spacing: 1,
48
- // How many cubes are core (a life each). Never more than a third of the board.
49
- coreCount: 6,
50
- cellColor: '#3ee6c1',
51
- coreColor: '#ff6b6b',
52
- // Reveal core cubes in their own colour instead of hiding them (debug / tutorial).
53
- revealCores: false,
54
- clearSound: '',
55
- coreSound: '',
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: 'Board', min, max, step, slider: true },
64
- getValue: (s: unknown) => (s as GridBoard).config[name],
65
- setValue: (s: unknown, v: unknown) => {
66
- const n = Number(v);
67
- (s as GridBoard).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 ? { editor } : {}) },
74
- getValue: (s: unknown) => (s as GridBoard).config[name],
75
- setValue: (s: unknown, v: unknown) => {
76
- (s as GridBoard).config[name] = typeof v === 'string' ? v : '';
77
- },
78
- });
79
- const color = (name: string, label: string) => ({
80
- name,
81
- type: 'color' as const,
82
- ui: { label, group: 'Look' },
83
- getValue: (s: unknown) => (s as GridBoard).config[name],
84
- setValue: (s: unknown, v: unknown) => {
85
- (s as GridBoard).config[name] = typeof v === 'string' ? v : '#ffffff';
86
- },
87
- });
88
-
89
- return {
90
- nodeType: 'GridBoard',
91
- properties: [
92
- str('boardAnchor', 'Board Anchor', 'Board'),
93
- num('sizeX', 'Size X', 1, 10, 1),
94
- num('sizeY', 'Size Y', 1, 10, 1),
95
- num('sizeZ', 'Size Z', 1, 10, 1),
96
- num('cellSize', 'Cell Size', 0.1, 3, 0.05),
97
- num('spacing', 'Spacing', 0.1, 4, 0.05),
98
- num('coreCount', 'Core Cubes', 0, 40, 1),
99
- color('cellColor', 'Cell Colour'),
100
- color('coreColor', 'Core Colour'),
101
- {
102
- name: 'revealCores',
103
- type: 'boolean' as const,
104
- ui: { label: 'Reveal Cores', group: 'Look' },
105
- getValue: (s: unknown) => (s as GridBoard).config.revealCores,
106
- setValue: (s: unknown, v: unknown) => {
107
- (s as GridBoard).config.revealCores = Boolean(v);
108
- },
109
- },
110
- str('clearSound', 'Clear Sound', 'Feedback', 'audio-resource'),
111
- str('coreSound', 'Core Sound', 'Feedback', 'audio-resource'),
112
- ],
113
- groups: {
114
- Board: { label: 'Board', expanded: true },
115
- Look: { label: 'Look', expanded: true },
116
- Feedback: { label: 'Feedback', expanded: false },
117
- },
118
- };
119
- }
120
-
121
- onStart(): void {
122
- this.build();
123
- }
124
-
125
- /**
126
- * One removal per finger that landed this frame.
127
- *
128
- * Taps over the HUD are ignored the same way every recipe does it, so a thumb parked on a button
129
- * cannot carve the board underneath it.
130
- */
131
- onUpdate(): void {
132
- const input = this.input;
133
- if (this.cleared || !input || !this.scene) {
134
- return;
135
- }
136
- for (const event of input.pointerEvents) {
137
- if (event.type !== 'down') {
138
- continue;
139
- }
140
- const overUI =
141
- input.pointerDownCount > 0 ? input.isPointerOverUI(event.pointerId) : input.isHoveringUI;
142
- if (overUI) {
143
- continue;
144
- }
145
- this.resolveTap(event.x, event.y);
146
- }
147
- }
148
-
149
- /** Live board state, for the rules script and for tests. */
150
- getRemaining(): number {
151
- return this.remaining;
152
- }
153
-
154
- private build(): void {
155
- const anchor = this.findAnchor();
156
- if (!anchor) {
157
- console.warn('[GridBoard] No board anchor node — nothing was built.');
158
- return;
159
- }
160
- const sizeX = this.intConfig('sizeX', 1, 10);
161
- const sizeY = this.intConfig('sizeY', 1, 10);
162
- const sizeZ = this.intConfig('sizeZ', 1, 10);
163
- const cellSize = Math.max(0.1, Number(this.config.cellSize) || 0.9);
164
- const spacing = Math.max(cellSize, Number(this.config.spacing) || 1);
165
- const total = sizeX * sizeY * sizeZ;
166
- // A board that is mostly core is not a puzzle, it is a minefield.
167
- const coreCount = Math.min(this.intConfig('coreCount', 0, 40), Math.floor(total / 3));
168
- const coreKeys = this.pickCoreKeys(sizeX, sizeY, sizeZ, coreCount);
169
- const reveal = Boolean(this.config.revealCores);
170
- const cellColor = String(this.config.cellColor ?? '#3ee6c1');
171
- const coreColor = String(this.config.coreColor ?? '#ff6b6b');
172
-
173
- // Centre the board on the anchor so the camera framing survives a size change.
174
- const offset = (n: number) => ((n - 1) * spacing) / 2;
175
-
176
- for (let x = 0; x < sizeX; x++) {
177
- for (let y = 0; y < sizeY; y++) {
178
- for (let z = 0; z < sizeZ; z++) {
179
- const key = `${x},${y},${z}`;
180
- const isCore = coreKeys.has(key);
181
- const cube = new GeometryMesh({
182
- id: `cell-${key}`,
183
- name: `Cell ${key}`,
184
- geometry: 'box',
185
- size: [cellSize, cellSize, cellSize],
186
- material: {
187
- type: 'lambert',
188
- color: isCore && reveal ? coreColor : cellColor,
189
- },
190
- });
191
- cube.position.set(
192
- x * spacing - offset(sizeX),
193
- y * spacing - offset(sizeY),
194
- z * spacing - offset(sizeZ)
195
- );
196
- anchor.add(cube);
197
- this.cells.set(key, { node: cube, isCore });
198
- }
199
- }
200
- }
201
- this.remaining = total - coreCount;
202
- this.node?.emit('board-built', this.remaining, coreCount);
203
- }
204
-
205
- /**
206
- * Resolve one contact against the board.
207
- *
208
- * The hit object is a three.js `Mesh` inside the cube node, so walk up to the `GeometryMesh` the
209
- * cell map is keyed by — the raycaster reports what it struck, not what owns it.
210
- */
211
- private resolveTap(screenX: number, screenY: number): void {
212
- const scene = this.scene;
213
- const input = this.input;
214
- if (!scene || !input) {
215
- return;
216
- }
217
- const width = input.width;
218
- const height = input.height;
219
- if (!(width > 0) || !(height > 0)) {
220
- return;
221
- }
222
- const hit = scene.raycastViewport((screenX / width) * 2 - 1, 1 - (screenY / height) * 2);
223
- if (!hit) {
224
- return;
225
- }
226
- const entry = this.findCell(hit.node);
227
- if (!entry) {
228
- return;
229
- }
230
- this.removeCell(entry[0], entry[1]);
231
- }
232
-
233
- private removeCell(key: string, cell: Cell): void {
234
- this.cells.delete(key);
235
- const owner = this.node;
236
- if (cell.isCore) {
237
- this.scene?.juice.flash({ color: '#ff4d6d', intensity: 0.45, durationSec: 0.22 });
238
- this.playSound(String(this.config.coreSound ?? ''));
239
- cell.node.queueFree();
240
- owner?.emit('core-hit', this.remaining);
241
- return;
242
- }
243
- this.remaining = Math.max(0, this.remaining - 1);
244
- this.scene?.juice.punchScale(cell.node, { amount: 0.35, duration: 0.16 });
245
- this.playSound(String(this.config.clearSound ?? ''));
246
- cell.node.queueFree();
247
- owner?.emit('cell-cleared', this.remaining);
248
- if (this.remaining === 0 && !this.cleared) {
249
- this.cleared = true;
250
- owner?.emit('board-cleared');
251
- }
252
- }
253
-
254
- /** Walk from the raycast hit up to the cube node this board owns. */
255
- private findCell(hitNode: NodeBase): [string, Cell] | null {
256
- let current: NodeBase | null = hitNode;
257
- while (current) {
258
- for (const [key, cell] of this.cells) {
259
- if (cell.node === current) {
260
- return [key, cell];
261
- }
262
- }
263
- current = (current.parent as NodeBase | null) ?? null;
264
- }
265
- return null;
266
- }
267
-
268
- private findAnchor(): NodeBase | null {
269
- const name = String(this.config.boardAnchor ?? '').trim();
270
- if (!name) {
271
- return this.node ?? null;
272
- }
273
- // `findNode` is the Script helper (scene-global by name/id), the same one Spawner uses.
274
- return this.findNode(name) ?? this.node ?? null;
275
- }
276
-
277
- /**
278
- * Choose which cubes are core.
279
- *
280
- * Cores are drawn from the INTERIOR when the board has one: a core on the surface can be tapped
281
- * before the player has learned anything, which reads as an unfair loss rather than a puzzle.
282
- * Small boards have no interior, so they fall back to the whole set.
283
- */
284
- private pickCoreKeys(sizeX: number, sizeY: number, sizeZ: number, count: number): Set<string> {
285
- const interior: string[] = [];
286
- const all: string[] = [];
287
- for (let x = 0; x < sizeX; x++) {
288
- for (let y = 0; y < sizeY; y++) {
289
- for (let z = 0; z < sizeZ; z++) {
290
- const key = `${x},${y},${z}`;
291
- all.push(key);
292
- const inner =
293
- x > 0 && x < sizeX - 1 && y > 0 && y < sizeY - 1 && z > 0 && z < sizeZ - 1;
294
- if (inner) {
295
- interior.push(key);
296
- }
297
- }
298
- }
299
- }
300
- const pool = interior.length >= count ? interior : all;
301
- const chosen = new Set<string>();
302
- while (chosen.size < count && chosen.size < pool.length) {
303
- chosen.add(pool[Math.floor(Math.random() * pool.length)]);
304
- }
305
- return chosen;
306
- }
307
-
308
- private intConfig(name: string, min: number, max: number): number {
309
- const value = Math.round(Number(this.config[name]));
310
- return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min));
311
- }
312
-
313
- private playSound(path: string): void {
314
- if (!path) {
315
- return;
316
- }
317
- void this.scene?.audio.play(path, { bus: 'sfx', pitchVariation: 0.08 });
318
- }
319
- }
1
+ /**
2
+ * GridBoard — the block you carve.
3
+ *
4
+ * Builds a solid `sizeX × sizeY × sizeZ` grid of cubes under `boardAnchor` at start, then turns a
5
+ * tap into the removal of one cube. Some cubes are secretly CORE: hitting one costs a life instead
6
+ * of scoring. Clear every non-core cube to finish the board.
7
+ *
8
+ * It emits the semantic signals the rules script listens for, on `game-root`:
9
+ * `cell-cleared` (remaining)
10
+ * `core-hit` (remaining)
11
+ * `board-cleared` ()
12
+ *
13
+ * **Picking is a 3D raycast, not a 2D hit test.** 2D nodes are hit-tested against `Hitbox2D`
14
+ * groups; a mesh in a perspective frustum has no such thing, so the contact comes from
15
+ * `scene.raycastViewport(nx, ny)` with the pointer converted to normalized device coordinates. That
16
+ * conversion is the part everyone gets wrong — Y is flipped, and the divisor is the INPUT surface
17
+ * (`input.width/height`), not the window.
18
+ *
19
+ * **Mobile budget.** Every cube is a `GeometryMesh` with `material.type: 'lambert'`: this recipe
20
+ * ships hundreds of them, and PBR on a phone at this count is the difference between a smooth board
21
+ * and a slideshow. A 4×4×4 board is 64 draw calls — if you grow it past ~8³, move to an
22
+ * `InstancedMesh3D` before you move to prettier materials.
23
+ */
24
+ import { GeometryMesh, Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
25
+
26
+ /** Cube metadata, kept in a map rather than on the node so nothing depends on node naming. */
27
+ interface Cell {
28
+ node: GeometryMesh;
29
+ isCore: boolean;
30
+ }
31
+
32
+ export class GridBoard extends Script {
33
+ private cells = new Map<string, Cell>();
34
+ private remaining = 0;
35
+ private cleared = false;
36
+
37
+ constructor(id: string, type: string) {
38
+ super(id, type);
39
+ this.config = {
40
+ // Node the cubes are parented to (kept separate from game-root so clearing is one subtree).
41
+ boardAnchor: 'board-anchor',
42
+ sizeX: 4,
43
+ sizeY: 4,
44
+ sizeZ: 4,
45
+ cellSize: 0.9,
46
+ // Centre-to-centre spacing; > cellSize leaves visible seams between cubes.
47
+ spacing: 1,
48
+ // How many cubes are core (a life each). Never more than a third of the board.
49
+ coreCount: 6,
50
+ cellColor: '#3ee6c1',
51
+ coreColor: '#ff6b6b',
52
+ // Reveal core cubes in their own colour instead of hiding them (debug / tutorial).
53
+ revealCores: false,
54
+ clearSound: '',
55
+ coreSound: '',
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: 'Board', min, max, step, slider: true },
64
+ getValue: (s: unknown) => (s as GridBoard).config[name],
65
+ setValue: (s: unknown, v: unknown) => {
66
+ const n = Number(v);
67
+ (s as GridBoard).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 ? { editor } : {}) },
74
+ getValue: (s: unknown) => (s as GridBoard).config[name],
75
+ setValue: (s: unknown, v: unknown) => {
76
+ (s as GridBoard).config[name] = typeof v === 'string' ? v : '';
77
+ },
78
+ });
79
+ const color = (name: string, label: string) => ({
80
+ name,
81
+ type: 'color' as const,
82
+ ui: { label, group: 'Look' },
83
+ getValue: (s: unknown) => (s as GridBoard).config[name],
84
+ setValue: (s: unknown, v: unknown) => {
85
+ (s as GridBoard).config[name] = typeof v === 'string' ? v : '#ffffff';
86
+ },
87
+ });
88
+
89
+ return {
90
+ nodeType: 'GridBoard',
91
+ properties: [
92
+ str('boardAnchor', 'Board Anchor', 'Board'),
93
+ num('sizeX', 'Size X', 1, 10, 1),
94
+ num('sizeY', 'Size Y', 1, 10, 1),
95
+ num('sizeZ', 'Size Z', 1, 10, 1),
96
+ num('cellSize', 'Cell Size', 0.1, 3, 0.05),
97
+ num('spacing', 'Spacing', 0.1, 4, 0.05),
98
+ num('coreCount', 'Core Cubes', 0, 40, 1),
99
+ color('cellColor', 'Cell Colour'),
100
+ color('coreColor', 'Core Colour'),
101
+ {
102
+ name: 'revealCores',
103
+ type: 'boolean' as const,
104
+ ui: { label: 'Reveal Cores', group: 'Look' },
105
+ getValue: (s: unknown) => (s as GridBoard).config.revealCores,
106
+ setValue: (s: unknown, v: unknown) => {
107
+ (s as GridBoard).config.revealCores = Boolean(v);
108
+ },
109
+ },
110
+ str('clearSound', 'Clear Sound', 'Feedback', 'audio-resource'),
111
+ str('coreSound', 'Core Sound', 'Feedback', 'audio-resource'),
112
+ ],
113
+ groups: {
114
+ Board: { label: 'Board', expanded: true },
115
+ Look: { label: 'Look', expanded: true },
116
+ Feedback: { label: 'Feedback', expanded: false },
117
+ },
118
+ };
119
+ }
120
+
121
+ onStart(): void {
122
+ this.build();
123
+ }
124
+
125
+ /**
126
+ * One removal per finger that landed this frame.
127
+ *
128
+ * Taps over the HUD are ignored the same way every recipe does it, so a thumb parked on a button
129
+ * cannot carve the board underneath it.
130
+ */
131
+ onUpdate(): void {
132
+ const input = this.input;
133
+ if (this.cleared || !input || !this.scene) {
134
+ return;
135
+ }
136
+ for (const event of input.pointerEvents) {
137
+ if (event.type !== 'down') {
138
+ continue;
139
+ }
140
+ const overUI =
141
+ input.pointerDownCount > 0 ? input.isPointerOverUI(event.pointerId) : input.isHoveringUI;
142
+ if (overUI) {
143
+ continue;
144
+ }
145
+ this.resolveTap(event.x, event.y);
146
+ }
147
+ }
148
+
149
+ /** Live board state, for the rules script and for tests. */
150
+ getRemaining(): number {
151
+ return this.remaining;
152
+ }
153
+
154
+ private build(): void {
155
+ const anchor = this.findAnchor();
156
+ if (!anchor) {
157
+ console.warn('[GridBoard] No board anchor node — nothing was built.');
158
+ return;
159
+ }
160
+ const sizeX = this.intConfig('sizeX', 1, 10);
161
+ const sizeY = this.intConfig('sizeY', 1, 10);
162
+ const sizeZ = this.intConfig('sizeZ', 1, 10);
163
+ const cellSize = Math.max(0.1, Number(this.config.cellSize) || 0.9);
164
+ const spacing = Math.max(cellSize, Number(this.config.spacing) || 1);
165
+ const total = sizeX * sizeY * sizeZ;
166
+ // A board that is mostly core is not a puzzle, it is a minefield.
167
+ const coreCount = Math.min(this.intConfig('coreCount', 0, 40), Math.floor(total / 3));
168
+ const coreKeys = this.pickCoreKeys(sizeX, sizeY, sizeZ, coreCount);
169
+ const reveal = Boolean(this.config.revealCores);
170
+ const cellColor = String(this.config.cellColor ?? '#3ee6c1');
171
+ const coreColor = String(this.config.coreColor ?? '#ff6b6b');
172
+
173
+ // Centre the board on the anchor so the camera framing survives a size change.
174
+ const offset = (n: number) => ((n - 1) * spacing) / 2;
175
+
176
+ for (let x = 0; x < sizeX; x++) {
177
+ for (let y = 0; y < sizeY; y++) {
178
+ for (let z = 0; z < sizeZ; z++) {
179
+ const key = `${x},${y},${z}`;
180
+ const isCore = coreKeys.has(key);
181
+ const cube = new GeometryMesh({
182
+ id: `cell-${key}`,
183
+ name: `Cell ${key}`,
184
+ geometry: 'box',
185
+ size: [cellSize, cellSize, cellSize],
186
+ material: {
187
+ type: 'lambert',
188
+ color: isCore && reveal ? coreColor : cellColor,
189
+ },
190
+ });
191
+ cube.position.set(
192
+ x * spacing - offset(sizeX),
193
+ y * spacing - offset(sizeY),
194
+ z * spacing - offset(sizeZ)
195
+ );
196
+ anchor.add(cube);
197
+ this.cells.set(key, { node: cube, isCore });
198
+ }
199
+ }
200
+ }
201
+ this.remaining = total - coreCount;
202
+ this.node?.emit('board-built', this.remaining, coreCount);
203
+ }
204
+
205
+ /**
206
+ * Resolve one contact against the board.
207
+ *
208
+ * The hit object is a three.js `Mesh` inside the cube node, so walk up to the `GeometryMesh` the
209
+ * cell map is keyed by — the raycaster reports what it struck, not what owns it.
210
+ */
211
+ private resolveTap(screenX: number, screenY: number): void {
212
+ const scene = this.scene;
213
+ const input = this.input;
214
+ if (!scene || !input) {
215
+ return;
216
+ }
217
+ const width = input.width;
218
+ const height = input.height;
219
+ if (!(width > 0) || !(height > 0)) {
220
+ return;
221
+ }
222
+ const hit = scene.raycastViewport((screenX / width) * 2 - 1, 1 - (screenY / height) * 2);
223
+ if (!hit) {
224
+ return;
225
+ }
226
+ const entry = this.findCell(hit.node);
227
+ if (!entry) {
228
+ return;
229
+ }
230
+ this.removeCell(entry[0], entry[1]);
231
+ }
232
+
233
+ private removeCell(key: string, cell: Cell): void {
234
+ this.cells.delete(key);
235
+ const owner = this.node;
236
+ if (cell.isCore) {
237
+ this.scene?.juice.flash({ color: '#ff4d6d', intensity: 0.45, durationSec: 0.22 });
238
+ this.playSound(String(this.config.coreSound ?? ''));
239
+ cell.node.queueFree();
240
+ owner?.emit('core-hit', this.remaining);
241
+ return;
242
+ }
243
+ this.remaining = Math.max(0, this.remaining - 1);
244
+ this.scene?.juice.punchScale(cell.node, { amount: 0.35, duration: 0.16 });
245
+ this.playSound(String(this.config.clearSound ?? ''));
246
+ cell.node.queueFree();
247
+ owner?.emit('cell-cleared', this.remaining);
248
+ if (this.remaining === 0 && !this.cleared) {
249
+ this.cleared = true;
250
+ owner?.emit('board-cleared');
251
+ }
252
+ }
253
+
254
+ /** Walk from the raycast hit up to the cube node this board owns. */
255
+ private findCell(hitNode: NodeBase): [string, Cell] | null {
256
+ let current: NodeBase | null = hitNode;
257
+ while (current) {
258
+ for (const [key, cell] of this.cells) {
259
+ if (cell.node === current) {
260
+ return [key, cell];
261
+ }
262
+ }
263
+ current = (current.parent as NodeBase | null) ?? null;
264
+ }
265
+ return null;
266
+ }
267
+
268
+ private findAnchor(): NodeBase | null {
269
+ const name = String(this.config.boardAnchor ?? '').trim();
270
+ if (!name) {
271
+ return this.node ?? null;
272
+ }
273
+ // `findNode` is the Script helper (scene-global by name/id), the same one Spawner uses.
274
+ return this.findNode(name) ?? this.node ?? null;
275
+ }
276
+
277
+ /**
278
+ * Choose which cubes are core.
279
+ *
280
+ * Cores are drawn from the INTERIOR when the board has one: a core on the surface can be tapped
281
+ * before the player has learned anything, which reads as an unfair loss rather than a puzzle.
282
+ * Small boards have no interior, so they fall back to the whole set.
283
+ */
284
+ private pickCoreKeys(sizeX: number, sizeY: number, sizeZ: number, count: number): Set<string> {
285
+ const interior: string[] = [];
286
+ const all: string[] = [];
287
+ for (let x = 0; x < sizeX; x++) {
288
+ for (let y = 0; y < sizeY; y++) {
289
+ for (let z = 0; z < sizeZ; z++) {
290
+ const key = `${x},${y},${z}`;
291
+ all.push(key);
292
+ const inner =
293
+ x > 0 && x < sizeX - 1 && y > 0 && y < sizeY - 1 && z > 0 && z < sizeZ - 1;
294
+ if (inner) {
295
+ interior.push(key);
296
+ }
297
+ }
298
+ }
299
+ }
300
+ const pool = interior.length >= count ? interior : all;
301
+ const chosen = new Set<string>();
302
+ while (chosen.size < count && chosen.size < pool.length) {
303
+ chosen.add(pool[Math.floor(Math.random() * pool.length)]);
304
+ }
305
+ return chosen;
306
+ }
307
+
308
+ private intConfig(name: string, min: number, max: number): number {
309
+ const value = Math.round(Number(this.config[name]));
310
+ return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min));
311
+ }
312
+
313
+ private playSound(path: string): void {
314
+ if (!path) {
315
+ return;
316
+ }
317
+ void this.scene?.audio.play(path, { bus: 'sfx', pitchVariation: 0.08 });
318
+ }
319
+ }