| 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<Group | null>; |
| pulseRef: RefObject<Mesh | null>; |
| }; |
|
|
| 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<number>; |
| attackBlendRef: RefObject<number>; |
| }; |
|
|
| 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, |
| ); |
| } |
|
|
| |
| dampGroupToRest(refs.torsoRef, delta); |
| dampGroupToRest(refs.headRef, delta); |
|
|
| |
| |
| 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<Group | null>, 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<Group | null>, |
| 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; |
| } |
|
|
| |
| 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, |
| ); |
| |
| 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<Group | null>, |
| stride: number, |
| zRotation: number, |
| scale: number, |
| walkBlend: number, |
| ) { |
| if (!ref.current) { |
| return; |
| } |
| ref.current.rotation.x = stride * scale; |
| ref.current.rotation.z = zRotation * walkBlend; |
| } |
|
|