@jgengine/shell 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/Shell3dPresentation.js +1 -1
  3. package/dist/environment/EnvironmentScene.js +1 -1
  4. package/dist/presentationResolve.d.ts +2 -0
  5. package/dist/presentationResolve.js +1 -0
  6. package/dist/render/modelLoad.d.ts +24 -0
  7. package/dist/render/modelLoad.js +46 -0
  8. package/dist/scene/CityRenderer.js +28 -54
  9. package/dist/structures/BuildingKitBatch.d.ts +13 -0
  10. package/dist/structures/BuildingKitBatch.js +78 -0
  11. package/dist/structures/GeneratedBuilding.d.ts +19 -3
  12. package/dist/structures/GeneratedBuilding.js +13 -81
  13. package/dist/structures/buildingKitFit.d.ts +59 -0
  14. package/dist/structures/buildingKitFit.js +188 -0
  15. package/dist/structures/buildingKitRegistry.d.ts +9 -0
  16. package/dist/structures/buildingKitRegistry.js +26 -0
  17. package/dist/weather/DustField.d.ts +41 -0
  18. package/dist/weather/DustField.js +129 -0
  19. package/dist/weather/WeatherLayer.d.ts +8 -2
  20. package/dist/weather/WeatherLayer.js +5 -2
  21. package/dist/weather/index.d.ts +1 -0
  22. package/dist/weather/index.js +1 -0
  23. package/dist/weather/weatherMath.d.ts +4 -0
  24. package/dist/weather/weatherMath.js +4 -0
  25. package/dist/world/WorldHud.d.ts +6 -2
  26. package/dist/world/WorldHud.js +4 -4
  27. package/dist/world/worldBarSamples.d.ts +15 -3
  28. package/dist/world/worldBarSamples.js +34 -3
  29. package/package.json +4 -4
  30. package/skills/jgengine/api.md +15 -3
  31. package/skills/jgengine/capabilities.md +12 -0
  32. package/skills/jgengine-ui/api.md +33 -12
  33. package/skills/jgengine-ui/capabilities.md +12 -0
  34. package/skills/jgengine-world/SKILL.md +5 -0
  35. package/skills/jgengine-world/api.md +21 -2
  36. package/skills/jgengine-world/capabilities.md +16 -0
  37. package/skills/jgengine-world/recipes/building-kit.md +78 -0
