@jgengine/core 0.14.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 (209) hide show
  1. package/CHANGELOG.md +483 -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 +1 -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/gameContext.js +6 -1
  106. package/dist/runtime/gameContextTypes.d.ts +18 -0
  107. package/dist/runtime/inputSnapshot.d.ts +10 -1
  108. package/dist/runtime/inputSnapshot.js +13 -1
  109. package/dist/runtime/runtimeSave.d.ts +3 -3
  110. package/dist/runtime/runtimeSave.js +11 -2
  111. package/dist/scene/assetDiagnostics.js +2 -2
  112. package/dist/scene/autoTarget.js +2 -1
  113. package/dist/scene/captureCheck.js +3 -2
  114. package/dist/scene/entityStore.d.ts +4 -1
  115. package/dist/scene/entityStore.js +3 -0
  116. package/dist/scene/objectStore.d.ts +10 -0
  117. package/dist/scene/objectStore.js +29 -6
  118. package/dist/scene/sceneKinds.d.ts +18 -0
  119. package/dist/scene/sceneKinds.js +19 -0
  120. package/dist/scene/sequenceDirector.d.ts +110 -0
  121. package/dist/scene/sequenceDirector.js +146 -0
  122. package/dist/session/roles.js +3 -2
  123. package/dist/settings/settingsModel.d.ts +1 -0
  124. package/dist/settings/settingsModel.js +1 -0
  125. package/dist/stats/rollCheck.js +4 -2
  126. package/dist/time/dayNightCycle.d.ts +114 -0
  127. package/dist/time/dayNightCycle.js +208 -0
  128. package/dist/time/timerSet.d.ts +132 -0
  129. package/dist/time/timerSet.js +243 -0
  130. package/dist/ui/accessibility.d.ts +51 -0
  131. package/dist/ui/accessibility.js +83 -0
  132. package/dist/ui/coachMarks.d.ts +99 -0
  133. package/dist/ui/coachMarks.js +126 -0
  134. package/dist/ui/floatingText.d.ts +114 -0
  135. package/dist/ui/floatingText.js +203 -0
  136. package/dist/ui/modalStack.d.ts +143 -0
  137. package/dist/ui/modalStack.js +160 -0
  138. package/dist/ui/objectiveBanner.d.ts +123 -0
  139. package/dist/ui/objectiveBanner.js +162 -0
  140. package/dist/ui/photoMode.d.ts +32 -0
  141. package/dist/ui/photoMode.js +51 -0
  142. package/dist/ui/radialMenu.d.ts +47 -0
  143. package/dist/ui/radialMenu.js +74 -0
  144. package/dist/ui/screenMarkers.d.ts +128 -0
  145. package/dist/ui/screenMarkers.js +118 -0
  146. package/dist/ui.d.ts +8 -0
  147. package/dist/ui.js +8 -0
  148. package/dist/vfx/cameraShake.d.ts +97 -0
  149. package/dist/vfx/cameraShake.js +136 -0
  150. package/dist/vfx/damageDirection.d.ts +102 -0
  151. package/dist/vfx/damageDirection.js +188 -0
  152. package/dist/vfx/particles.d.ts +103 -0
  153. package/dist/vfx/particles.js +256 -0
  154. package/dist/vfx/screenEffects.d.ts +149 -0
  155. package/dist/vfx/screenEffects.js +191 -0
  156. package/dist/world/authoredObjects.d.ts +18 -0
  157. package/dist/world/authoredObjects.js +21 -0
  158. package/dist/world/authoredSpawn.js +19 -0
  159. package/dist/world/buildingLots.d.ts +54 -11
  160. package/dist/world/buildingLots.js +116 -30
  161. package/dist/world/cityBlocks.d.ts +45 -0
  162. package/dist/world/cityBlocks.js +159 -0
  163. package/dist/world/cityContent.d.ts +46 -2
  164. package/dist/world/cityContent.js +160 -2
  165. package/dist/world/cityGenerator.d.ts +261 -23
  166. package/dist/world/cityGenerator.js +643 -33
  167. package/dist/world/cityGeometry.d.ts +20 -0
  168. package/dist/world/cityGeometry.js +91 -0
  169. package/dist/world/cityKind.js +1 -44
  170. package/dist/world/fastTravel.d.ts +64 -0
  171. package/dist/world/fastTravel.js +108 -0
  172. package/dist/world/features.d.ts +16 -0
  173. package/dist/world/features.js +2 -0
  174. package/dist/world/grounded.d.ts +11 -0
  175. package/dist/world/grounded.js +12 -0
  176. package/dist/world/mapAnnotations.d.ts +75 -0
  177. package/dist/world/mapAnnotations.js +139 -0
  178. package/dist/world/markers.js +1 -0
  179. package/dist/world/resourceNode.d.ts +1 -1
  180. package/dist/world/resourceNode.js +2 -1
  181. package/dist/world/roads.d.ts +294 -5
  182. package/dist/world/roads.js +1262 -21
  183. package/dist/world/sharedAnnotations.d.ts +84 -0
  184. package/dist/world/sharedAnnotations.js +76 -0
  185. package/dist/world/spawnOverride.d.ts +37 -0
  186. package/dist/world/spawnOverride.js +50 -0
  187. package/dist/world/streetGenerator.d.ts +60 -11
  188. package/dist/world/streetGenerator.js +1786 -129
  189. package/dist/world/terrain.d.ts +1 -0
  190. package/dist/world/terrain.js +2 -0
  191. package/dist/world/waypoints.d.ts +63 -0
  192. package/dist/world/waypoints.js +137 -0
  193. package/dist/world.d.ts +17 -2
  194. package/dist/world.js +17 -2
  195. package/package.json +1 -1
  196. package/skills/ce-handoff/SKILL.md +149 -0
  197. package/skills/game-design/SKILL.md +2 -0
  198. package/skills/jgengine/SKILL.md +3 -1
  199. package/skills/jgengine/api.md +17 -7
  200. package/skills/jgengine/capabilities.md +12 -0
  201. package/skills/jgengine/recipes/minimal-game.md +7 -4
  202. package/skills/jgengine-combat/api.md +40 -4
  203. package/skills/jgengine-combat/capabilities.md +12 -0
  204. package/skills/jgengine-combat/reference.md +2 -0
  205. package/skills/jgengine-gameplay/api.md +212 -11
  206. package/skills/jgengine-gameplay/capabilities.md +66 -1
  207. package/skills/jgengine-world/api.md +378 -11
  208. package/skills/jgengine-world/capabilities.md +123 -2
  209. package/skills/jgengine-world/reference.md +15 -1
