Release v0.1.5 2026-07-12

This commit is contained in:
Warren H
2026-07-12 22:10:12 -04:00
parent bef71d391a
commit 35553c18dd
41 changed files with 2005 additions and 2654 deletions
+775 -69
View File
@@ -1,9 +1,34 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
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",
@@ -36,18 +61,62 @@ export const BOSS_MECHANIC_POOL = [
},
] as const;
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
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 = {
firstAt: 15,
repeatDelay: 26,
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.68,
flashDuration: 0.9,
inputDuration: 7,
tileSize: 2.15,
raidwideDamage: 15,
@@ -84,13 +153,6 @@ const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function bossPoolOffset(bossId: string) {
let value = 0;
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
return value;
}
function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0)
? targetId
@@ -220,9 +282,10 @@ function beginPoolMechanic(
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
time: number,
requestedId: BossMechanicId,
) {
const count = motion.poolMechanicCount + 1;
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
const entry = MECHANIC_COPY_BY_ID[requestedId];
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined;
@@ -281,7 +344,6 @@ function beginPoolMechanic(
motion: {
...motion,
poolMechanicCount: count,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: telegraphs,
},
event: {
@@ -498,61 +560,6 @@ function resolveTelegraph(
return next;
}
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
export function advancePooledBossMechanics(
context: BossMechanicContext,
result: BossMechanicResult,
): BossMechanicResult {
if (context.allowPooledMechanics === false) return result;
const source = result.motion;
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
let motion: BossMotionState = {
...source,
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
...telegraph,
center: [telegraph.center[0], telegraph.center[1]],
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
soulSiphon: telegraph.soulSiphon && {
...telegraph.soulSiphon,
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
},
hitIds: [...telegraph.hitIds],
})),
};
let party = result.party;
const events = [...result.events];
for (const telegraph of motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
telegraph.resolved = true;
}
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
motion = started.motion;
events.push(started.event);
}
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
}
return { ...result, motion, party, events };
}
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;
@@ -582,3 +589,702 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U
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." };
}