@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,47 @@
1
+ /**
2
+ * Arc the slices span. Omit for a full wheel (index 0 at the top). A partial
3
+ * `sweep` (e.g. `Math.PI` for a bottom half, `Math.PI / 2` for a quarter) packs
4
+ * the slices evenly inside `[startAngle, startAngle + sweep]` — the arc/quick-bar
5
+ * forms. Angles are radians from "up" (−Y), clockwise.
6
+ */
7
+ export interface RadialArc {
8
+ startAngle?: number;
9
+ sweep?: number;
10
+ }
11
+ /** One wedge of a radial menu — geometry for rendering a slice. Angles are radians from "up" (−Y), clockwise. */
12
+ export interface RadialSlice {
13
+ index: number;
14
+ /** Angle at the slice's center. */
15
+ centerAngle: number;
16
+ /** Leading edge angle. */
17
+ startAngle: number;
18
+ /** Trailing edge angle. */
19
+ endAngle: number;
20
+ }
21
+ /**
22
+ * Divide a ring (or `arc`) into `count` wedges — the geometry a radial/quick
23
+ * menu (weapon wheel, emote arc, ping bar) renders. A full wheel centers index 0
24
+ * at the top; a partial `arc` packs the slices inside its sweep.
25
+ */
26
+ export declare function radialSlices(count: number, arc?: RadialArc): RadialSlice[];
27
+ /** Map an angle to a slice index; returns -1 when the angle falls outside a partial `arc`. */
28
+ export declare function radialIndexFromAngle(angle: number, count: number, arc?: RadialArc): number;
29
+ /** Options for {@link radialIndexFromVector}. */
30
+ export interface RadialVectorOptions extends RadialArc {
31
+ /** Minimum vector magnitude to register a selection; shorter vectors return null (the neutral center). Default 0. */
32
+ deadZone?: number;
33
+ }
34
+ /**
35
+ * Pick the slice a pointer/stick vector points at. `dx`/`dy` are screen-space
36
+ * (y down): up is `(0, -1)`. Returns null inside `deadZone` or (for a partial
37
+ * `arc`) outside the arc, so a centered stick or a click off the arc selects
38
+ * nothing.
39
+ *
40
+ * @capability radial-select map a pointer/stick vector to a radial-menu slice index, with a neutral dead zone and optional arc bounds
41
+ */
42
+ export declare function radialIndexFromVector(dx: number, dy: number, count: number, options?: RadialVectorOptions): number | null;
43
+ /** Unit-circle position of a slice center (up = `(0, -1)`), scaled by `radius` — where to place a wedge's icon/label. */
44
+ export declare function radialSlicePosition(index: number, count: number, radius: number, arc?: RadialArc): {
45
+ x: number;
46
+ y: number;
47
+ };
@@ -0,0 +1,74 @@
1
+ const TWO_PI = Math.PI * 2;
2
+ function normalize(angle) {
3
+ let value = angle % TWO_PI;
4
+ if (value < 0)
5
+ value += TWO_PI;
6
+ return value;
7
+ }
8
+ function arcConfig(count, arc) {
9
+ const startAngle = arc?.startAngle ?? 0;
10
+ const sweep = arc?.sweep ?? TWO_PI;
11
+ const full = sweep >= TWO_PI - 1e-6;
12
+ const size = full ? (count > 0 ? TWO_PI / count : 0) : (count > 0 ? sweep / count : 0);
13
+ // Full wheels wrap, so a slice sits exactly on the start angle (index 0 = top).
14
+ // Partial arcs offset by half a slice so wedges fit inside the arc.
15
+ const centerOf = (index) => startAngle + (full ? index : index + 0.5) * size;
16
+ return { startAngle, sweep, full, size, centerOf };
17
+ }
18
+ /**
19
+ * Divide a ring (or `arc`) into `count` wedges — the geometry a radial/quick
20
+ * menu (weapon wheel, emote arc, ping bar) renders. A full wheel centers index 0
21
+ * at the top; a partial `arc` packs the slices inside its sweep.
22
+ */
23
+ export function radialSlices(count, arc) {
24
+ if (count <= 0)
25
+ return [];
26
+ const { size, centerOf } = arcConfig(count, arc);
27
+ const slices = [];
28
+ for (let index = 0; index < count; index += 1) {
29
+ const centerAngle = centerOf(index);
30
+ slices.push({
31
+ index,
32
+ centerAngle: normalize(centerAngle),
33
+ startAngle: normalize(centerAngle - size / 2),
34
+ endAngle: normalize(centerAngle + size / 2),
35
+ });
36
+ }
37
+ return slices;
38
+ }
39
+ /** Map an angle to a slice index; returns -1 when the angle falls outside a partial `arc`. */
40
+ export function radialIndexFromAngle(angle, count, arc) {
41
+ if (count <= 0)
42
+ return -1;
43
+ const { full, size, startAngle, sweep } = arcConfig(count, arc);
44
+ if (full)
45
+ return Math.round(normalize(angle - startAngle) / size) % count;
46
+ const rel = normalize(angle - startAngle);
47
+ if (rel > sweep)
48
+ return -1;
49
+ return Math.max(0, Math.min(count - 1, Math.floor(rel / size)));
50
+ }
51
+ /**
52
+ * Pick the slice a pointer/stick vector points at. `dx`/`dy` are screen-space
53
+ * (y down): up is `(0, -1)`. Returns null inside `deadZone` or (for a partial
54
+ * `arc`) outside the arc, so a centered stick or a click off the arc selects
55
+ * nothing.
56
+ *
57
+ * @capability radial-select map a pointer/stick vector to a radial-menu slice index, with a neutral dead zone and optional arc bounds
58
+ */
59
+ export function radialIndexFromVector(dx, dy, count, options = {}) {
60
+ if (count <= 0)
61
+ return null;
62
+ const magnitude = Math.hypot(dx, dy);
63
+ if (magnitude < (options.deadZone ?? 0))
64
+ return null;
65
+ // atan2(dx, -dy): 0 = up, increasing clockwise toward +x (right).
66
+ const index = radialIndexFromAngle(Math.atan2(dx, -dy), count, options);
67
+ return index < 0 ? null : index;
68
+ }
69
+ /** Unit-circle position of a slice center (up = `(0, -1)`), scaled by `radius` — where to place a wedge's icon/label. */
70
+ export function radialSlicePosition(index, count, radius, arc) {
71
+ const { centerOf } = arcConfig(count, arc);
72
+ const angle = count <= 0 ? 0 : centerOf(index);
73
+ return { x: Math.sin(angle) * radius, y: -Math.cos(angle) * radius };
74
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A world position projected to screen space by the caller's camera. This is the
3
+ * minimal shape {@link layoutScreenMarker} consumes — deliberately a structural
4
+ * subset of `@jgengine/react`'s `EntityScreenProjection` (and what its
5
+ * `ProjectEntity`/`useWorldProjection` produce), mirrored here because `core`
6
+ * imports nothing higher. Feed it straight from a world→screen projector.
7
+ */
8
+ export interface ScreenProjection {
9
+ /** CSS px from the viewport's left edge. */
10
+ x: number;
11
+ /** CSS px from the viewport's top edge. */
12
+ y: number;
13
+ /** Optional depth key (larger = farther); carried through untouched, unused by the math. */
14
+ depth?: number;
15
+ /**
16
+ * `true` when the point is behind the camera. Such a projection's `x`/`y` are
17
+ * mirrored through the origin, so the layout flips the bearing to point at the
18
+ * real off-screen direction rather than trusting the raw coordinates.
19
+ */
20
+ behind?: boolean;
21
+ }
22
+ /** The `{ width, height }` of the viewport the marker is laid out within (CSS px). */
23
+ export interface ScreenMarkerViewport {
24
+ width: number;
25
+ height: number;
26
+ }
27
+ /** Options for {@link layoutScreenMarker}. */
28
+ export interface ScreenMarkerOptions {
29
+ /**
30
+ * Inset (px) from each viewport edge that an off-screen/behind marker is
31
+ * clamped inside, so a directional arrow sits fully on-frame rather than half
32
+ * off the edge. Default `24`.
33
+ */
34
+ margin?: number;
35
+ /**
36
+ * Optional result object to write into and return, so a per-frame layout over
37
+ * many waypoints allocates nothing. Omit to get a fresh object each call.
38
+ */
39
+ out?: ScreenMarkerLayout;
40
+ }
41
+ /** Where and how to draw one marker, from {@link layoutScreenMarker}. */
42
+ export interface ScreenMarkerLayout {
43
+ /**
44
+ * `true` when the target projects inside the viewport in front of the camera —
45
+ * draw an on-screen pin at `(x, y)`. `false` when it is off-screen or behind —
46
+ * draw a directional arrow clamped to the edge, rotated by `angle`.
47
+ */
48
+ onScreen: boolean;
49
+ /** Screen x to place the marker (px). On-screen: the projection; else clamped to the edge. */
50
+ x: number;
51
+ /** Screen y to place the marker (px). On-screen: the projection; else clamped to the edge. */
52
+ y: number;
53
+ /**
54
+ * Bearing from the viewport center toward the target, in radians (`atan2`
55
+ * convention: `0` = +x/right, `PI/2` = +y/down). Rotate an arrow glyph by this
56
+ * so it points at the target; also meaningful for an on-screen pin.
57
+ */
58
+ angle: number;
59
+ }
60
+ /**
61
+ * Pure, allocation-aware screen-marker math: given a projected target point, the
62
+ * viewport, and a `margin`, decide whether the target is on-screen and where to
63
+ * draw its marker. An on-screen point passes straight through (`onScreen: true`).
64
+ * An off-screen — or behind-camera — point is CLAMPED to the viewport edge (inset
65
+ * by `margin`) with `angle` set to the bearing from screen center toward the
66
+ * target, so a caller can pin an arrow to the frame edge and rotate it to point
67
+ * the way. Behind-camera projections have mirrored coordinates, so the bearing is
68
+ * flipped to face the true direction. This is the edge-clamp/arrow half that
69
+ * `layoutEntityFrames` lacks (it culls off-screen entries instead). No allocation
70
+ * when an `out` object is supplied — reuse one across a whole frame's waypoints.
71
+ *
72
+ * @capability offscreen-markers pure edge-clamp + bearing math turning a projected point into an on-screen pin or an off-screen directional arrow clamped to the viewport edge
73
+ */
74
+ export declare function layoutScreenMarker(projection: ScreenProjection, viewport: ScreenMarkerViewport, options?: ScreenMarkerOptions): ScreenMarkerLayout;
75
+ /** A tracked objective/waypoint the game wants a screen marker for. */
76
+ export interface Waypoint {
77
+ /** Stable id — the key for `set`/`remove` and React lists. */
78
+ id: string;
79
+ /** World-space anchor `[x, y, z]` the caller projects to screen space. */
80
+ position: readonly [number, number, number];
81
+ /**
82
+ * Free-string label the game owns and styles ("objective", "loot", "ally", …).
83
+ * The model never interprets it — it only carries it through so a game can
84
+ * color/skin per kind.
85
+ */
86
+ kind: string;
87
+ /** Optional display label ("Extraction", "Ammo cache", …). */
88
+ label?: string;
89
+ }
90
+ /** Serializable state of a {@link WaypointTracker}, for save/restore. */
91
+ export interface WaypointSnapshot {
92
+ /** Every tracked waypoint, in insertion order. */
93
+ waypoints: readonly Waypoint[];
94
+ }
95
+ /** An observable, serializable set of world waypoints keyed by id. */
96
+ export interface WaypointTracker {
97
+ /** Add or replace the waypoint with this id. Notifies subscribers. */
98
+ set(waypoint: Waypoint): void;
99
+ /** Remove the waypoint with this id, if present. Notifies when it existed. */
100
+ remove(id: string): void;
101
+ /** Remove every waypoint. Notifies when the set was non-empty. */
102
+ clear(): void;
103
+ /** The tracked waypoint with this id, or `undefined`. */
104
+ get(id: string): Waypoint | undefined;
105
+ /** Every tracked waypoint, in insertion order (a fresh array — safe to retain). */
106
+ all(): readonly Waypoint[];
107
+ /** How many waypoints are tracked. */
108
+ size(): number;
109
+ /** Observe changes (set, remove, clear, restore). Returns an unsubscribe fn. */
110
+ subscribe(listener: () => void): () => void;
111
+ /** Serializable state for a save. */
112
+ snapshot(): WaypointSnapshot;
113
+ /** Restore from a {@link WaypointSnapshot}, replacing the current set. */
114
+ restore(snapshot: WaypointSnapshot): void;
115
+ }
116
+ /**
117
+ * A serializable, observable tracker of world waypoints — the objective/point-of-
118
+ * interest half of offscreen markers. A game `set`s waypoints by id (each an
119
+ * `{ id, position, kind, label? }`, where `kind` is a free string the game styles
120
+ * and the model never branches on), and a React host projects each to screen space
121
+ * and lays it out with {@link layoutScreenMarker}: an on-screen pin, or an
122
+ * off-screen directional arrow clamped to the viewport edge with a distance the
123
+ * caller computes. Purely a model — no camera, projection, or renderer here.
124
+ * `snapshot`/`restore` round-trips the set through a save.
125
+ *
126
+ * @capability offscreen-markers serializable waypoint tracker + pure edge-clamp/bearing math for on-screen pins and off-screen directional arrows with distance
127
+ */
128
+ export declare function createWaypointTracker(): WaypointTracker;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pure, allocation-aware screen-marker math: given a projected target point, the
3
+ * viewport, and a `margin`, decide whether the target is on-screen and where to
4
+ * draw its marker. An on-screen point passes straight through (`onScreen: true`).
5
+ * An off-screen — or behind-camera — point is CLAMPED to the viewport edge (inset
6
+ * by `margin`) with `angle` set to the bearing from screen center toward the
7
+ * target, so a caller can pin an arrow to the frame edge and rotate it to point
8
+ * the way. Behind-camera projections have mirrored coordinates, so the bearing is
9
+ * flipped to face the true direction. This is the edge-clamp/arrow half that
10
+ * `layoutEntityFrames` lacks (it culls off-screen entries instead). No allocation
11
+ * when an `out` object is supplied — reuse one across a whole frame's waypoints.
12
+ *
13
+ * @capability offscreen-markers pure edge-clamp + bearing math turning a projected point into an on-screen pin or an off-screen directional arrow clamped to the viewport edge
14
+ */
15
+ export function layoutScreenMarker(projection, viewport, options = {}) {
16
+ const margin = options.margin ?? 24;
17
+ const out = options.out ?? { onScreen: false, x: 0, y: 0, angle: 0 };
18
+ const { width, height } = viewport;
19
+ const cx = width / 2;
20
+ const cy = height / 2;
21
+ const behind = projection.behind === true;
22
+ // Direction from screen center to the target; flip it when the point is behind
23
+ // the camera (its projected coords are mirrored through the origin).
24
+ let dx = projection.x - cx;
25
+ let dy = projection.y - cy;
26
+ if (behind) {
27
+ dx = -dx;
28
+ dy = -dy;
29
+ }
30
+ // Degenerate: target sits exactly at center — pick a stable downward bearing.
31
+ if (dx === 0 && dy === 0)
32
+ dy = 1;
33
+ const angle = Math.atan2(dy, dx);
34
+ const onScreen = !behind &&
35
+ projection.x >= 0 &&
36
+ projection.x <= width &&
37
+ projection.y >= 0 &&
38
+ projection.y <= height;
39
+ if (onScreen) {
40
+ out.onScreen = true;
41
+ out.x = projection.x;
42
+ out.y = projection.y;
43
+ out.angle = angle;
44
+ return out;
45
+ }
46
+ // Clamp along the center→target ray to the rectangle inset by `margin`.
47
+ const hx = Math.max(0, width / 2 - margin);
48
+ const hy = Math.max(0, height / 2 - margin);
49
+ // Scale the direction so the longer axis just reaches its half-extent.
50
+ const scaleX = dx === 0 ? Infinity : hx / Math.abs(dx);
51
+ const scaleY = dy === 0 ? Infinity : hy / Math.abs(dy);
52
+ const scale = Math.min(scaleX, scaleY);
53
+ const safeScale = Number.isFinite(scale) ? scale : 0;
54
+ out.onScreen = false;
55
+ out.x = cx + dx * safeScale;
56
+ out.y = cy + dy * safeScale;
57
+ out.angle = angle;
58
+ return out;
59
+ }
60
+ /**
61
+ * A serializable, observable tracker of world waypoints — the objective/point-of-
62
+ * interest half of offscreen markers. A game `set`s waypoints by id (each an
63
+ * `{ id, position, kind, label? }`, where `kind` is a free string the game styles
64
+ * and the model never branches on), and a React host projects each to screen space
65
+ * and lays it out with {@link layoutScreenMarker}: an on-screen pin, or an
66
+ * off-screen directional arrow clamped to the viewport edge with a distance the
67
+ * caller computes. Purely a model — no camera, projection, or renderer here.
68
+ * `snapshot`/`restore` round-trips the set through a save.
69
+ *
70
+ * @capability offscreen-markers serializable waypoint tracker + pure edge-clamp/bearing math for on-screen pins and off-screen directional arrows with distance
71
+ */
72
+ export function createWaypointTracker() {
73
+ // Insertion-ordered map keeps `all()`/snapshot stable across sets.
74
+ const waypoints = new Map();
75
+ const listeners = new Set();
76
+ function notify() {
77
+ for (const listener of listeners)
78
+ listener();
79
+ }
80
+ return {
81
+ set(waypoint) {
82
+ waypoints.set(waypoint.id, waypoint);
83
+ notify();
84
+ },
85
+ remove(id) {
86
+ if (waypoints.delete(id))
87
+ notify();
88
+ },
89
+ clear() {
90
+ if (waypoints.size === 0)
91
+ return;
92
+ waypoints.clear();
93
+ notify();
94
+ },
95
+ get(id) {
96
+ return waypoints.get(id);
97
+ },
98
+ all() {
99
+ return [...waypoints.values()];
100
+ },
101
+ size() {
102
+ return waypoints.size;
103
+ },
104
+ subscribe(listener) {
105
+ listeners.add(listener);
106
+ return () => listeners.delete(listener);
107
+ },
108
+ snapshot() {
109
+ return { waypoints: [...waypoints.values()] };
110
+ },
111
+ restore(snapshot) {
112
+ waypoints.clear();
113
+ for (const waypoint of snapshot.waypoints)
114
+ waypoints.set(waypoint.id, waypoint);
115
+ notify();
116
+ },
117
+ };
118
+ }
package/dist/ui.d.ts CHANGED
@@ -1,16 +1,24 @@
1
1
  export { formatDistance } from "./format/distance.js";
