@jgengine/core 0.33.0 → 0.35.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 +38 -0
- package/dist/ai/driver.d.ts +41 -2
- package/dist/ai/driver.js +33 -2
- package/dist/audio/ambience.d.ts +47 -0
- package/dist/audio/ambience.js +75 -0
- package/dist/audio/audioFalloff.d.ts +12 -0
- package/dist/audio/occlusion.d.ts +40 -0
- package/dist/audio/occlusion.js +27 -0
- package/dist/game/playableGame.d.ts +21 -1
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +34 -1
- package/dist/run/runLoop.d.ts +88 -0
- package/dist/run/runLoop.js +92 -0
- package/dist/runtime/headlessRunner.d.ts +10 -1
- package/dist/runtime/headlessRunner.js +10 -1
- package/dist/world/pathTerrain.js +23 -28
- package/dist/world/polylineIndex.d.ts +30 -0
- package/dist/world/polylineIndex.js +80 -0
- package/dist/world/spatialBuckets.d.ts +15 -0
- package/dist/world/spatialBuckets.js +80 -0
- package/dist/world/terrain.js +58 -11
- package/package.json +1 -1
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-gameplay/api.md +11 -0
- package/skills/jgengine-gameplay/capabilities.md +4 -0
- package/skills/jgengine-world/api.md +21 -3
- package/skills/jgengine-world/capabilities.md +8 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -26,6 +26,8 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
|
|
|
26
26
|
};
|
|
27
27
|
/** Wall-clock source for the sim clock — inject a deterministic counter for reproducible runs. */
|
|
28
28
|
now?: () => number;
|
|
29
|
+
/** Seed for `ctx.rng` — pass one so a recorded run and its replay draw the same numbers. Defaults to the definition's name. */
|
|
30
|
+
seed?: string | number;
|
|
29
31
|
/** Per-step real-dt clamp in seconds, matching the shell's frame cap so one long stall can't fast-forward the sim. Default `0.05`. */
|
|
30
32
|
maxStepSeconds?: number;
|
|
31
33
|
/**
|
|
@@ -36,7 +38,9 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
|
|
|
36
38
|
playerMovement?: boolean;
|
|
37
39
|
/** Movement tuning — when `playerMovement` is on, the shell resolves from `definition.world`/`physics`; pass a direct config to test flight or other movement overrides headlessly. */
|
|
38
40
|
movement?: import("../game/playableGame.js").PlayerMovementConfig;
|
|
39
|
-
/**
|
|
41
|
+
/** Voxel collision config (`defineGame({ collision })`) — the shell's walk controller reads it, so a headless caller passes the same object to walk the same geometry. */
|
|
42
|
+
collision?: import("../game/playableGame.js").VoxelCollisionConfig;
|
|
43
|
+
/** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. Change it mid-run with {@link HeadlessRunner.aim}. */
|
|
40
44
|
heading?: number;
|
|
41
45
|
/** Pitch (radians) fed alongside yaw for 6DOF spectator flight when `alignWithLook` is set. */
|
|
42
46
|
pitch?: number;
|
|
@@ -78,6 +82,11 @@ export interface HeadlessRunner {
|
|
|
78
82
|
publishInput(input: HeadlessInput): void;
|
|
79
83
|
/** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
|
|
80
84
|
readonly ui: HeadlessCommandInterface;
|
|
85
|
+
/** Point the movement controller at a yaw/pitch mid-run — what the shell does when the camera turns, so a scripted run can walk one way and then another. */
|
|
86
|
+
aim(look: {
|
|
87
|
+
heading?: number;
|
|
88
|
+
pitch?: number;
|
|
89
|
+
}): void;
|
|
81
90
|
/** Capture the whole simulation (world, clock, loop, rng cursor) for {@link restore}; with a fixed `simulation.hz` and recorded input, replaying from it reproduces the run exactly. */
|
|
82
91
|
snapshot(): SimSnapshot;
|
|
83
92
|
restore(snapshot: SimSnapshot): void;
|
|
@@ -12,13 +12,16 @@ export function createHeadlessRunner(options) {
|
|
|
12
12
|
content,
|
|
13
13
|
player,
|
|
14
14
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
15
|
+
...(options.seed === undefined ? {} : { seed: options.seed }),
|
|
15
16
|
...(options.models === undefined ? {} : { models: options.models }),
|
|
16
17
|
});
|
|
17
18
|
loop?.onInit?.(ctx);
|
|
18
19
|
loop?.onNewPlayer?.(ctx);
|
|
19
20
|
const simSnapshot = createContextSimSnapshot(ctx);
|
|
21
|
+
const look = { heading: options.heading ?? 0, pitch: options.pitch ?? 0 };
|
|
20
22
|
const tuning = options.playerMovement === true
|
|
21
23
|
? { ...resolvePlayerMovementTuning({
|
|
24
|
+
collision: options.collision,
|
|
22
25
|
physics: definition.physics,
|
|
23
26
|
world: definition.world,
|
|
24
27
|
movement: options.movement,
|
|
@@ -48,6 +51,12 @@ export function createHeadlessRunner(options) {
|
|
|
48
51
|
names: () => ctx.game.commands.names(),
|
|
49
52
|
has: (name) => ctx.game.commands.has(name),
|
|
50
53
|
},
|
|
54
|
+
aim(next) {
|
|
55
|
+
if (next.heading !== undefined && Number.isFinite(next.heading))
|
|
56
|
+
look.heading = next.heading;
|
|
57
|
+
if (next.pitch !== undefined && Number.isFinite(next.pitch))
|
|
58
|
+
look.pitch = next.pitch;
|
|
59
|
+
},
|
|
51
60
|
step(dtSeconds, input) {
|
|
52
61
|
if (input !== undefined)
|
|
53
62
|
publishInput(input);
|
|
@@ -58,7 +67,7 @@ export function createHeadlessRunner(options) {
|
|
|
58
67
|
total += gameDt;
|
|
59
68
|
ctx.sim.runStages("beforeMovement", stepDt);
|
|
60
69
|
if (tuning !== null && gameDt > 0) {
|
|
61
|
-
stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning,
|
|
70
|
+
stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, look.heading, look.pitch);
|
|
62
71
|
}
|
|
63
72
|
ctx.sim.runStages("afterMovement", stepDt);
|
|
64
73
|
loop?.onTick?.(ctx, gameDt);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPolylineIndex } from "./polylineIndex.js";
|
|
1
2
|
function clamp01(value) {
|
|
2
3
|
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
3
4
|
}
|
|
@@ -15,34 +16,28 @@ function buildCorridor(points) {
|
|
|
15
16
|
const b = points[i];
|
|
16
17
|
cumulative.push(cumulative[i - 1] + Math.hypot(b[0] - a[0], b[1] - a[1]));
|
|
17
18
|
}
|
|
18
|
-
return { points, cumulative, total: cumulative[cumulative.length - 1] ?? 0 };
|
|
19
|
+
return { points, cumulative, total: cumulative[cumulative.length - 1] ?? 0, index: buildPolylineIndex(points) };
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
|
-
* Closest point on the corridor to `(x, z)`: unsigned lateral distance and arc-length
|
|
22
|
-
* contained (each segment projects independently and the minimum lateral wins) so a
|
|
23
|
-
* a near-zero lateral, regardless of which side of the line the neighbours fall.
|
|
22
|
+
* Closest point on the corridor to `(x, z)` within `limit`: unsigned lateral distance and arc-length
|
|
23
|
+
* position. Self-contained (each segment projects independently and the minimum lateral wins) so a
|
|
24
|
+
* vertex always reports a near-zero lateral, regardless of which side of the line the neighbours fall.
|
|
25
|
+
* Beyond `limit` the corridor cannot change the sample, so the answer is `null`.
|
|
24
26
|
*/
|
|
25
|
-
function nearestOnCorridor(corridor, x, z) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (lateral < bestLateral) {
|
|
40
|
-
bestLateral = lateral;
|
|
41
|
-
bestAlong = corridor.cumulative[i - 1] + t * Math.sqrt(segLenSq);
|
|
42
|
-
bestPoint = [px, pz];
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return { lateral: bestLateral, along: bestAlong, point: bestPoint };
|
|
27
|
+
function nearestOnCorridor(corridor, x, z, limit) {
|
|
28
|
+
const hit = corridor.index.nearest(x, z, limit);
|
|
29
|
+
if (hit === null)
|
|
30
|
+
return null;
|
|
31
|
+
const a = corridor.points[hit.segment];
|
|
32
|
+
const b = corridor.points[hit.segment + 1];
|
|
33
|
+
const abx = b[0] - a[0];
|
|
34
|
+
const abz = b[1] - a[1];
|
|
35
|
+
const segLenSq = abx * abx + abz * abz;
|
|
36
|
+
return {
|
|
37
|
+
lateral: hit.distance,
|
|
38
|
+
along: corridor.cumulative[hit.segment] + hit.t * Math.sqrt(segLenSq),
|
|
39
|
+
point: hit.point,
|
|
40
|
+
};
|
|
46
41
|
}
|
|
47
42
|
function resolveProfile(profile) {
|
|
48
43
|
if (profile.points.length < 2 || profile.width <= 0)
|
|
@@ -94,10 +89,10 @@ function centerlineTarget(profile, along, point, base) {
|
|
|
94
89
|
return base(point[0], point[1]);
|
|
95
90
|
}
|
|
96
91
|
function applyProfileAt(profile, x, z, surrounding, base) {
|
|
97
|
-
const hit = nearestOnCorridor(profile.corridor, x, z);
|
|
98
|
-
|
|
99
|
-
if (lateral > profile.reach)
|
|
92
|
+
const hit = nearestOnCorridor(profile.corridor, x, z, profile.reach);
|
|
93
|
+
if (hit === null)
|
|
100
94
|
return surrounding;
|
|
95
|
+
const lateral = hit.lateral;
|
|
101
96
|
const target = clampCutFill(centerlineTarget(profile, hit.along, hit.point, base), surrounding, profile.maxCut, profile.maxFill);
|
|
102
97
|
// Inside the core: carve a channel below the target, deepest at the centerline, level at the edge.
|
|
103
98
|
if (lateral <= profile.half) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nearest-point queries against a polyline within a distance bound. The corridor sampler, road
|
|
3
|
+
* draping and the palette's polyline regions ask "is this vertex near that line?" for every vertex of
|
|
4
|
+
* a terrain mesh, so a linear scan of every segment per vertex turned one authored road into seconds
|
|
5
|
+
* of boot time. This keeps the exact same arithmetic as a full scan while visiting only the segments
|
|
6
|
+
* that can possibly win: a query returns the same segment, the same `t` and the same distance a scan
|
|
7
|
+
* would, and `null` only when the whole polyline is farther away than the bound.
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
import type { Vec2 } from "./geometry.js";
|
|
11
|
+
/** Where the polyline comes closest to the query, within the query's bound. @internal */
|
|
12
|
+
export interface PolylineHit {
|
|
13
|
+
/** Index of the winning segment, i.e. the one from `points[segment]` to `points[segment + 1]`. */
|
|
14
|
+
segment: number;
|
|
15
|
+
/** Position along that segment, 0..1. */
|
|
16
|
+
t: number;
|
|
17
|
+
/** Distance from the query to the projected point. */
|
|
18
|
+
distance: number;
|
|
19
|
+
/** Projected point on the polyline. */
|
|
20
|
+
point: Vec2;
|
|
21
|
+
}
|
|
22
|
+
/** @internal */
|
|
23
|
+
export interface PolylineIndex {
|
|
24
|
+
/** Exact nearest segment sample within `limit`, or null when the polyline stays farther away. */
|
|
25
|
+
nearest(x: number, z: number, limit: number): PolylineHit | null;
|
|
26
|
+
/** Segments tested by the most recent query — the bounded-work invariant tests assert on. */
|
|
27
|
+
examined(): number;
|
|
28
|
+
}
|
|
29
|
+
/** @internal */
|
|
30
|
+
export declare function buildPolylineIndex(points: readonly Vec2[]): PolylineIndex;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { buildSpatialBuckets } from "./spatialBuckets.js";
|
|
2
|
+
/** @internal */
|
|
3
|
+
export function buildPolylineIndex(points) {
|
|
4
|
+
const count = Math.max(0, points.length - 1);
|
|
5
|
+
const ax = new Float64Array(count);
|
|
6
|
+
const az = new Float64Array(count);
|
|
7
|
+
const abx = new Float64Array(count);
|
|
8
|
+
const abz = new Float64Array(count);
|
|
9
|
+
const lengthSq = new Float64Array(count);
|
|
10
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
11
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
12
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
13
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
14
|
+
for (let i = 0; i < count; i += 1) {
|
|
15
|
+
const a = points[i];
|
|
16
|
+
const b = points[i + 1];
|
|
17
|
+
ax[i] = a[0];
|
|
18
|
+
az[i] = a[1];
|
|
19
|
+
abx[i] = b[0] - a[0];
|
|
20
|
+
abz[i] = b[1] - a[1];
|
|
21
|
+
lengthSq[i] = abx[i] * abx[i] + abz[i] * abz[i];
|
|
22
|
+
minX = Math.min(minX, a[0], b[0]);
|
|
23
|
+
maxX = Math.max(maxX, a[0], b[0]);
|
|
24
|
+
minZ = Math.min(minZ, a[1], b[1]);
|
|
25
|
+
maxZ = Math.max(maxZ, a[1], b[1]);
|
|
26
|
+
}
|
|
27
|
+
const span = count === 0 ? 1 : Math.max(maxX - minX, maxZ - minZ, 1);
|
|
28
|
+
// Twice the average segment length keeps a typical reach inside one or two cells, so a query tests a
|
|
29
|
+
// handful of segments; the 256-cell cap bounds memory on a very long, very dense polyline.
|
|
30
|
+
const buckets = buildSpatialBuckets(Math.max(1, span / 256, (span / Math.max(1, count)) * 2));
|
|
31
|
+
for (let i = 0; i < count; i += 1) {
|
|
32
|
+
buckets.insert(i, Math.min(ax[i], ax[i] + abx[i]), Math.min(az[i], az[i] + abz[i]), Math.max(ax[i], ax[i] + abx[i]), Math.max(az[i], az[i] + abz[i]));
|
|
33
|
+
}
|
|
34
|
+
const candidates = [];
|
|
35
|
+
let examined = 0;
|
|
36
|
+
return {
|
|
37
|
+
nearest(x, z, limit) {
|
|
38
|
+
if (count === 0 || !(limit >= 0)) {
|
|
39
|
+
examined = 0;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (x < minX - limit || x > maxX + limit || z < minZ - limit || z > maxZ + limit) {
|
|
43
|
+
examined = 0;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// A segment closer than `limit` passes through this box, so it is registered in one of its cells.
|
|
47
|
+
// Clamping to the polyline's own box keeps an infinite bound from walking an infinite grid.
|
|
48
|
+
const reach = limit * (1 + 1e-9) + 1e-9;
|
|
49
|
+
buckets.query(Math.max(minX, x - reach), Math.max(minZ, z - reach), Math.min(maxX, x + reach), Math.min(maxZ, z + reach), candidates);
|
|
50
|
+
examined = candidates.length;
|
|
51
|
+
if (candidates.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
let bestSegment = -1;
|
|
54
|
+
let bestT = 0;
|
|
55
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
56
|
+
let bestX = 0;
|
|
57
|
+
let bestZ = 0;
|
|
58
|
+
for (const segment of candidates) {
|
|
59
|
+
const offsetX = x - ax[segment];
|
|
60
|
+
const offsetZ = z - az[segment];
|
|
61
|
+
const segLengthSq = lengthSq[segment];
|
|
62
|
+
const t = segLengthSq <= 0 ? 0 : Math.max(0, Math.min(1, (offsetX * abx[segment] + offsetZ * abz[segment]) / segLengthSq));
|
|
63
|
+
const px = ax[segment] + abx[segment] * t;
|
|
64
|
+
const pz = az[segment] + abz[segment] * t;
|
|
65
|
+
const distance = Math.hypot(x - px, z - pz);
|
|
66
|
+
if (distance < bestDistance) {
|
|
67
|
+
bestDistance = distance;
|
|
68
|
+
bestSegment = segment;
|
|
69
|
+
bestT = t;
|
|
70
|
+
bestX = px;
|
|
71
|
+
bestZ = pz;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (bestSegment < 0 || bestDistance > limit)
|
|
75
|
+
return null;
|
|
76
|
+
return { segment: bestSegment, t: bestT, distance: bestDistance, point: [bestX, bestZ] };
|
|
77
|
+
},
|
|
78
|
+
examined: () => examined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uniform XZ grid over items with a rectangular footprint: insert once, then ask which items cover a
|
|
3
|
+
* box. One mechanism behind the two sampling hot paths — a corridor's segments and a palette's regions
|
|
4
|
+
* — so a per-vertex query tests the handful of items nearby instead of every item in the world.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
/** @internal */
|
|
8
|
+
export interface SpatialBuckets {
|
|
9
|
+
/** Register `id` under every cell its rectangle overlaps. Ids are returned in insertion order. */
|
|
10
|
+
insert(id: number, minX: number, minZ: number, maxX: number, maxZ: number): void;
|
|
11
|
+
/** Ids whose rectangle overlaps the query box, ascending and deduped, written into `out`. */
|
|
12
|
+
query(minX: number, minZ: number, maxX: number, maxZ: number, out: number[]): number[];
|
|
13
|
+
}
|
|
14
|
+
/** @internal */
|
|
15
|
+
export declare function buildSpatialBuckets(cellSize: number): SpatialBuckets;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uniform XZ grid over items with a rectangular footprint: insert once, then ask which items cover a
|
|
3
|
+
* box. One mechanism behind the two sampling hot paths — a corridor's segments and a palette's regions
|
|
4
|
+
* — so a per-vertex query tests the handful of items nearby instead of every item in the world.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
/** @internal */
|
|
8
|
+
export function buildSpatialBuckets(cellSize) {
|
|
9
|
+
const cell = Math.max(1e-6, cellSize);
|
|
10
|
+
const columns = new Map();
|
|
11
|
+
let marks = new Int32Array(16);
|
|
12
|
+
let generation = 0;
|
|
13
|
+
let highest = -1;
|
|
14
|
+
const mark = (id) => {
|
|
15
|
+
if (id <= highest)
|
|
16
|
+
return;
|
|
17
|
+
highest = id;
|
|
18
|
+
if (id < marks.length)
|
|
19
|
+
return;
|
|
20
|
+
let size = marks.length;
|
|
21
|
+
while (size <= id)
|
|
22
|
+
size *= 2;
|
|
23
|
+
const grown = new Int32Array(size);
|
|
24
|
+
grown.set(marks);
|
|
25
|
+
marks = grown;
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
insert(id, minX, minZ, maxX, maxZ) {
|
|
29
|
+
mark(id);
|
|
30
|
+
const cx0 = Math.floor(minX / cell);
|
|
31
|
+
const cx1 = Math.floor(maxX / cell);
|
|
32
|
+
const cz0 = Math.floor(minZ / cell);
|
|
33
|
+
const cz1 = Math.floor(maxZ / cell);
|
|
34
|
+
for (let cx = cx0; cx <= cx1; cx += 1) {
|
|
35
|
+
let column = columns.get(cx);
|
|
36
|
+
if (column === undefined) {
|
|
37
|
+
column = new Map();
|
|
38
|
+
columns.set(cx, column);
|
|
39
|
+
}
|
|
40
|
+
for (let cz = cz0; cz <= cz1; cz += 1) {
|
|
41
|
+
const bucket = column.get(cz);
|
|
42
|
+
if (bucket === undefined)
|
|
43
|
+
column.set(cz, [id]);
|
|
44
|
+
else
|
|
45
|
+
bucket.push(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
query(minX, minZ, maxX, maxZ, out) {
|
|
50
|
+
out.length = 0;
|
|
51
|
+
generation += 1;
|
|
52
|
+
if (generation >= 2147483647) {
|
|
53
|
+
marks.fill(0);
|
|
54
|
+
generation = 1;
|
|
55
|
+
}
|
|
56
|
+
const cx0 = Math.floor(minX / cell);
|
|
57
|
+
const cx1 = Math.floor(maxX / cell);
|
|
58
|
+
const cz0 = Math.floor(minZ / cell);
|
|
59
|
+
const cz1 = Math.floor(maxZ / cell);
|
|
60
|
+
for (let cx = cx0; cx <= cx1; cx += 1) {
|
|
61
|
+
const column = columns.get(cx);
|
|
62
|
+
if (column === undefined)
|
|
63
|
+
continue;
|
|
64
|
+
for (let cz = cz0; cz <= cz1; cz += 1) {
|
|
65
|
+
const bucket = column.get(cz);
|
|
66
|
+
if (bucket === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
for (const id of bucket) {
|
|
69
|
+
if (marks[id] === generation)
|
|
70
|
+
continue;
|
|
71
|
+
marks[id] = generation;
|
|
72
|
+
out.push(id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
out.sort((a, b) => a - b);
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/world/terrain.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withPathProfiles } from "./pathTerrain.js";
|
|
2
|
-
import {
|
|
2
|
+
import { buildPolylineIndex } from "./polylineIndex.js";
|
|
3
|
+
import { buildSpatialBuckets } from "./spatialBuckets.js";
|
|
3
4
|
function clamp01(value) {
|
|
4
5
|
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
5
6
|
}
|
|
@@ -321,10 +322,15 @@ export function flattenFieldAround(base, zones) {
|
|
|
321
322
|
export function roadDrapedField(base, roads) {
|
|
322
323
|
if (roads.length === 0)
|
|
323
324
|
return base;
|
|
325
|
+
const draped = roads.map((road) => ({
|
|
326
|
+
elevation: road.elevation,
|
|
327
|
+
half: road.width / 2,
|
|
328
|
+
index: buildPolylineIndex(road.path),
|
|
329
|
+
}));
|
|
324
330
|
const sampleHeight = (x, z) => {
|
|
325
331
|
let lift = 0;
|
|
326
|
-
for (const road of
|
|
327
|
-
if (road.elevation > lift &&
|
|
332
|
+
for (const road of draped) {
|
|
333
|
+
if (road.elevation > lift && road.index.nearest(x, z, road.half) !== null)
|
|
328
334
|
lift = road.elevation;
|
|
329
335
|
}
|
|
330
336
|
return base.sampleHeight(x, z) + lift;
|
|
@@ -680,9 +686,13 @@ export function createTerrainPaletteSampler(descriptor) {
|
|
|
680
686
|
}));
|
|
681
687
|
if (regions.length === 0 && bandSampler === null)
|
|
682
688
|
return () => base;
|
|
689
|
+
const buckets = buildSpatialBuckets(PALETTE_REGION_CELL);
|
|
690
|
+
regions.forEach((region, index) => buckets.insert(index, region.minX, region.minZ, region.maxX, region.maxZ));
|
|
691
|
+
const nearby = [];
|
|
683
692
|
return (x, z) => {
|
|
684
693
|
let palette = bandSampler === null ? base : bandSampler(z);
|
|
685
|
-
for (const
|
|
694
|
+
for (const index of buckets.query(x, z, x, z, nearby)) {
|
|
695
|
+
const region = regions[index];
|
|
686
696
|
const distance = region.coreDistance(x, z);
|
|
687
697
|
if (distance <= 0) {
|
|
688
698
|
palette = region.palette;
|
|
@@ -695,20 +705,44 @@ export function createTerrainPaletteSampler(descriptor) {
|
|
|
695
705
|
return palette;
|
|
696
706
|
};
|
|
697
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Cell size for the palette's region index, in world units: coarse enough that inserting a small
|
|
710
|
+
* region touches one cell, fine enough that a vertex only tests the handful of regions over it.
|
|
711
|
+
*/
|
|
712
|
+
const PALETTE_REGION_CELL = 32;
|
|
698
713
|
/**
|
|
699
714
|
* Resolves a `TerrainMaterialRegion` of any shape to a `coreDistance(x, z)` — 0 inside the painted
|
|
700
|
-
* core, growing positive outward — plus its blend-ring `falloff
|
|
701
|
-
* shape through one shape-agnostic
|
|
715
|
+
* core, growing positive outward — plus its blend-ring `falloff` and the box a region can paint
|
|
716
|
+
* inside (core plus blend ring), so the palette sampler blends every shape through one shape-agnostic
|
|
717
|
+
* loop that only visits regions covering the sample.
|
|
702
718
|
*/
|
|
703
719
|
function resolveRegionShape(region) {
|
|
704
720
|
if (region.shape === "polyline") {
|
|
705
721
|
const half = region.width * 0.5;
|
|
722
|
+
const falloff = region.falloff ?? half;
|
|
723
|
+
const index = buildPolylineIndex(region.points);
|
|
724
|
+
const limit = half + falloff;
|
|
725
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
726
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
727
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
728
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
729
|
+
for (const point of region.points) {
|
|
730
|
+
minX = Math.min(minX, point[0]);
|
|
731
|
+
maxX = Math.max(maxX, point[0]);
|
|
732
|
+
minZ = Math.min(minZ, point[1]);
|
|
733
|
+
maxZ = Math.max(maxZ, point[1]);
|
|
734
|
+
}
|
|
735
|
+
const empty = region.points.length < 2;
|
|
706
736
|
return {
|
|
707
|
-
falloff
|
|
737
|
+
falloff,
|
|
708
738
|
coreDistance: (x, z) => {
|
|
709
|
-
const
|
|
710
|
-
return
|
|
739
|
+
const hit = index.nearest(x, z, limit);
|
|
740
|
+
return hit === null ? Number.POSITIVE_INFINITY : Math.max(0, hit.distance - half);
|
|
711
741
|
},
|
|
742
|
+
minX: empty ? 0 : minX - limit,
|
|
743
|
+
minZ: empty ? 0 : minZ - limit,
|
|
744
|
+
maxX: empty ? -1 : maxX + limit,
|
|
745
|
+
maxZ: empty ? -1 : maxZ + limit,
|
|
712
746
|
};
|
|
713
747
|
}
|
|
714
748
|
if (region.shape === "rect") {
|
|
@@ -716,8 +750,11 @@ function resolveRegionShape(region) {
|
|
|
716
750
|
const angle = region.rotationY ?? 0;
|
|
717
751
|
const cos = Math.cos(angle);
|
|
718
752
|
const sin = Math.sin(angle);
|
|
753
|
+
const falloff = region.falloff ?? Math.min(hx, hz) * 0.5;
|
|
754
|
+
const extentX = Math.abs(hx * cos) + Math.abs(hz * sin) + falloff;
|
|
755
|
+
const extentZ = Math.abs(hx * sin) + Math.abs(hz * cos) + falloff;
|
|
719
756
|
return {
|
|
720
|
-
falloff
|
|
757
|
+
falloff,
|
|
721
758
|
coreDistance: (x, z) => {
|
|
722
759
|
const dx = x - region.center[0];
|
|
723
760
|
const dz = z - region.center[1];
|
|
@@ -725,11 +762,21 @@ function resolveRegionShape(region) {
|
|
|
725
762
|
const lz = -dx * sin + dz * cos;
|
|
726
763
|
return Math.hypot(Math.max(0, Math.abs(lx) - hx), Math.max(0, Math.abs(lz) - hz));
|
|
727
764
|
},
|
|
765
|
+
minX: region.center[0] - extentX,
|
|
766
|
+
minZ: region.center[1] - extentZ,
|
|
767
|
+
maxX: region.center[0] + extentX,
|
|
768
|
+
maxZ: region.center[1] + extentZ,
|
|
728
769
|
};
|
|
729
770
|
}
|
|
771
|
+
const falloff = region.falloff ?? region.radius * 0.5;
|
|
772
|
+
const extent = region.radius + falloff;
|
|
730
773
|
return {
|
|
731
|
-
falloff
|
|
774
|
+
falloff,
|
|
732
775
|
coreDistance: (x, z) => Math.max(0, Math.hypot(x - region.center[0], z - region.center[1]) - region.radius),
|
|
776
|
+
minX: region.center[0] - extent,
|
|
777
|
+
minZ: region.center[1] - extent,
|
|
778
|
+
maxX: region.center[0] + extent,
|
|
779
|
+
maxZ: region.center[1] + extent,
|
|
733
780
|
};
|
|
734
781
|
}
|
|
735
782
|
function withFlattenMasks(sampleHeight, masks) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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.35.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.35.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -436,7 +436,7 @@
|
|
|
436
436
|
- `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
|
|
437
437
|
- `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
|
|
438
438
|
- `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
|
|
439
|
-
- `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> { definition: GameDefinition<TAssetRef, TMultiplayer>; content?: GameContextContent; loop?: GameLoop<GameContext>; player?: {userId: string; isNew: boolean}; now?: () => number;
|
|
439
|
+
- `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> { definition: GameDefinition<TAssetRef, TMultiplayer>; content?: GameContextContent; loop?: GameLoop<GameContext>; player?: {userId: string; isNew: boolean}; now?: () => number; seed?: string | number; maxStepSeconds?: number; playerMovemen… — ⚠ undocumented
|
|
440
440
|
- `createHeadlessRunner` (function): function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner — ⚠ undocumented
|
|
441
441
|
|
|
442
442
|
## @jgengine/core/runtime/hostPersistence
|
|
@@ -2653,6 +2653,17 @@
|
|
|
2653
2653
|
- `TriggeredRuleState` (interface): interface TriggeredRuleState — Serializable runtime state — everything that changes future triggers. Rules travel with it.
|
|
2654
2654
|
- `createTriggeredRuleEngine` (function): function createTriggeredRuleEngine(rules: readonly TriggeredRule[] = []): TriggeredRuleEngine — Create an empty triggered-rule engine. Rules are added as data; dispatching an event returns the firings the caller applies to its own effect system. Time is caller-supplied (`now` in ms), so the engine is deterministic and drives equally from a fixed-step loop or a save-restored timeline.
|
|
2655
2655
|
|
|
2656
|
+
## @jgengine/core/run/runLoop
|
|
2657
|
+
|
|
2658
|
+
- `RunChange` (interface): interface RunChange<T> — One change the next run can open with. `T` is the game's own payload (a tighter rival line, an unlocked part, a route modifier) - the loop only draws and remembers which id was drawn.
|
|
2659
|
+
- `RunLoop` (interface): interface RunLoop<T> — A run's lifecycle: what it carries and can lose, and the change it hands the next run.
|
|
2660
|
+
- `RunLoopConfig` (interface): interface RunLoopConfig<T> — Configuration for {@link createRunLoop}.
|
|
2661
|
+
- `RunLoopState` (interface): interface RunLoopState — Everything a run loop stores as plain JSON: the run in progress plus lifetime totals.
|
|
2662
|
+
- `RunOffer` (interface): interface RunOffer<T> — The change a run opens with, and which run it is.
|
|
2663
|
+
- `RunOutcome` (type): type RunOutcome = "banked" | "lost" — How a run ended: what it carried reached the bank, or it was lost.
|
|
2664
|
+
- `RunSettlement` (interface): interface RunSettlement<T> — What one closed run banked or lost, and what the next run changes.
|
|
2665
|
+
- `createRunLoop` (function): function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T> — Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
|
|
2666
|
+
|
|
2656
2667
|
## @jgengine/core/session/contestedChannel
|
|
2657
2668
|
|
|
2658
2669
|
- `ContestReaction` (type): type ContestReaction = "pause" | "decay" — ⚠ undocumented
|
|
@@ -648,6 +648,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
648
648
|
- `rerollRules` (function) · `import { rerollRules } from "@jgengine/core/gameplay"`
|
|
649
649
|
- `selectRules` (function) · `import { selectRules } from "@jgengine/core/gameplay"`
|
|
650
650
|
|
|
651
|
+
## run-loop — bank or lose what a run carries, with one deterministic change per run
|
|
652
|
+
|
|
653
|
+
- `createRunLoop` (function) · `import { createRunLoop } from "@jgengine/core/run/runLoop"`
|
|
654
|
+
|
|
651
655
|
## run-modifiers — a roguelike run built from stacking drafted modifier picks
|
|
652
656
|
|
|
653
657
|
- `createRunDraft` (function) · `import { createRunDraft } from "@jgengine/core/gameplay"`
|
|
@@ -106,8 +106,10 @@
|
|
|
106
106
|
- `DriverState` (interface): interface DriverState — Serializable per-driver state advanced in place by {@link driveStep}.
|
|
107
107
|
- `DriverStep` (interface): interface DriverStep extends AxisInput — {@link driveStep}'s result: the axis sample to feed the vehicle sim, plus arrival.
|
|
108
108
|
- `DriverTuning` (interface): interface DriverTuning — Vehicle-feel numbers the game supplies; everything has a sensible default except `maxSpeed`.
|
|
109
|
+
- `PaceBand` (interface): interface PaceBand — Catch-up pacing for one car in a field: the gap it tries to hold to a reference car (a rival, the player, a convoy lead) and how its pace bends when it drifts off that gap. Past `margin` by `tolerance` it sheds up to `ease` of its pace; short of it by `tolerance` it gains up to `push`; inside that tolerance it leaves the car alone. The push never outdrives `DriverTuning.maxSpeed`, so a band changes how hard a car tries, never what it is built to do.
|
|
109
110
|
- `createDriverState` (function): function createDriverState(targetX = 0, targetZ = 0): DriverState — Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn position itself).
|
|
110
|
-
- `driveStep` (function): function driveStep(state: DriverState, dt: number, pose: DriverPose, goal: DriverGoal, profile: DifficultyProfile, tuning: DriverTuning, rng: () => number, obstacleAhead: number | null = null): DriverStep — Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving corridor this step (`null`/omitted when clear) — the driver perceives it late by `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`, so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut toward where the target *was*. When throttle produces no motion for a tier-scaled while the driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
|
|
111
|
+
- `driveStep` (function): function driveStep(state: DriverState, dt: number, pose: DriverPose, goal: DriverGoal, profile: DifficultyProfile, tuning: DriverTuning, rng: () => number, obstacleAhead: number | null = null): DriverStep — Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving corridor this step (`null`/omitted when clear) — the driver perceives it late by `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`, so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut toward where the target *was*. A {@link PaceBand} on the goal eases a car ahead of its margin and pushes one behind it, bounded by the car's own top speed. When throttle produces no motion for a tier-scaled while the driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
|
|
112
|
+
- `paceBandFactor` (function): function paceBandFactor(band: PaceBand, gap: number): number — The pace multiplier a {@link PaceBand} asks for at `gap`: `1` inside the tolerance, `1 - ease * scale` fully ahead of it, `1 + push * scale` fully behind it, ramped linearly across `easeAt` / `pushAt` in between. Pure scalar math, so a band is deterministic and costs no allocation per step; the result never goes negative.
|
|
111
113
|
- `pathTargetAhead` (function): function pathTargetAhead(path: readonly DrivePathPoint[], x: number, z: number, lookahead: number, loop = false): DrivePathPoint | null — The pure-pursuit target for street/route following: the point `lookahead` meters further along the polyline from wherever the vehicle currently projects onto it. Feed the result to {@link driveStep} as the goal each tick and the driver follows the road; `loop` wraps a circuit (traffic block, race lap) instead of pinning at the final point. Returns `null` for a degenerate path. Give sharper tiers a longer `lookahead` to cut corners cleanly and duller tiers a short one so they steer segment-to-segment.
|
|
112
114
|
|
|
113
115
|
## @jgengine/core/ai/flock
|
|
@@ -453,13 +455,22 @@
|
|
|
453
455
|
- `sumMagnitude` (function): function sumMagnitude<P>(memberships: readonly AreaMembership<P>[], magnitudeOf: MagnitudeOf<P>): number — Sum a numeric magnitude across memberships — additive aggregation for a total field strength (total damage per tick, total slow). A terminal reducer, not a filter, so it returns the number.
|
|
454
456
|
- `uniqueByStackKey` (function): function uniqueByStackKey<P>(magnitudeOf?: MagnitudeOf<P>): AreaStackPolicy<P> — Keep at most one membership per `stackKey`. With `magnitudeOf` the strongest per key wins (ties broken by `sourceId` for determinism); without it the first-seen per key wins. Use for unique-by-key buffs where reapplying the same aura should not stack.
|
|
455
457
|
|
|
458
|
+
## @jgengine/core/audio/ambience
|
|
459
|
+
|
|
460
|
+
- `AmbienceWeight` (interface): interface AmbienceWeight — One zone's share of the ambience mix at a listener position, 0..1.
|
|
461
|
+
- `AmbienceZoneDef` (interface): interface AmbienceZoneDef — One ambience zone: a sound bed keyed to a volume of the scene.
|
|
462
|
+
- `AmbienceZoneShape` (type): type AmbienceZoneShape = | { kind: "sphere"; center: readonly [number, number, number]; radius: number } | { kind: "box"; min: readonly [number, number, number]; max: readonly [number, number, number] } — A world-space volume whose sound bed plays while the listener is inside it.
|
|
463
|
+
- `activeAmbienceZone` (function): function activeAmbienceZone(weights: readonly AmbienceWeight[]): string | null — The loudest zone in a weight list, or null when every zone is silent.
|
|
464
|
+
- `ambienceWeights` (function): function ambienceWeights(zones: readonly AmbienceZoneDef[], x: number, y: number, z: number, out: AmbienceWeight[] = []): AmbienceWeight[] — Every zone's mix weight at a listener position. A zone is at full level `crossfade` units inside its boundary, at half level on the boundary and silent `crossfade` units outside, so a listener walking between two zones that share a boundary hears a constant-sum handover. Weights that would sum above 1 (overlapping zones) are normalized so total bed level never exceeds unity. Writes into `out` when given, so a per-frame caller allocates nothing.
|
|
465
|
+
- `zoneSignedDistance` (function): function zoneSignedDistance(shape: AmbienceZoneShape, x: number, y: number, z: number): number — Distance from a point to a zone boundary, positive inside and negative outside. Spheres measure from the surface; boxes use the nearest face.
|
|
466
|
+
|
|
456
467
|
## @jgengine/core/audio/audioFalloff
|
|
457
468
|
|
|
458
469
|
- `AudioBusDef` (interface): interface AudioBusDef { id: AudioBusId; gain?: number } — ⚠ undocumented · used by `withFeedbackCueSounds` (@jgengine/core/audio/feedbackCues): Merge the default feedback reactions into a game's sound catalog.
|
|
459
470
|
- `AudioBusId` (type): type AudioBusId = string — ⚠ undocumented
|
|
460
471
|
- `AudioFalloffConfig` (interface): interface AudioFalloffConfig { minDistance?: number; maxDistance?: number; curve?: FalloffCurve } — ⚠ undocumented
|
|
461
472
|
- `FalloffCurve` (type): type FalloffCurve = "linear" | "inverse" | "none" — ⚠ undocumented
|
|
462
|
-
- `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number;
|
|
473
|
+
- `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; duck?: {buses?: readonly string[]; amount?: number; seconds?: number;}; positional?: boolean; falloff?: A… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
|
|
463
474
|
- `computeFalloffGain` (function): function computeFalloffGain(distance: number, config: AudioFalloffConfig = {}): number — ⚠ undocumented
|
|
464
475
|
- `distance3` (function): function distance3(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number — ⚠ undocumented
|
|
465
476
|
- `resolveEmitterGain` (function): function resolveEmitterGain(distance: number, sound: Pick<SoundDef, "gain" | "positional" | "falloff">, busGain: number): number — ⚠ undocumented
|
|
@@ -511,6 +522,13 @@
|
|
|
511
522
|
- `createMusicState` (function): function createMusicState(): MusicStateSnapshot — Fresh state with no tier entered, so the first resolve always settles and reports `changed`.
|
|
512
523
|
- `resolveMusicState` (function): function resolveMusicState(state: MusicStateSnapshot, input: MusicStateInput, config: MusicStateConfig): MusicStateResult — Pick the theme for the current intensity. Escalation is immediate; de-escalation waits for the tier's `holdMs` and needs the intensity to fall a `release` margin below the tier it is leaving.
|
|
513
524
|
|
|
525
|
+
## @jgengine/core/audio/occlusion
|
|
526
|
+
|
|
527
|
+
- `AUDIO_OCCLUSION_DEFAULTS` (const): const AUDIO_OCCLUSION_DEFAULTS: { readonly maxTestsPerTick: 8; readonly intervalMs: 150; readonly attenuation: 0.55; readonly lowpassHz: 1200; readonly maxDistance: 60; } — Defaults for {@link AudioOcclusionConfig}.
|
|
528
|
+
- `AudioOcclusionConfig` (interface): interface AudioOcclusionConfig — Geometry occlusion for retained positional loops: a cheap line test between the listener and an emitter behind blocking geometry, bounded so a busy scene can never turn audio into a raycast pass. The shell runs the test against the scene's blockable colliders; the engine applies the resulting attenuation and low-pass to the loop it already owns.
|
|
529
|
+
- `ResolvedAudioOcclusionConfig` (interface): interface ResolvedAudioOcclusionConfig — A fully specified occlusion policy, with every unset field replaced by its default.
|
|
530
|
+
- `resolveOcclusionConfig` (function): function resolveOcclusionConfig(config: AudioOcclusionConfig | false = {}): ResolvedAudioOcclusionConfig — Fill in the occlusion defaults and clamp each field to a workable range: a finite test budget, a non-negative retest interval and distance cap, and an attenuation in `0..1`.
|
|
531
|
+
|
|
514
532
|
## @jgengine/core/audio/stepCadence
|
|
515
533
|
|
|
516
534
|
- `StepCadenceConfig` (interface): interface StepCadenceConfig — Distance-driven footstep cadence. Pure state-in/state-out: feed it how far a mover travelled this frame and it tells you how many steps landed, so foley stays locked to ground distance instead of a wall-clock timer that desyncs when speed changes.
|
|
@@ -2857,7 +2875,7 @@
|
|
|
2857
2875
|
- `SnapMode` (type): type SnapMode = "grid" | "free" | "surface" — ⚠ undocumented
|
|
2858
2876
|
- `SnowEnvironmentDescriptor` (type): type SnowEnvironmentDescriptor = { kind: "snow" } & Required< Pick<SnowEnvironmentConfig, "area" | "density" | "speed" | "flakeSize" | "drift" | "wind" | "color" | "opacity"> > — ⚠ undocumented · used by `snow`: Declares a snowfall weather effect for `environment()` — area, density, drift, wind, and flake opacity.
|
|
2859
2877
|
- `SoilRules` (interface): interface SoilRules — Fully-defaulted soil params parsed from a volume's `meta`.
|
|
2860
|
-
- `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number;
|
|
2878
|
+
- `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; duck?: {buses?: readonly string[]; amount?: number; seconds?: number;}; positional?: boolean; falloff?: A… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
|
|
2861
2879
|
- `SpatialGrid` (class): class SpatialGrid — A uniform-grid broad-phase over the x/z plane, separate from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers (swarm enemies). Rebuild each tick from the caller's own position arrays, then `queryCircle` (enemies hitting the player / an AoE) or `forEachPair` (mutual separation). Both are precise: no false negatives, no false positives beyond the exact distance test.
|
|
2862
2880
|
- `SpawnDirectorConfig` (interface): interface SpawnDirectorConfig { waves: readonly WaveManifest[]; maxAlive?: number; escalationPerSecond?: number; alertBudgetPerSecond?: number; alertDecayPerSecond?: number; playerBudgetPerSecond?: number; maxSpawnsPerTick?: number; loop?: boolean; seed?: number; spawnP… — ⚠ undocumented
|
|
2863
2881
|
- `SpawnDirectorState` (interface): interface SpawnDirectorState { wave: number; elapsed: number; waveElapsed: number; budget: number; alert: number; spawnedThisWave: number; spawnedTotal: number; rng: RandomSeed; done: boolean } — ⚠ undocumented
|