@jgengine/core 0.13.0 → 0.15.0-next.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 (223) hide show
  1. package/CHANGELOG.md +529 -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/authoring.d.ts +6 -4
  12. package/dist/authoring.js +6 -4
  13. package/dist/combat/castRunner.d.ts +13 -1
  14. package/dist/combat/castRunner.js +5 -1
  15. package/dist/combat/comboMeter.d.ts +140 -0
  16. package/dist/combat/comboMeter.js +215 -0
  17. package/dist/combat/comboPoints.d.ts +11 -0
  18. package/dist/combat/comboPoints.js +11 -2
  19. package/dist/combat/damageResolution.d.ts +1 -1
  20. package/dist/combat/defensiveWindow.d.ts +5 -0
  21. package/dist/combat/effects.d.ts +24 -0
  22. package/dist/combat/effects.js +6 -1
  23. package/dist/combat/projectiles.d.ts +6 -0
  24. package/dist/combat/projectiles.js +2 -2
  25. package/dist/combat/regenShield.d.ts +40 -2
  26. package/dist/combat/regenShield.js +66 -18
  27. package/dist/combat/resourcePool.d.ts +19 -0
  28. package/dist/combat/resourcePool.js +20 -2
  29. package/dist/combat/shotOrigin.d.ts +5 -0
  30. package/dist/combat/statusApplication.d.ts +1 -1
  31. package/dist/combat/statusApplication.js +2 -1
  32. package/dist/combat/statusEffectView.d.ts +56 -0
  33. package/dist/combat/statusEffectView.js +52 -0
  34. package/dist/combat.d.ts +4 -2
  35. package/dist/combat.js +2 -0
  36. package/dist/devtools/fallbackSeams.d.ts +3 -2
  37. package/dist/devtools/fallbackSeams.js +1 -1
  38. package/dist/devtools/textureErrors.d.ts +40 -0
  39. package/dist/devtools/textureErrors.js +68 -0
  40. package/dist/economy/shopStock.d.ts +110 -0
  41. package/dist/economy/shopStock.js +157 -0
  42. package/dist/economy/wallet.d.ts +6 -0
  43. package/dist/economy/wallet.js +1 -0
  44. package/dist/game/achievements.d.ts +80 -0
  45. package/dist/game/achievements.js +124 -0
  46. package/dist/game/cameraConfig.d.ts +7 -0
  47. package/dist/game/codex.d.ts +60 -0
  48. package/dist/game/codex.js +89 -0
  49. package/dist/game/credits.d.ts +36 -0
  50. package/dist/game/credits.js +0 -0
  51. package/dist/game/defineGame.d.ts +28 -3
  52. package/dist/game/defineGame.js +5 -0
  53. package/dist/game/dialogueGraph.d.ts +116 -0
  54. package/dist/game/dialogueGraph.js +103 -0
  55. package/dist/game/eventTicker.d.ts +95 -0
  56. package/dist/game/eventTicker.js +91 -0
  57. package/dist/game/events.d.ts +24 -5
  58. package/dist/game/events.js +18 -0
  59. package/dist/game/gamePhase.d.ts +1 -1
  60. package/dist/game/gamePhase.js +1 -1
  61. package/dist/game/leaderboardRank.d.ts +71 -0
  62. package/dist/game/leaderboardRank.js +76 -0
  63. package/dist/game/lootTable.d.ts +10 -1
  64. package/dist/game/lootTable.js +4 -2
  65. package/dist/game/notifications.d.ts +57 -0
  66. package/dist/game/notifications.js +107 -0
  67. package/dist/game/playableGame.d.ts +39 -2
  68. package/dist/game/quest.d.ts +26 -0
  69. package/dist/game/quest.js +31 -0
  70. package/dist/game/runDraft.js +2 -1
  71. package/dist/game/saveSlots.d.ts +102 -0
  72. package/dist/game/saveSlots.js +136 -0
  73. package/dist/game/talentTreeView.d.ts +106 -0
  74. package/dist/game/talentTreeView.js +114 -0
  75. package/dist/game/worldItem.js +2 -1
  76. package/dist/gameplay.d.ts +15 -4
  77. package/dist/gameplay.js +13 -2
  78. package/dist/i18n/i18n.d.ts +51 -0
  79. package/dist/i18n/i18n.js +82 -0
  80. package/dist/input/axisInput.d.ts +1 -1
  81. package/dist/input/axisInput.js +18 -2
  82. package/dist/input/rebindSession.d.ts +141 -0
  83. package/dist/input/rebindSession.js +235 -0
  84. package/dist/input/touchControlsMode.d.ts +16 -0
  85. package/dist/input/touchControlsMode.js +23 -0
  86. package/dist/input/touchScheme.d.ts +30 -1
  87. package/dist/input/touchScheme.js +31 -1
  88. package/dist/interaction/promptRegistry.d.ts +78 -0
  89. package/dist/interaction/promptRegistry.js +130 -0
  90. package/dist/inventory/storageTier.js +3 -2
  91. package/dist/meta/changelog.d.ts +1 -1
  92. package/dist/meta/changelog.js +21 -1
  93. package/dist/movement/movementModel.d.ts +26 -3
  94. package/dist/movement/movementModel.js +121 -15
  95. package/dist/movement/playerMovement.js +161 -25
  96. package/dist/physics/drivableVehicle.js +3 -1
  97. package/dist/physics/kinematicVehicle.d.ts +56 -1
  98. package/dist/physics/kinematicVehicle.js +99 -10
  99. package/dist/physics/vehicleObstacles.js +19 -0
  100. package/dist/random/resolveRng.d.ts +14 -0
  101. package/dist/random/resolveRng.js +31 -0
  102. package/dist/render/postProcessing.d.ts +12 -1
  103. package/dist/runtime/cameraDirector.d.ts +2 -2
  104. package/dist/runtime/context/combat.js +8 -0
  105. package/dist/runtime/context/scene.d.ts +2 -0
  106. package/dist/runtime/context/scene.js +48 -1
  107. package/dist/runtime/gameContext.js +8 -2
  108. package/dist/runtime/gameContextTypes.d.ts +39 -0
  109. package/dist/runtime/inputSnapshot.d.ts +10 -1
  110. package/dist/runtime/inputSnapshot.js +13 -1
  111. package/dist/runtime/runtimeSave.d.ts +3 -3
  112. package/dist/runtime/runtimeSave.js +11 -2
  113. package/dist/scene/assetDiagnostics.js +2 -2
  114. package/dist/scene/autoTarget.js +2 -1
  115. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  116. package/dist/scene/builtinSceneKinds.js +10 -3
  117. package/dist/scene/captureCheck.js +3 -2
  118. package/dist/scene/colliders.d.ts +20 -0
  119. package/dist/scene/colliders.js +78 -0
  120. package/dist/scene/collisionMesh.d.ts +8 -0
  121. package/dist/scene/collisionMesh.js +48 -0
  122. package/dist/scene/entityStore.d.ts +4 -1
  123. package/dist/scene/entityStore.js +3 -0
  124. package/dist/scene/objectStore.d.ts +10 -0
  125. package/dist/scene/objectStore.js +29 -6
  126. package/dist/scene/sceneKinds.d.ts +37 -0
  127. package/dist/scene/sceneKinds.js +19 -0
  128. package/dist/scene/sequenceDirector.d.ts +110 -0
  129. package/dist/scene/sequenceDirector.js +146 -0
  130. package/dist/session/roles.js +3 -2
  131. package/dist/settings/settingsModel.d.ts +1 -0
  132. package/dist/settings/settingsModel.js +1 -0
  133. package/dist/stats/rollCheck.js +4 -2
  134. package/dist/time/dayNightCycle.d.ts +114 -0
  135. package/dist/time/dayNightCycle.js +208 -0
  136. package/dist/time/timerSet.d.ts +132 -0
  137. package/dist/time/timerSet.js +243 -0
  138. package/dist/ui/accessibility.d.ts +51 -0
  139. package/dist/ui/accessibility.js +83 -0
  140. package/dist/ui/coachMarks.d.ts +99 -0
  141. package/dist/ui/coachMarks.js +126 -0
  142. package/dist/ui/floatingText.d.ts +114 -0
  143. package/dist/ui/floatingText.js +203 -0
  144. package/dist/ui/hudScale.d.ts +16 -1
  145. package/dist/ui/hudScale.js +22 -1
  146. package/dist/ui/modalStack.d.ts +143 -0
  147. package/dist/ui/modalStack.js +160 -0
  148. package/dist/ui/objectiveBanner.d.ts +123 -0
  149. package/dist/ui/objectiveBanner.js +162 -0
  150. package/dist/ui/photoMode.d.ts +32 -0
  151. package/dist/ui/photoMode.js +51 -0
  152. package/dist/ui/radialMenu.d.ts +47 -0
  153. package/dist/ui/radialMenu.js +74 -0
  154. package/dist/ui/screenMarkers.d.ts +128 -0
  155. package/dist/ui/screenMarkers.js +118 -0
  156. package/dist/ui.d.ts +8 -0
  157. package/dist/ui.js +8 -0
  158. package/dist/vfx/cameraShake.d.ts +97 -0
  159. package/dist/vfx/cameraShake.js +136 -0
  160. package/dist/vfx/damageDirection.d.ts +102 -0
  161. package/dist/vfx/damageDirection.js +188 -0
  162. package/dist/vfx/particles.d.ts +103 -0
  163. package/dist/vfx/particles.js +256 -0
  164. package/dist/vfx/screenEffects.d.ts +149 -0
  165. package/dist/vfx/screenEffects.js +191 -0
  166. package/dist/world/authoredObjects.d.ts +18 -0
  167. package/dist/world/authoredObjects.js +21 -0
  168. package/dist/world/authoredSpawn.js +19 -0
  169. package/dist/world/buildingLots.d.ts +54 -11
  170. package/dist/world/buildingLots.js +116 -30
  171. package/dist/world/cityBlocks.d.ts +45 -0
  172. package/dist/world/cityBlocks.js +159 -0
  173. package/dist/world/cityContent.d.ts +46 -2
  174. package/dist/world/cityContent.js +160 -2
  175. package/dist/world/cityGenerator.d.ts +261 -23
  176. package/dist/world/cityGenerator.js +643 -33
  177. package/dist/world/cityGeometry.d.ts +20 -0
  178. package/dist/world/cityGeometry.js +91 -0
  179. package/dist/world/cityKind.js +9 -46
  180. package/dist/world/fastTravel.d.ts +64 -0
  181. package/dist/world/fastTravel.js +108 -0
  182. package/dist/world/features.d.ts +16 -0
  183. package/dist/world/features.js +2 -0
  184. package/dist/world/grassKind.js +4 -1
  185. package/dist/world/grounded.d.ts +11 -0
  186. package/dist/world/grounded.js +12 -0
  187. package/dist/world/mapAnnotations.d.ts +75 -0
  188. package/dist/world/mapAnnotations.js +139 -0
  189. package/dist/world/markers.js +1 -0
  190. package/dist/world/resourceNode.d.ts +1 -1
  191. package/dist/world/resourceNode.js +2 -1
  192. package/dist/world/roads.d.ts +294 -5
  193. package/dist/world/roads.js +1262 -21
  194. package/dist/world/scatterCoverage.d.ts +98 -0
  195. package/dist/world/scatterCoverage.js +101 -0
  196. package/dist/world/scatterRegion.js +6 -0
  197. package/dist/world/sharedAnnotations.d.ts +84 -0
  198. package/dist/world/sharedAnnotations.js +76 -0
  199. package/dist/world/spawnOverride.d.ts +37 -0
  200. package/dist/world/spawnOverride.js +50 -0
  201. package/dist/world/streetGenerator.d.ts +60 -11
  202. package/dist/world/streetGenerator.js +1786 -129
  203. package/dist/world/terrain.d.ts +1 -0
  204. package/dist/world/terrain.js +2 -0
  205. package/dist/world/waypoints.d.ts +63 -0
  206. package/dist/world/waypoints.js +137 -0
  207. package/dist/world.d.ts +18 -2
  208. package/dist/world.js +18 -2
  209. package/package.json +2 -2
  210. package/skills/ce-handoff/SKILL.md +149 -0
  211. package/skills/game-design/SKILL.md +2 -0
  212. package/skills/jgengine/SKILL.md +7 -3
  213. package/skills/jgengine/api.md +17 -7
  214. package/skills/jgengine/capabilities.md +12 -0
  215. package/skills/jgengine/recipes/minimal-game.md +7 -4
  216. package/skills/jgengine-combat/api.md +40 -4
  217. package/skills/jgengine-combat/capabilities.md +12 -0
  218. package/skills/jgengine-combat/reference.md +2 -0
  219. package/skills/jgengine-gameplay/api.md +212 -11
  220. package/skills/jgengine-gameplay/capabilities.md +66 -1
  221. package/skills/jgengine-world/api.md +408 -12
  222. package/skills/jgengine-world/capabilities.md +127 -2
  223. package/skills/jgengine-world/reference.md +15 -1
