@jgengine/shell 0.7.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 (168) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +37 -1
  3. package/dist/GameHost.d.ts +12 -0
  4. package/dist/GameHost.js +33 -0
  5. package/dist/GamePlayer.d.ts +11 -0
  6. package/dist/GamePlayer.js +24 -0
  7. package/dist/GamePlayerShell.d.ts +36 -1
  8. package/dist/GamePlayerShell.js +751 -80
  9. package/dist/GameUiPreview.js +2 -1
  10. package/dist/audio/AudioComponents.js +25 -8
  11. package/dist/audio/audioEngine.d.ts +1 -0
  12. package/dist/audio/audioEngine.js +4 -0
  13. package/dist/behaviour.d.ts +12 -0
  14. package/dist/behaviour.js +27 -0
  15. package/dist/behaviourAttach.d.ts +9 -0
  16. package/dist/behaviourAttach.js +32 -0
  17. package/dist/behaviourDriver.d.ts +7 -0
  18. package/dist/behaviourDriver.js +21 -0
  19. package/dist/camera/GameCameraRig.d.ts +8 -3
  20. package/dist/camera/GameCameraRig.js +28 -14
  21. package/dist/camera/GameFirstPersonCamera.js +16 -0
  22. package/dist/camera/GameInspectionCamera.d.ts +6 -0
  23. package/dist/camera/GameInspectionCamera.js +24 -0
  24. package/dist/camera/GameOrbitCamera.js +10 -1
  25. package/dist/camera/PlayerFov.d.ts +18 -0
  26. package/dist/camera/PlayerFov.js +48 -0
  27. package/dist/camera/cameraBlendMath.d.ts +13 -0
  28. package/dist/camera/cameraBlendMath.js +36 -0
  29. package/dist/camera/cameraRigs.d.ts +8 -1
  30. package/dist/camera/cameraRigs.js +78 -38
  31. package/dist/camera/fovPreference.d.ts +25 -0
  32. package/dist/camera/fovPreference.js +73 -0
  33. package/dist/camera/index.d.ts +7 -2
  34. package/dist/camera/index.js +7 -2
  35. package/dist/camera/inspectionCameraMath.d.ts +25 -0
  36. package/dist/camera/inspectionCameraMath.js +44 -0
  37. package/dist/camera/rigMath.d.ts +50 -1
  38. package/dist/camera/rigMath.js +91 -1
  39. package/dist/camera/rigResolve.d.ts +15 -0
  40. package/dist/camera/rigResolve.js +52 -0
  41. package/dist/camera/shakeChannel.d.ts +3 -17
  42. package/dist/camera/shakeChannel.js +2 -21
  43. package/dist/camera/shakeChannelMath.d.ts +8 -0
  44. package/dist/camera/shakeChannelMath.js +21 -0
  45. package/dist/cartridge.d.ts +145 -0
  46. package/dist/cartridge.js +245 -0
  47. package/dist/defineGame.d.ts +16 -0
  48. package/dist/defineGame.js +52 -0
  49. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  50. package/dist/devtools/CollisionDebugWorld.js +180 -0
  51. package/dist/devtools/DevtoolsOverlay.d.ts +78 -0
  52. package/dist/devtools/DevtoolsOverlay.js +676 -0
  53. package/dist/devtools/collisionDebug.d.ts +57 -0
  54. package/dist/devtools/collisionDebug.js +127 -0
  55. package/dist/devtools/collisionDebugMath.d.ts +102 -0
  56. package/dist/devtools/collisionDebugMath.js +128 -0
  57. package/dist/environment/Daylight.d.ts +56 -0
  58. package/dist/environment/Daylight.js +101 -0
  59. package/dist/environment/EnvironmentScene.js +87 -48
  60. package/dist/environment/GroundPad.d.ts +7 -0
  61. package/dist/environment/GroundPad.js +12 -0
  62. package/dist/environment/daylightCycle.d.ts +26 -0
  63. package/dist/environment/daylightCycle.js +116 -0
  64. package/dist/environment/groundPadMath.d.ts +13 -0
  65. package/dist/environment/groundPadMath.js +10 -0
  66. package/dist/environment/index.d.ts +3 -0
  67. package/dist/environment/index.js +3 -0
  68. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  69. package/dist/environment/skyLightingPolicy.js +6 -0
  70. package/dist/input/mouseLook.d.ts +27 -0
  71. package/dist/input/mouseLook.js +35 -0
  72. package/dist/materialOverride.d.ts +6 -0
  73. package/dist/materialOverride.js +28 -0
  74. package/dist/multiplayer.d.ts +16 -9
  75. package/dist/multiplayer.js +62 -12
  76. package/dist/pointer/PointerProbe.js +20 -3
  77. package/dist/pointer/pointerService.d.ts +5 -0
  78. package/dist/pointer/pointerService.js +51 -24
  79. package/dist/registry.d.ts +4 -1
  80. package/dist/registry.js +3 -1
  81. package/dist/render/modelRender.d.ts +23 -0
  82. package/dist/render/modelRender.js +106 -0
  83. package/dist/render/resolveModel.d.ts +14 -0
  84. package/dist/render/resolveModel.js +24 -0
  85. package/dist/settings/QuickControls.d.ts +4 -0
  86. package/dist/settings/QuickControls.js +42 -0
  87. package/dist/settings/SettingsChrome.d.ts +1 -0
  88. package/dist/settings/SettingsChrome.js +10 -0
  89. package/dist/settings/SettingsMenu.d.ts +6 -0
  90. package/dist/settings/SettingsMenu.js +148 -0
  91. package/dist/settings/SettingsRuntime.d.ts +11 -0
  92. package/dist/settings/SettingsRuntime.js +19 -0
  93. package/dist/settings/appliedSettings.d.ts +14 -0
  94. package/dist/settings/appliedSettings.js +27 -0
  95. package/dist/settings/settingsController.d.ts +20 -0
  96. package/dist/settings/settingsController.js +165 -0
  97. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  98. package/dist/structures/GeneratedBuilding.js +96 -8
  99. package/dist/structures/index.d.ts +1 -1
  100. package/dist/structures/index.js +1 -1
  101. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  102. package/dist/terrain/CarvedTerrain.js +3 -3
  103. package/dist/terrain/GrassField.d.ts +3 -1
  104. package/dist/terrain/GrassField.js +4 -2
  105. package/dist/terrain/grassBudget.d.ts +3 -0
  106. package/dist/terrain/grassBudget.js +6 -0
  107. package/dist/terrain/grassGeometry.js +1 -1
  108. package/dist/terrain/index.d.ts +1 -1
  109. package/dist/terrain/index.js +1 -1
  110. package/dist/terrain/terrainMath.d.ts +9 -0
  111. package/dist/terrain/terrainMath.js +17 -2
  112. package/dist/touch/OrientationHint.d.ts +3 -0
  113. package/dist/touch/OrientationHint.js +13 -0
  114. package/dist/touch/TouchControlsOverlay.d.ts +34 -0
  115. package/dist/touch/TouchControlsOverlay.js +279 -0
  116. package/dist/visibility/CullingProvider.d.ts +21 -0
  117. package/dist/visibility/CullingProvider.js +134 -0
  118. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  119. package/dist/vision/FrustumSensorHud.js +25 -27
  120. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  121. package/dist/vision/frustumSampleEqual.js +10 -0
  122. package/dist/water/Ocean.js +1 -1
  123. package/dist/water/OceanConfig.d.ts +13 -0
  124. package/dist/water/OceanConfig.js +25 -17
  125. package/dist/water/OceanShader.d.ts +1 -1
  126. package/dist/water/OceanShader.js +3 -3
  127. package/dist/water/index.d.ts +1 -1
  128. package/dist/water/index.js +1 -1
  129. package/dist/weather/FireSpreadLayer.js +7 -2
  130. package/dist/weather/RainField.d.ts +3 -1
  131. package/dist/weather/RainField.js +4 -4
  132. package/dist/weather/SnowField.d.ts +3 -1
  133. package/dist/weather/SnowField.js +4 -4
  134. package/dist/weather/fireSpreadPose.d.ts +2 -0
  135. package/dist/weather/fireSpreadPose.js +4 -0
  136. package/dist/weather/weatherMath.d.ts +5 -1
  137. package/dist/weather/weatherMath.js +7 -2
  138. package/dist/world/DataObjects.d.ts +44 -0
  139. package/dist/world/DataObjects.js +77 -0
  140. package/dist/world/GridWorldScene.d.ts +10 -0
  141. package/dist/world/GridWorldScene.js +54 -0
  142. package/dist/world/SpriteBatch.d.ts +44 -0
  143. package/dist/world/SpriteBatch.js +112 -0
  144. package/dist/world/WorldHud.d.ts +8 -1
  145. package/dist/world/WorldHud.js +90 -39
  146. package/dist/world/WorldItems.js +21 -2
  147. package/dist/world/entityPose.d.ts +14 -0
  148. package/dist/world/entityPose.js +10 -0
  149. package/dist/world/telegraphPulse.d.ts +1 -0
  150. package/dist/world/telegraphPulse.js +4 -0
  151. package/dist/world/worldBarSamples.d.ts +30 -0
  152. package/dist/world/worldBarSamples.js +51 -0
  153. package/llms.txt +2643 -0
  154. package/package.json +6 -5
  155. package/dist/demo/builderDemo.d.ts +0 -2
  156. package/dist/demo/builderDemo.js +0 -189
  157. package/dist/demo/demoGame.d.ts +0 -2
  158. package/dist/demo/demoGame.js +0 -208
  159. package/dist/demo/environmentShowcase.d.ts +0 -2
  160. package/dist/demo/environmentShowcase.js +0 -15
  161. package/dist/demo/mapDemo.d.ts +0 -2
  162. package/dist/demo/mapDemo.js +0 -206
  163. package/dist/demo/pointerDemo.d.ts +0 -2
  164. package/dist/demo/pointerDemo.js +0 -151
  165. package/dist/demo/sensorShowcase.d.ts +0 -2
  166. package/dist/demo/sensorShowcase.js +0 -131
  167. package/dist/demo/survivalDemo.d.ts +0 -2
  168. package/dist/demo/survivalDemo.js +0 -291