2
2
  export { formatDelta, formatDuration, formatOrdinal } from "./format/duration.js";
3
3
  export { formatSpeed } from "./format/speed.js";
4
+ export { createI18n, interpolate, type Catalog, type I18n, type I18nOptions, type Locale, type Messages, type TParams, } from "./i18n/i18n.js";
4
5
  export { resolveGameLook, type LookPreset } from "./render/lookPreset.js";
5
6
  export { STUDIO_STAGE_POST, type GradeConfig, type PostProcessingConfig, type ToneMappingMode, } from "./render/postProcessing.js";
6
7
  export { BUILT_IN_SETTING_CATEGORIES, DEFAULT_GRAPHICS_QUALITY, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_DPR, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, busVolumeSettingId, createSettingsStore, type GameSettingDef, type GameSettingsConfig, type GraphicsQuality, type SettingCategory, type SettingCategoryDef, type SettingKind, type SettingOption, type SettingValue, type SettingsActionDef, type SettingsStore, type SettingsSurface, type SettingsVariant, } from "./settings/settingsModel.js";
8
+ export { COLORBLIND_MATRICES, DEFAULT_ACCESSIBILITY, TEXT_SCALE_MAX, TEXT_SCALE_MIN, clampTextScale, createAccessibilityStore, reducedMotionDuration, type AccessibilityState, type AccessibilityStore, type ColorblindMode, } from "./ui/accessibility.js";
7
9
  export { actionByHotkey, actionCooldown, actionCooldownFromFraction, actionCost, moveGridFocus, resolveAction, resolveActionCollection, type ActionCooldown, type ActionCost, type ActionDef, type ActionReason, type FocusDirection, type GridFocusOptions, type ResolvedAction, } from "./ui/actionModel.js";
