@jgengine/core 0.14.0 → 0.15.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 (214) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/audio/musicState.d.ts +47 -0
  12. package/dist/audio/musicState.js +38 -0
  13. package/dist/audio/stepCadence.d.ts +47 -0
  14. package/dist/audio/stepCadence.js +29 -0
  15. package/dist/audio/synth.d.ts +12 -0
  16. package/dist/authoring.d.ts +6 -4
  17. package/dist/authoring.js +6 -4
  18. package/dist/combat/castRunner.d.ts +13 -1
  19. package/dist/combat/castRunner.js +5 -1
  20. package/dist/combat/comboMeter.d.ts +140 -0
  21. package/dist/combat/comboMeter.js +215 -0
  22. package/dist/combat/comboPoints.d.ts +11 -0
  23. package/dist/combat/comboPoints.js +11 -2
  24. package/dist/combat/damageResolution.d.ts +1 -1
  25. package/dist/combat/defensiveWindow.d.ts +5 -0
  26. package/dist/combat/effects.d.ts +24 -0
  27. package/dist/combat/effects.js +6 -1
  28. package/dist/combat/projectiles.d.ts +6 -0
  29. package/dist/combat/projectiles.js +2 -2
  30. package/dist/combat/regenShield.d.ts +40 -2
  31. package/dist/combat/regenShield.js +66 -18
  32. package/dist/combat/resourcePool.d.ts +19 -0
  33. package/dist/combat/resourcePool.js +20 -2
  34. package/dist/combat/shotOrigin.d.ts +5 -0
  35. package/dist/combat/statusApplication.d.ts +1 -1
  36. package/dist/combat/statusApplication.js +2 -1
  37. package/dist/combat/statusEffectView.d.ts +56 -0
  38. package/dist/combat/statusEffectView.js +52 -0
  39. package/dist/combat.d.ts +4 -2
  40. package/dist/combat.js +2 -0
  41. package/dist/devtools/fallbackSeams.d.ts +3 -2
  42. package/dist/devtools/fallbackSeams.js +1 -1
  43. package/dist/devtools/textureErrors.d.ts +40 -0
  44. package/dist/devtools/textureErrors.js +68 -0
  45. package/dist/economy/shopStock.d.ts +110 -0
  46. package/dist/economy/shopStock.js +157 -0
  47. package/dist/economy/wallet.d.ts +6 -0
  48. package/dist/economy/wallet.js +1 -0
  49. package/dist/game/achievements.d.ts +80 -0
  50. package/dist/game/achievements.js +124 -0
  51. package/dist/game/cameraConfig.d.ts +7 -0
  52. package/dist/game/codex.d.ts +60 -0
  53. package/dist/game/codex.js +89 -0
  54. package/dist/game/credits.d.ts +36 -0
  55. package/dist/game/credits.js +0 -0
  56. package/dist/game/defineGame.d.ts +28 -3
  57. package/dist/game/defineGame.js +5 -0
  58. package/dist/game/dialogueGraph.d.ts +116 -0
  59. package/dist/game/dialogueGraph.js +103 -0
  60. package/dist/game/eventTicker.d.ts +95 -0
  61. package/dist/game/eventTicker.js +91 -0
  62. package/dist/game/events.d.ts +24 -5
  63. package/dist/game/events.js +18 -0
  64. package/dist/game/gamePhase.d.ts +1 -1
  65. package/dist/game/gamePhase.js +1 -1
  66. package/dist/game/leaderboardRank.d.ts +71 -0
  67. package/dist/game/leaderboardRank.js +76 -0
  68. package/dist/game/lootTable.d.ts +10 -1
  69. package/dist/game/lootTable.js +4 -2
  70. package/dist/game/notifications.d.ts +57 -0
  71. package/dist/game/notifications.js +107 -0
  72. package/dist/game/playableGame.d.ts +39 -2
  73. package/dist/game/quest.d.ts +26 -0
  74. package/dist/game/quest.js +31 -0
  75. package/dist/game/runDraft.js +2 -1
  76. package/dist/game/saveSlots.d.ts +102 -0
  77. package/dist/game/saveSlots.js +136 -0
  78. package/dist/game/talentTreeView.d.ts +106 -0
  79. package/dist/game/talentTreeView.js +114 -0
  80. package/dist/game/worldItem.js +2 -1
  81. package/dist/gameplay.d.ts +15 -4
  82. package/dist/gameplay.js +13 -2
  83. package/dist/i18n/i18n.d.ts +51 -0
  84. package/dist/i18n/i18n.js +82 -0
  85. package/dist/input/axisInput.d.ts +1 -1
  86. package/dist/input/axisInput.js +18 -2
  87. package/dist/input/rebindSession.d.ts +141 -0
  88. package/dist/input/rebindSession.js +235 -0
  89. package/dist/input/touchControlsMode.d.ts +16 -0
  90. package/dist/input/touchControlsMode.js +23 -0
  91. package/dist/input/touchScheme.d.ts +30 -1
  92. package/dist/input/touchScheme.js +31 -1
  93. package/dist/interaction/promptRegistry.d.ts +78 -0
  94. package/dist/interaction/promptRegistry.js +130 -0
  95. package/dist/inventory/storageTier.js +3 -2
  96. package/dist/meta/changelog.d.ts +1 -1
  97. package/dist/meta/changelog.js +145 -1
  98. package/dist/movement/movementModel.d.ts +26 -3
  99. package/dist/movement/movementModel.js +121 -15
  100. package/dist/movement/playerMovement.js +161 -25
  101. package/dist/physics/drivableVehicle.js +3 -1
  102. package/dist/physics/kinematicVehicle.d.ts +56 -1
  103. package/dist/physics/kinematicVehicle.js +99 -10
  104. package/dist/physics/vehicleObstacles.js +19 -0
  105. package/dist/random/resolveRng.d.ts +14 -0
  106. package/dist/random/resolveRng.js +31 -0
  107. package/dist/render/postProcessing.d.ts +12 -1
  108. package/dist/runtime/cameraDirector.d.ts +2 -2
  109. package/dist/runtime/context/combat.js +8 -0
  110. package/dist/runtime/gameContext.js +6 -1
  111. package/dist/runtime/gameContextTypes.d.ts +18 -0
  112. package/dist/runtime/inputSnapshot.d.ts +10 -1
  113. package/dist/runtime/inputSnapshot.js +13 -1
  114. package/dist/runtime/runtimeSave.d.ts +3 -3
  115. package/dist/runtime/runtimeSave.js +11 -2
  116. package/dist/scene/assetDiagnostics.js +2 -2
  117. package/dist/scene/autoTarget.js +2 -1
  118. package/dist/scene/captureCheck.js +3 -2
  119. package/dist/scene/entityStore.d.ts +4 -1
  120. package/dist/scene/entityStore.js +3 -0
  121. package/dist/scene/objectStore.d.ts +10 -0
  122. package/dist/scene/objectStore.js +29 -6
  123. package/dist/scene/sceneKinds.d.ts +18 -0
  124. package/dist/scene/sceneKinds.js +19 -0
  125. package/dist/scene/sequenceDirector.d.ts +110 -0
  126. package/dist/scene/sequenceDirector.js +146 -0
  127. package/dist/session/roles.js +3 -2
  128. package/dist/settings/settingsModel.d.ts +1 -0
  129. package/dist/settings/settingsModel.js +1 -0
  130. package/dist/stats/rollCheck.js +4 -2
  131. package/dist/time/dayNightCycle.d.ts +114 -0
  132. package/dist/time/dayNightCycle.js +208 -0
  133. package/dist/time/timerSet.d.ts +132 -0
  134. package/dist/time/timerSet.js +243 -0
  135. package/dist/ui/accessibility.d.ts +51 -0
  136. package/dist/ui/accessibility.js +83 -0
  137. package/dist/ui/coachMarks.d.ts +99 -0
  138. package/dist/ui/coachMarks.js +126 -0
  139. package/dist/ui/floatingText.d.ts +114 -0
  140. package/dist/ui/floatingText.js +203 -0
  141. package/dist/ui/modalStack.d.ts +143 -0
  142. package/dist/ui/modalStack.js +160 -0
  143. package/dist/ui/objectiveBanner.d.ts +123 -0
  144. package/dist/ui/objectiveBanner.js +162 -0
  145. package/dist/ui/photoMode.d.ts +32 -0
  146. package/dist/ui/photoMode.js +51 -0
  147. package/dist/ui/radialMenu.d.ts +47 -0
  148. package/dist/ui/radialMenu.js +74 -0
  149. package/dist/ui/screenMarkers.d.ts +128 -0
  150. package/dist/ui/screenMarkers.js +118 -0
  151. package/dist/ui.d.ts +8 -0
  152. package/dist/ui.js +8 -0
  153. package/dist/vfx/cameraShake.d.ts +97 -0
  154. package/dist/vfx/cameraShake.js +136 -0
  155. package/dist/vfx/damageDirection.d.ts +102 -0
  156. package/dist/vfx/damageDirection.js +188 -0
  157. package/dist/vfx/particles.d.ts +103 -0
  158. package/dist/vfx/particles.js +256 -0
  159. package/dist/vfx/screenEffects.d.ts +149 -0
  160. package/dist/vfx/screenEffects.js +191 -0
  161. package/dist/world/authoredObjects.d.ts +18 -0
  162. package/dist/world/authoredObjects.js +21 -0
  163. package/dist/world/authoredSpawn.js +19 -0
  164. package/dist/world/buildingLots.d.ts +54 -11
  165. package/dist/world/buildingLots.js +116 -30
  166. package/dist/world/cityBlocks.d.ts +45 -0
  167. package/dist/world/cityBlocks.js +159 -0
  168. package/dist/world/cityContent.d.ts +46 -2
  169. package/dist/world/cityContent.js +160 -2
  170. package/dist/world/cityGenerator.d.ts +261 -23
  171. package/dist/world/cityGenerator.js +643 -33
  172. package/dist/world/cityGeometry.d.ts +20 -0
  173. package/dist/world/cityGeometry.js +91 -0
  174. package/dist/world/cityKind.js +1 -44
  175. package/dist/world/fastTravel.d.ts +64 -0
  176. package/dist/world/fastTravel.js +108 -0
  177. package/dist/world/features.d.ts +16 -0
  178. package/dist/world/features.js +2 -0
  179. package/dist/world/grounded.d.ts +11 -0
  180. package/dist/world/grounded.js +12 -0
  181. package/dist/world/mapAnnotations.d.ts +75 -0
  182. package/dist/world/mapAnnotations.js +139 -0
  183. package/dist/world/markers.js +1 -0
  184. package/dist/world/resourceNode.d.ts +1 -1
  185. package/dist/world/resourceNode.js +2 -1
  186. package/dist/world/roads.d.ts +294 -5
  187. package/dist/world/roads.js +1262 -21
  188. package/dist/world/sharedAnnotations.d.ts +84 -0
  189. package/dist/world/sharedAnnotations.js +76 -0
  190. package/dist/world/spawnOverride.d.ts +37 -0
  191. package/dist/world/spawnOverride.js +50 -0
  192. package/dist/world/streetGenerator.d.ts +60 -11
  193. package/dist/world/streetGenerator.js +1786 -129
  194. package/dist/world/terrain.d.ts +1 -0
  195. package/dist/world/terrain.js +2 -0
  196. package/dist/world/waypoints.d.ts +63 -0
  197. package/dist/world/waypoints.js +137 -0
  198. package/dist/world.d.ts +17 -2
  199. package/dist/world.js +17 -2
  200. package/package.json +1 -1
  201. package/skills/ce-handoff/SKILL.md +149 -0
  202. package/skills/game-design/SKILL.md +2 -0
  203. package/skills/jgengine/SKILL.md +3 -1
  204. package/skills/jgengine/api.md +17 -7
  205. package/skills/jgengine/capabilities.md +12 -0
  206. package/skills/jgengine/recipes/minimal-game.md +7 -4
  207. package/skills/jgengine-combat/api.md +40 -4
  208. package/skills/jgengine-combat/capabilities.md +12 -0
  209. package/skills/jgengine-combat/reference.md +2 -0
  210. package/skills/jgengine-gameplay/api.md +212 -11
  211. package/skills/jgengine-gameplay/capabilities.md +66 -1
  212. package/skills/jgengine-world/api.md +397 -11
  213. package/skills/jgengine-world/capabilities.md +123 -2
  214. package/skills/jgengine-world/reference.md +15 -1
