@jgengine/core 0.24.0 → 0.26.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 (98) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/ai/rvo.d.ts +106 -0
  3. package/dist/ai/rvo.js +581 -0
  4. package/dist/ai/utilityBrain.d.ts +173 -0
  5. package/dist/ai/utilityBrain.js +277 -0
  6. package/dist/combat/cover.d.ts +169 -0
  7. package/dist/combat/cover.js +264 -0
  8. package/dist/combat/damageRuntime.d.ts +150 -0
  9. package/dist/combat/damageRuntime.js +208 -0
  10. package/dist/combat/damageTable.d.ts +116 -0
  11. package/dist/combat/damageTable.js +165 -0
  12. package/dist/combat/hitZones.d.ts +70 -0
  13. package/dist/combat/hitZones.js +77 -0
  14. package/dist/combat/magazine.d.ts +8 -0
  15. package/dist/combat/magazine.js +20 -2
  16. package/dist/combat/squadCombat.d.ts +167 -0
  17. package/dist/combat/squadCombat.js +557 -0
  18. package/dist/combat/squadTactics.d.ts +73 -0
  19. package/dist/combat/squadTactics.js +49 -0
  20. package/dist/combat/statusRuntime.d.ts +142 -0
  21. package/dist/combat/statusRuntime.js +236 -0
  22. package/dist/combat/weaponDefinition.d.ts +106 -0
  23. package/dist/combat/weaponDefinition.js +108 -0
  24. package/dist/combat/weaponSystem.d.ts +189 -0
  25. package/dist/combat/weaponSystem.js +469 -0
  26. package/dist/combat.d.ts +9 -0
  27. package/dist/combat.js +9 -0
  28. package/dist/devtools/devtools.d.ts +4 -2
  29. package/dist/devtools/devtools.js +2 -1
  30. package/dist/devtools/discover.d.ts +2 -1
  31. package/dist/devtools/discover.js +38 -0
  32. package/dist/devtools/frame.d.ts +0 -2
  33. package/dist/devtools/frame.js +1 -2
  34. package/dist/devtools/logBus.d.ts +58 -0
  35. package/dist/devtools/logBus.js +79 -0
  36. package/dist/devtools/logs.d.ts +6 -3
  37. package/dist/devtools/logs.js +17 -13
  38. package/dist/devtools/report.d.ts +120 -0
  39. package/dist/devtools/report.js +49 -0
  40. package/dist/devtools/reportEndpoint.d.ts +17 -0
  41. package/dist/devtools/reportEndpoint.js +7 -0
  42. package/dist/devtools/types.d.ts +48 -11
  43. package/dist/editor/document.js +210 -0
  44. package/dist/editor/environment.d.ts +48 -1
  45. package/dist/editor/environment.js +161 -0
  46. package/dist/editor/index.d.ts +6 -2
  47. package/dist/editor/index.js +1 -1
  48. package/dist/editor/types.d.ts +54 -0
  49. package/dist/game/notices.d.ts +4 -0
  50. package/dist/game/notices.js +4 -3
  51. package/dist/game/playableGame.d.ts +9 -1
  52. package/dist/gameplay.d.ts +4 -0
  53. package/dist/gameplay.js +4 -0
  54. package/dist/meta/changelog.d.ts +1 -1
  55. package/dist/meta/changelog.js +78 -1
  56. package/dist/nav/navCrowd.d.ts +138 -0
  57. package/dist/nav/navCrowd.js +412 -0
  58. package/dist/nav/navRuntime.d.ts +108 -0
  59. package/dist/nav/navRuntime.js +256 -0
  60. package/dist/render/colorPipeline.d.ts +53 -0
  61. package/dist/render/colorPipeline.js +79 -0
  62. package/dist/render/lightProbes.d.ts +97 -0
  63. package/dist/render/lightProbes.js +221 -0
  64. package/dist/render/lookPreset.js +4 -4
  65. package/dist/render/physicalSky.d.ts +39 -0
  66. package/dist/render/physicalSky.js +89 -0
  67. package/dist/render/postProcessing.d.ts +40 -8
  68. package/dist/render/postProcessing.js +1 -1
  69. package/dist/render/skyOwnedLighting.d.ts +31 -0
  70. package/dist/render/skyOwnedLighting.js +46 -0
  71. package/dist/sensor/perception.d.ts +66 -5
  72. package/dist/sensor/perception.js +167 -42
  73. package/dist/sim/nearAgents.d.ts +68 -0
  74. package/dist/sim/nearAgents.js +152 -0
  75. package/dist/sim/needs.d.ts +60 -0
  76. package/dist/sim/needs.js +108 -0
  77. package/dist/sim/offScreen.d.ts +181 -0
  78. package/dist/sim/offScreen.js +450 -0
  79. package/dist/sim/routine.d.ts +79 -0
  80. package/dist/sim/routine.js +137 -0
  81. package/dist/visibility/visibilitySystem.js +21 -0
  82. package/dist/world/features.d.ts +8 -0
  83. package/dist/world.d.ts +5 -0
  84. package/dist/world.js +5 -0
  85. package/package.json +1 -1
  86. package/skills/jgengine/api.md +59 -6
  87. package/skills/jgengine/capabilities.md +4 -0
  88. package/skills/jgengine-combat/SKILL.md +17 -0
  89. package/skills/jgengine-combat/api.md +246 -0
  90. package/skills/jgengine-combat/capabilities.md +112 -0
  91. package/skills/jgengine-combat/reference.md +32 -0
  92. package/skills/jgengine-gameplay/SKILL.md +8 -0
  93. package/skills/jgengine-gameplay/api.md +71 -1
  94. package/skills/jgengine-gameplay/capabilities.md +16 -0
  95. package/skills/jgengine-world/SKILL.md +3 -1
  96. package/skills/jgengine-world/api.md +107 -3
  97. package/skills/jgengine-world/capabilities.md +22 -2
  98. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