@@ -0,0 +1,59 @@
1
+ import * as THREE from "three";
2
+ import type { BuildingKit, BuildingKitFit, BuildingKitPart } from "@jgengine/core/world/buildingKit";
3
+ import type { BuildingFacade, BuildingPartKind, BuildingPartPlacement, InstancedBuildingPlacement } from "./GeneratedBuilding.js";
4
+ /**
5
+ * One kit-bound part resolved to the world slot the fallback box would have filled, carrying the
6
+ * binding that replaces it. `slotScale` is the slot box in world units; `yaw` already includes the
7
+ * whole-building rotation.
8
+ * @internal
9
+ */
10
+ export interface BuildingKitInstance {
11
+ position: readonly [number, number, number];
12
+ yaw: number;
13
+ slotScale: readonly [number, number, number];
14
+ part: BuildingKitPart;
15
+ fit: BuildingKitFit;
16
+ }
17
+ /** A kit model's native extents, measured once per URL from the harvested root. @internal */
18
+ export interface BuildingKitModelBounds {
19
+ size: readonly [number, number, number];
20
+ center: readonly [number, number, number];
21
+ }
22
+ /** Box-path matrices per part kind plus kit-bound instances per resolved model URL. @internal */
23
+ export interface BuildingPartBuckets {
24
+ boxes: Map<BuildingPartKind, THREE.Matrix4[]>;
25
+ models: Map<string, BuildingKitInstance[]>;
26
+ }
27
+ /** Outward XZ normal of a facade; `roof` has none. @internal */
28
+ export declare function normalFor(facade: BuildingFacade): readonly [number, number];
29
+ /** How far a part sits proud of its facade, so decor never z-fights the wall behind it. @internal */
30
+ export declare function outwardOffset(part: BuildingPartPlacement): number;
31
+ /**
32
+ * Splits every placed part into the two render paths: parts a kit binds to a model land in a bucket
33
+ * keyed by their resolved URL, everything else keeps today's per-kind box matrices. With no kit every
34
+ * part resolves to `box`, so the box path is untouched.
35
+ * @internal
36
+ */
37
+ export declare function bucketBuildingParts(buildings: readonly InstancedBuildingPlacement[], visible: ReadonlySet<BuildingPartKind> | null, kit?: BuildingKit, resolveModelUrl?: (model: string) => string): BuildingPartBuckets;
38
+ /**
39
+ * Whether a model needs a quarter-turn about Y to run along its slot. Modular kits disagree on which
40
+ * horizontal axis a panel is authored along — within one pack a wall may run along Z while a door
41
+ * runs along X — so compare which horizontal axis is longer on each and turn when they disagree.
42
+ * @internal
43
+ */
44
+ export declare function buildingKitQuarterTurn(fit: BuildingKitFit, slotScale: readonly [number, number, number], nativeSize: readonly [number, number, number], autoOrient?: boolean): boolean;
45
+ /**
46
+ * Maps a model's native extents onto the slot box per {@link BuildingKitFit}. Degenerate or
47
+ * non-finite native extents fall back to a ratio of 1 on that axis rather than blowing the model up.
48
+ * @internal
49
+ */
50
+ export declare function buildingKitFitScale(fit: BuildingKitFit, slotScale: readonly [number, number, number], nativeSize: readonly [number, number, number]): [number, number, number];
51
+ /** Measures the harvested model root once so every instance of that URL shares one fit basis. @internal */
52
+ export declare function measureBuildingKitModel(root: THREE.Object3D): BuildingKitModelBounds;
53
+ /**
54
+ * Composes one kit instance's world matrix: fit scale times the binding's `scale`, the binding's
55
+ * Euler XYZ rotation on top of the slot's facade yaw, the binding's slot-local `offset` rotated into
56
+ * world space by that total yaw, and a recentre so the model's bounds center lands on the slot center.
57
+ * @internal
58
+ */
59
+ export declare function composeBuildingKitMatrix(instance: BuildingKitInstance, bounds: BuildingKitModelBounds, target?: THREE.Matrix4): THREE.Matrix4;
@@ -0,0 +1,188 @@
1
+ import * as THREE from "three";
2
+ import { resolveBuildingKitPart } from "@jgengine/core/world/buildingKit";
3
+ const CLOTHESLINE_ROW_OFFSETS = [-0.09, 0.09];
4
+ /** Outward XZ normal of a facade; `roof` has none. @internal */
5
+ export function normalFor(facade) {
6
+ if (facade === "front")
7
+ return [0, 1];
8
+ if (facade === "back")
9
+ return [0, -1];
10
+ if (facade === "left")
11
+ return [-1, 0];
12
+ if (facade === "right")
13
+ return [1, 0];
14
+ return [0, 0];
15
+ }
16
+ /** How far a part sits proud of its facade, so decor never z-fights the wall behind it. @internal */
17
+ export function outwardOffset(part) {
18
+ if (part.kind === "wall" || part.kind === "roof" || part.kind === "corner")
19
+ return 0;
20
+ if (part.kind === "window" || part.kind === "shutter" || part.kind === "storefront")
21
+ return 0.025;
22
+ if (part.kind === "awning" || part.kind === "storeSign")
23
+ return 0.09;
24
+ return 0.12;
25
+ }
26
+ /**
27
+ * Splits every placed part into the two render paths: parts a kit binds to a model land in a bucket
28
+ * keyed by their resolved URL, everything else keeps today's per-kind box matrices. With no kit every
29
+ * part resolves to `box`, so the box path is untouched.
30
+ * @internal
31
+ */
32
+ export function bucketBuildingParts(buildings, visible, kit, resolveModelUrl) {
33
+ const dummy = new THREE.Object3D();
34
+ const boxes = new Map();
35
+ const models = new Map();
36
+ for (const placement of buildings) {
37
+ const [ox, oy, oz] = placement.position ?? [0, 0, 0];
38
+ // Building yaw turns the whole massing about its center. Engine Y-rotation convention (matches
39
+ // dummy.rotation.y below): a local vector (x, z) maps to (x·cos + z·sin, −x·sin + z·cos).
40
+ const yaw = placement.rotationY ?? 0;
41
+ const [px0, pz0] = placement.pivot ?? [0, 0];
42
+ const cos = Math.cos(yaw);
43
+ const sin = Math.sin(yaw);
44
+ for (const part of placement.building.parts) {
45
+ if (visible !== null && !visible.has(part.kind))
46
+ continue;
47
+ const binding = resolveBuildingKitPart(kit, part.kind, part.kit);
48
+ if (binding.type === "omit")
49
+ continue;
50
+ const [nx, nz] = normalFor(part.facade);
51
+ const offset = outwardOffset(part);
52
+ // Local offset from the pivot (part position + facade-normal nudge), then rotated by the yaw.
53
+ const lx = part.position[0] - px0 + nx * offset;
54
+ const lz = part.position[2] - pz0 + nz * offset;
55
+ const px = ox + px0 + lx * cos + lz * sin;
56
+ const py = oy + part.position[1];
57
+ const pz = oz + pz0 - lx * sin + lz * cos;
58
+ const partYaw = part.rotationY + yaw;
59
+ if (binding.type === "model") {
60
+ const url = resolveModelUrl?.(binding.part.model) ?? binding.part.model;
61
+ const instance = {
62
+ position: [px, py, pz],
63
+ yaw: partYaw,
64
+ slotScale: part.scale,
65
+ part: binding.part,
66
+ fit: binding.fit,
67
+ };
68
+ const bucket = models.get(url);
69
+ if (bucket === undefined)
70
+ models.set(url, [instance]);
71
+ else
72
+ bucket.push(instance);
73
+ continue;
74
+ }
75
+ let bucket = boxes.get(part.kind);
76
+ if (bucket === undefined) {
77
+ bucket = [];
78
+ boxes.set(part.kind, bucket);
79
+ }
80
+ if (part.kind === "clothesline") {
81
+ const rowSin = Math.sin(partYaw);
82
+ const rowCos = Math.cos(partYaw);
83
+ for (const row of CLOTHESLINE_ROW_OFFSETS) {
84
+ dummy.position.set(px + row * rowSin, py, pz + row * rowCos);
85
+ dummy.rotation.set(0, partYaw, 0);
86
+ dummy.scale.set(part.scale[0], Math.max(part.scale[1], 0.025), 0.025);
87
+ dummy.updateMatrix();
88
+ bucket.push(dummy.matrix.clone());
89
+ }
90
+ continue;
91
+ }
92
+ dummy.position.set(px, py, pz);
93
+ dummy.rotation.set(0, partYaw, 0);
94
+ dummy.scale.set(part.scale[0], part.scale[1], part.scale[2]);
95
+ dummy.updateMatrix();
96
+ bucket.push(dummy.matrix.clone());
97
+ }
98
+ }
99
+ return { boxes, models };
100
+ }
101
+ function ratio(slot, native) {
102
+ if (!Number.isFinite(slot) || !Number.isFinite(native) || Math.abs(native) < 1e-6)
103
+ return 1;
104
+ return slot / native;
105
+ }
106
+ /**
107
+ * Whether a model needs a quarter-turn about Y to run along its slot. Modular kits disagree on which
108
+ * horizontal axis a panel is authored along — within one pack a wall may run along Z while a door
109
+ * runs along X — so compare which horizontal axis is longer on each and turn when they disagree.
110
+ * @internal
111
+ */
112
+ export function buildingKitQuarterTurn(fit, slotScale, nativeSize, autoOrient = true) {
113
+ if (!autoOrient || fit === "native")
114
+ return false;
115
+ const [sx, , sz] = slotScale;
116
+ const [nx, , nz] = nativeSize;
117
+ if (![sx, sz, nx, nz].every((value) => Number.isFinite(value)))
118
+ return false;
119
+ if (Math.abs(nx - nz) < 1e-6 || Math.abs(sx - sz) < 1e-6)
120
+ return false;
121
+ return sx >= sz !== nx >= nz;
122
+ }
123
+ /**
124
+ * Maps a model's native extents onto the slot box per {@link BuildingKitFit}. Degenerate or
125
+ * non-finite native extents fall back to a ratio of 1 on that axis rather than blowing the model up.
126
+ * @internal
127
+ */
128
+ export function buildingKitFitScale(fit, slotScale, nativeSize) {
129
+ if (fit === "native")
130
+ return [1, 1, 1];
131
+ const rx = ratio(slotScale[0], nativeSize[0]);
132
+ const ry = ratio(slotScale[1], nativeSize[1]);
133
+ const rz = ratio(slotScale[2], nativeSize[2]);
134
+ if (fit === "stretch")
135
+ return [rx, ry, rz];
136
+ const uniform = fit === "cover" ? Math.max(rx, ry, rz) : Math.min(rx, ry, rz);
137
+ return [uniform, uniform, uniform];
138
+ }
139
+ /** Measures the harvested model root once so every instance of that URL shares one fit basis. @internal */
140
+ export function measureBuildingKitModel(root) {
141
+ const box = new THREE.Box3().setFromObject(root);
142
+ if (box.isEmpty())
143
+ return { size: [0, 0, 0], center: [0, 0, 0] };
144
+ return {
145
+ size: [box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z],
146
+ center: [(box.min.x + box.max.x) / 2, (box.min.y + box.max.y) / 2, (box.min.z + box.max.z) / 2],
147
+ };
148
+ }
149
+ const UP = new THREE.Vector3(0, 1, 0);
150
+ const scratchScale = new THREE.Vector3();
151
+ const scratchPosition = new THREE.Vector3();
152
+ const scratchCenter = new THREE.Vector3();
153
+ const scratchQuat = new THREE.Quaternion();
154
+ const scratchYaw = new THREE.Quaternion();
155
+ const scratchEuler = new THREE.Euler();
156
+ /**
157
+ * Composes one kit instance's world matrix: fit scale times the binding's `scale`, the binding's
158
+ * Euler XYZ rotation on top of the slot's facade yaw, the binding's slot-local `offset` rotated into
159
+ * world space by that total yaw, and a recentre so the model's bounds center lands on the slot center.
160
+ * @internal
161
+ */
162
+ export function composeBuildingKitMatrix(instance, bounds, target = new THREE.Matrix4()) {
163
+ const turn = buildingKitQuarterTurn(instance.fit, instance.slotScale, bounds.size, instance.part.autoOrient);
164
+ // The turn swaps which native axis faces the slot's width, so fit against the swapped extents.
165
+ const native = turn
166
+ ? [bounds.size[2], bounds.size[1], bounds.size[0]]
167
+ : bounds.size;
168
+ const fit = buildingKitFitScale(instance.fit, instance.slotScale, native);
169
+ const mul = instance.part.scale ?? [1, 1, 1];
170
+ const fitted = turn
171
+ ? [fit[2] * mul[0], fit[1] * mul[1], fit[0] * mul[2]]
172
+ : [fit[0] * mul[0], fit[1] * mul[1], fit[2] * mul[2]];
173
+ scratchScale.set(fitted[0], fitted[1], fitted[2]);
174
+ const rotation = instance.part.rotation;
175
+ const turnYaw = turn ? Math.PI / 2 : 0;
176
+ scratchEuler.set(rotation?.[0] ?? 0, (rotation?.[1] ?? 0) + turnYaw, rotation?.[2] ?? 0, "XYZ");
177
+ scratchQuat.setFromEuler(scratchEuler);
178
+ scratchQuat.premultiply(scratchYaw.setFromAxisAngle(UP, instance.yaw));
179
+ const offset = instance.part.offset ?? [0, 0, 0];
180
+ const totalYaw = instance.yaw + (rotation?.[1] ?? 0) + turnYaw;
181
+ const cos = Math.cos(totalYaw);
182
+ const sin = Math.sin(totalYaw);
183
+ scratchCenter
184
+ .set(bounds.center[0] * scratchScale.x, bounds.center[1] * scratchScale.y, bounds.center[2] * scratchScale.z)
185
+ .applyQuaternion(scratchQuat);
186
+ scratchPosition.set(instance.position[0] + offset[0] * cos + offset[2] * sin - scratchCenter.x, instance.position[1] + offset[1] - scratchCenter.y, instance.position[2] - offset[0] * sin + offset[2] * cos - scratchCenter.z);
187
+ return target.compose(scratchPosition, scratchQuat, scratchScale);
188
+ }
@@ -0,0 +1,9 @@
1
+ import type { BuildingKit } from "@jgengine/core/world/buildingKit";
2
+ /** Registers a kit under its own `id`, replacing any kit already registered under that name. */
3
+ export declare function registerBuildingKit(kit: BuildingKit): void;
4
+ /** The kit registered under `id`, or `undefined` for an unset or unknown name (renders blocks). */
5
+ export declare function getBuildingKit(id: string | undefined): BuildingKit | undefined;
6
+ /** Every registered kit id — the editor's kit picker and diagnostics read this. */
7
+ export declare function listBuildingKits(): string[];
8
+ /** Drops every registration. For tests and hot-reload teardown. */
9
+ export declare function clearBuildingKits(): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Kits a game has bound, keyed by `BuildingKit.id`.
3
+ *
4
+ * Scene documents name a kit rather than embedding it, so an authored city stays portable and free
5
+ * of model paths while the game keeps ownership of its art. Registration is the game's job — the
6
+ * engine ships no kits.
7
+ */
8
+ const kits = new Map();
9
+ /** Registers a kit under its own `id`, replacing any kit already registered under that name. */
10
+ export function registerBuildingKit(kit) {
11
+ kits.set(kit.id, kit);
12
+ }
13
+ /** The kit registered under `id`, or `undefined` for an unset or unknown name (renders blocks). */
14
+ export function getBuildingKit(id) {
15
+ if (id === undefined || id === "")
16
+ return undefined;
17
+ return kits.get(id);
18
+ }
19
+ /** Every registered kit id — the editor's kit picker and diagnostics read this. */
20
+ export function listBuildingKits() {
21
+ return [...kits.keys()].sort();
22
+ }
23
+ /** Drops every registration. For tests and hot-reload teardown. */
24
+ export function clearBuildingKits() {
25
+ kits.clear();
26
+ }
@@ -0,0 +1,41 @@
1
+ import * as THREE from "three";
2
+ import { type WeatherVector } from "./weatherUniforms.js";
3
+ /**
4
+ * Wind-borne particulate: dust, sand, ash, pollen, spores. Unlike rain and snow this is not falling
5
+ * — motion is dominated by horizontal wind with a slow vertical bob, and density thins with height
6
+ * so the air near the ground carries the most grit. Any arid, volcanic, or blown-out world needs it.
7
+ */
8
+ export interface DustFieldProps {
9
+ count?: number;
10
+ density?: number;
11
+ budget?: number;
12
+ volume?: WeatherVector;
13
+ wind?: WeatherVector;
14
+ origin?: WeatherVector;
15
+ followCamera?: boolean;
16
+ /** Horizontal drift added to `wind`, in world units per second. */
17
+ speed?: number;
18
+ size?: number;
19
+ /** Amplitude of the slow vertical bob that keeps motes from travelling on rails. */
20
+ sway?: number;
21
+ opacity?: number;
22
+ color?: THREE.ColorRepresentation;
23
+ /**
24
+ * How fast density falls off with height across the volume, 0..1. At 0 the volume is filled
25
+ * evenly; at 1 nearly everything hugs the ground. Default 0.65.
26
+ */
27
+ groundBias?: number;
28
+ timeScale?: number;
29
+ seed?: number;
30
+ renderOrder?: number;
31
+ frustumCulled?: boolean;
32
+ }
33
+ /**
34
+ * Renders wind-borne particulate as camera-facing motes. Reach for it whenever the air itself
35
+ * should read as weather — a desert gale, ash off a volcano, pollen in a meadow, grain dust in a
36
+ * silo. Compose it through {@link WeatherLayer}'s `"dust"` mode (or `dustAlways` alongside rain and
37
+ * snow) rather than mounting it directly, so it shares one wind and time source with the other layers.
38
+ *
39
+ * @capability weather-dust airborne particulate that turns still air into visible wind
40
+ */
41
+ export declare function DustField({ count, density, budget, volume, wind, origin, followCamera, speed, size, sway, opacity, color, groundBias, timeScale, seed, renderOrder, frustumCulled, }: DustFieldProps): import("react").JSX.Element;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { useDisposable } from "../render/useDisposable.js";
5
+ import { createWeatherQuadGeometry } from "./weatherGeometry.js";
6
+ import { DEFAULT_DUST_COUNT, DEFAULT_DUST_DENSITY, resolveWeatherInstanceCount } from "./weatherMath.js";
7
+ import { useWeatherUniformSet } from "./weatherUniforms.js";
8
+ const DEFAULT_VOLUME = [70, 22, 70];
9
+ const DEFAULT_ORIGIN = [0, 0, 0];
10
+ const DEFAULT_DUST_COLOR = "#c8a878";
11
+ /**
12
+ * Renders wind-borne particulate as camera-facing motes. Reach for it whenever the air itself
13
+ * should read as weather — a desert gale, ash off a volcano, pollen in a meadow, grain dust in a
14
+ * silo. Compose it through {@link WeatherLayer}'s `"dust"` mode (or `dustAlways` alongside rain and
15
+ * snow) rather than mounting it directly, so it shares one wind and time source with the other layers.
16
+ *
17
+ * @capability weather-dust airborne particulate that turns still air into visible wind
18
+ */
19
+ export function DustField({ count = DEFAULT_DUST_COUNT, density = DEFAULT_DUST_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 2.4, size = 0.16, sway = 0.5, opacity = 0.3, color = DEFAULT_DUST_COLOR, groundBias = 0.65, timeScale, seed = 51407, renderOrder = 10, frustumCulled, }) {
20
+ const { camera } = useThree();
21
+ const shared = useWeatherUniformSet({ wind, timeScale });
22
+ const geometry = useDisposable(() => createWeatherQuadGeometry(count, seed), [count, seed]);
23
+ const material = useDisposable(() => {
24
+ const uniforms = {
25
+ uTime: shared.time,
26
+ uWind: shared.wind,
27
+ uAnchor: { value: new THREE.Vector3() },
28
+ uVolume: { value: new THREE.Vector3() },
29
+ uSpeed: { value: speed },
30
+ uSize: { value: size },
31
+ uSway: { value: sway },
32
+ uOpacity: { value: opacity },
33
+ uColor: { value: new THREE.Color(color) },
34
+ uGroundBias: { value: groundBias },
35
+ };
36
+ return new THREE.ShaderMaterial({
37
+ uniforms,
38
+ transparent: true,
39
+ depthWrite: false,
40
+ blending: THREE.NormalBlending,
41
+ vertexShader: `
42
+ uniform float uTime;
43
+ uniform vec3 uWind;
44
+ uniform vec3 uAnchor;
45
+ uniform vec3 uVolume;
46
+ uniform float uSpeed;
47
+ uniform float uSize;
48
+ uniform float uSway;
49
+ uniform float uGroundBias;
50
+
51
+ attribute vec3 aSpawn;
52
+ attribute float aDrift;
53
+
54
+ varying vec2 vUv;
55
+ varying float vDrift;
56
+ varying float vFade;
57
+
58
+ void main() {
59
+ vUv = uv;
60
+ vDrift = aDrift;
61
+
62
+ // Bias the spawn height toward the ground: pow() on a uniform [0,1) packs samples low
63
+ // without needing a second attribute or a reseed.
64
+ vec3 spawn = aSpawn;
65
+ spawn.y = pow(spawn.y, 1.0 + uGroundBias * 4.0);
66
+
67
+ vec3 origin = uAnchor - vec3(uVolume.x * 0.5, uVolume.y * 0.12, uVolume.z * 0.5);
68
+ float phase = aDrift * 6.28318530718;
69
+ // Faster motes ride higher in the gust, the way real airborne grit sorts itself.
70
+ float carry = uSpeed * (0.55 + 0.9 * aDrift);
71
+ vec3 bob = vec3(
72
+ sin(uTime * 0.43 + phase) * 0.35,
73
+ sin(uTime * 0.67 + phase * 1.9) + sin(uTime * 0.31 + phase) * 0.4,
74
+ cos(uTime * 0.39 + phase * 1.3) * 0.35
75
+ ) * uSway * (0.4 + 0.6 * aDrift);
76
+ vec3 travel = normalize(vec3(uWind.x, 0.0, uWind.z) + vec3(0.0001, 0.0, 0.0)) * carry;
77
+ vec3 local = spawn * uVolume + (uWind + travel) * uTime + bob;
78
+ vec3 worldCenter = mod(local - origin, uVolume) + origin;
79
+
80
+ // Fade at the top of the volume so recycled motes do not pop into view against the sky.
81
+ float height = (worldCenter.y - origin.y) / max(uVolume.y, 0.001);
82
+ vFade = 1.0 - smoothstep(0.55, 1.0, height);
83
+
84
+ float moteSize = uSize * (0.4 + 1.3 * aDrift);
85
+ vec3 right = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
86
+ vec3 up = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
87
+ vec3 world = worldCenter + right * position.x * moteSize + up * position.y * moteSize;
88
+
89
+ gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
90
+ }
91
+ `,
92
+ fragmentShader: `
93
+ uniform float uOpacity;
94
+ uniform vec3 uColor;
95
+
96
+ varying vec2 vUv;
97
+ varying float vDrift;
98
+ varying float vFade;
99
+
100
+ void main() {
101
+ float distanceFromCenter = length(vUv - 0.5) * 2.0;
102
+ // No hard core: dust is diffuse, and a bright centre reads as snow.
103
+ float puff = smoothstep(1.0, 0.05, distanceFromCenter);
104
+ float alpha = puff * puff * uOpacity * (0.35 + 0.65 * vDrift) * vFade;
105
+ if (alpha < 0.001) discard;
106
+ gl_FragColor = vec4(uColor, alpha);
107
+ }
108
+ `,
109
+ });
110
+ }, [color, groundBias, opacity, shared, size, speed, sway]);
111
+ useFrame(() => {
112
+ const anchor = followCamera ? camera.position : null;
113
+ const uniforms = material.uniforms;
114
+ const target = uniforms.uAnchor.value;
115
+ if (anchor !== null)
116
+ target.copy(anchor);
117
+ else
118
+ target.set(origin[0], origin[1], origin[2]);
119
+ uniforms.uVolume.value.set(volume[0], volume[1], volume[2]);
120
+ uniforms.uSpeed.value = speed;
121
+ uniforms.uSize.value = size;
122
+ uniforms.uSway.value = sway;
123
+ uniforms.uOpacity.value = opacity;
124
+ uniforms.uGroundBias.value = groundBias;
125
+ uniforms.uColor.value.set(color);
126
+ geometry.instanceCount = resolveWeatherInstanceCount(count, density, budget);
127
+ });
128
+ return (_jsx("mesh", { geometry: geometry, material: material, frustumCulled: frustumCulled ?? !followCamera, renderOrder: renderOrder }));
129
+ }
@@ -1,8 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
+ import { type DustFieldProps } from "./DustField.js";
2
3
  import { type RainFieldProps } from "./RainField.js";
