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

+221 -1
View File
@@ -19,7 +19,72 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null;
}
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
export interface PartyMovementProfile {
readonly followDistanceJitter: number;
readonly lateralOffset: number;
readonly comfortSlack: number;
readonly speedMultiplier: number;
readonly swayAmplitude: number;
readonly swayPeriodMs: number;
readonly swayPhase: number;
readonly combatAngleOffset: number;
readonly combatRangeBias: number;
}
export interface PartyCombatRangeLike {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
function stableHash(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(value: string): number {
return stableHash(value) / 0xffff_ffff;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
/** Stable per-character preferences keep motion reproducible without looking cloned. */
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
const prefix = `${memberId}|${rank}`;
const lateralMagnitude = rank === 0
? hashUnit(`${prefix}|leader-lateral`) * 0.3
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
const lateralSign = rank === 0
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
: rank % 2 === 1 ? -1 : 1;
const angleSlots = [0, -35, 35, -50, 50];
const angleDegrees = clamp(
(angleSlots[rank % angleSlots.length] ?? 0)
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
-50,
50,
);
return {
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
lateralOffset: lateralSign * lateralMagnitude,
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
combatAngleOffset: angleDegrees * Math.PI / 180,
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
};
}
export const PARTY_STUCK_RECALL_DISTANCE = 12;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
@@ -66,6 +131,25 @@ export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
return { breadcrumbId: null };
}
export function synchronizePartyBreadcrumbCursor(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
trailingDistance: number,
): void {
const latest = trail.points[trail.points.length - 1];
if (!latest) {
cursor.breadcrumbId = null;
return;
}
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
let target = trail.points[0];
for (const point of trail.points) {
if (point.distance > maximumDistance + EPSILON) break;
target = point;
}
cursor.breadcrumbId = target.id;
}
export function resetPartyBreadcrumbTrail(
trail: PartyBreadcrumbTrail,
position?: PartyWorldPosition,
@@ -271,6 +355,114 @@ export function partyFormationPosition(
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
}
export function partyNaturalFollowPosition(
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
baseTrailingDistance: number,
profile: PartyMovementProfile,
nowMs: number,
out: MutablePartyWorldPosition,
): void {
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
const sway = Math.sin(
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
) * profile.swayAmplitude;
partyFormationPosition(
anchor,
forwardX,
forwardZ,
profile.lateralOffset + sway,
trailing,
out,
);
}
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
export function partyShouldMoveToComfortTarget(
current: PartyWorldPosition,
target: PartyWorldPosition,
wasMoving: boolean,
comfortSlack: number,
): boolean {
const distance = Math.hypot(
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
);
if (!Number.isFinite(distance)) return false;
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
return distance > (wasMoving ? 0.18 : startDistance);
}
export function personalizedPartyCombatRangeBand(
base: PartyCombatRangeLike,
profile: PartyMovementProfile,
): PartyCombatRangeLike {
if (base.minimum > 0) {
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
return {
minimum,
preferred: clamp(
base.preferred + profile.combatRangeBias * 1.25,
minimum + 0.75,
maximum - 0.5,
),
maximum,
};
}
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
return {
minimum: 0,
preferred: clamp(
base.preferred + profile.combatRangeBias * 0.3,
2.35,
maximum - 0.12,
),
maximum,
};
}
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/**
* Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band.
@@ -323,3 +515,31 @@ export function partyCombatMovementGoal(
out[2] = target[2] + awayZ / planarLength * preferred;
return "retreat";
}
export function partyPersonalizedCombatMovementGoal(
current: PartyWorldPosition,
target: PartyWorldPosition,
targetVisible: boolean,
rangeBand: PartyCombatRangeLike,
preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition,
): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal(
current,
target,
targetVisible,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
out,
Math.sin(preferredBearing),
Math.cos(preferredBearing),
);
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent;
}