@jgengine/core 0.9.0 → 0.10.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 (114) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/ai/spawnDirector.d.ts +13 -0
  3. package/dist/ai/spawnDirector.js +40 -16
  4. package/dist/ai/spawnPoint.d.ts +5 -0
  5. package/dist/ai/spawnPoint.js +5 -0
  6. package/dist/audio/audioFalloff.d.ts +5 -2
  7. package/dist/audio/music.d.ts +53 -0
  8. package/dist/audio/music.js +34 -0
  9. package/dist/audio/synth.d.ts +60 -0
  10. package/dist/audio/synth.js +8 -0
  11. package/dist/authoring.d.ts +14 -0
  12. package/dist/authoring.js +14 -0
  13. package/dist/cartridge/runtime.js +2 -2
  14. package/dist/combat/dotField.d.ts +43 -0
  15. package/dist/combat/dotField.js +48 -0
  16. package/dist/combat/projectiles.d.ts +2 -2
  17. package/dist/combat/projectiles.js +18 -5
  18. package/dist/combat/regenShield.d.ts +35 -0
  19. package/dist/combat/regenShield.js +45 -0
  20. package/dist/combat/shotOrigin.d.ts +13 -1
  21. package/dist/combat/shotOrigin.js +31 -2
  22. package/dist/editor/commands.d.ts +65 -0
  23. package/dist/editor/commands.js +193 -0
  24. package/dist/editor/document.d.ts +40 -0
  25. package/dist/editor/document.js +153 -0
  26. package/dist/editor/index.d.ts +30 -0
  27. package/dist/editor/index.js +6 -0
  28. package/dist/editor/types.d.ts +69 -0
  29. package/dist/editor/types.js +29 -0
  30. package/dist/game/connectedPlayers.d.ts +28 -0
  31. package/dist/game/connectedPlayers.js +21 -0
  32. package/dist/game/defineGame.d.ts +42 -1
  33. package/dist/game/defineGame.js +1 -0
  34. package/dist/game/events.d.ts +15 -0
  35. package/dist/game/keyValueStore.d.ts +25 -0
  36. package/dist/game/keyValueStore.js +61 -0
  37. package/dist/game/levelSequence.d.ts +24 -2
  38. package/dist/game/levelSequence.js +51 -2
  39. package/dist/game/lootTable.d.ts +4 -0
  40. package/dist/game/lootTable.js +1 -0
  41. package/dist/game/modelAnimation.d.ts +8 -0
  42. package/dist/game/modelAnimation.js +18 -0
  43. package/dist/game/playableGame.d.ts +68 -5
  44. package/dist/game/recordBook.d.ts +3 -5
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +7 -0
  47. package/dist/input/actionBindings.d.ts +2 -0
  48. package/dist/input/actionBindings.js +1 -0
  49. package/dist/input/axisInput.d.ts +7 -0
  50. package/dist/input/axisInput.js +16 -0
  51. package/dist/interaction/contextMenu.d.ts +1 -0
  52. package/dist/interaction/contextMenu.js +1 -0
  53. package/dist/interaction/proximityPrompt.d.ts +3 -1
  54. package/dist/interaction/proximityPrompt.js +3 -3
  55. package/dist/meta/changelog.d.ts +1 -1
  56. package/dist/meta/changelog.js +22 -1
  57. package/dist/movement/movementModel.d.ts +4 -0
  58. package/dist/movement/movementModel.js +13 -0
  59. package/dist/movement/playerMovement.d.ts +41 -0
  60. package/dist/movement/playerMovement.js +181 -0
  61. package/dist/random/rng.d.ts +2 -0
  62. package/dist/random/rng.js +2 -0
  63. package/dist/render/postProcessing.d.ts +56 -0
  64. package/dist/render/postProcessing.js +1 -0
  65. package/dist/runtime/adapter.d.ts +19 -0
  66. package/dist/runtime/adapter.js +11 -5
  67. package/dist/runtime/gameContext.d.ts +53 -10
  68. package/dist/runtime/gameContext.js +192 -54
  69. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  70. package/dist/runtime/hostedGameRunner.js +62 -0
  71. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  72. package/dist/runtime/hostedWorldSession.js +57 -0
  73. package/dist/runtime/inputSnapshot.d.ts +13 -0
  74. package/dist/runtime/inputSnapshot.js +2 -0
  75. package/dist/runtime/motionIntents.d.ts +2 -0
  76. package/dist/runtime/motionIntents.js +11 -0
  77. package/dist/runtime/worldChannel.d.ts +58 -0
  78. package/dist/runtime/worldChannel.js +69 -0
  79. package/dist/runtime/worldMirror.d.ts +24 -0
  80. package/dist/runtime/worldMirror.js +31 -0
  81. package/dist/runtime/worldReplication.d.ts +46 -0
  82. package/dist/runtime/worldReplication.js +185 -0
  83. package/dist/runtime/worldSnapshot.d.ts +17 -0
  84. package/dist/runtime/worldSnapshot.js +15 -0
  85. package/dist/scene/assetCatalog.d.ts +1 -0
  86. package/dist/scene/colliders.d.ts +6 -0
  87. package/dist/scene/colliders.js +25 -1
  88. package/dist/scene/entityStats.d.ts +4 -0
  89. package/dist/scene/entityStats.js +21 -0
  90. package/dist/scene/entityStore.d.ts +29 -0
  91. package/dist/scene/entityStore.js +60 -0
  92. package/dist/store/observableKeyedStore.d.ts +4 -0
  93. package/dist/store/observableKeyedStore.js +9 -0
  94. package/dist/tactics/fallingGrid.d.ts +2 -2
  95. package/dist/tactics/fallingGrid.js +2 -2
  96. package/dist/ui/gameLayout.d.ts +125 -0
  97. package/dist/ui/gameLayout.js +129 -0
  98. package/dist/ui/orientation.d.ts +39 -0
  99. package/dist/ui/orientation.js +49 -0
  100. package/dist/ui/swingTimer.d.ts +42 -0
  101. package/dist/ui/swingTimer.js +38 -0
  102. package/dist/visibility/spatialIndex.js +34 -20
  103. package/dist/world/environmentSummary.d.ts +11 -2
  104. package/dist/world/environmentSummary.js +11 -1
  105. package/dist/world/features.d.ts +134 -5
  106. package/dist/world/features.js +40 -3
  107. package/dist/world/roads.d.ts +47 -0
  108. package/dist/world/roads.js +146 -0
  109. package/dist/world/streets.d.ts +80 -0
  110. package/dist/world/streets.js +144 -0
  111. package/dist/world/terrain.d.ts +32 -5
  112. package/dist/world/terrain.js +124 -12
  113. package/llms.txt +485 -79
  114. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -11,7 +11,27 @@ Agents building on the published SDK can also read this programmatically:
