@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.
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-CBFYKDS7.mjs} +1 -1
  6. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.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-CFQFEI52.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 +39 -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 +43 -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,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.0 -->
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
- `Label2D.setText(text)`; `Bar2D.maxValue`, `Bar2D.setValue(v)`; `Slider2D.value`,
138
- `Checkbox2D.checked`. Check types with `instanceof Label2D` (import the class).
139
- - **Assigning a display property redraws immediately** — no `setText` / `updateLabel` call
140
- needed: `label.label = 'x2'`, `label.labelColor = '#ff3355'`, `label.labelFontSize`,
141
- `label.glowStrength = 2` (clamped 0..4), `glowColor`, `outlineWidth`, `width`, `height` all
142
- repaint on assignment, exactly as an inspector edit does. `setText(text)` is the same as
143
- `label = text` plus clearing a bound `labelKey` (localization) and restarting the typewriter.
144
- - Create at runtime with the YAML property names, then parent it:
145
- `const r = new ColorRect2D({ id: 'flash', width: 100, height: 100, color: '#ffffff' }); parent.adoptChild(r);`
146
- (also `Sprite2D`, `Label2D({ id, label, labelFontSize, labelColor })`, `Group2D`).
147
-
148
- ## Signals
149
-
150
- - `node.emit('name', ...args)`, `node.connect('name', this, handler)`,
151
- `node.disconnect('name', this, handler)`. Every handler connected with `this` as the target
152
- is **auto-disconnected on detach, like own-node signals** — including ones on another node
153
- (`gameRoot.connect('score', this, this.onScore)`). Window listeners, store subscriptions and
154
- timers are still yours to clean up in `onDetach` (see "Hosting an existing game"). Keep the
155
- handler in a field (no `.bind(this)`) so an early manual `disconnect` gets the same function.
156
- - UI controls (`Button2D`, `Slider2D`, `Checkbox2D`, `Joystick2D`, …) emit `pressed` (touch
157
- went down inside), `released`, `click` (down and up inside — a completed tap),
158
- `pointerdown`, `pointerup`; `Checkbox2D` also `toggled`; `Label2D` emits
159
- `typewriter-complete`. The recipes wire buttons to `pressed` (instant); `click` can be
160
- cancelled by sliding off — use it for menus and anything costly.
161
- - Recipe scripts talk through signals on `game-root` (`touch-scored`, `touch-damaged`,
162
- `score-changed`, `lives-changed`, `time-changed`, `game-won`, `game-lost`). Listen to
163
- them; add new signal names for new mechanics; never rename existing ones.
164
-
165
- ## `this.scene`
166
-
167
- - Lookup: `findNode(q)`, `findNodeById`, `findNodeByName`, `findNodeByPath`,
168
- `getRootNodes()`, `getViewportInfo()`, `isPortrait()`.
169
- - Pointer in 2D world space: `scene.getPointer2DWorldPosition()` → `Vector2 | null`
170
- (primary finger), or `(pointerId)`.
171
- - Spawn: `const n = await scene.instantiate('res://scenes/prefabs/x.pix3scene', { parent, instanceId })`
172
- (`parent` = node | query; prefab = a scene file with one root). Despawn: `n.queueFree()`.
173
- - Scene change: `await scene.changeScene('res://scenes/menu.pix3scene', { transition: 'fade' | 'none', durationSec: 0.3, onLoaded })`
174
- (`durationSec` is each of fade-out and fade-in).
175
- - Time: `scene.time.hitstop(ms)` — no options; only on a contact START, never per frame.
176
- `scene.time.slowMotion(scale, { durationMs, blendMs })` — both real-time ms; no `durationMs`
177
- = until `scene.time.reset()`.
178
- - Juice — call it together with the mechanic. Every option is optional; defaults shown. Note
179
- the three transform effects take `duration` (seconds), **not** `durationSec`:
180
- - `scene.juice.shake(target, { amplitude: 8, frequency: 24, duration: 0.35, decay: 1.5 })` —
181
- `amplitude` in the node's units (px in 2D), `duration: 0` = until stopped.
182
- - `punchScale(target, { amount: 0.3, duration: 0.35, vibrato: 3 })` — `amount` 0.3 = +30%.
183
- - `popIn(target, { from: 0, duration: 0.4, easing: 'backOut' })` — `from` = start scale factor.
184
- - `flash({ color: '#ffffff', intensity: 1, durationSec: 0.2 })` — full-screen, no target.
185
- - `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).
186
- - `floatText('+25', { at, color: '#ffffff', fontSizePx: 28, fontFamily: 'Arial', driftPx: 60, durationSec: 0.8, glow, glowStrength: 1.5, zIndex })`.
187
- - `trail(node, { lifeSec: 0.35, widthPx: 14, color | colors, additive: true, zIndex, maxPoints: 48 })` → `trail.stop()`.
188
-
189
- `target` = node | query | `'camera'` | `'camera2d'`; `anchor` = node | query | `{ x, y }`.
190
- - 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 })`
191
- → `{ cancel(), finished, isRunning }` (`rotation` in radians, `repeat: -1` = forever, `ease` =
192
- any easing name: `linear`, `quadOut`, `cubicInOut`, `backOut`, `elasticOut`, …);
193
- `fadeIn(node, sec)` and `crossFade(a, b, sec)` — no options; `fadeOut(node, sec, { hide: true })`;
194
- `killAll(target?)`. Default `sec` = 0.3. Prefer these over hand-lerping.
195
- - Audio with no asset: `scene.audio.sfx('tap' | 'score' | 'bounce' | 'explosion' | 'powerup' | 'win' | 'lose' | 'laser' | 'tick', { volume: 1, pitch: 1 })`;
196
- a file: `await scene.audio.play('res://audio/hit.ogg', { bus: 'sfx' | 'music' | 'master', volume, loop, playbackRate, pan, pitchVariation: 0, volumeVariation: 0 })`.
197
- Sound without the editor: `pix3 sfx coin` writes `audio/coin.wav` (presets `coin`, `jump`,
198
- `hit`, `explosion`, `powerup`, `click`, or words: `pix3 sfx "short high coin pickup" --out audio/pickup.wav`;
199
- `--seed <n>` for a variation) — offline, no key; `.wav` plays everywhere `.ogg`/`.mp3` do.
200
- Sound cannot be proven audible from the live channel: the browser only starts Web Audio after
201
- a real user gesture, and `game_input`'s synthetic taps/keys are not one. Report a sound as
202
- "code path verified, audibility not" and ask the human to listen.
203
- - Overlap queries without physics response: `scene.collision2d.overlapPoint(x, y, group?)`,
204
- `overlapCircle(x, y, r, group?)`, `overlapRect(cx, cy, w, h, group?)`, `raycast(…)` over
205
- nodes carrying `core:Hitbox2D` (the tapper/arena recipes use this).
206
- - 2D physics with response: `core:PhysicsBody2D` + `core:Collider2D` on the same node;
207
- `scene.physics2d.getBody(node)` → `setVelocity`, `applyImpulse`, `teleport`. Never
208
- hand-write a 2D solver, never import rapier for 2D. (The bouncer recipe runs its own
209
- swept solver in `BallBody.ts` — do not add engine physics to its ball.)
210
- - Intents: `scene.commands.register('name', handler, { description })`, `dispatch(name)`.
211
- The recipes register theirs and publish state with `registerGameDebug` in `GameRules` —
212
- when you add a mechanic, add its field to that snapshot instead of registering a second one.
213
-
214
- ## `this.input`
215
-
216
- - Per frame: `input.pointerEvents` → `{ type: 'down' | 'move' | 'up' | 'cancel', pointerId, x, y }[]`
217
- (`cancel` is not a tap); `input.keyEvents`.
218
- - Polled: `input.isPointerDown`, `input.pointerPosition` (canvas space — use
219
- `scene.getPointer2DWorldPosition()` for world), `input.isPointerOverUI(pointerId)`,
220
- `input.getButton(name)`, `input.getAxis(name)`.
221
- - Keys held: `input.getButton('Key_ArrowLeft')`, `getButton('Key_D')` — `Key_` + the
222
- `KeyboardEvent.code` (this is what the recipes' controllers use). In `input.keyEvents`
223
- compare `event.code` (`'KeyW'`, `'ArrowUp'`, `'Space'`) — case-sensitive.
224
-
225
- ## Hosting an existing game (not a recipe)
226
-
227
- - **Use the game's own channel.** Before wiring a script, find how the game talks:
228
- `grep -rn "CustomEvent\|dispatchEvent\|addEventListener\|subscribe(\|EventTarget\|emit(" src scripts`.
229
- Window `CustomEvent`s, an `EventTarget` bus or a store with `subscribe()` are common; node
230
- signals are only one option. Import the game's bus/store module with a relative path from
231
- your script.
232
- - **`onDetach` cleans up node signals only** (any node, as long as the target is `this`).
233
- Everything else you register must be undone by hand, or each play/stop in the editor leaks
234
- one more listener:
235
-
236
- ```ts
237
- private readonly onScore = (e: Event): void => { /* (e as CustomEvent).detail */ };
238
- private unsubscribe: (() => void) | null = null;
239
- private timer: ReturnType<typeof setInterval> | null = null;
240
-
241
- onStart(): void {
242
- window.addEventListener('score-changed', this.onScore);
243
- this.unsubscribe = store.subscribe(() => { /* … */ }); // the game's own store; keep what it returns
244
- }
245
-
246
- onDetach(): void {
247
- window.removeEventListener('score-changed', this.onScore);
248
- this.unsubscribe?.();
249
- if (this.timer !== null) clearInterval(this.timer);
250
- super.onDetach();
251
- }
252
- ```
253
- - **Prefer the game's own feedback helpers.** When the game's camera controller writes the
254
- camera's position every frame, `scene.juice.shake('camera')` / `core:Shake` on that 3D camera
255
- (or on any node the game positions itself) is overwritten every frame and never shows. Use the
256
- game's own shake (`grep -rn "shake" src scripts`) when it has one; the same goes for its own
257
- sound and tween helpers.
258
- - The project's own `AGENTS.md` wins on process (planning first, where config goes); this kit
259
- wins on Pix3 facts (YAML, runtime API, `pix3 check`).
260
-
261
- ## Traps (compile clean, break at runtime)
262
-
263
- - Assigning `position` / `rotation` / `scale` throws. Mutate them.
264
- - A component that throws in `onStart` / `onUpdate` is **auto-disabled** and the game keeps
265
- running — the thing just freezes. Guard lookups (`if (!node) { console.warn(...); return; }`).
266
- With the live channel, `read_errors` after a run shows it; without, tell the human to look
267
- at the editor's console.
268
- - `as any` on `this.node` hides exactly the error above, and from `pix3 check`. Use
269
- `instanceof` narrowing.
270
- - Hitstop every frame of an overlap freezes the game — edge-trigger it.
271
- - A hidden `UIControl2D` takes no input; `onUpdate` still runs on hidden nodes.
272
- - `getComponent('user:X')` is wrong — pass the class.
273
- - Ending a run belongs to the recipe's `GameRules`: call its `finish(won)`; do not show the
274
- result overlay yourself (RETRY would stay disabled).
275
-
276
- ## Known gaps
277
-
278
- - `lit`'s `property` / `state` decorators are re-exported by `@pix3/runtime` but their types
279
- are not bundled into `.pix3/types/` (they type-check as `any`); scripts do not need them.
280
- - Scripts reach engine internals only through `@pix3/runtime`'s public exports; anything the
281
- declarations do not show is not part of the contract.
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.