@pix3/cli 1.6.0 → 1.6.3
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-CBFYKDS7.mjs} +1 -1
- package/dist/smoke/prebuilt/chunk-VJHJ5BCO.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-CFQFEI52.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 +39 -24
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +43 -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,281 +1,285 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: pix3-scripts
|
|
3
|
-
description: How to write a Pix3 game script (scripts/*.ts, `export class X extends Script`, attached as `user:X`) — lifecycle, the required getPropertySchema, config, node lookup, signals, transforms, spawning, input, juice/tweens/audio, 2D physics, scene changes, the built-in core:* components and their config keys (reference.md), and the traps that compile clean and break at runtime. Use BEFORE writing or editing any file in scripts/ or attaching a core: component.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<!-- Pix3 agent kit 1.6.
|
|
7
|
-
|
|
8
|
-
# Writing a script
|
|
9
|
-
|
|
10
|
-
Everything here is exported from `@pix3/runtime`; use it directly instead of searching for
|
|
11
|
-
it. Import only from `@pix3/runtime`, `three`, and other files under the scripts root
|
|
12
|
-
(`scripts/` or `src/scripts/`, relative paths — they are bundled together). `pix3 check` type-checks every script
|
|
13
|
-
against the runtime's declarations (`.pix3/types/`, or your `node_modules` when the project
|
|
14
|
-
has its own `tsconfig.json`); your editor sees the same types through the root `tsconfig.json`.
|
|
15
|
-
|
|
16
|
-
`reference.md` beside this file has the engine's own API map, the capability catalog and
|
|
17
|
-
**every `core:` component with its config keys and defaults** — reach for a built-in before
|
|
18
|
-
writing a script for the same effect.
|
|
19
|
-
|
|
20
|
-
## Shape (copy this)
|
|
21
|
-
|
|
22
|
-
```ts
|
|
23
|
-
import { Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
|
|
24
|
-
|
|
25
|
-
export class Combo extends Script {
|
|
26
|
-
private count = 0;
|
|
27
|
-
private sinceLastHit = Infinity;
|
|
28
|
-
private source: NodeBase | null = null;
|
|
29
|
-
private readonly onScoredSignal = (...args: unknown[]): void => this.onScored(Number(args[0]) || 0);
|
|
30
|
-
|
|
31
|
-
constructor(id: string, type: string) {
|
|
32
|
-
super(id, type);
|
|
33
|
-
// Defaults. A scene's `config:` block is merged over these, key by key.
|
|
34
|
-
this.config = {
|
|
35
|
-
sourceNode: 'game-root',
|
|
36
|
-
windowSec: 1.2,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// REQUIRED. A class without a static getPropertySchema() is not registered as a
|
|
41
|
-
// component, and `user:Combo` in the scene stays pending forever.
|
|
42
|
-
static getPropertySchema(): PropertySchema {
|
|
43
|
-
return {
|
|
44
|
-
nodeType: 'Combo',
|
|
45
|
-
properties: [
|
|
46
|
-
{
|
|
47
|
-
name: 'sourceNode',
|
|
48
|
-
type: 'string',
|
|
49
|
-
ui: { label: 'Source Node', group: 'Combo' },
|
|
50
|
-
getValue: (s: unknown) => (s as Combo).config.sourceNode,
|
|
51
|
-
setValue: (s: unknown, v: unknown) => {
|
|
52
|
-
(s as Combo).config.sourceNode = typeof v === 'string' ? v : '';
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
name: 'windowSec',
|
|
57
|
-
type: 'number',
|
|
58
|
-
ui: { label: 'Window (s)', group: 'Combo', min: 0.1, max: 10, step: 0.1 },
|
|
59
|
-
getValue: (s: unknown) => (s as Combo).config.windowSec,
|
|
60
|
-
setValue: (s: unknown, v: unknown) => {
|
|
61
|
-
const n = Number(v);
|
|
62
|
-
(s as Combo).config.windowSec = Math.min(10, Math.max(0.1, Number.isFinite(n) ? n : 1.2));
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
],
|
|
66
|
-
groups: { Combo: { label: 'Combo', expanded: true } },
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
onStart(): void {
|
|
71
|
-
this.source = this.findNode(String(this.config.sourceNode ?? ''));
|
|
72
|
-
if (!this.source) {
|
|
73
|
-
console.warn(`[Combo] Source node "${this.config.sourceNode}" not found.`);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
this.source.connect('touch-scored', this, this.onScoredSignal);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
onUpdate(dt: number): void {
|
|
80
|
-
if (!this.scene) return; // editor previews may run without a scene
|
|
81
|
-
this.sinceLastHit += dt;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
onDetach(): void {
|
|
85
|
-
// The 'touch-scored' handler on the source node is auto-disconnected (target === this).
|
|
86
|
-
this.source = null;
|
|
87
|
-
super.onDetach();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private onScored(_amount: number): void {
|
|
91
|
-
const windowSec = Number(this.config.windowSec) || 1.2;
|
|
92
|
-
this.count = this.sinceLastHit <= windowSec ? this.count + 1 : 1;
|
|
93
|
-
this.sinceLastHit = 0;
|
|
94
|
-
this.node?.emit('combo-changed', this.count); // a new signal for a new mechanic
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- Attach in a scene as `type: user:<ExportedClassName>` (here `user:Combo`). The id is the
|
|
100
|
-
exported class name, not the file name — keep them equal.
|
|
101
|
-
- Every `config` key you want editable in the inspector gets one schema entry: `name`
|
|
102
|
-
(= the config key), `type` (`string` `number` `boolean` `color` `enum` `select` `vector2`
|
|
103
|
-
`vector3` `object` `node`), `ui` (`label`, `group`, `min`, `max`, `step`, `slider`,
|
|
104
|
-
`options`, `description`), `getValue`, `setValue`. `setValue` is what the loader calls
|
|
105
|
-
with the scene's value — clamp and coerce there.
|
|
106
|
-
- One mechanic = one script, 70–140 lines. Tunable numbers go into `config`, not into
|
|
107
|
-
constants: the human tunes them in the inspector.
|
|
108
|
-
- Name methods after intent (`startGame()`, `restart()`, `addCombo()`), not after widgets.
|
|
109
|
-
|
|
110
|
-
## Lifecycle
|
|
111
|
-
|
|
112
|
-
`onAttach(node)` → `onStart()` (first frame, scene loaded) → `onUpdate(dt)` every frame
|
|
113
|
-
(`dt` = scaled seconds; frozen during hitstop) → `onDetach()`. Parent components start
|
|
114
|
-
before children's.
|
|
115
|
-
|
|
116
|
-
## Members of a Script
|
|
117
|
-
|
|
118
|
-
- `this.node` — the owner node. `this.config` — the merged config.
|
|
119
|
-
- `this.scene` — `SceneService`; may be `undefined` in editor previews, guard it.
|
|
120
|
-
- `this.input` — `InputService`.
|
|
121
|
-
- `this.findNode(query)` — id, name or slash path → node or `null`.
|
|
122
|
-
`this.getNode(query)` — same, throws when missing.
|
|
123
|
-
- Another script on a node: `import { Other } from './Other'; node.getComponent(Other)` —
|
|
124
|
-
pass the **class**, never the `'user:Other'` string.
|
|
125
|
-
|
|
126
|
-
## Nodes
|
|
127
|
-
|
|
128
|
-
- Transforms are three.js objects, **mutate, never assign**: `node.position.set(x, y, 0)`,
|
|
129
|
-
`node.position.x += dx`, `node.rotation.z = radians`, `node.scale.set(s, s, 1)`.
|
|
130
|
-
`node.position = …` throws at runtime.
|
|
131
|
-
- 2D space: design pixels, origin centre, X right, **Y up**. YAML rotation is degrees;
|
|
132
|
-
`rotation.z` is radians.
|
|
133
|
-
- `visible`, `name`, `id`, `children`, `parentNode`, `findById(id)`, `findByName(name)`,
|
|
134
|
-
`adoptChild(child)`, `queueFree()` (safe inside `onUpdate`), `getComponent(Class)`,
|
|
135
|
-
`addComponent(c)`, `removeComponent(c)`.
|
|
136
|
-
- 2D node props as fields: `width`, `height`, `opacity`, `zIndex`, `blendMode`.
|
|
137
|
-
`
|
|
138
|
-
`
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
`
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
(`
|
|
173
|
-
-
|
|
174
|
-
(
|
|
175
|
-
-
|
|
176
|
-
`
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
- `
|
|
185
|
-
|
|
186
|
-
- `
|
|
187
|
-
- `
|
|
188
|
-
|
|
189
|
-
`
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
one
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
-
|
|
269
|
-
`
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- `
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1
|
+
---
|
|
2
|
+
name: pix3-scripts
|
|
3
|
+
description: How to write a Pix3 game script (scripts/*.ts, `export class X extends Script`, attached as `user:X`) — lifecycle, the required getPropertySchema, config, node lookup, signals, transforms, spawning, input, juice/tweens/audio, 2D physics, scene changes, the built-in core:* components and their config keys (reference.md), and the traps that compile clean and break at runtime. Use BEFORE writing or editing any file in scripts/ or attaching a core: component.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!-- Pix3 agent kit 1.6.3 -->
|
|
7
|
+
|
|
8
|
+
# Writing a script
|
|
9
|
+
|
|
10
|
+
Everything here is exported from `@pix3/runtime`; use it directly instead of searching for
|
|
11
|
+
it. Import only from `@pix3/runtime`, `three`, and other files under the scripts root
|
|
12
|
+
(`scripts/` or `src/scripts/`, relative paths — they are bundled together). `pix3 check` type-checks every script
|
|
13
|
+
against the runtime's declarations (`.pix3/types/`, or your `node_modules` when the project
|
|
14
|
+
has its own `tsconfig.json`); your editor sees the same types through the root `tsconfig.json`.
|
|
15
|
+
|
|
16
|
+
`reference.md` beside this file has the engine's own API map, the capability catalog and
|
|
17
|
+
**every `core:` component with its config keys and defaults** — reach for a built-in before
|
|
18
|
+
writing a script for the same effect.
|
|
19
|
+
|
|
20
|
+
## Shape (copy this)
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
|
|
24
|
+
|
|
25
|
+
export class Combo extends Script {
|
|
26
|
+
private count = 0;
|
|
27
|
+
private sinceLastHit = Infinity;
|
|
28
|
+
private source: NodeBase | null = null;
|
|
29
|
+
private readonly onScoredSignal = (...args: unknown[]): void => this.onScored(Number(args[0]) || 0);
|
|
30
|
+
|
|
31
|
+
constructor(id: string, type: string) {
|
|
32
|
+
super(id, type);
|
|
33
|
+
// Defaults. A scene's `config:` block is merged over these, key by key.
|
|
34
|
+
this.config = {
|
|
35
|
+
sourceNode: 'game-root',
|
|
36
|
+
windowSec: 1.2,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// REQUIRED. A class without a static getPropertySchema() is not registered as a
|
|
41
|
+
// component, and `user:Combo` in the scene stays pending forever.
|
|
42
|
+
static getPropertySchema(): PropertySchema {
|
|
43
|
+
return {
|
|
44
|
+
nodeType: 'Combo',
|
|
45
|
+
properties: [
|
|
46
|
+
{
|
|
47
|
+
name: 'sourceNode',
|
|
48
|
+
type: 'string',
|
|
49
|
+
ui: { label: 'Source Node', group: 'Combo' },
|
|
50
|
+
getValue: (s: unknown) => (s as Combo).config.sourceNode,
|
|
51
|
+
setValue: (s: unknown, v: unknown) => {
|
|
52
|
+
(s as Combo).config.sourceNode = typeof v === 'string' ? v : '';
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'windowSec',
|
|
57
|
+
type: 'number',
|
|
58
|
+
ui: { label: 'Window (s)', group: 'Combo', min: 0.1, max: 10, step: 0.1 },
|
|
59
|
+
getValue: (s: unknown) => (s as Combo).config.windowSec,
|
|
60
|
+
setValue: (s: unknown, v: unknown) => {
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
(s as Combo).config.windowSec = Math.min(10, Math.max(0.1, Number.isFinite(n) ? n : 1.2));
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
groups: { Combo: { label: 'Combo', expanded: true } },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onStart(): void {
|
|
71
|
+
this.source = this.findNode(String(this.config.sourceNode ?? ''));
|
|
72
|
+
if (!this.source) {
|
|
73
|
+
console.warn(`[Combo] Source node "${this.config.sourceNode}" not found.`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.source.connect('touch-scored', this, this.onScoredSignal);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onUpdate(dt: number): void {
|
|
80
|
+
if (!this.scene) return; // editor previews may run without a scene
|
|
81
|
+
this.sinceLastHit += dt;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onDetach(): void {
|
|
85
|
+
// The 'touch-scored' handler on the source node is auto-disconnected (target === this).
|
|
86
|
+
this.source = null;
|
|
87
|
+
super.onDetach();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private onScored(_amount: number): void {
|
|
91
|
+
const windowSec = Number(this.config.windowSec) || 1.2;
|
|
92
|
+
this.count = this.sinceLastHit <= windowSec ? this.count + 1 : 1;
|
|
93
|
+
this.sinceLastHit = 0;
|
|
94
|
+
this.node?.emit('combo-changed', this.count); // a new signal for a new mechanic
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- Attach in a scene as `type: user:<ExportedClassName>` (here `user:Combo`). The id is the
|
|
100
|
+
exported class name, not the file name — keep them equal.
|
|
101
|
+
- Every `config` key you want editable in the inspector gets one schema entry: `name`
|
|
102
|
+
(= the config key), `type` (`string` `number` `boolean` `color` `enum` `select` `vector2`
|
|
103
|
+
`vector3` `object` `node`), `ui` (`label`, `group`, `min`, `max`, `step`, `slider`,
|
|
104
|
+
`options`, `description`), `getValue`, `setValue`. `setValue` is what the loader calls
|
|
105
|
+
with the scene's value — clamp and coerce there.
|
|
106
|
+
- One mechanic = one script, 70–140 lines. Tunable numbers go into `config`, not into
|
|
107
|
+
constants: the human tunes them in the inspector.
|
|
108
|
+
- Name methods after intent (`startGame()`, `restart()`, `addCombo()`), not after widgets.
|
|
109
|
+
|
|
110
|
+
## Lifecycle
|
|
111
|
+
|
|
112
|
+
`onAttach(node)` → `onStart()` (first frame, scene loaded) → `onUpdate(dt)` every frame
|
|
113
|
+
(`dt` = scaled seconds; frozen during hitstop) → `onDetach()`. Parent components start
|
|
114
|
+
before children's.
|
|
115
|
+
|
|
116
|
+
## Members of a Script
|
|
117
|
+
|
|
118
|
+
- `this.node` — the owner node. `this.config` — the merged config.
|
|
119
|
+
- `this.scene` — `SceneService`; may be `undefined` in editor previews, guard it.
|
|
120
|
+
- `this.input` — `InputService`.
|
|
121
|
+
- `this.findNode(query)` — id, name or slash path → node or `null`.
|
|
122
|
+
`this.getNode(query)` — same, throws when missing.
|
|
123
|
+
- Another script on a node: `import { Other } from './Other'; node.getComponent(Other)` —
|
|
124
|
+
pass the **class**, never the `'user:Other'` string.
|
|
125
|
+
|
|
126
|
+
## Nodes
|
|
127
|
+
|
|
128
|
+
- Transforms are three.js objects, **mutate, never assign**: `node.position.set(x, y, 0)`,
|
|
129
|
+
`node.position.x += dx`, `node.rotation.z = radians`, `node.scale.set(s, s, 1)`.
|
|
130
|
+
`node.position = …` throws at runtime.
|
|
131
|
+
- 2D space: design pixels, origin centre, X right, **Y up**. YAML rotation is degrees;
|
|
132
|
+
`rotation.z` is radians.
|
|
133
|
+
- `visible`, `name`, `id`, `children`, `parentNode`, `findById(id)`, `findByName(name)`,
|
|
134
|
+
`adoptChild(child)`, `queueFree()` (safe inside `onUpdate`), `getComponent(Class)`,
|
|
135
|
+
`addComponent(c)`, `removeComponent(c)`.
|
|
136
|
+
- 2D node props as fields: `width`, `height`, `opacity`, `zIndex`, `blendMode`. Assigning
|
|
137
|
+
`width`/`height` resizes the drawn mesh on `Sprite2D`, `ColorRect2D`, `Button2D`, `Label2D`,
|
|
138
|
+
`Group2D` … (the same setter the inspector uses). One trap that looks like "it did not resize":
|
|
139
|
+
a `Sprite2D` created without `width`/`height` takes the texture's natural size when the texture
|
|
140
|
+
loads — set both, or set them after the load.
|
|
141
|
+
`Label2D.setText(text)`; `Bar2D.maxValue`, `Bar2D.setValue(v)`; `Slider2D.value`,
|
|
142
|
+
`Checkbox2D.checked`. Check types with `instanceof Label2D` (import the class).
|
|
143
|
+
- **Assigning a display property redraws immediately** — no `setText` / `updateLabel` call
|
|
144
|
+
needed: `label.label = 'x2'`, `label.labelColor = '#ff3355'`, `label.labelFontSize`,
|
|
145
|
+
`label.glowStrength = 2` (clamped 0..4), `glowColor`, `outlineWidth`, `width`, `height` all
|
|
146
|
+
repaint on assignment, exactly as an inspector edit does. `setText(text)` is the same as
|
|
147
|
+
`label = text` plus clearing a bound `labelKey` (localization) and restarting the typewriter.
|
|
148
|
+
- Create at runtime with the YAML property names, then parent it:
|
|
149
|
+
`const r = new ColorRect2D({ id: 'flash', width: 100, height: 100, color: '#ffffff' }); parent.adoptChild(r);`
|
|
150
|
+
(also `Sprite2D`, `Label2D({ id, label, labelFontSize, labelColor })`, `Group2D`).
|
|
151
|
+
|
|
152
|
+
## Signals
|
|
153
|
+
|
|
154
|
+
- `node.emit('name', ...args)`, `node.connect('name', this, handler)`,
|
|
155
|
+
`node.disconnect('name', this, handler)`. Every handler connected with `this` as the target
|
|
156
|
+
is **auto-disconnected on detach, like own-node signals** — including ones on another node
|
|
157
|
+
(`gameRoot.connect('score', this, this.onScore)`). Window listeners, store subscriptions and
|
|
158
|
+
timers are still yours to clean up in `onDetach` (see "Hosting an existing game"). Keep the
|
|
159
|
+
handler in a field (no `.bind(this)`) so an early manual `disconnect` gets the same function.
|
|
160
|
+
- UI controls (`Button2D`, `Slider2D`, `Checkbox2D`, `Joystick2D`, …) emit `pressed` (touch
|
|
161
|
+
went down inside), `released`, `click` (down and up inside — a completed tap),
|
|
162
|
+
`pointerdown`, `pointerup`; `Checkbox2D` also `toggled`; `Label2D` emits
|
|
163
|
+
`typewriter-complete`. The recipes wire buttons to `pressed` (instant); `click` can be
|
|
164
|
+
cancelled by sliding off — use it for menus and anything costly.
|
|
165
|
+
- Recipe scripts talk through signals on `game-root` (`touch-scored`, `touch-damaged`,
|
|
166
|
+
`score-changed`, `lives-changed`, `time-changed`, `game-won`, `game-lost`). Listen to
|
|
167
|
+
them; add new signal names for new mechanics; never rename existing ones.
|
|
168
|
+
|
|
169
|
+
## `this.scene`
|
|
170
|
+
|
|
171
|
+
- Lookup: `findNode(q)`, `findNodeById`, `findNodeByName`, `findNodeByPath`,
|
|
172
|
+
`getRootNodes()`, `getViewportInfo()`, `isPortrait()`.
|
|
173
|
+
- Pointer in 2D world space: `scene.getPointer2DWorldPosition()` → `Vector2 | null`
|
|
174
|
+
(primary finger), or `(pointerId)`.
|
|
175
|
+
- Spawn: `const n = await scene.instantiate('res://scenes/prefabs/x.pix3scene', { parent, instanceId })`
|
|
176
|
+
(`parent` = node | query; prefab = a scene file with one root). Despawn: `n.queueFree()`.
|
|
177
|
+
- Scene change: `await scene.changeScene('res://scenes/menu.pix3scene', { transition: 'fade' | 'none', durationSec: 0.3, onLoaded })`
|
|
178
|
+
(`durationSec` is each of fade-out and fade-in).
|
|
179
|
+
- Time: `scene.time.hitstop(ms)` — no options; only on a contact START, never per frame.
|
|
180
|
+
`scene.time.slowMotion(scale, { durationMs, blendMs })` — both real-time ms; no `durationMs`
|
|
181
|
+
= until `scene.time.reset()`.
|
|
182
|
+
- Juice — call it together with the mechanic. Every option is optional; defaults shown. Note
|
|
183
|
+
the three transform effects take `duration` (seconds), **not** `durationSec`:
|
|
184
|
+
- `scene.juice.shake(target, { amplitude: 8, frequency: 24, duration: 0.35, decay: 1.5 })` —
|
|
185
|
+
`amplitude` in the node's units (px in 2D), `duration: 0` = until stopped.
|
|
186
|
+
- `punchScale(target, { amount: 0.3, duration: 0.35, vibrato: 3 })` — `amount` 0.3 = +30%.
|
|
187
|
+
- `popIn(target, { from: 0, duration: 0.4, easing: 'backOut' })` — `from` = start scale factor.
|
|
188
|
+
- `flash({ color: '#ffffff', intensity: 1, durationSec: 0.2 })` — full-screen, no target.
|
|
189
|
+
- `burst(anchor, { count: 14, speed: 260, spread: 2π, direction: π/2, lifeSec: 0.5, color | colors, sizePx: 10, gravityY: -600, fadeOut: true, additive: true, zIndex })` (angles in radians).
|
|
190
|
+
- `floatText('+25', { at, color: '#ffffff', fontSizePx: 28, fontFamily: 'Arial', driftPx: 60, durationSec: 0.8, glow, glowStrength: 1.5, zIndex })`.
|
|
191
|
+
- `trail(node, { lifeSec: 0.35, widthPx: 14, color | colors, additive: true, zIndex, maxPoints: 48 })` → `trail.stop()`.
|
|
192
|
+
|
|
193
|
+
`target` = node | query | `'camera'` | `'camera2d'`; `anchor` = node | query | `{ x, y }`.
|
|
194
|
+
- Tweens: `scene.tween.to(node, { x, y, position: { x, y }, scale, rotation, opacity, width, height }, { durationSec: 0.3, ease: 'cubicOut', delaySec: 0, yoyo: false, repeat: 0, onUpdate, onComplete })`
|
|
195
|
+
→ `{ cancel(), finished, isRunning }` (`rotation` in radians, `repeat: -1` = forever, `ease` =
|
|
196
|
+
any easing name: `linear`, `quadOut`, `cubicInOut`, `backOut`, `elasticOut`, …);
|
|
197
|
+
`fadeIn(node, sec)` and `crossFade(a, b, sec)` — no options; `fadeOut(node, sec, { hide: true })`;
|
|
198
|
+
`killAll(target?)`. Default `sec` = 0.3. Prefer these over hand-lerping.
|
|
199
|
+
- Audio with no asset: `scene.audio.sfx('tap' | 'score' | 'bounce' | 'explosion' | 'powerup' | 'win' | 'lose' | 'laser' | 'tick', { volume: 1, pitch: 1 })`;
|
|
200
|
+
a file: `await scene.audio.play('res://audio/hit.ogg', { bus: 'sfx' | 'music' | 'master', volume, loop, playbackRate, pan, pitchVariation: 0, volumeVariation: 0 })`.
|
|
201
|
+
Sound without the editor: `pix3 sfx coin` writes `audio/coin.wav` (presets `coin`, `jump`,
|
|
202
|
+
`hit`, `explosion`, `powerup`, `click`, or words: `pix3 sfx "short high coin pickup" --out audio/pickup.wav`;
|
|
203
|
+
`--seed <n>` for a variation) — offline, no key; `.wav` plays everywhere `.ogg`/`.mp3` do.
|
|
204
|
+
Sound cannot be proven audible from the live channel: the browser only starts Web Audio after
|
|
205
|
+
a real user gesture, and `game_input`'s synthetic taps/keys are not one. Report a sound as
|
|
206
|
+
"code path verified, audibility not" and ask the human to listen.
|
|
207
|
+
- Overlap queries without physics response: `scene.collision2d.overlapPoint(x, y, group?)`,
|
|
208
|
+
`overlapCircle(x, y, r, group?)`, `overlapRect(cx, cy, w, h, group?)`, `raycast(…)` over
|
|
209
|
+
nodes carrying `core:Hitbox2D` (the tapper/arena recipes use this).
|
|
210
|
+
- 2D physics with response: `core:PhysicsBody2D` + `core:Collider2D` on the same node;
|
|
211
|
+
`scene.physics2d.getBody(node)` → `setVelocity`, `applyImpulse`, `teleport`. Never
|
|
212
|
+
hand-write a 2D solver, never import rapier for 2D. (The bouncer recipe runs its own
|
|
213
|
+
swept solver in `BallBody.ts` — do not add engine physics to its ball.)
|
|
214
|
+
- Intents: `scene.commands.register('name', handler, { description })`, `dispatch(name)`.
|
|
215
|
+
The recipes register theirs and publish state with `registerGameDebug` in `GameRules` —
|
|
216
|
+
when you add a mechanic, add its field to that snapshot instead of registering a second one.
|
|
217
|
+
|
|
218
|
+
## `this.input`
|
|
219
|
+
|
|
220
|
+
- Per frame: `input.pointerEvents` → `{ type: 'down' | 'move' | 'up' | 'cancel', pointerId, x, y }[]`
|
|
221
|
+
(`cancel` is not a tap); `input.keyEvents`.
|
|
222
|
+
- Polled: `input.isPointerDown`, `input.pointerPosition` (canvas space — use
|
|
223
|
+
`scene.getPointer2DWorldPosition()` for world), `input.isPointerOverUI(pointerId)`,
|
|
224
|
+
`input.getButton(name)`, `input.getAxis(name)`.
|
|
225
|
+
- Keys held: `input.getButton('Key_ArrowLeft')`, `getButton('Key_D')` — `Key_` + the
|
|
226
|
+
`KeyboardEvent.code` (this is what the recipes' controllers use). In `input.keyEvents`
|
|
227
|
+
compare `event.code` (`'KeyW'`, `'ArrowUp'`, `'Space'`) — case-sensitive.
|
|
228
|
+
|
|
229
|
+
## Hosting an existing game (not a recipe)
|
|
230
|
+
|
|
231
|
+
- **Use the game's own channel.** Before wiring a script, find how the game talks:
|
|
232
|
+
`grep -rn "CustomEvent\|dispatchEvent\|addEventListener\|subscribe(\|EventTarget\|emit(" src scripts`.
|
|
233
|
+
Window `CustomEvent`s, an `EventTarget` bus or a store with `subscribe()` are common; node
|
|
234
|
+
signals are only one option. Import the game's bus/store module with a relative path from
|
|
235
|
+
your script.
|
|
236
|
+
- **`onDetach` cleans up node signals only** (any node, as long as the target is `this`).
|
|
237
|
+
Everything else you register must be undone by hand, or each play/stop in the editor leaks
|
|
238
|
+
one more listener:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
private readonly onScore = (e: Event): void => { /* (e as CustomEvent).detail */ };
|
|
242
|
+
private unsubscribe: (() => void) | null = null;
|
|
243
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
244
|
+
|
|
245
|
+
onStart(): void {
|
|
246
|
+
window.addEventListener('score-changed', this.onScore);
|
|
247
|
+
this.unsubscribe = store.subscribe(() => { /* … */ }); // the game's own store; keep what it returns
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
onDetach(): void {
|
|
251
|
+
window.removeEventListener('score-changed', this.onScore);
|
|
252
|
+
this.unsubscribe?.();
|
|
253
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
254
|
+
super.onDetach();
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
- **Prefer the game's own feedback helpers.** When the game's camera controller writes the
|
|
258
|
+
camera's position every frame, `scene.juice.shake('camera')` / `core:Shake` on that 3D camera
|
|
259
|
+
(or on any node the game positions itself) is overwritten every frame and never shows. Use the
|
|
260
|
+
game's own shake (`grep -rn "shake" src scripts`) when it has one; the same goes for its own
|
|
261
|
+
sound and tween helpers.
|
|
262
|
+
- The project's own `AGENTS.md` wins on process (planning first, where config goes); this kit
|
|
263
|
+
wins on Pix3 facts (YAML, runtime API, `pix3 check`).
|
|
264
|
+
|
|
265
|
+
## Traps (compile clean, break at runtime)
|
|
266
|
+
|
|
267
|
+
- Assigning `position` / `rotation` / `scale` throws. Mutate them.
|
|
268
|
+
- A component that throws in `onStart` / `onUpdate` is **auto-disabled** and the game keeps
|
|
269
|
+
running — the thing just freezes. Guard lookups (`if (!node) { console.warn(...); return; }`).
|
|
270
|
+
With the live channel, `read_errors` after a run shows it; without, tell the human to look
|
|
271
|
+
at the editor's console.
|
|
272
|
+
- `as any` on `this.node` hides exactly the error above, and from `pix3 check`. Use
|
|
273
|
+
`instanceof` narrowing.
|
|
274
|
+
- Hitstop every frame of an overlap freezes the game — edge-trigger it.
|
|
275
|
+
- A hidden `UIControl2D` takes no input; `onUpdate` still runs on hidden nodes.
|
|
276
|
+
- `getComponent('user:X')` is wrong — pass the class.
|
|
277
|
+
- Ending a run belongs to the recipe's `GameRules`: call its `finish(won)`; do not show the
|
|
278
|
+
result overlay yourself (RETRY would stay disabled).
|
|
279
|
+
|
|
280
|
+
## Known gaps
|
|
281
|
+
|
|
282
|
+
- `lit`'s `property` / `state` decorators are re-exported by `@pix3/runtime` but their types
|
|
283
|
+
are not bundled into `.pix3/types/` (they type-check as `any`); scripts do not need them.
|
|
284
|
+
- Scripts reach engine internals only through `@pix3/runtime`'s public exports; anything the
|
|
285
|
+
declarations do not show is not part of the contract.
|