1291 lines
56 KiB
TypeScript
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." };
|
|
}
|