forked from phenom/healer-man
Release Healer Man 0.1.48
This commit is contained in:
1 parent
2162039b49
commit
39425f2494
9 files changed
+219
-40
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const componentSource = readFileSync(
|
||||
new URL("./DungeonSimulationSceneBridge.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("dungeon simulation render-loop contract", () => {
|
||||
it("leaves React Three Fiber in charge of rendering each frame", () => {
|
||||
expect(componentSource).toContain("useFrame(() => {");
|
||||
expect(componentSource).not.toMatch(/useFrame\([\s\S]*?\},\s*[1-9]\d*\s*\);/);
|
||||
});
|
||||
});
|
||||
@@ -436,7 +436,9 @@ export function DungeonSimulationSceneBridge() {
|
||||
}
|
||||
if (simulation.runState === "finished") return;
|
||||
inspectLiveSimulation(cursorRef.current);
|
||||
}, 1);
|
||||
// Keep the default frame priority. A positive priority takes ownership of
|
||||
// R3F rendering and would require this bridge to call gl.render() itself.
|
||||
});
|
||||
|
||||
if (sessionMode === "play") return null;
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useRapier } from "@react-three/rapier";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Vector3 } from "three";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Quaternion, Vector3 } from "three";
|
||||
import { consumeMouseLook } from "../game/inputManager";
|
||||
import { cameraYawAfterHorizontalLook, DEFAULT_CAMERA_PITCH } from "../game/inputMath";
|
||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
|
||||
@@ -12,9 +13,16 @@ import {
|
||||
spectatorCameraTranslation,
|
||||
spectatorKeyboardAxes,
|
||||
} from "../game/spectatorCameraMath";
|
||||
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||
import { DUNGEON_SIGHT_QUERY_GROUPS } from "./dungeonLineOfSight";
|
||||
|
||||
const FOLLOW_DISTANCE = 7;
|
||||
const FOLLOW_HEIGHT = 2.4;
|
||||
const FOLLOW_DISTANCE = 3.2;
|
||||
const FOLLOW_FOCUS_HEIGHT = 1.3;
|
||||
const FOLLOW_VERTICAL_OFFSET = 0.35;
|
||||
const FOLLOW_LOOK_AHEAD = 1.25;
|
||||
const CAMERA_COLLISION_RADIUS = 0.24;
|
||||
const CAMERA_WALL_GAP = 0.08;
|
||||
const CAMERA_PULLBACK_DAMPING = 7;
|
||||
const LOOK_SENSITIVITY = 0.0021;
|
||||
const SPECTATOR_FLIGHT_KEYS = new Set([
|
||||
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
|
||||
@@ -25,11 +33,21 @@ export function DungeonSpectatorCamera() {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const { rapier, world } = useRapier();
|
||||
const yawRef = useRef(useGameStore.getState().cameraYaw);
|
||||
const pitchRef = useRef(-0.22);
|
||||
const orbitPitchRef = useRef(DEFAULT_CAMERA_PITCH);
|
||||
const freePitchRef = useRef(-DEFAULT_CAMERA_PITCH);
|
||||
const freePositionRef = useRef(new Vector3().copy(camera.position));
|
||||
const pressedKeysRef = useRef(new Set<string>());
|
||||
const initializedFreeRef = useRef(false);
|
||||
const followedMemberRef = useRef<string | null>(null);
|
||||
const boomDistanceRef = useRef(FOLLOW_DISTANCE);
|
||||
const cameraShape = useMemo(() => new rapier.Ball(CAMERA_COLLISION_RADIUS), [rapier]);
|
||||
const shapeRotation = useMemo(() => new Quaternion(), []);
|
||||
const focus = useMemo(() => new Vector3(), []);
|
||||
const desired = useMemo(() => new Vector3(), []);
|
||||
const boomDirection = useMemo(() => new Vector3(), []);
|
||||
const safeDesired = useMemo(() => new Vector3(), []);
|
||||
const lookAt = useMemo(() => new Vector3(), []);
|
||||
const forward = useMemo(() => new Vector3(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -58,25 +76,28 @@ export function DungeonSpectatorCamera() {
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
if (simulation.sessionMode === "play") return;
|
||||
const [mouseX, mouseY] = consumeMouseLook();
|
||||
yawRef.current -= mouseX * LOOK_SENSITIVITY;
|
||||
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - mouseY * LOOK_SENSITIVITY));
|
||||
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||
|
||||
if (simulation.cameraMode === "free-fly") {
|
||||
if (!initializedFreeRef.current) {
|
||||
freePositionRef.current.copy(camera.position);
|
||||
camera.getWorldDirection(forward);
|
||||
yawRef.current = Math.atan2(forward.x, forward.z);
|
||||
freePitchRef.current = Math.asin(Math.max(-1, Math.min(1, forward.y)));
|
||||
initializedFreeRef.current = true;
|
||||
}
|
||||
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, mouseX * LOOK_SENSITIVITY);
|
||||
freePitchRef.current = Math.max(-1.35, Math.min(1.15, freePitchRef.current - mouseY * LOOK_SENSITIVITY));
|
||||
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||
followedMemberRef.current = null;
|
||||
const forward = new Vector3(
|
||||
Math.sin(yawRef.current) * Math.cos(pitchRef.current),
|
||||
Math.sin(pitchRef.current),
|
||||
Math.cos(yawRef.current) * Math.cos(pitchRef.current),
|
||||
forward.set(
|
||||
Math.sin(yawRef.current) * Math.cos(freePitchRef.current),
|
||||
Math.sin(freePitchRef.current),
|
||||
Math.cos(yawRef.current) * Math.cos(freePitchRef.current),
|
||||
);
|
||||
const keyboard = spectatorKeyboardAxes(pressedKeysRef.current);
|
||||
const movement = spectatorCameraTranslation(
|
||||
yawRef.current,
|
||||
pitchRef.current,
|
||||
freePitchRef.current,
|
||||
keyboard,
|
||||
delta,
|
||||
spectatorCameraBoosted(pressedKeysRef.current),
|
||||
@@ -85,11 +106,18 @@ export function DungeonSpectatorCamera() {
|
||||
freePositionRef.current.y += movement[1];
|
||||
freePositionRef.current.z += movement[2];
|
||||
camera.position.copy(freePositionRef.current);
|
||||
camera.lookAt(new Vector3().copy(camera.position).add(forward));
|
||||
lookAt.copy(camera.position).add(forward);
|
||||
camera.lookAt(lookAt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializedFreeRef.current) {
|
||||
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, -freePitchRef.current));
|
||||
}
|
||||
initializedFreeRef.current = false;
|
||||
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, mouseX * LOOK_SENSITIVITY);
|
||||
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, orbitPitchRef.current + mouseY * LOOK_SENSITIVITY));
|
||||
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||
const party = usePartyStore.getState();
|
||||
const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
|
||||
const candidates = requested
|
||||
@@ -98,20 +126,68 @@ export function DungeonSpectatorCamera() {
|
||||
const followed = candidates.map((member) => ({ member, position: getPartyRuntimePosition(member.id) }))
|
||||
.find((candidate) => candidate.position !== null);
|
||||
if (!followed?.position) return;
|
||||
const anchor = new Vector3(followed.position[0], followed.position[1] + FOLLOW_HEIGHT, followed.position[2]);
|
||||
const backward = new Vector3(
|
||||
-Math.sin(yawRef.current) * Math.cos(pitchRef.current),
|
||||
-Math.sin(pitchRef.current),
|
||||
-Math.cos(yawRef.current) * Math.cos(pitchRef.current),
|
||||
).normalize();
|
||||
const ray = new rapier.Ray(anchor, backward);
|
||||
const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
|
||||
const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE;
|
||||
const desired = anchor.clone().addScaledVector(backward, distance);
|
||||
if (followedMemberRef.current !== followed.member.id) camera.position.copy(desired);
|
||||
else camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
|
||||
focus.set(
|
||||
followed.position[0],
|
||||
followed.position[1] + FOLLOW_FOCUS_HEIGHT,
|
||||
followed.position[2],
|
||||
);
|
||||
const horizontalDistance = FOLLOW_DISTANCE * Math.cos(orbitPitchRef.current);
|
||||
desired.set(
|
||||
focus.x - Math.sin(yawRef.current) * horizontalDistance,
|
||||
focus.y + FOLLOW_VERTICAL_OFFSET + FOLLOW_DISTANCE * Math.sin(orbitPitchRef.current),
|
||||
focus.z - Math.cos(yawRef.current) * horizontalDistance,
|
||||
);
|
||||
boomDirection.copy(desired).sub(focus);
|
||||
const fullDistance = boomDirection.length();
|
||||
if (!Number.isFinite(fullDistance) || fullDistance <= 1e-5) return;
|
||||
boomDirection.multiplyScalar(1 / fullDistance);
|
||||
const hit = world.castShape(
|
||||
focus,
|
||||
shapeRotation,
|
||||
boomDirection,
|
||||
cameraShape,
|
||||
CAMERA_WALL_GAP,
|
||||
fullDistance,
|
||||
false,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||
);
|
||||
const timeOfImpact = hit?.time_of_impact;
|
||||
const physicalDistance = typeof timeOfImpact === "number" && Number.isFinite(timeOfImpact)
|
||||
? Math.max(0, Math.min(fullDistance, timeOfImpact))
|
||||
: fullDistance;
|
||||
// WMO sight blockers more closely match the visible wall shell than the
|
||||
// simplified physical terrain. Include them in the boom test so a camera
|
||||
// cannot sit behind a rendered wall while HTML nameplates remain visible.
|
||||
const sightHit = world.castRay(
|
||||
new rapier.Ray(focus, boomDirection),
|
||||
fullDistance,
|
||||
true,
|
||||
rapier.QueryFilterFlags.EXCLUDE_DYNAMIC | rapier.QueryFilterFlags.EXCLUDE_KINEMATIC,
|
||||
DUNGEON_SIGHT_QUERY_GROUPS,
|
||||
);
|
||||
const sightDistance = sightHit && Number.isFinite(sightHit.timeOfImpact)
|
||||
? Math.max(0, Math.min(fullDistance, sightHit.timeOfImpact - CAMERA_COLLISION_RADIUS - CAMERA_WALL_GAP))
|
||||
: fullDistance;
|
||||
const allowedDistance = Math.min(physicalDistance, sightDistance);
|
||||
if (followedMemberRef.current !== followed.member.id || !Number.isFinite(boomDistanceRef.current)) {
|
||||
boomDistanceRef.current = allowedDistance;
|
||||
} else if (allowedDistance < boomDistanceRef.current) {
|
||||
boomDistanceRef.current = allowedDistance;
|
||||
} else {
|
||||
const pullbackAlpha = 1 - Math.exp(-CAMERA_PULLBACK_DAMPING * Math.min(delta, 0.05));
|
||||
boomDistanceRef.current += (allowedDistance - boomDistanceRef.current) * pullbackAlpha;
|
||||
}
|
||||
safeDesired.copy(focus).addScaledVector(boomDirection, boomDistanceRef.current);
|
||||
if (followedMemberRef.current !== followed.member.id) camera.position.copy(safeDesired);
|
||||
else camera.position.lerp(safeDesired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
|
||||
followedMemberRef.current = followed.member.id;
|
||||
camera.lookAt(anchor);
|
||||
lookAt.set(
|
||||
focus.x + Math.sin(yawRef.current) * FOLLOW_LOOK_AHEAD,
|
||||
focus.y + 0.08,
|
||||
focus.z + Math.cos(yawRef.current) * FOLLOW_LOOK_AHEAD,
|
||||
);
|
||||
camera.lookAt(lookAt);
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user