@@ -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
+ }
@@ -0,0 +1,149 @@
1
+ /** How a transient effect's intensity curves from peak to zero over its life. */
2
+ export type ScreenEffectEasing = "linear" | "easeIn" | "easeOut" | "easeInOut";
3
+ /**
4
+ * The screen region an effect tints. `"full"` grades the whole frame (a flash);
5
+ * `"vignette"` grades only the edges, leaving the center clear (a directional or
6
+ * ambient border tint). Purely a shape hint for the renderer — no genre meaning.
7
+ */
8
+ export type ScreenEffectShape = "full" | "vignette";
9
+ /**
10
+ * One screen-feedback effect, fully data-first so a whole controller serializes.
11
+ * `kind` is a free label the game assigns and styles ("damage", "heal", "poison",
12
+ * "boost", …); the model never interprets it. Everything visible is a parameter:
13
+ * color, peak intensity, region shape, easing, and either a transient duration or
14
+ * a sustained (optionally oscillating) hold.
15
+ */
16
+ export interface ScreenEffectSpec {
17
+ /** Free-string label the game owns; the model treats it as opaque. */
18
+ kind: string;
19
+ /** CSS color the region is tinted with (e.g. `"#ff2b2b"`, `"rgb(80,200,120)"`). */
20
+ color: string;
21
+ /** Peak opacity `0..1` at full strength. Default `1`. */
22
+ intensity?: number;
23
+ /** Which region is tinted. Default `"full"`. */
24
+ shape?: ScreenEffectShape;
25
+ /** Intensity curve for a transient effect. Default `"easeOut"`. */
26
+ easing?: ScreenEffectEasing;
27
+ /**
28
+ * Lifetime in ms for a transient effect (ignored when `sustained`). The effect
29
+ * peaks immediately and eases to zero over this window. Default `400`.
30
+ */
31
+ durationMs?: number;
32
+ /**
33
+ * When true the effect holds until {@link ScreenEffectsController.clear} instead
34
+ * of expiring — a low-health tint, a status aura. Default `false`.
35
+ */
36
+ sustained?: boolean;
37
+ /**
38
+ * Oscillation frequency in Hz for a sustained effect (a pulse). `0` holds steady
39
+ * at `intensity`; `>0` breathes between `minIntensity` and `intensity`. Ignored
40
+ * for transient effects. Default `0`.
41
+ */
42
+ pulseHz?: number;
43
+ /** Trough opacity `0..1` a pulsing sustained effect dips to. Default `0`. */
44
+ minIntensity?: number;
45
+ }
46
+ /**
47
+ * A live effect in the current composite: its label, color, region, and the
48
+ * eased opacity `0..1` to draw *right now*. These objects are pooled and reused
49
+ * across {@link ScreenEffectsController.composite} calls — read them, don't retain.
50
+ */
51
+ export interface ScreenEffect {
52
+ /** Stable id assigned at trigger time (use it to {@link ScreenEffectsController.clear}). */
53
+ id: number;
54
+ /** The game-owned label from the spec. */
55
+ kind: string;
56
+ /** CSS color to tint with. */
57
+ color: string;
58
+ /** Region to tint. */
59
+ shape: ScreenEffectShape;
60
+ /** Current eased opacity `0..1`. */
61
+ intensity: number;
62
+ }
63
+ /** A persisted effect record (all spec fields resolved plus its start time and id). */
64
+ export interface StoredScreenEffect {
65
+ id: number;
66
+ kind: string;
67
+ color: string;
68
+ shape: ScreenEffectShape;
69
+ easing: ScreenEffectEasing;
70
+ intensity: number;
71
+ durationMs: number;
72
+ sustained: boolean;
73
+ pulseHz: number;
74
+ minIntensity: number;
75
+ /** Clock time (ms) the effect started, for elapsed-time evaluation. */
76
+ startAt: number;
77
+ }
78
+ /** Serializable state of every active effect, for save/restore and replay. */
79
+ export interface ScreenEffectsSnapshot {
80
+ /** Wall-clock (ms) the snapshot was taken, so restore re-anchors elapsed time. */
81
+ now: number;
82
+ /** Next id to hand out. */
83
+ nextId: number;
84
+ /** Every active effect, transient and sustained. */
85
+ effects: readonly StoredScreenEffect[];
86
+ }
87
+ /** A live, clock-driven screen-feedback controller. */
88
+ export interface ScreenEffectsController {
89
+ /**
90
+ * Trigger a transient full-screen flash. Convenience for {@link trigger} with
91
+ * `shape: "full"`. Returns the new effect id.
92
+ */
93
+ flash(kind: string, spec: Omit<ScreenEffectSpec, "kind" | "shape" | "sustained">): number;
94
+ /**
95
+ * Trigger a transient edge vignette. Convenience for {@link trigger} with
96
+ * `shape: "vignette"`. Returns the new effect id.
97
+ */
98
+ vignette(kind: string, spec: Omit<ScreenEffectSpec, "kind" | "shape" | "sustained">): number;
99
+ /**
100
+ * Start a sustained pulse that holds until {@link clear}. Convenience for a
101
+ * sustained effect; pass `pulseHz > 0` to breathe. Returns the new effect id.
102
+ */
103
+ pulse(kind: string, spec: Omit<ScreenEffectSpec, "kind" | "sustained">): number;
104
+ /** Trigger any effect from a full spec. Returns the new effect id. */
105
+ trigger(spec: ScreenEffectSpec): number;
106
+ /** Remove one effect by id (typically a sustained one). No-op if unknown. */
107
+ clear(id: number): void;
108
+ /** Remove every active effect. */
109
+ clearAll(): void;
110
+ /**
111
+ * Re-evaluate every effect against the clock: recompute eased intensities, reap
112
+ * expired transients, and notify subscribers. Allocation-free on the hot path.
113
+ * Pass an explicit `nowMs` to drive deterministically, else the injected clock.
114
+ */
115
+ advance(nowMs?: number): void;
116
+ /**
117
+ * The current composite: active effects with their eased `intensity`. The array
118
+ * and its entries are pooled and overwritten on the next call — do not retain.
119
+ */
120
+ composite(): readonly ScreenEffect[];
121
+ /** Number of active effects. */
122
+ active(): number;
123
+ /** Observe changes (trigger, clear, advance). Returns an unsubscribe fn. */
124
+ subscribe(listener: () => void): () => void;
125
+ /** Serializable state for a save. */
126
+ snapshot(): ScreenEffectsSnapshot;
127
+ /** Restore from a {@link ScreenEffectsSnapshot}. */
128
+ restore(snapshot: ScreenEffectsSnapshot): void;
129
+ }
130
+ /** Options for {@link createScreenEffects}. */
131
+ export interface ScreenEffectsOptions {
132
+ /** Injected clock (ms). Default `Date.now`. */
133
+ now?: () => number;
134
+ }
135
+ /**
136
+ * A serializable screen-feedback controller: a game triggers transient flashes and
137
+ * edge vignettes (a red damage hit, a green heal flash) or sustained, optionally
138
+ * oscillating tints (a low-health pulse), and reads back a composite of the effects
139
+ * to draw right now with their eased opacities. It is clock-driven — call
140
+ * `advance()` each frame against an injected `now` — and allocation-aware: the
141
+ * composite array and its entries are pooled and reused, so steady-state ticking
142
+ * never allocates. Nothing here is genre-specific: `kind` is a free label the game
143
+ * owns and styles, and vignette / flash / pulse are just parameterizations of the
144
+ * same data (region shape, decay easing, sustained oscillation). A shell overlay
145
+ * subscribes and renders; `snapshot`/`restore` round-trips through a save.
146
+ *
147
+ * @capability screen-effects serializable, pooled screen-feedback controller — transient flash/vignette and sustained low-health-style pulses with eased opacities, free-string kinds the game styles, and snapshot/restore
148
+ */
149
+ export declare function createScreenEffects(options?: ScreenEffectsOptions): ScreenEffectsController;