Release v0.1.2 2026-07-11
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } 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(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
|
||||
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(): BossMotionState {
|
||||
return {
|
||||
...createBaseMotion("ember-mantis-duelist"),
|
||||
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 ? "Cross Slash" : "Line Slash"}.`,
|
||||
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") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(
|
||||
motion.position,
|
||||
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
|
||||
2.5 * context.delta,
|
||||
);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
motion = beginSidestep(motion, party, context.partyPositions, context.time);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `Ember Mantis sidesteps toward ${memberName(party, motion.chargeTargetId)}. Track the blades.`,
|
||||
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 ? "Cross Slash! Find a safe quadrant." : "Line Slash! 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 {
|
||||
void boss;
|
||||
if (motion.mode === "mantis_sidestep") {
|
||||
return { name: "Duelist repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_line_telegraph") {
|
||||
return { name: "Line Slash — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_cross_telegraph") {
|
||||
return { name: "Cross Slash — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_recover") {
|
||||
return { name: "Cinderblade 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 ? "Cross Slash" : "Line Slash",
|
||||
remaining,
|
||||
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
|
||||
urgent: remaining < 2.5,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user