@jgengine/shell 0.8.0 → 0.9.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 (127) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +11 -0
  3. package/dist/GamePlayerShell.d.ts +5 -2
  4. package/dist/GamePlayerShell.js +358 -98
  5. package/dist/GameUiPreview.js +2 -1
  6. package/dist/audio/AudioComponents.js +25 -8
  7. package/dist/audio/audioEngine.d.ts +1 -0
  8. package/dist/audio/audioEngine.js +4 -0
  9. package/dist/behaviour.d.ts +5 -24
  10. package/dist/behaviour.js +14 -50
  11. package/dist/behaviourAttach.d.ts +9 -0
  12. package/dist/behaviourAttach.js +32 -0
  13. package/dist/behaviourDriver.d.ts +7 -0
  14. package/dist/behaviourDriver.js +21 -0
  15. package/dist/camera/GameCameraRig.js +1 -1
  16. package/dist/camera/GameFirstPersonCamera.js +14 -0
  17. package/dist/camera/GameOrbitCamera.js +10 -1
  18. package/dist/camera/PlayerFov.d.ts +18 -0
  19. package/dist/camera/PlayerFov.js +48 -0
  20. package/dist/camera/cameraBlendMath.d.ts +13 -0
  21. package/dist/camera/cameraBlendMath.js +36 -0
  22. package/dist/camera/cameraRigs.d.ts +3 -0
  23. package/dist/camera/cameraRigs.js +36 -33
  24. package/dist/camera/fovPreference.d.ts +25 -0
  25. package/dist/camera/fovPreference.js +73 -0
  26. package/dist/camera/index.d.ts +3 -0
  27. package/dist/camera/index.js +3 -0
  28. package/dist/camera/rigMath.d.ts +10 -0
  29. package/dist/camera/rigMath.js +36 -1
  30. package/dist/camera/shakeChannel.d.ts +3 -17
  31. package/dist/camera/shakeChannel.js +2 -21
  32. package/dist/camera/shakeChannelMath.d.ts +8 -0
  33. package/dist/camera/shakeChannelMath.js +21 -0
  34. package/dist/cartridge.d.ts +145 -0
  35. package/dist/cartridge.js +245 -0
  36. package/dist/defineGame.js +2 -1
  37. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  38. package/dist/devtools/CollisionDebugWorld.js +180 -0
  39. package/dist/devtools/DevtoolsOverlay.d.ts +65 -1
  40. package/dist/devtools/DevtoolsOverlay.js +383 -41
  41. package/dist/devtools/collisionDebug.d.ts +57 -0
  42. package/dist/devtools/collisionDebug.js +127 -0
  43. package/dist/devtools/collisionDebugMath.d.ts +102 -0
  44. package/dist/devtools/collisionDebugMath.js +128 -0
  45. package/dist/environment/Daylight.d.ts +13 -7
  46. package/dist/environment/Daylight.js +12 -11
  47. package/dist/environment/EnvironmentScene.js +82 -35
  48. package/dist/environment/groundPadMath.d.ts +2 -2
  49. package/dist/environment/groundPadMath.js +1 -1
  50. package/dist/environment/index.d.ts +2 -1
  51. package/dist/environment/index.js +1 -0
  52. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  53. package/dist/environment/skyLightingPolicy.js +6 -0
  54. package/dist/input/mouseLook.d.ts +27 -0
  55. package/dist/input/mouseLook.js +35 -0
  56. package/dist/materialOverride.d.ts +4 -6
  57. package/dist/materialOverride.js +12 -16
  58. package/dist/pointer/PointerProbe.js +6 -2
  59. package/dist/pointer/pointerService.d.ts +3 -0
  60. package/dist/pointer/pointerService.js +6 -0
  61. package/dist/render/modelRender.d.ts +10 -1
  62. package/dist/render/modelRender.js +32 -5
  63. package/dist/render/resolveModel.d.ts +14 -0
  64. package/dist/render/resolveModel.js +24 -0
  65. package/dist/settings/QuickControls.d.ts +4 -0
  66. package/dist/settings/QuickControls.js +42 -0
  67. package/dist/settings/SettingsChrome.d.ts +1 -0
  68. package/dist/settings/SettingsChrome.js +10 -0
  69. package/dist/settings/SettingsMenu.d.ts +6 -0
  70. package/dist/settings/SettingsMenu.js +148 -0
  71. package/dist/settings/SettingsRuntime.d.ts +11 -0
  72. package/dist/settings/SettingsRuntime.js +19 -0
  73. package/dist/settings/appliedSettings.d.ts +14 -0
  74. package/dist/settings/appliedSettings.js +27 -0
  75. package/dist/settings/settingsController.d.ts +20 -0
  76. package/dist/settings/settingsController.js +165 -0
  77. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  78. package/dist/structures/GeneratedBuilding.js +96 -8
  79. package/dist/structures/index.d.ts +1 -1
  80. package/dist/structures/index.js +1 -1
  81. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  82. package/dist/terrain/CarvedTerrain.js +3 -3
  83. package/dist/terrain/GrassField.d.ts +3 -1
  84. package/dist/terrain/GrassField.js +4 -2
  85. package/dist/terrain/grassBudget.d.ts +3 -0
  86. package/dist/terrain/grassBudget.js +6 -0
  87. package/dist/terrain/grassGeometry.js +1 -1
  88. package/dist/terrain/terrainMath.d.ts +8 -0
  89. package/dist/terrain/terrainMath.js +9 -0
  90. package/dist/touch/OrientationHint.d.ts +3 -0
  91. package/dist/touch/OrientationHint.js +13 -0
  92. package/dist/touch/TouchControlsOverlay.d.ts +17 -1
  93. package/dist/touch/TouchControlsOverlay.js +115 -9
  94. package/dist/visibility/CullingProvider.d.ts +21 -0
  95. package/dist/visibility/CullingProvider.js +134 -0
  96. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  97. package/dist/vision/FrustumSensorHud.js +25 -27
  98. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  99. package/dist/vision/frustumSampleEqual.js +10 -0
  100. package/dist/water/Ocean.js +1 -1
  101. package/dist/water/OceanConfig.d.ts +13 -0
  102. package/dist/water/OceanConfig.js +25 -17
  103. package/dist/water/index.d.ts +1 -1
  104. package/dist/water/index.js +1 -1
  105. package/dist/weather/FireSpreadLayer.js +7 -2
  106. package/dist/weather/RainField.d.ts +3 -1
  107. package/dist/weather/RainField.js +4 -4
  108. package/dist/weather/SnowField.d.ts +3 -1
  109. package/dist/weather/SnowField.js +4 -4
  110. package/dist/weather/fireSpreadPose.d.ts +2 -0
  111. package/dist/weather/fireSpreadPose.js +4 -0
  112. package/dist/weather/weatherMath.d.ts +5 -1
  113. package/dist/weather/weatherMath.js +7 -2
  114. package/dist/world/DataObjects.d.ts +1 -1
  115. package/dist/world/DataObjects.js +3 -1
  116. package/dist/world/SpriteBatch.d.ts +44 -0
  117. package/dist/world/SpriteBatch.js +112 -0
  118. package/dist/world/WorldHud.d.ts +3 -0
  119. package/dist/world/WorldHud.js +89 -42
  120. package/dist/world/entityPose.d.ts +14 -0
  121. package/dist/world/entityPose.js +10 -0
  122. package/dist/world/telegraphPulse.d.ts +1 -0
  123. package/dist/world/telegraphPulse.js +4 -0
  124. package/dist/world/worldBarSamples.d.ts +30 -0
  125. package/dist/world/worldBarSamples.js +51 -0
  126. package/llms.txt +1404 -1143
  127. package/package.json +4 -4
