@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
package/dist/ai/flock.js CHANGED
@@ -88,40 +88,28 @@ export function flockSteer(agent, neighbors, config, target) {
88
88
  return [steerX * scale, steerY * scale, steerZ * scale];
89
89
  }
90
90
  const CELL_KEY_STRIDE = 1_048_576;
91
- const gridCells = new Map();
92
- const freeBuckets = [];
93
- const neighborScratch = [];
94
- let steerScratch = new Float64Array(0);
95
91
  function cellKey(cx, cz) {
96
92
  return cx * CELL_KEY_STRIDE + cz;
97
93
  }
98
- function takeBucket() {
99
- return freeBuckets.pop() ?? [];
100
- }
101
- function ensureSteerCapacity(agentCount) {
102
- const needed = agentCount * 3;
103
- if (steerScratch.length < needed) {
104
- steerScratch = new Float64Array(needed);
105
- }
106
- }
94
+ /**
95
+ * Per-call spatial index + scratch — never module-global.
96
+ * Two `stepFlock` worlds in one process must not share a grid (#1545 / sdk remediation Phase 3a).
97
+ */
107
98
  function buildAgentGrid(agents, invCell) {
108
- for (const bucket of gridCells.values()) {
109
- bucket.length = 0;
110
- freeBuckets.push(bucket);
111
- }
112
- gridCells.clear();
99
+ const gridCells = new Map();
113
100
  for (let i = 0; i < agents.length; i += 1) {
114
101
  const position = agents[i].position;
115
102
  const key = cellKey(Math.floor(position[0] * invCell), Math.floor(position[2] * invCell));
116
103
  let bucket = gridCells.get(key);
117
104
  if (bucket === undefined) {
118
- bucket = takeBucket();
105
+ bucket = [];
119
106
  gridCells.set(key, bucket);
120
107
  }
121
108
  bucket.push(i);
122
109
  }
110
+ return gridCells;
123
111
  }
