Release v0.1.2 2026-07-11

This commit is contained in:
Warren H
2026-07-11 00:12:57 -04:00
parent 6726c600e4
commit 076f6cf97c
16 changed files with 556 additions and 21 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -790,6 +790,7 @@ def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None:
def main() -> None: def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True) OUT_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True) PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
bpy.context.preferences.filepaths.save_version = 0
reset_scene() reset_scene()
mats = { mats = {
"chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42), "chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42),
+42 -6
View File
@@ -17,6 +17,7 @@ const SPIDER_TEXTURE_URLS: Record<string, string> = {
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href, "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 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<MemberId, string> = { const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href, 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, 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 ( return (
<mesh castShadow position={[position[0], 1.1, position[1]]}> <mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} /> <dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} /> <meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "ember-mantis-duelist" ? "#a42d18" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh> </mesh>
); );
} }
@@ -580,7 +581,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
); );
} }
type AlternateBossKind = "vexa" | "cindermaw"; type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
const ALTERNATE_BOSS_CONFIG = { const ALTERNATE_BOSS_CONFIG = {
vexa: { vexa: {
@@ -605,6 +606,17 @@ const ALTERNATE_BOSS_CONFIG = {
light: "#ff8742", light: "#ff8742",
rotationOffset: 0, 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; } as const;
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) { function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
@@ -634,6 +646,16 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const clipName = phase === "victory" || defeated const clipName = phase === "victory" || defeated
? config.death ? config.death
: kind === "ember-mantis-duelist"
? motionMode === "mantis_sidestep"
? config.move
: motionMode === "mantis_line_telegraph"
? config.attack
: motionMode === "mantis_cross_telegraph"
? config.special
: motionMode === "mantis_recover"
? "Recover"
: config.idle
: motionMode === "skyfall" : motionMode === "skyfall"
? config.move ? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping" : motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
@@ -646,15 +668,21 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const next = actions[clipName]; const next = actions[clipName];
if (!next) return; if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16); for (const action of Object.values(actions)) action?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).fadeIn(0.16).play(); const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
if (phase === "victory" || defeated) { ? 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.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true; next.clampWhenFinished = true;
} else { } else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
} }
return () => { next.fadeOut(0.16); }; return () => { next.fadeOut(0.16); };
}, [actions, clipName, defeated, phase]); }, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
useFrame((_, delta) => { useFrame((_, delta) => {
if (!group.current) return; 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")) { if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle; 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( const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y), Math.sin(targetAngle - group.current.rotation.y),
@@ -877,7 +912,7 @@ function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const state = useGameStore.getState(); const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0]; const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]]; 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) => { useFrame((_, delta) => {
age.current += delta; age.current += delta;
if (!ring.current || !material.current) return; if (!ring.current || !material.current) return;
@@ -926,6 +961,7 @@ export function GameScene() {
} }
useGLTF.preload(BULL_URL, false, true); 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 modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) { for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
useGLTF.preload(loadout.right, false, true); useGLTF.preload(loadout.right, false, true);
@@ -8,6 +8,7 @@ import { useGameStore } from "../../game/store";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const; 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 STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = []; const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
type GameStoreState = ReturnType<typeof useGameStore.getState>; type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) { 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<THREE.MeshBasicMaterial>(null);
const edgeMaterial = useRef<THREE.MeshBasicMaterial>(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 (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color="#fff0a8" transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
);
}
export function SlashLaneIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const lanes = useGameStore((state) => motionAt(state, bossIndex)?.slashLanes ?? EMPTY_SLASH_LANES);
return <>{lanes.map((lane) => <SlashLaneIndicator key={lane.id} laneId={lane.id} bossIndex={bossIndex} />)}</>;
}
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) { export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode); 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 }>[] = [ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator, ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator, PounceStackIndicator,
BindingWebIndicator, BindingWebIndicator,
BreathConeIndicator, BreathConeIndicator,
+2 -1
View File
@@ -10,7 +10,8 @@ describe("game mode configuration", () => {
it("selects a boss across the full encounter pool", () => { it("selects a boss across the full encounter pool", () => {
expect(selectRandomBoss(() => 0)).toBe("bulldrome"); expect(selectRandomBoss(() => 0)).toBe("bulldrome");
expect(selectRandomBoss(() => 0.34)).toBe("vexa"); 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", () => { it("selects two distinct bosses for PVE", () => {
+13 -2
View File
@@ -44,6 +44,17 @@ export const DEFAULT_COLLECTIONS: BossCollection[] = [
{ id: "maw-breath", name: "Bottled Breath", icon: "☀", rarity: "Mythic", count: 0 }, { 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<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = { export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
@@ -58,7 +69,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
eyebrow: "14 hunters · chosen encounter", eyebrow: "14 hunters · chosen encounter",
title: "Dungeons", title: "Dungeons",
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.", description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
detail: "Bulldrome · Vexa · Cindermaw", detail: "Bulldrome · Vexa · Cindermaw · Ember Mantis",
status: "Playable now", status: "Playable now",
}, },
"roguelike-pvp": { "roguelike-pvp": {
@@ -118,7 +129,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
flawlessClears: 5, flawlessClears: 5,
alliesSaved: 143, alliesSaved: 143,
healingDone: 284_650, healingDone: 284_650,
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4 }, bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4, "Ember Mantis Duelist": 0 },
}, },
collections: structuredClone(DEFAULT_COLLECTIONS), collections: structuredClone(DEFAULT_COLLECTIONS),
}; };
+3
View File
@@ -28,7 +28,10 @@ describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [ const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"], ["bulldrome", "vexa"],
["bulldrome", "cindermaw"], ["bulldrome", "cindermaw"],
["bulldrome", "ember-mantis-duelist"],
["vexa", "cindermaw"], ["vexa", "cindermaw"],
["vexa", "ember-mantis-duelist"],
["cindermaw", "ember-mantis-duelist"],
]; ];
it.each(combinations)("party rotations defeat %s + %s", (first, second) => { it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
+16 -1
View File
@@ -16,7 +16,7 @@ export interface BossDefinition {
maxHp: number; 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<BossId, BossDefinition> = { export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
bulldrome: { bulldrome: {
@@ -64,4 +64,19 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
mechanics: ["Searing Sweep", "Skyfall"], mechanics: ["Searing Sweep", "Skyfall"],
maxHp: 410, 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,
},
}; };
+5
View File
@@ -1,5 +1,6 @@
import { BOSS_DEFINITIONS } from "./bossCatalog"; import { BOSS_DEFINITIONS } from "./bossCatalog";
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw"; import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
import { createBaseMotion } from "./bosses/shared"; import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types"; import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa"; 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 { export function createBossState(bossId: BossId = "bulldrome"): BossState {
if (bossId === "vexa") return createVexaState(); if (bossId === "vexa") return createVexaState();
if (bossId === "cindermaw") return createCindermawState(); if (bossId === "cindermaw") return createCindermawState();
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
const definition = BOSS_DEFINITIONS.bulldrome; const definition = BOSS_DEFINITIONS.bulldrome;
return { return {
id: "bulldrome", id: "bulldrome",
@@ -55,6 +57,7 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState {
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState { export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
if (bossId === "vexa") return createVexaMotion(); if (bossId === "vexa") return createVexaMotion();
if (bossId === "cindermaw") return createCindermawMotion(); if (bossId === "cindermaw") return createCindermawMotion();
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
return { return {
...createBaseMotion("bulldrome"), ...createBaseMotion("bulldrome"),
mode: "holding", mode: "holding",
@@ -348,6 +351,7 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult { export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
if (context.boss.id === "vexa") return advanceVexaMechanics(context); if (context.boss.id === "vexa") return advanceVexaMechanics(context);
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context); if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
return advanceBulldromeMechanics(context); return advanceBulldromeMechanics(context);
} }
@@ -371,6 +375,7 @@ export function handleBossDispel(
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) { export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time); if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
if (boss.id === "cindermaw") return upcomingCindermawMechanic(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") { if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true }; return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
} }
+97
View File
@@ -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<typeof createEmberMantisState>,
motion: ReturnType<typeof createEmberMantisMotion>,
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);
});
});
+247
View File
@@ -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,
};
}
+6
View File
@@ -30,6 +30,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathStartAngle: 0, breathStartAngle: 0,
breathEndAngle: 0, breathEndAngle: 0,
hazards: [], hazards: [],
slashLanes: [],
}; };
} }
@@ -50,6 +51,11 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
nextDamageAt: { ...hazard.nextDamageAt }, nextDamageAt: { ...hazard.nextDamageAt },
hitIds: [...hazard.hitIds], hitIds: [...hazard.hitIds],
})), })),
slashLanes: source.slashLanes.map((lane) => ({
...lane,
start: [lane.start[0], lane.start[1]],
end: [lane.end[0], lane.end[1]],
})),
}; };
} }
+39 -1
View File
@@ -1,5 +1,6 @@
import { BULL_CHARGE } from "./bossMechanics"; import { BULL_CHARGE } from "./bossMechanics";
import { CINDER_BREATH } from "./bosses/cindermaw"; import { CINDER_BREATH } from "./bosses/cindermaw";
import { EMBER_MANTIS_SLASH } from "./bosses/emberMantis";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry"; import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; 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 = { export const avoidCircleHazardsBehavior: PartyBehavior = {
id: "avoid-circle-hazards", id: "avoid-circle-hazards",
decide: ({ memberId, current, formationTarget, bossMotion, time }) => { decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
@@ -143,7 +180,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
export const maintainFormationBehavior: PartyBehavior = { export const maintainFormationBehavior: PartyBehavior = {
id: "maintain-formation", id: "maintain-formation",
decide: ({ formationTarget, bossMotion, memberId }) => { 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] }; return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
}, },
}; };
@@ -152,6 +189,7 @@ export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
breakTetherBehavior, breakTetherBehavior,
stackForPounceBehavior, stackForPounceBehavior,
evadeChargeBehavior, evadeChargeBehavior,
evadeSlashLanesBehavior,
avoidBreathBehavior, avoidBreathBehavior,
avoidCircleHazardsBehavior, avoidCircleHazardsBehavior,
maintainFormationBehavior, maintainFormationBehavior,
+3
View File
@@ -144,7 +144,10 @@ describe("dual-boss damage simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [ const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"], ["bulldrome", "vexa"],
["bulldrome", "cindermaw"], ["bulldrome", "cindermaw"],
["bulldrome", "ember-mantis-duelist"],
["vexa", "cindermaw"], ["vexa", "cindermaw"],
["vexa", "ember-mantis-duelist"],
["cindermaw", "ember-mantis-duelist"],
]; ];
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => { it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
+16 -3
View File
@@ -1,9 +1,9 @@
export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale"; export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier"; 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 GamePhase = "briefing" | "combat" | "victory" | "defeat";
export type BottomTab = "combat" | "map" | "pack"; 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 = export type BossMotionMode =
| "holding" | "holding"
| "telegraph" | "telegraph"
@@ -15,7 +15,11 @@ export type BossMotionMode =
| "venom_cast" | "venom_cast"
| "breath_telegraph" | "breath_telegraph"
| "breath_sweeping" | "breath_sweeping"
| "skyfall"; | "skyfall"
| "mantis_sidestep"
| "mantis_line_telegraph"
| "mantis_cross_telegraph"
| "mantis_recover";
export type WorldPosition = [number, number]; export type WorldPosition = [number, number];
export type CircleHazardKind = "venom_pool" | "skyfall"; export type CircleHazardKind = "venom_pool" | "skyfall";
@@ -34,6 +38,14 @@ export interface CircleHazard {
hitIds: MemberId[]; hitIds: MemberId[];
} }
export interface SlashLane {
id: string;
start: WorldPosition;
end: WorldPosition;
width: number;
damage: number;
}
export interface Debuff { export interface Debuff {
id: string; id: string;
name: string; name: string;
@@ -95,6 +107,7 @@ export interface BossMotionState {
breathStartAngle: number; breathStartAngle: number;
breathEndAngle: number; breathEndAngle: number;
hazards: CircleHazard[]; hazards: CircleHazard[];
slashLanes: SlashLane[];
} }
export interface AbilityDefinition { export interface AbilityDefinition {