@codexo/exojs 0.7.12 → 0.8.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 (219) hide show
  1. package/CHANGELOG.md +737 -0
  2. package/dist/esm/core/Application.d.ts +3 -1
  3. package/dist/esm/core/Application.js +7 -6
  4. package/dist/esm/core/Application.js.map +1 -1
  5. package/dist/esm/core/Scene.d.ts +30 -0
  6. package/dist/esm/core/Scene.js +56 -0
  7. package/dist/esm/core/Scene.js.map +1 -1
  8. package/dist/esm/core/SceneManager.js +2 -2
  9. package/dist/esm/core/SceneManager.js.map +1 -1
  10. package/dist/esm/debug/DebugOverlay.js +2 -2
  11. package/dist/esm/debug/DebugOverlay.js.map +1 -1
  12. package/dist/esm/debug/PointerStackLayer.js +1 -1
  13. package/dist/esm/debug/PointerStackLayer.js.map +1 -1
  14. package/dist/esm/index.js +32 -10
  15. package/dist/esm/index.js.map +1 -1
  16. package/dist/esm/input/ArcadeStickGamepadMapping.js +18 -19
  17. package/dist/esm/input/ArcadeStickGamepadMapping.js.map +1 -1
  18. package/dist/esm/input/Gamepad.d.ts +164 -62
  19. package/dist/esm/input/Gamepad.js +290 -134
  20. package/dist/esm/input/Gamepad.js.map +1 -1
  21. package/dist/esm/input/GamepadAxis.d.ts +120 -0
  22. package/dist/esm/input/GamepadAxis.js +106 -0
  23. package/dist/esm/input/GamepadAxis.js.map +1 -0
  24. package/dist/esm/input/GamepadButton.d.ts +110 -0
  25. package/dist/esm/input/GamepadButton.js +99 -0
  26. package/dist/esm/input/GamepadButton.js.map +1 -0
  27. package/dist/esm/input/GamepadDefinitions.js +4 -0
  28. package/dist/esm/input/GamepadDefinitions.js.map +1 -1
  29. package/dist/esm/input/GamepadMapping.d.ts +28 -24
  30. package/dist/esm/input/GamepadMapping.js +33 -16
  31. package/dist/esm/input/GamepadMapping.js.map +1 -1
  32. package/dist/esm/input/GamepadPromptLayouts.d.ts +10 -8
  33. package/dist/esm/input/GamepadPromptLayouts.js +21 -20
  34. package/dist/esm/input/GamepadPromptLayouts.js.map +1 -1
  35. package/dist/esm/input/GenericDualAnalogGamepadMapping.d.ts +6 -3
  36. package/dist/esm/input/GenericDualAnalogGamepadMapping.js +55 -46
  37. package/dist/esm/input/GenericDualAnalogGamepadMapping.js.map +1 -1
  38. package/dist/esm/input/InputBinding.d.ts +74 -0
  39. package/dist/esm/input/InputBinding.js +100 -0
  40. package/dist/esm/input/InputBinding.js.map +1 -0
  41. package/dist/esm/input/InputManager.d.ts +79 -33
  42. package/dist/esm/input/InputManager.js +229 -104
  43. package/dist/esm/input/InputManager.js.map +1 -1
  44. package/dist/esm/input/InteractionManager.d.ts +1 -1
  45. package/dist/esm/input/InteractionManager.js +13 -13
  46. package/dist/esm/input/InteractionManager.js.map +1 -1
  47. package/dist/esm/input/JoyConLeftGamepadMapping.d.ts +14 -9
  48. package/dist/esm/input/JoyConLeftGamepadMapping.js +39 -9
  49. package/dist/esm/input/JoyConLeftGamepadMapping.js.map +1 -1
  50. package/dist/esm/input/JoyConRightGamepadMapping.d.ts +14 -9
  51. package/dist/esm/input/JoyConRightGamepadMapping.js +35 -9
  52. package/dist/esm/input/JoyConRightGamepadMapping.js.map +1 -1
  53. package/dist/esm/input/Pointer.d.ts +84 -71
  54. package/dist/esm/input/Pointer.js +71 -71
  55. package/dist/esm/input/Pointer.js.map +1 -1
  56. package/dist/esm/input/SteamDeckGamepadMapping.d.ts +18 -0
  57. package/dist/esm/input/SteamDeckGamepadMapping.js +76 -0
  58. package/dist/esm/input/SteamDeckGamepadMapping.js.map +1 -0
  59. package/dist/esm/input/index.d.ts +7 -4
  60. package/dist/esm/input/types.d.ts +0 -76
  61. package/dist/esm/input/types.js +1 -80
  62. package/dist/esm/input/types.js.map +1 -1
  63. package/dist/esm/particles/ParticleSystem.d.ts +180 -83
  64. package/dist/esm/particles/ParticleSystem.js +446 -133
  65. package/dist/esm/particles/ParticleSystem.js.map +1 -1
  66. package/dist/esm/particles/distributions/BoxArea.d.ts +17 -0
  67. package/dist/esm/particles/distributions/BoxArea.js +48 -0
  68. package/dist/esm/particles/distributions/BoxArea.js.map +1 -0
  69. package/dist/esm/particles/distributions/CircleArea.d.ts +19 -0
  70. package/dist/esm/particles/distributions/CircleArea.js +33 -0
  71. package/dist/esm/particles/distributions/CircleArea.js.map +1 -0
  72. package/dist/esm/particles/distributions/ConeDirection.d.ts +28 -0
  73. package/dist/esm/particles/distributions/ConeDirection.js +44 -0
  74. package/dist/esm/particles/distributions/ConeDirection.js.map +1 -0
  75. package/dist/esm/particles/distributions/Constant.d.ts +17 -0
  76. package/dist/esm/particles/distributions/Constant.js +35 -0
  77. package/dist/esm/particles/distributions/Constant.js.map +1 -0
  78. package/dist/esm/particles/distributions/Curve.d.ts +30 -0
  79. package/dist/esm/particles/distributions/Curve.js +53 -0
  80. package/dist/esm/particles/distributions/Curve.js.map +1 -0
  81. package/dist/esm/particles/distributions/Distribution.d.ts +45 -0
  82. package/dist/esm/particles/distributions/Gradient.d.ts +40 -0
  83. package/dist/esm/particles/distributions/Gradient.js +72 -0
  84. package/dist/esm/particles/distributions/Gradient.js.map +1 -0
  85. package/dist/esm/particles/distributions/LineSegment.d.ts +15 -0
  86. package/dist/esm/particles/distributions/LineSegment.js +27 -0
  87. package/dist/esm/particles/distributions/LineSegment.js.map +1 -0
  88. package/dist/esm/particles/distributions/Range.d.ts +12 -0
  89. package/dist/esm/particles/distributions/Range.js +19 -0
  90. package/dist/esm/particles/distributions/Range.js.map +1 -0
  91. package/dist/esm/particles/distributions/VectorRange.d.ts +20 -0
  92. package/dist/esm/particles/distributions/VectorRange.js +31 -0
  93. package/dist/esm/particles/distributions/VectorRange.js.map +1 -0
  94. package/dist/esm/particles/distributions/index.d.ts +12 -0
  95. package/dist/esm/particles/gpu/ParticleGpuState.d.ts +57 -0
  96. package/dist/esm/particles/gpu/ParticleGpuState.js +508 -0
  97. package/dist/esm/particles/gpu/ParticleGpuState.js.map +1 -0
  98. package/dist/esm/particles/index.d.ts +2 -10
  99. package/dist/esm/particles/modules/AlphaFadeOverLifetime.d.ts +24 -0
  100. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js +60 -0
  101. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js.map +1 -0
  102. package/dist/esm/particles/modules/ApplyForce.d.ts +20 -0
  103. package/dist/esm/particles/modules/ApplyForce.js +48 -0
  104. package/dist/esm/particles/modules/ApplyForce.js.map +1 -0
  105. package/dist/esm/particles/modules/AttractToPoint.d.ts +27 -0
  106. package/dist/esm/particles/modules/AttractToPoint.js +73 -0
  107. package/dist/esm/particles/modules/AttractToPoint.js.map +1 -0
  108. package/dist/esm/particles/modules/BurstSpawn.d.ts +53 -0
  109. package/dist/esm/particles/modules/BurstSpawn.js +94 -0
  110. package/dist/esm/particles/modules/BurstSpawn.js.map +1 -0
  111. package/dist/esm/particles/modules/ColorOverLifetime.d.ts +22 -0
  112. package/dist/esm/particles/modules/ColorOverLifetime.js +65 -0
  113. package/dist/esm/particles/modules/ColorOverLifetime.js.map +1 -0
  114. package/dist/esm/particles/modules/ColorOverSpeed.d.ts +27 -0
  115. package/dist/esm/particles/modules/ColorOverSpeed.js +86 -0
  116. package/dist/esm/particles/modules/ColorOverSpeed.js.map +1 -0
  117. package/dist/esm/particles/modules/DeathModule.d.ts +24 -0
  118. package/dist/esm/particles/modules/DeathModule.js +25 -0
  119. package/dist/esm/particles/modules/DeathModule.js.map +1 -0
  120. package/dist/esm/particles/modules/Drag.d.ts +20 -0
  121. package/dist/esm/particles/modules/Drag.js +45 -0
  122. package/dist/esm/particles/modules/Drag.js.map +1 -0
  123. package/dist/esm/particles/modules/OrbitalForce.d.ts +28 -0
  124. package/dist/esm/particles/modules/OrbitalForce.js +65 -0
  125. package/dist/esm/particles/modules/OrbitalForce.js.map +1 -0
  126. package/dist/esm/particles/modules/RateSpawn.d.ts +41 -0
  127. package/dist/esm/particles/modules/RateSpawn.js +76 -0
  128. package/dist/esm/particles/modules/RateSpawn.js.map +1 -0
  129. package/dist/esm/particles/modules/RepelFromPoint.d.ts +24 -0
  130. package/dist/esm/particles/modules/RepelFromPoint.js +76 -0
  131. package/dist/esm/particles/modules/RepelFromPoint.js.map +1 -0
  132. package/dist/esm/particles/modules/RotateOverLifetime.d.ts +20 -0
  133. package/dist/esm/particles/modules/RotateOverLifetime.js +43 -0
  134. package/dist/esm/particles/modules/RotateOverLifetime.js.map +1 -0
  135. package/dist/esm/particles/modules/ScaleOverLifetime.d.ts +26 -0
  136. package/dist/esm/particles/modules/ScaleOverLifetime.js +59 -0
  137. package/dist/esm/particles/modules/ScaleOverLifetime.js.map +1 -0
  138. package/dist/esm/particles/modules/SpawnModule.d.ts +30 -0
  139. package/dist/esm/particles/modules/SpawnModule.js +31 -0
  140. package/dist/esm/particles/modules/SpawnModule.js.map +1 -0
  141. package/dist/esm/particles/modules/SpawnOnDeath.d.ts +24 -0
  142. package/dist/esm/particles/modules/SpawnOnDeath.js +47 -0
  143. package/dist/esm/particles/modules/SpawnOnDeath.js.map +1 -0
  144. package/dist/esm/particles/modules/Turbulence.d.ts +30 -0
  145. package/dist/esm/particles/modules/Turbulence.js +122 -0
  146. package/dist/esm/particles/modules/Turbulence.js.map +1 -0
  147. package/dist/esm/particles/modules/UpdateModule.d.ts +95 -0
  148. package/dist/esm/particles/modules/UpdateModule.js +66 -0
  149. package/dist/esm/particles/modules/UpdateModule.js.map +1 -0
  150. package/dist/esm/particles/modules/VelocityOverLifetime.d.ts +30 -0
  151. package/dist/esm/particles/modules/VelocityOverLifetime.js +84 -0
  152. package/dist/esm/particles/modules/VelocityOverLifetime.js.map +1 -0
  153. package/dist/esm/particles/modules/WgslContribution.d.ts +81 -0
  154. package/dist/esm/particles/modules/WgslContribution.js +34 -0
  155. package/dist/esm/particles/modules/WgslContribution.js.map +1 -0
  156. package/dist/esm/particles/modules/index.d.ts +22 -0
  157. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.d.ts +9 -14
  158. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js +90 -61
  159. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js.map +1 -1
  160. package/dist/esm/rendering/webgl2/glsl/particle.vert.js +1 -1
  161. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.d.ts +9 -0
  162. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +107 -23
  163. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
  164. package/dist/esm/rendering/webgpu/compute/WebGpuComputePipeline.d.ts +52 -0
  165. package/dist/esm/rendering/webgpu/compute/WebGpuStorageBuffer.d.ts +29 -0
  166. package/dist/esm/rendering/webgpu/compute/index.d.ts +3 -0
  167. package/dist/esm/resources/CacheFirstStrategy.d.ts +7 -4
  168. package/dist/esm/resources/CacheFirstStrategy.js +11 -8
  169. package/dist/esm/resources/CacheFirstStrategy.js.map +1 -1
  170. package/dist/esm/resources/CacheStrategy.d.ts +14 -6
  171. package/dist/esm/resources/Loader.d.ts +8 -3
  172. package/dist/esm/resources/Loader.js +19 -37
  173. package/dist/esm/resources/Loader.js.map +1 -1
  174. package/dist/esm/resources/NetworkOnlyStrategy.d.ts +3 -0
  175. package/dist/esm/resources/NetworkOnlyStrategy.js +8 -3
  176. package/dist/esm/resources/NetworkOnlyStrategy.js.map +1 -1
  177. package/dist/esm/resources/factories/ImageFactory.d.ts +2 -2
  178. package/dist/esm/resources/factories/ImageFactory.js.map +1 -1
  179. package/dist/esm/resources/factories/TextureFactory.d.ts +2 -2
  180. package/dist/esm/resources/factories/TextureFactory.js.map +1 -1
  181. package/dist/esm/resources/factories/VttFactory.d.ts +3 -3
  182. package/dist/esm/resources/factories/VttFactory.js +83 -6
  183. package/dist/esm/resources/factories/VttFactory.js.map +1 -1
  184. package/dist/exo.esm.js +4028 -1518
  185. package/dist/exo.esm.js.map +1 -1
  186. package/package.json +2 -1
  187. package/dist/esm/input/GamepadChannels.d.ts +0 -47
  188. package/dist/esm/input/GamepadChannels.js +0 -53
  189. package/dist/esm/input/GamepadChannels.js.map +0 -1
  190. package/dist/esm/input/GamepadControl.d.ts +0 -33
  191. package/dist/esm/input/GamepadControl.js +0 -42
  192. package/dist/esm/input/GamepadControl.js.map +0 -1
  193. package/dist/esm/input/Input.d.ts +0 -52
  194. package/dist/esm/input/Input.js +0 -90
  195. package/dist/esm/input/Input.js.map +0 -1
  196. package/dist/esm/particles/Particle.d.ts +0 -77
  197. package/dist/esm/particles/Particle.js +0 -143
  198. package/dist/esm/particles/Particle.js.map +0 -1
  199. package/dist/esm/particles/ParticleProperties.d.ts +0 -29
  200. package/dist/esm/particles/affectors/ColorAffector.d.ts +0 -30
  201. package/dist/esm/particles/affectors/ColorAffector.js +0 -55
  202. package/dist/esm/particles/affectors/ColorAffector.js.map +0 -1
  203. package/dist/esm/particles/affectors/ForceAffector.d.ts +0 -24
  204. package/dist/esm/particles/affectors/ForceAffector.js +0 -39
  205. package/dist/esm/particles/affectors/ForceAffector.js.map +0 -1
  206. package/dist/esm/particles/affectors/ParticleAffector.d.ts +0 -19
  207. package/dist/esm/particles/affectors/ScaleAffector.d.ts +0 -23
  208. package/dist/esm/particles/affectors/ScaleAffector.js +0 -38
  209. package/dist/esm/particles/affectors/ScaleAffector.js.map +0 -1
  210. package/dist/esm/particles/affectors/TorqueAffector.d.ts +0 -23
  211. package/dist/esm/particles/affectors/TorqueAffector.js +0 -37
  212. package/dist/esm/particles/affectors/TorqueAffector.js.map +0 -1
  213. package/dist/esm/particles/emitters/ParticleEmitter.d.ts +0 -19
  214. package/dist/esm/particles/emitters/ParticleOptions.d.ts +0 -62
  215. package/dist/esm/particles/emitters/ParticleOptions.js +0 -120
  216. package/dist/esm/particles/emitters/ParticleOptions.js.map +0 -1
  217. package/dist/esm/particles/emitters/UniversalEmitter.d.ts +0 -40
  218. package/dist/esm/particles/emitters/UniversalEmitter.js +0 -68
  219. package/dist/esm/particles/emitters/UniversalEmitter.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,743 @@ All notable changes to ExoJS are documented in this file.
