@jgengine/shell 0.14.0 → 0.15.0-next.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 (114) hide show
  1. package/CHANGELOG.md +483 -1
  2. package/dist/GamePlayerShell.js +25 -5
  3. package/dist/Shell3dPresentation.d.ts +6 -6
  4. package/dist/Shell3dPresentation.js +4 -4
  5. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  6. package/dist/camera/ControllerCameraShake.js +34 -0
  7. package/dist/camera/GameCameraRig.js +10 -5
  8. package/dist/camera/GameOrbitCamera.js +1 -10
  9. package/dist/camera/cameraRigs.js +12 -4
  10. package/dist/camera/index.d.ts +1 -0
  11. package/dist/camera/index.js +1 -0
  12. package/dist/camera/rigMath.d.ts +2 -0
  13. package/dist/camera/rigMath.js +7 -0
  14. package/dist/devtools/CollisionDebugWorld.js +2 -2
  15. package/dist/devtools/DevtoolsOverlay.js +2 -0
  16. package/dist/drivers/FrameDriver.d.ts +4 -1
  17. package/dist/drivers/FrameDriver.js +5 -3
  18. package/dist/environment/DayNightSky.d.ts +33 -0
  19. package/dist/environment/DayNightSky.js +51 -0
  20. package/dist/environment/Daylight.d.ts +3 -1
  21. package/dist/environment/Daylight.js +7 -3
  22. package/dist/environment/EnvironmentScene.js +6 -6
  23. package/dist/environment/RoadJunctions.d.ts +8 -4
  24. package/dist/environment/RoadJunctions.js +16 -15
  25. package/dist/environment/RoadRibbons.d.ts +42 -6
  26. package/dist/environment/RoadRibbons.js +150 -27
  27. package/dist/environment/VolumetricClouds.js +3 -3
  28. package/dist/environment/index.d.ts +1 -0
  29. package/dist/environment/index.js +1 -0
  30. package/dist/gameKit.d.ts +7 -4
  31. package/dist/gameKit.js +8 -5
  32. package/dist/inputSink.js +14 -0
  33. package/dist/materialOverride.js +30 -0
  34. package/dist/postfx/PostProcessing.d.ts +3 -2
  35. package/dist/postfx/PostProcessing.js +11 -3
  36. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  37. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  38. package/dist/presentationResolve.d.ts +3 -2
  39. package/dist/presentationResolve.js +11 -6
  40. package/dist/render/CascadedShadows.d.ts +13 -0
  41. package/dist/render/CascadedShadows.js +96 -0
  42. package/dist/render/EnvironmentLighting.d.ts +23 -7
  43. package/dist/render/EnvironmentLighting.js +69 -10
  44. package/dist/render/PartMotion.js +4 -4
  45. package/dist/render/SceneLighting.js +7 -0
  46. package/dist/render/SceneModels.d.ts +37 -1
  47. package/dist/render/SceneModels.js +40 -11
  48. package/dist/render/modelLoad.d.ts +41 -5
  49. package/dist/render/modelLoad.js +85 -18
  50. package/dist/render/sceneCapture.d.ts +27 -0
  51. package/dist/render/sceneCapture.js +43 -0
  52. package/dist/render/useDisposable.d.ts +16 -0
  53. package/dist/render/useDisposable.js +24 -0
  54. package/dist/render/useModelAnimation.js +47 -4
  55. package/dist/scene/AuthoredScene.js +14 -24
  56. package/dist/scene/CityRenderer.js +103 -36
  57. package/dist/scene/GrassFieldRenderer.js +4 -1
  58. package/dist/scene/PoleLineRenderer.js +7 -16
  59. package/dist/settings/QuickControls.js +5 -3
  60. package/dist/settings/SettingsMenu.js +6 -3
  61. package/dist/settings/appliedSettings.d.ts +3 -1
  62. package/dist/settings/appliedSettings.js +17 -5
  63. package/dist/settings/settingsController.js +11 -5
  64. package/dist/shellMovement.d.ts +15 -1
  65. package/dist/shellMovement.js +15 -1
  66. package/dist/structures/GeneratedBuilding.js +4 -5
  67. package/dist/structures/TransformGizmo.js +6 -5
  68. package/dist/terrain/CarvedTerrain.js +3 -7
  69. package/dist/terrain/GrassField.js +2 -2
  70. package/dist/terrain/ProceduralGround.js +3 -5
  71. package/dist/terrain/grassMaterial.d.ts +8 -2
  72. package/dist/terrain/grassMaterial.js +24 -5
  73. package/dist/terrain/index.d.ts +1 -1
  74. package/dist/terrain/terrainDetailMaterial.js +11 -2
  75. package/dist/terrain/terrainMath.d.ts +38 -1
  76. package/dist/terrain/terrainMath.js +86 -64
  77. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  78. package/dist/touch/TouchControlsOverlay.js +136 -17
  79. package/dist/vfx/ParticleField.d.ts +32 -0
  80. package/dist/vfx/ParticleField.js +91 -0
  81. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  82. package/dist/vfx/WorldFloatingText.js +32 -0
  83. package/dist/water/Ocean.js +3 -6
  84. package/dist/weather/LightningStrike.js +3 -9
  85. package/dist/weather/RainField.js +3 -7
  86. package/dist/weather/SnowField.js +3 -7
  87. package/dist/world/DataObjects.js +3 -6
  88. package/dist/world/GridWorldScene.js +3 -6
  89. package/dist/world/InstancedBodies.js +3 -6
  90. package/dist/world/InstancedJoints.js +2 -2
  91. package/dist/world/SpriteBatch.js +3 -6
  92. package/dist/world/WorldEntityFrames.d.ts +47 -0
  93. package/dist/world/WorldEntityFrames.js +75 -0
  94. package/dist/world/WorldHud.js +7 -5
  95. package/dist/world/WorldPings.d.ts +26 -0
  96. package/dist/world/WorldPings.js +68 -0
  97. package/dist/world/WorldScene.js +9 -4
  98. package/dist/world/pingPulse.d.ts +21 -0
  99. package/dist/world/pingPulse.js +17 -0
  100. package/package.json +5 -5
  101. package/skills/ce-handoff/SKILL.md +149 -0
  102. package/skills/game-design/SKILL.md +2 -0
  103. package/skills/jgengine/SKILL.md +3 -1
  104. package/skills/jgengine/api.md +17 -7
  105. package/skills/jgengine/capabilities.md +12 -0
  106. package/skills/jgengine/recipes/minimal-game.md +7 -4
  107. package/skills/jgengine-ui/SKILL.md +5 -3
  108. package/skills/jgengine-ui/api.md +708 -19
  109. package/skills/jgengine-ui/capabilities.md +362 -1
  110. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  111. package/skills/jgengine-ui/reference.md +7 -4
  112. package/skills/jgengine-world/api.md +378 -11
  113. package/skills/jgengine-world/capabilities.md +123 -2
  114. package/skills/jgengine-world/reference.md +15 -1
