@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
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import { useFrame } from "@react-three/fiber";
4
4
  import * as THREE from "three";
5
+ import { setBillboardQuaternion } from "./fireSpreadPose.js";
5
6
  const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
6
7
  export function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, flameHeight = 1.6, burningColor = "#ff6a1a", emberColor = "#4a1206", }) {
7
8
  const flames = useRef(null);
@@ -24,6 +25,8 @@ export function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, fla
24
25
  const matrix = useMemo(() => new THREE.Matrix4(), []);
25
26
  const position = useMemo(() => new THREE.Vector3(), []);
26
27
  const quaternion = useMemo(() => new THREE.Quaternion(), []);
28
+ const identityQuat = useMemo(() => new THREE.Quaternion(), []);
29
+ const euler = useMemo(() => new THREE.Euler(), []);
27
30
  const scale = useMemo(() => new THREE.Vector3(1, 1, 1), []);
28
31
  useFrame((state) => {
29
32
  const flameMesh = flames.current;
@@ -42,7 +45,7 @@ export function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, fla
42
45
  const groundY = heightAt?.(x, z) ?? 0;
43
46
  if (cell.state === "burning") {
44
47
  position.set(x, groundY + flameHeight * 0.5, z);
45
- quaternion.setFromEuler(new THREE.Euler(0, state.camera.rotation.y, 0));
48
+ setBillboardQuaternion(quaternion, euler, state.camera.rotation.y);
46
49
  scale.set(1, flicker, 1);
47
50
  matrix.compose(position, quaternion, scale);
48
51
  flameMesh.setMatrixAt(flameIndex, matrix);
@@ -50,7 +53,9 @@ export function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, fla
50
53
  }
51
54
  else if (cell.state === "burnt") {
52
55
  position.set(x, groundY + 0.05, z);
53
- scorchMesh.setMatrixAt(scorchIndex, matrix.compose(position, new THREE.Quaternion(), scale.set(1, 1, 1)));
56
+ scale.set(1, 1, 1);
57
+ matrix.compose(position, identityQuat, scale);
58
+ scorchMesh.setMatrixAt(scorchIndex, matrix);
54
59
  scorchIndex += 1;
55
60
  }
56
61
  }
@@ -3,6 +3,7 @@ import { type WeatherVector } from "./weatherUniforms.js";
3
3
  export interface RainFieldProps {
4
4
  count?: number;
5
5
  density?: number;
6
+ budget?: number;
6
7
  volume?: WeatherVector;
7
8
  wind?: WeatherVector;
8
9
  origin?: WeatherVector;
@@ -16,5 +17,6 @@ export interface RainFieldProps {
16
17
  timeScale?: number;
17
18
  seed?: number;
18
19
  renderOrder?: number;
20
+ frustumCulled?: boolean;
19
21
  }
20
- export declare function RainField({ count, density, volume, wind, origin, followCamera, speed, length, width, opacity, color, lightning, timeScale, seed, renderOrder, }: RainFieldProps): import("react").JSX.Element;
22
+ export declare function RainField({ count, density, budget, volume, wind, origin, followCamera, speed, length, width, opacity, color, lightning, timeScale, seed, renderOrder, frustumCulled, }: RainFieldProps): import("react").JSX.Element;
@@ -3,12 +3,12 @@ import { useEffect, useMemo } from "react";
3
3
  import { useFrame, useThree } from "@react-three/fiber";
4
4
  import * as THREE from "three";
5
5
  import { createWeatherQuadGeometry } from "./weatherGeometry.js";
6
- import { resolveWeatherInstanceCount } from "./weatherMath.js";
6
+ import { DEFAULT_RAIN_COUNT, DEFAULT_RAIN_DENSITY, resolveWeatherInstanceCount } from "./weatherMath.js";
7
7
  import { useWeatherUniformSet } from "./weatherUniforms.js";
8
8
  const DEFAULT_VOLUME = [56, 42, 56];
9
9
  const DEFAULT_ORIGIN = [0, 0, 0];
10
10
  const DEFAULT_RAIN_COLOR = "#b8c4d8";
11
- export function RainField({ count = 8000, density = 0.45, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 22, length = 1.35, width = 0.018, opacity = 0.48, color = DEFAULT_RAIN_COLOR, lightning, timeScale, seed = 11939, renderOrder = 10, }) {
11
+ export function RainField({ count = DEFAULT_RAIN_COUNT, density = DEFAULT_RAIN_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 22, length = 1.35, width = 0.018, opacity = 0.48, color = DEFAULT_RAIN_COLOR, lightning, timeScale, seed = 11939, renderOrder = 10, frustumCulled, }) {
12
12
  const { camera } = useThree();
13
13
  const shared = useWeatherUniformSet({ wind, lightning, timeScale });
14
14
  const geometry = useMemo(() => createWeatherQuadGeometry(count, seed), [count, seed]);
@@ -97,11 +97,11 @@ export function RainField({ count = 8000, density = 0.45, volume = DEFAULT_VOLUM
97
97
  uniforms.uWidth.value = width;
98
98
  uniforms.uOpacity.value = opacity;
99
99
  uniforms.uColor.value.set(color);
100
- geometry.instanceCount = resolveWeatherInstanceCount(count, density);
100
+ geometry.instanceCount = resolveWeatherInstanceCount(count, density, budget);
101
101
  });
102
102
  useEffect(() => () => {
103
103
  geometry.dispose();
104
104
  material.dispose();
105
105
  }, [geometry, material]);
106
- return _jsx("mesh", { geometry: geometry, material: material, frustumCulled: false, renderOrder: renderOrder });
106
+ return (_jsx("mesh", { geometry: geometry, material: material, frustumCulled: frustumCulled ?? !followCamera, renderOrder: renderOrder }));
107
107
  }
@@ -3,6 +3,7 @@ import { type WeatherVector } from "./weatherUniforms.js";
3
3
  export interface SnowFieldProps {
4
4
  count?: number;
5
5
  density?: number;
6
+ budget?: number;
6
7
  volume?: WeatherVector;
7
8
  wind?: WeatherVector;
8
9
  origin?: WeatherVector;
@@ -15,5 +16,6 @@ export interface SnowFieldProps {
15
16
  timeScale?: number;
16
17
  seed?: number;
17
18
  renderOrder?: number;
19
+ frustumCulled?: boolean;
18
20
  }
19
- export declare function SnowField({ count, density, volume, wind, origin, followCamera, speed, size, sway, opacity, color, timeScale, seed, renderOrder, }: SnowFieldProps): import("react").JSX.Element;
21
+ export declare function SnowField({ count, density, budget, volume, wind, origin, followCamera, speed, size, sway, opacity, color, timeScale, seed, renderOrder, frustumCulled, }: SnowFieldProps): import("react").JSX.Element;
@@ -3,12 +3,12 @@ import { useEffect, useMemo } from "react";
3
3
  import { useFrame, useThree } from "@react-three/fiber";
4
4
  import * as THREE from "three";
5
5
  import { createWeatherQuadGeometry } from "./weatherGeometry.js";
6
- import { resolveWeatherInstanceCount } from "./weatherMath.js";
6
+ import { DEFAULT_SNOW_COUNT, DEFAULT_SNOW_DENSITY, resolveWeatherInstanceCount } from "./weatherMath.js";
7
7
  import { useWeatherUniformSet } from "./weatherUniforms.js";
8
8
  const DEFAULT_VOLUME = [52, 38, 52];
9
9
  const DEFAULT_ORIGIN = [0, 0, 0];
10
10
  const DEFAULT_SNOW_COLOR = "#ffffff";
11
- export function SnowField({ count = 6000, density = 0.5, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, }) {
11
+ export function SnowField({ count = DEFAULT_SNOW_COUNT, density = DEFAULT_SNOW_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, frustumCulled, }) {
12
12
  const { camera } = useThree();
13
13
  const shared = useWeatherUniformSet({ wind, timeScale });
14
14
  const geometry = useMemo(() => createWeatherQuadGeometry(count, seed), [count, seed]);
@@ -98,11 +98,11 @@ export function SnowField({ count = 6000, density = 0.5, volume = DEFAULT_VOLUME
98
98
  uniforms.uSway.value = sway;
99
99
  uniforms.uOpacity.value = opacity;
100
100
  uniforms.uColor.value.set(color);
101
- geometry.instanceCount = resolveWeatherInstanceCount(count, density);
101
+ geometry.instanceCount = resolveWeatherInstanceCount(count, density, budget);
102
102
  });
103
103
  useEffect(() => () => {
104
104
  geometry.dispose();
105
105
  material.dispose();
106
106
  }, [geometry, material]);
107
- return _jsx("mesh", { geometry: geometry, material: material, frustumCulled: false, renderOrder: renderOrder });
107
+ return (_jsx("mesh", { geometry: geometry, material: material, frustumCulled: frustumCulled ?? !followCamera, renderOrder: renderOrder }));
108
108
  }
@@ -0,0 +1,2 @@
1
+ import type { Euler, Quaternion } from "three";
2
+ export declare function setBillboardQuaternion(quaternion: Quaternion, euler: Euler, yaw: number): void;
@@ -0,0 +1,4 @@
1
+ export function setBillboardQuaternion(quaternion, euler, yaw) {
2
+ euler.set(0, yaw, 0);
3
+ quaternion.setFromEuler(euler);
4
+ }
@@ -1,5 +1,9 @@
1
+ export declare const DEFAULT_RAIN_COUNT = 2000;
2
+ export declare const DEFAULT_SNOW_COUNT = 1500;
3
+ export declare const DEFAULT_RAIN_DENSITY = 0.45;
4
+ export declare const DEFAULT_SNOW_DENSITY = 0.5;
1
5
  export declare function clampWeatherRatio(value: number): number;
2
- export declare function resolveWeatherInstanceCount(maxCount: number, density: number): number;
6
+ export declare function resolveWeatherInstanceCount(maxCount: number, density: number, budget?: number): number;
3
7
  export interface WeatherSeedAttributes {
4
8
  spawn: Float32Array;
5
9
  drift: Float32Array;
@@ -1,3 +1,7 @@
1
+ export const DEFAULT_RAIN_COUNT = 2000;
2
+ export const DEFAULT_SNOW_COUNT = 1500;
3
+ export const DEFAULT_RAIN_DENSITY = 0.45;
4
+ export const DEFAULT_SNOW_DENSITY = 0.5;
1
5
  export function clampWeatherRatio(value) {
2
6
  if (!Number.isFinite(value))
3
7
  return 0;
@@ -7,9 +11,10 @@ export function clampWeatherRatio(value) {
7
11
  return 1;
8
12
  return value;
9
13
  }
10
- export function resolveWeatherInstanceCount(maxCount, density) {
14
+ export function resolveWeatherInstanceCount(maxCount, density, budget) {
11
15
  const safeMax = Math.max(0, Math.floor(maxCount));
12
- return Math.floor(safeMax * clampWeatherRatio(density));
16
+ const capped = budget === undefined ? safeMax : Math.min(safeMax, Math.max(0, Math.floor(budget)));
17
+ return Math.floor(capped * clampWeatherRatio(density));
13
18
  }
14
19
  export function createWeatherSeedAttributes(maxCount, seed) {
15
20
  const count = Math.max(0, Math.floor(maxCount));
@@ -41,4 +41,4 @@ export interface DataObjectsProps<T> {
41
41
  height: number;
42
42
  }) => ReactNode;
43
43
  }
44
- export declare function DataObjects<T>({ data, position, height, color, cellSize, hovered, hoverColor, onHover, grow, castShadow, receiveShadow, renderItem, }: DataObjectsProps<T>): import("react").JSX.Element;
44
+ export declare function DataObjects<T>({ data, position, height, color, cellSize, hovered, hoverColor, onHover, grow, castShadow, receiveShadow, renderItem, }: DataObjectsProps<T>): import("react").JSX.Element | null;
@@ -71,5 +71,7 @@ export function DataObjects({ data, position, height, color, cellSize = 0.28, ho
71
71
  return (_jsx("group", { position: [x, 0, z], children: renderItem(item, i, { x, z, height: height(item, i) }) }, i));
72
72
  }) }));
73
73
  }
74
- return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, Math.max(capacity, 1)], castShadow: castShadow, receiveShadow: receiveShadow, frustumCulled: false, onPointerMove: onHover === undefined ? undefined : handleMove, onPointerOut: onHover === undefined ? undefined : () => onHover(null) }, capacity));
74
+ if (capacity === 0)
75
+ return null;
76
+ return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, capacity], castShadow: castShadow, receiveShadow: receiveShadow, frustumCulled: false, onPointerMove: onHover === undefined ? undefined : handleMove, onPointerOut: onHover === undefined ? undefined : () => onHover(null) }, capacity));
75
77
  }
