@pix3/cli 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +754 -0
  3. package/dist/index.js +494 -0
  4. package/dist/runtime-types.json +1 -0
  5. package/dist/smoke/prebuilt/build-7MIXIMSZ.mjs +948 -0
  6. package/dist/smoke/prebuilt/chunk-J2SJVMDO.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +239 -0
  8. package/dist/smoke/prebuilt/chunk-P2CBRXPJ.mjs +4050 -0
  9. package/dist/smoke/prebuilt/runtime.mjs +2 -0
  10. package/dist/smoke/prebuilt/smoke-worker.mjs +3 -0
  11. package/dist/smoke/prebuilt/three.mjs +2 -0
  12. package/dist/smoke/prebuilt/tree-defaults.mjs +2 -0
  13. package/dist/validate/prebuilt/build-AUGKXER3.js +948 -0
  14. package/dist/validate/prebuilt/bundle-main.js +24 -0
  15. package/dist/validate/prebuilt/chunk-6ACT6FNV.js +4050 -0
  16. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +239 -0
  17. package/dist/validate/prebuilt/runtime.js +2 -0
  18. package/dist/validate/prebuilt/three.js +2 -0
  19. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -0
  20. package/kit/files/.claude/skills/pix3-nodes/reference.md +1331 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +235 -0
  22. package/kit/files/.claude/skills/pix3-scene-format/reference.md +176 -0
  23. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +281 -0
  24. package/kit/files/.claude/skills/pix3-scripts/reference.md +638 -0
  25. package/kit/files/.claude/skills/pix3-verify/SKILL.md +386 -0
  26. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -0
  27. package/kit/files/AGENTS.md +203 -0
  28. package/kit/files/CLAUDE.md +1 -0
  29. package/kit/kit.json +24 -0
  30. package/package.json +54 -0
  31. package/templates/empty-2d/files/README.md +20 -0
  32. package/templates/empty-2d/files/scenes/main.pix3scene +68 -0
  33. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  34. package/templates/empty-2d/template.yaml +7 -0
  35. package/templates/empty-3d/files/README.md +20 -0
  36. package/templates/empty-3d/files/scenes/main.pix3scene +108 -0
  37. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  38. package/templates/empty-3d/template.yaml +7 -0
  39. package/templates/idea-blank/files/README.md +22 -0
  40. package/templates/idea-blank/files/scenes/main.pix3scene +37 -0
  41. package/templates/idea-blank/template.yaml +10 -0
  42. package/templates/minigame-2d/files/README.md +44 -0
  43. package/templates/minigame-2d/files/design/tests/reachability.json +1 -0
  44. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -0
  45. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -0
  46. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -0
  47. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -0
  48. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -0
  49. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -0
  50. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -0
  51. package/templates/minigame-2d/template.yaml +10 -0
  52. package/templates/playable-2d/files/README.md +30 -0
  53. package/templates/playable-2d/files/design/recipe.md +83 -0
  54. package/templates/playable-2d/files/design/tests/reachability.json +4 -0
  55. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -0
  56. package/templates/playable-2d/files/scenes/main.pix3scene +111 -0
  57. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -0
  58. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -0
  59. package/templates/playable-2d/files/scripts/CtaButton.ts +55 -0
  60. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -0
  61. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  62. package/templates/playable-2d/template.yaml +9 -0
  63. package/templates/playable-3d/files/README.md +32 -0
  64. package/templates/playable-3d/files/design/recipe.md +127 -0
  65. package/templates/playable-3d/files/design/tests/reachability.json +4 -0
  66. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -0
  67. package/templates/playable-3d/files/scenes/main.pix3scene +230 -0
  68. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -0
  69. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -0
  70. package/templates/playable-3d/files/scripts/CtaButton.ts +55 -0
  71. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -0
  72. package/templates/playable-3d/files/scripts/GameRules.ts +290 -0
  73. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -0
  74. package/templates/playable-3d/template.yaml +9 -0
  75. package/templates/recipe-arena-2d/files/README.md +48 -0
  76. package/templates/recipe-arena-2d/files/design/recipe.md +127 -0
  77. package/templates/recipe-arena-2d/files/design/tests/reachability.json +4 -0
  78. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +23 -0
  79. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -0
  80. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -0
  81. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  82. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -0
  83. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -0
  84. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -0
  85. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -0
  86. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -0
  87. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -0
  88. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -0
  89. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -0
  90. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  91. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  92. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  93. package/templates/recipe-arena-2d/template.yaml +10 -0
  94. package/templates/recipe-blank-2d/files/README.md +61 -0
  95. package/templates/recipe-blank-2d/files/design/recipe.md +148 -0
  96. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -0
  97. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -0
  98. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +23 -0
  99. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -0
  100. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -0
  101. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -0
  102. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -0
  103. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  104. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  105. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  106. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  107. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  108. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  109. package/templates/recipe-blank-2d/template.yaml +10 -0
  110. package/templates/recipe-bouncer-2d/files/README.md +81 -0
  111. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -0
  112. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +4 -0
  113. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +23 -0
  114. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -0
  115. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -0
  116. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -0
  117. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -0
  118. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -0
  119. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -0
  120. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -0
  121. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -0
  122. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -0
  123. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -0
  124. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  125. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  126. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  127. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  128. package/templates/recipe-bouncer-2d/template.yaml +10 -0
  129. package/templates/recipe-grid-3d/files/README.md +22 -0
  130. package/templates/recipe-grid-3d/files/design/recipe.md +110 -0
  131. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -0
  132. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -0
  133. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -0
  134. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -0
  135. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -0
  136. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -0
  137. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -0
  138. package/templates/recipe-grid-3d/template.yaml +10 -0
  139. package/templates/recipe-tapper-2d/files/README.md +39 -0
  140. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -0
  141. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -0
  142. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -0
  143. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -0
  144. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -0
  145. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  146. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -0
  147. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -0
  148. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -0
  149. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -0
  150. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -0
  151. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -0
  152. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -0
  153. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  154. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  155. package/templates/recipe-tapper-2d/template.yaml +10 -0