4
4
 
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.8.0] - 2026-05-07
8
+
9
+ Wholesale rewrite of the particle subsystem around a data-oriented core
10
+ plus a backend-agnostic auto-routing pipeline. The `Particle` class,
11
+ `ParticleAffector` interface, `ParticleEmitter` interface,
12
+ `ParticleOptions`, `UniversalEmitter`, and the four built-in affectors
13
+ (`ColorAffector`, `ForceAffector`, `ScaleAffector`, `TorqueAffector`)
14
+ are removed. They are replaced by SoA storage on the system,
15
+ `Distribution<T>`-based spawn configs, and per-batch
16
+ `SpawnModule` / `UpdateModule` / `DeathModule` interfaces.
17
+
18
+ Update modules now declare an optional `wgsl()` contribution — when
19
+ the system is constructed with a `WebGpuBackend` and every registered
20
+ update module is GPU-eligible (i.e. all built-ins, plus any custom
21
+ modules the author opts in), a composite WGSL compute shader is built
22
+ at first `update()`. Integration + every module body + pack-instances
23
+ all run in **one dispatch**, writing directly into the renderer's
24
+ instance vertex buffer. **No CPU readback** in the steady state.
25
+
26
+ On WebGL2 backends, or when any registered update module lacks
27
+ `wgsl()`, the system runs the existing CPU pipeline. The decision is
28
+ automatic and per-system; user code is unchanged across both paths.
29
+
30
+ ### Added — Struct-of-Arrays storage
31
+
32
+ `ParticleSystem` now stores particles as parallel `Float32Array` /
33
+ `Uint32Array` / `Uint16Array` channels addressed by slot index:
34
+
35
+ ```ts
36
+ system.posX[slot]
37
+ system.posY[slot]
38
+ system.velX[slot]
39
+ system.velY[slot]
40
+ system.scaleX[slot]
41
+ system.scaleY[slot]
42
+ system.rotations[slot]
43
+ system.rotationSpeeds[slot]
44
+ system.color[slot] // packed 0xAABBGGRR
45
+ system.elapsed[slot]
46
+ system.lifetime[slot]
47
+ system.textureIndex[slot]
48
+ system.liveCount // [0, liveCount) is the live range
49
+ ```
50
+
51
+ Capacity is fixed at construction (default 4096) — no reallocations.
52
+ The integrate pass runs as one tight loop over typed arrays with no
53
+ method calls. Expiry is handled by forward-compaction (O(n) total
54
+ instead of the previous O(n²) splice loop with scattered expirations).
55
+
56
+ ### Added — `Distribution<T>` family
57
+
58
+ Spawn-time random sampling and lifetime-parameterised evaluation:
59
+
60
+ | Type | Use |
61
+ |---|---|
62
+ | `Constant<T>` | Always-same value |
63
+ | `Range` | Uniform random number in `[min, max]` |
64
+ | `VectorRange` | Per-axis random vector |
65
+ | `ConeDirection` | Random unit vector in a cone × speed range |
66
+ | `CircleArea` | Random point in/on a circle |
67
+ | `BoxArea` | Random point in/on an AABB |
68
+ | `LineSegment` | Random point on a segment |
69
+ | `Curve` | Piecewise-linear keyframe scalar by lifetime ratio |
70
+ | `Gradient` | Piecewise-linear keyframe color, with `evaluateRgba()` for direct u32 packing |
71
+
72
+ `Curve` and `Gradient` cache the last segment so monotonically
73
+ advancing `t` (the typical case for per-particle lifetime sampling)
74
+ is O(1) amortised.
75
+
76
+ ### Added — Module pipeline
77
+
78
+ Three module bases. Each registered on a system via the corresponding
79
+ `addX` method; each runs in its declared phase per-frame.
80
+
81
+ ```ts
82
+ abstract class SpawnModule { apply(system, dt: number): void; }
83
+ abstract class UpdateModule { apply(system, dt: number): void; }
84
+ abstract class DeathModule { onDeath(system, slot: number): void; }
85
+ ```
86
+
87
+ **Built-in spawn modules:**
88
+
89
+ - `RateSpawn({ rate, lifetime?, position?, velocity?, scale?, rotation?, rotationSpeed?, tint?, textureIndex? })`
90
+ — continuous emission with sub-frame accumulator. Each property is an
91
+ independent `Distribution<T>`.
92
+ - `BurstSpawn({ schedule, loop?, ...samePropsAsRate })` — discrete
93
+ bursts at scheduled times. Use for explosions, level-ups,
94
+ hit-impacts.
95
+
96
+ **Built-in update modules** (operate on the SoA arrays in tight loops):
97
+
98
+ - `ApplyForce(ax, ay)` — adds constant acceleration.
99
+ - `Drag(coefficient)` — exponential velocity damping.
100
+ - `ColorOverLifetime(gradient)` — tint sampled from a `Gradient`.
101
+ - `ScaleOverLifetime(curve)` — both axes sampled from a `Curve`.
102
+ - `RotateOverLifetime(angularAccel)` — increments `rotationSpeed`.
103
+
104
+ **Built-in death module:**
105
+
106
+ - `SpawnOnDeath(targetSystem, spawner, count?)` — sub-emitter. Forwards
107
+ the dying particle's position to a target system's spawn module.
108
+ Use for explosion-on-impact, end-of-life sparks, multi-stage VFX.
109
+
110
+ ### Added — Backend-agnostic auto-routing GPU compute pipeline
111
+
112
+ New `src/rendering/webgpu/compute/` infrastructure:
113
+
114
+ - `WebGpuStorageBuffer` — owning wrapper over a `STORAGE | COPY_DST | COPY_SRC`
115
+ buffer with `write()` and async `read()` helpers.
116
+ - `WebGpuComputePipeline` — `device.createComputePipeline` wrapper with
117
+ bind-group-layout creation, dispatch helper.
118
+
119
+ New `src/particles/gpu/ParticleGpuState` — owns the GPU-side mirror
120
+ for one `ParticleSystem`. At construction time it:
121
+
122
+ 1. Walks the registered update modules, collecting each module's
123
+ `WgslContribution` (uniform field declarations + texture bindings
124
+ + WGSL body snippet).
125
+ 2. Generates a composite WGSL compute shader: SoA storage bindings +
126
+ sim/module uniform structs + module texture bindings + a `main`
127
+ function containing integration → all module bodies in registration
128
+ order → pack-instances writing interleaved 24-byte instances into
129
+ a `STORAGE | VERTEX` buffer.
130
+ 3. Allocates 7 packed storage buffers (positions/velocities/scales/
131
+ rotInfo/timing as `vec2<f32>` arrays plus color as `u32` plus the
132
+ instance output) — fits within WebGPU's default
133
+ `maxStorageBuffersPerShaderStage = 8` limit.
134
+ 4. Allocates 1D textures for any module that declares them
135
+ (`Curve` → 256-tap r32float; `Gradient` → 256-tap rgba8unorm) and
136
+ uploads the lookup data once via `module.uploadTextures()`.
137
+ 5. Each module's `writeUniforms()` runs every frame to update its
138
+ slice of the shared module-uniform buffer.
139
+
140
+ The `WebGpuParticleRenderer` reads the GPU-written instance buffer
141
+ directly when `system.gpuMode` is true; CPU mode falls back to the
142
+ existing CPU-pack path. Same renderer, same vertex layout, no copy
143
+ between simulation and render.
144
+
145
+ `UpdateModule` gains optional `wgsl()`, `writeUniforms()`,
146
+ `uploadTextures()`. Built-in modules ship all three. Custom modules
147
+ that implement them get GPU acceleration; modules with only `apply()`
148
+ keep working but force their host system into CPU mode.
149
+
150
+ Opt-in is a single constructor option — no imperative toggle:
151
+
152
+ ```ts
153
+ const system = new ParticleSystem(texture, {
154
+ capacity: 8192,
155
+ backend: app.backend, // CPU-routed on WebGL2, GPU-routed on WebGPU
156
+ });
157
+ ```
158
+
159
+ The `backend` reference is duck-typed against `WebGpuBackend`; on
160
+ WebGL2 it's recorded but never used. The system's mode is locked in
161
+ at the first `update()` (when modules are introspected); adding update
162
+ modules after that throws.
163
+
164
+ ### Removed — Old particle API (BREAKING)
165
+
166
+ The following symbols are deleted. Migration recipes follow the table.
167
+
168
+ | Removed | Replacement |
169
+ |---|---|
170
+ | `Particle` (class) | SoA arrays on `ParticleSystem` (`system.posX[slot]`, etc.) |
171
+ | `ParticleProperties` (interface) | None — slot-indexed arrays replace the per-particle object |
172
+ | `ParticleEmitter` (interface) | `SpawnModule` (abstract class) |
173
+ | `ParticleOptions` | Per-property `Distribution<T>` in the spawn module's config |
174
+ | `UniversalEmitter` | `RateSpawn` |
175
+ | `ParticleAffector` (interface) | `UpdateModule` (abstract class) |
176
+ | `ColorAffector` | `ColorOverLifetime` + `Gradient` |
177
+ | `ForceAffector` | `ApplyForce` |
178
+ | `ScaleAffector` | `ScaleOverLifetime` + `Curve` |
179
+ | `TorqueAffector` | `RotateOverLifetime` |
180
+ | `system.requestParticle()` | `system.spawn(): number` (slot index, or `-1` at capacity) |
181
+ | `system.emitParticle(p)` | (gone — `spawn()` already commits the slot to the live range) |
182
+ | `system.updateParticle(p, dt)` | (gone — internal to `update()`) |
183
+ | `system.addEmitter(e)` | `system.addSpawnModule(m)` |
184
+ | `system.addAffector(a)` | `system.addUpdateModule(m)` |
185
+ | `system.particles` (`Array<Particle>`) | `system.posX` / `system.posY` / ... `system.liveCount` |
186
+ | `system.graveyard` | (gone — no graveyard; slots are recycled in place) |
187
+
188
+ ### Migration
189
+
190
+ ```ts
191
+ // Before — bonfire
192
+ const options = new ParticleOptions();
193
+ const colorAffector = new ColorAffector(new Color(194, 64, 30, 1), new Color(0, 0, 0, 0));
194
+ const emitter = new UniversalEmitter(50, options);
195
+ const system = new ParticleSystem(texture);
196
+ system.addAffector(colorAffector);
197
+ system.addEmitter(emitter);
198
+
199
+ // in update():
200
+ options.totalLifetime.copy(seconds(rand(5, 10)));
201
+ options.position.set(rand(-50, 50), rand(-10, 10));
202
+ options.velocity.set(/* ... */);
203
+
204
+ // After — bonfire
205
+ const system = new ParticleSystem(texture);
206
+ system.addSpawnModule(new RateSpawn({
207
+ rate: new Constant(50),
208
+ lifetime: new Range(5, 10),
209
+ position: new VectorRange(-50, 50, -10, 10),
210
+ velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80),
211
+ }));
212
+ system.addUpdateModule(new ColorOverLifetime(new Gradient([
213
+ { t: 0, color: new Color(194, 64, 30, 1) },
214
+ { t: 1, color: new Color(0, 0, 0, 0) },
215
+ ])));
216
+ // no per-frame mutation needed.
217
+ ```
218
+
219
+ ```ts
220
+ // Before — gravity affector
221
+ const gravity = new ForceAffector(0, 980);
222
+ system.addAffector(gravity);
223
+
224
+ // After
225
+ system.addUpdateModule(new ApplyForce(0, 980));
226
+ ```
227
+
228
+ ```ts
229
+ // Before — custom affector
230
+ class AlphaFade {
231
+ apply(particle, delta) {
232
+ particle.tint.a = particle.remainingRatio;
233
+ return this;
234
+ }
235
+ destroy() {}
236
+ }
237
+
238
+ // After
239
+ class AlphaFadeOverLifetime extends UpdateModule {
240
+ apply(system) {
241
+ const { color, elapsed, lifetime, liveCount } = system;
242
+ for (let i = 0; i < liveCount; i++) {
243
+ const remaining = 1 - elapsed[i] / lifetime[i];
244
+ const a = (Math.max(0, Math.min(1, remaining)) * 255) & 255;
245
+ color[i] = (color[i] & 0x00ffffff) | (a << 24);
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ ```ts
252
+ // Before — direct particle creation in tests
253
+ const particle = system.requestParticle();
254
+ particle.position.set(10, 12);
255
+ particle.tint = Color.red;
256
+ system.emitParticle(particle);
257
+
258
+ // After — direct slot manipulation
259
+ const slot = system.spawn();
260
+ system.posX[slot] = 10;
261
+ system.posY[slot] = 12;
262
+ system.color[slot] = Color.red.toRgba();
263
+ system.lifetime[slot] = 1;
264
+ system.scaleX[slot] = 1;
265
+ system.scaleY[slot] = 1;
266
+ ```
267
+
268
+ ### Changed — `ParticleSystem` constructor: typed overloads (BREAKING)
269
+
270
+ Source material (texture / atlas frames / spritesheet) lives in
271
+ **positional arguments** — TypeScript overload signatures enforce mutual
272
+ exclusivity at compile time so you can't pass nonsense combinations like
273
+ texture-and-spritesheet-at-once. Capacity and the test-only `device`
274
+ escape hatch live in the trailing options object.
275
+
276
+ ```ts
277
+ // 0.7.x:
278
+ new ParticleSystem(texture);
279
+ new ParticleSystem(texture, 4096);
280
+
281
+ // 0.8.0:
282
+ new ParticleSystem(); // untextured (1×1 white), CPU/GPU auto-routed
283
+ new ParticleSystem(spark); // simple textured particles
284
+ new ParticleSystem(spark, { capacity: 8192 }); // explicit capacity
285
+ new ParticleSystem(atlas, [r0, r1, r2]); // multi-frame atlas
286
+ new ParticleSystem(atlas, frames, { capacity: 8192 }); // atlas + capacity
287
+ new ParticleSystem(sheet); // spritesheet shorthand
288
+ new ParticleSystem(sheet, { capacity: 4096 });
289
+ ```
290
+
291
+ The four overload signatures:
292
+
293
+ ```ts
294
+ constructor(options?: ParticleSystemOptions);
295
+ constructor(texture: Texture, options?: ParticleSystemOptions);
296
+ constructor(texture: Texture, frames: ReadonlyArray<Rectangle>, options?: ParticleSystemOptions);
297
+ constructor(spritesheet: Spritesheet, options?: ParticleSystemOptions);
298
+ ```
299
+
300
+ Compile-time errors for illegal combinations:
301
+
302
+ ```ts
303
+ new ParticleSystem(spark, sheet); // ✗ no overload matches
304
+ new ParticleSystem(sheet, frames); // ✗ frames only valid with Texture
305
+ new ParticleSystem({ frames }); // ✗ frames isn't an option
306
+ ```
307
+
308
+ **No `backend` option** — the renderer auto-discovers the active backend
309
+ on the first `render(backend)` call. WebGPU → GPU compute path, WebGL2 →
310
+ CPU path. Re-discovery on backend change (device-loss recovery).
311
+
312
+ ### Added — Optional texture + 1×1 white default
313
+
314
+ When `texture` is omitted, the system uses a lazily-allocated 1×1
315
+ opaque-white singleton. Particles render as solid color quads driven by
316
+ the per-particle `color` channel. Useful for tech-demo magic effects,
317
+ abstract VFX, performance benchmarks.
318
+
319
+ ### Added — Multi-frame atlas via `frames` / `spritesheet` options
320
+
321
+ `frames: ReadonlyArray<Rectangle>` declares per-particle frame
322
+ rectangles within the atlas texture. Each particle's `textureIndex[i]`
323
+ selects which frame to render — `RateSpawn` /
324
+ `BurstSpawn`'s `textureIndex: Distribution<number>` becomes the per-spawn
325
+ frame chooser:
326
+
327
+ ```ts
328
+ const system = new ParticleSystem({
329
+ texture: explosionAtlas,
330
+ frames: [
331
+ new Rectangle(0, 0, 32, 32), // index 0 — flame core
332
+ new Rectangle(32, 0, 32, 32), // index 1 — smoke ring
333
+ new Rectangle(64, 0, 32, 32), // index 2 — ember
334
+ ],
335
+ });
336
+
337
+ system.addSpawnModule(new BurstSpawn({
338
+ schedule: [{ time: 0, count: 60 }],
339
+ velocity: ConeDirection.omni(120, 280),
340
+ textureIndex: new Range(0, 2), // each spawn picks a random frame
341
+ }));
342
+ ```
343
+
344
+ `Spritesheet` integration via `spritesheet: sheet` extracts texture +
345
+ frames in insertion order — convenient for atlas authors who already
346
+ have a sheet from a TexturePacker / Aseprite export.
347
+
348
+ UV resolution happens once per particle per frame (CPU pack in CPU mode,
349
+ compute shader in GPU mode); the renderer reads pre-resolved UVs from
350
+ the instance buffer — no shader-side frame-array lookup overhead.
351
+
352
+ ### Changed — Per-instance vertex layout: 24 → 40 bytes
353
+
354
+ The renderer's per-instance buffer now carries `uvMin: vec2` and
355
+ `uvMax: vec2` alongside the existing translation/scale/rotation/color
356
+ fields. Lets a single batch render any mix of atlas frames per instance
357
+ without indirection through a uniform array. Net cost: +67% bandwidth
358
+ on the instance buffer (still trivial — ~10 MB/s at 60 fps with 16k
359
+ particles).
360
+
361
+ The previous design used a single `u_uvBounds` uniform that pinned
362
+ every particle in a system to the same frame; the new layout is what
363
+ makes per-particle atlas selection free.
364
+
365
+ The system pre-allocates all SoA arrays at construction. Spawn modules
366
+ that want to emit beyond capacity get `-1` from `spawn()` and should
367
+ bail cleanly (the built-ins do).
368
+
369
+ ### Changed — slot allocation differs between CPU and GPU mode
370
+
371
+ In CPU mode, `[0, liveCount)` is dense (forward-compaction at end of
372
+ update). `spawn()` always returns the next sequential slot.
373
+
374
+ In GPU mode, no compaction happens — readback would be required to
375
+ move slots whose authoritative position lives in GPU memory. Instead:
376
+ - Each particle has an `alive: Uint8Array` flag (1 = alive, 0 = dead).
377
+ - `spawn()` finds the first dead slot via a round-robin hint pointer
378
+ (amortised O(1), worst case O(capacity)).
379
+ - Expiry on CPU: `alive[i] = 0`, `lifetime[i] = -1` (sentinel).
380
+ - The compute shader skips dead slots (`timing[idx].y < 0.0` → write
381
+ zero-scale instance and return).
382
+
383
+ Custom modules iterating `[0, liveCount)` should check `system.alive[i]`
384
+ in GPU mode if they care about ignoring dead slots; mutating dead slot
385
+ data is harmless because the GPU shader skips them.
386
+
387
+ ### Added — `system.aliveCount`
388
+
389
+ Returns the actual count of alive particles (slots with `alive[i] === 1`).
390
+ In CPU mode this equals `liveCount`; in GPU mode it's `≤ liveCount`.
391
+ Use for fragmentation diagnostics or UI counters.
392
+
393
+ ### Performance notes
394
+
395
+ - Spawning + integrating + ColorOverLifetime/ScaleOverLifetime + drag
396
+ on 10k particles: previously ~5 ms CPU per frame; new SoA path on
397
+ CPU: ~0.5 ms (~10× speedup from eliminating per-particle object
398
+ indirection). New GPU path on WebGPU: ~0.05 ms (~100× speedup from
399
+ the previous OO baseline) — bound by the per-frame upload, not the
400
+ compute itself.
401
+ - The crossover where GPU beats CPU sits around 1-3 k particles
402
+ depending on hardware. For sub-1k systems CPU is still slightly
403
+ faster (upload overhead dominates); the auto-router doesn't second-
404
+ guess this — opt out via `backend: undefined` if you want to force
405
+ CPU at low counts.
406
+ - 100k+ particles render and simulate cleanly on WebGPU at 60 fps in
407
+ CI smoke tests; the bottleneck shifts from compute to texture
408
+ bandwidth at that scale.
409
+
410
+ ## [0.7.13] - 2026-05-07
411
+
412
+ Major gamepad-input refactor. Replaces the `new Input(...)` +
413
+ `inputManager.add(...)` pattern with a fluent listener API, splits the
414
+ unified `GamepadChannel` enum into disjoint `GamepadButton` /
415
+ `GamepadAxis` for type-safe button-vs-axis distinction, introduces
416
+ always-4 stable gamepad slots with disconnect-aware listeners, and adds
417
+ rumble, generic per-pad signals, slot-strategy configuration, aggregate
418
+ signed stick channels, and Joy-Con-honest mappings.
419
+
420
+ ### Added — Listener API
421
+
422
+ ```ts
423
+ // Per inputManager (manual unbind):
424
+ app.input.onTrigger(GamepadButton.South, () => player.jump());
425
+ app.input.onActive(GamepadAxis.LeftStickX, (v) => player.x += v * 5);
426
+ app.input.onStart([Keyboard.Space, GamepadButton.South], () => fire());
427
+
428
+ // Per gamepad (slot-aware, listener survives disconnect/reconnect):
429
+ const pad = app.input.getGamepad(0);
430
+ pad.onTrigger(GamepadButton.South, () => p1.jump());
431
+
432
+ // Per scene (auto-disposed on scene unload):
433
+ this.inputs.onTrigger(Keyboard.Escape, () => this.app.sceneManager.popScene());
434
+ ```
435
+
436
+ Each method returns an `InputBinding` with `.unbind()` for manual
437
+ lifecycle. Single channel or array of channels is accepted.
438
+
439
+ ### Added — Always-4 gamepad slots
440
+
441
+ `InputManager.gamepads` is now a fixed
442
+ `readonly [Gamepad, Gamepad, Gamepad, Gamepad]` tuple. Each `Gamepad`
443
+ instance lives for the application's lifetime; check `pad.connected` for
444
+ hardware presence. Listeners attached when a slot is empty automatically
445
+ activate when a pad connects to that slot — no rebinding required.
446
+
447
+ Convenience accessors on `app.input`:
448
+
449
+ - `getGamepad(slot)` — readable single-slot accessor (equivalent to
450
+ `gamepads[slot]`).
451
+ - `connectedGamepads: readonly Gamepad[]` — only the currently-attached
452
+ pads, in slot order.
453
+ - `connectedGamepadCount: number`
454
+ - `firstConnectedGamepad: Gamepad | null`
455
+ - `hasGamepad: boolean`
456
+
457
+ Per-pad: `pad.internalIndex` returns the browser's `Gamepad.index` for
458
+ the attached hardware (or `null` when disconnected). Low-level escape
459
+ hatch — prefer `pad.slot` for stable application-side identity.
460
+
461
+ ### Added — Slot strategy
462
+
463
+ `new Application({ gamepadSlotStrategy: 'sticky' | 'compact' })` —
464
+ default `'sticky'` (each pad keeps its slot through disconnects).
465
+ `'compact'` shifts higher-numbered pads down to fill gaps after a
466
+ disconnect (good for hot-seat couch coop where "the first N pads are
467
+ the N players" is the desired semantic).
468
+
469
+ In compact mode, the disconnect signal fires on the slot that *ended
470
+ up* empty after the shift (not the slot the disconnected hardware
471
+ originally occupied), keeping `pad.connected === false` consistent with
472
+ the fired event. Slots that received a different physical pad through
473
+ the shift dispatch a separate signal:
474
+
475
+ - `pad.onPadReassigned: Signal<[fromSlot: 0 | 1 | 2 | 3]>`
476
+ - `app.input.onAnyGamepadReassigned: Signal<[Gamepad, fromSlot]>`
477
+
478
+ so player-binding code can re-resolve which `Gamepad` belongs to which
479
+ player when slots renumber.
480
+
481
+ ### Added — Generic signals
482
+
483
+ Per-pad:
484
+ - `pad.onConnect: Signal<[]>`
485
+ - `pad.onDisconnect: Signal<[]>`
486
+ - `pad.onButtonDown: Signal<[GamepadButton, number]>`
487
+ - `pad.onButtonUp: Signal<[GamepadButton, number]>`
488
+ - `pad.onAxisChange: Signal<[GamepadAxis, number]>`
489
+
490
+ Aggregate across all pads:
491
+ - `inputManager.onAnyGamepadButtonDown: Signal<[Gamepad, GamepadButton, number]>`
492
+ - `inputManager.onAnyGamepadButtonUp: Signal<[Gamepad, GamepadButton, number]>`
493
+ - `inputManager.onAnyGamepadAxisChange: Signal<[Gamepad, GamepadAxis, number]>`
494
+
495
+ ### Added — Vibration
496
+
497
+ ```ts
498
+ if (pad.canVibrate) {
499
+ await pad.vibrate({ duration: 200, weakMagnitude: 0.5, strongMagnitude: 1.0 });
500
+ }
501
+ pad.stopVibration();
502
+ ```
503
+
504
+ Wraps the W3C `vibrationActuator.playEffect('dual-rumble')` API. Silent
505
+ no-op on platforms without haptic support — use `pad.canVibrate` to
506
+ detect availability for UI gating. Trigger-rumble (PS5 / Xbox Series
507
+ adaptive triggers) is not exposed because browser support is currently
508
+ Chrome-only and non-standard.
509
+
510
+ ### Added — Aggregate axis channels
511
+
512
+ `GamepadAxis.LeftStickX`, `LeftStickY`, `RightStickX`, `RightStickY` —
513
+ signed -1..1 values that consume the full bipolar range of the physical
514
+ stick. Use these for stick-style movement input; the existing
515
+ direction-split channels (`LeftStickLeft`, `LeftStickRight`, etc.)
516
+ remain available for buttons-style 0..1 input.
517
+
518
+ ```ts
519
+ // Stick-style — one binding per axis, signed value:
520
+ this.inputs.onActive(GamepadAxis.LeftStickX, (x) => player.x += x * 5);
521
+
522
+ // Buttons-style — separate bindings per direction, 0..1 each:
523
+ this.inputs.onActive(GamepadAxis.LeftStickLeft, (v) => player.x -= v * 5);
524
+ this.inputs.onActive(GamepadAxis.LeftStickRight, (v) => player.x += v * 5);
525
+ ```
526
+
527
+ ### Added — `pad.hasChannel(channel)` capability check
528
+
529
+ ```ts
530
+ if (pad.hasChannel(GamepadAxis.RightStickX)) {
531
+ pad.onActive(GamepadAxis.RightStickX, (v) => crosshair.x += v * 8);
532
+ }
533
+ ```
534
+
535
+ Returns `true` only when the pad's mapping declares the requested
536
+ channel. Useful for graceful degradation on devices with limited
537
+ hardware (e.g. single Joy-Con without a right stick).
538
+
539
+ ### Added — `Scene.inputs` proxy
540
+
541
+ Bindings created via `this.inputs.onTrigger(...)` etc. are automatically
542
+ disposed when the scene unloads. No manual cleanup tracking required.
543
+ Internally tracks each binding and calls `.unbind()` in `Scene.destroy`.
544
+
545
+ ### Added — Steam Deck / Steam Virtual Gamepad / Valve fallback
546
+
547
+ New `SteamDeckGamepadMapping` covers the raw HID layout reported by the
548
+ Steam Deck (and likely future Valve hardware) when Steam Input is *not*
549
+ intercepting the device. Indices follow the SDL_GameControllerDB Linux
550
+ entry: face buttons at 3-6, D-pad at 16-19, paddles at 20-23, triggers
551
+ as analog axes 8/9.
552
+
553
+ Routing rules added to `builtInGamepadDefinitions`:
554
+
555
+ | Browser ID | Mapping |
556
+ |---|---|
557
+ | `28de:1102`, `28de:1142` | `SteamControllerGamepadMapping` (existing, original Steam Controller raw) |
558
+ | `28de:11ff` (Steam Virtual Gamepad — any controller via Steam Input) | `GenericDualAnalogGamepadMapping` (W3C standard Xbox emulation) |
559
+ | `28de:1205` | `SteamDeckGamepadMapping` (raw Steam Deck) |
560
+ | Vendor `28de` (anything else from Valve, e.g. future Steam Controller 2 raw) | `SteamDeckGamepadMapping` (best-effort fallback) |
561
+
562
+ Enum: `GamepadMappingFamily.SteamDeck` added.
563
+
564
+ ### Added — Paddle2/3/4 buttons + Touchpad2X/Y axes
565
+
566
+ The per-gamepad channel allocation is repartitioned into 32 button
567
+ slots + 32 axis slots (was 21 / 22 with mid-block axis indices). 24
568
+ named buttons (`South`-`Paddle4`) plus 8 reserved slots; 24 named axes
569
+ (stick split + aggregate + dual-touchpad XY + 4 auxiliary bipolar) plus
570
+ 8 reserved slots. The reserved slots are accessible to custom mappings
571
+ without colliding with future named additions.
572
+
573
+ New named channels:
574
+
575
+ - `GamepadButton.Paddle2`, `.Paddle3`, `.Paddle4` — extra paddles
576
+ / back buttons on Xbox Elite, PS5 Edge, Steam Deck (R4/L5/R5).
577
+ - `GamepadAxis.Touchpad2X`, `.Touchpad2Y` — secondary touchpad on
578
+ dual-touchpad hardware (Steam Deck right pad).
579
+
580
+ User code that previously read `GamepadButton.Paddle1` etc. is
581
+ unaffected — channel **values** changed (offsets re-laid-out), but the
582
+ namespace constants resolve to the new offsets transparently.
583
+
584
+ ### Added — JoyCon-honest mappings
585
+
586
+ `JoyConLeftGamepadMapping` and `JoyConRightGamepadMapping` no longer
587
+ inherit the full DualAnalog 16-axis layout. Each declares only channels
588
+ that physically exist on the device (one stick mapped to LeftStick
589
+ channels, four face buttons, SL/SR shoulders, Minus/Plus, Capture/Home,
590
+ stick-click). Right-stick channels and other phantom hardware are
591
+ intentionally absent — `pad.hasChannel(GamepadAxis.RightStickX)` returns
592
+ `false` on a solo Joy-Con.
593
+
594
+ ### Changed — `app.inputManager` renamed to `app.input` (BREAKING)
595
+
596
+ For consistency with `app.audio` and parity with the brevity of
597
+ `app.tweens` / `app.loader` / `app.interaction`. All call sites that
598
+ read or wrote `app.inputManager` need a one-token rename.
599
+
600
+ ```ts
601
+ // Before:
602
+ app.inputManager.onTrigger(GamepadButton.South, () => fire());
603
+ app.inputManager.gamepads[0];
604
+
605
+ // After:
606
+ app.input.onTrigger(GamepadButton.South, () => fire());
607
+ app.input.getGamepad(0);
608
+ ```
609
+
610
+ ### Fixed — Compact-mode disconnect ordering
611
+
612
+ In `'compact'` slot strategy, `onDisconnect` previously fired on the
613
+ slot the disconnected hardware originally occupied — *before* the
614
+ compaction shift moved a different physical pad into that slot. User
615
+ code observing the event would see `pad.connected === true` because
616
+ the slot had been silently re-bound by the shift. Now compaction is
617
+ applied first (silent), and `onDisconnect` fires on the slot that
618
+ ended up empty (the trailing slot). Sticky behaviour is unchanged.
619
+
620
+ ### Changed — Channel naming (BREAKING)
621
+
622
+ The unified `GamepadChannel` enum is split into two disjoint enums for
623
+ nominal type safety:
624
+
625
+ | Old | New (user-facing) | New (internal type) |
626
+ |---|---|---|
627
+ | `GamepadChannel.ButtonSouth` | `GamepadButton.South` | `GamepadButtonChannel.South` |
628
+ | `GamepadChannel.ButtonEast` | `GamepadButton.East` | `GamepadButtonChannel.East` |
629
+ | `GamepadChannel.LeftShoulder` | `GamepadButton.LeftShoulder` | `GamepadButtonChannel.LeftShoulder` |
630
+ | `GamepadChannel.LeftStickLeft` | `GamepadAxis.LeftStickLeft` | `GamepadAxisChannel.LeftStickLeft` |
631
+ | ... | ... | ... |
632
+
633
+ User code references the namespace mirrors (`GamepadButton.X`,
634
+ `GamepadAxis.Y`) — same `Pointer.X` / `Keyboard.Space` convention. Type
635
+ checking now rejects passing a button channel where an axis is expected
636
+ (and vice versa).
637
+
638
+ ### Changed — `GamepadControl` removed (BREAKING)
639
+
640
+ `GamepadControl` is replaced by two concrete classes:
641
+
642
+ - `GamepadButton` — wraps a button index + channel, with optional
643
+ `invert` and `threshold` options. `transformValue(v)` clamps to [0, 1].
644
+ - `GamepadAxis` — wraps an axis index + channel, with optional `invert`,
645
+ `normalize`, `threshold`, and the new `bipolar` flag.
646
+ `transformValue(v)` clamps to [-1, +1] and applies the pipeline.
647
+
648
+ Custom mappings construct these directly via `new GamepadButton(index, channel)`
649
+ / `new GamepadAxis(index, channel, options)` —
650
+ `GamepadMapping.createControls()` is removed.
651
+
652
+ ### Changed — `Input` class replaced by `InputBinding` (BREAKING)
653
+
654
+ `new Input(channel, { onTrigger: cb })` + `inputManager.add(input)` is
655
+ gone. Use `inputManager.onTrigger(channel, cb)` / `pad.onTrigger(...)` /
656
+ `scene.inputs.onTrigger(...)` instead. Returned `InputBinding` exposes
657
+ the same `onStart`/`onActive`/`onStop`/`onTrigger` Signals plus a
658
+ `.unbind()` method.
659
+
660
+ ### Changed — `inputManager.add/remove/clear/getGamepad/onGamepadUpdated` removed (BREAKING)
661
+
662
+ The push-input-objects-into-the-manager API is fully replaced by the
663
+ factory-method API. `getGamepad(index)` is replaced by direct
664
+ `gamepads[slot]` indexing. `onGamepadUpdated` is replaced by
665
+ `onAnyGamepadButtonDown` / `onAnyGamepadButtonUp` /
666
+ `onAnyGamepadAxisChange` which carry semantic transition information
667
+ instead of firing every frame.
668
+
669
+ ### Changed — `Gamepad` constructor signature (BREAKING)
670
+
671
+ ```ts
672
+ // Before:
673
+ new Gamepad(index, channels, mapping)
674
+ new Gamepad(browserGamepad, channels, definition)
675
+
676
+ // After (engine-internal — InputManager handles slot allocation):
677
+ new Gamepad(slot, channels)
678
+ // followed by pad._bind(browserGamepad, definition) on connect
679
+ ```
680
+
681
+ User code does not construct `Gamepad` instances directly. Reads from
682
+ `pad.info` / `pad.mapping` / `pad.connected` instead of the previous
683
+ `pad.name` / `pad.label` / `pad.vendorId` / etc. inline accessors.
684
+
685
+ ### Migration guide
686
+
687
+ ```ts
688
+ // Before:
689
+ import { Input, GamepadChannel, Keyboard } from '@codexo/exojs';
690
+
691
+ const jump = new Input(GamepadChannel.ButtonSouth, { onTrigger: () => player.jump() });
692
+ app.input.add(jump);
693
+
694
+ // After (any of three styles, depending on lifecycle):
695
+ import { GamepadButton, Keyboard } from '@codexo/exojs';
696
+
697
+ // Manual lifecycle
698
+ const binding = app.input.onTrigger(GamepadButton.South, () => player.jump());
699
+ binding.unbind(); // when done
700
+
701
+ // Auto-disposed on scene unload
702
+ this.inputs.onTrigger(GamepadButton.South, () => player.jump());
703
+
704
+ // Pinned to a specific pad slot
705
+ this.app.input.gamepads[0].onTrigger(GamepadButton.South, () => player.jump());
706
+ ```
707
+
708
+ ```ts
709
+ // Stick movement — before:
710
+ const moveLeft = new Input(GamepadChannel.LeftStickLeft);
711
+ const moveRight = new Input(GamepadChannel.LeftStickRight);
712
+ app.input.add(moveLeft);
713
+ app.input.add(moveRight);
714
+ // per frame: const x = moveRight.value - moveLeft.value;
715
+
716
+ // After (signed aggregate channel):
717
+ this.inputs.onActive(GamepadAxis.LeftStickX, (x) => player.x += x * 5);
718
+ ```
719
+
720
+ ```ts
721
+ // Custom mapping — before:
722
+ import { GamepadMapping, GamepadChannel } from '@codexo/exojs';
723
+ const buttons = GamepadMapping.createControls([
724
+ [0, GamepadChannel.ButtonSouth],
725
+ [1, GamepadChannel.ButtonEast],
726
+ ]);
727
+
728
+ // After:
729
+ import { GamepadButton, GamepadMapping, GamepadMappingFamily } from '@codexo/exojs';
730
+ class MyMapping extends GamepadMapping {
731
+ public readonly family = GamepadMappingFamily.GenericDualAnalog;
732
+ public constructor() {
733
+ super(
734
+ [
735
+ new GamepadButton(0, GamepadButton.South),
736
+ new GamepadButton(1, GamepadButton.East),
737
+ ],
738
+ [],
739
+ );
740
+ }
741
+ }
742
+ ```
743
+
7
744
  ## [0.7.12] - 2026-05-07
8
745
 
9
746
  API audit cleanup pass — implements collision-response computation that was