@jgengine/shell 0.8.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 (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +11 -0
  3. package/dist/GamePlayerShell.d.ts +10 -22
  4. package/dist/GamePlayerShell.js +709 -264
  5. package/dist/GameUiPreview.js +2 -1
  6. package/dist/audio/AudioComponents.js +25 -8
  7. package/dist/audio/audioEngine.d.ts +9 -0
  8. package/dist/audio/audioEngine.js +37 -0
  9. package/dist/audio/musicDirector.d.ts +31 -0
  10. package/dist/audio/musicDirector.js +125 -0
  11. package/dist/audio/musicVoices.d.ts +10 -0
  12. package/dist/audio/musicVoices.js +338 -0
  13. package/dist/audio/synthEngine.d.ts +9 -0
  14. package/dist/audio/synthEngine.js +57 -0
  15. package/dist/behaviour.d.ts +5 -24
  16. package/dist/behaviour.js +14 -50
  17. package/dist/behaviourAttach.d.ts +9 -0
  18. package/dist/behaviourAttach.js +32 -0
  19. package/dist/behaviourDriver.d.ts +7 -0
  20. package/dist/behaviourDriver.js +21 -0
  21. package/dist/camera/GameCameraRig.js +1 -1
  22. package/dist/camera/GameFirstPersonCamera.d.ts +3 -0
  23. package/dist/camera/GameFirstPersonCamera.js +35 -4
  24. package/dist/camera/GameInspectionCamera.js +12 -1
  25. package/dist/camera/GameOrbitCamera.js +44 -1
  26. package/dist/camera/PlayerFov.d.ts +18 -0
  27. package/dist/camera/PlayerFov.js +48 -0
  28. package/dist/camera/cameraBlendMath.d.ts +13 -0
  29. package/dist/camera/cameraBlendMath.js +36 -0
  30. package/dist/camera/cameraRigs.d.ts +3 -0
  31. package/dist/camera/cameraRigs.js +36 -33
  32. package/dist/camera/fovPreference.d.ts +25 -0
  33. package/dist/camera/fovPreference.js +73 -0
  34. package/dist/camera/index.d.ts +3 -0
  35. package/dist/camera/index.js +3 -0
  36. package/dist/camera/orbitCameraMath.d.ts +18 -0
  37. package/dist/camera/orbitCameraMath.js +6 -1
  38. package/dist/camera/rigMath.d.ts +10 -0
  39. package/dist/camera/rigMath.js +36 -1
  40. package/dist/camera/shakeChannel.d.ts +3 -17
  41. package/dist/camera/shakeChannel.js +2 -21
  42. package/dist/camera/shakeChannelMath.d.ts +8 -0
  43. package/dist/camera/shakeChannelMath.js +21 -0
  44. package/dist/cartridge.d.ts +145 -0
  45. package/dist/cartridge.js +246 -0
  46. package/dist/commandSink.d.ts +20 -0
  47. package/dist/commandSink.js +27 -0
  48. package/dist/defineGame.js +4 -1
  49. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  50. package/dist/devtools/CollisionDebugWorld.js +181 -0
  51. package/dist/devtools/DevtoolsOverlay.d.ts +65 -1
  52. package/dist/devtools/DevtoolsOverlay.js +383 -41
  53. package/dist/devtools/collisionDebug.d.ts +57 -0
  54. package/dist/devtools/collisionDebug.js +127 -0
  55. package/dist/devtools/collisionDebugMath.d.ts +103 -0
  56. package/dist/devtools/collisionDebugMath.js +129 -0
  57. package/dist/environment/Daylight.d.ts +20 -8
  58. package/dist/environment/Daylight.js +63 -15
  59. package/dist/environment/EnvironmentScene.js +91 -35
  60. package/dist/environment/RoadRibbons.d.ts +7 -0
  61. package/dist/environment/RoadRibbons.js +57 -0
  62. package/dist/environment/groundPadMath.d.ts +2 -2
  63. package/dist/environment/groundPadMath.js +1 -1
  64. package/dist/environment/index.d.ts +2 -1
  65. package/dist/environment/index.js +1 -0
  66. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  67. package/dist/environment/skyLightingPolicy.js +6 -0
  68. package/dist/input/mouseLook.d.ts +27 -0
  69. package/dist/input/mouseLook.js +35 -0
  70. package/dist/inputSink.d.ts +18 -0
  71. package/dist/inputSink.js +35 -0
  72. package/dist/materialOverride.d.ts +4 -6
  73. package/dist/materialOverride.js +12 -16
  74. package/dist/multiplayer.js +7 -3
  75. package/dist/pointer/PointerProbe.js +6 -2
  76. package/dist/pointer/pointerService.d.ts +3 -0
  77. package/dist/pointer/pointerService.js +6 -0
  78. package/dist/postfx/PostProcessing.d.ts +10 -0
  79. package/dist/postfx/PostProcessing.js +82 -0
  80. package/dist/postfx/gradeShader.d.ts +4 -0
  81. package/dist/postfx/gradeShader.js +64 -0
  82. package/dist/render/modelRender.d.ts +10 -1
  83. package/dist/render/modelRender.js +32 -5
  84. package/dist/render/resolveModel.d.ts +14 -0
  85. package/dist/render/resolveModel.js +24 -0
  86. package/dist/settings/QuickControls.d.ts +4 -0
  87. package/dist/settings/QuickControls.js +42 -0
  88. package/dist/settings/SettingsChrome.d.ts +1 -0
  89. package/dist/settings/SettingsChrome.js +10 -0
  90. package/dist/settings/SettingsMenu.d.ts +6 -0
  91. package/dist/settings/SettingsMenu.js +148 -0
  92. package/dist/settings/SettingsRuntime.d.ts +11 -0
  93. package/dist/settings/SettingsRuntime.js +19 -0
  94. package/dist/settings/appliedSettings.d.ts +14 -0
  95. package/dist/settings/appliedSettings.js +27 -0
  96. package/dist/settings/settingsController.d.ts +20 -0
  97. package/dist/settings/settingsController.js +165 -0
  98. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  99. package/dist/structures/GeneratedBuilding.js +96 -8
  100. package/dist/structures/index.d.ts +1 -1
  101. package/dist/structures/index.js +1 -1
  102. package/dist/terrain/CarvedTerrain.d.ts +5 -1
  103. package/dist/terrain/CarvedTerrain.js +6 -5
  104. package/dist/terrain/GrassField.d.ts +3 -1
  105. package/dist/terrain/GrassField.js +4 -2
  106. package/dist/terrain/grassBudget.d.ts +3 -0
  107. package/dist/terrain/grassBudget.js +6 -0
  108. package/dist/terrain/grassGeometry.js +1 -1
  109. package/dist/terrain/terrainDetailMaterial.d.ts +14 -0
  110. package/dist/terrain/terrainDetailMaterial.js +90 -0
  111. package/dist/terrain/terrainMath.d.ts +8 -0
  112. package/dist/terrain/terrainMath.js +9 -0
  113. package/dist/touch/OrientationHint.d.ts +3 -0
  114. package/dist/touch/OrientationHint.js +13 -0
  115. package/dist/touch/TouchControlsOverlay.d.ts +17 -1
  116. package/dist/touch/TouchControlsOverlay.js +125 -9
  117. package/dist/visibility/CullingProvider.d.ts +21 -0
  118. package/dist/visibility/CullingProvider.js +134 -0
  119. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  120. package/dist/vision/FrustumSensorHud.js +25 -27
  121. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  122. package/dist/vision/frustumSampleEqual.js +10 -0
  123. package/dist/water/Ocean.js +1 -1
  124. package/dist/water/OceanConfig.d.ts +13 -0
  125. package/dist/water/OceanConfig.js +25 -17
  126. package/dist/water/index.d.ts +1 -1
  127. package/dist/water/index.js +1 -1
  128. package/dist/weather/FireSpreadLayer.js +7 -2
  129. package/dist/weather/RainField.d.ts +3 -1
  130. package/dist/weather/RainField.js +4 -4
  131. package/dist/weather/SnowField.d.ts +3 -1
  132. package/dist/weather/SnowField.js +4 -4
  133. package/dist/weather/fireSpreadPose.d.ts +2 -0
  134. package/dist/weather/fireSpreadPose.js +4 -0
  135. package/dist/weather/weatherMath.d.ts +5 -1
  136. package/dist/weather/weatherMath.js +7 -2
  137. package/dist/world/DataObjects.d.ts +1 -1
  138. package/dist/world/DataObjects.js +3 -1
  139. package/dist/world/SpriteBatch.d.ts +44 -0
  140. package/dist/world/SpriteBatch.js +112 -0
  141. package/dist/world/WorldHud.d.ts +6 -1
  142. package/dist/world/WorldHud.js +95 -48
  143. package/dist/world/entityPose.d.ts +14 -0
  144. package/dist/world/entityPose.js +10 -0
  145. package/dist/world/telegraphPulse.d.ts +1 -0
  146. package/dist/world/telegraphPulse.js +4 -0
  147. package/dist/world/worldBarSamples.d.ts +30 -0
  148. package/dist/world/worldBarSamples.js +57 -0
  149. package/dist/worldSync.d.ts +10 -0
  150. package/dist/worldSync.js +19 -0
  151. package/llms.txt +1522 -1143
  152. package/package.json +4 -4
@@ -1,67 +1,123 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { useFrame } from "@react-three/fiber";
4
+ import { useGameContext } from "@jgengine/react/provider";
5
+ import { resolveBuildingPalette } from "@jgengine/core/world/buildings";
3
6
  import { resolveStructureBuildings } from "@jgengine/core/world/environmentSummary";
4
- import { resolveTerrainField, resolveTerrainPalette } from "@jgengine/core/world/terrain";
5
- import { SkyDaylight } from "./Daylight.js";
7
+ import { createTerrainPaletteSampler, resolveEnvironmentField, resolveTerrainDetail, resolveTerrainPalette, } from "@jgengine/core/world/terrain";
6
8
  import { GroundPad } from "./GroundPad.js";
7
- import { GeneratedBuilding } from "../structures/GeneratedBuilding.js";
9
+ import { RoadRibbons } from "./RoadRibbons.js";
10
+ import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
8
11
  import { GrassField } from "../terrain/GrassField.js";
9
- import { ProceduralGround } from "../terrain/ProceduralGround.js";
12
+ import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
13
+ import { createTerrainDetailMaterial } from "../terrain/terrainDetailMaterial.js";
10
14
  import { Ocean } from "../water/Ocean.js";
11
15
  import { RainField } from "../weather/RainField.js";
12
16
  import { SnowField } from "../weather/SnowField.js";
13
17
  import { WeatherUniformProvider } from "../weather/weatherUniforms.js";
14
- const DEFAULT_TERRAIN_FREQUENCY = 0.03;
15
- function TerrainGround({ terrain }) {
16
- const palette = resolveTerrainPalette(terrain);
17
- return (_jsx(ProceduralGround, { terrain: {
18
- size: [terrain.bounds.w, terrain.bounds.d],
19
- segments: terrain.segments,
20
- height: terrain.height,
21
- seed: terrain.seed,
22
- moundScale: terrain.frequency ?? DEFAULT_TERRAIN_FREQUENCY,
23
- octaves: terrain.octaves,
24
- ridged: terrain.ridged,
25
- baseOffset: terrain.baseHeight,
26
- }, colors: {
27
- low: palette.low,
28
- high: palette.high,
29
- ...(terrain.waterLevel === undefined ? {} : { waterline: palette.waterline, waterlineHeight: terrain.waterLevel }),
30
- } }));
18
+ function TerrainGround({ terrain, field, center, }) {
19
+ const palette = useMemo(() => resolveTerrainPalette(terrain), [terrain]);
20
+ const paletteAt = useMemo(() => {
21
+ const hasRegions = terrain.materialRegions !== undefined && terrain.materialRegions.length > 0;
22
+ const hasBands = terrain.biomeBands !== undefined && terrain.biomeBands.length > 0;
23
+ if (!hasRegions && !hasBands)
24
+ return undefined;
25
+ const sampler = createTerrainPaletteSampler(terrain);
26
+ if (center === undefined)
27
+ return sampler;
28
+ return (x, z) => sampler(x - center[0], z - center[1]);
29
+ }, [terrain, center]);
30
+ const colors = useMemo(() => ({
31
+ low: palette.low,
32
+ high: palette.high,
33
+ ...(terrain.waterLevel === undefined ? {} : { waterline: palette.waterline, waterlineHeight: terrain.waterLevel }),
34
+ }), [palette, terrain.waterLevel]);
35
+ const size = useMemo(() => [terrain.bounds.w, terrain.bounds.d], [terrain.bounds]);
36
+ const heightRange = useMemo(() => {
37
+ const base = terrain.baseHeight ?? 0;
38
+ const swing = terrain.height * 1.2;
39
+ return [base - swing, base + swing];
40
+ }, [terrain.baseHeight, terrain.height]);
41
+ const detailMaterial = useMemo(() => terrain.detail === undefined
42
+ ? undefined
43
+ : createTerrainDetailMaterial(resolveTerrainDetail(terrain.detail, terrain.waterLevel)).material, [terrain.detail, terrain.waterLevel]);
44
+ useEffect(() => () => detailMaterial?.dispose(), [detailMaterial]);
45
+ return (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, center: center, roughness: 0.94, surfaceMaterial: detailMaterial }));
46
+ }
47
+ function areaCenter(area) {
48
+ return area.position ?? [0, 0];
31
49
  }
