@jgengine/shell 0.15.0-next.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.
- package/CHANGELOG.md +49 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/audio/synthEngine.js +12 -2
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/presentationResolve.d.ts +2 -0
- package/dist/presentationResolve.js +1 -0
- package/dist/render/modelLoad.d.ts +24 -0
- package/dist/render/modelLoad.js +46 -0
- package/dist/scene/CityRenderer.js +28 -54
- package/dist/structures/BuildingKitBatch.d.ts +13 -0
- package/dist/structures/BuildingKitBatch.js +78 -0
- package/dist/structures/GeneratedBuilding.d.ts +19 -3
- package/dist/structures/GeneratedBuilding.js +13 -81
- package/dist/structures/buildingKitFit.d.ts +59 -0
- package/dist/structures/buildingKitFit.js +188 -0
- package/dist/structures/buildingKitRegistry.d.ts +9 -0
- package/dist/structures/buildingKitRegistry.js +26 -0
- package/dist/weather/DustField.d.ts +41 -0
- package/dist/weather/DustField.js +129 -0
- package/dist/weather/WeatherLayer.d.ts +8 -2
- package/dist/weather/WeatherLayer.js +5 -2
- package/dist/weather/index.d.ts +1 -0
- package/dist/weather/index.js +1 -0
- package/dist/weather/weatherMath.d.ts +4 -0
- package/dist/weather/weatherMath.js +4 -0
- package/dist/world/WorldHud.d.ts +6 -2
- package/dist/world/WorldHud.js +4 -4
- package/dist/world/worldBarSamples.d.ts +15 -3
- package/dist/world/worldBarSamples.js +34 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +15 -3
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-ui/api.md +33 -12
- package/skills/jgengine-ui/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +40 -2
- package/skills/jgengine-world/capabilities.md +16 -0
- package/skills/jgengine-world/recipes/building-kit.md +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -28,6 +28,49 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
28
28
|
|
|
29
29
|
### Migrate
|
|
30
30
|
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
## 0.16.0
|
|
38
|
+
|
|
39
|
+
### Migrate
|
|
40
|
+
|
|
41
|
+
- **Bump lockstep SDK packages to `^0.16.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.
|
|
42
|
+
- **Placed objects now replicate.** `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`**; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`modelLoadFallbacks()` from `@jgengine/shell/render/modelLoad`** lists every model the shared loader diagnosed as broken and replaced with a magenta placeholder. A placeholder was only ever a console warning, so a shipped game could render one in its starting area and nobody found out until someone looked at a screenshot; a capture host or smoke test can now require the list to be empty. `clearModelLoadFallbacks()` resets it between runs.
|
|
51
|
+
- **`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop.** World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.
|
|
52
|
+
|
|
53
|
+
- **`DustField` + `WeatherLayer` mode `"dust"` (`@jgengine/shell/weather`) — wind-borne particulate.** `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.
|
|
54
|
+
|
|
55
|
+
- **Authored `city` volumes render their near-LOD facades with a `BuildingKit`.** `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive — not just the `building()` feature — on the kit path.
|
|
56
|
+
- **`BuildingKit` from `@jgengine/core/world/buildingKit`** binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: ["Wall_A.glb", …] } })` maps a part kind — or a `${facade}.${kind}` slot — to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.
|
|
57
|
+
- **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
|
|
58
|
+
- **`capture.views` on `defineGame`** — named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.
|
|
59
|
+
- **`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad`** reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.
|
|
60
|
+
- **Per-instance state on placed objects** — `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.
|
|
61
|
+
- **`slotInventory` is a working feature** — `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.
|
|
62
|
+
- **`loadServerSnapshot` and `persistServerSnapshot` are public** (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **Open storefronts are no longer holes through the building.** `generateBuilding` emitted an open ground-floor store as a lone `storefront` panel with no wall behind it, while every upper-floor window was correctly backed by one. Storefront and window materials render at 0.56 opacity and a generated building is a hollow shell, so those bays let you see straight through the building to the world beyond — and open stores only land on the street-facing facade, so a row of shops along a road was the worst case. The bay now emits its wall first, exactly as an upper floor does.
|
|
67
|
+
- **`joinServer`, `flushSave`, `flushDirtyServers`, and the `auto` autosave can write player profiles again.** All four cleared the snapshot's dirty set before handing it to the persist plan, which gates profile writes on exactly that set — so the functions named "flush" flushed the server row and chunks only, and an `onNewPlayer` grant (starting money, starting items) never reached `jgPlayerProfiles`. `joinServer` now persists the snapshot it just marked; the flush paths mark every player dirty first, since hydrating from storage resets the dirty set. `planServerPersist` also clears `dirtyAt` once everything outstanding is written, so a successful flush stops autosave from re-firing forever, and a profile patch no longer clobbers a host-maintained `dirtyAt`.
|
|
68
|
+
|
|
69
|
+
## 0.15.0
|
|
70
|
+
|
|
71
|
+
### Migrate
|
|
72
|
+
|
|
73
|
+
- **Bump lockstep SDK packages to `^0.15.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.12.0`; `@jgengine/github` is `0.2.0`.
|
|
31
74
|
- **`jgengine create` scaffolds a textured world by default (#1550).** `--world` is now the default and `--no-world` is the opt-out (matching `--no-editor`); `--world` is still accepted. The default scaffold previously emitted untextured proxy geometry, which the AGENTS.md it ships declares a failing result. `create` now also pulls the starter asset packs into `public/models` after install, and never fails the scaffold if that pull cannot run (offline, blocked proxy, `--no-install`) — it prints `npx assets pull starter` in next steps instead, and the emitted AGENTS.md documents the same command. Skip the pull explicitly with `--no-assets`.
|
|
32
75
|
- **Start game code at `@jgengine/shell/gameKit` (#1541).** Prefer that surface for `defineGame`, `GameHost`, stores, systems, authored-scene helpers, and HUD building blocks. `@jgengine/core/authoring` remains for pure core helpers the kit does not re-export but is no longer a competing start-here path.
|
|
33
76
|
- **Every game must now declare its run-phase story — silence is a gate error (#1337).** The run-phase
|
|
@@ -73,10 +116,16 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
73
116
|
|
|
74
117
|
### Added
|
|
75
118
|
|
|
119
|
+
- **`advanceStepCadence` (`@jgengine/core/audio/stepCadence`) — distance-driven footstep timing.** Pure state-in/state-out: feed it the ground distance a mover covered this frame and it reports how many steps landed and which foot led, so foley stays locked to distance instead of a wall-clock timer that desyncs the moment speed changes. Configurable stride, per-advance `strideScale` for crouch/sprint, and a shorter first step from rest so walking off is heard immediately.
|
|
120
|
+
- **`resolveMusicState` (`@jgengine/core/audio/musicState`) — combat-reactive soundtrack selection.** The engine could already crossfade themes but nothing decided *when*, so every game re-handrolled the threshold and got a track that flapped as one enemy walked in and out of range. Give it a tier ladder (intensity → theme) and it resolves the theme to play: escalation is immediate, de-escalation waits for the tier's `holdMs` and needs intensity to fall a `release` margin below the tier it is leaving. Pure and serialisable.
|
|
121
|
+
- **Synth voices take a `sustain` (`ToneVoice`/`NoiseVoice`).** Seconds held at full gain before the decay. Default 0 preserves every existing patch. Without it there was no way to author a *sustained* sound at all: every voice decayed to silence inside its duration, so a patch rendered once and looped as an ambient bed pulsed audibly once per loop. Set `sustain` to `duration` for a flat wind, rain, or machine-hum bed that loops seamlessly.
|
|
122
|
+
|
|
76
123
|
- **`ModelMaterialOverride.rim` — a fresnel rim light on any entity/object model.** `{ color, strength, power }` adds a bright edge where the surface turns away from the viewer, patched into the material's own shader so it shadows, fogs, and tonemaps with the body rather than floating in front of it. This is the standard fix for an actor that shares a value band with its ground: no albedo choice separates a body from terrain lit by the same key, but an edge does. Omit for no rim (default).
|
|
77
124
|
|
|
78
125
|
### Fixed
|
|
79
126
|
|
|
127
|
+
- **Noise voices longer than the shared noise buffer no longer go silent partway through.** The 1-second buffer is sampled from a random offset, so any `NoiseVoice` whose duration exceeded the remainder ran off the end and finished in silence — invisible on short impact bursts, fatal for anything sustained. The source now wraps; the voice still ends on time.
|
|
128
|
+
|
|
80
129
|
- **`terrain({ detail: { material: { tint } } })` separates a ground texture's grain from its hue.** A detail material's colour map used to replace the terrain's authored colour outright, so the biome palette, macro sweeps, and slope-rock blend under it were painted over and a whole region flattened to the texture's single hue — despite the API documenting that it "blends with, never replaces". `tint` defaults to 1 (the existing behaviour, so no shipped game changes); drop it toward 0 to keep the authored colour and take only the map's light and shade.
|
|
81
130
|
- **`sky({ gradientExponent, cloudiness })` are now authorable — and `sky()` no longer silently drops them.** `gradientExponent` controls how fast the gradient reaches `zenithColor` off the horizon (default 0.65, unchanged): below 1 pulls the zenith hue down into the frame, which is what an eye-level camera in flat terrain actually needs — at the default the sky reads as one horizon-coloured wash however saturated the authored zenith is. `cloudiness` (default 0.35, unchanged) is the procedural cloud-band opacity.
|
|
82
131
|
- **A model that throws while rendering is now reported instead of vanishing (#1588).** `ModelFallbackBoundary` swapped in its fallback (usually nothing) and stayed silent: `probes.fallbacks` stayed empty, no warning was logged, and an entity simply disappeared. It now reports a new `renderError` fallback cause on every failed render and warns once with the model url and the caught error.
|
|
@@ -289,7 +289,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
289
289
|
fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
|
|
290
290
|
near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
|
|
291
291
|
far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
|
|
292
|
-
}, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
292
|
+
}, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
293
293
|
cameraDraggingRef.current = dragging;
|
|
294
294
|
} }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled }), _jsx(DevtoolsRendererProbe, {}), resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality })) : null] }), !poster &&
|
|
295
295
|
!orientationGate &&
|
|
@@ -16,10 +16,13 @@ function realizeTone(ctx, out, voice, cueGain, at) {
|
|
|
16
16
|
osc.frequency.setValueAtTime(voice.freq, t);
|
|
17
17
|
if (voice.slideTo !== undefined)
|
|
18
18
|
osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
|
|
19
|
+
const sustainEnd = Math.max(attack, voice.sustain ?? 0);
|
|
19
20
|
const g = ctx.createGain();
|
|
20
21
|
g.gain.setValueAtTime(0, t);
|
|
21
22
|
g.gain.linearRampToValueAtTime(peak, t + attack);
|
|
22
|
-
|
|
23
|
+
if (sustainEnd > attack)
|
|
24
|
+
g.gain.setValueAtTime(peak, t + sustainEnd);
|
|
25
|
+
g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration, sustainEnd + 0.001));
|
|
23
26
|
osc.connect(g).connect(out);
|
|
24
27
|
osc.start(t);
|
|
25
28
|
osc.stop(t + voice.duration + 0.05);
|
|
@@ -31,12 +34,19 @@ function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
|
|
|
31
34
|
const src = ctx.createBufferSource();
|
|
32
35
|
src.buffer = noiseBuf;
|
|
33
36
|
src.playbackRate.value = 0.8 + Math.random() * 0.4;
|
|
37
|
+
// The shared buffer is 1 s and playback starts at a random offset inside it, so any voice longer
|
|
38
|
+
// than the remainder used to run off the end and go silent early. Wrapping keeps it noise throughout;
|
|
39
|
+
// the `duration` argument to start() still ends the voice on time.
|
|
40
|
+
src.loop = true;
|
|
34
41
|
const filter = ctx.createBiquadFilter();
|
|
35
42
|
filter.type = voice.filterType ?? "lowpass";
|
|
36
43
|
filter.frequency.value = voice.filterFreq;
|
|
44
|
+
const sustain = Math.max(0, voice.sustain ?? 0);
|
|
37
45
|
const g = ctx.createGain();
|
|
38
46
|
g.gain.setValueAtTime(peak, t);
|
|
39
|
-
|
|
47
|
+
if (sustain > 0)
|
|
48
|
+
g.gain.setValueAtTime(peak, t + sustain);
|
|
49
|
+
g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration * decay, sustain + 0.001));
|
|
40
50
|
src.connect(filter).connect(g).connect(out);
|
|
41
51
|
src.start(t, Math.random() * 0.5, voice.duration);
|
|
42
52
|
}
|
|
@@ -147,7 +147,7 @@ function Structures({ structures, field }) {
|
|
|
147
147
|
...(building.rotationY === undefined ? {} : { rotationY: building.rotationY, pivot: building.center }),
|
|
148
148
|
})), [structures, field]);
|
|
149
149
|
const palette = useMemo(() => resolveBuildingPalette(structures.style, structures.palette), [structures.style, structures.palette]);
|
|
150
|
-
return _jsx(InstancedBuildings, { buildings: placements, palette: palette });
|
|
150
|
+
return (_jsx(InstancedBuildings, { buildings: placements, palette: palette, ...(structures.kit === undefined ? {} : { kit: structures.kit }) }));
|
|
151
151
|
}
|
|
152
152
|
export function EnvironmentScene({ feature }) {
|
|
153
153
|
const field = useMemo(() => resolveEnvironmentField(feature), [feature]);
|
|
@@ -7,6 +7,7 @@ export interface ResolvedWorldOverlayBars {
|
|
|
7
7
|
maxDistance?: number;
|
|
8
8
|
/** Nameplates only: draw the built-in HP bar. `false` yields a name-only plate. Defaults to `true`. */
|
|
9
9
|
showHealth?: boolean;
|
|
10
|
+
occlude?: boolean;
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
13
|
* Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
|
|
@@ -20,6 +21,7 @@ export declare function resolveWorldOverlayBars(config: boolean | {
|
|
|
20
21
|
roles?: readonly CatalogEntityRole[];
|
|
21
22
|
maxDistance?: number;
|
|
22
23
|
showHealth?: boolean;
|
|
24
|
+
occlude?: boolean;
|
|
23
25
|
} | undefined): ResolvedWorldOverlayBars | null;
|
|
24
26
|
/** Resolved combat presentation channels (each boolean is whether that stack piece mounts). @internal */
|
|
25
27
|
export interface ResolvedPresentationEffects {
|
|
@@ -15,6 +15,7 @@ export function resolveWorldOverlayBars(config) {
|
|
|
15
15
|
...(config.roles === undefined ? {} : { roles: config.roles }),
|
|
16
16
|
...(config.maxDistance === undefined ? {} : { maxDistance: config.maxDistance }),
|
|
17
17
|
...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
|
|
18
|
+
...(config.occlude === undefined ? {} : { occlude: config.occlude }),
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
// Every channel defaults on EXCEPT tracers. `fireProjectile` is a generic seam (bullets,
|
|
@@ -44,6 +44,22 @@ export declare function handleModelLoadFailure(resolvedUrl: string, originalEven
|
|
|
44
44
|
probe?: typeof probeModelUrl;
|
|
45
45
|
warn?: (message: string) => void;
|
|
46
46
|
}): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Every model the shared loader diagnosed as broken and replaced with a magenta placeholder, in
|
|
49
|
+
* load order.
|
|
50
|
+
*
|
|
51
|
+
* A placeholder is already a console warning, but a warning does not fail anything — a shipped game
|
|
52
|
+
* can render one in its starting area and nobody finds out until someone looks at a screenshot.
|
|
53
|
+
* Reading this turns "the loader knows the asset is bad" into an assertable fact: a capture host or
|
|
54
|
+
* a smoke test can require it to be empty.
|
|
55
|
+
* @capability model-fallbacks models the loader replaced with a placeholder, for capture and smoke assertions
|
|
56
|
+
*/
|
|
57
|
+
export declare function modelLoadFallbacks(): readonly {
|
|
58
|
+
url: string;
|
|
59
|
+
message: string;
|
|
60
|
+
}[];
|
|
61
|
+
/** Drops the recorded placeholder list. For tests and between capture runs. @internal */
|
|
62
|
+
export declare function clearModelLoadFallbacks(): void;
|
|
47
63
|
/**
|
|
48
64
|
* A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
|
|
49
65
|
* gracefully: when a load errors, it probes the URL and — for a diagnosed broken
|
|
@@ -56,5 +72,13 @@ export declare function handleModelLoadFailure(resolvedUrl: string, originalEven
|
|
|
56
72
|
export declare class DiagnosticGLTFLoader extends GLTFLoader {
|
|
57
73
|
load(url: string, onLoad: (gltf: GLTF) => void, onProgress?: (event: ProgressEvent) => void, onError?: (event: unknown) => void): void;
|
|
58
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* How long the shared GLB loader has been idle, in ms — `0` while any model is still
|
|
77
|
+
* in flight. A capture host reads this to wait for streaming to finish instead of
|
|
78
|
+
* guessing a settle delay: models that pop in after the shot are why an establishing
|
|
79
|
+
* capture used to come back half-empty until someone hand-tuned `--settle`.
|
|
80
|
+
* @capability model-load-idle how long the shared GLB loader has been idle, for capture settle waits
|
|
81
|
+
*/
|
|
82
|
+
export declare function modelLoadIdleMs(): number;
|
|
59
83
|
/** Shared GLTF loader used by shell entity/object model mounts. @internal */
|
|
60
84
|
export declare const sharedGltfLoader: DiagnosticGLTFLoader;
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -117,8 +117,27 @@ export async function handleModelLoadFailure(resolvedUrl, originalEvent, onLoad,
|
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
119
|
(overrides.warn ?? warnMissingModel)(diagnosis.message);
|
|
120
|
+
servedFallbacks.push({ url: resolvedUrl, message: diagnosis.message });
|
|
120
121
|
onLoad(createFallbackModel(diagnosis));
|
|
121
122
|
}
|
|
123
|
+
const servedFallbacks = [];
|
|
124
|
+
/**
|
|
125
|
+
* Every model the shared loader diagnosed as broken and replaced with a magenta placeholder, in
|
|
126
|
+
* load order.
|
|
127
|
+
*
|
|
128
|
+
* A placeholder is already a console warning, but a warning does not fail anything — a shipped game
|
|
129
|
+
* can render one in its starting area and nobody finds out until someone looks at a screenshot.
|
|
130
|
+
* Reading this turns "the loader knows the asset is bad" into an assertable fact: a capture host or
|
|
131
|
+
* a smoke test can require it to be empty.
|
|
132
|
+
* @capability model-fallbacks models the loader replaced with a placeholder, for capture and smoke assertions
|
|
133
|
+
*/
|
|
134
|
+
export function modelLoadFallbacks() {
|
|
135
|
+
return servedFallbacks;
|
|
136
|
+
}
|
|
137
|
+
/** Drops the recorded placeholder list. For tests and between capture runs. @internal */
|
|
138
|
+
export function clearModelLoadFallbacks() {
|
|
139
|
+
servedFallbacks.length = 0;
|
|
140
|
+
}
|
|
122
141
|
/**
|
|
123
142
|
* A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
|
|
124
143
|
* gracefully: when a load errors, it probes the URL and — for a diagnosed broken
|
|
@@ -138,6 +157,33 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
|
|
|
138
157
|
});
|
|
139
158
|
}
|
|
140
159
|
}
|
|
160
|
+
// LoadingManager reports queue transitions, not per-item events: `onStart` fires when the
|
|
161
|
+
// queue goes busy and `onLoad` when it drains (errors included, via itemEnd), so a flag plus
|
|
162
|
+
// a drain timestamp is the whole state.
|
|
163
|
+
let modelQueueBusy = false;
|
|
164
|
+
let modelQueueDrainedAt = 0;
|
|
165
|
+
const now = () => (typeof performance === "undefined" ? Date.now() : performance.now());
|
|
166
|
+
modelLoadingManager.onStart = () => {
|
|
167
|
+
modelQueueBusy = true;
|
|
168
|
+
};
|
|
169
|
+
modelLoadingManager.onLoad = () => {
|
|
170
|
+
modelQueueBusy = false;
|
|
171
|
+
modelQueueDrainedAt = now();
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* How long the shared GLB loader has been idle, in ms — `0` while any model is still
|
|
175
|
+
* in flight. A capture host reads this to wait for streaming to finish instead of
|
|
176
|
+
* guessing a settle delay: models that pop in after the shot are why an establishing
|
|
177
|
+
* capture used to come back half-empty until someone hand-tuned `--settle`.
|
|
178
|
+
* @capability model-load-idle how long the shared GLB loader has been idle, for capture settle waits
|
|
179
|
+
*/
|
|
180
|
+
export function modelLoadIdleMs() {
|
|
181
|
+
if (modelQueueBusy)
|
|
182
|
+
return 0;
|
|
183
|
+
if (modelQueueDrainedAt === 0)
|
|
184
|
+
return Number.POSITIVE_INFINITY;
|
|
185
|
+
return now() - modelQueueDrainedAt;
|
|
186
|
+
}
|
|
141
187
|
/** Shared GLTF loader used by shell entity/object model mounts. @internal */
|
|
142
188
|
export const sharedGltfLoader = new DiagnosticGLTFLoader(modelLoadingManager);
|
|
143
189
|
sharedGltfLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
@@ -22,6 +22,8 @@ import { triangulatePolygon, triangulateRingBand } from "@jgengine/core/world/ci
|
|
|
22
22
|
import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/world/cityKind";
|
|
23
23
|
import { generateBuilding, resolveBuildingPalette } from "@jgengine/core/world/buildings";
|
|
24
24
|
import { buildScatterProxy } from "../scatter/scatterProxies.js";
|
|
25
|
+
import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
|
|
26
|
+
import { getBuildingKit } from "../structures/buildingKitRegistry.js";
|
|
25
27
|
import { createCityWindowMaterial } from "./cityWindowMaterial.js";
|
|
26
28
|
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
27
29
|
const ASPHALT_COLOR = "#3d3f45";
|
|
@@ -323,58 +325,25 @@ function roleColor(palette, role) {
|
|
|
323
325
|
const DETAIL_RADIUS = 95;
|
|
324
326
|
const DETAIL_MAX = 12;
|
|
325
327
|
const DETAIL_CLASSES = new Set(["tower", "slab", "shop", "rowhouse"]);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
346
|
-
const out = [];
|
|
347
|
-
// Rooftop mechanical units read as debug cubes in the district's bright palette accent — force a
|
|
348
|
-
// muted metal grey so they look like real HVAC/plant, never placeholder geometry.
|
|
349
|
-
const ROOF_PROP_GREY = "#9a9ea3";
|
|
350
|
-
for (const [kind, matrices] of buckets) {
|
|
351
|
-
const color = kind === "roofProp" ? ROOF_PROP_GREY : palette[kind];
|
|
352
|
-
const material = kind === "window" || kind === "storefront"
|
|
353
|
-
? new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 })
|
|
354
|
-
: kind === "storeSign"
|
|
355
|
-
? new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 })
|
|
356
|
-
: new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
|
|
357
|
-
const mesh = new THREE.InstancedMesh(geometry, material, matrices.length);
|
|
358
|
-
matrices.forEach((matrix, i) => mesh.setMatrixAt(i, matrix));
|
|
359
|
-
mesh.instanceMatrix.needsUpdate = true;
|
|
360
|
-
mesh.castShadow = true;
|
|
361
|
-
mesh.receiveShadow = true;
|
|
362
|
-
mesh.frustumCulled = false;
|
|
363
|
-
out.push(mesh);
|
|
364
|
-
}
|
|
365
|
-
return out;
|
|
366
|
-
}, [buildings, palette]);
|
|
367
|
-
useEffect(() => () => {
|
|
368
|
-
if (meshes === null)
|
|
369
|
-
return;
|
|
370
|
-
for (const mesh of meshes) {
|
|
371
|
-
mesh.geometry.dispose();
|
|
372
|
-
mesh.material.dispose();
|
|
373
|
-
}
|
|
374
|
-
}, [meshes]);
|
|
375
|
-
if (meshes === null)
|
|
328
|
+
// Rooftop mechanical units read as debug cubes in the district's bright palette accent — force a
|
|
329
|
+
// muted metal grey so they look like real HVAC/plant, never placeholder geometry.
|
|
330
|
+
const ROOF_PROP_GREY = "#9a9ea3";
|
|
331
|
+
/**
|
|
332
|
+
* Near-LOD facade buildings, rendered through the shared building path so an authored city picks up
|
|
333
|
+
* a `BuildingKit` exactly like a `building()` feature does: kit-bound parts instance their models,
|
|
334
|
+
* everything else keeps the palette blocks.
|
|
335
|
+
*/
|
|
336
|
+
function DetailBuildings({ buildings, palette, kitName, }) {
|
|
337
|
+
const placements = useMemo(() => buildings.map((entry) => ({
|
|
338
|
+
building: { id: entry.lotId, parts: entry.parts },
|
|
339
|
+
position: [entry.center[0], entry.baseY, entry.center[1]],
|
|
340
|
+
rotationY: entry.rotationY,
|
|
341
|
+
})), [buildings]);
|
|
342
|
+
const detailPalette = useMemo(() => ({ ...palette, roofProp: ROOF_PROP_GREY }), [palette]);
|
|
343
|
+
const kit = getBuildingKit(kitName);
|
|
344
|
+
if (placements.length === 0)
|
|
376
345
|
return null;
|
|
377
|
-
return
|
|
346
|
+
return _jsx(InstancedBuildings, { buildings: placements, palette: detailPalette, ...(kit === undefined ? {} : { kit }) });
|
|
378
347
|
}
|
|
379
348
|
/** One authored city volume → the full merged/instanced district. */
|
|
380
349
|
function OneCity({ object, context }) {
|
|
@@ -449,9 +418,14 @@ function OneCity({ object, context }) {
|
|
|
449
418
|
bayWidth: lot.size[0] / baysWide,
|
|
450
419
|
floorHeight: resolved.rules.floorHeight,
|
|
451
420
|
});
|
|
452
|
-
const matrix = new THREE.Matrix4().compose(new THREE.Vector3(lot.center[0], maxY, lot.center[1]), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), lot.rotationY), new THREE.Vector3(1, 1, 1));
|
|
453
421
|
ids.add(lot.id);
|
|
454
|
-
buildings.push({
|
|
422
|
+
buildings.push({
|
|
423
|
+
lotId: lot.id,
|
|
424
|
+
center: lot.center,
|
|
425
|
+
baseY: maxY,
|
|
426
|
+
rotationY: lot.rotationY,
|
|
427
|
+
parts: building.parts,
|
|
428
|
+
});
|
|
455
429
|
}
|
|
456
430
|
return { ids, buildings };
|
|
457
431
|
}, [resolved, detailKey, sample]);
|
|
@@ -1072,7 +1046,7 @@ function OneCity({ object, context }) {
|
|
|
1072
1046
|
}, [furniture]);
|
|
1073
1047
|
if (resolved === null)
|
|
1074
1048
|
return null;
|
|
1075
|
-
return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette })] }));
|
|
1049
|
+
return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
|
|
1076
1050
|
}
|
|
1077
1051
|
/** One instanced unit-box mesh from precomposed matrices (bridge parts). */
|
|
1078
1052
|
function InstancedBoxes({ matrices, color }) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type BuildingKitInstance } from "./buildingKitFit.js";
|
|
2
|
+
/** Props for {@link BuildingKitBatch}: one resolved model URL and every kit instance bound to it. @internal */
|
|
3
|
+
export interface BuildingKitBatchProps {
|
|
4
|
+
url: string;
|
|
5
|
+
instances: readonly BuildingKitInstance[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* GPU-instances one kit model across every building part bound to it: the GLB loads once, its meshes
|
|
9
|
+
* are harvested into instanceable draw sources, and each part's slot box drives the fit scale. Suspends
|
|
10
|
+
* while the GLB loads — mount it inside a `<Suspense>` so one slow model never blanks the rest.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare function BuildingKitBatch({ url, instances }: BuildingKitBatchProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { useLoader } from "@react-three/fiber";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { sharedGltfLoader } from "../render/modelLoad.js";
|
|
6
|
+
import { buildScatterModelSources, disposeScatterModelSources, } from "../scatter/scatterModels.js";
|
|
7
|
+
import { composeBuildingKitMatrix, measureBuildingKitModel, } from "./buildingKitFit.js";
|
|
8
|
+
function KitSourceInstances({ source, material, matrices, }) {
|
|
9
|
+
const meshRef = useRef(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const mesh = meshRef.current;
|
|
12
|
+
if (mesh === null)
|
|
13
|
+
return;
|
|
14
|
+
const composed = new THREE.Matrix4();
|
|
15
|
+
for (let i = 0; i < matrices.length; i += 1) {
|
|
16
|
+
composed.multiplyMatrices(matrices[i], source.localMatrix);
|
|
17
|
+
mesh.setMatrixAt(i, composed);
|
|
18
|
+
}
|
|
19
|
+
mesh.count = matrices.length;
|
|
20
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
21
|
+
mesh.computeBoundingSphere();
|
|
22
|
+
}, [matrices, source]);
|
|
23
|
+
return (_jsx("instancedMesh", { ref: meshRef, args: [source.geometry, material, matrices.length], castShadow: true, receiveShadow: true }, matrices.length));
|
|
24
|
+
}
|
|
25
|
+
function tintOne(material, color) {
|
|
26
|
+
const clone = material.clone();
|
|
27
|
+
const tintable = clone;
|
|
28
|
+
if (tintable.color !== undefined)
|
|
29
|
+
tintable.color.copy(color);
|
|
30
|
+
return clone;
|
|
31
|
+
}
|
|
32
|
+
function tintMaterial(material, tint) {
|
|
33
|
+
const color = new THREE.Color(tint);
|
|
34
|
+
return Array.isArray(material) ? material.map((entry) => tintOne(entry, color)) : tintOne(material, color);
|
|
35
|
+
}
|
|
36
|
+
function disposeMaterial(material) {
|
|
37
|
+
if (Array.isArray(material))
|
|
38
|
+
for (const entry of material)
|
|
39
|
+
entry.dispose();
|
|
40
|
+
else
|
|
41
|
+
material.dispose();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* GPU-instances one kit model across every building part bound to it: the GLB loads once, its meshes
|
|
45
|
+
* are harvested into instanceable draw sources, and each part's slot box drives the fit scale. Suspends
|
|
46
|
+
* while the GLB loads — mount it inside a `<Suspense>` so one slow model never blanks the rest.
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export function BuildingKitBatch({ url, instances }) {
|
|
50
|
+
const gltf = useLoader(sharedGltfLoader, url);
|
|
51
|
+
const { sources, root } = useMemo(() => buildScatterModelSources(gltf.scene, { url }), [gltf, url]);
|
|
52
|
+
useEffect(() => () => disposeScatterModelSources(root), [root]);
|
|
53
|
+
const bounds = useMemo(() => measureBuildingKitModel(root), [root]);
|
|
54
|
+
const groups = useMemo(() => {
|
|
55
|
+
const byTint = new Map();
|
|
56
|
+
for (const instance of instances) {
|
|
57
|
+
const matrix = composeBuildingKitMatrix(instance, bounds, new THREE.Matrix4());
|
|
58
|
+
const tint = instance.part.tint ?? "";
|
|
59
|
+
const bucket = byTint.get(tint);
|
|
60
|
+
if (bucket === undefined)
|
|
61
|
+
byTint.set(tint, [matrix]);
|
|
62
|
+
else
|
|
63
|
+
bucket.push(matrix);
|
|
64
|
+
}
|
|
65
|
+
return [...byTint.entries()];
|
|
66
|
+
}, [instances, bounds]);
|
|
67
|
+
const tinted = useMemo(() => {
|
|
68
|
+
const map = new Map();
|
|
69
|
+
for (const [tint] of groups) {
|
|
70
|
+
if (tint === "")
|
|
71
|
+
continue;
|
|
72
|
+
map.set(tint, sources.map((source) => tintMaterial(source.material, tint)));
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}, [groups, sources]);
|
|
76
|
+
useEffect(() => () => tinted.forEach((list) => list.forEach(disposeMaterial)), [tinted]);
|
|
77
|
+
return (_jsx(_Fragment, { children: groups.map(([tint, matrices]) => sources.map((source, index) => (_jsx(KitSourceInstances, { source: source, material: tinted.get(tint)?.[index] ?? source.material, matrices: matrices }, `${tint}:${index}`)))) }));
|
|
78
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import type { BuildingKitSlot } from "@jgengine/core/world/buildings";
|
|
3
|
+
import type { BuildingKit } from "@jgengine/core/world/buildingKit";
|
|
2
4
|
export type BuildingFacade = "front" | "back" | "left" | "right" | "roof";
|
|
3
5
|
export type BuildingPartKind = "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner";
|
|
4
6
|
export interface BuildingPartPlacement {
|
|
@@ -8,6 +10,8 @@ export interface BuildingPartPlacement {
|
|
|
8
10
|
position: readonly [number, number, number];
|
|
9
11
|
rotationY: number;
|
|
10
12
|
scale: readonly [number, number, number];
|
|
13
|
+
/** Which kit variant this slot asks for; emitted by the generator, consumed by `BuildingKit` binding. */
|
|
14
|
+
kit?: BuildingKitSlot;
|
|
11
15
|
}
|
|
12
16
|
export interface GeneratedBuildingData {
|
|
13
17
|
id: string;
|
|
@@ -37,7 +41,12 @@ export interface BuildingBlockProps {
|
|
|
37
41
|
export interface GeneratedBuildingProps {
|
|
38
42
|
building: GeneratedBuildingData;
|
|
39
43
|
palette?: BuildingMaterialPalette;
|
|
40
|
-
kit
|
|
44
|
+
/** Per-part React escape hatch, tried before the kit; return undefined to fall through. */
|
|
45
|
+
partRenderer?: BuildingKitRenderer;
|
|
46
|
+
/** Binds part kinds to instanced GLB models; unbound kinds keep the palette boxes. */
|
|
47
|
+
kit?: BuildingKit;
|
|
48
|
+
/** Maps a kit part's `model` reference to a loadable URL. Defaults to identity. */
|
|
49
|
+
resolveModelUrl?: (model: string) => string;
|
|
41
50
|
visibleKinds?: readonly BuildingPartKind[];
|
|
42
51
|
}
|
|
43
52
|
export interface InstancedBuildingPlacement {
|
|
@@ -51,8 +60,15 @@ export interface InstancedBuildingPlacement {
|
|
|
51
60
|
export interface InstancedBuildingsProps {
|
|
52
61
|
buildings: readonly InstancedBuildingPlacement[];
|
|
53
62
|
palette?: BuildingMaterialPalette;
|
|
63
|
+
/**
|
|
64
|
+
* Binds part kinds to instanced GLB models. Kinds the kit leaves unbound keep rendering the palette
|
|
65
|
+
* boxes, and kinds it lists in `omit` render nothing; omit the prop for today's all-box massing.
|
|
66
|
+
*/
|
|
67
|
+
kit?: BuildingKit;
|
|
68
|
+
/** Maps a kit part's `model` reference to a loadable URL. Defaults to identity. */
|
|
69
|
+
resolveModelUrl?: (model: string) => string;
|
|
54
70
|
visibleKinds?: readonly BuildingPartKind[];
|
|
55
71
|
}
|
|
56
|
-
export declare function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): import("react").JSX.Element | null;
|
|
72
|
+
export declare function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }: InstancedBuildingsProps): import("react").JSX.Element | null;
|
|
57
73
|
export declare function BuildingBlock({ part, palette }: BuildingBlockProps): import("react").JSX.Element;
|
|
58
|
-
export declare function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): import("react").JSX.Element;
|
|
74
|
+
export declare function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }: GeneratedBuildingProps): import("react").JSX.Element;
|