Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076f6cf97c |
Binary file not shown.
+1
-1
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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: "1–4 hunters · chosen encounter",
|
eyebrow: "1–4 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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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]],
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user