32
50
  function Vegetation({ grass, field }) {
33
- return (_jsx(GrassField, { area: [grass.area.w, grass.area.d], density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, heightAt: field.sampleHeight, colorBase: grass.colors[0], colorTip: grass.colors[grass.colors.length - 1], wind: { strength: grass.windStrength } }));
51
+ const [cx, cz] = areaCenter(grass.area);
52
+ const heightAt = useMemo(() => cx === 0 && cz === 0
53
+ ? field.sampleHeight
54
+ : (x, z) => field.sampleHeight(x + cx, z + cz), [cx, cz, field]);
55
+ const area = useMemo(() => [grass.area.w, grass.area.d], [grass.area.w, grass.area.d]);
56
+ const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
57
+ const colorBase = grass.colors[0];
58
+ const colorTip = grass.colors[grass.colors.length - 1];
59
+ return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, wind: wind, frustumCulled: true }) }));
34
60
  }
35
61
  function weatherVolume(area) {
36
62
  return [area.w, area.h ?? 60, area.d];
37
63
  }
38
64
  function Precipitation({ weather }) {
65
+ const [cx, cz] = areaCenter(weather.area);
39
66
  if (weather.kind === "rain") {
40
- return (_jsx(RainField, { density: weather.density, speed: weather.speed, length: weather.dropLength, color: weather.color, wind: [weather.wind[0], 0, weather.wind[1]], volume: weatherVolume(weather.area) }));
67
+ return (_jsx("group", { position: [cx, 0, cz], children: _jsx(RainField, { density: weather.density, speed: weather.speed, length: weather.dropLength, color: weather.color, wind: [weather.wind[0], 0, weather.wind[1]], volume: weatherVolume(weather.area) }) }));
41
68
  }
42
- return (_jsx(SnowField, { density: weather.density, speed: weather.speed, size: weather.flakeSize, sway: weather.drift, color: weather.color, wind: [weather.wind[0], 0, weather.wind[1]], volume: weatherVolume(weather.area) }));
69
+ return (_jsx("group", { position: [cx, 0, cz], children: _jsx(SnowField, { density: weather.density, speed: weather.speed, size: weather.flakeSize, sway: weather.drift, color: weather.color, wind: [weather.wind[0], 0, weather.wind[1]], volume: weatherVolume(weather.area) }) }));
43
70
  }
