@jgengine/shell 0.18.0 → 0.19.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.
- package/CHANGELOG.md +265 -2
- package/dist/GamePlayerShell.js +27 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +9 -8
- package/dist/Shell3dPresentation.js +57 -8
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +13 -2
- package/dist/audio/audioEngine.d.ts +15 -1
- package/dist/audio/audioEngine.js +132 -11
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +77 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +38 -6
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/KeysPanel.js +3 -3
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +74 -26
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +10 -5
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +22 -0
- package/dist/input/gamepadSource.js +153 -0
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +11 -8
- package/dist/postfx/PostProcessing.js +38 -16
- package/dist/postfx/gradeShader.js +13 -0
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/CascadedShadows.d.ts +11 -0
- package/dist/render/CascadedShadows.js +19 -8
- package/dist/render/EnvironmentLighting.d.ts +4 -1
- package/dist/render/EnvironmentLighting.js +45 -9
- package/dist/render/SceneLighting.d.ts +16 -2
- package/dist/render/SceneLighting.js +56 -6
- package/dist/render/SceneModels.js +45 -9
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +39 -0
- package/dist/render/modelLoad.js +89 -1
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +58 -0
- package/dist/render/useFootIk.js +216 -0
- package/dist/render/useModelAnimation.d.ts +26 -0
- package/dist/render/useModelAnimation.js +184 -107
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +666 -241
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +10 -2
- package/dist/settings/appliedSettings.js +9 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +9 -2
- package/dist/useShellMultiplayerSync.js +30 -8
- package/dist/visibility/CullingProvider.d.ts +2 -1
- package/dist/visibility/CullingProvider.js +7 -2
- package/dist/world/GridWorldScene.js +4 -0
- package/dist/world/TileLayerRenderer.d.ts +9 -0
- package/dist/world/TileLayerRenderer.js +64 -0
- package/dist/world/WorldParticles.d.ts +31 -0
- package/dist/world/WorldParticles.js +128 -0
- package/dist/world/WorldScene.d.ts +2 -1
- package/dist/world/WorldScene.js +46 -4
- package/dist/world/entityPose.d.ts +4 -0
- package/dist/world/entityPose.js +9 -0
- package/dist/world/tileLayerInstances.d.ts +11 -0
- package/dist/world/tileLayerInstances.js +28 -0
- package/dist/worldSync.d.ts +5 -1
- package/dist/worldSync.js +25 -4
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +20 -15
- package/skills/jgengine-ui/api.md +538 -326
- package/skills/jgengine-ui/capabilities.md +37 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -78
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
const SURFACE_ROLES = ["color", "normal", "roughness", "ao", "metalness"];
|
|
4
|
+
/** True when the surface names at least one map URL, so the batch needs the textured path. */
|
|
5
|
+
export function surfaceHasMaps(surface) {
|
|
6
|
+
if (surface?.maps === undefined)
|
|
7
|
+
return false;
|
|
8
|
+
return SURFACE_ROLES.some((role) => surface.maps?.[role] !== undefined);
|
|
9
|
+
}
|
|
10
|
+
/** A stable key for bucketing instances that share one surface (same colour, maps, tiling, and response). */
|
|
11
|
+
export function surfaceKey(surface) {
|
|
12
|
+
if (surface === undefined)
|
|
13
|
+
return "";
|
|
14
|
+
const repeat = surface.repeat === undefined ? 1 : surface.repeat;
|
|
15
|
+
const maps = surface.maps === undefined ? "" : SURFACE_ROLES.map((role) => surface.maps?.[role] ?? "").join(",");
|
|
16
|
+
const tiling = typeof repeat === "number" ? String(repeat) : repeat.join("x");
|
|
17
|
+
return `${surface.color ?? ""}|${maps}|${tiling}|${surface.roughness ?? ""}|${surface.metalness ?? ""}`;
|
|
18
|
+
}
|
|
19
|
+
function repeatOf(surface) {
|
|
20
|
+
const repeat = surface.repeat ?? 1;
|
|
21
|
+
return typeof repeat === "number" ? [repeat, repeat] : [repeat[0], repeat[1]];
|
|
22
|
+
}
|
|
23
|
+
const textureLoader = new THREE.TextureLoader();
|
|
24
|
+
const loads = new Map();
|
|
25
|
+
/** One shared decode per URL; a missing file (a pack without `ao.jpg`) resolves to null instead of failing the batch. */
|
|
26
|
+
function loadSurfaceTexture(url) {
|
|
27
|
+
let pending = loads.get(url);
|
|
28
|
+
if (pending === undefined) {
|
|
29
|
+
pending = new Promise((resolve) => {
|
|
30
|
+
textureLoader.load(url, (texture) => resolve(texture), undefined, () => resolve(null));
|
|
31
|
+
});
|
|
32
|
+
loads.set(url, pending);
|
|
33
|
+
}
|
|
34
|
+
return pending;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Loads a surface's PBR maps and returns per-caller clones configured to tile `repeat` times per
|
|
38
|
+
* slot, or `undefined` until the first map arrives so the batch draws its flat colour meanwhile.
|
|
39
|
+
* Clones share the decoded image, so two kinds using one map at different repeats cost one
|
|
40
|
+
* download. Maps whose file is missing are skipped rather than failing the whole surface.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function useBuildingSurfaceTextures(surface) {
|
|
44
|
+
const maps = surface?.maps;
|
|
45
|
+
const color = maps?.color;
|
|
46
|
+
const normal = maps?.normal;
|
|
47
|
+
const roughness = maps?.roughness;
|
|
48
|
+
const ao = maps?.ao;
|
|
49
|
+
const metalness = maps?.metalness;
|
|
50
|
+
const urls = useMemo(() => {
|
|
51
|
+
const record = {};
|
|
52
|
+
if (color !== undefined)
|
|
53
|
+
record.color = color;
|
|
54
|
+
if (normal !== undefined)
|
|
55
|
+
record.normal = normal;
|
|
56
|
+
if (roughness !== undefined)
|
|
57
|
+
record.roughness = roughness;
|
|
58
|
+
if (ao !== undefined)
|
|
59
|
+
record.ao = ao;
|
|
60
|
+
if (metalness !== undefined)
|
|
61
|
+
record.metalness = metalness;
|
|
62
|
+
return record;
|
|
63
|
+
}, [color, normal, roughness, ao, metalness]);
|
|
64
|
+
const [loaded, setLoaded] = useState(undefined);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const entries = Object.entries(urls);
|
|
67
|
+
if (entries.length === 0) {
|
|
68
|
+
setLoaded(undefined);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
Promise.all(entries.map(([role, url]) => loadSurfaceTexture(url).then((texture) => [role, texture]))).then((results) => {
|
|
73
|
+
if (cancelled)
|
|
74
|
+
return;
|
|
75
|
+
const record = {};
|
|
76
|
+
for (const [role, texture] of results)
|
|
77
|
+
if (texture !== null)
|
|
78
|
+
record[role] = texture;
|
|
79
|
+
setLoaded(Object.keys(record).length === 0 ? undefined : record);
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
}, [urls]);
|
|
85
|
+
const [repeatX, repeatY] = surface === undefined ? [1, 1] : repeatOf(surface);
|
|
86
|
+
const textures = useMemo(() => {
|
|
87
|
+
if (loaded === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const role of SURFACE_ROLES) {
|
|
91
|
+
const source = loaded[role];
|
|
92
|
+
if (source === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const texture = source.clone();
|
|
95
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
96
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
97
|
+
texture.repeat.set(repeatX, repeatY);
|
|
98
|
+
texture.colorSpace = role === "color" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
|
|
99
|
+
texture.needsUpdate = true;
|
|
100
|
+
out[role] = texture;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}, [loaded, repeatX, repeatY]);
|
|
104
|
+
useEffect(() => () => {
|
|
105
|
+
if (textures === undefined)
|
|
106
|
+
return;
|
|
107
|
+
for (const role of SURFACE_ROLES)
|
|
108
|
+
textures[role]?.dispose();
|
|
109
|
+
}, [textures]);
|
|
110
|
+
return textures;
|
|
111
|
+
}
|
|
112
|
+
/** Applies a surface's colour, response, and loaded maps onto a standard or physical material in place. @internal */
|
|
113
|
+
export function applyBuildingSurface(material, surface, textures) {
|
|
114
|
+
if (surface.color !== undefined)
|
|
115
|
+
material.color.set(surface.color);
|
|
116
|
+
if (surface.roughness !== undefined)
|
|
117
|
+
material.roughness = surface.roughness;
|
|
118
|
+
if (surface.metalness !== undefined)
|
|
119
|
+
material.metalness = surface.metalness;
|
|
120
|
+
if (textures !== undefined) {
|
|
121
|
+
if (textures.color !== undefined)
|
|
122
|
+
material.map = textures.color;
|
|
123
|
+
if (textures.normal !== undefined)
|
|
124
|
+
material.normalMap = textures.normal;
|
|
125
|
+
if (textures.roughness !== undefined)
|
|
126
|
+
material.roughnessMap = textures.roughness;
|
|
127
|
+
if (textures.ao !== undefined)
|
|
128
|
+
material.aoMap = textures.ao;
|
|
129
|
+
if (textures.metalness !== undefined)
|
|
130
|
+
material.metalnessMap = textures.metalness;
|
|
131
|
+
material.needsUpdate = true;
|
|
132
|
+
}
|
|
133
|
+
return material;
|
|
134
|
+
}
|
|
@@ -48,6 +48,8 @@ export function createTerrainDetailMaterial(detail, textures) {
|
|
|
48
48
|
uRockColor: { value: new THREE.Color(detail.rockColor) },
|
|
49
49
|
uSandColor: { value: new THREE.Color(detail.sandColor) },
|
|
50
50
|
uSnowColor: { value: new THREE.Color(detail.snowColor) },
|
|
51
|
+
uSweepDry: { value: new THREE.Vector3(...detail.sweeps.dry) },
|
|
52
|
+
uSweepWet: { value: new THREE.Vector3(...detail.sweeps.wet) },
|
|
51
53
|
uRockSlopeStart: { value: detail.rockSlopeStart },
|
|
52
54
|
uSnowHeight: { value: detail.snowHeight },
|
|
53
55
|
uWaterLevel: { value: detail.waterLevel },
|
|
@@ -126,6 +128,8 @@ varying vec3 vJgWorldNormal;
|
|
|
126
128
|
uniform vec3 uRockColor;
|
|
127
129
|
uniform vec3 uSandColor;
|
|
128
130
|
uniform vec3 uSnowColor;
|
|
131
|
+
uniform vec3 uSweepDry;
|
|
132
|
+
uniform vec3 uSweepWet;
|
|
129
133
|
uniform float uRockSlopeStart;
|
|
130
134
|
uniform float uSnowHeight;
|
|
131
135
|
uniform float uWaterLevel;
|
|
@@ -149,9 +153,9 @@ float jgFleck = jgVNoise(jgWp / max(0.05, uDetailScale * 0.024));
|
|
|
149
153
|
float jgFleckMid = jgVNoise(jgWp / max(0.12, uDetailScale * 0.07) + vec2(51.3, 7.9));
|
|
150
154
|
vec3 jgBase = diffuseColor.rgb;
|
|
151
155
|
jgBase *= mix(1.0 - 0.2 * uStrength, 1.0 + 0.16 * uStrength, jgFine);
|
|
152
|
-
//
|
|
153
|
-
jgBase = mix(jgBase, jgBase *
|
|
154
|
-
jgBase = mix(jgBase, jgBase *
|
|
156
|
+
// Biome patchwork: hue-shifted dry sweeps and cooler wet pockets, tinted per-world via detail.sweeps.
|
|
157
|
+
jgBase = mix(jgBase, jgBase * uSweepDry, smoothstep(0.5, 0.8, jgMacro) * 0.55 * uStrength);
|
|
158
|
+
jgBase = mix(jgBase, jgBase * uSweepWet, smoothstep(0.52, 0.85, jgPatch) * 0.5 * uStrength);
|
|
155
159
|
// Rolling large-scale light/dark sweeps keep far hills from flattening into one tone.
|
|
156
160
|
jgBase *= 1.0 + (jgFbm(jgWp / (uMacroScale * 2.3) + vec2(9.1, 73.4)) - 0.5) * 0.22 * uStrength;
|
|
157
161
|
jgBase *= 1.0 + (jgFleck - 0.5) * 0.48 * uStrength * jgNear;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { type Dispatch, type SetStateAction } from "react";
|
|
2
2
|
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
3
|
-
import type
|
|
3
|
+
import { type PresencePoseRow } from "@jgengine/core/runtime/transport";
|
|
4
|
+
import { type AuthoritativeFrameHandler } from "./worldSync.js";
|
|
4
5
|
import type { ShellMultiplayer } from "./multiplayer.js";
|
|
5
6
|
import type { PlayableGame } from "./registry.js";
|
|
6
7
|
/** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
|
|
7
8
|
export declare function useShellMultiplayerSync(ctx: GameContext | null, multiplayer: ShellMultiplayer | null, playable: PlayableGame, serverIdRef: {
|
|
8
9
|
current: string | null;
|
|
9
|
-
}, setRemotePlayers: Dispatch<SetStateAction<PresencePoseRow[]
|
|
10
|
+
}, setRemotePlayers: Dispatch<SetStateAction<PresencePoseRow[]>>, authoritativeFrameRef?: {
|
|
11
|
+
current: AuthoritativeFrameHandler | null;
|
|
12
|
+
}): {
|
|
13
|
+
status: "joining" | "joined" | "failed";
|
|
14
|
+
failureReason: string | null;
|
|
15
|
+
retry: () => void;
|
|
16
|
+
};
|
|
@@ -1,23 +1,39 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
|
|
3
|
+
import { createTransportSessionId } from "@jgengine/core/runtime/transport";
|
|
3
4
|
import { attachWorldSync } from "./worldSync.js";
|
|
4
5
|
/** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
|
|
5
|
-
export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers) {
|
|
6
|
+
export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers, authoritativeFrameRef) {
|
|
7
|
+
const [status, setStatus] = useState("joining");
|
|
8
|
+
const [failureReason, setFailureReason] = useState(null);
|
|
9
|
+
const [attempt, setAttempt] = useState(0);
|
|
10
|
+
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
|
6
11
|
useEffect(() => {
|
|
7
12
|
if (ctx === null || multiplayer === null)
|
|
8
13
|
return;
|
|
14
|
+
setStatus("joining");
|
|
15
|
+
setFailureReason(null);
|
|
9
16
|
let disposed = false;
|
|
10
17
|
const cleanups = [];
|
|
18
|
+
const sessionId = createTransportSessionId();
|
|
11
19
|
void multiplayer.backend.transport
|
|
12
|
-
.joinServer({ gameId: multiplayer.gameId })
|
|
20
|
+
.joinServer({ gameId: multiplayer.gameId, sessionId })
|
|
13
21
|
.then((joined) => {
|
|
22
|
+
if (!joined.ok) {
|
|
23
|
+
if (!disposed) {
|
|
24
|
+
setFailureReason(joined.reason);
|
|
25
|
+
setStatus("failed");
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
14
29
|
if (disposed) {
|
|
15
|
-
void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId });
|
|
30
|
+
void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId, sessionId }).catch(() => undefined);
|
|
16
31
|
return;
|
|
17
32
|
}
|
|
18
33
|
serverIdRef.current = joined.serverId;
|
|
34
|
+
setStatus("joined");
|
|
19
35
|
if (isServerAuthoritative(playable.game.multiplayer) && multiplayer.backend.feeds !== undefined) {
|
|
20
|
-
cleanups.push(attachWorldSync(multiplayer.backend.feeds, joined.serverId, ctx));
|
|
36
|
+
cleanups.push(attachWorldSync(multiplayer.backend.feeds, joined.serverId, ctx, (revision) => authoritativeFrameRef?.current?.(revision)));
|
|
21
37
|
}
|
|
22
38
|
cleanups.push(multiplayer.backend.presenceSync.subscribe(joined.serverId, (rows) => {
|
|
23
39
|
setRemotePlayers(rows.filter((row) => row.userId !== multiplayer.userId));
|
|
@@ -82,7 +98,12 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
|
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
100
|
})
|
|
85
|
-
.catch(() =>
|
|
101
|
+
.catch((error) => {
|
|
102
|
+
if (!disposed) {
|
|
103
|
+
setFailureReason(error instanceof Error ? error.message : "Connection failed");
|
|
104
|
+
setStatus("failed");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
86
107
|
return () => {
|
|
87
108
|
disposed = true;
|
|
88
109
|
for (const cleanup of cleanups)
|
|
@@ -91,8 +112,9 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
|
|
|
91
112
|
serverIdRef.current = null;
|
|
92
113
|
setRemotePlayers([]);
|
|
93
114
|
if (serverId !== null) {
|
|
94
|
-
void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined);
|
|
115
|
+
void multiplayer.backend.transport.leaveServer({ serverId, sessionId }).catch(() => undefined);
|
|
95
116
|
}
|
|
96
117
|
};
|
|
97
|
-
}, [ctx, multiplayer, playable]);
|
|
118
|
+
}, [ctx, multiplayer, playable, attempt]);
|
|
119
|
+
return { status: multiplayer === null ? "joined" : status, failureReason, retry };
|
|
98
120
|
}
|
|
@@ -14,8 +14,9 @@ export declare function useRenderVisibility(): MutableRefObject<VisiblePredicate
|
|
|
14
14
|
* conservative preload margin) are never submitted to the renderer, without unmounting them or
|
|
15
15
|
* touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
|
|
16
16
|
*/
|
|
17
|
-
export declare function CullingProvider({ config, children }: {
|
|
17
|
+
export declare function CullingProvider({ config, drawDistance, children }: {
|
|
18
18
|
config: VisibilityConfig | undefined;
|
|
19
|
+
drawDistance?: number;
|
|
19
20
|
children: ReactNode;
|
|
20
21
|
}): ReactNode;
|
|
21
22
|
export {};
|
|
@@ -110,12 +110,17 @@ function viewFromCamera(camera) {
|
|
|
110
110
|
* conservative preload margin) are never submitted to the renderer, without unmounting them or
|
|
111
111
|
* touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
|
|
112
112
|
*/
|
|
113
|
-
export function CullingProvider({ config, children }) {
|
|
113
|
+
export function CullingProvider({ config, drawDistance, children }) {
|
|
114
114
|
const ctx = useGameContext();
|
|
115
115
|
const enabled = config?.enabled !== false;
|
|
116
116
|
const predicateRef = useRef(ALWAYS_VISIBLE);
|
|
117
117
|
const camera = useThree((state) => state.camera);
|
|
118
|
-
const
|
|
118
|
+
const resolvedConfig = useMemo(() => {
|
|
119
|
+
if (drawDistance === undefined)
|
|
120
|
+
return config;
|
|
121
|
+
return { ...config, culling: { ...config?.culling, defaultMaxRenderDistance: config?.culling?.defaultMaxRenderDistance ?? drawDistance } };
|
|
122
|
+
}, [config, drawDistance]);
|
|
123
|
+
const driver = useMemo(() => (enabled ? buildSystem(ctx, resolvedConfig) : null), [ctx, resolvedConfig, enabled]);
|
|
119
124
|
useFrame(() => {
|
|
120
125
|
if (driver === null) {
|
|
121
126
|
predicateRef.current = ALWAYS_VISIBLE;
|
|
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { resolveGridInstances } from "@jgengine/core/world/gridInstances";
|
|
5
5
|
import { useDisposable } from "../render/useDisposable.js";
|
|
6
|
+
import { TileLayerRenderer } from "./TileLayerRenderer.js";
|
|
6
7
|
function isGridWorldFeature(feature) {
|
|
7
8
|
return (feature.kind === "biomes" || feature.kind === "voxel" || feature.kind === "plots" || feature.kind === "tilemap");
|
|
8
9
|
}
|
|
@@ -12,6 +13,9 @@ function isGridWorldFeature(feature) {
|
|
|
12
13
|
* `cells`, following the same direct-buffer pattern as `InstancedBodies`.
|
|
13
14
|
*/
|
|
14
15
|
export function GridWorldScene({ feature }) {
|
|
16
|
+
if (feature.kind === "tilemap" && feature.tileSet !== undefined) {
|
|
17
|
+
return _jsx(TileLayerRenderer, { config: feature });
|
|
18
|
+
}
|
|
15
19
|
const instances = useMemo(() => (isGridWorldFeature(feature) ? resolveGridInstances(feature) : []), [feature]);
|
|
16
20
|
const meshRef = useRef(null);
|
|
17
21
|
const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TilemapWorldConfig } from "@jgengine/core/world/features";
|
|
2
|
+
/** Inputs for the instanced textured tile-layer renderer. */
|
|
3
|
+
export interface TileLayerRendererProps {
|
|
4
|
+
config: TilemapWorldConfig;
|
|
5
|
+
parallax?: readonly [number, number];
|
|
6
|
+
pixelated?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Renders a tilemap as one instanced quad mesh with atlas UV rectangles. */
|
|
9
|
+
export declare function TileLayerRenderer({ config, parallax, pixelated }: TileLayerRendererProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
import { sortingOrder } from "@jgengine/core/render/sprite2d";
|
|
6
|
+
import { useDisposable } from "../render/useDisposable.js";
|
|
7
|
+
import { resolveTileLayerInstances } from "./tileLayerInstances.js";
|
|
8
|
+
/** Renders a tilemap as one instanced quad mesh with atlas UV rectangles. */
|
|
9
|
+
export function TileLayerRenderer({ config, parallax = [1, 1], pixelated = true }) {
|
|
10
|
+
const tileSet = config.tileSet;
|
|
11
|
+
const { camera } = useThree();
|
|
12
|
+
const texture = useLoader(THREE.TextureLoader, tileSet?.atlas.image ?? "");
|
|
13
|
+
const instances = useMemo(() => resolveTileLayerInstances(config), [config]);
|
|
14
|
+
const meshRef = useRef(null);
|
|
15
|
+
const geometry = useDisposable(() => new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), []);
|
|
16
|
+
const uvOffset = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 2), 2), [instances.length]);
|
|
17
|
+
const uvScale = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 2), 2), [instances.length]);
|
|
18
|
+
const material = useDisposable(() => {
|
|
19
|
+
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, alphaTest: 0.08, depthWrite: false });
|
|
20
|
+
mat.onBeforeCompile = (shader) => {
|
|
21
|
+
shader.vertexShader = shader.vertexShader.replace("#include <common>", "#include <common>\nattribute vec2 tileUvOffset;\nattribute vec2 tileUvScale;").replace("#include <uv_vertex>", "#include <uv_vertex>\n#ifdef USE_MAP\n vMapUv = vMapUv * tileUvScale + tileUvOffset;\n#endif");
|
|
22
|
+
};
|
|
23
|
+
mat.customProgramCacheKey = () => "jgengine-tile-layer";
|
|
24
|
+
return mat;
|
|
25
|
+
}, [texture]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
28
|
+
texture.magFilter = pixelated ? THREE.NearestFilter : THREE.LinearFilter;
|
|
29
|
+
texture.minFilter = pixelated ? THREE.NearestFilter : THREE.LinearMipmapLinearFilter;
|
|
30
|
+
texture.needsUpdate = true;
|
|
31
|
+
}, [texture, pixelated]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
geometry.setAttribute("tileUvOffset", uvOffset);
|
|
34
|
+
geometry.setAttribute("tileUvScale", uvScale);
|
|
35
|
+
}, [geometry, uvOffset, uvScale]);
|
|
36
|
+
const matrix = useMemo(() => new THREE.Matrix4(), []);
|
|
37
|
+
useFrame(() => {
|
|
38
|
+
const mesh = meshRef.current;
|
|
39
|
+
if (mesh === null)
|
|
40
|
+
return;
|
|
41
|
+
mesh.position.set(-camera.position.x * (1 - parallax[0]), 0, camera.position.z * (1 - parallax[1]));
|
|
42
|
+
});
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const mesh = meshRef.current;
|
|
45
|
+
if (mesh === null)
|
|
46
|
+
return;
|
|
47
|
+
mesh.count = instances.length;
|
|
48
|
+
const array = mesh.instanceMatrix.array;
|
|
49
|
+
const offset = uvOffset.array;
|
|
50
|
+
const scale = uvScale.array;
|
|
51
|
+
for (let i = 0; i < instances.length; i += 1) {
|
|
52
|
+
const instance = instances[i];
|
|
53
|
+
matrix.makeTranslation(instance.x, 0, instance.z).toArray(array, i * 16);
|
|
54
|
+
offset.set(instance.uvOffset, i * 2);
|
|
55
|
+
scale.set(instance.uvScale, i * 2);
|
|
56
|
+
}
|
|
57
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
58
|
+
uvOffset.needsUpdate = true;
|
|
59
|
+
uvScale.needsUpdate = true;
|
|
60
|
+
}, [instances, matrix, uvOffset, uvScale]);
|
|
61
|
+
if (tileSet === undefined || instances.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
return _jsx("instancedMesh", { ref: meshRef, args: [geometry, material, instances.length], renderOrder: sortingOrder(["ground", "objects", "effects"], config.sortingLayer ?? "ground", 0), frustumCulled: false });
|
|
64
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
|
|
3
|
+
import type { ParticleEmitterSpec } from "@jgengine/core/vfx/particleDirector";
|
|
4
|
+
/**
|
|
5
|
+
* The per-effect particle pool a graphics tier allows: the requested `max`
|
|
6
|
+
* clamped to the tier cap, so lower tiers degrade density instead of dropping
|
|
7
|
+
* effects entirely.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveParticleBudget(quality: GraphicsQuality, requested: number | undefined, particleCap?: number): number;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a spec change touches something the JSX reads (blending, static offset,
|
|
12
|
+
* follow binding) and therefore needs a React re-render — config-only retunes are
|
|
13
|
+
* applied imperatively to the live system and must stay render-free.
|
|
14
|
+
*
|
|
15
|
+
* @internal WorldParticles render-boundary helper, exported for its regression test.
|
|
16
|
+
*/
|
|
17
|
+
export declare function specNeedsRender(prev: ParticleEmitterSpec, next: ParticleEmitterSpec): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Renders `ctx.particles` — the game-reachable particle seam. Standing emitters
|
|
20
|
+
* become persistent `ParticleField`s (recreated only when their pool size changes,
|
|
21
|
+
* retuned in place otherwise), `follow` emitters track their entity's live pose
|
|
22
|
+
* each frame, and drained bursts become transient systems reaped once empty.
|
|
23
|
+
* Auto-mounted by the player shell; pools are capped by graphics quality via
|
|
24
|
+
* {@link resolveParticleBudget}.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function WorldParticles({ quality, particleCap }: {
|
|
29
|
+
quality: GraphicsQuality;
|
|
30
|
+
particleCap?: number;
|
|
31
|
+
}): ReactElement;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { createParticleSystem } from "@jgengine/core/vfx/particles";
|
|
5
|
+
import { useGameContext } from "@jgengine/react/provider";
|
|
6
|
+
import { ParticleField } from "../vfx/ParticleField.js";
|
|
7
|
+
const QUALITY_POOL_CAP = { low: 128, medium: 256, high: 512, ultra: 1024 };
|
|
8
|
+
/**
|
|
9
|
+
* The per-effect particle pool a graphics tier allows: the requested `max`
|
|
10
|
+
* clamped to the tier cap, so lower tiers degrade density instead of dropping
|
|
11
|
+
* effects entirely.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveParticleBudget(quality, requested, particleCap) {
|
|
14
|
+
const cap = Math.max(1, Math.floor(particleCap ?? QUALITY_POOL_CAP[quality]));
|
|
15
|
+
return Math.max(1, Math.min(requested ?? cap, cap));
|
|
16
|
+
}
|
|
17
|
+
const MAX_CONCURRENT_BURSTS = 32;
|
|
18
|
+
/**
|
|
19
|
+
* Whether a spec change touches something the JSX reads (blending, static offset,
|
|
20
|
+
* follow binding) and therefore needs a React re-render — config-only retunes are
|
|
21
|
+
* applied imperatively to the live system and must stay render-free.
|
|
22
|
+
*
|
|
23
|
+
* @internal WorldParticles render-boundary helper, exported for its regression test.
|
|
24
|
+
*/
|
|
25
|
+
export function specNeedsRender(prev, next) {
|
|
26
|
+
if (prev.blending !== next.blending || prev.follow !== next.follow)
|
|
27
|
+
return true;
|
|
28
|
+
const a = prev.offset;
|
|
29
|
+
const b = next.offset;
|
|
30
|
+
if (a === b)
|
|
31
|
+
return false;
|
|
32
|
+
if (a === undefined || b === undefined)
|
|
33
|
+
return true;
|
|
34
|
+
return a[0] !== b[0] || a[1] !== b[1] || a[2] !== b[2];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Renders `ctx.particles` — the game-reachable particle seam. Standing emitters
|
|
38
|
+
* become persistent `ParticleField`s (recreated only when their pool size changes,
|
|
39
|
+
* retuned in place otherwise), `follow` emitters track their entity's live pose
|
|
40
|
+
* each frame, and drained bursts become transient systems reaped once empty.
|
|
41
|
+
* Auto-mounted by the player shell; pools are capped by graphics quality via
|
|
42
|
+
* {@link resolveParticleBudget}.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
export function WorldParticles({ quality, particleCap }) {
|
|
47
|
+
const ctx = useGameContext();
|
|
48
|
+
const emittersRef = useRef(new Map());
|
|
49
|
+
const burstsRef = useRef([]);
|
|
50
|
+
const [, setGeneration] = useState(0);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const emitters = emittersRef.current;
|
|
53
|
+
function sync() {
|
|
54
|
+
let changed = false;
|
|
55
|
+
const specs = ctx.particles.emitters();
|
|
56
|
+
const liveIds = new Set();
|
|
57
|
+
for (const spec of specs) {
|
|
58
|
+
liveIds.add(spec.id);
|
|
59
|
+
const poolMax = resolveParticleBudget(quality, spec.config.max, particleCap);
|
|
60
|
+
const current = emitters.get(spec.id);
|
|
61
|
+
if (current === undefined || current.poolMax !== poolMax) {
|
|
62
|
+
emitters.set(spec.id, {
|
|
63
|
+
spec,
|
|
64
|
+
poolMax,
|
|
65
|
+
system: createParticleSystem({ ...spec.config, max: poolMax, seed: spec.id }),
|
|
66
|
+
});
|
|
67
|
+
changed = true;
|
|
68
|
+
}
|
|
69
|
+
else if (current.spec !== spec) {
|
|
70
|
+
if (specNeedsRender(current.spec, spec))
|
|
71
|
+
changed = true;
|
|
72
|
+
current.system.configure({ ...spec.config, max: poolMax });
|
|
73
|
+
current.spec = spec;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const id of emitters.keys()) {
|
|
77
|
+
if (liveIds.has(id))
|
|
78
|
+
continue;
|
|
79
|
+
emitters.delete(id);
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
for (const burst of ctx.particles.drainBursts()) {
|
|
83
|
+
const poolMax = resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count), particleCap);
|
|
84
|
+
const system = createParticleSystem({ ...burst.config, max: poolMax, seed: burst.seq });
|
|
85
|
+
system.emit(burst.count);
|
|
86
|
+
burstsRef.current.push({ seq: burst.seq, system, blending: burst.blending ?? "additive" });
|
|
87
|
+
if (burstsRef.current.length > MAX_CONCURRENT_BURSTS)
|
|
88
|
+
burstsRef.current.shift();
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
if (changed)
|
|
92
|
+
setGeneration((n) => n + 1);
|
|
93
|
+
}
|
|
94
|
+
sync();
|
|
95
|
+
const off = ctx.particles.subscribe(sync);
|
|
96
|
+
return () => {
|
|
97
|
+
off();
|
|
98
|
+
emitters.clear();
|
|
99
|
+
burstsRef.current = [];
|
|
100
|
+
};
|
|
101
|
+
}, [ctx, quality, particleCap]);
|
|
102
|
+
useFrame(() => {
|
|
103
|
+
for (const instance of emittersRef.current.values()) {
|
|
104
|
+
const follow = instance.spec.follow;
|
|
105
|
+
if (follow === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
const entity = ctx.scene.entity.get(follow);
|
|
108
|
+
if (entity === null || entity === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
const offset = instance.spec.offset ?? [0, 0, 0];
|
|
111
|
+
instance.system.configure({
|
|
112
|
+
position: [
|
|
113
|
+
entity.position[0] + offset[0],
|
|
114
|
+
entity.position[1] + offset[1],
|
|
115
|
+
entity.position[2] + offset[2],
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const finished = burstsRef.current.filter((burst) => burst.system.count() === 0);
|
|
120
|
+
if (finished.length > 0) {
|
|
121
|
+
burstsRef.current = burstsRef.current.filter((burst) => burst.system.count() > 0);
|
|
122
|
+
setGeneration((n) => n + 1);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return (_jsxs(_Fragment, { children: [Array.from(emittersRef.current.values()).map((instance) => (_jsx("group", { position: instance.spec.follow === undefined && instance.spec.offset !== undefined
|
|
126
|
+
? instance.spec.offset
|
|
127
|
+
: [0, 0, 0], children: _jsx(ParticleField, { system: instance.system, blending: instance.spec.blending ?? "normal" }) }, instance.spec.id))), burstsRef.current.map((burst) => (_jsx(ParticleField, { system: burst.system, blending: burst.blending }, burst.seq)))] }));
|
|
128
|
+
}
|
|
@@ -16,6 +16,7 @@ export declare function WorldView({ entitySprites, entityModels, objectModels, o
|
|
|
16
16
|
selectedIds: ReadonlySet<string>;
|
|
17
17
|
hideLocalActor: boolean;
|
|
18
18
|
}): import("react").JSX.Element;
|
|
19
|
-
export declare function RemotePlayers({ rows }: {
|
|
19
|
+
export declare function RemotePlayers({ rows, presenceInterpolationMs }: {
|
|
20
20
|
rows: PresencePoseRow[];
|
|
21
|
+
presenceInterpolationMs?: number;
|
|
21
22
|
}): import("react").JSX.Element;
|
package/dist/world/WorldScene.js
CHANGED
|
@@ -14,7 +14,8 @@ import { MeasuredBoundsGroup } from "../render/measureBounds.js";
|
|
|
14
14
|
import { EntitySprite, IsolatedEntityModel } from "../render/SceneModels.js";
|
|
15
15
|
import { resolveModel, resolveEntityModel, tryResolveCatalogModel } from "../render/resolveModel.js";
|
|
16
16
|
import { useRenderVisibility } from "../visibility/CullingProvider.js";
|
|
17
|
-
import { writeEntityPose } from "./entityPose.js";
|
|
17
|
+
import { writeEntityPose, writeRenderPose } from "./entityPose.js";
|
|
18
|
+
import { createSnapshotBuffer } from "@jgengine/core/runtime/snapshotBuffer";
|
|
18
19
|
import { POINTER_ENTITY_KEY, POINTER_OBJECT_KEY } from "../pointer/pointerService.js";
|
|
19
20
|
const GROUND_SIZE = 160;
|
|
20
21
|
const GROUND_SEGMENTS = 80;
|
|
@@ -26,6 +27,7 @@ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, select
|
|
|
26
27
|
const role = entity.role;
|
|
27
28
|
const name = entity.name;
|
|
28
29
|
const color = isLocal ? "#4ade80" : role === "npc" ? colorFromId(name) : "#9ca3af";
|
|
30
|
+
const scratchRef = useRef([0, 0, 0, 0]);
|
|
29
31
|
useFrame(() => {
|
|
30
32
|
const group = groupRef.current;
|
|
31
33
|
if (group === null)
|
|
@@ -33,7 +35,7 @@ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, select
|
|
|
33
35
|
const live = ctx.scene.entity.get(entityId);
|
|
34
36
|
if (live === null)
|
|
35
37
|
return;
|
|
36
|
-
|
|
38
|
+
writeRenderPose(group, ctx, entityId, live, scratchRef.current);
|
|
37
39
|
group.visible = live.hidden !== true && visibleRef.current(entityId);
|
|
38
40
|
});
|
|
39
41
|
return (_jsxs("group", { ref: groupRef, userData: { [POINTER_ENTITY_KEY]: entityId }, onPointerDown: (event) => {
|
|
@@ -187,6 +189,46 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
|
|
|
187
189
|
export function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment, assets, renderEntity, renderObject, selectedIds, hideLocalActor, }) {
|
|
188
190
|
return (_jsxs(_Fragment, { children: [_jsx(WorldEnvironment, { environment: environment }), _jsx(WorldActors, { entitySprites: entitySprites, entityModels: entityModels, objectModels: objectModels, objectStyles: objectStyles, assets: assets, renderEntity: renderEntity, renderObject: renderObject, selectedIds: selectedIds, hideLocalActor: hideLocalActor })] }));
|
|
189
191
|
}
|
|
190
|
-
export function RemotePlayers({ rows }) {
|
|
191
|
-
|
|
192
|
+
export function RemotePlayers({ rows, presenceInterpolationMs = 100 }) {
|
|
193
|
+
const groups = useRef(new Map());
|
|
194
|
+
const buffers = useRef(new Map());
|
|
195
|
+
const rowsRef = useRef(rows);
|
|
196
|
+
rowsRef.current = rows;
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const active = new Set(rows.map((row) => row.userId));
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
let buffer = buffers.current.get(row.userId);
|
|
201
|
+
if (buffer === undefined) {
|
|
202
|
+
buffer = createSnapshotBuffer({ delayMs: presenceInterpolationMs, capacity: 16 });
|
|
203
|
+
buffers.current.set(row.userId, buffer);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
buffer.retune({ delayMs: presenceInterpolationMs });
|
|
207
|
+
}
|
|
208
|
+
buffer.push(row.lastSeenAt, row);
|
|
209
|
+
}
|
|
210
|
+
for (const userId of buffers.current.keys()) {
|
|
211
|
+
if (!active.has(userId))
|
|
212
|
+
buffers.current.delete(userId);
|
|
213
|
+
}
|
|
214
|
+
}, [rows, presenceInterpolationMs]);
|
|
215
|
+
useFrame(({ clock }) => {
|
|
216
|
+
const now = clock.getElapsedTime() * 1000;
|
|
217
|
+
for (const [userId, buffer] of buffers.current) {
|
|
218
|
+
const group = groups.current.get(userId);
|
|
219
|
+
const sample = buffer.sampleAt(now);
|
|
220
|
+
if (group === undefined || sample === null)
|
|
221
|
+
continue;
|
|
222
|
+
const before = sample.before;
|
|
223
|
+
const after = sample.after;
|
|
224
|
+
group.position.set(before.position.x + (after.position.x - before.position.x) * sample.alpha, before.position.y + (after.position.y - before.position.y) * sample.alpha, before.position.z + (after.position.z - before.position.z) * sample.alpha);
|
|
225
|
+
group.rotation.y = before.rotationY + (after.rotationY - before.rotationY) * sample.alpha;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return (_jsx(_Fragment, { children: rows.map((row) => (_jsxs("group", { ref: (group) => {
|
|
229
|
+
if (group === null)
|
|
230
|
+
groups.current.delete(row.userId);
|
|
231
|
+
else
|
|
232
|
+
groups.current.set(row.userId, group);
|
|
233
|
+
}, children: [_jsxs("mesh", { "position-y": 0.95, children: [_jsx("capsuleGeometry", { args: [0.35, 1.1, 6, 14] }), _jsx("meshStandardMaterial", { color: colorFromId(row.userId) })] }), _jsxs("mesh", { position: [0, 1.35, 0.32], children: [_jsx("boxGeometry", { args: [0.16, 0.16, 0.16] }), _jsx("meshStandardMaterial", { color: "#f8fafc" })] })] }, row.userId))) }));
|
|
192
234
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
2
|
+
import type { RenderPose } from "@jgengine/core/runtime/poseInterpolation";
|
|
1
3
|
export interface PoseWritable {
|
|
2
4
|
position: {
|
|
3
5
|
set(x: number, y: number, z: number): void;
|
|
@@ -12,5 +14,7 @@ export interface PoseSource {
|
|
|
12
14
|
}
|
|
13
15
|
/** @internal */
|
|
14
16
|
export declare function writeEntityPose(target: PoseWritable, source: PoseSource): void;
|
|
17
|
+
/** Write the interpolated render pose from `ctx.sim` (falls back to `live` when no history exists). @internal */
|
|
18
|
+
export declare function writeRenderPose(target: PoseWritable, ctx: Pick<GameContext, "sim">, entityId: string, live: PoseSource, scratch: RenderPose): void;
|
|
15
19
|
/** @internal */
|
|
16
20
|
export declare function posesEqual(a: PoseSource, b: PoseSource): boolean;
|