forked from phenom/healer-man
484 lines
17 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|