updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen

This commit is contained in:
phenom committed 2026-08-16 11:16:51 -04:00
1 parent b5c9bce1aa
commit c00f3211ea
39 files changed
+2037 -208

No files matched your search

+268 -77
View File
@@ -31,14 +31,21 @@ import {
createPartyBreadcrumbCursor,
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
type PartyBreadcrumbCursor,
type PartyMovementProfile,
} from "../game/partyMovement";
import {
buildPartyNavigationGraph,
@@ -82,13 +89,7 @@ const PARTY_MAX_FLOOR_DELTA = 0.55;
const PARTY_MAX_GROUND_CORRECTION = 0.85;
const EMPTY_NAVIGATION_LINKS = [] as const;
/** Lateral and trailing offsets behind the current squad anchor. */
const PARTY_FORMATION: readonly (readonly [number, number])[] = [
[0, 1.45],
[0, 2.6],
[0, 3.75],
[0, 4.9],
];
const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9];
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
@@ -106,6 +107,11 @@ interface PartyStuckProgress {
lastProgressAt: number;
}
interface PartyCombatAnchor {
readonly targetId: string;
readonly bearing: number;
}
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
@@ -418,6 +424,9 @@ function PartyActors({
const lastCommandRef = useRef(usePartyStore.getState().command);
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const memberIdKey = members.map((member) => member.id).join("|");
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
@@ -448,6 +457,8 @@ function PartyActors({
trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
lastActiveTargetIdRef.current = party.activeMobId;
lastLeaderWaypointRef.current = null;
@@ -473,6 +484,17 @@ function PartyActors({
for (const id of stuckProgressRef.current.keys()) {
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
}
for (const id of directFollowMovingRef.current.keys()) {
if (!activeIds.has(id)) directFollowMovingRef.current.delete(id);
}
for (const id of combatAnchorsRef.current.keys()) {
if (!activeIds.has(id)) combatAnchorsRef.current.delete(id);
}
for (const key of movementProfilesRef.current.keys()) {
if (![...activeIds].some((id) => key.startsWith(`${id}|`))) {
movementProfilesRef.current.delete(key);
}
}
const cleanups = members.map((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
positionsRef.current.set(member.id, position);
@@ -534,6 +556,8 @@ function PartyActors({
trailLeaderIdRef.current = firstLiving?.id ?? null;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
lastActiveTargetIdRef.current = null;
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
@@ -561,6 +585,8 @@ function PartyActors({
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
followerCursorsRef.current.clear();
stuckProgressRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
}, [navigationGraph]);
// Time spent in a pause/menu must not count toward the stuck watchdog.
@@ -575,24 +601,39 @@ function PartyActors({
const party = usePartyStore.getState();
if (!party.members.length) return;
const game = useGameStore.getState();
const player = game.playerPosition;
appendPartyBreadcrumb(playerTrailRef.current, player);
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const playerTrail = playerTrailRef.current.points;
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
let playerForwardX = Math.sin(game.cameraYaw);
let playerForwardZ = Math.cos(game.cameraYaw);
if (latestPlayerTrailPoint && previousPlayerTrailPoint) {
const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0];
const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2];
if (Math.hypot(trailX, trailZ) > 0.05) {
playerForwardX = trailX;
playerForwardZ = trailZ;
}
}
if (lastRecallRevisionRef.current !== party.recallRevision) {
lastRecallRevisionRef.current = party.recallRevision;
teleportPartyToPlayer(player, routeNow);
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
if (lastCommandRef.current !== party.command) {
lastCommandRef.current = party.command;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
const livingLeader = party.members.find((member) => member.health > 0);
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
resetPartyBreadcrumbTrail(
leaderTrailRef.current,
livingLeaderPosition ?? player,
livingLeaderPosition ?? playerNavigation,
);
lastLeaderWaypointRef.current = null;
}
@@ -602,9 +643,29 @@ function PartyActors({
const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`;
let profile = movementProfilesRef.current.get(key);
if (!profile) {
profile = partyMovementProfile(memberId, livingRank);
movementProfilesRef.current.set(key, profile);
}
return profile;
};
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false;
let leaderMovementExpected = false;
let leaderFollowingPlayer = false;
@@ -623,7 +684,7 @@ function PartyActors({
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
followerCursorsRef.current.clear();
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? player);
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation);
lastLeaderWaypointRef.current = null;
}
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
@@ -631,6 +692,7 @@ function PartyActors({
trailLeaderIdRef.current = leaderMember.id;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
combatAnchorsRef.current.clear();
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
}
@@ -640,18 +702,32 @@ function PartyActors({
let goalKey: string;
let hasDestination = true;
if (targetPosition && leaderTargetId) {
const rangeBand = partyCombatRangeBand(leader);
const actor = actorRefs.current[leaderIndex];
const movementIntent = partyCombatMovementGoal(
const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = {
targetId: leaderTargetId,
bearing: Math.atan2(
leaderPosition[0] - targetPosition[0],
leaderPosition[2] - targetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(leader.id, combatAnchor);
}
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition,
targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
rangeBand,
combatAnchor.bearing,
crowded,
leaderDestination,
actor ? Math.sin(actor.rotation.y) : 0,
actor ? Math.cos(actor.rotation.y) : 1,
);
if (movementIntent === "hold") {
hasDestination = false;
@@ -677,10 +753,9 @@ function PartyActors({
goalKey = `leader:${leader.id}|objective:${objective.id}`;
} else {
leaderFollowingPlayer = true;
leaderDestination[0] = player[0];
leaderDestination[1] = player[1];
leaderDestination[2] = player[2];
if (fullDistance(leaderPosition, player) <= PARTY_FORMATION[0][1]) hasDestination = false;
leaderDestination[0] = playerNavigation[0];
leaderDestination[1] = playerNavigation[1];
leaderDestination[2] = playerNavigation[2];
goalKey = `leader:${leader.id}|player`;
}
leaderMovementExpected = hasDestination
@@ -689,28 +764,68 @@ function PartyActors({
if (leaderFollowingPlayer) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const cursor = leaderPlayerCursorRef.current;
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
PARTY_FORMATION[0][1],
BREADCRUMB_REACHED_DISTANCE,
desired,
const profile = movementProfileFor(leader.id, leaderIndex);
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
const personalizedTrailingDistance = Math.max(
0.9,
baseTrailingDistance + profile.followDistanceJitter,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
baseTrailingDistance,
profile,
routeNow,
leaderDestination,
);
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition,
leaderDestination,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(leader.id, leaderHasWaypoint);
synchronizePartyBreadcrumbCursor(
playerTrailRef.current,
cursor,
personalizedTrailingDistance,
);
if (leaderHasWaypoint) {
desired[0] = leaderDestination[0];
desired[1] = leaderDestination[1];
desired[2] = leaderDestination[2];
}
leaderMovementExpected = leaderHasWaypoint;
} else {
directFollowMovingRef.current.set(leader.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
personalizedTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, player) > PARTY_FORMATION[0][1] + 0.75;
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if (
routeRef.current.goalKey !== goalKey
@@ -860,17 +975,32 @@ function PartyActors({
}
} else {
if (memberTargetPosition && memberTargetId) {
const rangeBand = partyCombatRangeBand(member);
const movementIntent = partyCombatMovementGoal(
const profile = movementProfileFor(member.id, index);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(member),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(member.id);
if (!combatAnchor || combatAnchor.targetId !== memberTargetId) {
combatAnchor = {
targetId: memberTargetId,
bearing: Math.atan2(
current[0] - memberTargetPosition[0],
current[2] - memberTargetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(member.id, combatAnchor);
}
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
current,
memberTargetPosition,
canSeeActiveTarget,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
rangeBand,
combatAnchor.bearing,
crowded,
desired,
Math.sin(actor.rotation.y),
Math.cos(actor.rotation.y),
);
movementExpected = movementIntent !== "hold";
if (movementIntent === "hold") {
@@ -933,30 +1063,71 @@ function PartyActors({
cursor = createPartyBreadcrumbCursor();
followerCursorsRef.current.set(member.id, cursor);
}
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
const livingRank = Math.max(1, livingIndices.indexOf(index));
const trailingDistance = PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15;
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
trailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
const profile = movementProfileFor(member.id, index);
const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank]
?? 1.45 + livingRank * 1.15;
const leaderTrailingDistance = Math.max(
0.9,
(PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15)
+ profile.followDistanceJitter,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
playerTrailingDistance,
profile,
routeNow,
naturalFollowTarget,
);
if (directSegmentAllowed(current, naturalFollowTarget)) {
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget(
current,
naturalFollowTarget,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(member.id, hasDesired);
synchronizePartyBreadcrumbCursor(
leaderTrailRef.current,
cursor,
leaderTrailingDistance,
);
if (hasDesired) {
desired[0] = naturalFollowTarget[0];
desired[1] = naturalFollowTarget[1];
desired[2] = naturalFollowTarget[2];
}
movementExpected = hasDesired;
} else {
directFollowMovingRef.current.set(member.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
leaderTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? player) > trailingDistance + 0.75;
}
if (memberTargetPosition) {
faceX = memberTargetPosition[0] - current[0];
@@ -967,9 +1138,29 @@ function PartyActors({
}
}
const currentPlanarDistance = planarDistance(current, player);
const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && party.command !== "stop" && hasDesired) {
const speed = currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED;
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
* movementProfile.speedMultiplier;
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
// Route progress, not radial player distance, is authoritative. A
// valid path may need to move away briefly around a U-bend.
@@ -1015,7 +1206,7 @@ function PartyActors({
stuckProgress.lastProgressAt = routeNow;
} else if (partyShouldRecallForStuck(
current,
player,
playerNavigation,
movementExpected,
routeNow,
stuckProgress.lastProgressAt,
@@ -1042,7 +1233,7 @@ function PartyActors({
if (autoRecallRequested) {
party.recallParty();
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
teleportPartyToPlayer(player, routeNow);
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
party.setRouteBlocked(frameRouteBlocked);
+90 -9
View File
@@ -13,7 +13,12 @@ import { useShellStore } from "../app/shellStore";
import type { CharacterProfile } from "../app/types";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
import {
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
readInputSnapshot,
} from "../game/inputManager";
import { useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import {
@@ -33,6 +38,18 @@ import {
isBelowDungeonRecoveryPlane,
isBelowManastormRecoveryPlane,
} from "../game/playerRecovery";
import {
PLAYER_GROUND_PROBE_DISTANCE,
PLAYER_JUMP_VELOCITY,
PLAYER_LANDING_PRESENTATION_MS,
bufferPlayerJump,
cancelBufferedPlayerJump,
characterVerticalMotion,
createPlayerJumpTiming,
isWalkableGroundHit,
updatePlayerJumpTiming,
type CharacterVerticalMotion,
} from "../game/playerJump";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
@@ -62,6 +79,7 @@ interface PlayerControllerProps extends PlayerRigRefs {
identity: CharacterProfile | null;
equipmentItems: readonly InventoryItem[];
movingRef: React.MutableRefObject<boolean>;
verticalMotionRef: React.MutableRefObject<CharacterVerticalMotion>;
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
}
@@ -85,6 +103,7 @@ function PlayerController({
identity,
equipmentItems,
movingRef,
verticalMotionRef,
animationEventRef,
}: PlayerControllerProps) {
const paused = useGameStore((state) => state.paused);
@@ -98,11 +117,24 @@ function PlayerController({
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 jumpTimingRef = useRef(createPlayerJumpTiming());
const wasGroundedRef = useRef(true);
const landingUntilRef = useRef(Number.NEGATIVE_INFINITY);
useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
lastReported.current = [...spawn.footPosition];
}, [bodyRef, orbitRef, resetRevision, spawn]);
clearJumpRequest();
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
wasGroundedRef.current = true;
landingUntilRef.current = Number.NEGATIVE_INFINITY;
verticalMotionRef.current = "grounded";
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
useFrame((_, delta) => {
const body = bodyRef.current;
@@ -131,18 +163,68 @@ function PlayerController({
}
const velocity = body.linvel();
if (paused || mapOpen || companionOpen || controlledUntil > Date.now()) {
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,
);
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
const requestedAtMs = consumeJumpRequest();
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false;
verticalMotionRef.current = characterVerticalMotion(
grounded,
velocity.y,
nowMs,
landingUntilRef.current,
);
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
nowMs,
grounded,
velocity.y,
);
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
if (shouldJump) grounded = false;
if (grounded && !wasGroundedRef.current) {
landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS;
}
wasGroundedRef.current = grounded;
verticalMotionRef.current = characterVerticalMotion(
grounded,
verticalVelocity,
nowMs,
landingUntilRef.current,
);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
);
body.setLinvel({ x: worldX * MOVE_SPEED, y: velocity.y, z: worldZ * MOVE_SPEED }, true);
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
const moving = Math.hypot(worldX, worldZ) > 0.05;
movingRef.current = moving;
@@ -152,11 +234,6 @@ function PlayerController({
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
}
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
return;
}
const activeManastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
@@ -185,6 +262,7 @@ function PlayerController({
position: footPosition,
yaw: orbit.yaw,
distanceDelta,
grounded,
});
lastReported.current = footPosition;
}
@@ -214,6 +292,7 @@ function PlayerController({
equipment={equipmentItems}
active={!paused && !mapOpen && !companionOpen}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
/>
@@ -331,6 +410,7 @@ export function PlayerRig() {
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);
@@ -349,6 +429,7 @@ export function PlayerRig() {
identity={activeCharacter}
equipmentItems={equipmentItems}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
/>
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />