@jgengine/shell 0.9.0 → 0.10.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 (49) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/GamePlayerShell.d.ts +6 -21
  3. package/dist/GamePlayerShell.js +419 -234
  4. package/dist/audio/audioEngine.d.ts +8 -0
  5. package/dist/audio/audioEngine.js +33 -0
  6. package/dist/audio/musicDirector.d.ts +31 -0
  7. package/dist/audio/musicDirector.js +125 -0
  8. package/dist/audio/musicVoices.d.ts +10 -0
  9. package/dist/audio/musicVoices.js +338 -0
  10. package/dist/audio/synthEngine.d.ts +9 -0
  11. package/dist/audio/synthEngine.js +57 -0
  12. package/dist/camera/GameFirstPersonCamera.d.ts +3 -0
  13. package/dist/camera/GameFirstPersonCamera.js +21 -4
  14. package/dist/camera/GameInspectionCamera.js +12 -1
  15. package/dist/camera/GameOrbitCamera.js +35 -1
  16. package/dist/camera/orbitCameraMath.d.ts +18 -0
  17. package/dist/camera/orbitCameraMath.js +6 -1
  18. package/dist/cartridge.js +1 -0
  19. package/dist/commandSink.d.ts +20 -0
  20. package/dist/commandSink.js +27 -0
  21. package/dist/defineGame.js +3 -1
  22. package/dist/devtools/CollisionDebugWorld.js +1 -0
  23. package/dist/devtools/collisionDebugMath.d.ts +1 -0
  24. package/dist/devtools/collisionDebugMath.js +2 -1
  25. package/dist/environment/Daylight.d.ts +7 -1
  26. package/dist/environment/Daylight.js +52 -5
  27. package/dist/environment/EnvironmentScene.js +14 -5
  28. package/dist/environment/RoadRibbons.d.ts +7 -0
  29. package/dist/environment/RoadRibbons.js +57 -0
  30. package/dist/inputSink.d.ts +18 -0
  31. package/dist/inputSink.js +35 -0
  32. package/dist/multiplayer.js +7 -3
  33. package/dist/postfx/PostProcessing.d.ts +10 -0
  34. package/dist/postfx/PostProcessing.js +82 -0
  35. package/dist/postfx/gradeShader.d.ts +4 -0
  36. package/dist/postfx/gradeShader.js +64 -0
  37. package/dist/terrain/CarvedTerrain.d.ts +4 -1
  38. package/dist/terrain/CarvedTerrain.js +4 -3
  39. package/dist/terrain/terrainDetailMaterial.d.ts +14 -0
  40. package/dist/terrain/terrainDetailMaterial.js +90 -0
  41. package/dist/touch/TouchControlsOverlay.js +11 -1
  42. package/dist/world/WorldHud.d.ts +3 -1
  43. package/dist/world/WorldHud.js +7 -7
  44. package/dist/world/worldBarSamples.d.ts +1 -1
  45. package/dist/world/worldBarSamples.js +7 -1
  46. package/dist/worldSync.d.ts +10 -0
  47. package/dist/worldSync.js +19 -0
  48. package/llms.txt +140 -22
  49. package/package.json +4 -4
