Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClassInventory } from "./healers";
|
||||
import { useGameStore } from "./store";
|
||||
import type { BossId } from "./types";
|
||||
|
||||
interface BattleResult {
|
||||
phase: ReturnType<typeof useGameStore.getState>["phase"];
|
||||
time: number;
|
||||
damageBySource: ReturnType<typeof useGameStore.getState>["partyCombat"]["combatants"];
|
||||
}
|
||||
|
||||
function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds = 200): BattleResult {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), bossIds);
|
||||
useGameStore.getState().startEncounter();
|
||||
|
||||
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
|
||||
useGameStore.setState((state) => ({
|
||||
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
|
||||
}));
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
|
||||
const state = useGameStore.getState();
|
||||
return { phase: state.phase, time: state.time, damageBySource: state.partyCombat.combatants };
|
||||
}
|
||||
|
||||
describe("full-mechanics dual-boss battle simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = [
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "cindermaw"],
|
||||
["vexa", "cindermaw"],
|
||||
];
|
||||
|
||||
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||
const result = simulateControlledBattle([first, second]);
|
||||
expect(result.phase).toBe("victory");
|
||||
expect(result.time).toBeGreaterThan(70);
|
||||
expect(result.time).toBeLessThan(110);
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId].damageDone).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { BossId } from "./types";
|
||||
|
||||
export interface BossDefinition {
|
||||
id: BossId;
|
||||
name: string;
|
||||
title: string;
|
||||
trial: string;
|
||||
icon: string;
|
||||
accent: string;
|
||||
summary: string;
|
||||
briefing: string;
|
||||
failure: string;
|
||||
mapTitle: string;
|
||||
mapCopy: string;
|
||||
mechanics: readonly [string, string];
|
||||
maxHp: number;
|
||||
}
|
||||
|
||||
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
|
||||
|
||||
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
bulldrome: {
|
||||
id: "bulldrome",
|
||||
name: "Bulldrome",
|
||||
title: "The Cinder Bull",
|
||||
trial: "Trial I · Healer Initiation",
|
||||
icon: "♜",
|
||||
accent: "#e2744e",
|
||||
summary: "Charges marked lanes and crushes grouped targets.",
|
||||
briefing: "Keep formation alive. Sidestep the charge lane, then stack tightly for the Bull's pounce.",
|
||||
failure: "Protect Brann and the healer. Purify Ember Brand before it burns through formation.",
|
||||
mapTitle: "Hall of the Bull",
|
||||
mapCopy: "Keep Brann between formation and Bull. Move clear when the charge lane turns red.",
|
||||
mechanics: ["Bull Charge", "Crushing Pounce"],
|
||||
maxHp: 500,
|
||||
},
|
||||
vexa: {
|
||||
id: "vexa",
|
||||
name: "Vexa",
|
||||
title: "The Webmother",
|
||||
trial: "Trial II · Tangled Remedy",
|
||||
icon: "✣",
|
||||
accent: "#b56cff",
|
||||
summary: "Binds allies together and weaponizes every cleanse.",
|
||||
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its pool poisons the formation.",
|
||||
failure: "Break purple tethers quickly. Cleanse venom only after its target reaches open ground.",
|
||||
mapTitle: "The Tangled Loom",
|
||||
mapCopy: "Spread tethered allies toward opposite edges. Keep dropped venom pools away from the center lane.",
|
||||
mechanics: ["Binding Web", "Venom Purge"],
|
||||
maxHp: 535,
|
||||
},
|
||||
cindermaw: {
|
||||
id: "cindermaw",
|
||||
name: "Cindermaw",
|
||||
title: "The Sky Tyrant",
|
||||
trial: "Trial III · Ashen Orbit",
|
||||
icon: "◆",
|
||||
accent: "#ff9b45",
|
||||
summary: "Sweeps the arena with flame and removes safe ground.",
|
||||
briefing: "Rotate behind Searing Sweep. During Skyfall, leave each numbered impact circle before it becomes persistent fire.",
|
||||
failure: "Follow the safe side of the breath cone. Keep moving as Skyfall removes sections of the arena.",
|
||||
mapTitle: "The Ashen Crown",
|
||||
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between Skyfall impacts.",
|
||||
mechanics: ["Searing Sweep", "Skyfall"],
|
||||
maxHp: 410,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,399 @@
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
|
||||
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
|
||||
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
export const BULL_CHARGE = {
|
||||
firstAt: 7,
|
||||
repeatDelay: 4,
|
||||
telegraphDuration: 1.8,
|
||||
distance: 13.5,
|
||||
speed: 10.5,
|
||||
hitRadius: 1.35,
|
||||
damage: 18,
|
||||
knockdownDuration: 0.75,
|
||||
aiClearance: 1.9,
|
||||
aiEvadeSpeed: 3.4,
|
||||
} as const;
|
||||
|
||||
export const BULL_POUNCE = {
|
||||
afterCharges: 3,
|
||||
stackDuration: 5,
|
||||
stackRadius: 2.2,
|
||||
sharedDamage: 300,
|
||||
leapDuration: 0.55,
|
||||
} as const;
|
||||
|
||||
export const BOSS_PERIODIC_MECHANICS = {
|
||||
melee: { firstAt: 2, interval: 2.5, damage: 15 },
|
||||
nova: { firstAt: 9, interval: 12, damage: 13 },
|
||||
brand: { firstAt: 5, interval: 9, duration: 7, tickDamage: 6 },
|
||||
} as const;
|
||||
|
||||
const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
|
||||
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
|
||||
|
||||
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
if (bossId === "vexa") return createVexaState();
|
||||
if (bossId === "cindermaw") return createCindermawState();
|
||||
const definition = BOSS_DEFINITIONS.bulldrome;
|
||||
return {
|
||||
id: "bulldrome",
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
|
||||
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
|
||||
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||
if (bossId === "vexa") return createVexaMotion();
|
||||
if (bossId === "cindermaw") return createCindermawMotion();
|
||||
return {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "holding",
|
||||
position: [0, -8.2],
|
||||
chargeStart: [0, -8.2],
|
||||
chargeEnd: [0, 5.5],
|
||||
chargeTargetId: "nia",
|
||||
chargeHitIds: [],
|
||||
phaseEndsAt: 0,
|
||||
nextChargeAt: BULL_CHARGE.firstAt,
|
||||
chargeCount: 0,
|
||||
chargesSincePounce: 0,
|
||||
pounceTargetId: "aelia",
|
||||
pounceCenter: [0, 4.5],
|
||||
pounceCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
|
||||
const dx = target[0] - start[0];
|
||||
const dz = target[1] - start[1];
|
||||
const length = Math.max(0.001, Math.hypot(dx, dz));
|
||||
return [
|
||||
Math.max(-7.3, Math.min(7.3, start[0] + (dx / length) * BULL_CHARGE.distance)),
|
||||
Math.max(-8.8, Math.min(7.1, start[1] + (dz / length) * BULL_CHARGE.distance)),
|
||||
];
|
||||
}
|
||||
|
||||
function livingMember(party: PartyMember[], memberId: MemberId) {
|
||||
return party.find((member) => member.id === memberId && member.hp > 0);
|
||||
}
|
||||
|
||||
function chooseTarget(party: PartyMember[], order: readonly MemberId[], startIndex: number): MemberId {
|
||||
for (let offset = 0; offset < order.length; offset += 1) {
|
||||
const candidate = order[(startIndex + offset) % order.length];
|
||||
if (livingMember(party, candidate)) return candidate;
|
||||
}
|
||||
return order[0];
|
||||
}
|
||||
|
||||
function advanceMotionMechanics(
|
||||
source: BossMotionState,
|
||||
party: PartyMember[],
|
||||
partyPositions: Record<MemberId, WorldPosition>,
|
||||
time: number,
|
||||
delta: number,
|
||||
damageMember: BossMechanicContext["damageMember"],
|
||||
events: BossMechanicEvent[],
|
||||
) {
|
||||
let motion: BossMotionState = {
|
||||
...source,
|
||||
position: [source.position[0], source.position[1]],
|
||||
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
|
||||
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
|
||||
chargeHitIds: [...source.chargeHitIds],
|
||||
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
|
||||
};
|
||||
let updatedParty = party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * delta);
|
||||
if (time >= motion.nextChargeAt) {
|
||||
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
|
||||
const target = partyPositions[targetId];
|
||||
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "telegraph",
|
||||
chargeStart: [motion.position[0], motion.position[1]],
|
||||
chargeEnd: chargeEndpoint(motion.position, target),
|
||||
chargeTargetId: targetId,
|
||||
chargeHitIds: [],
|
||||
phaseEndsAt: time + BULL_CHARGE.telegraphDuration,
|
||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
||||
chargeCount: motion.chargeCount + 1,
|
||||
chargesSincePounce: motion.chargesSincePounce + 1,
|
||||
};
|
||||
events.push({
|
||||
at: time,
|
||||
message: `Bulldrome lines up a charge on ${targetName}.`,
|
||||
tone: "danger",
|
||||
pulseKind: "charge",
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
} else if (motion.mode === "telegraph" && time >= motion.phaseEndsAt) {
|
||||
const chargeDuration = distance(motion.chargeStart, motion.chargeEnd) / BULL_CHARGE.speed;
|
||||
motion = { ...motion, mode: "charging", phaseEndsAt: time + chargeDuration };
|
||||
events.push({ at: time, message: "Bulldrome charges! Clear the marked lane.", tone: "danger" });
|
||||
} else if (motion.mode === "charging") {
|
||||
const previousPosition: WorldPosition = [motion.position[0], motion.position[1]];
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, BULL_CHARGE.speed * delta);
|
||||
updatedParty = updatedParty.map((member) => {
|
||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
|
||||
if (pointToSegmentDistance(partyPositions[member.id], previousPosition, motion.position) > BULL_CHARGE.hitRadius) {
|
||||
return member;
|
||||
}
|
||||
motion.chargeHitIds = [...motion.chargeHitIds, member.id];
|
||||
events.push({
|
||||
at: time,
|
||||
message: `${member.name} is knocked down by the charge.`,
|
||||
tone: "danger",
|
||||
pulseKind: "charge",
|
||||
targetId: member.id,
|
||||
});
|
||||
return {
|
||||
...damageMember(member, BULL_CHARGE.damage, partyPositions[member.id], time),
|
||||
knockedUntil: time + BULL_CHARGE.knockdownDuration,
|
||||
};
|
||||
});
|
||||
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
|
||||
}
|
||||
} else if (motion.mode === "returning") {
|
||||
const tank = partyPositions.brann;
|
||||
const returnPoint: WorldPosition = [tank[0] + motion.formationOffsetX, tank[1] - 4.25];
|
||||
motion.position = moveToward(motion.position, returnPoint, 4.4 * delta);
|
||||
if (distance(motion.position, returnPoint) < 0.12) {
|
||||
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
|
||||
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
|
||||
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
|
||||
motion = {
|
||||
...motion,
|
||||
position: returnPoint,
|
||||
mode: "stacking",
|
||||
phaseEndsAt: time + BULL_POUNCE.stackDuration,
|
||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
||||
chargesSincePounce: 0,
|
||||
pounceTargetId: targetId,
|
||||
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]],
|
||||
pounceCount: motion.pounceCount + 1,
|
||||
};
|
||||
events.push({
|
||||
at: time,
|
||||
message: `Bulldrome marks ${targetName}. Stack inside the circle!`,
|
||||
tone: "danger",
|
||||
pulseKind: "pounce",
|
||||
targetId,
|
||||
});
|
||||
} else {
|
||||
motion = {
|
||||
...motion,
|
||||
position: returnPoint,
|
||||
mode: "holding",
|
||||
nextChargeAt: time + BULL_CHARGE.repeatDelay,
|
||||
};
|
||||
events.push({ at: time, message: "Bulldrome returns to Brann and paws at the stone." });
|
||||
}
|
||||
}
|
||||
} else if (motion.mode === "stacking") {
|
||||
motion.pounceCenter = [partyPositions[motion.pounceTargetId][0], partyPositions[motion.pounceTargetId][1]];
|
||||
if (time >= motion.phaseEndsAt) {
|
||||
const targetName = updatedParty.find((member) => member.id === motion.pounceTargetId)?.name ?? motion.pounceTargetId;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "pouncing",
|
||||
chargeStart: [motion.position[0], motion.position[1]],
|
||||
chargeEnd: [motion.pounceCenter[0], motion.pounceCenter[1]],
|
||||
phaseEndsAt: time + BULL_POUNCE.leapDuration,
|
||||
};
|
||||
events.push({ at: time, message: `Bulldrome leaps at ${targetName}!`, tone: "danger" });
|
||||
}
|
||||
} else if (motion.mode === "pouncing") {
|
||||
const leapDistance = distance(motion.chargeStart, motion.chargeEnd);
|
||||
const leapSpeed = Math.max(12, leapDistance / BULL_POUNCE.leapDuration);
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, leapSpeed * delta);
|
||||
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
|
||||
const stackedIds = updatedParty
|
||||
.filter((member) => member.hp > 0 && distance(partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius)
|
||||
.map((member) => member.id);
|
||||
const sharedDamage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
|
||||
updatedParty = updatedParty.map((member) => stackedIds.includes(member.id)
|
||||
? damageMember(member, sharedDamage, partyPositions[member.id], time)
|
||||
: member);
|
||||
motion = {
|
||||
...motion,
|
||||
position: [motion.chargeEnd[0], motion.chargeEnd[1]],
|
||||
mode: "returning",
|
||||
phaseEndsAt: 0,
|
||||
};
|
||||
events.push({
|
||||
at: time,
|
||||
message: `Bulldrome pounces for ${Math.round(sharedDamage)} damage across ${stackedIds.length} stacked allies.`,
|
||||
tone: "danger",
|
||||
pulseKind: "pounce",
|
||||
targetId: motion.pounceTargetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { motion, party: updatedParty };
|
||||
}
|
||||
|
||||
function resolvePeriodicMechanics(
|
||||
boss: BossState,
|
||||
motion: BossMotionState,
|
||||
party: PartyMember[],
|
||||
partyPositions: Record<MemberId, WorldPosition>,
|
||||
time: number,
|
||||
damageMember: BossMechanicContext["damageMember"],
|
||||
events: BossMechanicEvent[],
|
||||
) {
|
||||
let updatedParty = party;
|
||||
|
||||
while (boss.nextMeleeAt <= time) {
|
||||
if (motion.mode === "holding") {
|
||||
const tankIndex = updatedParty.findIndex((member) => member.id === "brann");
|
||||
updatedParty[tankIndex] = damageMember(
|
||||
updatedParty[tankIndex],
|
||||
BOSS_PERIODIC_MECHANICS.melee.damage,
|
||||
partyPositions.brann,
|
||||
boss.nextMeleeAt,
|
||||
);
|
||||
}
|
||||
boss.nextMeleeAt += BOSS_PERIODIC_MECHANICS.melee.interval;
|
||||
}
|
||||
|
||||
while (boss.nextNovaAt <= time) {
|
||||
updatedParty = updatedParty.map((member) => damageMember(
|
||||
member,
|
||||
BOSS_PERIODIC_MECHANICS.nova.damage,
|
||||
partyPositions[member.id],
|
||||
boss.nextNovaAt,
|
||||
));
|
||||
events.push({
|
||||
at: boss.nextNovaAt,
|
||||
message: "Cinder Nova strikes the party.",
|
||||
tone: "danger",
|
||||
pulseKind: "boss",
|
||||
});
|
||||
boss.nextNovaAt += BOSS_PERIODIC_MECHANICS.nova.interval;
|
||||
}
|
||||
|
||||
while (boss.nextBrandAt <= time) {
|
||||
const targetId = CHARGE_TARGET_ORDER[boss.brandCount % CHARGE_TARGET_ORDER.length];
|
||||
const targetIndex = updatedParty.findIndex((member) => member.id === targetId);
|
||||
if (updatedParty[targetIndex].hp > 0) {
|
||||
const appliedAt = boss.nextBrandAt;
|
||||
updatedParty[targetIndex] = {
|
||||
...updatedParty[targetIndex],
|
||||
debuffs: [
|
||||
...updatedParty[targetIndex].debuffs,
|
||||
{
|
||||
id: `brand-${boss.brandCount}`,
|
||||
name: "Ember Brand",
|
||||
expiresAt: appliedAt + BOSS_PERIODIC_MECHANICS.brand.duration,
|
||||
nextTickAt: appliedAt + 1,
|
||||
tickDamage: BOSS_PERIODIC_MECHANICS.brand.tickDamage,
|
||||
},
|
||||
],
|
||||
};
|
||||
events.push({
|
||||
at: appliedAt,
|
||||
message: `Ember Brand afflicts ${updatedParty[targetIndex].name}.`,
|
||||
tone: "danger",
|
||||
pulseKind: "debuff",
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
boss.brandCount += 1;
|
||||
boss.nextBrandAt += BOSS_PERIODIC_MECHANICS.brand.interval;
|
||||
}
|
||||
return updatedParty;
|
||||
}
|
||||
|
||||
function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
const events: BossMechanicEvent[] = [];
|
||||
const motionResult = advanceMotionMechanics(
|
||||
context.motion,
|
||||
context.party,
|
||||
context.partyPositions,
|
||||
context.time,
|
||||
context.delta,
|
||||
context.damageMember,
|
||||
events,
|
||||
);
|
||||
const party = resolvePeriodicMechanics(
|
||||
boss,
|
||||
motionResult.motion,
|
||||
motionResult.party,
|
||||
context.partyPositions,
|
||||
context.time,
|
||||
context.damageMember,
|
||||
events,
|
||||
);
|
||||
return { boss, motion: motionResult.motion, party, events };
|
||||
}
|
||||
|
||||
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
|
||||
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
|
||||
return advanceBulldromeMechanics(context);
|
||||
}
|
||||
|
||||
export function handleBossDispel(
|
||||
bossId: BossId,
|
||||
motion: BossMotionState,
|
||||
memberId: MemberId,
|
||||
position: WorldPosition,
|
||||
time: number,
|
||||
debuffNames: readonly string[],
|
||||
) {
|
||||
if (bossId === "vexa" && debuffNames.includes("Widow Venom")) {
|
||||
return {
|
||||
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
|
||||
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
||||
};
|
||||
}
|
||||
return { motion, message: "Harmful magic removed." };
|
||||
}
|
||||
|
||||
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
|
||||
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
|
||||
if (motion.mode === "telegraph") {
|
||||
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "charging") {
|
||||
return { name: "Charge active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
|
||||
}
|
||||
if (motion.mode === "stacking") {
|
||||
const names: Record<MemberId, string> = { aelia: "Aelia", brann: "Brann", nia: "Nia", orin: "Orin", vale: "Vale" };
|
||||
return { name: `Stack on ${names[motion.pounceTargetId]}`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_POUNCE.stackDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "pouncing") {
|
||||
return { name: "Bulldrome Pounce", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 0.75, urgent: true };
|
||||
}
|
||||
const candidates: Array<{ name: string; remaining: number; cycle: number }> = [
|
||||
{ name: "Cinder Nova", remaining: Math.max(0, boss.nextNovaAt - time), cycle: BOSS_PERIODIC_MECHANICS.nova.interval },
|
||||
{ name: "Ember Brand", remaining: Math.max(0, boss.nextBrandAt - time), cycle: BOSS_PERIODIC_MECHANICS.brand.interval },
|
||||
];
|
||||
if (motion.mode === "holding" && Number.isFinite(motion.nextChargeAt)) {
|
||||
candidates.push({ name: "Bull Charge", remaining: Math.max(0, motion.nextChargeAt - time), cycle: BULL_CHARGE.firstAt + 1 });
|
||||
}
|
||||
let next = candidates[0];
|
||||
for (let index = 1; index < candidates.length; index += 1) {
|
||||
if (candidates[index].remaining < next.remaining) next = candidates[index];
|
||||
}
|
||||
return { ...next, urgent: next.remaining < 2.5 };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointInCone } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CINDER_BREATH = {
|
||||
firstAt: 6,
|
||||
telegraphDuration: 2,
|
||||
sweepDuration: 3.2,
|
||||
range: 10.5,
|
||||
halfAngle: Math.PI / 7,
|
||||
sweepArc: Math.PI * 0.95,
|
||||
tickDamage: 9,
|
||||
tickInterval: 0.45,
|
||||
} as const;
|
||||
|
||||
export const CINDER_SKYFALL = {
|
||||
warning: 2,
|
||||
stagger: 0.9,
|
||||
radius: 1.8,
|
||||
damage: 30,
|
||||
fireDuration: 5,
|
||||
} as const;
|
||||
|
||||
const SKYFALL_TARGETS: readonly MemberId[][] = [
|
||||
["nia", "orin", "vale"],
|
||||
["aelia", "brann", "orin"],
|
||||
["vale", "nia", "aelia"],
|
||||
];
|
||||
|
||||
export function createCindermawState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS.cindermaw;
|
||||
return {
|
||||
id: "cindermaw",
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: 2.5,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCindermawMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("cindermaw"), position: [0, -2.8], nextMechanicAt: CINDER_BREATH.firstAt };
|
||||
}
|
||||
|
||||
export function advanceCindermawMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
let party = context.party;
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.9 * context.delta);
|
||||
}
|
||||
|
||||
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
|
||||
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
|
||||
const startAngle = aimedAngle - direction * CINDER_BREATH.sweepArc * 0.5;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "breath_telegraph",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + CINDER_BREATH.telegraphDuration,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
breathAngle: startAngle,
|
||||
breathStartAngle: startAngle,
|
||||
breathEndAngle: startAngle + direction * CINDER_BREATH.sweepArc,
|
||||
mechanicHitIds: [],
|
||||
mechanicNextDamageAt: {},
|
||||
};
|
||||
events.push({ at: context.time, message: "Cindermaw draws a sweeping breath. Rotate behind it!", tone: "danger", pulseKind: "breath" });
|
||||
} else {
|
||||
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
|
||||
const hazards = set.map((memberId, index) => {
|
||||
const activatesAt = context.time + CINDER_SKYFALL.warning + index * CINDER_SKYFALL.stagger;
|
||||
return {
|
||||
id: `skyfall-${motion.mechanicCount}-${index}`,
|
||||
kind: "skyfall" as const,
|
||||
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
|
||||
radius: CINDER_SKYFALL.radius,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + CINDER_SKYFALL.fireDuration,
|
||||
damage: CINDER_SKYFALL.damage,
|
||||
nextDamageAt: {},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
});
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "skyfall",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: hazards[hazards.length - 1].activatesAt + 0.5,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
hazards: [...motion.hazards, ...hazards],
|
||||
};
|
||||
events.push({ at: context.time, message: "Cindermaw takes flight. Three Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
|
||||
}
|
||||
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "breath_sweeping",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
|
||||
breathAngle: motion.breathStartAngle,
|
||||
};
|
||||
events.push({ at: context.time, message: "Searing Sweep crosses the arena!", tone: "danger", pulseKind: "breath" });
|
||||
} else if (motion.mode === "breath_sweeping") {
|
||||
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
|
||||
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
|
||||
party = party.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, CINDER_BREATH.halfAngle, CINDER_BREATH.range);
|
||||
if (!exposed) {
|
||||
motion.mechanicNextDamageAt[member.id] = context.time;
|
||||
return member;
|
||||
}
|
||||
let next = member;
|
||||
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
|
||||
while (tickAt <= context.time + 0.001) {
|
||||
next = context.damageMember(next, CINDER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
|
||||
tickAt += CINDER_BREATH.tickInterval;
|
||||
}
|
||||
motion.mechanicNextDamageAt[member.id] = tickAt;
|
||||
if (!motion.mechanicHitIds.includes(member.id)) {
|
||||
motion.mechanicHitIds.push(member.id);
|
||||
events.push({ at: context.time, message: `${member.name} is scorched by Searing Sweep.`, tone: "danger", pulseKind: "breath", targetId: member.id });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
|
||||
}
|
||||
} else if (motion.mode === "skyfall" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
|
||||
}
|
||||
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.6, 17, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "breath_telegraph") return { name: "Searing Sweep", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
|
||||
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
|
||||
if (motion.mode === "skyfall") return { name: "Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
|
||||
const nextIsBreath = motion.mechanicCount % 2 === 0;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: nextIsBreath ? "Searing Sweep" : "Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { distance } from "../geometry";
|
||||
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "../types";
|
||||
import type { BossMechanicContext, BossMechanicEvent } from "./types";
|
||||
|
||||
export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||
return {
|
||||
bossId,
|
||||
formationOffsetX: 0,
|
||||
mode: "holding",
|
||||
position: [0, -8.2],
|
||||
chargeStart: [0, -8.2],
|
||||
chargeEnd: [0, 5.5],
|
||||
chargeTargetId: "nia",
|
||||
chargeHitIds: [],
|
||||
phaseEndsAt: 0,
|
||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
||||
chargeCount: 0,
|
||||
chargesSincePounce: 0,
|
||||
pounceTargetId: "aelia",
|
||||
pounceCenter: [0, 4.5],
|
||||
pounceCount: 0,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: 0,
|
||||
phaseStartedAt: 0,
|
||||
mechanicHitIds: [],
|
||||
mechanicNextDamageAt: {},
|
||||
tetherIds: [],
|
||||
tetherBreakDistance: 0,
|
||||
breathAngle: 0,
|
||||
breathStartAngle: 0,
|
||||
breathEndAngle: 0,
|
||||
hazards: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneMotion(source: BossMotionState): BossMotionState {
|
||||
return {
|
||||
...source,
|
||||
position: [source.position[0], source.position[1]],
|
||||
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
|
||||
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
|
||||
chargeHitIds: [...source.chargeHitIds],
|
||||
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
|
||||
mechanicHitIds: [...source.mechanicHitIds],
|
||||
mechanicNextDamageAt: { ...source.mechanicNextDamageAt },
|
||||
tetherIds: [...source.tetherIds],
|
||||
hazards: source.hazards.map((hazard) => ({
|
||||
...hazard,
|
||||
center: [hazard.center[0], hazard.center[1]],
|
||||
nextDamageAt: { ...hazard.nextDamageAt },
|
||||
hitIds: [...hazard.hitIds],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function chooseLivingTarget(
|
||||
party: PartyMember[],
|
||||
order: readonly MemberId[],
|
||||
startIndex: number,
|
||||
) {
|
||||
for (let offset = 0; offset < order.length; offset += 1) {
|
||||
const candidate = order[(startIndex + offset) % order.length];
|
||||
if (party.some((member) => member.id === candidate && member.hp > 0)) return candidate;
|
||||
}
|
||||
return order[0];
|
||||
}
|
||||
|
||||
export function applyMelee(
|
||||
boss: BossState,
|
||||
motion: BossMotionState,
|
||||
party: PartyMember[],
|
||||
positions: Record<MemberId, WorldPosition>,
|
||||
time: number,
|
||||
interval: number,
|
||||
amount: number,
|
||||
damageMember: BossMechanicContext["damageMember"],
|
||||
) {
|
||||
while (boss.nextMeleeAt <= time) {
|
||||
if (motion.mode === "holding") {
|
||||
const tankIndex = party.findIndex((member) => member.id === "brann");
|
||||
if (tankIndex >= 0 && party[tankIndex].hp > 0) {
|
||||
party[tankIndex] = damageMember(party[tankIndex], amount, positions.brann, boss.nextMeleeAt);
|
||||
}
|
||||
}
|
||||
boss.nextMeleeAt += interval;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCircleHazards(
|
||||
motion: BossMotionState,
|
||||
party: PartyMember[],
|
||||
positions: Record<MemberId, WorldPosition>,
|
||||
time: number,
|
||||
damageMember: BossMechanicContext["damageMember"],
|
||||
events: BossMechanicEvent[],
|
||||
) {
|
||||
let updatedParty = party;
|
||||
for (const hazard of motion.hazards) {
|
||||
if (time < hazard.activatesAt || time >= hazard.expiresAt) continue;
|
||||
const newlyHit: MemberId[] = [];
|
||||
updatedParty = updatedParty.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const inside = distance(positions[member.id], hazard.center) <= hazard.radius;
|
||||
if (!inside) {
|
||||
delete hazard.nextDamageAt[member.id];
|
||||
return member;
|
||||
}
|
||||
if (!hazard.hitIds.includes(member.id)) {
|
||||
hazard.hitIds.push(member.id);
|
||||
newlyHit.push(member.id);
|
||||
}
|
||||
if (hazard.kind !== "venom_pool") {
|
||||
return hazard.resolved || hazard.hitIds.includes(member.id) && !newlyHit.includes(member.id)
|
||||
? member
|
||||
: damageMember(member, hazard.damage, positions[member.id], time);
|
||||
}
|
||||
|
||||
let next = member;
|
||||
let tickAt = hazard.nextDamageAt[member.id] ?? time;
|
||||
while (tickAt <= time + 0.001) {
|
||||
next = damageMember(next, hazard.damage, positions[member.id], tickAt);
|
||||
tickAt += hazard.tickInterval ?? 1;
|
||||
}
|
||||
hazard.nextDamageAt[member.id] = tickAt;
|
||||
return next;
|
||||
});
|
||||
if (newlyHit.length && !hazard.resolved) {
|
||||
const label = hazard.kind === "skyfall" ? "Skyfall" : "Venom pool";
|
||||
events.push({ at: time, message: `${label} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind: hazard.kind === "skyfall" ? "skyfall" : "venom" });
|
||||
}
|
||||
if (newlyHit.length || hazard.kind === "skyfall" && time >= hazard.activatesAt) hazard.resolved = true;
|
||||
}
|
||||
motion.hazards = motion.hazards.filter((hazard) => hazard.expiresAt > time);
|
||||
return updatedParty;
|
||||
}
|
||||
|
||||
export function memberName(party: PartyMember[], memberId: MemberId) {
|
||||
return party.find((member) => member.id === memberId)?.name ?? memberId;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { BossMotionState, BossState, MemberId, PartyMember, PulseKind, WorldPosition } from "../types";
|
||||
|
||||
export interface BossMechanicEvent {
|
||||
at: number;
|
||||
message: string;
|
||||
tone?: "danger" | "neutral";
|
||||
pulseKind?: PulseKind;
|
||||
targetId?: MemberId;
|
||||
}
|
||||
|
||||
export interface BossMechanicResult {
|
||||
boss: BossState;
|
||||
motion: BossMotionState;
|
||||
party: PartyMember[];
|
||||
events: BossMechanicEvent[];
|
||||
}
|
||||
|
||||
export interface BossMechanicContext {
|
||||
boss: BossState;
|
||||
motion: BossMotionState;
|
||||
party: PartyMember[];
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
delta: number;
|
||||
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number) => PartyMember;
|
||||
}
|
||||
|
||||
export interface UpcomingMechanic {
|
||||
name: string;
|
||||
remaining: number;
|
||||
cycle: number;
|
||||
urgent: boolean;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { distance, moveToward } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const VEXA_TETHER = {
|
||||
firstAt: 6,
|
||||
duration: 4.5,
|
||||
breakDistance: 6.8,
|
||||
failureDamage: 24,
|
||||
rootDuration: 1.4,
|
||||
} as const;
|
||||
|
||||
export const VEXA_VENOM = {
|
||||
duration: 10,
|
||||
tickDamage: 5,
|
||||
castDuration: 2.5,
|
||||
poolRadius: 2,
|
||||
poolDuration: 7,
|
||||
poolDamage: 14,
|
||||
} as const;
|
||||
|
||||
const TETHER_PAIRS: readonly (readonly [MemberId, MemberId])[] = [
|
||||
["brann", "vale"],
|
||||
["nia", "orin"],
|
||||
["aelia", "nia"],
|
||||
];
|
||||
const VENOM_TARGETS: readonly MemberId[][] = [
|
||||
["nia", "vale"],
|
||||
["orin", "brann"],
|
||||
["aelia", "vale"],
|
||||
];
|
||||
|
||||
export function createVexaState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS.vexa;
|
||||
return {
|
||||
id: "vexa",
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: 2.5,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createVexaMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("vexa"), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
|
||||
}
|
||||
|
||||
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
let party = context.party;
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
|
||||
}
|
||||
|
||||
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const pair = TETHER_PAIRS[Math.floor(motion.mechanicCount / 2) % TETHER_PAIRS.length];
|
||||
const livingPair = pair.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
|
||||
if (livingPair.length === 2) {
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "tethering",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + VEXA_TETHER.duration,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
tetherIds: [...livingPair],
|
||||
tetherBreakDistance: VEXA_TETHER.breakDistance,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
};
|
||||
events.push({ at: context.time, message: `Vexa binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
|
||||
}
|
||||
} else {
|
||||
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
|
||||
const targets = targetSet.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
|
||||
party = party.map((member) => targets.includes(member.id)
|
||||
? {
|
||||
...member,
|
||||
debuffs: [...member.debuffs, {
|
||||
id: `widow-venom-${motion.mechanicCount}-${member.id}`,
|
||||
name: "Widow Venom",
|
||||
expiresAt: context.time + VEXA_VENOM.duration,
|
||||
nextTickAt: context.time + 1,
|
||||
tickDamage: VEXA_VENOM.tickDamage,
|
||||
}],
|
||||
}
|
||||
: member);
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "venom_cast",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + VEXA_VENOM.castDuration,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
};
|
||||
events.push({ at: context.time, message: "Vexa injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
||||
}
|
||||
} else if (motion.mode === "tethering") {
|
||||
const [first, second] = motion.tetherIds;
|
||||
if (!first || !second || distance(context.partyPositions[first], context.partyPositions[second]) >= motion.tetherBreakDistance) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
|
||||
events.push({ at: context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
|
||||
} else if (context.time >= motion.phaseEndsAt) {
|
||||
party = party.map((member) => motion.tetherIds.includes(member.id)
|
||||
? {
|
||||
...context.damageMember(member, VEXA_TETHER.failureDamage, context.partyPositions[member.id], context.time),
|
||||
knockedUntil: context.time + VEXA_TETHER.rootDuration,
|
||||
}
|
||||
: member);
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
|
||||
events.push({ at: context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
|
||||
}
|
||||
} else if (motion.mode === "venom_cast" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
|
||||
}
|
||||
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.7, 13, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function dropVexaVenomPool(
|
||||
motion: BossMotionState,
|
||||
memberId: MemberId,
|
||||
center: [number, number],
|
||||
time: number,
|
||||
) {
|
||||
const next = cloneMotion(motion);
|
||||
next.hazards.push({
|
||||
id: `venom-pool-${memberId}-${time.toFixed(2)}`,
|
||||
kind: "venom_pool",
|
||||
center: [center[0], center[1]],
|
||||
radius: VEXA_VENOM.poolRadius,
|
||||
activatesAt: time + 0.25,
|
||||
expiresAt: time + VEXA_VENOM.poolDuration,
|
||||
damage: VEXA_VENOM.poolDamage,
|
||||
tickInterval: 1,
|
||||
nextDamageAt: {},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function upcomingVexaMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "tethering") return { name: "Break Binding Web", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_TETHER.duration, urgent: true };
|
||||
if (motion.mode === "venom_cast") return { name: "Move, then Purify", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_VENOM.castDuration, urgent: true };
|
||||
const nextIsTether = motion.mechanicCount % 2 === 0;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: nextIsTether ? "Binding Web" : "Venom Purge", remaining, cycle: 8, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { HEALER_CLASSES } from "./healers";
|
||||
import type { AbilityId, HealerClassId, PartyMember } from "./types";
|
||||
|
||||
export const ABILITIES = HEALER_CLASSES.priest.abilities;
|
||||
|
||||
export const ABILITY_ORDER: AbilityId[] = ["mend", "renew", "shield", "purify", "radiance", "barrier"];
|
||||
|
||||
export function freshParty(classId: HealerClassId = "priest", playerName = "Aelia"): PartyMember[] {
|
||||
const healer = HEALER_CLASSES[classId];
|
||||
return [
|
||||
{ id: "aelia", name: playerName, className: healer.specialization, role: "Healer", color: healer.color, maxHp: 100, hp: 100, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
|
||||
{ id: "brann", name: "Brann", className: "Knight", role: "Tank", color: "#69a8dd", maxHp: 150, hp: 150, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
|
||||
{ id: "nia", name: "Nia", className: "Ranger", role: "Damage", color: "#74c987", maxHp: 94, hp: 94, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
|
||||
{ id: "orin", name: "Orin", className: "Mage", role: "Damage", color: "#b17ee6", maxHp: 86, hp: 86, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
|
||||
{ id: "vale", name: "Vale", className: "Rogue", role: "Damage", color: "#d97171", maxHp: 92, hp: 92, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { WorldPosition } from "./types";
|
||||
|
||||
export function distance(a: WorldPosition, b: WorldPosition) {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
}
|
||||
|
||||
export function moveToward(
|
||||
current: WorldPosition,
|
||||
target: WorldPosition,
|
||||
maximumDistance: number,
|
||||
): WorldPosition {
|
||||
const dx = target[0] - current[0];
|
||||
const dz = target[1] - current[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
if (length <= maximumDistance || length < 0.0001) return [target[0], target[1]];
|
||||
return [current[0] + (dx / length) * maximumDistance, current[1] + (dz / length) * maximumDistance];
|
||||
}
|
||||
|
||||
export function pointToSegmentDistance(
|
||||
point: WorldPosition,
|
||||
start: WorldPosition,
|
||||
end: WorldPosition,
|
||||
) {
|
||||
const dx = end[0] - start[0];
|
||||
const dz = end[1] - start[1];
|
||||
const lengthSquared = dx * dx + dz * dz;
|
||||
if (lengthSquared === 0) return distance(point, start);
|
||||
const projection = Math.max(
|
||||
0,
|
||||
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
|
||||
);
|
||||
return Math.hypot(
|
||||
point[0] - (start[0] + projection * dx),
|
||||
point[1] - (start[1] + projection * dz),
|
||||
);
|
||||
}
|
||||
|
||||
export function pointOutsideLane(
|
||||
point: WorldPosition,
|
||||
start: WorldPosition,
|
||||
end: WorldPosition,
|
||||
clearance: number,
|
||||
preferredSide: -1 | 1,
|
||||
): WorldPosition {
|
||||
const dx = end[0] - start[0];
|
||||
const dz = end[1] - start[1];
|
||||
const lengthSquared = dx * dx + dz * dz;
|
||||
if (lengthSquared < 0.0001) return [point[0], point[1]];
|
||||
|
||||
const length = Math.sqrt(lengthSquared);
|
||||
const projection = Math.max(
|
||||
0,
|
||||
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
|
||||
);
|
||||
const nearestX = start[0] + projection * dx;
|
||||
const nearestZ = start[1] + projection * dz;
|
||||
const normalX = -dz / length;
|
||||
const normalZ = dx / length;
|
||||
const signedDistance = (point[0] - nearestX) * normalX + (point[1] - nearestZ) * normalZ;
|
||||
const side = Math.abs(signedDistance) > 0.01 ? Math.sign(signedDistance) : preferredSide;
|
||||
|
||||
return [nearestX + normalX * clearance * side, nearestZ + normalZ * clearance * side];
|
||||
}
|
||||
|
||||
export function angleTo(origin: WorldPosition, target: WorldPosition) {
|
||||
return Math.atan2(target[0] - origin[0], target[1] - origin[1]);
|
||||
}
|
||||
|
||||
export function angularDistance(first: number, second: number) {
|
||||
return Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second)));
|
||||
}
|
||||
|
||||
export function pointInCone(
|
||||
point: WorldPosition,
|
||||
origin: WorldPosition,
|
||||
facingAngle: number,
|
||||
halfAngle: number,
|
||||
range: number,
|
||||
) {
|
||||
return distance(point, origin) <= range && angularDistance(angleTo(origin, point), facingAngle) <= halfAngle;
|
||||
}
|
||||
|
||||
export function pointOutsideCircle(
|
||||
point: WorldPosition,
|
||||
center: WorldPosition,
|
||||
clearance: number,
|
||||
fallbackAngle: number,
|
||||
): WorldPosition {
|
||||
const dx = point[0] - center[0];
|
||||
const dz = point[1] - center[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
if (length < 0.001) {
|
||||
return [center[0] + Math.sin(fallbackAngle) * clearance, center[1] + Math.cos(fallbackAngle) * clearance];
|
||||
}
|
||||
return [center[0] + (dx / length) * clearance, center[1] + (dz / length) * clearance];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
|
||||
|
||||
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
|
||||
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" },
|
||||
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" },
|
||||
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" },
|
||||
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" },
|
||||
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" },
|
||||
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" },
|
||||
};
|
||||
|
||||
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
|
||||
return { ...bindings[id], ...definition };
|
||||
}
|
||||
|
||||
export const HEALER_CLASS_ORDER: HealerClassId[] = ["priest", "druid", "shaman"];
|
||||
|
||||
export const HEALER_CLASSES: Record<HealerClassId, HealerClassDefinition> = {
|
||||
priest: {
|
||||
id: "priest",
|
||||
name: "Priest",
|
||||
specialization: "Discipline Priest",
|
||||
icon: "✦",
|
||||
color: "#e8c872",
|
||||
resourceName: "Grace",
|
||||
description: "Direct healing, protective shields, cleansing, and a damage-reducing sanctuary.",
|
||||
abilities: {
|
||||
mend: ability("mend", { name: "Mend", shortName: "Mend", cooldown: 0, castTime: 0.5, mana: 5, icon: "+", description: "Cast for 0.5 seconds to heal the selected ally for 38 health. No cooldown.", color: "#fff0bd" }),
|
||||
renew: ability("renew", { name: "Renew", shortName: "Renew", cooldown: 0, mana: 7, icon: "✣", description: "Heal selected ally for 7 health every second for 8 seconds. No cooldown.", color: "#71df9c" }),
|
||||
shield: ability("shield", { name: "Aegis Shield", shortName: "Shield", cooldown: 10, mana: 8, icon: "◇", description: "Give selected ally a 36-point damage shield.", color: "#6fc6ff" }),
|
||||
purify: ability("purify", { name: "Purify", shortName: "Purify", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally.", color: "#b58cff" }),
|
||||
radiance: ability("radiance", { name: "Radiance", shortName: "Radiance", cooldown: 14, mana: 12, icon: "☀", description: "Heal every party member for 22 health.", color: "#ffd66b" }),
|
||||
barrier: ability("barrier", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 3m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }),
|
||||
},
|
||||
},
|
||||
druid: {
|
||||
id: "druid",
|
||||
name: "Druid",
|
||||
specialization: "Restoration Druid",
|
||||
icon: "❧",
|
||||
color: "#79d36f",
|
||||
resourceName: "Mana",
|
||||
description: "Placeholder nature kit built around regeneration, bark wards, and restorative growth.",
|
||||
abilities: {
|
||||
mend: ability("mend", { name: "Healing Touch", shortName: "Heal Touch", cooldown: 0, castTime: 0.5, mana: 5, icon: "❦", description: "Placeholder: cast a focused nature heal for 38 health.", color: "#b8ef8b" }),
|
||||
renew: ability("renew", { name: "Rejuvenation", shortName: "Rejuvenate", cooldown: 0, mana: 7, icon: "☘", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#63d77e" }),
|
||||
shield: ability("shield", { name: "Ironbark", shortName: "Ironbark", cooldown: 10, mana: 8, icon: "♧", description: "Placeholder: grant the selected ally 36 absorption.", color: "#a6c76b" }),
|
||||
purify: ability("purify", { name: "Nature's Cure", shortName: "Nature Cure", cooldown: 3, mana: 5, icon: "✤", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#8de2b2" }),
|
||||
radiance: ability("radiance", { name: "Wild Growth", shortName: "Wild Growth", cooldown: 14, mana: 12, icon: "✾", description: "Placeholder: heal every party member for 22 health.", color: "#d1ed73" }),
|
||||
barrier: ability("barrier", { name: "Grove Ward", shortName: "Grove Ward", cooldown: 60, mana: 10, icon: "◌", description: "Placeholder: grow an 8-second protective grove that reduces damage by 30%.", color: "#70bc72" }),
|
||||
},
|
||||
},
|
||||
shaman: {
|
||||
id: "shaman",
|
||||
name: "Shaman",
|
||||
specialization: "Restoration Shaman",
|
||||
icon: "ϟ",
|
||||
color: "#65b9ed",
|
||||
resourceName: "Mana",
|
||||
description: "Placeholder elemental kit using tides, earth wards, cleansing, and spirit protection.",
|
||||
abilities: {
|
||||
mend: ability("mend", { name: "Healing Wave", shortName: "Heal Wave", cooldown: 0, castTime: 0.5, mana: 5, icon: "≈", description: "Placeholder: cast a focused water heal for 38 health.", color: "#8fdcf2" }),
|
||||
renew: ability("renew", { name: "Riptide", shortName: "Riptide", cooldown: 0, mana: 7, icon: "≋", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#54c9c5" }),
|
||||
shield: ability("shield", { name: "Earth Shield", shortName: "Earth Shield", cooldown: 10, mana: 8, icon: "⬡", description: "Placeholder: grant the selected ally 36 absorption.", color: "#d2b66c" }),
|
||||
purify: ability("purify", { name: "Cleanse Spirit", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#9aaef5" }),
|
||||
radiance: ability("radiance", { name: "Chain Heal", shortName: "Chain Heal", cooldown: 14, mana: 12, icon: "⌁", description: "Placeholder: heal every party member for 22 health.", color: "#6ee2db" }),
|
||||
barrier: ability("barrier", { name: "Spirit Link", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Placeholder: place an 8-second spirit field that reduces damage by 30%.", color: "#9d8cf2" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CLASS_INVENTORIES: Record<HealerClassId, InventoryItem[]> = {
|
||||
priest: [
|
||||
{ id: "priest-censer", name: "Censer of First Light", slot: "Main Hand", rarity: "Rare", icon: "♰", stats: ["+12 Grace", "+8% Mend healing"], effect: "Mend restores 2 mana when it lands on an ally below 50% health.", equipped: true },
|
||||
{ id: "priest-vestment", name: "Ashwoven Vestment", slot: "Chest", rarity: "Uncommon", icon: "♜", stats: ["+18 Armor", "+6 Spirit"], effect: "Renew ticks have a 10% chance to extend Aegis Shield by 4 absorption.", equipped: true },
|
||||
{ id: "priest-phial", name: "Moonwater Phial", slot: "Consumable", rarity: "Common", icon: "⚗", stats: ["Restores 40 mana"], effect: "Single use. Cannot be used during this prototype encounter.", equipped: false },
|
||||
{ id: "priest-sigil", name: "Sigil of Quiet Resolve", slot: "Trinket", rarity: "Rare", icon: "◈", stats: ["+10% Purify range", "+5 Haste"], effect: "Purify grants its target 8 absorption when it removes Ember Brand.", equipped: false },
|
||||
],
|
||||
druid: [
|
||||
{ id: "druid-branch", name: "Verdant Branch", slot: "Main Hand", rarity: "Common", icon: "❧", stats: ["+5 Spirit"], effect: "Placeholder Druid starter weapon.", equipped: true },
|
||||
{ id: "druid-hide", name: "Mossbound Hide", slot: "Chest", rarity: "Common", icon: "♧", stats: ["+10 Armor"], effect: "Placeholder Druid starter armor.", equipped: true },
|
||||
{ id: "druid-seed", name: "Dreamseed", slot: "Trinket", rarity: "Uncommon", icon: "•", stats: ["+3 Haste"], effect: "Placeholder Druid trinket.", equipped: false },
|
||||
],
|
||||
shaman: [
|
||||
{ id: "shaman-totem", name: "Raincall Totem", slot: "Main Hand", rarity: "Common", icon: "ϟ", stats: ["+5 Spirit"], effect: "Placeholder Shaman starter focus.", equipped: true },
|
||||
{ id: "shaman-mail", name: "Tideworn Mail", slot: "Chest", rarity: "Common", icon: "▧", stats: ["+12 Armor"], effect: "Placeholder Shaman starter armor.", equipped: true },
|
||||
{ id: "shaman-stone", name: "Whispering Stone", slot: "Trinket", rarity: "Uncommon", icon: "◇", stats: ["+3 Haste"], effect: "Placeholder Shaman trinket.", equipped: false },
|
||||
],
|
||||
};
|
||||
|
||||
export function createClassInventory(classId: HealerClassId): InventoryItem[] {
|
||||
return structuredClone(CLASS_INVENTORIES[classId]);
|
||||
}
|
||||
|
||||
export function healerClass(classId: HealerClassId): HealerClassDefinition {
|
||||
return HEALER_CLASSES[classId];
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { CINDER_BREATH } from "./bosses/cindermaw";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
export type AiMemberId = Exclude<MemberId, "aelia">;
|
||||
|
||||
export interface PartyBehaviorContext {
|
||||
memberId: AiMemberId;
|
||||
current: WorldPosition;
|
||||
formationTarget: WorldPosition;
|
||||
bossMotion: BossMotionState;
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface PartyBehaviorDecision {
|
||||
target: WorldPosition;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface PartyBehavior {
|
||||
id: string;
|
||||
decide: (context: PartyBehaviorContext) => PartyBehaviorDecision | null;
|
||||
}
|
||||
|
||||
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
|
||||
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
|
||||
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
|
||||
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||
brann: [-0.55, 0],
|
||||
nia: [0.45, 0.4],
|
||||
orin: [0.45, -0.4],
|
||||
vale: [0, 0.65],
|
||||
};
|
||||
|
||||
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
|
||||
|
||||
export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPosition> {
|
||||
return {
|
||||
brann: [boss[0], boss[1] + 4.25],
|
||||
nia: [boss[0] - 3.3, boss[1] + 7.2],
|
||||
orin: [boss[0] + 3.3, boss[1] + 7.2],
|
||||
vale: [boss[0] + 1.75, boss[1] + 3.4],
|
||||
};
|
||||
}
|
||||
|
||||
function clampToArena(position: WorldPosition): WorldPosition {
|
||||
return [
|
||||
Math.max(ARENA_BOUNDS.minX, Math.min(ARENA_BOUNDS.maxX, position[0])),
|
||||
Math.max(ARENA_BOUNDS.minZ, Math.min(ARENA_BOUNDS.maxZ, position[1])),
|
||||
];
|
||||
}
|
||||
|
||||
export const stackForPounceBehavior: PartyBehavior = {
|
||||
id: "stack-for-pounce",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
if (bossMotion.mode !== "stacking") return null;
|
||||
const offset = STACK_OFFSETS[memberId];
|
||||
return {
|
||||
target: memberId === bossMotion.pounceTargetId
|
||||
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
|
||||
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
|
||||
speed: 3.4,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const breakTetherBehavior: PartyBehavior = {
|
||||
id: "break-tether",
|
||||
decide: ({ memberId, current, bossMotion, partyPositions }) => {
|
||||
if (bossMotion.mode !== "tethering" || !bossMotion.tetherIds.includes(memberId)) return null;
|
||||
const otherId = bossMotion.tetherIds.find((id) => id !== memberId);
|
||||
if (!otherId) return null;
|
||||
const other = partyPositions[otherId];
|
||||
const dx = current[0] - other[0];
|
||||
const dz = current[1] - other[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const fallback = EVADE_SIDES[memberId] * Math.PI * 0.5;
|
||||
const target = length < 0.01
|
||||
? [current[0] + Math.sin(fallback) * bossMotion.tetherBreakDistance, current[1] + Math.cos(fallback) * bossMotion.tetherBreakDistance] as WorldPosition
|
||||
: [current[0] + (dx / length) * bossMotion.tetherBreakDistance, current[1] + (dz / length) * bossMotion.tetherBreakDistance] as WorldPosition;
|
||||
return { target: clampToArena(target), speed: 3.7 };
|
||||
},
|
||||
};
|
||||
|
||||
export const evadeChargeBehavior: PartyBehavior = {
|
||||
id: "evade-charge",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion }) => {
|
||||
if (bossMotion.mode !== "telegraph" && bossMotion.mode !== "charging") return null;
|
||||
const { chargeStart, chargeEnd } = bossMotion;
|
||||
const currentUnsafe = pointToSegmentDistance(current, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
|
||||
const formationUnsafe = pointToSegmentDistance(formationTarget, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
|
||||
if (!currentUnsafe && !formationUnsafe) return null;
|
||||
|
||||
// Derive from the stable formation slot so the target cannot flip sides as the member moves.
|
||||
const evadeTarget = pointOutsideLane(
|
||||
formationTarget,
|
||||
chargeStart,
|
||||
chargeEnd,
|
||||
BULL_CHARGE.aiClearance,
|
||||
EVADE_SIDES[memberId],
|
||||
);
|
||||
return { target: clampToArena(evadeTarget), speed: BULL_CHARGE.aiEvadeSpeed };
|
||||
},
|
||||
};
|
||||
|
||||
export const avoidBreathBehavior: PartyBehavior = {
|
||||
id: "avoid-breath",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
|
||||
const side = EVADE_SIDES[memberId];
|
||||
const safeAngle = bossMotion.breathAngle + side * (CINDER_BREATH.halfAngle + Math.PI * 0.42);
|
||||
const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4;
|
||||
return {
|
||||
target: clampToArena([
|
||||
bossMotion.position[0] + Math.sin(safeAngle) * radius,
|
||||
bossMotion.position[1] + Math.cos(safeAngle) * radius,
|
||||
]),
|
||||
speed: 4.1,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
id: "avoid-circle-hazards",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
|
||||
for (let index = 0; index < bossMotion.hazards.length; index += 1) {
|
||||
const hazard = bossMotion.hazards[index];
|
||||
if (hazard.expiresAt <= time || hazard.activatesAt - time > 2.2) continue;
|
||||
const clearance = hazard.radius + 0.55;
|
||||
const currentUnsafe = Math.hypot(current[0] - hazard.center[0], current[1] - hazard.center[1]) < clearance;
|
||||
const formationUnsafe = Math.hypot(formationTarget[0] - hazard.center[0], formationTarget[1] - hazard.center[1]) < clearance;
|
||||
if (!currentUnsafe && !formationUnsafe) continue;
|
||||
const source = formationUnsafe ? formationTarget : current;
|
||||
const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
|
||||
return { target: clampToArena(pointOutsideCircle(source, hazard.center, clearance, fallbackAngle)), speed: 4 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
|
||||
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
|
||||
breakTetherBehavior,
|
||||
stackForPounceBehavior,
|
||||
evadeChargeBehavior,
|
||||
avoidBreathBehavior,
|
||||
avoidCircleHazardsBehavior,
|
||||
maintainFormationBehavior,
|
||||
];
|
||||
|
||||
export function updatePartyPositions(
|
||||
current: Record<MemberId, WorldPosition>,
|
||||
bossMotionOrMotions: BossMotionState | readonly BossMotionState[],
|
||||
party: PartyMember[],
|
||||
time: number,
|
||||
delta: number,
|
||||
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
|
||||
) {
|
||||
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
|
||||
const activeMotions = bossMotions;
|
||||
const next: Record<MemberId, WorldPosition> = {
|
||||
aelia: [current.aelia[0], current.aelia[1]],
|
||||
brann: [current.brann[0], current.brann[1]],
|
||||
nia: [current.nia[0], current.nia[1]],
|
||||
orin: [current.orin[0], current.orin[1]],
|
||||
vale: [current.vale[0], current.vale[1]],
|
||||
};
|
||||
if (!activeMotions.length) return next;
|
||||
const formationOrigin: WorldPosition = [
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
];
|
||||
const formation = combatFormation(formationOrigin);
|
||||
// Vale fights from the boss-cluster midpoint so short-range cleaves can
|
||||
// connect with both targets when their hit volumes overlap.
|
||||
formation.vale = [formationOrigin[0], formationOrigin[1] + 1.7];
|
||||
|
||||
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
|
||||
const memberId = AI_MEMBER_IDS[index];
|
||||
const member = party[index + 1];
|
||||
if (!member || member.hp <= 0 || member.knockedUntil > time) continue;
|
||||
for (const behavior of behaviors) {
|
||||
let handled = false;
|
||||
for (const bossMotion of activeMotions) {
|
||||
const decision = behavior.decide({
|
||||
memberId,
|
||||
current: next[memberId],
|
||||
formationTarget: formation[memberId],
|
||||
bossMotion,
|
||||
partyPositions: next,
|
||||
time,
|
||||
});
|
||||
if (!decision) continue;
|
||||
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||
import { freshParty } from "./data";
|
||||
import {
|
||||
advancePartyCombat,
|
||||
createPartyCombatState,
|
||||
PARTY_ABILITY_LOADOUTS,
|
||||
tankAuraProtects,
|
||||
type AiCombatantId,
|
||||
type PartyAbilityId,
|
||||
type PartyCombatTarget,
|
||||
} from "./partyCombat";
|
||||
import type { BossId, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
const BASE_POSITIONS: Record<MemberId, WorldPosition> = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [0, 0],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
};
|
||||
|
||||
interface SimulationOptions {
|
||||
duration?: number;
|
||||
movingIds?: readonly AiCombatantId[];
|
||||
activeIds?: readonly AiCombatantId[];
|
||||
targetPositions?: readonly WorldPosition[];
|
||||
stopOnVictory?: boolean;
|
||||
upcomingMechanicRemaining?: number;
|
||||
}
|
||||
|
||||
function createTargets(bossIds: readonly BossId[], positions?: readonly WorldPosition[]): PartyCombatTarget[] {
|
||||
return bossIds.map((bossId, index) => {
|
||||
const motion = createBossMotionState(bossId);
|
||||
motion.position = positions?.[index]
|
||||
? [positions[index][0], positions[index][1]]
|
||||
: bossIds.length > 1
|
||||
? [index === 0 ? -2.4 : 2.4, -4]
|
||||
: [0, -4];
|
||||
return { instanceId: `sim-${index}-${bossId}`, boss: createBossState(bossId), motion };
|
||||
});
|
||||
}
|
||||
|
||||
function simulate(bossIds: readonly BossId[], options: SimulationOptions = {}) {
|
||||
const activeIds = options.activeIds ?? ["brann", "nia", "orin", "vale"];
|
||||
const party = freshParty().map((member) => member.id === "aelia" || activeIds.includes(member.id as AiCombatantId)
|
||||
? member
|
||||
: { ...member, hp: 0 });
|
||||
let combat = createPartyCombatState(party);
|
||||
const targets = createTargets(bossIds, options.targetPositions);
|
||||
const damageBySource: Partial<Record<AiCombatantId, number>> = {};
|
||||
const usedAbilities = new Set<PartyAbilityId>();
|
||||
const secondaryEvents: PartyAbilityId[] = [];
|
||||
const step = 0.1;
|
||||
let time = 0;
|
||||
|
||||
while (time < (options.duration ?? 180)) {
|
||||
const nextTime = Number((time + step).toFixed(4));
|
||||
const oldPositions = structuredClone(BASE_POSITIONS);
|
||||
const positions = structuredClone(BASE_POSITIONS);
|
||||
for (const memberId of options.movingIds ?? []) oldPositions[memberId] = [positions[memberId][0] - 0.1, positions[memberId][1]];
|
||||
const result = advancePartyCombat(combat, {
|
||||
oldTime: time,
|
||||
time: nextTime,
|
||||
party,
|
||||
oldPositions,
|
||||
positions,
|
||||
targets,
|
||||
upcomingMechanicRemaining: options.upcomingMechanicRemaining ?? Number.POSITIVE_INFINITY,
|
||||
});
|
||||
combat = result.state;
|
||||
for (const actor of Object.values(combat.combatants)) {
|
||||
if (actor.visualAction) usedAbilities.add(actor.visualAction.abilityId);
|
||||
}
|
||||
for (const event of result.events) {
|
||||
const target = targets.find((entry) => entry.instanceId === event.targetInstanceId)!;
|
||||
target.boss.hp = Math.max(0, target.boss.hp - event.amount);
|
||||
damageBySource[event.sourceId] = (damageBySource[event.sourceId] ?? 0) + event.amount;
|
||||
if (event.secondary) secondaryEvents.push(event.abilityId);
|
||||
}
|
||||
time = nextTime;
|
||||
if (options.stopOnVictory !== false && targets.every((target) => target.boss.hp <= 0)) break;
|
||||
}
|
||||
|
||||
return { time, targets, combat, damageBySource, usedAbilities, secondaryEvents };
|
||||
}
|
||||
|
||||
describe("party ability combat", () => {
|
||||
it("defines five unique ability slots for every AI party class", () => {
|
||||
for (const loadout of Object.values(PARTY_ABILITY_LOADOUTS)) {
|
||||
expect(loadout).toHaveLength(5);
|
||||
expect(new Set(loadout).size).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not damage a boss before an ability impact lands", () => {
|
||||
const result = simulate(["bulldrome"], { duration: 0.1, stopOnVictory: false });
|
||||
expect(result.targets[0].boss.hp).toBe(result.targets[0].boss.maxHp);
|
||||
expect(Object.values(result.damageBySource)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reduces ranged damage while moving without reducing it to zero", () => {
|
||||
const stationary = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], stopOnVictory: false });
|
||||
const moving = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], movingIds: ["nia", "orin"], stopOnVictory: false });
|
||||
const stationaryDamage = (stationary.damageBySource.nia ?? 0) + (stationary.damageBySource.orin ?? 0);
|
||||
const movingDamage = (moving.damageBySource.nia ?? 0) + (moving.damageBySource.orin ?? 0);
|
||||
expect(movingDamage).toBeGreaterThan(0);
|
||||
expect(movingDamage).toBeLessThan(stationaryDamage * 0.7);
|
||||
});
|
||||
|
||||
it("lets Vale cleave only when both bosses are inside melee radius", () => {
|
||||
const clustered = simulate(["vexa", "cindermaw"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
|
||||
const separated = simulate(["vexa", "cindermaw"], {
|
||||
duration: 30,
|
||||
activeIds: ["vale"],
|
||||
targetPositions: [[0, -4], [7, -4]],
|
||||
stopOnVictory: false,
|
||||
});
|
||||
expect(clustered.secondaryEvents.length).toBeGreaterThan(0);
|
||||
expect(separated.secondaryEvents).toHaveLength(0);
|
||||
expect(clustered.damageBySource.vale).toBeGreaterThan(separated.damageBySource.vale ?? 0);
|
||||
});
|
||||
|
||||
it("activates Brann's moving six-second Bulwark aura before incoming damage", () => {
|
||||
const result = simulate(["bulldrome"], { duration: 0.1, activeIds: ["brann"], stopOnVictory: false, upcomingMechanicRemaining: 1 });
|
||||
expect(result.combat.combatants.brann.visualAction?.abilityId).toBe("bulwark_march");
|
||||
expect(result.combat.tankAura.expiresAt).toBe(6);
|
||||
expect(tankAuraProtects([2.9, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(true);
|
||||
expect(tankAuraProtects([3.1, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(false);
|
||||
expect(tankAuraProtects([0, 0], [0, 0], result.combat.tankAura, 6)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses every button in each five-ability DPS rotation during a full fight", () => {
|
||||
const result = simulate(["bulldrome", "vexa"]);
|
||||
const movementPhase = simulate(["bulldrome"], { duration: 5, activeIds: ["nia"], movingIds: ["nia"], stopOnVictory: false });
|
||||
const usedAbilities = new Set([...result.usedAbilities, ...movementPhase.usedAbilities]);
|
||||
for (const memberId of ["nia", "orin", "vale"] as const) {
|
||||
expect([...usedAbilities]).toEqual(expect.arrayContaining([...PARTY_ABILITY_LOADOUTS[memberId]]));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dual-boss damage simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = [
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "cindermaw"],
|
||||
["vexa", "cindermaw"],
|
||||
];
|
||||
|
||||
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
|
||||
const result = simulate([first, second]);
|
||||
expect(result.targets.every((target) => target.boss.hp <= 0)).toBe(true);
|
||||
expect(result.time).toBeGreaterThan(35);
|
||||
expect(result.time).toBeLessThan(150);
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId]).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { distance } from "./geometry";
|
||||
import type { BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
export type AiCombatantId = Exclude<MemberId, "aelia">;
|
||||
|
||||
export type PartyAbilityId =
|
||||
| "sword_slash" | "shield_slam" | "revenge" | "sweeping_guard" | "bulwark_march"
|
||||
| "quick_shot" | "aimed_shot" | "rapid_fire" | "kill_shot" | "deadeye"
|
||||
| "arcane_bolt" | "ember_lance" | "arcane_burst" | "comet" | "overcharge"
|
||||
| "quick_cut" | "twin_fang" | "backstab" | "fan_of_blades" | "blade_flurry";
|
||||
|
||||
export const PARTY_ABILITY_NAMES: Record<PartyAbilityId, string> = {
|
||||
sword_slash: "Sword Slash",
|
||||
shield_slam: "Shield Slam",
|
||||
revenge: "Revenge",
|
||||
sweeping_guard: "Sweeping Guard",
|
||||
bulwark_march: "Bulwark March",
|
||||
quick_shot: "Quick Shot",
|
||||
aimed_shot: "Aimed Shot",
|
||||
rapid_fire: "Rapid Fire",
|
||||
kill_shot: "Kill Shot",
|
||||
deadeye: "Deadeye",
|
||||
arcane_bolt: "Arcane Bolt",
|
||||
ember_lance: "Ember Lance",
|
||||
arcane_burst: "Arcane Burst",
|
||||
comet: "Comet",
|
||||
overcharge: "Overcharge",
|
||||
quick_cut: "Quick Cut",
|
||||
twin_fang: "Twin Fang",
|
||||
backstab: "Backstab",
|
||||
fan_of_blades: "Fan of Blades",
|
||||
blade_flurry: "Blade Flurry",
|
||||
};
|
||||
|
||||
export const PARTY_ABILITY_LOADOUTS: Record<AiCombatantId, readonly PartyAbilityId[]> = {
|
||||
brann: ["sword_slash", "shield_slam", "revenge", "sweeping_guard", "bulwark_march"],
|
||||
nia: ["quick_shot", "aimed_shot", "rapid_fire", "kill_shot", "deadeye"],
|
||||
orin: ["arcane_bolt", "ember_lance", "arcane_burst", "comet", "overcharge"],
|
||||
vale: ["quick_cut", "twin_fang", "backstab", "fan_of_blades", "blade_flurry"],
|
||||
};
|
||||
|
||||
export interface PartyCombatTarget {
|
||||
instanceId: string;
|
||||
boss: BossState;
|
||||
motion: BossMotionState;
|
||||
}
|
||||
|
||||
export interface PartyDamageEvent {
|
||||
id: number;
|
||||
at: number;
|
||||
sourceId: AiCombatantId;
|
||||
abilityId: PartyAbilityId;
|
||||
targetInstanceId: string;
|
||||
amount: number;
|
||||
secondary: boolean;
|
||||
}
|
||||
|
||||
export interface PartyCombatAction {
|
||||
abilityId: PartyAbilityId;
|
||||
targetInstanceId: string;
|
||||
startedAt: number;
|
||||
completesAt: number;
|
||||
impactTimes: number[];
|
||||
nextImpactIndex: number;
|
||||
baseDamage: number;
|
||||
requiresStationary: boolean;
|
||||
multiplier: number;
|
||||
}
|
||||
|
||||
export interface PartyVisualAction {
|
||||
abilityId: PartyAbilityId;
|
||||
targetInstanceId: string;
|
||||
startedAt: number;
|
||||
impactAt: number;
|
||||
endsAt: number;
|
||||
}
|
||||
|
||||
export interface PartyCombatantState {
|
||||
id: AiCombatantId;
|
||||
readyAt: number;
|
||||
resource: number;
|
||||
points: number;
|
||||
cooldowns: Partial<Record<PartyAbilityId, number>>;
|
||||
activeAction: PartyCombatAction | null;
|
||||
visualAction: PartyVisualAction | null;
|
||||
overchargeStacks: number;
|
||||
bladeFlurryUntil: number;
|
||||
revengeReadyUntil: number;
|
||||
lastHp: number;
|
||||
damageDone: number;
|
||||
}
|
||||
|
||||
export interface TankAuraState {
|
||||
expiresAt: number;
|
||||
radius: number;
|
||||
damageReduction: number;
|
||||
}
|
||||
|
||||
export interface PartyCombatState {
|
||||
combatants: Record<AiCombatantId, PartyCombatantState>;
|
||||
tankAura: TankAuraState;
|
||||
nextEventId: number;
|
||||
}
|
||||
|
||||
export interface PartyCombatContext {
|
||||
oldTime: number;
|
||||
time: number;
|
||||
party: PartyMember[];
|
||||
oldPositions: Record<MemberId, WorldPosition>;
|
||||
positions: Record<MemberId, WorldPosition>;
|
||||
targets: PartyCombatTarget[];
|
||||
upcomingMechanicRemaining: number;
|
||||
}
|
||||
|
||||
interface AbilitySpec {
|
||||
id: PartyAbilityId;
|
||||
duration: number;
|
||||
impactOffsets: number[];
|
||||
damage: number;
|
||||
gcd: number;
|
||||
cooldown?: number;
|
||||
requiresStationary?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_AURA: TankAuraState = { expiresAt: 0, radius: 3, damageReduction: 0.3 };
|
||||
const RANGED_IDS: readonly AiCombatantId[] = ["nia", "orin"];
|
||||
const VALE_CLEAVE_RADIUS = 3.6;
|
||||
const VALE_MELEE_RANGE = 3.65;
|
||||
const BRANN_MELEE_RANGE = 4.8;
|
||||
const PARTY_DAMAGE_SCALE = 1.65;
|
||||
|
||||
function combatant(id: AiCombatantId, hp: number): PartyCombatantState {
|
||||
return {
|
||||
id,
|
||||
readyAt: 0,
|
||||
resource: id === "vale" ? 100 : 0,
|
||||
points: 0,
|
||||
cooldowns: {},
|
||||
activeAction: null,
|
||||
visualAction: null,
|
||||
overchargeStacks: 0,
|
||||
bladeFlurryUntil: 0,
|
||||
revengeReadyUntil: 0,
|
||||
lastHp: hp,
|
||||
damageDone: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPartyCombatState(party: PartyMember[]): PartyCombatState {
|
||||
const hp = (id: AiCombatantId) => party.find((member) => member.id === id)?.hp ?? 0;
|
||||
return {
|
||||
combatants: {
|
||||
brann: combatant("brann", hp("brann")),
|
||||
nia: combatant("nia", hp("nia")),
|
||||
orin: combatant("orin", hp("orin")),
|
||||
vale: combatant("vale", hp("vale")),
|
||||
},
|
||||
tankAura: { ...EMPTY_AURA },
|
||||
nextEventId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCombatant(source: PartyCombatantState): PartyCombatantState {
|
||||
return {
|
||||
...source,
|
||||
cooldowns: { ...source.cooldowns },
|
||||
activeAction: source.activeAction ? { ...source.activeAction, impactTimes: [...source.activeAction.impactTimes] } : null,
|
||||
visualAction: source.visualAction ? { ...source.visualAction } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function isReady(actor: PartyCombatantState, abilityId: PartyAbilityId, at: number) {
|
||||
return (actor.cooldowns[abilityId] ?? 0) <= at + 0.001;
|
||||
}
|
||||
|
||||
function moving(id: AiCombatantId, context: PartyCombatContext) {
|
||||
const elapsed = Math.max(0.001, context.time - context.oldTime);
|
||||
return distance(context.oldPositions[id], context.positions[id]) / elapsed > 0.35;
|
||||
}
|
||||
|
||||
function livingTargets(targets: PartyCombatTarget[]) {
|
||||
return targets.filter((target) => target.boss.hp > 0);
|
||||
}
|
||||
|
||||
function targetsInRange(source: WorldPosition, targets: PartyCombatTarget[], range: number) {
|
||||
return livingTargets(targets).filter((target) => distance(source, target.motion.position) <= range);
|
||||
}
|
||||
|
||||
function targetFor(id: AiCombatantId, context: PartyCombatContext, range = Number.POSITIVE_INFINITY) {
|
||||
const eligible = targetsInRange(context.positions[id], context.targets, range);
|
||||
if (!eligible.length) return undefined;
|
||||
if (id === "orin" && eligible.length > 1) return eligible[1];
|
||||
if (RANGED_IDS.includes(id)) return eligible[0];
|
||||
let nearest = eligible[0];
|
||||
for (let index = 1; index < eligible.length; index += 1) {
|
||||
if (distance(context.positions[id], eligible[index].motion.position) < distance(context.positions[id], nearest.motion.position)) nearest = eligible[index];
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function partyNeedsBulwark(context: PartyCombatContext) {
|
||||
const brann = context.party.find((member) => member.id === "brann");
|
||||
const lowMembers = context.party.filter((member) => member.hp > 0 && member.hp / member.maxHp < 0.6).length;
|
||||
return context.upcomingMechanicRemaining <= 2 || (brann?.hp ?? 0) / Math.max(1, brann?.maxHp ?? 1) < 0.7 || lowMembers >= 2;
|
||||
}
|
||||
|
||||
function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean, context: PartyCombatContext): AbilitySpec | null {
|
||||
const source = context.positions[actor.id];
|
||||
const rangedTarget = targetFor(actor.id, context);
|
||||
if (actor.id === "nia") {
|
||||
if (!rangedTarget) return null;
|
||||
if (rangedTarget.boss.hp / rangedTarget.boss.maxHp <= 0.25 && isReady(actor, "kill_shot", at)) return { id: "kill_shot", duration: 0.45, impactOffsets: [0.24], damage: 7, gcd: 1.1, cooldown: 10 };
|
||||
if (!isMoving && isReady(actor, "rapid_fire", at)) return { id: "rapid_fire", duration: 2, impactOffsets: [0.4, 0.8, 1.2, 1.6], damage: 2, gcd: 2, cooldown: 9, requiresStationary: true };
|
||||
if (!isMoving && actor.resource >= 50) return { id: "deadeye", duration: 1, impactOffsets: [1], damage: 10, gcd: 1.1, requiresStationary: true };
|
||||
if (!isMoving) return { id: "aimed_shot", duration: 1.5, impactOffsets: [1.5], damage: 4, gcd: 1.5, requiresStationary: true };
|
||||
return { id: "quick_shot", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.15 };
|
||||
}
|
||||
|
||||
if (actor.id === "orin") {
|
||||
if (!rangedTarget) return null;
|
||||
if (!isMoving && actor.overchargeStacks === 0 && isReady(actor, "overcharge", at)) return { id: "overcharge", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 20 };
|
||||
if (!isMoving && isReady(actor, "comet", at)) return { id: "comet", duration: 2, impactOffsets: [2], damage: 9, gcd: 2, cooldown: 12, requiresStationary: true };
|
||||
if (!isMoving && actor.points >= 3) return { id: "arcane_burst", duration: 1, impactOffsets: [1], damage: 8, gcd: 1.1, requiresStationary: true };
|
||||
if (isReady(actor, "ember_lance", at)) return { id: "ember_lance", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.1, cooldown: 4 };
|
||||
if (isMoving) return null;
|
||||
return { id: "arcane_bolt", duration: 1.4, impactOffsets: [1.4], damage: 3, gcd: 1.4, requiresStationary: true };
|
||||
}
|
||||
|
||||
if (actor.id === "vale") {
|
||||
const nearby = targetsInRange(source, context.targets, VALE_CLEAVE_RADIUS);
|
||||
const target = targetFor(actor.id, context, VALE_MELEE_RANGE);
|
||||
if (!target) return null;
|
||||
if (nearby.length > 1 && actor.bladeFlurryUntil <= at && actor.resource >= 20 && isReady(actor, "blade_flurry", at)) return { id: "blade_flurry", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 15 };
|
||||
if (nearby.length > 1 && actor.resource >= 35 && isReady(actor, "fan_of_blades", at)) return { id: "fan_of_blades", duration: 0.65, impactOffsets: [0.4], damage: 3, gcd: 1.1, cooldown: 6 };
|
||||
if (actor.points >= 3 && actor.resource >= 25) return { id: "backstab", duration: 0.7, impactOffsets: [0.45], damage: 5, gcd: 1.1 };
|
||||
if (nearby.length > 1 && actor.resource >= 30) return { id: "twin_fang", duration: 0.65, impactOffsets: [0.4], damage: 3, gcd: 1.1 };
|
||||
return { id: "quick_cut", duration: 0.55, impactOffsets: [0.32], damage: 2, gcd: 1.1 };
|
||||
}
|
||||
|
||||
const target = targetFor(actor.id, context, BRANN_MELEE_RANGE);
|
||||
if (!target) return null;
|
||||
if (partyNeedsBulwark(context) && isReady(actor, "bulwark_march", at)) return { id: "bulwark_march", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 30 };
|
||||
if (actor.revengeReadyUntil > at && isReady(actor, "revenge", at)) return { id: "revenge", duration: 0.6, impactOffsets: [0.35], damage: 3, gcd: 1.1, cooldown: 5 };
|
||||
if (isReady(actor, "shield_slam", at)) return { id: "shield_slam", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 6 };
|
||||
if (targetsInRange(source, context.targets, BRANN_MELEE_RANGE).length > 1 && isReady(actor, "sweeping_guard", at)) return { id: "sweeping_guard", duration: 0.65, impactOffsets: [0.4], damage: 2, gcd: 1.1, cooldown: 4 };
|
||||
return { id: "sword_slash", duration: 0.6, impactOffsets: [0.35], damage: 1, gcd: 1.2 };
|
||||
}
|
||||
|
||||
function applyStartCosts(actor: PartyCombatantState, spec: AbilitySpec, at: number, state: PartyCombatState) {
|
||||
if (spec.cooldown) actor.cooldowns[spec.id] = at + spec.cooldown;
|
||||
if (spec.id === "quick_shot") actor.resource = Math.min(100, actor.resource + 10);
|
||||
if (spec.id === "aimed_shot") actor.resource = Math.min(100, actor.resource + 15);
|
||||
if (spec.id === "deadeye") actor.resource -= 50;
|
||||
if (spec.id === "arcane_bolt") actor.points = Math.min(3, actor.points + 1);
|
||||
if (spec.id === "arcane_burst") actor.points = 0;
|
||||
if (spec.id === "overcharge") actor.overchargeStacks = 3;
|
||||
if (spec.id === "quick_cut") actor.points = Math.min(5, actor.points + 1);
|
||||
if (spec.id === "twin_fang") actor.resource -= 30;
|
||||
if (spec.id === "backstab") { actor.resource -= 25; actor.points = Math.max(0, actor.points - 3); }
|
||||
if (spec.id === "fan_of_blades") actor.resource -= 35;
|
||||
if (spec.id === "blade_flurry") { actor.resource -= 20; actor.bladeFlurryUntil = at + 8; }
|
||||
if (spec.id === "bulwark_march") state.tankAura.expiresAt = at + 6;
|
||||
}
|
||||
|
||||
function startAction(actor: PartyCombatantState, spec: AbilitySpec, target: PartyCombatTarget, at: number, state: PartyCombatState) {
|
||||
applyStartCosts(actor, spec, at, state);
|
||||
let multiplier = 1;
|
||||
if (actor.id === "orin" && spec.damage > 0 && actor.overchargeStacks > 0) {
|
||||
multiplier = 1.25;
|
||||
actor.overchargeStacks -= 1;
|
||||
}
|
||||
actor.readyAt = at + spec.gcd;
|
||||
actor.activeAction = {
|
||||
abilityId: spec.id,
|
||||
targetInstanceId: target.instanceId,
|
||||
startedAt: at,
|
||||
completesAt: at + spec.duration,
|
||||
impactTimes: spec.impactOffsets.map((offset) => at + offset),
|
||||
nextImpactIndex: 0,
|
||||
baseDamage: spec.damage,
|
||||
requiresStationary: spec.requiresStationary ?? false,
|
||||
multiplier,
|
||||
};
|
||||
actor.visualAction = {
|
||||
abilityId: spec.id,
|
||||
targetInstanceId: target.instanceId,
|
||||
startedAt: at,
|
||||
impactAt: spec.impactOffsets.length ? at + spec.impactOffsets[0] : at + spec.duration,
|
||||
endsAt: at + Math.max(spec.duration, spec.gcd),
|
||||
};
|
||||
}
|
||||
|
||||
function damageTarget(
|
||||
state: PartyCombatState,
|
||||
actor: PartyCombatantState,
|
||||
action: PartyCombatAction,
|
||||
target: PartyCombatTarget,
|
||||
amount: number,
|
||||
at: number,
|
||||
secondary: boolean,
|
||||
events: PartyDamageEvent[],
|
||||
) {
|
||||
if (target.boss.hp <= 0 || amount <= 0) return;
|
||||
const dealt = Math.min(target.boss.hp, amount * action.multiplier * PARTY_DAMAGE_SCALE);
|
||||
target.boss.hp -= dealt;
|
||||
actor.damageDone += dealt;
|
||||
events.push({ id: state.nextEventId++, at, sourceId: actor.id, abilityId: action.abilityId, targetInstanceId: target.instanceId, amount: dealt, secondary });
|
||||
}
|
||||
|
||||
function resolveImpact(state: PartyCombatState, actor: PartyCombatantState, action: PartyCombatAction, at: number, context: PartyCombatContext, targets: PartyCombatTarget[], events: PartyDamageEvent[]) {
|
||||
const source = context.positions[actor.id];
|
||||
const range = actor.id === "vale" ? VALE_MELEE_RANGE : actor.id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
|
||||
let target = targets.find((entry) => entry.instanceId === action.targetInstanceId && entry.boss.hp > 0 && distance(source, entry.motion.position) <= range);
|
||||
target ??= targetFor(actor.id, { ...context, targets }, range);
|
||||
if (!target) return;
|
||||
|
||||
if (action.abilityId === "fan_of_blades" || action.abilityId === "sweeping_guard") {
|
||||
const radius = action.abilityId === "fan_of_blades" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE;
|
||||
for (const nearby of targetsInRange(source, targets, radius)) damageTarget(state, actor, action, nearby, action.baseDamage, at, nearby.instanceId !== target.instanceId, events);
|
||||
return;
|
||||
}
|
||||
|
||||
damageTarget(state, actor, action, target, action.baseDamage, at, false, events);
|
||||
const secondaryTargets = targetsInRange(source, targets, actor.id === "vale" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE).filter((entry) => entry.instanceId !== target!.instanceId);
|
||||
if (action.abilityId === "twin_fang") {
|
||||
for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, 2, at, true, events);
|
||||
} else if (actor.id === "vale" && actor.bladeFlurryUntil > at && !["fan_of_blades", "blade_flurry"].includes(action.abilityId)) {
|
||||
for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, action.baseDamage * 0.5, at, true, events);
|
||||
}
|
||||
}
|
||||
|
||||
export function advancePartyCombat(source: PartyCombatState, context: PartyCombatContext) {
|
||||
const state: PartyCombatState = {
|
||||
combatants: {
|
||||
brann: cloneCombatant(source.combatants.brann),
|
||||
nia: cloneCombatant(source.combatants.nia),
|
||||
orin: cloneCombatant(source.combatants.orin),
|
||||
vale: cloneCombatant(source.combatants.vale),
|
||||
},
|
||||
tankAura: { ...source.tankAura },
|
||||
nextEventId: source.nextEventId,
|
||||
};
|
||||
const targets = context.targets.map((target) => ({ ...target, boss: { ...target.boss } }));
|
||||
const events: PartyDamageEvent[] = [];
|
||||
const elapsed = context.time - context.oldTime;
|
||||
|
||||
for (const id of ["brann", "nia", "orin", "vale"] as const) {
|
||||
const actor = state.combatants[id];
|
||||
const member = context.party.find((entry) => entry.id === id);
|
||||
if (!member || member.hp <= 0) continue;
|
||||
if (member.knockedUntil > context.time) {
|
||||
actor.activeAction = null;
|
||||
actor.readyAt = Math.max(actor.readyAt, member.knockedUntil);
|
||||
continue;
|
||||
}
|
||||
if (id === "vale") actor.resource = Math.min(100, actor.resource + 12 * elapsed);
|
||||
if (id === "brann" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5;
|
||||
actor.lastHp = member.hp;
|
||||
const isMoving = moving(id, context);
|
||||
if (isMoving && actor.activeAction?.requiresStationary) {
|
||||
actor.activeAction = null;
|
||||
actor.readyAt = Math.max(actor.readyAt, context.oldTime + 0.2);
|
||||
if (actor.visualAction) actor.visualAction.endsAt = context.oldTime;
|
||||
}
|
||||
|
||||
for (let safety = 0; safety < 12; safety += 1) {
|
||||
const action = actor.activeAction;
|
||||
if (action) {
|
||||
while (action.nextImpactIndex < action.impactTimes.length && action.impactTimes[action.nextImpactIndex] <= context.time + 0.001) {
|
||||
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], context, targets, events);
|
||||
action.nextImpactIndex += 1;
|
||||
}
|
||||
if (action.completesAt > context.time + 0.001) break;
|
||||
actor.activeAction = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const startAt = Math.max(context.oldTime, actor.readyAt);
|
||||
if (startAt > context.time + 0.001) break;
|
||||
const spec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
|
||||
if (!spec) { actor.readyAt = context.time + 0.1; break; }
|
||||
const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
|
||||
const target = targetFor(id, { ...context, targets }, range);
|
||||
if (!target) { actor.readyAt = context.time + 0.1; break; }
|
||||
startAction(actor, spec, target, startAt, state);
|
||||
}
|
||||
}
|
||||
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
export function tankAuraProtects(position: WorldPosition, tankPosition: WorldPosition, aura: TankAuraState, time: number) {
|
||||
return aura.expiresAt > time && distance(position, tankPosition) <= aura.radius;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { distance, pointToSegmentDistance } from "./geometry";
|
||||
import { barrierProtects, useGameStore } from "./store";
|
||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
|
||||
|
||||
describe("Disc Priest combat simulation", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
|
||||
useGameStore.getState().startEncounter();
|
||||
});
|
||||
|
||||
it("resolves Mend after a 0.5 second cast with no cooldown", () => {
|
||||
const party = useGameStore.getState().party.map((member) =>
|
||||
member.id === "nia" ? { ...member, hp: 40 } : member,
|
||||
);
|
||||
useGameStore.setState({ party });
|
||||
useGameStore.getState().selectMember("nia");
|
||||
|
||||
expect(useGameStore.getState().castAbility("mend")).toBe(true);
|
||||
useGameStore.getState().tick(0.4);
|
||||
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.hp).toBe(40);
|
||||
useGameStore.getState().tick(0.11);
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.party.find((member) => member.id === "nia")?.hp).toBe(78);
|
||||
expect(state.activeCast).toBeNull();
|
||||
expect(state.cooldowns.mend).toBe(0);
|
||||
expect(state.castAbility("mend")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the rebalanced Priest mana costs", () => {
|
||||
expect(Object.fromEntries(Object.entries(HEALER_CLASSES.priest.abilities).map(([id, ability]) => [id, ability.mana]))).toEqual({
|
||||
mend: 5,
|
||||
renew: 7,
|
||||
shield: 8,
|
||||
purify: 5,
|
||||
radiance: 12,
|
||||
barrier: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a three-second Purify cooldown for every healer class", () => {
|
||||
expect(HEALER_CLASSES.priest.abilities.purify.cooldown).toBe(3);
|
||||
expect(HEALER_CLASSES.druid.abilities.purify.cooldown).toBe(3);
|
||||
expect(HEALER_CLASSES.shaman.abilities.purify.cooldown).toBe(3);
|
||||
});
|
||||
|
||||
it("configures placeholder healer kits and class-owned inventory", () => {
|
||||
const inventory = createClassInventory("druid");
|
||||
useGameStore.getState().configureHealer("druid", "Aelia", inventory);
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.healerClassId).toBe("druid");
|
||||
expect(state.party[0].className).toBe("Restoration Druid");
|
||||
expect(state.party[0].name).toBe("Aelia");
|
||||
expect(state.inventory).toEqual(inventory);
|
||||
expect(HEALER_CLASSES.druid.abilities.mend.name).toBe("Healing Touch");
|
||||
expect(HEALER_CLASSES.shaman.abilities.radiance.name).toBe("Chain Heal");
|
||||
});
|
||||
|
||||
it("ticks Renew once per second for eight seconds", () => {
|
||||
const party = useGameStore.getState().party.map((member) =>
|
||||
member.id === "brann" ? { ...member, hp: 70 } : member,
|
||||
);
|
||||
useGameStore.setState({ party });
|
||||
useGameStore.getState().castAbility("renew");
|
||||
|
||||
for (let index = 0; index < 8; index += 1) useGameStore.getState().tick(1);
|
||||
|
||||
const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
|
||||
expect(brann.renewExpiresAt).toBe(0);
|
||||
expect(brann.hp).toBeGreaterThan(70);
|
||||
});
|
||||
|
||||
it("gives Renew no individual cooldown", () => {
|
||||
expect(useGameStore.getState().castAbility("renew")).toBe(true);
|
||||
expect(useGameStore.getState().cooldowns.renew).toBe(0);
|
||||
|
||||
useGameStore.getState().tick(0.5);
|
||||
expect(useGameStore.getState().castAbility("renew")).toBe(true);
|
||||
expect(useGameStore.getState().cooldowns.renew).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks all abilities during the shared 0.5 second global cooldown", () => {
|
||||
expect(useGameStore.getState().castAbility("renew")).toBe(true);
|
||||
expect(useGameStore.getState().castAbility("shield")).toBe(false);
|
||||
|
||||
useGameStore.getState().tick(0.49);
|
||||
expect(useGameStore.getState().castAbility("shield")).toBe(false);
|
||||
|
||||
useGameStore.getState().tick(0.02);
|
||||
expect(useGameStore.getState().castAbility("shield")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses blue absorption before health", () => {
|
||||
useGameStore.getState().castAbility("shield");
|
||||
useGameStore.getState().tick(2);
|
||||
|
||||
const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
|
||||
expect(brann.hp).toBe(150);
|
||||
expect(brann.absorb).toBe(21);
|
||||
});
|
||||
|
||||
it("Purify removes Ember Brand from selected ally", () => {
|
||||
useGameStore.getState().tick(2);
|
||||
useGameStore.getState().tick(2);
|
||||
useGameStore.getState().tick(1.1);
|
||||
const branded = useGameStore.getState().party.find((member) => member.id === "nia")!;
|
||||
expect(branded.debuffs).toHaveLength(1);
|
||||
|
||||
useGameStore.getState().selectMember("nia");
|
||||
expect(useGameStore.getState().castAbility("purify")).toBe(true);
|
||||
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.debuffs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Radiance heals every living party member", () => {
|
||||
useGameStore.setState({
|
||||
party: useGameStore.getState().party.map((member) => ({ ...member, hp: member.hp - 30 })),
|
||||
});
|
||||
useGameStore.getState().castAbility("radiance");
|
||||
|
||||
for (const member of useGameStore.getState().party) {
|
||||
expect(member.hp).toBe(member.maxHp - 8);
|
||||
}
|
||||
});
|
||||
|
||||
it("reduces damage by 30% for party members inside Barrier", () => {
|
||||
useGameStore.getState().setPlayerPosition([0, -3.7]);
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextNovaAt: 999, nextBrandAt: 999 },
|
||||
bossMotion: { ...state.bossMotion, nextChargeAt: 999 },
|
||||
}));
|
||||
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
|
||||
useGameStore.getState().tick(2);
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.party.find((member) => member.id === "brann")?.hp).toBeCloseTo(139.5, 4);
|
||||
expect(barrierProtects(state.partyPositions.brann, state.barrier, state.time)).toBe(true);
|
||||
expect(state.cooldowns.barrier).toBe(60);
|
||||
});
|
||||
|
||||
it("expires Barrier after eight seconds", () => {
|
||||
useGameStore.getState().castAbility("barrier");
|
||||
const barrier = useGameStore.getState().barrier;
|
||||
expect(barrierProtects(barrier.center, barrier, 7.99)).toBe(true);
|
||||
expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps ranged allies stable while Vale closes to melee range", () => {
|
||||
const start = structuredClone(useGameStore.getState().partyPositions);
|
||||
const bossPosition = useGameStore.getState().bossMotion.position;
|
||||
useGameStore.getState().tick(1);
|
||||
const moved = useGameStore.getState().partyPositions;
|
||||
expect(moved.aelia).toEqual(start.aelia);
|
||||
expect(moved.brann).toEqual(start.brann);
|
||||
expect(moved.nia).toEqual(start.nia);
|
||||
expect(moved.orin).toEqual(start.orin);
|
||||
expect(distance(moved.vale, bossPosition)).toBeLessThan(distance(start.vale, bossPosition));
|
||||
});
|
||||
|
||||
it("freezes authoritative simulation while paused", () => {
|
||||
useGameStore.getState().tick(1);
|
||||
const before = useGameStore.getState();
|
||||
useGameStore.getState().setPaused(true);
|
||||
useGameStore.getState().tick(5);
|
||||
|
||||
const paused = useGameStore.getState();
|
||||
expect(paused.time).toBe(before.time);
|
||||
expect(paused.party).toEqual(before.party);
|
||||
expect(paused.boss.hp).toBe(before.boss.hp);
|
||||
expect(paused.castAbility("renew")).toBe(false);
|
||||
});
|
||||
|
||||
it("telegraphs, executes, and recovers from a Bull charge", () => {
|
||||
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
|
||||
const telegraph = useGameStore.getState().bossMotion;
|
||||
expect(telegraph.mode).toBe("telegraph");
|
||||
expect(telegraph.chargeTargetId).toBe("nia");
|
||||
|
||||
const midpoint: [number, number] = [
|
||||
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
|
||||
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
|
||||
];
|
||||
useGameStore.setState((state) => ({
|
||||
partyPositions: { ...state.partyPositions, nia: midpoint },
|
||||
party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member),
|
||||
}));
|
||||
|
||||
while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
|
||||
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes("nia"); step += 1) {
|
||||
useGameStore.getState().tick(0.05);
|
||||
}
|
||||
|
||||
const hitState = useGameStore.getState();
|
||||
expect(hitState.bossMotion.chargeHitIds).toContain("nia");
|
||||
expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
|
||||
|
||||
for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
const recovered = useGameStore.getState();
|
||||
expect(recovered.bossMotion.mode).toBe("holding");
|
||||
expect(recovered.bossMotion.nextChargeAt).toBeGreaterThan(recovered.time);
|
||||
});
|
||||
|
||||
it("moves every mobile AI party member out of the charge lane before impact", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
||||
}));
|
||||
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
|
||||
const warning = useGameStore.getState().bossMotion;
|
||||
const exposedAtWarning = (["brann", "nia", "orin", "vale"] as const).filter((memberId) =>
|
||||
pointToSegmentDistance(
|
||||
useGameStore.getState().partyPositions[memberId],
|
||||
warning.chargeStart,
|
||||
warning.chargeEnd,
|
||||
) <= BULL_CHARGE.hitRadius,
|
||||
);
|
||||
expect(exposedAtWarning).toContain(warning.chargeTargetId);
|
||||
|
||||
while (useGameStore.getState().bossMotion.mode === "telegraph") {
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
const charge = useGameStore.getState();
|
||||
for (const memberId of exposedAtWarning) {
|
||||
expect(pointToSegmentDistance(
|
||||
charge.partyPositions[memberId],
|
||||
charge.bossMotion.chargeStart,
|
||||
charge.bossMotion.chargeEnd,
|
||||
)).toBeGreaterThan(BULL_CHARGE.hitRadius);
|
||||
}
|
||||
|
||||
while (useGameStore.getState().bossMotion.mode === "charging") {
|
||||
useGameStore.getState().tick(0.05);
|
||||
}
|
||||
const hitIds = useGameStore.getState().bossMotion.chargeHitIds;
|
||||
for (const memberId of exposedAtWarning) expect(hitIds).not.toContain(memberId);
|
||||
});
|
||||
|
||||
it("marks a stack target after three charges and splits 300 pounce damage", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
||||
playerPosition: [0, 0],
|
||||
partyPositions: {
|
||||
aelia: [0, 0],
|
||||
brann: [0, 0],
|
||||
nia: [0, 0],
|
||||
orin: [0, 0],
|
||||
vale: [0, 0],
|
||||
},
|
||||
bossMotion: {
|
||||
...state.bossMotion,
|
||||
mode: "returning",
|
||||
position: [0, -4.25],
|
||||
chargesSincePounce: 3,
|
||||
},
|
||||
}));
|
||||
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
|
||||
|
||||
useGameStore.getState().tick(0.05);
|
||||
expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
|
||||
expect(useGameStore.getState().bossMotion.pounceTargetId).toBe("aelia");
|
||||
expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
|
||||
|
||||
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
expect(useGameStore.getState().bossMotion.mode).toBe("pouncing");
|
||||
|
||||
for (let step = 0; step < 20 && useGameStore.getState().bossMotion.mode === "pouncing"; step += 1) {
|
||||
useGameStore.getState().tick(0.05);
|
||||
}
|
||||
const impacted = useGameStore.getState();
|
||||
expect(impacted.bossMotion.mode).toBe("returning");
|
||||
for (const member of impacted.party) {
|
||||
expect(member.hp).toBeCloseTo(startingHp[member.id] - 42, 3);
|
||||
}
|
||||
expect(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vexa encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "vexa");
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
|
||||
});
|
||||
|
||||
it("lets modular party behavior spread tethered allies until Binding Web snaps", () => {
|
||||
while (useGameStore.getState().bossMotion.mode !== "tethering") useGameStore.getState().tick(0.1);
|
||||
const tether = useGameStore.getState().bossMotion;
|
||||
expect(tether.tetherIds).toEqual(["brann", "vale"]);
|
||||
const [first, second] = tether.tetherIds;
|
||||
expect(Math.hypot(
|
||||
useGameStore.getState().partyPositions[first][0] - useGameStore.getState().partyPositions[second][0],
|
||||
useGameStore.getState().partyPositions[first][1] - useGameStore.getState().partyPositions[second][1],
|
||||
)).toBeLessThan(tether.tetherBreakDistance);
|
||||
|
||||
for (let step = 0; step < 50 && useGameStore.getState().bossMotion.mode === "tethering"; step += 1) {
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
expect(useGameStore.getState().bossMotion.mode).toBe("holding");
|
||||
expect(useGameStore.getState().bossMotion.tetherIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a persistent venom pool when Widow Venom is purified", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
|
||||
}));
|
||||
useGameStore.getState().tick(0.05);
|
||||
const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
|
||||
expect(poisoned).toBeDefined();
|
||||
|
||||
useGameStore.getState().selectMember(poisoned.id);
|
||||
expect(useGameStore.getState().castAbility("purify")).toBe(true);
|
||||
const state = useGameStore.getState();
|
||||
expect(state.party.find((member) => member.id === poisoned.id)?.debuffs).toEqual([]);
|
||||
expect(state.bossMotion.hazards).toHaveLength(1);
|
||||
expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
|
||||
});
|
||||
|
||||
it("repeatedly damages the player while they remain in a venom pool", () => {
|
||||
useGameStore.getState().setPlayerPosition([0, 0]);
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||
bossMotion: {
|
||||
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
|
||||
nextMechanicAt: 999,
|
||||
},
|
||||
}));
|
||||
const startingHp = useGameStore.getState().party[0].hp;
|
||||
|
||||
useGameStore.getState().tick(0.3);
|
||||
const firstTickHp = useGameStore.getState().party[0].hp;
|
||||
useGameStore.getState().tick(1);
|
||||
const secondTickHp = useGameStore.getState().party[0].hp;
|
||||
|
||||
expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage);
|
||||
expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage);
|
||||
|
||||
useGameStore.getState().setPlayerPosition([6, 6]);
|
||||
useGameStore.getState().tick(1);
|
||||
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
|
||||
});
|
||||
|
||||
it("follows Brann when the tank is displaced", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
partyPositions: { ...state.partyPositions, brann: [3, 1] },
|
||||
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
|
||||
}));
|
||||
const startX = useGameStore.getState().bossMotion.position[0];
|
||||
useGameStore.getState().tick(0.5);
|
||||
expect(useGameStore.getState().bossMotion.position[0]).toBeGreaterThan(startX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PVE dual-boss encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["vexa", "cindermaw"]);
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, nextMeleeAt: 999 } })),
|
||||
}));
|
||||
});
|
||||
|
||||
it("runs two distinct bosses concurrently and requires both to fall", () => {
|
||||
const initial = useGameStore.getState();
|
||||
expect(initial.boss.id).toBe("vexa");
|
||||
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["cindermaw"]);
|
||||
expect(initial.bossMotion.position[0]).toBeLessThan(initial.additionalBosses[0].motion.position[0]);
|
||||
|
||||
useGameStore.getState().tick(1);
|
||||
const damaged = useGameStore.getState();
|
||||
const primaryDamage = damaged.partyDamageEvents
|
||||
.filter((event) => event.targetInstanceId === `boss-0-${damaged.boss.id}`)
|
||||
.reduce((sum, event) => sum + event.amount, 0);
|
||||
const secondaryDamage = damaged.partyDamageEvents
|
||||
.filter((event) => event.targetInstanceId === damaged.additionalBosses[0].instanceId)
|
||||
.reduce((sum, event) => sum + event.amount, 0);
|
||||
expect(primaryDamage + secondaryDamage).toBeGreaterThan(0);
|
||||
expect(damaged.boss.hp).toBeCloseTo(damaged.boss.maxHp - primaryDamage, 4);
|
||||
expect(damaged.additionalBosses[0].boss.hp).toBeCloseTo(damaged.additionalBosses[0].boss.maxHp - secondaryDamage, 4);
|
||||
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, hp: 0 },
|
||||
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 1 } })),
|
||||
}));
|
||||
useGameStore.getState().tick(0.05);
|
||||
expect(useGameStore.getState().phase).toBe("combat");
|
||||
|
||||
useGameStore.getState().tick(1);
|
||||
expect(useGameStore.getState().phase).toBe("victory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cindermaw encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
|
||||
});
|
||||
|
||||
it("rotates Searing Sweep while AI party behavior moves to safe flanks", () => {
|
||||
while (useGameStore.getState().bossMotion.mode !== "breath_telegraph") useGameStore.getState().tick(0.1);
|
||||
expect(useGameStore.getState().bossMotion.breathEndAngle).not.toBe(useGameStore.getState().bossMotion.breathStartAngle);
|
||||
|
||||
for (let step = 0; step < 80 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
const hitIds = useGameStore.getState().bossMotion.mechanicHitIds;
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hitIds).not.toContain(memberId);
|
||||
});
|
||||
|
||||
it("creates three staggered Skyfall impacts and moves AI clear", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
|
||||
}));
|
||||
useGameStore.getState().tick(0.05);
|
||||
const warning = useGameStore.getState();
|
||||
expect(warning.bossMotion.mode).toBe("skyfall");
|
||||
expect(warning.bossMotion.hazards).toHaveLength(3);
|
||||
expect(warning.bossMotion.hazards[1].activatesAt).toBeGreaterThan(warning.bossMotion.hazards[0].activatesAt);
|
||||
|
||||
for (let step = 0; step < 45; step += 1) useGameStore.getState().tick(0.1);
|
||||
expect(useGameStore.getState().bossMotion.hazards).toHaveLength(3);
|
||||
for (const hazard of useGameStore.getState().bossMotion.hazards) {
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hazard.hitIds).not.toContain(memberId);
|
||||
}
|
||||
});
|
||||
|
||||
it("deals repeated Searing Sweep damage until the exposed player moves out", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
|
||||
bossMotion: {
|
||||
...state.bossMotion,
|
||||
mode: "breath_sweeping",
|
||||
position: [0, -2.8],
|
||||
phaseStartedAt: state.time,
|
||||
phaseEndsAt: state.time + 3,
|
||||
breathAngle: 0,
|
||||
breathStartAngle: 0,
|
||||
breathEndAngle: 0,
|
||||
mechanicHitIds: [],
|
||||
mechanicNextDamageAt: {},
|
||||
},
|
||||
}));
|
||||
const startingHp = useGameStore.getState().party[0].hp;
|
||||
|
||||
useGameStore.getState().tick(0.1);
|
||||
const firstTickHp = useGameStore.getState().party[0].hp;
|
||||
useGameStore.getState().tick(0.5);
|
||||
const secondTickHp = useGameStore.getState().party[0].hp;
|
||||
|
||||
expect(firstTickHp).toBeLessThan(startingHp);
|
||||
expect(secondTickHp).toBeLessThan(firstTickHp);
|
||||
|
||||
useGameStore.getState().setPlayerPosition([6, 6]);
|
||||
useGameStore.getState().tick(0.5);
|
||||
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
advanceBossMechanics,
|
||||
createBossMotionState,
|
||||
createBossState,
|
||||
handleBossDispel,
|
||||
upcomingMechanic,
|
||||
} from "./bossMechanics";
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { cloneMotion } from "./bosses/shared";
|
||||
import { freshParty } from "./data";
|
||||
import { distance } from "./geometry";
|
||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||
import type {
|
||||
ActiveCast,
|
||||
AbilityId,
|
||||
BarrierState,
|
||||
BossMotionState,
|
||||
BossState,
|
||||
BossId,
|
||||
BottomTab,
|
||||
GamePhase,
|
||||
HealerClassId,
|
||||
InventoryItem,
|
||||
MemberId,
|
||||
PartyMember,
|
||||
ScenePulse,
|
||||
WorldPosition,
|
||||
} from "./types";
|
||||
|
||||
export interface CombatLogEntry {
|
||||
id: number;
|
||||
time: number;
|
||||
message: string;
|
||||
tone: "good" | "danger" | "neutral";
|
||||
}
|
||||
|
||||
export interface AdditionalBossState {
|
||||
instanceId: string;
|
||||
boss: BossState;
|
||||
motion: BossMotionState;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
bossId: BossId;
|
||||
paused: boolean;
|
||||
pauseSelection: "resume" | "exit";
|
||||
healerClassId: HealerClassId;
|
||||
playerName: string;
|
||||
phase: GamePhase;
|
||||
time: number;
|
||||
party: PartyMember[];
|
||||
boss: BossState;
|
||||
additionalBosses: AdditionalBossState[];
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
bossMotion: BossMotionState;
|
||||
partyCombat: PartyCombatState;
|
||||
partyDamageEvents: PartyDamageEvent[];
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
selectedMemberId: MemberId;
|
||||
cooldowns: Record<AbilityId, number>;
|
||||
globalCooldownUntil: number;
|
||||
activeTab: BottomTab;
|
||||
selectedItemId: string;
|
||||
inventory: InventoryItem[];
|
||||
combatLog: CombatLogEntry[];
|
||||
scenePulse: ScenePulse;
|
||||
playerPosition: [number, number];
|
||||
activeCast: ActiveCast | null;
|
||||
barrier: BarrierState;
|
||||
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[]) => void;
|
||||
startEncounter: () => void;
|
||||
restart: () => void;
|
||||
tick: (delta: number) => void;
|
||||
castAbility: (abilityId: AbilityId) => boolean;
|
||||
selectMember: (memberId: MemberId) => void;
|
||||
cycleMember: (direction: 1 | -1) => void;
|
||||
setActiveTab: (tab: BottomTab) => void;
|
||||
selectItem: (itemId: string) => void;
|
||||
setPlayerPosition: (position: [number, number]) => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
togglePause: () => void;
|
||||
setPauseSelection: (selection: "resume" | "exit") => void;
|
||||
}
|
||||
|
||||
const emptyCooldowns = (): Record<AbilityId, number> => ({
|
||||
mend: 0,
|
||||
renew: 0,
|
||||
shield: 0,
|
||||
purify: 0,
|
||||
radiance: 0,
|
||||
barrier: 0,
|
||||
});
|
||||
|
||||
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
||||
|
||||
export const BARRIER_RADIUS = 3;
|
||||
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
||||
|
||||
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
|
||||
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
|
||||
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 2);
|
||||
return unique.length ? unique : ["bulldrome"];
|
||||
};
|
||||
|
||||
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
|
||||
const motion = cloneMotion(createBossMotionState(bossId));
|
||||
const offset = count > 1 ? (index === 0 ? -2.65 : 2.65) : 0;
|
||||
motion.formationOffsetX = offset;
|
||||
motion.position[0] += offset;
|
||||
motion.chargeStart[0] += offset;
|
||||
motion.chargeEnd[0] += offset;
|
||||
motion.pounceCenter[0] += offset;
|
||||
const stagger = index * 2.4;
|
||||
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
|
||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
|
||||
return motion;
|
||||
}
|
||||
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number): AdditionalBossState {
|
||||
const boss = createBossState(bossId);
|
||||
const stagger = index * 0.8;
|
||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
|
||||
if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4;
|
||||
if (Number.isFinite(boss.nextBrandAt)) boss.nextBrandAt += index * 2.4;
|
||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
|
||||
}
|
||||
|
||||
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
|
||||
const bossPosition = createBossMotionState(bossIds[0]).position;
|
||||
if (bossIds.length > 1) bossPosition[0] = 0;
|
||||
return { aelia: [0, 4.5], ...combatFormation(bossPosition) };
|
||||
};
|
||||
|
||||
export function damageMember(member: PartyMember, amount: number): PartyMember {
|
||||
const absorbed = Math.min(member.absorb, amount);
|
||||
return {
|
||||
...member,
|
||||
absorb: Math.max(0, member.absorb - absorbed),
|
||||
hp: Math.max(0, member.hp - (amount - absorbed)),
|
||||
};
|
||||
}
|
||||
|
||||
export function healMember(member: PartyMember, amount: number): PartyMember {
|
||||
if (member.hp <= 0) return member;
|
||||
return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
|
||||
}
|
||||
|
||||
export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
|
||||
return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
|
||||
}
|
||||
|
||||
function damageMemberAt(
|
||||
member: PartyMember,
|
||||
amount: number,
|
||||
position: WorldPosition,
|
||||
barrier: BarrierState,
|
||||
time: number,
|
||||
partyCombat?: PartyCombatState,
|
||||
tankPosition?: WorldPosition,
|
||||
) {
|
||||
const protectedByTank = partyCombat && tankPosition
|
||||
? tankAuraProtects(position, tankPosition, partyCombat.tankAura, time)
|
||||
: false;
|
||||
const reduction = Math.max(
|
||||
barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
|
||||
protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
|
||||
);
|
||||
return damageMember(member, amount * (1 - reduction));
|
||||
}
|
||||
|
||||
function addLog(
|
||||
log: CombatLogEntry[],
|
||||
time: number,
|
||||
message: string,
|
||||
tone: CombatLogEntry["tone"] = "neutral",
|
||||
): CombatLogEntry[] {
|
||||
const next = [{ id: Date.now() + Math.random(), time, message, tone }, ...log];
|
||||
return next.slice(0, 12);
|
||||
}
|
||||
|
||||
|
||||
function initialState(
|
||||
healerClassId: HealerClassId = "priest",
|
||||
playerName = "Aelia",
|
||||
inventory: InventoryItem[] = createClassInventory(healerClassId),
|
||||
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
|
||||
) {
|
||||
const bossIds = normalizeBossIds(requestedBossIds);
|
||||
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(bossId, index, bossIds.length));
|
||||
const primary = encounterBosses[0];
|
||||
const party = freshParty(healerClassId, playerName);
|
||||
return {
|
||||
bossId: primary.boss.id,
|
||||
paused: false,
|
||||
pauseSelection: "resume" as const,
|
||||
healerClassId,
|
||||
playerName,
|
||||
phase: "briefing" as GamePhase,
|
||||
time: 0,
|
||||
party,
|
||||
boss: primary.boss,
|
||||
additionalBosses: encounterBosses.slice(1),
|
||||
partyPositions: freshPartyPositions(bossIds),
|
||||
bossMotion: primary.motion,
|
||||
partyCombat: createPartyCombatState(party),
|
||||
partyDamageEvents: [] as PartyDamageEvent[],
|
||||
mana: 100,
|
||||
maxMana: 100,
|
||||
selectedMemberId: "brann" as MemberId,
|
||||
cooldowns: emptyCooldowns(),
|
||||
globalCooldownUntil: 0,
|
||||
activeTab: "combat" as BottomTab,
|
||||
selectedItemId: inventory[0]?.id ?? "",
|
||||
inventory: structuredClone(inventory),
|
||||
combatLog: [] as CombatLogEntry[],
|
||||
scenePulse: { id: 0, kind: "mend" as const },
|
||||
playerPosition: [0, 4.5] as [number, number],
|
||||
activeCast: null as ActiveCast | null,
|
||||
barrier: { center: [0, 4.5], expiresAt: 0 } as BarrierState,
|
||||
};
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameState>((set, get) => ({
|
||||
...initialState(),
|
||||
|
||||
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome") => set(initialState(healerClassId, playerName, inventory, bossIds)),
|
||||
|
||||
startEncounter: () => {
|
||||
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
|
||||
const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
|
||||
set({
|
||||
...initialState(healerClassId, playerName, inventory, bossIds),
|
||||
phase: "combat",
|
||||
activeTab: "combat",
|
||||
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
|
||||
});
|
||||
},
|
||||
|
||||
restart: () => {
|
||||
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
|
||||
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]));
|
||||
},
|
||||
|
||||
selectMember: (selectedMemberId) => set({ selectedMemberId }),
|
||||
|
||||
cycleMember: (direction) => {
|
||||
const { party, selectedMemberId } = get();
|
||||
const living = party.filter((member) => member.hp > 0);
|
||||
if (!living.length) return;
|
||||
const currentIndex = living.findIndex((member) => member.id === selectedMemberId);
|
||||
const nextIndex = (Math.max(0, currentIndex) + direction + living.length) % living.length;
|
||||
set({ selectedMemberId: living[nextIndex].id });
|
||||
},
|
||||
|
||||
setActiveTab: (activeTab) => set({ activeTab }),
|
||||
selectItem: (selectedItemId) => set({ selectedItemId }),
|
||||
setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
|
||||
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
|
||||
setPauseSelection: (pauseSelection) => set({ pauseSelection }),
|
||||
setPlayerPosition: (playerPosition) => set((state) => ({
|
||||
playerPosition,
|
||||
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
|
||||
})),
|
||||
|
||||
castAbility: (abilityId) => {
|
||||
const state = get();
|
||||
if (state.phase !== "combat") return false;
|
||||
if (state.paused) return false;
|
||||
if (state.activeCast) return false;
|
||||
|
||||
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
|
||||
const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
|
||||
const selected = state.party[selectedIndex];
|
||||
|
||||
if (state.cooldowns[abilityId] > state.time + 0.01) return false;
|
||||
if (state.globalCooldownUntil > state.time + 0.001) return false;
|
||||
if (state.mana < ability.mana) {
|
||||
set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
|
||||
return false;
|
||||
}
|
||||
if (ability.targeting === "ally" && (!selected || selected.hp <= 0)) return false;
|
||||
if (abilityId === "purify" && selected.debuffs.length === 0) {
|
||||
set({ combatLog: addLog(state.combatLog, state.time, `${selected.name} has nothing to ${ability.name}.`) });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (abilityId === "mend") {
|
||||
set({
|
||||
activeCast: {
|
||||
abilityId: "mend",
|
||||
targetId: selected.id,
|
||||
startedAt: state.time,
|
||||
completesAt: state.time + (ability.castTime ?? 0.5),
|
||||
},
|
||||
mana: Math.max(0, state.mana - ability.mana),
|
||||
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
|
||||
combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let party = state.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
|
||||
let message = ability.name;
|
||||
let barrier = state.barrier;
|
||||
let bossMotion = state.bossMotion;
|
||||
let additionalBosses = state.additionalBosses;
|
||||
|
||||
switch (abilityId) {
|
||||
case "renew":
|
||||
party[selectedIndex] = {
|
||||
...party[selectedIndex],
|
||||
renewExpiresAt: state.time + 8,
|
||||
renewNextTickAt: state.time + 1,
|
||||
};
|
||||
message = `${ability.name} placed on ${selected.name}.`;
|
||||
break;
|
||||
case "shield":
|
||||
party[selectedIndex] = {
|
||||
...party[selectedIndex],
|
||||
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36),
|
||||
};
|
||||
message = `${selected.name} gains 36 absorption.`;
|
||||
break;
|
||||
case "purify":
|
||||
{
|
||||
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
|
||||
if (state.boss.id === "vexa") {
|
||||
const dispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
||||
bossMotion = dispel.motion;
|
||||
}
|
||||
additionalBosses = state.additionalBosses.map((entry) => {
|
||||
if (entry.boss.id !== "vexa") return entry;
|
||||
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
||||
return { ...entry, motion: dispel.motion };
|
||||
});
|
||||
message = dispelledNames.includes("Widow Venom")
|
||||
? "Widow Venom purged. A venom pool forms where the target stood."
|
||||
: `${dispelledNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
|
||||
}
|
||||
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
|
||||
break;
|
||||
case "radiance":
|
||||
party = party.map((member) => healMember(member, 22));
|
||||
message = `${ability.name} heals the full party.`;
|
||||
break;
|
||||
case "barrier":
|
||||
barrier = { center: [...state.partyPositions.aelia], expiresAt: state.time + 8 };
|
||||
message = `${ability.name} protects a 3m circle for 8 seconds.`;
|
||||
break;
|
||||
}
|
||||
|
||||
const cooldowns = {
|
||||
...state.cooldowns,
|
||||
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown : 0,
|
||||
};
|
||||
const pulse: ScenePulse = {
|
||||
id: state.scenePulse.id + 1,
|
||||
kind: abilityId,
|
||||
targetId: abilityId === "barrier" ? "aelia" : selected?.id,
|
||||
};
|
||||
|
||||
set({
|
||||
party,
|
||||
cooldowns,
|
||||
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
|
||||
barrier,
|
||||
bossMotion,
|
||||
additionalBosses,
|
||||
mana: Math.max(0, state.mana - ability.mana),
|
||||
combatLog: addLog(state.combatLog, state.time, message, "good"),
|
||||
scenePulse: pulse,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
tick: (delta) => {
|
||||
const state = get();
|
||||
if (state.phase !== "combat" || state.paused || delta <= 0) return;
|
||||
|
||||
const oldTime = state.time;
|
||||
const time = oldTime + Math.min(delta, 2);
|
||||
let party = state.party.map((member) => ({ ...member, debuffs: member.debuffs.map((debuff) => ({ ...debuff })) }));
|
||||
let boss = { ...state.boss };
|
||||
let bossMotion = { ...state.bossMotion };
|
||||
let additionalBosses = state.additionalBosses.map((entry) => ({
|
||||
...entry,
|
||||
boss: { ...entry.boss },
|
||||
motion: cloneMotion(entry.motion),
|
||||
}));
|
||||
let partyPositions = state.partyPositions;
|
||||
let combatLog = state.combatLog;
|
||||
let pulse = state.scenePulse;
|
||||
let activeCast = state.activeCast ? { ...state.activeCast } : null;
|
||||
let partyCombat = state.partyCombat;
|
||||
let partyDamageEvents = state.partyDamageEvents;
|
||||
const barrier = state.barrier;
|
||||
|
||||
if (activeCast && activeCast.completesAt <= time) {
|
||||
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
|
||||
const target = party[targetIndex];
|
||||
if (target?.hp > 0) {
|
||||
party[targetIndex] = healMember(target, 38);
|
||||
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
|
||||
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for 38.`, "good");
|
||||
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
|
||||
}
|
||||
activeCast = null;
|
||||
}
|
||||
|
||||
party = party.map((member) => {
|
||||
let next = member;
|
||||
if (next.renewExpiresAt > oldTime && next.renewNextTickAt <= time) {
|
||||
let tickAt = next.renewNextTickAt;
|
||||
const lastTickAt = Math.min(time, next.renewExpiresAt);
|
||||
while (tickAt <= lastTickAt + 0.001) {
|
||||
next = healMember(next, 7);
|
||||
tickAt += 1;
|
||||
}
|
||||
next = {
|
||||
...next,
|
||||
renewExpiresAt: time >= next.renewExpiresAt ? 0 : next.renewExpiresAt,
|
||||
renewNextTickAt: time >= next.renewExpiresAt ? 0 : tickAt,
|
||||
};
|
||||
}
|
||||
|
||||
const activeDebuffs = next.debuffs
|
||||
.map((debuff) => {
|
||||
let updated = { ...debuff };
|
||||
while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
|
||||
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann);
|
||||
updated.nextTickAt += 1;
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
.filter((debuff) => debuff.expiresAt > time);
|
||||
return { ...next, debuffs: activeDebuffs };
|
||||
});
|
||||
|
||||
const livingMotions = [
|
||||
...(boss.hp > 0 ? [bossMotion] : []),
|
||||
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
|
||||
];
|
||||
partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime);
|
||||
|
||||
const encounterBosses: AdditionalBossState[] = [
|
||||
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
|
||||
...additionalBosses,
|
||||
];
|
||||
for (let index = 0; index < encounterBosses.length; index += 1) {
|
||||
const encounterBoss = encounterBosses[index];
|
||||
if (encounterBoss.boss.hp <= 0) continue;
|
||||
const mechanicResult = advanceBossMechanics({
|
||||
boss: encounterBoss.boss,
|
||||
motion: encounterBoss.motion,
|
||||
party,
|
||||
partyPositions,
|
||||
time,
|
||||
delta: time - oldTime,
|
||||
damageMember: (member, amount, position, at) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann),
|
||||
});
|
||||
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: mechanicResult.motion };
|
||||
party = mechanicResult.party;
|
||||
for (const event of mechanicResult.events) {
|
||||
combatLog = addLog(combatLog, event.at, event.message, event.tone);
|
||||
if (event.pulseKind) {
|
||||
pulse = { id: pulse.id + 1, kind: event.pulseKind, targetId: event.targetId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mechanicRemaining = encounterBosses
|
||||
.filter((entry) => entry.boss.hp > 0)
|
||||
.map((entry) => upcomingMechanic(entry.boss, entry.motion, time).remaining);
|
||||
const partyCombatResult = advancePartyCombat(partyCombat, {
|
||||
oldTime,
|
||||
time,
|
||||
party,
|
||||
oldPositions: state.partyPositions,
|
||||
positions: partyPositions,
|
||||
targets: encounterBosses,
|
||||
upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY,
|
||||
});
|
||||
partyCombat = partyCombatResult.state;
|
||||
partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24);
|
||||
for (const event of partyCombatResult.events) {
|
||||
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
|
||||
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
|
||||
}
|
||||
boss = encounterBosses[0].boss;
|
||||
bossMotion = encounterBosses[0].motion;
|
||||
additionalBosses = encounterBosses.slice(1);
|
||||
const tank = party.find((member) => member.id === "brann")!;
|
||||
const healer = party.find((member) => member.id === "aelia")!;
|
||||
let phase: GamePhase = state.phase;
|
||||
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
||||
phase = "victory";
|
||||
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
|
||||
} else if (tank.hp <= 0 || healer.hp <= 0) {
|
||||
phase = "defeat";
|
||||
combatLog = addLog(combatLog, time, tank.hp <= 0 ? `Brann falls. ${boss.name} breaks formation.` : `${healer.name} falls. Healing ends.`, "danger");
|
||||
}
|
||||
|
||||
set({
|
||||
time,
|
||||
party,
|
||||
boss,
|
||||
additionalBosses,
|
||||
partyCombat,
|
||||
partyDamageEvents,
|
||||
partyPositions,
|
||||
bossMotion,
|
||||
phase,
|
||||
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
|
||||
activeCast,
|
||||
combatLog,
|
||||
scenePulse: pulse,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) {
|
||||
return Math.max(0, cooldowns[abilityId] - time);
|
||||
}
|
||||
|
||||
export { upcomingMechanic };
|
||||
|
||||
export function upcomingEncounterMechanic(state: Pick<GameState, "boss" | "bossMotion" | "additionalBosses" | "time">) {
|
||||
const candidates = [
|
||||
...(state.boss.hp > 0 ? [{ boss: state.boss, motion: state.bossMotion }] : []),
|
||||
...state.additionalBosses.filter((entry) => entry.boss.hp > 0),
|
||||
].map((entry) => upcomingMechanic(entry.boss, entry.motion, state.time));
|
||||
return candidates.reduce((next, candidate) => candidate.remaining < next.remaining ? candidate : next, candidates[0]);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
|
||||
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
|
||||
export type BossId = "bulldrome" | "vexa" | "cindermaw";
|
||||
export type GamePhase = "briefing" | "combat" | "victory" | "defeat";
|
||||
export type BottomTab = "combat" | "map" | "pack";
|
||||
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall";
|
||||
export type BossMotionMode =
|
||||
| "holding"
|
||||
| "telegraph"
|
||||
| "charging"
|
||||
| "returning"
|
||||
| "stacking"
|
||||
| "pouncing"
|
||||
| "tethering"
|
||||
| "venom_cast"
|
||||
| "breath_telegraph"
|
||||
| "breath_sweeping"
|
||||
| "skyfall";
|
||||
export type WorldPosition = [number, number];
|
||||
|
||||
export type CircleHazardKind = "venom_pool" | "skyfall";
|
||||
|
||||
export interface CircleHazard {
|
||||
id: string;
|
||||
kind: CircleHazardKind;
|
||||
center: WorldPosition;
|
||||
radius: number;
|
||||
activatesAt: number;
|
||||
expiresAt: number;
|
||||
damage: number;
|
||||
tickInterval?: number;
|
||||
nextDamageAt: Partial<Record<MemberId, number>>;
|
||||
resolved: boolean;
|
||||
hitIds: MemberId[];
|
||||
}
|
||||
|
||||
export interface Debuff {
|
||||
id: string;
|
||||
name: string;
|
||||
expiresAt: number;
|
||||
nextTickAt: number;
|
||||
tickDamage: number;
|
||||
}
|
||||
|
||||
export interface PartyMember {
|
||||
id: MemberId;
|
||||
name: string;
|
||||
className: string;
|
||||
role: "Healer" | "Tank" | "Damage";
|
||||
color: string;
|
||||
maxHp: number;
|
||||
hp: number;
|
||||
absorb: number;
|
||||
renewExpiresAt: number;
|
||||
renewNextTickAt: number;
|
||||
knockedUntil: number;
|
||||
debuffs: Debuff[];
|
||||
}
|
||||
|
||||
export interface BossState {
|
||||
id: BossId;
|
||||
name: string;
|
||||
maxHp: number;
|
||||
hp: number;
|
||||
nextMeleeAt: number;
|
||||
nextNovaAt: number;
|
||||
nextBrandAt: number;
|
||||
brandCount: number;
|
||||
}
|
||||
|
||||
export interface BossMotionState {
|
||||
bossId: BossId;
|
||||
formationOffsetX: number;
|
||||
mode: BossMotionMode;
|
||||
position: WorldPosition;
|
||||
chargeStart: WorldPosition;
|
||||
chargeEnd: WorldPosition;
|
||||
chargeTargetId: MemberId;
|
||||
chargeHitIds: MemberId[];
|
||||
phaseEndsAt: number;
|
||||
nextChargeAt: number;
|
||||
chargeCount: number;
|
||||
chargesSincePounce: number;
|
||||
pounceTargetId: MemberId;
|
||||
pounceCenter: WorldPosition;
|
||||
pounceCount: number;
|
||||
nextMechanicAt: number;
|
||||
mechanicCount: number;
|
||||
phaseStartedAt: number;
|
||||
mechanicHitIds: MemberId[];
|
||||
mechanicNextDamageAt: Partial<Record<MemberId, number>>;
|
||||
tetherIds: MemberId[];
|
||||
tetherBreakDistance: number;
|
||||
breathAngle: number;
|
||||
breathStartAngle: number;
|
||||
breathEndAngle: number;
|
||||
hazards: CircleHazard[];
|
||||
}
|
||||
|
||||
export interface AbilityDefinition {
|
||||
id: AbilityId;
|
||||
name: string;
|
||||
shortName: string;
|
||||
key: string;
|
||||
gamepad: string;
|
||||
cooldown: number;
|
||||
castTime?: number;
|
||||
mana: number;
|
||||
icon: string;
|
||||
description: string;
|
||||
targeting: "enemy" | "ally" | "party";
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ActiveCast {
|
||||
abilityId: "mend";
|
||||
targetId: MemberId;
|
||||
startedAt: number;
|
||||
completesAt: number;
|
||||
}
|
||||
|
||||
export interface BarrierState {
|
||||
center: WorldPosition;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface ScenePulse {
|
||||
id: number;
|
||||
kind: PulseKind;
|
||||
targetId?: MemberId;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slot: string;
|
||||
rarity: "Common" | "Uncommon" | "Rare";
|
||||
icon: string;
|
||||
stats: string[];
|
||||
effect: string;
|
||||
equipped: boolean;
|
||||
}
|
||||
|
||||
export type HealerClassId = "priest" | "druid" | "shaman";
|
||||
|
||||
export interface HealerClassDefinition {
|
||||
id: HealerClassId;
|
||||
name: string;
|
||||
specialization: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
resourceName: string;
|
||||
description: string;
|
||||
abilities: Record<AbilityId, AbilityDefinition>;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ABILITY_ORDER } from "./data";
|
||||
import { useGameStore } from "./store";
|
||||
import type { AbilityId } from "./types";
|
||||
|
||||
const gamepadAbilityMap: Record<number, AbilityId> = {
|
||||
0: "purify",
|
||||
1: "shield",
|
||||
2: "mend",
|
||||
3: "renew",
|
||||
4: "radiance",
|
||||
5: "barrier",
|
||||
};
|
||||
|
||||
export function useGameLoop() {
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let previous = performance.now();
|
||||
let accumulator = 0;
|
||||
const loop = (now: number) => {
|
||||
const delta = Math.min((now - previous) / 1000, 0.25);
|
||||
previous = now;
|
||||
accumulator += delta;
|
||||
if (accumulator >= 0.1) {
|
||||
useGameStore.getState().tick(accumulator);
|
||||
accumulator = 0;
|
||||
}
|
||||
frame = requestAnimationFrame(loop);
|
||||
};
|
||||
frame = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
const exitRef = useRef(onExit);
|
||||
exitRef.current = onExit;
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
if (event.repeat) return;
|
||||
const store = useGameStore.getState();
|
||||
const key = event.key.toLowerCase();
|
||||
if (store.paused) {
|
||||
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
|
||||
if (key === "escape") store.setPaused(false);
|
||||
if (key === "arrowup") store.setPauseSelection("resume");
|
||||
if (key === "arrowdown") store.setPauseSelection("exit");
|
||||
if (key === "enter") {
|
||||
if (store.pauseSelection === "resume") store.setPaused(false);
|
||||
else exitRef.current?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const numberIndex = Number(event.key) - 1;
|
||||
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
||||
store.castAbility(ABILITY_ORDER[numberIndex]);
|
||||
return;
|
||||
}
|
||||
switch (key) {
|
||||
case "q":
|
||||
store.cycleMember(-1);
|
||||
break;
|
||||
case "e":
|
||||
store.cycleMember(1);
|
||||
break;
|
||||
case "m":
|
||||
store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
|
||||
break;
|
||||
case "i":
|
||||
store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack");
|
||||
break;
|
||||
case "enter":
|
||||
if (store.phase === "briefing") store.startEncounter();
|
||||
if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
break;
|
||||
case "escape":
|
||||
if (store.phase === "combat") store.setPaused(true);
|
||||
else exitRef.current?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let previousButtons: boolean[] = [];
|
||||
const poll = () => {
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (gamepad && enabled) {
|
||||
const buttons = gamepad.buttons.map((button) => button.pressed);
|
||||
const store = useGameStore.getState();
|
||||
if (store.paused) {
|
||||
if (buttons[12] && !previousButtons[12]) store.setPauseSelection("resume");
|
||||
if (buttons[13] && !previousButtons[13]) store.setPauseSelection("exit");
|
||||
if ((buttons[1] && !previousButtons[1]) || (buttons[9] && !previousButtons[9])) store.setPaused(false);
|
||||
if (buttons[0] && !previousButtons[0]) {
|
||||
if (store.pauseSelection === "resume") store.setPaused(false);
|
||||
else exitRef.current?.();
|
||||
}
|
||||
} else {
|
||||
for (const [button, ability] of Object.entries(gamepadAbilityMap)) {
|
||||
const index = Number(button);
|
||||
if (buttons[index] && !previousButtons[index]) store.castAbility(ability);
|
||||
}
|
||||
if (buttons[12] && !previousButtons[12]) store.cycleMember(-1);
|
||||
if (buttons[13] && !previousButtons[13]) store.cycleMember(1);
|
||||
if (buttons[8] && !previousButtons[8]) store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
|
||||
if (buttons[9] && !previousButtons[9]) {
|
||||
if (store.phase === "briefing") store.startEncounter();
|
||||
if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
if (store.phase === "combat") store.setPaused(true);
|
||||
}
|
||||
}
|
||||
previousButtons = buttons;
|
||||
} else {
|
||||
previousButtons = [];
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user