3
4
  import { type SnowFieldProps } from "./SnowField.js";
4
5
  import { type WeatherVector } from "./weatherUniforms.js";
5
- export type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed";
6
+ /** `"mixed"` runs rain and snow together; `"dust"` is airborne particulate and composes with either via `dust`. */
7
+ export type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed" | "dust";
6
8
  export interface WeatherLayerProps {
7
9
  mode?: WeatherLayerMode;
8
10
  intensity?: number;
@@ -11,7 +13,11 @@ export interface WeatherLayerProps {
11
13
  timeScale?: number;
12
14
  rain?: Omit<RainFieldProps, "wind" | "lightning" | "timeScale"> | false;
13
15
  snow?: Omit<SnowFieldProps, "wind" | "timeScale"> | false;
16
+ /** Airborne particulate. Drawn in `"dust"` mode, and alongside rain/snow whenever `dustAlways` is set. */
17
+ dust?: Omit<DustFieldProps, "wind" | "timeScale"> | false;
18
+ /** Keep dust in the air in every mode — a world that is dusty whatever else the sky is doing. */
19
+ dustAlways?: boolean;
14
20
  enabled?: boolean;
15
21
  children?: ReactNode;
16
22
  }
17
- export declare function WeatherLayer({ mode, intensity, wind, lightning, timeScale, rain, snow, enabled, children, }: WeatherLayerProps): import("react").JSX.Element | null;
23
+ export declare function WeatherLayer({ mode, intensity, wind, lightning, timeScale, rain, snow, dust, dustAlways, enabled, children, }: WeatherLayerProps): import("react").JSX.Element | null;
@@ -1,16 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DustField } from "./DustField.js";
2
3
  import { RainField } from "./RainField.js";