@@ -0,0 +1,82 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { useEffect, useMemo } from "react";
3
+ import * as THREE from "three";
4
+ import { EffectComposer, RenderPass, UnrealBloomPass } from "three-stdlib";
5
+ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
6
+ import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
+ import { createGradePass } from "./gradeShader.js";
8
+ const TONE_MAPPING = {
9
+ aces: THREE.ACESFilmicToneMapping,
10
+ agx: THREE.AgXToneMapping,
11
+ reinhard: THREE.ReinhardToneMapping,
12
+ cineon: THREE.CineonToneMapping,
13
+ linear: THREE.LinearToneMapping,
14
+ none: THREE.NoToneMapping,
15
+ };
16
+ /**
17
+ * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
18
+ * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
19
+ * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
20
+ * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
21
+ */
22
+ export function PostProcessing({ config }) {
23
+ const gl = useThree((s) => s.gl);
24
+ const scene = useThree((s) => s.scene);
25
+ const camera = useThree((s) => s.camera);
26
+ const size = useThree((s) => s.size);
27
+ const built = useMemo(() => {
28
+ const width = Math.max(1, size.width);
29
+ const height = Math.max(1, size.height);
30
+ const target = new THREE.WebGLRenderTarget(width, height, {
31
+ type: THREE.HalfFloatType,
32
+ samples: 2,
33
+ });
34
+ const composer = new EffectComposer(gl, target);
35
+ composer.addPass(new RenderPass(scene, camera));
36
+ if (config.ao !== undefined && config.ao !== false) {
37
+ const ao = new GTAOPass(scene, camera, width, height);
38
+ ao.blendIntensity = config.ao.blend ?? 1;
39
+ ao.updateGtaoMaterial({
40
+ radius: config.ao.radius ?? 1.8,
41
+ distanceExponent: 1,
42
+ thickness: config.ao.distanceFalloff ?? 3.6,
43
+ scale: config.ao.intensity ?? 2.4,
44
+ samples: 16,
45
+ });
46
+ composer.addPass(ao);
47
+ }
48
+ if (config.bloom !== false) {
49
+ const b = config.bloom ?? {};
50
+ composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), b.strength ?? 0.32, b.radius ?? 0.55, b.threshold ?? 0.85));
51
+ }
52
+ composer.addPass(new OutputPass());
53
+ let grade = null;
54
+ if (config.grade !== false) {
55
+ grade = createGradePass(config.grade ?? {});
56
+ composer.addPass(grade);
57
+ }
58
+ return { composer, grade };
59
+ }, [gl, scene, camera, config, size.width, size.height]);
60
+ useEffect(() => {
61
+ const prevTone = gl.toneMapping;
62
+ const prevExposure = gl.toneMappingExposure;
63
+ gl.toneMapping = TONE_MAPPING[config.toneMapping ?? "aces"];
64
+ gl.toneMappingExposure = config.exposure ?? 1;
65
+ return () => {
66
+ gl.toneMapping = prevTone;
67
+ gl.toneMappingExposure = prevExposure;
68
+ };
69
+ }, [gl, config.toneMapping, config.exposure]);
70
+ useEffect(() => {
71
+ const { composer } = built;
72
+ composer.setPixelRatio(gl.getPixelRatio());
73
+ composer.setSize(size.width, size.height);
74
+ return () => composer.dispose();
75
+ }, [built, gl, size.width, size.height]);
76
+ useFrame((_, delta) => {
77
+ if (built.grade !== null)
78
+ built.grade.uniforms.uTime.value += delta;
79
+ built.composer.render(delta);
80
+ }, 1);
81
+ return null;
82
+ }
@@ -0,0 +1,4 @@
1
+ import { ShaderPass } from "three-stdlib";
2
+ import type { GradeConfig } from "@jgengine/core/render/postProcessing";
3
+ /** Build the display-space colour-grade pass (lift/gain/gamma, saturation, vignette, grain). Advance `uniforms.uTime.value` each frame to animate the grain. */
4
+ export declare function createGradePass(config?: GradeConfig): ShaderPass;
@@ -0,0 +1,64 @@
1
+ import * as THREE from "three";
2
+ import { ShaderPass } from "three-stdlib";
3
+ const DEFAULT_LIFT = [0.012, 0.01, 0.018];
4
+ const DEFAULT_GAIN = [1.05, 1.02, 0.98];
5
+ const DEFAULT_GAMMA = 0.96;
6
+ const DEFAULT_SATURATION = 1.12;
7
+ const DEFAULT_VIGNETTE = 0.2;
8
+ const DEFAULT_GRAIN = 0.012;
9
+ const fragmentShader = /* glsl */ `
10
+ uniform sampler2D tDiffuse;
11
+ uniform vec3 uLift;
12
+ uniform vec3 uGain;
13
+ uniform float uGamma;
14
+ uniform float uSaturation;
15
+ uniform float uVignette;
16
+ uniform float uGrain;
17
+ uniform float uTime;
18
+ varying vec2 vUv;
19
+
20
+ float hash(vec2 p) {
21
+ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
22
+ }
23
+
24
+ void main() {
25
+ vec3 c = texture2D(tDiffuse, vUv).rgb;
26
+ c = c + uLift;
27
+ c = c * uGain;
28
+ c = pow(max(c, 0.0), vec3(uGamma));
29
+ float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
30
+ c = mix(vec3(luma), c, uSaturation);
31
+ vec2 d = vUv - 0.5;
32
+ float vig = 1.0 - uVignette * smoothstep(0.6, 0.95, dot(d, d) * 2.2);
33
+ c *= vig;
34
+ float g = (hash(vUv + fract(uTime)) - 0.5) * uGrain * 2.0;
35
+ c += g;
36
+ gl_FragColor = vec4(clamp(c, 0.0, 1.0), 1.0);
37
+ }
38
+ `;
39
+ const vertexShader = /* glsl */ `
40
+ varying vec2 vUv;
41
+ void main() {
42
+ vUv = uv;
43
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
44
+ }
45
+ `;
46
+ /** Build the display-space colour-grade pass (lift/gain/gamma, saturation, vignette, grain). Advance `uniforms.uTime.value` each frame to animate the grain. */
47
+ export function createGradePass(config = {}) {
48
+ const lift = config.lift ?? DEFAULT_LIFT;
49
+ const gain = config.gain ?? DEFAULT_GAIN;
50
+ return new ShaderPass({
51
+ uniforms: {
52
+ tDiffuse: { value: null },
53
+ uLift: { value: new THREE.Vector3(lift[0], lift[1], lift[2]) },
54
+ uGain: { value: new THREE.Vector3(gain[0], gain[1], gain[2]) },
55
+ uGamma: { value: config.gamma ?? DEFAULT_GAMMA },
56
+ uSaturation: { value: config.saturation ?? DEFAULT_SATURATION },
57
+ uVignette: { value: config.vignette ?? DEFAULT_VIGNETTE },
58
+ uGrain: { value: config.grain ?? DEFAULT_GRAIN },
59
+ uTime: { value: 0 },
60
+ },
61
+ vertexShader,
62
+ fragmentShader,
63
+ });
64
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ThreeElements } from "@react-three/fiber";
2
+ import * as THREE from "three";
2
3
  import type { TerrainField } from "@jgengine/core/world/terrain";