@@ -0,0 +1,221 @@
1
+ import { hexToRgb, mixHex } from "./color.js";
2
+ /**
3
+ * Baked irradiance volume: a grid of second-order (9-coefficient, RGB) spherical-harmonic probes
4
+ * over a box region, sampled at runtime for ambient lighting. It is the datum an interior has that
5
+ * a hemisphere light cannot: a room's fill is its own, not the sky's, and it varies across the space.
6
+ *
7
+ * Serializable and pure; the bake is deterministic heuristics (sky visibility, authored lamps and
8
+ * bounce colours), not a path tracer.
9
+ */
10
+ /** SH coefficients per channel for the second band — 3 bands, real basis. */
11
+ export const SH_COEFFICIENTS = 9;
12
+ /** Floats stored per probe: {@link SH_COEFFICIENTS} × RGB. */
13
+ export const PROBE_STRIDE = SH_COEFFICIENTS * 3;
14
+ const SH_BAND_0 = 0.282095;
15
+ const SH_BAND_1 = 0.488603;
16
+ const SH_BAND_2_0 = 1.092548;
17
+ const SH_BAND_2_1 = 0.315392;
18
+ const SH_BAND_2_2 = 0.546274;
19
+ /**
20
+ * Real-basis SH values for a unit direction, into `out` (length 9):
21
+ * `[Y00, Y1-1, Y10, Y11, Y2-2, Y2-1, Y20, Y21, Y22]`.
22
+ * @internal
23
+ */
24
+ export function shDirection(x, y, z, out) {
25
+ out[0] = SH_BAND_0;
26
+ out[1] = SH_BAND_1 * y;
27
+ out[2] = SH_BAND_1 * z;
28
+ out[3] = SH_BAND_1 * x;
29
+ out[4] = SH_BAND_2_0 * x * y;
30
+ out[5] = SH_BAND_2_0 * y * z;
31
+ out[6] = SH_BAND_2_1 * (3 * z * z - 1);
32
+ out[7] = SH_BAND_2_0 * x * z;
33
+ out[8] = SH_BAND_2_2 * (x * x - y * y);
34
+ }
35
+ /** Fibonacci sphere direction `index` of `count`, in the renderer's Y-up frame. */
36
+ function sphereDirection(index, count) {
37
+ const y = 1 - (2 * (index + 0.5)) / count;
38
+ const radius = Math.sqrt(Math.max(0, 1 - y * y));
39
+ const theta = index * 2.399963229728653;
40
+ return [Math.cos(theta) * radius, y, Math.sin(theta) * radius];
41
+ }
42
+ function scaledRgb(color, scale) {
43
+ const [r, g, b] = hexToRgb(color);
44
+ return [(r / 255) * scale, (g / 255) * scale, (b / 255) * scale];
45
+ }
46
+ /** World-space position of probe `(ix, iy, iz)`; the outer probes sit on the box faces.
47
+ * @internal
48
+ */
49
+ export function probePosition(volume, ix, iy, iz) {
50
+ const [nx, ny, nz] = volume.resolution;
51
+ const t = (index, count) => (count <= 1 ? 0.5 : index / (count - 1));
52
+ return [
53
+ volume.center[0] + (t(ix, nx) - 0.5) * volume.size[0],
54
+ volume.center[1] + (t(iy, ny) - 0.5) * volume.size[1],
55
+ volume.center[2] + (t(iz, nz) - 0.5) * volume.size[2],
56
+ ];
57
+ }
58
+ /**
59
+ * Bakes an irradiance volume from authored inputs: sky radiance where the region is open, an
60
+ * authored ceiling/floor fill where it is enclosed, plus inverse-square lamp radiance. Each probe
61
+ * projects its directional radiance into L2 SH, so ambient varies across the grid and keeps a lit
62
+ * side instead of flattening to one number.
63
+ * @capability light-probes bake an irradiance volume from authored sky, fill and lamp inputs
64
+ */
65
+ export function bakeIrradianceVolume(input) {
66
+ const { settings } = input;
67
+ const count = Math.max(8, Math.min(256, Math.floor(input.samples ?? 64)));
68
+ const [nx, ny, nz] = input.resolution.map((value) => Math.max(1, Math.floor(value)));
69
+ const visibility = Math.max(0, Math.min(1, settings.skyVisibility));
70
+ const fill = Math.max(0, settings.fillIntensity ?? 1);
71
+ const sky = settings.skyColor ?? "#9ec4e8";
72
+ const ground = settings.groundColor ?? "#4c4a42";
73
+ const ceiling = settings.ceilingColor ?? "#8d8a80";
74
+ const floor = settings.floorColor ?? "#4a4438";
75
+ const lamps = settings.lights ?? [];
76
+ const samples = new Array(PROBE_STRIDE * nx * ny * nz).fill(0);
77
+ const basis = new Float64Array(SH_COEFFICIENTS);
78
+ const accum = new Float64Array(PROBE_STRIDE);
79
+ for (let iz = 0; iz < nz; iz += 1) {
80
+ for (let iy = 0; iy < ny; iy += 1) {
81
+ for (let ix = 0; ix < nx; ix += 1) {
82
+ const position = probePosition({ center: input.center, size: input.size, resolution: [nx, ny, nz] }, ix, iy, iz);
83
+ accum.fill(0);
84
+ for (let s = 0; s < count; s += 1) {
85
+ const [dx, dy, dz] = sphereDirection(s, count);
86
+ shDirection(dx, dy, dz, basis);
87
+ const radiance = radianceIn(position, dx, dy, dz, { visibility, fill, sky, ground, ceiling, floor, lamps });
88
+ for (let band = 0; band < SH_COEFFICIENTS; band += 1) {
89
+ const weighted = basis[band] * (4 * Math.PI) / count;
90
+ accum[band * 3] += radiance[0] * weighted;
91
+ accum[band * 3 + 1] += radiance[1] * weighted;
92
+ accum[band * 3 + 2] += radiance[2] * weighted;
93
+ }
94
+ }
95
+ const offset = ((iz * ny + iy) * nx + ix) * PROBE_STRIDE;
96
+ for (let index = 0; index < PROBE_STRIDE; index += 1)
97
+ samples[offset + index] = accum[index];
98
+ }
99
+ }
100
+ }
101
+ return { id: input.id, center: [...input.center], size: [...input.size], resolution: [nx, ny, nz], samples };
102
+ }
103
+ function radianceIn(position, dx, dy, dz, inputs) {
104
+ const open = inputs.visibility;
105
+ const enclosed = 1 - inputs.visibility;
106
+ let color;
107
+ let scale;
108
+ if (dy >= 0) {
109
+ // Above the horizon an open probe sees sky; an enclosed one sees its own ceiling.
110
+ color = mixHex(inputs.ceiling, inputs.sky, open);
111
+ scale = 0.55 + 0.45 * dy;
112
+ }
113
+ else {
114
+ // Below the horizon: ground bounce outside, floor bounce inside.
115
+ color = mixHex(inputs.floor, inputs.ground, open);
116
+ scale = 0.55 + 0.45 * -dy;
117
+ }
118
+ scale *= enclosed * inputs.fill + open;
119
+ const radiance = scaledRgb(color, scale);
120
+ for (const lamp of inputs.lamps) {
121
+ const lx = lamp.position[0] - position[0];
122
+ const ly = lamp.position[1] - position[1];
123
+ const lz = lamp.position[2] - position[2];
124
+ const distanceSquared = lx * lx + ly * ly + lz * lz;
125
+ if (distanceSquared < 1e-4)
126
+ continue;
127
+ const reach = lamp.distance ?? 0;
128
+ if (reach > 0 && distanceSquared > reach * reach)
129
+ continue;
130
+ const distance = Math.sqrt(distanceSquared);
131
+ const facing = (dx * lx + dy * ly + dz * lz) / distance;
132
+ if (facing <= 0)
133
+ continue;
134
+ const falloff = (lamp.intensity ?? 1) / (distanceSquared + 1) * Math.pow(facing, 6);
135
+ const lampRadiant = scaledRgb(lamp.color ?? "#ffd6a0", falloff);
136
+ radiance[0] += lampRadiant[0];
137
+ radiance[1] += lampRadiant[1];
138
+ radiance[2] += lampRadiant[2];
139
+ }
140
+ return radiance;
141
+ }
142
+ /**
143
+ * The volume that governs a world position: the one whose box contains it, else the nearest by
144
+ * centre. A scene usually authors one volume per interior plus one for the exterior shell.
145
+ * @internal
146
+ */
147
+ export function selectIrradianceVolume(volumes, position) {
148
+ let nearest;
149
+ let nearestDistance = Infinity;
150
+ for (const volume of volumes) {
151
+ if (volume.samples === undefined)
152
+ continue;
153
+ const dx = Math.abs(position[0] - volume.center[0]) - volume.size[0] / 2;
154
+ const dy = Math.abs(position[1] - volume.center[1]) - volume.size[1] / 2;
155
+ const dz = Math.abs(position[2] - volume.center[2]) - volume.size[2] / 2;
156
+ const outside = Math.max(dx, dy, dz);
157
+ if (outside <= 0)
158
+ return volume;
159
+ const distance = Math.hypot(Math.max(dx, 0), Math.max(dy, 0), Math.max(dz, 0));
160
+ if (distance < nearestDistance) {
161
+ nearestDistance = distance;
162
+ nearest = volume;
163
+ }
164
+ }
165
+ return nearest;
166
+ }
167
+ /**
168
+ * Trilinearly samples the volume's SH at a world position, writing {@link PROBE_STRIDE} floats into
169
+ * `out`. Positions outside the grid clamp to its faces, so a probe volume never pops to black at its
170
+ * edge. Returns false when the volume has no baked samples.
171
+ * @internal
172
+ */
173
+ export function sampleIrradianceVolume(volume, position, out) {
174
+ const samples = volume.samples;
175
+ if (samples === undefined || samples.length < PROBE_STRIDE)
176
+ return false;
177
+ const [nx, ny, nz] = volume.resolution;
178
+ const axis = (value, center, size, count) => {
179
+ if (count <= 1 || size === 0)
180
+ return { low: 0, high: 0, t: 0 };
181
+ const normalized = ((value - center) / size + 0.5) * (count - 1);
182
+ const clamped = Math.max(0, Math.min(count - 1, normalized));
183
+ const low = Math.floor(clamped);
184
+ return { low, high: Math.min(count - 1, low + 1), t: clamped - low };
185
+ };
186
+ const x = axis(position[0], volume.center[0], volume.size[0], nx);
187
+ const y = axis(position[1], volume.center[1], volume.size[1], ny);
188
+ const z = axis(position[2], volume.center[2], volume.size[2], nz);
189
+ out.fill(0);
190
+ const weights = [];
191
+ for (const [ix, wx] of [[x.low, 1 - x.t], [x.high, x.t]]) {
192
+ for (const [iy, wy] of [[y.low, 1 - y.t], [y.high, y.t]]) {
193
+ for (const [iz, wz] of [[z.low, 1 - z.t], [z.high, z.t]]) {
194
+ const weight = wx * wy * wz;
195
+ if (weight <= 0)
196
+ continue;
197
+ weights.push([ix, iy, iz]);
198
+ const offset = ((iz * ny + iy) * nx + ix) * PROBE_STRIDE;
199
+ for (let index = 0; index < PROBE_STRIDE; index += 1)
200
+ out[index] += (samples[offset + index] ?? 0) * weight;
201
+ }
202
+ }
203
+ }
204
+ return weights.length > 0;
205
+ }
206
+ /**
207
+ * Radiance along a direction from the SH, `Σ coeff·basis` clamped at zero. Used by tests and by
208
+ * tooling that previews a volume without a renderer.
209
+ * @internal
210
+ */
211
+ export function radianceAlong(sh, normal) {
212
+ const basis = new Float64Array(SH_COEFFICIENTS);
213
+ shDirection(normal[0], normal[1], normal[2], basis);
214
+ const rgb = [0, 0, 0];
215
+ for (let band = 0; band < SH_COEFFICIENTS; band += 1) {
216
+ rgb[0] += (sh[band * 3] ?? 0) * basis[band];
217
+ rgb[1] += (sh[band * 3 + 1] ?? 0) * basis[band];
218
+ rgb[2] += (sh[band * 3 + 2] ?? 0) * basis[band];
219
+ }
220
+ return [Math.max(0, rgb[0]), Math.max(0, rgb[1]), Math.max(0, rgb[2])];
221
+ }
@@ -4,7 +4,7 @@ export const LOOK_PRESETS = ["neutral", "photoreal", "cinematic", "toon", "comic
4
4
  export const CINEMATIC_SKY = { preset: "day" };
5
5
  /** Tuned tone-map + bloom + gentle SSAO + vignette/grade stack — the shipped-game post look. */
6
6
  export const CINEMATIC_POST_PROCESSING = {
7
- toneMapping: "aces",
7
+ toneMapping: "agx",
8
8
  bloom: {},
9
9
  ao: { intensity: 1.4 },
10
10
  grade: {},
@@ -17,7 +17,7 @@ export const NEUTRAL_POST_PROCESSING = {
17
17
  };
18
18
  /** High-contrast physically based presentation for authored HDR environments. */
19
19
  export const PHOTOREAL_POST_PROCESSING = {
20
- toneMapping: "aces",
20
+ toneMapping: "agx",
21
21
  bloom: { strength: 0.28, radius: 0.58, threshold: 0.9 },
22
22
  ao: { intensity: 1.25, radius: 1.6 },
23
23
  grade: { saturation: 1.04, vignette: 0.08, grain: 0.004 },
@@ -31,7 +31,7 @@ export const TOON_POST_PROCESSING = {
31
31
  };
32
32
  /** Ink outlines, four cel bands and a punchy grade — a hand-inked comic look on any model. */
33
33
  export const COMIC_POST_PROCESSING = {
34
- toneMapping: "aces",
34
+ toneMapping: "agx",
35
35
  exposure: 1.05,
36
36
  aa: "msaa",
37
37
  bloom: { strength: 0.12, radius: 0.4, threshold: 1 },
@@ -41,7 +41,7 @@ export const COMIC_POST_PROCESSING = {
41
41
  };
42
42
  /** Chunky pixels and posterized colour with no smoothing — a low-resolution retro look. */
43
43
  export const RETRO_POST_PROCESSING = {
44
- toneMapping: "aces",
44
+ toneMapping: "agx",
45
45
  aa: false,
46
46
  bloom: false,
47
47
  ao: false,
@@ -0,0 +1,39 @@
1
+ /** Peak moonlight as a fraction of the authored sunlight, at the moon's highest point.
2
+ * Real moonlight is ~2.5e-6 of daylight; every game exaggerates it to stay readable, and this is
3
+ * the exaggeration, not a measurement. Games override it through `SkyEnvironmentConfig.moon`. */
4
+ export declare const MOONLIGHT_PEAK = 0.12;
5
+ /** Moonlight's colour: scattered sunlight, blue-shifted because the eye's scotopic response peaks there. */
6
+ export declare const MOONLIGHT_COLOR = "#a9c3e8";
7
+ /** Fog color once the sun is gone: the night sky's own haze, lifted enough to keep silhouettes readable. */
8
+ export declare const NIGHT_FOG_COLOR = "#0a0e16";
9
+ /** Elevation of a world-space direction in degrees; a zero vector reads as overhead. */
10
+ export declare function elevationOf(direction: readonly [number, number, number]): number;
11
+ /**
12
+ * Relative air mass along a line of sight at `elevationDeg` - Kasten & Young (1989). 1 at the
13
+ * zenith, ~38 at the horizon, and clamped below the horizon so a set sun does not run away.
14
+ * @internal
15
+ */
16
+ export declare function airMass(elevationDeg: number): number;
17
+ /**
18
+ * Fraction of each RGB channel of sunlight that survives the atmosphere at `elevationDeg`:
19
+ * `exp(-τ · air mass)`. High sun keeps nearly all of it; a low sun loses the short wavelengths
20
+ * first, which is the red sunset.
21
+ * @internal
22
+ */
23
+ export declare function sunTransmission(elevationDeg: number): [number, number, number];
24
+ /**
25
+ * The sun's colour at `elevationDeg`, normalised so its brightest channel is full: white overhead,
26
+ * orange at the horizon, moon-blue once it has set. Intensity stays the authored cycle's job.
27
+ */
28
+ export declare function sunlightColor(elevationDeg: number): string;
29
+ /** Perceived brightness (Rec. 709 luma) of a `#rrggbb` color, 0–1.
30
+ * @internal
31
+ */
32
+ export declare function luminanceOf(color: string): number;
33
+ /**
34
+ * Moonlight at `sunElevationDeg`'s opposite point: full at the top of the moon's arc, zero while
35
+ * the moon is below the horizon, scaled by `peak` (a fraction of the authored sun intensity).
36
+ */
37
+ export declare function moonIllumination(moonElevationDeg: number, peak?: number): number;
38
+ /** How visible the stars are: none in daylight, half at the first hint of a set sun, full once it is 6° under. */
39
+ export declare function starOpacity(sunElevationDeg: number): number;
@@ -0,0 +1,89 @@
1
+ import { rgbToHex, hexToRgb } from "./color.js";
2
+ /**
3
+ * Analytic atmosphere for the day/night sky: Rayleigh air mass, sunlight reddening, moonlight and
4
+ * star visibility. The engine keeps a game's authored sky colors as its noon keyframe; these
5
+ * functions supply the parts a color table cannot — the sun's own tint as it drops toward the
6
+ * horizon, the moon that replaces it after sunset, and how much starlight shows.
7
+ *
8
+ * Pure and deterministic so the whole cycle is testable without a renderer.
9
+ */
10
+ /**
11
+ * Rayleigh scattering coefficient per RGB channel in m^-1 (680, 550, 440 nm, the standard
12
+ * β_R ∝ 1/λ⁴ curve) times an 8000 m scale height — the zenith optical depth `τ` of each channel.
13
+ */
14
+ const RAYLEIGH_DEPTH = [0.0464, 0.108, 0.2648];
15
+ /** Peak moonlight as a fraction of the authored sunlight, at the moon's highest point.
16
+ * Real moonlight is ~2.5e-6 of daylight; every game exaggerates it to stay readable, and this is
17
+ * the exaggeration, not a measurement. Games override it through `SkyEnvironmentConfig.moon`. */
18
+ export const MOONLIGHT_PEAK = 0.12;
19
+ /** Moonlight's colour: scattered sunlight, blue-shifted because the eye's scotopic response peaks there. */
20
+ export const MOONLIGHT_COLOR = "#a9c3e8";
21
+ /** Fog color once the sun is gone: the night sky's own haze, lifted enough to keep silhouettes readable. */
22
+ export const NIGHT_FOG_COLOR = "#0a0e16";
23
+ const DEG = Math.PI / 180;
24
+ function clamp01(value) {
25
+ return value < 0 ? 0 : value > 1 ? 1 : value;
26
+ }
27
+ function smoothstep(edge0, edge1, value) {
28
+ const t = clamp01((value - edge0) / (edge1 - edge0));
29
+ return t * t * (3 - 2 * t);
30
+ }
31
+ /** Elevation of a world-space direction in degrees; a zero vector reads as overhead. */
32
+ export function elevationOf(direction) {
33
+ const length = Math.hypot(direction[0], direction[1], direction[2]);
34
+ if (length === 0)
35
+ return 90;
36
+ return Math.asin(Math.max(-1, Math.min(1, direction[1] / length))) / DEG;
37
+ }
38
+ /**
39
+ * Relative air mass along a line of sight at `elevationDeg` - Kasten & Young (1989). 1 at the
40
+ * zenith, ~38 at the horizon, and clamped below the horizon so a set sun does not run away.
41
+ * @internal
42
+ */
43
+ export function airMass(elevationDeg) {
44
+ const zenithDeg = 90 - Math.max(elevationDeg, 0);
45
+ if (zenithDeg >= 90)
46
+ return 38;
47
+ return 1 / (Math.cos(zenithDeg * DEG) + 0.50572 * Math.pow(96.07995 - zenithDeg, -1.6364));
48
+ }
49
+ /**
50
+ * Fraction of each RGB channel of sunlight that survives the atmosphere at `elevationDeg`:
51
+ * `exp(-τ · air mass)`. High sun keeps nearly all of it; a low sun loses the short wavelengths
52
+ * first, which is the red sunset.
53
+ * @internal
54
+ */
55
+ export function sunTransmission(elevationDeg) {
56
+ const mass = airMass(elevationDeg);
57
+ return [0, 1, 2].map((channel) => Math.exp(-RAYLEIGH_DEPTH[channel] * mass));
58
+ }
59
+ /**
60
+ * The sun's colour at `elevationDeg`, normalised so its brightest channel is full: white overhead,
61
+ * orange at the horizon, moon-blue once it has set. Intensity stays the authored cycle's job.
62
+ */
63
+ export function sunlightColor(elevationDeg) {
64
+ if (elevationDeg <= -6)
65
+ return MOONLIGHT_COLOR;
66
+ const transmission = sunTransmission(Math.max(elevationDeg, 0));
67
+ const brightest = Math.max(transmission[0], transmission[1], transmission[2], 1e-6);
68
+ return rgbToHex((transmission[0] / brightest) * 255, (transmission[1] / brightest) * 255, (transmission[2] / brightest) * 255);
69
+ }
70
+ /** Perceived brightness (Rec. 709 luma) of a `#rrggbb` color, 0–1.
71
+ * @internal
72
+ */
73
+ export function luminanceOf(color) {
74
+ const [r, g, b] = hexToRgb(color);
75
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
76
+ }
77
+ /**
78
+ * Moonlight at `sunElevationDeg`'s opposite point: full at the top of the moon's arc, zero while
79
+ * the moon is below the horizon, scaled by `peak` (a fraction of the authored sun intensity).
80
+ */
81
+ export function moonIllumination(moonElevationDeg, peak = MOONLIGHT_PEAK) {
82
+ if (moonElevationDeg <= 0)
83
+ return 0;
84
+ return Math.sin(Math.min(moonElevationDeg, 90) * DEG) * Math.max(0, peak);
85
+ }
86
+ /** How visible the stars are: none in daylight, half at the first hint of a set sun, full once it is 6° under. */
87
+ export function starOpacity(sunElevationDeg) {
88
+ return 1 - smoothstep(-6, 0, sunElevationDeg);
89
+ }
@@ -1,5 +1,27 @@
1
- /** Renderer tone-mapping curve applied by the post chain's output stage. */
1
+ import type { AutoExposureConfig } from "./colorPipeline.js";
2
+ /**
3
+ * Renderer tone-mapping curve applied by the post chain's output stage. `"agx"` is the pipeline
4
+ * default: it keeps hue in saturated highlights and rolls a sun-lit surface off gradually, where
5
+ * `"aces"` skews bright colours toward white.
6
+ */
2
7
  export type ToneMappingMode = "aces" | "agx" | "reinhard" | "cineon" | "linear" | "none";
8
+ /**
9
+ * A baked display-space LUT cube: `size³` RGB entries with red varying fastest, three bytes per entry
10
+ * in 0..255. Kept as plain data so a scene document can author one and a test can assert on it.
11
+ */
12
+ export interface LutCube {
13
+ /** Edge length of the cube, 2..16. */
14
+ size: number;
15
+ /** `size³ × 3` bytes, red fastest, then green, then blue. */
16
+ data: Uint8Array;
17
+ }
18
+ /** A grade's LUT: a strip PNG (one `size`-wide tile per blue slice) or a baked cube. */
19
+ export type GradeLut = {
20
+ url: string;
21
+ size?: number;
22
+ } | {
23
+ cube: LutCube;
24
+ };
3
25
  /** UnrealBloom stage — soft HDR glow around bright pixels (sun, glints, emissive). */
4
26
  export interface BloomConfig {
5
27
  /** Glow intensity. Default 0.32. */
@@ -26,11 +48,12 @@ export interface AoConfig {
26
48
  }
27
49
  /** Final colour-grade stage: lift/gain/gamma, saturation, vignette, film grain — applied in display space after tone mapping. */
28
50
  export interface GradeConfig {
29
- /** Optional 2D strip LUT URL. The strip contains size square tiles, one tile per blue slice. */
30
- lut?: {
31
- url: string;
32
- size?: number;
33
- };
51
+ /**
52
+ * Colour LUT applied after the numeric grade, in display space. Either a strip PNG (one
53
+ * `size`-wide tile per blue slice, trilinearly sampled) or a baked {@link LutCube} such as the one
54
+ * a scene document's authored grading produces.
55
+ */
56
+ lut?: GradeLut;
34
57
  /** Additive shadow tint (RGB, ~0..0.1). Default cool [0.012, 0.010, 0.018]. */
35
58
  lift?: readonly [number, number, number];
36
59
  /** Multiplicative highlight tint (RGB, ~1). Default warm [1.05, 1.02, 0.98]. */
@@ -99,10 +122,19 @@ export type PostAaMode = "smaa" | "msaa" | false;
99
122
  export interface PostProcessingConfig {
100
123
  /** Master switch; a config object with `enabled: false` mounts nothing. Default true. */
101
124
  enabled?: boolean;
102
- /** Tone-mapping curve the output stage applies. Default "aces". */
125
+ /** Tone-mapping curve the output stage applies. Default "agx". */
103
126
  toneMapping?: ToneMappingMode;
104
- /** Exposure multiplier applied before tone mapping. Default 1. */
127
+ /**
128
+ * Exposure multiplier applied before tone mapping. Default 1. With `autoExposure` on this is the
129
+ * baseline the meter corrects around, not a fixed value.
130
+ */
105
131
  exposure?: number;
132
+ /**
133
+ * Clamped auto-exposure: meters the rendered frame and adapts exposure toward
134
+ * {@link import("./colorPipeline.js").MID_GREY}, bounded by `min`/`max`. Omitted uses
135
+ * {@link import("./colorPipeline.js").DEFAULT_AUTO_EXPOSURE} on every tier; `false` pins `exposure`.
136
+ */
137
+ autoExposure?: AutoExposureConfig | false;
106
138
  /**
107
139
  * Antialiasing. Default `"smaa"` (plus a light MSAA resolve on the beauty target).
108
140
  * Set `"msaa"` to skip the SMAA pass, or `false` for neither.
@@ -7,7 +7,7 @@ export const DEFAULT_AO = { radius: 1.3, intensity: 1.3, distanceFalloff: 2.8, b
7
7
  * is left off by default (it needs a per-scene focus distance); set `dof` to enable it.
8
8
  */
9
9
  export const STUDIO_STAGE_POST = {
10
- toneMapping: "aces",
10
+ toneMapping: "agx",
11
11
  exposure: 1.05,
12
12
  bloom: { strength: 0.28, radius: 0.6, threshold: 0.8 },
13
13
  ao: { radius: 1.1, intensity: 1.5, distanceFalloff: 3, blend: 1 },
@@ -0,0 +1,31 @@
1
+ import type { LightingConfig } from "../game/playableGame.js";
2
+ import type { SkyEnvironmentConfig } from "../world/features.js";
3
+ /**
4
+ * Which half of a game's authored `lighting` a time-of-day sky takes over.
5
+ *
6
+ * A time-of-day sky has to move the sun or the whole world stays lit like noon after dark: the
7
+ * dome turns black while the ground keeps its midday key light. So under a day cycle the first
8
+ * directional light is the **sun**, and `ambient`/`hemisphere` are the **noon keyframes** of the
9
+ * ambient fill — the same convention the sky already uses for `horizonColor`/`zenithColor`. Every
10
+ * other light (fill directionals, point and spot lamps) stays exactly as authored.
11
+ */
12
+ export interface SkyOwnedLighting {
13
+ /** Sun + ambient terms the sky now drives from the day cycle; intensities read as noon values. */
14
+ sky: LightingConfig;
15
+ /** Lights the sky does not own; mounted as authored. */
16
+ rest: LightingConfig;
17
+ }
18
+ /**
19
+ * Splits authored lighting into the sun/ambient terms a time-of-day sky drives and the rest.
20
+ * Returns an empty `sky` when the game authored no lighting, so callers can keep engine defaults.
21
+ * @capability day-night-sky hand the sun and ambient fill to the day cycle while authored lamps keep running
22
+ */
23
+ export declare function splitSkyOwnedLighting(lighting: LightingConfig | undefined): SkyOwnedLighting;
24
+ /**
25
+ * The authored `lighting` as the day cycle's **noon keyframes**, filling only the sky fields the
26
+ * game left unset. A game's midday sun, fill and fog therefore become the peak the cycle scales
27
+ * and cross-fades from, so an authored world gets a sunset and a night without re-authoring the
28
+ * same numbers in the sky. Authored sky fields always win over the lighting config.
29
+ * @capability day-night-sky read authored lighting as the day cycle's noon keyframe
30
+ */
31
+ export declare function skyNoonKeyframes<T extends SkyEnvironmentConfig>(sky: T, lighting: LightingConfig | undefined, fogColor?: string): T;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Splits authored lighting into the sun/ambient terms a time-of-day sky drives and the rest.
3
+ * Returns an empty `sky` when the game authored no lighting, so callers can keep engine defaults.
4
+ * @capability day-night-sky hand the sun and ambient fill to the day cycle while authored lamps keep running
5
+ */
6
+ export function splitSkyOwnedLighting(lighting) {
7
+ if (lighting === undefined)
8
+ return { sky: {}, rest: {} };
9
+ const directional = lighting.directional ?? [];
10
+ const [sun, ...fill] = directional;
11
+ const sky = {
12
+ ...(sun === undefined ? {} : { directional: [sun] }),
13
+ ...(lighting.ambient === undefined ? {} : { ambient: lighting.ambient }),
14
+ ...(lighting.hemisphere === undefined ? {} : { hemisphere: lighting.hemisphere }),
15
+ };
16
+ const rest = {
17
+ ...(fill.length === 0 ? {} : { directional: fill }),
18
+ ...(lighting.point === undefined ? {} : { point: lighting.point }),
19
+ ...(lighting.spot === undefined ? {} : { spot: lighting.spot }),
20
+ ...(lighting.maxDynamicLights === undefined ? {} : { maxDynamicLights: lighting.maxDynamicLights }),
21
+ ...(lighting.iblAmbientScale === undefined ? {} : { iblAmbientScale: lighting.iblAmbientScale }),
22
+ ...(lighting.fallbackKey === undefined ? {} : { fallbackKey: lighting.fallbackKey }),
23
+ };
24
+ return { sky, rest };
25
+ }
26
+ /**
27
+ * The authored `lighting` as the day cycle's **noon keyframes**, filling only the sky fields the
28
+ * game left unset. A game's midday sun, fill and fog therefore become the peak the cycle scales
29
+ * and cross-fades from, so an authored world gets a sunset and a night without re-authoring the
30
+ * same numbers in the sky. Authored sky fields always win over the lighting config.
31
+ * @capability day-night-sky read authored lighting as the day cycle's noon keyframe
32
+ */
33
+ export function skyNoonKeyframes(sky, lighting, fogColor) {
34
+ const sun = lighting?.directional?.[0];
35
+ const fill = lighting?.hemisphere?.intensity ?? lighting?.ambient?.intensity;
36
+ const noon = { ...sky };
37
+ if (noon.sunIntensity === undefined && sun?.intensity !== undefined)
38
+ noon.sunIntensity = sun.intensity;
39
+ if (noon.ambientIntensity === undefined && fill !== undefined)
40
+ noon.ambientIntensity = fill;
41
+ if (noon.sunColor === undefined && sun?.color !== undefined)
42
+ noon.sunColor = sun.color;
43
+ if (noon.fogColor === undefined && fogColor !== undefined)
44
+ noon.fogColor = fogColor;
45
+ return noon;
46
+ }
@@ -25,11 +25,17 @@ export interface PerceptionCandidate {
25
25
  /** A remembered target and its linearly decaying confidence. */
26
26
  export interface PerceptionMemory {
27
27
  targetId: string;
28
+ /** When the fact was observed, on the same clock `observe` is given; its age is `now − lastSeenAt`. */
28
29
  lastSeenAt: number;
29
30
  lastKnownPos: PerceptionPosition;
31
+ /** 1 at the moment of the sighting, falling to 0 across `memorySeconds`. */
30
32
  confidence: number;
33
+ /** Whose senses produced the fact: this observer's id for its own sighting, a squadmate's for a shared one. */
34
+ sourceId: string;
35
+ /** Observe pass that recorded the fact, so a shared sighting is attributable to the tick it arrived. */
36
+ sourceTick: number;
31
37
  }
32
- /** Configuration for sight, hearing, memory decay, and optional occlusion. */
38
+ /** Configuration for sight, hearing, memory decay, squad sharing and optional occlusion. */
33
39
  export interface PerceptionConfig {
34
40
  sightRange: number;
35
41
  sightConeDeg: number;
@@ -37,7 +43,43 @@ export interface PerceptionConfig {
37
43
  memorySeconds: number;
38
44
  /** Most stimuli retained at once; the oldest are dropped first. Default 256. */
39
45
  maxStimuli?: number;
40
- occluded?: (from: PerceptionPosition, to: PerceptionPosition) => boolean;
46
+ /**
47
+ * Cheap "nothing blocks the segment from `from` to `to`" test, supplied by the game's collision
48
+ * layer. Only called for pairs already inside range and cone, so the costly test sees the pruned
49
+ * set. Omit for open ground.
50
+ */
51
+ isSegmentClear?: (from: PerceptionPosition, to: PerceptionPosition) => boolean;
52
+ /**
53
+ * The other members of an observer's squad, by id. A sighting one member makes is written into
54
+ * each mate's memory with the seeing member's id and the pass it happened on. Omit for lone
55
+ * observers. Keep the returned set small — the roster is the caller's, so the caller bounds it.
56
+ */
57
+ squadMates?: (observerId: string) => Iterable<string>;
58
+ /** Most sighting shares one pass may write, so a large squad cannot blow the tick budget. Default 64. */
59
+ maxSharesPerTick?: number;
60
+ /** Register a devtools probe under this name so the overlay shows perception work and memory. */
61
+ devtoolsName?: string;
62
+ }
63
+ /** Counters a perf test or a devtools probe reads. Reset with {@link PerceptionService.resetStats}. */
64
+ export interface PerceptionStats {
65
+ /** Observe passes run. Keeps counting across `resetStats`, since it is also the recorded tick. */
66
+ passes: number;
67
+ /** `(observer, candidate)` pairs examined — one check per pair per pass. */
68
+ pairChecks: number;
69
+ /** Pairs rejected by distance before any cone or occlusion work. */
70
+ rangeCulled: number;
71
+ /** Pairs inside sight range, so they paid the cone test. */
72
+ coneTests: number;
73
+ /** Calls into the injected `isSegmentClear`. */
74
+ occlusionChecks: number;
75
+ /** Pairs that produced the observer's own sighting. */
76
+ sightings: number;
77
+ /** Sound and damage stimuli an observer resolved inside its hearing range. */
78
+ heard: number;
79
+ /** Sightings written into a squadmate's memory. */
80
+ shares: number;
81
+ /** Sightings the per-pass share cap kept from being broadcast. */
82
+ shareDropped: number;
41
83
  }
42
84
  /** Serializable state for a perception service. */
43
85
  export interface PerceptionSnapshot {
@@ -52,19 +94,38 @@ export interface PerceptionSnapshot {
52
94
  /** Per observer, the last stimulus sequence number it has already judged. Absent in older saves. */
53
95
  heard?: Record<string, number>;
54
96
  nextSeq?: number;
97
+ /** Observe passes recorded, which is also the tick a memory carries. Absent in older saves. */
98
+ passes?: number;
55
99
  }
56
100
  /** Stateful perception API with retuning and save/restore support. */
57
101
  export interface PerceptionService {
58
102
  pushStimulus(stimulus: PerceptionStimulus): void;
59
103
  observe(observer: PerceptionObserver, candidates: readonly PerceptionCandidate[], nowMs: number): void;
60
104
  memory(observerId: string): PerceptionMemory[];
105
+ /** One remembered target with its confidence now, or `null` when never seen or already faded. */
106
+ memoryOf(observerId: string, targetId: string): PerceptionMemory | null;
61
107
  /** Drop one remembered target, or everything an observer remembers (despawn, reset, disguise). */
62
108
  forget(observerId: string, targetId?: string): void;
63
- retune(patch: Partial<PerceptionConfig>): void;
109
+ stats(): PerceptionStats;
110
+ /** Zero the work counters, keeping the pass count that memories are stamped with. */
111
+ resetStats(): void;
112
+ retune(patch: Partial<Omit<PerceptionConfig, "devtoolsName">>): void;
64
113
  snapshot(): PerceptionSnapshot;
65
114
  restore(snapshot: PerceptionSnapshot): void;
115
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
116
+ dispose(): void;
66
117
  }
67
- /** Creates a deterministic sight, sound, and damage perception service with expiring target memory.
68
- * @capability perception-service retain sight, sound, and damage observations with range, cone, occlusion, and decay policy
118
+ /**
119
+ * Creates a deterministic sight, sound, and damage perception service with expiring target memory.
120
+ * Signals are judged in the order the caller supplies them, never from the wall clock.
121
+ *
122
+ * A sighting needs range, cone and a clear line; a sound carries `hearingRange × loudness` and
123
+ * fades with the distance it crossed, so a distant footstep is inaudible while a near gunshot is
124
+ * not. Memory is the only record of "seen recently": each fact fades linearly over `memorySeconds`
125
+ * and is then gone. With `squadMates` injected, a sighting one member makes lands in every mate's
126
+ * memory stamped with the seeing member and the pass, which is what lets a squad react to one pair
127
+ * of eyes without each member re-seeing anything.
128
+ *
129
+ * @capability perception-service retain sightings, sounds and damage with cone, range, occlusion, decaying memory and shared squad knowledge
69
130
  */
70
131
  export declare function createPerception(initial: PerceptionConfig): PerceptionService;