Release v0.1.5 2026-07-12
This commit is contained in:
+775
-69
@@ -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." };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user