3
4
  import { type FieldGroundOptions } from "./terrainMath.js";
4
5
  export interface CarvedTerrainProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material"> {
@@ -12,6 +13,8 @@ export interface CarvedTerrainProps extends Omit<ThreeElements["mesh"], "args" |
12
13
  paletteAt?: FieldGroundOptions["paletteAt"];
13
14
  roughness?: number;
14
15
  metalness?: number;
16
+ /** Override the default vertex-colour material (e.g. a procedural detail material). The caller owns its lifecycle; it is not disposed here. */
17
+ surfaceMaterial?: THREE.Material;
15
18
  /** Bump after a runtime carve/deposit to re-mesh the deformed surface. */
16
19
  epoch?: number;
17
20
  }
@@ -20,4 +23,4 @@ export interface CarvedTerrainProps extends Omit<ThreeElements["mesh"], "args" |
20
23
  * Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl
21
24
  * once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
22
25
  */
23
- export declare function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness, metalness, receiveShadow, epoch, ...meshProps }: CarvedTerrainProps): import("react").JSX.Element;
26
+ export declare function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness, metalness, surfaceMaterial, receiveShadow, epoch, ...meshProps }: CarvedTerrainProps): import("react").JSX.Element;
@@ -8,12 +8,13 @@ import { createFieldGroundGeometry } from "./terrainMath.js";
8
8
  * Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl
9
9
  * once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
10
10
  */
11
- export function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }) {
11
+ export function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, surfaceMaterial, receiveShadow = true, epoch = 0, ...meshProps }) {
12
12
  const geometry = useMemo(() => createFieldGroundGeometry(field, { size, segments, center, colors, heightRange, paletteAt }),
13
13
  // eslint-disable-next-line react-hooks/exhaustive-deps
14
14
  [field, size, segments, center, colors, heightRange, paletteAt, epoch]);
15
- const material = useMemo(() => new THREE.MeshStandardMaterial({ color: "#ffffff", roughness, metalness, vertexColors: true }), [metalness, roughness]);
15
+ const defaultMaterial = useMemo(() => new THREE.MeshStandardMaterial({ color: "#ffffff", roughness, metalness, vertexColors: true }), [metalness, roughness]);
16
+ const material = surfaceMaterial ?? defaultMaterial;
16
17
  useEffect(() => () => geometry.dispose(), [geometry]);
17
- useEffect(() => () => material.dispose(), [material]);
18
+ useEffect(() => () => defaultMaterial.dispose(), [defaultMaterial]);
18
19
  return _jsx("mesh", { ...meshProps, geometry: geometry, material: material, receiveShadow: receiveShadow });
19
20
  }
