Spaces:
Runtime error
Runtime error
| 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, | |
| ); | |
| } | |
| // 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<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; | |
| } | |
| // 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<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; | |
| } | |