@jgengine/shell 0.14.0 → 0.15.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 (115) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/GamePlayerShell.js +25 -5
  3. package/dist/Shell3dPresentation.d.ts +6 -6
  4. package/dist/Shell3dPresentation.js +4 -4
  5. package/dist/audio/synthEngine.js +12 -2
  6. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  7. package/dist/camera/ControllerCameraShake.js +34 -0
  8. package/dist/camera/GameCameraRig.js +10 -5
  9. package/dist/camera/GameOrbitCamera.js +1 -10
  10. package/dist/camera/cameraRigs.js +12 -4
  11. package/dist/camera/index.d.ts +1 -0
  12. package/dist/camera/index.js +1 -0
  13. package/dist/camera/rigMath.d.ts +2 -0
  14. package/dist/camera/rigMath.js +7 -0
  15. package/dist/devtools/CollisionDebugWorld.js +2 -2
  16. package/dist/devtools/DevtoolsOverlay.js +2 -0
  17. package/dist/drivers/FrameDriver.d.ts +4 -1
  18. package/dist/drivers/FrameDriver.js +5 -3
  19. package/dist/environment/DayNightSky.d.ts +33 -0
  20. package/dist/environment/DayNightSky.js +51 -0
  21. package/dist/environment/Daylight.d.ts +3 -1
  22. package/dist/environment/Daylight.js +7 -3
  23. package/dist/environment/EnvironmentScene.js +6 -6
  24. package/dist/environment/RoadJunctions.d.ts +8 -4
  25. package/dist/environment/RoadJunctions.js +16 -15
  26. package/dist/environment/RoadRibbons.d.ts +42 -6
  27. package/dist/environment/RoadRibbons.js +150 -27
  28. package/dist/environment/VolumetricClouds.js +3 -3
  29. package/dist/environment/index.d.ts +1 -0
  30. package/dist/environment/index.js +1 -0
  31. package/dist/gameKit.d.ts +7 -4
  32. package/dist/gameKit.js +8 -5
  33. package/dist/inputSink.js +14 -0
  34. package/dist/materialOverride.js +30 -0
  35. package/dist/postfx/PostProcessing.d.ts +3 -2
  36. package/dist/postfx/PostProcessing.js +11 -3
  37. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  38. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  39. package/dist/presentationResolve.d.ts +3 -2
  40. package/dist/presentationResolve.js +11 -6
  41. package/dist/render/CascadedShadows.d.ts +13 -0
  42. package/dist/render/CascadedShadows.js +96 -0
  43. package/dist/render/EnvironmentLighting.d.ts +23 -7
  44. package/dist/render/EnvironmentLighting.js +69 -10
  45. package/dist/render/PartMotion.js +4 -4
  46. package/dist/render/SceneLighting.js +7 -0
  47. package/dist/render/SceneModels.d.ts +37 -1
  48. package/dist/render/SceneModels.js +40 -11
  49. package/dist/render/modelLoad.d.ts +41 -5
  50. package/dist/render/modelLoad.js +85 -18
  51. package/dist/render/sceneCapture.d.ts +27 -0
  52. package/dist/render/sceneCapture.js +43 -0
  53. package/dist/render/useDisposable.d.ts +16 -0
  54. package/dist/render/useDisposable.js +24 -0
  55. package/dist/render/useModelAnimation.js +47 -4
  56. package/dist/scene/AuthoredScene.js +14 -24
  57. package/dist/scene/CityRenderer.js +103 -36
  58. package/dist/scene/GrassFieldRenderer.js +4 -1
  59. package/dist/scene/PoleLineRenderer.js +7 -16
  60. package/dist/settings/QuickControls.js +5 -3
  61. package/dist/settings/SettingsMenu.js +6 -3
  62. package/dist/settings/appliedSettings.d.ts +3 -1
  63. package/dist/settings/appliedSettings.js +17 -5
  64. package/dist/settings/settingsController.js +11 -5
  65. package/dist/shellMovement.d.ts +15 -1
  66. package/dist/shellMovement.js +15 -1
  67. package/dist/structures/GeneratedBuilding.js +4 -5
  68. package/dist/structures/TransformGizmo.js +6 -5
  69. package/dist/terrain/CarvedTerrain.js +3 -7
  70. package/dist/terrain/GrassField.js +2 -2
  71. package/dist/terrain/ProceduralGround.js +3 -5
  72. package/dist/terrain/grassMaterial.d.ts +8 -2
  73. package/dist/terrain/grassMaterial.js +24 -5
  74. package/dist/terrain/index.d.ts +1 -1
  75. package/dist/terrain/terrainDetailMaterial.js +11 -2
  76. package/dist/terrain/terrainMath.d.ts +38 -1
  77. package/dist/terrain/terrainMath.js +86 -64
  78. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  79. package/dist/touch/TouchControlsOverlay.js +136 -17
  80. package/dist/vfx/ParticleField.d.ts +32 -0
  81. package/dist/vfx/ParticleField.js +91 -0
  82. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  83. package/dist/vfx/WorldFloatingText.js +32 -0
  84. package/dist/water/Ocean.js +3 -6
  85. package/dist/weather/LightningStrike.js +3 -9
  86. package/dist/weather/RainField.js +3 -7
  87. package/dist/weather/SnowField.js +3 -7
  88. package/dist/world/DataObjects.js +3 -6
  89. package/dist/world/GridWorldScene.js +3 -6
  90. package/dist/world/InstancedBodies.js +3 -6
  91. package/dist/world/InstancedJoints.js +2 -2
  92. package/dist/world/SpriteBatch.js +3 -6
  93. package/dist/world/WorldEntityFrames.d.ts +47 -0
  94. package/dist/world/WorldEntityFrames.js +75 -0
  95. package/dist/world/WorldHud.js +7 -5
  96. package/dist/world/WorldPings.d.ts +26 -0
  97. package/dist/world/WorldPings.js +68 -0
  98. package/dist/world/WorldScene.js +9 -4
  99. package/dist/world/pingPulse.d.ts +21 -0
  100. package/dist/world/pingPulse.js +17 -0
  101. package/package.json +5 -5
  102. package/skills/ce-handoff/SKILL.md +149 -0
  103. package/skills/game-design/SKILL.md +2 -0
  104. package/skills/jgengine/SKILL.md +3 -1
  105. package/skills/jgengine/api.md +17 -7
  106. package/skills/jgengine/capabilities.md +12 -0
  107. package/skills/jgengine/recipes/minimal-game.md +7 -4
  108. package/skills/jgengine-ui/SKILL.md +5 -3
  109. package/skills/jgengine-ui/api.md +708 -19
  110. package/skills/jgengine-ui/capabilities.md +362 -1
  111. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  112. package/skills/jgengine-ui/reference.md +7 -4
  113. package/skills/jgengine-world/api.md +397 -11
  114. package/skills/jgengine-world/capabilities.md +123 -2
  115. package/skills/jgengine-world/reference.md +15 -1