@@ -0,0 +1,14 @@
1
+ import * as THREE from "three";
2
+ import type { ResolvedTerrainDetail } from "@jgengine/core/world/terrain";
3
+ /** The built procedural detail terrain material, ready to mount on the ground mesh. */
4
+ export interface TerrainDetailMaterialHandle {
5
+ material: THREE.MeshStandardMaterial;
6
+ }
7
+ /**
8
+ * A `MeshStandardMaterial` whose fragment shader keeps the biome-tinted vertex
9
+ * colour as the base ground and blends procedural, noise-broken rock (by slope),
10
+ * sand (by waterline), and snow (by height) over it — textured-reading terrain
11
+ * with no image assets. Full PBR: lit, shadowed, and fogged like any standard
12
+ * material, so it composes with the post-processing chain.
13
+ */
14
+ export declare function createTerrainDetailMaterial(detail: ResolvedTerrainDetail): TerrainDetailMaterialHandle;
@@ -0,0 +1,90 @@
1
+ import * as THREE from "three";
2
+ const NOISE_GLSL = /* glsl */ `
3
+ float jgHash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
4
+ float jgVNoise(vec2 p){
5
+ vec2 i = floor(p); vec2 f = fract(p);
6
+ vec2 u = f * f * (3.0 - 2.0 * f);
7
+ float a = jgHash(i);
8
+ float b = jgHash(i + vec2(1.0, 0.0));
9
+ float c = jgHash(i + vec2(0.0, 1.0));
10
+ float d = jgHash(i + vec2(1.0, 1.0));
11
+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
12
+ }
13
+ float jgFbm(vec2 p){
14
+ float v = 0.0; float amp = 0.5;
15
+ for (int i = 0; i < 4; i++){ v += amp * jgVNoise(p); p *= 2.0; amp *= 0.5; }
16
+ return v;
17
+ }
18
+ `;
19
+ /**
20
+ * A `MeshStandardMaterial` whose fragment shader keeps the biome-tinted vertex
21
+ * colour as the base ground and blends procedural, noise-broken rock (by slope),
22
+ * sand (by waterline), and snow (by height) over it — textured-reading terrain
23
+ * with no image assets. Full PBR: lit, shadowed, and fogged like any standard
24
+ * material, so it composes with the post-processing chain.
25
+ */
26
+ export function createTerrainDetailMaterial(detail) {
27
+ const uniforms = {
28
+ uRockColor: { value: new THREE.Color(detail.rockColor) },
29
+ uSandColor: { value: new THREE.Color(detail.sandColor) },
30
+ uSnowColor: { value: new THREE.Color(detail.snowColor) },
31
+ uRockSlopeStart: { value: detail.rockSlopeStart },
32
+ uSnowHeight: { value: detail.snowHeight },
33
+ uWaterLevel: { value: detail.waterLevel },
34
+ uDetailScale: { value: Math.max(0.5, detail.detailScale) },
35
+ uMacroScale: { value: Math.max(0.5, detail.macroScale) },
36
+ uStrength: { value: detail.strength },
37
+ };
38
+ const material = new THREE.MeshStandardMaterial({
39
+ color: "#ffffff",
40
+ roughness: detail.roughness,
41
+ metalness: 0,
42
+ vertexColors: true,
43
+ });
44
+ material.onBeforeCompile = (shader) => {
45
+ Object.assign(shader.uniforms, uniforms);
46
+ shader.vertexShader = shader.vertexShader
47
+ .replace("#include <common>", `#include <common>
48
+ varying vec3 vJgWorldPos;
49
+ varying vec3 vJgWorldNormal;`)
50
+ .replace("#include <beginnormal_vertex>", `#include <beginnormal_vertex>
51
+ vJgWorldNormal = normalize(mat3(modelMatrix) * objectNormal);`)
52
+ .replace("#include <begin_vertex>", `#include <begin_vertex>
53
+ vJgWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`);
54
+ shader.fragmentShader = shader.fragmentShader
55
+ .replace("#include <common>", `#include <common>
56
+ varying vec3 vJgWorldPos;
57
+ varying vec3 vJgWorldNormal;
58
+ uniform vec3 uRockColor;
59
+ uniform vec3 uSandColor;
60
+ uniform vec3 uSnowColor;
61
+ uniform float uRockSlopeStart;
62
+ uniform float uSnowHeight;
63
+ uniform float uWaterLevel;
64
+ uniform float uDetailScale;
65
+ uniform float uMacroScale;
66
+ uniform float uStrength;
67
+ ${NOISE_GLSL}`)
68
+ .replace("#include <color_fragment>", `#include <color_fragment>
69
+ vec2 jgWp = vJgWorldPos.xz;
70
+ float jgFine = jgFbm(jgWp / uDetailScale);
71
+ float jgMacro = jgFbm(jgWp / uMacroScale);
72
+ float jgSlope = 1.0 - clamp(vJgWorldNormal.y, 0.0, 1.0);
73
+ float jgH = vJgWorldPos.y;
74
+ vec3 jgBase = diffuseColor.rgb;
75
+ jgBase *= mix(1.0 - 0.22 * uStrength, 1.0 + 0.18 * uStrength, jgFine);
76
+ jgBase = mix(jgBase, jgBase * vec3(0.86, 0.9, 0.82), jgMacro * 0.25 * uStrength);
77
+ vec3 jgRock = uRockColor * mix(0.72, 1.15, jgFbm(jgWp / (uDetailScale * 1.7)));
78
+ float jgRockW = smoothstep(uRockSlopeStart, uRockSlopeStart + 0.18, jgSlope) * uStrength;
79
+ vec3 jgCol = mix(jgBase, jgRock, jgRockW);
80
+ vec3 jgSand = uSandColor * mix(0.9, 1.08, jgFine);
81
+ float jgSandW = smoothstep(uWaterLevel + 2.4, uWaterLevel - 0.4, jgH) * (1.0 - jgRockW) * uStrength;
82
+ jgCol = mix(jgCol, jgSand, jgSandW);
83
+ vec3 jgSnow = uSnowColor * mix(0.94, 1.04, jgFine);
84
+ float jgSnowW = smoothstep(uSnowHeight, uSnowHeight + 8.0, jgH) * (1.0 - jgRockW * 0.6) * uStrength;
85
+ jgCol = mix(jgCol, jgSnow, jgSnowW);
86
+ diffuseColor.rgb = jgCol;`);
87
+ };
88
+ material.customProgramCacheKey = () => `jgengine-terrain-detail-${detail.rockSlopeStart}-${detail.snowHeight}`;
89
+ return { material };
90
+ }
@@ -4,6 +4,10 @@ import { createGestureSurfaceTracker } from "@jgengine/core/input/gestureSurface
4
4
  import { createTouchGestureTracker } from "@jgengine/core/input/touchGestures";