@@ -0,0 +1,32 @@
1
+ import { type ReactElement } from "react";
2
+ import type { ParticleSystem } from "@jgengine/core/vfx/particles";
3
+ /** How particle fragments composite: `additive` for fire/sparks/magic glow, `normal` for smoke/dust. */
4
+ export type ParticleBlending = "additive" | "normal";
5
+ /** Props for {@link ParticleField}. */
6
+ export interface ParticleFieldProps {
7
+ /** The core simulation to render (from `createParticleSystem`). */
8
+ system: ParticleSystem;
9
+ /**
10
+ * Advance the simulation each frame with the render delta. Leave `true` for a
11
+ * fire-and-forget effect; set `false` when your game loop already ticks the
12
+ * system (so it advances exactly once per fixed step).
13
+ */
14
+ advance?: boolean;
15
+ /** Compositing mode. Default `additive` (glowy). */
16
+ blending?: ParticleBlending;
17
+ /** Pixel-size multiplier applied to every particle's world radius. Default 300. */
18
+ scale?: number;
19
+ /** Write depth (occludes particles behind geometry). Default `false` so a glow layer never self-occludes. */
20
+ depthWrite?: boolean;
21
+ }
22
+ /**
23
+ * Renders a core `ParticleSystem` as a GPU point cloud: one draw call, per-particle
24
+ * size / color / alpha via a soft-round shader, and a `drawRange` clamped to the live
25
+ * count so dead particles cost nothing. The simulation stays engine-side and
26
+ * genre-agnostic; this is purely how it reaches the screen. By default it advances
27
+ * the sim with the frame delta — pass `advance={false}` to drive it from your own
28
+ * fixed loop instead.
29
+ *
30
+ * @capability particle-field render a core particle system as a soft-point GPU cloud with per-particle size, color, and alpha
31
+ */
32
+ export declare function ParticleField({ system, advance, blending, scale, depthWrite, }: ParticleFieldProps): ReactElement;
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import * as THREE from "three";
5
+ const VERTEX_SHADER = /* glsl */ `
6
+ attribute float aSize;
7
+ attribute float aAlpha;
8
+ attribute vec3 aColor;
9
+ varying float vAlpha;
10
+ varying vec3 vColor;
11
+ uniform float uScale;
12
+ void main() {
13
+ vAlpha = aAlpha;
14
+ vColor = aColor;
15
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
16
+ gl_PointSize = aSize * uScale / max(0.0001, -mv.z);
17
+ gl_Position = projectionMatrix * mv;
18
+ }
19
+ `;
20
+ const FRAGMENT_SHADER = /* glsl */ `
21
+ varying float vAlpha;
22
+ varying vec3 vColor;
23
+ void main() {
24
+ // Soft round sprite: radial falloff from the point center, discard the corners.
25
+ vec2 d = gl_PointCoord - vec2(0.5);
26
+ float r = length(d);
27
+ if (r > 0.5) discard;
28
+ float soft = smoothstep(0.5, 0.0, r);
29
+ gl_FragColor = vec4(vColor, vAlpha * soft);
30
+ }
31
+ `;
32
+ /**
33
+ * Renders a core `ParticleSystem` as a GPU point cloud: one draw call, per-particle
34
+ * size / color / alpha via a soft-round shader, and a `drawRange` clamped to the live
35
+ * count so dead particles cost nothing. The simulation stays engine-side and
36
+ * genre-agnostic; this is purely how it reaches the screen. By default it advances
37
+ * the sim with the frame delta — pass `advance={false}` to drive it from your own
38
+ * fixed loop instead.
39
+ *
40
+ * @capability particle-field render a core particle system as a soft-point GPU cloud with per-particle size, color, and alpha
41
+ */
42
+ export function ParticleField({ system, advance = true, blending = "additive", scale = 300, depthWrite = false, }) {
43
+ const pointsRef = useRef(null);
44
+ const dpr = useThree((state) => state.viewport.dpr);
45
+ const { geometry, material } = useMemo(() => {
46
+ const initial = system.buffers();
47
+ const max = initial.positions.length / 3;
48
+ const geo = new THREE.BufferGeometry();
49
+ geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(max * 3), 3));
50
+ geo.setAttribute("aSize", new THREE.BufferAttribute(new Float32Array(max), 1));
51
+ geo.setAttribute("aColor", new THREE.BufferAttribute(new Float32Array(max * 3), 3));
52
+ geo.setAttribute("aAlpha", new THREE.BufferAttribute(new Float32Array(max), 1));
53
+ geo.setDrawRange(0, 0);
54
+ const mat = new THREE.ShaderMaterial({
55
+ vertexShader: VERTEX_SHADER,
56
+ fragmentShader: FRAGMENT_SHADER,
57
+ uniforms: { uScale: { value: scale } },
58
+ transparent: true,
59
+ depthWrite,
60
+ blending: blending === "additive" ? THREE.AdditiveBlending : THREE.NormalBlending,
61
+ });
62
+ return { geometry: geo, material: mat };
63
+ }, [system, scale, depthWrite, blending]);
64
+ useEffect(() => {
65
+ return () => {
66
+ geometry.dispose();
67
+ material.dispose();
68
+ };
69
+ }, [geometry, material]);
70
+ useFrame((_, delta) => {
71
+ if (advance)
72
+ system.update(Math.min(delta, 0.1));
73
+ const buffers = system.buffers();
74
+ const count = buffers.count;
75
+ const pos = geometry.getAttribute("position");
76
+ const size = geometry.getAttribute("aSize");
77
+ const color = geometry.getAttribute("aColor");
78
+ const alpha = geometry.getAttribute("aAlpha");
79
+ pos.array.set(buffers.positions.subarray(0, count * 3));
80
+ size.array.set(buffers.sizes.subarray(0, count));
81
+ color.array.set(buffers.colors.subarray(0, count * 3));
82
+ alpha.array.set(buffers.alphas.subarray(0, count));
83
+ pos.needsUpdate = true;
84
+ size.needsUpdate = true;
85
+ color.needsUpdate = true;
86
+ alpha.needsUpdate = true;
87
+ geometry.setDrawRange(0, count);
88
+ material.uniforms.uScale.value = scale * dpr;
89
+ });
90
+ return _jsx("points", { ref: pointsRef, geometry: geometry, material: material, frustumCulled: false });
91
+ }
@@ -0,0 +1,32 @@
1
+ import { type CSSProperties } from "react";
2
+ import type { FloatingTextField, FloatingTextView } from "@jgengine/core/ui/floatingText";
3
+ import { type FloatingTextLayoutOptions } from "@jgengine/react/floatingText";
4
+ /** Props for {@link WorldFloatingText}. */
5
+ export interface WorldFloatingTextProps extends FloatingTextLayoutOptions {
6
+ /** The core field to render (`createFloatingTextField`). */
7
+ field: FloatingTextField;
8
+ /**
9
+ * Advance the field each frame with the render delta. Leave `true` for a
10
+ * fire-and-forget overlay; set `false` when your game loop already ticks the
11
+ * field so it advances exactly once per fixed step.
12
+ */
13
+ advance?: boolean;
14
+ /** Per-`kind` art direction merged over the defaults (color, font, shadow). */
15
+ styleFor?: (view: FloatingTextView) => CSSProperties;
16
+ /** Base font size in px at `size === 1`. Default 20. */
17
+ fontSizePx?: number;
18
+ /** Class on the overlay layer. */
19
+ className?: string;
20
+ }
21
+ /**
22
+ * R3F one-line drop-in for world-anchored floating text: mounts inside the scene,
23
+ * (optionally) advances a core {@link FloatingTextField} each frame, samples the
24
+ * live camera to project every live entry to the screen, and renders
25
+ * `@jgengine/react`'s {@link FloatingText} through a fullscreen `<Html>` overlay.
26
+ * This is the turnkey way an R3F game gets damage numbers / heal pops / XP gains
27
+ * from its own field without wiring projection by hand — the camera binding,
28
+ * viewport culling, and depth stacking come for free. Skin per `kind` with `styleFor`.
29
+ *
30
+ * @capability floating-text R3F drop-in that advances a floating-text field and renders it camera-projected over the scene
31
+ */
32
+ export declare function WorldFloatingText({ field, advance, styleFor, fontSizePx, className, ...layout }: WorldFloatingTextProps): import("react").JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Html } from "@react-three/drei";
3
+ import { useFrame, useThree } from "@react-three/fiber";
4
+ import { useState } from "react";
5
+ import { FloatingText } from "@jgengine/react/floatingText";
6
+ import { useWorldProjection } from "../world/WorldEntityFrames.js";
7
+ function pinToViewport(_el, _camera, size) {
8
+ return [size.width / 2, size.height / 2];
9
+ }
10
+ /**
11
+ * R3F one-line drop-in for world-anchored floating text: mounts inside the scene,
12
+ * (optionally) advances a core {@link FloatingTextField} each frame, samples the
13
+ * live camera to project every live entry to the screen, and renders
14
+ * `@jgengine/react`'s {@link FloatingText} through a fullscreen `<Html>` overlay.
15
+ * This is the turnkey way an R3F game gets damage numbers / heal pops / XP gains
16
+ * from its own field without wiring projection by hand — the camera binding,
17
+ * viewport culling, and depth stacking come for free. Skin per `kind` with `styleFor`.
18
+ *
19
+ * @capability floating-text R3F drop-in that advances a floating-text field and renders it camera-projected over the scene
20
+ */
21
+ export function WorldFloatingText({ field, advance = true, styleFor, fontSizePx, className, ...layout }) {
22
+ const size = useThree((state) => state.size);
23
+ const project = useWorldProjection();
24
+ const [, setTick] = useState(0);
25
+ useFrame((_, delta) => {
26
+ if (advance)
27
+ field.update(Math.min(delta, 0.1));
28
+ // Re-render every frame so projected positions follow the camera and the sim.
29
+ setTick((n) => n + 1);
30
+ });
31
+ return (_jsx(Html, { fullscreen: true, calculatePosition: pinToViewport, zIndexRange: [20, 0], style: { pointerEvents: "none" }, children: _jsx(FloatingText, { entries: field.active(), project: project, viewport: { width: size.width, height: size.height }, styleFor: styleFor, fontSizePx: fontSizePx, className: className, ...layout }) }));
32
+ }
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  import { createOceanConfig } from "./OceanConfig.js";
6
7
  import { createOceanMaterial, syncOceanMaterial } from "./OceanMaterial.js";
