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
+221
-1
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user