5
5
  import { touchCode } from "@jgengine/core/input/touchScheme";
6
6
  import { GameIcon, iconForAction, isGameIconName } from "@jgengine/react/gameIcons";
7
+ import { useRegisterLayoutRegion } from "@jgengine/react/gameViewport";
8
+ const MOVEMENT_ZONE = { id: "control:movement", kind: "control", collisionPolicy: "forbid", collisionGroup: "touch-dock" };
9
+ const ACTIONS_ZONE = { id: "control:actions", kind: "control", collisionPolicy: "forbid", collisionGroup: "touch-dock" };
10
+ const UTILITY_ZONE = { id: "control:utility", kind: "control", collisionPolicy: "warn", collisionGroup: "touch-dock" };
7
11
  const JOYSTICK_SIZE = 132;
8
12
  const JOYSTICK_THUMB = 52;
9
13
  const JOYSTICK_DEADZONE = 0.28;
@@ -275,5 +279,11 @@ function PrimaryButtonCluster({ buttons, sink, scale = 1 }) {
275
279
  export function TouchControlsDock({ scheme, sink, scale = 1 }) {
276
280
  const primary = scheme.buttons.filter((button) => button.kind !== "utility");
277
281
  const utility = scheme.buttons.filter((button) => button.kind === "utility");
278
- return (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 bottom-0 z-40", style: { paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${DOCK_BASE_PADDING}px)` }, children: [_jsxs("div", { className: "flex items-end justify-between gap-4 px-5", children: [_jsx("div", { children: scheme.joystick !== null ? _jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink, scale: scale }) : null }), _jsx(PrimaryButtonCluster, { buttons: primary, sink: sink, scale: scale })] }), utility.length > 0 ? (_jsx("div", { className: "pointer-events-none absolute inset-x-0 flex justify-center gap-2", style: { bottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)" }, children: utility.map((button) => (_jsx(TouchUtilityChip, { button: button, sink: sink }, button.action))) })) : null] }));
282
+ const joystickRef = useRef(null);
283
+ const actionsRef = useRef(null);
284
+ const utilityRef = useRef(null);
285
+ useRegisterLayoutRegion(MOVEMENT_ZONE, joystickRef, scheme.joystick !== null);
286
+ useRegisterLayoutRegion(ACTIONS_ZONE, actionsRef, primary.length > 0);
287
+ useRegisterLayoutRegion(UTILITY_ZONE, utilityRef, utility.length > 0);
288
+ return (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 bottom-0 z-40", style: { paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${DOCK_BASE_PADDING}px)` }, children: [_jsxs("div", { className: "flex items-end justify-between gap-4 px-5", children: [_jsx("div", { ref: joystickRef, children: scheme.joystick !== null ? _jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink, scale: scale }) : null }), _jsx("div", { ref: actionsRef, children: _jsx(PrimaryButtonCluster, { buttons: primary, sink: sink, scale: scale }) })] }), utility.length > 0 ? (_jsx("div", { className: "pointer-events-none absolute inset-x-0 flex justify-center", style: { bottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)" }, children: _jsx("div", { ref: utilityRef, className: "flex gap-2", children: utility.map((button) => (_jsx(TouchUtilityChip, { button: button, sink: sink }, button.action))) }) })) : null] }));
279
289
  }