@@ -0,0 +1,44 @@
1
+ export interface SpriteBatchInstance {
2
+ /** World X position. */
3
+ x: number;
4
+ /** World Y position. */
5
+ y: number;
6
+ /** World Z position. Defaults to 0. */
7
+ z?: number;
8
+ /** Atlas frame index, row-major from the sheet's top-left. Defaults to 0. */
9
+ frame?: number;
10
+ /** Uniform scale multiplier for the unit quad. Defaults to 1. */
11
+ scale?: number;
12
+ /** Rotation in radians about the plane's facing axis. Defaults to 0. */
13
+ rotation?: number;
14
+ }
15
+ export interface SpriteBatchProps {
16
+ /** Sprite sheet / atlas texture URL. */
17
+ url: string;
18
+ /** Atlas column count. Defaults to 1. */
19
+ columns?: number;
20
+ /** Atlas row count. Defaults to 1. */
21
+ rows?: number;
22
+ /** Max instances the InstancedMesh is allocated for. Defaults to 1024. */
23
+ capacity?: number;
24
+ /** Per-frame instance source, called once inside useFrame. */
25
+ instances: () => readonly SpriteBatchInstance[];
26
+ /** Facing plane when not billboarding: "xy" faces +Z (side-scroll/ortho), "xz" lies flat facing +Y. Defaults to "xy". */
27
+ plane?: "xy" | "xz";
28
+ /** When true, every instance orients to face the camera each frame, overriding `plane`. Defaults to false. */
29
+ billboard?: boolean;
30
+ /** Use nearest-neighbor filtering for crisp pixel art. Defaults to true. */
31
+ pixelated?: boolean;
32
+ /** Alpha test cutoff for transparent pixels. Defaults to 0.08. */
33
+ alphaTest?: number;
34
+ /** Material opacity. Defaults to 1. */
35
+ opacity?: number;
36
+ }
37
+ /**
38
+ * Renders a sprite sheet / tile atlas as a single InstancedMesh — one draw call for the whole
39
+ * batch. Each instance picks its atlas frame via a per-instance UV offset attribute patched into
40
+ * the material's vertex shader, so platformer/puzzle-grid presentation never needs one draw call
41
+ * per sprite. Transforms and UV offsets are written directly into the mesh's typed arrays each
42
+ * frame from a plain instance list (bodies never touch the per-entity React path).
43
+ */
44
+ export declare function SpriteBatch({ url, columns, rows, capacity, instances, plane, billboard, pixelated, alphaTest, opacity, }: SpriteBatchProps): import("react").JSX.Element;
@@ -0,0 +1,112 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useLoader, useThree } from "@react-three/fiber";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import * as THREE from "three";
5
+ const DEFAULT_CAPACITY = 1024;
6
+ const DEFAULT_ALPHA_TEST = 0.08;
7
+ const XZ_BASE_QUATERNION = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
8
+ const LOCAL_NORMAL_AXIS = new THREE.Vector3(0, 0, 1);
9
+ /**
10
+ * Renders a sprite sheet / tile atlas as a single InstancedMesh — one draw call for the whole
11
+ * batch. Each instance picks its atlas frame via a per-instance UV offset attribute patched into
12
+ * the material's vertex shader, so platformer/puzzle-grid presentation never needs one draw call
13
+ * per sprite. Transforms and UV offsets are written directly into the mesh's typed arrays each
14
+ * frame from a plain instance list (bodies never touch the per-entity React path).
15
+ */
16
+ export function SpriteBatch({ url, columns = 1, rows = 1, capacity = DEFAULT_CAPACITY, instances, plane = "xy", billboard = false, pixelated = true, alphaTest = DEFAULT_ALPHA_TEST, opacity = 1, }) {
17
+ const meshRef = useRef(null);
18
+ const { camera } = useThree();
19
+ const texture = useLoader(THREE.TextureLoader, url);
20
+ useEffect(() => {
21
+ texture.colorSpace = THREE.SRGBColorSpace;
22
+ if (pixelated) {
23
+ texture.magFilter = THREE.NearestFilter;
24
+ texture.minFilter = THREE.NearestFilter;
25
+ texture.anisotropy = 1;
26
+ }
27
+ else {
28
+ texture.anisotropy = 4;
29
+ }
30
+ texture.needsUpdate = true;
31
+ }, [texture, pixelated]);
32
+ const geometry = useMemo(() => new THREE.PlaneGeometry(1, 1), []);
33
+ const spriteUvOffset = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(capacity * 2), 2), [capacity]);
34
+ const material = useMemo(() => {
35
+ const mat = new THREE.MeshBasicMaterial({
36
+ map: texture,
37
+ transparent: true,
38
+ alphaTest,
39
+ depthWrite: false,
40
+ opacity,
41
+ });
42
+ mat.onBeforeCompile = (shader) => {
43
+ shader.uniforms.spriteUvScale = { value: new THREE.Vector2(1 / columns, 1 / rows) };
44
+ shader.vertexShader = shader.vertexShader
45
+ .replace("#include <common>", `#include <common>
46
+ attribute vec2 spriteUvOffset;
47
+ uniform vec2 spriteUvScale;`)
48
+ .replace("#include <uv_vertex>", `#include <uv_vertex>
49
+ #ifdef USE_MAP
50
+ vMapUv = vMapUv * spriteUvScale + spriteUvOffset;
51
+ #endif`);
52
+ };
53
+ mat.customProgramCacheKey = () => `jgengine-sprite-batch-${columns}x${rows}`;
54
+ return mat;
55
+ }, [texture, columns, rows, alphaTest, opacity]);
56
+ useEffect(() => {
57
+ const mesh = meshRef.current;
58
+ if (mesh === null)
59
+ return;
60
+ mesh.geometry.setAttribute("spriteUvOffset", spriteUvOffset);
61
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
62
+ spriteUvOffset.setUsage(THREE.DynamicDrawUsage);
63
+ }, [spriteUvOffset]);
64
+ const baseQuaternion = useMemo(() => (plane === "xz" ? XZ_BASE_QUATERNION.clone() : new THREE.Quaternion()), [plane]);
65
+ const position = useMemo(() => new THREE.Vector3(), []);
66
+ const quaternion = useMemo(() => new THREE.Quaternion(), []);
67
+ const rotationQuaternion = useMemo(() => new THREE.Quaternion(), []);
68
+ const scaleVector = useMemo(() => new THREE.Vector3(), []);
69
+ const matrix = useMemo(() => new THREE.Matrix4(), []);
70
+ useFrame(() => {
71
+ const mesh = meshRef.current;
72
+ if (mesh === null)
73
+ return;
74
+ const list = instances();
75
+ const count = Math.min(list.length, capacity);
76
+ mesh.count = count;
77
+ const matrixArray = mesh.instanceMatrix.array;
78
+ const uvArray = spriteUvOffset.array;
79
+ for (let i = 0; i < count; i += 1) {
80
+ const instance = list[i];
81
+ position.set(instance.x, instance.y, instance.z ?? 0);
82
+ if (billboard) {
83
+ quaternion.copy(camera.quaternion);
84
+ }
85
+ else {
86
+ quaternion.copy(baseQuaternion);
87
+ }
88
+ const rotation = instance.rotation ?? 0;
89
+ if (rotation !== 0) {
90
+ rotationQuaternion.setFromAxisAngle(LOCAL_NORMAL_AXIS, rotation);
91
+ quaternion.multiply(rotationQuaternion);
92
+ }
93
+ const scale = instance.scale ?? 1;
94
+ scaleVector.set(scale, scale, scale);
95
+ matrix.compose(position, quaternion, scaleVector);
96
+ matrix.toArray(matrixArray, i * 16);
97
+ const frame = instance.frame ?? 0;
98
+ const col = frame % columns;
99
+ const row = Math.floor(frame / columns);
100
+ const uo = i * 2;
101
+ uvArray[uo] = col / columns;
102
+ uvArray[uo + 1] = 1 - (row + 1) / rows;
103
+ }
104
+ mesh.instanceMatrix.needsUpdate = true;
105
+ spriteUvOffset.needsUpdate = true;
106
+ });
107
+ useEffect(() => () => {
108
+ geometry.dispose();
109
+ material.dispose();
110
+ }, [geometry, material]);
111
+ return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, capacity], frustumCulled: false, castShadow: false, receiveShadow: false }));
112
+ }
@@ -1,10 +1,15 @@
1
1
  import type { SceneEntity } from "@jgengine/core/scene/entityStore";
