Release Healer Man 0.1.4

This commit is contained in:
phenom
2026-08-18 12:06:04 -04:00
parent 5bb170039a
commit f4c9cf356c
63 changed files with 2694 additions and 81 deletions
+310 -27
View File
@@ -153,6 +153,16 @@ import {
type ThreatSource,
type ThreatTable,
} from "./aggro";
import { distanceSquared, planarDistanceSquared } from "./mobAi";
import {
emitCombatPresentation,
presentationDeliveryForAbility,
presentationSchoolForAbility,
presentationSourceForAbility,
presentationSourceForSpellId,
resetCombatPresentationSession,
type CombatPresentationPhase,
} from "./combatPresentation";
/** Stable target id used by named player auras and HUD consumers. */
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
@@ -166,6 +176,7 @@ export type MinimapRotation = "north-up" | "player-up";
export interface GameplaySettings {
readonly showMobHealthBars: boolean;
readonly showMobAggroRanges: boolean;
readonly showTargetFrame: boolean;
readonly showThreatMeter: boolean;
readonly threatMeterPosition: NormalizedHudPosition;
@@ -179,6 +190,7 @@ export interface GameplaySettings {
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showMobHealthBars: true,
showMobAggroRanges: false,
showTargetFrame: true,
showThreatMeter: false,
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
@@ -196,6 +208,9 @@ export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
export const DEFAULT_MOB_AGGRO_RANGE = 16;
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
export interface MobStatus {
readonly kind: MobStatusKind;
@@ -276,6 +291,7 @@ export interface MobCombatState {
readonly attackRange: number;
readonly attackDamage: number;
readonly swingMs: number;
readonly aggroRange: number;
readonly leashRange: number;
readonly moveSpeed: number;
readonly attackRevision: number;
@@ -397,6 +413,7 @@ export interface MobRegistration {
readonly damageMultiplier?: number;
readonly bonusLootChance?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
readonly attacks?: readonly EnemyAttackDefinition[];
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
@@ -504,6 +521,7 @@ export interface CombatState {
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
healPlayer: (amount: number) => number;
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void;
@@ -576,6 +594,85 @@ function nextAnimationEvent(
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
}
function emitPlayerAbilityPresentation(
ability: AbilityDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
...(targetId ? { targetActorId: targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
});
}
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
const ability = abilityById(abilityId);
if (!ability) return;
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
}
function emitPlayerAbilityResolutionPresentation(
ability: AbilityDefinition,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
occurredAt,
origin,
targetId,
targetPosition,
);
}
function emitEnemyAttackPresentation(
mob: MobCombatState,
attack: EnemyAttackDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetActorId: AggroActorId | null,
targetPosition: CombatPosition | null,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "enemy",
sourceActorId: mob.id,
...(targetActorId ? { targetActorId } : {}),
abilityId: attack.id,
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
name: attack.name,
source: presentationSourceForSpellId(attack.spellId),
school: attack.school,
delivery: attack.delivery,
origin,
targetPosition: targetPosition ?? origin,
...(attack.radius === undefined ? {} : { radius: attack.radius }),
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
});
}
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
}
@@ -721,10 +818,7 @@ function eligibleAggroActorIds(
const withinLeash = (position: CombatPosition | null) => {
if (!position) return false;
if (!home) return true;
const dx = position[0] - home[0];
const dy = position[1] - home[1];
const dz = position[2] - home[2];
return dx * dx + dy * dy + dz * dz <= mob.leashRange * mob.leashRange;
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
};
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
for (const member of usePartyStore.getState().members) {
@@ -1266,6 +1360,7 @@ function normalizedSettings(
): GameplaySettings {
return {
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
threatMeterPosition: normalizeHudPosition(
@@ -1444,6 +1539,7 @@ function summonedMobCombatState(
attackRange: attack.range,
attackDamage: stats.attackDamage,
swingMs: stats.swingMs,
aggroRange: 0,
leashRange: summoner.leashRange,
moveSpeed: summoner.moveSpeed,
attackRevision: 0,
@@ -1749,34 +1845,76 @@ function mobGroupId(id: string): string {
return separator > 0 ? id.slice(0, separator) : id;
}
function engageMobGroup(
mobs: Record<string, MobCombatState>,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): boolean {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(id))
: (getMobPosition(id) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
changed = true;
}
return changed;
}
function engageMobGroupInWork(
work: MutableCastWork,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): void {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(work.mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
for (const [id, mob] of Object.entries(work.mobs)) {
if (mob.dead || mobGroupId(id) !== groupId) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
work.mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: mob.homePosition ?? getMobPosition(id),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
}
function nearestAggroActorId(
state: Pick<CombatState, "health" | "playerPosition">,
mobPosition: CombatPosition,
aggroRange: number,
): AggroActorId | null {
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
let nearestId: AggroActorId | null = null;
let nearestDistanceSquared = aggroRange * aggroRange;
if (state.health > 0) {
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
if (playerDistanceSquared <= nearestDistanceSquared) {
nearestId = PLAYER_AGGRO_ID;
nearestDistanceSquared = playerDistanceSquared;
}
}
for (const member of usePartyStore.getState().members) {
if (member.health <= 0) continue;
const position = getPartyRuntimePosition(member.id);
if (!position) continue;
const memberDistanceSquared = distanceSquared(mobPosition, position);
if (memberDistanceSquared < nearestDistanceSquared) {
nearestId = member.id;
nearestDistanceSquared = memberDistanceSquared;
}
}
return nearestId;
}
function mobResistanceForSchool(
@@ -1893,7 +2031,7 @@ function damageMobInWork(
forcedTarget: killed ? null : engagedMob.forcedTarget,
engaged: !killed,
combatPhase: killed ? "idle" : "chasing",
homePosition: mob.homePosition ?? getMobPosition(id),
homePosition: engagedMob.homePosition ?? getMobPosition(id),
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
activeCast: killed ? null : engagedMob.activeCast,
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
@@ -2266,6 +2404,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
...initialState(),
initializeCharacter: (profile) => {
resetCombatPresentationSession();
const classId = profile.classId;
const secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human";
@@ -2371,6 +2510,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackRange + 1,
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
);
const aggroRange = Math.max(
0,
Math.min(
leashRange,
registration.aggroRange
?? existing?.aggroRange
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
),
);
const moveSpeed = Math.max(
0,
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
@@ -2403,6 +2551,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attacks,
attackRange,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackDamage,
@@ -2454,6 +2603,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackRange,
attackDamage,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackRevision: 0,
@@ -2690,6 +2840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const movedWhileCasting = Boolean(cast && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
));
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
return {
playerPosition: next,
activeCast: movedWhileCasting ? null : cast,
@@ -2814,6 +2965,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? { playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
: {}),
});
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
return emptyCastResult(reason, abilityId);
}
@@ -2885,6 +3037,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{ abilityId: ability.id, targetId: targetId ?? undefined },
),
});
emitPlayerAbilityPresentation(
ability,
"cast-start",
now,
playerOrigin,
targetId,
targetPosition,
);
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
}
@@ -3452,6 +3612,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
),
});
emitPlayerAbilityResolutionPresentation(
ability,
now,
playerOrigin,
targetId,
targetPosition,
);
return {
ok: true,
@@ -3611,7 +3778,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: mob.forcedTarget,
engaged: true,
combatPhase: "chasing",
homePosition: mob.homePosition ?? getMobPosition(mobId),
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(mobId))
: (getMobPosition(mobId) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
@@ -3706,9 +3875,30 @@ export const useCombatStore = create<CombatState>((set, get) => ({
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
});
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
return true;
},
engageNearbyMobs: (now = Date.now(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
: state;
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let engagedGroups = 0;
for (const id of Object.keys(mobs)) {
const mob = mobs[id];
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
const position = getMobPosition(id);
if (!position) continue;
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
if (!actorId) continue;
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
}
if (engagedGroups > 0) set({ mobs });
return engagedGroups;
},
advanceMobCombat: (now = Date.now(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
@@ -3852,6 +4042,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackAnimation = "cast";
lastAttackId = attack.id;
nextAttackAt = enemyActiveCast.completesAt;
emitEnemyAttackPresentation(
mob,
attack,
"cast-start",
now,
position,
resolution.targetActorId,
targetPosition,
);
} else if (selectedAttack && !cannotAttack) {
const attack = selectedAttack!;
enemyActiveCast = null;
@@ -3881,6 +4080,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: attack.target === "primary"
? [resolution.targetActorId]
: [];
const presentationTargetId = targets[0] ?? resolution.targetActorId;
emitEnemyAttackPresentation(
mob,
attack,
"release",
now,
position,
presentationTargetId,
actorPosition(runtimeState, presentationTargetId),
);
if (effect.kind === "damage") {
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
for (const actorId of targets) {
@@ -4102,6 +4311,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
let appliedDamage = 0;
for (const hit of incomingHits) {
const impactOrigin = getMobPosition(hit.mob.id);
const impactTarget = actorPosition(get(), hit.actorId);
if (impactOrigin && impactTarget) {
emitEnemyAttackPresentation(
hit.mob,
hit.attack,
"impact",
now,
impactOrigin,
hit.actorId,
impactTarget,
);
}
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
@@ -4437,6 +4659,23 @@ export const useCombatStore = create<CombatState>((set, get) => ({
work.resourcePools = rage.resourcePools;
}
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
if (result.amount > 0) {
const targetPosition = getMobPosition(autoAttackTargetId!);
emitCombatPresentation({
occurredAt: now,
phase: "release",
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: autoAttackTargetId!,
abilityId: "player-auto-attack",
name: "Auto Attack",
source: "fallback",
school: autoAttackSchool,
delivery: "melee",
origin: state.playerPosition,
targetPosition: targetPosition ?? state.playerPosition,
});
}
nextAutoAttackAt = now + profile.intervalMs;
if (result.killed) {
autoAttackTargetId = null;
@@ -4530,6 +4769,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
amount,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}, nextTickAt);
if (ability) {
const sourceActorId = effect.sourceActorId;
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
const origin = sourceActorId === PLAYER_AGGRO_ID
? state.playerPosition
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
const targetPosition = effect.targetId
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
: state.playerPosition;
emitCombatPresentation({
occurredAt: nextTickAt,
phase: "tick",
actorGroup,
sourceActorId,
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
});
}
remainingTicks -= 1;
nextTickAt += effect.intervalMs;
}
@@ -4711,6 +4975,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
}
}
emitPlayerAbilityPresentation(
ability,
"tick",
nextTickAt,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
ticksCompleted += 1;
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
}
@@ -4724,6 +4996,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: cast.targetId ?? undefined,
}),
});
if (ability) {
emitPlayerAbilityPresentation(
ability,
"release",
now,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
}
} else if (ticksCompleted !== cast.ticksCompleted) {
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
}
@@ -4736,6 +5018,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
resetEncounter: () => set((state) => {
resetCombatPresentationSession();
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}