242 lines
8.7 KiB
TypeScript
242 lines
8.7 KiB
TypeScript
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
|
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
|
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, returnBossToArenaCenter } from "./shared";
|
|
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
|
|
export const EMBER_MANTIS_SLASH = {
|
|
firstAt: 5,
|
|
repeatDelay: 3.4,
|
|
sidestepDuration: 0.55,
|
|
sidestepDistance: 3.8,
|
|
sidestepSpeed: 7.2,
|
|
telegraphDuration: 0.9,
|
|
recoverDuration: 0.7,
|
|
laneLength: 18,
|
|
lineWidth: 1.65,
|
|
crossWidth: 1.45,
|
|
lineDamage: 32,
|
|
crossDamage: 25,
|
|
crossAngle: Math.PI * 0.18,
|
|
staggerDuration: 0.35,
|
|
aiClearance: 1.35,
|
|
aiEvadeSpeed: 4.8,
|
|
} as const;
|
|
|
|
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
|
|
const MIN_BOSS_X = -5.8;
|
|
const MAX_BOSS_X = 5.8;
|
|
|
|
export function createEmberMantisState(bossId: BossId = "warcaller-orc"): BossState {
|
|
const definition = BOSS_DEFINITIONS[bossId];
|
|
return {
|
|
id: definition.id,
|
|
name: definition.name,
|
|
maxHp: definition.maxHp,
|
|
hp: definition.maxHp,
|
|
nextMeleeAt: 2.2,
|
|
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
brandCount: 0,
|
|
};
|
|
}
|
|
|
|
export function createEmberMantisMotion(bossId: BossId = "warcaller-orc"): BossMotionState {
|
|
return {
|
|
...createBaseMotion(bossId),
|
|
position: [0, -6.6],
|
|
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
|
|
};
|
|
}
|
|
|
|
function clampBossX(value: number) {
|
|
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
|
|
}
|
|
|
|
function createLane(
|
|
id: string,
|
|
center: WorldPosition,
|
|
angle: number,
|
|
width: number,
|
|
damage: number,
|
|
): SlashLane {
|
|
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
|
|
const dx = Math.sin(angle) * halfLength;
|
|
const dz = Math.cos(angle) * halfLength;
|
|
return {
|
|
id,
|
|
start: [center[0] - dx, center[1] - dz],
|
|
end: [center[0] + dx, center[1] + dz],
|
|
width,
|
|
damage,
|
|
};
|
|
}
|
|
|
|
function beginSidestep(
|
|
motion: BossMotionState,
|
|
party: BossMechanicContext["party"],
|
|
partyPositions: BossMechanicContext["partyPositions"],
|
|
time: number,
|
|
) {
|
|
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
|
|
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
|
|
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
|
if (Math.abs(targetX - motion.position[0]) < 1) {
|
|
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
|
}
|
|
return {
|
|
...motion,
|
|
mode: "mantis_sidestep" as const,
|
|
chargeTargetId: targetId,
|
|
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
|
|
phaseStartedAt: time,
|
|
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
|
|
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
slashLanes: [],
|
|
mechanicHitIds: [],
|
|
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
|
|
};
|
|
}
|
|
|
|
function beginSlashTelegraph(
|
|
motion: BossMotionState,
|
|
partyPositions: BossMechanicContext["partyPositions"],
|
|
time: number,
|
|
) {
|
|
const target = partyPositions[motion.chargeTargetId];
|
|
const aimedAngle = angleTo(motion.position, target);
|
|
const isCrossSlash = motion.mechanicCount % 2 === 1;
|
|
const slashNumber = motion.mechanicCount + 1;
|
|
const lanes = isCrossSlash
|
|
? [
|
|
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
|
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
|
]
|
|
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
|
|
|
|
return {
|
|
...motion,
|
|
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
|
|
phaseStartedAt: time,
|
|
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
|
|
mechanicCount: slashNumber,
|
|
slashLanes: lanes,
|
|
mechanicHitIds: [],
|
|
};
|
|
}
|
|
|
|
function resolveSlash(
|
|
motion: BossMotionState,
|
|
context: BossMechanicContext,
|
|
events: BossMechanicResult["events"],
|
|
) {
|
|
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
|
|
const hitIds: MemberId[] = [];
|
|
const party = context.party.map((member) => {
|
|
if (member.hp <= 0) return member;
|
|
const lane = motion.slashLanes.find((candidate) =>
|
|
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
|
|
);
|
|
if (!lane) return member;
|
|
hitIds.push(member.id);
|
|
events.push({
|
|
at: context.time,
|
|
message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`,
|
|
tone: "danger",
|
|
pulseKind: "slash",
|
|
targetId: member.id,
|
|
});
|
|
return {
|
|
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
|
|
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
|
|
};
|
|
});
|
|
return { party, hitIds };
|
|
}
|
|
|
|
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
const boss = { ...context.boss };
|
|
let motion = cloneMotion(context.motion);
|
|
let party = context.party;
|
|
const events: BossMechanicResult["events"] = [];
|
|
|
|
if (motion.mode === "holding") {
|
|
returnBossToArenaCenter(motion, context.delta, 2.5);
|
|
if (context.time >= motion.nextMechanicAt) {
|
|
motion = beginSidestep(motion, party, context.partyPositions, context.time);
|
|
events.push({
|
|
at: context.time,
|
|
message: `${boss.name} shifts toward ${memberName(party, motion.chargeTargetId)}. Track its attack.`,
|
|
tone: "danger",
|
|
pulseKind: "slash",
|
|
targetId: motion.chargeTargetId,
|
|
});
|
|
}
|
|
} else if (motion.mode === "mantis_sidestep") {
|
|
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
|
|
if (context.time >= motion.phaseEndsAt) {
|
|
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
|
|
const cross = motion.mode === "mantis_cross_telegraph";
|
|
events.push({
|
|
at: context.time,
|
|
message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.",
|
|
tone: "danger",
|
|
pulseKind: "slash",
|
|
targetId: motion.chargeTargetId,
|
|
});
|
|
}
|
|
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
|
|
if (context.time >= motion.phaseEndsAt) {
|
|
const resolved = resolveSlash(motion, context, events);
|
|
party = resolved.party;
|
|
motion = {
|
|
...motion,
|
|
mode: "mantis_recover",
|
|
phaseStartedAt: context.time,
|
|
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
|
|
mechanicHitIds: resolved.hitIds,
|
|
};
|
|
}
|
|
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
|
|
motion = {
|
|
...motion,
|
|
mode: "holding",
|
|
phaseStartedAt: context.time,
|
|
phaseEndsAt: 0,
|
|
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
|
|
slashLanes: [],
|
|
mechanicHitIds: [],
|
|
};
|
|
}
|
|
|
|
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
|
|
return { boss, motion, party, events };
|
|
}
|
|
|
|
export function upcomingEmberMantisMechanic(
|
|
boss: BossState,
|
|
motion: BossMotionState,
|
|
time: number,
|
|
): UpcomingMechanic {
|
|
if (motion.mode === "mantis_sidestep") {
|
|
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
|
|
}
|
|
if (motion.mode === "mantis_line_telegraph") {
|
|
return { name: "Elemental Beam — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
|
}
|
|
if (motion.mode === "mantis_cross_telegraph") {
|
|
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
|
}
|
|
if (motion.mode === "mantis_recover") {
|
|
return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
|
|
}
|
|
const nextIsCross = motion.mechanicCount % 2 === 1;
|
|
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
return {
|
|
name: nextIsCross ? "Guardian Cross" : "Elemental Beam",
|
|
remaining,
|
|
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
|
|
urgent: remaining < 2.5,
|
|
};
|
|
}
|