2
2
  import type { CatalogEntityRole } from "@jgengine/core/runtime/gameContext";
3
- export declare function WorldEntityBars({ statId, height, roles, resolveRole, }: {
3
+ export type { WorldBarSample } from "./worldBarSamples.js";
4
+ export { collectWorldBarSamples, paintWorldBarSamples } from "./worldBarSamples.js";
5
+ export { telegraphPulseOpacity } from "./telegraphPulse.js";
6
+ export declare function WorldEntityBars({ statId, height, roles, resolveRole, maxDistance, }: {
4
7
  statId: string;
5
8
  height?: number;
6
9
  roles?: readonly CatalogEntityRole[];
7
10
  resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
11
+ /** Hide bars for entities farther than this from the player (world units). Default 60. */
12
+ maxDistance?: number;
8
13
  }): import("react").JSX.Element;
9
14
  export declare function WorldFloatText({ height, lifeMs }: {
10
15
  height?: number;
@@ -3,27 +3,43 @@ import { Html, Line } from "@react-three/drei";
3
3
  import { useFrame, useThree } from "@react-three/fiber";
4
4
  import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import * as THREE from "three";
6
- import { worldHealthBarAllowsRole } from "@jgengine/core/game/playableGame";
7
6
  import { useGameContext } from "@jgengine/react/provider";
8
- import { useEntityStat, useSceneEntities } from "@jgengine/react/hooks";
7
+ import { useCameraShake } from "../camera/shakeChannel.js";
8
+ import { readFirstPersonMuzzle } from "../camera/GameFirstPersonCamera.js";
9
9
  import { resolveFloatTextStyle } from "./floatTextStyle.js";
10
- const MUZZLE_HEIGHT = 1.4;
11
- function EntityBar({ entity, statId, height }) {
12
- const stat = useEntityStat(entity.id, statId);
13
- if (stat === null)
14
- return null;
15
- const range = stat.max - stat.min;
16
- const percent = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
17
- return (_jsx(Html, { position: [entity.position[0], entity.position[1] + height, entity.position[2]], center: true, distanceFactor: 12, zIndexRange: [20, 0], children: _jsx("div", { className: "h-2.5 w-28 overflow-hidden rounded-sm border border-black/70 bg-black/70 shadow", children: _jsx("div", { className: "h-full bg-gradient-to-r from-rose-600 to-red-400", style: { width: `${percent * 100}%` } }) }) }));
10
+ import { collectWorldBarSamples, paintWorldBarSamples, } from "./worldBarSamples.js";
11
+ import { telegraphPulseOpacity } from "./telegraphPulse.js";
12
+ export { collectWorldBarSamples, paintWorldBarSamples } from "./worldBarSamples.js";
13
+ export { telegraphPulseOpacity } from "./telegraphPulse.js";
14
+ function pinOverlayToViewport(_el, _camera, size) {
15
+ return [size.width / 2, size.height / 2];
18
16
  }
19
- export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, }) {
17
+ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, }) {
20
18
  const ctx = useGameContext();
21
- const entities = useSceneEntities();
22
- const playerId = ctx.player.userId;
23
- return (_jsx(_Fragment, { children: entities
24
- .filter((entity) => entity.id !== playerId)
25
- .filter((entity) => worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
26
- .map((entity) => (_jsx(EntityBar, { entity: entity, statId: statId, height: height }, entity.id))) }));
19
+ const camera = useThree((state) => state.camera);
20
+ const size = useThree((state) => state.size);
21
+ const gl = useThree((state) => state.gl);
22
+ const canvasRef = useRef(null);
23
+ const samplesRef = useRef([]);
24
+ const projectRef = useRef(new THREE.Vector3());
25
+ useFrame(() => {
26
+ const canvas = canvasRef.current;
27
+ if (canvas === null)
28
+ return;
29
+ const dpr = Math.min(2, gl.getPixelRatio());
30
+ const cssW = size.width;
31
+ const cssH = size.height;
32
+ const pixelW = Math.max(1, Math.floor(cssW * dpr));
33
+ const pixelH = Math.max(1, Math.floor(cssH * dpr));
34
+ if (canvas.width !== pixelW || canvas.height !== pixelH) {
35
+ canvas.width = pixelW;
36
+ canvas.height = pixelH;
37
+ }
38
+ camera.updateMatrixWorld();
39
+ collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, { width: cssW, height: cssH }, samplesRef.current, projectRef.current, maxDistance);
40
+ paintWorldBarSamples(canvas, samplesRef.current, dpr);
41
+ });
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" } }) }));
27
43
  }
28
44
  function FloatTextItem({ event, lifeMs }) {
29
45
  const [shown, setShown] = useState(false);
@@ -47,12 +63,24 @@ export function WorldFloatText({ height = 1.9, lifeMs = 950 }) {
47
63
  const ctx = useGameContext();
48
64
  const [floaters, setFloaters] = useState([]);
49
65
  const nextId = useRef(0);
66
+ const timers = useRef(new Set());
50
67
  useEffect(() => {
51
- return ctx.game.events.on("entity.floatText", (event) => {
68
+ const pending = timers.current;
69
+ const off = ctx.game.events.on("entity.floatText", (event) => {
52
70
  const id = nextId.current++;
53
71
  setFloaters((current) => [...current, { id, position: event.position, event }]);
54
- window.setTimeout(() => setFloaters((current) => current.filter((floater) => floater.id !== id)), lifeMs);
72
+ const timer = window.setTimeout(() => {
73
+ pending.delete(timer);
74
+ setFloaters((current) => current.filter((floater) => floater.id !== id));
75
+ }, lifeMs);
76
+ pending.add(timer);
55
77
  });
78
+ return () => {
79
+ off();
80
+ for (const timer of pending)
81
+ window.clearTimeout(timer);
82
+ pending.clear();
83
+ };
56
84
  }, [ctx, lifeMs]);
57
85
  return (_jsx(_Fragment, { children: floaters.map((floater) => (_jsx(Html, { position: [floater.position[0], floater.position[1] + height, floater.position[2]], center: true, distanceFactor: 12, zIndexRange: [30, 0], children: _jsx(FloatTextItem, { event: floater.event, lifeMs: lifeMs }) }, floater.id))) }));
58
86
  }
@@ -65,73 +93,92 @@ function TelegraphGeometry({ shape }) {
65
93
  return _jsx("circleGeometry", { args: [shape.radius, 40, -shape.angle / 2, shape.angle] });
66
94
  return _jsx("planeGeometry", { args: [shape.width, shape.length] });
67
95
  }
68
- function TelegraphDecal({ active, nowMs }) {
69
- const progress = Math.max(0, Math.min(1, (nowMs - active.bornMs) / active.event.windupMs));
96
+ function TelegraphDecal({ active }) {
97
+ const materialRef = useRef(null);
70
98
  const [x, y, z] = active.event.position;
71
99
  const dir = active.event.dir ?? 0;
72
100
  const shape = active.event.shape;
73
101
  const forwardOffset = shape.kind === "line" ? shape.length / 2 : 0;
74
102
  const color = active.event.kind === "danger" ? "#ef4444" : "#f59e0b";
75
- const pulse = 0.45 + 0.5 * progress;
76
- return (_jsxs("mesh", { position: [x + Math.sin(dir) * forwardOffset, y + 0.06, z + Math.cos(dir) * forwardOffset], rotation: [-Math.PI / 2, 0, shape.kind === "line" || shape.kind === "cone" ? -dir : 0], renderOrder: 999, children: [_jsx(TelegraphGeometry, { shape: shape }), _jsx("meshBasicMaterial", { color: color, transparent: true, opacity: pulse, side: THREE.DoubleSide, depthWrite: false, depthTest: false, toneMapped: false })] }));
103
+ useFrame(() => {
104
+ const material = materialRef.current;
105
+ if (material === null)
106
+ return;
107
+ material.opacity = telegraphPulseOpacity(active.bornMs, active.event.windupMs, performance.now());
108
+ });
109
+ return (_jsxs("mesh", { position: [x + Math.sin(dir) * forwardOffset, y + 0.06, z + Math.cos(dir) * forwardOffset], rotation: [-Math.PI / 2, 0, shape.kind === "line" || shape.kind === "cone" ? -dir : 0], renderOrder: 999, children: [_jsx(TelegraphGeometry, { shape: shape }), _jsx("meshBasicMaterial", { ref: materialRef, color: color, transparent: true, opacity: 0.45, side: THREE.DoubleSide, depthWrite: false, depthTest: false, toneMapped: false })] }));
77
110
  }
78
111
  export function WorldTelegraphs() {
79
112
  const ctx = useGameContext();
80
113
  const [telegraphs, setTelegraphs] = useState([]);
81
114
  const key = useRef(0);
82
- const [nowMs, setNowMs] = useState(() => performance.now());
83
- useFrame(() => setNowMs(performance.now()));
115
+ const timers = useRef(new Set());
84
116
  useEffect(() => {
85
- return ctx.game.events.on("combat.telegraph", (event) => {
117
+ const pending = timers.current;
118
+ const off = ctx.game.events.on("combat.telegraph", (event) => {
86
119
  const entry = { key: key.current++, event, bornMs: performance.now() };
87
120
  setTelegraphs((current) => [...current, entry]);
88
- window.setTimeout(() => setTelegraphs((current) => current.filter((t) => t.key !== entry.key)), event.windupMs + 120);
121
+ const timer = window.setTimeout(() => {
122
+ pending.delete(timer);
123
+ setTelegraphs((current) => current.filter((t) => t.key !== entry.key));
124
+ }, event.windupMs + 120);
125
+ pending.add(timer);
89
126
  });
127
+ const offCancel = ctx.game.events.on("combat.telegraphCancelled", (event) => {
128
+ setTelegraphs((current) => current.filter((t) => t.event.id !== event.id));
129
+ });
130
+ return () => {
131
+ off();
132
+ offCancel();
133
+ for (const timer of pending)
134
+ window.clearTimeout(timer);
135
+ pending.clear();
136
+ };
90
137
  }, [ctx]);
91
- return (_jsx(_Fragment, { children: telegraphs.map((active) => (_jsx(TelegraphDecal, { active: active, nowMs: nowMs }, active.key))) }));
138
+ return (_jsx(_Fragment, { children: telegraphs.map((active) => (_jsx(TelegraphDecal, { active: active }, active.key))) }));
92
139
  }
93
140
  export function CombatCameraShake() {
94
141
  const ctx = useGameContext();
95
- const camera = useThree((state) => state.camera);
96
- const trauma = useRef(0);
97
- const decay = useRef(4);
142
+ const shake = useCameraShake();
98
143
  useEffect(() => {
99
144
  return ctx.game.events.on("combat.hitReaction", (event) => {
100
145
  if (event.shake === undefined)
101
146
  return;
102
- trauma.current = Math.min(1, trauma.current + event.shake.amplitude);
103
- decay.current = event.shake.decay;
147
+ shake.shake(event.shake.amplitude, event.shake.decay);
104
148
  });
105
- }, [ctx]);
106
- useFrame((_state, dt) => {
107
- if (trauma.current <= 0.0001)
108
- return;
109
- const magnitude = trauma.current * trauma.current;
110
- camera.position.x += (Math.random() * 2 - 1) * magnitude * 0.4;
111
- camera.position.y += (Math.random() * 2 - 1) * magnitude * 0.4;
112
- trauma.current = Math.max(0, trauma.current - decay.current * dt);
113
- });
149
+ }, [ctx, shake]);
114
150
  return null;
115
151
  }
116
152
  export function ProjectileTracers({ lifeMs = 130 }) {
117
153
  const ctx = useGameContext();
118
154
  const [tracers, setTracers] = useState([]);
119
155
  const nextId = useRef(0);
156
+ const timers = useRef(new Set());
120
157
  useEffect(() => {
121
- return ctx.game.events.on("projectile.settled", (event) => {
158
+ const unsub = ctx.game.events.on("projectile.settled", (event) => {
122
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);
123
163
  setTracers((current) => [
124
164
  ...current,
125
165
  {
126
166
  id,
127
- points: [
128
- new THREE.Vector3(event.origin[0], event.origin[1] + MUZZLE_HEIGHT, event.origin[2]),
129
- new THREE.Vector3(event.at[0], event.at[1], event.at[2]),
130
- ],
167
+ points: [start, new THREE.Vector3(event.at[0], event.at[1], event.at[2])],
131
168
  },
132
169
  ]);
133
- window.setTimeout(() => setTracers((current) => current.filter((tracer) => tracer.id !== id)), lifeMs);
170
+ const handle = setTimeout(() => {
171
+ timers.current.delete(handle);
172
+ setTracers((current) => current.filter((tracer) => tracer.id !== id));
173
+ }, lifeMs);
174
+ timers.current.add(handle);
134
175
  });
176
+ return () => {
177
+ unsub();
178
+ for (const handle of timers.current)
179
+ clearTimeout(handle);
180
+ timers.current.clear();
181
+ };
135
182
  }, [ctx, lifeMs]);
136
183
  return (_jsx(_Fragment, { children: tracers.map((tracer) => (_jsx(Line, { points: tracer.points, color: "#fde68a", lineWidth: 2, transparent: true, opacity: 0.85 }, tracer.id))) }));
137
184
  }
@@ -0,0 +1,14 @@
1
+ export interface PoseWritable {
2
+ position: {
3
+ set(x: number, y: number, z: number): void;
4
+ };
5
+ rotation: {
6
+ y: number;
7
+ };
8
+ }
9
+ export interface PoseSource {
10
+ position: readonly [number, number, number];
11
+ rotationY: number;
12
+ }
13
+ export declare function writeEntityPose(target: PoseWritable, source: PoseSource): void;
14
+ export declare function posesEqual(a: PoseSource, b: PoseSource): boolean;
@@ -0,0 +1,10 @@
1
+ export function writeEntityPose(target, source) {
2
+ target.position.set(source.position[0], source.position[1], source.position[2]);
3
+ target.rotation.y = source.rotationY;
4
+ }
5
+ export function posesEqual(a, b) {
6
+ return (a.position[0] === b.position[0] &&
7
+ a.position[1] === b.position[1] &&
8
+ a.position[2] === b.position[2] &&
9
+ a.rotationY === b.rotationY);
10
+ }
@@ -0,0 +1 @@
1
+ export declare function telegraphPulseOpacity(bornMs: number, windupMs: number, nowMs: number): number;
@@ -0,0 +1,4 @@
1
+ export function telegraphPulseOpacity(bornMs, windupMs, nowMs) {
2
+ const progress = Math.max(0, Math.min(1, (nowMs - bornMs) / Math.max(1, windupMs)));
3
+ return 0.45 + 0.5 * progress;
4
+ }