@@ -3,11 +3,13 @@ import type { CatalogEntityRole } from "@jgengine/core/runtime/gameContext";
3
3
  export type { WorldBarSample } from "./worldBarSamples.js";
4
4
  export { collectWorldBarSamples, paintWorldBarSamples } from "./worldBarSamples.js";
5
5
  export { telegraphPulseOpacity } from "./telegraphPulse.js";
6
- export declare function WorldEntityBars({ statId, height, roles, resolveRole, }: {
6
+ export declare function WorldEntityBars({ statId, height, roles, resolveRole, maxDistance, }: {
7
7
  statId: string;
8
8
  height?: number;
9
9
  roles?: readonly CatalogEntityRole[];
10
10
  resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
11
+ /** Hide bars for entities farther than this from the player (world units). Default 60. */
12
+ maxDistance?: number;
11
13
  }): import("react").JSX.Element;
12
14
  export declare function WorldFloatText({ height, lifeMs }: {
13
15
  height?: number;
@@ -5,16 +5,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { useCameraShake } from "../camera/shakeChannel.js";
8
+ import { readFirstPersonMuzzle } from "../camera/GameFirstPersonCamera.js";
8
9
  import { resolveFloatTextStyle } from "./floatTextStyle.js";
9
10
  import { collectWorldBarSamples, paintWorldBarSamples, } from "./worldBarSamples.js";
10
11
  import { telegraphPulseOpacity } from "./telegraphPulse.js";
11
12
  export { collectWorldBarSamples, paintWorldBarSamples } from "./worldBarSamples.js";
12
13
  export { telegraphPulseOpacity } from "./telegraphPulse.js";
13
- const MUZZLE_HEIGHT = 1.4;
14
14
  function pinOverlayToViewport(_el, _camera, size) {
15
15
  return [size.width / 2, size.height / 2];
16
16
  }
17
- export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, }) {
17
+ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, }) {
18
18
  const ctx = useGameContext();
19
19
  const camera = useThree((state) => state.camera);
20
20
  const size = useThree((state) => state.size);
@@ -36,7 +36,7 @@ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, }) {
36
36
  canvas.height = pixelH;
37
37
  }
38
38
  camera.updateMatrixWorld();
39
- collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current);
39
+ collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current, maxDistance);
40
40
  paintWorldBarSamples(canvas, samplesRef.current, dpr);
41
41
  });
42
42
  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" } }) }));