124
- function fillNeighbors(agents, agentIndex, invCell) {
112
+ function fillNeighbors(agents, agentIndex, invCell, gridCells, neighborScratch) {
125
113
  const agent = agents[agentIndex];
126
114
  const cx = Math.floor(agent.position[0] * invCell);
127
115
  const cz = Math.floor(agent.position[2] * invCell);
@@ -152,10 +140,12 @@ export function stepFlock(agents, config, dt, target) {
152
140
  return;
153
141
  const cellSize = config.neighborRadius > 1e-6 ? config.neighborRadius : 1;
154
142
  const invCell = 1 / cellSize;
155
- buildAgentGrid(agents, invCell);
156
- ensureSteerCapacity(count);
143
+ // All scratch is call-local so concurrent worlds (or re-entrant steps) never share a grid.
144
+ const gridCells = buildAgentGrid(agents, invCell);
145
+ const neighborScratch = [];
146
+ const steerScratch = new Float64Array(count * 3);
157
147
  for (let i = 0; i < count; i += 1) {
158
- fillNeighbors(agents, i, invCell);
148
+ fillNeighbors(agents, i, invCell, gridCells, neighborScratch);
159
149
  const steer = flockSteer(agents[i], neighborScratch, config, target);
160
150
  const base = i * 3;
161
151
  steerScratch[base] = steer[0];
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  /**
2
3
  * Live cost-based selection over parallel corridors (#286.7) — the racing-line / lane-merge pick
3
4
  * every AI driver hand-rolled. Evaluate each lane's cost this tick (crowding, hazards, distance)
@@ -9,7 +10,7 @@ export function pickLane(candidates, options = {}) {
9
10
  return null;
10
11
  const stickiness = options.stickiness ?? 1.2;
11
12
  const tieEpsilon = options.tieEpsilon ?? 0;
12
- const rng = options.rng ?? Math.random;
13
+ const rng = resolveRng(options.rng, "selectLane");
13
14
  let best = candidates[0];
14
15
  for (const candidate of candidates) {
15
16
  if (candidate.cost < best.cost)
@@ -1,4 +1,5 @@
1
1
  import { createThreatTable } from "./threat.js";
2
+ import { resolveRng } from "../random/resolveRng.js";
2
3
  const HOLD = {
3
4
  targetId: null,
4
5
  moveTo: null,
@@ -18,7 +19,7 @@ export function createMobBrain(config, deps) {
18
19
  const wander = config.wander === false || config.wander === undefined ? null : config.wander;
19
20
  const wanderInterval = wander?.intervalSeconds ?? 4;
20
21
  const wanderArrive = wander?.arriveRadius ?? 0.6;
21
- const rng = deps.rng ?? Math.random;
22
+ const rng = resolveRng(deps.rng, "createMobBrain");
22
23
  let currentMode = "idle";
23
24
  let currentTarget = null;
24
25
  let evading = false;
@@ -0,0 +1,81 @@
1
+ import { type DirectorContext, type SpawnDirectorConfig, type SpawnDirectorState, type SpawnRequest } from "./spawnDirector.js";
2
+ /**
3
+ * A callback the wave runner hands each {@link SpawnRequest} the underlying spawn
4
+ * director emits. The runner never instantiates entities itself — it forwards the
5
+ * request (a free-string `entryId` "kind", cost, wave, optional point/lane) to the
6
+ * game, which decides what to build. Keeps the runner genre-agnostic.
7
+ */
8
+ export type WaveSpawnSink = (request: SpawnRequest) => void;
9
+ /**
10
+ * Configuration for {@link createWaveRunner}: the full {@link SpawnDirectorConfig}
11
+ * (escalating waves, budgets, seed, spawn points) plus an optional {@link WaveSpawnSink}.
12
+ * The runner owns the director state — you never pass a `SpawnDirectorState` here.
13
+ */
14
+ export interface WaveRunnerConfig extends SpawnDirectorConfig {
15
+ /** Optional sink invoked once per spawned {@link SpawnRequest} during {@link WaveRunner.update}. */
16
+ onSpawn?: WaveSpawnSink;
17
+ }
18
+ /**
19
+ * A pooled, per-frame readout of the current wave — the "brain behind WAVE 3".
20
+ * Reused across {@link WaveRunner.view} calls (never per-frame allocated), so read
21
+ * it and render it, don't retain it.
22
+ */
23
+ export interface WaveView {
24
+ /** 1-based wave number for display ("WAVE N"). The 0-based index lives on {@link WaveRunner.state}. */
25
+ wave: number;
26
+ /**
27
+ * Progress `0..1` through the current wave: `waveElapsed / duration` when the
28
+ * wave declares a `duration`, otherwise a budget-drain estimate (`1 - budget /
29
+ * waveBudget`). A best-effort readout for a progress bar, not an exact schedule.
30
+ */
31
+ waveProgress: number;
32
+ /** Spawn budget currently banked in the director. */
33
+ budget: number;
34
+ /** How many entities have spawned during the current wave. */
35
+ spawnedThisWave: number;
36
+ /** How many entities have spawned across every wave so far. */
37
+ spawnedTotal: number;
38
+ /** Current alert level `0..1` (raised by {@link WaveRunner.raiseAlert}, decays over time). */
39
+ alert: number;
40
+ /** True once the final non-looping wave has finished. */
41
+ done: boolean;
42
+ }
43
+ /** A live, observable, serializable wave/spawn runner over a seeded spawn director. */
44
+ export interface WaveRunner {
45
+ /**
46
+ * Advance the director by `dt` seconds: bank budget, roll escalation/alert, cross
47
+ * wave boundaries, and forward every emitted {@link SpawnRequest} to the configured
48
+ * `onSpawn` sink. Pass a {@link DirectorContext} (defaults to `{ alive: 0 }`) to feed
49
+ * back the live alive-count / player positions. Notifies subscribers on change.
50
+ */
51
+ update(dt: number, ctx?: DirectorContext): void;
52
+ /** The pooled current-wave readout to render (do not retain). */
53
+ view(): WaveView;
54
+ /** Force an immediate jump to the next wave (delegates to `advanceWave`). Notifies. */
55
+ forceNextWave(): void;
56
+ /** Raise the alert level by `amount` (clamped `0..1`), boosting spawn budget. Notifies. */
57
+ raiseAlert(amount: number): void;
58
+ /** The raw underlying {@link SpawnDirectorState} (0-based `wave`); read-only snapshot of internals. */
59
+ state(): SpawnDirectorState;
60
+ /** Observe changes (update, forceNextWave, raiseAlert, restore). Returns an unsubscribe fn. */
61
+ subscribe(listener: () => void): () => void;
62
+ /** Serializable state for a save — the plain {@link SpawnDirectorState} object. */
63
+ snapshot(): SpawnDirectorState;
64
+ /** Restore from a {@link snapshot} (a plain {@link SpawnDirectorState}). Notifies. */
65
+ restore(snapshot: SpawnDirectorState): void;
66
+ }
67
+ /**
68
+ * A thin, stateful, observable wrapper around the seeded {@link createSpawnDirectorState}
69
+ * / {@link advanceSpawnDirector} model. It OWNS a {@link SpawnDirectorState}, ticks it
70
+ * from `update(dt, ctx?)`, keeps the new state, and forwards each emitted
71
+ * {@link SpawnRequest} to an optional `onSpawn` sink so the model never instantiates
72
+ * entities. `view()` returns a pooled per-frame readout — the "brain behind WAVE 3":
73
+ * the 1-based wave number, wave progress `0..1`, banked budget, per-wave and total
74
+ * spawn counts, alert, and done flag. `forceNextWave` / `raiseAlert` drive it,
75
+ * `subscribe` observes it, and `snapshot`/`restore` round-trip the plain serializable
76
+ * state object through a save. Nothing here is genre-specific: spawn-entry `kind`s
77
+ * (the director's `entryId`s) stay free strings the runner never interprets.
78
+ *
79
+ * @capability wave-runner observable serializable wave/spawn runner over the seeded spawn director — WAVE N, wave progress, budget/alert readout, onSpawn sink, snapshot/restore
80
+ */
81
+ export declare function createWaveRunner(config: WaveRunnerConfig): WaveRunner;
@@ -0,0 +1,104 @@
1
+ import { advanceSpawnDirector, advanceWave, createSpawnDirectorState, raiseAlert as raiseDirectorAlert, } from "./spawnDirector.js";
2
+ const DEFAULT_CONTEXT = { alive: 0 };
3
+ function clamp01(value) {
4
+ return value < 0 ? 0 : value > 1 ? 1 : value;
5
+ }
6
+ /**
7
+ * A thin, stateful, observable wrapper around the seeded {@link createSpawnDirectorState}
8
+ * / {@link advanceSpawnDirector} model. It OWNS a {@link SpawnDirectorState}, ticks it
9
+ * from `update(dt, ctx?)`, keeps the new state, and forwards each emitted
10
+ * {@link SpawnRequest} to an optional `onSpawn` sink so the model never instantiates
11
+ * entities. `view()` returns a pooled per-frame readout — the "brain behind WAVE 3":
12
+ * the 1-based wave number, wave progress `0..1`, banked budget, per-wave and total
13
+ * spawn counts, alert, and done flag. `forceNextWave` / `raiseAlert` drive it,
14
+ * `subscribe` observes it, and `snapshot`/`restore` round-trip the plain serializable
15
+ * state object through a save. Nothing here is genre-specific: spawn-entry `kind`s
16
+ * (the director's `entryId`s) stay free strings the runner never interprets.
17
+ *
18
+ * @capability wave-runner observable serializable wave/spawn runner over the seeded spawn director — WAVE N, wave progress, budget/alert readout, onSpawn sink, snapshot/restore
19
+ */
20
+ export function createWaveRunner(config) {
21
+ const onSpawn = config.onSpawn;
22
+ let state = createSpawnDirectorState(config);
23
+ const listeners = new Set();
24
+ // Pooled readout — reused across view() calls, never per-frame allocated.
25
+ const readout = {
26
+ wave: 1,
27
+ waveProgress: 0,
28
+ budget: 0,
29
+ spawnedThisWave: 0,
30
+ spawnedTotal: 0,
31
+ alert: 0,
32
+ done: false,
33
+ };
34
+ function notify() {
35
+ for (const listener of listeners)
36
+ listener();
37
+ }
38
+ /** True if any field a HUD cares about differs between two states. */
39
+ function changed(a, b) {
40
+ return (a.wave !== b.wave ||
41
+ a.budget !== b.budget ||
42
+ a.spawnedThisWave !== b.spawnedThisWave ||
43
+ a.spawnedTotal !== b.spawnedTotal ||
44
+ a.alert !== b.alert ||
45
+ a.done !== b.done);
46
+ }
47
+ function waveProgress() {
48
+ const manifest = config.waves[state.wave];
49
+ if (manifest === undefined)
50
+ return state.done ? 1 : 0;
51
+ if (manifest.duration !== undefined && manifest.duration > 0) {
52
+ return clamp01(state.waveElapsed / manifest.duration);
53
+ }
54
+ if (manifest.budget > 0) {
55
+ return clamp01(1 - state.budget / manifest.budget);
56
+ }
57
+ return state.done ? 1 : 0;
58
+ }
59
+ return {
60
+ update(dt, ctx = DEFAULT_CONTEXT) {
61
+ const previous = state;
62
+ const step = advanceSpawnDirector(config, state, dt, ctx);
63
+ state = step.state;
64
+ if (onSpawn !== undefined) {
65
+ for (const spawn of step.spawns)
66
+ onSpawn(spawn);
67
+ }
68
+ if (step.spawns.length > 0 || changed(previous, state))
69
+ notify();
70
+ },
71
+ view() {
72
+ readout.wave = state.wave + 1;
73
+ readout.waveProgress = waveProgress();
74
+ readout.budget = state.budget;
75
+ readout.spawnedThisWave = state.spawnedThisWave;
76
+ readout.spawnedTotal = state.spawnedTotal;
77
+ readout.alert = state.alert;
78
+ readout.done = state.done;
79
+ return readout;
80
+ },
81
+ forceNextWave() {
82
+ state = advanceWave(config, state);
83
+ notify();
84
+ },
85
+ raiseAlert(amount) {
86
+ state = raiseDirectorAlert(state, amount);
87
+ notify();
88
+ },
89
+ state() {
90
+ return state;
91
+ },
92
+ subscribe(listener) {
93
+ listeners.add(listener);
94
+ return () => listeners.delete(listener);
95
+ },
96
+ snapshot() {
97
+ return state;
98
+ },
99
+ restore(snapshot) {
100
+ state = snapshot;
101
+ notify();
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Hysteretic music-state selector: turns a continuous intensity signal (nearby threat,
3
+ * alert count, chase proximity) into a stable theme id. The engine can already crossfade
4
+ * a soundtrack; this decides *when*, without the flapping a bare threshold produces when
5
+ * one enemy walks in and out of range.
6
+ */
7
+ export interface MusicTier {
8
+ id: string;
9
+ /** Theme id passed to `ctx.game.audio.music`; null silences the soundtrack in this tier. */
10
+ theme: string | null;
11
+ /** Intensity at or above which this tier may be entered. */
12
+ enter: number;
13
+ /** Minimum time in this tier before it may drop to a lower one. Default 0. */
14
+ holdMs?: number;
15
+ }
16
+ /** The tier ladder plus the release margin that keeps a hovering intensity from flapping between tiers. */
17
+ export interface MusicStateConfig {
18
+ /** Ordered or unordered; the highest `enter` at or below the intensity wins. */
19
+ tiers: readonly MusicTier[];
20
+ /** Intensity must fall this far below a tier's `enter` before leaving it. Default 0.2. */
21
+ release?: number;
22
+ }
23
+ /** Which tier is playing and when it was entered, on the caller's clock. Serialisable. */
24
+ export interface MusicStateSnapshot {
25
+ tierId: string | null;
26
+ /** Timestamp the current tier was entered, on the caller's clock. */
27
+ sinceMs: number;
28
+ }
29
+ /** The current intensity signal and clock reading for one resolve. */
30
+ export interface MusicStateInput {
31
+ intensity: number;
32
+ nowMs: number;
33
+ }
34
+ /** Next state, the theme that should be playing, and whether this advance is the one that changed it. */
35
+ export interface MusicStateResult {
36
+ state: MusicStateSnapshot;
37
+ theme: string | null;
38
+ /** True only on the advance the tier changed — the caller's cue to crossfade. */
39
+ changed: boolean;
40
+ }
41
+ /** Fresh state with no tier entered, so the first resolve always settles and reports `changed`. */
42
+ export declare function createMusicState(): MusicStateSnapshot;
43
+ /**
44
+ * Pick the theme for the current intensity. Escalation is immediate; de-escalation waits for the
45
+ * tier's `holdMs` and needs the intensity to fall a `release` margin below the tier it is leaving.
46
+ */
47
+ export declare function resolveMusicState(state: MusicStateSnapshot, input: MusicStateInput, config: MusicStateConfig): MusicStateResult;
@@ -0,0 +1,38 @@
1
+ /** Fresh state with no tier entered, so the first resolve always settles and reports `changed`. */
2
+ export function createMusicState() {
3
+ return { tierId: null, sinceMs: 0 };
4
+ }
5
+ function tierById(config, id) {
6
+ return id === null ? undefined : config.tiers.find((tier) => tier.id === id);
7
+ }
8
+ function highestTierAt(config, intensity) {
9
+ let best;
10
+ for (const tier of config.tiers) {
11
+ if (intensity < tier.enter)
12
+ continue;
13
+ if (best === undefined || tier.enter > best.enter)
14
+ best = tier;
15
+ }
16
+ return best;
17
+ }
18
+ /**
19
+ * Pick the theme for the current intensity. Escalation is immediate; de-escalation waits for the
20
+ * tier's `holdMs` and needs the intensity to fall a `release` margin below the tier it is leaving.
21
+ */
22
+ export function resolveMusicState(state, input, config) {
23
+ const current = tierById(config, state.tierId);
24
+ const candidate = highestTierAt(config, input.intensity) ?? config.tiers.find((tier) => tier.enter <= 0);
25
+ if (current !== undefined && candidate !== undefined && candidate.enter < current.enter) {
26
+ const release = config.release ?? 0.2;
27
+ const heldLongEnough = input.nowMs - state.sinceMs >= (current.holdMs ?? 0);
28
+ if (!heldLongEnough || input.intensity > current.enter - release) {
29
+ return { state, theme: current.theme, changed: false };
30
+ }
31
+ }
32
+ const next = candidate ?? current;
33
+ if (next === undefined)
34
+ return { state, theme: null, changed: false };
35
+ if (next.id === state.tierId)
36
+ return { state, theme: next.theme, changed: false };
37
+ return { state: { tierId: next.id, sinceMs: input.nowMs }, theme: next.theme, changed: true };
38
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Distance-driven footstep cadence. Pure state-in/state-out: feed it how far a mover
3
+ * travelled this frame and it tells you how many steps landed, so foley stays locked to
4
+ * ground distance instead of a wall-clock timer that desyncs when speed changes.
5
+ */
6
+ export interface StepCadenceConfig {
7
+ /** World units travelled per step at `strideScale` 1. */
8
+ strideDistance: number;
9
+ /**
10
+ * Fraction of a stride the first step after standing still lands at, so walking off
11
+ * from rest is heard immediately rather than half a stride later. Default 0.5.
12
+ */
13
+ firstStepFraction?: number;
14
+ }
15
+ /** Cadence bookkeeping between advances — banked distance, last moving state, and the step count whose parity is the foot. */
16
+ export interface StepCadenceState {
17
+ /** Distance banked toward the next step. */
18
+ travelled: number;
19
+ /** Whether the mover was moving last advance — drives the from-rest first step. */
20
+ moving: boolean;
21
+ /** Monotonic step counter; its parity is the left/right foot. */
22
+ steps: number;
23
+ }
24
+ /** What the mover did this advance: ground distance covered, whether feet are planting, and the stride multiplier. */
25
+ export interface StepCadenceInput {
26
+ /** Ground distance covered since the last advance, in world units. */
27
+ distance: number;
28
+ /** False when the mover is airborne, downed, or otherwise not planting feet. */
29
+ moving: boolean;
30
+ /** Multiplies `strideDistance` — crouch shortens it, sprint lengthens it. Default 1. */
31
+ strideScale?: number;
32
+ }
33
+ /** Outcome of one advance: the next state plus how many steps landed and which foot led. */
34
+ export interface StepCadenceResult {
35
+ state: StepCadenceState;
36
+ /** Steps that landed this advance — 0 or 1 normally, more only on a very long frame. */
37
+ steps: number;
38
+ /** Parity of the first step landed this advance: 0 = left, 1 = right. */
39
+ foot: 0 | 1;
40
+ }
41
+ /** Fresh cadence state for a mover that is standing still. */
42
+ export declare function createStepCadenceState(): StepCadenceState;
43
+ /**
44
+ * Bank this advance's distance and report the steps it completed. Pure — same state and input
45
+ * always give the same result, so footstep timing is testable headless and replays identically.
46
+ */
47
+ export declare function advanceStepCadence(state: StepCadenceState, input: StepCadenceInput, config: StepCadenceConfig): StepCadenceResult;
@@ -0,0 +1,29 @@
1
+ /** Fresh cadence state for a mover that is standing still. */
2
+ export function createStepCadenceState() {
3
+ return { travelled: 0, moving: false, steps: 0 };
4
+ }
5
+ /**
6
+ * Bank this advance's distance and report the steps it completed. Pure — same state and input
7
+ * always give the same result, so footstep timing is testable headless and replays identically.
8
+ */
9
+ export function advanceStepCadence(state, input, config) {
10
+ const stride = Math.max(config.strideDistance * (input.strideScale ?? 1), Number.EPSILON);
11
+ if (!input.moving) {
12
+ return { state: { travelled: 0, moving: false, steps: state.steps }, steps: 0, foot: (state.steps % 2) };
13
+ }
14
+ const fromRest = !state.moving;
15
+ const threshold = fromRest ? stride * (config.firstStepFraction ?? 0.5) : stride;
16
+ let travelled = state.travelled + Math.max(0, input.distance);
17
+ let steps = 0;
18
+ let remaining = threshold;
19
+ while (travelled >= remaining) {
20
+ travelled -= remaining;
21
+ steps += 1;
22
+ remaining = stride;
23
+ }
24
+ return {
25
+ state: { travelled, moving: true, steps: state.steps + steps },
26
+ steps,
27
+ foot: (state.steps % 2),
28
+ };
29
+ }
@@ -23,6 +23,13 @@ export interface ToneVoice {
23
23
  slideTo?: number;
24
24
  /** Attack ramp in seconds. Default 0.012. */
25
25
  attack?: number;
26
+ /**
27
+ * Seconds held at full gain after the attack, before the decay to silence at `duration`.
28
+ * Default 0 — a plain percussive decay. Set it to `duration` for a flat, sustained bed:
29
+ * a looped buffer only reads as continuous if its amplitude does not fall across the loop,
30
+ * so ambient hums and wind beds need this or they pulse once per loop.
31
+ */
32
+ sustain?: number;
26
33
  }
27
34
  /**
28
35
  * A filtered white-noise burst — impacts, whooshes, breath, crackle. Realised
@@ -43,6 +50,11 @@ export interface NoiseVoice {
43
50
  decay?: number;
44
51
  /** Start offset in seconds from the cue trigger. Default 0. */
45
52
  delay?: number;
53
+ /**
54
+ * Seconds held at full gain before the decay begins. Default 0. Set it to `duration` for a
55
+ * flat noise bed (wind, rain, machine roar) that loops without pulsing.
56
+ */
57
+ sustain?: number;
46
58
  }
47
59
  /** One layered voice of a synth cue. */
48
60
  export type SynthVoice = ToneVoice | NoiseVoice;
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Task-first JGEngine authoring surface.
2
+ * Core-side convenience barrel for pure authored helpers (loot, world features, ownership,
3
+ * spawn selection, RNG). **Not the game entrypoint** — game projects start at
4
+ * `@jgengine/shell/gameKit` (`defineGame`, `GameHost`, stores, systems, HUD). Prefer that
5
+ * surface; import from here only when you need a core helper the kit does not re-export.
3
6
  *
4
- * Game code should begin here. Domain and runtime deep imports remain available for
5
- * advanced composition, but this module intentionally exposes common authored
6
- * concepts without requiring callers to understand the repository layout.
7
+ * @deprecated Prefer `@jgengine/shell/gameKit` as the start-here surface for games. This
8
+ * module remains for deep core helpers and existing pure-import consumers; it is not removed.
7
9
  */
8
10
  export { type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type PhysicsConfig, type GameServerConfig, } from "./game/defineGame.js";
9
11
  export { lootTable, type LootTableDef, type LootEntry, type Drop, } from "./game/lootTable.js";
package/dist/authoring.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Task-first JGEngine authoring surface.
2
+ * Core-side convenience barrel for pure authored helpers (loot, world features, ownership,
3
+ * spawn selection, RNG). **Not the game entrypoint** — game projects start at
4
+ * `@jgengine/shell/gameKit` (`defineGame`, `GameHost`, stores, systems, HUD). Prefer that
5
+ * surface; import from here only when you need a core helper the kit does not re-export.
3
6
  *
4
- * Game code should begin here. Domain and runtime deep imports remain available for
5
- * advanced composition, but this module intentionally exposes common authored
6
- * concepts without requiring callers to understand the repository layout.
7
+ * @deprecated Prefer `@jgengine/shell/gameKit` as the start-here surface for games. This
8
+ * module remains for deep core helpers and existing pure-import consumers; it is not removed.
7
9
  */
8
10
  export {} from "./game/defineGame.js";
9
11
  export { lootTable, } from "./game/lootTable.js";
@@ -3,6 +3,15 @@ export interface CastConfig {
3
3
  castTimeMs: number;
4
4
  /** Total movement distance tolerated before the cast auto-interrupts; default `0.04`. `Infinity` allows casting on the move. */
5
5
  moveTolerance?: number;
6
+ /**
7
+ * Per-tick movement at or below this is ignored rather than added to the running total — the
8
+ * caster's noise floor. A 3D game's pose jitters every frame from terrain-following ground snap
9
+ * and float drift; since `moveTolerance` accumulates, feeding those raw deltas straight in will
10
+ * silently cancel casts the player never interrupted, and the failure is intermittent rather than
11
+ * obvious. Only the game knows its own noise floor, so declare it once here instead of filtering
12
+ * at every `tick` call site. Default `0` (count every reported movement).
13
+ */
14
+ moveDeadzone?: number;
6
15
  }
7
16
  export interface CastBarSnapshot {
8
17
  abilityId: string;
@@ -34,7 +43,10 @@ export interface CastRunner {
34
43
  state(): CastBarSnapshot | null;
35
44
  /** Interrupt the in-flight cast now; returns the interruption event, or `null` when idle. */
36
45
  interrupt(reason?: CastInterruptReason): CastEvent | null;
37
- /** Advance by `dtSeconds`; `movedDistance` accumulates toward the config's `moveTolerance`. */
46
+ /**
47
+ * Advance by `dtSeconds`; `movedDistance` accumulates toward the config's `moveTolerance`, except
48
+ * for per-tick movement at or below `moveDeadzone`, which is treated as jitter and ignored.
49
+ */
38
50
  tick(dtSeconds: number, movedDistance?: number): CastEvent | null;
39
51
  }
40
52
  /**
@@ -26,6 +26,7 @@ export function createCastRunner() {
26
26
  abilityId: config.abilityId,
27
27
  castTimeMs: Math.max(0, config.castTimeMs),
28
28
  moveTolerance: config.moveTolerance ?? DEFAULT_MOVE_TOLERANCE,
29
+ moveDeadzone: Math.max(0, config.moveDeadzone ?? 0),
29
30
  elapsedMs: 0,
30
31
  movedTotal: 0,
31
32
  };
@@ -43,7 +44,10 @@ export function createCastRunner() {
43
44
  tick(dtSeconds, movedDistance = 0) {
44
45
  if (active === null)
45
46
  return null;
46
- active.movedTotal += Math.max(0, movedDistance);
47
+ const moved = Math.max(0, movedDistance);
48
+ // Below the caster's noise floor this is jitter, not travel — do not accumulate it.
49
+ if (moved > active.moveDeadzone)
50
+ active.movedTotal += moved;
47
51
  if (active.movedTotal > active.moveTolerance) {
48
52
  const abilityId = active.abilityId;
49
53
  active = null;