@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.
- package/LICENSE +201 -201
- package/README.md +788 -754
- package/dist/index.js +208 -203
- package/dist/runtime-types.json +1 -1
- package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-7MZGPAUK.mjs} +1 -1
- package/dist/smoke/prebuilt/chunk-7WOFGLW3.mjs +239 -0
- package/dist/smoke/prebuilt/runtime.mjs +1 -1
- package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
- package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
- package/dist/validate/prebuilt/bundle-main.js +13 -13
- package/dist/validate/prebuilt/chunk-HKG6YI6Y.js +239 -0
- package/dist/validate/prebuilt/runtime.js +1 -1
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
- package/kit/files/.claude/skills/pix3-nodes/reference.md +32 -24
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -28
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
- package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
- package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
- package/kit/files/AGENTS.md +203 -203
- package/kit/files/CLAUDE.md +1 -1
- package/kit/kit.json +2 -2
- package/package.json +54 -54
- package/templates/empty-2d/files/README.md +20 -20
- package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
- package/templates/empty-2d/template.yaml +7 -7
- package/templates/empty-3d/files/README.md +20 -20
- package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
- package/templates/empty-3d/template.yaml +7 -7
- package/templates/idea-blank/files/README.md +22 -22
- package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
- package/templates/idea-blank/template.yaml +10 -10
- package/templates/minigame-2d/files/README.md +44 -44
- package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
- package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
- package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
- package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
- package/templates/minigame-2d/template.yaml +10 -10
- package/templates/playable-2d/files/README.md +30 -30
- package/templates/playable-2d/files/design/recipe.md +83 -83
- package/templates/playable-2d/files/design/tests/reachability.json +4 -4
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
- package/templates/playable-2d/template.yaml +9 -9
- package/templates/playable-3d/files/README.md +32 -32
- package/templates/playable-3d/files/design/recipe.md +127 -127
- package/templates/playable-3d/files/design/tests/reachability.json +4 -4
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
- package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
- package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
- package/templates/playable-3d/template.yaml +9 -9
- package/templates/recipe-arena-2d/files/README.md +48 -48
- package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
- package/templates/recipe-arena-2d/template.yaml +10 -10
- package/templates/recipe-blank-2d/files/README.md +61 -61
- package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
- package/templates/recipe-blank-2d/template.yaml +10 -10
- package/templates/recipe-bouncer-2d/files/README.md +81 -81
- package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
- package/templates/recipe-bouncer-2d/template.yaml +10 -10
- package/templates/recipe-grid-3d/files/README.md +22 -22
- package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-grid-3d/template.yaml +10 -10
- package/templates/recipe-tapper-2d/files/README.md +39 -39
- package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
- package/templates/recipe-tapper-2d/template.yaml +10 -10
- package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
- 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
|
+
}
|