@@ -5,6 +5,7 @@ import { EffectComposer, RenderPass, UnrealBloomPass } from "three-stdlib";
5
5
  import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
6
6
  import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
7
  import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
8
+ import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
8
9
  import { createGradePass } from "./gradeShader.js";
9
10
  import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
10
11
  /**
@@ -59,14 +60,15 @@ function syncSize(built, width, height, pixelRatio) {
59
60
  /**
60
61
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
61
62
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
62
- * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
63
+ * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
63
64
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
64
65
  *
65
66
  * `quality` (the player's graphics-quality setting) gates the passes whose cost
66
67
  * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
67
68
  * the whole scene for depth/normals and runs a multi-sample full-screen pass,
68
69
  * and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
69
- * tone mapping, and grade stay on every tier.
70
+ * SMAA, tone mapping, and grade stay on every tier (SMAA is the cheap edge fix
71
+ * for alpha-tested foliage that MSAA samples alone leave crawling).
70
72
  */
71
73
  export function PostProcessing({ config, quality = "high" }) {
72
74
  const gl = useThree((s) => s.gl);
@@ -77,9 +79,11 @@ export function PostProcessing({ config, quality = "high" }) {
77
79
  useEffect(() => {
78
80
  const width = Math.max(1, size.width);
79
81
  const height = Math.max(1, size.height);
82
+ const aa = config.aa === undefined ? "smaa" : config.aa;
83
+ const msaaSamples = aa === false ? 0 : 2;
80
84
  const target = new THREE.WebGLRenderTarget(width, height, {
81
85
  type: THREE.HalfFloatType,
82
- samples: 2,
86
+ samples: msaaSamples,
83
87
  });
84
88
  const composer = new EffectComposer(gl, target);
85
89
  composer.addPass(new RenderPass(scene, camera));
@@ -110,6 +114,10 @@ export function PostProcessing({ config, quality = "high" }) {
110
114
  });
111
115
  composer.addPass(dof);
112
116
  }
117
+ // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
118
+ if (aa === "smaa") {
119
+ composer.addPass(new SMAAPass());
120
+ }
113
121
  composer.addPass(new OutputPass());
114
122
  let grade = null;
115
123
  if (config.grade !== false) {
@@ -0,0 +1,32 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { ScreenEffectsController } from "@jgengine/core/vfx/screenEffects";
3
+ /** Reskin tokens for {@link ScreenEffectsOverlay}. */
4
+ export interface ScreenEffectsOverlayProps {
5
+ /** The model to render. */
6
+ controller: ScreenEffectsController;
7
+ /**
8
+ * CSS blend mode for the tint layers. Default `"normal"` (straight alpha
9
+ * composite). `"screen"` or `"multiply"` read as more filmic color grades.
10
+ */
11
+ blendMode?: CSSProperties["mixBlendMode"];
12
+ /** Extra `z-index` for the overlay root. Default `40`. */
13
+ zIndex?: number;
14
+ /**
15
+ * Drive `advance()` on an animation frame. Default `true`. Turn off if the game
16
+ * already advances the controller from its own tick.
17
+ */
18
+ animate?: boolean;
19
+ }
20
+ /**
21
+ * A DOM overlay that renders a {@link ScreenEffectsController} as full-screen
22
+ * color-grade layers over the game canvas — damage vignettes, heal flashes, a
23
+ * sustained low-health pulse — through the shell's postfx overlay layer, so a game
24
+ * gets screen feedback without hand-rolling shader or overlay timing. It subscribes
25
+ * to the model, drives `advance()` on an animation frame (unless the game ticks it),
26
+ * and paints one absolutely-positioned, pointer-transparent layer per active effect
27
+ * at its eased opacity. Presentation only: all timing and state live in the core
28
+ * model, and `kind` is never interpreted here.
29
+ *
30
+ * @capability screen-effects-overlay shell DOM overlay that renders a core screen-effects controller as full-screen flash / edge-vignette / low-health-pulse color-grade layers
31
+ */
32
+ export declare function ScreenEffectsOverlay({ controller, blendMode, zIndex, animate, }: ScreenEffectsOverlayProps): ReactNode;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef } from "react";
3
+ /**
4
+ * Build the CSS `background` for one effect at its current eased opacity. A
5
+ * `"full"` effect tints the whole frame; a `"vignette"` effect tints only the
6
+ * edges via a radial gradient that stays transparent in the center. Genre-blind —
7
+ * it reads `color`/`shape`/`intensity` off the model and nothing else.
8
+ */
9
+ function effectBackground(effect) {
10
+ if (effect.shape === "vignette") {
11
+ // Transparent core → the effect color at the corners, scaled by opacity.
12
+ return `radial-gradient(ellipse at center, transparent 40%, ${effect.color} 140%)`;
13
+ }
14
+ return effect.color;
15
+ }
16
+ /** Per-effect fill layer opacity, so `color` can stay a plain CSS color string. */
17
+ function effectOpacity(effect) {
18
+ const a = effect.intensity;
19
+ return a < 0 ? 0 : a > 1 ? 1 : a;
20
+ }
21
+ /**
22
+ * A DOM overlay that renders a {@link ScreenEffectsController} as full-screen
23
+ * color-grade layers over the game canvas — damage vignettes, heal flashes, a
24
+ * sustained low-health pulse — through the shell's postfx overlay layer, so a game
25
+ * gets screen feedback without hand-rolling shader or overlay timing. It subscribes
26
+ * to the model, drives `advance()` on an animation frame (unless the game ticks it),
27
+ * and paints one absolutely-positioned, pointer-transparent layer per active effect
28
+ * at its eased opacity. Presentation only: all timing and state live in the core
29
+ * model, and `kind` is never interpreted here.
30
+ *
31
+ * @capability screen-effects-overlay shell DOM overlay that renders a core screen-effects controller as full-screen flash / edge-vignette / low-health-pulse color-grade layers
32
+ */
33
+ export function ScreenEffectsOverlay({ controller, blendMode = "normal", zIndex = 40, animate = true, }) {
34
+ const [, bump] = useReducer((n) => n + 1, 0);
35
+ const rafRef = useRef(0);
36
+ useEffect(() => controller.subscribe(bump), [controller]);
37
+ useEffect(() => {
38
+ if (!animate)
39
+ return;
40
+ let running = true;
41
+ const loop = () => {
42
+ if (!running)
43
+ return;
44
+ controller.advance();
45
+ rafRef.current = requestAnimationFrame(loop);
46
+ };
47
+ rafRef.current = requestAnimationFrame(loop);
48
+ return () => {
49
+ running = false;
50
+ cancelAnimationFrame(rafRef.current);
51
+ };
52
+ }, [controller, animate]);
53
+ const composite = controller.composite();
54
+ if (composite.length === 0)
55
+ return null;
56
+ const root = {
57
+ position: "absolute",
58
+ inset: 0,
59
+ pointerEvents: "none",
60
+ overflow: "hidden",
61
+ zIndex,
62
+ };
63
+ return (_jsx("div", { style: root, "aria-hidden": "true", "data-jg-screen-effects": "", children: composite.map((effect) => (_jsx("div", { style: {
64
+ position: "absolute",
65
+ inset: 0,
66
+ background: effectBackground(effect),
67
+ opacity: effectOpacity(effect),
68
+ mixBlendMode: blendMode,
69
+ } }, effect.id))) }));
70
+ }
@@ -31,9 +31,10 @@ export interface ResolvedPresentationEffects {
31
31
  }
32
32
  /**
33
33
  * Resolve `presentationEffects` for the 3D shell.
34
- * - `undefined` / `true` → all channels on (historical default).
34
+ * - `undefined` / `true` → default channels (telegraphs, vfx, floatText, shake on; tracers off).
35
35
  * - `false` → none.
36
- * - object → per-channel; missing keys default to on so games can disable one channel.
36
+ * - object → per-channel; missing keys default on, EXCEPT `tracers`, which is opt-in and only
37
+ * enabled by an explicit `tracers: true`.
37
38
  * @internal
38
39
  */
39
40
  export declare function resolvePresentationEffects(config: boolean | PresentationEffectsConfig | undefined): ResolvedPresentationEffects;
@@ -17,11 +17,15 @@ export function resolveWorldOverlayBars(config) {
17
17
  ...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
18
18
  };
19
19
  }
20
- const ALL_ON = {
20
+ // Every channel defaults on EXCEPT tracers. `fireProjectile` is a generic seam (bullets,
21
+ // bolts, grenades, launchers), so a muzzle→impact tracer line is only ever right for a subset
22
+ // of shots — it must never appear in a game that never asked for it. Tracers are therefore
23
+ // opt-in: a game turns them on with `presentationEffects: { tracers: true }`.
24
+ const DEFAULTS = {
21
25
  telegraphs: true,
22
26
  vfx: true,
23
27
  floatText: true,
24
- tracers: true,
28
+ tracers: false,
25
29
  shake: true,
26
30
  };
27
31
  const ALL_OFF = {
@@ -33,21 +37,22 @@ const ALL_OFF = {
33
37
  };
34
38
  /**
35
39
  * Resolve `presentationEffects` for the 3D shell.
36
- * - `undefined` / `true` → all channels on (historical default).
40
+ * - `undefined` / `true` → default channels (telegraphs, vfx, floatText, shake on; tracers off).
37
41
  * - `false` → none.
38
- * - object → per-channel; missing keys default to on so games can disable one channel.
42
+ * - object → per-channel; missing keys default on, EXCEPT `tracers`, which is opt-in and only
43
+ * enabled by an explicit `tracers: true`.
39
44
  * @internal
40
45
  */
41
46
  export function resolvePresentationEffects(config) {
42
47
  if (config === false)
43
48
  return ALL_OFF;
44
49
  if (config === undefined || config === true)
45
- return ALL_ON;
50
+ return { ...DEFAULTS };
46
51
  return {
47
52
  telegraphs: config.telegraphs !== false,
48
53
  vfx: config.vfx !== false,
49
54
  floatText: config.floatText !== false,
50
- tracers: config.tracers !== false,
55
+ tracers: config.tracers === true,
51
56
  shake: config.shake !== false,
52
57
  };
53
58
  }
@@ -0,0 +1,13 @@
1
+ import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
2
+ /**
3
+ * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
4
+ * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
5
+ *
6
+ * Mount when `entry.cascades > 1` and `castShadow`. Replaces the single R3F directional
7
+ * shadow light for that entry.
8
+ *
9
+ * @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
10
+ */
11
+ export declare function CascadedShadows({ entry }: {
12
+ entry: DirectionalLightingConfig;
13
+ }): null;
@@ -0,0 +1,96 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { useEffect, useRef } from "react";
3
+ import * as THREE from "three";
4
+ import { CSM } from "three/examples/jsm/csm/CSM.js";
5
+ function isStandardMaterial(mat) {
6
+ return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
7
+ }
8
+ /**
9
+ * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
10
+ * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
11
+ *
12
+ * Mount when `entry.cascades > 1` and `castShadow`. Replaces the single R3F directional
13
+ * shadow light for that entry.
14
+ *
15
+ * @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
16
+ */
17
+ export function CascadedShadows({ entry }) {
18
+ const camera = useThree((s) => s.camera);
19
+ const scene = useThree((s) => s.scene);
20
+ const csmRef = useRef(null);
21
+ const patchedRef = useRef(new WeakSet());
22
+ const frameRef = useRef(0);
23
+ useEffect(() => {
24
+ const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
25
+ const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
26
+ if (lightDirection.lengthSq() < 1e-8)
27
+ lightDirection.set(-1, -1, -1);
28
+ lightDirection.normalize();
29
+ const csm = new CSM({
30
+ camera,
31
+ parent: scene,
32
+ cascades,
33
+ maxFar: entry.shadowMaxFar ?? 200,
34
+ mode: "practical",
35
+ shadowMapSize: entry.shadowMapSize ?? 1024,
36
+ shadowBias: entry.shadowBias ?? -0.0004,
37
+ lightDirection,
38
+ lightIntensity: entry.intensity ?? 1.3,
39
+ lightNear: 0.5,
40
+ lightFar: Math.max(200, (entry.shadowCameraSize ?? 40) * 6),
41
+ lightMargin: entry.shadowCameraSize ?? 40,
42
+ });
43
+ for (const light of csm.lights) {
44
+ light.color = new THREE.Color(entry.color ?? "#ffffff");
45
+ light.castShadow = true;
46
+ }
47
+ patchedRef.current = new WeakSet();
48
+ patchSceneMaterials(scene, csm, patchedRef.current);
49
+ csmRef.current = csm;
50
+ frameRef.current = 0;
51
+ return () => {
52
+ csm.dispose();
53
+ csmRef.current = null;
54
+ };
55
+ }, [
56
+ camera,
57
+ scene,
58
+ entry.cascades,
59
+ entry.color,
60
+ entry.intensity,
61
+ entry.position[0],
62
+ entry.position[1],
63
+ entry.position[2],
64
+ entry.shadowBias,
65
+ entry.shadowCameraSize,
66
+ entry.shadowMapSize,
67
+ entry.shadowMaxFar,
68
+ ]);
69
+ useFrame(() => {
70
+ const csm = csmRef.current;
71
+ if (csm === null)
72
+ return;
73
+ csm.camera = camera;
74
+ frameRef.current += 1;
75
+ // Async model mounts need setupMaterial — re-scan every 30 frames after the first.
76
+ if (frameRef.current === 1 || frameRef.current % 30 === 0) {
77
+ patchSceneMaterials(scene, csm, patchedRef.current);
78
+ }
79
+ csm.update();
80
+ });
81
+ return null;
82
+ }
83
+ function patchSceneMaterials(scene, csm, patched) {
84
+ scene.traverse((obj) => {
85
+ const mesh = obj;
86
+ if (!mesh.isMesh)
87
+ return;
88
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
89
+ for (const mat of materials) {
90
+ if (!isStandardMaterial(mat) || patched.has(mat))
91
+ continue;
92
+ csm.setupMaterial(mat);
93
+ patched.add(mat);
94
+ }
95
+ });
96
+ }
@@ -1,10 +1,26 @@
1
+ /** @internal Props for shell {@link EnvironmentLighting}. */
2
+ export interface EnvironmentLightingProps {
3
+ /** IBL intensity. Default 0.35. */
4
+ intensity?: number;
5
+ /** Upper-hemisphere tint for the procedural sky probe. Default cool daylight. */
6
+ skyColor?: string;
7
+ /** Ground / lower-hemisphere tint. Default soft earth. */
8
+ groundColor?: string;
9
+ /**
10
+ * Optional sun direction (world space, not normalized). When set, a bright disc
11
+ * is baked into the probe so metals pick up a real sun glint that tracks daylight.
12
+ */
13
+ sunDirection?: readonly [number, number, number];
14
+ /** Sun disc tint. Default warm white. */
15
+ sunColor?: string;
16
+ }
1
17
  /**
2
- * Sets a neutral, network-free image-based-lighting environment on the scene so every
3
- * `MeshStandardMaterial` — models and un-modeled primitive fallbacks alike — picks up soft
4
- * reflections and fill instead of rendering dead-flat. A game's own sky/lighting still layers on
5
- * top; this only supplies the reflective ambient the shell never had. Opt out with `look: "flat"`.
18
+ * Image-based lighting from a procedural sky/ground probe (not three's stock
19
+ * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
20
+ * an optional sun glint; re-render when sky/sun props change so a daylight cycle
21
+ * regenerates the environment map with the authored world.
22
+ *
23
+ * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
6
24
  * @internal shell-internal default lighting; games never import it.
7
25
  */
8
- export declare function EnvironmentLighting({ intensity }: {
9
- intensity?: number;
10
- }): null;
26
+ export declare function EnvironmentLighting({ intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;
@@ -1,27 +1,28 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { useEffect } from "react";
3
3
  import * as THREE from "three";
4
- import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
5
4
  /**
6
- * Sets a neutral, network-free image-based-lighting environment on the scene so every
7
- * `MeshStandardMaterial` — models and un-modeled primitive fallbacks alike — picks up soft
8
- * reflections and fill instead of rendering dead-flat. A game's own sky/lighting still layers on
9
- * top; this only supplies the reflective ambient the shell never had. Opt out with `look: "flat"`.
5
+ * Image-based lighting from a procedural sky/ground probe (not three's stock
6
+ * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
7
+ * an optional sun glint; re-render when sky/sun props change so a daylight cycle
8
+ * regenerates the environment map with the authored world.
9
+ *
10
+ * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
10
11
  * @internal shell-internal default lighting; games never import it.
11
12
  */
12
- export function EnvironmentLighting({ intensity = 0.28 }) {
13
+ export function EnvironmentLighting({ intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
13
14
  const gl = useThree((state) => state.gl);
14
15
  const scene = useThree((state) => state.scene);
15
16
  useEffect(() => {
16
17
  const pmrem = new THREE.PMREMGenerator(gl);
17
- const room = new RoomEnvironment();
18
- const target = pmrem.fromScene(room, 0.04);
18
+ const probe = buildSkyProbeScene({ skyColor, groundColor, sunDirection, sunColor });
19
+ const target = pmrem.fromScene(probe, 0.04);
19
20
  const previous = scene.environment;
20
21
  scene.environment = target.texture;
21
22
  const sceneWithIntensity = scene;
22
23
  const previousIntensity = sceneWithIntensity.environmentIntensity;
23
24
  sceneWithIntensity.environmentIntensity = intensity;
24
- room.dispose?.();
25
+ disposeScene(probe);
25
26
  pmrem.dispose();
26
27
  return () => {
27
28
  target.dispose();
@@ -29,6 +30,64 @@ export function EnvironmentLighting({ intensity = 0.28 }) {
29
30
  if (previousIntensity !== undefined)
30
31
  sceneWithIntensity.environmentIntensity = previousIntensity;
31
32
  };
32
- }, [gl, scene, intensity]);
33
+ }, [gl, scene, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
33
34
  return null;
34
35
  }
36
+ function buildSkyProbeScene(options) {
37
+ const probe = new THREE.Scene();
38
+ // Inward-facing sphere: sky above, ground below, so PMREM samples a sky dome not a studio box.
39
+ const geo = new THREE.SphereGeometry(50, 32, 16);
40
+ const mat = new THREE.ShaderMaterial({
41
+ side: THREE.BackSide,
42
+ depthWrite: false,
43
+ uniforms: {
44
+ sky: { value: new THREE.Color(options.skyColor) },
45
+ ground: { value: new THREE.Color(options.groundColor) },
46
+ },
47
+ vertexShader: /* glsl */ `
48
+ varying vec3 vDir;
49
+ void main() {
50
+ vDir = normalize(position);
51
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
52
+ }
53
+ `,
54
+ fragmentShader: /* glsl */ `
55
+ uniform vec3 sky;
56
+ uniform vec3 ground;
57
+ varying vec3 vDir;
58
+ void main() {
59
+ float t = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0);
60
+ // Soft horizon blend so the equator is not a hard band.
61
+ float w = smoothstep(0.0, 1.0, t);
62
+ gl_FragColor = vec4(mix(ground, sky, w), 1.0);
63
+ }
64
+ `,
65
+ });
66
+ probe.add(new THREE.Mesh(geo, mat));
67
+ if (options.sunDirection !== undefined) {
68
+ const dir = new THREE.Vector3(options.sunDirection[0], options.sunDirection[1], options.sunDirection[2]);
69
+ if (dir.lengthSq() > 1e-8) {
70
+ dir.normalize();
71
+ const sunGeo = new THREE.SphereGeometry(3.5, 16, 12);
72
+ const sunMat = new THREE.MeshBasicMaterial({ color: options.sunColor });
73
+ const sun = new THREE.Mesh(sunGeo, sunMat);
74
+ // Place the disc outside the sky sphere sample so it stays a compact highlight.
75
+ sun.position.copy(dir.multiplyScalar(42));
76
+ probe.add(sun);
77
+ }
78
+ }
79
+ return probe;
80
+ }
81
+ function disposeScene(scene) {
82
+ scene.traverse((obj) => {
83
+ const mesh = obj;
84
+ if (mesh.isMesh) {
85
+ mesh.geometry?.dispose();
86
+ const material = mesh.material;
87
+ if (Array.isArray(material))
88
+ material.forEach((m) => m.dispose());
89
+ else
90
+ material?.dispose();
91
+ }
92
+ });
93
+ }
@@ -3,7 +3,7 @@ import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { createPartPose, partMotionPhase, sampleBodyPose, samplePartPose, } from "@jgengine/core/game/partAnimation";
6
- import { useGameContext } from "@jgengine/react/provider";
6
+ import { useOptionalGameContext } from "@jgengine/react/provider";
7
7
  const FLINCH_RELEASE_SEC = 0.35;
8
8
  const DEATH_RAMP_PER_SEC = 2.5;
9
9
  const SPEED_SMOOTHING = 12;
@@ -16,7 +16,7 @@ const SPEED_SMOOTHING = 12;
16
16
  * as static kit pieces. Children are the base model content (primitive, attachments).
17
17
  */
18
18
  export function PartMotionRig({ parts, model, instanceId, renderPart, children, }) {
19
- const ctx = useGameContext();
19
+ const ctx = useOptionalGameContext();
20
20
  const rootRef = useRef(null);
21
21
  const partRefs = useRef([]);
22
22
  const motionRef = useRef({
@@ -27,7 +27,7 @@ export function PartMotionRig({ parts, model, instanceId, renderPart, children,
27
27
  });
28
28
  const phase = useMemo(() => partMotionPhase(instanceId ?? model.url), [instanceId, model.url]);
29
29
  useEffect(() => {
30
- if (instanceId === undefined)
30
+ if (ctx === null || instanceId === undefined)
31
31
  return;
32
32
  const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
33
33
  if (event.instanceId === instanceId)
@@ -46,7 +46,7 @@ export function PartMotionRig({ parts, model, instanceId, renderPart, children,
46
46
  const pose = useMemo(() => createPartPose(), []);
47
47
  useFrame((frameState, delta) => {
48
48
  const motion = motionRef.current;
49
- if (delta > 0 && instanceId !== undefined) {
49
+ if (delta > 0 && instanceId !== undefined && ctx !== null) {
50
50
  const entity = ctx.scene.entity.get(instanceId);
51
51
  if (entity !== null) {
52
52
  const [x, , z] = entity.position;
@@ -1,6 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CascadedShadows } from "./CascadedShadows.js";
2
3
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
4
+ function usesCascades(entry) {
5
+ return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
6
+ }
3
7
  function DirectionalShadowLight({ entry }) {
8
+ if (usesCascades(entry)) {
9
+ return _jsx(CascadedShadows, { entry: entry });
10
+ }
4
11
  const size = entry.shadowCameraSize ?? 40;
5
12
  return (_jsx("directionalLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": entry.shadowMapSize ?? 1024, "shadow-mapSize-height": entry.shadowMapSize ?? 1024, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": Math.max(200, size * 6), "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
6
13
  }
@@ -1,8 +1,44 @@
1
- import { type ReactNode } from "react";
1
+ import { Component, type ReactNode } from "react";
2
2
  import type { EntitySpriteConfig, ModelConfig } from "@jgengine/core/game/playableGame";
3
+ import { type FallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
3
4
  export declare function EntitySprite({ sprite }: {
4
5
  sprite: EntitySpriteConfig;
5
6
  }): import("react").JSX.Element;
7
+ /**
8
+ * Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
9
+ * failed state re-reports to the fallback-seam probe on every render (matching the per-seam frame
10
+ * boundary in `WorldScene`) and the catch warns once with the model url and the error. Without
11
+ * that, a throw anywhere in a composition erases the actor with an empty `probes.fallbacks`.
12
+ * @internal
13
+ */
14
+ export declare class ModelFallbackBoundary extends Component<{
15
+ fallback: ReactNode;
16
+ children: ReactNode;
17
+ url: string;
18
+ seam?: FallbackSeam;
19
+ }, {
20
+ failed: boolean;
21
+ }> {
22
+ state: {
23
+ failed: boolean;
24
+ };
25
+ static getDerivedStateFromError(): {
26
+ failed: boolean;
27
+ };
28
+ componentDidCatch(error: unknown): void;
29
+ render(): ReactNode;
30
+ }
31
+ /**
32
+ * One model subtree behind its own error boundary and Suspense boundary, so a throwing or
33
+ * still-loading piece cannot erase its siblings. Every nested piece of a composition — parts,
34
+ * attachments, material maps — mounts through this.
35
+ * @internal
36
+ */
37
+ export declare function IsolatedModelPart({ model, seam, children, }: {
38
+ model: ModelConfig;
39
+ seam?: FallbackSeam;
40
+ children: ReactNode;
41
+ }): import("react").JSX.Element;
6
42
  export declare function IsolatedEntityModel({ model, instanceId, measure, fallback, }: {
7
43
  model: ModelConfig;
8
44
  instanceId?: string;