@@ -9,6 +9,77 @@ const JOYSTICK_THUMB = 52;
9
9
  const JOYSTICK_DEADZONE = 0.28;
10
10
  const JOYSTICK_AXIS_THRESHOLD = 0.42;
11
11
  const LOOK_TAP_TUNING = { tapMoveThresholdPx: 12, tapMaxMs: 280, longPressMs: 450 };
12
+ const BUTTON_SIZE = 56;
13
+ const RING1_RADIUS = 112;
14
+ const RING2_RADIUS = 178;
15
+ const RING1_CAPACITY = 3;
16
+ const RING2_CAPACITY = 5;
17
+ const RING1_ANGLES = [[], [40], [16, 64], [12, 44, 76]];
18
+ const DOCK_BASE_PADDING = 20;
19
+ const UTILITY_ROW_HEIGHT = 28;
20
+ function polar(radius, deg, buttonSize) {
21
+ const rad = (deg * Math.PI) / 180;
22
+ return {
23
+ right: radius * Math.cos(rad) - buttonSize / 2,
24
+ bottom: radius * Math.sin(rad) - buttonSize / 2,
25
+ };
26
+ }
27
+ function ringAngles(count, from, to) {
28
+ if (count === 1)
29
+ return [(from + to) / 2];
30
+ const step = (to - from) / (count - 1);
31
+ return Array.from({ length: count }, (_, index) => from + index * step);
32
+ }
33
+ /**
34
+ * Thumb-arc placement for primary buttons around the bottom-right corner:
35
+ * up to three on an inner ring, the rest on an outer ring. Null means too
36
+ * many buttons for an arc — the dock falls back to a wrapping grid.
37
+ */
38
+ export function primaryButtonOffsets(count, scale = 1) {
39
+ if (count === 0)
40
+ return [];
41
+ if (count > RING1_CAPACITY + RING2_CAPACITY)
42
+ return null;
43
+ const button = BUTTON_SIZE * scale;
44
+ const ring1 = RING1_ANGLES[Math.min(count, RING1_CAPACITY)] ?? [];
45
+ const offsets = ring1.map((angle) => polar(RING1_RADIUS * scale, angle, button));
46
+ const rest = count - ring1.length;
47
+ if (rest > 0) {
48
+ for (const angle of ringAngles(rest, 10, 80))
49
+ offsets.push(polar(RING2_RADIUS * scale, angle, button));
50
+ }
51
+ return offsets;
52
+ }
53
+ function primaryClusterExtent(count, scale = 1) {
54
+ if (count === 0)
55
+ return 0;
56
+ if (count <= RING1_CAPACITY)
57
+ return (RING1_RADIUS + BUTTON_SIZE / 2) * scale;
58
+ if (count <= RING1_CAPACITY + RING2_CAPACITY)
59
+ return (RING2_RADIUS + BUTTON_SIZE / 2) * scale;
60
+ return Math.ceil(count / 4) * (BUTTON_SIZE + 12) * scale;
61
+ }
62
+ /**
63
+ * Vertical space (px, excluding device safe areas) the dock occupies above
64
+ * the bottom edge. The shell publishes it as `--jg-hud-dock-clearance` so
65
+ * `HudCanvas` regions never collide with touch controls.
66
+ */
67
+ export function touchDockClearance(scheme, scale = 1) {
68
+ if (scheme === null)
69
+ return 0;
70
+ let primary = 0;
71
+ let utility = 0;
72
+ for (const button of scheme.buttons) {
73
+ if (button.kind === "utility")
74
+ utility += 1;
75
+ else
76
+ primary += 1;
77
+ }
78
+ const tallest = Math.max(scheme.joystick !== null ? JOYSTICK_SIZE * scale : 0, primaryClusterExtent(primary, scale), utility > 0 ? UTILITY_ROW_HEIGHT : 0);
79
+ if (tallest === 0)
80
+ return 0;
81
+ return tallest + DOCK_BASE_PADDING + 12;
82
+ }
12
83
  function pressOnce(sink, action) {
13
84
  const code = touchCode(action);
14
85
  sink.onCodeDown(code);
@@ -83,11 +154,13 @@ function joystickDirections(joystick, nx, ny) {
83
154
  active.add(joystick.right);
84
155
  return active;
85
156
  }
86
- function VirtualJoystick({ joystick, sink }) {
157
+ function VirtualJoystick({ joystick, sink, scale = 1 }) {
87
158
  const baseRef = useRef(null);
88
159
  const thumbRef = useRef(null);
89
160
  const pointerIdRef = useRef(null);
90
161
  const activeRef = useRef(new Set());
162
+ const size = JOYSTICK_SIZE * scale;
163
+ const thumbSize = JOYSTICK_THUMB * scale;
91
164
  const applyVector = (clientX, clientY) => {
92
165
  const base = baseRef.current;
93
166
  const thumb = thumbRef.current;
@@ -102,7 +175,7 @@ function VirtualJoystick({ joystick, sink }) {
102
175
  nx /= length;
103
176
  ny /= length;
104
177
  }
105
- const travel = radius - JOYSTICK_THUMB / 2;
178
+ const travel = radius - thumbSize / 2;
106
179
  thumb.style.transform = `translate(${nx * travel}px, ${ny * travel}px)`;
107
180
  const next = joystickDirections(joystick, nx, ny);
108
181
  for (const action of activeRef.current) {
@@ -123,7 +196,7 @@ function VirtualJoystick({ joystick, sink }) {
123
196
  if (thumbRef.current !== null)
124
197
  thumbRef.current.style.transform = "translate(0px, 0px)";
125
198
  };
126
- return (_jsx("div", { ref: baseRef, className: "pointer-events-auto relative flex touch-none select-none items-center justify-center rounded-full border border-white/20 bg-white/10 backdrop-blur-sm", style: { width: JOYSTICK_SIZE, height: JOYSTICK_SIZE }, onPointerDown: (event) => {
199
+ return (_jsx("div", { ref: baseRef, className: "pointer-events-auto relative flex touch-none select-none items-center justify-center rounded-full border border-white/20 bg-white/10 backdrop-blur-sm", style: { width: size, height: size }, onPointerDown: (event) => {
127
200
  if (pointerIdRef.current !== null)
128
201
  return;
129
202
  pointerIdRef.current = event.pointerId;
@@ -139,7 +212,7 @@ function VirtualJoystick({ joystick, sink }) {
139
212
  return;
140
213
  event.stopPropagation();
141
214
  releaseAll();
142
- }, onPointerCancel: () => releaseAll(), children: _jsx("div", { ref: thumbRef, className: "rounded-full border border-white/30 bg-white/25", style: { width: JOYSTICK_THUMB, height: JOYSTICK_THUMB } }) }));
215
+ }, onPointerCancel: () => releaseAll(), children: _jsx("div", { ref: thumbRef, className: "rounded-full border border-white/30 bg-white/25", style: { width: thumbSize, height: thumbSize } }) }));
143
216
  }
144
217
  function touchButtonIcon(button) {
145
218
  if (button.icon === false)
@@ -148,16 +221,36 @@ function touchButtonIcon(button) {
148
221
  return button.icon;
149
222
  return iconForAction(button.action);
150
223
  }
151
- function TouchActionButton({ button, sink }) {
224
+ function TouchActionButton({ button, sink, scale = 1 }) {
152
225
  const pointerIdRef = useRef(null);
153
226
  const icon = touchButtonIcon(button);
227
+ const size = BUTTON_SIZE * scale;
228
+ const releaseIfHeld = () => {
229
+ if (pointerIdRef.current === null)
230
+ return;
231
+ pointerIdRef.current = null;
232
+ sink.onCodeUp(touchCode(button.action));
233
+ };
234
+ return (_jsx("button", { type: "button", "aria-label": button.label, style: { width: size, height: size }, className: "pointer-events-auto flex touch-none select-none items-center justify-center rounded-full border border-white/25 bg-white/10 text-center text-[10px] font-semibold uppercase leading-tight tracking-wide text-white/90 backdrop-blur-sm active:border-white/50 active:bg-white/30", onPointerDown: (event) => {
235
+ if (pointerIdRef.current !== null)
236
+ return;
237
+ pointerIdRef.current = event.pointerId;
238
+ event.currentTarget.setPointerCapture(event.pointerId);
239
+ sink.onCodeDown(touchCode(button.action));
240
+ }, onPointerUp: (event) => {
241
+ event.stopPropagation();
242
+ releaseIfHeld();
243
+ }, onPointerCancel: () => releaseIfHeld(), onContextMenu: (event) => event.preventDefault(), children: icon !== null ? _jsx(GameIcon, { name: icon, size: Math.round(26 * scale) }) : _jsx("span", { className: "px-1", children: button.label }) }));
244
+ }
245
+ function TouchUtilityChip({ button, sink }) {
246
+ const pointerIdRef = useRef(null);
154
247
  const releaseIfHeld = () => {
155
248
  if (pointerIdRef.current === null)
156
249
  return;
157
250
  pointerIdRef.current = null;
158
251
  sink.onCodeUp(touchCode(button.action));
159
252
  };
160
- return (_jsx("button", { type: "button", "aria-label": button.label, className: "pointer-events-auto flex h-14 w-14 touch-none select-none items-center justify-center rounded-full border border-white/25 bg-white/10 text-center text-[10px] font-semibold uppercase leading-tight tracking-wide text-white/90 backdrop-blur-sm active:border-white/50 active:bg-white/30", onPointerDown: (event) => {
253
+ return (_jsx("button", { type: "button", "aria-label": button.label, className: "pointer-events-auto flex h-7 touch-none select-none items-center rounded-full border border-white/20 bg-black/40 px-3 text-[10px] font-semibold uppercase tracking-wide text-white/80 backdrop-blur-sm active:border-white/50 active:bg-white/25", onPointerDown: (event) => {
161
254
  if (pointerIdRef.current !== null)
162
255
  return;
163
256
  pointerIdRef.current = event.pointerId;
@@ -166,8 +259,21 @@ function TouchActionButton({ button, sink }) {
166
259
  }, onPointerUp: (event) => {
167
260
  event.stopPropagation();
168
261
  releaseIfHeld();
169
- }, onPointerCancel: () => releaseIfHeld(), onContextMenu: (event) => event.preventDefault(), children: icon !== null ? _jsx(GameIcon, { name: icon, size: 26 }) : _jsx("span", { className: "px-1", children: button.label }) }));
262
+ }, onPointerCancel: () => releaseIfHeld(), onContextMenu: (event) => event.preventDefault(), children: button.label }));
263
+ }
264
+ function PrimaryButtonCluster({ buttons, sink, scale = 1 }) {
265
+ const offsets = primaryButtonOffsets(buttons.length, scale);
266
+ if (offsets === null) {
267
+ return (_jsx("div", { className: "flex max-w-[60%] flex-wrap items-end justify-end gap-3", children: buttons.map((button) => (_jsx(TouchActionButton, { button: button, sink: sink, scale: scale }, button.action))) }));
268
+ }
269
+ const extent = primaryClusterExtent(buttons.length, scale);
270
+ return (_jsx("div", { className: "pointer-events-none relative", style: { width: extent, height: extent }, children: buttons.map((button, index) => {
271
+ const offset = offsets[index];
272
+ return (_jsx("div", { className: "absolute", style: { right: offset?.right ?? 0, bottom: offset?.bottom ?? 0 }, children: _jsx(TouchActionButton, { button: button, sink: sink, scale: scale }) }, button.action));
273
+ }) }));
170
274
  }
171
- export function TouchControlsDock({ scheme, sink }) {
172
- return (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 bottom-0 z-40 flex items-end justify-between gap-4 px-5", style: { paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 20px)" }, children: [_jsx("div", { children: scheme.joystick !== null ? _jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink }) : null }), _jsx("div", { className: "flex max-w-[60%] flex-wrap items-end justify-end gap-3", children: scheme.buttons.map((button) => (_jsx(TouchActionButton, { button: button, sink: sink }, button.action))) })] }));
275
+ export function TouchControlsDock({ scheme, sink, scale = 1 }) {
276
+ const primary = scheme.buttons.filter((button) => button.kind !== "utility");
277
+ 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] }));
173
279
  }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode, type MutableRefObject } from "react";