@@ -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,6 +1,13 @@
1
- export declare function WorldEntityBars({ statId, height }: {
1
+ import type { SceneEntity } from "@jgengine/core/scene/entityStore";
2
+ import type { CatalogEntityRole } from "@jgengine/core/runtime/gameContext";
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, }: {
2
7
  statId: string;
3
8
  height?: number;
9
+ roles?: readonly CatalogEntityRole[];
10
+ resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined;
4
11
  }): import("react").JSX.Element;
5
12
  export declare function WorldFloatText({ height, lifeMs }: {
6
13
  height?: number;
@@ -4,22 +4,42 @@ import { useFrame, useThree } from "@react-three/fiber";
4
4
  import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
- import { useEntityStat, useSceneEntities } from "@jgengine/react/hooks";
7
+ import { useCameraShake } from "../camera/shakeChannel.js";
8
8
  import { resolveFloatTextStyle } from "./floatTextStyle.js";
9
+ import { collectWorldBarSamples, paintWorldBarSamples, } from "./worldBarSamples.js";
10
+ import { telegraphPulseOpacity } from "./telegraphPulse.js";
11
+ export { collectWorldBarSamples, paintWorldBarSamples } from "./worldBarSamples.js";
12
+ export { telegraphPulseOpacity } from "./telegraphPulse.js";
9
13
  const MUZZLE_HEIGHT = 1.4;
10
- function EntityBar({ entity, statId, height }) {
11
- const stat = useEntityStat(entity.id, statId);
12
- if (stat === null)
13
- return null;
14
- const range = stat.max - stat.min;
15
- const percent = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
16
- 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}%` } }) }) }));
14
+ function pinOverlayToViewport(_el, _camera, size) {
15
+ return [size.width / 2, size.height / 2];
17
16
  }
18
- export function WorldEntityBars({ statId, height = 2.2 }) {
17
+ export function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, }) {
19
18
  const ctx = useGameContext();
20
- const entities = useSceneEntities();
21
- const playerId = ctx.player.userId;
22
- return (_jsx(_Fragment, { children: entities.map((entity) => entity.id === playerId ? null : (_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);
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" } }) }));
23
43
  }
24
44
  function FloatTextItem({ event, lifeMs }) {
25
45
  const [shown, setShown] = useState(false);
@@ -43,12 +63,24 @@ export function WorldFloatText({ height = 1.9, lifeMs = 950 }) {
43
63
  const ctx = useGameContext();
44
64
  const [floaters, setFloaters] = useState([]);
45
65
  const nextId = useRef(0);
66
+ const timers = useRef(new Set());
46
67
  useEffect(() => {
47
- return ctx.game.events.on("entity.floatText", (event) => {
68
+ const pending = timers.current;
69
+ const off = ctx.game.events.on("entity.floatText", (event) => {
48
70
  const id = nextId.current++;
49
71
  setFloaters((current) => [...current, { id, position: event.position, event }]);
50
- 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);
51
77
  });
78
+ return () => {
79
+ off();
80
+ for (const timer of pending)
81
+ window.clearTimeout(timer);
82
+ pending.clear();
83
+ };
52
84
  }, [ctx, lifeMs]);
53
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))) }));
54
86
  }
@@ -61,60 +93,69 @@ function TelegraphGeometry({ shape }) {
61
93
  return _jsx("circleGeometry", { args: [shape.radius, 40, -shape.angle / 2, shape.angle] });
62
94
  return _jsx("planeGeometry", { args: [shape.width, shape.length] });
63
95
  }
64
- function TelegraphDecal({ active, nowMs }) {
65
- const progress = Math.max(0, Math.min(1, (nowMs - active.bornMs) / active.event.windupMs));
96
+ function TelegraphDecal({ active }) {
97
+ const materialRef = useRef(null);
66
98
  const [x, y, z] = active.event.position;
67
99
  const dir = active.event.dir ?? 0;
68
100
  const shape = active.event.shape;
69
101
  const forwardOffset = shape.kind === "line" ? shape.length / 2 : 0;
70
102
  const color = active.event.kind === "danger" ? "#ef4444" : "#f59e0b";
71
- const pulse = 0.45 + 0.5 * progress;
72
- 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 })] }));
73
110
  }
74
111
  export function WorldTelegraphs() {
75
112
  const ctx = useGameContext();
76
113
  const [telegraphs, setTelegraphs] = useState([]);
77
114
  const key = useRef(0);
78
- const [nowMs, setNowMs] = useState(() => performance.now());
79
- useFrame(() => setNowMs(performance.now()));
115
+ const timers = useRef(new Set());
80
116
  useEffect(() => {
81
- return ctx.game.events.on("combat.telegraph", (event) => {
117
+ const pending = timers.current;
118
+ const off = ctx.game.events.on("combat.telegraph", (event) => {
82
119
  const entry = { key: key.current++, event, bornMs: performance.now() };
83
120
  setTelegraphs((current) => [...current, entry]);
84
- 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);
85
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
+ };
86
137
  }, [ctx]);
87
- 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))) }));
88
139
  }
89
140
  export function CombatCameraShake() {
90
141
  const ctx = useGameContext();
91
- const camera = useThree((state) => state.camera);
92
- const trauma = useRef(0);
93
- const decay = useRef(4);
142
+ const shake = useCameraShake();
94
143
  useEffect(() => {
95
144
  return ctx.game.events.on("combat.hitReaction", (event) => {
96
145
  if (event.shake === undefined)
97
146
  return;
98
- trauma.current = Math.min(1, trauma.current + event.shake.amplitude);
99
- decay.current = event.shake.decay;
147
+ shake.shake(event.shake.amplitude, event.shake.decay);
100
148
  });
101
- }, [ctx]);
102
- useFrame((_state, dt) => {
103
- if (trauma.current <= 0.0001)
104
- return;
105
- const magnitude = trauma.current * trauma.current;
106
- camera.position.x += (Math.random() * 2 - 1) * magnitude * 0.4;
107
- camera.position.y += (Math.random() * 2 - 1) * magnitude * 0.4;
108
- trauma.current = Math.max(0, trauma.current - decay.current * dt);
109
- });
149
+ }, [ctx, shake]);
110
150
  return null;
111
151
  }
112
152
  export function ProjectileTracers({ lifeMs = 130 }) {
113
153
  const ctx = useGameContext();
114
154
  const [tracers, setTracers] = useState([]);
115
155
  const nextId = useRef(0);
156
+ const timers = useRef(new Set());
116
157
  useEffect(() => {
117
- return ctx.game.events.on("projectile.settled", (event) => {
158
+ const unsub = ctx.game.events.on("projectile.settled", (event) => {
118
159
  const id = nextId.current++;
119
160
  setTracers((current) => [
120
161
  ...current,
@@ -126,8 +167,18 @@ export function ProjectileTracers({ lifeMs = 130 }) {
126
167
  ],
127
168
  },
128
169
  ]);
129
- 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);
130
175
  });
176
+ return () => {
177
+ unsub();
178
+ for (const handle of timers.current)
179
+ clearTimeout(handle);
180
+ timers.current.clear();
181
+ };
131
182
  }, [ctx, lifeMs]);
132
183
  return (_jsx(_Fragment, { children: tracers.map((tracer) => (_jsx(Line, { points: tracer.points, color: "#fde68a", lineWidth: 2, transparent: true, opacity: 0.85 }, tracer.id))) }));
133
184
  }
@@ -1,18 +1,37 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Html, Line } from "@react-three/drei";
3
- import { useMemo } from "react";
3
+ import { useMemo, useRef } from "react";
4
+ import { useFrame } from "@react-three/fiber";
4
5
  import { resolveWorldItemPresentation } from "@jgengine/core/game/worldItem";
5
6
  import { useGameContext } from "@jgengine/react/provider";
6
7
  import { useWorldItems } from "@jgengine/react/hooks";
7
8
  import { POINTER_ENTITY_KEY } from "../pointer/pointerService.js";
8
9
  const DEFAULT_BEAM_HEIGHT = 2.5;
9
10
  const FALLBACK_COLOR = "#e5e7eb";
11
+ const REST_HEIGHT = 0.32;
12
+ const SPAWN_DURATION = 0.4;
10
13
  function WorldItemMarker({ instanceId, position, color, beam, label, beamHeight, }) {
11
14
  const beamPoints = useMemo(() => [
12
15
  [0, 0, 0],
13
16
  [0, beamHeight, 0],
14
17
  ], [beamHeight]);
15
- return (_jsxs("group", { position: [position[0], position[1], position[2]], userData: { [POINTER_ENTITY_KEY]: instanceId }, children: [_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.03, children: [_jsx("ringGeometry", { args: [0.26, 0.4, 24] }), _jsx("meshBasicMaterial", { color: color, transparent: true, opacity: 0.85 })] }), _jsxs("mesh", { "position-y": 0.32, children: [_jsx("octahedronGeometry", { args: [0.22, 0] }), _jsx("meshStandardMaterial", { color: color, emissive: color, emissiveIntensity: 0.6 })] }), beam ? _jsx(Line, { points: beamPoints, color: color, lineWidth: 2, transparent: true, opacity: 0.55 }) : null, label !== undefined && label.length > 0 ? (_jsx(Html, { position: [0, beamHeight + 0.2, 0], center: true, distanceFactor: 12, zIndexRange: [15, 0], children: _jsx("div", { className: "whitespace-nowrap rounded-sm border px-1.5 py-0.5 text-[11px] font-bold shadow", style: { borderColor: color, color, backgroundColor: "rgba(10,10,14,0.75)" }, children: label }) })) : null] }));
18
+ // Drop animation: the gem leaps up on spawn, settles, then idles with a slow
19
+ // spin + hover so a fresh drop reads as "something just fell here". Age is
20
+ // accumulated from frame deltas (Date.now is unavailable to keep runs pure).
21
+ const gemRef = useRef(null);
22
+ const ageRef = useRef(0);
23
+ useFrame((_state, delta) => {
24
+ const gem = gemRef.current;
25
+ if (gem === null)
26
+ return;
27
+ const age = ageRef.current + delta;
28
+ ageRef.current = age;
29
+ const spawn = age < SPAWN_DURATION ? Math.sin((age / SPAWN_DURATION) * Math.PI) * 0.35 : 0;
30
+ const bob = Math.sin(age * 2.2) * 0.06;
31
+ gem.position.y = REST_HEIGHT + bob + spawn;
32
+ gem.rotation.y = age * 1.6;
33
+ });
34
+ return (_jsxs("group", { position: [position[0], position[1], position[2]], userData: { [POINTER_ENTITY_KEY]: instanceId }, children: [_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.03, children: [_jsx("ringGeometry", { args: [0.26, 0.4, 24] }), _jsx("meshBasicMaterial", { color: color, transparent: true, opacity: 0.85 })] }), _jsx("group", { ref: gemRef, "position-y": REST_HEIGHT, children: _jsxs("mesh", { children: [_jsx("octahedronGeometry", { args: [0.22, 0] }), _jsx("meshStandardMaterial", { color: color, emissive: color, emissiveIntensity: 0.6 })] }) }), beam ? _jsx(Line, { points: beamPoints, color: color, lineWidth: 2, transparent: true, opacity: 0.55 }) : null, label !== undefined && label.length > 0 ? (_jsx(Html, { position: [0, beamHeight + 0.2, 0], center: true, distanceFactor: 12, zIndexRange: [15, 0], children: _jsx("div", { className: "whitespace-nowrap rounded-sm border px-1.5 py-0.5 text-[11px] font-bold shadow", style: { borderColor: color, color, backgroundColor: "rgba(10,10,14,0.75)" }, children: label }) })) : null] }));
16
35
  }
17
36
  /** Rarity→beam/color/label render binding + loot-filter overlay (#32/#33) for every dropped `worldItem`. */
18
37
  export function WorldItems({ config }) {
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ import type { SceneEntity } from "@jgengine/core/scene/entityStore";
2
+ import type { CatalogEntityRole } from "@jgengine/core/runtime/gameContext";
3
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
4
+ export interface WorldBarSample {
5
+ x: number;
6
+ y: number;
7
+ percent: number;
8
+ }
9
+ export interface Projectable {
10
+ set(x: number, y: number, z: number): this;
11
+ project(camera: {
12
+ matrixWorldInverse: unknown;
13
+ projectionMatrix: unknown;
14
+ }): this;
15
+ x: number;
16
+ y: number;
17
+ z: number;
18
+ }
19
+ export declare function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: {
20
+ matrixWorldInverse: unknown;
21
+ projectionMatrix: unknown;
22
+ }, viewport: {
23
+ width: number;
24
+ height: number;
25
+ }, into: WorldBarSample[], project: Projectable): number;
26
+ export declare function paintWorldBarSamples(canvas: {
27
+ width: number;
28
+ height: number;
29
+ getContext(kind: "2d"): CanvasRenderingContext2D | null;
30
+ }, samples: readonly WorldBarSample[], dpr: number, barWidthPx?: number, barHeightPx?: number): void;
@@ -0,0 +1,51 @@
1
+ import { worldHealthBarAllowsRole } from "@jgengine/core/game/playableGame";
2
+ export function collectWorldBarSamples(ctx, statId, height, roles, resolveRole, camera, viewport, into, project) {
3
+ into.length = 0;
4
+ const playerId = ctx.player.userId;
5
+ for (const entity of ctx.scene.entity.list()) {
6
+ if (entity.id === playerId)
7
+ continue;
8
+ if (!worldHealthBarAllowsRole(roles, resolveRole?.(entity)))
9
+ continue;
10
+ const stat = ctx.scene.entity.stats.get(entity.id, statId);
11
+ if (stat === null)
12
+ continue;
13
+ const range = stat.max - stat.min;
14
+ const percent = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
15
+ project.set(entity.position[0], entity.position[1] + height, entity.position[2]);
16
+ project.project(camera);
17
+ if (project.z < -1 || project.z > 1)
18
+ continue;
19
+ const x = (project.x * 0.5 + 0.5) * viewport.width;
20
+ const y = (-project.y * 0.5 + 0.5) * viewport.height;
21
+ into.push({ x, y, percent });
22
+ }
23
+ return into.length;
24
+ }
25
+ export function paintWorldBarSamples(canvas, samples, dpr, barWidthPx = 112, barHeightPx = 10) {
26
+ const g = canvas.getContext("2d");
27
+ if (g === null)
28
+ return;
29
+ g.setTransform(1, 0, 0, 1, 0, 0);
30
+ g.clearRect(0, 0, canvas.width, canvas.height);
31
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
32
+ const halfW = barWidthPx / 2;
33
+ const halfH = barHeightPx / 2;
34
+ for (const sample of samples) {
35
+ const left = sample.x - halfW;
36
+ const top = sample.y - halfH;
37
+ g.fillStyle = "rgba(0,0,0,0.7)";
38
+ g.strokeStyle = "rgba(0,0,0,0.7)";
39
+ g.lineWidth = 1;
40
+ g.fillRect(left, top, barWidthPx, barHeightPx);
41
+ g.strokeRect(left + 0.5, top + 0.5, barWidthPx - 1, barHeightPx - 1);
42
+ const fill = Math.max(0, Math.min(barWidthPx, sample.percent * barWidthPx));
43
+ if (fill > 0) {
44
+ const gradient = g.createLinearGradient(left, top, left + barWidthPx, top);
45
+ gradient.addColorStop(0, "#e11d48");
46
+ gradient.addColorStop(1, "#f87171");
47
+ g.fillStyle = gradient;
48
+ g.fillRect(left, top, fill, barHeightPx);
49
+ }
50
+ }
51
+ }