Files
i-want-to-heal-mmo/src/game/bosses/mechanicPool.ts
T
2026-07-12 22:10:12 -04:00

1291 lines
56 KiB
TypeScript

import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const BOSS_MECHANIC_POOL = [
{ id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." },
{ id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." },
{ id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." },
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." },
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
{ id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." },
{ id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." },
{ id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." },
{ id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." },
{ id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." },
{ id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." },
{ id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." },
{ id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." },
{ id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." },
{ id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." },
{ id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." },
{ id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." },
{ id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." },
{ id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." },
{
id: "meteor-spread",
name: "Meteor Spread",
instruction: "Break formation before the marked circles land.",
},
{
id: "hollow-collapse",
name: "Hollow Collapse",
instruction: "Move inside the inner safe circle.",
},
{
id: "aetheric-soak",
name: "Aetheric Soak",
instruction: "Stack in the marked circle to split the hit.",
},
{
id: "prism-beam",
name: "Prism Beam",
instruction: "Clear the marked beam lane.",
},
{
id: "memory-sequence",
name: "Memory Sequence",
instruction: "Healer only: watch four symbols, then cross matching tiles in order.",
},
{
id: "soul-siphon",
name: "Soul Siphon",
instruction: "Healer only: run through the gold cleansing ward before the shade drains you.",
},
] as const;
const MECHANIC_COPY_BY_ID = Object.fromEntries(
BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]),
) as Record<BossMechanicId, (typeof BOSS_MECHANIC_POOL)[number]>;
export function bossMechanicName(id: BossMechanicId) {
return MECHANIC_COPY_BY_ID[id].name;
}
export const POOLED_MECHANIC_TIMING = {
activeDuration: 0.42,
warningDuration: 1.4,
} as const;
export const BULL_CHARGE = {
warning: 1.8,
speed: 10.5,
distance: 13.5,
hitRadius: 1.35,
damage: 18,
knockdown: 0.75,
cooldown: 4,
aiClearance: 1.9,
aiEvadeSpeed: 3.4,
} as const;
export const BULL_POUNCE = {
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 200,
leapDuration: 0.55,
cooldown: 4,
} as const;
export const SKY_SWEEPER_BREATH = {
telegraphDuration: 2,
sweepDuration: 3.2,
range: 10.5,
halfAngle: Math.PI / 7,
sweepArc: Math.PI * 0.95,
tickDamage: 9,
tickInterval: 0.45,
cooldown: 4,
} as const;
export const VENOM_PURGE = {
duration: 10,
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
} as const;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.9,
inputDuration: 7,
tileSize: 2.15,
raidwideDamage: 15,
} as const;
export const SOUL_SIPHON = {
wardRadius: 1.3,
wardDistance: ARENA_RADIUS - 1.05,
ghostSpeed: 2.6,
tickInterval: 0.7,
tickDamage: 6,
tickRamp: 2,
} as const;
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
triangle: { label: "Triangle", color: "#ff4d55" },
cross: { label: "Cross", color: "#4fa8ff" },
circle: { label: "Circle", color: "#65d67a" },
square: { label: "Square", color: "#ffd34d" },
};
const MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPosition }[] = [
{ symbol: "triangle", center: [-2.35, 1.35] },
{ symbol: "cross", center: [2.35, 1.35] },
{ symbol: "circle", center: [-2.35, -3.15] },
{ symbol: "square", center: [2.35, -3.15] },
];
const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
["triangle", "cross", "circle", "square"],
["circle", "triangle", "square", "cross"],
["square", "circle", "cross", "triangle"],
["cross", "square", "triangle", "circle"],
];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0)
? targetId
: party.find((member) => member.hp > 0)?.id ?? targetId;
}
function circleTelegraph({
id,
kind,
name,
center,
radius,
innerRadius,
activatesAt,
damage,
targetId,
}: {
id: string;
kind: "spread" | "donut" | "soak";
name: string;
center: WorldPosition;
radius: number;
innerRadius?: number;
activatesAt: number;
damage: number;
targetId?: MemberId;
}): PoolTelegraph {
return {
id,
kind,
name,
center: [center[0], center[1]],
radius,
innerRadius,
activatesAt,
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
damage,
targetId,
resolved: false,
hitIds: [],
};
}
function beamTelegraph(id: string, center: WorldPosition, target: WorldPosition, activatesAt: number): PoolTelegraph {
const angle = angleTo(center, target);
const halfLength = 9.2;
const dx = Math.sin(angle) * halfLength;
const dz = Math.cos(angle) * halfLength;
return {
id,
kind: "beam",
name: "Prism Beam",
center: [center[0], center[1]],
radius: 0,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: 1.45,
activatesAt,
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
damage: 29,
resolved: false,
hitIds: [],
};
}
function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: number, inputStartsAt: number): PoolTelegraph {
const sequence = MEMORY_SEQUENCES[count % MEMORY_SEQUENCES.length];
return {
id,
kind: "memory",
name: "Memory Sequence",
center: [bossPosition[0], bossPosition[1]],
radius: 0,
activatesAt: inputStartsAt,
inputStartsAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration,
expiresAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration + MEMORY_SEQUENCE.inputDuration,
damage: MEMORY_SEQUENCE.raidwideDamage,
targetId: "aelia",
sequence: [...sequence],
tiles: MEMORY_TILE_LAYOUT.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })),
inputIndex: 0,
resolved: false,
hitIds: [],
};
}
function oppositeWardPosition(healerPosition: WorldPosition, count: number): WorldPosition {
const offsetX = healerPosition[0] - ARENA_CENTER[0];
const offsetZ = healerPosition[1] - ARENA_CENTER[1];
const offsetLength = Math.hypot(offsetX, offsetZ);
const fallbackAngle = count % 2 === 0 ? 0 : Math.PI / 2;
const directionX = offsetLength > 0.05 ? -offsetX / offsetLength : Math.sin(fallbackAngle);
const directionZ = offsetLength > 0.05 ? -offsetZ / offsetLength : Math.cos(fallbackAngle);
return clampToArena([
ARENA_CENTER[0] + directionX * SOUL_SIPHON.wardDistance,
ARENA_CENTER[1] + directionZ * SOUL_SIPHON.wardDistance,
], 0.25);
}
function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: number, time: number): PoolTelegraph {
const wardPosition = oppositeWardPosition(healerPosition, count);
return {
id,
kind: "soul-siphon",
name: "Soul Siphon",
center: [wardPosition[0], wardPosition[1]],
radius: 0,
activatesAt: time,
expiresAt: Number.POSITIVE_INFINITY,
damage: 0,
targetId: "aelia",
soulSiphon: {
targetId: "aelia",
ghostPosition: clampToArena([healerPosition[0] - 0.85, healerPosition[1] + 0.85], 0.2),
wardPosition,
wardRadius: SOUL_SIPHON.wardRadius,
nextDamageAt: time + SOUL_SIPHON.tickInterval,
tickCount: 0,
},
resolved: false,
hitIds: [],
};
}
function beginPoolMechanic(
motion: BossMotionState,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
time: number,
requestedId: BossMechanicId,
) {
const count = motion.poolMechanicCount + 1;
const entry = MECHANIC_COPY_BY_ID[requestedId];
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined;
if (entry.id === "meteor-spread") {
const targets = [0, 1, 2].map((offset) => liveTarget(party, TARGET_ORDER[(count + offset) % TARGET_ORDER.length]));
telegraphs = targets.map((id, index) => circleTelegraph({
id: `pool-spread-${count}-${index}`,
kind: "spread",
name: "Meteor Spread",
center: positions[id],
radius: 1.75,
activatesAt: activatesAt + index * 0.42,
damage: 24,
targetId: id,
}));
targetId = targets[0];
} else if (entry.id === "hollow-collapse") {
telegraphs = [circleTelegraph({
id: `pool-donut-${count}`,
kind: "donut",
name: "Hollow Collapse",
center: motion.position,
radius: 7.25,
innerRadius: 2.15,
activatesAt,
damage: 27,
})];
} else if (entry.id === "aetheric-soak") {
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
const soak = circleTelegraph({
id: `pool-soak-${count}`,
kind: "soak",
name: "Aetheric Soak",
center: positions[targetId],
radius: 2.25,
activatesAt: time + 1.75,
damage: 0,
targetId,
});
soak.totalDamage = 78;
soak.minimumParticipants = 3;
telegraphs = [soak];
} else if (entry.id === "memory-sequence") {
targetId = "aelia";
telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time)];
} else if (entry.id === "soul-siphon") {
targetId = "aelia";
telegraphs = [soulSiphonTelegraph(`pool-soul-siphon-${count}`, positions.aelia, count, time)];
} else {
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
telegraphs = [beamTelegraph(`pool-beam-${count}`, motion.position, positions[targetId], activatesAt)];
}
return {
motion: {
...motion,
poolMechanicCount: count,
poolTelegraphs: telegraphs,
},
event: {
at: time,
message: `${entry.name}: ${entry.instruction}`,
tone: "danger" as const,
pulseKind: "boss" as const,
targetId,
},
};
}
function memoryTileAt(telegraph: PoolTelegraph, position: WorldPosition) {
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
return telegraph.tiles?.find((tile) =>
Math.abs(position[0] - tile.center[0]) <= halfSize && Math.abs(position[1] - tile.center[1]) <= halfSize,
);
}
function failMemorySequence(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: `${telegraph.name} fails — every party member takes ${telegraph.damage} raidwide damage.`,
tone: "danger",
pulseKind: "boss",
targetId: "aelia",
});
return party.map((member) => member.hp > 0
? context.damageMember(member, telegraph.damage, positions[member.id], context.time)
: member);
}
function resolveMemorySequence(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined || telegraph.inputIndex === undefined) {
return failMemorySequence(telegraph, party, positions, context, events);
}
if (context.time < telegraph.inputStartsAt) return party;
if (context.time >= telegraph.expiresAt) return failMemorySequence(telegraph, party, positions, context, events);
const selectedTile = memoryTileAt(telegraph, positions.aelia);
if (!selectedTile) {
telegraph.lastHealerTileId = undefined;
} else if (selectedTile.symbol !== telegraph.lastHealerTileId) {
telegraph.lastHealerTileId = selectedTile.symbol;
if (selectedTile.symbol !== telegraph.sequence[telegraph.inputIndex]) {
return failMemorySequence(telegraph, party, positions, context, events);
}
telegraph.inputIndex += 1;
if (telegraph.inputIndex === telegraph.sequence.length) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: "Memory Sequence cleared by healer.",
tone: "neutral",
pulseKind: "boss",
targetId: "aelia",
});
return party;
}
}
return party;
}
function resolveSoulSiphon(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const siphon = telegraph.soulSiphon;
if (!siphon) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
return party;
}
const healerPosition = positions[siphon.targetId];
if (distance(healerPosition, siphon.wardPosition) <= siphon.wardRadius) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: "Aelia reaches the cleansing ward. Soul Siphon collapses.",
tone: "neutral",
pulseKind: "purify",
targetId: "aelia",
});
return party;
}
const nextSiphon = {
...siphon,
ghostPosition: moveToward(siphon.ghostPosition, healerPosition, SOUL_SIPHON.ghostSpeed * context.delta),
};
let nextParty = party;
const healerIndex = nextParty.findIndex((member) => member.id === siphon.targetId);
if (healerIndex >= 0 && nextParty[healerIndex].hp > 0) {
let nextDamageAt = nextSiphon.nextDamageAt;
let tickCount = nextSiphon.tickCount;
let healer = nextParty[healerIndex];
while (nextDamageAt <= context.time + 0.001) {
healer = context.damageMember(
healer,
SOUL_SIPHON.tickDamage + Math.min(tickCount, 4) * SOUL_SIPHON.tickRamp,
healerPosition,
nextDamageAt,
);
nextDamageAt += SOUL_SIPHON.tickInterval;
tickCount += 1;
}
nextSiphon.nextDamageAt = nextDamageAt;
nextSiphon.tickCount = tickCount;
if (healer !== nextParty[healerIndex]) {
nextParty = [...nextParty];
nextParty[healerIndex] = healer;
}
}
telegraph.soulSiphon = nextSiphon;
return nextParty;
}
function isHit(telegraph: PoolTelegraph, position: WorldPosition) {
if (telegraph.kind === "beam") {
return !!telegraph.start && !!telegraph.end
&& pointToSegmentDistance(position, telegraph.start, telegraph.end) <= (telegraph.width ?? 0) * 0.5;
}
const distance = Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
return distance <= telegraph.radius && distance >= (telegraph.innerRadius ?? 0);
}
function resolveSoak(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const participants = party.filter((member) => member.hp > 0 && isHit(telegraph, positions[member.id]));
const minimum = telegraph.minimumParticipants ?? 1;
const failed = participants.length < minimum;
const totalDamage = (telegraph.totalDamage ?? 0) * (failed ? 1.5 : 1);
if (!participants.length) {
const marked = party.find((member) => member.id === telegraph.targetId && member.hp > 0);
if (!marked) return party;
events.push({
at: context.time,
message: `${telegraph.name} fails — ${marked.name} takes ${Math.round(totalDamage)} damage alone.`,
tone: "danger",
pulseKind: "boss",
targetId: marked.id,
});
return party.map((member) => member.id === marked.id
? context.damageMember(member, totalDamage, positions[member.id], context.time)
: member);
}
const splitDamage = totalDamage / participants.length;
events.push({
at: context.time,
message: failed
? `${telegraph.name} is under-soaked by ${participants.length}. ${Math.round(splitDamage)} damage each.`
: `${telegraph.name} splits ${Math.round(totalDamage)} damage across ${participants.length} allies.`,
tone: "danger",
pulseKind: "boss",
targetId: telegraph.targetId,
});
const participantIds = new Set(participants.map((member) => member.id));
return party.map((member) => participantIds.has(member.id)
? context.damageMember(member, splitDamage, positions[member.id], context.time)
: member);
}
function resolveTelegraph(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
if (telegraph.kind === "soak") return resolveSoak(telegraph, party, positions, context, events);
const hitIds: MemberId[] = [];
const next = party.map((member) => {
if (member.hp <= 0 || !isHit(telegraph, positions[member.id])) return member;
hitIds.push(member.id);
return context.damageMember(member, telegraph.damage, positions[member.id], context.time, "hazard");
});
telegraph.hitIds = hitIds;
events.push({
at: context.time,
message: hitIds.length
? `${telegraph.name} catches ${hitIds.length} ${hitIds.length === 1 ? "ally" : "allies"}.`
: `${telegraph.name} misses the party.`,
tone: "danger",
pulseKind: "boss",
targetId: telegraph.targetId,
});
return next;
}
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
if (!telegraphs.length) return null;
const next = telegraphs.reduce((earliest, telegraph) => telegraph.activatesAt < earliest.activatesAt ? telegraph : earliest);
if (next.kind === "memory") {
const inputStartsAt = next.inputStartsAt ?? next.activatesAt;
const showingSequence = time < inputStartsAt;
return {
name: showingSequence ? "Memory Sequence — watch boss" : "Memory Sequence — match tiles",
remaining: Math.max(0, (showingSequence ? inputStartsAt : next.expiresAt) - time),
cycle: showingSequence ? Math.max(0.01, inputStartsAt - next.activatesAt) : MEMORY_SEQUENCE.inputDuration,
urgent: true,
};
}
if (next.kind === "soul-siphon" && next.soulSiphon) {
return {
name: "Soul Siphon — reach cleansing ward",
remaining: Math.max(0, next.soulSiphon.nextDamageAt - time),
cycle: SOUL_SIPHON.tickInterval,
urgent: true,
};
}
return {
name: next.kind === "soak" ? `${next.name} — stack` : next.kind === "donut" ? `${next.name} — move in` : next.kind === "spread" ? `${next.name} — spread` : `${next.name} — clear lane`,
remaining: Math.max(0, next.activatesAt - time),
cycle: Math.max(0.01, next.activatesAt - (next.activatesAt - POOLED_MECHANIC_TIMING.warningDuration)),
urgent: true,
};
}
interface MechanicRuntime {
readonly context: BossMechanicContext;
boss: BossMechanicResult["boss"];
motion: BossMotionState;
party: PartyMember[];
events: BossMechanicResult["events"];
}
export interface BossMechanicDefinition {
readonly id: BossMechanicId;
readonly name: string;
readonly instruction: string;
readonly cooldown: number;
readonly passive?: boolean;
readonly start: (runtime: MechanicRuntime) => void;
readonly advance: (runtime: MechanicRuntime) => void;
upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic;
readonly animationCue: (motion: BossMotionState) => BossAnimationCue;
}
function finishMechanic(runtime: MechanicRuntime, cooldown: number) {
runtime.motion.activeMechanicId = null;
runtime.motion.mode = "holding";
runtime.motion.phaseEndsAt = 0;
runtime.motion.nextMechanicAt = runtime.context.time + cooldown;
runtime.motion.chargeHitIds = [];
runtime.motion.mechanicHitIds = [];
runtime.motion.tetherIds = [];
runtime.motion.slashLanes = [];
}
function timedAdvance(runtime: MechanicRuntime, cooldown: number) {
if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown);
}
function defaultUpcoming(definition: Pick<BossMechanicDefinition, "name" | "cooldown">, motion: BossMotionState, time: number): UpcomingMechanic {
const remaining = Math.max(0, (motion.activeMechanicId ? motion.phaseEndsAt : motion.nextMechanicAt) - time);
return { name: definition.name, remaining, cycle: definition.cooldown, urgent: motion.activeMechanicId !== null || remaining < 2.5 };
}
function mechanicCopy(id: BossMechanicId) {
return MECHANIC_COPY_BY_ID[id];
}
export function bossMechanicIsPassive(id: BossMechanicId) {
return BOSS_MECHANIC_REGISTRY[id].passive === true;
}
interface LaneChargeConfig {
id: BossMechanicId;
warning: number;
speed: number;
distance: number;
width: number;
damage: number;
knockdown: number;
cooldown: number;
targetOrder: readonly MemberId[];
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition, travelDistance: number): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * travelDistance,
start[1] + Math.cos(angle) * travelDistance,
]);
}
function laneChargeDefinition(config: LaneChargeConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
const targetId = chooseLivingTarget(runtime.party, config.targetOrder, runtime.motion.mechanicCount);
const end = chargeEndpoint(runtime.motion.position, runtime.context.partyPositions[targetId], config.distance);
runtime.motion.mode = "telegraph";
runtime.motion.chargeTargetId = targetId;
runtime.motion.chargeStart = [...runtime.motion.position];
runtime.motion.chargeEnd = end;
runtime.motion.chargeHitIds = [];
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
runtime.motion.slashLanes = [{
id: `${config.id}-${runtime.motion.mechanicCount}`,
start: [...runtime.motion.position],
end,
width: config.width,
damage: config.damage,
}];
runtime.events.push({
at: runtime.context.time,
message: `${copy.name} targets ${memberName(runtime.party, targetId)}. ${copy.instruction}`,
tone: "danger",
pulseKind: "charge",
targetId,
});
},
advance(runtime) {
const { context, motion } = runtime;
if (motion.mode === "telegraph" && context.time >= motion.phaseEndsAt) {
motion.mode = "charging";
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + distance(motion.position, motion.chargeEnd) / config.speed;
return;
}
if (motion.mode !== "charging") return;
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, config.speed * context.delta);
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > config.width * 0.5) return member;
motion.chargeHitIds.push(member.id);
return {
...context.damageMember(member, config.damage, context.partyPositions[member.id], context.time),
knockedUntil: context.time + config.knockdown,
};
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.position = [...motion.chargeEnd];
finishMechanic(runtime, config.cooldown);
}
},
animationCue: (motion) => motion.mode === "charging" ? "move" : "attack",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
interface CircleAttackConfig {
id: BossMechanicId;
warning: number;
radius: number;
damage: number;
cooldown: number;
kind: CircleHazard["kind"];
targets: number;
duration?: number;
tickInterval?: number;
centeredOnBoss?: boolean;
stagger?: number;
}
function circleAttackDefinition(config: CircleAttackConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
const activatesAt = runtime.context.time + config.warning;
const targetIds = Array.from({ length: config.targets }, (_, offset) =>
chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount + offset));
const centers = config.centeredOnBoss
? [[...runtime.motion.position] as WorldPosition]
: targetIds.map((targetId) => [...runtime.context.partyPositions[targetId]] as WorldPosition);
runtime.motion.mode = config.id === "stormfall" ? "skyfall" : "golem_crownfall";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + (centers.length - 1) * (config.stagger ?? 0) + Math.max(0.32, config.duration ?? 0.32);
runtime.motion.hazards.push(...centers.map((center, index) => createCircleHazard({
id: `${config.id}-${runtime.motion.mechanicCount}-${index}`,
kind: config.kind,
center,
radius: config.radius,
activatesAt: activatesAt + index * (config.stagger ?? 0),
duration: config.duration ?? 0.32,
damage: config.damage,
tickInterval: config.tickInterval,
})));
runtime.events.push({
at: runtime.context.time,
message: `${copy.name}: ${copy.instruction}`,
tone: "danger",
pulseKind: "skyfall",
targetId: targetIds[0],
});
},
advance(runtime) { timedAdvance(runtime, config.cooldown); },
animationCue: () => "special",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
interface LaneAttackConfig {
id: BossMechanicId;
warning: number;
width: number;
damage: number;
cooldown: number;
angles: readonly number[];
rotateFollowup?: number;
}
function laneFromAngle(id: string, center: WorldPosition, angle: number, width: number, damage: number): SlashLane {
const dx = Math.sin(angle) * 9;
const dz = Math.cos(angle) * 9;
return { id, start: [center[0] - dx, center[1] - dz], end: [center[0] + dx, center[1] + dz], width, damage };
}
function resolveLanes(runtime: MechanicRuntime, name: string) {
const hitIds: MemberId[] = [];
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0) return member;
const lane = runtime.motion.slashLanes.find((entry) =>
pointToSegmentDistance(runtime.context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (!lane) return member;
hitIds.push(member.id);
runtime.events.push({ at: runtime.context.time, message: `${member.name} is struck by ${name}.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return runtime.context.damageMember(member, lane.damage, runtime.context.partyPositions[member.id], runtime.context.time);
});
runtime.motion.mechanicHitIds.push(...hitIds);
}
function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition {
const copy = mechanicCopy(config.id);
const startLanes = (runtime: MechanicRuntime, rotation = 0) => {
const targetId = chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount);
const center = runtime.context.partyPositions[targetId];
const aimed = angleTo(runtime.motion.position, center) + rotation;
runtime.motion.slashLanes = config.angles.map((offset, index) =>
laneFromAngle(`${config.id}-${runtime.motion.mechanicCount}-${index}-${rotation}`, center, aimed + offset, config.width, config.damage));
runtime.motion.chargeTargetId = targetId;
};
const definition: BossMechanicDefinition = {
id: config.id,
name: copy.name,
instruction: copy.instruction,
cooldown: config.cooldown,
start(runtime) {
runtime.motion.mode = "mantis_line_telegraph";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
runtime.motion.chargeCount = 0;
runtime.motion.mechanicHitIds = [];
startLanes(runtime);
runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "slash", targetId: runtime.motion.chargeTargetId });
},
advance(runtime) {
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
resolveLanes(runtime, copy.name);
if (config.rotateFollowup && runtime.motion.chargeCount === 0) {
runtime.motion.chargeCount = 1;
runtime.motion.mode = "mantis_cross_telegraph";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
startLanes(runtime, config.rotateFollowup);
runtime.events.push({ at: runtime.context.time, message: `${copy.name} rotates. Find new safe ground.`, tone: "danger", pulseKind: "slash" });
return;
}
finishMechanic(runtime, config.cooldown);
},
animationCue: () => "attack",
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS });
const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS });
const burrowRush = laneChargeDefinition({ id: "burrow-rush", warning: 1.3, speed: 10.8, distance: 13, width: 2, damage: 25, knockdown: 0.35, cooldown: 3.8, targetOrder: ["aelia", "nia", "orin", "vale", "brann"] });
const sidewinderRush = laneChargeDefinition({ id: "sidewinder-rush", warning: 1.25, speed: 11, distance: 13.2, width: 2.3, damage: 24, knockdown: 0.42, cooldown: 3.7, targetOrder: BULL_TARGETS });
const crushingPounce: BossMechanicDefinition = {
id: "crushing-pounce",
name: bossMechanicName("crushing-pounce"),
instruction: mechanicCopy("crushing-pounce").instruction,
cooldown: 4,
start(runtime) {
const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount);
runtime.motion.mode = "stacking";
runtime.motion.pounceTargetId = targetId;
runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]];
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + BULL_POUNCE.stackDuration;
runtime.events.push({ at: runtime.context.time, message: `Crushing Pounce marks ${memberName(runtime.party, targetId)}. Stack to split the impact.`, tone: "danger", pulseKind: "pounce", targetId });
},
advance(runtime) {
const { motion, context } = runtime;
if (motion.mode === "stacking") {
motion.pounceCenter = [...context.partyPositions[motion.pounceTargetId]];
if (context.time < motion.phaseEndsAt) return;
motion.mode = "pouncing";
motion.chargeStart = [...motion.position];
motion.chargeEnd = [...motion.pounceCenter];
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + BULL_POUNCE.leapDuration;
return;
}
if (motion.mode !== "pouncing") return;
motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta);
if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return;
const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id);
const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
runtime.party = runtime.party.map((member) => stackedIds.includes(member.id)
? context.damageMember(member, damage, context.partyPositions[member.id], context.time)
: member);
runtime.events.push({ at: context.time, message: `Crushing Pounce deals ${Math.round(damage)} damage across ${stackedIds.length} stacked allies.`, tone: "danger", pulseKind: "pounce", targetId: motion.pounceTargetId });
finishMechanic(runtime, BULL_POUNCE.cooldown);
},
animationCue: (motion) => motion.mode === "pouncing" ? "special" : "attack",
};
crushingPounce.upcoming = (motion, time) => defaultUpcoming(crushingPounce, motion, time);
function instantTimedDefinition(id: BossMechanicId, cooldown: number, start: (runtime: MechanicRuntime) => void, cue: BossAnimationCue = "attack"): BossMechanicDefinition {
const copy = mechanicCopy(id);
const definition: BossMechanicDefinition = {
id, name: copy.name, instruction: copy.instruction, cooldown,
start(runtime) {
runtime.motion.mode = "golem_shockwave";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 0.55;
start(runtime);
},
advance(runtime) { timedAdvance(runtime, cooldown); },
animationCue: () => cue,
};
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
return definition;
}
const cinderNova = instantTimedDefinition("cinder-nova", 5, (runtime) => {
runtime.party = runtime.party.map((member) => member.hp > 0
? runtime.context.damageMember(member, 13, runtime.context.partyPositions[member.id], runtime.context.time)
: member);
runtime.events.push({ at: runtime.context.time, message: "Cinder Nova strikes the party.", tone: "danger", pulseKind: "boss" });
}, "special");
const emberBrand = instantTimedDefinition("ember-brand", 5, (runtime) => {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount);
runtime.party = runtime.party.map((member) => member.id === targetId ? {
...member,
debuffs: [...member.debuffs, { id: `ember-brand-${runtime.motion.mechanicCount}`, name: "Ember Brand", expiresAt: runtime.context.time + 7, nextTickAt: runtime.context.time + 1, tickDamage: 6 }],
} : member);
runtime.events.push({ at: runtime.context.time, message: `Ember Brand afflicts ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "debuff", targetId });
});
const bindingWeb: BossMechanicDefinition = {
id: "binding-web", name: bossMechanicName("binding-web"), instruction: mechanicCopy("binding-web").instruction, cooldown: 4,
start(runtime) {
const pairs: readonly (readonly [MemberId, MemberId])[] = [["brann", "vale"], ["nia", "orin"], ["aelia", "nia"]];
const pair = pairs[(runtime.motion.mechanicCount - 1) % pairs.length];
const first = chooseLivingTarget(runtime.party, pair, 0);
const second = chooseLivingTarget(runtime.party, pair.filter((id) => id !== first), 0);
runtime.motion.mode = "tethering";
runtime.motion.tetherIds = [first, second];
runtime.motion.tetherBreakDistance = 6.8;
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
},
advance(runtime) {
const [first, second] = runtime.motion.tetherIds;
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
finishMechanic(runtime, 4);
return;
}
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
runtime.party = runtime.party.map((member) => runtime.motion.tetherIds.includes(member.id)
? { ...runtime.context.damageMember(member, 24, runtime.context.partyPositions[member.id], runtime.context.time), knockedUntil: runtime.context.time + 1.4 }
: member);
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
finishMechanic(runtime, 4);
},
animationCue: () => "attack",
};
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
runtime.motion.mode = "venom_cast";
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
...member,
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }],
} : member);
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
});
const stormBreath: BossMechanicDefinition = {
id: "storm-breath", name: bossMechanicName("storm-breath"), instruction: mechanicCopy("storm-breath").instruction, cooldown: 4,
start(runtime) {
const aimed = angleTo(runtime.motion.position, runtime.context.partyPositions.brann);
const direction = runtime.motion.mechanicCount % 2 === 0 ? 1 : -1;
runtime.motion.mode = "breath_telegraph";
runtime.motion.breathStartAngle = aimed - direction * Math.PI * 0.475;
runtime.motion.breathEndAngle = aimed + direction * Math.PI * 0.475;
runtime.motion.breathAngle = runtime.motion.breathStartAngle;
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + SKY_SWEEPER_BREATH.telegraphDuration;
runtime.motion.mechanicNextDamageAt = {};
runtime.events.push({ at: runtime.context.time, message: "Storm Breath gathers. Rotate behind the sweep.", tone: "danger", pulseKind: "breath" });
},
advance(runtime) {
const { motion, context } = runtime;
if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion.mode = "breath_sweeping";
motion.phaseStartedAt = context.time;
motion.phaseEndsAt = context.time + SKY_SWEEPER_BREATH.sweepDuration;
return;
}
if (motion.mode !== "breath_sweeping") return;
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0) return member;
const dx = context.partyPositions[member.id][0] - motion.position[0];
const dz = context.partyPositions[member.id][1] - motion.position[1];
const memberAngle = Math.atan2(dx, dz);
const exposed = Math.hypot(dx, dz) <= SKY_SWEEPER_BREATH.range && Math.abs(Math.atan2(Math.sin(memberAngle - motion.breathAngle), Math.cos(memberAngle - motion.breathAngle))) <= SKY_SWEEPER_BREATH.halfAngle;
if (!exposed) return member;
const nextAt = motion.mechanicNextDamageAt[member.id] ?? context.time;
if (nextAt > context.time) return member;
motion.mechanicNextDamageAt[member.id] = context.time + SKY_SWEEPER_BREATH.tickInterval;
return context.damageMember(member, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], context.time);
});
if (context.time >= motion.phaseEndsAt) finishMechanic(runtime, SKY_SWEEPER_BREATH.cooldown);
},
animationCue: () => "attack",
};
stormBreath.upcoming = (motion, time) => defaultUpcoming(stormBreath, motion, time);
const stormfall = circleAttackDefinition({ id: "stormfall", warning: 2, radius: 1.8, damage: 30, cooldown: 4, kind: "skyfall", targets: 3, duration: 5, tickInterval: 1, stagger: 0.9 });
const crushingTide = circleAttackDefinition({ id: "crushing-tide", warning: 1.35, radius: 1.7, damage: 26, cooldown: 3.7, kind: "tidal_burst", targets: 3 });
const hauntingRifts = circleAttackDefinition({ id: "haunting-rifts", warning: 1.45, radius: 1.75, damage: 5, cooldown: 3.9, kind: "soul_rift", targets: 2, duration: 4.2, tickInterval: 0.8 });
const ultimateSkyfall = circleAttackDefinition({ id: "ultimate-skyfall", warning: 1.5, radius: 1.85, damage: 28, cooldown: 4, kind: "crownfall", targets: 3 });
const ruinQuake = circleAttackDefinition({ id: "ruin-quake", warning: 1.35, radius: 3.6, damage: 31, cooldown: 3.8, kind: "quake", targets: 1, centeredOnBoss: true });
const elementalBeam = laneAttackDefinition({ id: "elemental-beam", warning: 0.9, width: 1.65, damage: 32, cooldown: 3.4, angles: [0] });
const guardianCross = laneAttackDefinition({ id: "guardian-cross", warning: 0.9, width: 1.45, damage: 25, cooldown: 3.4, angles: [-Math.PI * 0.18, Math.PI * 0.18] });
const destructionPulse = laneAttackDefinition({ id: "destruction-pulse", warning: 1.2, width: 1.25, damage: 24, cooldown: 3.8, angles: [0, Math.PI / 3, -Math.PI / 3] });
const vineScissors = laneAttackDefinition({ id: "vine-scissors", warning: 1.25, width: 1.55, damage: 22, cooldown: 3.9, angles: [0, Math.PI / 2], rotateFollowup: Math.PI / 4 });
const ricochetRush: BossMechanicDefinition = {
...laneChargeDefinition({ id: "ricochet-rush", warning: 1.25, speed: 12.5, distance: 13, width: 2.25, damage: 22, knockdown: 0.4, cooldown: 3.6, targetOrder: ["orin", "nia", "aelia", "vale", "brann"] }),
advance(runtime) {
const motion = runtime.motion;
if (motion.mode === "telegraph" && runtime.context.time >= motion.phaseEndsAt) {
motion.mode = "charging";
motion.chargeCount = 0;
motion.phaseStartedAt = runtime.context.time;
motion.phaseEndsAt = runtime.context.time + distance(motion.position, motion.chargeEnd) / 12.5;
return;
}
if (motion.mode !== "charging") return;
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, 12.5 * runtime.context.delta);
runtime.party = runtime.party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(runtime.context.partyPositions[member.id], previous, motion.position) > 1.125) return member;
motion.chargeHitIds.push(member.id);
return runtime.context.damageMember(member, 22, runtime.context.partyPositions[member.id], runtime.context.time);
});
if (distance(motion.position, motion.chargeEnd) >= 0.08 && runtime.context.time < motion.phaseEndsAt) return;
motion.hazards.push(createCircleHazard({ id: `ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, kind: "lava_pool", center: motion.chargeEnd, radius: 1.5, activatesAt: runtime.context.time, duration: 4.5, damage: 5, tickInterval: 0.8 }));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = chargeEndpoint(start, runtime.context.partyPositions[targetId], 13);
motion.position = start;
motion.chargeStart = start;
motion.chargeEnd = end;
motion.chargeTargetId = targetId;
motion.chargeHitIds = [];
motion.chargeCount = 1;
motion.phaseEndsAt = runtime.context.time + distance(start, end) / 12.5;
motion.slashLanes = [{ id: `ricochet-${motion.mechanicCount}-1`, start, end, width: 2.25, damage: 22 }];
runtime.events.push({ at: runtime.context.time, message: `Ricochet Rush rebounds toward ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return;
}
finishMechanic(runtime, 3.6);
},
};
const meteorSlam: BossMechanicDefinition = {
id: "meteor-slam", name: bossMechanicName("meteor-slam"), instruction: mechanicCopy("meteor-slam").instruction, cooldown: 3.6,
start(runtime) {
const activatesAt = runtime.context.time + 1.3;
runtime.motion.mode = "cinderback_slam";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + 0.3;
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-slam-${runtime.motion.mechanicCount}`, kind: "quake", center: runtime.motion.position, radius: 3.1, activatesAt, duration: 0.3, damage: 29 }));
for (let index = 0; index < 3; index += 1) {
const angle = index / 3 * Math.PI * 2;
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-flame-${runtime.motion.mechanicCount}-${index}`, kind: "lava_pool", center: clampToArena([runtime.motion.position[0] + Math.sin(angle) * 3.7, runtime.motion.position[1] + Math.cos(angle) * 3.7]), radius: 1.5, activatesAt, duration: 4.5, damage: 5, tickInterval: 0.8 }));
}
runtime.events.push({ at: runtime.context.time, message: "Meteor Slam: leave the impact and spreading flame.", tone: "danger", pulseKind: "boss" });
},
advance(runtime) { timedAdvance(runtime, 3.6); },
animationCue: () => "special",
};
meteorSlam.upcoming = (motion, time) => defaultUpcoming(meteorSlam, motion, time);
const hourglassEruption: BossMechanicDefinition = {
id: "hourglass-eruption", name: bossMechanicName("hourglass-eruption"), instruction: mechanicCopy("hourglass-eruption").instruction, cooldown: 3.8,
start(runtime) {
const activatesAt = runtime.context.time + 1.55;
for (let index = 0; index < 3; index += 1) {
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + index);
runtime.motion.hazards.push(createCircleHazard({ id: `stinger-${runtime.motion.mechanicCount}-${index}`, kind: "stinger_eruption", center: runtime.context.partyPositions[targetId], radius: 1.75, activatesAt, duration: 0.32, damage: 27 }));
}
runtime.motion.hazards.push(createCircleHazard({ id: `hourglass-${runtime.motion.mechanicCount}`, kind: "hourglass", center: clampToArena([runtime.motion.position[0], runtime.motion.position[1] + 3]), radius: 2.55, activatesAt: activatesAt + 0.9, duration: 3.6, damage: 6, tickInterval: 0.75 }));
runtime.motion.mode = "sandglass_hourglass";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = activatesAt + 4.5;
runtime.events.push({ at: runtime.context.time, message: "Hourglass Eruption: leave the eruptions and moving zone.", tone: "danger", pulseKind: "skyfall" });
},
advance(runtime) { timedAdvance(runtime, 3.8); },
animationCue: () => "special",
};
hourglassEruption.upcoming = (motion, time) => defaultUpcoming(hourglassEruption, motion, time);
const triBurst: BossMechanicDefinition = {
id: "tri-burst", name: bossMechanicName("tri-burst"), instruction: mechanicCopy("tri-burst").instruction, cooldown: 4,
start(runtime) {
const firstActivation = runtime.context.time + 1.2;
const bands = [{ innerRadius: 0, radius: 2.35 }, { innerRadius: 2.35, radius: 4.7 }, { innerRadius: 4.7, radius: 7.05 }];
runtime.motion.mode = "golem_shockwave";
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = firstActivation + 1.62;
runtime.motion.hazards.push(...bands.map((band, index) => createCircleHazard({ id: `tri-burst-${runtime.motion.mechanicCount}-${index}`, kind: "royal_shockwave", center: runtime.motion.position, innerRadius: band.innerRadius, radius: band.radius, activatesAt: firstActivation + index * 0.65, duration: 0.3, damage: 19 })));
runtime.events.push({ at: runtime.context.time, message: "Tri-Burst expands in three rings. Follow the safe bands.", tone: "danger", pulseKind: "boss" });
},
advance(runtime) { timedAdvance(runtime, 4); },
animationCue: () => "special",
};
triBurst.upcoming = (motion, time) => defaultUpcoming(triBurst, motion, time);
function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition {
const copy = mechanicCopy(id);
const definition: BossMechanicDefinition = {
id, name: copy.name, instruction: copy.instruction, cooldown: 5,
start(runtime) {
const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id);
runtime.motion = started.motion;
runtime.motion.mode = "golem_crownfall";
runtime.events.push(started.event);
},
advance(runtime) {
for (const telegraph of runtime.motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) runtime.party = resolveMemorySequence(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) runtime.party = resolveSoulSiphon(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
continue;
}
if (telegraph.resolved || runtime.context.time < telegraph.activatesAt) continue;
runtime.party = resolveTelegraph(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
telegraph.resolved = true;
}
runtime.motion.poolTelegraphs = runtime.motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > runtime.context.time);
if (!runtime.motion.poolTelegraphs.length) finishMechanic(runtime, 5);
},
upcoming(motion, time) { return upcomingPooledMechanic(motion, time) ?? defaultUpcoming(definition, motion, time); },
animationCue: () => id === "soul-siphon" || id === "memory-sequence" ? "special" : "attack",
};
return definition;
}
const basicMelee: BossMechanicDefinition = {
id: "basic-melee",
name: bossMechanicName("basic-melee"),
instruction: mechanicCopy("basic-melee").instruction,
cooldown: 0,
passive: true,
start() {},
advance(runtime) {
applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember);
},
animationCue: () => "idle",
};
export const BOSS_MECHANIC_REGISTRY: Record<BossMechanicId, BossMechanicDefinition> = {
"basic-melee": basicMelee,
"bull-charge": bullCharge,
"crushing-pounce": crushingPounce,
"cinder-nova": cinderNova,
"ember-brand": emberBrand,
"binding-web": bindingWeb,
"venom-purge": venomPurge,
"storm-breath": stormBreath,
stormfall,
"elemental-beam": elementalBeam,
"guardian-cross": guardianCross,
"destruction-rush": destructionRush,
"ruin-quake": ruinQuake,
"destruction-pulse": destructionPulse,
"ricochet-rush": ricochetRush,
"meteor-slam": meteorSlam,
"burrow-rush": burrowRush,
"hourglass-eruption": hourglassEruption,
"sidewinder-rush": sidewinderRush,
"crushing-tide": crushingTide,
"vine-scissors": vineScissors,
"haunting-rifts": hauntingRifts,
"tri-burst": triBurst,
"ultimate-skyfall": ultimateSkyfall,
"meteor-spread": telegraphDefinition("meteor-spread"),
"hollow-collapse": telegraphDefinition("hollow-collapse"),
"aetheric-soak": telegraphDefinition("aetheric-soak"),
"prism-beam": telegraphDefinition("prism-beam"),
"memory-sequence": telegraphDefinition("memory-sequence"),
"soul-siphon": telegraphDefinition("soul-siphon"),
};
function scheduledMechanicId(
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
mechanicCount: number,
) {
let activeCount = 0;
for (const id of loadout) if (!BOSS_MECHANIC_REGISTRY[id].passive) activeCount += 1;
if (!activeCount) throw new Error("Boss loadout requires at least one active mechanic.");
let targetIndex = mechanicCount % activeCount;
for (const id of loadout) {
if (BOSS_MECHANIC_REGISTRY[id].passive) continue;
if (targetIndex === 0) return id;
targetIndex -= 1;
}
return loadout[0];
}
export function advanceMechanicLoadout(
context: BossMechanicContext,
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
): BossMechanicResult {
const runtime: MechanicRuntime = {
context,
boss: { ...context.boss },
motion: cloneMotion(context.motion),
party: context.party,
events: [],
};
if (runtime.motion.mode === "holding") returnBossToArenaCenter(runtime.motion, context.delta, 2);
for (const mechanicId of loadout) {
const definition = BOSS_MECHANIC_REGISTRY[mechanicId];
if (definition.passive) definition.advance(runtime);
}
if (runtime.motion.activeMechanicId) {
BOSS_MECHANIC_REGISTRY[runtime.motion.activeMechanicId].advance(runtime);
}
if (!runtime.motion.activeMechanicId && context.time >= runtime.motion.nextMechanicAt) {
const mechanicId = scheduledMechanicId(loadout, runtime.motion.mechanicCount);
runtime.motion.mechanicCount += 1;
runtime.motion.activeMechanicId = mechanicId;
runtime.motion.nextMechanicAt = Number.POSITIVE_INFINITY;
BOSS_MECHANIC_REGISTRY[mechanicId].start(runtime);
}
runtime.party = resolveCircleHazards(runtime.motion, runtime.party, context.partyPositions, context.time, context.damageMember, runtime.events);
return { boss: runtime.boss, motion: runtime.motion, party: runtime.party, events: runtime.events };
}
export function upcomingLoadoutMechanic(
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
motion: BossMotionState,
time: number,
): UpcomingMechanic {
const id = motion.activeMechanicId ?? scheduledMechanicId(loadout, motion.mechanicCount);
const definition = BOSS_MECHANIC_REGISTRY[id];
return definition.upcoming?.(motion, time) ?? defaultUpcoming(definition, motion, time);
}
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle";
}
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
const next = cloneMotion(motion);
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
return next;
}
export function handleMechanicDispel(
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
) {
if (debuffNames.includes("Widow Venom")) {
return {
motion: dropVenomPool(motion, memberId, position, time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
}
return { motion, message: "Harmful magic removed." };
}