@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,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;
@@ -0,0 +1,160 @@
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
+ /** Conventional confirm result for a two-button dialog — a shared string, not model behavior. */
12
+ export const MODAL_CONFIRM = "confirm";
13
+ /** Conventional cancel/dismiss result (also the default auto-dismiss and pop result). */
14
+ export const MODAL_CANCEL = "cancel";
15
+ /**
16
+ * A stack of modal dialogs: push opaque records to open blocking overlays (a pause menu, a confirm
17
+ * prompt, a reward popup), pop or resolve them with a free-form result to close, and query the top /
18
+ * open / depth to gate game input. The model is deliberately vocabulary-free — it never reads a
19
+ * record's `kind` or a resolution's `result`, so a game owns every dialog style — and fully
20
+ * serializable, with an optional injected clock driving per-modal auto-dismiss. A React host renders
21
+ * `top()`; `subscribe` re-renders it, `snapshot`/`restore` round-trip it through a save.
22
+ *
23
+ * @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
24
+ */
25
+ export function createModalStack(options = {}) {
26
+ const now = options.now ?? Date.now;
27
+ const onOpen = options.onOpen;
28
+ const onResolve = options.onResolve;
29
+ const listeners = new Set();
30
+ let entries = [];
31
+ let seq = 0;
32
+ let last = null;
33
+ function notify() {
34
+ for (const listener of listeners)
35
+ listener();
36
+ }
37
+ function nextId() {
38
+ return `modal-${seq++}`;
39
+ }
40
+ function toRecord(input) {
41
+ const record = { id: input.id ?? nextId(), kind: input.kind };
42
+ if (input.payload !== undefined)
43
+ record.payload = input.payload;
44
+ if (input.timeoutMs !== undefined)
45
+ record.timeoutMs = input.timeoutMs;
46
+ if (input.timeoutResult !== undefined)
47
+ record.timeoutResult = input.timeoutResult;
48
+ return record;
49
+ }
50
+ /** @internal Remove the entry at `index` and produce+record its resolution. Does not notify. */
51
+ function resolveAt(index, result, payload, at = now()) {
52
+ const [entry] = entries.splice(index, 1);
53
+ const record = entry.record;
54
+ const resolution = {
55
+ id: record.id,
56
+ kind: record.kind,
57
+ result,
58
+ at,
59
+ };
60
+ const echo = payload !== undefined ? payload : record.payload;
61
+ if (echo !== undefined)
62
+ resolution.payload = echo;
63
+ last = resolution;
64
+ onResolve?.(resolution);
65
+ return resolution;
66
+ }
67
+ function push(input) {
68
+ const record = toRecord(input);
69
+ entries.push({ record, openedAt: now() });
70
+ onOpen?.(record);
71
+ notify();
72
+ return record;
73
+ }
74
+ for (const input of options.initial ?? [])
75
+ push(input);
76
+ return {
77
+ push,
78
+ pop() {
79
+ if (entries.length === 0)
80
+ return null;
81
+ const record = entries[entries.length - 1].record;
82
+ resolveAt(entries.length - 1, MODAL_CANCEL);
83
+ notify();
84
+ return record;
85
+ },
86
+ resolve(result, opts) {
87
+ const index = opts?.id !== undefined
88
+ ? entries.findIndex((entry) => entry.record.id === opts.id)
89
+ : entries.length - 1;
90
+ if (index < 0)
91
+ return null;
92
+ const resolution = resolveAt(index, result, opts?.payload);
93
+ notify();
94
+ return resolution;
95
+ },
96
+ top() {
97
+ return entries.length === 0 ? null : entries[entries.length - 1].record;
98
+ },
99
+ isOpen() {
100
+ return entries.length > 0;
101
+ },
102
+ has(id) {
103
+ return entries.some((entry) => entry.record.id === id);
104
+ },
105
+ depth() {
106
+ return entries.length;
107
+ },
108
+ stack() {
109
+ return entries.map((entry) => entry.record);
110
+ },
111
+ lastResolution() {
112
+ return last;
113
+ },
114
+ clear() {
115
+ if (entries.length === 0)
116
+ return;
117
+ while (entries.length > 0)
118
+ resolveAt(entries.length - 1, MODAL_CANCEL);
119
+ notify();
120
+ },
121
+ tick(nowOverride) {
122
+ const at = nowOverride ?? now();
123
+ const fired = [];
124
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
125
+ const entry = entries[i];
126
+ const timeout = entry.record.timeoutMs;
127
+ if (timeout !== undefined && at - entry.openedAt >= timeout) {
128
+ fired.push(resolveAt(i, entry.record.timeoutResult ?? MODAL_CANCEL, undefined, at));
129
+ }
130
+ }
131
+ if (fired.length > 0)
132
+ notify();
133
+ return fired;
134
+ },
135
+ timeRemaining(id) {
136
+ const entry = id !== undefined
137
+ ? entries.find((e) => e.record.id === id)
138
+ : entries[entries.length - 1];
139
+ if (entry === undefined || entry.record.timeoutMs === undefined)
140
+ return null;
141
+ return Math.max(0, entry.openedAt + entry.record.timeoutMs - now());
142
+ },
143
+ subscribe(listener) {
144
+ listeners.add(listener);
145
+ return () => listeners.delete(listener);
146
+ },
147
+ snapshot() {
148
+ return {
149
+ entries: entries.map((entry) => ({ record: { ...entry.record }, openedAt: entry.openedAt })),
150
+ seq,
151
+ };
152
+ },
153
+ restore(snapshot) {
154
+ entries = snapshot.entries.map((entry) => ({ record: { ...entry.record }, openedAt: entry.openedAt }));
155
+ seq = snapshot.seq;
156
+ last = null;
157
+ notify();
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Which part of its life a banner is in: flying `"in"`, holding centered, or
3
+ * fading `"out"`. Progress `0..1` (see {@link ObjectiveBannerView.progress})
4
+ * runs within whichever phase is active — the renderer maps phase + progress to
5
+ * opacity/scale/translate however it likes.
6
+ */
7
+ export type ObjectiveBannerPhase = "in" | "hold" | "out";
8
+ /** One announcement request handed to {@link ObjectiveBannerController.announce}. */
9
+ export interface ObjectiveBannerAnnouncement {
10
+ /** The big centered headline (e.g. `"WAVE 3"`, `"VICTORY"`). */
11
+ title: string;
12
+ /** Optional smaller line under the title (e.g. `"Defend the core"`). */
13
+ subtitle?: string;
14
+ /**
15
+ * Free-string label the game owns and styles ("wave", "victory", "defeat",
16
+ * "objective", …); the model never interprets it — it only carries it through
17
+ * to the renderer so a game can color/skin per kind. Default `"default"`.
18
+ */
19
+ kind?: string;
20
+ /** Fly-in duration in ms. Default `350`. */
21
+ inMs?: number;
22
+ /** Centered hold duration in ms. Default `2000`. */
23
+ holdMs?: number;
24
+ /** Fade-out duration in ms. Default `500`. */
25
+ outMs?: number;
26
+ }
27
+ /**
28
+ * The banner to draw right now: its content, the game-owned `kind`, the current
29
+ * {@link ObjectiveBannerPhase}, and `progress` `0..1` through that phase. This
30
+ * object is pooled and overwritten on the next {@link ObjectiveBannerController.current}
31
+ * call — read it, don't retain it.
32
+ */
33
+ export interface ObjectiveBannerView {
34
+ /** Stable id assigned at announce time. */
35
+ id: number;
36
+ /** The headline. */
37
+ title: string;
38
+ /** The optional subtitle, or `undefined`. */
39
+ subtitle: string | undefined;
40
+ /** The game-owned free-string label. */
41
+ kind: string;
42
+ /** Which phase the banner is in. */
43
+ phase: ObjectiveBannerPhase;
44
+ /** Progress `0..1` within the current phase. */
45
+ progress: number;
46
+ }
47
+ /** A persisted banner record — all announcement fields resolved, plus id and schedule. */
48
+ export interface StoredObjectiveBanner {
49
+ id: number;
50
+ title: string;
51
+ subtitle: string | undefined;
52
+ kind: string;
53
+ inMs: number;
54
+ holdMs: number;
55
+ outMs: number;
56
+ /** Clock time (ms) this banner started its fly-in, or `null` while still queued. */
57
+ startAt: number | null;
58
+ }
59
+ /** Serializable state of the queue and the active banner, for save/restore. */
60
+ export interface ObjectiveBannerSnapshot {
61
+ /** Wall-clock (ms) the snapshot was taken, so restore re-anchors elapsed time. */
62
+ now: number;
63
+ /** Next id to hand out. */
64
+ nextId: number;
65
+ /** The active banner (head) followed by everything still queued, in order. */
66
+ queue: readonly StoredObjectiveBanner[];
67
+ }
68
+ /** A live, clock-driven objective/stage banner controller. */
69
+ export interface ObjectiveBannerController {
70
+ /**
71
+ * Enqueue a banner. If nothing is showing it starts flying in immediately;
72
+ * otherwise it waits its turn (one banner shows at a time). Returns the new id.
73
+ */
74
+ announce(announcement: ObjectiveBannerAnnouncement): number;
75
+ /**
76
+ * Advance the clock: transition the active banner through in → hold → out,
77
+ * reap it when its life ends, and start the next queued banner. Pass an explicit
78
+ * `nowMs` to drive deterministically, else the injected clock is read. Notifies
79
+ * subscribers. Call it each frame from the renderer.
80
+ */
81
+ advance(nowMs?: number): void;
82
+ /** The banner to draw right now (pooled — do not retain), or `null` when idle. */
83
+ current(): ObjectiveBannerView | null;
84
+ /** Dismiss the active banner immediately and start the next queued one, if any. */
85
+ skip(): void;
86
+ /** Drop the active banner and everything queued. */
87
+ clear(): void;
88
+ /** How many banners are waiting behind the active one. */
89
+ pending(): number;
90
+ /** Whether a banner is currently active. */
91
+ isActive(): boolean;
92
+ /** Observe changes (announce, advance, skip, clear, restore). Returns an unsubscribe fn. */
93
+ subscribe(listener: () => void): () => void;
94
+ /** Serializable state for a save. */
95
+ snapshot(): ObjectiveBannerSnapshot;
96
+ /** Restore from an {@link ObjectiveBannerSnapshot}. */
97
+ restore(snapshot: ObjectiveBannerSnapshot): void;
98
+ }
99
+ /** Options for {@link createObjectiveBanner}. */
100
+ export interface ObjectiveBannerOptions {
101
+ /** Injected clock (ms). Default `Date.now`. */
102
+ now?: () => number;
103
+ /** Default fly-in ms for announcements that omit `inMs`. Default `350`. */
104
+ inMs?: number;
105
+ /** Default hold ms for announcements that omit `holdMs`. Default `2000`. */
106
+ holdMs?: number;
107
+ /** Default fade-out ms for announcements that omit `outMs`. Default `500`. */
108
+ outMs?: number;
109
+ }
110
+ /**
111
+ * A serializable, clock-driven stage/objective banner controller: the classic
112
+ * transient centered title stamp ("WAVE 3", "VICTORY", "OBJECTIVE COMPLETE"). A
113
+ * game calls `announce({ title, subtitle?, kind? })` and the controller queues it,
114
+ * flies it in, holds it, and fades it out on an injected clock — one banner at a
115
+ * time — exposing the current banner plus its phase (in/hold/out) and `progress`
116
+ * `0..1` for a renderer to animate opacity/scale/translate. Nothing here is
117
+ * genre-specific: `kind` is a free label the game owns and styles, and all timings
118
+ * are parameters. Call `advance()` each frame; `current()` returns the pooled view
119
+ * to draw; `snapshot`/`restore` round-trips the queue through a save.
120
+ *
121
+ * @capability objective-banner serializable, clock-driven stage/objective banner queue — flies a big transient centered title + optional subtitle in, holds, and fades out one at a time, with phase + progress for animation, free-string kinds the game styles, and snapshot/restore
122
+ */
123
+ export declare function createObjectiveBanner(options?: ObjectiveBannerOptions): ObjectiveBannerController;
@@ -0,0 +1,162 @@
1
+ function clamp01(v) {
2
+ return v < 0 ? 0 : v > 1 ? 1 : v;
3
+ }
4
+ /**
5
+ * A serializable, clock-driven stage/objective banner controller: the classic
6
+ * transient centered title stamp ("WAVE 3", "VICTORY", "OBJECTIVE COMPLETE"). A
7
+ * game calls `announce({ title, subtitle?, kind? })` and the controller queues it,
8
+ * flies it in, holds it, and fades it out on an injected clock — one banner at a
9
+ * time — exposing the current banner plus its phase (in/hold/out) and `progress`
10
+ * `0..1` for a renderer to animate opacity/scale/translate. Nothing here is
11
+ * genre-specific: `kind` is a free label the game owns and styles, and all timings
12
+ * are parameters. Call `advance()` each frame; `current()` returns the pooled view
13
+ * to draw; `snapshot`/`restore` round-trips the queue through a save.
14
+ *
15
+ * @capability objective-banner serializable, clock-driven stage/objective banner queue — flies a big transient centered title + optional subtitle in, holds, and fades out one at a time, with phase + progress for animation, free-string kinds the game styles, and snapshot/restore
16
+ */
17
+ export function createObjectiveBanner(options = {}) {
18
+ const now = options.now ?? Date.now;
19
+ const defaultIn = Math.max(0, options.inMs ?? 350);
20
+ const defaultHold = Math.max(0, options.holdMs ?? 2000);
21
+ const defaultOut = Math.max(0, options.outMs ?? 500);
22
+ // queue[0] is the active banner; the rest wait their turn.
23
+ const queue = [];
24
+ const listeners = new Set();
25
+ // Pooled view — reused across current() calls, never per-frame allocated.
26
+ const view = { id: 0, title: "", subtitle: undefined, kind: "", phase: "in", progress: 0 };
27
+ let nextId = 1;
28
+ function notify() {
29
+ for (const listener of listeners)
30
+ listener();
31
+ }
32
+ /** Total lifetime of a banner in ms. */
33
+ function totalMs(banner) {
34
+ return banner.inMs + banner.holdMs + banner.outMs;
35
+ }
36
+ /**
37
+ * Activate/chain the head, reap any that have fully expired at `t`, and cache
38
+ * the active banner's phase + progress. Deterministic regardless of how often
39
+ * it is called: a finished banner hands its scheduled end time to the next.
40
+ */
41
+ function recompute(t) {
42
+ while (queue.length > 0) {
43
+ const head = queue[0];
44
+ if (head.startAt === null)
45
+ head.startAt = t;
46
+ const total = totalMs(head);
47
+ const elapsed = t - head.startAt;
48
+ if (total <= 0 || elapsed >= total) {
49
+ queue.shift();
50
+ const next = queue[0];
51
+ if (next !== undefined && next.startAt === null)
52
+ next.startAt = head.startAt + total;
53
+ continue;
54
+ }
55
+ if (elapsed < head.inMs) {
56
+ head.phase = "in";
57
+ head.progress = head.inMs > 0 ? clamp01(elapsed / head.inMs) : 1;
58
+ }
59
+ else if (elapsed < head.inMs + head.holdMs) {
60
+ head.phase = "hold";
61
+ head.progress = head.holdMs > 0 ? clamp01((elapsed - head.inMs) / head.holdMs) : 1;
62
+ }
63
+ else {
64
+ head.phase = "out";
65
+ head.progress = head.outMs > 0 ? clamp01((elapsed - head.inMs - head.holdMs) / head.outMs) : 1;
66
+ }
67
+ return;
68
+ }
69
+ }
70
+ return {
71
+ announce(announcement) {
72
+ const id = nextId++;
73
+ queue.push({
74
+ id,
75
+ title: announcement.title,
76
+ subtitle: announcement.subtitle,
77
+ kind: announcement.kind ?? "default",
78
+ inMs: Math.max(0, announcement.inMs ?? defaultIn),
79
+ holdMs: Math.max(0, announcement.holdMs ?? defaultHold),
80
+ outMs: Math.max(0, announcement.outMs ?? defaultOut),
81
+ startAt: null,
82
+ phase: "in",
83
+ progress: 0,
84
+ });
85
+ recompute(now());
86
+ notify();
87
+ return id;
88
+ },
89
+ advance(nowMs) {
90
+ recompute(nowMs ?? now());
91
+ notify();
92
+ },
93
+ current() {
94
+ const head = queue[0];
95
+ if (head === undefined || head.startAt === null)
96
+ return null;
97
+ view.id = head.id;
98
+ view.title = head.title;
99
+ view.subtitle = head.subtitle;
100
+ view.kind = head.kind;
101
+ view.phase = head.phase;
102
+ view.progress = head.progress;
103
+ return view;
104
+ },
105
+ skip() {
106
+ if (queue.length === 0)
107
+ return;
108
+ queue.shift();
109
+ recompute(now());
110
+ notify();
111
+ },
112
+ clear() {
113
+ if (queue.length === 0)
114
+ return;
115
+ queue.length = 0;
116
+ notify();
117
+ },
118
+ pending() {
119
+ return queue.length === 0 ? 0 : queue.length - 1;
120
+ },
121
+ isActive() {
122
+ const head = queue[0];
123
+ return head !== undefined && head.startAt !== null;
124
+ },
125
+ subscribe(listener) {
126
+ listeners.add(listener);
127
+ return () => listeners.delete(listener);
128
+ },
129
+ snapshot() {
130
+ return {
131
+ now: now(),
132
+ nextId,
133
+ queue: queue.map((banner) => ({
134
+ id: banner.id,
135
+ title: banner.title,
136
+ subtitle: banner.subtitle,
137
+ kind: banner.kind,
138
+ inMs: banner.inMs,
139
+ holdMs: banner.holdMs,
140
+ outMs: banner.outMs,
141
+ startAt: banner.startAt,
142
+ })),
143
+ };
144
+ },
145
+ restore(snapshot) {
146
+ queue.length = 0;
147
+ nextId = snapshot.nextId;
148
+ const t = now();
149
+ const drift = t - snapshot.now;
150
+ for (const stored of snapshot.queue) {
151
+ queue.push({
152
+ ...stored,
153
+ startAt: stored.startAt === null ? null : stored.startAt + drift,
154
+ phase: "in",
155
+ progress: 0,
156
+ });
157
+ }
158
+ recompute(t);
159
+ notify();
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,32 @@
1
+ /** Serializable photo-mode preferences a game reads to compose its capture UI. */
2
+ export interface PhotoModeState {
3
+ /** Photo mode is engaged (game frees the camera / pauses as it sees fit). */
4
+ active: boolean;
5
+ /** Hide the gameplay HUD for a clean shot. */
6
+ hideHud: boolean;
7
+ }
8
+ /** The out-of-the-box photo-mode state: not active, HUD hidden when it engages. */
9
+ export declare const DEFAULT_PHOTO_MODE: PhotoModeState;
10
+ /** Observable photo-mode store — genre-agnostic state a game binds its capture flow to. */
11
+ export interface PhotoModeStore {
12
+ get(): PhotoModeState;
13
+ /** Engage photo mode. */
14
+ enter(): void;
15
+ /** Leave photo mode. */
16
+ exit(): void;
17
+ toggle(): void;
18
+ setHideHud(hide: boolean): void;
19
+ set(patch: Partial<PhotoModeState>): void;
20
+ subscribe(listener: () => void): () => void;
21
+ snapshot(): PhotoModeState;
22
+ restore(snapshot: Partial<PhotoModeState>): void;
23
+ }
24
+ /**
25
+ * Serializable, observable photo-mode state (active + hide-HUD). Genre-agnostic
26
+ * plumbing — a game engages it to free its camera and hide gameplay chrome for a
27
+ * clean capture, and reads `hideHud` to conditionally render its HUD. Pair with
28
+ * React `PhotoModeControls` and shell `captureCanvas`.
29
+ *
30
+ * @capability photo-mode observable photo-mode state (active + hide-HUD) a game binds its screenshot/camera flow to
31
+ */
32
+ export declare function createPhotoModeStore(initial?: Partial<PhotoModeState>): PhotoModeStore;
@@ -0,0 +1,51 @@
1
+ /** The out-of-the-box photo-mode state: not active, HUD hidden when it engages. */
2
+ export const DEFAULT_PHOTO_MODE = { active: false, hideHud: true };
3
+ /**
4
+ * Serializable, observable photo-mode state (active + hide-HUD). Genre-agnostic
5
+ * plumbing — a game engages it to free its camera and hide gameplay chrome for a
6
+ * clean capture, and reads `hideHud` to conditionally render its HUD. Pair with
7
+ * React `PhotoModeControls` and shell `captureCanvas`.
8
+ *
9
+ * @capability photo-mode observable photo-mode state (active + hide-HUD) a game binds its screenshot/camera flow to
10
+ */
11
+ export function createPhotoModeStore(initial = {}) {
12
+ let state = { ...DEFAULT_PHOTO_MODE, ...initial };
13
+ const listeners = new Set();
14
+ function commit(next) {
15
+ if (next.active === state.active && next.hideHud === state.hideHud)
16
+ return;
17
+ state = next;
18
+ for (const listener of listeners)
19
+ listener();
20
+ }
21
+ return {
22
+ get() {
23
+ return state;
24
+ },
25
+ enter() {
26
+ commit({ ...state, active: true });
27
+ },
28
+ exit() {
29
+ commit({ ...state, active: false });
30
+ },
31
+ toggle() {
32
+ commit({ ...state, active: !state.active });
33
+ },
34
+ setHideHud(hide) {
35
+ commit({ ...state, hideHud: hide });
36
+ },
37
+ set(patch) {
38
+ commit({ ...state, ...patch });
39
+ },
40
+ subscribe(listener) {
41
+ listeners.add(listener);
42
+ return () => listeners.delete(listener);
43
+ },
44
+ snapshot() {
45
+ return state;
46
+ },
47
+ restore(snapshot) {
48
+ commit({ ...DEFAULT_PHOTO_MODE, ...snapshot });
49
+ },
50
+ };
51
+ }