@@ -157,14 +157,14 @@ export function ProjectileTracers({ lifeMs = 130 }) {
157
157
  useEffect(() => {
158
158
  const unsub = ctx.game.events.on("projectile.settled", (event) => {
159
159
  const id = nextId.current++;
160
+ const start = new THREE.Vector3(event.origin[0], event.origin[1], event.origin[2]);
161
+ if (event.from === ctx.player.userId)
162
+ readFirstPersonMuzzle(start);
160
163
  setTracers((current) => [
161
164
  ...current,
162
165
  {
163
166
  id,
164
- points: [
165
- new THREE.Vector3(event.origin[0], event.origin[1] + MUZZLE_HEIGHT, event.origin[2]),
166
- new THREE.Vector3(event.at[0], event.at[1], event.at[2]),
167
- ],
167
+ points: [start, new THREE.Vector3(event.at[0], event.at[1], event.at[2])],
168
168
  },
169
169
  ]);
170
170
  const handle = setTimeout(() => {
@@ -22,7 +22,7 @@ export declare function collectWorldBarSamples(ctx: GameContext, statId: string,
22
22
  }, viewport: {
23
23
  width: number;
24
24
  height: number;
25
- }, into: WorldBarSample[], project: Projectable): number;
25
+ }, into: WorldBarSample[], project: Projectable, maxDistance?: number): number;
26
26
  export declare function paintWorldBarSamples(canvas: {
27
27
  width: number;
28
28
  height: number;
@@ -1,12 +1,18 @@
1
1
  import { worldHealthBarAllowsRole } from "@jgengine/core/game/playableGame";
2
- export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project) {
2
+ export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project, maxDistance = 60) {
3
3
  into.length = 0;
4
4
  const playerId = ctx.player.userId;
5
+ const player = ctx.scene.entity.get(playerId);
5
6
  for (const entity of ctx.scene.entity.list()) {
6
7
  if (entity.id === playerId)
7
8
  continue;
8
9
  if (!worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
9
10
  continue;
11
+ if (player !== null &&
12
+ Math.hypot(entity.position[0] - player.position[0], entity.position[2] - player.position[2]) >
13
+ maxDistance) {
14
+ continue;
15
+ }
10
16
  const stat = ctx.scene.entity.stats.get(entity.id, statId);
11
17
  if (stat === null)
12
18
  continue;
@@ -0,0 +1,10 @@
1
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
2
+ import type { GameRuntimeFeeds } from "@jgengine/core/runtime/transport";
3
+ /**
4
+ * Client half of host-authoritative play: subscribe to the server-state channel and mirror each authoritative
5
+ * `WorldSnapshot` (carried in `serverState`) into the local `ctx`, so the game renders the host's world instead
6
+ * of a locally-simulated one. Pure and transport-agnostic — the backend's `feeds.subscribeServer` is the only
7
+ * dependency; returns the unsubscribe. The shell attaches this (and gates its local sim) when the game's adapter
8
+ * opts into `authority: "server"`.
9
+ */
10
+ export declare function attachWorldSync(feeds: Pick<GameRuntimeFeeds, "subscribeServer">, serverId: string, ctx: Pick<GameContext, "hydrate">): () => void;
@@ -0,0 +1,19 @@
1
+ import { createWorldMirror } from "@jgengine/core/runtime/worldMirror";
2
+ /**
3
+ * Client half of host-authoritative play: subscribe to the server-state channel and mirror each authoritative
4
+ * `WorldSnapshot` (carried in `serverState`) into the local `ctx`, so the game renders the host's world instead
5
+ * of a locally-simulated one. Pure and transport-agnostic — the backend's `feeds.subscribeServer` is the only
6
+ * dependency; returns the unsubscribe. The shell attaches this (and gates its local sim) when the game's adapter
7
+ * opts into `authority: "server"`.
8
+ */
9
+ export function attachWorldSync(feeds, serverId, ctx) {
10
+ const mirror = createWorldMirror(ctx);
11
+ return feeds.subscribeServer(serverId, (view) => {
12
+ if (view === null)
13
+ return;
14
+ const snapshot = view.serverState;
15
+ if (snapshot === null || snapshot === undefined)
16
+ return;
17
+ mirror.applyBaseline(view.revision, snapshot);
18
+ });
19
+ }