10
+ export { createCoachMarkSequence, type CoachMarkPlacement, type CoachMarkSequence, type CoachMarkSequenceOptions, type CoachMarkSnapshot, type CoachMarkStep, type CoachMarkView, } from "./ui/coachMarks.js";
8
11
  export { type GameLayoutMode, type GameViewportLayout, type HudPriority, type Insets, type LayoutCollision, type LayoutCollisionPolicy, type LayoutRect, type LayoutRegion, type MobileHudBehavior, } from "./ui/gameLayout.js";
9
12
  export { listHudPanelTypes, registerHudPanelType, resizePanelSize, resolveHudPanelLayout, type EditorUiDocument, type EditorUiPanelLayout, type HudResizeAxes, } from "./ui/hudDocument.js";
10
13
  export { HUD_ANCHOR_FRACTIONS, type HudAnchor, type HudLayoutStore, type HudPlacement, type HudSize, } from "./ui/hudLayout.js";
11
14
  export { hudScaleForViewport, overflowingPanels, resolveHudFit, type HudPlatform, type HudViewportConfig, } from "./ui/hudScale.js";
15
+ export { MODAL_CANCEL, MODAL_CONFIRM, createModalStack, type ModalInput, type ModalRecord, type ModalResolution, type ModalStack, type ModalStackEntry, type ModalStackOptions, type ModalStackSnapshot, } from "./ui/modalStack.js";
16
+ export { createObjectiveBanner, type ObjectiveBannerAnnouncement, type ObjectiveBannerController, type ObjectiveBannerOptions, type ObjectiveBannerPhase, type ObjectiveBannerSnapshot, type ObjectiveBannerView, type StoredObjectiveBanner, } from "./ui/objectiveBanner.js";
12
17
  export { orientationGateActive, orientationHintActive, resolveOrientationRequirement, type LayoutOrientation, } from "./ui/orientation.js";