11
11
  same data as typed values, so an updater can diff its installed version against
12
12
  the latest and surface the migration steps.
13
13
 
14
- ## Unreleased
14
+ ## 0.10.0
15
+
16
+ ### Migrate
17
+
18
+ - **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
19
+ - **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
20
+ - **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
21
+ - **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
22
+
23
+ ### Added
24
+
25
+ - Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
26
+ - Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
27
+ - Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
28
+ - HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
29
+ - Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
30
+ - Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
31
+
32
+ ### Changed
33
+
34
+ - First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
15
35
 
16
36
  ## 0.9.0
17
37
 
@@ -47,6 +47,16 @@ export interface SpawnRequest {
47
47
  point?: NavPoint;
48
48
  laneId?: number;
49
49
  }
50
+ /** Preference for picking a spawn point relative to `avoid` positions: closer, farther, or unweighted. */
51
+ export type SpawnPointDistanceBias = "near" | "far" | "none";
52
+ /** Semantic options for selecting a spawn point without exposing weighting internals. */
53
+ export interface SpawnPointSelectionOptions {
54
+ candidates: readonly NavPoint[];
55
+ avoid?: readonly NavPoint[];
56
+ random: () => number;
57
+ distanceBias?: SpawnPointDistanceBias;
58
+ biasStrength?: number;
59
+ }
50
60
  export interface DirectorStep {
51
61
  state: SpawnDirectorState;
52
62
  spawns: SpawnRequest[];
@@ -55,6 +65,9 @@ export declare function createSpawnDirectorState(config: SpawnDirectorConfig): S
55
65
  export declare function raiseAlert(state: SpawnDirectorState, amount: number): SpawnDirectorState;
56
66
  export declare function advanceWave(config: SpawnDirectorConfig, state: SpawnDirectorState): SpawnDirectorState;
57
67
  export declare function advanceSpawnDirector(config: SpawnDirectorConfig, state: SpawnDirectorState, dt: number, ctx: DirectorContext): DirectorStep;
68
+ /** Selects a candidate spawn point using a semantic distance preference and caller-supplied randomness. */
69
+ export declare function pickSpawnPoint(options: SpawnPointSelectionOptions): NavPoint | null;
70
+ /** @deprecated Pass one SpawnPointSelectionOptions object instead. */
58
71
  export declare function pickSpawnPoint(points: readonly NavPoint[], players: readonly NavPoint[], options: {
59
72
  roll: number;
60
73
  bias?: number;
@@ -118,7 +118,14 @@ export function advanceSpawnDirector(config, state, dt, ctx) {
118
118
  if (spawnPoints !== undefined && spawnPoints.length > 0) {
119
119
  const [roll, nextRng] = nextRandom(rng);
120
120
  rng = nextRng;
121
- const point = pickSpawnPoint(spawnPoints, playerPositions, { roll, bias: config.spawnPointBias });
121
+ const bias = config.spawnPointBias ?? 1;
122
+ const point = pickSpawnPoint({
123
+ candidates: spawnPoints,
124
+ avoid: playerPositions,
125
+ random: () => roll,
126
+ distanceBias: bias < 0 ? "far" : bias > 0 ? "near" : "none",
127
+ biasStrength: Math.abs(bias),
128
+ });
122
129
  if (point !== null) {
123
130
  spawn.point = point;
124
131
  spawn.laneId = spawnPoints.indexOf(point);
@@ -134,29 +141,46 @@ export function advanceSpawnDirector(config, state, dt, ctx) {
134
141
  spawns,
135
142
  };
136
143
  }
137
- export function pickSpawnPoint(points, players, options) {
138
- if (points.length === 0)
144
+ export function pickSpawnPoint(optionsOrPoints, legacyPlayers = [], legacyOptions = { roll: 0 }) {
145
+ let options;
146
+ if (Array.isArray(optionsOrPoints)) {
147
+ const legacyBias = legacyOptions.bias ?? 1;
148
+ options = {
149
+ candidates: optionsOrPoints,
150
+ avoid: legacyPlayers,
151
+ random: () => legacyOptions.roll,
152
+ distanceBias: legacyBias < 0 ? "far" : legacyBias > 0 ? "near" : "none",
153
+ biasStrength: Math.abs(legacyBias),
154
+ };
155
+ }
156
+ else {
157
+ options = optionsOrPoints;
158
+ }
159
+ const { candidates, avoid = [], random, distanceBias = "near", biasStrength = 1 } = options;
160
+ if (candidates.length === 0)
139
161
  return null;
140
- const bias = options.bias ?? 1;
162
+ if (distanceBias === "none" || avoid.length === 0 || biasStrength === 0) {
163
+ return candidates[Math.min(candidates.length - 1, Math.floor(clamp01(random()) * candidates.length))];
164
+ }
165
+ const exponent = distanceBias === "far" ? biasStrength : -biasStrength;
141
166
  const weights = [];
142
167
  let total = 0;
143
- for (const point of points) {
168
+ for (const point of candidates) {
144
169
  let nearest = Number.POSITIVE_INFINITY;
145
- for (const player of players) {
146
- const d = Math.hypot(point[0] - player[0], point[1] - player[1]);
147
- if (d < nearest)
148
- nearest = d;
170
+ for (const avoidedPoint of avoid) {
171
+ const distance = Math.hypot(point[0] - avoidedPoint[0], point[1] - avoidedPoint[1]);
172
+ if (distance < nearest)
173
+ nearest = distance;
149
174
  }
150
- const distance = nearest === Number.POSITIVE_INFINITY ? 0 : nearest;
151
- const weight = Math.max(1e-6, (1 + distance) ** -bias);
175
+ const weight = Math.max(1e-6, (1 + nearest) ** exponent);
152
176
  weights.push(weight);
153
177
  total += weight;
154
178
  }
155
- let cursor = clamp01(options.roll) * total;
156
- for (let i = 0; i < points.length; i += 1) {
157
- cursor -= weights[i];
179
+ let cursor = clamp01(random()) * total;
180
+ for (let index = 0; index < candidates.length; index += 1) {
181
+ cursor -= weights[index];
158
182
  if (cursor <= 0)
159
- return points[i];
183
+ return candidates[index];
160
184
  }
161
- return points[points.length - 1];
185
+ return candidates[candidates.length - 1];
162
186
  }
@@ -0,0 +1,5 @@
1
+ import type { NavPoint } from "../nav/navGrid.js";
2
+ import { type SpawnPointDistanceBias, type SpawnPointSelectionOptions } from "./spawnDirector.js";
3
+ export type { SpawnPointDistanceBias, SpawnPointSelectionOptions };
4
+ /** Selects a spawn point by game intent while keeping weighting mechanics internal. */
5
+ export declare function selectSpawnPoint(options: SpawnPointSelectionOptions): NavPoint | null;
@@ -0,0 +1,5 @@
1
+ import { pickSpawnPoint, } from "./spawnDirector.js";
2
+ /** Selects a spawn point by game intent while keeping weighting mechanics internal. */
3
+ export function selectSpawnPoint(options) {
4
+ return pickSpawnPoint(options);
5
+ }
@@ -1,3 +1,4 @@
1
+ import type { SynthPatch } from "./synth.js";
1
2
  export type AudioBusId = string;
2
3
  export type FalloffCurve = "linear" | "inverse" | "none";
3
4
  export interface AudioFalloffConfig {
@@ -15,8 +16,10 @@ export interface AudioBusDef {
15
16
  }
16
17
  export interface SoundDef {
17
18
  id: string;
18
- /** Playable source — a URL/data URI the shell's Web Audio glue fetches and decodes. */
19
- url: string;
19
+ /** Sample source — a URL/data URI the shell's Web Audio glue fetches and decodes. Provide exactly one of `url` or `synth`. */
20
+ url?: string;
21
+ /** Procedural source — a pure synth patch the shell realises on Web Audio, no file fetch. Provide exactly one of `url` or `synth`. */
22
+ synth?: SynthPatch;
20
23
  bus: AudioBusId;
21
24
  /** Base gain before bus/falloff multipliers. Default 1. */
22
25
  gain?: number;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Named synthesised instrument. Each maps to a voice in the shell's instrument
3
+ * library (`@jgengine/shell/audio/musicVoices`); an unknown name falls back to a
4
+ * plain sine voice so a theme is never silent.
5
+ */
6
+ export type MusicInstrument = "strings" | "flute" | "harp" | "horn" | "choir" | "bell" | "timpani" | "bass" | "stacc" | "pad" | "lute" | "dulcimer" | "frameDrum" | "warDrum" | "reed" | "pipe" | "squareLead" | "woodBlock" | "tinyBell" | "piano" | "shaker" | "brassStab" | "cymSwell" | "oboe";
7
+ /** One scheduled note in a theme, positioned on the loop's quarter-note grid. */
8
+ export interface NoteEvent {
9
+ /** Quarter-note position within the loop (0-based). */
10
+ beat: number;
11
+ /** MIDI note number (60 = middle C). */
12
+ midi: number;
13
+ /** Sounding length in quarter-note beats. */
14
+ dur: number;
15
+ /** Velocity 0..1. */
16
+ vel: number;
17
+ /** Which instrument voice plays it. */
18
+ inst: MusicInstrument;
19
+ }
20
+ /**
21
+ * A through-composed, looping music track. `events` need not be sorted; the
22
+ * director schedules them ahead against a fixed anchor so loops are seamless.
23
+ */
24
+ export interface MusicTheme {
25
+ id: string;
26
+ /** Tempo in beats (quarter notes) per minute. */
27
+ bpm: number;
28
+ /** Loop length in 4/4 bars; the loop is `bars * 4` beats long. */
29
+ bars: number;
30
+ events: readonly NoteEvent[];
31
+ /**
32
+ * Per-theme loudness normalisation multiplied into the crossfade target, so
33
+ * dense and sparse themes sit at the same perceived level. Default 1.
34
+ */
35
+ trim?: number;
36
+ }
37
+ /** A resolved note occurrence: the source note plus its absolute onset in seconds. */
38
+ export interface ScheduledNote {
39
+ event: NoteEvent;
40
+ /** Absolute onset in seconds, on the timeline of the `anchorSec`/window args. */
41
+ when: number;
42
+ }
43
+ /** Standard equal-temperament MIDI-to-frequency (A4 = 440 Hz at MIDI 69). */
44
+ export declare function mtof(midi: number): number;
45
+ /** Loop length of a theme in seconds. */
46
+ export declare function themeLoopSeconds(theme: MusicTheme): number;
47
+ /**
48
+ * Pure lookahead scheduler: every note occurrence of `theme` whose onset falls
49
+ * in the half-open window `(fromSec, toSec]`, given the theme's loop-zero at
50
+ * `anchorSec`. Handles any number of loop wraps, so a director calls it once per
51
+ * tick with a non-overlapping window and never double-schedules a note.
52
+ */
53
+ export declare function notesInWindow(theme: MusicTheme, anchorSec: number, fromSec: number, toSec: number): ScheduledNote[];
@@ -0,0 +1,34 @@
1
+ /** Standard equal-temperament MIDI-to-frequency (A4 = 440 Hz at MIDI 69). */
2
+ export function mtof(midi) {
3
+ return 440 * 2 ** ((midi - 69) / 12);
4
+ }
5
+ /** Loop length of a theme in seconds. */
6
+ export function themeLoopSeconds(theme) {
7
+ return (theme.bars * 4 * 60) / theme.bpm;
8
+ }
9
+ /**
10
+ * Pure lookahead scheduler: every note occurrence of `theme` whose onset falls
11
+ * in the half-open window `(fromSec, toSec]`, given the theme's loop-zero at
12
+ * `anchorSec`. Handles any number of loop wraps, so a director calls it once per
13
+ * tick with a non-overlapping window and never double-schedules a note.
14
+ */
15
+ export function notesInWindow(theme, anchorSec, fromSec, toSec) {
16
+ const loop = themeLoopSeconds(theme);
17
+ if (loop <= 0 || toSec <= fromSec)
18
+ return [];
19
+ const spb = 60 / theme.bpm;
20
+ const out = [];
21
+ for (const event of theme.events) {
22
+ const onset = event.beat * spb;
23
+ let k = Math.max(0, Math.floor((fromSec - anchorSec - onset) / loop));
24
+ for (;;) {
25
+ const when = anchorSec + onset + k * loop;
26
+ if (when > toSec)
27
+ break;
28
+ if (when > fromSec)
29
+ out.push({ event, when });
30
+ k += 1;
31
+ }
32
+ }
33
+ return out;
34
+ }
@@ -0,0 +1,60 @@
1
+ /** Oscillator waveform available to a pitched synth voice. */
2
+ export type SynthWave = "sine" | "square" | "sawtooth" | "triangle";
3
+ /** Biquad filter shape a noise voice is coloured through. */
4
+ export type SynthFilter = "lowpass" | "highpass" | "bandpass";
5
+ /**
6
+ * A pitched oscillator voice: a 12ms linear attack to `gain`, then an
7
+ * exponential decay to silence across `duration`, with an optional
8
+ * exponential pitch slide from `freq` to `slideTo`.
9
+ */
10
+ export interface ToneVoice {
11
+ kind: "tone";
12
+ /** Start frequency in Hz. */
13
+ freq: number;
14
+ /** Total voice length in seconds (the decay spans this). */
15
+ duration: number;
16
+ /** Peak gain before patch/bus scaling. Default 1. */
17
+ gain?: number;
18
+ /** Oscillator waveform. Default "sine". */
19
+ wave?: SynthWave;
20
+ /** Start offset in seconds from the cue trigger. Default 0. */
21
+ delay?: number;
22
+ /** When set, pitch ramps exponentially from `freq` to this Hz across `duration`. */
23
+ slideTo?: number;
24
+ /** Attack ramp in seconds. Default 0.012. */
25
+ attack?: number;
26
+ }
27
+ /**
28
+ * A filtered white-noise burst — impacts, whooshes, breath, crackle. Realised
29
+ * from a shared 1s noise buffer at a randomised playback rate and start offset,
30
+ * decaying exponentially to silence at `duration * decay`.
31
+ */
32
+ export interface NoiseVoice {
33
+ kind: "noise";
34
+ /** Total voice length in seconds. */
35
+ duration: number;
36
+ /** Biquad cutoff/centre in Hz. */
37
+ filterFreq: number;
38
+ /** Peak gain before patch/bus scaling. Default 1. */
39
+ gain?: number;
40
+ /** Filter shape. Default "lowpass". */
41
+ filterType?: SynthFilter;
42
+ /** Fraction of `duration` over which the burst decays to silence. Default 0.9. */
43
+ decay?: number;
44
+ /** Start offset in seconds from the cue trigger. Default 0. */
45
+ delay?: number;
46
+ }
47
+ /** One layered voice of a synth cue. */
48
+ export type SynthVoice = ToneVoice | NoiseVoice;
49
+ /**
50
+ * A procedural sound cue: a set of voices triggered together, each with its own
51
+ * `delay`, summed into one one-shot. Pure serialisable data — the shell realises
52
+ * it on Web Audio, so the same catalog runs headless in tests with no `AudioContext`.
53
+ */
54
+ export interface SynthPatch {
55
+ voices: readonly SynthVoice[];
56
+ /** Overall cue gain multiplier applied to every voice. Default 1. */
57
+ gain?: number;
58
+ }
59
+ /** Total wall-clock length of a patch in seconds — the latest voice end across all voices. */
60
+ export declare function patchDuration(patch: SynthPatch): number;
@@ -0,0 +1,8 @@
1
+ /** Total wall-clock length of a patch in seconds — the latest voice end across all voices. */
2
+ export function patchDuration(patch) {
3
+ let end = 0;
4
+ for (const voice of patch.voices) {
5
+ end = Math.max(end, (voice.delay ?? 0) + voice.duration);
6
+ }
7
+ return end;
8
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Task-first JGEngine authoring surface.
3
+ *
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
+ */
8
+ export { defineGame, type GameDefinition, type GameDefinitionConfig, type GameLoop, type InventoryDeclaration, type PhysicsConfig, type GameServerConfig, } from "./game/defineGame.js";
9
+ export { lootTable, type LootTableDef, type LootEntry, type Drop, } from "./game/lootTable.js";
10
+ export { biomes, voxel, plots, tilemap, flat, environment, terrain, rain, snow, grass, ocean, building, road, type WorldFeature, } from "./world/features.js";
11
+ export { seededRng, seededStreams } from "./random/rng.js";
12
+ export { selectSpawnPoint, type SpawnPointSelectionOptions, type SpawnPointDistanceBias, } from "./ai/spawnPoint.js";
13
+ export { contextVerb } from "./interaction/contextMenu.js";
14
+ export { type ActionCodesMap } from "./input/actionBindings.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Task-first JGEngine authoring surface.
3
+ *
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
+ */
8
+ export { defineGame, } from "./game/defineGame.js";
9
+ export { lootTable, } from "./game/lootTable.js";
10
+ export { biomes, voxel, plots, tilemap, flat, environment, terrain, rain, snow, grass, ocean, building, road, } from "./world/features.js";
11
+ export { seededRng, seededStreams } from "./random/rng.js";
12
+ export { selectSpawnPoint, } from "./ai/spawnPoint.js";
13
+ export { contextVerb } from "./interaction/contextMenu.js";
14
+ export {} from "./input/actionBindings.js";
@@ -409,7 +409,7 @@ export function createCartridge(spec) {
409
409
  const run = getRun(ctx);
410
410
  syncPhase(ctx, run);
411
411
  if (spec.rules.killLeaderboardStat !== undefined) {
412
- ctx.game.leaderboard.track({ stat: spec.rules.killLeaderboardStat, scope: "profile" });
412
+ ctx.game.leaderboard?.track({ stat: spec.rules.killLeaderboardStat, scope: "profile" });
413
413
  }
414
414
  run.disposeEvents?.();
415
415
  run.disposeEvents = ctx.game.events.on("entity.died", (event) => {
@@ -428,7 +428,7 @@ export function createCartridge(spec) {
428
428
  return;
429
429
  run.kills += 1;
430
430
  if (spec.rules.killLeaderboardStat !== undefined) {
431
- ctx.game.leaderboard.increment(ctx.player.userId, spec.rules.killLeaderboardStat, { scope: "profile" });
431
+ ctx.game.leaderboard?.increment(ctx.player.userId, spec.rules.killLeaderboardStat, { scope: "profile" });
432
432
  }
433
433
  ctx.scene.worldItem.spawn({
434
434
  itemId: XP_GEM_BASE_TYPE,
@@ -0,0 +1,43 @@
1
+ import type { BuildupProc } from "./breakMeters.js";
2
+ /** A single damage-over-time instance: how hard, how often, and for how long. */
3
+ export interface DotSpec {
4
+ /** Damage dealt at each interval. */
5
+ damagePerTick: number;
6
+ /** Milliseconds between damage ticks; clamped to a positive minimum. */
7
+ intervalMs: number;
8
+ /** Total lifetime in milliseconds; the DoT expires once its age reaches this. */
9
+ durationMs: number;
10
+ /** Label echoed on each emitted tick (bleed, poison, burn); defaults to the apply key. */
11
+ status?: string;
12
+ }
13
+ /** One damage event (or expiry marker) emitted by `DotField.tick` for the game to route through its effects. */
14
+ export interface DotTick {
15
+ /** The key the DoT was applied under. */
16
+ id: string;
17
+ /** The DoT's `status` label, or its key when none was given. */
18
+ status: string;
19
+ /** Damage to route through the game's effect pipeline this frame; 0 on a bare expiry marker. */
20
+ damage: number;
21
+ /** True on the entry that ends the DoT — remove any HUD icon on it. */
22
+ expired: boolean;
23
+ }
24
+ /**
25
+ * A pool of timed damage-over-time instances keyed by string (`bleed`, `poison`, `burn`) — the
26
+ * recurring-damage counterpart to `breakMeters`' one-shot buildup procs (#536.4). `tick` advances
27
+ * every active DoT and returns the damage due this frame, one entry per interval crossed, so the game
28
+ * routes each through its own effect pipeline; a pure scheduler that never applies damage itself.
29
+ */
30
+ export interface DotField {
31
+ /** Start or refresh a DoT under `id`; reapplying the same key restarts its age and lifetime. */
32
+ apply(id: string, spec: DotSpec): void;
33
+ /** Bridge a `breakMeters` buildup proc into recurring damage under `id`. */
34
+ applyProc(id: string, proc: BuildupProc, damagePerTick: number, intervalMs: number): void;
35
+ active(id: string): boolean;
36
+ cancel(id: string): void;
37
+ clear(): void;
38
+ count(): number;
39
+ /** Advance every active DoT; returns the damage due this frame plus an `expired` marker per finished DoT. */
40
+ tick(dtSeconds: number): DotTick[];
41
+ }
42
+ /** Builds an empty {@link DotField}; `apply` DoTs onto it and drain damage each frame with `tick`. */
43
+ export declare function createDotField(): DotField;
@@ -0,0 +1,48 @@
1
+ /** Builds an empty {@link DotField}; `apply` DoTs onto it and drain damage each frame with `tick`. */
2
+ export function createDotField() {
3
+ const dots = new Map();
4
+ return {
5
+ apply(id, spec) {
6
+ dots.set(id, {
7
+ status: spec.status ?? id,
8
+ damagePerTick: spec.damagePerTick,
9
+ intervalMs: Math.max(1, spec.intervalMs),
10
+ durationMs: Math.max(0, spec.durationMs),
11
+ ageMs: 0,
12
+ ticksEmitted: 0,
13
+ });
14
+ },
15
+ applyProc(id, proc, damagePerTick, intervalMs) {
16
+ this.apply(id, { damagePerTick, intervalMs, durationMs: proc.durationMs, status: proc.status });
17
+ },
18
+ active: (id) => dots.has(id),
19
+ cancel: (id) => void dots.delete(id),
20
+ clear: () => dots.clear(),
21
+ count: () => dots.size,
22
+ tick(dtSeconds) {
23
+ if (dtSeconds <= 0)
24
+ return [];
25
+ const dtMs = dtSeconds * 1000;
26
+ const emitted = [];
27
+ for (const [id, dot] of dots) {
28
+ dot.ageMs += dtMs;
29
+ const activeMs = Math.min(dot.ageMs, dot.durationMs);
30
+ const dueTicks = Math.floor(activeMs / dot.intervalMs);
31
+ let last = null;
32
+ while (dot.ticksEmitted < dueTicks) {
33
+ dot.ticksEmitted += 1;
34
+ last = { id, status: dot.status, damage: dot.damagePerTick, expired: false };
35
+ emitted.push(last);
36
+ }
37
+ if (dot.ageMs >= dot.durationMs) {
38
+ dots.delete(id);
39
+ if (last !== null)
40
+ last.expired = true;
41
+ else
42
+ emitted.push({ id, status: dot.status, damage: 0, expired: true });
43
+ }
44
+ }
45
+ return emitted;
46
+ },
47
+ };
48
+ }
@@ -1,6 +1,6 @@
1
1
  import type { BallisticSweep } from "../physics/ballisticSweep.js";
2
2
  import type { EntityPosition } from "../scene/entityStore.js";
3
- import type { EntityColliderSet } from "../scene/colliders.js";
3
+ import { type EntityColliderSet } from "../scene/colliders.js";
4
4
  import { type SceneRaycastApi } from "../scene/sceneRaycast.js";
5
5
  import type { Aim } from "../scene/spatial.js";
6
6
  import type { CombatSpatialDeps, EffectResult, EffectSystem, EffectVia } from "./effects.js";
@@ -10,7 +10,7 @@ export interface ProjectileShotInput {
10
10
  via: EffectVia;
11
11
  aim: Aim;
12
12
  effect: string;
13
- /** Defaults to `{ kind: "legacy" }` — aim.origin or shooter position. */
13
+ /** Defaults to `{ kind: "eye" }` — aim.origin when given, else shooter position at eye height. */
14
14
  originPolicy?: ShotOriginPolicy;
15
15
  }
16
16
  export interface EntityRaycastHit {
@@ -1,3 +1,4 @@
1
+ import { defaultEntityColliders, resolveColliders } from "../scene/colliders.js";
1
2
  import { createSceneRaycast, firstImpact, hitsUntilBlocked, } from "../scene/sceneRaycast.js";
2
3
  import { aimDirection, aimSpreadDeg, resolveShot, } from "./shotOrigin.js";
3
4
  const DEFAULT_RANGE = 100;
@@ -70,7 +71,7 @@ export function createProjectileSystem(deps) {
70
71
  const shots = new Map();
71
72
  let shotCounter = 0;
72
73
  const now = deps.now ?? (() => Date.now());
73
- const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "legacy" };
74
+ const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "eye" };
74
75
  function itemStat(via, stat) {
75
76
  return via.item === undefined ? null : deps.getStat(via.item, stat);
76
77
  }
@@ -84,6 +85,7 @@ export function createProjectileSystem(deps) {
84
85
  return resolveShot({
85
86
  positionOf: deps.spatial.positionOf,
86
87
  rotationYOf: deps.rotationYOf,
88
+ collidersOf: deps.entityCollidersOf,
87
89
  }, from, aim, policy ?? defaultPolicy);
88
90
  }
89
91
  const internalSceneRaycast = deps.sceneRaycast ??
@@ -149,15 +151,26 @@ export function createProjectileSystem(deps) {
149
151
  if (hit.targetKind === "entity" && spreadRad > 0) {
150
152
  const position = deps.spatial.positionOf(hit.instanceId);
151
153
  if (position !== undefined) {
152
- const dx = position[0] - origin[0];
153
- const dy = position[1] - origin[1];
154
- const dz = position[2] - origin[2];
154
+ const colliders = resolveColliders(deps.entityCollidersOf?.(hit.instanceId) ?? defaultEntityColliders());
155
+ const collider = colliders.find((candidate) => candidate.name === hit.colliderName) ?? colliders[0];
156
+ const localOffset = collider?.shape.offset ?? [0, 0, 0];
157
+ const slack = collider === undefined
158
+ ? BASE_HIT_RADIUS
159
+ : collider.shape.kind === "sphere"
160
+ ? collider.shape.radius
161
+ : Math.hypot(...collider.shape.halfExtents);
162
+ const rotationY = deps.rotationYOf?.(hit.instanceId) ?? 0;
163
+ const cos = Math.cos(rotationY);
164
+ const sin = Math.sin(rotationY);
165
+ const dx = position[0] + localOffset[0] * cos + localOffset[2] * sin - origin[0];
166
+ const dy = position[1] + localOffset[1] - origin[1];
167
+ const dz = position[2] - localOffset[0] * sin + localOffset[2] * cos - origin[2];
155
168
  const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
156
169
  const px = dx - direction[0] * along;
157
170
  const py = dy - direction[1] * along;
158
171
  const pz = dz - direction[2] * along;
159
172
  const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
160
- if (perpendicular > 0.5 + Math.tan(spreadRad) * along)
173
+ if (perpendicular > slack + Math.tan(spreadRad) * along)
161
174
  continue;
162
175
  }
163
176
  }
@@ -0,0 +1,35 @@
1
+ /** Tuning for `createRegenShield`: pool size, refill rate, and the post-damage grace period. */
2
+ export interface RegenShieldConfig {
3
+ max: number;
4
+ /** Points restored per second once the post-damage grace period has elapsed. */
5
+ regenPerSecond: number;
6
+ /** Grace period after the last damage before regen resumes. */
7
+ regenDelayMs: number;
8
+ /** Starting value; defaults to `max` (full). */
9
+ current?: number;
10
+ }
11
+ /**
12
+ * A shield pool that stops regenerating for `regenDelayMs` after every hit, then refills at
13
+ * `regenPerSecond` — the delayed-regen primitive that replaces snapshot-comparing stat values per
14
+ * tick to detect "damage taken" (#536.3). `damage` resets the grace timer; `tick` counts it down and
15
+ * regenerates once it elapses.
16
+ */
17
+ export interface RegenShield {
18
+ current(): number;
19
+ max(): number;
20
+ setMax(value: number): void;
21
+ fraction(): number;
22
+ isFull(): boolean;
23
+ isBroken(): boolean;
24
+ /** True while the post-damage grace period is suppressing regen. */
25
+ suppressed(): boolean;
26
+ /** Absorb `amount` and reset the regen grace timer; returns damage that got through (0 while the shield holds). */
27
+ damage(amount: number): number;
28
+ /** Add `amount` without touching the regen timer (a pickup or scripted refill). */
29
+ restore(amount: number): void;
30
+ set(value: number): void;
31
+ /** Advance time: count down the grace period, then regenerate once it has elapsed. */
32
+ tick(dtSeconds: number): void;
33
+ }
34
+ /** Builds a {@link RegenShield} that suppresses regen for `regenDelayMs` after each hit. */
35
+ export declare function createRegenShield(config: RegenShieldConfig): RegenShield;
@@ -0,0 +1,45 @@
1
+ /** Builds a {@link RegenShield} that suppresses regen for `regenDelayMs` after each hit. */
2
+ export function createRegenShield(config) {
3
+ let max = Math.max(0, config.max);
4
+ const regenPerSecond = Math.max(0, config.regenPerSecond);
5
+ const regenDelayMs = Math.max(0, config.regenDelayMs);
6
+ function clamp(value, ceiling) {
7
+ return Math.max(0, Math.min(ceiling, value));
8
+ }
9
+ let current = clamp(config.current ?? max, max);
10
+ let sinceDamageMs = regenDelayMs;
11
+ return {
12
+ current: () => current,
13
+ max: () => max,
14
+ setMax(value) {
15
+ max = Math.max(0, value);
16
+ current = clamp(current, max);
17
+ },
18
+ fraction: () => (max <= 0 ? 0 : current / max),
19
+ isFull: () => current >= max,
20
+ isBroken: () => current <= 0,
21
+ suppressed: () => sinceDamageMs < regenDelayMs,
22
+ damage(amount) {
23
+ if (amount <= 0)
24
+ return 0;
25
+ sinceDamageMs = 0;
26
+ const overflow = Math.max(0, amount - current);
27
+ current = clamp(current - amount, max);
28
+ return overflow;
29
+ },
30
+ restore(amount) {
31
+ if (amount > 0)
32
+ current = clamp(current + amount, max);
33
+ },
34
+ set(value) {
35
+ current = clamp(value, max);
36
+ },
37
+ tick(dtSeconds) {
38
+ if (dtSeconds <= 0)
39
+ return;
40
+ sinceDamageMs += dtSeconds * 1000;
41
+ if (sinceDamageMs >= regenDelayMs)
42
+ current = clamp(current + regenPerSecond * dtSeconds, max);
43
+ },
44
+ };
45
+ }