@@ -0,0 +1,235 @@
1
+ import { bindingLabel, normalizeKeyCode, } from "./actionBindings.js";
2
+ import { applyBindingOverrides } from "./bindingOverrides.js";
3
+ /** All codes an {@link ActionCodes} entry binds (hold + toggle, or the plain array). */
4
+ function allCodes(codes) {
5
+ if (Array.isArray(codes))
6
+ return [...codes];
7
+ const modes = codes;
8
+ return [...(modes.hold ?? []), ...(modes.toggle ?? [])];
9
+ }
10
+ /** The first (primary) code of an {@link ActionCodes} entry, or `null` when unbound. */
11
+ function primaryCode(codes) {
12
+ return allCodes(codes)[0] ?? null;
13
+ }
14
+ /** Replace the primary code of `codes` with `code`, keeping the hold/toggle/repeat shape. */
15
+ function withPrimaryCode(codes, code) {
16
+ if (Array.isArray(codes)) {
17
+ return [code, ...codes.slice(1)];
18
+ }
19
+ const modes = codes;
20
+ if (modes.hold !== undefined && modes.hold.length > 0) {
21
+ return { ...modes, hold: [code, ...modes.hold.slice(1)] };
22
+ }
23
+ if (modes.toggle !== undefined && modes.toggle.length > 0) {
24
+ return { ...modes, toggle: [code, ...modes.toggle.slice(1)] };
25
+ }
26
+ return { ...modes, hold: [code] };
27
+ }
28
+ function normalizeConfig(config) {
29
+ if ("actions" in config)
30
+ return config.actions.map((a) => ({ ...a }));
31
+ const labels = config.labels ?? {};
32
+ return Object.keys(config.input).map((id) => ({
33
+ id,
34
+ label: labels[id] ?? id,
35
+ defaultCodes: config.input[id],
36
+ }));
37
+ }
38
+ /**
39
+ * A conflict-aware key-remap session over the action-binding model — the missing
40
+ * editor behind a controls-settings menu. It takes the game's authored actions
41
+ * ({@link RebindActionConfig}s, or an {@link ActionCodesMap} + labels) plus the
42
+ * player's saved {@link BindingOverrides}, and tracks the *effective* binding per
43
+ * action (default merged with the override via `applyBindingOverrides`). `rows()`
44
+ * exposes each action's label, current key glyph (via `bindingLabel`), whether it
45
+ * is still the default, and which other actions it collides with; `conflicts()`
46
+ * groups every action pair sharing a normalized code (the value-add a raw
47
+ * override map can't give you). `beginCapture`/`capture`/`cancelCapture` drive a
48
+ * "press a key" rebind (codes normalized via `normalizeKeyCode`), `reset`/
49
+ * `resetAll` return to defaults, and `overrides()`/`apply()` hand the caller the
50
+ * `BindingOverrides` to persist. Nothing here is genre-specific: action ids and
51
+ * labels are free strings the session never interprets, and hold/toggle/repeat
52
+ * binding shapes survive a rebind. `snapshot`/`restore` round-trips the editor
53
+ * (including the in-flight capture) through a save.
54
+ *
55
+ * @capability key-rebinding conflict-aware key-remap session over the action-binding model — capture/reassign, conflict detection, reset-to-defaults, persist overrides, snapshot/restore
56
+ */
57
+ export function createRebindSession(config) {
58
+ const now = config.now ?? Date.now;
59
+ const actionList = normalizeConfig(config);
60
+ const defaultMap = {};
61
+ for (const action of actionList)
62
+ defaultMap[action.id] = action.defaultCodes;
63
+ const knownIds = new Set(actionList.map((a) => a.id));
64
+ const overrides = {};
65
+ const changedAt = new Map();
66
+ let capturingActionId = null;
67
+ const listeners = new Set();
68
+ function ingestOverrides(source) {
69
+ for (const id of Object.keys(source)) {
70
+ if (knownIds.has(id))
71
+ overrides[id] = source[id];
72
+ }
73
+ }
74
+ if (config.overrides !== undefined)
75
+ ingestOverrides(config.overrides);
76
+ function notify() {
77
+ for (const listener of listeners)
78
+ listener();
79
+ }
80
+ /** Effective codes for an action: override merged over the authored default. */
81
+ function effectiveMap() {
82
+ return applyBindingOverrides(defaultMap, overrides);
83
+ }
84
+ /** Map of normalized code → action ids currently bound to it. */
85
+ function codeIndex() {
86
+ const effective = effectiveMap();
87
+ const index = new Map();
88
+ for (const action of actionList) {
89
+ for (const raw of allCodes(effective[action.id])) {
90
+ const norm = normalizeKeyCode(raw);
91
+ const bucket = index.get(norm);
92
+ if (bucket === undefined)
93
+ index.set(norm, [action.id]);
94
+ else
95
+ bucket.push(action.id);
96
+ }
97
+ }
98
+ return index;
99
+ }
100
+ function rows() {
101
+ const effective = effectiveMap();
102
+ const index = codeIndex();
103
+ return actionList.map((action) => {
104
+ const codes = effective[action.id];
105
+ const primaryRaw = primaryCode(codes);
106
+ const primary = primaryRaw === null ? null : normalizeKeyCode(primaryRaw);
107
+ const conflictWith = new Set();
108
+ for (const raw of allCodes(codes)) {
109
+ const bound = index.get(normalizeKeyCode(raw));
110
+ if (bound === undefined)
111
+ continue;
112
+ for (const other of bound)
113
+ if (other !== action.id)
114
+ conflictWith.add(other);
115
+ }
116
+ return {
117
+ actionId: action.id,
118
+ label: action.label,
119
+ codes,
120
+ code: primary,
121
+ bindingLabel: primary === null ? "" : bindingLabel(primary),
122
+ isDefault: overrides[action.id] === undefined,
123
+ conflictWith: [...conflictWith],
124
+ changedAt: changedAt.get(action.id) ?? null,
125
+ };
126
+ });
127
+ }
128
+ return {
129
+ actions() {
130
+ return actionList;
131
+ },
132
+ rows,
133
+ row(actionId) {
134
+ return rows().find((r) => r.actionId === actionId) ?? null;
135
+ },
136
+ beginCapture(actionId) {
137
+ if (!knownIds.has(actionId) || capturingActionId === actionId)
138
+ return;
139
+ capturingActionId = actionId;
140
+ notify();
141
+ },
142
+ capturingActionId() {
143
+ return capturingActionId;
144
+ },
145
+ isCapturing(actionId) {
146
+ if (actionId === undefined)
147
+ return capturingActionId !== null;
148
+ return capturingActionId === actionId;
149
+ },
150
+ capture(code) {
151
+ const target = capturingActionId;
152
+ if (target === null)
153
+ return false;
154
+ const normalized = normalizeKeyCode(code);
155
+ const base = effectiveMap()[target];
156
+ overrides[target] = withPrimaryCode(base, normalized);
157
+ changedAt.set(target, now());
158
+ capturingActionId = null;
159
+ notify();
160
+ return true;
161
+ },
162
+ cancelCapture() {
163
+ if (capturingActionId === null)
164
+ return;
165
+ capturingActionId = null;
166
+ notify();
167
+ },
168
+ conflicts() {
169
+ const groups = [];
170
+ for (const [code, actionIds] of codeIndex()) {
171
+ if (actionIds.length >= 2)
172
+ groups.push({ code, actionIds: [...actionIds] });
173
+ }
174
+ return groups;
175
+ },
176
+ hasConflicts() {
177
+ for (const actionIds of codeIndex().values())
178
+ if (actionIds.length >= 2)
179
+ return true;
180
+ return false;
181
+ },
182
+ reset(actionId) {
183
+ if (overrides[actionId] === undefined)
184
+ return;
185
+ delete overrides[actionId];
186
+ changedAt.delete(actionId);
187
+ notify();
188
+ },
189
+ resetAll() {
190
+ if (Object.keys(overrides).length === 0 && changedAt.size === 0)
191
+ return;
192
+ for (const key of Object.keys(overrides))
193
+ delete overrides[key];
194
+ changedAt.clear();
195
+ notify();
196
+ },
197
+ isDefault(actionId) {
198
+ return overrides[actionId] === undefined;
199
+ },
200
+ overrides() {
201
+ return { ...overrides };
202
+ },
203
+ apply(persistFn) {
204
+ const snapshot = { ...overrides };
205
+ persistFn?.(snapshot);
206
+ return snapshot;
207
+ },
208
+ subscribe(listener) {
209
+ listeners.add(listener);
210
+ return () => listeners.delete(listener);
211
+ },
212
+ snapshot() {
213
+ return {
214
+ overrides: { ...overrides },
215
+ changedAt: Object.fromEntries(changedAt),
216
+ capturingActionId,
217
+ };
218
+ },
219
+ restore(snapshot) {
220
+ for (const key of Object.keys(overrides))
221
+ delete overrides[key];
222
+ changedAt.clear();
223
+ ingestOverrides(snapshot.overrides);
224
+ for (const [id, at] of Object.entries(snapshot.changedAt)) {
225
+ if (knownIds.has(id))
226
+ changedAt.set(id, at);
227
+ }
228
+ capturingActionId =
229
+ snapshot.capturingActionId !== null && knownIds.has(snapshot.capturingActionId)
230
+ ? snapshot.capturingActionId
231
+ : null;
232
+ notify();
233
+ },
234
+ };
235
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Runtime switch for the active touch control context (see `TouchControlsConfig.modes`): gameplay
3
+ * calls `setTouchControlsMode(ctx, "car")` when the player boards a vehicle and `null` on exit, and
4
+ * the shell re-derives the touch scheme so only that context's controls are on screen. Backed by the
5
+ * ctx keyed store, so writes notify `ctx.subscribe` (the shell re-renders) and the value rides along
6
+ * with whole-world saves exactly like the gameplay state that implies it.
7
+ */
8
+ import type { GameContext } from "../runtime/gameContext.js";
9
+ /**
10
+ * Activate a named touch control mode, or `null` to return to the base config.
11
+ *
12
+ * @capability touch-controls switch the on-screen touch control set when gameplay context changes (enter/exit vehicle, mount, build mode)
13
+ */
14
+ export declare function setTouchControlsMode(ctx: GameContext, mode: string | null): void;
15
+ /** The active touch control mode, `null` when the base config applies. */
16
+ export declare function activeTouchControlsMode(ctx: GameContext): string | null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Runtime switch for the active touch control context (see `TouchControlsConfig.modes`): gameplay
3
+ * calls `setTouchControlsMode(ctx, "car")` when the player boards a vehicle and `null` on exit, and
4
+ * the shell re-derives the touch scheme so only that context's controls are on screen. Backed by the
5
+ * ctx keyed store, so writes notify `ctx.subscribe` (the shell re-renders) and the value rides along
6
+ * with whole-world saves exactly like the gameplay state that implies it.
7
+ */
8
+ import { defineStore } from "../store/defineStore.js";
9
+ const touchModeStore = defineStore("engine.touch.mode", null);
10
+ /**
11
+ * Activate a named touch control mode, or `null` to return to the base config.
12
+ *
13
+ * @capability touch-controls switch the on-screen touch control set when gameplay context changes (enter/exit vehicle, mount, build mode)
14
+ */
15
+ export function setTouchControlsMode(ctx, mode) {
16
+ if (touchModeStore.read(ctx) === mode)
17
+ return;
18
+ touchModeStore.write(ctx, mode);
19
+ }
20
+ /** The active touch control mode, `null` when the base config applies. */
21
+ export function activeTouchControlsMode(ctx) {
22
+ return touchModeStore.read(ctx);
23
+ }
@@ -62,6 +62,22 @@ export declare const TOUCH_STYLE_OPTIONS: readonly {
62
62
  }[];
63
63
  /** Skin used when neither the game nor the player picks one. */
64
64
  export declare const DEFAULT_TOUCH_STYLE: TouchStyle;
65
+ /**
66
+ * How the movement joystick behaves — the two shipped variants, player-selectable in
67
+ * Settings → Controls. `fixed` is the classic always-visible stick in its dock corner;
68
+ * `floating` keeps the corner as an empty capture zone and spawns the stick centered under
69
+ * wherever the thumb lands, so the hand never has to find a fixed target mid-play.
70
+ */
71
+ export type TouchJoystickVariant = "fixed" | "floating";
72
+ /** Every joystick variant id, in menu order. */
73
+ export declare const TOUCH_JOYSTICK_VARIANTS: readonly TouchJoystickVariant[];
74
+ /** Joystick variants as `{ value, label }` rows for the Settings → Controls selector. */
75
+ export declare const TOUCH_JOYSTICK_VARIANT_OPTIONS: readonly {
76
+ value: TouchJoystickVariant;
77
+ label: string;
78
+ }[];
79
+ /** Variant used when the player has not picked one. */
80
+ export declare const DEFAULT_TOUCH_JOYSTICK_VARIANT: TouchJoystickVariant;
65
81
  /** Where each touch cluster docks; unset falls back to the classic bottom layout. */
66
82
  export interface TouchLayoutConfig {
67
83
  /** Virtual joystick / movement zone. Default `bottom-left`. */
@@ -95,6 +111,9 @@ export interface TouchButtonSpec {
95
111
  export interface TouchMovementConfig {
96
112
  axis?: "both" | "horizontal" | "vertical";
97
113
  }
114
+ /** One named control context's touch config — the same shape as the base config, minus nested modes. */
115
+ export type TouchControlsModeConfig = Omit<TouchControlsConfig, "modes">;
116
+ /** Game-authored refinement of the derived touch scheme on `defineGame({ touch })` — gestures, curated buttons, hidden actions, cluster layout, skin, and per-context `modes`. */
98
117
  export interface TouchControlsConfig {
99
118
  /** `false` removes the virtual joystick even when movement actions are bound; an object restricts it to one axis. */
100
119
  movement?: false | TouchMovementConfig;
@@ -112,6 +131,14 @@ export interface TouchControlsConfig {
112
131
  layout?: TouchLayoutConfig;
113
132
  /** Suggested default skin; the player's Settings choice overrides it. Default `glass`. */
114
133
  style?: TouchStyle;
134
+ /**
135
+ * Per-context control sets for games whose verbs change at runtime — on foot vs driving vs
136
+ * flying. Each key is a mode name whose fields *replace* the matching base fields while that
137
+ * mode is active (base config = the default mode). Gameplay switches with
138
+ * `setTouchControlsMode(ctx, "car")` and back with `null`; only the actions a context actually
139
+ * uses should be visible in it, so a runner on foot never sees flight or vehicle chrome.
140
+ */
141
+ modes?: Record<string, TouchControlsModeConfig>;
115
142
  }
116
143
  export interface TouchJoystick {
117
144
  up: string | null;
@@ -157,9 +184,11 @@ export interface DeriveTouchSchemeOptions {
157
184
  reserved: ReadonlySet<string>;
158
185
  firstPerson: boolean;
159
186
  config?: TouchControlsConfig | false;
187
+ /** Active control context (see {@link TouchControlsConfig.modes}); null/unknown falls back to the base config. */
188
+ mode?: string | null;
160
189
  }
161
190
  /**
162
191
  * Null means "render no touch controls" — either the game opted out or there
163
192
  * is nothing to synthesize.
164
193
  */
165
- export declare function deriveTouchScheme(input: ActionCodesMap | undefined, { reserved, firstPerson, config }: DeriveTouchSchemeOptions): TouchScheme | null;
194
+ export declare function deriveTouchScheme(input: ActionCodesMap | undefined, { reserved, firstPerson, config: rawConfig, mode }: DeriveTouchSchemeOptions): TouchScheme | null;
@@ -44,6 +44,15 @@ export const TOUCH_STYLE_OPTIONS = [
44
44
  ];
45
45
  /** Skin used when neither the game nor the player picks one. */
46
46
  export const DEFAULT_TOUCH_STYLE = "glass";
47
+ /** Every joystick variant id, in menu order. */
48
+ export const TOUCH_JOYSTICK_VARIANTS = ["fixed", "floating"];
49
+ /** Joystick variants as `{ value, label }` rows for the Settings → Controls selector. */
50
+ export const TOUCH_JOYSTICK_VARIANT_OPTIONS = [
51
+ { value: "floating", label: "Floating (under your thumb)" },
52
+ { value: "fixed", label: "Fixed" },
53
+ ];
54
+ /** Variant used when the player has not picked one. */
55
+ export const DEFAULT_TOUCH_JOYSTICK_VARIANT = "floating";
47
56
  const DEFAULT_LOOK_SENSITIVITY = 0.005;
48
57
  const DEFAULT_TOUCH_LAYOUT = {
49
58
  movement: "bottom-left",
@@ -175,11 +184,27 @@ function gestureActions(gestures) {
175
184
  }
176
185
  return used;
177
186
  }
187
+ /** Resolve the effective config for a mode: mode fields replace base fields; the base is the default mode. */
188
+ function resolveModeConfig(config, mode) {
189
+ if (config === false || config === undefined || mode === null || mode === undefined)
190
+ return config;
191
+ const active = config.modes?.[mode];
192
+ if (active === undefined)
193
+ return config;
194
+ const { modes: _modes, ...base } = config;
195
+ return { ...base, ...active };
196
+ }
197
+ /**
198
+ * Auto-derived button count past which the dock stops being playable — two full thumb arcs. A
199
+ * curated `touch.buttons` list may exceed it deliberately; blind derivation may not.
200
+ */
201
+ const DERIVED_BUTTON_BUDGET = 8;
178
202
  /**
179
203
  * Null means "render no touch controls" — either the game opted out or there
180
204
  * is nothing to synthesize.
181
205
  */
182
- export function deriveTouchScheme(input, { reserved, firstPerson, config }) {
206
+ export function deriveTouchScheme(input, { reserved, firstPerson, config: rawConfig, mode }) {
207
+ const config = resolveModeConfig(rawConfig, mode);
183
208
  if (config === false)
184
209
  return null;
185
210
  const actions = Object.keys(input ?? {});
@@ -246,6 +271,11 @@ export function deriveTouchScheme(input, { reserved, firstPerson, config }) {
246
271
  anchor: null,
247
272
  image: null,
248
273
  }));
274
+ if (buttons.length > DERIVED_BUTTON_BUDGET) {
275
+ console.warn(`[jgengine:touch] derived ${buttons.length} on-screen buttons (${buttons
276
+ .map((button) => button.action)
277
+ .join(", ")}) — more than a thumb can reach. Curate touch.buttons, hide actions with touch.hidden, or split contexts with touch.modes.`);
278
+ }
249
279
  }
250
280
  const look = config?.look ?? firstPerson;
251
281
  if (joystick === null && buttons.length === 0 && gestures === null && !look)
@@ -0,0 +1,78 @@
1
+ import { type PositionedPrompt, type PromptPoint } from "./proximityPrompt.js";
2
+ /**
3
+ * A partial edit applied to an already-registered {@link PositionedPrompt} by
4
+ * {@link PromptRegistry.update}. Any subset of the mutable fields — everything
5
+ * but the stable `id` — may be supplied; omitted fields keep their prior value.
6
+ */
7
+ export type PositionedPromptPatch = Partial<Omit<PositionedPrompt, "id">>;
8
+ /**
9
+ * The serializable shape {@link PromptRegistry.snapshot} returns and
10
+ * {@link PromptRegistry.restore} consumes: the full set of positioned prompts
11
+ * (in registration order) plus the id of the prompt that was active when the
12
+ * snapshot was taken. Prompt display/command payloads are free strings and ids —
13
+ * keep them serializable and this round-trips through a save cleanly.
14
+ */
15
+ export interface PromptRegistrySnapshot {
16
+ /** Every registered prompt, in registration order (tie-break order for resolve). */
17
+ prompts: readonly PositionedPrompt[];
18
+ /** Id of the active prompt at snapshot time, or `null` if none was active. */
19
+ activeId: string | null;
20
+ }
21
+ /**
22
+ * A live, observable set of interaction prompts that OWNS the registered
23
+ * {@link PositionedPrompt}s and tracks which one is active for a player. It is a
24
+ * thin stateful layer over {@link resolveActivePrompt}: registration/order lives
25
+ * here, the nearest-within-radius / higher-priority selection stays in the pure
26
+ * resolver. {@link resolve} is the per-frame hot path — call it as the player
27
+ * moves; subscribers fire only when the active prompt actually CHANGES.
28
+ */
29
+ export interface PromptRegistry {
30
+ /**
31
+ * Add (or replace, by id) a positioned prompt. Replacing an existing id keeps
32
+ * its registration slot so tie-break order is stable. Does not re-resolve on
33
+ * its own — the next {@link resolve} picks up the change.
34
+ */
35
+ register(prompt: PositionedPrompt): void;
36
+ /**
37
+ * Patch a registered prompt in place. No-op if `id` is unknown. Only the fields
38
+ * present in `patch` change; the rest are preserved.
39
+ */
40
+ update(id: string, patch: PositionedPromptPatch): void;
41
+ /** Remove the prompt with this id, if present. Returns whether one was removed. */
42
+ unregister(id: string): boolean;
43
+ /** Remove every prompt and clear the active selection. */
44
+ clear(): void;
45
+ /** All registered prompts in registration order (do not mutate the returned array). */
46
+ all(): readonly PositionedPrompt[];
47
+ /**
48
+ * Resolve the active prompt for a player at `playerPosition` by delegating to
49
+ * {@link resolveActivePrompt}, cache its id, and notify subscribers only when
50
+ * the active prompt changed since the last call — so a HUD bound to this
51
+ * registry re-renders on transitions, not every frame. Returns the active
52
+ * prompt or `null`.
53
+ */
54
+ resolve(playerPosition: PromptPoint): PositionedPrompt | null;
55
+ /** The prompt selected by the most recent {@link resolve}, or `null`. */
56
+ active(): PositionedPrompt | null;
57
+ /** Observe active-prompt changes (resolve transitions, clear, restore). Returns an unsubscribe fn. */
58
+ subscribe(listener: () => void): () => void;
59
+ /** Serializable state for a save. */
60
+ snapshot(): PromptRegistrySnapshot;
61
+ /** Restore prompts + active id from a {@link PromptRegistrySnapshot}. */
62
+ restore(snapshot: PromptRegistrySnapshot): void;
63
+ }
64
+ /**
65
+ * Create a live, observable interaction-prompt registry over the pure
66
+ * {@link resolveActivePrompt} resolver. Register positioned "press E" prompts,
67
+ * `resolve(playerPosition)` each frame as the hero moves, and read `active()` —
68
+ * the nearest strictly-in-range prompt (higher `priority` beats closer). The set
69
+ * is owned here (register/update/unregister/clear/all); selection stays pure.
70
+ * Subscribers are notified only when the active prompt *changes*, so a HUD does
71
+ * not thrash per frame, and `snapshot`/`restore` round-trip the prompts plus the
72
+ * active id through a save (display payloads are free strings/ids the game owns —
73
+ * the registry never branches on their meaning). Allocation-aware: `all()` and
74
+ * `resolve()` reuse a cached array instead of rebuilding per call.
75
+ *
76
+ * @capability interaction-prompt observable nearest-interactable prompt registry over the proximity-prompt resolver — register/resolve/active with priority+range and change-notified snapshot/restore
77
+ */
78
+ export declare function createPromptRegistry(): PromptRegistry;
@@ -0,0 +1,130 @@
1
+ import { resolveActivePrompt, } from "./proximityPrompt.js";
2
+ /** Structural copy of a positioned prompt so external mutation can't corrupt registry state. */
3
+ function clonePrompt(prompt) {
4
+ const copy = {
5
+ id: prompt.id,
6
+ position: { x: prompt.position.x, z: prompt.position.z },
7
+ prompt: {
8
+ radius: prompt.prompt.radius,
9
+ display: { ...prompt.prompt.display },
10
+ invoke: prompt.prompt.invoke === null ? null : { ...prompt.prompt.invoke },
11
+ },
12
+ };
13
+ if (prompt.priority !== undefined)
14
+ copy.priority = prompt.priority;
15
+ return copy;
16
+ }
17
+ /**
18
+ * Create a live, observable interaction-prompt registry over the pure
19
+ * {@link resolveActivePrompt} resolver. Register positioned "press E" prompts,
20
+ * `resolve(playerPosition)` each frame as the hero moves, and read `active()` —
21
+ * the nearest strictly-in-range prompt (higher `priority` beats closer). The set
22
+ * is owned here (register/update/unregister/clear/all); selection stays pure.
23
+ * Subscribers are notified only when the active prompt *changes*, so a HUD does
24
+ * not thrash per frame, and `snapshot`/`restore` round-trip the prompts plus the
25
+ * active id through a save (display payloads are free strings/ids the game owns —
26
+ * the registry never branches on their meaning). Allocation-aware: `all()` and
27
+ * `resolve()` reuse a cached array instead of rebuilding per call.
28
+ *
29
+ * @capability interaction-prompt observable nearest-interactable prompt registry over the proximity-prompt resolver — register/resolve/active with priority+range and change-notified snapshot/restore
30
+ */
31
+ export function createPromptRegistry() {
32
+ // Insertion-ordered store; Map preserves registration order for resolve tie-breaks.
33
+ const prompts = new Map();
34
+ const listeners = new Set();
35
+ // Cached view of prompts.values(), rebuilt lazily only after a mutation, so the
36
+ // per-frame resolve() does not allocate a fresh array each call.
37
+ let listCache = null;
38
+ let activeId = null;
39
+ function notify() {
40
+ for (const listener of listeners)
41
+ listener();
42
+ }
43
+ function invalidate() {
44
+ listCache = null;
45
+ }
46
+ function list() {
47
+ if (listCache === null)
48
+ listCache = [...prompts.values()];
49
+ return listCache;
50
+ }
51
+ return {
52
+ register(prompt) {
53
+ prompts.set(prompt.id, clonePrompt(prompt));
54
+ invalidate();
55
+ },
56
+ update(id, patch) {
57
+ const existing = prompts.get(id);
58
+ if (existing === undefined)
59
+ return;
60
+ const merged = clonePrompt(existing);
61
+ if (patch.position !== undefined)
62
+ merged.position = { x: patch.position.x, z: patch.position.z };
63
+ if (patch.priority !== undefined)
64
+ merged.priority = patch.priority;
65
+ if (patch.prompt !== undefined) {
66
+ merged.prompt = {
67
+ radius: patch.prompt.radius,
68
+ display: { ...patch.prompt.display },
69
+ invoke: patch.prompt.invoke === null ? null : { ...patch.prompt.invoke },
70
+ };
71
+ }
72
+ prompts.set(id, merged);
73
+ invalidate();
74
+ },
75
+ unregister(id) {
76
+ const removed = prompts.delete(id);
77
+ if (removed) {
78
+ invalidate();
79
+ if (activeId === id) {
80
+ activeId = null;
81
+ notify();
82
+ }
83
+ }
84
+ return removed;
85
+ },
86
+ clear() {
87
+ if (prompts.size === 0 && activeId === null)
88
+ return;
89
+ prompts.clear();
90
+ invalidate();
91
+ if (activeId !== null) {
92
+ activeId = null;
93
+ notify();
94
+ }
95
+ },
96
+ all() {
97
+ return list();
98
+ },
99
+ resolve(playerPosition) {
100
+ const next = resolveActivePrompt(playerPosition, list());
101
+ const nextId = next === null ? null : next.id;
102
+ if (nextId !== activeId) {
103
+ activeId = nextId;
104
+ notify();
105
+ }
106
+ return next;
107
+ },
108
+ active() {
109
+ return activeId === null ? null : prompts.get(activeId) ?? null;
110
+ },
111
+ subscribe(listener) {
112
+ listeners.add(listener);
113
+ return () => listeners.delete(listener);
114
+ },
115
+ snapshot() {
116
+ return {
117
+ prompts: [...prompts.values()].map(clonePrompt),
118
+ activeId,
119
+ };
120
+ },
121
+ restore(snapshot) {
122
+ prompts.clear();
123
+ for (const prompt of snapshot.prompts)
124
+ prompts.set(prompt.id, clonePrompt(prompt));
125
+ invalidate();
126
+ activeId = snapshot.activeId !== null && prompts.has(snapshot.activeId) ? snapshot.activeId : null;
127
+ notify();
128
+ },
129
+ };
130
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  export function tierOf(tiers, inventoryId) {
2
3
  return tiers[inventoryId] ?? "carried";
3
4
  }
@@ -59,7 +60,7 @@ function resolveDelay(delay, rng) {
59
60
  const [min, max] = delay;
60
61
  return min + rng() * (max - min);
61
62
  }
62
- export function insureLost(lost, policy, userId, now, rng = Math.random) {
63
+ export function insureLost(lost, policy, userId, now, rng) {
63
64
  const items = lost.filter((stack) => policy.isInsured(stack.itemId));
64
65
  if (items.length === 0)
65
66
  return null;
@@ -67,7 +68,7 @@ export function insureLost(lost, policy, userId, now, rng = Math.random) {
67
68
  userId,
68
69
  inventoryId: policy.returnInventoryId,
69
70
  items,
70
- deliverAt: now + resolveDelay(policy.delaySeconds, rng),
71
+ deliverAt: now + resolveDelay(policy.delaySeconds, resolveRng(rng, "scheduleDelivery")),
71
72
  };
72
73
  }
73
74
  export function resolveConsolation(policy, partition) {
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.14.0";
2
+ export declare const VERSION = "0.15.0-next.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.14.0";
2
+ export const VERSION = "0.15.0-next.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
5
  "0.14.0": {