138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
import type {
|
|
CombatPresentationEvent,
|
|
CombatVfxQuality,
|
|
} from "../game/combatPresentation";
|
|
|
|
export interface ActiveCombatEffect {
|
|
readonly key: string;
|
|
readonly event: CombatPresentationEvent;
|
|
}
|
|
|
|
export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
|
off: 0,
|
|
low: 24,
|
|
high: 32,
|
|
};
|
|
|
|
export interface CombatEffectFrameState {
|
|
readonly elapsedMs: number;
|
|
readonly motionDeltaSeconds: number;
|
|
readonly progress: number;
|
|
readonly done: boolean;
|
|
}
|
|
|
|
export function advanceCombatEffectFrame(
|
|
elapsedMs: number,
|
|
rawDeltaSeconds: number,
|
|
durationMs: number,
|
|
): CombatEffectFrameState {
|
|
const safeElapsedMs = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0;
|
|
const deltaSeconds = Number.isFinite(rawDeltaSeconds) ? Math.max(0, rawDeltaSeconds) : 0;
|
|
const safeDurationMs = Number.isFinite(durationMs) ? Math.max(1, durationMs) : 1;
|
|
// Age follows wall time so a slow frame expires work instead of extending the
|
|
// overload. Motion remains bounded to avoid a single-frame visual jump.
|
|
const nextElapsedMs = safeElapsedMs + deltaSeconds * 1_000;
|
|
const progress = Math.min(1, nextElapsedMs / safeDurationMs);
|
|
return {
|
|
elapsedMs: nextElapsedMs,
|
|
motionDeltaSeconds: Math.min(0.05, deltaSeconds),
|
|
progress,
|
|
done: progress >= 1,
|
|
};
|
|
}
|
|
|
|
function actorPriority(event: CombatPresentationEvent): number {
|
|
const groupPriority = event.actorGroup === "player" ? 30 : event.actorGroup === "party" ? 20 : 10;
|
|
// Preserve readable combat beats before actor ownership. An enemy cast or
|
|
// impact is more useful than another periodic tick from any actor group.
|
|
const phasePriority = event.phase === "cast-start"
|
|
? 300
|
|
: event.phase === "release" || event.phase === "impact"
|
|
? 200
|
|
: event.phase === "tick"
|
|
? 0
|
|
: 100;
|
|
return groupPriority + phasePriority;
|
|
}
|
|
|
|
function areaBurstKey(event: CombatPresentationEvent): string | null {
|
|
if (event.delivery !== "area" || event.phase === "cast-start" || event.phase === "cast-cancel") {
|
|
return null;
|
|
}
|
|
return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}:${event.occurredAt}`;
|
|
}
|
|
|
|
function effectKey(event: CombatPresentationEvent): string {
|
|
return `${event.sessionId}:${event.id}`;
|
|
}
|
|
|
|
function trimCombatEffects(
|
|
effects: readonly ActiveCombatEffect[],
|
|
limit: number,
|
|
): readonly ActiveCombatEffect[] {
|
|
if (effects.length <= limit) return effects;
|
|
const next = [...effects];
|
|
while (next.length > limit) {
|
|
let removalIndex = 0;
|
|
let removalPriority = actorPriority(next[0].event);
|
|
for (let index = 1; index < next.length; index += 1) {
|
|
const priority = actorPriority(next[index].event);
|
|
if (priority < removalPriority) {
|
|
removalIndex = index;
|
|
removalPriority = priority;
|
|
}
|
|
}
|
|
next.splice(removalIndex, 1);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function capCombatEffects(
|
|
effects: readonly ActiveCombatEffect[],
|
|
quality: CombatVfxQuality,
|
|
): readonly ActiveCombatEffect[] {
|
|
const limit = COMBAT_EFFECT_SYSTEM_LIMIT[quality];
|
|
if (limit === 0) return effects.length === 0 ? effects : [];
|
|
return trimCombatEffects(effects, limit);
|
|
}
|
|
|
|
export function updateCombatEffectQueue(
|
|
current: readonly ActiveCombatEffect[],
|
|
event: CombatPresentationEvent,
|
|
quality: CombatVfxQuality,
|
|
): readonly ActiveCombatEffect[] {
|
|
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
|
|
? current.filter((effect) => !(
|
|
effect.event.phase === "cast-start"
|
|
&& effect.event.sourceActorId === event.sourceActorId
|
|
&& effect.event.abilityId === event.abilityId
|
|
))
|
|
: current;
|
|
if (quality === "off") return current.length === 0 ? current : [];
|
|
if (event.phase === "cast-cancel") {
|
|
return withoutCast.length === current.length ? current : withoutCast;
|
|
}
|
|
|
|
const burstKey = areaBurstKey(event);
|
|
if (burstKey) {
|
|
const existingIndex = withoutCast.findIndex((effect) => areaBurstKey(effect.event) === burstKey);
|
|
if (existingIndex >= 0) {
|
|
const existing = withoutCast[existingIndex];
|
|
if (existing.event.phase === event.phase) return withoutCast;
|
|
const next = [...withoutCast];
|
|
next[existingIndex] = { key: effectKey(event), event };
|
|
return capCombatEffects(next, quality);
|
|
}
|
|
}
|
|
|
|
const next = capCombatEffects(
|
|
[...withoutCast, { key: effectKey(event), event }],
|
|
quality,
|
|
);
|
|
if (
|
|
next.length === current.length
|
|
&& next.every((effect, index) => effect.key === current[index]?.key)
|
|
) return current;
|
|
return next;
|
|
}
|