@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.
- package/CHANGELOG.md +84 -0
- package/dist/combat/projectiles.d.ts +6 -2
- package/dist/combat/projectiles.js +55 -52
- package/dist/combat/shotSpec.d.ts +239 -0
- package/dist/combat/shotSpec.js +216 -0
- package/dist/combat/weaponSystem.js +26 -21
- package/dist/combat.d.ts +1 -0
- package/dist/combat.js +1 -0
- package/dist/game/playableGame.d.ts +9 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +58 -1
- package/dist/movement/characterController.d.ts +5 -0
- package/dist/movement/characterController.js +8 -1
- package/dist/movement/movementModel.js +4 -1
- package/dist/movement/playerMovement.js +4 -0
- package/dist/physics/physicsBackendConformance.js +17 -0
- package/dist/physics/physicsWorld.d.ts +7 -0
- package/dist/physics/physicsWorld.js +27 -6
- package/dist/run/runLoop.d.ts +44 -1
- package/dist/run/runLoop.js +66 -5
- package/dist/world/features.d.ts +15 -1
- package/dist/world/features.js +51 -2
- package/dist/world/lod.d.ts +8 -0
- package/dist/world/lod.js +13 -5
- package/dist/world/modelLod.d.ts +44 -0
- package/dist/world/modelLod.js +75 -0
- package/dist/world.d.ts +2 -1
- package/dist/world.js +2 -1
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +7 -0
- package/skills/jgengine-combat/api.md +61 -1
- package/skills/jgengine-combat/capabilities.md +4 -0
- package/skills/jgengine-combat/reference.md +1 -0
- package/skills/jgengine-gameplay/api.md +6 -0
- package/skills/jgengine-gameplay/capabilities.md +1 -0
- 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
|
-
|
|
617
|
-
|
|
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
|
package/dist/run/runLoop.d.ts
CHANGED
|
@@ -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;
|
package/dist/run/runLoop.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/world/features.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/world/features.js
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
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 }),
|
package/dist/world/lod.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|