7
8
  /** aDepth when no sampler is given: everywhere-deep, matching the pre-depth shader look. */
@@ -26,14 +27,10 @@ function createOceanGeometry(config, depthAt) {
26
27
  export function Ocean({ config, depthAt, ...meshProps }) {
27
28
  const resolved = useMemo(() => createOceanConfig(config), [config]);
28
29
  const elapsedRef = useRef(0);
29
- const geometry = useMemo(() => createOceanGeometry(resolved, depthAt), [resolved, depthAt]);
30
- const material = useMemo(() => createOceanMaterial(resolved), [resolved]);
31
- useEffect(() => {
32
- return () => geometry.dispose();
33
- }, [geometry]);
30
+ const geometry = useDisposable(() => createOceanGeometry(resolved, depthAt), [resolved, depthAt]);
31
+ const material = useDisposable(() => createOceanMaterial(resolved), [resolved]);
34
32
  useEffect(() => {
35
33
  elapsedRef.current = 0;
36
- return () => material.dispose();
37
34
  }, [material]);
38
35
  useFrame((_, delta) => {
39
36
  elapsedRef.current += delta * resolved.timeScale;
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
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 { useDisposable } from "../render/useDisposable.js";
5
6
  const DEFAULT_COLOR = "#bdd4ff";
6
7
  function createRandom(seed) {
7
8
  let state = seed >>> 0;
@@ -80,8 +81,8 @@ function writeLightningGeometry(geometry, segments) {
80
81
  export function LightningStrike({ origin, target, strikeKey = 0, seed = 451, visible = true, duration = 0.18, color = DEFAULT_COLOR, glow = 2.4, branches = 5, jaggedness = 0.08, impactLight = 26, renderOrder = 20, }) {
81
82
  const lifeRef = useRef(0);
82
83
  const lightRef = useRef(null);
83
- const geometry = useMemo(() => new THREE.BufferGeometry(), []);
84
- const material = useMemo(() => new THREE.LineBasicMaterial({
84
+ const geometry = useDisposable(() => new THREE.BufferGeometry(), []);
85
+ const material = useDisposable(() => new THREE.LineBasicMaterial({
85
86
  color,
86
87
  transparent: true,
87
88
  opacity: 0,
@@ -94,9 +95,6 @@ export function LightningStrike({ origin, target, strikeKey = 0, seed = 451, vis
94
95
  next.renderOrder = renderOrder;
95
96
  return next;
96
97
  }, [geometry, material, renderOrder]);
97
- useEffect(() => {
98
- material.color.set(color);
99
- }, [color, material]);
100
98
  useEffect(() => {
101
99
  if (!visible) {
102
100
  lifeRef.current = 0;
@@ -122,9 +120,5 @@ export function LightningStrike({ origin, target, strikeKey = 0, seed = 451, vis
122
120
  if (lightRef.current !== null)
123
121
  lightRef.current.intensity = amount * impactLight * flicker;
124
122
  });
125
- useEffect(() => () => {
126
- geometry.dispose();
127
- material.dispose();
128
- }, [geometry, material]);
129
123
  return (_jsxs(_Fragment, { children: [_jsx("primitive", { object: line }), _jsx("pointLight", { ref: lightRef, position: [target[0], target[1], target[2]], color: color, intensity: 0, distance: 56, decay: 2 })] }));
130
124
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useMemo } from "react";
3
2
  import { useFrame, useThree } from "@react-three/fiber";
4
3
  import * as THREE from "three";
4
+ import { useDisposable } from "../render/useDisposable.js";
5
5
  import { createWeatherQuadGeometry } from "./weatherGeometry.js";
6
6
  import { DEFAULT_RAIN_COUNT, DEFAULT_RAIN_DENSITY, resolveWeatherInstanceCount } from "./weatherMath.js";
7
7
  import { useWeatherUniformSet } from "./weatherUniforms.js";
@@ -11,8 +11,8 @@ const DEFAULT_RAIN_COLOR = "#b8c4d8";
11
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
- const geometry = useMemo(() => createWeatherQuadGeometry(count, seed), [count, seed]);
15
- const material = useMemo(() => {
14
+ const geometry = useDisposable(() => createWeatherQuadGeometry(count, seed), [count, seed]);
15
+ const material = useDisposable(() => {
16
16
  const uniforms = {
17
17
  uTime: shared.time,
18
18
  uWind: shared.wind,
@@ -99,9 +99,5 @@ export function RainField({ count = DEFAULT_RAIN_COUNT, density = DEFAULT_RAIN_D
99
99
  uniforms.uColor.value.set(color);
100
100
  geometry.instanceCount = resolveWeatherInstanceCount(count, density, budget);
101
101
  });
102
- useEffect(() => () => {
103
- geometry.dispose();
104
- material.dispose();
105
- }, [geometry, material]);
106
102
  return (_jsx("mesh", { geometry: geometry, material: material, frustumCulled: frustumCulled ?? !followCamera, renderOrder: renderOrder }));
107
103
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useMemo } from "react";
3
2
  import { useFrame, useThree } from "@react-three/fiber";
4
3
  import * as THREE from "three";
4
+ import { useDisposable } from "../render/useDisposable.js";
5
5
  import { createWeatherQuadGeometry } from "./weatherGeometry.js";
6
6
  import { DEFAULT_SNOW_COUNT, DEFAULT_SNOW_DENSITY, resolveWeatherInstanceCount } from "./weatherMath.js";
7
7
  import { useWeatherUniformSet } from "./weatherUniforms.js";
@@ -11,8 +11,8 @@ const DEFAULT_SNOW_COLOR = "#ffffff";
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
- const geometry = useMemo(() => createWeatherQuadGeometry(count, seed), [count, seed]);
15
- const material = useMemo(() => {
14
+ const geometry = useDisposable(() => createWeatherQuadGeometry(count, seed), [count, seed]);
15
+ const material = useDisposable(() => {
16
16
  const uniforms = {
17
17
  uTime: shared.time,
18
18
  uWind: shared.wind,
@@ -100,9 +100,5 @@ export function SnowField({ count = DEFAULT_SNOW_COUNT, density = DEFAULT_SNOW_D
100
100
  uniforms.uColor.value.set(color);
101
101
  geometry.instanceCount = resolveWeatherInstanceCount(count, density, budget);
102
102
  });
103
- useEffect(() => () => {
104
- geometry.dispose();
105
- material.dispose();
106
- }, [geometry, material]);
107
103
  return (_jsx("mesh", { geometry: geometry, material: material, frustumCulled: frustumCulled ?? !followCamera, renderOrder: renderOrder }));
108
104
  }
@@ -2,14 +2,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  function clamp01(v) {
6
7
  return v < 0 ? 0 : v > 1 ? 1 : v;
7
8
  }
8
9
  export function DataObjects({ data, position, height, color, cellSize = 0.28, hovered = null, hoverColor = "#ffffff", onHover, grow, castShadow = true, receiveShadow = true, renderItem, }) {
9
10
  const meshRef = useRef(null);
10
11
  const dummy = useMemo(() => new THREE.Object3D(), []);
11
- const geometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
12
- const material = useMemo(() => new THREE.MeshStandardMaterial({ roughness: 0.62, metalness: 0.04 }), []);
12
+ const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
13
+ const material = useDisposable(() => new THREE.MeshStandardMaterial({ roughness: 0.62, metalness: 0.04 }), []);
13
14
  const hover = useMemo(() => new THREE.Color(hoverColor), [hoverColor]);
14
15
  const startRef = useRef(null);
15
16
  const capacity = data.length;
@@ -32,10 +33,6 @@ export function DataObjects({ data, position, height, color, cellSize = 0.28, ho
32
33
  useEffect(() => {
33
34
  startRef.current = null;
34
35
  }, [data]);
35
- useEffect(() => () => {
36
- geometry.dispose();
37
- material.dispose();
38
- }, [geometry, material]);
39
36
  const duration = grow?.duration ?? 0;
40
37
  useFrame((state) => {
41
38
  const mesh = meshRef.current;
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { resolveGridInstances } from "@jgengine/core/world/gridInstances";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  function isGridWorldFeature(feature) {
6
7
  return (feature.kind === "biomes" || feature.kind === "voxel" || feature.kind === "plots" || feature.kind === "tilemap");
7
8
  }
@@ -13,8 +14,8 @@ function isGridWorldFeature(feature) {
13
14
  export function GridWorldScene({ feature }) {
14
15
  const instances = useMemo(() => (isGridWorldFeature(feature) ? resolveGridInstances(feature) : []), [feature]);
15
16
  const meshRef = useRef(null);
16
- const geometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
17
- const material = useMemo(() => new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 }), []);
17
+ const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
18
+ const material = useDisposable(() => new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 }), []);
18
19
  const instanceColor = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 3), 3), [instances.length]);
19
20
  useEffect(() => {
20
21
  const mesh = meshRef.current;
@@ -44,10 +45,6 @@ export function GridWorldScene({ feature }) {
44
45
  mesh.instanceMatrix.needsUpdate = true;
45
46
  instanceColor.needsUpdate = true;
46
47
  }, [instances, instanceColor]);
47
- useEffect(() => () => {
48
- geometry.dispose();
49
- material.dispose();
50
- }, [geometry, material]);
51
48
  if (instances.length === 0)
52
49
  return null;
53
50
  return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, instances.length], castShadow: true, receiveShadow: true }));
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  const SLEEPING_COLOR = new THREE.Color(0.32, 0.4, 0.62);
6
7
  const AWAKE_COLOR = new THREE.Color(0.45, 0.78, 0.5);
7
8
  const CONTACT_COLOR = new THREE.Color(0.96, 0.55, 0.18);
@@ -13,8 +14,8 @@ const DEFAULT_BASE = new THREE.Color(0.62, 0.64, 0.68);
13
14
  */
14
15
  export function InstancedBodies({ world, debugTint = false, baseColors, epoch = 0 }) {
15
16
  const meshRef = useRef(null);
16
- const geometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
17
- const material = useMemo(() => new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, vertexColors: false }), []);
17
+ const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
18
+ const material = useDisposable(() => new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, vertexColors: false }), []);
18
19
  const capacity = world.capacity;
19
20
  const instanceColor = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3), [capacity]);
20
21
  useEffect(() => {
@@ -76,9 +77,5 @@ export function InstancedBodies({ world, debugTint = false, baseColors, epoch =
76
77
  }
77
78
  lastTintRef.current = debugTint;
78
79
  });
79
- useEffect(() => () => {
80
- geometry.dispose();
81
- material.dispose();
82
- }, [geometry, material]);
83
80
  return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, capacity], frustumCulled: false, castShadow: false, receiveShadow: false }));
84
81
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  /**
6
7
  * Debug overlay drawing a PhysicsWorld's joints (suspension, ragdoll links, carry tethers) as one
7
8
  * LineSegments batch. Endpoints are streamed each frame from `world.readJointSegments`; pair with
@@ -11,7 +12,7 @@ export function InstancedJoints({ world, color = "#f5c542" }) {
11
12
  const geometryRef = useRef(null);
12
13
  const positions = useMemo(() => new Float32Array(world.jointCapacity * 6), [world.jointCapacity]);
13
14
  const attribute = useMemo(() => new THREE.BufferAttribute(positions, 3), [positions]);
14
- const material = useMemo(() => new THREE.LineBasicMaterial({ color }), [color]);
15
+ const material = useDisposable(() => new THREE.LineBasicMaterial({ color }), [color]);
15
16
  useEffect(() => {
16
17
  const geometry = geometryRef.current;
17
18
  if (geometry === null)
@@ -27,6 +28,5 @@ export function InstancedJoints({ world, color = "#f5c542" }) {
27
28
  attribute.needsUpdate = true;
28
29
  geometry.setDrawRange(0, count * 2);
29
30
  });
30
- useEffect(() => () => material.dispose(), [material]);
31
31
  return (_jsxs("lineSegments", { frustumCulled: false, children: [_jsx("bufferGeometry", { ref: geometryRef }), _jsx("primitive", { object: material, attach: "material" })] }));
32
32
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame, useLoader, useThree } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  const DEFAULT_CAPACITY = 1024;
6
7
  const DEFAULT_ALPHA_TEST = 0.08;
7
8
  const XZ_BASE_QUATERNION = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
@@ -29,9 +30,9 @@ export function SpriteBatch({ url, columns = 1, rows = 1, capacity = DEFAULT_CAP
29
30
  }
30
31
  texture.needsUpdate = true;
31
32
  }, [texture, pixelated]);
32
- const geometry = useMemo(() => new THREE.PlaneGeometry(1, 1), []);
33
+ const geometry = useDisposable(() => new THREE.PlaneGeometry(1, 1), []);
33
34
  const spriteUvOffset = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(capacity * 2), 2), [capacity]);
34
- const material = useMemo(() => {
35
+ const material = useDisposable(() => {
35
36
  const mat = new THREE.MeshBasicMaterial({
36
37
  map: texture,
37
38
  transparent: true,
@@ -104,9 +105,5 @@ uniform vec2 spriteUvScale;`)
104
105
  mesh.instanceMatrix.needsUpdate = true;
105
106
  spriteUvOffset.needsUpdate = true;
106
107
  });
107
- useEffect(() => () => {
108
- geometry.dispose();
109
- material.dispose();
110
- }, [geometry, material]);
111
108
  return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, capacity], frustumCulled: false, castShadow: false, receiveShadow: false }));
112
109
  }
@@ -0,0 +1,47 @@
1
+ import { type ReactNode } from "react";
2
+ import * as THREE from "three";
3
+ import { type EntityFrameEntry, type EntityFrameLayoutOptions, type EntityFramePlacement, type EntityScreenProjection, type ProjectEntity } from "@jgengine/react/entityFrames";
4
+ /**
5
+ * Returns a {@link ProjectEntity} bound to the live R3F camera and canvas size —
6
+ * the world→screen half of the data-first entity-frame seam. Call the returned
7
+ * function with a world position to get a CSS-pixel screen point (or `null`/`behind`
8
+ * when the point is outside the frustum). Reads the camera fresh on every call, so
9
+ * sample it inside a frame loop. Games that already own their entities use this to
10
+ * feed `@jgengine/react`'s `EntityFrames` without mirroring state into a store.
11
+ *
12
+ * @capability entity-frames R3F camera→screen projector for world-anchored frames
13
+ */
14
+ export declare function useWorldProjection(): ProjectEntity;
15
+ /**
16
+ * Pure world→screen projection: NDC via `camera.project`, mapped to CSS pixels.
17
+ * `behind` is set when the point falls outside the near/far clip range (NDC z
18
+ * beyond [-1, 1]), which covers positions behind the camera. Exposed for tests
19
+ * and for callers wiring their own frame loop around a bare THREE camera.
20
+ *
21
+ * @capability entity-frames pure world→screen projection for a bare THREE camera
22
+ */
23
+ export declare function projectWorldToScreen(scratch: THREE.Vector3, camera: THREE.Camera, worldPosition: readonly [number, number, number], width: number, height: number): EntityScreenProjection;
24
+ /** Props for {@link WorldEntityFrames}. */
25
+ export interface WorldEntityFramesProps<E extends EntityFrameEntry> extends EntityFrameLayoutOptions {
26
+ /** Caller-owned entities to anchor; `worldPosition` is the overhead point. */
27
+ entries: readonly E[];
28
+ /** Renders one entity's frame — compose `HealthBar value/max` + a name, themed via `barTokens`. */
29
+ renderFrame: (entry: E, placement: EntityFramePlacement<E>) => ReactNode;
30
+ /** Min ms between projection refreshes — trades smoothness for fewer re-renders at scale. Default 0 (every frame). */
31
+ tickMs?: number;
32
+ /** Class on the overlay layer. */
33
+ className?: string;
34
+ /** Per-frame wrapper transform. Default `translate(-50%, -100%)`. */
35
+ anchorTransform?: string;
36
+ }
37
+ /**
38
+ * R3F convenience that mounts inside the scene, samples the live camera each
39
+ * frame (throttled by `tickMs`), and renders `@jgengine/react`'s {@link EntityFrames}
40
+ * through a fullscreen `<Html>` overlay. This is the one-line way an R3F game
41
+ * gets overhead enemy nameplates/health bars from its own entity array without a
42
+ * store: pass `entries` + a `renderFrame` that composes the shipped bars. The
43
+ * projection, viewport culling, and stacking come for free.
44
+ *
45
+ * @capability entity-frames R3F overhead entity frames from a caller-owned entity array
46
+ */
47
+ export declare function WorldEntityFrames<E extends EntityFrameEntry>({ entries, renderFrame, tickMs, className, anchorTransform, ...layout }: WorldEntityFramesProps<E>): import("react").JSX.Element;
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Html } from "@react-three/drei";
3
+ import { useFrame, useThree } from "@react-three/fiber";
4
+ import { useCallback, useRef, useState } from "react";
5
+ import * as THREE from "three";
6
+ import { EntityFrames, } from "@jgengine/react/entityFrames";
7
+ /**
8
+ * Returns a {@link ProjectEntity} bound to the live R3F camera and canvas size —
9
+ * the world→screen half of the data-first entity-frame seam. Call the returned
10
+ * function with a world position to get a CSS-pixel screen point (or `null`/`behind`
11
+ * when the point is outside the frustum). Reads the camera fresh on every call, so
12
+ * sample it inside a frame loop. Games that already own their entities use this to
13
+ * feed `@jgengine/react`'s `EntityFrames` without mirroring state into a store.
14
+ *
15
+ * @capability entity-frames R3F camera→screen projector for world-anchored frames
16
+ */
17
+ export function useWorldProjection() {
18
+ const camera = useThree((state) => state.camera);
19
+ const size = useThree((state) => state.size);
20
+ const scratch = useRef(new THREE.Vector3());
21
+ return useCallback((worldPosition) => {
22
+ camera.updateMatrixWorld();
23
+ return projectWorldToScreen(scratch.current, camera, worldPosition, size.width, size.height);
24
+ }, [camera, size.width, size.height]);
25
+ }
26
+ /**
27
+ * Pure world→screen projection: NDC via `camera.project`, mapped to CSS pixels.
28
+ * `behind` is set when the point falls outside the near/far clip range (NDC z
29
+ * beyond [-1, 1]), which covers positions behind the camera. Exposed for tests
30
+ * and for callers wiring their own frame loop around a bare THREE camera.
31
+ *
32
+ * @capability entity-frames pure world→screen projection for a bare THREE camera
33
+ */
34
+ export function projectWorldToScreen(scratch, camera, worldPosition, width, height) {
35
+ scratch.set(worldPosition[0], worldPosition[1], worldPosition[2]);
36
+ scratch.project(camera);
37
+ return {
38
+ x: (scratch.x * 0.5 + 0.5) * width,
39
+ y: (-scratch.y * 0.5 + 0.5) * height,
40
+ depth: scratch.z,
41
+ behind: scratch.z < -1 || scratch.z > 1,
42
+ };
43
+ }
44
+ function pinToViewport(_el, _camera, size) {
45
+ return [size.width / 2, size.height / 2];
46
+ }
47
+ /**
48
+ * R3F convenience that mounts inside the scene, samples the live camera each
49
+ * frame (throttled by `tickMs`), and renders `@jgengine/react`'s {@link EntityFrames}
50
+ * through a fullscreen `<Html>` overlay. This is the one-line way an R3F game
51
+ * gets overhead enemy nameplates/health bars from its own entity array without a
52
+ * store: pass `entries` + a `renderFrame` that composes the shipped bars. The
53
+ * projection, viewport culling, and stacking come for free.
54
+ *
55
+ * @capability entity-frames R3F overhead entity frames from a caller-owned entity array
56
+ */
57
+ export function WorldEntityFrames({ entries, renderFrame, tickMs = 0, className, anchorTransform, ...layout }) {
58
+ const size = useThree((state) => state.size);
59
+ const project = useWorldProjection();
60
+ // Re-render on a frame cadence so projected screen positions follow the camera.
61
+ const [, setTick] = useState(0);
62
+ useThreeFrameTick(setTick, tickMs);
63
+ return (_jsx(Html, { fullscreen: true, calculatePosition: pinToViewport, zIndexRange: [19, 0], style: { pointerEvents: "none" }, children: _jsx(EntityFrames, { entries: entries, project: project, renderFrame: renderFrame, viewport: { width: size.width, height: size.height }, className: className, anchorTransform: anchorTransform, ...layout }) }));
64
+ }
65
+ /** Bumps a state setter on a throttled frame cadence to drive re-projection. */
66
+ function useThreeFrameTick(setTick, tickMs) {
67
+ const last = useRef(0);
68
+ useFrame((state) => {
69
+ const elapsedMs = state.clock.elapsedTime * 1000;
70
+ if (elapsedMs - last.current < tickMs)
71
+ return;
72
+ last.current = elapsedMs;
73
+ setTick((n) => n + 1);
74
+ });
75
+ }
@@ -3,6 +3,7 @@ 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 { useGameStore } from "@jgengine/react/hooks";
6
7
  import { useGameContext } from "@jgengine/react/provider";
7
8
  import { useCameraShake } from "../camera/shakeChannel.js";
8
9
  import { CALIBRATED_TRAUMA_SHAKE_DECAY_PER_SECOND } from "../camera/rigMath.js";
@@ -108,11 +109,7 @@ export function WorldNameplates({ statId = "health", height = 2.3, roles, resolv
108
109
  */
109
110
  export function WorldObjectHighlights({ color = "#facc15", radius, y = 0.05 }) {
110
111
  const ctx = useGameContext();
111
- const [ids, setIds] = useState(() => ctx.scene.object.selection.list());
112
- useEffect(() => {
113
- setIds(ctx.scene.object.selection.list());
114
- return ctx.subscribe(() => setIds(ctx.scene.object.selection.list()));
115
- }, [ctx]);
112
+ const ids = useGameStore((c) => c.scene.object.selection.list());
116
113
  return (_jsx(_Fragment, { children: ids.map((id) => {
117
114
  const object = ctx.scene.object.get(id);
118
115
  if (object === null)
@@ -241,6 +238,11 @@ export function ProjectileTracers({ lifeMs = 130 }) {
241
238
  const timers = useRef(new Set());
242
239
  useEffect(() => {
243
240
  const unsub = ctx.game.events.on("projectile.settled", (event) => {
241
+ // fireProjectile is a generic seam (bullets, bolts, grenades, launchers). A straight
242
+ // muzzle→impact line only reads as a real shot for direct-fire projectiles; drawing one
243
+ // for an arced/lobbed shot fakes a beam through a path it never flew, so skip those.
244
+ if (event.ballistic)
245
+ return;
244
246
  const id = nextId.current++;
245
247
  const start = new THREE.Vector3(event.origin[0], event.origin[1], event.origin[2]);
246
248
  if (event.from === ctx.player.userId)
@@ -0,0 +1,26 @@
1
+ import { type ReactNode } from "react";
2
+ import { type MarkerCollection, type MarkerKindStyle, type MarkerView } from "@jgengine/core/world/markers";
3
+ /** Props for {@link WorldPings}. */
4
+ export interface WorldPingsProps {
5
+ /** Static views, an external marker source, or a native `MarkerSet` (e.g. the one a `createPingSystem` writes to). */
6
+ markers: MarkerCollection;
7
+ kindStyles?: Record<string, MarkerKindStyle>;
8
+ /** Only render markers whose `meta.ping === true`. Default true; false renders every marker as a ping. */
9
+ pingsOnly?: boolean;
10
+ /** Height above the ground the arrowhead floats. Default 2.4. */
11
+ height?: number;
12
+ /** Draw a billboarded callout chip (glyph + `meta.callout`/`label`). Default true. */
13
+ showCallout?: boolean;
14
+ /** Full override for a ping's callout markup. */
15
+ renderCallout?: (marker: MarkerView, style: MarkerKindStyle) => ReactNode;
16
+ }
17
+ /**
18
+ * World-space pings — the in-scene side of a ping/marker: a bobbing downward
19
+ * arrowhead pointing at the spot, a ground ring, and a billboarded callout,
20
+ * colored by marker kind and fading in/out over the marker's lifetime. Reads a
21
+ * `MarkerSet` (typically the one a `createPingSystem` writes to), a marker
22
+ * source, or a static array; mount through `PlayableGame.WorldOverlay`.
23
+ *
24
+ * @capability world-pings in-scene ping markers — a bobbing downward arrowhead, ground ring, and billboarded callout per marker, colored by kind and fading over the marker's lifetime
25
+ */
26
+ export declare function WorldPings({ markers, kindStyles, pingsOnly, height, showCallout, renderCallout, }: WorldPingsProps): ReactNode;