44
71
  function Weather({ weather }) {
45
72
  return (_jsx(WeatherUniformProvider, { children: weather.map((entry, index) => (_jsx(Precipitation, { weather: entry, index: index }, `${entry.kind}-${index}`))) }));
46
73
  }
74
+ function oceanConfig(ocean) {
75
+ return {
76
+ size: ocean.bounds.w,
77
+ depth: ocean.bounds.d,
78
+ amplitude: ocean.waveHeight,
79
+ speed: ocean.waveSpeed,
80
+ waveScale: ocean.waveScale,
81
+ color: { shallow: ocean.color },
82
+ };
83
+ }
84
+ function useOceanConfig(ocean) {
85
+ return useMemo(() => oceanConfig(ocean), [ocean.bounds.w, ocean.bounds.d, ocean.waveHeight, ocean.waveSpeed, ocean.color]);
86
+ }
87
+ function DynamicWater({ ocean }) {
88
+ const ctx = useGameContext();
89
+ const groupRef = useRef(null);
90
+ const [x, z] = ocean.position ?? [0, 0];
91
+ const config = useOceanConfig(ocean);
92
+ useFrame(() => {
93
+ if (groupRef.current !== null)
94
+ groupRef.current.position.y = ocean.levelAt(ctx.time.now());
95
+ });
96
+ return (_jsx("group", { ref: groupRef, position: [0, ocean.level, 0], children: _jsx(Ocean, { position: [x, 0, z], config: config }) }));
97
+ }
47
98
  function Water({ ocean }) {
48
99
  const [x, z] = ocean.position ?? [0, 0];
49
- return (_jsx(Ocean, { position: [x, ocean.level, z], config: {
50
- size: Math.max(ocean.bounds.w, ocean.bounds.d),
51
- amplitude: ocean.waveHeight,
52
- speed: ocean.waveSpeed,
53
- color: { shallow: ocean.color },
54
- } }));
100
+ const config = useOceanConfig(ocean);
101
+ if (ocean.levelAt !== undefined) {
102
+ return _jsx(DynamicWater, { ocean: ocean });
103
+ }
104
+ return _jsx(Ocean, { position: [x, ocean.level, z], config: config });
55
105
  }
