updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen
This commit is contained in:
1 parent
b5c9bce1aa
commit
c00f3211ea
39 files changed
+2037
-208
No files matched your search
+268
-77
@@ -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
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user