world-simulator / frontend /src /scene /useNpcActorAnimation.ts
DeltaZN
fix: death animation improvements
1203e8e
Raw
History Blame Contribute Delete
8.53 kB
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;
}