diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend1 b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend1 deleted file mode 100644 index 7c3e368..0000000 Binary files a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend1 and /dev/null differ diff --git a/package.json b/package.json index 194158f..083074f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/scripts/blender/build_ember_mantis_duelist.py b/scripts/blender/build_ember_mantis_duelist.py index 7127200..6ab87f6 100644 --- a/scripts/blender/build_ember_mantis_duelist.py +++ b/scripts/blender/build_ember_mantis_duelist.py @@ -790,6 +790,7 @@ def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None: def main() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + bpy.context.preferences.filepaths.save_version = 0 reset_scene() mats = { "chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42), diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 88871d9..7725f72 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -17,6 +17,7 @@ const SPIDER_TEXTURE_URLS: Record = { "haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href, }; const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href; +const EMBER_MANTIS_URL = new URL("../../game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb", import.meta.url).href; const PARTY_MODEL_URLS: Record = { aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href, brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href, @@ -491,7 +492,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) { return ( - + ); } @@ -580,7 +581,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { ); } -type AlternateBossKind = "vexa" | "cindermaw"; +type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist"; const ALTERNATE_BOSS_CONFIG = { vexa: { @@ -605,6 +606,17 @@ const ALTERNATE_BOSS_CONFIG = { light: "#ff8742", rotationOffset: 0, }, + "ember-mantis-duelist": { + url: EMBER_MANTIS_URL, + scale: 0.78, + idle: "Idle", + move: "Sidestep", + attack: "LineSlash", + special: "CrossSlash", + death: "Death", + light: "#ff5a24", + rotationOffset: 0, + }, } as const; function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) { @@ -634,27 +646,43 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex const clipName = phase === "victory" || defeated ? config.death - : motionMode === "skyfall" - ? config.move - : motionMode === "breath_telegraph" || motionMode === "breath_sweeping" - ? config.special - : motionMode === "tethering" || motionMode === "venom_cast" + : kind === "ember-mantis-duelist" + ? motionMode === "mantis_sidestep" + ? config.move + : motionMode === "mantis_line_telegraph" ? config.attack - : config.idle; + : motionMode === "mantis_cross_telegraph" + ? config.special + : motionMode === "mantis_recover" + ? "Recover" + : config.idle + : motionMode === "skyfall" + ? config.move + : motionMode === "breath_telegraph" || motionMode === "breath_sweeping" + ? config.special + : motionMode === "tethering" || motionMode === "venom_cast" + ? config.attack + : config.idle; useEffect(() => { const next = actions[clipName]; if (!next) return; for (const action of Object.values(actions)) action?.fadeOut(0.16); - next.reset().setEffectiveWeight(1).fadeIn(0.16).play(); - if (phase === "victory" || defeated) { + const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph" + ? 0.55 + : kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph" + ? 0.6 + : 1; + next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play(); + const emberOneShot = kind === "ember-mantis-duelist" && clipName !== config.idle; + if (phase === "victory" || defeated || emberOneShot) { next.setLoop(THREE.LoopOnce, 1); next.clampWhenFinished = true; } else { next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); } return () => { next.fadeOut(0.16); }; - }, [actions, clipName, defeated, phase]); + }, [actions, clipName, config.idle, defeated, kind, motionMode, phase]); useFrame((_, delta) => { if (!group.current) return; @@ -672,6 +700,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex ); if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) { targetAngle = motion.breathAngle; + } else if (kind === "ember-mantis-duelist" && ( + motion.mode === "mantis_sidestep" + || motion.mode === "mantis_line_telegraph" + || motion.mode === "mantis_cross_telegraph" + )) { + const target = state.partyPositions[motion.chargeTargetId]; + targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]); } const difference = Math.atan2( Math.sin(targetAngle - group.current.rotation.y), @@ -877,7 +912,7 @@ function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) { const state = useGameStore.getState(); const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0]; const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]]; - const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087"; + const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087"; useFrame((_, delta) => { age.current += delta; if (!ring.current || !material.current) return; @@ -926,6 +961,7 @@ export function GameScene() { } useGLTF.preload(BULL_URL, false, true); +useGLTF.preload(EMBER_MANTIS_URL, false, true); for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true); for (const loadout of Object.values(PARTY_WEAPON_URLS)) { useGLTF.preload(loadout.right, false, true); diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx index 909f098..ab725e7 100644 --- a/src/components/boss/BossMechanicIndicators.tsx +++ b/src/components/boss/BossMechanicIndicators.tsx @@ -8,6 +8,7 @@ import { useGameStore } from "../../game/store"; const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const; const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2); const EMPTY_HAZARDS: never[] = []; +const EMPTY_SLASH_LANES: never[] = []; type GameStoreState = ReturnType; function motionAt(state: GameStoreState, bossIndex: number) { @@ -68,6 +69,63 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) { ); } +function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: number }) { + const phase = useGameStore((state) => state.phase); + const motion = useGameStore((state) => motionAt(state, bossIndex)); + const material = useRef(null); + const edgeMaterial = useRef(null); + useFrame(({ clock }) => { + if (!material.current || !edgeMaterial.current) return; + const current = motionAt(useGameStore.getState(), bossIndex); + const active = current?.mode === "mantis_recover"; + material.current.opacity = active ? 0.5 : 0.14 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.09; + edgeMaterial.current.opacity = active ? 1 : 0.62 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.15; + }); + const lane = motion?.slashLanes.find((candidate) => candidate.id === laneId); + if (!lane || phase !== "combat") return null; + const visible = motion.mode === "mantis_line_telegraph" + || motion.mode === "mantis_cross_telegraph" + || motion.mode === "mantis_recover"; + if (!visible) return null; + + const dx = lane.end[0] - lane.start[0]; + const dz = lane.end[1] - lane.start[1]; + const length = Math.hypot(dx, dz); + const angle = Math.atan2(dx, dz); + const midpoint: [number, number, number] = [ + (lane.start[0] + lane.end[0]) * 0.5, + 0.052, + (lane.start[1] + lane.end[1]) * 0.5, + ]; + const active = motion.mode === "mantis_recover"; + const color = active ? "#ffd36a" : "#ff5128"; + return ( + + + + + + {[-1, 1].map((side) => ( + + + + + ))} + {active && ( + + + + + )} + + ); +} + +export function SlashLaneIndicators({ bossIndex = 0 }: { bossIndex?: number }) { + const lanes = useGameStore((state) => motionAt(state, bossIndex)?.slashLanes ?? EMPTY_SLASH_LANES); + return <>{lanes.map((lane) => )}; +} + export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) { const phase = useGameStore((state) => state.phase); const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode); @@ -205,6 +263,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number } const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [ ChargeLaneIndicator, + SlashLaneIndicators, PounceStackIndicator, BindingWebIndicator, BreathConeIndicator, diff --git a/src/frontend/data.test.ts b/src/frontend/data.test.ts index 5090213..918b385 100644 --- a/src/frontend/data.test.ts +++ b/src/frontend/data.test.ts @@ -10,7 +10,8 @@ describe("game mode configuration", () => { it("selects a boss across the full encounter pool", () => { expect(selectRandomBoss(() => 0)).toBe("bulldrome"); expect(selectRandomBoss(() => 0.34)).toBe("vexa"); - expect(selectRandomBoss(() => 0.99)).toBe("cindermaw"); + expect(selectRandomBoss(() => 0.7)).toBe("cindermaw"); + expect(selectRandomBoss(() => 0.99)).toBe("ember-mantis-duelist"); }); it("selects two distinct bosses for PVE", () => { diff --git a/src/frontend/data.ts b/src/frontend/data.ts index 81e5b1d..7616fea 100644 --- a/src/frontend/data.ts +++ b/src/frontend/data.ts @@ -44,6 +44,17 @@ export const DEFAULT_COLLECTIONS: BossCollection[] = [ { id: "maw-breath", name: "Bottled Breath", icon: "☀", rarity: "Mythic", count: 0 }, ], }, + { + bossId: "ember-mantis-duelist", + bossName: "Ember Mantis Duelist", + defeated: false, + drops: [ + { id: "mantis-chitin", name: "Ember Chitin", icon: "◇", rarity: "Common", count: 0 }, + { id: "mantis-edge", name: "Cinderblade Edge", icon: "⚔", rarity: "Uncommon", count: 0 }, + { id: "mantis-antenna", name: "Duelist Antenna", icon: "⌁", rarity: "Rare", count: 0 }, + { id: "mantis-core", name: "Molten Mantis Core", icon: "✦", rarity: "Mythic", count: 0 }, + ], + }, ]; export const MODE_COPY: Record = { @@ -58,7 +69,7 @@ export const MODE_COPY: Record { const combinations: readonly (readonly [BossId, BossId])[] = [ ["bulldrome", "vexa"], ["bulldrome", "cindermaw"], + ["bulldrome", "ember-mantis-duelist"], ["vexa", "cindermaw"], + ["vexa", "ember-mantis-duelist"], + ["cindermaw", "ember-mantis-duelist"], ]; it.each(combinations)("party rotations defeat %s + %s", (first, second) => { diff --git a/src/game/bossCatalog.ts b/src/game/bossCatalog.ts index e4e6702..374380a 100644 --- a/src/game/bossCatalog.ts +++ b/src/game/bossCatalog.ts @@ -16,7 +16,7 @@ export interface BossDefinition { maxHp: number; } -export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"]; +export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw", "ember-mantis-duelist"]; export const BOSS_DEFINITIONS: Record = { bulldrome: { @@ -64,4 +64,19 @@ export const BOSS_DEFINITIONS: Record = { mechanics: ["Searing Sweep", "Skyfall"], maxHp: 410, }, + "ember-mantis-duelist": { + id: "ember-mantis-duelist", + name: "Ember Mantis Duelist", + title: "The Cinderblade", + trial: "Trial IV · Blades in Motion", + icon: "⚔", + accent: "#ff6a2a", + summary: "Sidesteps across the arena before carving single and crossed slash lanes.", + briefing: "Track each sidestep. Clear the glowing Line Slash, then find a safe quadrant when both scythes form Cross Slash.", + failure: "Do not chase the duelist through a telegraph. Preserve space and move perpendicular to each ember lane.", + mapTitle: "The Cinderblade Court", + mapCopy: "Follow the mantis laterally, but cross glowing cut lanes only after the blades finish their recovery.", + mechanics: ["Line Slash", "Cross Slash"], + maxHp: 520, + }, }; diff --git a/src/game/bossMechanics.ts b/src/game/bossMechanics.ts index a26cc9c..de8ad63 100644 --- a/src/game/bossMechanics.ts +++ b/src/game/bossMechanics.ts @@ -1,5 +1,6 @@ import { BOSS_DEFINITIONS } from "./bossCatalog"; import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw"; +import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis"; import { createBaseMotion } from "./bosses/shared"; import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types"; import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa"; @@ -39,6 +40,7 @@ const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale" export function createBossState(bossId: BossId = "bulldrome"): BossState { if (bossId === "vexa") return createVexaState(); if (bossId === "cindermaw") return createCindermawState(); + if (bossId === "ember-mantis-duelist") return createEmberMantisState(); const definition = BOSS_DEFINITIONS.bulldrome; return { id: "bulldrome", @@ -55,6 +57,7 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState { export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState { if (bossId === "vexa") return createVexaMotion(); if (bossId === "cindermaw") return createCindermawMotion(); + if (bossId === "ember-mantis-duelist") return createEmberMantisMotion(); return { ...createBaseMotion("bulldrome"), mode: "holding", @@ -348,6 +351,7 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult { if (context.boss.id === "vexa") return advanceVexaMechanics(context); if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context); + if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context); return advanceBulldromeMechanics(context); } @@ -371,6 +375,7 @@ export function handleBossDispel( export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) { if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time); if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time); + if (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time); if (motion.mode === "telegraph") { return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true }; } diff --git a/src/game/bosses/emberMantis.test.ts b/src/game/bosses/emberMantis.test.ts new file mode 100644 index 0000000..53f52a5 --- /dev/null +++ b/src/game/bosses/emberMantis.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { freshParty } from "../data"; +import { pointToSegmentDistance } from "../geometry"; +import { evadeSlashLanesBehavior } from "../partyBehaviors"; +import type { BossMechanicContext } from "./types"; +import { + advanceEmberMantisMechanics, + createEmberMantisMotion, + createEmberMantisState, + EMBER_MANTIS_SLASH, +} from "./emberMantis"; + +const POSITIONS: BossMechanicContext["partyPositions"] = { + aelia: [0, 4.5], + brann: [0, 0], + nia: [-3, 2], + orin: [3, 2], + vale: [0, -2], +}; + +function context( + boss: ReturnType, + motion: ReturnType, + party = freshParty(), + time = 0, + delta = 0.1, +): BossMechanicContext { + return { + boss, + motion, + party, + partyPositions: structuredClone(POSITIONS), + time, + delta, + damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }), + }; +} + +describe("Ember Mantis Duelist mechanics", () => { + it("sidesteps, telegraphs Line Slash, then damages targets left in the lane", () => { + const boss = createEmberMantisState(); + const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1)); + expect(sidestep.motion.mode).toBe("mantis_sidestep"); + expect(sidestep.motion.chargeTargetId).toBe("nia"); + + const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6)); + expect(telegraph.motion.mode).toBe("mantis_line_telegraph"); + expect(telegraph.motion.slashLanes).toHaveLength(1); + + const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp; + const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91)); + expect(impact.motion.mode).toBe("mantis_recover"); + expect(impact.motion.mechanicHitIds).toContain("nia"); + expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage); + expect(impact.events.some((event) => event.message.includes("Line Slash"))).toBe(true); + }); + + it("alternates into two crossed slash lanes", () => { + const motion = { + ...createEmberMantisMotion(), + mode: "mantis_sidestep" as const, + mechanicCount: 1, + chargeTargetId: "orin" as const, + chargeEnd: [0, -6.6] as [number, number], + phaseEndsAt: 0, + }; + const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)); + expect(result.motion.mode).toBe("mantis_cross_telegraph"); + expect(result.motion.slashLanes).toHaveLength(2); + expect(result.motion.slashLanes[0].id).toContain("cross"); + }); + + it("gives mobile allies a target outside crossed lanes", () => { + const motion = { + ...createEmberMantisMotion(), + mode: "mantis_sidestep" as const, + mechanicCount: 1, + chargeTargetId: "orin" as const, + chargeEnd: [0, -6.6] as [number, number], + phaseEndsAt: 0, + }; + const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion; + const decision = evadeSlashLanesBehavior.decide({ + memberId: "orin", + current: POSITIONS.orin, + formationTarget: POSITIONS.orin, + bossMotion: telegraph, + partyPositions: POSITIONS, + time: 1, + }); + expect(decision).not.toBeNull(); + const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) => + pointToSegmentDistance(decision!.target, lane.start, lane.end), + )); + expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance); + }); +}); diff --git a/src/game/bosses/emberMantis.ts b/src/game/bosses/emberMantis.ts new file mode 100644 index 0000000..b76a11d --- /dev/null +++ b/src/game/bosses/emberMantis.ts @@ -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, + }; +} diff --git a/src/game/bosses/shared.ts b/src/game/bosses/shared.ts index 8865538..86b93fb 100644 --- a/src/game/bosses/shared.ts +++ b/src/game/bosses/shared.ts @@ -30,6 +30,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState { breathStartAngle: 0, breathEndAngle: 0, hazards: [], + slashLanes: [], }; } @@ -50,6 +51,11 @@ export function cloneMotion(source: BossMotionState): BossMotionState { nextDamageAt: { ...hazard.nextDamageAt }, hitIds: [...hazard.hitIds], })), + slashLanes: source.slashLanes.map((lane) => ({ + ...lane, + start: [lane.start[0], lane.start[1]], + end: [lane.end[0], lane.end[1]], + })), }; } diff --git a/src/game/partyBehaviors.ts b/src/game/partyBehaviors.ts index e5f5b8b..50b63d7 100644 --- a/src/game/partyBehaviors.ts +++ b/src/game/partyBehaviors.ts @@ -1,5 +1,6 @@ import { BULL_CHARGE } from "./bossMechanics"; import { CINDER_BREATH } from "./bosses/cindermaw"; +import { EMBER_MANTIS_SLASH } from "./bosses/emberMantis"; import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry"; import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; @@ -122,6 +123,42 @@ export const avoidBreathBehavior: PartyBehavior = { }, }; +export const evadeSlashLanesBehavior: PartyBehavior = { + id: "evade-slash-lanes", + decide: ({ memberId, current, formationTarget, bossMotion }) => { + if (bossMotion.mode !== "mantis_line_telegraph" && bossMotion.mode !== "mantis_cross_telegraph") return null; + if (!bossMotion.slashLanes.length) return null; + const unsafe = (position: WorldPosition) => bossMotion.slashLanes.some((lane) => + pointToSegmentDistance(position, lane.start, lane.end) < EMBER_MANTIS_SLASH.aiClearance, + ); + if (!unsafe(current) && !unsafe(formationTarget)) return null; + + const origin = unsafe(formationTarget) ? formationTarget : current; + const memberOffset = AI_MEMBER_IDS.indexOf(memberId) * (Math.PI / 4); + let best = current; + let bestScore = Number.NEGATIVE_INFINITY; + for (const radius of [2.2, 3.4]) { + for (let index = 0; index < 8; index += 1) { + const angle = memberOffset + (index / 8) * Math.PI * 2; + const candidate = clampToArena([ + origin[0] + Math.sin(angle) * radius, + origin[1] + Math.cos(angle) * radius, + ]); + const laneDistance = Math.min(...bossMotion.slashLanes.map((lane) => + pointToSegmentDistance(candidate, lane.start, lane.end), + )); + const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08; + const score = laneDistance - travelPenalty; + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + } + return { target: best, speed: EMBER_MANTIS_SLASH.aiEvadeSpeed }; + }, +}; + export const avoidCircleHazardsBehavior: PartyBehavior = { id: "avoid-circle-hazards", decide: ({ memberId, current, formationTarget, bossMotion, time }) => { @@ -143,7 +180,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = { export const maintainFormationBehavior: PartyBehavior = { id: "maintain-formation", decide: ({ formationTarget, bossMotion, memberId }) => { - if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null; + if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover"].includes(bossMotion.mode)) return null; return { target: formationTarget, speed: MOVE_SPEEDS[memberId] }; }, }; @@ -152,6 +189,7 @@ export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [ breakTetherBehavior, stackForPounceBehavior, evadeChargeBehavior, + evadeSlashLanesBehavior, avoidBreathBehavior, avoidCircleHazardsBehavior, maintainFormationBehavior, diff --git a/src/game/partyCombat.test.ts b/src/game/partyCombat.test.ts index dafc60b..727b624 100644 --- a/src/game/partyCombat.test.ts +++ b/src/game/partyCombat.test.ts @@ -144,7 +144,10 @@ describe("dual-boss damage simulations", () => { const combinations: readonly (readonly [BossId, BossId])[] = [ ["bulldrome", "vexa"], ["bulldrome", "cindermaw"], + ["bulldrome", "ember-mantis-duelist"], ["vexa", "cindermaw"], + ["vexa", "ember-mantis-duelist"], + ["cindermaw", "ember-mantis-duelist"], ]; it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => { diff --git a/src/game/types.ts b/src/game/types.ts index 551368a..c777f79 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,9 +1,9 @@ export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale"; export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier"; -export type BossId = "bulldrome" | "vexa" | "cindermaw"; +export type BossId = "bulldrome" | "vexa" | "cindermaw" | "ember-mantis-duelist"; export type GamePhase = "briefing" | "combat" | "victory" | "defeat"; export type BottomTab = "combat" | "map" | "pack"; -export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall"; +export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash"; export type BossMotionMode = | "holding" | "telegraph" @@ -15,7 +15,11 @@ export type BossMotionMode = | "venom_cast" | "breath_telegraph" | "breath_sweeping" - | "skyfall"; + | "skyfall" + | "mantis_sidestep" + | "mantis_line_telegraph" + | "mantis_cross_telegraph" + | "mantis_recover"; export type WorldPosition = [number, number]; export type CircleHazardKind = "venom_pool" | "skyfall"; @@ -34,6 +38,14 @@ export interface CircleHazard { hitIds: MemberId[]; } +export interface SlashLane { + id: string; + start: WorldPosition; + end: WorldPosition; + width: number; + damage: number; +} + export interface Debuff { id: string; name: string; @@ -95,6 +107,7 @@ export interface BossMotionState { breathStartAngle: number; breathEndAngle: number; hazards: CircleHazard[]; + slashLanes: SlashLane[]; } export interface AbilityDefinition {