3
4
  import { SnowField } from "./SnowField.js";
4
5
  import { WeatherUniformProvider } from "./weatherUniforms.js";
5
6
  function resolveLayerDensity(value, fallback, intensity) {
6
7
  return (value ?? fallback) * intensity;
7
8
  }
8
- export function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }) {
9
+ export function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, dust, dustAlways = false, enabled = true, children, }) {
9
10
  if (!enabled)
10
11
  return null;
11
12
  const rainProps = rain === false ? null : (rain ?? {});
12
13
  const snowProps = snow === false ? null : (snow ?? {});
13
14
  const showRain = rainProps !== null && (mode === "rain" || mode === "mixed");
14
15
  const showSnow = snowProps !== null && (mode === "snow" || mode === "mixed");
15
- return (_jsxs(WeatherUniformProvider, { wind: wind, lightning: lightning, timeScale: timeScale, children: [showRain ? (_jsx(RainField, { ...rainProps, density: resolveLayerDensity(rainProps.density, 0.45, intensity) })) : null, showSnow ? (_jsx(SnowField, { ...snowProps, density: resolveLayerDensity(snowProps.density, 0.5, intensity) })) : null, children] }));
16
+ const dustProps = dust === false ? null : (dust ?? {});
17
+ const showDust = dustProps !== null && (mode === "dust" || dustAlways);
18
+ return (_jsxs(WeatherUniformProvider, { wind: wind, lightning: lightning, timeScale: timeScale, children: [showRain ? (_jsx(RainField, { ...rainProps, density: resolveLayerDensity(rainProps.density, 0.45, intensity) })) : null, showSnow ? (_jsx(SnowField, { ...snowProps, density: resolveLayerDensity(snowProps.density, 0.5, intensity) })) : null, showDust ? (_jsx(DustField, { ...dustProps, density: resolveLayerDensity(dustProps.density, 0.4, intensity) })) : null, children] }));
16
19
  }
