Files
healer-man/src/scene/PlayerRig.tsx
T
2026-08-18 16:22:19 -04:00

484 lines
17 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
CapsuleCollider,
RigidBody,
useRapier,
type RapierRigidBody,
} from "@react-three/rapier";
import { Object3D, Quaternion, Vector3 } from "three";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { classById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import type { CharacterProfile } from "../app/types";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import {
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
readInputSnapshot,
} from "../game/inputManager";
import { useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import {
equipmentItemsForOwner,
PLAYER_EQUIPMENT_OWNER_ID,
} from "../game/equipment";
import type { InventoryItem } from "../game/lootTypes";
import {
cameraRelativeMovement,
cameraYawAfterHorizontalLook,
clampCameraPitch,
DEFAULT_CAMERA_PITCH,
planarDistance,
wrapAngle,
} from "../game/inputMath";
import {
isBelowDungeonRecoveryPlane,
isBelowManastormRecoveryPlane,
} from "../game/playerRecovery";
import {
PLAYER_GROUND_PROBE_DISTANCE,
PLAYER_JUMP_VELOCITY,
bufferPlayerJump,
cancelBufferedPlayerJump,
createCharacterVerticalPresentationTiming,
createPlayerJumpTiming,
isWalkableGroundHit,
updateCharacterVerticalPresentation,
updatePlayerJumpTiming,
type CharacterVerticalMotion,
} from "../game/playerJump";
import {
PLAYER_CHARACTER_CONTROLLER_OFFSET,
PLAYER_GROUND_STICK_VELOCITY,
airbornePlayerVerticalVelocity,
configurePlayerCharacterController,
playerFrameDeltaSeconds,
} from "../game/playerMovement";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
const PLAYER_RADIUS = 0.36;
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT;
const MOVE_SPEED = 4.8;
const CAMERA_DISTANCE = 5.8;
const CAMERA_FOCUS_OFFSET = 0.45;
const CAMERA_COLLISION_RADIUS = 0.24;
const CAMERA_WALL_GAP = 0.08;
const CAMERA_PULLBACK_DAMPING = 7;
const AVATAR_HIDE_DISTANCE = 0.6;
interface OrbitState {
yaw: number;
pitch: number;
}
interface PlayerRigRefs {
bodyRef: React.RefObject<RapierRigidBody | null>;
orbitRef: React.MutableRefObject<OrbitState>;
avatarRef: React.RefObject<Object3D | null>;
}
interface PlayerControllerProps extends PlayerRigRefs {
identity: CharacterProfile | null;
equipmentItems: readonly InventoryItem[];
movingRef: React.MutableRefObject<boolean>;
verticalMotionRef: React.MutableRefObject<CharacterVerticalMotion>;
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
}
function placeAtActiveSpawn(body: RapierRigidBody, orbit: OrbitState): void {
const spawn = useGameStore.getState().activeSpawn;
const translation = {
x: spawn.footPosition[0],
y: spawn.footPosition[1] + PLAYER_CENTER_HEIGHT,
z: spawn.footPosition[2],
};
body.setTranslation(translation, true);
body.setNextKinematicTranslation(translation);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
orbit.yaw = spawn.yaw;
orbit.pitch = DEFAULT_CAMERA_PITCH;
}
function PlayerController({
bodyRef,
orbitRef,
avatarRef,
identity,
equipmentItems,
movingRef,
verticalMotionRef,
animationEventRef,
}: PlayerControllerProps) {
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const resetRevision = useGameStore((state) => state.resetRevision);
const reportAccumulator = useRef(0);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const controlledUntil = useCombatStore((state) => state.controlledUntil);
const spawn = useGameStore((state) => state.activeSpawn);
const dungeon = requireDungeonDefinition(activeDungeonId);
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
const { rapier, world } = useRapier();
const groundRay = useMemo(
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
[rapier],
);
const characterController = useMemo(
() => configurePlayerCharacterController(
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
),
[world],
);
const jumpTimingRef = useRef(createPlayerJumpTiming());
const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming());
const verticalVelocityRef = useRef(0);
useEffect(() => () => {
world.removeCharacterController(characterController);
}, [characterController, world]);
useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
lastReported.current = [...spawn.footPosition];
clearJumpRequest();
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming();
verticalVelocityRef.current = 0;
verticalMotionRef.current = "grounded";
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
useFrame((_, delta) => {
const body = bodyRef.current;
if (!body) return;
const snapshot = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook();
const orbit = orbitRef.current;
// Never let a malformed browser/controller event poison the persistent
// orbit state. A NaN camera matrix makes the scene look like it froze on
// the dark-green clear color even though the render loop is still alive.
if (!Number.isFinite(orbit.yaw)) orbit.yaw = spawn.yaw;
if (!Number.isFinite(orbit.pitch)) orbit.pitch = DEFAULT_CAMERA_PITCH;
if (!paused && !mapOpen && !companionOpen) {
// Read this flag imperatively inside the frame so beginning or ending a
// right-mouse drag updates the DOM HUD without reconciling the Rapier
// player subtree.
if (useGameStore.getState().cameraLookActive) {
orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, mouseX * 0.0021);
orbit.pitch = clampCameraPitch(orbit.pitch + mouseY * 0.0017);
}
orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, snapshot.lookX * delta * 2.25);
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
}
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
return;
}
const nowMs = performance.now();
groundRay.origin.x = translation.x;
groundRay.origin.y = translation.y;
groundRay.origin.z = translation.z;
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
const groundHit = world.castRayAndGetNormal(
groundRay,
groundProbeLength,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
undefined,
undefined,
body,
);
const groundedBeforeMove = verticalVelocityRef.current <= 0.5
&& isWalkableGroundHit(groundHit, groundProbeLength);
const requestedAtMs = consumeJumpRequest();
const gameplayBlocked = paused
|| mapOpen
|| companionOpen
|| useCombatStore.getState().health <= 0
|| controlledUntil > Date.now();
if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false;
verticalMotionRef.current = updateCharacterVerticalPresentation(
verticalPresentationTimingRef.current,
groundedBeforeMove,
verticalVelocityRef.current,
nowMs,
false,
);
body.setNextKinematicTranslation(translation);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
nowMs,
groundedBeforeMove,
verticalVelocityRef.current,
);
const movementDeltaSeconds = playerFrameDeltaSeconds(delta);
let verticalVelocity = shouldJump
? PLAYER_JUMP_VELOCITY
: groundedBeforeMove
? PLAYER_GROUND_STICK_VELOCITY
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
);
if (body.numColliders() === 0) return;
characterController.computeColliderMovement(
body.collider(0),
{
x: worldX * MOVE_SPEED * movementDeltaSeconds,
y: verticalVelocity * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementDeltaSeconds,
},
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
const computedMovement = characterController.computedMovement();
const nextTranslation = {
x: translation.x + computedMovement.x,
y: translation.y + computedMovement.y,
z: translation.z + computedMovement.z,
};
body.setNextKinematicTranslation(nextTranslation);
const grounded = !shouldJump && characterController.computedGrounded();
if (grounded && verticalVelocity <= 0) verticalVelocity = 0;
verticalVelocityRef.current = verticalVelocity;
verticalMotionRef.current = updateCharacterVerticalPresentation(
verticalPresentationTimingRef.current,
grounded,
verticalVelocity,
nowMs,
shouldJump,
);
const moving = Math.hypot(worldX, worldZ) > 0.05;
movingRef.current = moving;
if (avatarRef.current && moving) {
const desiredYaw = Math.atan2(worldX, worldZ);
const difference = wrapAngle(desiredYaw - avatarRef.current.rotation.y);
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
}
const activeManastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
const belowRecoveryPlane = activeManastormPackage
? isBelowManastormRecoveryPlane(
nextTranslation.y,
activeManastormPackage.anchors.map((anchor) => anchor.position[1]),
dungeon.bounds,
)
: isBelowDungeonRecoveryPlane(nextTranslation.y, dungeon.bounds);
if (belowRecoveryPlane) {
useGameStore.getState().resetAtActiveSpawn();
return;
}
reportAccumulator.current += delta;
if (reportAccumulator.current >= 0.1) {
reportAccumulator.current = 0;
const footPosition: [number, number, number] = [
nextTranslation.x,
nextTranslation.y - PLAYER_CENTER_HEIGHT,
nextTranslation.z,
];
const distanceDelta = planarDistance(lastReported.current, footPosition);
useGameStore.getState().updatePlayerSnapshot({
position: footPosition,
yaw: orbit.yaw,
distanceDelta,
grounded,
});
lastReported.current = footPosition;
}
});
return (
<RigidBody
ref={bodyRef}
name="player-capsule"
type="kinematicPosition"
colliders={false}
canSleep={false}
enabledRotations={[false, false, false]}
linearDamping={7}
angularDamping={10}
friction={0}
position={[
spawn.footPosition[0],
spawn.footPosition[1] + PLAYER_CENTER_HEIGHT,
spawn.footPosition[2],
]}
>
<CapsuleCollider args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]} friction={0} />
<group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}>
<CharacterModel
identity={identity}
equipment={equipmentItems}
active={!paused && !mapOpen && !companionOpen}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
/>
</group>
<pointLight position={[0, 0.68, 0.35]} color="#d8bc6a" intensity={1.2} distance={4.5} />
</RigidBody>
);
}
function ThirdPersonCamera({
bodyRef,
orbitRef,
avatarRef,
}: PlayerRigRefs) {
const { camera } = useThree();
const { rapier, world } = useRapier();
const desired = useMemo(() => new Vector3(), []);
const safeDesired = useMemo(() => new Vector3(), []);
const boomDirection = useMemo(() => new Vector3(), []);
const target = useMemo(() => new Vector3(), []);
const lookAt = useMemo(() => new Vector3(), []);
const cameraShape = useMemo(
() => new rapier.Ball(CAMERA_COLLISION_RADIUS),
[rapier],
);
const shapeRotation = useMemo(() => new Quaternion(), []);
const boomDistance = useRef(CAMERA_DISTANCE);
useFrame((_, delta) => {
const body = bodyRef.current;
if (!body) return;
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) return;
const orbit = orbitRef.current;
if (!Number.isFinite(orbit.yaw)) orbit.yaw = requireDungeonDefinition(useGameStore.getState().activeDungeonId).entrance.yaw;
if (!Number.isFinite(orbit.pitch)) orbit.pitch = DEFAULT_CAMERA_PITCH;
const { yaw, pitch } = orbit;
const horizontalDistance = CAMERA_DISTANCE * Math.cos(pitch);
target.set(translation.x, translation.y + CAMERA_FOCUS_OFFSET, translation.z);
desired.set(
target.x - Math.sin(yaw) * horizontalDistance,
target.y + 0.8 + CAMERA_DISTANCE * Math.sin(pitch),
target.z - Math.cos(yaw) * horizontalDistance,
);
boomDirection.copy(desired).sub(target);
const fullDistance = boomDirection.length();
if (!Number.isFinite(fullDistance) || fullDistance <= 1e-5) return;
boomDirection.multiplyScalar(1 / fullDistance);
// Cast a small sphere instead of a single ray. This behaves like a camera
// spring arm and keeps the whole near-camera volume out of walls, pillars,
// and low ceilings. The player's own capsule is explicitly ignored.
const hit = world.castShape(
target,
shapeRotation,
boomDirection,
cameraShape,
CAMERA_WALL_GAP,
fullDistance,
false,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
undefined,
undefined,
body,
);
const timeOfImpact = hit?.time_of_impact;
const allowedDistance = typeof timeOfImpact === "number" && Number.isFinite(timeOfImpact)
? Math.max(0, Math.min(fullDistance, timeOfImpact))
: fullDistance;
if (!Number.isFinite(boomDistance.current)) boomDistance.current = allowedDistance;
// Pull in immediately so no smoothing frame can cross a wall. Ease back
// out after the obstruction clears to avoid a distracting camera pop.
if (allowedDistance < boomDistance.current) {
boomDistance.current = allowedDistance;
} else {
const pullbackAlpha = 1 - Math.exp(-CAMERA_PULLBACK_DAMPING * Math.min(delta, 0.05));
boomDistance.current += (allowedDistance - boomDistance.current) * pullbackAlpha;
}
if (avatarRef.current) {
avatarRef.current.visible = boomDistance.current >= AVATAR_HIDE_DISTANCE;
}
safeDesired.copy(target).addScaledVector(boomDirection, boomDistance.current);
camera.position.copy(safeDesired);
lookAt.set(
target.x + Math.sin(yaw) * 1.25,
target.y + 0.08,
target.z + Math.cos(yaw) * 1.25,
);
camera.lookAt(lookAt);
// Keep the default frame priority. A positive priority takes ownership of
// R3F's render loop and requires this callback to call gl.render() itself.
});
return null;
}
export function PlayerRig() {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const animationEvent = useCombatStore((state) => state.playerAnimationEvent);
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const equipmentItems = useMemo(
() => equipmentItemsForOwner(
equipment,
inventory,
PLAYER_EQUIPMENT_OWNER_ID,
),
[equipment, inventory],
);
const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(null);
const movingRef = useRef(false);
const verticalMotionRef = useRef<CharacterVerticalMotion>("grounded");
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(animationEvent);
animationEventRef.current = animationEvent;
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const spawn = useGameStore((state) => state.activeSpawn);
const orbitRef = useRef<OrbitState>({
yaw: spawn.yaw,
pitch: DEFAULT_CAMERA_PITCH,
});
return (
<>
<PlayerController
bodyRef={bodyRef}
orbitRef={orbitRef}
avatarRef={avatarRef}
identity={activeCharacter}
equipmentItems={equipmentItems}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
/>
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />
</>
);
}