@@ -0,0 +1,1331 @@
1
+ <!-- Pix3 agent kit 1.6.0 — generated from docs/node-types-reference.md. Do not edit: pix3 kit --update regenerates it. -->
2
+ # Pix3 node reference
3
+
4
+ Every node type the loader knows, with its properties. **Grep `### <NodeName>`** for one node
5
+ instead of reading this file. Keys are YAML keys under `properties:`; `position` / `rotation` /
6
+ `scale` go in the `transform:` block, `flow.*` in the `flow:` block, and the anchor keys in
7
+ `layout:` (see `pix3-scene-format`). Where a row says "not saved", the key does nothing in a file.
8
+ Source paths quoted here (`src/…`, `packages/…`) are files of the Pix3 engine repository, not of this project.
9
+
10
+ ## Quick reference
11
+
12
+ | Node Type | Key Properties |
13
+ | -------------------- | ---------------------------------------------------------------------------------------------------- |
14
+ | NodeBase | id, name, type, visible, locked |
15
+ | Node2D | position (Vector2), rotation, scale (Vector2), opacity, blendMode, zIndex |
16
+ | Node3D | position (Vector3), rotation (Euler), scale (Vector3) |
17
+ | Sprite2D | texture, width, height, anchor, aspectRatioLocked |
18
+ | ColorRect2D | width, height, color (the only untextured 2D fill) |
19
+ | TiledSprite2D | texture, width, height, patchMode, sliceBorderLeft/Right/Top/Bottom, drawCenter (nine-slice) |
20
+ | AnimatedSprite2D | animationResourcePath, currentClip, isPlaying, freeOnFinish, width, height, sizeMode |
21
+ | Group2D | width, height (+ flow\* / anchor layout — the 2D container) |
22
+ | ScrollContainer2D | width, height, scrollY, dragScrollEnabled, inertiaDamping, showScrollbar |
23
+ | SpineSkeleton2D | skeletonPath, atlasPath, animation, loop, skin, timeScale, defaultMix (optional Spine runtime) |
24
+ | Camera2D | priority, zoom, offset, followTargetId, limitsEnabled, shakeAmplitude |
25
+ | CanvasLayer2D | width, height (fixed HUD overlay; renders after post) |
26
+ | Camera3D | projection, fov, near, far |
27
+ | VirtualCamera3D | priority, followTargetId, lookAtTargetId, blendDuration, fov |
28
+ | GeometryMesh | geometry, size, material, castShadow, receiveShadow |
29
+ | MeshInstance | src |
30
+ | Sprite3D | texture, width, height (world units), billboard, billboardRoll, opacity |
31
+ | AnimatedSprite3D | width, height, fps, playing, loop, freeOnFinish, billboard (default true) |
32
+ | InstancedMesh3D | maxInstances, enablePerInstanceColor, material.type/color (bulk write\*/flush from scripts) |
33
+ | DirectionalLightNode | color, intensity, castShadow |
34
+ | AmbientLightNode | color, intensity (flat fill, no shadows) |
35
+ | HemisphereLightNode | skyColor, groundColor, intensity |
36
+ | PointLightNode | color, intensity, distance, decay |
37
+ | SpotLightNode | color, intensity, distance, angle, penumbra |
38
+ | Button2D | width, height, backgroundColor, buttonAction, textureNormal/Hover/Pressed/Disabled, sliceBorder\* |
39
+ | Slider2D | width, minValue, maxValue, value, textureTrack/Fill/Thumb, sliceBorder\* |
40
+ | Joystick2D | enabled, radius, floating, axisHorizontal, axisVertical |
41
+ | Checkbox2D | size, checked, textureBox, textureBoxChecked, textureMark |
42
+ | Bar2D | width, value, minValue, maxValue, barColor, backBackgroundColor, textureTrough/Fill, sliceBorder\* |
43
+ | InventorySlot2D | width, height, quantity, showQuantity, backdropColor, selectionColor |
44
+ | AudioPlayer | audioTrack, autoplay, loop, volume, bus, pitchVariation, volumeVariation |
45
+ | PostProcess | affect2D, bloom*, vignette*, chromaticAberration*, aoMode, ssao*, lut\* |
46
+ | Group | NodeBase only — an organisational container |
47
+
48
+ ## Choosing the right node
49
+
50
+ #### For 2D Projects:
51
+
52
+ 1. **Start with a Group2D** as your scene root (the game viewport size comes from project settings; use anchor layout for responsiveness)
53
+ 2. Add **Sprite2D** for images and graphics
54
+ 3. Before the art exists, build with **ColorRect2D** — it is the only solid-colour 2D primitive, and
55
+ a game made of coloured rects is playable a day before a game waiting on sprites
56
+ 4. Use **TiledSprite2D** in `nine-slice` mode for panels, windows and bars — stretching a bordered
57
+ texture with Sprite2D smears its corners
58
+ 5. Use **Button2D**, **Slider2D**, **Joystick2D** for UI controls, **ScrollContainer2D** for lists
59
+ 6. Use **Node2D** as bare transforms and **Group2D** wherever children must lay out against a size
60
+
61
+ #### For 3D Projects:
62
+
63
+ 1. Add a **Camera3D** to define your viewpoint
64
+ 2. Use **GeometryMesh** for simple shapes
65
+ 3. Use **MeshInstance** for imported 3D models
66
+ 4. Add **DirectionalLightNode** for overall lighting
67
+ 5. Add **PointLightNode** or **SpotLightNode** for localized lighting
68
+ 6. Add **AmbientLightNode** or **HemisphereLightNode** as fill so unlit faces are not black
69
+ 7. For cinematics / dynamic framing, add **VirtualCamera3D** nodes and a **Camera Brain** (`core:CameraBrain`) on the Camera3D to blend between them by priority
70
+ 8. For bloom / vignette / colour grading, add one **PostProcess** node anywhere in the tree
71
+
72
+ ## 2D Nodes
73
+
74
+ All 2D nodes operate in screen space and are rendered using an orthographic camera. They use a left-handed coordinate system where X increases to the right and Y increases upward.
75
+
76
+ ### Node2D
77
+
78
+ The base class for all 2D scene nodes. Use this for simple grouping or as a container for other 2D elements.
79
+
80
+ **Type String:** `Node2D`
81
+
82
+ **Properties:**
83
+
84
+ | Property | Type | Default | Description |
85
+ | --------------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
86
+ | `position` | Vector2 | (0, 0) | X and Y coordinates |
87
+ | `rotation` | number | 0 | Rotation in degrees |
88
+ | `scale` | Vector2 | (1, 1) | X and Y scale factors |
89
+ | `opacity` | number | 1 | Local opacity multiplier, inherited by child 2D nodes |
90
+ | `blendMode` | enum | normal | How the node's own visuals combine with the backdrop: `normal`, `additive`, `multiply`, `subtract`. NOT inherited by children |
91
+ | `zIndex` | number | 0 | Draw-order override, `-4096..4096` (integer). Higher draws on top |
92
+ | `zAsRelative` | boolean | true | Add `zIndex` to the parent's effective z instead of treating it as absolute |
93
+ | `flow.enabled` | boolean | false | Stack this container's children in tree order instead of leaving them where they were authored |
94
+ | `flow.direction` | enum | vertical | `vertical` (a column) or `horizontal` (a row) |
95
+ | `flow.gap` | number | 0 | Space between two children, px |
96
+ | `flow.paddingX` / `flow.paddingY` | number | 0 | Inset from the container's edges |
97
+ | `flow.align` | enum | start | Cross-axis placement of each child: `start`, `center`, `end`. Inspector label: **Cross Axis** |
98
+ | `flow.autoSize` | boolean | false | Grow the container along the flow axis so the last child fits |
99
+
100
+ **Usage Notes:**
101
+
102
+ - **Flow vs. anchors.** An anchor (`layout`) pins ONE node to its parent's edges; a
103
+ flow is the container deciding where each child begins. With `flow.enabled` the
104
+ container owns the main axis and each child's own anchor still owns the cross
105
+ one — a settings row can pin its toggle to the right edge while the column
106
+ decides how far down the row sits. This is why no `Layout2D` node exists.
107
+ - **Vocabulary and how the inspector shows it.** Three different things used to be
108
+ called "align", so the words are now fixed: **Anchors** is the persistent rule
109
+ placing this node in its parent (`layout*` properties), **Flow** is the
110
+ persistent rule placing this node's children, **Cross Axis** is the flow's
111
+ perpendicular placement (`flow.align`), and **Align** / **Distribute** are the
112
+ one-shot operations on a selection (the viewport strip and `Node ▸ Align ▸`).
113
+ Because Anchors and Flow are orthogonal — a panel can stretch to its parent
114
+ *and* stack its children — the inspector shows them as two sub-blocks of one
115
+ **Layout** section, each with its own switch, rather than as one mode selector.
116
+ A child of a flow container sees only its *driven* Position axis disabled:
117
+ the main axis always, the cross axis only when its own Anchors are off.
118
+ - Cannot have children by default (set `isContainer = true` to enable)
119
+ - Transforms affect all children in local space
120
+ - Rotation is in degrees; positive is counter-clockwise (Y points up), and scripts see radians in `rotation.z`
121
+ - **Draw order:** by default the 2D pass paints in scene-tree DFS order (a later/deeper node draws
122
+ on top — Godot-like). `zIndex` lifts a node out of that order without moving it in the tree:
123
+ nodes are bucketed by _effective_ z first, and tree order only breaks ties inside a bucket. With
124
+ `zAsRelative` (the default) a subtree keeps its internal layering wherever it is reparented; set
125
+ it to `false` for an "always on top" overlay that must not inherit an ancestor's offset. Both
126
+ fields serialize only when non-default, so scenes that never touch z-order are unchanged.
127
+ - **Blend mode:** `blendMode` applies to the materials the node itself owns (a sprite's quad, a
128
+ control's skin + label) and is _not_ inherited the way `opacity` is — set it on each node that
129
+ should glow, not on a wrapping `Group2D`. `additive` is the glow/VFX mode; `multiply` and
130
+ `subtract` darken, and because a transparent PNG's cutout pixels are usually black they will
131
+ darken through the cutout too. A non-normal mode forces `material.transparent` on (three.js
132
+ disables blending for opaque materials) and opts the node's meshes out of the 2D quad batcher,
133
+ so use it for the handful of nodes that need it rather than across a whole scene. `screen` is
134
+ intentionally not offered: it needs `CustomBlending`, and no factor pair reproduces it without
135
+ either ignoring `opacity` or darkening the backdrop as the node fades. Spine skeletons are
136
+ unaffected — their blend modes come per-slot from the spine runtime.
137
+
138
+ ---
139
+
140
+ ### ColorRect2D
141
+
142
+ A solid-colour rectangle — the engine's **only** untextured 2D fill primitive, and the placeholder
143
+ both agent skills tell you to reach for when art does not exist yet. Blocking gameplay on art is the
144
+ mistake it exists to prevent: build the whole game out of coloured rects, then swap them for
145
+ `Sprite2D`/`TiledSprite2D` once the art lands.
146
+
147
+ **Type String:** `ColorRect2D`
148
+
149
+ **Properties** (plus everything on Node2D):
150
+
151
+ | Property | Type | Default | Description |
152
+ | -------- | ------ | ------- | ------------------------------- |
153
+ | `width` | number | 100 | Width in design pixels |
154
+ | `height` | number | 100 | Height in design pixels |
155
+ | `color` | color | #ffffff | Fill colour (authored sRGB hex) |
156
+
157
+ **Usage Notes:**
158
+
159
+ - Centred on its own origin, like every 2D node — `position` is the rect's centre, not a corner.
160
+ - `opacity` comes from `Node2D` and multiplies the fill; there is no separate alpha property.
161
+ - No texture, no nine-slice: for a panel with a border use `TiledSprite2D` in `nine-slice` mode.
162
+
163
+ ---
164
+
165
+ ### Sprite2D
166
+
167
+ A 2D image display node. Renders a textured quad that always faces the camera.
168
+
169
+ **Type String:** `Sprite2D`
170
+
171
+ **Properties:**
172
+
173
+ | Property | Type | Default | Description |
174
+ | ------------------- | ------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- |
175
+ | `texture` | texture | null | Texture resource (`{ type: texture, url: res://… }`); a legacy `texturePath` string is still accepted on load |
176
+ | `textureKey` | string | "" | Localization sprite key; when it resolves, the localized path wins over `texture` |
177
+ | `anchor` | vector2 | [0.5, 0.5] | Normalized pivot the image is positioned around |
178
+ | `width` | number | texture width | Display width in pixels; when unset it takes the texture's natural width on load (64 placeholder until then) |
179
+ | `height` | number | texture height | Display height in pixels; when unset it takes the texture's natural height on load (64 placeholder until then) |
180
+ | `aspectRatioLocked` | boolean | false | Keep width/height at the texture's aspect ratio while resizing |
181
+ | `color` | color | #ffffff | Constructor-only placeholder colour shown before the texture loads; reset to white on load, not in the schema or saved |
182
+
183
+ **Usage Notes:**
184
+
185
+ - Supports PNG, JPG, WebP textures
186
+ - Aspect ratio is controlled by width/height properties
187
+ - Texture is scaled to fit the specified dimensions
188
+
189
+ ---
190
+
191
+ ### TiledSprite2D
192
+
193
+ A texture mapped onto a rectangle of arbitrary size by one of five fill algorithms — the node for
194
+ **UI panels, windows, bars and repeating backgrounds**, where `Sprite2D`'s plain stretch would smear
195
+ the corners. This is the engine's nine-slice; UI Kit Forge emits its slice borders directly.
196
+
197
+ **Type String:** `TiledSprite2D`
198
+
199
+ **Properties** (plus everything on Node2D):
200
+
201
+ | Property | Type | Default | Description |
202
+ | ---------------------------------- | ------- | ---------- | ------------------------------------------------------------------------------- |
203
+ | `texture` | texture | null | `{ type: 'texture', url: 'res://…' }` (a bare `texturePath` string is read too) |
204
+ | `width` | number | 128 | Rect width in design pixels (0 is valid — a collapsed rect) |
205
+ | `height` | number | 128 | Rect height in design pixels |
206
+ | `patchMode` | enum | `stretch` | `stretch` \| `tile` \| `nine-slice` \| `three-slice-h` \| `three-slice-v` |
207
+ | `sliceBorderLeft/Right/Top/Bottom` | number | 0 | Border insets in **source-texture pixels** (Godot's `patch_margin_*`) |
208
+ | `drawCenter` | boolean | true | Draw the middle patch; false leaves a hollow frame |
209
+ | `axisStretchHorizontal` | enum | `stretch` | `stretch` \| `tile` — how the stretchable regions fill horizontally |
210
+ | `axisStretchVertical` | enum | `stretch` | Same, vertically |
211
+ | `tileScale` | vector2 | (1, 1) | Repeat scale in `tile` mode |
212
+ | `tileOffset` | vector2 | (0, 0) | Repeat phase shift |
213
+ | `anchor` | vector2 | (0.5, 0.5) | Normalized pivot: (0,0) bottom-left, (1,1) top-right |
214
+
215
+ **Usage Notes:**
216
+
217
+ - The four `sliceBorder*` scalars are separate schema properties, not a nested object — set them
218
+ individually from a script or a tool call.
219
+ - A 64×64 frame with a 16 px border covers a window of any size: corners stay crisp, edges scale.
220
+ - `three-slice-h` is the right mode for a horizontal bar (left cap · middle · right cap); only the
221
+ left/right borders matter.
222
+
223
+ ---
224
+
225
+ ### AnimatedSprite2D
226
+
227
+ Flipbook sprite animation driven by a `.pix3anim` resource — a set of named clips over a
228
+ spritesheet or a list of frame images. Authored in the editor's Sprite Editor. For **skeletal**
229
+ animation use SpineSkeleton2D instead; for a property/timeline animation over any
230
+ node, use the `core:AnimationPlayer` behaviour.
231
+
232
+ **Type String:** `AnimatedSprite2D`
233
+
234
+ **Properties** (plus everything on Node2D):
235
+
236
+ | Property | Type | Default | Description |
237
+ | ----------------------- | ------- | ---------- | -------------------------------------------------------------------------- |
238
+ | `animationResourcePath` | string | "" | `res://…/foo.pix3anim` |
239
+ | `currentClip` | string | "" | Clip name from that resource (empty = first clip) |
240
+ | `isPlaying` | boolean | true | Play on start |
241
+ | `currentFrame` | number | 0 | Frame index within the active clip |
242
+ | `freeOnFinish` | boolean | false | Remove the node when a non-looping clip ends (one-shot VFX) |
243
+ | `width` | number | 64 | Display width in pixels |
244
+ | `height` | number | 64 | Display height in pixels |
245
+ | `sizeMode` | enum | `stretch` | `stretch` (fit width/height) \| `native` (use each frame's own pixel size) |
246
+ | `anchor` | vector2 | (0.5, 0.5) | Normalized pivot |
247
+ | `color` | color | #ffffff | Tint |
248
+
249
+ **Usage Notes:**
250
+
251
+ - Frame timing lives in the clip (per-frame duration multipliers), not on the node — there is no
252
+ `fps` property here, unlike AnimatedSprite3D.
253
+ - `freeOnFinish` + a one-shot clip is the whole "spawn an explosion" pattern: no script needed.
254
+ - `sizeMode: native` is what you want for a trimmed spritesheet whose frames differ in size.
255
+
256
+ ---
257
+
258
+ ### Group2D
259
+
260
+ A sized 2D container. Unlike `Node2D` (a bare transform), it has a `width`/`height` rectangle, which
261
+ is what anchor layout and flow layout resolve their children against — so this is the node to use as
262
+ a **scene root** and as any panel that lays out its children.
263
+
264
+ **Type String:** `Group2D`
265
+
266
+ **Properties** (plus everything on Node2D, including the full `flow*` and anchor-layout set):
267
+
268
+ | Property | Type | Default | Description |
269
+ | -------- | ------ | ------- | --------------------------------- |
270
+ | `width` | number | 100 | Container width in design pixels |
271
+ | `height` | number | 100 | Container height in design pixels |
272
+
273
+ **Usage Notes:**
274
+
275
+ - It draws nothing. The editor outlines it as an adornment; a build shows only its children.
276
+ - With `flowEnabled` it becomes a row/column stack (`flowDirection`, `flowGap`, `flowPaddingX/Y`,
277
+ `flowAlign`, `flowAutoSize`) — the engine's layout container, no script required.
278
+ - Node order inside it is paint order, as everywhere in 2D.
279
+
280
+ ---
281
+
282
+ ### SpineSkeleton2D
283
+
284
+ A Spine skeletal-animation node. Renders a skeleton exported from the
285
+ [Spine editor](https://esotericsoftware.com/) in the 2D layer, with animations
286
+ selectable in the Inspector and drivable from scripts.
287
+
288
+ **Type String:** `SpineSkeleton2D`
289
+
290
+ **Requires the optional Spine runtime.** `@esotericsoftware/spine-threejs` (`~4.3`)
291
+ is an _optional_ peer dependency: pix3 declares the module contract and the host
292
+ registers a loader for it (`setSpineModuleLoader`), so projects that never use
293
+ Spine neither install nor download it. The editor and the exported player register
294
+ it automatically; a consumer project that places a `SpineSkeleton2D` adds:
295
+
296
+ ```ts
297
+ import { setSpineModuleLoader } from '@pix3/runtime';
298
+
299
+ setSpineModuleLoader(() => import('@esotericsoftware/spine-threejs'));
300
+ ```
301
+
302
+ Using the official Spine Runtimes requires a Spine Editor license (Spine Runtimes
303
+ License). The skeleton export must come from a Spine version matching the installed
304
+ runtime's minor (4.3 export ⇄ 4.3 runtime); a mismatch surfaces as a load error
305
+ naming both files.
306
+
307
+ **Properties:**
308
+
309
+ | Property | Type | Default | Description |
310
+ | ----------------- | --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
311
+ | `skeletonPath` | string | null | Skeleton export, `.json` or `.skel` (res://) |
312
+ | `atlasPath` | string | null | Atlas export, `.atlas` (res://) |
313
+ | `texture` | texture ref | null | Optional page-image override; single-page atlases only |
314
+ | `animation` | string / select | '' | Animation to play; a dropdown of real names once loaded |
315
+ | `loop` | boolean | true | Loop the authored animation |
316
+ | `isPlaying` | boolean | true | Advance time in play mode |
317
+ | `skin` | string / select | '' | Skin to apply; empty = the skeleton's default |
318
+ | `timeScale` | number | 1 | Playback speed multiplier |
319
+ | `defaultMix` | number | 0 | Crossfade duration between animations, seconds |
320
+ | `color` | color | #ffffff | Tint (applied through spine's skeleton color) |
321
+ | `twoColorTint` | boolean | false | Enable tint-black rendering (dark-tint exports) |
322
+ | `freeOnFinish` | boolean | false | `queueFree()` when a non-looping animation ends |
323
+ | `previewInEditor` | boolean | false | Animate in the editor viewport. **Off by default** — a placed skeleton holds its first frame; the Inspector's Play/Reset buttons drive it |
324
+
325
+ **Script API:**
326
+
327
+ ```ts
328
+ const hero = scene.getNode<SpineSkeleton2D>('Hero');
329
+
330
+ hero.play('run', { loop: true, mixDuration: 0.2 });
331
+ hero.queue('idle', { loop: true, delay: 0.5 }); // after the current entry
332
+ hero.stop({ mixDuration: 0.25 }); // mix back to the setup pose
333
+ hero.pause();
334
+ hero.resume();
335
+ hero.setSkin('blue');
336
+ hero.setMix('run', 'idle', 0.3); // per-pair crossfade
337
+ hero.setTimeScale(1.5);
338
+
339
+ hero.getAnimationNames(); // ['idle', 'run', …]
340
+ hero.getSkinNames();
341
+ hero.getCurrentAnimation(); // animation on track 0, or null
342
+ hero.getSetupBounds(); // setup-pose AABB, or null before load
343
+ hero.isLoaded;
344
+
345
+ hero.resetToFirstFrame(); // rewind the current animation (pose only)
346
+ ```
347
+
348
+ In the Inspector the **Animation** group shows the animation and skin as dropdowns
349
+ of the loaded skeleton's real names, plus an **Editor Preview** row: `Play`/`Pause`
350
+ toggles `previewInEditor` (an ordinary undoable edit) and `Reset` rewinds to the
351
+ first frame. Reset is transient pose-only state — it never enters undo history and
352
+ never dirties the scene, matching how the animation timeline's scrub preview
353
+ behaves.
354
+
355
+ **Signals:**
356
+
357
+ | Signal | Arguments | When |
358
+ | -------------------- | -------------------------------------------- | ---------------------------------------------------------- |
359
+ | `animation-started` | `(name, trackIndex)` | A track entry became current |
360
+ | `animation-finished` | `(name, trackIndex)` | A non-looping animation ended (also flips `isPlaying` off) |
361
+ | `animation-looped` | `(name, trackIndex)` | A looping animation completed a loop |
362
+ | `spine-event` | `(name, { int, float, string }, trackIndex)` | A keyed animation event fired |
363
+
364
+ **Export:** the HTML / zip playable export bundles the Spine runtime _statically_
365
+ into `index.html`, but only when a scene actually places a `SpineSkeleton2D` — a
366
+ dynamic import would become a chunk that a single-file export can never fetch.
367
+ Skeleton, atlas and the atlas' page images ship with it; projects without a
368
+ skeleton are unaffected in size. The generated npm project gets the dependency
369
+ added to its `package.json` on the same condition.
370
+
371
+ **Usage Notes:**
372
+
373
+ - Sizing comes from the node transform (`scale`), not a width/height pair — the
374
+ skeleton is authored in its own pixel units. The parse-time skeleton scale stays
375
+ at 1 so all instances share one cached `SkeletonData`.
376
+ - The skeleton data and atlas page textures are shared across every node (and the
377
+ editor viewport proxy) that references the same files; each node owns only its
378
+ own `Skeleton`/`AnimationState`.
379
+ - Atlas pages are loaded as standalone textures and are excluded from the
380
+ pre-launch texture atlas — their UVs come from the `.atlas` file. For the same
381
+ reason a Spine skeleton does not join the 2D quad batcher.
382
+ - Shader effects (`core:adjust`, …) are not supported: spine creates its batch
383
+ materials dynamically. Use `color` / `twoColorTint` for tinting.
384
+ - CPU cost is per skeleton per frame (geometry is rebuilt on the CPU). Budget for
385
+ units-to-dozens of visible skeletons, not hundreds.
386
+
387
+ ---
388
+
389
+ ### Button2D
390
+
391
+ An interactive button control for 2D user interfaces. Responds to pointer clicks and provides visual feedback.
392
+
393
+ **Type String:** `Button2D`
394
+
395
+ **Properties:**
396
+
397
+ | Property | Type | Default | Description |
398
+ | ------------------- | ------- | -------- | ---------------------------------------------------------------- |
399
+ | `width` | number | 100 | Button width in pixels |
400
+ | `height` | number | 40 | Button height in pixels |
401
+ | `backgroundColor` | color | #4a4a4a | Default background |
402
+ | `hoverColor` | color | #5a5a5a | Background on hover |
403
+ | `pressedColor` | color | #3a3a3a | Background when pressed |
404
+ | `buttonAction` | string | "Submit" | Action identifier |
405
+ | `textureNormal` | texture | null | Skin sprite for the idle state |
406
+ | `textureHover` | texture | null | Skin sprite on hover; falls back to `textureNormal` |
407
+ | `texturePressed` | texture | null | Skin sprite while pressed; falls back to `textureNormal` |
408
+ | `textureDisabled` | texture | null | Skin sprite while disabled; falls back to `textureNormal` |
409
+ | `sliceBorderLeft` | number | 0 | Left 9-slice inset of the state skins, in source px; 0 = stretch |
410
+ | `sliceBorderRight` | number | 0 | Right 9-slice inset, in source px |
411
+ | `sliceBorderTop` | number | 0 | Top 9-slice inset, in source px |
412
+ | `sliceBorderBottom` | number | 0 | Bottom 9-slice inset, in source px |
413
+
414
+ **Usage Notes:**
415
+
416
+ - Emits button press events when clicked
417
+ - Visual states: default, hover, pressed
418
+ - Use `buttonAction` to identify button function in scripts
419
+ - A state sprite replaces the flat colour for that state (the material tint goes white); with no
420
+ sprites at all the button keeps its colour behaviour
421
+ - **Nine-slice**: set the four `sliceBorder*` insets (source-texture pixels, Godot's `patch_margin_*`) to stop the skin being smeared. The corners keep their pixel size while the edges and centre stretch, so one 64x64 sprite fits any size. All-zero (the default) is the plain stretch, unchanged.
422
+ - The four insets apply to **every** state sprite, and each state's patch is cut against _its own_
423
+ natural size — a hover skin authored at 2x still gets the same physical corners
424
+ - A sliced skin opts out of the 2D quad batcher (the batcher extracts four _unit_ corners, which a
425
+ baked patch is not); an unsliced button still batches
426
+
427
+ ---
428
+
429
+ ### Label2D
430
+
431
+ A multiline text label for 2D UI. Wraps text to a fixed box, aligns it in both axes, and can reveal it with a typewriter effect.
432
+
433
+ **Type String:** `Label2D`
434
+
435
+ **Properties:**
436
+
437
+ | Property | Type | Default | Description |
438
+ | ------------------------------------------- | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
439
+ | `label` | string | "" | Text to display; `\n` breaks lines |
440
+ | `labelFontFamily` | string | Arial | Font family. A family the project ships (`ProjectManifest.fonts`) is registered before the first frame; anything else falls back to a system face |
441
+ | `labelFontSize` | number | 16 | Font size in pixels |
442
+ | `labelFontWeight` | number \| string | normal | CSS weight (400/700/900…) — a display face and its Cyrillic supplier usually differ |
443
+ | `labelColor` | color | #ffffff | Text color |
444
+ | `labelOutlineWidth` | number | 0 | Alias of `outlineWidth` below (the shared `UIControl2D` caption outline); one outline, two spellings |
445
+ | `labelOutlineColor` | color | #000000 | Alias of `outlineColor` below |
446
+ | `labelShadowColor` | color \| null | null | Drop-shadow colour; empty = no shadow |
447
+ | `labelShadowOffsetX` / `labelShadowOffsetY` | number | 0 | Drop-shadow offset in px |
448
+ | `labelLetterSpacing` | number | 0 | Extra px between glyphs |
449
+ | `labelAlign` | enum | center | Horizontal alignment: `left`, `center`, `right` |
450
+ | `labelVAlign` | enum | middle | Vertical alignment: `top`, `middle`, `bottom` |
451
+ | `width` | number | 0 | Fixed box width; text word-wraps to it. 0 = auto-size (no wrap) |
452
+ | `height` | number | 0 | Fixed box height for vertical alignment. 0 = auto-size to the lines |
453
+ | `typewriterSpeed` | number | 0 | Characters per second for the typewriter reveal; 0 = off |
454
+ | `glowColor` | color | #ffffff | Glow colour; inert while `glowStrength` is 0 |
455
+ | `glowStrength` | number | 0 | Neon glow around the glyphs (0–4); 0 = off |
456
+ | `outlineColor` | color | #000000 | Outline colour; inert while `outlineWidth` is 0 |
457
+ | `outlineWidth` | number | 0 | Contrast outline half-width in px; 0 = off |
458
+
459
+ **Usage Notes:**
460
+
461
+ - The box is centered on the node position (like other UI controls); alignment places the text inside that box.
462
+ - Set `width` manually to get word wrap — there is no auto-grow layout yet.
463
+ - **Glow/outline are canvas-drawn** (`ctx.shadowBlur` passes + a `strokeText` underlay), not post-processing — which is exactly why HUD text can glow: a `CanvasLayer2D` subtree is drawn _after_ the post-processing composer and can never bloom. Both are off by default, so existing scenes are unchanged.
464
+ - Turning either on grows the label's canvas (and the mesh showing it) by a bleed on each side so the blur/stroke isn't clipped; the tap target stays the authored box.
465
+ - `glowStrength` 1 is a subtle halo, 2–3 reads as neon (each whole step adds an additive shadow pass, capped at 3); the blur scales with `labelFontSize`, so a HUD label and a title glow proportionally.
466
+ - Scripts: `setText(text)` replaces the text and restarts the typewriter; `skipTypewriter()` completes it instantly; `restartTypewriter()` replays it; `isTyping` reports progress; the node emits `'typewriter-complete'` when the reveal finishes.
467
+ - The typewriter runs in play mode only (it advances in `tick`); the editor viewport always shows the full text.
468
+
469
+ ---
470
+
471
+ ### Slider2D
472
+
473
+ A horizontal slider control for selecting numeric values. Useful for volume controls, brightness settings, or any continuous value input.
474
+
475
+ **Type String:** `Slider2D`
476
+
477
+ **Properties:**
478
+
479
+ | Property | Type | Default | Description |
480
+ | ---------------------- | ------- | -------- | ---------------------------------------------------------- |
481
+ | `width` | number | 200 | Slider width in pixels |
482
+ | `height` | number | 20 | Slider height in pixels |
483
+ | `handleSize` | number | 20 | Handle knob size |
484
+ | `trackBackgroundColor` | color | #333333 | Empty track color |
485
+ | `trackFilledColor` | color | #4a9eff | Filled track color |
486
+ | `handleColor` | color | #ffffff | Handle color |
487
+ | `minValue` | number | 0 | Minimum value |
488
+ | `maxValue` | number | 100 | Maximum value |
489
+ | `value` | number | 50 | Current value |
490
+ | `axisName` | string | "Slider" | Identifier for axis |
491
+ | `textureTrack` | texture | null | Sprite for the track background |
492
+ | `textureFill` | texture | null | Sprite for the filled portion left of the handle |
493
+ | `textureThumb` | texture | null | Sprite for the handle; never nine-sliced |
494
+ | `sliceBorderLeft` | number | 0 | Left 9-slice inset of the track/fill sprites, in source px |
495
+ | `sliceBorderRight` | number | 0 | Right 9-slice inset, in source px |
496
+ | `sliceBorderTop` | number | 0 | Top 9-slice inset, in source px |
497
+ | `sliceBorderBottom` | number | 0 | Bottom 9-slice inset, in source px |
498
+
499
+ **Usage Notes:**
500
+
501
+ - Drag the handle to change values
502
+ - A slot's sprite replaces its flat colour (tint goes white); unset slots keep the colour
503
+ - **Nine-slice**: set the four `sliceBorder*` insets (source-texture pixels, Godot's `patch_margin_*`) to stop the skin being smeared. The corners keep their pixel size while the edges and centre stretch, so one 64x64 sprite fits any size. All-zero (the default) is the plain stretch, unchanged.
504
+ - The border applies to the track and the fill, never the thumb — a knob is drawn at its authored
505
+ size, so there is nothing to slice. The fill is **re-cut** as the value moves, so its caps keep
506
+ their pixel size instead of squashing
507
+ - Value is clamped between min and max
508
+ - Emits the pointer lifecycle signals (`pointerdown`/`pressed`/`pointerup`/`released`/`click`) but **no** state signal: the value is written during the drag, so by the time `released`/`click` fire, `value` is already the value the gesture produced. Read `slider.value` there, or track the live value through `input.getAxis(axisName)`.
509
+
510
+ ---
511
+
512
+ ### Joystick2D
513
+
514
+ A virtual analog stick control for touch or mouse input. Commonly used for character movement or camera control in games.
515
+
516
+ **Type String:** `Joystick2D`
517
+
518
+ **Properties:**
519
+
520
+ | Property | Type | Default | Description |
521
+ | ---------------- | ------- | ---------- | --------------------------------------------------------------------------- |
522
+ | `enabled` | boolean | true | When false the stick accepts no input and drops any drag in progress |
523
+ | `radius` | number | 50 | Base radius; also the handle's travel limit |
524
+ | `floating` | boolean | false | Hidden until a touch summons it under the finger, then fades out on release |
525
+ | `axisHorizontal` | string | Horizontal | Virtual axis written with the X deflection (-1..1) |
526
+ | `axisVertical` | string | Vertical | Virtual axis written with the Y deflection (-1..1) |
527
+
528
+ **Usage Notes:**
529
+
530
+ - Writes normalized X/Y values (-1 to 1) into the two named axes; centred is (0, 0)
531
+ - Captures the finger that started the drag and follows only that one, so a second thumb can press
532
+ buttons (or drive a second stick) without disturbing it. A `pointercancel` — a finger dragged off
533
+ the edge of the screen — returns the axes to zero, it never leaves them pushed
534
+ - A floating stick starts on a **new** touch that is not over UI, asked per finger: a button held by
535
+ another thumb no longer blocks it
536
+ - Ideal for mobile/touch interfaces
537
+
538
+ ---
539
+
540
+ ### Checkbox2D
541
+
542
+ A toggle checkbox control for boolean settings.
543
+
544
+ **Type String:** `Checkbox2D`
545
+
546
+ **Properties:**
547
+
548
+ | Property | Type | Default | Description |
549
+ | ------------------- | ------- | -------- | --------------------------------------------------- |
550
+ | `size` | number | 30 | Side of the square box, in pixels |
551
+ | `checked` | boolean | false | Checked state |
552
+ | `uncheckedColor` | color | #ffffff | Box fill colour while unchecked |
553
+ | `checkedColor` | color | #4a9eff | Box fill colour while checked |
554
+ | `checkmarkColor` | color | #ffffff | Colour of the fallback tick (ignored with a sprite) |
555
+ | `checkmarkAction` | string | Checkbox | Virtual input action pulsed / latched on toggle |
556
+ | `textureBox` | texture | null | Sprite for the box; also the checked fallback |
557
+ | `textureBoxChecked` | texture | null | Optional sprite for the box while checked |
558
+ | `textureMark` | texture | null | Sprite drawn over the box while checked (the tick) |
559
+
560
+ **Signals:**
561
+
562
+ | Signal | Arguments | When |
563
+ | ------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- |
564
+ | `pointerdown` / `pressed` | — | A press landed inside the box |
565
+ | `pointerup` / `released` | — | The press ended |
566
+ | `click` | — | Released inside the box — a completed _gesture_, emitted **before** the box flips |
567
+ | `toggled` | `(checked)` | The checked state **changed**, emitted **after** the flip, the repaint and the virtual action are in place |
568
+
569
+ `click` and `toggled` are deliberately separate, as in Godot (`pressed` vs `toggled`):
570
+ `click` says "the user clicked me" and runs while `checked` still holds its
571
+ pre-click value, so a handler that applies `checked` from `click` applies the
572
+ _previous_ state (inverted behaviour). Connect anything that _acts on the state_
573
+ to `toggled` and read the payload or the node:
574
+
575
+ ```ts
576
+ musicToggle.connect('toggled', this, checked => scene.audio.setBusVolume('music', checked ? 1 : 0));
577
+ ```
578
+
579
+ `toggled` fires for every spelling of the change — a tap, the `toggle` /
580
+ `setChecked` interactions, `checkbox.checked = x` from a script, an Inspector
581
+ edit — and never fires when the assigned value equals the current one.
582
+
583
+ **Usage Notes:**
584
+
585
+ - Toggle between checked/unchecked states
586
+ - The hit area spans the box plus its label, so clicking the text toggles too
587
+ - `checkmarkAction` pulses a virtual button for one frame and latches an axis (1/0) with the state
588
+ - A sprite in a slot replaces the corresponding flat colour (the tint goes white). `textureBoxChecked`
589
+ is optional: without it the checked box keeps drawing `textureBox`
590
+ - The mark exists **only while checked**, sprite or not. With `textureMark` it is a box-sized,
591
+ unrotated quad; without one it stays the historical tilted colour bar
592
+
593
+ ---
594
+
595
+ ### Bar2D
596
+
597
+ A progress bar or health bar display.
598
+
599
+ **Type String:** `Bar2D`
600
+
601
+ **Properties:**
602
+
603
+ | Property | Type | Default | Description |
604
+ | --------------------- | ------- | ------- | ------------------------------------------------------------------------------------ |
605
+ | `width` | number | 150 | Bar width in pixels |
606
+ | `height` | number | 20 | Bar height in pixels |
607
+ | `value` | number | 100 | Current fill value, clamped to `minValue`..`maxValue` |
608
+ | `minValue` | number | 0 | Value at which the bar is empty |
609
+ | `maxValue` | number | 100 | Value at which the bar is full |
610
+ | `backBackgroundColor` | color | #333333 | Trough (background) colour |
611
+ | `barColor` | color | #ff4444 | Fill colour |
612
+ | `showBorder` | boolean | true | Draw a frame around the bar |
613
+ | `borderColor` | color | #000000 | Frame colour |
614
+ | `borderWidth` | number | 2 | Frame thickness in pixels (not in the Inspector schema; authored/saved in YAML only) |
615
+ | `textureTrough` | texture | null | Sprite for the empty trough behind the fill |
616
+ | `textureFill` | texture | null | Sprite for the filled portion |
617
+ | `sliceBorderLeft` | number | 0 | Left 9-slice inset of the trough/fill sprites, in source px |
618
+ | `sliceBorderRight` | number | 0 | Right 9-slice inset, in source px |
619
+ | `sliceBorderTop` | number | 0 | Top 9-slice inset, in source px |
620
+ | `sliceBorderBottom` | number | 0 | Bottom 9-slice inset, in source px |
621
+
622
+ **Usage Notes:**
623
+
624
+ - Fill fraction = (value - minValue) / (maxValue - minValue)
625
+ - Useful for health bars, mana bars, loading progress
626
+ - Can be oriented horizontally
627
+ - A slot's sprite replaces its flat colour (tint goes white); unset slots keep the colour
628
+ - **Nine-slice**: set the four `sliceBorder*` insets (source-texture pixels, Godot's `patch_margin_*`) to stop the skin being smeared. The corners keep their pixel size while the edges and centre stretch, so one 64x64 sprite fits any size. All-zero (the default) is the plain stretch, unchanged.
629
+ - The fill is **re-cut** at the new width every time `value` changes, so a sliced fill keeps its
630
+ end caps at full pixel size all the way down to an empty bar instead of squashing them
631
+
632
+ ---
633
+
634
+ ### InventorySlot2D
635
+
636
+ A specialized slot control for inventory systems. Supports drag-and-drop for item management.
637
+
638
+ **Type String:** `InventorySlot2D`
639
+
640
+ **Properties:**
641
+
642
+ | Property | Type | Default | Description |
643
+ | ------------------ | ------- | -------------- | ------------------------------------------------- |
644
+ | `width` | number | 60 | Slot size (20..200) |
645
+ | `height` | number | 60 | Slot size (20..200) |
646
+ | `backdropColor` | color | #555555 | Slot background |
647
+ | `borderColor` | color | #888888 | Border colour |
648
+ | `borderWidth` | number | 2 | Border thickness, px |
649
+ | `quantity` | number | 0 | Stack count shown in the corner (≥ 0) |
650
+ | `showQuantity` | boolean | true | Draw the count |
651
+ | `quantityFontSize` | number | 12 | Count text size, px |
652
+ | `selectionColor` | color | #ffff00 | Highlight colour while selected |
653
+ | `selectedAction` | string | `SlotSelected` | Virtual button pressed while the slot is selected |
654
+
655
+ **Signals:**
656
+
657
+ | Signal | Arguments | When |
658
+ | ---------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------- |
659
+ | `pointerdown` / `pressed` / `pointerup` / `released` | — | Pointer lifecycle, same as every UI control |
660
+ | `click` | — | Released inside the slot, emitted **before** the selection flips |
661
+ | `toggled` | `(selected)` | The selection **changed**, emitted **after** the highlight and the virtual action are in place |
662
+
663
+ Same split as `Checkbox2D`: read `selected` from a `toggled` listener, not from a
664
+ `click` one (which still sees the pre-click value). A slot inside a
665
+ `ScrollContainer2D` refuses the click entirely while the list is being flicked,
666
+ so neither signal fires for a scroll drag.
667
+
668
+ **Usage Notes:**
669
+
670
+ - Can hold one item at a time
671
+ - Visual indicator for item count
672
+ - Supports drag operations
673
+
674
+ ---
675
+
676
+ ### ScrollContainer2D
677
+
678
+ A vertically scrolling viewport that clips its children, with drag, wheel and inertia, plus an
679
+ optional themeable scrollbar. Use it for inventories, leaderboards, long settings lists.
680
+
681
+ **Type String:** `ScrollContainer2D`
682
+
683
+ **Properties** (plus everything on Node2D):
684
+
685
+ | Property | Type | Default | Description |
686
+ | ----------------------- | ------- | ------- | ---------------------------------------------------------------------------------------- |
687
+ | `width` | number | 100 | Viewport width |
688
+ | `height` | number | 100 | Viewport height (the clipped window) |
689
+ | `scrollY` | number | 0 | Current scroll offset, clamped to content |
690
+ | `dragScrollEnabled` | boolean | true | Drag the content to scroll |
691
+ | `wheelScrollEnabled` | boolean | true | Mouse wheel / trackpad |
692
+ | `inertiaEnabled` | boolean | true | Fling continues after release |
693
+ | `wheelSensitivity` | number | 1 | Wheel multiplier |
694
+ | `dragThreshold` | number | 6 | Pixels of movement before a press becomes a drag (below it, the press goes to the child) |
695
+ | `inertiaDamping` | number | 14 | Higher stops the fling sooner |
696
+ | `showScrollbar` | boolean | true | Draw the scrollbar |
697
+ | `scrollbarWidth` | number | 8 | Bar width (min 2) |
698
+ | `scrollbarMinHeight` | number | 24 | Shortest the thumb may get (min 8) |
699
+ | `scrollbarInset` | number | 8 | Inset from the right edge |
700
+ | `scrollbarColor` | color | #f5f7ff | Thumb tint |
701
+ | `scrollbarTrackColor` | color | #ffffff | Track tint |
702
+ | `scrollbarThumbTexture` | texture | null | Optional thumb skin |
703
+ | `scrollbarTrackTexture` | texture | null | Optional track skin |
704
+
705
+ **Usage Notes:**
706
+
707
+ - Script API: `scrollBy(delta)`, `scrollTo(offset)`, `fling(velocity)`, and the `scrollY` accessor.
708
+ - The scrollbar meshes carry the 2D overlay flag, so they float above the container's _children_
709
+ rather than being painted under them.
710
+ - Vertical only. A horizontal carousel is a `Group2D` with a flow row and your own drag script.
711
+
712
+ ---
713
+
714
+ ### Camera2D
715
+
716
+ A 2D game camera (Godot-style). Like `VirtualCamera3D` it does **not** render — it _describes_ how the shared 2D orthographic pass is framed. Each frame the runtime picks the highest-priority visible `Camera2D` and applies its pan (`position` + `offset`), `zoom`, clamped `limits`, and shake to the 2D camera. With no `Camera2D` in the scene the 2D pass keeps its default identity framing, so existing 2D scenes / playable ads are unaffected. Every knob is a flat schema property, so the keyframe timeline animates `position`, `offset`, `zoom`, `priority`, etc. with no animation code.
717
+
718
+ **Type String:** `Camera2D`
719
+
720
+ **Properties:**
721
+
722
+ | Property | Type | Default | Description |
723
+ | ---------------- | ------- | --------- | ----------------------------------------------------------------------- |
724
+ | `priority` | number | 10 | Highest-priority visible Camera2D drives the 2D view (animatable) |
725
+ | `zoom` | number | 1 | >1 magnifies (zooms in), <1 zooms out |
726
+ | `offset` | vector2 | 0,0 | Framing offset added to position (never written by follow / shake) |
727
+ | `followTargetId` | node | — | Node whose position this camera follows (empty = authored position) |
728
+ | `followOffset` | vector2 | 0,0 | Offset from the follow target |
729
+ | `followDamping` | number | 8 | Higher = snappier follow (0 = instant) |
730
+ | `deadzone` | vector2 | 0,0 | World half-extents the target may move within before the camera follows |
731
+ | `limitsEnabled` | boolean | false | Clamp the visible view inside an axis-aligned world box |
732
+ | `limitsCenter` | vector2 | 0,0 | Limits box center |
733
+ | `limitsSize` | vector2 | 1000,1000 | Limits box size |
734
+ | `shakeAmplitude` | number | 8 | Peak shake displacement in world units |
735
+ | `shakeFrequency` | number | 24 | Shake oscillation speed |
736
+ | `shakeDuration` | number | 0.35 | Shake duration in seconds |
737
+ | `shakeDecay` | number | 1.5 | Falloff power (0 = steady, 1 = linear, >1 = punchy tail) |
738
+
739
+ **Usage Notes:**
740
+
741
+ - `position` is the camera center (follow damps it toward the target); `offset` is a separate framing bias that follow and shake never touch.
742
+ - Limits clamp the view **center** zoom-aware, so the view edge never crosses the box; a box smaller than the view pins the center to `limitsCenter`.
743
+ - Shake is additive at apply time (never mutates `position`) and, being tick-driven, respects `Time.scale` — a hitstop freezes it, slow-mo stretches it. Trigger it from a script with `scene.juice.shake('camera2d')` (or `scene.juice.shake('camera')` in a pure-2D scene).
744
+ - v1: screen-anchored HUD shares this camera and pans / zooms with the world. Use a `CanvasLayer2D` to pin a HUD.
745
+
746
+ ---
747
+
748
+ ### CanvasLayer2D
749
+
750
+ A Godot-style `CanvasLayer` — a clean UI overlay band. Its subtree renders on a separate layer through an always-identity camera **after** the post-processing composer, so it is (a) a **fixed HUD** that ignores any `Camera2D` pan/zoom, and (b) **never post-processed** — bloom/vignette/chromatic-aberration leave it crisp (e.g. a restart dialog over a blurred game-over scene). Extends `Group2D` (width/height container); no extra authored properties in v1.
751
+
752
+ **Type String:** `CanvasLayer2D`
753
+
754
+ **Properties:** inherits `Group2D` (`width`, `height`) + `Node2D` transform/anchor/opacity.
755
+
756
+ **Usage Notes:**
757
+
758
+ - Content under a CanvasLayer2D is pinned in design-space coordinates regardless of the active Camera2D, and its pointer hit-tests stay correct while the world camera pans.
759
+ - Multiple CanvasLayer2D nodes stack by scene-tree order (like ordinary 2D draw order).
760
+ - Unlike Godot, inheritance is **not** broken: an ancestor's transform, opacity, and visibility still flow into the overlay subtree — only the render camera differs. Author at the scene root for a fully independent layer.
761
+ - Runtime overlay behavior is play-mode only; in the editor it renders as a normal Group2D container.
762
+
763
+ ## 3D Nodes
764
+
765
+ All 3D nodes operate in world space using a perspective camera by default. They use a right-handed coordinate system where X is right, Y is up, and Z is toward the viewer.
766
+
767
+ ### Node3D
768
+
769
+ The base class for all 3D scene nodes. Use this for simple grouping or as a container for other 3D elements.
770
+
771
+ **Type String:** `Node3D`
772
+
773
+ **Properties:**
774
+
775
+ | Property | Type | Default | Description |
776
+ | ---------- | ------- | --------- | --------------------------- |
777
+ | `position` | Vector3 | (0, 0, 0) | X, Y, Z coordinates |
778
+ | `rotation` | Euler | (0, 0, 0) | Pitch, Yaw, Roll in degrees |
779
+ | `scale` | Vector3 | (1, 1, 1) | X, Y, Z scale factors |
780
+
781
+ **Rotation Order:** XYZ (Pitch → Yaw → Roll)
782
+
783
+ **Usage Notes:**
784
+
785
+ - Rotation values are in degrees, stored as radians internally
786
+ - Default scale (1, 1, 1) means no scaling
787
+ - Children inherit all transforms
788
+
789
+ ---
790
+
791
+ ### Camera3D
792
+
793
+ A camera node that defines the viewpoint for rendering. The scene can have multiple cameras, but only one is active at a time.
794
+
795
+ **Type String:** `Camera3D`
796
+
797
+ **Properties:**
798
+
799
+ | Property | Type | Default | Description |
800
+ | ------------ | ------ | ----------- | ----------------------- |
801
+ | `projection` | enum | perspective | Projection type |
802
+ | `fov` | number | 60 | Field of view (degrees) |
803
+ | `near` | number | 0.1 | Near clipping plane |
804
+ | `far` | number | 1000 | Far clipping plane |
805
+
806
+ **Projection Types:**
807
+
808
+ | Type | Description |
809
+ | -------------- | -------------------------------- |
810
+ | `perspective` | Perspective projection (default) |
811
+ | `orthographic` | Orthographic projection |
812
+
813
+ **Usage Notes:**
814
+
815
+ - Perspective: Objects get smaller with distance
816
+ - Orthographic: No perspective distortion
817
+ - Default looks down the negative Z axis
818
+ - Use `setTargetPosition()` to point camera at a target
819
+
820
+ ---
821
+
822
+ ### VirtualCamera3D
823
+
824
+ A lightweight "virtual camera" (Cinemachine-lite). It does **not** render — it only describes a desired framing. Attach a **Camera Brain** (`core:CameraBrain`) component to a real `Camera3D`; each frame the brain picks the highest-priority visible virtual camera and blends the render camera toward it. Because every knob is a schema property, the keyframe timeline can animate `priority`, `fov`, `position`, etc. — switching cameras is "raise this one's priority above that one" — with no animation code.
825
+
826
+ **Type String:** `VirtualCamera3D`
827
+
828
+ **Properties:**
829
+
830
+ | Property | Type | Default | Description |
831
+ | ------------------ | ------- | ---------- | ------------------------------------------------------------------------- |
832
+ | `priority` | number | 10 | Highest-priority live virtual camera wins (animatable) |
833
+ | `fov` | number | 60 | Field of view applied to a perspective render camera |
834
+ | `orthographicSize` | number | 5 | Size applied to an orthographic render camera |
835
+ | `followTargetId` | node | — | Node whose position this camera follows (empty = authored position) |
836
+ | `followOffset` | vector3 | 0,0,0 | World-space offset from the follow target |
837
+ | `followDamping` | number | 8 | Higher = snappier follow (0 = instant) |
838
+ | `deadzone` | vector3 | 0,0,0 | World half-extents the target may move within before the camera follows |
839
+ | `lookAtTargetId` | node | — | Node this camera orients toward (empty = authored rotation) |
840
+ | `lookAtWeight` | number | 1 | 0 = keep authored rotation, 1 = fully track the target |
841
+ | `rotationDamping` | number | 8 | Higher = snappier aim (0 = instant) |
842
+ | `confinerEnabled` | boolean | false | Clamp the camera position inside an axis-aligned box |
843
+ | `confinerCenter` | vector3 | 0,0,0 | Confiner box center |
844
+ | `confinerSize` | vector3 | 10,10,10 | Confiner box size |
845
+ | `blendDuration` | number | 1 | Seconds to blend the render camera toward this one when it becomes active |
846
+ | `blendEasing` | enum | cubicInOut | Easing curve used for the blend |
847
+
848
+ **Usage Notes:**
849
+
850
+ - Requires a `Camera3D` carrying the `core:CameraBrain` component — that camera is the only one that renders.
851
+ - Standby cameras are still solved every frame, so a camera is already framed when it is cut to.
852
+ - With no follow target, position is left to authored / keyframed values (dolly by keyframes). Same for rotation with no look-at target.
853
+ - Setting a Camera Brain's **Blend On Switch** off makes cuts instantaneous.
854
+ - Scripts can force the _next_ activation blend with `CameraBrainBehavior.overrideNextBlend(durationSec, easing?)` — a one-shot override that wins even when Blend On Switch is off. The Cutscene Director (`scene.cutscene.playCinematic`) uses it to smooth the cut into and out of a cinematic virtual camera. See the runtime spec §6.13.
855
+
856
+ ---
857
+
858
+ ### GeometryMesh
859
+
860
+ A 3D mesh node with a built-in primitive geometry and a PBR material. The shape
861
+ is inspector-switchable and animatable via the `geometry`/`size` schema
862
+ properties.
863
+
864
+ **Type String:** `GeometryMesh`
865
+
866
+ **Properties:**
867
+
868
+ | Property | Type | Default | Description |
869
+ | ------------------------- | ------- | --------- | ----------------------------------------------------------------------- |
870
+ | `geometry` | enum | "box" | Primitive shape (see below) |
871
+ | `size` | Vector3 | (1, 1, 1) | Interpreted per shape (see below) |
872
+ | `material.color` | color | #4e8df5 | Surface color |
873
+ | `material.roughness` | number | 0.35 | Surface roughness (0-1) |
874
+ | `material.metalness` | number | 0.25 | Metallic appearance (0-1) |
875
+ | `material.map` | texture | — | Albedo (diffuse) texture (res://); required for UV Scroll to be visible |
876
+ | `material.aoMap` | texture | — | Baked ambient-occlusion map (set by the AO baker) |
877
+ | `material.aoMapIntensity` | number | 1 | Strength of the baked AO map (0 = off) |
878
+ | `castShadow` | boolean | true | Whether the mesh casts shadows |
879
+ | `receiveShadow` | boolean | true | Whether the mesh receives shadows cast by others |
880
+
881
+ **Shader Effects (attached list):**
882
+
883
+ Shader effects are **added from a picker** (Inspector → **Effects** → **Add**),
884
+ Unity/Godot-style — not fixed checkboxes. Each attached effect shows an **enable
885
+ toggle**, its params, and a **Remove** control; the built-in effects come from a
886
+ registry (`core:*`) and can be extended with `user:*` effects later. One instance
887
+ of each type per mesh (v1). Effects are injected into the standard PBR material
888
+ via `onBeforeCompile` and `#ifdef`-gated on `material.defines`, so a disabled
889
+ effect costs zero GPU and lighting/shadows/albedo/AO maps still apply.
890
+
891
+ Attached effects contribute their params to the node's schema **per instance**
892
+ (as `fx.<effect>.<param>`), so each param — and each effect's `enabled` flag — is
893
+ individually **keyframe-animatable** from the timeline. Animate the numeric
894
+ params rather than the enable toggles (a toggle flip recompiles the shader; cheap
895
+ after the first compile, which three caches per variant).
896
+
897
+ | Effect (`type`) | Params | What it does |
898
+ | ---------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
899
+ | Dissolve (`core:dissolve`) | `amount` (0-1), `scale`, `edgeWidth`, `edgeColor` | Noise-thresholded `discard` with an emissive glowing edge; drive `amount` 0→1 to dissolve away |
900
+ | Rim Light (`core:rim`) | `color`, `intensity` (0-5), `power` | Fresnel-based emissive rim, brightest at grazing angles |
901
+ | UV Scroll (`core:uv-scroll`) | `speed` (uv/s) | Scrolls the albedo map. **Play-mode only** (accumulated per tick); static in the edit viewport. Needs `material.map` |
902
+ | Flash Tint (`core:flash`) | `color`, `amount` (0-1) | Blends the final lit color toward a flat color; a hit/damage flash |
903
+
904
+ Serialized under `material.effects` as an ordered array of `{ type, enabled,
905
+ params }` (only non-default params are written).
906
+
907
+ **Geometry Types & `size` semantics:**
908
+
909
+ `size` is a single `[x, y, z]` vector reinterpreted per shape, so one editable
910
+ field works for every primitive:
911
+
912
+ | Type | `size` meaning |
913
+ | ---------- | ---------------------------------------------------- |
914
+ | `box` | Full extents: width `x`, height `y`, depth `z` |
915
+ | `sphere` | Diameter `x` (y, z ignored) |
916
+ | `plane` | A horizontal floor `x` by `z` (lies in the XZ plane) |
917
+ | `cylinder` | Diameter `x`, height `y` |
918
+ | `cone` | Base diameter `x`, height `y` |
919
+ | `torus` | Outer diameter `x`; tube thickness scales with `y` |
920
+
921
+ **Usage Notes:**
922
+
923
+ - Changing `geometry` or `size` in the inspector rebuilds the mesh live.
924
+ - Material uses PBR (Physically Based Rendering).
925
+ - Roughness: 0 = glossy, 1 = matte. Metalness: 0 = non-metal, 1 = metal.
926
+ - Material color/roughness/metalness edits persist through save and play mode
927
+ (the live material is serialized, not a stale authored snapshot).
928
+ - Node opacity does **not** currently fade a GeometryMesh (its material isn't
929
+ registered for opacity blending).
930
+ - Effects are attached from a registry-backed picker (see above), not fixed
931
+ checkboxes; their params surface per-instance as `fx.<effect>.<param>` schema
932
+ props so they remain keyframe-animatable.
933
+ - A dissolving mesh still casts an intact shadow (the depth/shadow pass has no
934
+ `discard`); accepted for now.
935
+ - `castShadow`/`receiveShadow` (Inspector → **Rendering**) are authored on the
936
+ node and mirrored onto the child render mesh; both default to **true**, which is
937
+ what this node hardcoded before they were exposed, so existing scenes are
938
+ unchanged. Turn `receiveShadow` off for a mesh that should stay evenly lit
939
+ (unlit-looking floors, UI-ish props) without touching the light itself.
940
+
941
+ ---
942
+
943
+ ### MeshInstance
944
+
945
+ A node that loads and displays external 3D models in GLB or GLTF format.
946
+
947
+ **Type String:** `MeshInstance`
948
+
949
+ **Properties:**
950
+
951
+ | Property | Type | Default | Description |
952
+ | -------- | ------ | ------- | --------------------------- |
953
+ | `src` | string | null | Path to model file (res://) |
954
+
955
+ **Usage Notes:**
956
+
957
+ - Supports GLB (binary) and GLTF formats
958
+ - Path uses `res://` protocol for project resources
959
+ - Can contain multiple meshes, materials, and animations
960
+ - Animations can be played via script components
961
+
962
+ ---
963
+
964
+ ### InstancedMesh3D
965
+
966
+ A `THREE.InstancedMesh` wrapper for rendering many copies of one geometry/material in a single draw call — for large ECS-driven simulations (crowds, particles, tiles). Populate it in bulk from a script/system, not the inspector.
967
+
968
+ **Type String:** `InstancedMesh3D`
969
+
970
+ **Node-level (serialized) properties:**
971
+
972
+ | Property | Type | Default | Description |
973
+ | ------------------------------------------- | ------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
974
+ | `maxInstances` | number | — | Buffer capacity (instance count ceiling); read-only in inspector |
975
+ | `enablePerInstanceColor` | boolean | false | Allocate a per-instance color buffer |
976
+ | `castShadow` | boolean | — | Forwarded to the underlying mesh |
977
+ | `receiveShadow` | boolean | — | Forwarded to the underlying mesh |
978
+ | `frustumCulled` | boolean | — | Forwarded to the underlying mesh |
979
+ | `visibleInstanceCount` | number | — | How many instances currently draw (read-only display; not saved) |
980
+ | `material.type` | enum | `standard` | Material family — `standard` (PBR) / `lambert` (mobile default) / `basic` (unlit); inspector: **Material Type** |
981
+ | `material.color` | color | `#ffffff` | Colour every instance shares; a per-instance colour multiplies it |
982
+ | `material.roughness` / `material.metalness` | number | 0.35 / 0.25 | `standard` only; dropped from the file for the other families |
983
+
984
+ **Bulk API (call from a script/system, then `flush()`):**
985
+
986
+ - `writeMatrices(data, options?)` — write raw N×16 `Float32Array` instance matrices.
987
+ - `writeTransforms(data, options?)` — write position/rotation/scale transforms (composed to matrices for you).
988
+ - `writeColors(data, options?)` — write per-instance colors (requires `enablePerInstanceColor`).
989
+ - `flush()` — upload dirty buffers to the GPU. `SceneRunner` calls `flush()` on every `InstancedMesh3D` before each frame; call it yourself if you mutate outside the runner loop.
990
+ - `setGeometry(geometry)` / `setMaterial(material)`; `getInstanceMatrixBuffer()` / `getInstanceColorBuffer()` for direct access.
991
+
992
+ **Usage Notes:**
993
+
994
+ - **Instance buffers are NOT serialized** — only the node-level config above is saved. Repopulate buffers at runtime.
995
+ - The `material` block IS serialized, and is what makes an instanced mesh authorable: before it existed the loader built no material at all, so a scene-authored instanced mesh always rendered with a shared white PBR default — unreachable from the inspector and past the project's mobile material policy. A `material` handed to the constructor in code still wins over the authored block.
996
+ - Raycasts against an instanced mesh return the hit `instanceId`.
997
+ - Backed by `ECSService` for project-managed ECS worlds; runtime lives in `packages/pix3-runtime/src/nodes/3D/InstancedMesh3D.ts` + `core/ECSService.ts`.
998
+
999
+ ---
1000
+
1001
+ ### AmbientLightNode
1002
+
1003
+ Uniform light from every direction with no falloff and no shadows — the cheapest possible fill.
1004
+ Raises the floor of a scene so unlit faces are not pure black.
1005
+
1006
+ **Type String:** `AmbientLightNode`
1007
+
1008
+ **Properties** (plus everything on Node3D):
1009
+
1010
+ | Property | Type | Default | Description |
1011
+ | ----------- | ------ | ------- | ------------ |
1012
+ | `color` | color | #ffffff | Light colour |
1013
+ | `intensity` | number | 0.5 | Brightness |
1014
+
1015
+ **Usage Notes:**
1016
+
1017
+ - Position and rotation are inert: ambient light has no direction and no origin.
1018
+ - Flattens everything it touches. Use it under a `DirectionalLightNode`, not instead of one; for a
1019
+ fill with some sense of sky and ground, prefer HemisphereLightNode.
1020
+ - Authored colours convert exactly once — never call `convertSRGBToLinear` on the hex you set.
1021
+
1022
+ ---
1023
+
1024
+ ### HemisphereLightNode
1025
+
1026
+ A two-colour gradient fill: sky colour from above, ground colour from below, interpolated by each
1027
+ surface's normal. An outdoor scene's fill light, and a much better default than flat ambient.
1028
+
1029
+ **Type String:** `HemisphereLightNode`
1030
+
1031
+ **Properties** (plus everything on Node3D):
1032
+
1033
+ | Property | Type | Default | Description |
1034
+ | ------------- | ------ | ------- | -------------------------- |
1035
+ | `skyColor` | color | #ffffff | Colour arriving from above |
1036
+ | `groundColor` | color | #444444 | Colour bounced from below |
1037
+ | `intensity` | number | 0.5 | Brightness |
1038
+
1039
+ **Usage Notes:**
1040
+
1041
+ - No shadows and no falloff, like ambient — it is a fill, not a key light.
1042
+ - A warm `skyColor` with a cool `groundColor` (or the reverse) reads as outdoor bounce for free.
1043
+
1044
+ ---
1045
+
1046
+ ### DirectionalLightNode
1047
+
1048
+ A light source that emits parallel rays in a single direction, like the sun. Illuminates all objects from the same angle.
1049
+
1050
+ **Type String:** `DirectionalLight`
1051
+
1052
+ **Properties:**
1053
+
1054
+ | Property | Type | Default | Description |
1055
+ | ------------ | ------- | ------- | --------------------- |
1056
+ | `color` | color | #ffffff | Light color |
1057
+ | `intensity` | number | 1.0 | Light brightness |
1058
+ | `castShadow` | boolean | true | Enable shadow casting |
1059
+
1060
+ **Usage Notes:**
1061
+
1062
+ - Light direction is determined by node rotation
1063
+ - Good for outdoor lighting and sun simulation
1064
+ - Constant illumination regardless of distance
1065
+ - Shadow map size is auto-calculated
1066
+
1067
+ ---
1068
+
1069
+ ### PointLightNode
1070
+
1071
+ A light source that emits rays in all directions from a single point. Like a light bulb.
1072
+
1073
+ **Type String:** `PointLight`
1074
+
1075
+ **Properties:**
1076
+
1077
+ | Property | Type | Default | Description |
1078
+ | ------------ | ------- | ------- | ---------------------------- |
1079
+ | `color` | color | #ffffff | Light color |
1080
+ | `intensity` | number | 1.0 | Light brightness |
1081
+ | `distance` | number | 0 | Maximum range (0 = infinite) |
1082
+ | `decay` | number | 2 | Falloff rate |
1083
+ | `castShadow` | boolean | true | Enable shadow casting |
1084
+
1085
+ **Usage Notes:**
1086
+
1087
+ - Intensity decreases with distance (inverse square law)
1088
+ - Use `distance` to limit effective range
1089
+ - Decay of 2 is physically accurate
1090
+ - Good for lamps, candles, torches
1091
+
1092
+ ---
1093
+
1094
+ ### SpotLightNode
1095
+
1096
+ A light source that emits a cone of light in a specific direction. Like a flashlight or spotlight.
1097
+
1098
+ **Type String:** `SpotLight`
1099
+
1100
+ **Properties:**
1101
+
1102
+ | Property | Type | Default | Description |
1103
+ | ------------ | ------- | ------- | ---------------------------- |
1104
+ | `color` | color | #ffffff | Light color |
1105
+ | `intensity` | number | 1.0 | Light brightness |
1106
+ | `distance` | number | 0 | Maximum range (0 = infinite) |
1107
+ | `angle` | number | 60 | Cone angle (degrees) |
1108
+ | `penumbra` | number | 0 | Edge softness (0-1) |
1109
+ | `decay` | number | 2 | Falloff rate |
1110
+ | `castShadow` | boolean | true | Enable shadow casting |
1111
+
1112
+ **Usage Notes:**
1113
+
1114
+ - Penumbra creates soft edge transitions
1115
+ - Angle controls the cone width
1116
+ - Good for stage lights, flashlights, focused lighting
1117
+ - Target direction is determined by node rotation
1118
+
1119
+ ---
1120
+
1121
+ ### Sprite3D
1122
+
1123
+ A textured quad in the 3D world — optionally billboarded so it always faces the camera. For
1124
+ impostors, floating markers, particles you place by hand, and 2.5D characters.
1125
+
1126
+ **Type String:** `Sprite3D`
1127
+
1128
+ **Properties** (plus everything on Node3D):
1129
+
1130
+ | Property | Type | Default | Description |
1131
+ | -------------------- | ------- | ------- | ---------------------------------------------------------------------- |
1132
+ | `texture` | texture | null | `{ type: 'texture', url: 'res://…' }` (`texturePath` string also read) |
1133
+ | `width` | number | 1 | Quad width in **world units**, not pixels |
1134
+ | `height` | number | 1 | Quad height in world units |
1135
+ | `billboard` | boolean | false | Face the camera every frame |
1136
+ | `billboardRoll` | number | 0 | Roll, in degrees, applied after billboarding |
1137
+ | `opacity` | number | 1 | Alpha |
1138
+ | `textureAspectRatio` | number | null | Captured from the texture on load (not saved) |
1139
+ | `aspectRatioLocked` | boolean | false | Keep width/height at the texture's aspect (inspector-only, not saved) |
1140
+
1141
+ **Usage Notes:**
1142
+
1143
+ - Sizes are world units — a 1×1 sprite is one metre wide, not 1 px. This is the single most common
1144
+ surprise when porting a 2D idea into 3D.
1145
+ - Unlike 2D nodes, this one keeps mipmaps: it is a 3D texture and is minified by distance.
1146
+
1147
+ ---
1148
+
1149
+ ### AnimatedSprite3D
1150
+
1151
+ A billboarded flipbook in the 3D world: same spritesheet idea as
1152
+ AnimatedSprite2D, placed in world space. Unlike the 2D node it carries its own
1153
+ frame rate.
1154
+
1155
+ **Type String:** `AnimatedSprite3D`
1156
+
1157
+ **Properties** (plus everything on Node3D):
1158
+
1159
+ | Property | Type | Default | Description |
1160
+ | -------------- | ------- | -------- | ------------------------------------------------------------ |
1161
+ | `width` | number | 1 | Quad width in world units |
1162
+ | `height` | number | 1 | Quad height in world units |
1163
+ | `fps` | number | 10 | Frames per second |
1164
+ | `playing` | boolean | true | Play on start |
1165
+ | `loop` | boolean | true | Repeat when the clip ends |
1166
+ | `freeOnFinish` | boolean | false | Remove the node when a non-looping clip ends |
1167
+ | `currentFrame` | number | 0 | Frame index (playback state, not saved) |
1168
+ | `billboard` | boolean | **true** | Face the camera (note: the opposite default from `Sprite3D`) |
1169
+ | `color` | color | #ffffff | Tint |
1170
+ | `opacity` | number | 1 | Alpha |
1171
+
1172
+ **Usage Notes:**
1173
+
1174
+ - `billboard` defaults to true here and false on `Sprite3D` — an explosion wants to face you, a
1175
+ poster on a wall does not.
1176
+ - `loop: false` + `freeOnFinish: true` is the fire-and-forget 3D hit effect.
1177
+
1178
+ ---
1179
+
1180
+ ### Particles3D
1181
+
1182
+ A CPU-simulated particle emitter rendered as a single `InstancedMesh`. Supports
1183
+ billboarded planes, spheres or cubes, per-particle color/alpha/size ramps, an
1184
+ emitter shape (point/sphere/box), optional ribbon **trails**, and **sub-emitters**
1185
+ that burst a second emitter on particle death.
1186
+
1187
+ **Type String:** `Particles3D`
1188
+
1189
+ **Key Properties:**
1190
+
1191
+ | Property | Type | Default | Description |
1192
+ | --------------------------- | ------- | ----------- | ----------------------------------------------------------------------------------------------------- |
1193
+ | `emissionRate` | number | 24 | Particles spawned per second |
1194
+ | `maxParticles` | number | 512 | Simulation pool size (also the instance cap) |
1195
+ | `lifetime` | number | 2 | Base particle lifetime (s), jittered ±15% |
1196
+ | `speed` / `speedSpread` | number | 2 / 0.5 | Initial speed and its random spread |
1197
+ | `gravity` | vector3 | (0,0,0) | Constant acceleration (sim-space vector) |
1198
+ | `startColor`/`endColor` | color | white/amber | Color ramp over life |
1199
+ | `startAlpha`/`endAlpha` | number | 1 / 0 | Alpha ramp over life |
1200
+ | `simulationSpace` | enum | `local` | `local` = particles follow the emitter; `world` = particles are emitted into world space and stay put |
1201
+ | `trailEnabled` | boolean | false | Draw a camera-facing ribbon behind each particle |
1202
+ | `trailLifetime` | number | 0.3 | How long (s) a trail sample survives |
1203
+ | `trailWidth` | number | 0.05 | Ribbon width at the head |
1204
+ | `trailSegments` | number | 16 | Ribbon resolution (clamped 2–64) |
1205
+ | `trailFade` | number | 1 | Alpha falloff along the ribbon (0 = solid, 1 = fade to transparent) |
1206
+ | `subEmitterId` | node | — | Another `Particles3D` fired as a burst at each particle death |
1207
+ | `subEmitterBurstCount` | number | 8 | Particles spawned per death (0–128) |
1208
+ | `subEmitterInheritVelocity` | number | 0 | Fraction of the dead particle's velocity passed to the burst (0–1) |
1209
+
1210
+ **Simulation space (behavior change):** `simulationSpace` was persisted since it
1211
+ shipped but was previously **ignored** — every emitter simulated in `local` space
1212
+ regardless of the value. It now works: in `world` mode already-spawned particles
1213
+ keep their world position when the emitter moves (trails, exhaust, muzzle smoke),
1214
+ implemented by neutralizing the emitter's ancestor transform each frame
1215
+ (`renderRoot.matrix = matrixWorld⁻¹`). Any externally-authored scene that set
1216
+ `simulationSpace: world` will change from the old (buggy) local-follow to true
1217
+ world-space; there is no migration — the field finally does what its label says.
1218
+
1219
+ **Usage Notes:**
1220
+
1221
+ - Trails are best with `simulationSpace: world`; in `local` mode on a moving
1222
+ emitter the whole ribbon rides with the node.
1223
+ - Trails allocate `maxParticles × trailSegments` samples — keep `maxParticles`
1224
+ moderate when trails are enabled (buffers exist only while `trailEnabled`).
1225
+ - The sub-emitter target is a normal `Particles3D`, typically authored with
1226
+ `emissionRate: 0` so it only fires from bursts. Bursts are deferred to after the
1227
+ simulation loop, so self-reference and any tick order are safe (≤1 frame latency).
1228
+ - Trail material is additive and untextured in v1.
1229
+ - All new fields are flat scalars, so they are keyframe-animatable from the timeline.
1230
+
1231
+ ## Post-processing
1232
+
1233
+ ### PostProcess
1234
+
1235
+ The scene's post-processing stack: bloom, vignette, chromatic aberration, ambient occlusion and a
1236
+ LUT colour grade, as one node. The runner picks the **first active** `PostProcess` node in the tree
1237
+ and builds a composer from it; with no such node the plain two-pass path runs and costs nothing.
1238
+
1239
+ **Type String:** `PostProcess`
1240
+
1241
+ **Properties:**
1242
+
1243
+ | Property | Type | Default | Description |
1244
+ | ---------------------------- | ------- | --------- | ---------------------------------------------------------------------------- |
1245
+ | `affect2D` | boolean | true | Also run the effects over the 2D pass |
1246
+ | `bloomEnabled` | boolean | true | Bloom on |
1247
+ | `bloomIntensity` | number | 1 | Bloom strength |
1248
+ | `bloomThreshold` | number | 0.9 | Luminance above which pixels bloom |
1249
+ | `bloomSmoothing` | number | 0.025 | Threshold knee |
1250
+ | `bloomRadius` | number | 0.85 | Blur radius |
1251
+ | `vignetteEnabled` | boolean | false | Vignette on |
1252
+ | `vignetteOffset` | number | 0.35 | Where the darkening starts |
1253
+ | `vignetteDarkness` | number | 0.5 | How dark the edges go |
1254
+ | `chromaticAberrationEnabled` | boolean | false | Chromatic aberration on |
1255
+ | `chromaticAberrationOffset` | number | 0.002 | Channel separation |
1256
+ | `aoMode` | enum | `inherit` | Ambient occlusion: `inherit` (project setting) \| `off` \| `ssao` \| `baked` |
1257
+ | `ssaoIntensity` | number | 2.5 | SSAO strength |
1258
+ | `ssaoRadius` | number | 0.25 | SSAO sample radius, world units |
1259
+ | `lutEnabled` | boolean | false | Colour grading on |
1260
+ | `lutSrc` | string | "" | `res://…` path to the LUT image |
1261
+ | `lutIntensity` | number | 1 | Grade blend amount |
1262
+
1263
+ **Usage Notes:**
1264
+
1265
+ - It has no transform: the properties sit flat in `properties`, and position/rotation/scale do not
1266
+ apply.
1267
+ - The whole stack is one lazily-created composer. Every effect off = no composer at all.
1268
+ - In a single-file playable export, `postprocessing` is pulled in through a generated
1269
+ `virtual:runtime-*` module — using this node is fine for an export, it just adds weight.
1270
+
1271
+ ## Audio
1272
+
1273
+ ### AudioPlayer
1274
+
1275
+ A node that plays an audio clip through the runtime mixer. Attach it anywhere in
1276
+ the scene; drive it via `autoplay`, from a script (`node.play()`), or from an
1277
+ `AnimationPlayer` audio track.
1278
+
1279
+ **Type String:** `AudioPlayer`
1280
+
1281
+ **Key Properties:**
1282
+
1283
+ | Property | Type | Default | Description |
1284
+ | ----------------- | ------- | ------- | ------------------------------------------------------ |
1285
+ | `audioTrack` | string | — | Asset URL (`res://…`, `data:audio/…`, or absolute URL) |
1286
+ | `autoplay` | boolean | false | Play automatically on the first tick |
1287
+ | `loop` | boolean | false | Loop the clip |
1288
+ | `volume` | number | 1 | Per-clip volume (0–1), before the bus gain |
1289
+ | `bus` | enum | `sfx` | Mixer bus: `master`, `music`, or `sfx` |
1290
+ | `pitchVariation` | number | 0 | Random ± playback-rate spread per play (0–1) |
1291
+ | `volumeVariation` | number | 0 | Random ± volume spread per play (0–1) |
1292
+
1293
+ `bus`, `pitchVariation` and `volumeVariation` are also available on the
1294
+ `core:PlaySound` behavior (`PlaySoundBehavior`), with identical semantics.
1295
+
1296
+ ### Buses, snapshots & `scene.audio`
1297
+
1298
+ The runtime mixer routes every playback through three fixed buses:
1299
+
1300
+ ```
1301
+ sound → sfx ┐
1302
+ music ┼→ master → output
1303
+ ```
1304
+
1305
+ Each bus has a volume and a permanently-wired (transparent) lowpass filter, so
1306
+ mixing and snapshot transitions are click-free `AudioParam` ramps. Scripts reach
1307
+ the mixer through `this.scene.audio`:
1308
+
1309
+ ```ts
1310
+ // One-shot playback (loads + caches via the AssetLoader)
1311
+ this.scene.audio.play('res://sfx/hit.ogg', { bus: 'sfx', pitchVariation: 0.1 });
1312
+
1313
+ // Mixer volume (e.g. from a settings menu)
1314
+ this.scene.audio.setBusVolume('music', 0.5);
1315
+
1316
+ // Snapshots — named per-bus lowpass + volume-scale states
1317
+ this.scene.audio.registerSnapshot({ name: 'underwater', lowpassHz: { master: 500 } });
1318
+ this.scene.audio.applySnapshot('underwater');
1319
+ this.scene.audio.resetSnapshot(); // back to 'default'
1320
+ ```
1321
+
1322
+ **Built-in snapshots:** `'default'` (fully open) and `'muffled'`
1323
+ (`master` lowpass 700 Hz, volume ×0.85). A snapshot's volume scale composes _on
1324
+ top of_ the user's bus volume, so entering/leaving a snapshot never forgets the
1325
+ authored mix.
1326
+
1327
+ **Slow-motion auto-muffle:** while `scene.time` is in slow motion the mixer
1328
+ automatically blends to `'muffled'` and back on return to normal speed. This is
1329
+ driven by the slow-mo base scale, **not** the frozen scale — a `hitstop(…)`
1330
+ freeze does **not** muffle audio (otherwise every micro-freeze would pump the
1331
+ filter).