@@ -1,3 +1,4 @@
1
+ export { DustField, type DustFieldProps } from "./DustField.js";
1
2
  export { FireSpreadLayer, type FireSpreadLayerProps } from "./FireSpreadLayer.js";
2
3
  export { LightningStrike, type LightningStrikeProps } from "./LightningStrike.js";
3
4
  export { RainField, type RainFieldProps } from "./RainField.js";
@@ -1,3 +1,4 @@
1
+ export { DustField } from "./DustField.js";
1
2
  export { FireSpreadLayer } from "./FireSpreadLayer.js";
2
3
  export { LightningStrike } from "./LightningStrike.js";
3
4
  export { RainField } from "./RainField.js";
@@ -2,6 +2,10 @@ export declare const DEFAULT_RAIN_COUNT = 2000;
2
2
  export declare const DEFAULT_SNOW_COUNT = 1500;
3
3
  export declare const DEFAULT_RAIN_DENSITY = 0.45;
4
4
  export declare const DEFAULT_SNOW_DENSITY = 0.5;
5
+ /** Mote budget a {@link DustField} allocates before `density` scales it down. */
6
+ export declare const DEFAULT_DUST_COUNT = 1800;
7
+ /** Share of {@link DEFAULT_DUST_COUNT} drawn at intensity 1 — dust reads thinner than rain or snow. */
8
+ export declare const DEFAULT_DUST_DENSITY = 0.4;
5
9
  /** @internal */