56
106
  function Structures({ structures, field }) {
57
- const buildings = useMemo(() => resolveStructureBuildings(structures), [structures]);
58
- return (_jsx(_Fragment, { children: buildings.map((building) => (_jsx("group", { "position-y": field.sampleHeight(building.center[0], building.center[1]), children: _jsx(GeneratedBuilding, { building: building }) }, building.id))) }));
107
+ const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
108
+ building,
109
+ position: [0, field.sampleHeight(building.center[0], building.center[1]), 0],
110
+ })), [structures, field]);
111
+ const palette = useMemo(() => resolveBuildingPalette(structures.style, structures.palette), [structures.style, structures.palette]);
112
+ return _jsx(InstancedBuildings, { buildings: placements, palette: palette });
59
113
  }
60
114
  export function EnvironmentScene({ feature }) {
61
- const field = useMemo(() => resolveTerrainField(feature.terrain), [feature.terrain]);
115
+ const field = useMemo(() => resolveEnvironmentField(feature), [feature]);
62
116
  const vegetation = feature.vegetation ?? [];
63
117
  const water = feature.water ?? [];
64
118
  const structures = feature.structures ?? [];
65
119
  const pads = feature.pads ?? [];
66
- return (_jsxs(_Fragment, { children: [feature.sky !== undefined && !feature.sky.timeOfDay ? _jsx(SkyDaylight, { sky: feature.sky }) : null, feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain }) : null, water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
120
+ const islands = feature.islands ?? [];
121
+ const roads = feature.roads ?? [];
122
+ return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field }, `road-${index}`))), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
67
123
  }