13
18
  export { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen, movePanel, openPanel, orderedOpen, panelByHotkey, togglePanel, type PanelDef, type PanelPosition, type PanelState, } from "./ui/panelModel.js";
19
+ export { DEFAULT_PHOTO_MODE, createPhotoModeStore, type PhotoModeState, type PhotoModeStore } from "./ui/photoMode.js";
20
+ export { radialIndexFromAngle, radialIndexFromVector, radialSlicePosition, radialSlices, type RadialArc, type RadialSlice, type RadialVectorOptions, } from "./ui/radialMenu.js";
21
+ export { createWaypointTracker, layoutScreenMarker, type ScreenMarkerLayout, type ScreenMarkerOptions, type ScreenMarkerViewport, type ScreenProjection, type Waypoint, type WaypointSnapshot, type WaypointTracker, } from "./ui/screenMarkers.js";
14
22
  export { moveSelectionFocus, selectionWindow, summarizeSelection, type EntitySummaryDef, type EntityVital, type SelectionFocusDirection, type SelectionGroup, type SelectionView, type SelectionWindow, type SummarizeSelectionOptions, } from "./ui/selectionModel.js";
15
23
  export { swingTimerState, type SwingTargetInput } from "./ui/swingTimer.js";
16
24
  export { actionTooltip, placePopover, type PopoverOptions, type PopoverPlacement, type PopoverSide, type TooltipContent, type UiRect, type UiSize, } from "./ui/tooltipModel.js";