6
10
  export declare function clampWeatherRatio(value: number): number;
7
11
  /** @internal */
@@ -2,6 +2,10 @@ export const DEFAULT_RAIN_COUNT = 2000;
2
2
  export const DEFAULT_SNOW_COUNT = 1500;
3
3
  export const DEFAULT_RAIN_DENSITY = 0.45;
4
4
  export const DEFAULT_SNOW_DENSITY = 0.5;
5
+ /** Mote budget a {@link DustField} allocates before `density` scales it down. */
6
+ export const DEFAULT_DUST_COUNT = 1800;
7
+ /** Share of {@link DEFAULT_DUST_COUNT} drawn at intensity 1 — dust reads thinner than rain or snow. */
8
+ export const DEFAULT_DUST_DENSITY = 0.4;
5
9
  /** @internal */
6
10
  export function clampWeatherRatio(value) {
7
11
  if (!Number.isFinite(value))
@@ -5,13 +5,15 @@ import { type NameplateSample } from "./worldBarSamples.js";
5
5
  export type { NameplateSample, WorldBarSample } from "./worldBarSamples.js";
6
6
  export { collectNameplateSamples, collectWorldBarSamples, paintWorldBarSamples } from "./worldBarSamples.js";
7
7
  export { telegraphPulseOpacity } from "./telegraphPulse.js";
8
- export declare function WorldEntityBars({ statId, height, roles, resolveRole, maxDistance, }: {
8
+ export declare function WorldEntityBars({ statId, height, roles, resolveRole, maxDistance, occlude, }: {
9
9
  statId: string;
10
10
  height?: number;
11
11
  roles?: readonly CatalogEntityRole[];
12
12
  resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
13
13
  /** Hide bars for entities farther than this from the player (world units). Default 60. */
14
14
  maxDistance?: number;
15
+ /** Hide the bar when world geometry stands between the player and the entity. These bars are a screen-space overlay outside the depth buffer, so without this they read through walls. Default false, preserving the always-visible behaviour. */
16
+ occlude?: boolean;
15
17
  }): import("react").JSX.Element;
16
18
  /** Props for `WorldNameplates` — entity filter, refresh rate, and headless className/render hooks. */
17
19
  export interface WorldNameplatesProps {
@@ -21,6 +23,8 @@ export interface WorldNameplatesProps {
21
23
  resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
22
24
  /** Hide nameplates for entities farther than this from the player (world units). Default 40. */
23
25
  maxDistance?: number;
26
+ /** Hide the plate when world geometry stands between the player and the entity. Default false. */
27
+ occlude?: boolean;
24
28
  /** Minimum ms between position/health refreshes — trades smoothness for fewer re-renders at scale. Default 120. */
25
29
  tickMs?: number;
26
30
  /** Draw the built-in HP bar under the name. Set `false` for a name-only plate when the game already draws its own health bar (its own HUD, or `worldHealthBars`). Default true. */
@@ -41,7 +45,7 @@ export interface WorldNameplatesProps {
41
45
  *
42
46
  * @capability nameplates billboarded name + HP bar over nearby entities
43
47
  */
44
- export declare function WorldNameplates({ statId, height, roles, resolveRole, maxDistance, tickMs, showHealth, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): import("react").JSX.Element;
48
+ export declare function WorldNameplates({ statId, height, roles, resolveRole, maxDistance, occlude, tickMs, showHealth, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): import("react").JSX.Element;
45
49
  /** Props for {@link WorldObjectHighlights}. */
46
50
  export interface WorldObjectHighlightsProps {
47
51
  /** Ring tint. Default a construction-select amber. */
@@ -16,7 +16,7 @@ export { telegraphPulseOpacity } from "./telegraphPulse.js";
16
16
  function pinOverlayToViewport(_el, _camera, size) {
17
17
  return [size.width / 2, size.height / 2];
18
18
  }
19
- export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, }) {
19
+ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, occlude = false, }) {
20
20
  const ctx = useGameContext();
21
21
  const camera = useThree((state) => state.camera);
22
22
  const size = useThree((state) => state.size);
@@ -38,7 +38,7 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxD
38
38
  canvas.height = pixelH;
39
39
  }
40
40
  camera.updateMatrixWorld();
41
- collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current, maxDistance);
41
+ collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current, maxDistance, occlude);
42
42
  paintWorldBarSamples(canvas, samplesRef.current, dpr);
43
43
  });
44
44
  return (_jsx(Html, { fullscreen: true, calculatePosition: pinOverlayToViewport, zIndexRange: [20, 0], style: { pointerEvents: "none" }, children: _jsx("canvas", { ref: canvasRef, style: { width: "100%", height: "100%", display: "block", pointerEvents: "none" } }) }));
@@ -51,7 +51,7 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxD
51
51
  *
52
52
  * @capability nameplates billboarded name + HP bar over nearby entities
53
53
  */
54
- export function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, tickMs = 120, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }) {
54
+ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, occlude = false, tickMs = 120, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }) {
55
55
  const ctx = useGameContext();
56
56
  const camera = useThree((state) => state.camera);
57
57
  const size = useThree((state) => state.size);
@@ -65,7 +65,7 @@ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolv
65
65
  return;
66
66
  lastTickRef.current = nowMs;
67
67
  camera.updateMatrixWorld();
68
- collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, { width: size.width, height: size.height }, samplesRef.current, projectRef.current, maxDistance);
68
+ collectNameplateSamples(ctx, statId, height, roles, resolveRole, camera, { width: size.width, height: size.height }, samplesRef.current, projectRef.current, maxDistance, occlude);
69
69
  setSamples([...samplesRef.current]);