@@ -0,0 +1,7 @@
1
+ import type { RoadEnvironmentDescriptor } from "@jgengine/core/world/features";
2
+ import type { TerrainField } from "@jgengine/core/world/terrain";
3
+ /** Renders one `road()` descriptor: an asphalt ribbon plus optional dashed centerline, draped on the terrain. */
4
+ export declare function RoadRibbons({ road, field }: {
5
+ road: RoadEnvironmentDescriptor;
6
+ field: TerrainField;
7
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
4
+ import { buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
5
+ import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
6
+ function toGeometry(positions, indices) {
7
+ if (positions.length === 0)
8
+ return null;
9
+ const geometry = new BufferGeometry();
10
+ geometry.setAttribute("position", new BufferAttribute(positions, 3));
11
+ geometry.setIndex(new BufferAttribute(indices, 1));
12
+ geometry.computeVertexNormals();
13
+ return geometry;
14
+ }
15
+ function ribbonGeometry(path, width, field, elevation) {
16
+ const ribbon = buildRoadRibbon(path, width, (x, z) => field.sampleHeight(x, z), { elevation });
17
+ return toGeometry(ribbon.positions, ribbon.indices);
18
+ }
19
+ function mergedDashGeometry(road, field) {
20
+ const width = Math.max(0.25, road.width * 0.035);
21
+ const ribbons = dashSegments(road.path, 3.2, 4).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
22
+ const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
23
+ const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
24
+ if (vertexCount === 0)
25
+ return null;
26
+ const positions = new Float32Array(vertexCount);
27
+ const indices = new Uint32Array(indexCount);
28
+ let vertexOffset = 0;
29
+ let indexOffset = 0;
30
+ for (const ribbon of ribbons) {
31
+ positions.set(ribbon.positions, vertexOffset);
32
+ const base = vertexOffset / 3;
33
+ for (let i = 0; i < ribbon.indices.length; i += 1) {
34
+ indices[indexOffset + i] = ribbon.indices[i] + base;
35
+ }
36
+ vertexOffset += ribbon.positions.length;
37
+ indexOffset += ribbon.indices.length;
38
+ }
39
+ return toGeometry(positions, indices);
40
+ }
41
+ /** Renders one `road()` descriptor: an asphalt ribbon plus optional dashed centerline, draped on the terrain. */
42
+ export function RoadRibbons({ road, field }) {
43
+ const asphalt = useMemo(() => ribbonGeometry(road.path, road.width, field, road.elevation), [road, field]);
44
+ const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field) : null), [road, field]);
45
+ const sidewalks = useMemo(() => {
46
+ const width = sidewalkWidthOf(road);
47
+ if (width <= 0)
48
+ return [];
49
+ const offset = road.width / 2 + width / 2;
50
+ return [offsetPath(road.path, offset), offsetPath(road.path, -offset)]
51
+ .map((path) => ribbonGeometry(path, width, field, road.elevation + 0.06))
52
+ .filter((geometry) => geometry !== null);
53
+ }, [road, field]);
54
+ if (asphalt === null)
55
+ return null;
56
+ return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
57
+ }
@@ -9,5 +9,5 @@ export type PadShape = {
9
9
  depth: number;
10
10
  };
11
11
  export declare function resolvePadShape(size: PadSize): PadShape;
12
- export declare function resolvePadSurfaceY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height">): number;
13
- export declare function resolvePadMeshY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height">): number;
12
+ export declare function resolvePadSurfaceY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height" | "elevation">): number;
13
+ export declare function resolvePadMeshY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height" | "elevation">): number;
@@ -3,7 +3,7 @@ export function resolvePadShape(size) {
3
3
  return "radius" in size ? { circular: true, radius: size.radius } : { circular: false, width: size[0], depth: size[1] };
4
4
  }
5
5
  export function resolvePadSurfaceY(groundHeight, pad) {
6
- return groundHeight + pad.height;
6
+ return pad.elevation ?? groundHeight + pad.height;
7
7
  }
