@jgengine/core 0.36.0 → 0.37.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 (37) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/combat/projectiles.d.ts +6 -2
  3. package/dist/combat/projectiles.js +55 -52
  4. package/dist/combat/shotSpec.d.ts +239 -0
  5. package/dist/combat/shotSpec.js +216 -0
  6. package/dist/combat/weaponSystem.js +26 -21
  7. package/dist/combat.d.ts +1 -0
  8. package/dist/combat.js +1 -0
  9. package/dist/game/playableGame.d.ts +9 -0
  10. package/dist/meta/changelog.d.ts +1 -1
  11. package/dist/meta/changelog.js +58 -1
  12. package/dist/movement/characterController.d.ts +5 -0
  13. package/dist/movement/characterController.js +8 -1
  14. package/dist/movement/movementModel.js +4 -1
  15. package/dist/movement/playerMovement.js +4 -0
  16. package/dist/physics/physicsBackendConformance.js +17 -0
  17. package/dist/physics/physicsWorld.d.ts +7 -0
  18. package/dist/physics/physicsWorld.js +27 -6
  19. package/dist/run/runLoop.d.ts +44 -1
  20. package/dist/run/runLoop.js +66 -5
  21. package/dist/world/features.d.ts +15 -1
  22. package/dist/world/features.js +51 -2
  23. package/dist/world/lod.d.ts +8 -0
  24. package/dist/world/lod.js +13 -5
  25. package/dist/world/modelLod.d.ts +44 -0
  26. package/dist/world/modelLod.js +75 -0
  27. package/dist/world.d.ts +2 -1
  28. package/dist/world.js +2 -1
  29. package/package.json +1 -1
  30. package/skills/jgengine/api.md +2 -2
  31. package/skills/jgengine-combat/SKILL.md +7 -0
  32. package/skills/jgengine-combat/api.md +61 -1
  33. package/skills/jgengine-combat/capabilities.md +4 -0
  34. package/skills/jgengine-combat/reference.md +1 -0
  35. package/skills/jgengine-gameplay/api.md +6 -0
  36. package/skills/jgengine-gameplay/capabilities.md +1 -0
  37. package/skills/jgengine-world/api.md +17 -0
@@ -565,7 +565,7 @@ export class PhysicsWorld {
565
565
  this.posX[i] += this.velX[i] * dt;
566
566
  this.posY[i] += this.velY[i] * dt;
567
567
  this.posZ[i] += this.velZ[i] * dt;
568
- this.constrainBounds(i);
568
+ this.constrainBounds(i, dt, true);
569
569
  }
570
570
  this.buildGrid();
571
571
  const iterations = this.solverIterations;
@@ -575,10 +575,10 @@ export class PhysicsWorld {
575
575
  this.solveJoints(dt);
576
576
  }
577
577
  for (let a = 0; a < this.awakeCount; a += 1)
578
- this.constrainBounds(this.awakeList[a]);
578
+ this.constrainBounds(this.awakeList[a], dt, false);
579
579
  this.updateSleep();
580
580
  }