@@ -0,0 +1,102 @@
1
+ /**
2
+ * A single incoming hit to register on a {@link DamageDirectionTracker}. The
3
+ * angle is relative to the player's facing (renderer-agnostic): `0` points at the
4
+ * front/top of the reticle and increases clockwise, so a game passes the bearing
5
+ * from the player toward the attacker without knowing anything about the screen.
6
+ */
7
+ export interface HitInput {
8
+ /** Direction the damage came from, in radians. `0` = front/up, increasing clockwise. */
9
+ angle: number;
10
+ /** Peak strength `0..1` driving the arc's opacity/scale. Clamped. Default `1`. */
11
+ intensity?: number;
12
+ /** Free-form game style tag (e.g. `"melee"`, `"fire"`, `"crit"`). Never interpreted by the model — a renderer maps it to color. Default `"default"`. */
13
+ kind?: string;
14
+ }
15
+ /**
16
+ * A live directional indicator with its eased, time-decayed strength, produced by
17
+ * {@link DamageDirectionTracker.active}. The `intensity` here is the *current*
18
+ * eased value (peak faded over the elapsed lifetime), ready to drive opacity/scale.
19
+ */
20
+ export interface DamageIndicator {
21
+ /** Direction the damage came from, in radians (`0` = front/up, clockwise). */
22
+ angle: number;
23
+ /** Style tag carried from the originating {@link HitInput}. */
24
+ kind: string;
25
+ /** Current eased strength `0..1` — peak intensity decayed over its lifetime. */
26
+ intensity: number;
27
+ /** Seconds elapsed since the hit was registered, on the injected clock. */
28
+ age: number;
29
+ }
30
+ /** Serializable state for save/restore — the clock reading plus every live indicator. */
31
+ export interface DamageDirectionSnapshot {
32
+ /** Clock reading (ms) when the snapshot was taken, so ages restore correctly. */
33
+ now: number;
34
+ /** Live indicators, each with its peak intensity, birth time, and fade duration. */
35
+ indicators: readonly {
36
+ angle: number;
37
+ kind: string;
38
+ intensity: number;
39
+ bornAt: number;
40
+ duration: number;
41
+ }[];
42
+ }
43
+ /** Options for {@link createDamageDirectionTracker}. */
44
+ export interface DamageDirectionOptions {
45
+ /** Injected clock (ms). Default `Date.now`. */
46
+ now?: () => number;
47
+ /** How long an indicator takes to fade out, in ms. Default `1200`. */
48
+ duration?: number;
49
+ /** Hard cap on simultaneous indicators; the pool never grows past this. Default `32`. */
50
+ max?: number;
51
+ /**
52
+ * If `> 0`, a new hit whose angle is within this many radians of a live
53
+ * indicator with the same `kind` refreshes and strengthens that indicator
54
+ * instead of adding a new one — so a burst from one direction reads as one
55
+ * strong arc. Default `0` (every hit is its own indicator).
56
+ */
57
+ mergeWindow?: number;
58
+ /**
59
+ * Fade curve mapping remaining-life fraction `1..0` to strength `1..0`.
60
+ * Default ease-out (`1 - (1 - x) ** 2`) — holds bright, then drops off.
61
+ */
62
+ easing?: (remaining: number) => number;
63
+ }
64
+ /**
65
+ * A serializable, allocation-aware brain that tracks where recent damage came
66
+ * from. Each registered hit becomes a directional indicator that fades over a
67
+ * fixed duration on the injected clock; {@link DamageDirectionTracker.active}
68
+ * reports the live ones with their eased current strength.
69
+ */
70
+ export interface DamageDirectionTracker {
71
+ /** Register an incoming hit and return the indicator's current view. */
72
+ registerHit(hit: HitInput): DamageIndicator;
73
+ /**
74
+ * The live indicators with eased current strength, sorted strongest-first.
75
+ * The returned array and its entries are reused between calls (no per-call
76
+ * allocation) — read or copy them before the next `active`/`registerHit`.
77
+ */
78
+ active(): readonly DamageIndicator[];
79
+ /** Number of live (not-yet-faded) indicators. */
80
+ count(): number;
81
+ /** Drop every indicator immediately. */
82
+ clear(): void;
83
+ /** Observe changes (hit registered, cleared, restored). Returns an unsubscribe fn. */
84
+ subscribe(listener: () => void): () => void;
85
+ /** Serializable state for a save. */
86
+ snapshot(): DamageDirectionSnapshot;
87
+ /** Restore from a {@link DamageDirectionSnapshot}. */
88
+ restore(snapshot: DamageDirectionSnapshot): void;
89
+ }
90
+ /**
91
+ * Create a damage-direction tracker: the classic "hit-from" feedback brain. A
92
+ * game calls `registerHit({ angle, intensity, kind })` with the bearing from the
93
+ * player toward the attacker (radians, `0` = front) and the tracker owns the fade
94
+ * timers and eased strength so the renderer just draws an arc per `active()`
95
+ * entry. It is renderer-free and genre-agnostic (the `kind` tag is never
96
+ * interpreted here), allocation-aware (a fixed pool, no per-frame garbage), and
97
+ * fully serializable via `snapshot`/`restore`. Optional angle merging collapses a
98
+ * burst from one direction into a single strong arc.
99
+ *
100
+ * @capability damage-direction serializable hit-from damage-direction indicator brain — pooled directional arcs that flare on `registerHit(angle, intensity)` and fade on an injected clock, with optional same-direction merging
101
+ */
102
+ export declare function createDamageDirectionTracker(options?: DamageDirectionOptions): DamageDirectionTracker;
@@ -0,0 +1,188 @@
1
+ const TAU = Math.PI * 2;
2
+ /** Shortest angular distance (magnitude) between two angles, in `0..π`. */
3
+ function angularDistance(a, b) {
4
+ let d = (a - b) % TAU;
5
+ if (d < -Math.PI)
6
+ d += TAU;
7
+ else if (d > Math.PI)
8
+ d -= TAU;
9
+ return Math.abs(d);
10
+ }
11
+ function clamp01(v) {
12
+ return v < 0 ? 0 : v > 1 ? 1 : v;
13
+ }
14
+ /**
15
+ * Create a damage-direction tracker: the classic "hit-from" feedback brain. A
16
+ * game calls `registerHit({ angle, intensity, kind })` with the bearing from the
17
+ * player toward the attacker (radians, `0` = front) and the tracker owns the fade
18
+ * timers and eased strength so the renderer just draws an arc per `active()`
19
+ * entry. It is renderer-free and genre-agnostic (the `kind` tag is never
20
+ * interpreted here), allocation-aware (a fixed pool, no per-frame garbage), and
21
+ * fully serializable via `snapshot`/`restore`. Optional angle merging collapses a
22
+ * burst from one direction into a single strong arc.
23
+ *
24
+ * @capability damage-direction serializable hit-from damage-direction indicator brain — pooled directional arcs that flare on `registerHit(angle, intensity)` and fade on an injected clock, with optional same-direction merging
25
+ */
26
+ export function createDamageDirectionTracker(options = {}) {
27
+ const now = options.now ?? Date.now;
28
+ const defaultDuration = Math.max(1, options.duration ?? 1200);
29
+ const max = Math.max(1, Math.floor(options.max ?? 32));
30
+ const mergeWindow = Math.max(0, options.mergeWindow ?? 0);
31
+ const easing = options.easing ?? ((x) => 1 - (1 - x) * (1 - x));
32
+ // Fixed pool — slots are reused, never re-allocated.
33
+ const slots = [];
34
+ // Reused output views + array, so `active()` allocates nothing on the hot path.
35
+ const views = [];
36
+ const out = [];
37
+ for (let i = 0; i < max; i += 1) {
38
+ slots.push({ active: false, angle: 0, kind: "default", intensity: 0, bornAt: 0, duration: 0 });
39
+ views.push({ angle: 0, kind: "default", intensity: 0, age: 0 });
40
+ }
41
+ const listeners = new Set();
42
+ function notify() {
43
+ for (const listener of listeners)
44
+ listener();
45
+ }
46
+ /** Current eased strength of a slot at time `t` (ms), or `0` once fully faded. */
47
+ function strengthAt(slot, t) {
48
+ const elapsed = t - slot.bornAt;
49
+ if (elapsed <= 0)
50
+ return slot.intensity;
51
+ if (elapsed >= slot.duration)
52
+ return 0;
53
+ return slot.intensity * easing(1 - elapsed / slot.duration);
54
+ }
55
+ /** Find a reusable slot: a dead one, else the weakest live one to evict. */
56
+ function acquireSlot(t) {
57
+ let weakest = null;
58
+ let weakestStrength = Infinity;
59
+ for (const slot of slots) {
60
+ if (!slot.active)
61
+ return slot;
62
+ const s = strengthAt(slot, t);
63
+ if (s <= 0)
64
+ return slot;
65
+ if (s < weakestStrength) {
66
+ weakestStrength = s;
67
+ weakest = slot;
68
+ }
69
+ }
70
+ return weakest ?? slots[0];
71
+ }
72
+ function writeView(index, slot, t) {
73
+ const view = views[index];
74
+ view.angle = slot.angle;
75
+ view.kind = slot.kind;
76
+ view.intensity = strengthAt(slot, t);
77
+ view.age = Math.max(0, (t - slot.bornAt) / 1000);
78
+ return view;
79
+ }
80
+ return {
81
+ registerHit(hit) {
82
+ const t = now();
83
+ const angle = hit.angle;
84
+ const kind = hit.kind ?? "default";
85
+ const intensity = clamp01(hit.intensity ?? 1);
86
+ let slot = null;
87
+ if (mergeWindow > 0) {
88
+ // Refresh a live, same-kind indicator from a nearby direction.
89
+ for (const candidate of slots) {
90
+ if (!candidate.active || candidate.kind !== kind)
91
+ continue;
92
+ if (strengthAt(candidate, t) <= 0)
93
+ continue;
94
+ if (angularDistance(candidate.angle, angle) <= mergeWindow) {
95
+ slot = candidate;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ const merged = slot !== null;
101
+ if (slot === null)
102
+ slot = acquireSlot(t);
103
+ // A merged refresh keeps the stronger peak so a burst reads bright.
104
+ const peak = merged ? Math.max(intensity, slot.intensity) : intensity;
105
+ slot.active = true;
106
+ slot.angle = angle;
107
+ slot.kind = kind;
108
+ slot.intensity = peak;
109
+ slot.bornAt = t;
110
+ slot.duration = defaultDuration;
111
+ notify();
112
+ return writeView(slots.indexOf(slot), slot, t);
113
+ },
114
+ active() {
115
+ const t = now();
116
+ out.length = 0;
117
+ for (let i = 0; i < slots.length; i += 1) {
118
+ const slot = slots[i];
119
+ if (!slot.active)
120
+ continue;
121
+ if (t - slot.bornAt >= slot.duration) {
122
+ slot.active = false;
123
+ continue;
124
+ }
125
+ out.push(writeView(i, slot, t));
126
+ }
127
+ out.sort((a, b) => b.intensity - a.intensity);
128
+ return out;
129
+ },
130
+ count() {
131
+ const t = now();
132
+ let n = 0;
133
+ for (const slot of slots) {
134
+ if (!slot.active)
135
+ continue;
136
+ if (t - slot.bornAt < slot.duration)
137
+ n += 1;
138
+ else
139
+ slot.active = false;
140
+ }
141
+ return n;
142
+ },
143
+ clear() {
144
+ let changed = false;
145
+ for (const slot of slots) {
146
+ if (slot.active) {
147
+ slot.active = false;
148
+ changed = true;
149
+ }
150
+ }
151
+ if (changed)
152
+ notify();
153
+ },
154
+ subscribe(listener) {
155
+ listeners.add(listener);
156
+ return () => listeners.delete(listener);
157
+ },
158
+ snapshot() {
159
+ const t = now();
160
+ const indicators = slots
161
+ .filter((slot) => slot.active && t - slot.bornAt < slot.duration)
162
+ .map((slot) => ({
163
+ angle: slot.angle,
164
+ kind: slot.kind,
165
+ intensity: slot.intensity,
166
+ bornAt: slot.bornAt,
167
+ duration: slot.duration,
168
+ }));
169
+ return { now: t, indicators };
170
+ },
171
+ restore(snapshot) {
172
+ for (const slot of slots)
173
+ slot.active = false;
174
+ const limit = Math.min(slots.length, snapshot.indicators.length);
175
+ for (let i = 0; i < limit; i += 1) {
176
+ const src = snapshot.indicators[i];
177
+ const slot = slots[i];
178
+ slot.active = true;
179
+ slot.angle = src.angle;
180
+ slot.kind = src.kind;
181
+ slot.intensity = clamp01(src.intensity);
182
+ slot.bornAt = src.bornAt;
183
+ slot.duration = Math.max(1, src.duration);
184
+ }
185
+ notify();
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,103 @@
1
+ /** A 3D vector `[x, y, z]`. */
2
+ export type Vec3 = readonly [number, number, number];
3
+ /** A `[min, max]` range a spawned particle draws uniformly from. */
4
+ export interface Range {
5
+ min: number;
6
+ max: number;
7
+ }
8
+ /** A per-life start→end curve (linear interpolation from birth to death). */
9
+ export interface Curve {
10
+ start: number;
11
+ end: number;
12
+ }
13
+ /**
14
+ * A particle emitter: how particles spawn and how each one evolves over its life.
15
+ * Every field is data — no functions — so an emitter is fully serializable and an
16
+ * editor/tunable can drive it. Genre-agnostic: smoke, sparks, rain, magic, dust.
17
+ */
18
+ export interface EmitterConfig {
19
+ /** Hard cap on live particles; the pool never grows past this. Default 512. */
20
+ max?: number;
21
+ /** Continuous emission rate in particles/second. Default 0 (burst-only). */
22
+ rate?: number;
23
+ /** Spawn origin. Default `[0, 0, 0]`. */
24
+ position?: Vec3;
25
+ /** Half-extents of a box the spawn point is jittered within. Default `[0, 0, 0]`. */
26
+ spawnJitter?: Vec3;
27
+ /** Particle lifetime in seconds. Default `{ min: 1, max: 1 }`. */
28
+ lifetime?: Range;
29
+ /** Initial speed along the emit direction, in units/second. Default `{ min: 1, max: 1 }`. */
30
+ speed?: Range;
31
+ /** Base emit direction (need not be normalized). Default `[0, 1, 0]` (up). */
32
+ direction?: Vec3;
33
+ /** Cone half-angle in radians around `direction`; `Math.PI` emits in all directions. Default 0. */
34
+ spread?: number;
35
+ /** Constant acceleration (e.g. gravity `[0, -9.8, 0]`). Default `[0, 0, 0]`. */
36
+ gravity?: Vec3;
37
+ /** Linear velocity damping per second, `0` (none) to `1` (stop instantly). Default 0. */
38
+ drag?: number;
39
+ /** Particle radius over life. Default `{ start: 1, end: 1 }`. */
40
+ size?: Curve;
41
+ /** `0xRRGGBB` color at birth. Default `0xffffff`. */
42
+ colorStart?: number;
43
+ /** `0xRRGGBB` color at death. Default = `colorStart`. */
44
+ colorEnd?: number;
45
+ /** Opacity over life, each `0..1`. Default `{ start: 1, end: 0 }` (fade out). */
46
+ alpha?: Curve;
47
+ /** Injected clock — unused by the sim (it is dt-driven) but reserved; determinism comes from `seed`. */
48
+ seed?: string | number;
49
+ }
50
+ /**
51
+ * Read-only packed buffers of the live particles, laid out for a renderer to
52
+ * upload directly (Structure-of-Arrays, no per-particle objects). Only the first
53
+ * `count` entries are live; the arrays themselves are reused every frame.
54
+ */
55
+ export interface ParticleBuffers {
56
+ count: number;
57
+ /** `count * 3` floats: x, y, z per particle. */
58
+ positions: Float32Array;
59
+ /** `count` floats: current radius per particle. */
60
+ sizes: Float32Array;
61
+ /** `count * 3` floats: r, g, b in `0..1` per particle. */
62
+ colors: Float32Array;
63
+ /** `count` floats: opacity `0..1` per particle. */
64
+ alphas: Float32Array;
65
+ }
66
+ /** Serializable simulation state for save/restore and deterministic replay. */
67
+ export interface ParticleSnapshot {
68
+ seed: number;
69
+ accumulator: number;
70
+ count: number;
71
+ data: number[];
72
+ }
73
+ /** A live, dt-driven particle simulation. */
74
+ export interface ParticleSystem {
75
+ /** Advance the sim by `dt` seconds: emit from `rate`, integrate, and reap dead particles. */
76
+ update(dt: number): void;
77
+ /** Spawn `n` particles immediately (a one-shot puff/explosion), independent of `rate`. */
78
+ emit(n: number): void;
79
+ /** Live particle count. */
80
+ count(): number;
81
+ /** The packed buffers for rendering (valid until the next `update`/`emit`). */
82
+ buffers(): ParticleBuffers;
83
+ /** Kill all particles and reset the emission accumulator (keeps the seed stream). */
84
+ clear(): void;
85
+ /** Patch the emitter config in place (e.g. move the origin, change the rate). */
86
+ configure(patch: Partial<EmitterConfig>): void;
87
+ subscribe(listener: () => void): () => void;
88
+ snapshot(): ParticleSnapshot;
89
+ restore(snapshot: ParticleSnapshot): void;
90
+ }
91
+ /**
92
+ * A generic, allocation-aware particle system: one emitter, a fixed pool, and
93
+ * Structure-of-Arrays buffers a renderer uploads straight to the GPU. It is
94
+ * dt-driven (call `update(dt)` each frame) and deterministic — all randomness
95
+ * flows from an injected `seed`, so the same seed and dt sequence reproduce the
96
+ * same frames, and `snapshot`/`restore` round-trips the live pool. Nothing here
97
+ * is combat- or genre-specific: configure it for smoke, sparks, rain, dust,
98
+ * embers, magic, or confetti. Travel/gameplay stays elsewhere; this owns only
99
+ * the spawn-integrate-fade lifecycle.
100
+ *
101
+ * @capability particle-system deterministic pooled particle emitter with SoA render buffers, burst/continuous emission, gravity/drag, size/color/alpha-over-life, and serializable state
102
+ */
103
+ export declare function createParticleSystem(config?: EmitterConfig): ParticleSystem;
@@ -0,0 +1,256 @@
1
+ import { hashString, randomSeedFrom, stepRandomSeed } from "../random/rng.js";
2
+ function clamp01(v) {
3
+ return v < 0 ? 0 : v > 1 ? 1 : v;
4
+ }
5
+ /**
6
+ * A generic, allocation-aware particle system: one emitter, a fixed pool, and
7
+ * Structure-of-Arrays buffers a renderer uploads straight to the GPU. It is
8
+ * dt-driven (call `update(dt)` each frame) and deterministic — all randomness
9
+ * flows from an injected `seed`, so the same seed and dt sequence reproduce the
10
+ * same frames, and `snapshot`/`restore` round-trips the live pool. Nothing here
11
+ * is combat- or genre-specific: configure it for smoke, sparks, rain, dust,
12
+ * embers, magic, or confetti. Travel/gameplay stays elsewhere; this owns only
13
+ * the spawn-integrate-fade lifecycle.
14
+ *
15
+ * @capability particle-system deterministic pooled particle emitter with SoA render buffers, burst/continuous emission, gravity/drag, size/color/alpha-over-life, and serializable state
16
+ */
17
+ export function createParticleSystem(config = {}) {
18
+ const cfg = {
19
+ max: config.max ?? 512,
20
+ rate: config.rate ?? 0,
21
+ position: config.position ?? [0, 0, 0],
22
+ spawnJitter: config.spawnJitter ?? [0, 0, 0],
23
+ lifetime: config.lifetime ?? { min: 1, max: 1 },
24
+ speed: config.speed ?? { min: 1, max: 1 },
25
+ direction: config.direction ?? [0, 1, 0],
26
+ spread: config.spread ?? 0,
27
+ gravity: config.gravity ?? [0, 0, 0],
28
+ drag: config.drag ?? 0,
29
+ size: config.size ?? { start: 1, end: 1 },
30
+ colorStart: config.colorStart ?? 0xffffff,
31
+ colorEnd: config.colorEnd ?? config.colorStart ?? 0xffffff,
32
+ alpha: config.alpha ?? { start: 1, end: 0 },
33
+ };
34
+ const max = Math.max(1, Math.floor(cfg.max));
35
+ // SoA live pool.
36
+ const px = new Float32Array(max);
37
+ const py = new Float32Array(max);
38
+ const pz = new Float32Array(max);
39
+ const vx = new Float32Array(max);
40
+ const vy = new Float32Array(max);
41
+ const vz = new Float32Array(max);
42
+ const age = new Float32Array(max);
43
+ const life = new Float32Array(max);
44
+ // Per-particle baked endpoints (so mid-flight config changes don't recolor existing particles).
45
+ const sizeS = new Float32Array(max);
46
+ const sizeE = new Float32Array(max);
47
+ const r0 = new Float32Array(max);
48
+ const g0 = new Float32Array(max);
49
+ const b0 = new Float32Array(max);
50
+ const r1 = new Float32Array(max);
51
+ const g1 = new Float32Array(max);
52
+ const b1 = new Float32Array(max);
53
+ const a0 = new Float32Array(max);
54
+ const a1 = new Float32Array(max);
55
+ // Render buffers (rebuilt each frame from the live pool).
56
+ const outPos = new Float32Array(max * 3);
57
+ const outSize = new Float32Array(max);
58
+ const outColor = new Float32Array(max * 3);
59
+ const outAlpha = new Float32Array(max);
60
+ let live = 0;
61
+ let accumulator = 0;
62
+ let seed = randomSeedFrom(typeof config.seed === "number" ? config.seed : hashString(String(config.seed ?? "particles")));
63
+ const listeners = new Set();
64
+ function notify() {
65
+ for (const listener of listeners)
66
+ listener();
67
+ }
68
+ function rand() {
69
+ const [value, next] = stepRandomSeed(seed);
70
+ seed = next;
71
+ return value;
72
+ }
73
+ function range(r) {
74
+ return r.min + (r.max - r.min) * rand();
75
+ }
76
+ function unpackColor(rgb) {
77
+ return [((rgb >> 16) & 0xff) / 255, ((rgb >> 8) & 0xff) / 255, (rgb & 0xff) / 255];
78
+ }
79
+ function spawnOne() {
80
+ if (live >= max)
81
+ return;
82
+ const i = live++;
83
+ const [cr0, cg0, cb0] = unpackColor(cfg.colorStart);
84
+ const [cr1, cg1, cb1] = unpackColor(cfg.colorEnd);
85
+ px[i] = cfg.position[0] + (rand() * 2 - 1) * cfg.spawnJitter[0];
86
+ py[i] = cfg.position[1] + (rand() * 2 - 1) * cfg.spawnJitter[1];
87
+ pz[i] = cfg.position[2] + (rand() * 2 - 1) * cfg.spawnJitter[2];
88
+ // Direction: base dir perturbed within a cone of half-angle `spread`.
89
+ let dx = cfg.direction[0];
90
+ let dy = cfg.direction[1];
91
+ let dz = cfg.direction[2];
92
+ const dlen = Math.hypot(dx, dy, dz) || 1;
93
+ dx /= dlen;
94
+ dy /= dlen;
95
+ dz /= dlen;
96
+ if (cfg.spread > 0) {
97
+ // Sample a random unit vector, then blend toward the base direction by (1 - spread/π).
98
+ const theta = rand() * Math.PI * 2;
99
+ const z = rand() * 2 - 1;
100
+ const rxy = Math.sqrt(Math.max(0, 1 - z * z));
101
+ const sx = rxy * Math.cos(theta);
102
+ const sy = rxy * Math.sin(theta);
103
+ const sz = z;
104
+ const t = clamp01(cfg.spread / Math.PI);
105
+ dx = dx * (1 - t) + sx * t;
106
+ dy = dy * (1 - t) + sy * t;
107
+ dz = dz * (1 - t) + sz * t;
108
+ const n = Math.hypot(dx, dy, dz) || 1;
109
+ dx /= n;
110
+ dy /= n;
111
+ dz /= n;
112
+ }
113
+ const sp = range(cfg.speed);
114
+ vx[i] = dx * sp;
115
+ vy[i] = dy * sp;
116
+ vz[i] = dz * sp;
117
+ age[i] = 0;
118
+ life[i] = Math.max(0.0001, range(cfg.lifetime));
119
+ sizeS[i] = cfg.size.start;
120
+ sizeE[i] = cfg.size.end;
121
+ r0[i] = cr0;
122
+ g0[i] = cg0;
123
+ b0[i] = cb0;
124
+ r1[i] = cr1;
125
+ g1[i] = cg1;
126
+ b1[i] = cb1;
127
+ a0[i] = cfg.alpha.start;
128
+ a1[i] = cfg.alpha.end;
129
+ }
130
+ function swapRemove(i) {
131
+ const last = live - 1;
132
+ if (i !== last) {
133
+ px[i] = px[last];
134
+ py[i] = py[last];
135
+ pz[i] = pz[last];
136
+ vx[i] = vx[last];
137
+ vy[i] = vy[last];
138
+ vz[i] = vz[last];
139
+ age[i] = age[last];
140
+ life[i] = life[last];
141
+ sizeS[i] = sizeS[last];
142
+ sizeE[i] = sizeE[last];
143
+ r0[i] = r0[last];
144
+ g0[i] = g0[last];
145
+ b0[i] = b0[last];
146
+ r1[i] = r1[last];
147
+ g1[i] = g1[last];
148
+ b1[i] = b1[last];
149
+ a0[i] = a0[last];
150
+ a1[i] = a1[last];
151
+ }
152
+ live--;
153
+ }
154
+ const system = {
155
+ update(dt) {
156
+ if (dt <= 0)
157
+ return;
158
+ const damp = cfg.drag > 0 ? Math.max(0, 1 - cfg.drag * dt) : 1;
159
+ // Integrate + reap (iterate backwards because we swap-remove).
160
+ for (let i = live - 1; i >= 0; i--) {
161
+ age[i] += dt;
162
+ if (age[i] >= life[i]) {
163
+ swapRemove(i);
164
+ continue;
165
+ }
166
+ vx[i] = (vx[i] + cfg.gravity[0] * dt) * damp;
167
+ vy[i] = (vy[i] + cfg.gravity[1] * dt) * damp;
168
+ vz[i] = (vz[i] + cfg.gravity[2] * dt) * damp;
169
+ px[i] += vx[i] * dt;
170
+ py[i] += vy[i] * dt;
171
+ pz[i] += vz[i] * dt;
172
+ }
173
+ // Continuous emission.
174
+ if (cfg.rate > 0) {
175
+ accumulator += cfg.rate * dt;
176
+ while (accumulator >= 1) {
177
+ accumulator -= 1;
178
+ spawnOne();
179
+ }
180
+ }
181
+ notify();
182
+ },
183
+ emit(n) {
184
+ const wanted = Math.max(0, Math.floor(n));
185
+ for (let k = 0; k < wanted; k++)
186
+ spawnOne();
187
+ notify();
188
+ },
189
+ count() {
190
+ return live;
191
+ },
192
+ buffers() {
193
+ for (let i = 0; i < live; i++) {
194
+ const t = clamp01(age[i] / life[i]);
195
+ outPos[i * 3] = px[i];
196
+ outPos[i * 3 + 1] = py[i];
197
+ outPos[i * 3 + 2] = pz[i];
198
+ outSize[i] = sizeS[i] + (sizeE[i] - sizeS[i]) * t;
199
+ outColor[i * 3] = r0[i] + (r1[i] - r0[i]) * t;
200
+ outColor[i * 3 + 1] = g0[i] + (g1[i] - g0[i]) * t;
201
+ outColor[i * 3 + 2] = b0[i] + (b1[i] - b0[i]) * t;
202
+ outAlpha[i] = a0[i] + (a1[i] - a0[i]) * t;
203
+ }
204
+ return { count: live, positions: outPos, sizes: outSize, colors: outColor, alphas: outAlpha };
205
+ },
206
+ clear() {
207
+ live = 0;
208
+ accumulator = 0;
209
+ notify();
210
+ },
211
+ configure(patch) {
212
+ Object.assign(cfg, patch);
213
+ notify();
214
+ },
215
+ subscribe(listener) {
216
+ listeners.add(listener);
217
+ return () => listeners.delete(listener);
218
+ },
219
+ snapshot() {
220
+ const data = [];
221
+ for (let i = 0; i < live; i++) {
222
+ data.push(px[i], py[i], pz[i], vx[i], vy[i], vz[i], age[i], life[i], sizeS[i], sizeE[i], r0[i], g0[i], b0[i], r1[i], g1[i], b1[i], a0[i], a1[i]);
223
+ }
224
+ return { seed: seed, accumulator, count: live, data };
225
+ },
226
+ restore(snapshot) {
227
+ seed = randomSeedFrom(snapshot.seed);
228
+ accumulator = snapshot.accumulator;
229
+ live = Math.min(max, snapshot.count);
230
+ const stride = 18;
231
+ for (let i = 0; i < live; i++) {
232
+ const o = i * stride;
233
+ px[i] = snapshot.data[o];
234
+ py[i] = snapshot.data[o + 1];
235
+ pz[i] = snapshot.data[o + 2];
236
+ vx[i] = snapshot.data[o + 3];
237
+ vy[i] = snapshot.data[o + 4];
238
+ vz[i] = snapshot.data[o + 5];
239
+ age[i] = snapshot.data[o + 6];
240
+ life[i] = snapshot.data[o + 7];
241
+ sizeS[i] = snapshot.data[o + 8];
242
+ sizeE[i] = snapshot.data[o + 9];
243
+ r0[i] = snapshot.data[o + 10];
244
+ g0[i] = snapshot.data[o + 11];
245
+ b0[i] = snapshot.data[o + 12];
246
+ r1[i] = snapshot.data[o + 13];
247
+ g1[i] = snapshot.data[o + 14];
248
+ b1[i] = snapshot.data[o + 15];
249
+ a0[i] = snapshot.data[o + 16];
250
+ a1[i] = snapshot.data[o + 17];
251
+ }
252
+ notify();
253
+ },
254
+ };
255
+ return system;
256
+ }