100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import type { MobCombatState } from "./combatStore";
|
|
import type { EnemyDamageSchool, EnemyTelegraphShape } from "./enemyAttacks";
|
|
|
|
export type EncounterTimelineKind = "casting" | "timer" | "health";
|
|
|
|
export interface EncounterTimelineRow {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly kind: EncounterTimelineKind;
|
|
readonly value: string;
|
|
readonly progress: number;
|
|
readonly sortAt: number;
|
|
readonly school: EnemyDamageSchool;
|
|
readonly telegraphShape?: EnemyTelegraphShape;
|
|
}
|
|
|
|
export interface EncounterTimelineModel {
|
|
readonly bossId: string;
|
|
readonly bossName: string;
|
|
readonly rows: readonly EncounterTimelineRow[];
|
|
}
|
|
|
|
function clampedProgress(value: number): number {
|
|
return Math.min(1, Math.max(0, value));
|
|
}
|
|
|
|
export function encounterTimeline(
|
|
mobs: Readonly<Record<string, MobCombatState>>,
|
|
now: number,
|
|
limit = 4,
|
|
): EncounterTimelineModel | null {
|
|
const boss = Object.values(mobs)
|
|
.filter((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned)
|
|
.sort((left, right) => (
|
|
Number(Boolean(right.activeCast)) - Number(Boolean(left.activeCast))
|
|
|| left.id.localeCompare(right.id)
|
|
))[0];
|
|
if (!boss) return null;
|
|
|
|
const healthPercent = (boss.health / Math.max(1, boss.maxHealth)) * 100;
|
|
const rows = boss.attacks.flatMap<EncounterTimelineRow>((attack) => {
|
|
if (attack.id === "basic-attack") return [];
|
|
const castCount = boss.abilityCastCounts[attack.id] ?? 0;
|
|
if (attack.maximumCasts && castCount >= attack.maximumCasts) return [];
|
|
const common = {
|
|
id: attack.id,
|
|
name: attack.name,
|
|
school: attack.school,
|
|
...(attack.telegraph ? { telegraphShape: attack.telegraph.shape } : {}),
|
|
};
|
|
if (boss.activeCast?.attackId === attack.id) {
|
|
const duration = Math.max(1, boss.activeCast.completesAt - boss.activeCast.startedAt);
|
|
const remaining = Math.max(0, boss.activeCast.completesAt - now);
|
|
return [{
|
|
...common,
|
|
kind: "casting",
|
|
value: `${(remaining / 1_000).toFixed(1)}s`,
|
|
progress: clampedProgress(remaining / duration),
|
|
sortAt: -1,
|
|
}];
|
|
}
|
|
if (attack.condition?.kind === "self-health-percent") {
|
|
const threshold = attack.condition.maximum;
|
|
return [{
|
|
...common,
|
|
kind: "health",
|
|
value: `${Math.round(threshold)}% HP`,
|
|
progress: clampedProgress(healthPercent / Math.max(1, threshold)),
|
|
sortAt: 1_000_000 + (100 - threshold) * 1_000,
|
|
}];
|
|
}
|
|
const readyAt = boss.abilityReadyAt[attack.id];
|
|
if (!Number.isFinite(readyAt)) return [];
|
|
const remaining = Math.max(0, (readyAt ?? now) - now);
|
|
const cooldown = Math.max(
|
|
1,
|
|
attack.repeatCooldownMs?.[1] ?? attack.initialCooldownMs?.[1] ?? attack.cooldownMs,
|
|
);
|
|
return [{
|
|
...common,
|
|
kind: "timer",
|
|
value: `${(remaining / 1_000).toFixed(1)}s`,
|
|
progress: clampedProgress(remaining / cooldown),
|
|
sortAt: remaining,
|
|
}];
|
|
}).sort((left, right) => left.sortAt - right.sortAt || left.id.localeCompare(right.id));
|
|
|
|
const alwaysVisibleHealthRows = rows.filter((row) => row.kind === "health");
|
|
const countdownRows = rows
|
|
.filter((row) => row.kind !== "health")
|
|
.slice(0, Math.max(1, limit));
|
|
const visibleRows = [...countdownRows, ...alwaysVisibleHealthRows];
|
|
|
|
return visibleRows.length ? {
|
|
bossId: boss.id,
|
|
bossName: boss.name,
|
|
rows: visibleRows,
|
|
} : null;
|
|
}
|