581
- constrainBounds(i) {
581
+ constrainBounds(i, dt, frictionPass) {
582
582
  const min = this.bounds.min;
583
583
  const max = this.bounds.max;
584
584
  const e = this.restitution;
@@ -613,9 +613,8 @@ export class PhysicsWorld {
613
613
  this.posY[i] = loY;
614
614
  if (this.velY[i] < 0)
615
615
  this.velY[i] = -this.velY[i] < rt ? 0 : this.velY[i] * -e;
616
- const tf = 1 - this.friction;
617
- this.velX[i] *= tf;
618
- this.velZ[i] *= tf;
616
+ if (frictionPass)
617
+ this.applyFloorFriction(i, dt);
619
618
  }
620
619
  else if (this.posY[i] > hiY) {
621
620
  this.posY[i] = hiY;
@@ -623,6 +622,28 @@ export class PhysicsWorld {
623
622
  this.velY[i] = this.velY[i] < rt ? 0 : -this.velY[i] * e;
624
623
  }
625
624
  }
625
+ /**
626
+ * Coulomb friction against the bounds floor, using the same `friction` coefficient a contact
627
+ * applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old
628
+ * multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body
629
+ * on the world floor stopped ~6× shorter than the same body on a static floor collider.
630
+ */
631
+ applyFloorFriction(i, dt) {
632
+ const decel = this.friction * Math.abs(this.gravity) * dt;
633
+ if (!(decel > 0))
634
+ return;
635
+ const vx = this.velX[i];
636
+ const vz = this.velZ[i];
637
+ const speed = Math.sqrt(vx * vx + vz * vz);
638
+ if (speed <= decel) {
639
+ this.velX[i] = 0;
640
+ this.velZ[i] = 0;
641
+ return;
642
+ }
643
+ const keep = (speed - decel) / speed;
644
+ this.velX[i] = vx * keep;
645
+ this.velZ[i] = vz * keep;
646
+ }
626
647
  /**
627
648
  * Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The
628
649
  * cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count
@@ -19,6 +19,28 @@ export interface RunOffer<T> {
19
19
  changeId: string | null;
20
20
  change: T | null;
21
21
  }
22
+ /** One change a run could open with, in the shape a choice screen shows: the id and its payload. */
23
+ export interface RunChoice<T> {
24
+ id: string;
25
+ change: T;
26
+ }
27
+ /** What the loop reports about the run it is playing, for a capture or devtools read. */
28
+ export interface RunReadout {
29
+ /** The run in progress, or the last settled run when none is open. */
30
+ run: number;
31
+ /** What the run in progress carries and can still lose. */
32
+ carried: number;
33
+ banked: number;
34
+ lost: number;
35
+ /** The best single bank across the career. */
36
+ best: number;
37
+ /** The change the run in progress opened with, or the one the next run is offered. */
38
+ offered: string | null;
39
+ }
40
+ /** Read-only handle a capture or devtools uses to prove a run loop is live, not merely imported. */
41
+ export interface RunProbe {
42
+ read(): RunReadout;
43
+ }
22
44
  /** Everything a run loop stores as plain JSON: the run in progress plus lifetime totals. */
23
45
  export interface RunLoopState {
24
46
  /** The run in progress, or `0` before the first `start`. */
@@ -66,6 +88,17 @@ export interface RunLoop<T> {
66
88
  haul(): number;
67
89
  /** The run in progress's offer, or the offer the next `start` would open. */
68
90
  offer(): RunOffer<T>;
91
+ /**
92
+ * Up to `count` distinct changes the next run could open with, drawn weighted and in draw order,
93
+ * so a game can offer a choice instead of the single change {@link RunOffer} names. `choose` takes one.
94
+ */
95
+ present(count: number): RunChoice<T>[];
96
+ /**
97
+ * Open the next run around `changeId` (or around no change for `null`), which is how a presented
98
+ * choice becomes the change the run carries. Returns false for an id this loop's table does not
99
+ * carry, leaving the offer untouched.
100
+ */
101
+ choose(changeId: string | null): boolean;
69
102
  /**
70
103
  * Close the run in progress: bank the haul scaled by `multiplier`, or lose it. Throws when no run
71
104
  * is open, so a double settle cannot bank the same haul twice.
@@ -81,8 +114,18 @@ export interface RunLoop<T> {
81
114
  * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
82
115
  * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
83
116
  * it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
84
- * seed and the same run history give the same sequence, before or after a save/load.
117
+ * seed and the same run history give the same sequence, before or after a save/load. `present(count)`
118
+ * draws several of those changes for a choice screen and `choose(id)` adopts the pick as the next run's
119
+ * change.
85
120
  *
86
121
  * @capability run-loop bank or lose what a run carries, with one deterministic change per run
87
122
  */
88
123
  export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
124
+ /**
125
+ * Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
126
+ * the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
127
+ * starts, settles or changes a run. Returns the uninstaller.
128
+ *
129
+ * @capability run-loop bank or lose what a run carries, with one deterministic change per run
130
+ */
131
+ export declare function installRunProbe<T>(loop: RunLoop<T>): () => void;
@@ -10,7 +10,9 @@ function positive(value) {
10
10
  * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
11
11
  * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
12
12
  * it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
13
- * seed and the same run history give the same sequence, before or after a save/load.
13
+ * seed and the same run history give the same sequence, before or after a save/load. `present(count)`
14
+ * draws several of those changes for a choice screen and `choose(id)` adopts the pick as the next run's
15
+ * change.
14
16
  *
15
17
  * @capability run-loop bank or lose what a run carries, with one deterministic change per run
16
18
  */
@@ -30,14 +32,18 @@ export function createRunLoop(config) {
30
32
  let lostTotal = 0;
31
33
  let bestHaul = 0;
32
34
  function drawFor(index, previous) {
33
- const eligible = table.filter((change) => (change.after ?? 0) <= index - 1);
34
- const fresh = eligible.filter((change) => change.id !== previous);
35
- // A one-entry table still offers its change rather than nothing.
36
- const pool = fresh.length > 0 ? fresh : eligible;
35
+ const pool = poolFor(index, previous);
37
36
  const rng = seededRng(`${config.seed}:run:${index}`);
38
37
  const drawn = pickWeighted(rng, pool, (change) => change.weight ?? 1) ?? null;
39
38
  return { run: index, changeId: drawn?.id ?? null, change: drawn?.value ?? null };
40
39
  }
40
+ /** The changes run `index` may open with: `after`-eligible, and never the one just played while others remain. */
41
+ function poolFor(index, previous) {
42
+ const eligible = table.filter((change) => (change.after ?? 0) <= index - 1);
43
+ const fresh = eligible.filter((change) => change.id !== previous);
44
+ // A one-entry table still offers its change rather than nothing.
45
+ return fresh.length > 0 ? fresh : eligible;
46
+ }
41
47
  function offer() {
42
48
  // An open run's change was adopted at `start`, so the offer repeats it instead of redrawing.
43
49
  if (run > completed) {
@@ -62,6 +68,32 @@ export function createRunLoop(config) {
62
68
  },
63
69
  haul: () => haul,
64
70
  offer,
71
+ present(count) {
72
+ const index = run > completed ? run : completed + 1;
73
+ const remaining = poolFor(index, changeId);
74
+ const drawn = [];
75
+ const wanted = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
76
+ const rng = seededRng(`${config.seed}:present:${index}`);
77
+ while (drawn.length < wanted && remaining.length > 0) {
78
+ const picked = pickWeighted(rng, remaining, (change) => change.weight ?? 1);
79
+ if (picked === undefined)
80
+ break;
81
+ drawn.push({ id: picked.id, change: picked.value });
82
+ remaining.splice(remaining.indexOf(picked), 1);
83
+ }
84
+ return drawn;
85
+ },
86
+ choose(nextChangeId) {
87
+ if (nextChangeId !== null && !known.has(nextChangeId))
88
+ return false;
89
+ // A settled loop has no run to adopt the pick, so the pick opens the next one.
90
+ if (run <= completed) {
91
+ run = completed + 1;
92
+ haul = 0;
93
+ }
94
+ changeId = nextChangeId;
95
+ return true;
96
+ },
65
97
  settle(outcome, multiplier) {
66
98
  if (run <= completed)
67
99
  throw new Error("run loop: no run is open to settle");
@@ -90,3 +122,32 @@ export function createRunLoop(config) {
90
122
  },
91
123
  };
92
124
  }
125
+ const RUN_PROBE_KEY = "__jgengineRun";
126
+ /**
127
+ * Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
128
+ * the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
129
+ * starts, settles or changes a run. Returns the uninstaller.
130
+ *
131
+ * @capability run-loop bank or lose what a run carries, with one deterministic change per run
132
+ */
133
+ export function installRunProbe(loop) {
134
+ const scope = globalThis;
135
+ const probe = {
136
+ read() {
137
+ const state = loop.snapshot();
138
+ return {
139
+ run: state.run,
140
+ carried: state.haul,
141
+ banked: state.bankedTotal,
142
+ lost: state.lostTotal,
143
+ best: state.bestHaul,
144
+ offered: loop.offer().changeId,
145
+ };
146
+ },
147
+ };
148
+ scope[RUN_PROBE_KEY] = probe;
149
+ return () => {
150
+ if (scope[RUN_PROBE_KEY] === probe)
151
+ delete scope[RUN_PROBE_KEY];
152
+ };
153
+ }
@@ -214,7 +214,21 @@ export interface TerrainEnvironmentConfig {
214
214
  materialRegions?: readonly TerrainMaterialRegion[];
215
215
  /** Ordered z-banded ground palettes that cross-fade along the world's z axis (vale → marsh → peaks). Painted under `materialRegions`. */
216
216
  biomeBands?: readonly BiomeBand[];
217
- segments?: number;
217
+ /**
218
+ * Grid segments per axis, or `[x, z]` for a non-square grid. Honoured as-is for compatibility,
219
+ * but a count says nothing about ground detail until you know the bounds — prefer `cellSize`.
220
+ */
221
+ segments?: number | readonly [x: number, z: number];
222
+ /**
223
+ * Ground resolution as world units per quad — the density form of `segments`. The per-axis grid is
224
+ * derived from `bounds`, so the same `cellSize` on a bigger world spends more cells instead of
225
+ * stretching one fixed count over it. Set this or `segments`, never both.
226
+ *
227
+ * A world that needs mixed density next (a coarse far ring around a fine playable core, or bands
228
+ * that tighten over a painted path) would carry `density?: readonly { cellSize: number; radius?: number }[]`
229
+ * in place of this single number. Not built.
230
+ */
231
+ cellSize?: number;
218
232
  seed?: string;
219
233
  frequency?: number;
220
234
  octaves?: number;
@@ -79,6 +79,53 @@ export function environment(config = {}) {
79
79
  ...(config.clearings === undefined || config.clearings.length === 0 ? {} : { clearings: config.clearings }),
80
80
  };
81
81
  }
82
+ /**
83
+ * Vertex ceiling for a terrain grid derived from `cellSize`. A density target that reads fine
84
+ * ("0.4 m cells") can quietly ask for millions of vertices on a large world; past this the
85
+ * declaration fails with its own numbers instead of booting a ground mesh nobody can afford.
86
+ */
87
+ const TERRAIN_VERTEX_CEILING = 1_000_000;
88
+ function gridVertices(bounds, cellSize) {
89
+ const x = Math.max(1, Math.round(bounds.w / cellSize));
90
+ const z = Math.max(1, Math.round(bounds.d / cellSize));
91
+ return { x, z, vertices: (x + 1) * (z + 1) };
92
+ }
93
+ /** Smallest `cellSize` whose derived grid fits the ceiling — quoted back in the failure, so the fix is one number. */
94
+ function minimumCellSize(bounds) {
95
+ let lo = Math.sqrt((bounds.w * bounds.d) / TERRAIN_VERTEX_CEILING) * 0.5;
96
+ let hi = Math.max(bounds.w, bounds.d, 1);
97
+ for (let step = 0; step < 64; step += 1) {
98
+ const mid = (lo + hi) / 2;
99
+ if (gridVertices(bounds, mid).vertices > TERRAIN_VERTEX_CEILING)
100
+ lo = mid;
101
+ else
102
+ hi = mid;
103
+ }
104
+ return hi;
105
+ }
106
+ /**
107
+ * Resolves the authored grid: `segments` passes through, `cellSize` derives a per-axis count from the
108
+ * bounds. Both forms in one declaration is an error rather than a silent precedence rule, because the
109
+ * pair produces exactly the accidental density this API exists to remove.
110
+ */
111
+ function terrainSegments(bounds, config) {
112
+ const { segments, cellSize } = config;
113
+ if (segments !== undefined && cellSize !== undefined) {
114
+ throw new Error("terrain: set segments or cellSize, not both — cellSize (world units per quad) derives the grid from the bounds, segments counts it directly. Keep cellSize and drop segments.");
115
+ }
116
+ if (cellSize === undefined)
117
+ return segments;
118
+ if (!Number.isFinite(cellSize) || cellSize <= 0) {
119
+ throw new Error(`terrain: cellSize must be a positive number of world units per quad (got ${cellSize})`);
120
+ }
121
+ const { x, z, vertices } = gridVertices(bounds, cellSize);
122
+ if (vertices > TERRAIN_VERTEX_CEILING) {
123
+ // Rounded up: the quoted cell size has to pass the same check, not read well and fail again.
124
+ const minimum = Math.ceil(minimumCellSize(bounds) * 1_000) / 1_000;
125
+ throw new Error(`terrain: cellSize ${cellSize} on ${bounds.w}×${bounds.d} bounds is ${x}×${z} cells = ${vertices.toLocaleString("en-US")} vertices, over the ${TERRAIN_VERTEX_CEILING.toLocaleString("en-US")}-vertex ceiling. Use cellSize ${minimum} or larger, or shrink the bounds.`);
126
+ }
127
+ return x === z ? x : [x, z];
128
+ }
82
129
  /** Declares a heightfield terrain patch for `environment()` — bounds, noise, materials, and flatten masks. */
83
130
  export function terrain(config = {}) {
84
131
  if (config.baseHeight !== undefined &&
@@ -87,9 +134,11 @@ export function terrain(config = {}) {
87
134
  typeof console !== "undefined") {
88
135
  console.warn("[jgengine:terrain] baseHeight is set but height is not — height is the noise amplitude (defaulting to 0 here), so this terrain is a flat plateau at baseHeight. Set height explicitly (height: 0 for an intentional plateau) to silence this.");
89
136
  }
137
+ const bounds = config.bounds ?? { w: 512, d: 512 };
138
+ const segments = terrainSegments(bounds, config);
90
139
  return withOptional({
91
140
  kind: "terrain",
92
- bounds: config.bounds ?? { w: 512, d: 512 },
141
+ bounds,
93
142
  height: config.height ?? 0,
94
143
  }, {
95
144
  ...(config.heightMap === undefined ? {} : { heightMap: config.heightMap }),
@@ -98,7 +147,7 @@ export function terrain(config = {}) {
98
147
  ...(config.colors === undefined ? {} : { colors: config.colors }),
99
148
  ...(config.materialRegions === undefined ? {} : { materialRegions: config.materialRegions }),
100
149
  ...(config.biomeBands === undefined ? {} : { biomeBands: config.biomeBands }),
101
- ...(config.segments === undefined ? {} : { segments: config.segments }),
150
+ ...(segments === undefined ? {} : { segments }),
102
151
  ...(config.seed === undefined ? {} : { seed: config.seed }),
103
152
  ...(config.frequency === undefined ? {} : { frequency: config.frequency }),
104
153
  ...(config.octaves === undefined ? {} : { octaves: config.octaves }),
@@ -20,4 +20,12 @@ export interface LodScheduler {
20
20
  snapshot(): LodSchedulerState;
21
21
  restore(next: LodSchedulerState): void;
22
22
  }
23
+ /**
24
+ * Index of the band `distance` falls in, `-1` past the last band. Bands must already be sorted near
25
+ * to far. The scheduler and `createModelVariantSelector` both read distances through this one
26
+ * function, so simulation throttling and mesh detail cannot disagree about what band an id is in.
27
+ */
28
+ export declare function lodBandIndex(bands: readonly {
29
+ maxDistance: number;
30
+ }[], distance: number): number;
23
31
  export declare function createLodScheduler(config: LodSchedulerConfig): LodScheduler;
package/dist/world/lod.js CHANGED
@@ -7,17 +7,25 @@ function hashSeed(text) {
7
7
  }
8
8
  return h >>> 0;
9
9
  }
10
+ /**
11
+ * Index of the band `distance` falls in, `-1` past the last band. Bands must already be sorted near
12
+ * to far. The scheduler and `createModelVariantSelector` both read distances through this one
13
+ * function, so simulation throttling and mesh detail cannot disagree about what band an id is in.
14
+ */
15
+ export function lodBandIndex(bands, distance) {
16
+ for (let i = 0; i < bands.length; i++) {
17
+ if (distance <= bands[i].maxDistance)
18
+ return i;
19
+ }
20
+ return -1;
21
+ }
10
22
  export function createLodScheduler(config) {
11
23
  const bands = [...config.bands].sort((a, b) => a.maxDistance - b.maxDistance);
12
24
  const beyondInterval = config.beyondInterval ?? null;
13
25
  const stagger = config.stagger ?? true;
14
26
  const buckets = new Map();
15
27
  function bandIndex(distance) {
16
- for (let i = 0; i < bands.length; i++) {
17
- if (distance <= bands[i].maxDistance)
18
- return i;
19
- }
20
- return -1;
28
+ return lodBandIndex(bands, distance);
21
29
  }
22
30
  function effectiveInterval(band) {
23
31
  return band === -1 ? beyondInterval : bands[band].interval;
@@ -0,0 +1,44 @@
1
+ import type { LodBand } from "./lod.js";
2
+ /** One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB and how far it draws. */
3
+ export interface ModelVariantConfig {
4
+ /** GLB url for this level — the source asset for the near level, a decimated asset for the far ones. */
5
+ url: string;
6
+ /** Furthest distance in metres from the camera this level draws. The last level omits it and covers everything beyond the level before it. */
7
+ maxDistance?: number;
8
+ }
9
+ /**
10
+ * Ordered mesh detail levels for one model, switched per instance by camera distance. The first level
11
+ * is the full mesh the model's own `url` names; every later level is a cheaper asset. A tree line uses
12
+ * this so near trees keep their full mesh while far ones draw a decimated copy of the same species.
13
+ */
14
+ export interface ModelVariantLodConfig {
15
+ /** Levels ordered near to far; the first covers everything up to its `maxDistance`. Two or more. */
16
+ levels: readonly ModelVariantConfig[];
17
+ /** Metres an instance must travel past a switch boundary before it changes level, so a camera resting on the boundary cannot flicker. Default 6. */
18
+ hysteresis?: number;
19
+ }
20
+ /** Plain JSON state of a {@link ModelVariantSelector}: each id's current level index. */
21
+ export interface ModelVariantSelectorState {
22
+ levels: Record<string, number>;
23
+ }
24
+ /** Per-instance distance switch over a model's detail levels: one answer per id, held across a boundary until the instance clears it. */
25
+ export interface ModelVariantSelector {
26
+ /** Levels near to far, with the last level's `maxDistance` normalized to `Infinity`. */
27
+ levels(): readonly {
28
+ url: string;
29
+ maxDistance: number;
30
+ }[];
31
+ /** The same boundaries as scheduler bands, so a `createLodScheduler` never throttles an id at a different distance than its mesh switches. */
32
+ bands(): readonly LodBand[];
33
+ /** Level index for `id` at `distance` metres, carrying hysteresis from its previous answer. */
34
+ level(id: string, distance: number): number;
35
+ forget(id: string): void;
36
+ clear(): void;
37
+ size(): number;
38
+ snapshot(): ModelVariantSelectorState;
39
+ restore(next: ModelVariantSelectorState): void;
40
+ }
41
+ /** Metres an instance must travel past a switch boundary before it changes level unless the model declares its own. */
42
+ export declare const DEFAULT_VARIANT_HYSTERESIS = 6;
43
+ /** Builds the per-instance distance switch for a model's variant levels. */
44
+ export declare function createModelVariantSelector(config: ModelVariantLodConfig): ModelVariantSelector;
@@ -0,0 +1,75 @@
1
+ import { lodBandIndex } from "./lod.js";
2
+ /** Metres an instance must travel past a switch boundary before it changes level unless the model declares its own. */
3
+ export const DEFAULT_VARIANT_HYSTERESIS = 6;
4
+ function normalize(config) {
5
+ if (!Array.isArray(config.levels) || config.levels.length < 2) {
6
+ throw new Error("Model variants need at least two levels: the full mesh and a cheaper one");
7
+ }
8
+ let previous = 0;
9
+ return config.levels.map((level, index) => {
10
+ if (typeof level.url !== "string" || level.url.length === 0) {
11
+ throw new Error(`Model variant level ${index} needs a url`);
12
+ }
13
+ const last = index === config.levels.length - 1;
14
+ const maxDistance = level.maxDistance;
15
+ if (last) {
16
+ if (maxDistance !== undefined && maxDistance !== Infinity) {
17
+ throw new Error("The last model variant level draws at every distance; leave its maxDistance undefined");
18
+ }
19
+ return { url: level.url, maxDistance: Infinity };
20
+ }
21
+ if (typeof maxDistance !== "number" || !Number.isFinite(maxDistance) || maxDistance <= previous) {
22
+ throw new Error(`Model variant level ${index} needs a finite maxDistance above the level before it`);
23
+ }
24
+ previous = maxDistance;
25
+ return { url: level.url, maxDistance };
26
+ });
27
+ }
28
+ /** Builds the per-instance distance switch for a model's variant levels. */
29
+ export function createModelVariantSelector(config) {
30
+ const levels = normalize(config);
31
+ const hysteresis = Math.max(0, config.hysteresis ?? DEFAULT_VARIANT_HYSTERESIS);
32
+ const bands = levels.map((level) => ({ maxDistance: level.maxDistance, interval: 0 }));
33
+ const current = new Map();
34
+ const last = levels.length - 1;
35
+ return {
36
+ levels() {
37
+ return levels;
38
+ },
39
+ bands() {
40
+ return bands;
41
+ },
42
+ level(id, distance) {
43
+ let index = current.get(id);
44
+ if (index === undefined) {
45
+ const band = lodBandIndex(bands, distance);
46
+ index = band === -1 ? last : band;
47
+ }
48
+ else {
49
+ while (index < last && distance > levels[index].maxDistance + hysteresis)
50
+ index += 1;
51
+ while (index > 0 && distance <= levels[index - 1].maxDistance - hysteresis)
52
+ index -= 1;
53
+ }
54
+ current.set(id, index);
55
+ return index;
56
+ },
57
+ forget(id) {
58
+ current.delete(id);
59
+ },
60
+ clear() {
61
+ current.clear();
62
+ },
63
+ size() {
64
+ return current.size;
65
+ },
66
+ snapshot() {
67
+ return { levels: Object.fromEntries(current) };
68
+ },
69
+ restore(next) {
70
+ current.clear();
71
+ for (const [id, index] of Object.entries(next.levels))
72
+ current.set(id, index);
73
+ },
74
+ };
75
+ }
package/dist/world.d.ts CHANGED
@@ -129,12 +129,13 @@ export { type Aabb, type AvoidZone } from "./world/geometry.js";
129
129
  export { GRASS_SCHEMA } from "./world/grassKind.js";
130
130
  export { resolveGridInstances } from "./world/gridInstances.js";
131
131
  export { grounded } from "./world/grounded.js";
132
- export { createLodScheduler } from "./world/lod.js";
132
+ export { createLodScheduler, lodBandIndex } from "./world/lod.js";
133
133
  export { createAnnotationLayer, type AnnotationLayer, type AnnotationLayerOptions, type AnnotationSnapshot, type MapNote, type MapShapeAnnotation, type MapStroke, } from "./world/mapAnnotations.js";
134
134
  export { mapLayerColor, type MapCellStates, type MapRoute, type MapZone } from "./world/mapLayers.js";
135
135
  export { DEFAULT_MARKER_KINDS, createMarkerSet, createMarkerSource, markerKindStyle, type MapMarker, type MarkerCollection, type MarkerKindStyle, type MarkerSet, type MarkerSource, type MarkerSourceOptions, type MarkerView, } from "./world/markers.js";
136
136
  export { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, type Cardinal, type MinimapView, type WorldXZ, } from "./world/minimap.js";
137
137
  export { DEFAULT_MINIMAP_PALETTE, bakeMinimapBackground, bakeMinimapImage, encodeBakePng, minimapBakeToPngDataUri, type MinimapBake, type MinimapBakeBounds, type MinimapBakeOptions, type MinimapBakePalette, type MinimapBakeSource, type MinimapBakeZone, } from "./world/minimapBake.js";
138
+ export { DEFAULT_VARIANT_HYSTERESIS, createModelVariantSelector, type ModelVariantConfig, type ModelVariantLodConfig, type ModelVariantSelector, type ModelVariantSelectorState, } from "./world/modelLod.js";
138
139
  export { placeAlongPath } from "./world/pathInstances.js";
139
140
  export { withPathProfiles, type PathHeightPolicy, type PathRetaining, type TerrainPathProfile, } from "./world/pathTerrain.js";
140
141
  export { isPlaceWorld, resolveWorldPhysics, seedForPlace, world, type BoardGround, type BoardGroundSize, type FlatGround, type FlatGroundSize, type GroundConfig, type GroundGenerator, type GroundMode, type PlaceConfig, type PlaceWorldFeature, type ResolvedGround, type RoundGround, type RoundGroundSize, type SurfaceLaws, type VoxelGround, type VoxelGroundSize, } from "./world/place.js";
package/dist/world.js CHANGED
@@ -129,12 +129,13 @@ export {} from "./world/geometry.js";
129
129
  export { GRASS_SCHEMA } from "./world/grassKind.js";
130
130
  export { resolveGridInstances } from "./world/gridInstances.js";
131
131
  export { grounded } from "./world/grounded.js";
132
- export { createLodScheduler } from "./world/lod.js";
132
+ export { createLodScheduler, lodBandIndex } from "./world/lod.js";
133
133
  export { createAnnotationLayer, } from "./world/mapAnnotations.js";
134
134
  export { mapLayerColor } from "./world/mapLayers.js";
135
135
  export { DEFAULT_MARKER_KINDS, createMarkerSet, createMarkerSource, markerKindStyle, } from "./world/markers.js";
136
136
  export { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, } from "./world/minimap.js";
137
137
  export { DEFAULT_MINIMAP_PALETTE, bakeMinimapBackground, bakeMinimapImage, encodeBakePng, minimapBakeToPngDataUri, } from "./world/minimapBake.js";
138
+ export { DEFAULT_VARIANT_HYSTERESIS, createModelVariantSelector, } from "./world/modelLod.js";
138
139
  export { placeAlongPath } from "./world/pathInstances.js";
139
140
  export { withPathProfiles, } from "./world/pathTerrain.js";
140
141
  export { isPlaceWorld, resolveWorldPhysics, seedForPlace, world, } from "./world/place.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.36.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.37.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.36.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.37.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -31,6 +31,13 @@ see
31
31
  For scene-backed `createProjectileSystem`, inject authoritative `rng`; spread
32
32
  samples independent pellet rays and prediction stays on the center ray. The
33
33
  weapon-plumbing recipe covers the bounded cone and cover contracts.
34
+ When a weapon is not a gun — grenade, mortar, arrow, hitscan explosive — state
35
+ how the shot travels and what it does on contact as one `ShotSpec`
36
+ (`combat/shotSpec`) and pass it as `ProjectileShotInput.spec`, or read
37
+ `resolveShotSpec` yourself. Delivery is `delivery.kind` ("trace" or "body") and
38
+ nothing else decides it; a splash, damage or falloff field never makes a shot
39
+ travel. A new weapon is a few fields, not a code path, so never branch combat
40
+ code on a weapon id or add a second classifier.
34
41
  Tune how a weapon feels (recoil, bloom, ADS, shake and rumble) with the
35
42
  [weapon-feel recipe](recipes/weapon-feel.md): feel target → `measureWeapon`
36
43
  numbers → handling knobs, and `createWeaponFeedbackSignals` into the feedback mixer.