import { useFrame } from "@react-three/fiber"; import type { RefObject } from "react"; import type { Group, Mesh } from "three"; import { MathUtils, Vector3 } from "three"; import type { EntitySnapshot } from "../types"; import { angularDistance, dampAngle } from "./characterUtils"; import type { NpcBodyRefs } from "./NpcBody"; type NpcAnimationRefs = NpcBodyRefs & { groupRef: RefObject; pulseRef: RefObject; }; type NpcActorAnimationOptions = { entity: EntitySnapshot; isSelected: boolean; refs: NpcAnimationRefs; target: readonly [number, number, number]; focusTarget: readonly [number, number, number] | null; moveDirection: Vector3; animationPhase: number; walkBlendRef: RefObject; attackBlendRef: RefObject; }; export function isAttackingIntention(intention: string): boolean { return /^attacking\b/.test(intention) || intention.includes("fighting"); } export function useNpcActorAnimation({ entity, isSelected, refs, target, focusTarget, moveDirection, animationPhase, walkBlendRef, attackBlendRef, }: NpcActorAnimationOptions) { useFrame((state, delta) => { const time = state.clock.elapsedTime + animationPhase; const walkTarget = updatePosition( entity, refs.groupRef.current, target, focusTarget, moveDirection, delta, ); if (refs.pulseRef.current) { const pulse = 1 + Math.sin(state.clock.elapsedTime * 4) * 0.08; refs.pulseRef.current.scale.setScalar(isSelected ? pulse : 0.001); } walkBlendRef.current = MathUtils.damp(walkBlendRef.current, walkTarget, 7, delta); const attackTarget = entity.state.is_alive && isAttackingIntention(entity.state.intention) ? 1 : 0; attackBlendRef.current = MathUtils.damp(attackBlendRef.current, attackTarget, 8, delta); if (!entity.state.is_alive) { animateDeath(refs, delta); return; } animateWalk(refs, time, walkBlendRef.current, delta); animateAttack(refs, time, attackBlendRef.current); }); } function updatePosition( entity: EntitySnapshot, group: Group | null, target: readonly [number, number, number], focusTarget: readonly [number, number, number] | null, moveDirection: Vector3, delta: number, ): number { if (!group) { return 0; } group.position.y = MathUtils.damp(group.position.y, target[1], 8, delta); if (!entity.state.is_alive) { return 0; } const deltaX = target[0] - group.position.x; const deltaZ = target[2] - group.position.z; const distanceToTarget = Math.hypot(deltaX, deltaZ); if (distanceToTarget <= 0.025) { group.position.x = target[0]; group.position.z = target[2]; faceFocusTarget(group, focusTarget, delta); return 0; } moveDirection.set(deltaX / distanceToTarget, 0, deltaZ / distanceToTarget); const desiredRotation = Math.atan2(moveDirection.x, moveDirection.z); const rotationDelta = angularDistance(group.rotation.y, desiredRotation); const alignment = MathUtils.clamp(1 - rotationDelta / (Math.PI * 0.62), 0, 1); const distanceBlend = MathUtils.clamp(distanceToTarget / 0.45, 0, 1); const movementBlend = distanceBlend * MathUtils.lerp(0.2, 1, alignment); const step = Math.min(distanceToTarget, 1.65 * MathUtils.lerp(0.18, 1, movementBlend) * delta); group.position.x += moveDirection.x * step; group.position.z += moveDirection.z * step; group.rotation.y = dampAngle(group.rotation.y, desiredRotation, 5.5, delta); return movementBlend; } function faceFocusTarget( group: Group, focusTarget: readonly [number, number, number] | null, delta: number, ) { if (!focusTarget) { return; } const deltaX = focusTarget[0] - group.position.x; const deltaZ = focusTarget[2] - group.position.z; if (Math.hypot(deltaX, deltaZ) <= 0.05) { return; } group.rotation.y = dampAngle(group.rotation.y, Math.atan2(deltaX, deltaZ), 5.5, delta); } function animateDeath(refs: NpcAnimationRefs, delta: number) { if (refs.characterRef.current) { refs.characterRef.current.position.y = MathUtils.damp( refs.characterRef.current.position.y, 0.12, 5, delta, ); refs.characterRef.current.rotation.x = MathUtils.damp( refs.characterRef.current.rotation.x, Math.PI / 2, 4, delta, ); refs.characterRef.current.rotation.z = MathUtils.damp( refs.characterRef.current.rotation.z, 0.22, 4, delta, ); } // Settle the torso and head out of whatever walk/attack pose they were in. dampGroupToRest(refs.torsoRef, delta); dampGroupToRest(refs.headRef, delta); // Limbs collapse into a relaxed, slightly splayed sprawl. Reset every channel // (not just rotation.x) so no leftover stride/chop rotation freezes mid-motion. dampLimb(refs.leftArmRef, -0.1, -0.4, delta); dampLimb(refs.rightArmRef, -0.1, 0.4, delta); dampLimb(refs.leftLegRef, 0.08, -0.18, delta); dampLimb(refs.rightLegRef, 0.08, 0.18, delta); } function dampGroupToRest(ref: RefObject, delta: number) { if (!ref.current) { return; } ref.current.rotation.x = MathUtils.damp(ref.current.rotation.x, 0, 5, delta); ref.current.rotation.y = MathUtils.damp(ref.current.rotation.y, 0, 5, delta); ref.current.rotation.z = MathUtils.damp(ref.current.rotation.z, 0, 5, delta); } function dampLimb( ref: RefObject, xRotation: number, zRotation: number, delta: number, ) { if (ref.current) { ref.current.rotation.x = MathUtils.damp(ref.current.rotation.x, xRotation, 5, delta); ref.current.rotation.y = MathUtils.damp(ref.current.rotation.y, 0, 5, delta); ref.current.rotation.z = MathUtils.damp(ref.current.rotation.z, zRotation, 5, delta); } } function animateWalk(refs: NpcAnimationRefs, time: number, walkBlend: number, delta: number) { const stride = Math.sin(time * 7.2) * walkBlend; const counterStride = Math.sin(time * 7.2 + Math.PI) * walkBlend; const bob = Math.abs(stride) * 0.055; if (refs.characterRef.current) { refs.characterRef.current.position.y = bob; refs.characterRef.current.rotation.x = MathUtils.damp( refs.characterRef.current.rotation.x, 0, 8, delta, ); refs.characterRef.current.rotation.z = Math.sin(time * 2.1) * 0.035 * walkBlend; } if (refs.torsoRef.current) { refs.torsoRef.current.rotation.x = Math.sin(time * 7.2) * 0.035 * walkBlend; } if (refs.headRef.current) { refs.headRef.current.rotation.y = Math.sin(time * 8.2) * 0.025 * walkBlend; refs.headRef.current.rotation.z = Math.sin(time * 3.1) * 0.018 * walkBlend; } setWalkLimb(refs.leftArmRef, counterStride, -0.16, 0.62, walkBlend); setWalkLimb(refs.rightArmRef, stride, 0.16, 0.62, walkBlend); setWalkLimb(refs.leftLegRef, stride, 0, 0.48, walkBlend); setWalkLimb(refs.rightLegRef, counterStride, 0, 0.48, walkBlend); } function animateAttack(refs: NpcAnimationRefs, time: number, attackBlend: number) { if (attackBlend <= 0.001) { return; } // Overhead chop with the right arm: raised (~-2.2) down to extended (~-0.7). const swing = (Math.sin(time * 10.5) + 1) / 2; const chop = -2.2 + swing * 1.5; if (refs.rightArmRef.current) { refs.rightArmRef.current.rotation.x = MathUtils.lerp( refs.rightArmRef.current.rotation.x, chop, attackBlend, ); refs.rightArmRef.current.rotation.z = MathUtils.lerp( refs.rightArmRef.current.rotation.z, 0.18, attackBlend, ); } if (refs.leftArmRef.current) { refs.leftArmRef.current.rotation.x = MathUtils.lerp( refs.leftArmRef.current.rotation.x, -0.55, attackBlend, ); refs.leftArmRef.current.rotation.z = MathUtils.lerp( refs.leftArmRef.current.rotation.z, -0.3, attackBlend, ); } if (refs.torsoRef.current) { refs.torsoRef.current.rotation.x = MathUtils.lerp( refs.torsoRef.current.rotation.x, 0.12 - swing * 0.18, attackBlend, ); // Walk never writes these channels, so scale by blend to decay back to rest. refs.torsoRef.current.rotation.y = (-0.16 + swing * 0.28) * attackBlend; } if (refs.headRef.current) { refs.headRef.current.rotation.x = -0.08 * attackBlend; } } function setWalkLimb( ref: RefObject, stride: number, zRotation: number, scale: number, walkBlend: number, ) { if (!ref.current) { return; } ref.current.rotation.x = stride * scale; ref.current.rotation.z = zRotation * walkBlend; }