70
70
  });
71
71
  return (_jsx(Html, { fullscreen: true, calculatePosition: pinOverlayToViewport, zIndexRange: [19, 0], style: { pointerEvents: "none" }, children: _jsx("div", { className: className, "data-nameplates": true, children: samples.map((sample) => renderNameplate !== undefined ? (_jsx("div", { style: { position: "absolute", left: sample.x, top: sample.y }, children: renderNameplate(sample) }, sample.id)) : (_jsxs("div", { className: nameplateClassName, "data-nameplate": sample.id, style: {
@@ -16,14 +16,26 @@ export interface Projectable {
16
16
  y: number;
17
17
  z: number;
18
18
  }
19
- /** @internal */
19
+ /**
20
+ * True when world geometry stands between the player's eye and `target`. World bars and nameplates
21
+ * are DOM/canvas overlays projected to screen space, so nothing in the depth buffer can hide them —
22
+ * without this test an enemy's health bar reads straight through the building it is standing behind.
23
+ * @internal
24
+ */
25
+ export declare function worldBarOccluded(ctx: GameContext, from: readonly [number, number, number], target: readonly [number, number, number], eyeHeight: number): boolean;
26
+ /**
27
+ * Projects every non-local, role/distance-filtered entity carrying `statId` into `into` as a
28
+ * `WorldBarSample`. Pure data step behind `WorldHealthBars` — call it directly to paint the bars
29
+ * with your own renderer.
30
+ * @internal
31
+ */
20
32
  export declare function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: {
21
33
  matrixWorldInverse: unknown;
22
34
  projectionMatrix: unknown;
23
35
  }, viewport: {
24
36
  width: number;
25
37
  height: number;
26
- }, into: WorldBarSample[], project: Projectable, maxDistance?: number): number;
38
+ }, into: WorldBarSample[], project: Projectable, maxDistance?: number, occlude?: boolean): number;
27
39
  /** One entity's projected nameplate: screen `x`/`y`, display `name`, health `percent` (or `null` when statless), and world `distance` from the player. */
28
40
  export interface NameplateSample {
29
41
  id: string;
@@ -46,7 +58,7 @@ export declare function collectNameplateSamples(ctx: GameContext, statId: string
46
58
  }, viewport: {
47
59
  width: number;
48
60
  height: number;
49
- }, into: NameplateSample[], project: Projectable, maxDistance?: number): number;
61
+ }, into: NameplateSample[], project: Projectable, maxDistance?: number, occlude?: boolean): number;
50
62
  /** @internal */
51
63
  export declare function paintWorldBarSamples(canvas: {
52
64
  width: number;