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);