2
+ import type { VisibilityConfig } from "@jgengine/core/visibility/config";
3
+ type VisiblePredicate = (id: string) => boolean;
4
+ /**
5
+ * Read the current render-visibility predicate. Backward compatible: with no CullingProvider
6
+ * mounted (or culling disabled) it returns an always-visible ref, so a marker that consults it
7
+ * behaves exactly as before this feature existed.
8
+ */
9
+ export declare function useRenderVisibility(): MutableRefObject<VisiblePredicate>;
10
+ /**
11
+ * Drives automatic frustum + distance culling for every entity and placed object. It reads the
12
+ * live render camera each frame, updates the engine VisibilitySystem, and exposes a predicate the
13
+ * entity/object markers consult to toggle `group.visible` — objects fully outside the view (plus a
14
+ * conservative preload margin) are never submitted to the renderer, without unmounting them or
15
+ * touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
16
+ */
17
+ export declare function CullingProvider({ config, children }: {
18
+ config: VisibilityConfig | undefined;
19
+ children: ReactNode;
20
+ }): ReactNode;
21
+ export {};
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo, useRef } from "react";
3
+ import { useThree, useFrame } from "@react-three/fiber";
4
+ import * as THREE from "three";
5
+ import { useGameContext } from "@jgengine/react/provider";
6
+ import { createVisibilitySystem } from "@jgengine/core/visibility/visibilitySystem";
7
+ import { CAMERA_POST_FRAME_PRIORITY } from "../camera/index.js";
8
+ const ALWAYS_VISIBLE = () => true;
9
+ const alwaysVisibleRef = { current: ALWAYS_VISIBLE };
10
+ const CullingContext = createContext(null);
11
+ /**
12
+ * Read the current render-visibility predicate. Backward compatible: with no CullingProvider
13
+ * mounted (or culling disabled) it returns an always-visible ref, so a marker that consults it
14
+ * behaves exactly as before this feature existed.
15
+ */
16
+ export function useRenderVisibility() {
17
+ return useContext(CullingContext) ?? alwaysVisibleRef;
18
+ }
19
+ const ENTITY_BOUNDS = { kind: "sphere", radius: 2, offset: [0, 1, 0] };
20
+ function objectRadius(scale) {
21
+ if (scale === undefined)
22
+ return 1;
23
+ const s = typeof scale === "number" ? scale : Math.max(scale[0], scale[1], scale[2]);
24
+ return Math.max(0.75, s);
25
+ }
26
+ function buildSystem(ctx, config) {
27
+ const versions = new Map();
28
+ let currentView = null;
29
+ const scratch = { id: "", position: [0, 0, 0], version: 0 };
30
+ const objectSpec = { kind: "sphere", radius: 1, offset: [0, 0.5, 0] };
31
+ function bump(id, position, rot) {
32
+ const prev = versions.get(id);
33
+ if (prev !== undefined && prev.x === position[0] && prev.y === position[1] && prev.z === position[2] && prev.rot === rot) {
34
+ return prev.version;
35
+ }
36
+ const version = (prev?.version ?? 0) + 1;
37
+ versions.set(id, { x: position[0], y: position[1], z: position[2], rot, version });
38
+ return version;
39
+ }
40
+ function* renderables() {
41
+ const entityOverrides = config?.entities;
42
+ for (const entity of ctx.scene.entity.list()) {
43
+ scratch.id = entity.id;
44
+ scratch.position = entity.position;
45
+ scratch.version = bump(entity.id, entity.position, entity.rotationY);
46
+ scratch.bounds = ENTITY_BOUNDS;
47
+ scratch.overrides = entityOverrides?.[entity.name];
48
+ scratch.layer = "entity";
49
+ yield scratch;
50
+ }
51
+ const objectOverrides = config?.objects;
52
+ for (const object of ctx.scene.object.list()) {
53
+ scratch.id = object.instanceId;
54
+ scratch.position = object.position;
55
+ scratch.version = bump(object.instanceId, object.position, object.rotationY);
56
+ objectSpec.radius = objectRadius(object.visual?.scale);
57
+ scratch.bounds = objectSpec;
58
+ scratch.overrides = objectOverrides?.[object.catalogId];
59
+ scratch.layer = "object";
60
+ yield scratch;
61
+ }
62
+ }
63
+ const cameras = () => (currentView === null ? EMPTY_CAMERAS : [{ id: "main", view: currentView }]);
64
+ const options = {
65
+ renderables,
66
+ cameras,
67
+ ...(config?.culling !== undefined ? { settings: config.culling } : {}),
68
+ ...(config?.scene !== undefined ? { sceneOverrides: config.scene } : {}),
69
+ };
70
+ const system = createVisibilitySystem(options);
71
+ return {
72
+ system,
73
+ setView(view) {
74
+ currentView = view;
75
+ },
76
+ };
77
+ }
78
+ const EMPTY_CAMERAS = [];
79
+ const tmpDir = new THREE.Vector3();
80
+ function viewFromCamera(camera) {
81
+ camera.getWorldDirection(tmpDir);
82
+ const p = camera.position;
83
+ const position = [p.x, p.y, p.z];
84
+ const target = [p.x + tmpDir.x, p.y + tmpDir.y, p.z + tmpDir.z];
85
+ const up = [camera.up.x, camera.up.y, camera.up.z];
86
+ if (camera.isPerspectiveCamera) {
87
+ const c = camera;
88
+ return { kind: "perspective", position, target, up, fovDeg: c.fov, aspect: c.aspect, near: c.near, far: c.far };
89
+ }
90
+ if (camera.isOrthographicCamera) {
91
+ const c = camera;
92
+ const zoom = c.zoom || 1;
93
+ return {
94
+ kind: "orthographic",
95
+ position,
96
+ target,
97
+ up,
98
+ halfWidth: Math.abs((c.right - c.left) / 2 / zoom),
99
+ halfHeight: Math.abs((c.top - c.bottom) / 2 / zoom),
100
+ near: c.near,
101
+ far: c.far,
102
+ };
103
+ }
104
+ return null;
105
+ }
106
+ /**
107
+ * Drives automatic frustum + distance culling for every entity and placed object. It reads the
108
+ * live render camera each frame, updates the engine VisibilitySystem, and exposes a predicate the
109
+ * entity/object markers consult to toggle `group.visible` — objects fully outside the view (plus a
110
+ * conservative preload margin) are never submitted to the renderer, without unmounting them or
111
+ * touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
112
+ */
113
+ export function CullingProvider({ config, children }) {
114
+ const ctx = useGameContext();
115
+ const enabled = config?.enabled !== false;
116
+ const predicateRef = useRef(ALWAYS_VISIBLE);
117
+ const camera = useThree((state) => state.camera);
118
+ const driver = useMemo(() => (enabled ? buildSystem(ctx, config) : null), [ctx, config, enabled]);
119
+ useFrame(() => {
120
+ if (driver === null) {
121
+ predicateRef.current = ALWAYS_VISIBLE;
122
+ return;
123
+ }
124
+ const view = viewFromCamera(camera);
125
+ if (view === null) {
126
+ predicateRef.current = ALWAYS_VISIBLE;
127
+ return;
128
+ }
129
+ driver.setView(view);
130
+ const visible = driver.system.update().visible;
131
+ predicateRef.current = (id) => visible.has(id);
132
+ }, CAMERA_POST_FRAME_PRIORITY);
133
+ return _jsx(CullingContext.Provider, { value: predicateRef, children: children });
134
+ }
@@ -1,14 +1,9 @@
1
1
  import { type FramingConfig, type FrustumSample } from "@jgengine/core/sensor/frustumSensor";
2
+ export { frustumSampleDisplayEqual } from "./frustumSampleEqual.js";
2
3
  export interface FrustumSensorProbeOptions extends FramingConfig {
3
4
  subjectIds: readonly string[];
4
5
  subjectRadius?: number;
5
6
  }
6
- /**
7
- * View-frustum sensor (#117) driven by the live render camera: which held-camera
8
- * subjects are in frame, how well framed, and their dwell time on-screen
9
- * (Content Warning-style "is this monster filmed well" scoring). Returns the
10
- * best-framed in-view subject each frame for a photo-mode HUD readout.
11
- */
12
7
  export declare function useFrustumSensor(options: FrustumSensorProbeOptions): FrustumSample | null;
13
8
  /**
14
9
  * Renders inside the Canvas (needs the live camera via `useFrame`/`useThree`)
@@ -5,21 +5,18 @@ import { useMemo, useRef, useState } from "react";
5
5
  import { PerspectiveCamera, Vector3 } from "three";
6
6
  import { createFrustumSensor, } from "@jgengine/core/sensor/frustumSensor";
7
7
  import { useSceneEntities } from "@jgengine/react/hooks";
8
+ import { frustumSampleDisplayEqual } from "./frustumSampleEqual.js";
9
+ export { frustumSampleDisplayEqual } from "./frustumSampleEqual.js";
8
10
  function isPerspective(camera) {
9
11
  return camera.isPerspectiveCamera === true;
10
12
  }
11
- /**
12
- * View-frustum sensor (#117) driven by the live render camera: which held-camera
13
- * subjects are in frame, how well framed, and their dwell time on-screen
14
- * (Content Warning-style "is this monster filmed well" scoring). Returns the
15
- * best-framed in-view subject each frame for a photo-mode HUD readout.
16
- */
17
13
  export function useFrustumSensor(options) {
18
14
  const camera = useThree((state) => state.camera);
19
15
  const size = useThree((state) => state.size);
20
16
  const entities = useSceneEntities();
21
17
  const sensor = useMemo(() => createFrustumSensor(options), []);
22
18
  const [best, setBest] = useState(null);
19
+ const bestRef = useRef(null);
23
20
  const forward = useRef(new Vector3());
24
21
  useFrame((_, dt) => {
25
22
  const targets = [];
@@ -29,28 +26,29 @@ export function useFrustumSensor(options) {
29
26
  continue;
30
27
  targets.push({ id, position: entity.position, radius: options.subjectRadius });
31
28
  }
32
- if (targets.length === 0) {
33
- setBest(null);
34
- return;
35
- }
36
- camera.getWorldDirection(forward.current);
37
- const samples = sensor.tick({
38
- position: [camera.position.x, camera.position.y, camera.position.z],
39
- lookAt: [
40
- camera.position.x + forward.current.x,
41
- camera.position.y + forward.current.y,
42
- camera.position.z + forward.current.z,
43
- ],
44
- fovDeg: isPerspective(camera) ? camera.fov : 55,
45
- aspect: size.height === 0 ? 16 / 9 : size.width / size.height,
46
- }, targets, dt);
47
29
  let leader = null;
48
- for (const sample of samples) {
49
- if (!sample.inView)
50
- continue;
51
- if (leader === null || sample.framing > leader.framing)
52
- leader = sample;
30
+ if (targets.length > 0) {
31
+ camera.getWorldDirection(forward.current);
32
+ const samples = sensor.tick({
33
+ position: [camera.position.x, camera.position.y, camera.position.z],
34
+ lookAt: [
35
+ camera.position.x + forward.current.x,
36
+ camera.position.y + forward.current.y,
37
+ camera.position.z + forward.current.z,
38
+ ],
39
+ fovDeg: isPerspective(camera) ? camera.fov : 55,
40
+ aspect: size.height === 0 ? 16 / 9 : size.width / size.height,
41
+ }, targets, dt);
42
+ for (const sample of samples) {
43
+ if (!sample.inView)
44
+ continue;
45
+ if (leader === null || sample.framing > leader.framing)
46
+ leader = sample;
47
+ }
53
48
  }
49
+ if (frustumSampleDisplayEqual(bestRef.current, leader))
50
+ return;
51
+ bestRef.current = leader;
54
52
  setBest(leader);
55
53
  });
56
54
  return best;
@@ -62,6 +60,6 @@ export function useFrustumSensor(options) {
62
60
  */
63
61
  export function FrustumSensorReadout(props) {
64
62
  const sample = useFrustumSensor(props);
65
- return (_jsx(Html, { fullscreen: true, children: _jsx("div", { className: props.wrapperClassName ?? "pointer-events-none absolute bottom-24 right-4", children: sample === null ? (_jsx("div", { className: props.className ?? "rounded border border-white/15 bg-black/60 px-3 py-1.5 text-xs text-white/50", children: "No subject framed" })) : (_jsxs("div", { className: props.className ??
63
+ return (_jsx(Html, { fullscreen: true, calculatePosition: (_el, _camera, size) => [size.width / 2, size.height / 2], children: _jsx("div", { className: props.wrapperClassName ?? "pointer-events-none absolute bottom-24 right-4", children: sample === null ? (_jsx("div", { className: props.className ?? "rounded border border-white/15 bg-black/60 px-3 py-1.5 text-xs text-white/50", children: "No subject framed" })) : (_jsxs("div", { className: props.className ??
66
64
  "rounded border border-amber-300/40 bg-black/70 px-3 py-1.5 text-xs text-amber-200 shadow-lg", children: [_jsx("span", { className: "font-semibold uppercase tracking-wide", children: sample.id }), _jsxs("span", { className: "ml-2 tabular-nums", children: ["framing ", Math.round(sample.framing * 100), "%"] }), _jsxs("span", { className: "ml-2 tabular-nums", children: ["dwell ", sample.dwellSeconds.toFixed(1), "s"] })] })) }) }));
67
65
  }
@@ -0,0 +1,2 @@
1
+ import type { FrustumSample } from "@jgengine/core/sensor/frustumSensor";
2
+ export declare function frustumSampleDisplayEqual(a: FrustumSample | null, b: FrustumSample | null): boolean;
@@ -0,0 +1,10 @@
1
+ export function frustumSampleDisplayEqual(a, b) {
2
+ if (a === b)
3
+ return true;
4
+ if (a === null || b === null)
5
+ return false;
6
+ return (a.id === b.id &&
7
+ a.inView === b.inView &&
8
+ Math.round(a.framing * 100) === Math.round(b.framing * 100) &&
9
+ Math.round(a.dwellSeconds * 10) === Math.round(b.dwellSeconds * 10));
10
+ }
@@ -5,7 +5,7 @@ import * as THREE from "three";
5
5
  import { createOceanConfig } from "./OceanConfig.js";
6
6
  import { createOceanMaterial, syncOceanMaterial } from "./OceanMaterial.js";
7
7
  function createOceanGeometry(config) {
8
- const geometry = new THREE.PlaneGeometry(config.size, config.size, config.resolution, config.resolution);
8
+ const geometry = new THREE.PlaneGeometry(config.size, config.depth, config.resolution, config.resolution);
9
9
  geometry.rotateX(-Math.PI / 2);
10
10
  geometry.computeBoundingSphere();
11
11
  return geometry;
@@ -30,7 +30,10 @@ export interface OceanFoamConfig {
30
30
  }
31
31
  export interface OceanConfig {
32
32
  quality?: OceanQualityPreset;
33
+ /** Ocean plane width in world units (X). When `depth` is omitted, also used for depth (square). */
33
34
  size?: number;
35
+ /** Ocean plane depth in world units (Z). Defaults to `size` for a square sheet. */
36
+ depth?: number;
34
37
  resolution?: number;
35
38
  amplitude?: number;
36
39
  speed?: number;
@@ -38,6 +41,12 @@ export interface OceanConfig {
38
41
  choppiness?: number;
39
42
  steepness?: number;
40
43
  timeScale?: number;
44
+ /**
45
+ * Primary Gerstner wavelength in world units — same unit as `@jgengine/core/world/water` `waveScale`
46
+ * (default 18). Scales the default wave set so the longest wave matches this value; explicit `waves`
47
+ * wavelengths are multiplied by `waveScale / 18`.
48
+ */
49
+ waveScale?: number;
41
50
  color?: OceanColorConfig;
42
51
  foam?: OceanFoamConfig;
43
52
  waves?: readonly OceanWaveConfig[];
@@ -67,6 +76,7 @@ export interface ResolvedOceanWaveConfig {
67
76
  export interface ResolvedOceanConfig {
68
77
  quality: OceanQualityPreset;
69
78
  size: number;
79
+ depth: number;
70
80
  resolution: number;
71
81
  amplitude: number;
72
82
  speed: number;
@@ -74,10 +84,13 @@ export interface ResolvedOceanConfig {
74
84
  choppiness: number;
75
85
  steepness: number;
76
86
  timeScale: number;
87
+ waveScale: number;
77
88
  color: ResolvedOceanColorConfig;
78
89
  foam: ResolvedOceanFoamConfig;
79
90
  waves: readonly ResolvedOceanWaveConfig[];
80
91
  }
92
+ /** Shared with `@jgengine/core/world/water` — primary wavelength in world units. */
93
+ export declare const DEFAULT_OCEAN_WAVE_SCALE = 18;
81
94
  export declare const OCEAN_QUALITY_PRESETS: Record<OceanQualityPreset, {
82
95
  size: number;
83
96
  resolution: number;
@@ -1,5 +1,7 @@
1
1
  import * as THREE from "three";
2
2
  export const MAX_OCEAN_WAVES = 6;
3
+ /** Shared with `@jgengine/core/world/water` — primary wavelength in world units. */
4
+ export const DEFAULT_OCEAN_WAVE_SCALE = 18;
3
5
  export const OCEAN_QUALITY_PRESETS = {
4
6
  low: { size: 220, resolution: 80 },
5
7
  medium: { size: 360, resolution: 128 },
@@ -9,6 +11,7 @@ export const OCEAN_QUALITY_PRESETS = {
9
11
  export const DEFAULT_OCEAN_CONFIG = {
10
12
  quality: "medium",
11
13
  size: OCEAN_QUALITY_PRESETS.medium.size,
14
+ depth: OCEAN_QUALITY_PRESETS.medium.size,
12
15
  resolution: OCEAN_QUALITY_PRESETS.medium.resolution,
13
16
  amplitude: 1,
14
17
  speed: 1,
@@ -16,6 +19,7 @@ export const DEFAULT_OCEAN_CONFIG = {
16
19
  choppiness: 1,
17
20
  steepness: 0.72,
18
21
  timeScale: 1,
22
+ waveScale: DEFAULT_OCEAN_WAVE_SCALE,
19
23
  color: {
20
24
  shallow: "#2dc5d3",
21
25
  deep: "#064468",
@@ -33,14 +37,10 @@ export const DEFAULT_OCEAN_CONFIG = {
33
37
  },
34
38
  waves: [],
35
39
  };
36
- const DEFAULT_WAVE_SHAPE = [
37
- { amplitude: 1.15, wavelength: 44, speed: 1, steepness: 0.68 },
38
- { amplitude: 0.72, wavelength: 28, speed: 1.08, steepness: 0.58 },
39
- { amplitude: 0.44, wavelength: 17, speed: 1.18, steepness: 0.48 },
40
- { amplitude: 0.28, wavelength: 10, speed: 1.34, steepness: 0.38 },
41
- { amplitude: 0.18, wavelength: 6.5, speed: 1.52, steepness: 0.28 },
42
- { amplitude: 0.11, wavelength: 3.8, speed: 1.75, steepness: 0.18 },
43
- ];
40
+ const WAVE_WAVELENGTH_FALLOFF = 0.65;
41
+ const WAVE_AMPLITUDE_SHAPE = [1.15, 0.72, 0.44, 0.28, 0.18, 0.11];
42
+ const WAVE_SPEED_SHAPE = [1, 1.08, 1.18, 1.34, 1.52, 1.75];
43
+ const WAVE_STEEPNESS_SHAPE = [0.68, 0.58, 0.48, 0.38, 0.28, 0.18];
44
44
  const DEFAULT_DIRECTION_OFFSETS = [-8, 7, 21, -31, 46, -58];
45
45
  function finiteOr(value, fallback) {
46
46
  return value === undefined || !Number.isFinite(value) ? fallback : value;
@@ -67,18 +67,21 @@ function normalizeDirection(direction, fallback) {
67
67
  }
68
68
  function createDefaultWaves(config) {
69
69
  const baseAngle = directionAngle(config.direction);
70
- return DEFAULT_WAVE_SHAPE.map((wave, index) => ({
71
- amplitude: wave.amplitude,
72
- wavelength: wave.wavelength,
73
- speed: wave.speed,
70
+ return WAVE_AMPLITUDE_SHAPE.map((amplitude, index) => ({
71
+ amplitude,
72
+ wavelength: Math.max(0.1, config.waveScale * WAVE_WAVELENGTH_FALLOFF ** index),
73
+ speed: WAVE_SPEED_SHAPE[index],
74
74
  direction: directionFromAngle(baseAngle + DEFAULT_DIRECTION_OFFSETS[index]),
75
- steepness: wave.steepness,
75
+ steepness: WAVE_STEEPNESS_SHAPE[index],
76
76
  }));
77
77
  }
78
- function resolveWave(wave, fallback, config) {
78
+ function resolveWave(wave, fallback, config, customWaves) {
79
+ const wavelength = customWaves && wave?.wavelength !== undefined
80
+ ? wave.wavelength * (config.waveScale / DEFAULT_OCEAN_WAVE_SCALE)
81
+ : finiteOr(wave?.wavelength, fallback.wavelength);
79
82
  return {
80
83
  amplitude: Math.max(0, finiteOr(wave?.amplitude, fallback.amplitude) * config.amplitude),
81
- wavelength: Math.max(0.1, finiteOr(wave?.wavelength, fallback.wavelength)),
84
+ wavelength: Math.max(0.1, wavelength),
82
85
  speed: finiteOr(wave?.speed, fallback.speed) * config.speed,
83
86
  direction: normalizeDirection(wave?.direction, fallback.direction),
84
87
  steepness: clamp(finiteOr(wave?.steepness, fallback.steepness) * config.steepness, 0, 1.2),
@@ -87,9 +90,12 @@ function resolveWave(wave, fallback, config) {
87
90
  export function createOceanConfig(patch = {}) {
88
91
  const quality = patch.quality ?? DEFAULT_OCEAN_CONFIG.quality;
89
92
  const preset = OCEAN_QUALITY_PRESETS[quality];
93
+ const size = Math.max(1, finiteOr(patch.size, preset.size));
94
+ const depth = Math.max(1, finiteOr(patch.depth, size));
90
95
  const withoutWaves = {
91
96
  quality,
92
- size: Math.max(1, Math.floor(finiteOr(patch.size, preset.size))),
97
+ size,
98
+ depth,
93
99
  resolution: Math.max(1, Math.floor(finiteOr(patch.resolution, preset.resolution))),
94
100
  amplitude: Math.max(0, finiteOr(patch.amplitude, DEFAULT_OCEAN_CONFIG.amplitude)),
95
101
  speed: finiteOr(patch.speed, DEFAULT_OCEAN_CONFIG.speed),
@@ -97,6 +103,7 @@ export function createOceanConfig(patch = {}) {
97
103
  choppiness: Math.max(0, finiteOr(patch.choppiness, DEFAULT_OCEAN_CONFIG.choppiness)),
98
104
  steepness: Math.max(0, finiteOr(patch.steepness, DEFAULT_OCEAN_CONFIG.steepness)),
99
105
  timeScale: finiteOr(patch.timeScale, DEFAULT_OCEAN_CONFIG.timeScale),
106
+ waveScale: Math.max(0.1, finiteOr(patch.waveScale, DEFAULT_OCEAN_CONFIG.waveScale)),
100
107
  color: {
101
108
  ...DEFAULT_OCEAN_CONFIG.color,
102
109
  ...patch.color,
@@ -114,8 +121,9 @@ export function createOceanConfig(patch = {}) {
114
121
  },
115
122
  };
116
123
  const defaultWaves = createDefaultWaves(withoutWaves);
124
+ const customWaves = patch.waves !== undefined;
117
125
  const inputWaves = patch.waves ?? defaultWaves;
118
- const waves = Array.from({ length: MAX_OCEAN_WAVES }, (_, index) => resolveWave(inputWaves[index], defaultWaves[index], withoutWaves));
126
+ const waves = Array.from({ length: MAX_OCEAN_WAVES }, (_, index) => resolveWave(inputWaves[index], defaultWaves[index], withoutWaves, customWaves));
119
127
  return { ...withoutWaves, waves };
120
128
  }
121
129
  export function buildOceanWaveUniforms(config) {
@@ -1,3 +1,3 @@
1
1
  export { Ocean, type OceanProps } from "./Ocean.js";
2
- export { DEFAULT_OCEAN_CONFIG, MAX_OCEAN_WAVES, OCEAN_QUALITY_PRESETS, buildOceanWaveUniforms, createOceanConfig, type OceanColorConfig, type OceanConfig, type OceanDirectionVector, type OceanFoamConfig, type OceanQualityPreset, type OceanWaveConfig, type OceanWaveDirection, type ResolvedOceanColorConfig, type ResolvedOceanConfig, type ResolvedOceanFoamConfig, type ResolvedOceanWaveConfig, } from "./OceanConfig.js";
2
+ export { DEFAULT_OCEAN_CONFIG, MAX_OCEAN_WAVES, OCEAN_QUALITY_PRESETS, buildOceanWaveUniforms, createOceanConfig, DEFAULT_OCEAN_WAVE_SCALE, type OceanColorConfig, type OceanConfig, type OceanDirectionVector, type OceanFoamConfig, type OceanQualityPreset, type OceanWaveConfig, type OceanWaveDirection, type ResolvedOceanColorConfig, type ResolvedOceanConfig, type ResolvedOceanFoamConfig, type ResolvedOceanWaveConfig, } from "./OceanConfig.js";
3
3
  export { createOceanMaterial, syncOceanMaterial, type OceanMaterialUniforms, type OceanShaderMaterial, } from "./OceanMaterial.js";
@@ -1,3 +1,3 @@
1
1
  export { Ocean } from "./Ocean.js";
2
- export { DEFAULT_OCEAN_CONFIG, MAX_OCEAN_WAVES, OCEAN_QUALITY_PRESETS, buildOceanWaveUniforms, createOceanConfig, } from "./OceanConfig.js";
2
+ export { DEFAULT_OCEAN_CONFIG, MAX_OCEAN_WAVES, OCEAN_QUALITY_PRESETS, buildOceanWaveUniforms, createOceanConfig, DEFAULT_OCEAN_WAVE_SCALE, } from "./OceanConfig.js";
3
3
  export { createOceanMaterial, syncOceanMaterial, } from "./OceanMaterial.js";