8
8
  export function resolvePadMeshY(groundHeight, pad) {
9
9
  return resolvePadSurfaceY(groundHeight, pad) - PAD_THICKNESS / 2;
@@ -1,3 +1,4 @@
1
- export { Daylight, SkyDaylight, SkyDome, TimeOfDayDaylight, type DaylightProps, type SkyDomeProps, type TimeOfDayDaylightProps, } from "./Daylight.js";
1
+ export { Daylight, SkyDaylight, SkyDome, TimeOfDayDaylight, type DaylightProps, type SkyDaylightProps, type SkyDomeProps, type TimeOfDayDaylightProps, } from "./Daylight.js";
2
2
  export { EnvironmentScene, type EnvironmentSceneProps } from "./EnvironmentScene.js";
3
3
  export { daylightStateAt, lerpHexColor, SKY_PRESET_DAY_FRACTION, type DaylightCycleConfig, type DaylightState, } from "./daylightCycle.js";
4
+ export { resolveSkyLightOwnership, skyEmitsLights, type SkyLightOwnership, } from "./skyLightingPolicy.js";
@@ -1,3 +1,4 @@
1
1
  export { Daylight, SkyDaylight, SkyDome, TimeOfDayDaylight, } from "./Daylight.js";
2
2
  export { EnvironmentScene } from "./EnvironmentScene.js";
3
3
  export { daylightStateAt, lerpHexColor, SKY_PRESET_DAY_FRACTION, } from "./daylightCycle.js";
4
+ export { resolveSkyLightOwnership, skyEmitsLights, } from "./skyLightingPolicy.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Policy for composing sky backdrops with `PlayableGame.lighting`:
3
+ * - authored lighting present → sky renders dome + fog only; lights stay game-owned
4
+ * - no authored lighting → sky may emit its default sun/hemisphere with the dome
5
+ * Time-of-day never rewrites configured lights; it only drives sky colors/fog (and
6
+ * sky-owned lights when the game did not author lighting).
7
+ */
8
+ export type SkyLightOwnership = "authored" | "sky-default";
9
+ export declare function resolveSkyLightOwnership(hasAuthoredLighting: boolean): SkyLightOwnership;
10
+ export declare function skyEmitsLights(ownership: SkyLightOwnership): boolean;
@@ -0,0 +1,6 @@
1
+ export function resolveSkyLightOwnership(hasAuthoredLighting) {
2
+ return hasAuthoredLighting ? "authored" : "sky-default";
3
+ }
4
+ export function skyEmitsLights(ownership) {
5
+ return ownership === "sky-default";
6
+ }
@@ -0,0 +1,27 @@
1
+ export interface MouseLookOptions {
2
+ /** Radians of look per pixel of mouse movement; default `0.0024`. */
3
+ sensitivity?: number;
4
+ /** Pitch clamp in radians; default `1.15`. */
5
+ maxPitch?: number;
6
+ /** Request pointer lock on click (skipped on coarse-pointer/touch devices); default `true`. */
7
+ pointerLock?: boolean;
8
+ initialYaw?: number;
9
+ initialPitch?: number;
10
+ }
11
+ export interface MouseLookAim {
12
+ yaw: number;
13
+ pitch: number;
14
+ }
15
+ /**
16
+ * The analog mouse-look service chase/orbit-cam games hand-rolled (#282.8) — pointer-lock
17
+ * lifecycle plus delta accumulation into a yaw/pitch aim, decoupled from the first-person rig.
18
+ * Attach it to the canvas, read `aim()` from `onTick`/`useFrame`, dispose on unmount.
19
+ */
20
+ export interface MouseLookTracker {
21
+ aim(): MouseLookAim;
22
+ setAim(yaw: number, pitch: number): void;
23
+ /** True while pointer lock is held on the tracked element. */
24
+ locked(): boolean;
25
+ dispose(): void;
26
+ }
27
+ export declare function createMouseLookTracker(element: HTMLElement, options?: MouseLookOptions): MouseLookTracker;
@@ -0,0 +1,35 @@
1
+ export function createMouseLookTracker(element, options = {}) {
2
+ const sensitivity = options.sensitivity ?? 0.0024;
3
+ const maxPitch = options.maxPitch ?? 1.15;
4
+ const pointerLock = options.pointerLock ?? true;
5
+ let yaw = options.initialYaw ?? 0;
6
+ let pitch = options.initialPitch ?? 0;
7
+ const requestLock = () => {
8
+ if (!pointerLock)
9
+ return;
10
+ if (window.matchMedia?.("(pointer: coarse)").matches)
11
+ return;
12
+ if (document.pointerLockElement !== element)
13
+ void element.requestPointerLock?.();
14
+ };
15
+ const onMove = (event) => {
16
+ if (pointerLock && document.pointerLockElement !== element)
17
+ return;
18
+ yaw -= event.movementX * sensitivity;
19
+ pitch = Math.max(-maxPitch, Math.min(maxPitch, pitch - event.movementY * sensitivity));
20
+ };
21
+ element.addEventListener("click", requestLock);
22
+ window.addEventListener("mousemove", onMove);
23
+ return {
24
+ aim: () => ({ yaw, pitch }),
25
+ setAim(nextYaw, nextPitch) {
26
+ yaw = nextYaw;
27
+ pitch = Math.max(-maxPitch, Math.min(maxPitch, nextPitch));
28
+ },
29
+ locked: () => document.pointerLockElement === element,
30
+ dispose() {
31
+ element.removeEventListener("click", requestLock);
32
+ window.removeEventListener("mousemove", onMove);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,18 @@
1
+ import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
2
+ import type { LiveGameBackend } from "@jgengine/core/runtime/transport";
3
+ /** Where the local player's per-frame input goes: discarded in single-player, sent to the authoritative host under `authority: "server"`. */
4
+ export interface InputSink {
5
+ send(frame: InputFrame): void;
6
+ }
7
+ /** Discards input — the single-player / client-authoritative default, where the client integrates movement itself. */
8
+ export declare function noopInputSink(): InputSink;
9
+ /** Sends each frame's input to the authoritative host over the transport, reusing the `runCommand` path via {@link INPUT_COMMAND}. */
10
+ export declare function remoteInputSink(backend: Pick<LiveGameBackend, "transport">, serverId: string): InputSink;
11
+ /** The sink a server-authoritative shell sends its per-frame input through: remote when `authority: "server"` and a server is joined, a no-op otherwise. */
12
+ export declare function resolveInputSink(opts: {
13
+ serverAuthoritative: boolean;
14
+ backend: Pick<LiveGameBackend, "transport"> | null;
15
+ serverId: string | null;
16
+ }): InputSink;
17
+ /** Whether two input frames carry identical intent — the shell skips resending unchanged frames so a still player floods the host with nothing. */
18
+ export declare function inputFramesEqual(a: InputFrame, b: InputFrame): boolean;
@@ -0,0 +1,35 @@
1
+ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
2
+ /** Discards input — the single-player / client-authoritative default, where the client integrates movement itself. */
3
+ export function noopInputSink() {
4
+ return { send() { } };
5
+ }
6
+ /** Sends each frame's input to the authoritative host over the transport, reusing the `runCommand` path via {@link INPUT_COMMAND}. */
7
+ export function remoteInputSink(backend, serverId) {
8
+ return {
9
+ send(frame) {
10
+ void backend.transport
11
+ .runCommand({ serverId, command: INPUT_COMMAND, input: frame })
12
+ .catch(() => undefined);
13
+ },
14
+ };
15
+ }
16
+ /** The sink a server-authoritative shell sends its per-frame input through: remote when `authority: "server"` and a server is joined, a no-op otherwise. */
17
+ export function resolveInputSink(opts) {
18
+ if (opts.serverAuthoritative && opts.backend !== null && opts.serverId !== null) {
19
+ return remoteInputSink(opts.backend, opts.serverId);
20
+ }
21
+ return noopInputSink();
22
+ }
23
+ /** Whether two input frames carry identical intent — the shell skips resending unchanged frames so a still player floods the host with nothing. */
24
+ export function inputFramesEqual(a, b) {
25
+ if (a.held.length !== b.held.length)
26
+ return false;
27
+ for (let i = 0; i < a.held.length; i++)
28
+ if (a.held[i] !== b.held[i])
29
+ return false;
30
+ const pa = a.pointer;
31
+ const pb = b.pointer;
32
+ if (pa === null || pb === null)
33
+ return pa === pb;
34
+ return pa.x === pb.x && pa.y === pb.y && pa.active === pb.active;
35
+ }
@@ -1,8 +1,6 @@
1
1
  import * as THREE from "three";
2
2
  import type { ModelMaterialOverride } from "@jgengine/core/game/playableGame";
3
- /**
4
- * Clones each `MeshStandardMaterial` under `root` and applies `override`'s
5
- * color/finish onto the clone, so shared GLTF-cache scenes are never mutated
6
- * in place (#151.3).
7
- */
8
- export declare function applyMaterialOverride(root: THREE.Object3D, override: ModelMaterialOverride): void;
3
+ export interface MaterialOverrideOptions {
4
+ clone?: boolean;
5
+ }
6
+ export declare function applyMaterialOverride(root: THREE.Object3D, override: ModelMaterialOverride, options?: MaterialOverrideOptions): void;
@@ -1,32 +1,28 @@
1
1
  import * as THREE from "three";
2
- /**
3
- * Clones each `MeshStandardMaterial` under `root` and applies `override`'s
4
- * color/finish onto the clone, so shared GLTF-cache scenes are never mutated
5
- * in place (#151.3).
6
- */
7
- export function applyMaterialOverride(root, override) {
2
+ export function applyMaterialOverride(root, override, options) {
3
+ const clone = options?.clone !== false;
8
4
  root.traverse((node) => {
9
5
  const mesh = node;
10
6
  if (!mesh.isMesh)
11
7
  return;
12
8
  mesh.material = Array.isArray(mesh.material)
13
- ? mesh.material.map((material) => overrideOne(material, override))
14
- : overrideOne(mesh.material, override);
9
+ ? mesh.material.map((material) => overrideOne(material, override, clone))
10
+ : overrideOne(mesh.material, override, clone);
15
11
  });
16
12
  }
17
- function overrideOne(material, override) {
13
+ function overrideOne(material, override, clone) {
18
14
  if (!(material instanceof THREE.MeshStandardMaterial))
19
15
  return material;
20
- const clone = material.clone();
16
+ const target = clone ? material.clone() : material;
21
17
  if (override.color !== undefined)
22
- clone.color.set(override.color);
18
+ target.color.set(override.color);
23
19
  if (override.metalness !== undefined)
24
- clone.metalness = override.metalness;
20
+ target.metalness = override.metalness;
25
21
  if (override.roughness !== undefined)
26
- clone.roughness = override.roughness;
22
+ target.roughness = override.roughness;
27
23
  if (override.emissive !== undefined)
28
- clone.emissive.set(override.emissive);
24
+ target.emissive.set(override.emissive);
29
25
  if (override.emissiveIntensity !== undefined)
30
- clone.emissiveIntensity = override.emissiveIntensity;
31
- return clone;
26
+ target.emissiveIntensity = override.emissiveIntensity;
27
+ return target;
32
28
  }
@@ -1,4 +1,4 @@
1
- import { adapterOf } from "@jgengine/core/runtime/adapter";
1
+ import { adapterOf, isServerAuthoritative } from "@jgengine/core/runtime/adapter";
2
2
  import { createWsBackend } from "@jgengine/ws/createWsBackend";
3
3
  import { announcePeerHost, broadcastChannelSignaling, createPeerGuest, createPeerHost, joinPeerSession, } from "@jgengine/ws/peer";
4
4
  export const DEFAULT_FEED_ACTIONS = ["entity.died"];
@@ -31,8 +31,12 @@ export function resolveShellMultiplayer(args) {
31
31
  const adapter = adapterOf(args.game.multiplayer);
32
32
  if (adapter === null)
33
33
  return null;
34
- if (adapter.kind === "ws")
35
- return build(args.url ?? adapter.url ?? DEFAULT_WS_URL);
34
+ if (adapter.kind === "ws") {
35
+ const url = args.url ?? adapter.url;
36
+ if (url === undefined && isServerAuthoritative(args.game.multiplayer))
37
+ return null;
38
+ return build(url ?? DEFAULT_WS_URL);
39
+ }
36
40
  if (adapter.kind === "lan")
37
41
  return build(args.url ?? lanUrl(adapter));
38
42
  return null;
@@ -5,7 +5,12 @@ export function PointerProbe({ service }) {
5
5
  const scene = useThree((state) => state.scene);
6
6
  const gl = useThree((state) => state.gl);
7
7
  const size = useThree((state) => state.size);
8
- service.bind({ camera, scene, width: size.width, height: size.height });
8
+ useEffect(() => {
9
+ service.bind({ camera, scene, width: size.width, height: size.height });
10
+ return () => {
11
+ service.bind(null);
12
+ };
13
+ }, [service, camera, scene, size.width, size.height]);
9
14
  useEffect(() => {
10
15
  const el = gl.domElement;
11
16
  let locked = false;
@@ -33,7 +38,6 @@ export function PointerProbe({ service }) {
33
38
  el.removeEventListener("pointermove", onMove);
34
39
  el.removeEventListener("pointerleave", onLeave);
35
40
  document.removeEventListener("pointerlockchange", onLockChange);
36
- service.bind(null);
37
41
  };
38
42
  }, [gl, service]);
39
43
  return null;
@@ -2,11 +2,14 @@ import * as THREE from "three";
2
2
  import type { PointerHit, PointerVec3 } from "@jgengine/core/input/pointer";
3
3
  export declare const POINTER_ENTITY_KEY = "jgEntityId";
4
4
  export declare const POINTER_OBJECT_KEY = "jgObjectId";
5
+ export type PointerHitFilter = (object: THREE.Object3D) => boolean;
5
6
  interface PointerDeps {
6
7
  camera: THREE.Camera;
7
8
  scene: THREE.Scene;
8
9
  width: number;
9
10
  height: number;
11
+ layers?: THREE.Layers;
12
+ filter?: PointerHitFilter;
10
13
  }
11
14
  export interface PointerService {
12
15
  /** Cast the current cursor into the world; null when the cursor is off-canvas. */
@@ -30,9 +30,15 @@ export function createPointerService() {
30
30
  function hitAtNdc(target) {
31
31
  if (deps === null)
32
32
  return null;
33
+ if (deps.layers !== undefined)
34
+ raycaster.layers.mask = deps.layers.mask;
35
+ else
36
+ raycaster.layers.enableAll();
33
37
  raycaster.setFromCamera(target, deps.camera);
34
38
  const intersects = raycaster.intersectObjects(deps.scene.children, true);
35
39
  for (const hit of intersects) {
40
+ if (deps.filter !== undefined && !deps.filter(hit.object))
41
+ continue;
36
42
  const mesh = hit.object;
37
43
  if (!mesh.isMesh)
38
44
  continue;
@@ -0,0 +1,10 @@
1
+ import type { PostProcessingConfig } from "@jgengine/core/render/postProcessing";
2
+ /**
3
+ * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
4
+ * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
5
+ * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
6
+ * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
7
+ */
8
+ export declare function PostProcessing({ config }: {
9
+ config: PostProcessingConfig;
10
+ }): null;
@@ -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;