Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076f6cf97c |
Binary file not shown.
+1
-1
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
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> = {
|
||||
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 (
|
||||
<mesh castShadow position={[position[0], 1.1, position[1]]}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof useGameStore.getState>;
|
||||
|
||||
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 }) {
|
||||
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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+13
-2
@@ -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<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: "1–4 hunters · chosen encounter",
|
||||
title: "Dungeons",
|
||||
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",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
@@ -118,7 +129,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
flawlessClears: 5,
|
||||
alliesSaved: 143,
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -28,7 +28,10 @@ describe("full-mechanics dual-boss battle 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)("party rotations defeat %s + %s", (first, second) => {
|
||||
|
||||
+16
-1
@@ -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<BossId, BossDefinition> = {
|
||||
bulldrome: {
|
||||
@@ -64,4 +64,19 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+16
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user