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, }; }