package/dist/ui.js CHANGED
@@ -1,16 +1,24 @@
1
1
  export { formatDistance } from "./format/distance.js";
2
2
  export { formatDelta, formatDuration, formatOrdinal } from "./format/duration.js";
3
3
  export { formatSpeed } from "./format/speed.js";
4
+ export { createI18n, interpolate, } from "./i18n/i18n.js";
4
5
  export { resolveGameLook } from "./render/lookPreset.js";
5
6
  export { STUDIO_STAGE_POST, } from "./render/postProcessing.js";
6
7
  export { BUILT_IN_SETTING_CATEGORIES, DEFAULT_GRAPHICS_QUALITY, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_DPR, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, busVolumeSettingId, createSettingsStore, } from "./settings/settingsModel.js";
8
+ export { COLORBLIND_MATRICES, DEFAULT_ACCESSIBILITY, TEXT_SCALE_MAX, TEXT_SCALE_MIN, clampTextScale, createAccessibilityStore, reducedMotionDuration, } from "./ui/accessibility.js";
7
9
  export { actionByHotkey, actionCooldown, actionCooldownFromFraction, actionCost, moveGridFocus, resolveAction, resolveActionCollection, } from "./ui/actionModel.js";
10
+ export { createCoachMarkSequence, } from "./ui/coachMarks.js";
8
11
  export {} from "./ui/gameLayout.js";
