Release v0.1.5 2026-07-12
This commit is contained in:
+29
-423
@@ -1,447 +1,53 @@
|
||||
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
|
||||
import { clampToArena } from "./arena";
|
||||
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
|
||||
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
|
||||
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
|
||||
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
|
||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
|
||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
|
||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
|
||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
|
||||
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";
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import {
|
||||
advanceMechanicLoadout,
|
||||
BOSS_MECHANIC_REGISTRY,
|
||||
BULL_CHARGE,
|
||||
BULL_POUNCE,
|
||||
handleMechanicDispel,
|
||||
upcomingLoadoutMechanic,
|
||||
} from "./bosses/mechanicPool";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
|
||||
import type { BossId, BossMotionState, BossState, MemberId, 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: 200,
|
||||
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 { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
|
||||
|
||||
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
||||
let state: BossState;
|
||||
if (archetype === "web-caster") state = createVexaState(bossId);
|
||||
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
|
||||
else if (archetype === "duelist") state = createEmberMantisState(bossId);
|
||||
else if (archetype === "ram") state = createObsidianRamState(bossId);
|
||||
else if (archetype === "ricochet") state = createCinderbackState(bossId);
|
||||
else if (archetype === "burrower") state = createSandglassState();
|
||||
else if (archetype === "crab") state = createCragclawState();
|
||||
else if (archetype === "ghost") state = createMournveilState();
|
||||
else if (archetype === "golem") state = createCrownshardState();
|
||||
else {
|
||||
state = {
|
||||
id: bossId,
|
||||
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,
|
||||
};
|
||||
}
|
||||
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
|
||||
return {
|
||||
id: bossId,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
||||
let motion: BossMotionState;
|
||||
if (archetype === "web-caster") motion = createVexaMotion(bossId);
|
||||
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
|
||||
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
|
||||
else if (archetype === "ram") motion = createObsidianRamMotion(bossId);
|
||||
else if (archetype === "ricochet") motion = createCinderbackMotion(bossId);
|
||||
else if (archetype === "burrower") motion = createSandglassMotion();
|
||||
else if (archetype === "crab") motion = createCragclawMotion();
|
||||
else if (archetype === "ghost") motion = createMournveilMotion();
|
||||
else if (archetype === "golem") motion = createCrownshardMotion();
|
||||
else motion = {
|
||||
...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,
|
||||
return {
|
||||
...createBaseMotion(bossId),
|
||||
position: [0, -6.8],
|
||||
chargeStart: [0, -6.8],
|
||||
nextMechanicAt: 5,
|
||||
};
|
||||
return { ...motion, bossId };
|
||||
}
|
||||
|
||||
function mechanicArchetype(bossId: BossId): BossArchetype {
|
||||
return BOSS_ARCHETYPE_BY_ID[bossId];
|
||||
}
|
||||
|
||||
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 clampToArena([
|
||||
start[0] + (dx / length) * BULL_CHARGE.distance,
|
||||
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") {
|
||||
returnBossToArenaCenter(motion, delta, 1.8);
|
||||
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") {
|
||||
if (returnBossToArenaCenter(motion, delta, 4.4)) {
|
||||
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,
|
||||
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,
|
||||
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 {
|
||||
const archetype = mechanicArchetype(context.boss.id);
|
||||
const result = archetype === "web-caster" ? advanceVexaMechanics(context)
|
||||
: archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context)
|
||||
: archetype === "duelist" ? advanceEmberMantisMechanics(context)
|
||||
: archetype === "ram" ? advanceObsidianRamMechanics(context)
|
||||
: archetype === "ricochet" ? advanceCinderbackMechanics(context)
|
||||
: archetype === "burrower" ? advanceSandglassMechanics(context)
|
||||
: archetype === "crab" ? advanceCragclawMechanics(context)
|
||||
: archetype === "ghost" ? advanceMournveilMechanics(context)
|
||||
: archetype === "golem" ? advanceCrownshardMechanics(context)
|
||||
: advanceBulldromeMechanics(context);
|
||||
return advancePooledBossMechanics(context, result);
|
||||
return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds);
|
||||
}
|
||||
|
||||
export function handleBossDispel(
|
||||
bossId: BossId,
|
||||
_bossId: BossId,
|
||||
motion: BossMotionState,
|
||||
memberId: MemberId,
|
||||
position: WorldPosition,
|
||||
time: number,
|
||||
debuffNames: readonly string[],
|
||||
) {
|
||||
if (mechanicArchetype(bossId) === "web-caster" && 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." };
|
||||
return handleMechanicDispel(motion, memberId, position, time, debuffNames);
|
||||
}
|
||||
|
||||
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||
const pooled = upcomingPooledMechanic(motion, time);
|
||||
if (pooled) return pooled;
|
||||
const archetype = mechanicArchetype(boss.id);
|
||||
if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time);
|
||||
if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time);
|
||||
if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time);
|
||||
if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time);
|
||||
if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time);
|
||||
if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time);
|
||||
if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time);
|
||||
if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time);
|
||||
if (archetype === "golem") return upcomingCrownshardMechanic(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 };
|
||||
return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, motion, time);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user