Files
i-want-to-heal-mmo/src/game/bosses/emberMantis.ts
T
2026-07-12 13:49:46 -04:00

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