@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,126 @@
1
+ /**
2
+ * An onboarding coach-mark run: an ordered sequence of hint steps, each shown as
3
+ * a centered callout or anchored to a UI element, with a persisted "seen" set so
4
+ * completed onboarding stays done across sessions, and data-first condition
5
+ * gating so a step waits until its trigger id is satisfied. Advance/skip/dismiss
6
+ * drive it; `snapshot`/`restore` round-trip the seen set through a save. Purely a
7
+ * model — a React host renders `current()`.
8
+ *
9
+ * @capability coach-marks ordered, gated, persist-once onboarding coach-marks/tutorial callouts — anchored or centered steps with a seen set and Next/Skip
10
+ */
11
+ export function createCoachMarkSequence(options) {
12
+ const steps = [...options.steps];
13
+ const now = options.now ?? Date.now;
14
+ const seenAtMap = new Map();
15
+ let satisfied = new Set(options.satisfied ?? []);
16
+ const listeners = new Set();
17
+ for (const id of options.seen ?? []) {
18
+ if (!seenAtMap.has(id))
19
+ seenAtMap.set(id, now());
20
+ }
21
+ function notify() {
22
+ for (const listener of listeners)
23
+ listener();
24
+ }
25
+ function markSeen(id) {
26
+ if (seenAtMap.has(id))
27
+ return false;
28
+ seenAtMap.set(id, now());
29
+ return true;
30
+ }
31
+ function eligible(step) {
32
+ if (seenAtMap.has(step.id))
33
+ return false;
34
+ return step.condition === undefined || satisfied.has(step.condition);
35
+ }
36
+ function remaining() {
37
+ let count = 0;
38
+ for (const step of steps)
39
+ if (!seenAtMap.has(step.id))
40
+ count += 1;
41
+ return count;
42
+ }
43
+ function current() {
44
+ for (let index = 0; index < steps.length; index += 1) {
45
+ const step = steps[index];
46
+ if (eligible(step)) {
47
+ return { step, index, total: steps.length, remaining: remaining() };
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+ return {
53
+ steps,
54
+ current,
55
+ advance() {
56
+ const active = current();
57
+ if (active !== null && markSeen(active.step.id))
58
+ notify();
59
+ return current();
60
+ },
61
+ next() {
62
+ return this.advance();
63
+ },
64
+ dismiss(id) {
65
+ if (markSeen(id))
66
+ notify();
67
+ },
68
+ skipAll() {
69
+ let changed = false;
70
+ for (const step of steps)
71
+ if (markSeen(step.id))
72
+ changed = true;
73
+ if (changed)
74
+ notify();
75
+ },
76
+ satisfy(...triggerIds) {
77
+ let changed = false;
78
+ for (const id of triggerIds) {
79
+ if (!satisfied.has(id)) {
80
+ satisfied.add(id);
81
+ changed = true;
82
+ }
83
+ }
84
+ if (changed)
85
+ notify();
86
+ },
87
+ setSatisfied(triggerIds) {
88
+ satisfied = new Set(triggerIds);
89
+ notify();
90
+ },
91
+ isTriggerSatisfied(triggerId) {
92
+ return satisfied.has(triggerId);
93
+ },
94
+ isSeen(id) {
95
+ return seenAtMap.has(id);
96
+ },
97
+ seenAt(id) {
98
+ return seenAtMap.get(id);
99
+ },
100
+ isComplete() {
101
+ return steps.every((step) => seenAtMap.has(step.id));
102
+ },
103
+ remaining,
104
+ subscribe(listener) {
105
+ listeners.add(listener);
106
+ return () => listeners.delete(listener);
107
+ },
108
+ snapshot() {
109
+ const seen = [];
110
+ const seenAt = {};
111
+ for (const [id, at] of seenAtMap) {
112
+ seen.push(id);
113
+ seenAt[id] = at;
114
+ }
115
+ return { seen, seenAt, satisfied: [...satisfied] };
116
+ },
117
+ restore(snapshot) {
118
+ seenAtMap.clear();
119
+ for (const id of snapshot.seen) {
120
+ seenAtMap.set(id, snapshot.seenAt[id] ?? now());
121
+ }
122
+ satisfied = new Set(snapshot.satisfied);
123
+ notify();
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,114 @@
1
+ /** A world-space anchor `[x, y, z]` a floating-text entry rises from. */
2
+ export type FloatingTextPosition = readonly [number, number, number];
3
+ /**
4
+ * One text pop to spawn: what to say, where in the world, and how it moves and
5
+ * fades. Everything but `position` and `text` is optional and falls back to the
6
+ * field's defaults. `kind` is a free-form tag (`"damage"`, `"heal"`, `"crit"`,
7
+ * `"xp"`, `"gold"`, `"bark"`, …) the presentation layer styles — the model never
8
+ * interprets it, so nothing here is combat- or genre-specific.
9
+ */
10
+ export interface FloatingTextDef {
11
+ /** World anchor the text rises from. */
12
+ position: FloatingTextPosition;
13
+ /** The text to show. */
14
+ text: string;
15
+ /** Free-form style tag the game colors/skins. Default `"default"`. */
16
+ kind?: string;
17
+ /** Optional CSS color hint carried straight through to the view (presentation may override per `kind`). */
18
+ color?: string;
19
+ /** Relative scale multiplier (before the birth pop-in). Default 1. */
20
+ size?: number;
21
+ /** Upward world-Y speed in units/second. Default = field `defaultRise`. */
22
+ rise?: number;
23
+ /** Horizontal world-X speed in units/second (signed). Default: a small seeded jitter in `±driftJitter`. */
24
+ drift?: number;
25
+ /** Lifetime in seconds. Default = field `defaultLifetime`. */
26
+ lifetime?: number;
27
+ }
28
+ /** Construction options for {@link createFloatingTextField}. */
29
+ export interface FloatingTextFieldConfig {
30
+ /** Hard cap on live entries; the pool never grows past this. When full, the oldest entry is recycled. Default 64. */
31
+ max?: number;
32
+ /** Injected wall clock in ms, stamped on each entry's `bornAt`. Default `Date.now`; inject for deterministic tests. */
33
+ now?: () => number;
34
+ /** Seed for the horizontal-drift jitter stream — same seed + same calls reproduce identical motion. Default `"floating-text"`. */
35
+ seed?: string | number;
36
+ /** Default lifetime in seconds when a def omits `lifetime`. Default 1.4. */
37
+ defaultLifetime?: number;
38
+ /** Default upward speed in units/second when a def omits `rise`. Default 1.2. */
39
+ defaultRise?: number;
40
+ /** Max magnitude of the seeded horizontal drift used when a def omits `drift`. Default 0.6. */
41
+ driftJitter?: number;
42
+ /** Fraction of life spent on the birth scale pop-in (0.6→1). Default 0.16. */
43
+ popIn?: number;
44
+ /** Fraction of life over which alpha fades to 0 at the end. Default 0.4. */
45
+ fadeFraction?: number;
46
+ }
47
+ /** A live entry resolved for rendering: current world position and fade/scale over life. */
48
+ export interface FloatingTextView {
49
+ /** Stable, monotonically increasing id assigned at emit — usable as a React key. */
50
+ id: number;
51
+ /** Current world position with rise (Y) and drift (X) applied. */
52
+ position: FloatingTextPosition;
53
+ /** The text to show. */
54
+ text: string;
55
+ /** The style tag (resolved; `"default"` when the def omitted `kind`). */
56
+ kind: string;
57
+ /** The def's color hint, if any. */
58
+ color?: string;
59
+ /** Resolved scale: base `size` times the current birth pop-in factor. */
60
+ size: number;
61
+ /** Opacity `0..1`, full until the fade window then easing to 0. */
62
+ alpha: number;
63
+ /** Age as a fraction of lifetime, `0..1`. */
64
+ progress: number;
65
+ /** Wall-clock ms the entry was emitted (from the injected `now`). */
66
+ bornAt: number;
67
+ }
68
+ /** Serializable state for save/restore and deterministic replay. */
69
+ export interface FloatingTextSnapshot {
70
+ seed: number;
71
+ nextId: number;
72
+ count: number;
73
+ /** Numeric per-entry columns, flattened `count * NUMERIC_STRIDE`. */
74
+ data: number[];
75
+ /** Per-entry text. */
76
+ text: string[];
77
+ /** Per-entry kind. */
78
+ kind: string[];
79
+ /** Per-entry color (empty string = none). */
80
+ color: string[];
81
+ }
82
+ /** A live, dt-driven field of world-anchored floating text. */
83
+ export interface FloatingTextField {
84
+ /** Spawn one entry; returns its id. When the pool is full the oldest live entry is recycled. */
85
+ emit(def: FloatingTextDef): number;
86
+ /** Advance every entry by `dt` seconds: age, rise, drift, and reap the expired. */
87
+ update(dt: number): void;
88
+ /** Snapshot of the live entries resolved for rendering (a fresh array each call). */
89
+ active(): FloatingTextView[];
90
+ /** Live entry count. */
91
+ count(): number;
92
+ /** Remove every entry (keeps the seed stream and id counter). */
93
+ clear(): void;
94
+ /** Subscribe to changes (emit/update/clear/restore); returns an unsubscribe. */
95
+ subscribe(listener: () => void): () => void;
96
+ snapshot(): FloatingTextSnapshot;
97
+ restore(snapshot: FloatingTextSnapshot): void;
98
+ }
99
+ /**
100
+ * A generic, allocation-aware field of world-anchored floating text: damage
101
+ * numbers, heals, crits, XP/gold gains, status pops, dialogue barks — anything a
102
+ * game wants to say at a point in the world for a moment. It is dt-driven (call
103
+ * `update(dt)` each frame) and deterministic: the only randomness is the
104
+ * horizontal drift jitter, which flows from the injected `seed`, so the same seed
105
+ * and the same emit/update sequence reproduce identical motion, and
106
+ * `snapshot`/`restore` round-trips the live pool. The model owns only the
107
+ * spawn → rise/drift → fade → reap lifecycle: `kind` is a free string and `color`
108
+ * a passthrough hint, so nothing here couples to combat or any genre. Presentation
109
+ * (projecting each entry to the screen and styling it per `kind`) lives in
110
+ * `@jgengine/react`'s `FloatingText`.
111
+ *
112
+ * @capability floating-text deterministic pooled world-anchored floating combat text / damage numbers with rise, drift, fade, and serializable state
113
+ */
114
+ export declare function createFloatingTextField(config?: FloatingTextFieldConfig): FloatingTextField;
@@ -0,0 +1,203 @@
1
+ import { hashString, randomSeedFrom, stepRandomSeed } from "../random/rng.js";
2
+ /** Columns per entry in the numeric SoA pool: id, ox, oy, oz, age, life, rise, drift, size. */
3
+ const NUMERIC_STRIDE = 9;
4
+ function clamp01(v) {
5
+ return v < 0 ? 0 : v > 1 ? 1 : v;
6
+ }
7
+ /**
8
+ * A generic, allocation-aware field of world-anchored floating text: damage
9
+ * numbers, heals, crits, XP/gold gains, status pops, dialogue barks — anything a
10
+ * game wants to say at a point in the world for a moment. It is dt-driven (call
11
+ * `update(dt)` each frame) and deterministic: the only randomness is the
12
+ * horizontal drift jitter, which flows from the injected `seed`, so the same seed
13
+ * and the same emit/update sequence reproduce identical motion, and
14
+ * `snapshot`/`restore` round-trips the live pool. The model owns only the
15
+ * spawn → rise/drift → fade → reap lifecycle: `kind` is a free string and `color`
16
+ * a passthrough hint, so nothing here couples to combat or any genre. Presentation
17
+ * (projecting each entry to the screen and styling it per `kind`) lives in
18
+ * `@jgengine/react`'s `FloatingText`.
19
+ *
20
+ * @capability floating-text deterministic pooled world-anchored floating combat text / damage numbers with rise, drift, fade, and serializable state
21
+ */
22
+ export function createFloatingTextField(config = {}) {
23
+ const max = Math.max(1, Math.floor(config.max ?? 64));
24
+ const now = config.now ?? (() => Date.now());
25
+ const defaultLifetime = config.defaultLifetime ?? 1.4;
26
+ const defaultRise = config.defaultRise ?? 1.2;
27
+ const driftJitter = config.driftJitter ?? 0.6;
28
+ const popIn = clamp01(config.popIn ?? 0.16);
29
+ const fadeFraction = clamp01(config.fadeFraction ?? 0.4);
30
+ // Numeric SoA columns.
31
+ const id = new Float64Array(max);
32
+ const ox = new Float32Array(max);
33
+ const oy = new Float32Array(max);
34
+ const oz = new Float32Array(max);
35
+ const age = new Float32Array(max);
36
+ const life = new Float32Array(max);
37
+ const rise = new Float32Array(max);
38
+ const drift = new Float32Array(max);
39
+ const size = new Float32Array(max);
40
+ const bornAt = new Float64Array(max);
41
+ // String columns (strings can't live in a typed array).
42
+ const text = new Array(max).fill("");
43
+ const kind = new Array(max).fill("");
44
+ const color = new Array(max).fill(undefined);
45
+ let live = 0;
46
+ let nextId = 1;
47
+ let seed = randomSeedFrom(typeof config.seed === "number" ? config.seed : hashString(String(config.seed ?? "floating-text")));
48
+ const listeners = new Set();
49
+ function notify() {
50
+ for (const listener of listeners)
51
+ listener();
52
+ }
53
+ function rand() {
54
+ const [value, next] = stepRandomSeed(seed);
55
+ seed = next;
56
+ return value;
57
+ }
58
+ /** Index of the oldest live entry (highest age) — the one recycled when the pool is full. */
59
+ function oldestIndex() {
60
+ let best = 0;
61
+ for (let i = 1; i < live; i++)
62
+ if (age[i] > age[best])
63
+ best = i;
64
+ return best;
65
+ }
66
+ function writeEntry(i, def) {
67
+ const entryId = nextId++;
68
+ id[i] = entryId;
69
+ ox[i] = def.position[0];
70
+ oy[i] = def.position[1];
71
+ oz[i] = def.position[2];
72
+ age[i] = 0;
73
+ life[i] = Math.max(0.0001, def.lifetime ?? defaultLifetime);
74
+ rise[i] = def.rise ?? defaultRise;
75
+ // A seeded per-entry drift keeps stacked numbers from overlapping; still deterministic.
76
+ drift[i] = def.drift ?? (rand() * 2 - 1) * driftJitter;
77
+ size[i] = def.size ?? 1;
78
+ bornAt[i] = now();
79
+ text[i] = def.text;
80
+ kind[i] = def.kind ?? "default";
81
+ color[i] = def.color;
82
+ return entryId;
83
+ }
84
+ function swapRemove(i) {
85
+ const last = live - 1;
86
+ if (i !== last) {
87
+ id[i] = id[last];
88
+ ox[i] = ox[last];
89
+ oy[i] = oy[last];
90
+ oz[i] = oz[last];
91
+ age[i] = age[last];
92
+ life[i] = life[last];
93
+ rise[i] = rise[last];
94
+ drift[i] = drift[last];
95
+ size[i] = size[last];
96
+ bornAt[i] = bornAt[last];
97
+ text[i] = text[last];
98
+ kind[i] = kind[last];
99
+ color[i] = color[last];
100
+ }
101
+ live--;
102
+ }
103
+ /** Current scale (base times birth pop-in) for entry `i`. */
104
+ function resolveScale(i) {
105
+ const t = clamp01(age[i] / life[i]);
106
+ const pop = popIn > 0 && t < popIn ? 0.6 + 0.4 * (t / popIn) : 1;
107
+ return size[i] * pop;
108
+ }
109
+ /** Current alpha (full until the fade window, then easing to 0) for entry `i`. */
110
+ function resolveAlpha(i) {
111
+ const t = clamp01(age[i] / life[i]);
112
+ if (fadeFraction <= 0)
113
+ return 1;
114
+ const fadeStart = 1 - fadeFraction;
115
+ return t <= fadeStart ? 1 : clamp01((1 - t) / fadeFraction);
116
+ }
117
+ const field = {
118
+ emit(def) {
119
+ const i = live < max ? live++ : oldestIndex();
120
+ const entryId = writeEntry(i, def);
121
+ notify();
122
+ return entryId;
123
+ },
124
+ update(dt) {
125
+ if (dt <= 0)
126
+ return;
127
+ for (let i = live - 1; i >= 0; i--) {
128
+ age[i] += dt;
129
+ if (age[i] >= life[i]) {
130
+ swapRemove(i);
131
+ }
132
+ }
133
+ notify();
134
+ },
135
+ active() {
136
+ const views = [];
137
+ for (let i = 0; i < live; i++) {
138
+ const a = age[i];
139
+ views.push({
140
+ id: id[i],
141
+ position: [ox[i] + drift[i] * a, oy[i] + rise[i] * a, oz[i]],
142
+ text: text[i],
143
+ kind: kind[i],
144
+ color: color[i],
145
+ size: resolveScale(i),
146
+ alpha: resolveAlpha(i),
147
+ progress: clamp01(a / life[i]),
148
+ bornAt: bornAt[i],
149
+ });
150
+ }
151
+ return views;
152
+ },
153
+ count() {
154
+ return live;
155
+ },
156
+ clear() {
157
+ live = 0;
158
+ notify();
159
+ },
160
+ subscribe(listener) {
161
+ listeners.add(listener);
162
+ return () => listeners.delete(listener);
163
+ },
164
+ snapshot() {
165
+ const data = [];
166
+ const outText = [];
167
+ const outKind = [];
168
+ const outColor = [];
169
+ for (let i = 0; i < live; i++) {
170
+ data.push(id[i], ox[i], oy[i], oz[i], age[i], life[i], rise[i], drift[i], size[i]);
171
+ outText.push(text[i]);
172
+ outKind.push(kind[i]);
173
+ outColor.push(color[i] ?? "");
174
+ }
175
+ return { seed: seed, nextId, count: live, data, text: outText, kind: outKind, color: outColor };
176
+ },
177
+ restore(snapshot) {
178
+ seed = randomSeedFrom(snapshot.seed);
179
+ nextId = snapshot.nextId;
180
+ live = Math.min(max, snapshot.count);
181
+ for (let i = 0; i < live; i++) {
182
+ const o = i * NUMERIC_STRIDE;
183
+ id[i] = snapshot.data[o];
184
+ ox[i] = snapshot.data[o + 1];
185
+ oy[i] = snapshot.data[o + 2];
186
+ oz[i] = snapshot.data[o + 3];
187
+ age[i] = snapshot.data[o + 4];
188
+ life[i] = snapshot.data[o + 5];
189
+ rise[i] = snapshot.data[o + 6];
190
+ drift[i] = snapshot.data[o + 7];
191
+ size[i] = snapshot.data[o + 8];
192
+ // bornAt is presentational only; it is not round-tripped, so re-stamp on restore.
193
+ bornAt[i] = now();
194
+ text[i] = snapshot.text[i];
195
+ kind[i] = snapshot.kind[i];
196
+ const c = snapshot.color[i];
197
+ color[i] = c === undefined || c === "" ? undefined : c;
198
+ }
199
+ notify();
200
+ },
201
+ };
202
+ return field;
203
+ }
@@ -37,7 +37,22 @@ export interface HudOverflow {
37
37
  }
38
38
  /** Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside). */
39
39
  export declare function rectOverflow(rect: HudRect, viewport: HudSize, tolerance?: number): Omit<HudOverflow, "id"> | null;
40
- /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
40
+ /**
41
+ * True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
42
+ * The boundary check uses this to tell a *shown* surface from a *closed* one: a
43
+ * mail drawer parked fully off-screen (or a menu that has not been opened) never
44
+ * intersects the viewport, so it is not "escaping" — it is simply not on screen.
45
+ * Only a surface that is partly visible and partly past an edge is a real defect.
46
+ *
47
+ * @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
48
+ */
49
+ export declare function rectIntersectsViewport(rect: HudRect, viewport: HudSize, minVisiblePx?: number): boolean;
50
+ /**
51
+ * Every panel rect that escapes the viewport — the data behind the HUD overflow
52
+ * gate. A rect is only reported when it is partly visible *and* crosses an edge;
53
+ * a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
54
+ * unopened menus never raise a false alarm.
55
+ */
41
56
  export declare function overflowingPanels(panels: readonly {
42
57
  id: string;
43
58
  rect: HudRect;
@@ -43,10 +43,31 @@ export function rectOverflow(rect, viewport, tolerance = 1.5) {
43
43
  const round = (n) => Math.round(n * 10) / 10;
44
44
  return { left: round(left), top: round(top), right: round(right), bottom: round(bottom) };
45
45
  }
46
- /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
46
+ /**
47
+ * True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
48
+ * The boundary check uses this to tell a *shown* surface from a *closed* one: a
49
+ * mail drawer parked fully off-screen (or a menu that has not been opened) never
50
+ * intersects the viewport, so it is not "escaping" — it is simply not on screen.
51
+ * Only a surface that is partly visible and partly past an edge is a real defect.
52
+ *
53
+ * @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
54
+ */
55
+ export function rectIntersectsViewport(rect, viewport, minVisiblePx = 1) {
56
+ const visibleWidth = Math.min(rect.x + rect.width, viewport.width) - Math.max(rect.x, 0);
57
+ const visibleHeight = Math.min(rect.y + rect.height, viewport.height) - Math.max(rect.y, 0);
58
+ return visibleWidth >= minVisiblePx && visibleHeight >= minVisiblePx;
59
+ }
60
+ /**
61
+ * Every panel rect that escapes the viewport — the data behind the HUD overflow
62
+ * gate. A rect is only reported when it is partly visible *and* crosses an edge;
63
+ * a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
64
+ * unopened menus never raise a false alarm.
65
+ */
47
66
  export function overflowingPanels(panels, viewport, tolerance = 1.5) {
48
67
  const out = [];
49
68
  for (const panel of panels) {
69
+ if (!rectIntersectsViewport(panel.rect, viewport))
70
+ continue;
50
71
  const overflow = rectOverflow(panel.rect, viewport, tolerance);
51
72
  if (overflow !== null)
52
73
  out.push({ id: panel.id, ...overflow });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Headless, serializable model for a stack of modal dialogs — the data layer behind a pause menu, a
3
+ * "quit to menu?" confirmation, a level-up popup, or any blocking overlay a game raises over its
4
+ * scene. It carries an ordered stack of opaque {@link ModalRecord}s (push to open, pop/resolve to
5
+ * close) and nothing about how they look: the model NEVER interprets a record's `kind` or a
6
+ * resolution's `result` string — those are free-form game vocabulary the React chrome in
7
+ * `@jgengine/react` renders. Observable ({@link ModalStack.subscribe}) and round-trips through a save
8
+ * ({@link ModalStack.snapshot}/{@link ModalStack.restore}). An injected clock powers optional
9
+ * per-modal auto-dismiss timers; leave those unset and the model is pure and clockless.
10
+ */
11
+ /**
12
+ * One entry on the modal stack — a caller-authored, opaque record the game renders however it likes.
13
+ * `kind` and `payload` are never inspected by the model, so a confirm dialog, a pause menu, and a
14
+ * bespoke reward popup all share this shape.
15
+ */
16
+ export interface ModalRecord {
17
+ /** Stable id, unique within the stack. Auto-assigned (`modal-<n>`) by {@link ModalStack.push} when omitted. */
18
+ id: string;
19
+ /** Free-form game style tag the renderer switches on (e.g. `"confirm"`, `"pause"`). Never interpreted here. */
20
+ kind: string;
21
+ /** Arbitrary serializable data for the renderer (title/body/button labels, context ids, …). */
22
+ payload?: unknown;
23
+ /**
24
+ * Auto-dismiss the modal this many ms after it opens, resolved by {@link ModalStack.tick} against
25
+ * the injected clock. Omit for a modal that only closes on an explicit pop/resolve.
26
+ */
27
+ timeoutMs?: number;
28
+ /** Result delivered when {@link ModalRecord.timeoutMs} fires. Default {@link MODAL_CANCEL}. */
29
+ timeoutResult?: string;
30
+ }
31
+ /** A modal to push — {@link ModalRecord} with an optional id (auto-assigned when absent). */
32
+ export type ModalInput = Omit<ModalRecord, "id"> & {
33
+ id?: string;
34
+ };
35
+ /**
36
+ * The outcome of closing a modal via {@link ModalStack.resolve} (or an auto-dismiss) — the closed
37
+ * record's identity plus the free-form `result` string the caller chose. Delivered to the
38
+ * `onResolve` option and stashed as {@link ModalStack.lastResolution}.
39
+ */
40
+ export interface ModalResolution {
41
+ /** The resolved record's id. */
42
+ id: string;
43
+ /** The resolved record's kind. */
44
+ kind: string;
45
+ /** Free-form outcome — e.g. {@link MODAL_CONFIRM}, {@link MODAL_CANCEL}, or a game-specific choice. Never interpreted here. */
46
+ result: string;
47
+ /** The resolved record's payload, echoed back so a handler needn't re-look-it-up. */
48
+ payload?: unknown;
49
+ /** Clock time (ms) the modal resolved, from the injected clock. */
50
+ at: number;
51
+ }
52
+ /** A serialized stack entry: the opaque record plus the clock time it opened (for timer restore). */
53
+ export interface ModalStackEntry {
54
+ record: ModalRecord;
55
+ /** Clock time (ms) this modal was pushed — the base for its auto-dismiss timer. */
56
+ openedAt: number;
57
+ }
58
+ /** Serializable state — the ordered entries (bottom-first) and the id counter. Plain JSON. */
59
+ export interface ModalStackSnapshot {
60
+ /** Stack entries from bottom to top; the last is the active (topmost) modal. */
61
+ entries: readonly ModalStackEntry[];
62
+ /** Next auto-id counter, so restored stacks keep generating unique ids. */
63
+ seq: number;
64
+ }
65
+ /** Options for {@link createModalStack}. */
66
+ export interface ModalStackOptions {
67
+ /** Injected clock (ms) for timestamps and auto-dismiss timers. Default `Date.now`. */
68
+ now?: () => number;
69
+ /** Modals to open at construction, bottom-first. */
70
+ initial?: readonly ModalInput[];
71
+ /** Called whenever a modal is pushed (opened). */
72
+ onOpen?: (record: ModalRecord) => void;
73
+ /** Called whenever a modal closes via {@link ModalStack.resolve}, {@link ModalStack.pop}, or an auto-dismiss. */
74
+ onResolve?: (resolution: ModalResolution) => void;
75
+ }
76
+ /** Conventional confirm result for a two-button dialog — a shared string, not model behavior. */
77
+ export declare const MODAL_CONFIRM = "confirm";
78
+ /** Conventional cancel/dismiss result (also the default auto-dismiss and pop result). */
79
+ export declare const MODAL_CANCEL = "cancel";
80
+ /** An observable, serializable stack of modal dialogs. See {@link createModalStack}. */
81
+ export interface ModalStack {
82
+ /**
83
+ * Push a modal onto the top of the stack and return the stored record (with its resolved id).
84
+ * Fires `onOpen`.
85
+ */
86
+ push(modal: ModalInput): ModalRecord;
87
+ /**
88
+ * Pop the topmost modal without a caller result — it resolves with {@link MODAL_CANCEL} (the ESC /
89
+ * backdrop-click behavior). Returns the removed record, or `null` when the stack is empty.
90
+ */
91
+ pop(): ModalRecord | null;
92
+ /**
93
+ * Resolve a modal with a free-form `result` and remove it: the top by default, or the entry with
94
+ * `options.id` from anywhere in the stack. Fires `onResolve` and records {@link ModalStack.lastResolution}.
95
+ * Returns the resolution, or `null` when the stack is empty or the id is unknown.
96
+ */
97
+ resolve(result: string, options?: {
98
+ id?: string;
99
+ payload?: unknown;
100
+ }): ModalResolution | null;
101
+ /** The active (topmost) modal, or `null` when nothing is open. */
102
+ top(): ModalRecord | null;
103
+ /** Whether any modal is open (`depth() > 0`) — a game gates input/pause on this. */
104
+ isOpen(): boolean;
105
+ /** Whether a modal with the given id is anywhere in the stack. */
106
+ has(id: string): boolean;
107
+ /** Number of stacked modals. */
108
+ depth(): number;
109
+ /** The full stack, bottom-first (last is the active modal). */
110
+ stack(): readonly ModalRecord[];
111
+ /** The most recent resolution (survives the modal being closed), or `null` if none yet. */
112
+ lastResolution(): ModalResolution | null;
113
+ /** Close every open modal, each resolving with {@link MODAL_CANCEL} (top-first). */
114
+ clear(): void;
115
+ /**
116
+ * Resolve every modal whose {@link ModalRecord.timeoutMs} has elapsed against `now` (default: the
117
+ * injected clock), top-first, using each record's `timeoutResult`. Call it from the game loop when
118
+ * any modal uses a timer. Returns the resolutions fired (possibly empty).
119
+ */
120
+ tick(now?: number): ModalResolution[];
121
+ /**
122
+ * Milliseconds until the given modal (default: the top) auto-dismisses, or `null` when it has no
123
+ * timer / is absent. Clamped at 0 once elapsed — feed a countdown ring off it.
124
+ */
125
+ timeRemaining(id?: string): number | null;
126
+ /** Observe every change (push/pop/resolve/clear/tick/restore). Returns an unsubscribe fn. */
127
+ subscribe(listener: () => void): () => void;
128
+ /** Serializable state for a save. */
129
+ snapshot(): ModalStackSnapshot;
130
+ /** Restore from a {@link ModalStackSnapshot}, replacing the current stack. */
131
+ restore(snapshot: ModalStackSnapshot): void;
132
+ }
133
+ /**
134
+ * A stack of modal dialogs: push opaque records to open blocking overlays (a pause menu, a confirm
135
+ * prompt, a reward popup), pop or resolve them with a free-form result to close, and query the top /
136
+ * open / depth to gate game input. The model is deliberately vocabulary-free — it never reads a
137
+ * record's `kind` or a resolution's `result`, so a game owns every dialog style — and fully
138
+ * serializable, with an optional injected clock driving per-modal auto-dismiss. A React host renders
139
+ * `top()`; `subscribe` re-renders it, `snapshot`/`restore` round-trip it through a save.
140
+ *
141
+ * @capability modal-stack serializable, observable stack of opaque modal/dialog records — push/pop/resolve blocking overlays (pause menu, confirm dialog) with optional auto-dismiss; never interprets kind or result
142
+ */
143
+ export declare function createModalStack(options?: ModalStackOptions): ModalStack;