9
12
  export { listHudPanelTypes, registerHudPanelType, resizePanelSize, resolveHudPanelLayout, } from "./ui/hudDocument.js";
10
13
  export { HUD_ANCHOR_FRACTIONS, } from "./ui/hudLayout.js";
11
14
  export { hudScaleForViewport, overflowingPanels, resolveHudFit, } from "./ui/hudScale.js";
15
+ export { MODAL_CANCEL, MODAL_CONFIRM, createModalStack, } from "./ui/modalStack.js";
16
+ export { createObjectiveBanner, } from "./ui/objectiveBanner.js";
12
17
  export { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "./ui/orientation.js";
13
18
  export { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen, movePanel, openPanel, orderedOpen, panelByHotkey, togglePanel, } from "./ui/panelModel.js";
19
+ export { DEFAULT_PHOTO_MODE, createPhotoModeStore } from "./ui/photoMode.js";
20
+ export { radialIndexFromAngle, radialIndexFromVector, radialSlicePosition, radialSlices, } from "./ui/radialMenu.js";
21
+ export { createWaypointTracker, layoutScreenMarker, } from "./ui/screenMarkers.js";
14
22
  export { moveSelectionFocus, selectionWindow, summarizeSelection, } from "./ui/selectionModel.js";
15
23
  export { swingTimerState } from "./ui/swingTimer.js";
16
24
  export { actionTooltip, placePopover, } from "./ui/tooltipModel.js";
@@ -0,0 +1,97 @@
1
+ /** A per-frame camera shake offset: three positional axes plus three rotational axes (radians). */
2
+ export interface CameraShakeOffset {
3
+ /** Positional kick along camera-space X. */
4
+ x: number;
5
+ /** Positional kick along camera-space Y. */
6
+ y: number;
7
+ /** Positional kick along camera-space Z. */
8
+ z: number;
9
+ /** Rotational kick about the pitch axis (radians). */
10
+ pitch: number;
11
+ /** Rotational kick about the yaw axis (radians). */
12
+ yaw: number;
13
+ /** Rotational kick about the roll axis (radians). */
14
+ roll: number;
15
+ }
16
+ /** Configuration for {@link createCameraShake}. Every field has a game-feel default. */
17
+ export interface CameraShakeConfig {
18
+ /** Trauma bled off per second while `update` runs. Default `1.6`. */
19
+ decayPerSecond?: number;
20
+ /**
21
+ * Exponent applied to trauma before it drives the offset (`shake = trauma^exp`),
22
+ * so small hits stay subtle and big hits dominate. Default `2` (the classic
23
+ * "trauma squared" curve).
24
+ */
25
+ traumaExponent?: number;
26
+ /** Peak positional offset at full shake, per `[x, y, z]` axis (world units). Default `[0.6, 0.6, 0.4]`. */
27
+ maxTranslation?: readonly [number, number, number];
28
+ /** Peak rotational offset at full shake, per `[pitch, yaw, roll]` axis (radians). Default `[0.06, 0.06, 0.12]`. */
29
+ maxRotation?: readonly [number, number, number];
30
+ /** Base noise frequency (oscillations per second) sampled along the time cursor. Default `18`. */
31
+ frequency?: number;
32
+ /** Deterministic seed for the value noise; same seed + dt sequence reproduces the same offsets. Default `"camera-shake"`. */
33
+ seed?: string | number;
34
+ /** Injected clock (ms) used when `update` is called with no explicit dt. Default `Date.now`. */
35
+ now?: () => number;
36
+ }
37
+ /** Serializable state for save/restore and deterministic replay. */
38
+ export interface CameraShakeSnapshot {
39
+ /** Current trauma in `[0, 1]`. */
40
+ trauma: number;
41
+ /** Advancing time cursor (seconds) the noise is sampled along. */
42
+ time: number;
43
+ /** The base value-noise seed as a plain integer. */
44
+ seed: number;
45
+ /** The last free-string `kind` label handed to `add`, or `null`. */
46
+ kind: string | null;
47
+ }
48
+ /** A live, dt-driven, trauma-based camera shake controller. */
49
+ export interface CameraShakeController {
50
+ /**
51
+ * Add trauma (clamped into `[0, 1]`). `kind` is a free string the model never
52
+ * interprets — it only carries it through so a game can style/label impacts
53
+ * ("hit", "explosion", "landing", …). Larger hits raise the ceiling toward 1.
54
+ */
55
+ add(amount: number, kind?: string): void;
56
+ /**
57
+ * Advance the shake: bleed trauma by `decayPerSecond` and move the noise time
58
+ * cursor forward. Pass explicit `dt` seconds to drive deterministically, else
59
+ * the injected clock supplies the delta since the last call.
60
+ */
61
+ update(dt?: number): void;
62
+ /**
63
+ * The shake to apply this frame — a POOLED object reused across calls (read it,
64
+ * don't retain it). Each axis is `trauma^traumaExponent * max[axis] * noise`,
65
+ * where the noise is deterministic seeded value-noise sampled at the time cursor.
66
+ */
67
+ offset(): CameraShakeOffset;
68
+ /** Current trauma in `[0, 1]`. */
69
+ trauma(): number;
70
+ /** The last free-string `kind` handed to `add`, or `undefined` if none yet. */
71
+ kind(): string | undefined;
72
+ /** Patch the config in place (e.g. retune decay or amplitudes at runtime). */
73
+ configure(patch: Partial<CameraShakeConfig>): void;
74
+ /** Reset trauma to 0 (keeps the seed and time cursor). */
75
+ clear(): void;
76
+ /** Observe changes (add, update, configure, clear, restore). Returns an unsubscribe fn. */
77
+ subscribe(listener: () => void): () => void;
78
+ /** Serializable state for a save. */
79
+ snapshot(): CameraShakeSnapshot;
80
+ /** Restore from a {@link CameraShakeSnapshot}. */
81
+ restore(snapshot: CameraShakeSnapshot): void;
82
+ }
83
+ /**
84
+ * A seeded, serializable, trauma-based camera shake / impulse controller. A game
85
+ * calls `add(amount, kind?)` on impacts (a hit, an explosion, a landing) to raise
86
+ * trauma in `[0, 1]`; `update(dt)` bleeds it off over time; and `offset()` returns
87
+ * a pooled `{ x, y, z, pitch, yaw, roll }` kick equal to `trauma^exponent` times
88
+ * per-axis maxima times deterministic seeded value-noise sampled along an internal
89
+ * time cursor. A shell consumer applies that offset to the active camera each frame
90
+ * so the view visibly shakes. Nothing here is genre-specific: `kind` is a free
91
+ * label the game styles, all amplitudes/decay are parameters, and `snapshot`/
92
+ * `restore` round-trips the shake through a save. Deterministic from `seed`: the
93
+ * same seed and dt sequence reproduce the same offsets.
94
+ *
95
+ * @capability camera-shake seeded serializable trauma-based camera shake/impulse controller (hit/explosion/landing juice) with pooled per-frame translation+rotation offset and snapshot/restore
96
+ */
97
+ export declare function createCameraShake(config?: CameraShakeConfig): CameraShakeController;
@@ -0,0 +1,136 @@
1
+ import { hashString, randomSeedFrom, stepRandomSeed } from "../random/rng.js";
2
+ function clamp01(v) {
3
+ return v < 0 ? 0 : v > 1 ? 1 : v;
4
+ }
5
+ /** A stable pseudo-random value in `[-1, 1]` for integer lattice point `i` off a base seed. */
6
+ function latticeValue(seed, i) {
7
+ const mixed = randomSeedFrom((seed ^ Math.imul(i | 0, 0x9e3779b1)) | 0);
8
+ const [value] = stepRandomSeed(mixed);
9
+ return value * 2 - 1;
10
+ }
11
+ /**
12
+ * 1-D smoothstep value noise: hash the two integer lattice points bracketing `t`
13
+ * and interpolate. Continuous and deterministic — no per-frame RNG, so the same
14
+ * seed and time reproduce the same value.
15
+ */
16
+ function valueNoise(seed, t) {
17
+ const i0 = Math.floor(t);
18
+ const f = t - i0;
19
+ const a = latticeValue(seed, i0);
20
+ const b = latticeValue(seed, i0 + 1);
21
+ const u = f * f * (3 - 2 * f);
22
+ return a + (b - a) * u;
23
+ }
24
+ // Per-axis frequency multipliers decorrelate the six channels so the shake never
25
+ // looks like a single sine sweep. Fixed constants (not tunable) — pure feel.
26
+ const AXIS_FREQ = [1, 1.17, 0.83, 1.31, 0.91, 1.09];
27
+ /**
28
+ * A seeded, serializable, trauma-based camera shake / impulse controller. A game
29
+ * calls `add(amount, kind?)` on impacts (a hit, an explosion, a landing) to raise
30
+ * trauma in `[0, 1]`; `update(dt)` bleeds it off over time; and `offset()` returns
31
+ * a pooled `{ x, y, z, pitch, yaw, roll }` kick equal to `trauma^exponent` times
32
+ * per-axis maxima times deterministic seeded value-noise sampled along an internal
33
+ * time cursor. A shell consumer applies that offset to the active camera each frame
34
+ * so the view visibly shakes. Nothing here is genre-specific: `kind` is a free
35
+ * label the game styles, all amplitudes/decay are parameters, and `snapshot`/
36
+ * `restore` round-trips the shake through a save. Deterministic from `seed`: the
37
+ * same seed and dt sequence reproduce the same offsets.
38
+ *
39
+ * @capability camera-shake seeded serializable trauma-based camera shake/impulse controller (hit/explosion/landing juice) with pooled per-frame translation+rotation offset and snapshot/restore
40
+ */
41
+ export function createCameraShake(config = {}) {
42
+ const cfg = {
43
+ decayPerSecond: config.decayPerSecond ?? 1.6,
44
+ traumaExponent: config.traumaExponent ?? 2,
45
+ maxTranslation: config.maxTranslation ?? [0.6, 0.6, 0.4],
46
+ maxRotation: config.maxRotation ?? [0.06, 0.06, 0.12],
47
+ frequency: config.frequency ?? 18,
48
+ };
49
+ const now = config.now ?? Date.now;
50
+ let seed = randomSeedFrom(typeof config.seed === "number" ? config.seed : hashString(String(config.seed ?? "camera-shake")));
51
+ let trauma = 0;
52
+ let time = 0;
53
+ let kind;
54
+ let lastNow = null;
55
+ const listeners = new Set();
56
+ // Pooled offset — reused across offset() calls, never per-frame allocated.
57
+ const out = { x: 0, y: 0, z: 0, pitch: 0, yaw: 0, roll: 0 };
58
+ function notify() {
59
+ for (const listener of listeners)
60
+ listener();
61
+ }
62
+ return {
63
+ add(amount, impactKind) {
64
+ if (impactKind !== undefined)
65
+ kind = impactKind;
66
+ trauma = clamp01(trauma + amount);
67
+ notify();
68
+ },
69
+ update(dt) {
70
+ let seconds;
71
+ if (dt === undefined) {
72
+ const t = now();
73
+ seconds = lastNow === null ? 0 : Math.max(0, (t - lastNow) / 1000);
74
+ lastNow = t;
75
+ }
76
+ else {
77
+ seconds = Math.max(0, dt);
78
+ }
79
+ if (seconds <= 0)
80
+ return;
81
+ time += seconds;
82
+ trauma = clamp01(trauma - cfg.decayPerSecond * seconds);
83
+ notify();
84
+ },
85
+ offset() {
86
+ const shake = Math.pow(trauma, cfg.traumaExponent);
87
+ if (shake <= 0) {
88
+ out.x = out.y = out.z = out.pitch = out.yaw = out.roll = 0;
89
+ return out;
90
+ }
91
+ const [mtx, mty, mtz] = cfg.maxTranslation;
92
+ const [mrp, mry, mrr] = cfg.maxRotation;
93
+ const f = cfg.frequency;
94
+ // Each axis samples an independent seed offset + frequency so channels decorrelate.
95
+ out.x = mtx * shake * valueNoise(randomSeedFrom((seed ^ 0x0100) | 0), time * f * AXIS_FREQ[0]);
96
+ out.y = mty * shake * valueNoise(randomSeedFrom((seed ^ 0x0200) | 0), time * f * AXIS_FREQ[1]);
97
+ out.z = mtz * shake * valueNoise(randomSeedFrom((seed ^ 0x0300) | 0), time * f * AXIS_FREQ[2]);
98
+ out.pitch = mrp * shake * valueNoise(randomSeedFrom((seed ^ 0x0400) | 0), time * f * AXIS_FREQ[3]);
99
+ out.yaw = mry * shake * valueNoise(randomSeedFrom((seed ^ 0x0500) | 0), time * f * AXIS_FREQ[4]);
100
+ out.roll = mrr * shake * valueNoise(randomSeedFrom((seed ^ 0x0600) | 0), time * f * AXIS_FREQ[5]);
101
+ return out;
102
+ },
103
+ trauma() {
104
+ return trauma;
105
+ },
106
+ kind() {
107
+ return kind;
108
+ },
109
+ configure(patch) {
110
+ Object.assign(cfg, patch);
111
+ if (patch.seed !== undefined) {
112
+ seed = randomSeedFrom(typeof patch.seed === "number" ? patch.seed : hashString(String(patch.seed)));
113
+ }
114
+ notify();
115
+ },
116
+ clear() {
117
+ trauma = 0;
118
+ notify();
119
+ },
120
+ subscribe(listener) {
121
+ listeners.add(listener);
122
+ return () => listeners.delete(listener);
123
+ },
124
+ snapshot() {
125
+ return { trauma, time, seed: seed, kind: kind ?? null };
126
+ },
127
+ restore(snapshot) {
128
+ trauma = clamp01(snapshot.trauma);
129
+ time = snapshot.time;
130
+ seed = randomSeedFrom(snapshot.seed | 0);
131
+ kind = snapshot.kind ?? undefined;
132
+ lastNow = null;
133
+ notify();
134
+ },
135
+ };
136
+ }