Release v0.1.3 2026-07-11
This commit is contained in:
+49
-14
@@ -5,6 +5,7 @@ import { FrontEnd } from "./components/FrontEnd";
|
||||
import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||
import { useGameStore } from "./game/store";
|
||||
import type { BossId } from "./game/types";
|
||||
import type { DifficultySlug } from "./game/progression/loot";
|
||||
import { useActionBindings, useGameLoop } from "./game/useGameLoop";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
||||
@@ -32,25 +33,35 @@ export default function App() {
|
||||
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
|
||||
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
||||
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const boss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const victoryRecorded = useRef(false);
|
||||
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
||||
const rewardedBossInstances = useRef(new Set<string>());
|
||||
const screenRef = useRef(screen);
|
||||
screenRef.current = screen;
|
||||
const leaveGame = useCallback(() => {
|
||||
updateActiveHealerInventory(useGameStore.getState().inventory);
|
||||
touchActiveSave();
|
||||
navigate("home");
|
||||
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
||||
const launchGame = useCallback((bossIds: readonly BossId[]) => {
|
||||
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
|
||||
if (!hunter) return;
|
||||
const progress = hunter.healers[hunter.activeClassId];
|
||||
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds);
|
||||
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
|
||||
const launchDifficulty = runMode === "roguelike"
|
||||
? "initiate"
|
||||
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
|
||||
rewardedBossInstances.current.clear();
|
||||
clearRecentRewards();
|
||||
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
|
||||
touchActiveSave();
|
||||
navigate("game");
|
||||
}, [hunter, navigate, touchActiveSave]);
|
||||
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDualScreenLaunch = (event: Event) => launchGame((event as CustomEvent<readonly BossId[]>).detail);
|
||||
const onDualScreenLaunch = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
|
||||
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
|
||||
else launchGame(detail);
|
||||
};
|
||||
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||
}, [launchGame]);
|
||||
@@ -63,12 +74,36 @@ export default function App() {
|
||||
}, [settings.largeText, settings.reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "combat") victoryRecorded.current = false;
|
||||
if (screen === "game" && phase === "victory" && !victoryRecorded.current) {
|
||||
victoryRecorded.current = true;
|
||||
for (const bossName of [boss.name, ...additionalBosses.map((entry) => entry.boss.name)]) recordBossVictory(bossName);
|
||||
}
|
||||
}, [additionalBosses, boss.name, phase, recordBossVictory, screen]);
|
||||
return useGameStore.subscribe((state, previousState) => {
|
||||
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
||||
|| previousState.phase === "intermission" && state.phase === "combat";
|
||||
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|
||||
rewardedBossInstances.current.clear();
|
||||
}
|
||||
if (startedFreshEncounter) clearRecentRewards();
|
||||
if (screenRef.current !== "game") return;
|
||||
const bossCount = 1 + state.additionalBosses.length;
|
||||
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
||||
const primaryInstanceId = `boss-0-${state.boss.id}`;
|
||||
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
||||
rewardedBossInstances.current.add(primaryInstanceId);
|
||||
const defeatedBefore = (state.round - 1) * bossCount;
|
||||
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||
recordBossVictory(state.boss.id, rewardDifficulty);
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < state.additionalBosses.length; index += 1) {
|
||||
const entry = state.additionalBosses[index];
|
||||
const previous = previousState.additionalBosses[index];
|
||||
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
||||
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
|
||||
rewardedBossInstances.current.add(entry.instanceId);
|
||||
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
||||
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||
recordBossVictory(entry.boss.id, rewardDifficulty);
|
||||
}
|
||||
});
|
||||
}, [clearRecentRewards, recordBossVictory]);
|
||||
|
||||
return (
|
||||
<main className="prototype-shell">
|
||||
|
||||
@@ -4,6 +4,13 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
import { useFrontendStore } from "../frontend/store";
|
||||
|
||||
function RewardSummary() {
|
||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||
if (!rewards.length) return null;
|
||||
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.coin.id}-${index}`}><b>{reward.coin.glyph}</b>{reward.coin.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
|
||||
}
|
||||
|
||||
function HealthBar({ member }: { member: PartyMember }) {
|
||||
const health = Math.max(0, (member.hp / member.maxHp) * 100);
|
||||
@@ -195,6 +202,7 @@ function EndPanel() {
|
||||
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
||||
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
||||
</div>
|
||||
{phase === "victory" && <RewardSummary />}
|
||||
<div className="end-actions">
|
||||
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||
<button className="secondary" onClick={restart}>Return to briefing</button>
|
||||
@@ -203,9 +211,24 @@ function EndPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function IntermissionStatusPanel() {
|
||||
const round = useGameStore((state) => state.round);
|
||||
return (
|
||||
<div className="intermission-status" aria-label={`Round ${round} cleared. Choose a blessing on the top display.`}>
|
||||
<i>✦</i>
|
||||
<span>Round {round} cleared</span>
|
||||
<h2>Choose on top display</h2>
|
||||
<p>Next encounter stays locked until one blessing is claimed.</p>
|
||||
<RewardSummary />
|
||||
<small>Use D-pad to choose · A to claim</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CombatPanel() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
if (phase === "briefing") return <BriefingPanel />;
|
||||
if (phase === "intermission") return <IntermissionStatusPanel />;
|
||||
if (phase === "victory" || phase === "defeat") return <EndPanel />;
|
||||
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
||||
const round = useGameStore((state) => state.round);
|
||||
const runBuffs = useGameStore((state) => state.runBuffs);
|
||||
const choices = useGameStore((state) => state.draftBuffIds);
|
||||
const selected = useGameStore((state) => state.selectedRunBuffId);
|
||||
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
|
||||
const choose = useGameStore((state) => state.chooseRunBuff);
|
||||
const nextRound = round + 1;
|
||||
return (
|
||||
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
|
||||
<header>
|
||||
<span>Round {round} cleared</span>
|
||||
<h2>Choose one blessing</h2>
|
||||
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
|
||||
</header>
|
||||
<div className="buff-choice-grid">
|
||||
{choices.map((buffId) => {
|
||||
const buff = RUN_BUFFS[buffId];
|
||||
const stacks = countRunBuff(runBuffs, buffId);
|
||||
return (
|
||||
<button
|
||||
key={buffId}
|
||||
className={selected === buffId ? "is-controller-focused" : ""}
|
||||
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
|
||||
onFocus={() => setSelected(buffId)}
|
||||
onPointerEnter={() => setSelected(buffId)}
|
||||
onClick={() => choose(buffId)}
|
||||
aria-pressed={selected === buffId}
|
||||
>
|
||||
<i>{buff.icon}</i>
|
||||
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
|
||||
<b>{buff.summary}</b>
|
||||
<p>{buff.detail}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<footer><b>← / →</b> Choose <i /> <b>A / ENTER</b> Claim</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,14 @@ import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displa
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
|
||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() =>
|
||||
new URLSearchParams(window.location.search).get("display") === "bottom" ? "bottom" : "top"
|
||||
);
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
||||
const activeSurfaceRef = useRef(activeSurface);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).has("display");
|
||||
if (dedicatedSurface) return;
|
||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
@@ -29,7 +27,11 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
|
||||
unsubscribeSurface();
|
||||
unsubscribeController();
|
||||
};
|
||||
}, []);
|
||||
}, [dedicatedSurface]);
|
||||
|
||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
||||
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`device-frame active-${activeSurface}`}>
|
||||
|
||||
+242
-42
@@ -1,12 +1,35 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
|
||||
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
|
||||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
import type { BossCollection, GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { selectRandomBossPair } from "../game/roguelike";
|
||||
import type { BossId } from "../game/types";
|
||||
import {
|
||||
GEAR_OWNER_LABELS,
|
||||
GEAR_OWNER_ORDER,
|
||||
GEAR_RECIPES,
|
||||
GEAR_SLOT_LABELS,
|
||||
GEAR_SLOT_ORDER,
|
||||
GEAR_STAT_LABELS,
|
||||
MAX_GEAR_LEVEL,
|
||||
canAffordGearUpgrade,
|
||||
gearBonusText,
|
||||
gearUpgradeCosts,
|
||||
} from "../game/progression/gear";
|
||||
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
|
||||
import {
|
||||
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||
PASSIVE_INFUSIONS,
|
||||
PASSIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||
activeInfusionUnlocked,
|
||||
infusionCosts,
|
||||
infusionsForOwner,
|
||||
passiveInfusionUnlocked,
|
||||
} from "../game/progression/infusions";
|
||||
import { requestDisplaySurface } from "../platform/displayRouting";
|
||||
import { DualDisplayFrame } from "./DualDisplayFrame";
|
||||
|
||||
@@ -330,15 +353,16 @@ function HomeScreen() {
|
||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-pvp" } },
|
||||
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } },
|
||||
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "dungeons", down: "settings" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "settings", down: "class-priest" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "profile", down: "class-shaman" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } },
|
||||
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
|
||||
...HEALER_CLASS_ORDER.map((classId, index) => ({
|
||||
id: `class-${classId}`,
|
||||
run: () => selectHealerClass(classId),
|
||||
neighbors: {
|
||||
left: `class-${HEALER_CLASS_ORDER[(index + HEALER_CLASS_ORDER.length - 1) % HEALER_CLASS_ORDER.length]}`,
|
||||
right: `class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
|
||||
up: index === 2 ? "settings" : "profile",
|
||||
up: index === 0 ? "profile" : index === 1 ? "gear" : "settings",
|
||||
down: "change-save",
|
||||
},
|
||||
})),
|
||||
@@ -364,6 +388,7 @@ function HomeScreen() {
|
||||
</div>
|
||||
<div className="home-secondary-actions">
|
||||
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i>♙</i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b>›</b></FocusButton>
|
||||
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i>⚒</i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b>›</b></FocusButton>
|
||||
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i>⚙</i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b>›</b></FocusButton>
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
@@ -398,12 +423,13 @@ function HomeScreen() {
|
||||
function ProfileScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const [bossId, setBossId] = useState(hunter?.collections[0].bossId ?? "");
|
||||
const collection = hunter?.collections.find((boss) => boss.bossId === bossId) ?? hunter?.collections[0];
|
||||
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
|
||||
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
|
||||
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...(hunter?.collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })) ?? []),
|
||||
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
|
||||
{ id: "back", run: () => navigate("home") },
|
||||
], [hunter?.collections, navigate]);
|
||||
], [collections, navigate]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
if (!hunter || !collection) return null;
|
||||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||||
@@ -422,6 +448,7 @@ function ProfileScreen() {
|
||||
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
|
||||
<small>{drop.rarity}</small><strong>{drop.name}</strong>
|
||||
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
|
||||
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -437,9 +464,9 @@ function ProfileScreen() {
|
||||
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
||||
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
||||
</div>
|
||||
<div className="boss-log"><span>Boss records</span>{hunter.collections.map((boss) => (
|
||||
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
|
||||
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
|
||||
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossName] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
|
||||
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
|
||||
</FocusButton>
|
||||
))}</div>
|
||||
</FrontSurface>
|
||||
@@ -448,6 +475,140 @@ function ProfileScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function GearScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const notice = useFrontendStore((state) => state.notice);
|
||||
const selectedOwnerId = useFrontendStore((state) => state.selectedGearOwnerId);
|
||||
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
|
||||
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
|
||||
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
|
||||
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
|
||||
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
||||
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
||||
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
|
||||
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
|
||||
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
|
||||
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
|
||||
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
|
||||
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
|
||||
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
|
||||
const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs));
|
||||
const infusionChoices = infusionsForOwner(selectedOwnerId);
|
||||
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
|
||||
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
|
||||
const activeUnlocked = Boolean(hunter && activeInfusionUnlocked(hunter.gearProgress[selectedOwnerId]));
|
||||
const anchorUnlocked = Boolean(slot && slot.level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
|
||||
const infusionEquipped = hunter?.gearProgress[selectedOwnerId].infusionAbilityId === selectedInfusion.id;
|
||||
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
|
||||
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
|
||||
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
|
||||
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
|
||||
id: `owner-${ownerId}`,
|
||||
run: () => selectOwner(ownerId),
|
||||
neighbors: {
|
||||
up: index === 0 ? "back" : `owner-${GEAR_OWNER_ORDER[index - 1]}`,
|
||||
down: index === GEAR_OWNER_ORDER.length - 1 ? previewEntryId : `owner-${GEAR_OWNER_ORDER[index + 1]}`,
|
||||
right: `slot-${selectedSlotId}`,
|
||||
},
|
||||
})),
|
||||
...GEAR_SLOT_ORDER.map((slotId, index) => ({
|
||||
id: `slot-${slotId}`,
|
||||
run: () => selectSlot(slotId),
|
||||
neighbors: {
|
||||
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
|
||||
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
|
||||
left: `owner-${selectedOwnerId}`,
|
||||
right: previewEntryId,
|
||||
},
|
||||
})),
|
||||
{ id: "workshop-upgrade", run: () => selectWorkshopMode("upgrade"), neighbors: { right: "workshop-infusion", down: "slot-weapon" } },
|
||||
{ id: "workshop-infusion", run: () => selectWorkshopMode("infusion"), neighbors: { left: "workshop-upgrade", down: `infusion-${infusionChoices[0].id}` } },
|
||||
...infusionChoices.map((infusion, index) => ({
|
||||
id: `infusion-${infusion.id}`,
|
||||
run: () => selectInfusion(infusion.id),
|
||||
neighbors: {
|
||||
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
|
||||
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
|
||||
left: `slot-${selectedSlotId}`,
|
||||
},
|
||||
})),
|
||||
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
|
||||
id: `passive-${passive.id}`,
|
||||
run: () => installPassive(passive.id),
|
||||
enabled: passiveUnlocked,
|
||||
neighbors: {
|
||||
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
|
||||
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
|
||||
left: `slot-${selectedSlotId}`,
|
||||
},
|
||||
})) : []),
|
||||
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
|
||||
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
|
||||
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
||||
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
if (!hunter || !slot) return null;
|
||||
const currentBonus = gearBonusText(recipe.statId, slot.level);
|
||||
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
||||
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<div className="gear-workshop-layout">
|
||||
<section className="gear-owner-list" aria-label="Party gear owners">
|
||||
{GEAR_OWNER_ORDER.map((ownerId) => {
|
||||
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
|
||||
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} className={ownerId === selectedOwnerId ? "is-selected" : ""} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
|
||||
})}
|
||||
</section>
|
||||
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
|
||||
{GEAR_SLOT_ORDER.map((slotId) => {
|
||||
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
|
||||
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
|
||||
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} className={slotId === selectedSlotId ? "is-selected" : ""} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
|
||||
})}
|
||||
</section>
|
||||
{workshopMode === "upgrade" ? <article className="gear-preview">
|
||||
<span>Selected upgrade</span>
|
||||
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
|
||||
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
|
||||
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i>→</i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
|
||||
</article> : <article className="gear-preview gear-infusion-preview">
|
||||
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
|
||||
<h2>{selectedInfusion.icon} {selectedInfusion.name}</h2>
|
||||
<p>{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.</p>
|
||||
<div className="gear-infusion-options">
|
||||
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
|
||||
</div>
|
||||
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
|
||||
</article>}
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
|
||||
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
|
||||
<div className="gear-costs">
|
||||
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
||||
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
|
||||
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
|
||||
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
|
||||
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
||||
</div>
|
||||
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
|
||||
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingToggle({ id, label, copy, value, focusedId, focus, onClick }: { id: string; label: string; copy: string; value: boolean; focusedId: string; focus: (id: string) => void; onClick: () => void }) {
|
||||
return <FocusButton id={id} focusedId={focusedId} focus={focus} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></FocusButton>;
|
||||
}
|
||||
@@ -494,36 +655,65 @@ function SettingsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
|
||||
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
|
||||
const hunter = useActiveHunter();
|
||||
const modeId = useFrontendStore((state) => state.selectedMode);
|
||||
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
|
||||
const selectedDifficultySlug = useFrontendStore((state) => state.selectedDifficultySlug);
|
||||
const selectBoss = useFrontendStore((state) => state.selectBoss);
|
||||
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const [message, setMessage] = useState("");
|
||||
const mode = MODE_COPY[modeId];
|
||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||
const progress = hunter?.healers[hunter.activeClassId];
|
||||
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
|
||||
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
|
||||
const isPve = modeId === "roguelike-pve";
|
||||
const isDungeon = modeId === "dungeons";
|
||||
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
|
||||
const launch = () => {
|
||||
if (isPve) return onLaunch(selectRandomBossPair());
|
||||
if (isDungeon) return onLaunch([selectedBossId]);
|
||||
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
|
||||
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
|
||||
setMessage("Online matchmaking connects here when game server is configured.");
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...(isDungeon ? BOSS_ORDER.map((bossId, index) => ({
|
||||
id: `boss-${bossId}`,
|
||||
run: () => selectBoss(bossId),
|
||||
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
|
||||
const column = Math.floor(index / bossGridRows);
|
||||
const row = index % bossGridRows;
|
||||
const neighborInColumn = (targetColumn: number) => {
|
||||
const columnStart = targetColumn * bossGridRows;
|
||||
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
|
||||
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
|
||||
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
|
||||
};
|
||||
|
||||
return {
|
||||
id: `boss-${bossId}`,
|
||||
run: () => selectBoss(bossId),
|
||||
neighbors: {
|
||||
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
|
||||
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
|
||||
? `boss-${BOSS_ORDER[index + 1]}`
|
||||
: `difficulty-${DIFFICULTIES[0].slug}`,
|
||||
left: neighborInColumn(column - 1),
|
||||
right: neighborInColumn(column + 1),
|
||||
},
|
||||
};
|
||||
}) : []),
|
||||
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
||||
id: `difficulty-${difficulty.slug}`,
|
||||
run: () => selectDifficulty(difficulty.slug),
|
||||
neighbors: {
|
||||
up: index > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
|
||||
down: index < BOSS_ORDER.length - 1 ? `boss-${BOSS_ORDER[index + 1]}` : "launch",
|
||||
left: index > 0 ? `difficulty-${DIFFICULTIES[index - 1].slug}` : `boss-${selectedBossId}`,
|
||||
right: index < DIFFICULTIES.length - 1 ? `difficulty-${DIFFICULTIES[index + 1].slug}` : "launch",
|
||||
up: `boss-${selectedBossId}`,
|
||||
down: "launch",
|
||||
},
|
||||
})) : []),
|
||||
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `boss-${BOSS_ORDER[BOSS_ORDER.length - 1]}` } : { up: "back" } },
|
||||
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
|
||||
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
|
||||
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectedBossId]);
|
||||
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
|
||||
const contextRules = isDungeon
|
||||
@@ -536,7 +726,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
|
||||
? [
|
||||
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
|
||||
["Dual-boss pressure", "Both guardians fight simultaneously and must be defeated."],
|
||||
["Roguelike foundation", "Three-choice buff drafts are next in development."],
|
||||
["Buff intermission", "Choose one of three stacking buffs after every cleared round."],
|
||||
]
|
||||
: [
|
||||
["Draft a healing path", "Choose rites after every completed room."],
|
||||
@@ -548,27 +738,35 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
|
||||
top={
|
||||
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
|
||||
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>
|
||||
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
||||
{isDungeon && (
|
||||
<div className="boss-picker" aria-label="Choose boss encounter">
|
||||
<span>Choose encounter</span>
|
||||
{BOSS_ORDER.map((bossId) => {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
return (
|
||||
<FocusButton
|
||||
key={bossId}
|
||||
id={`boss-${bossId}`}
|
||||
focusedId={controller.focusedId}
|
||||
focus={controller.focus}
|
||||
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
|
||||
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
|
||||
aria-pressed={selectedBossId === bossId}
|
||||
onClick={() => selectBoss(bossId)}
|
||||
>
|
||||
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
|
||||
{BOSS_ORDER.map((bossId) => {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
return (
|
||||
<FocusButton
|
||||
key={bossId}
|
||||
id={`boss-${bossId}`}
|
||||
focusedId={controller.focusedId}
|
||||
focus={controller.focus}
|
||||
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
|
||||
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
|
||||
aria-pressed={selectedBossId === bossId}
|
||||
onClick={() => selectBoss(bossId)}
|
||||
>
|
||||
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isDungeon && (
|
||||
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
|
||||
<span>Difficulty</span>
|
||||
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
|
||||
</div>
|
||||
)}
|
||||
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
|
||||
@@ -577,21 +775,23 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
|
||||
<header className="context-header"><span>Run preparation</span><b>{mode.status.toUpperCase()}</b></header>
|
||||
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
|
||||
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
|
||||
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
|
||||
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>1–3 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
|
||||
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
|
||||
const screen = useFrontendStore((state) => state.screen);
|
||||
if (screen === "login") return <LoginScreen />;
|
||||
if (screen === "saves") return <SaveScreen />;
|
||||
if (screen === "home") return <HomeScreen />;
|
||||
if (screen === "profile") return <ProfileScreen />;
|
||||
if (screen === "gear") return <GearScreen />;
|
||||
if (screen === "settings") return <SettingsScreen />;
|
||||
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
|
||||
return null;
|
||||
|
||||
+393
-92
@@ -4,20 +4,29 @@ import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "rea
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
|
||||
import {
|
||||
isActorAnimationOneShot,
|
||||
shouldStartActorAnimation,
|
||||
type ActorAnimationState,
|
||||
} from "../game/actorAnimation";
|
||||
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
|
||||
const SPIDER_TEXTURE_URLS: Record<string, string> = {
|
||||
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
|
||||
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
|
||||
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.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 EMBER_MANTIS_URL = new URL("../../game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb", import.meta.url).href;
|
||||
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
|
||||
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
|
||||
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
|
||||
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
|
||||
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
|
||||
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
|
||||
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
|
||||
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
|
||||
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
|
||||
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
|
||||
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.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,
|
||||
@@ -54,6 +63,14 @@ const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
});
|
||||
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
|
||||
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
|
||||
const angle = (index / 16) * Math.PI * 2;
|
||||
return {
|
||||
angle,
|
||||
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
|
||||
};
|
||||
});
|
||||
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -72,15 +89,6 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
|
||||
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
|
||||
}
|
||||
|
||||
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
|
||||
loader.manager.setURLModifier((url) => {
|
||||
const fileName = url.slice(url.lastIndexOf("/") + 1);
|
||||
return SPIDER_TEXTURE_URLS[fileName] ?? url;
|
||||
});
|
||||
};
|
||||
|
||||
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
|
||||
|
||||
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
|
||||
|
||||
const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
|
||||
@@ -141,9 +149,11 @@ function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" |
|
||||
function PartyCharacterModel({
|
||||
memberId,
|
||||
animationState,
|
||||
animationTrigger,
|
||||
}: {
|
||||
memberId: MemberId;
|
||||
animationState: MutableRefObject<ActorAnimationState>;
|
||||
animationTrigger: MutableRefObject<number>;
|
||||
}) {
|
||||
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
@@ -163,6 +173,8 @@ function PartyCharacterModel({
|
||||
);
|
||||
const { actions } = useAnimations(gltf.animations, actorScene);
|
||||
const activeClip = useRef<string | undefined>(undefined);
|
||||
const activeState = useRef<ActorAnimationState | undefined>(undefined);
|
||||
const activeTrigger = useRef(Number.NaN);
|
||||
|
||||
useEffect(() => {
|
||||
actorScene.traverse((object) => {
|
||||
@@ -189,6 +201,7 @@ function PartyCharacterModel({
|
||||
|
||||
useFrame(() => {
|
||||
const state = animationState.current;
|
||||
const trigger = animationTrigger.current;
|
||||
const clipName = state === "death"
|
||||
? "Death_A"
|
||||
: state === "hit"
|
||||
@@ -202,20 +215,24 @@ function PartyCharacterModel({
|
||||
: state === "attack"
|
||||
? PARTY_ATTACK_CLIPS[memberId]
|
||||
: "Idle";
|
||||
if (activeClip.current === clipName) return;
|
||||
if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
if (activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1).fadeIn(0.16);
|
||||
if (state === "death" || state === "hit" || state === "attack" || state === "cast") {
|
||||
const clipChanged = activeClip.current !== clipName;
|
||||
if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
|
||||
if (clipChanged) next.fadeIn(0.16);
|
||||
if (isActorAnimationOneShot(state)) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = state === "death";
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
next.clampWhenFinished = false;
|
||||
}
|
||||
next.play();
|
||||
activeClip.current = clipName;
|
||||
activeState.current = state;
|
||||
activeTrigger.current = trigger;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -270,6 +287,7 @@ function Arena() {
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
<ArenaWalls />
|
||||
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
|
||||
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
|
||||
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
|
||||
@@ -277,9 +295,32 @@ function Arena() {
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaWalls() {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
useFrame(({ camera }) => {
|
||||
if (!walls.current) return;
|
||||
for (const child of walls.current.children) {
|
||||
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
|
||||
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
|
||||
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<group ref={walls}>
|
||||
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
|
||||
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
|
||||
<boxGeometry args={[3.86, 2.3, 0.18]} />
|
||||
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
const animationTrigger = useRef(0);
|
||||
useEffect(() => {
|
||||
const start = useGameStore.getState().partyPositions[memberId];
|
||||
group.current?.position.set(start[0], 0.025, start[1]);
|
||||
@@ -303,6 +344,13 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
const attacking = state.phase === "combat"
|
||||
&& visualAction !== null
|
||||
&& visualAction.endsAt > state.time;
|
||||
animationTrigger.current = member.hp <= 0
|
||||
? 0
|
||||
: knocked
|
||||
? member.knockedUntil
|
||||
: attacking
|
||||
? visualAction.startedAt
|
||||
: 0;
|
||||
animationState.current = member.hp <= 0
|
||||
? "death"
|
||||
: knocked
|
||||
@@ -328,7 +376,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId={memberId} animationState={animationState} />
|
||||
<PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
@@ -342,6 +390,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
function PlayerCharacter() {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
const animationTrigger = useRef(0);
|
||||
const keys = useRef(new Set<string>());
|
||||
const scenePulse = useGameStore((state) => state.scenePulse);
|
||||
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
|
||||
@@ -349,6 +398,7 @@ function PlayerCharacter() {
|
||||
const { camera } = useThree();
|
||||
const broadcastTimer = useRef(0);
|
||||
const castingUntil = useRef(0);
|
||||
const instantCastTrigger = useRef(0);
|
||||
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -359,6 +409,7 @@ function PlayerCharacter() {
|
||||
useEffect(() => {
|
||||
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
|
||||
castingUntil.current = performance.now() + 700;
|
||||
instantCastTrigger.current = scenePulse.id;
|
||||
}
|
||||
}, [scenePulse]);
|
||||
|
||||
@@ -372,8 +423,9 @@ function PlayerCharacter() {
|
||||
const nudgeX = Number(key === "d") - Number(key === "a");
|
||||
const nudgeZ = Number(key === "s") - Number(key === "w");
|
||||
if (!nudgeX && !nudgeZ) return;
|
||||
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + nudgeX * 0.18, -7.2, 7.2);
|
||||
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + nudgeZ * 0.18, -4.8, 7.2);
|
||||
const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]);
|
||||
group.current.position.x = next[0];
|
||||
group.current.position.z = next[1];
|
||||
setPlayerPosition([group.current.position.x, group.current.position.z]);
|
||||
};
|
||||
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
|
||||
@@ -401,9 +453,10 @@ function PlayerCharacter() {
|
||||
}
|
||||
const length = Math.hypot(inputX, inputZ);
|
||||
if (length > 0.05) {
|
||||
const speed = 4.6 * delta / Math.max(1, length);
|
||||
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + inputX * speed, -7.2, 7.2);
|
||||
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + inputZ * speed, -4.8, 7.2);
|
||||
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
|
||||
const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]);
|
||||
group.current.position.x = next[0];
|
||||
group.current.position.z = next[1];
|
||||
group.current.rotation.y = Math.atan2(inputX, inputZ);
|
||||
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
|
||||
const boss = targetBossMotion(state).position;
|
||||
@@ -414,6 +467,16 @@ function PlayerCharacter() {
|
||||
);
|
||||
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
|
||||
}
|
||||
const instantCasting = performance.now() < castingUntil.current;
|
||||
animationTrigger.current = player.hp <= 0
|
||||
? 0
|
||||
: knocked
|
||||
? player.knockedUntil
|
||||
: state.activeCast
|
||||
? state.activeCast.startedAt
|
||||
: instantCasting
|
||||
? instantCastTrigger.current
|
||||
: 0;
|
||||
animationState.current = player.hp <= 0
|
||||
? "death"
|
||||
: knocked
|
||||
@@ -422,7 +485,7 @@ function PlayerCharacter() {
|
||||
? "cast"
|
||||
: length > 0.05
|
||||
? "run"
|
||||
: performance.now() < castingUntil.current
|
||||
: instantCasting
|
||||
? "cast"
|
||||
: "idle";
|
||||
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
|
||||
@@ -440,7 +503,7 @@ function PlayerCharacter() {
|
||||
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId="aelia" animationState={animationState} />
|
||||
<PartyCharacterModel memberId="aelia" animationState={animationState} animationTrigger={animationTrigger} />
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
@@ -453,16 +516,15 @@ function PlayerCharacter() {
|
||||
}
|
||||
|
||||
function Party() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
const selected = useGameStore((state) => state.selectedMemberId);
|
||||
return (
|
||||
<>
|
||||
<PlayerCharacter />
|
||||
{party.slice(1).map((member) => (
|
||||
{PARTY_MEMBER_IDS.map((memberId) => (
|
||||
<Character
|
||||
key={member.id}
|
||||
memberId={member.id as Exclude<MemberId, "aelia">}
|
||||
selected={selected === member.id}
|
||||
key={memberId}
|
||||
memberId={memberId}
|
||||
selected={selected === memberId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -492,7 +554,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" : bossId === "ember-mantis-duelist" ? "#a42d18" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -581,44 +643,217 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
|
||||
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
|
||||
|
||||
const ALTERNATE_BOSS_CONFIG = {
|
||||
vexa: {
|
||||
url: SPIDER_URL,
|
||||
scale: 0.022,
|
||||
idle: "Spider_Armature|warte_pose",
|
||||
move: "Spider_Armature|run_ani_vor",
|
||||
attack: "Spider_Armature|Attack",
|
||||
special: "Spider_Armature|Jump",
|
||||
death: "Spider_Armature|die",
|
||||
url: INSECT_QUEEN_URL,
|
||||
scale: 9,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#bb67ff",
|
||||
rotationOffset: Math.PI,
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
cindermaw: {
|
||||
url: DRAGON_URL,
|
||||
scale: 1.15,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Headbutt",
|
||||
special: "Punch",
|
||||
url: BLUE_EYES_WHITE_URL,
|
||||
scale: 10,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff8742",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
url: EMBER_MANTIS_URL,
|
||||
scale: 0.78,
|
||||
url: GATE_GUARDIAN_URL,
|
||||
scale: 4.3,
|
||||
idle: "Idle",
|
||||
move: "Sidestep",
|
||||
attack: "LineSlash",
|
||||
special: "CrossSlash",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff5a24",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"obsidian-ram-golem": {
|
||||
url: GANDORA_URL,
|
||||
scale: 6.2,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff7438",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"cinderback-ricochet": {
|
||||
url: RED_EYES_BLACK_URL,
|
||||
scale: 10,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff8b3d",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"sandglass-scorpion": {
|
||||
url: SANDGLASS_URL,
|
||||
scale: 0.7,
|
||||
idle: "Idle",
|
||||
move: "Burrow",
|
||||
attack: "Eruption",
|
||||
special: "Hourglass",
|
||||
death: "Death",
|
||||
light: "#e9b94f",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"cragclaw-crab": {
|
||||
url: CRAGCLAW_URL,
|
||||
scale: 1.2,
|
||||
idle: "Idle",
|
||||
move: "Walk",
|
||||
attack: "Bite_Front",
|
||||
special: "Bite_InPlace",
|
||||
death: "Death",
|
||||
light: "#49d5df",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"pumpking-king-of-ghosts": {
|
||||
url: PUMPKING_URL,
|
||||
scale: 1.5,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#d87842",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"blue-eyes-ultimate-dragon": {
|
||||
url: BLUE_EYES_ULTIMATE_URL,
|
||||
scale: 4.5,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#8fc8ff",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"mournveil-ghost": {
|
||||
url: MOURNVEIL_URL,
|
||||
scale: 1.1,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Punch",
|
||||
special: "Headbutt",
|
||||
death: "Death",
|
||||
light: "#9d72ff",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"crownshard-golem": {
|
||||
url: CROWNSHARD_URL,
|
||||
scale: 1.15,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Punch",
|
||||
special: "Headbutt",
|
||||
death: "Death",
|
||||
light: "#e0bd45",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROTOTYPE_MOVE_MODES = [
|
||||
"skyfall",
|
||||
"mantis_sidestep",
|
||||
"ram_charging",
|
||||
"cinderback_ricochet",
|
||||
] as const;
|
||||
|
||||
const PROTOTYPE_ATTACK_MODES = [
|
||||
"tethering",
|
||||
"venom_cast",
|
||||
"breath_telegraph",
|
||||
"breath_sweeping",
|
||||
"mantis_line_telegraph",
|
||||
"mantis_cross_telegraph",
|
||||
"ram_charge_telegraph",
|
||||
"ram_quake",
|
||||
"ram_shatter",
|
||||
"cinderback_curl",
|
||||
"cinderback_slam",
|
||||
"ghost_soul_cross",
|
||||
"ghost_soul_cross_followup",
|
||||
"ghost_haunting",
|
||||
"golem_shockwave",
|
||||
"golem_crownfall",
|
||||
] as const;
|
||||
|
||||
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
if ("prototype" in config && config.prototype) {
|
||||
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
|
||||
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
|
||||
return config.idle;
|
||||
}
|
||||
if (kind === "ember-mantis-duelist") {
|
||||
if (motionMode === "mantis_sidestep") return config.move;
|
||||
if (motionMode === "mantis_line_telegraph") return config.attack;
|
||||
if (motionMode === "mantis_cross_telegraph") return config.special;
|
||||
if (motionMode === "mantis_recover") return "Recover";
|
||||
}
|
||||
if (kind === "obsidian-ram-golem") {
|
||||
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
|
||||
if (motionMode === "ram_quake") return config.special;
|
||||
if (motionMode === "ram_shatter") return "ArmorShatter";
|
||||
if (motionMode === "ram_recover") return "Stagger";
|
||||
}
|
||||
if (kind === "cinderback-ricochet") {
|
||||
if (motionMode === "cinderback_curl") return config.attack;
|
||||
if (motionMode === "cinderback_ricochet") return config.move;
|
||||
if (motionMode === "cinderback_slam") return config.special;
|
||||
if (motionMode === "cinderback_recover") return "Recover";
|
||||
}
|
||||
if (kind === "sandglass-scorpion") {
|
||||
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
|
||||
if (motionMode === "sandglass_eruption") return config.attack;
|
||||
if (motionMode === "sandglass_hourglass") return config.special;
|
||||
if (motionMode === "sandglass_recover") return "Stagger";
|
||||
}
|
||||
if (kind === "cragclaw-crab") {
|
||||
if (motionMode === "crab_scuttling") return config.move;
|
||||
if (motionMode === "crab_scuttle_telegraph") return config.attack;
|
||||
if (motionMode === "crab_tidal_burst") return config.special;
|
||||
}
|
||||
if (kind === "mournveil-ghost") {
|
||||
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
|
||||
if (motionMode === "ghost_haunting") return config.special;
|
||||
}
|
||||
if (kind === "crownshard-golem") {
|
||||
if (motionMode === "golem_shockwave") return config.attack;
|
||||
if (motionMode === "golem_crownfall") return config.special;
|
||||
}
|
||||
if (kind === "cindermaw") {
|
||||
if (motionMode === "skyfall") return config.move;
|
||||
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
|
||||
}
|
||||
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
|
||||
return config.idle;
|
||||
}
|
||||
|
||||
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
@@ -626,7 +861,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||
const defeated = bossHp <= 0;
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
|
||||
const gltf = useGLTF(config.url, false, true);
|
||||
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
const { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
@@ -638,31 +873,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
object.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
if (kind === "vexa") {
|
||||
const authoredHelperBox = model.getObjectByName("Box");
|
||||
if (authoredHelperBox) authoredHelperBox.visible = false;
|
||||
}
|
||||
}, [kind, model]);
|
||||
|
||||
const clipName = phase === "victory" || defeated
|
||||
? 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"
|
||||
? config.move
|
||||
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
|
||||
? config.special
|
||||
: motionMode === "tethering" || motionMode === "venom_cast"
|
||||
? config.attack
|
||||
: config.idle;
|
||||
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
|
||||
|
||||
useEffect(() => {
|
||||
const next = actions[clipName];
|
||||
@@ -674,8 +887,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
? 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) {
|
||||
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
|
||||
if (phase === "victory" || defeated || authoredOneShot) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
@@ -691,7 +904,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
if (!current) return;
|
||||
const motion = current.motion;
|
||||
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
|
||||
targetPosition.set(motion.position[0], airborne ? 3.2 : 0.03, motion.position[1]);
|
||||
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
|
||||
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
|
||||
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
|
||||
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||
|
||||
let targetAngle = Math.atan2(
|
||||
@@ -707,6 +922,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
)) {
|
||||
const target = state.partyPositions[motion.chargeTargetId];
|
||||
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
|
||||
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
|
||||
}
|
||||
const difference = Math.atan2(
|
||||
Math.sin(targetAngle - group.current.rotation.y),
|
||||
@@ -891,19 +1108,104 @@ function RangedProjectiles() {
|
||||
|
||||
function BossActor() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const primaryBoss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const primaryBossId = useGameStore((state) => state.boss.id);
|
||||
const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
|
||||
if (phase === "briefing") return null;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
|
||||
return (
|
||||
<>{bosses.map((boss, bossIndex) => (
|
||||
<Suspense key={`${boss.id}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
|
||||
{boss.id === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={boss.id} bossIndex={bossIndex} />}
|
||||
<>{bossIds.map((bossId, bossIndex) => (
|
||||
<Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
|
||||
{bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
|
||||
</Suspense>
|
||||
))}</>
|
||||
);
|
||||
}
|
||||
|
||||
type PerformanceMemory = Performance & {
|
||||
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
|
||||
};
|
||||
|
||||
function percentile(sorted: readonly number[], ratio: number) {
|
||||
if (!sorted.length) return 0;
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
|
||||
}
|
||||
|
||||
function PerformanceProbe() {
|
||||
const { gl } = useThree();
|
||||
const frameSamples = useRef<number[]>([]);
|
||||
const longTaskCount = useRef(0);
|
||||
const longTaskDuration = useRef(0);
|
||||
const lastPublishAt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
|
||||
let observer: PerformanceObserver | undefined;
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longTaskCount.current += 1;
|
||||
longTaskDuration.current += entry.duration;
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// Long Tasks API is optional on Android WebView implementations.
|
||||
}
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
delete document.documentElement.dataset.gamePerf;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
if (!PERFORMANCE_PROBE_ENABLED) return;
|
||||
const samples = frameSamples.current;
|
||||
if (samples.length === 300) samples.shift();
|
||||
samples.push(delta * 1000);
|
||||
if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
|
||||
lastPublishAt.current = clock.elapsedTime;
|
||||
const sorted = [...samples].sort((left, right) => left - right);
|
||||
let total = 0;
|
||||
let overBudget = 0;
|
||||
for (const duration of samples) {
|
||||
total += duration;
|
||||
if (duration > 16.67) overBudget += 1;
|
||||
}
|
||||
const memory = performance as PerformanceMemory;
|
||||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||||
let transferredBytes = 0;
|
||||
let decodedBytes = 0;
|
||||
for (const resource of resources) {
|
||||
transferredBytes += resource.transferSize;
|
||||
decodedBytes += resource.decodedBodySize;
|
||||
}
|
||||
document.documentElement.dataset.gamePerf = JSON.stringify({
|
||||
frame: {
|
||||
averageMs: total / samples.length,
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
p99Ms: percentile(sorted, 0.99),
|
||||
overBudget,
|
||||
samples: samples.length,
|
||||
},
|
||||
renderer: {
|
||||
calls: gl.info.render.calls,
|
||||
triangles: gl.info.render.triangles,
|
||||
geometries: gl.info.memory.geometries,
|
||||
textures: gl.info.memory.textures,
|
||||
},
|
||||
simulation: simulationTickSnapshot(),
|
||||
memory: memory.memory ? {
|
||||
usedJSHeapSize: memory.memory.usedJSHeapSize,
|
||||
totalJSHeapSize: memory.memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
|
||||
} : null,
|
||||
resources: { transferredBytes, decodedBytes, count: resources.length },
|
||||
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
|
||||
});
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||
const ring = useRef<THREE.Mesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
@@ -956,12 +1258,11 @@ export function GameScene() {
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HEALER_CLASSES } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { GameScene } from "./GameScene";
|
||||
import { tankAuraProtects } from "../game/partyCombat";
|
||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||
|
||||
function CompactParty() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
@@ -106,12 +107,21 @@ function PhaseOverlay() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const primaryBoss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||
if (phase === "combat") return null;
|
||||
const title = phase === "briefing" ? definitions.map((boss) => boss.title).join(" & ") : phase === "victory" ? `${bossNames} Broken` : "Party Broken";
|
||||
const eyebrow = phase === "briefing" ? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial) : phase === "victory" ? "Encounter Complete" : "Encounter Failed";
|
||||
const title = phase === "briefing"
|
||||
? definitions.map((boss) => boss.title).join(" & ")
|
||||
: phase === "victory"
|
||||
? `${bossNames} Broken`
|
||||
: "Party Broken";
|
||||
const eyebrow = phase === "briefing"
|
||||
? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial)
|
||||
: phase === "victory"
|
||||
? "Encounter Complete"
|
||||
: "Encounter Failed";
|
||||
const copy = phase === "briefing"
|
||||
? definitions.map((boss) => boss.briefing).join(" ")
|
||||
: phase === "victory"
|
||||
@@ -167,6 +177,8 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
||||
export function TopScreen({ onExit }: { onExit?: () => void }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||
const round = useGameStore((state) => state.round);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const setPaused = useGameStore((state) => state.setPaused);
|
||||
return (
|
||||
<section className="display top-display" aria-label="Main game viewport">
|
||||
@@ -175,7 +187,7 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
|
||||
<div className="top-hud">
|
||||
<CompactParty />
|
||||
<BossBar />
|
||||
<div className="objective-chip"><span>Objective</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||||
<div className="objective-chip"><span>{runMode === "roguelike" ? `Round ${round}` : "Objective"}</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||||
<EncounterCallout />
|
||||
<CastingBar />
|
||||
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ 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[] = [];
|
||||
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
|
||||
const DANGER_WARNING_COLOR = "#ff3b30";
|
||||
const DANGER_ACTIVE_COLOR = "#d4142a";
|
||||
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function motionAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -37,7 +41,7 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
0.045,
|
||||
(motion.chargeStart[1] + motion.chargeEnd[1]) / 2,
|
||||
];
|
||||
const color = motionMode === "charging" ? "#ffb04a" : "#ff4f37";
|
||||
const color = motionMode === "charging" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
const laneWidth = BULL_CHARGE.hitRadius * 2;
|
||||
return (
|
||||
<>
|
||||
@@ -77,16 +81,12 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
||||
useFrame(({ clock }) => {
|
||||
if (!material.current || !edgeMaterial.current) return;
|
||||
const current = motionAt(useGameStore.getState(), bossIndex);
|
||||
const active = current?.mode === "mantis_recover";
|
||||
const active = current ? ACTIVE_LANE_MODES.has(current.mode) : false;
|
||||
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];
|
||||
@@ -97,8 +97,8 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
||||
0.052,
|
||||
(lane.start[1] + lane.end[1]) * 0.5,
|
||||
];
|
||||
const active = motion.mode === "mantis_recover";
|
||||
const color = active ? "#ffd36a" : "#ff5128";
|
||||
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
||||
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
return (
|
||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
@@ -114,7 +114,7 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
||||
{active && (
|
||||
<mesh position={[0, 0.055, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, length]} />
|
||||
<meshBasicMaterial color="#fff0a8" transparent opacity={0.92} depthWrite={false} />
|
||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
@@ -209,7 +209,7 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
if (material.current) material.current.opacity = 0.2 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.07;
|
||||
});
|
||||
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
|
||||
const color = motion.mode === "breath_sweeping" ? "#ff7b2e" : "#ffb04f";
|
||||
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
return (
|
||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
|
||||
@@ -234,12 +234,27 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
||||
});
|
||||
if (!hazard) return null;
|
||||
const active = time >= hazard.activatesAt;
|
||||
const venom = hazard.kind === "venom_pool";
|
||||
const color = venom ? "#a94ee6" : active ? "#ff642d" : "#ffc14d";
|
||||
const colors = {
|
||||
venom_pool: "#a94ee6",
|
||||
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
||||
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
||||
lava_pool: "#ff5a24",
|
||||
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
||||
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
|
||||
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
|
||||
soul_rift: active ? "#7446d8" : "#aa78ff",
|
||||
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
|
||||
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
|
||||
} as const;
|
||||
const color = colors[hazard.kind];
|
||||
return (
|
||||
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[hazard.radius, 40]} />
|
||||
{hazard.innerRadius ? (
|
||||
<ringGeometry args={[hazard.innerRadius, hazard.radius, 48]} />
|
||||
) : (
|
||||
<circleGeometry args={[hazard.radius, 40]} />
|
||||
)}
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={active ? 0.24 : 0.16} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
@@ -249,7 +264,7 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
|
||||
{!active && (
|
||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.22, 0.34, 24]} />
|
||||
<meshBasicMaterial color="#fff0b0" transparent opacity={0.95} depthWrite={false} />
|
||||
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_COPY, selectRandomBoss, selectRandomBossPair } from "./data";
|
||||
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
|
||||
import { selectRandomBossPair } from "../game/roguelike";
|
||||
import { BOSS_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
|
||||
import { BOSS_ORDER } from "../game/bossCatalog";
|
||||
|
||||
describe("game mode configuration", () => {
|
||||
it("separates randomized PVE from selectable Dungeons", () => {
|
||||
@@ -8,16 +11,25 @@ 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.7)).toBe("cindermaw");
|
||||
expect(selectRandomBoss(() => 0.99)).toBe("ember-mantis-duelist");
|
||||
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
|
||||
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects two distinct bosses for PVE", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair(() => values.shift() ?? 0);
|
||||
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
|
||||
expect(pair).toEqual(["bulldrome", "vexa"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("derives collection entries from canonical boss drop tables", () => {
|
||||
const collections = buildCollections(createEmptyCollectionLog(), {});
|
||||
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
|
||||
for (const collection of collections) {
|
||||
expect(collection.drops.map((drop) => drop.id)).toEqual(
|
||||
BOSS_DROP_TABLES[collection.bossId as keyof typeof BOSS_DROP_TABLES].entries.map((drop) => drop.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+54
-63
@@ -1,7 +1,15 @@
|
||||
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { BossId } from "../game/types";
|
||||
import { createDefaultGearProgress } from "../game/progression/gear";
|
||||
import {
|
||||
BOSS_DROP_TABLES,
|
||||
createEmptyCollectionLog,
|
||||
type CollectionLog,
|
||||
type LootRarity,
|
||||
type MaterialStack,
|
||||
} from "../game/progression/loot";
|
||||
|
||||
export const DEFAULT_SETTINGS: GameSettings = {
|
||||
masterVolume: 80,
|
||||
@@ -10,52 +18,39 @@ export const DEFAULT_SETTINGS: GameSettings = {
|
||||
largeText: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_COLLECTIONS: BossCollection[] = [
|
||||
{
|
||||
bossId: "bulldrome",
|
||||
bossName: "Bulldrome",
|
||||
defeated: true,
|
||||
drops: [
|
||||
{ id: "bull-horn", name: "Cinder Horn", icon: "♜", rarity: "Common", count: 7 },
|
||||
{ id: "bull-hide", name: "Ember Hide", icon: "▧", rarity: "Uncommon", count: 3 },
|
||||
{ id: "bull-idol", name: "Vault Idol", icon: "◇", rarity: "Rare", count: 1 },
|
||||
{ id: "bull-heart", name: "Furnace Heart", icon: "✦", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
bossId: "vexa",
|
||||
bossName: "Vexa",
|
||||
defeated: false,
|
||||
drops: [
|
||||
{ id: "vexa-silk", name: "Living Silk", icon: "⌁", rarity: "Common", count: 0 },
|
||||
{ id: "vexa-venom", name: "Widow Venom", icon: "✣", rarity: "Uncommon", count: 0 },
|
||||
{ id: "vexa-eye", name: "Loom Eye", icon: "◉", rarity: "Rare", count: 0 },
|
||||
{ id: "vexa-heart", name: "Webmother Heart", icon: "✦", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
bossId: "cindermaw",
|
||||
bossName: "Cindermaw",
|
||||
defeated: true,
|
||||
drops: [
|
||||
{ id: "maw-scale", name: "Soot Scale", icon: "◈", rarity: "Common", count: 4 },
|
||||
{ id: "maw-gland", name: "Mending Gland", icon: "+", rarity: "Uncommon", count: 2 },
|
||||
{ id: "maw-crest", name: "Ashen Crest", icon: "⌁", rarity: "Rare", 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
|
||||
common: "Common",
|
||||
uncommon: "Uncommon",
|
||||
rare: "Rare",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
|
||||
return BOSS_ORDER.map((bossId) => {
|
||||
const table = BOSS_DROP_TABLES[bossId];
|
||||
return {
|
||||
bossId,
|
||||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||||
defeated: (bossKills[bossId] ?? 0) > 0,
|
||||
drops: table.entries.map((drop) => ({
|
||||
id: drop.id,
|
||||
name: drop.name,
|
||||
icon: drop.glyph,
|
||||
rarity: RARITY_LABELS[drop.rarity],
|
||||
count: drop.kind === "coin"
|
||||
? collectionLog.dropsFound[drop.id] ?? 0
|
||||
: collectionLog.petsFound[drop.id] ?? 0,
|
||||
chance: drop.chanceLabel,
|
||||
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
|
||||
kind: drop.kind,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
|
||||
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
"roguelike-pve": {
|
||||
@@ -69,7 +64,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 · Ember Mantis",
|
||||
detail: "Ten prototype guardians available",
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
@@ -92,12 +87,6 @@ export function selectRandomBoss(random: () => number = Math.random): BossId {
|
||||
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
|
||||
}
|
||||
|
||||
export function selectRandomBossPair(random: () => number = Math.random): readonly [BossId, BossId] {
|
||||
const firstIndex = Math.floor(random() * BOSS_ORDER.length) % BOSS_ORDER.length;
|
||||
const secondOffset = 1 + Math.floor(random() * (BOSS_ORDER.length - 1));
|
||||
return [BOSS_ORDER[firstIndex], BOSS_ORDER[(firstIndex + secondOffset) % BOSS_ORDER.length]];
|
||||
}
|
||||
|
||||
export const MAX_HUNTER_NAME_LENGTH = 20;
|
||||
|
||||
export function normalizeHunterName(value: string): string {
|
||||
@@ -112,25 +101,27 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
const normalizedName = normalizeHunterName(hunterName);
|
||||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 4,
|
||||
slotId,
|
||||
hunterName: normalizedName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: 12, inventory: createClassInventory("priest") },
|
||||
priest: { level: 1, inventory: createClassInventory("priest") },
|
||||
druid: { level: 1, inventory: createClassInventory("druid") },
|
||||
shaman: { level: 1, inventory: createClassInventory("shaman") },
|
||||
},
|
||||
location: "Ember Vault Approach",
|
||||
playSeconds: 8 * 60 * 60 + 42 * 60,
|
||||
playSeconds: 0,
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
totalBossKills: 16,
|
||||
flawlessClears: 5,
|
||||
alliesSaved: 143,
|
||||
healingDone: 284_650,
|
||||
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4, "Ember Mantis Duelist": 0 },
|
||||
totalBossKills: 0,
|
||||
flawlessClears: 0,
|
||||
alliesSaved: 0,
|
||||
healingDone: 0,
|
||||
bossKills: {},
|
||||
},
|
||||
collections: structuredClone(DEFAULT_COLLECTIONS),
|
||||
materials: [] as MaterialStack[],
|
||||
collectionLog: createEmptyCollectionLog(),
|
||||
gearProgress: createDefaultGearProgress(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
@@ -50,11 +51,11 @@ describe("SaveRepository", () => {
|
||||
}));
|
||||
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40);
|
||||
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(12);
|
||||
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1);
|
||||
|
||||
now = "2026-07-10T14:00:00.000Z";
|
||||
repository.download(1, "healer@example.com");
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(12);
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
|
||||
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
@@ -85,7 +86,7 @@ describe("SaveRepository", () => {
|
||||
expect(save.hunterName).toBe("Aelia");
|
||||
expect(save.activeClassId).toBe("druid");
|
||||
expect(save.healers.druid.level).toBe(8);
|
||||
expect(save.healers.priest.level).toBe(12);
|
||||
expect(save.healers.priest.level).toBe(1);
|
||||
expect(save.healers.druid.inventory).toHaveLength(4);
|
||||
expect(save.healers.priest.inventory).toHaveLength(4);
|
||||
});
|
||||
@@ -100,7 +101,7 @@ describe("SaveRepository", () => {
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(2);
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.hunterName).toBe("Legacy");
|
||||
expect(migrated.activeClassId).toBe("priest");
|
||||
expect(migrated.healers.priest.level).toBe(27);
|
||||
@@ -108,15 +109,35 @@ describe("SaveRepository", () => {
|
||||
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("adds newly shipped bosses to existing schema v2 collection logs", () => {
|
||||
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Veteran");
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({
|
||||
1: { ...created, collections: created.collections.filter((boss) => boss.bossId !== "vexa") },
|
||||
}));
|
||||
const legacy = { ...created, schemaVersion: 2, collections: DEFAULT_COLLECTIONS.filter((boss) => boss.bossId !== "vexa") } as Record<string, unknown>;
|
||||
delete legacy.collectionLog;
|
||||
delete legacy.materials;
|
||||
delete legacy.gearProgress;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.collections.some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
});
|
||||
|
||||
it("migrates valid infusion choices and discards stale ids", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Infused");
|
||||
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
|
||||
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
|
||||
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
|
||||
created.gearProgress.brann.passiveInfusionId = "deep-wells";
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 3 } }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
||||
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
|
||||
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
|
||||
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+121
-40
@@ -1,7 +1,11 @@
|
||||
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
|
||||
import { createHunterSave } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { HealerClassId } from "../game/types";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
|
||||
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
|
||||
import { BOSS_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type MaterialStack } from "../game/progression/loot";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
@@ -29,54 +33,131 @@ function browserStorage(): StorageAdapter {
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
|
||||
schemaVersion: 1;
|
||||
level: number;
|
||||
interface LegacyHunterSave {
|
||||
schemaVersion?: number;
|
||||
slotId?: SaveSlotId;
|
||||
hunterName?: string;
|
||||
activeClassId?: HealerClassId;
|
||||
healers?: HunterSave["healers"];
|
||||
level?: number;
|
||||
location?: string;
|
||||
playSeconds?: number;
|
||||
updatedAt?: string;
|
||||
stats?: HunterSave["stats"];
|
||||
collections?: BossCollection[];
|
||||
materials?: MaterialStack[];
|
||||
collectionLog?: CollectionLog;
|
||||
gearProgress?: GearProgress;
|
||||
}
|
||||
|
||||
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
|
||||
|
||||
function normalizeCollections(collections: HunterSave["collections"] | undefined) {
|
||||
const source = collections ?? [];
|
||||
const knownIds = new Set(DEFAULT_COLLECTIONS.map((boss) => boss.bossId));
|
||||
const current = DEFAULT_COLLECTIONS.map((fallback) => source.find((boss) => boss.bossId === fallback.bossId) ?? structuredClone(fallback));
|
||||
return [...current, ...source.filter((boss) => !knownIds.has(boss.bossId))];
|
||||
function positiveCounts(value: unknown): Record<string, number> {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([id, rawQuantity]) => {
|
||||
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0));
|
||||
return quantity > 0 ? [[id, quantity]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog {
|
||||
if (candidate.collectionLog) {
|
||||
return {
|
||||
dropsFound: positiveCounts(candidate.collectionLog.dropsFound),
|
||||
petsFound: positiveCounts(candidate.collectionLog.petsFound),
|
||||
};
|
||||
}
|
||||
const result = createEmptyCollectionLog();
|
||||
for (const legacyBoss of candidate.collections ?? []) {
|
||||
if (!BOSS_ORDER.includes(legacyBoss.bossId as BossId)) continue;
|
||||
const bossId = legacyBoss.bossId as BossId;
|
||||
const quantity = legacyBoss.drops.reduce((sum, drop) => sum + Math.max(0, Math.floor(drop.count || 0)), 0);
|
||||
if (quantity > 0) result.dropsFound[BOSS_DROP_TABLES[bossId].coins.initiate.id] = quantity;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function knownMaterial(id: string) {
|
||||
for (const bossId of BOSS_ORDER) {
|
||||
const coin = Object.values(BOSS_DROP_TABLES[bossId].coins).find((candidate) => candidate.id === id);
|
||||
if (coin) return coin;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMaterials(value: unknown, collectionLog: CollectionLog): MaterialStack[] {
|
||||
const quantities = new Map<string, number>();
|
||||
if (Array.isArray(value)) {
|
||||
for (const raw of value) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const item = raw as Partial<MaterialStack>;
|
||||
if (!item.id) continue;
|
||||
const quantity = Math.max(0, Math.floor(Number(item.quantity) || 0));
|
||||
if (quantity > 0) quantities.set(item.id, (quantities.get(item.id) ?? 0) + quantity);
|
||||
}
|
||||
} else {
|
||||
for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity);
|
||||
}
|
||||
return [...quantities].flatMap(([id, quantity]) => {
|
||||
const coin = knownMaterial(id);
|
||||
return coin ? [{ id, quantity, name: coin.name, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGearProgress(value: unknown): GearProgress {
|
||||
const defaults = createDefaultGearProgress();
|
||||
if (!value || typeof value !== "object") return defaults;
|
||||
const candidate = value as Partial<GearProgress>;
|
||||
for (const ownerId of GEAR_OWNER_ORDER) {
|
||||
for (const slotId of GEAR_SLOT_ORDER) {
|
||||
const level = Math.max(0, Math.min(MAX_GEAR_LEVEL, Math.floor(Number(candidate[ownerId]?.slots?.[slotId]?.level) || 0)));
|
||||
defaults[ownerId].slots[slotId].level = level as GearProgress[typeof ownerId]["slots"][typeof slotId]["level"];
|
||||
}
|
||||
defaults[ownerId].infusionAbilityId = normalizeActiveInfusionId(ownerId, candidate[ownerId]?.infusionAbilityId);
|
||||
defaults[ownerId].passiveInfusionId = normalizePassiveInfusionId(ownerId, candidate[ownerId]?.passiveInfusionId);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function normalizeBossKills(value: unknown): Record<string, number> {
|
||||
const source = positiveCounts(value);
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, quantity] of Object.entries(source)) {
|
||||
const bossId = BOSS_ORDER.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
|
||||
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<HunterSave> & Partial<LegacyHunterSave>;
|
||||
const candidate = value as LegacyHunterSave;
|
||||
if (!candidate.slotId || !candidate.hunterName) return null;
|
||||
|
||||
if (candidate.schemaVersion === 2 && candidate.healers) {
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
...(candidate as HunterSave),
|
||||
activeClassId,
|
||||
collections: normalizeCollections(candidate.collections),
|
||||
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
|
||||
level: Math.max(1, candidate.healers?.[classId]?.level ?? 1),
|
||||
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
|
||||
}])) as HunterSave["healers"],
|
||||
};
|
||||
}
|
||||
|
||||
const legacy = candidate as LegacyHunterSave;
|
||||
const collectionLog = normalizeCollectionLog(candidate);
|
||||
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
slotId: legacy.slotId,
|
||||
hunterName: legacy.hunterName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: Math.max(1, legacy.level || 1), inventory: createClassInventory("priest") },
|
||||
druid: { level: 1, inventory: createClassInventory("druid") },
|
||||
shaman: { level: 1, inventory: createClassInventory("shaman") },
|
||||
schemaVersion: 4,
|
||||
slotId: candidate.slotId,
|
||||
hunterName: candidate.hunterName,
|
||||
activeClassId,
|
||||
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
|
||||
level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)),
|
||||
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
|
||||
}])) as HunterSave["healers"],
|
||||
location: candidate.location ?? "Ember Vault Approach",
|
||||
playSeconds: Math.max(0, candidate.playSeconds ?? 0),
|
||||
updatedAt: candidate.updatedAt ?? new Date(0).toISOString(),
|
||||
stats: {
|
||||
totalBossKills: Math.max(0, candidate.stats?.totalBossKills ?? Object.values(bossKills).reduce((sum, count) => sum + count, 0)),
|
||||
flawlessClears: Math.max(0, candidate.stats?.flawlessClears ?? 0),
|
||||
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
|
||||
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
||||
bossKills,
|
||||
},
|
||||
location: legacy.location,
|
||||
playSeconds: legacy.playSeconds,
|
||||
updatedAt: legacy.updatedAt,
|
||||
stats: legacy.stats,
|
||||
collections: normalizeCollections(legacy.collections),
|
||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||
collectionLog,
|
||||
gearProgress: normalizeGearProgress(candidate.gearProgress),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+136
-9
@@ -4,6 +4,14 @@ import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
||||
import {
|
||||
equipActiveInfusion,
|
||||
equipPassiveInfusion,
|
||||
infusionsForOwner,
|
||||
} from "../game/progression/infusions";
|
||||
import type { RunBuffId } from "../game/types";
|
||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
@@ -46,6 +54,12 @@ export interface FrontendState {
|
||||
activeSlotId: SaveSlotId | null;
|
||||
selectedMode: GameModeId;
|
||||
selectedBossId: BossId;
|
||||
selectedDifficultySlug: DifficultySlug;
|
||||
selectedGearOwnerId: GearOwnerId;
|
||||
selectedGearSlotId: GearSlotId;
|
||||
gearWorkshopMode: "upgrade" | "infusion";
|
||||
selectedInfusionId: string;
|
||||
recentRewards: BossRewardAward[];
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
signIn: (username: string, password: string) => Promise<boolean>;
|
||||
@@ -62,11 +76,20 @@ export interface FrontendState {
|
||||
downloadSlot: (slotId: SaveSlotId) => void;
|
||||
selectMode: (mode: GameModeId) => void;
|
||||
selectBoss: (bossId: BossId) => void;
|
||||
selectDifficulty: (difficultySlug: DifficultySlug) => void;
|
||||
selectGearOwner: (ownerId: GearOwnerId) => void;
|
||||
selectGearSlot: (slotId: GearSlotId) => void;
|
||||
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
||||
selectInfusion: (infusionId: string) => void;
|
||||
upgradeSelectedGear: () => boolean;
|
||||
equipSelectedInfusion: () => boolean;
|
||||
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
||||
selectHealerClass: (classId: HealerClassId) => void;
|
||||
updateActiveHealerInventory: (inventory: InventoryItem[]) => void;
|
||||
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
|
||||
touchActiveSave: () => void;
|
||||
recordBossVictory: (bossName: string) => void;
|
||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||
clearRecentRewards: () => void;
|
||||
clearNotice: () => void;
|
||||
}
|
||||
|
||||
@@ -82,6 +105,12 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
activeSlotId: null,
|
||||
selectedMode: "roguelike-pve",
|
||||
selectedBossId: "bulldrome",
|
||||
selectedDifficultySlug: "initiate",
|
||||
selectedGearOwnerId: "priest",
|
||||
selectedGearSlotId: "weapon",
|
||||
gearWorkshopMode: "upgrade",
|
||||
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
||||
recentRewards: [],
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
@@ -149,12 +178,84 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
|
||||
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
||||
selectDifficulty: (selectedDifficultySlug) => set({ selectedDifficultySlug: normalizeDifficultySlug(selectedDifficultySlug), notice: "" }),
|
||||
selectGearOwner: (selectedGearOwnerId) => set({
|
||||
selectedGearOwnerId,
|
||||
selectedInfusionId: infusionsForOwner(selectedGearOwnerId)[0].id,
|
||||
notice: "",
|
||||
}),
|
||||
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
||||
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
||||
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
||||
upgradeSelectedGear: () => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
let message = "Gear upgrade failed.";
|
||||
let upgraded = false;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
try {
|
||||
const result = upgradeGearSlot(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId);
|
||||
upgraded = true;
|
||||
message = `${selectedGearOwnerId} ${selectedGearSlotId} upgraded to +${result.gearProgress[selectedGearOwnerId].slots[selectedGearSlotId].level}.`;
|
||||
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : message;
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
return upgraded;
|
||||
},
|
||||
equipSelectedInfusion: () => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
let message = "Infusion failed.";
|
||||
let equipped = false;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
try {
|
||||
const wasEquipped = save.gearProgress[selectedGearOwnerId].infusionAbilityId === selectedInfusionId;
|
||||
const result = equipActiveInfusion(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId);
|
||||
equipped = true;
|
||||
message = wasEquipped ? "Infusion already equipped." : `${selectedGearOwnerId} infusion equipped.`;
|
||||
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : message;
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
return equipped;
|
||||
},
|
||||
equipPassiveInfusion: (passiveId) => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
let message = "Passive infusion failed.";
|
||||
let equipped = false;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
try {
|
||||
const gearProgress = equipPassiveInfusion(save.gearProgress, selectedGearOwnerId, passiveId);
|
||||
equipped = true;
|
||||
message = "Passive infusion equipped. Applies next encounter.";
|
||||
return { ...save, gearProgress };
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : message;
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
return equipped;
|
||||
},
|
||||
selectHealerClass: (classId) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
|
||||
if (!updated) return;
|
||||
set({ slots: repository.list(accountId), notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.` });
|
||||
set({
|
||||
slots: repository.list(accountId),
|
||||
selectedGearOwnerId: classId,
|
||||
selectedInfusionId: infusionsForOwner(classId)[0].id,
|
||||
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
|
||||
});
|
||||
},
|
||||
updateActiveHealerInventory: (inventory) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
@@ -179,21 +280,29 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
repository.touch(activeSlotId);
|
||||
set({ slots: repository.list(accountId) });
|
||||
},
|
||||
recordBossVictory: (bossName) => {
|
||||
recordBossVictory: (bossId, difficultySlug) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
if (!activeSlotId) return null;
|
||||
let awarded: BossRewardAward | null = null;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
const bossKills = { ...save.stats.bossKills, [bossName]: (save.stats.bossKills[bossName] ?? 0) + 1 };
|
||||
const reward = rollBossReward(bossId, difficultySlug, save.materials, save.collectionLog);
|
||||
awarded = reward.award;
|
||||
const bossKills = { ...save.stats.bossKills, [bossId]: (save.stats.bossKills[bossId] ?? 0) + 1 };
|
||||
return {
|
||||
...save,
|
||||
stats: { ...save.stats, totalBossKills: save.stats.totalBossKills + 1, flawlessClears: save.stats.flawlessClears + 1, bossKills },
|
||||
collections: save.collections.map((boss) => boss.bossName === bossName
|
||||
? { ...boss, defeated: true, drops: boss.drops.map((drop, index) => index === 0 ? { ...drop, count: drop.count + 1 } : drop) }
|
||||
: boss),
|
||||
materials: reward.inventory,
|
||||
collectionLog: reward.collectionLog,
|
||||
};
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: `${bossName} clear saved offline.` });
|
||||
set((state) => ({
|
||||
slots: repository.list(accountId),
|
||||
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
|
||||
notice: awarded ? `${awarded.coin.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
||||
}));
|
||||
return awarded;
|
||||
},
|
||||
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||
clearNotice: () => set({ notice: "" }),
|
||||
}));
|
||||
|
||||
@@ -212,11 +321,20 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "downloadSlot"
|
||||
| "selectMode"
|
||||
| "selectBoss"
|
||||
| "selectDifficulty"
|
||||
| "selectGearOwner"
|
||||
| "selectGearSlot"
|
||||
| "selectGearWorkshopMode"
|
||||
| "selectInfusion"
|
||||
| "upgradeSelectedGear"
|
||||
| "equipSelectedInfusion"
|
||||
| "equipPassiveInfusion"
|
||||
| "selectHealerClass"
|
||||
| "updateActiveHealerInventory"
|
||||
| "updateSetting"
|
||||
| "touchActiveSave"
|
||||
| "recordBossVictory"
|
||||
| "clearRecentRewards"
|
||||
| "clearNotice"
|
||||
>;
|
||||
|
||||
@@ -236,11 +354,20 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
downloadSlot: _downloadSlot,
|
||||
selectMode: _selectMode,
|
||||
selectBoss: _selectBoss,
|
||||
selectDifficulty: _selectDifficulty,
|
||||
selectGearOwner: _selectGearOwner,
|
||||
selectGearSlot: _selectGearSlot,
|
||||
selectGearWorkshopMode: _selectGearWorkshopMode,
|
||||
selectInfusion: _selectInfusion,
|
||||
upgradeSelectedGear: _upgradeSelectedGear,
|
||||
equipSelectedInfusion: _equipSelectedInfusion,
|
||||
equipPassiveInfusion: _equipPassiveInfusion,
|
||||
selectHealerClass: _selectHealerClass,
|
||||
updateActiveHealerInventory: _updateActiveHealerInventory,
|
||||
updateSetting: _updateSetting,
|
||||
touchActiveSave: _touchActiveSave,
|
||||
recordBossVictory: _recordBossVictory,
|
||||
clearRecentRewards: _clearRecentRewards,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
} = useFrontendStore.getState();
|
||||
|
||||
+11
-4
@@ -1,15 +1,20 @@
|
||||
import type { HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { GearProgress } from "../game/progression/gear";
|
||||
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
||||
|
||||
export type SaveSlotId = 1 | 2 | 3;
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "settings" | "mode" | "game";
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
|
||||
export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
|
||||
|
||||
export interface CollectionDrop {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rarity: "Common" | "Uncommon" | "Rare" | "Mythic";
|
||||
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary";
|
||||
count: number;
|
||||
chance: string;
|
||||
itemLevel?: number;
|
||||
kind: "coin" | "pet";
|
||||
}
|
||||
|
||||
export interface BossCollection {
|
||||
@@ -33,7 +38,7 @@ export interface HealerProgress {
|
||||
}
|
||||
|
||||
export interface HunterSave {
|
||||
schemaVersion: 2;
|
||||
schemaVersion: 4;
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
activeClassId: HealerClassId;
|
||||
@@ -42,7 +47,9 @@ export interface HunterSave {
|
||||
playSeconds: number;
|
||||
updatedAt: string;
|
||||
stats: HunterStats;
|
||||
collections: BossCollection[];
|
||||
materials: MaterialStack[];
|
||||
collectionLog: CollectionLog;
|
||||
gearProgress: GearProgress;
|
||||
}
|
||||
|
||||
export interface SaveSlotState {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isActorAnimationOneShot, shouldStartActorAnimation } from "./actorAnimation";
|
||||
|
||||
describe("actor animation playback", () => {
|
||||
it("restarts a repeated attack when a new combat action begins", () => {
|
||||
expect(shouldStartActorAnimation("attack", 4.2, "attack", 5.3)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not restart the same combat action every frame", () => {
|
||||
expect(shouldStartActorAnimation("attack", 4.2, "attack", 4.2)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps looping locomotion stable while its trigger changes", () => {
|
||||
expect(shouldStartActorAnimation("walk", 0, "walk", 1)).toBe(false);
|
||||
expect(shouldStartActorAnimation("run", 0, "run", 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("starts when the actor changes animation state", () => {
|
||||
expect(shouldStartActorAnimation("attack", 4.2, "walk", 0)).toBe(true);
|
||||
expect(isActorAnimationOneShot("death")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
|
||||
|
||||
export function isActorAnimationOneShot(state: ActorAnimationState) {
|
||||
return state === "attack" || state === "cast" || state === "hit" || state === "death";
|
||||
}
|
||||
|
||||
export function shouldStartActorAnimation(
|
||||
activeState: ActorAnimationState | undefined,
|
||||
activeTrigger: number,
|
||||
nextState: ActorAnimationState,
|
||||
nextTrigger: number,
|
||||
) {
|
||||
if (activeState !== nextState) return true;
|
||||
if (nextState === "death" || !isActorAnimationOneShot(nextState)) return false;
|
||||
return activeTrigger !== nextTrigger;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BossMotionState, WorldPosition } from "./types";
|
||||
|
||||
export const ARENA_CENTER: WorldPosition = [0, -1];
|
||||
export const ARENA_RADIUS = 8.35;
|
||||
export const ARENA_WALL_RADIUS = 9.55;
|
||||
|
||||
export function clampToArena(position: WorldPosition, padding = 0): WorldPosition {
|
||||
const radius = Math.max(0, ARENA_RADIUS - padding);
|
||||
const dx = position[0] - ARENA_CENTER[0];
|
||||
const dz = position[1] - ARENA_CENTER[1];
|
||||
const distance = Math.hypot(dx, dz);
|
||||
if (distance <= radius || distance < 0.0001) return [position[0], position[1]];
|
||||
const scale = radius / distance;
|
||||
return [ARENA_CENTER[0] + dx * scale, ARENA_CENTER[1] + dz * scale];
|
||||
}
|
||||
|
||||
export function constrainBossMotion(motion: BossMotionState): BossMotionState {
|
||||
return {
|
||||
...motion,
|
||||
position: clampToArena(motion.position),
|
||||
chargeStart: clampToArena(motion.chargeStart),
|
||||
chargeEnd: clampToArena(motion.chargeEnd),
|
||||
pounceCenter: clampToArena(motion.pounceCenter),
|
||||
};
|
||||
}
|
||||
|
||||
export function isInsideArena(position: WorldPosition, tolerance = 0.001) {
|
||||
return Math.hypot(position[0] - ARENA_CENTER[0], position[1] - ARENA_CENTER[1]) <= ARENA_RADIUS + tolerance;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClassInventory } from "./healers";
|
||||
import { BOSS_ORDER } from "./bossCatalog";
|
||||
import { useGameStore } from "./store";
|
||||
import type { BossId } from "./types";
|
||||
|
||||
@@ -25,14 +26,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
|
||||
}
|
||||
|
||||
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"],
|
||||
];
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
|
||||
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
|
||||
);
|
||||
|
||||
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||
const result = simulateControlledBattle([first, second]);
|
||||
|
||||
+161
-28
@@ -16,7 +16,20 @@ export interface BossDefinition {
|
||||
maxHp: number;
|
||||
}
|
||||
|
||||
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw", "ember-mantis-duelist"];
|
||||
export const BOSS_ORDER: readonly BossId[] = [
|
||||
"bulldrome",
|
||||
"vexa",
|
||||
"cindermaw",
|
||||
"ember-mantis-duelist",
|
||||
"obsidian-ram-golem",
|
||||
"cinderback-ricochet",
|
||||
"sandglass-scorpion",
|
||||
"cragclaw-crab",
|
||||
"pumpking-king-of-ghosts",
|
||||
"blue-eyes-ultimate-dragon",
|
||||
"mournveil-ghost",
|
||||
"crownshard-golem",
|
||||
];
|
||||
|
||||
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
bulldrome: {
|
||||
@@ -36,47 +49,167 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
},
|
||||
vexa: {
|
||||
id: "vexa",
|
||||
name: "Vexa",
|
||||
title: "The Webmother",
|
||||
trial: "Trial II · Tangled Remedy",
|
||||
name: "Insect Queen",
|
||||
title: "The Hive Sovereign",
|
||||
trial: "Trial II · Royal Brood",
|
||||
icon: "✣",
|
||||
accent: "#b56cff",
|
||||
summary: "Binds allies together and weaponizes every cleanse.",
|
||||
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its pool poisons the formation.",
|
||||
failure: "Break purple tethers quickly. Cleanse venom only after its target reaches open ground.",
|
||||
mapTitle: "The Tangled Loom",
|
||||
mapCopy: "Spread tethered allies toward opposite edges. Keep dropped venom pools away from the center lane.",
|
||||
mechanics: ["Binding Web", "Venom Purge"],
|
||||
summary: "Pins prey with silk before flooding safe ground with venom.",
|
||||
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its brood pool poisons formation.",
|
||||
failure: "Break royal tethers quickly. Cleanse venom only after its target reaches open ground.",
|
||||
mapTitle: "The Royal Hive",
|
||||
mapCopy: "Spread tethered allies toward opposite edges. Keep venom brood pools away from center.",
|
||||
mechanics: ["Binding Web", "Venom Brood"],
|
||||
maxHp: 535,
|
||||
},
|
||||
cindermaw: {
|
||||
id: "cindermaw",
|
||||
name: "Cindermaw",
|
||||
title: "The Sky Tyrant",
|
||||
trial: "Trial III · Ashen Orbit",
|
||||
name: "Blue-Eyes White Dragon",
|
||||
title: "The White Lightning",
|
||||
trial: "Trial III · Burststream Orbit",
|
||||
icon: "◆",
|
||||
accent: "#ff9b45",
|
||||
summary: "Sweeps the arena with flame and removes safe ground.",
|
||||
briefing: "Rotate behind Searing Sweep. During Skyfall, leave each numbered impact circle before it becomes persistent fire.",
|
||||
failure: "Follow the safe side of the breath cone. Keep moving as Skyfall removes sections of the arena.",
|
||||
mapTitle: "The Ashen Crown",
|
||||
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between Skyfall impacts.",
|
||||
mechanics: ["Searing Sweep", "Skyfall"],
|
||||
summary: "Sweeps the arena with white lightning and dives through targeted ground.",
|
||||
briefing: "Rotate behind Burst Stream. During White Skyfall, leave each numbered impact before it becomes charged ground.",
|
||||
failure: "Follow the safe side of the breath cone. Keep moving as White Skyfall removes sections of arena.",
|
||||
mapTitle: "The Ivory Crown",
|
||||
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between skyfall impacts.",
|
||||
mechanics: ["Burst Stream", "White Skyfall"],
|
||||
maxHp: 410,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
id: "ember-mantis-duelist",
|
||||
name: "Ember Mantis Duelist",
|
||||
title: "The Cinderblade",
|
||||
trial: "Trial IV · Blades in Motion",
|
||||
name: "Gate Guardian",
|
||||
title: "The Tri-Element Sentinel",
|
||||
trial: "Trial IV · Elements 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"],
|
||||
summary: "Repositions its stacked body before firing single and crossed elemental lanes.",
|
||||
briefing: "Track each sidestep. Clear Elemental Beam, then find a safe quadrant when three powers form Guardian Cross.",
|
||||
failure: "Do not chase the guardian through a telegraph. Preserve space and move perpendicular to each beam lane.",
|
||||
mapTitle: "The Sealed Gate",
|
||||
mapCopy: "Follow the guardian laterally, but cross glowing lanes only after its arms finish firing.",
|
||||
mechanics: ["Elemental Beam", "Guardian Cross"],
|
||||
maxHp: 520,
|
||||
},
|
||||
"obsidian-ram-golem": {
|
||||
id: "obsidian-ram-golem",
|
||||
name: "Gandora the Dragon of Destruction",
|
||||
title: "The Ruin Orb",
|
||||
trial: "Trial V · Destruction March",
|
||||
icon: "♞",
|
||||
accent: "#ff7a38",
|
||||
summary: "Breaks formation with armored rushes, ruin quakes, and radial destruction beams.",
|
||||
briefing: "Clear Destruction Rush, leave Ruin Quake, then step between Gandora's radial destruction lines.",
|
||||
failure: "Do not remain in front of the armored dragon. Treat every glowing orb as an active strike lane.",
|
||||
mapTitle: "The Ruined Causeway",
|
||||
mapCopy: "Hold open flanks for Destruction Rush. Spread between radial fractures when its armor vents.",
|
||||
mechanics: ["Destruction Rush", "Ruin Quake"],
|
||||
maxHp: 540,
|
||||
},
|
||||
"cinderback-ricochet": {
|
||||
id: "cinderback-ricochet",
|
||||
name: "Red-Eyes Black Dragon",
|
||||
title: "The Black Flare",
|
||||
trial: "Trial VI · Inferno Rebound",
|
||||
icon: "⬢",
|
||||
accent: "#ff9345",
|
||||
summary: "Rebounds through marked flight lanes and leaves black-flame impact pools.",
|
||||
briefing: "Clear both Inferno Rush lanes. Meteor Slam blooms into black-flame pools around its landing zone.",
|
||||
failure: "Watch the second rebound before returning to formation. Preserve a clean route around black flame.",
|
||||
mapTitle: "The Inferno Circuit",
|
||||
mapCopy: "Bait the dragon along arena edges. Never cross a marked flight lane before second impact.",
|
||||
mechanics: ["Inferno Rush", "Meteor Slam"],
|
||||
maxHp: 505,
|
||||
},
|
||||
"sandglass-scorpion": {
|
||||
id: "sandglass-scorpion",
|
||||
name: "Sandglass Scorpion",
|
||||
title: "The Dune Chronarch",
|
||||
trial: "Trial VII · Hour of Venom",
|
||||
icon: "⌛",
|
||||
accent: "#e9b94f",
|
||||
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.",
|
||||
briefing: "Cross the Burrow Rush lane before it dives. Leave Stinger Eruptions, then outrun the active Hourglass zone.",
|
||||
failure: "Move before each timer completes. Sand warnings become damaging ground the instant they fill.",
|
||||
mapTitle: "The Sunken Hour",
|
||||
mapCopy: "Keep the center open. Burrow paths split formation while hourglass zones close escape routes.",
|
||||
mechanics: ["Burrow Rush", "Hourglass Eruption"],
|
||||
maxHp: 515,
|
||||
},
|
||||
"cragclaw-crab": {
|
||||
id: "cragclaw-crab",
|
||||
name: "Cragclaw",
|
||||
title: "The Breakwater Tyrant",
|
||||
trial: "Trial VIII · Tide in the Claws",
|
||||
icon: "♋",
|
||||
accent: "#49c7d4",
|
||||
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.",
|
||||
briefing: "Clear Sidewinder Rush, then leave every Crushing Tide circle before the claws close.",
|
||||
failure: "Cross the scuttle lane only after Cragclaw passes. Spread targeted circles away from formation.",
|
||||
mapTitle: "The Drowned Breakwater",
|
||||
mapCopy: "Keep open water between party lanes. Tidal marks punish overlapping escape routes.",
|
||||
mechanics: ["Sidewinder Rush", "Crushing Tide"],
|
||||
maxHp: 505,
|
||||
},
|
||||
"pumpking-king-of-ghosts": {
|
||||
id: "pumpking-king-of-ghosts",
|
||||
name: "Pumpking the King of Ghosts",
|
||||
title: "The Haunted Harvest",
|
||||
trial: "Trial XI · Vines Unbound",
|
||||
icon: "♚",
|
||||
accent: "#d87842",
|
||||
summary: "Whips the arena twice with spectral vines and grows hungry rifts beneath allies.",
|
||||
briefing: "Dodge both Vine Scissor patterns. Carry Haunting Rifts away before they sprout.",
|
||||
failure: "The second vine cross rotates. Do not return to the first safe quadrant early.",
|
||||
mapTitle: "The Haunted Patch",
|
||||
mapCopy: "Read both crossing vine patterns, then preserve clear ground for persistent ghost rifts.",
|
||||
mechanics: ["Vine Scissors", "Haunting Rifts"],
|
||||
maxHp: 505,
|
||||
},
|
||||
"blue-eyes-ultimate-dragon": {
|
||||
id: "blue-eyes-ultimate-dragon",
|
||||
name: "Blue-Eyes Ultimate Dragon",
|
||||
title: "The Three-Headed Tyrant",
|
||||
trial: "Trial XII · Ultimate Evolution",
|
||||
icon: "♕",
|
||||
accent: "#8fc8ff",
|
||||
summary: "Three heads fire expanding burst rings before marking allies for ultimate skyfall.",
|
||||
briefing: "Move through each Tri-Burst ring, then spread targeted Ultimate Skyfall circles.",
|
||||
failure: "Three shockwaves expand in sequence. Commit to each safe band before the next head fires.",
|
||||
mapTitle: "The Ultimate Aerie",
|
||||
mapCopy: "Follow expanding safe bands. Spread triple-head skyfall marks toward separate arena edges.",
|
||||
mechanics: ["Tri-Burst Rings", "Ultimate Skyfall"],
|
||||
maxHp: 500,
|
||||
},
|
||||
"mournveil-ghost": {
|
||||
id: "mournveil-ghost",
|
||||
name: "Mournveil",
|
||||
title: "The Hollow Choir",
|
||||
trial: "Trial IX · Echoes Unbound",
|
||||
icon: "◉",
|
||||
accent: "#9d72ff",
|
||||
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.",
|
||||
briefing: "Dodge both Soul Scissor patterns. Carry Haunting Rifts away before they open.",
|
||||
failure: "The second spectral cross rotates. Do not return to the first safe quadrant early.",
|
||||
mapTitle: "The Silent Reliquary",
|
||||
mapCopy: "Read both crossing patterns, then preserve clear ground for persistent soul rifts.",
|
||||
mechanics: ["Soul Scissors", "Haunting Rifts"],
|
||||
maxHp: 505,
|
||||
},
|
||||
"crownshard-golem": {
|
||||
id: "crownshard-golem",
|
||||
name: "Crownshard Golem",
|
||||
title: "The Fallen Idol",
|
||||
trial: "Trial X · Edict of Stone",
|
||||
icon: "♛",
|
||||
accent: "#e0bd45",
|
||||
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.",
|
||||
briefing: "Move through each Royal Shockwave ring, then clear targeted Crownfall circles.",
|
||||
failure: "Shockwaves expand in three steps. Commit to each safe band before the next ring fires.",
|
||||
mapTitle: "The Broken Coronation",
|
||||
mapCopy: "Follow the expanding safe bands. Spread Crownfall marks toward separate arena edges.",
|
||||
mechanics: ["Royal Shockwave", "Crownfall"],
|
||||
maxHp: 500,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { clampToArena } from "./arena";
|
||||
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
|
||||
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/cinderbackRicochet";
|
||||
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
|
||||
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
|
||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
|
||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
|
||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
|
||||
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
|
||||
@@ -24,7 +31,7 @@ export const BULL_POUNCE = {
|
||||
afterCharges: 3,
|
||||
stackDuration: 5,
|
||||
stackRadius: 2.2,
|
||||
sharedDamage: 300,
|
||||
sharedDamage: 200,
|
||||
leapDuration: 0.55,
|
||||
} as const;
|
||||
|
||||
@@ -41,6 +48,25 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
if (bossId === "vexa") return createVexaState();
|
||||
if (bossId === "cindermaw") return createCindermawState();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
|
||||
if (bossId === "obsidian-ram-golem") return createObsidianRamState();
|
||||
if (bossId === "cinderback-ricochet") return createCinderbackState();
|
||||
if (bossId === "sandglass-scorpion") return createSandglassState();
|
||||
if (bossId === "cragclaw-crab") return createCragclawState();
|
||||
if (bossId === "mournveil-ghost") return createMournveilState();
|
||||
if (bossId === "crownshard-golem") return createCrownshardState();
|
||||
if (bossId === "pumpking-king-of-ghosts" || bossId === "blue-eyes-ultimate-dragon") {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: bossId === "pumpking-king-of-ghosts" ? 2.35 : 2.4,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
const definition = BOSS_DEFINITIONS.bulldrome;
|
||||
return {
|
||||
id: "bulldrome",
|
||||
@@ -58,6 +84,14 @@ export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionS
|
||||
if (bossId === "vexa") return createVexaMotion();
|
||||
if (bossId === "cindermaw") return createCindermawMotion();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
|
||||
if (bossId === "obsidian-ram-golem") return createObsidianRamMotion();
|
||||
if (bossId === "cinderback-ricochet") return createCinderbackMotion();
|
||||
if (bossId === "sandglass-scorpion") return createSandglassMotion();
|
||||
if (bossId === "cragclaw-crab") return createCragclawMotion();
|
||||
if (bossId === "mournveil-ghost") return createMournveilMotion();
|
||||
if (bossId === "crownshard-golem") return createCrownshardMotion();
|
||||
if (bossId === "pumpking-king-of-ghosts") return { ...createMournveilMotion(), bossId };
|
||||
if (bossId === "blue-eyes-ultimate-dragon") return { ...createCrownshardMotion(), bossId };
|
||||
return {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "holding",
|
||||
@@ -80,10 +114,10 @@ function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosit
|
||||
const dx = target[0] - start[0];
|
||||
const dz = target[1] - start[1];
|
||||
const length = Math.max(0.001, Math.hypot(dx, dz));
|
||||
return [
|
||||
Math.max(-7.3, Math.min(7.3, start[0] + (dx / length) * BULL_CHARGE.distance)),
|
||||
Math.max(-8.8, Math.min(7.1, start[1] + (dz / length) * BULL_CHARGE.distance)),
|
||||
];
|
||||
return clampToArena([
|
||||
start[0] + (dx / length) * BULL_CHARGE.distance,
|
||||
start[1] + (dz / length) * BULL_CHARGE.distance,
|
||||
]);
|
||||
}
|
||||
|
||||
function livingMember(party: PartyMember[], memberId: MemberId) {
|
||||
@@ -352,6 +386,14 @@ export function advanceBossMechanics(context: BossMechanicContext): BossMechanic
|
||||
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);
|
||||
if (context.boss.id === "obsidian-ram-golem") return advanceObsidianRamMechanics(context);
|
||||
if (context.boss.id === "cinderback-ricochet") return advanceCinderbackMechanics(context);
|
||||
if (context.boss.id === "sandglass-scorpion") return advanceSandglassMechanics(context);
|
||||
if (context.boss.id === "cragclaw-crab") return advanceCragclawMechanics(context);
|
||||
if (context.boss.id === "mournveil-ghost") return advanceMournveilMechanics(context);
|
||||
if (context.boss.id === "crownshard-golem") return advanceCrownshardMechanics(context);
|
||||
if (context.boss.id === "pumpking-king-of-ghosts") return advanceMournveilMechanics(context);
|
||||
if (context.boss.id === "blue-eyes-ultimate-dragon") return advanceCrownshardMechanics(context);
|
||||
return advanceBulldromeMechanics(context);
|
||||
}
|
||||
|
||||
@@ -376,6 +418,14 @@ export function upcomingMechanic(boss: BossState, motion: BossMotionState, time:
|
||||
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 (boss.id === "obsidian-ram-golem") return upcomingObsidianRamMechanic(boss, motion, time);
|
||||
if (boss.id === "cinderback-ricochet") return upcomingCinderbackMechanic(boss, motion, time);
|
||||
if (boss.id === "sandglass-scorpion") return upcomingSandglassMechanic(boss, motion, time);
|
||||
if (boss.id === "cragclaw-crab") return upcomingCragclawMechanic(boss, motion, time);
|
||||
if (boss.id === "mournveil-ghost") return upcomingMournveilMechanic(boss, motion, time);
|
||||
if (boss.id === "crownshard-golem") return upcomingCrownshardMechanic(boss, motion, time);
|
||||
if (boss.id === "pumpking-king-of-ghosts") return upcomingMournveilMechanic(boss, motion, time);
|
||||
if (boss.id === "blue-eyes-ultimate-dragon") return upcomingCrownshardMechanic(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,115 @@
|
||||
import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CINDERBACK = {
|
||||
firstAt: 5,
|
||||
repeatDelay: 3.6,
|
||||
curlWarning: 1.25,
|
||||
speed: 12.5,
|
||||
distance: 13,
|
||||
laneWidth: 2.25,
|
||||
rushDamage: 22,
|
||||
slamWarning: 1.3,
|
||||
slamRadius: 3.1,
|
||||
slamDamage: 29,
|
||||
lavaRadius: 1.5,
|
||||
lavaDamage: 5,
|
||||
lavaDuration: 4.5,
|
||||
recoverDuration: 0.75,
|
||||
} as const;
|
||||
|
||||
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
|
||||
|
||||
export function createCinderbackState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["cinderback-ricochet"];
|
||||
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
|
||||
}
|
||||
|
||||
export function createCinderbackMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("cinderback-ricochet"), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
|
||||
}
|
||||
|
||||
function rushEnd(start: WorldPosition, target: WorldPosition) {
|
||||
const angle = angleTo(start, target);
|
||||
return clampToArena([start[0] + Math.sin(angle) * CINDERBACK.distance, start[1] + Math.cos(angle) * CINDERBACK.distance] as WorldPosition);
|
||||
}
|
||||
|
||||
function rushLane(id: string, start: WorldPosition, end: WorldPosition): SlashLane {
|
||||
return { id, start: [...start], end: [...end], width: CINDERBACK.laneWidth, damage: CINDERBACK.rushDamage };
|
||||
}
|
||||
|
||||
function lavaPool(id: string, center: WorldPosition, at: number): CircleHazard {
|
||||
return { id, kind: "lava_pool", center: [...center], radius: CINDERBACK.lavaRadius, activatesAt: at, expiresAt: at + CINDERBACK.lavaDuration, damage: CINDERBACK.lavaDamage, tickInterval: 0.8, nextDamageAt: {}, resolved: false, hitIds: [] };
|
||||
}
|
||||
|
||||
export function advanceCinderbackMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2 * context.delta);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
const count = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
|
||||
const end = rushEnd(motion.position, context.partyPositions[targetId]);
|
||||
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
|
||||
events.push({ at: context.time, message: `Red-Eyes dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
|
||||
} else {
|
||||
const activatesAt = context.time + CINDERBACK.slamWarning;
|
||||
const pools = [0, 1, 2].map((index) => {
|
||||
const angle = (index / 3) * Math.PI * 2;
|
||||
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
|
||||
});
|
||||
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
|
||||
events.push({ at: context.time, message: "Meteor Slam! Leave Red-Eyes and spreading black flame.", tone: "danger", pulseKind: "boss" });
|
||||
}
|
||||
}
|
||||
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "cinderback_ricochet", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CINDERBACK.speed };
|
||||
} else if (motion.mode === "cinderback_ricochet") {
|
||||
const previous = [...motion.position] as WorldPosition;
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, CINDERBACK.speed * context.delta);
|
||||
party = party.map((member) => {
|
||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CINDERBACK.laneWidth * 0.5) return member;
|
||||
motion.chargeHitIds.push(member.id);
|
||||
return { ...context.damageMember(member, CINDERBACK.rushDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.4 };
|
||||
});
|
||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
|
||||
motion.hazards.push(lavaPool(`ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, motion.chargeEnd, context.time));
|
||||
if (motion.chargeCount === 0) {
|
||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount + 2);
|
||||
const start = [...motion.chargeEnd] as WorldPosition;
|
||||
const end = rushEnd(start, context.partyPositions[targetId]);
|
||||
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
|
||||
events.push({ at: context.time, message: `Inferno Rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
|
||||
} else {
|
||||
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
|
||||
}
|
||||
}
|
||||
} else if (motion.mode === "cinderback_slam" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
|
||||
} else if (motion.mode === "cinderback_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", nextMechanicAt: context.time + CINDERBACK.repeatDelay, phaseEndsAt: 0, slashLanes: [], chargeHitIds: [] };
|
||||
}
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.1, 14, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingCinderbackMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "cinderback_curl") return { name: "Inferno Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
|
||||
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
|
||||
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
|
||||
if (motion.mode === "cinderback_recover") return { name: "Red-Eyes exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Inferno Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
mechanicHitIds: [],
|
||||
mechanicNextDamageAt: {},
|
||||
};
|
||||
events.push({ at: context.time, message: "Cindermaw draws a sweeping breath. Rotate behind it!", tone: "danger", pulseKind: "breath" });
|
||||
events.push({ at: context.time, message: "Blue-Eyes draws a sweeping Burst Stream. Rotate behind it!", tone: "danger", pulseKind: "breath" });
|
||||
} else {
|
||||
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
|
||||
const hazards = set.map((memberId, index) => {
|
||||
@@ -105,7 +105,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
hazards: [...motion.hazards, ...hazards],
|
||||
};
|
||||
events.push({ at: context.time, message: "Cindermaw takes flight. Three Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
|
||||
events.push({ at: context.time, message: "Blue-Eyes takes flight. Three White Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
|
||||
}
|
||||
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
@@ -115,7 +115,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
|
||||
breathAngle: motion.breathStartAngle,
|
||||
};
|
||||
events.push({ at: context.time, message: "Searing Sweep crosses the arena!", tone: "danger", pulseKind: "breath" });
|
||||
events.push({ at: context.time, message: "Burst Stream crosses the arena!", tone: "danger", pulseKind: "breath" });
|
||||
} else if (motion.mode === "breath_sweeping") {
|
||||
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
|
||||
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
|
||||
@@ -135,7 +135,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
motion.mechanicNextDamageAt[member.id] = tickAt;
|
||||
if (!motion.mechanicHitIds.includes(member.id)) {
|
||||
motion.mechanicHitIds.push(member.id);
|
||||
events.push({ at: context.time, message: `${member.name} is scorched by Searing Sweep.`, tone: "danger", pulseKind: "breath", targetId: member.id });
|
||||
events.push({ at: context.time, message: `${member.name} is struck by Burst Stream.`, tone: "danger", pulseKind: "breath", targetId: member.id });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -152,10 +152,10 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
|
||||
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "breath_telegraph") return { name: "Searing Sweep", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
|
||||
if (motion.mode === "breath_telegraph") return { name: "Burst Stream", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
|
||||
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
|
||||
if (motion.mode === "skyfall") return { name: "Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
|
||||
if (motion.mode === "skyfall") return { name: "White Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
|
||||
const nextIsBreath = motion.mechanicCount % 2 === 0;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: nextIsBreath ? "Searing Sweep" : "Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
|
||||
return { name: nextIsBreath ? "Burst Stream" : "White Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "../data";
|
||||
import type { BossMotionState, BossState, WorldPosition } from "../types";
|
||||
import { advanceCragclawMechanics, CRAGCLAW, createCragclawMotion, createCragclawState } from "./cragclawCrab";
|
||||
import { advanceCrownshardMechanics, CROWNSHARD, createCrownshardMotion, createCrownshardState } from "./crownshardGolem";
|
||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, MOURNVEIL } from "./mournveilGhost";
|
||||
import type { BossMechanicContext } from "./types";
|
||||
|
||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [0, 0],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
};
|
||||
|
||||
function context(
|
||||
boss: BossState,
|
||||
motion: BossMotionState,
|
||||
time: number,
|
||||
delta = 0.1,
|
||||
positions = POSITIONS,
|
||||
party = freshParty(),
|
||||
): BossMechanicContext {
|
||||
return {
|
||||
boss,
|
||||
motion,
|
||||
party,
|
||||
partyPositions: structuredClone(positions),
|
||||
time,
|
||||
delta,
|
||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaudeCraft boss trio mechanics", () => {
|
||||
it("telegraphs and resolves Cragclaw Sidewinder Rush", () => {
|
||||
const start = advanceCragclawMechanics(context(createCragclawState(), createCragclawMotion(), CRAGCLAW.firstAt));
|
||||
expect(start.motion.mode).toBe("crab_scuttle_telegraph");
|
||||
expect(start.motion.slashLanes).toHaveLength(1);
|
||||
|
||||
const active = advanceCragclawMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
||||
expect(active.motion.mode).toBe("crab_scuttling");
|
||||
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
|
||||
const impact = advanceCragclawMechanics(context(active.boss, active.motion, active.motion.phaseEndsAt, 2, POSITIONS, active.party));
|
||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - CRAGCLAW.scuttleDamage);
|
||||
});
|
||||
|
||||
it("places three Crushing Tide warnings on party positions", () => {
|
||||
const motion = { ...createCragclawMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
||||
const start = advanceCragclawMechanics(context(createCragclawState(), motion, 0));
|
||||
expect(start.motion.mode).toBe("crab_tidal_burst");
|
||||
expect(start.motion.hazards.filter((hazard) => hazard.kind === "tidal_burst")).toHaveLength(3);
|
||||
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
|
||||
const hpBefore = start.party.find((member) => member.id === "aelia")!.hp;
|
||||
const impact = advanceCragclawMechanics(context(start.boss, start.motion, CRAGCLAW.tidalWarning + 0.05, 0.1, positions, start.party));
|
||||
expect(impact.party.find((member) => member.id === "aelia")!.hp).toBe(hpBefore - CRAGCLAW.tidalDamage);
|
||||
});
|
||||
|
||||
it("rotates Mournveil Soul Scissors for a second crossing pattern", () => {
|
||||
const start = advanceMournveilMechanics(context(createMournveilState(), createMournveilMotion(), MOURNVEIL.firstAt));
|
||||
const firstLaneIds = start.motion.slashLanes.map((lane) => lane.id);
|
||||
expect(start.motion.mode).toBe("ghost_soul_cross");
|
||||
expect(start.motion.slashLanes).toHaveLength(2);
|
||||
|
||||
const followup = advanceMournveilMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
||||
expect(followup.motion.mode).toBe("ghost_soul_cross_followup");
|
||||
expect(followup.motion.slashLanes).toHaveLength(2);
|
||||
expect(followup.motion.slashLanes.map((lane) => lane.id)).not.toEqual(firstLaneIds);
|
||||
|
||||
const resolved = advanceMournveilMechanics(context(followup.boss, followup.motion, followup.motion.phaseEndsAt, 0.1, POSITIONS, followup.party));
|
||||
expect(resolved.motion.mode).toBe("ghost_recover");
|
||||
});
|
||||
|
||||
it("opens two persistent Haunting Rifts", () => {
|
||||
const motion = { ...createMournveilMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
||||
const start = advanceMournveilMechanics(context(createMournveilState(), motion, 0));
|
||||
const rifts = start.motion.hazards.filter((hazard) => hazard.kind === "soul_rift");
|
||||
expect(start.motion.mode).toBe("ghost_haunting");
|
||||
expect(rifts).toHaveLength(2);
|
||||
expect(rifts[0].expiresAt - rifts[0].activatesAt).toBe(MOURNVEIL.riftDuration);
|
||||
});
|
||||
|
||||
it("builds three non-overlapping Crownshard shockwave bands", () => {
|
||||
const start = advanceCrownshardMechanics(context(createCrownshardState(), createCrownshardMotion(), CROWNSHARD.firstAt));
|
||||
const rings = start.motion.hazards.filter((hazard) => hazard.kind === "royal_shockwave");
|
||||
expect(start.motion.mode).toBe("golem_shockwave");
|
||||
expect(rings).toHaveLength(3);
|
||||
expect(rings.map((ring) => [ring.innerRadius ?? 0, ring.radius])).toEqual([[0, 2.35], [2.35, 4.7], [4.7, 7.05]]);
|
||||
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = [...rings[0].center];
|
||||
positions.nia = [rings[0].center[0] + 3, rings[0].center[1]];
|
||||
const first = advanceCrownshardMechanics(context(start.boss, start.motion, rings[0].activatesAt + 0.05, 0.1, positions, start.party));
|
||||
const aeliaAfterFirst = first.party.find((member) => member.id === "aelia")!.hp;
|
||||
const niaAfterFirst = first.party.find((member) => member.id === "nia")!.hp;
|
||||
expect(aeliaAfterFirst).toBe(start.party.find((member) => member.id === "aelia")!.hp - CROWNSHARD.shockwaveDamage);
|
||||
expect(niaAfterFirst).toBe(start.party.find((member) => member.id === "nia")!.hp);
|
||||
|
||||
const second = advanceCrownshardMechanics(context(first.boss, first.motion, rings[1].activatesAt + 0.05, 0.1, positions, first.party));
|
||||
expect(second.party.find((member) => member.id === "aelia")!.hp).toBe(aeliaAfterFirst);
|
||||
expect(second.party.find((member) => member.id === "nia")!.hp).toBe(niaAfterFirst - CROWNSHARD.shockwaveDamage);
|
||||
});
|
||||
|
||||
it("marks three allies with Crownfall", () => {
|
||||
const motion = { ...createCrownshardMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
||||
const result = advanceCrownshardMechanics(context(createCrownshardState(), motion, 0));
|
||||
expect(result.motion.mode).toBe("golem_crownfall");
|
||||
expect(result.motion.hazards.filter((hazard) => hazard.kind === "crownfall")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CRAGCLAW = {
|
||||
firstAt: 5.1,
|
||||
repeatDelay: 3.7,
|
||||
scuttleWarning: 1.35,
|
||||
scuttleSpeed: 11.8,
|
||||
scuttleDistance: 13,
|
||||
scuttleWidth: 2.35,
|
||||
scuttleDamage: 24,
|
||||
tidalWarning: 1.45,
|
||||
tidalRadius: 1.7,
|
||||
tidalDamage: 26,
|
||||
recoverDuration: 0.72,
|
||||
} as const;
|
||||
|
||||
const SCUTTLE_TARGETS: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
|
||||
const TIDAL_TARGETS: readonly (readonly MemberId[])[] = [
|
||||
["aelia", "nia", "orin"],
|
||||
["brann", "vale", "aelia"],
|
||||
["nia", "orin", "vale"],
|
||||
];
|
||||
|
||||
export function createCragclawState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["cragclaw-crab"];
|
||||
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.3);
|
||||
}
|
||||
|
||||
export function createCragclawMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("cragclaw-crab"), position: [0, -6.4], nextMechanicAt: CRAGCLAW.firstAt };
|
||||
}
|
||||
|
||||
function scuttleEnd(start: WorldPosition, target: WorldPosition): WorldPosition {
|
||||
const angle = angleTo(start, target);
|
||||
return clampToArena([
|
||||
start[0] + Math.sin(angle) * CRAGCLAW.scuttleDistance,
|
||||
start[1] + Math.cos(angle) * CRAGCLAW.scuttleDistance,
|
||||
]);
|
||||
}
|
||||
|
||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
||||
const mechanicCount = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const targetId = chooseLivingTarget(context.party, SCUTTLE_TARGETS, motion.mechanicCount);
|
||||
const end = scuttleEnd(motion.position, context.partyPositions[targetId]);
|
||||
const lane: SlashLane = {
|
||||
id: `cragclaw-scuttle-${mechanicCount}`,
|
||||
start: [...motion.position],
|
||||
end,
|
||||
width: CRAGCLAW.scuttleWidth,
|
||||
damage: CRAGCLAW.scuttleDamage,
|
||||
};
|
||||
events.push({ at: context.time, message: `Cragclaw lines up Sidewinder Rush on ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
|
||||
return {
|
||||
...motion,
|
||||
mode: "crab_scuttle_telegraph" as const,
|
||||
chargeTargetId: targetId,
|
||||
chargeStart: [...motion.position] as WorldPosition,
|
||||
chargeEnd: end,
|
||||
chargeHitIds: [],
|
||||
slashLanes: [lane],
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + CRAGCLAW.scuttleWarning,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
};
|
||||
}
|
||||
|
||||
const activatesAt = context.time + CRAGCLAW.tidalWarning;
|
||||
const targetSet = TIDAL_TARGETS[Math.floor(motion.mechanicCount / 2) % TIDAL_TARGETS.length];
|
||||
events.push({ at: context.time, message: "Crushing Tide marks three allies. Spread before the claws close.", tone: "danger", pulseKind: "skyfall" });
|
||||
return {
|
||||
...motion,
|
||||
mode: "crab_tidal_burst" as const,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: activatesAt + 0.3,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
hazards: [
|
||||
...motion.hazards,
|
||||
...targetSet.map((targetId, index) => createCircleHazard({
|
||||
id: `cragclaw-tide-${mechanicCount}-${index}`,
|
||||
kind: "tidal_burst",
|
||||
center: context.partyPositions[targetId],
|
||||
radius: CRAGCLAW.tidalRadius,
|
||||
activatesAt,
|
||||
duration: 0.3,
|
||||
damage: CRAGCLAW.tidalDamage,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceCragclawMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.05 * context.delta);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "crab_scuttling",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CRAGCLAW.scuttleSpeed,
|
||||
};
|
||||
events.push({ at: context.time, message: "Sidewinder Rush! Clear the surf lane.", tone: "danger", pulseKind: "charge" });
|
||||
} else if (motion.mode === "crab_scuttling") {
|
||||
const previous = [...motion.position] as WorldPosition;
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, CRAGCLAW.scuttleSpeed * context.delta);
|
||||
party = party.map((member) => {
|
||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
|
||||
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CRAGCLAW.scuttleWidth * 0.5) return member;
|
||||
motion.chargeHitIds.push(member.id);
|
||||
events.push({ at: context.time, message: `${member.name} is crushed by Sidewinder Rush.`, tone: "danger", pulseKind: "charge", targetId: member.id });
|
||||
return { ...context.damageMember(member, CRAGCLAW.scuttleDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.42 };
|
||||
});
|
||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, position: [...motion.chargeEnd], mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
|
||||
}
|
||||
} else if (motion.mode === "crab_tidal_burst" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
|
||||
} else if (motion.mode === "crab_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CRAGCLAW.repeatDelay, slashLanes: [], chargeHitIds: [] };
|
||||
}
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 14, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingCragclawMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "crab_scuttle_telegraph" || motion.mode === "crab_scuttling") return { name: "Sidewinder Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.scuttleWarning, urgent: true };
|
||||
if (motion.mode === "crab_tidal_burst") return { name: "Crushing Tide — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.tidalWarning, urgent: true };
|
||||
if (motion.mode === "crab_recover") return { name: "Cragclaw exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Sidewinder Rush" : "Crushing Tide", remaining, cycle: CRAGCLAW.repeatDelay + CRAGCLAW.scuttleWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { moveToward } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CROWNSHARD = {
|
||||
firstAt: 5.5,
|
||||
repeatDelay: 4,
|
||||
shockwaveWarning: 1.2,
|
||||
shockwaveInterval: 0.65,
|
||||
shockwaveDamage: 19,
|
||||
crownfallWarning: 1.5,
|
||||
crownfallRadius: 1.85,
|
||||
crownfallDamage: 28,
|
||||
recoverDuration: 0.78,
|
||||
} as const;
|
||||
|
||||
const CROWNFALL_TARGETS: readonly (readonly MemberId[])[] = [
|
||||
["aelia", "nia", "orin"],
|
||||
["brann", "vale", "aelia"],
|
||||
["nia", "orin", "vale"],
|
||||
];
|
||||
|
||||
export function createCrownshardState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["crownshard-golem"];
|
||||
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.4);
|
||||
}
|
||||
|
||||
export function createCrownshardMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("crownshard-golem"), position: [0, -6.7], nextMechanicAt: CROWNSHARD.firstAt };
|
||||
}
|
||||
|
||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
||||
const mechanicCount = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const bands = [
|
||||
{ innerRadius: 0, radius: 2.35 },
|
||||
{ innerRadius: 2.35, radius: 4.7 },
|
||||
{ innerRadius: 4.7, radius: 7.05 },
|
||||
];
|
||||
const firstActivation = context.time + CROWNSHARD.shockwaveWarning;
|
||||
events.push({ at: context.time, message: "Tri-Burst expands in three rings. Move with each head's safe band.", tone: "danger", pulseKind: "boss" });
|
||||
return {
|
||||
...motion,
|
||||
mode: "golem_shockwave" as const,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: firstActivation + CROWNSHARD.shockwaveInterval * (bands.length - 1) + 0.32,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
hazards: [
|
||||
...motion.hazards,
|
||||
...bands.map((band, index) => createCircleHazard({
|
||||
id: `crownshard-shockwave-${mechanicCount}-${index}`,
|
||||
kind: "royal_shockwave",
|
||||
center: motion.position,
|
||||
innerRadius: band.innerRadius,
|
||||
radius: band.radius,
|
||||
activatesAt: firstActivation + index * CROWNSHARD.shockwaveInterval,
|
||||
duration: 0.3,
|
||||
damage: CROWNSHARD.shockwaveDamage,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const activatesAt = context.time + CROWNSHARD.crownfallWarning;
|
||||
const targetSet = CROWNFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % CROWNFALL_TARGETS.length];
|
||||
events.push({ at: context.time, message: "Ultimate Skyfall marks three allies. Break formation before impact.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
|
||||
return {
|
||||
...motion,
|
||||
mode: "golem_crownfall" as const,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: activatesAt + 0.32,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
hazards: [
|
||||
...motion.hazards,
|
||||
...targetSet.map((targetId, index) => createCircleHazard({
|
||||
id: `crownshard-fall-${mechanicCount}-${index}`,
|
||||
kind: "crownfall",
|
||||
center: context.partyPositions[targetId],
|
||||
radius: CROWNSHARD.crownfallRadius,
|
||||
activatesAt,
|
||||
duration: 0.3,
|
||||
damage: CROWNSHARD.crownfallDamage,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceCrownshardMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.55 * context.delta);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
|
||||
} else if (motion.mode === "golem_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CROWNSHARD.repeatDelay };
|
||||
}
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.3, 15, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingCrownshardMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "golem_shockwave") return { name: "Tri-Burst — follow rings", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.shockwaveWarning + CROWNSHARD.shockwaveInterval * 2, urgent: true };
|
||||
if (motion.mode === "golem_crownfall") return { name: "Ultimate Skyfall — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.crownfallWarning, urgent: true };
|
||||
if (motion.mode === "golem_recover") return { name: "Ultimate Dragon exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Tri-Burst" : "Ultimate Skyfall", remaining, cycle: CROWNSHARD.repeatDelay + CROWNSHARD.shockwaveWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -36,8 +36,8 @@ function context(
|
||||
};
|
||||
}
|
||||
|
||||
describe("Ember Mantis Duelist mechanics", () => {
|
||||
it("sidesteps, telegraphs Line Slash, then damages targets left in the lane", () => {
|
||||
describe("Gate Guardian mechanics", () => {
|
||||
it("sidesteps, telegraphs Elemental Beam, 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");
|
||||
@@ -52,7 +52,7 @@ describe("Ember Mantis Duelist mechanics", () => {
|
||||
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);
|
||||
expect(impact.events.some((event) => event.message.includes("Elemental Beam"))).toBe(true);
|
||||
});
|
||||
|
||||
it("alternates into two crossed slash lanes", () => {
|
||||
|
||||
@@ -141,7 +141,7 @@ function resolveSlash(
|
||||
hitIds.push(member.id);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `${member.name} is caught by ${isCrossSlash ? "Cross Slash" : "Line Slash"}.`,
|
||||
message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: member.id,
|
||||
@@ -171,7 +171,7 @@ export function advanceEmberMantisMechanics(context: BossMechanicContext): BossM
|
||||
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.`,
|
||||
message: `Gate Guardian shifts toward ${memberName(party, motion.chargeTargetId)}. Track its arms.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
@@ -184,7 +184,7 @@ export function advanceEmberMantisMechanics(context: BossMechanicContext): BossM
|
||||
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.",
|
||||
message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.",
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
@@ -225,21 +225,21 @@ export function upcomingEmberMantisMechanic(
|
||||
): 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 };
|
||||
return { name: "Guardian 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 };
|
||||
return { name: "Elemental Beam — 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 };
|
||||
return { name: "Guardian Cross — 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 };
|
||||
return { name: "Gate Guardian 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",
|
||||
name: nextIsCross ? "Guardian Cross" : "Elemental Beam",
|
||||
remaining,
|
||||
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
|
||||
urgent: remaining < 2.5,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "../data";
|
||||
import type { BossMotionState, BossState, WorldPosition } from "../types";
|
||||
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./cinderbackRicochet";
|
||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
|
||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
|
||||
import type { BossMechanicContext } from "./types";
|
||||
|
||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [0, 0],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
};
|
||||
|
||||
function context(
|
||||
boss: BossState,
|
||||
motion: BossMotionState,
|
||||
time: number,
|
||||
delta = 0.1,
|
||||
positions = POSITIONS,
|
||||
party = freshParty(),
|
||||
): BossMechanicContext {
|
||||
return {
|
||||
boss,
|
||||
motion,
|
||||
party,
|
||||
partyPositions: structuredClone(positions),
|
||||
time,
|
||||
delta,
|
||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("IWT2 boss trio mechanics", () => {
|
||||
it("telegraphs and resolves Obsidian Ram Plate Charge", () => {
|
||||
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
|
||||
expect(start.motion.mode).toBe("ram_charge_telegraph");
|
||||
expect(start.motion.slashLanes).toHaveLength(1);
|
||||
|
||||
const active = advanceObsidianRamMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
||||
expect(active.motion.mode).toBe("ram_charging");
|
||||
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
|
||||
const impact = advanceObsidianRamMechanics(context(active.boss, active.motion, active.motion.phaseStartedAt + 1, 1, POSITIONS, active.party));
|
||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - OBSIDIAN_RAM.chargeDamage);
|
||||
});
|
||||
|
||||
it("builds three Armor Shatter fault lanes", () => {
|
||||
const motion = { ...createObsidianRamMotion(), mechanicCount: 2, nextMechanicAt: 0 };
|
||||
const result = advanceObsidianRamMechanics(context(createObsidianRamState(), motion, 0));
|
||||
expect(result.motion.mode).toBe("ram_shatter");
|
||||
expect(result.motion.slashLanes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("executes both Cinderback rebounds and leaves lava at each impact", () => {
|
||||
const start = advanceCinderbackMechanics(context(createCinderbackState(), createCinderbackMotion(), CINDERBACK.firstAt));
|
||||
const firstRush = advanceCinderbackMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
||||
const firstImpact = advanceCinderbackMechanics(context(firstRush.boss, firstRush.motion, firstRush.motion.phaseStartedAt + 2, 2, POSITIONS, firstRush.party));
|
||||
expect(firstImpact.motion.mode).toBe("cinderback_ricochet");
|
||||
expect(firstImpact.motion.chargeCount).toBe(1);
|
||||
expect(firstImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(1);
|
||||
|
||||
const secondImpact = advanceCinderbackMechanics(context(firstImpact.boss, firstImpact.motion, firstImpact.motion.phaseEndsAt, 2, POSITIONS, firstImpact.party));
|
||||
expect(secondImpact.motion.mode).toBe("cinderback_recover");
|
||||
expect(secondImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("turns Sandglass stinger warnings into an active hourglass zone", () => {
|
||||
const motion = { ...createSandglassMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
||||
const start = advanceSandglassMechanics(context(createSandglassState(), motion, 0));
|
||||
expect(start.motion.mode).toBe("sandglass_eruption");
|
||||
expect(start.motion.hazards.filter((hazard) => hazard.kind === "stinger_eruption")).toHaveLength(3);
|
||||
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
|
||||
const aeliaBefore = start.party[0].hp;
|
||||
const eruption = advanceSandglassMechanics(context(start.boss, start.motion, SANDGLASS.eruptionWarning + 0.05, 0.1, positions, start.party));
|
||||
expect(eruption.party[0].hp).toBe(aeliaBefore - SANDGLASS.eruptionDamage);
|
||||
|
||||
const hourglass = advanceSandglassMechanics(context(eruption.boss, eruption.motion, eruption.motion.phaseEndsAt + 0.01, 0.1, POSITIONS, eruption.party));
|
||||
expect(hourglass.motion.mode).toBe("sandglass_hourglass");
|
||||
expect(hourglass.motion.hazards.some((hazard) => hazard.kind === "hourglass")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const MOURNVEIL = {
|
||||
firstAt: 5.3,
|
||||
repeatDelay: 3.9,
|
||||
crossWarning: 1.35,
|
||||
followupWarning: 1.05,
|
||||
laneWidth: 1.55,
|
||||
laneDamage: 22,
|
||||
riftWarning: 1.45,
|
||||
riftRadius: 1.75,
|
||||
riftDamage: 5,
|
||||
riftDuration: 4.2,
|
||||
recoverDuration: 0.75,
|
||||
} as const;
|
||||
|
||||
const RIFT_TARGETS: readonly (readonly MemberId[])[] = [
|
||||
["aelia", "nia"],
|
||||
["orin", "vale"],
|
||||
["brann", "aelia"],
|
||||
];
|
||||
|
||||
export function createMournveilState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["mournveil-ghost"];
|
||||
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.35);
|
||||
}
|
||||
|
||||
export function createMournveilMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("mournveil-ghost"), position: [0, -6.6], nextMechanicAt: MOURNVEIL.firstAt };
|
||||
}
|
||||
|
||||
function crossLanes(center: WorldPosition, angle: number, mechanicCount: number, phase: number): SlashLane[] {
|
||||
return [angle, angle + Math.PI / 2].map((laneAngle, index) => {
|
||||
const dx = Math.sin(laneAngle) * 9;
|
||||
const dz = Math.cos(laneAngle) * 9;
|
||||
return {
|
||||
id: `mournveil-cross-${mechanicCount}-${phase}-${index}`,
|
||||
start: [center[0] - dx, center[1] - dz],
|
||||
end: [center[0] + dx, center[1] + dz],
|
||||
width: MOURNVEIL.laneWidth,
|
||||
damage: MOURNVEIL.laneDamage,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCross(motion: BossMotionState, context: BossMechanicContext, party: BossMechanicContext["party"], events: BossMechanicResult["events"]) {
|
||||
const hitIds: MemberId[] = [];
|
||||
const nextParty = party.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const hit = motion.slashLanes.some((lane) => pointToSegmentDistance(context.partyPositions[member.id], lane.start, lane.end) <= lane.width * 0.5);
|
||||
if (!hit) return member;
|
||||
hitIds.push(member.id);
|
||||
events.push({ at: context.time, message: `${member.name} is cut by Vine Scissors.`, tone: "danger", pulseKind: "slash", targetId: member.id });
|
||||
return context.damageMember(member, MOURNVEIL.laneDamage, context.partyPositions[member.id], context.time);
|
||||
});
|
||||
return { party: nextParty, hitIds };
|
||||
}
|
||||
|
||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
||||
const mechanicCount = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const targetId = (["aelia", "nia", "orin", "vale", "brann"] as const)[motion.mechanicCount % 5];
|
||||
const angle = angleTo(motion.position, context.partyPositions[targetId]);
|
||||
events.push({ at: context.time, message: "Vine Scissors carve a spectral cross. A second cut will rotate.", tone: "danger", pulseKind: "slash", targetId });
|
||||
return {
|
||||
...motion,
|
||||
mode: "ghost_soul_cross" as const,
|
||||
breathStartAngle: angle,
|
||||
chargeCount: 0,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + MOURNVEIL.crossWarning,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
mechanicHitIds: [],
|
||||
slashLanes: crossLanes(motion.position, angle, mechanicCount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
const activatesAt = context.time + MOURNVEIL.riftWarning;
|
||||
const targetSet = RIFT_TARGETS[Math.floor(motion.mechanicCount / 2) % RIFT_TARGETS.length];
|
||||
events.push({ at: context.time, message: "Haunting Rifts follow two allies. Carry them away from formation.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
|
||||
return {
|
||||
...motion,
|
||||
mode: "ghost_haunting" as const,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: activatesAt + 0.35,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
hazards: [
|
||||
...motion.hazards,
|
||||
...targetSet.map((targetId, index) => createCircleHazard({
|
||||
id: `mournveil-rift-${mechanicCount}-${index}`,
|
||||
kind: "soul_rift",
|
||||
center: context.partyPositions[targetId],
|
||||
radius: MOURNVEIL.riftRadius,
|
||||
activatesAt,
|
||||
duration: MOURNVEIL.riftDuration,
|
||||
damage: MOURNVEIL.riftDamage,
|
||||
tickInterval: 0.8,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceMournveilMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.7 * context.delta);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
|
||||
const resolved = resolveCross(motion, { ...context, party }, party, events);
|
||||
party = resolved.party;
|
||||
const followupAngle = motion.breathStartAngle + Math.PI / 4;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "ghost_soul_cross_followup",
|
||||
chargeCount: 1,
|
||||
mechanicHitIds: resolved.hitIds,
|
||||
slashLanes: crossLanes(motion.position, followupAngle, motion.mechanicCount, 1),
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + MOURNVEIL.followupWarning,
|
||||
};
|
||||
events.push({ at: context.time, message: "Vine Scissors rotate. Find the new safe quadrant.", tone: "danger", pulseKind: "slash" });
|
||||
} else if (motion.mode === "ghost_soul_cross_followup" && context.time >= motion.phaseEndsAt) {
|
||||
const resolved = resolveCross(motion, { ...context, party }, party, events);
|
||||
party = resolved.party;
|
||||
motion = { ...motion, mode: "ghost_recover", mechanicHitIds: [...new Set([...motion.mechanicHitIds, ...resolved.hitIds])], phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
|
||||
} else if (motion.mode === "ghost_haunting" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "ghost_recover", phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
|
||||
} else if (motion.mode === "ghost_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + MOURNVEIL.repeatDelay, slashLanes: [], mechanicHitIds: [] };
|
||||
}
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.25, 14, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingMournveilMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
|
||||
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
|
||||
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
|
||||
if (motion.mode === "ghost_recover") return { name: "Pumpking exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const OBSIDIAN_RAM = {
|
||||
firstAt: 5.5,
|
||||
repeatDelay: 3.8,
|
||||
chargeWarning: 1.45,
|
||||
chargeSpeed: 11.5,
|
||||
chargeDistance: 14,
|
||||
chargeWidth: 2.5,
|
||||
chargeDamage: 27,
|
||||
quakeWarning: 1.35,
|
||||
quakeRadius: 3.6,
|
||||
quakeDamage: 31,
|
||||
shatterWarning: 1.2,
|
||||
shatterWidth: 1.25,
|
||||
shatterDamage: 24,
|
||||
recoverDuration: 0.7,
|
||||
} as const;
|
||||
|
||||
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
|
||||
|
||||
export function createObsidianRamState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["obsidian-ram-golem"];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: 2.3,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createObsidianRamMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("obsidian-ram-golem"), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
|
||||
}
|
||||
|
||||
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
|
||||
const half = 9;
|
||||
const dx = Math.sin(angle) * half;
|
||||
const dz = Math.cos(angle) * half;
|
||||
return {
|
||||
id,
|
||||
start: [center[0] - dx, center[1] - dz],
|
||||
end: [center[0] + dx, center[1] + dz],
|
||||
width: OBSIDIAN_RAM.shatterWidth,
|
||||
damage: OBSIDIAN_RAM.shatterDamage,
|
||||
};
|
||||
}
|
||||
|
||||
function endpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
|
||||
const angle = angleTo(start, target);
|
||||
return clampToArena([
|
||||
start[0] + Math.sin(angle) * OBSIDIAN_RAM.chargeDistance,
|
||||
start[1] + Math.cos(angle) * OBSIDIAN_RAM.chargeDistance,
|
||||
]);
|
||||
}
|
||||
|
||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
||||
const index = motion.mechanicCount % 3;
|
||||
const mechanicCount = motion.mechanicCount + 1;
|
||||
if (index === 0) {
|
||||
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
|
||||
const end = endpoint(motion.position, context.partyPositions[targetId]);
|
||||
events.push({ at: context.time, message: `Destruction Rush locks onto ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
|
||||
return {
|
||||
...motion,
|
||||
mode: "ram_charge_telegraph" as const,
|
||||
chargeTargetId: targetId,
|
||||
chargeStart: [...motion.position] as WorldPosition,
|
||||
chargeEnd: end,
|
||||
chargeHitIds: [],
|
||||
slashLanes: [{ id: `ram-charge-${mechanicCount}`, start: [...motion.position] as WorldPosition, end, width: OBSIDIAN_RAM.chargeWidth, damage: OBSIDIAN_RAM.chargeDamage }],
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + OBSIDIAN_RAM.chargeWarning,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
};
|
||||
}
|
||||
if (index === 1) {
|
||||
const activatesAt = context.time + OBSIDIAN_RAM.quakeWarning;
|
||||
events.push({ at: context.time, message: "Ruin Quake! Leave the destruction circle.", tone: "danger", pulseKind: "boss" });
|
||||
return {
|
||||
...motion,
|
||||
mode: "ram_quake" as const,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: activatesAt + 0.25,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
hazards: [...motion.hazards, {
|
||||
id: `ram-quake-${mechanicCount}`,
|
||||
kind: "quake" as const,
|
||||
center: [...motion.position] as WorldPosition,
|
||||
radius: OBSIDIAN_RAM.quakeRadius,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + 0.3,
|
||||
damage: OBSIDIAN_RAM.quakeDamage,
|
||||
nextDamageAt: {},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
|
||||
const aimed = angleTo(motion.position, context.partyPositions[targetId]);
|
||||
events.push({ at: context.time, message: "Destruction Pulse! Step between the radial beams.", tone: "danger", pulseKind: "slash", targetId });
|
||||
return {
|
||||
...motion,
|
||||
mode: "ram_shatter" as const,
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + OBSIDIAN_RAM.shatterWarning,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount,
|
||||
mechanicHitIds: [],
|
||||
slashLanes: [0, Math.PI / 3, -Math.PI / 3].map((offset, laneIndex) => laneAt(motion.position, aimed + offset, `ram-shatter-${mechanicCount}-${laneIndex}`)),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveShatter(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
||||
const hitIds: MemberId[] = [];
|
||||
const party = context.party.map((member) => {
|
||||
const lane = motion.slashLanes.find((entry) => pointToSegmentDistance(context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
|
||||
if (member.hp <= 0 || !lane) return member;
|
||||
hitIds.push(member.id);
|
||||
events.push({ at: context.time, message: `${member.name} is struck by Destruction Pulse.`, tone: "danger", pulseKind: "slash", targetId: member.id });
|
||||
return context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time);
|
||||
});
|
||||
return { party, hitIds };
|
||||
}
|
||||
|
||||
export function advanceObsidianRamMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * context.delta);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
|
||||
events.push({ at: context.time, message: "Destruction Rush! Clear the lane.", tone: "danger", pulseKind: "charge" });
|
||||
} else if (motion.mode === "ram_charging") {
|
||||
const previous = [...motion.position] as WorldPosition;
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, OBSIDIAN_RAM.chargeSpeed * context.delta);
|
||||
party = party.map((member) => {
|
||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > OBSIDIAN_RAM.chargeWidth * 0.5) return member;
|
||||
motion.chargeHitIds.push(member.id);
|
||||
return { ...context.damageMember(member, OBSIDIAN_RAM.chargeDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.55 };
|
||||
});
|
||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
|
||||
} else if (motion.mode === "ram_shatter" && context.time >= motion.phaseEndsAt) {
|
||||
const resolved = resolveShatter(motion, { ...context, party }, events);
|
||||
party = resolved.party;
|
||||
motion = { ...motion, mode: "ram_recover", mechanicHitIds: resolved.hitIds, phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
|
||||
} else if (motion.mode === "ram_quake" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
|
||||
} else if (motion.mode === "ram_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + OBSIDIAN_RAM.repeatDelay, slashLanes: [], chargeHitIds: [], mechanicHitIds: [] };
|
||||
}
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 15, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingObsidianRamMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
|
||||
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
|
||||
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
|
||||
if (motion.mode === "ram_recover") return { name: "Gandora exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
|
||||
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const SANDGLASS = {
|
||||
firstAt: 5.4,
|
||||
repeatDelay: 3.8,
|
||||
burrowWarning: 1.3,
|
||||
burrowSpeed: 10.8,
|
||||
burrowDistance: 13,
|
||||
burrowWidth: 2,
|
||||
burrowDamage: 25,
|
||||
eruptionWarning: 1.55,
|
||||
eruptionRadius: 1.75,
|
||||
eruptionDamage: 27,
|
||||
hourglassRadius: 2.55,
|
||||
hourglassDamage: 6,
|
||||
hourglassDuration: 3.6,
|
||||
recoverDuration: 0.7,
|
||||
} as const;
|
||||
|
||||
const TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
|
||||
const ERUPTION_TARGETS: readonly MemberId[][] = [["aelia", "nia", "orin"], ["brann", "vale", "aelia"], ["nia", "orin", "vale"]];
|
||||
|
||||
export function createSandglassState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["sandglass-scorpion"];
|
||||
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.2, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
|
||||
}
|
||||
|
||||
export function createSandglassMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("sandglass-scorpion"), position: [0, -6.5], nextMechanicAt: SANDGLASS.firstAt };
|
||||
}
|
||||
|
||||
function burrowEnd(start: WorldPosition, target: WorldPosition) {
|
||||
const angle = angleTo(start, target);
|
||||
return clampToArena([start[0] + Math.sin(angle) * SANDGLASS.burrowDistance, start[1] + Math.cos(angle) * SANDGLASS.burrowDistance] as WorldPosition);
|
||||
}
|
||||
|
||||
function eruption(id: string, center: WorldPosition, activatesAt: number): CircleHazard {
|
||||
return { id, kind: "stinger_eruption", center: [...center], radius: SANDGLASS.eruptionRadius, activatesAt, expiresAt: activatesAt + 0.32, damage: SANDGLASS.eruptionDamage, nextDamageAt: {}, resolved: false, hitIds: [] };
|
||||
}
|
||||
|
||||
export function advanceSandglassMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
const count = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
|
||||
const end = burrowEnd(motion.position, context.partyPositions[targetId]);
|
||||
const lane: SlashLane = { id: `burrow-${count}`, start: [...motion.position], end, width: SANDGLASS.burrowWidth, damage: SANDGLASS.burrowDamage };
|
||||
motion = { ...motion, mode: "sandglass_burrow_telegraph", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], phaseStartedAt: context.time, phaseEndsAt: context.time + SANDGLASS.burrowWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [lane] };
|
||||
events.push({ at: context.time, message: `Burrow Rush tracks ${memberName(party, targetId)}. Cross the sand trail.`, tone: "danger", pulseKind: "charge", targetId });
|
||||
} else {
|
||||
const activatesAt = context.time + SANDGLASS.eruptionWarning;
|
||||
const targets = ERUPTION_TARGETS[Math.floor(motion.mechanicCount / 2) % ERUPTION_TARGETS.length];
|
||||
motion = { ...motion, mode: "sandglass_eruption", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.3, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, ...targets.map((targetId, index) => eruption(`stinger-${count}-${index}`, context.partyPositions[targetId], activatesAt))] };
|
||||
events.push({ at: context.time, message: "Stinger Eruption! Leave the timed sand circles.", tone: "danger", pulseKind: "skyfall" });
|
||||
}
|
||||
}
|
||||
} else if (motion.mode === "sandglass_burrow_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "sandglass_burrowing", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / SANDGLASS.burrowSpeed };
|
||||
} else if (motion.mode === "sandglass_burrowing") {
|
||||
const previous = [...motion.position] as WorldPosition;
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, SANDGLASS.burrowSpeed * context.delta);
|
||||
party = party.map((member) => {
|
||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > SANDGLASS.burrowWidth * 0.5) return member;
|
||||
motion.chargeHitIds.push(member.id);
|
||||
return { ...context.damageMember(member, SANDGLASS.burrowDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.35 };
|
||||
});
|
||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
|
||||
} else if (motion.mode === "sandglass_eruption" && context.time >= motion.phaseEndsAt) {
|
||||
const center = clampToArena([motion.position[0], motion.position[1] + 3]);
|
||||
const activatesAt = context.time + 0.9;
|
||||
motion = { ...motion, mode: "sandglass_hourglass", phaseStartedAt: context.time, phaseEndsAt: activatesAt + SANDGLASS.hourglassDuration, hazards: [...motion.hazards, { id: `hourglass-${motion.mechanicCount}`, kind: "hourglass", center, radius: SANDGLASS.hourglassRadius, activatesAt, expiresAt: activatesAt + SANDGLASS.hourglassDuration, damage: SANDGLASS.hourglassDamage, tickInterval: 0.75, nextDamageAt: {}, resolved: false, hitIds: [] }] };
|
||||
events.push({ at: context.time, message: "Hourglass zone turns active. Keep moving.", tone: "danger", pulseKind: "skyfall" });
|
||||
} else if (motion.mode === "sandglass_hourglass" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
|
||||
} else if (motion.mode === "sandglass_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + SANDGLASS.repeatDelay, slashLanes: [], chargeHitIds: [] };
|
||||
}
|
||||
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.15, 14, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingSandglassMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "sandglass_burrow_telegraph" || motion.mode === "sandglass_burrowing") return { name: "Burrow Rush — clear trail", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.burrowWarning, urgent: true };
|
||||
if (motion.mode === "sandglass_eruption") return { name: "Stinger Eruption — move", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.eruptionWarning, urgent: true };
|
||||
if (motion.mode === "sandglass_hourglass") return { name: "Hourglass zone active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.hourglassDuration, urgent: true };
|
||||
if (motion.mode === "sandglass_recover") return { name: "Chronarch exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Burrow Rush" : "Hourglass Eruption", remaining, cycle: SANDGLASS.repeatDelay + SANDGLASS.burrowWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -1,7 +1,71 @@
|
||||
import { distance } from "../geometry";
|
||||
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "../types";
|
||||
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, WorldPosition } from "../types";
|
||||
import type { BossMechanicContext, BossMechanicEvent } from "./types";
|
||||
|
||||
const PERSISTENT_HAZARD_KINDS = new Set<CircleHazardKind>(["venom_pool", "lava_pool", "hourglass", "soul_rift"]);
|
||||
const HAZARD_LABELS = {
|
||||
venom_pool: "Venom pool",
|
||||
skyfall: "Skyfall",
|
||||
quake: "Fracture Quake",
|
||||
lava_pool: "Lava pool",
|
||||
stinger_eruption: "Stinger Eruption",
|
||||
hourglass: "Hourglass zone",
|
||||
tidal_burst: "Crushing Tide",
|
||||
soul_rift: "Soul rift",
|
||||
crownfall: "Crownfall",
|
||||
royal_shockwave: "Royal Shockwave",
|
||||
} as const;
|
||||
|
||||
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
|
||||
return {
|
||||
id: bossId,
|
||||
name,
|
||||
maxHp,
|
||||
hp: maxHp,
|
||||
nextMeleeAt,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCircleHazard({
|
||||
id,
|
||||
kind,
|
||||
center,
|
||||
innerRadius,
|
||||
radius,
|
||||
activatesAt,
|
||||
duration,
|
||||
damage,
|
||||
tickInterval,
|
||||
}: {
|
||||
id: string;
|
||||
kind: CircleHazardKind;
|
||||
center: WorldPosition;
|
||||
innerRadius?: number;
|
||||
radius: number;
|
||||
activatesAt: number;
|
||||
duration: number;
|
||||
damage: number;
|
||||
tickInterval?: number;
|
||||
}): CircleHazard {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
center: [...center],
|
||||
innerRadius,
|
||||
radius,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + duration,
|
||||
damage,
|
||||
tickInterval,
|
||||
nextDamageAt: {},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||
return {
|
||||
bossId,
|
||||
@@ -106,7 +170,8 @@ export function resolveCircleHazards(
|
||||
const newlyHit: MemberId[] = [];
|
||||
updatedParty = updatedParty.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const inside = distance(positions[member.id], hazard.center) <= hazard.radius;
|
||||
const hazardDistance = distance(positions[member.id], hazard.center);
|
||||
const inside = hazardDistance <= hazard.radius && hazardDistance >= (hazard.innerRadius ?? 0);
|
||||
if (!inside) {
|
||||
delete hazard.nextDamageAt[member.id];
|
||||
return member;
|
||||
@@ -115,26 +180,26 @@ export function resolveCircleHazards(
|
||||
hazard.hitIds.push(member.id);
|
||||
newlyHit.push(member.id);
|
||||
}
|
||||
if (hazard.kind !== "venom_pool") {
|
||||
if (!PERSISTENT_HAZARD_KINDS.has(hazard.kind)) {
|
||||
return hazard.resolved || hazard.hitIds.includes(member.id) && !newlyHit.includes(member.id)
|
||||
? member
|
||||
: damageMember(member, hazard.damage, positions[member.id], time);
|
||||
: damageMember(member, hazard.damage, positions[member.id], time, "hazard");
|
||||
}
|
||||
|
||||
let next = member;
|
||||
let tickAt = hazard.nextDamageAt[member.id] ?? time;
|
||||
while (tickAt <= time + 0.001) {
|
||||
next = damageMember(next, hazard.damage, positions[member.id], tickAt);
|
||||
next = damageMember(next, hazard.damage, positions[member.id], tickAt, "hazard");
|
||||
tickAt += hazard.tickInterval ?? 1;
|
||||
}
|
||||
hazard.nextDamageAt[member.id] = tickAt;
|
||||
return next;
|
||||
});
|
||||
if (newlyHit.length && !hazard.resolved) {
|
||||
const label = hazard.kind === "skyfall" ? "Skyfall" : "Venom pool";
|
||||
events.push({ at: time, message: `${label} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind: hazard.kind === "skyfall" ? "skyfall" : "venom" });
|
||||
const pulseKind = hazard.kind === "venom_pool" ? "venom" : hazard.kind === "skyfall" || hazard.kind === "stinger_eruption" || hazard.kind === "hourglass" ? "skyfall" : "boss";
|
||||
events.push({ at: time, message: `${HAZARD_LABELS[hazard.kind]} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind });
|
||||
}
|
||||
if (newlyHit.length || hazard.kind === "skyfall" && time >= hazard.activatesAt) hazard.resolved = true;
|
||||
if (!PERSISTENT_HAZARD_KINDS.has(hazard.kind) && (newlyHit.length || time >= hazard.activatesAt)) hazard.resolved = true;
|
||||
}
|
||||
motion.hazards = motion.hazards.filter((hazard) => hazard.expiresAt > time);
|
||||
return updatedParty;
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface BossMechanicContext {
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
delta: number;
|
||||
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number) => PartyMember;
|
||||
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
|
||||
}
|
||||
|
||||
export interface UpcomingMechanic {
|
||||
|
||||
@@ -78,7 +78,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
|
||||
tetherBreakDistance: VEXA_TETHER.breakDistance,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
};
|
||||
events.push({ at: context.time, message: `Vexa binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
|
||||
events.push({ at: context.time, message: `Insect Queen binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
|
||||
}
|
||||
} else {
|
||||
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
|
||||
@@ -103,7 +103,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
};
|
||||
events.push({ at: context.time, message: "Vexa injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
||||
events.push({ at: context.time, message: "Insect Queen injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
||||
}
|
||||
} else if (motion.mode === "tethering") {
|
||||
const [first, second] = motion.tetherIds;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import { freshParty } from "./data";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import type { MemberId, WorldPosition } from "./types";
|
||||
|
||||
describe("party boss positioning", () => {
|
||||
it("places Brann in front of the boss and Vale behind it", () => {
|
||||
const formation = combatFormation([2, -1]);
|
||||
|
||||
expect(formation.brann).toEqual([2, 3.25]);
|
||||
expect(formation.vale).toEqual([2, -2.7]);
|
||||
});
|
||||
|
||||
it("moves the tank toward the front and the rogue toward the rear during uptime", () => {
|
||||
const sharedStart: WorldPosition = [0, 0];
|
||||
const positions: Record<MemberId, WorldPosition> = {
|
||||
aelia: [0, 4],
|
||||
brann: [...sharedStart],
|
||||
nia: [-3, 4],
|
||||
orin: [3, 4],
|
||||
vale: [...sharedStart],
|
||||
};
|
||||
const motion = { ...createBaseMotion("bulldrome"), position: [0, -1] as WorldPosition };
|
||||
|
||||
const next = updatePartyPositions(positions, motion, freshParty(), 1, 0.5);
|
||||
|
||||
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
|
||||
expect(next.vale[1]).toBeLessThan(sharedStart[1]);
|
||||
});
|
||||
});
|
||||
+43
-23
@@ -1,6 +1,6 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { clampToArena } from "./arena";
|
||||
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";
|
||||
|
||||
@@ -34,25 +34,45 @@ const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||
orin: [0.45, -0.4],
|
||||
vale: [0, 0.65],
|
||||
};
|
||||
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
|
||||
"mantis_line_telegraph",
|
||||
"mantis_cross_telegraph",
|
||||
"ram_charge_telegraph",
|
||||
"ram_charging",
|
||||
"ram_shatter",
|
||||
"cinderback_curl",
|
||||
"cinderback_ricochet",
|
||||
"sandglass_burrow_telegraph",
|
||||
"sandglass_burrowing",
|
||||
"crab_scuttle_telegraph",
|
||||
"crab_scuttling",
|
||||
"ghost_soul_cross",
|
||||
"ghost_soul_cross_followup",
|
||||
];
|
||||
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
|
||||
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover",
|
||||
"ram_quake", "ram_recover", "cinderback_slam", "cinderback_recover", "sandglass_eruption", "sandglass_hourglass", "sandglass_recover",
|
||||
"crab_tidal_burst", "crab_recover", "ghost_haunting", "ghost_recover", "golem_shockwave", "golem_crownfall", "golem_recover",
|
||||
];
|
||||
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging", "ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"];
|
||||
|
||||
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
|
||||
const FORMATION_SLOTS: Record<AiMemberId, WorldPosition> = {
|
||||
// Bosses face Brann during normal uptime, making positive Z their front.
|
||||
brann: [0, 4.25],
|
||||
nia: [-3.3, 7.2],
|
||||
orin: [3.3, 7.2],
|
||||
vale: [0, -1.7],
|
||||
};
|
||||
|
||||
export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPosition> {
|
||||
return {
|
||||
brann: [boss[0], boss[1] + 4.25],
|
||||
nia: [boss[0] - 3.3, boss[1] + 7.2],
|
||||
orin: [boss[0] + 3.3, boss[1] + 7.2],
|
||||
vale: [boss[0] + 1.75, boss[1] + 3.4],
|
||||
brann: [boss[0] + FORMATION_SLOTS.brann[0], boss[1] + FORMATION_SLOTS.brann[1]],
|
||||
nia: [boss[0] + FORMATION_SLOTS.nia[0], boss[1] + FORMATION_SLOTS.nia[1]],
|
||||
orin: [boss[0] + FORMATION_SLOTS.orin[0], boss[1] + FORMATION_SLOTS.orin[1]],
|
||||
vale: [boss[0] + FORMATION_SLOTS.vale[0], boss[1] + FORMATION_SLOTS.vale[1]],
|
||||
};
|
||||
}
|
||||
|
||||
function clampToArena(position: WorldPosition): WorldPosition {
|
||||
return [
|
||||
Math.max(ARENA_BOUNDS.minX, Math.min(ARENA_BOUNDS.maxX, position[0])),
|
||||
Math.max(ARENA_BOUNDS.minZ, Math.min(ARENA_BOUNDS.maxZ, position[1])),
|
||||
];
|
||||
}
|
||||
|
||||
export const stackForPounceBehavior: PartyBehavior = {
|
||||
id: "stack-for-pounce",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
@@ -126,10 +146,10 @@ 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 (!LANE_EVADE_MODES.includes(bossMotion.mode)) 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,
|
||||
pointToSegmentDistance(position, lane.start, lane.end) < lane.width * 0.5 + 0.7,
|
||||
);
|
||||
if (!unsafe(current) && !unsafe(formationTarget)) return null;
|
||||
|
||||
@@ -155,7 +175,7 @@ export const evadeSlashLanesBehavior: PartyBehavior = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { target: best, speed: EMBER_MANTIS_SLASH.aiEvadeSpeed };
|
||||
return { target: best, speed: 4.8 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -180,7 +200,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover"].includes(bossMotion.mode)) return null;
|
||||
if (!FORMATION_MODES.includes(bossMotion.mode)) return null;
|
||||
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
|
||||
},
|
||||
};
|
||||
@@ -202,6 +222,7 @@ export function updatePartyPositions(
|
||||
time: number,
|
||||
delta: number,
|
||||
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
|
||||
moveSpeedMultipliers?: Partial<Record<AiMemberId, number>>,
|
||||
) {
|
||||
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
|
||||
const activeMotions = bossMotions;
|
||||
@@ -214,13 +235,10 @@ export function updatePartyPositions(
|
||||
};
|
||||
if (!activeMotions.length) return next;
|
||||
const formationOrigin: WorldPosition = [
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
];
|
||||
const formation = combatFormation(formationOrigin);
|
||||
// Vale fights from the boss-cluster midpoint so short-range cleaves can
|
||||
// connect with both targets when their hit volumes overlap.
|
||||
formation.vale = [formationOrigin[0], formationOrigin[1] + 1.7];
|
||||
|
||||
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
|
||||
const memberId = AI_MEMBER_IDS[index];
|
||||
@@ -238,12 +256,14 @@ export function updatePartyPositions(
|
||||
time,
|
||||
});
|
||||
if (!decision) continue;
|
||||
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
|
||||
next[memberId] = clampToArena(moveToward(next[memberId], decision.target, decision.speed * (moveSpeedMultipliers?.[memberId] ?? 1) * delta));
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) break;
|
||||
}
|
||||
}
|
||||
for (const memberId of AI_MEMBER_IDS) next[memberId] = clampToArena(next[memberId]);
|
||||
next.aelia = clampToArena(next.aelia);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||
import { BOSS_ORDER } from "./bossCatalog";
|
||||
import { freshParty } from "./data";
|
||||
import {
|
||||
advancePartyCombat,
|
||||
@@ -141,14 +142,9 @@ describe("party ability combat", () => {
|
||||
});
|
||||
|
||||
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"],
|
||||
];
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
|
||||
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
|
||||
);
|
||||
|
||||
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
|
||||
const result = simulate([first, second]);
|
||||
|
||||
+11
-1
@@ -110,6 +110,7 @@ export interface PartyCombatContext {
|
||||
positions: Record<MemberId, WorldPosition>;
|
||||
targets: PartyCombatTarget[];
|
||||
upcomingMechanicRemaining: number;
|
||||
gearModifiers?: Record<AiCombatantId, { damage: number; cooldown: number }>;
|
||||
}
|
||||
|
||||
interface AbilitySpec {
|
||||
@@ -377,7 +378,16 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
|
||||
|
||||
const startAt = Math.max(context.oldTime, actor.readyAt);
|
||||
if (startAt > context.time + 0.001) break;
|
||||
const spec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
|
||||
const baseSpec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
|
||||
const modifier = context.gearModifiers?.[id];
|
||||
const spec = baseSpec && modifier ? {
|
||||
...baseSpec,
|
||||
duration: baseSpec.duration * modifier.cooldown,
|
||||
impactOffsets: baseSpec.impactOffsets.map((offset) => offset * modifier.cooldown),
|
||||
damage: baseSpec.damage * modifier.damage,
|
||||
gcd: baseSpec.gcd * modifier.cooldown,
|
||||
cooldown: baseSpec.cooldown === undefined ? undefined : baseSpec.cooldown * modifier.cooldown,
|
||||
} : baseSpec;
|
||||
if (!spec) { actor.readyAt = context.time + 0.1; break; }
|
||||
const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
|
||||
const target = targetFor(id, { ...context, targets }, range);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
describe("runtime performance budgets", () => {
|
||||
it("keeps ten minutes of dual-boss simulation bounded", () => {
|
||||
const store = useGameStore.getState();
|
||||
store.configureHealer("priest", "Perf", [], ["cinderback-ricochet", "sandglass-scorpion"]);
|
||||
store.startEncounter();
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 },
|
||||
additionalBosses: state.additionalBosses.map((entry) => ({
|
||||
...entry,
|
||||
boss: { ...entry.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 },
|
||||
})),
|
||||
party: state.party.map((member) => ({ ...member, absorb: 1_000_000_000 })),
|
||||
}));
|
||||
|
||||
let maxHazards = 0;
|
||||
let maxDamageEvents = 0;
|
||||
let maxCombatLog = 0;
|
||||
const startedAt = performance.now();
|
||||
for (let step = 0; step < 6_000; step += 1) {
|
||||
useGameStore.getState().tick(0.1);
|
||||
const state = useGameStore.getState();
|
||||
const hazards = state.bossMotion.hazards.length
|
||||
+ state.additionalBosses.reduce((sum, entry) => sum + entry.motion.hazards.length, 0);
|
||||
maxHazards = Math.max(maxHazards, hazards);
|
||||
maxDamageEvents = Math.max(maxDamageEvents, state.partyDamageEvents.length);
|
||||
maxCombatLog = Math.max(maxCombatLog, state.combatLog.length);
|
||||
}
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
const result = useGameStore.getState();
|
||||
|
||||
expect(result.phase).toBe("combat");
|
||||
expect(result.time).toBeCloseTo(600, 5);
|
||||
expect(maxHazards).toBeLessThanOrEqual(64);
|
||||
expect(maxDamageEvents).toBeLessThanOrEqual(24);
|
||||
expect(maxCombatLog).toBeLessThanOrEqual(12);
|
||||
expect(elapsedMs).toBeLessThan(3_500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export const PERFORMANCE_PROBE_ENABLED = typeof window !== "undefined"
|
||||
&& new URLSearchParams(window.location.search).has("perf");
|
||||
|
||||
const MAX_TICK_SAMPLES = 300;
|
||||
const simulationTickSamples: number[] = [];
|
||||
|
||||
export function recordSimulationTick(durationMs: number) {
|
||||
if (!PERFORMANCE_PROBE_ENABLED) return;
|
||||
if (simulationTickSamples.length === MAX_TICK_SAMPLES) simulationTickSamples.shift();
|
||||
simulationTickSamples.push(durationMs);
|
||||
}
|
||||
|
||||
export function simulationTickSnapshot() {
|
||||
if (!simulationTickSamples.length) return { averageMs: 0, maximumMs: 0, samples: 0 };
|
||||
let total = 0;
|
||||
let maximumMs = 0;
|
||||
for (const duration of simulationTickSamples) {
|
||||
total += duration;
|
||||
if (duration > maximumMs) maximumMs = duration;
|
||||
}
|
||||
return {
|
||||
averageMs: total / simulationTickSamples.length,
|
||||
maximumMs,
|
||||
samples: simulationTickSamples.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEncounterGearModifiers } from "./gearEffects";
|
||||
import {
|
||||
GEAR_RECIPES,
|
||||
canAffordGearUpgrade,
|
||||
createDefaultGearProgress,
|
||||
gearUpgradeCosts,
|
||||
upgradeGearSlot,
|
||||
} from "./gear";
|
||||
import { bossCoinDrop, type MaterialStack } from "./loot";
|
||||
|
||||
describe("IWT2-style gear progression", () => {
|
||||
it("uses exact rank-one and rank-two boss coin costs", () => {
|
||||
const rankOne = gearUpgradeCosts("priest", "weapon", 0);
|
||||
const rankTwo = gearUpgradeCosts("priest", "weapon", 1);
|
||||
expect(rankOne.map((cost) => cost.quantity)).toEqual([2]);
|
||||
expect(rankTwo.map((cost) => cost.quantity)).toEqual([3, 1]);
|
||||
});
|
||||
|
||||
it("preserves IWT2 Veteran coin parity for ranks six through ten", () => {
|
||||
const recipe = GEAR_RECIPES.nia.weapon;
|
||||
const costs = gearUpgradeCosts("nia", "weapon", 5);
|
||||
expect(costs[0].itemId).toBe(bossCoinDrop(recipe.primaryBossId, "veteran").id);
|
||||
expect(costs.map((cost) => cost.quantity)).toEqual([6, 5]);
|
||||
});
|
||||
|
||||
it("spends all costs atomically and advances one rank", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
const costs = gearUpgradeCosts("brann", "weapon", 0);
|
||||
const coin = bossCoinDrop(GEAR_RECIPES.brann.weapon.primaryBossId, "initiate");
|
||||
const inventory: MaterialStack[] = [{ id: coin.id, name: coin.name, quantity: 3, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
|
||||
expect(canAffordGearUpgrade(inventory, costs)).toBe(true);
|
||||
const result = upgradeGearSlot(progress, inventory, "brann", "weapon");
|
||||
expect(result.gearProgress.brann.slots.weapon.level).toBe(1);
|
||||
expect(result.inventory[0].quantity).toBe(1);
|
||||
expect(progress.brann.slots.weapon.level).toBe(0);
|
||||
});
|
||||
|
||||
it("projects rank bonuses without mutating saved progress", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
progress.priest.slots.weapon.level = 10;
|
||||
progress.priest.slots.chest.level = 10;
|
||||
progress.brann.slots.weapon.level = 10;
|
||||
progress.brann.slots.helmet.level = 10;
|
||||
const modifiers = createEncounterGearModifiers(progress, "priest");
|
||||
expect(modifiers.aelia.healingPower).toBeCloseTo(1.4);
|
||||
expect(modifiers.aelia.maxHealth).toBeCloseTo(1.4);
|
||||
expect(modifiers.brann.damage).toBeCloseTo(1.4);
|
||||
expect(modifiers.brann.stunDuration).toBeCloseTo(0.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { HealerClassId, MemberId, BossId, RunBuffId } from "../types";
|
||||
import { bossCoinDrop, type DifficultySlug, type MaterialStack } from "./loot";
|
||||
|
||||
export type GearOwnerId = HealerClassId | Exclude<MemberId, "aelia">;
|
||||
export type GearSlotId = "weapon" | "helmet" | "chest" | "legs" | "feet";
|
||||
export type GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
export type GearStatId = "healingPower" | "damage" | "attackCooldown" | "maxHealth" | "moveSpeed" | "stunResist" | "hazardDamageTaken";
|
||||
|
||||
export interface GearSlotProgress {
|
||||
level: GearLevel;
|
||||
}
|
||||
|
||||
export interface ClassGearProgress {
|
||||
slots: Record<GearSlotId, GearSlotProgress>;
|
||||
infusionAbilityId: string | null;
|
||||
passiveInfusionId: RunBuffId | null;
|
||||
}
|
||||
|
||||
export type GearProgress = Record<GearOwnerId, ClassGearProgress>;
|
||||
|
||||
export interface GearRecipe {
|
||||
ownerId: GearOwnerId;
|
||||
slotId: GearSlotId;
|
||||
statId: GearStatId;
|
||||
primaryBossId: BossId;
|
||||
secondaryBossId: BossId;
|
||||
}
|
||||
|
||||
export interface GearUpgradeCost {
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export const GEAR_OWNER_ORDER: readonly GearOwnerId[] = ["priest", "druid", "shaman", "brann", "nia", "orin", "vale"];
|
||||
export const GEAR_SLOT_ORDER: readonly GearSlotId[] = ["weapon", "helmet", "chest", "legs", "feet"];
|
||||
export const MAX_GEAR_LEVEL: GearLevel = 10;
|
||||
|
||||
export const GEAR_OWNER_LABELS: Record<GearOwnerId, string> = {
|
||||
priest: "Priest",
|
||||
druid: "Druid",
|
||||
shaman: "Shaman",
|
||||
brann: "Brann · Knight",
|
||||
nia: "Nia · Ranger",
|
||||
orin: "Orin · Mage",
|
||||
vale: "Vale · Rogue",
|
||||
};
|
||||
|
||||
export const GEAR_SLOT_LABELS: Record<GearSlotId, string> = {
|
||||
weapon: "Weapon",
|
||||
helmet: "Helmet",
|
||||
chest: "Chest",
|
||||
legs: "Legs",
|
||||
feet: "Feet",
|
||||
};
|
||||
|
||||
export const GEAR_STAT_LABELS: Record<GearStatId, string> = {
|
||||
healingPower: "Healing Power",
|
||||
damage: "Damage",
|
||||
attackCooldown: "Cooldown",
|
||||
maxHealth: "Max Health",
|
||||
moveSpeed: "Move Speed",
|
||||
stunResist: "Stun Resist",
|
||||
hazardDamageTaken: "Hazard Damage Taken",
|
||||
};
|
||||
|
||||
type RecipeSeed = Record<GearSlotId, readonly [BossId, BossId]>;
|
||||
|
||||
const HEALER_RECIPES: RecipeSeed = {
|
||||
weapon: ["vexa", "cindermaw"],
|
||||
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
|
||||
chest: ["obsidian-ram-golem", "bulldrome"],
|
||||
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
|
||||
feet: ["vexa", "sandglass-scorpion"],
|
||||
};
|
||||
|
||||
const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
|
||||
priest: HEALER_RECIPES,
|
||||
druid: HEALER_RECIPES,
|
||||
shaman: HEALER_RECIPES,
|
||||
brann: {
|
||||
weapon: ["obsidian-ram-golem", "bulldrome"],
|
||||
helmet: ["ember-mantis-duelist", "sandglass-scorpion"],
|
||||
chest: ["obsidian-ram-golem", "cindermaw"],
|
||||
legs: ["bulldrome", "cinderback-ricochet"],
|
||||
feet: ["sandglass-scorpion", "vexa"],
|
||||
},
|
||||
nia: {
|
||||
weapon: ["cinderback-ricochet", "cindermaw"],
|
||||
helmet: ["vexa", "sandglass-scorpion"],
|
||||
chest: ["bulldrome", "obsidian-ram-golem"],
|
||||
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
|
||||
feet: ["sandglass-scorpion", "vexa"],
|
||||
},
|
||||
orin: {
|
||||
weapon: ["cindermaw", "vexa"],
|
||||
helmet: ["sandglass-scorpion", "cinderback-ricochet"],
|
||||
chest: ["vexa", "obsidian-ram-golem"],
|
||||
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
|
||||
feet: ["sandglass-scorpion", "bulldrome"],
|
||||
},
|
||||
vale: {
|
||||
weapon: ["ember-mantis-duelist", "cindermaw"],
|
||||
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
|
||||
chest: ["bulldrome", "obsidian-ram-golem"],
|
||||
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
|
||||
feet: ["vexa", "sandglass-scorpion"],
|
||||
},
|
||||
};
|
||||
|
||||
function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId {
|
||||
if (slotId === "weapon") return ownerId === "priest" || ownerId === "druid" || ownerId === "shaman" ? "healingPower" : "damage";
|
||||
if (slotId === "helmet") return ownerId === "brann" ? "stunResist" : "attackCooldown";
|
||||
if (slotId === "chest") return "maxHealth";
|
||||
if (slotId === "legs") return "moveSpeed";
|
||||
return "hazardDamageTaken";
|
||||
}
|
||||
|
||||
export const GEAR_RECIPES = Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
|
||||
ownerId,
|
||||
Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => {
|
||||
const [primaryBossId, secondaryBossId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
|
||||
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryBossId, secondaryBossId }];
|
||||
})),
|
||||
])) as Record<GearOwnerId, Record<GearSlotId, GearRecipe>>;
|
||||
|
||||
export function createDefaultGearProgress(): GearProgress {
|
||||
return Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
|
||||
ownerId,
|
||||
{
|
||||
slots: Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => [slotId, { level: 0 }])),
|
||||
infusionAbilityId: null,
|
||||
passiveInfusionId: null,
|
||||
},
|
||||
])) as GearProgress;
|
||||
}
|
||||
|
||||
export function gearUpgradeCosts(ownerId: GearOwnerId, slotId: GearSlotId, currentLevel: GearLevel): GearUpgradeCost[] {
|
||||
if (currentLevel >= MAX_GEAR_LEVEL) return [];
|
||||
const nextLevel = currentLevel + 1 as Exclude<GearLevel, 0>;
|
||||
const recipe = GEAR_RECIPES[ownerId][slotId];
|
||||
const difficultySlug = upgradeDifficultySlug(nextLevel);
|
||||
const primary = bossCoinDrop(recipe.primaryBossId, difficultySlug);
|
||||
const secondary = bossCoinDrop(recipe.secondaryBossId, difficultySlug);
|
||||
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }];
|
||||
const primaryQuantity = nextLevel <= 3 ? 3 : nextLevel <= 5 ? nextLevel : nextLevel;
|
||||
const secondaryQuantity = nextLevel === 2 ? 1 : nextLevel === 3 ? 2 : nextLevel - 1;
|
||||
return [
|
||||
{ itemId: primary.id, itemName: primary.name, quantity: primaryQuantity },
|
||||
{ itemId: secondary.id, itemName: secondary.name, quantity: secondaryQuantity },
|
||||
];
|
||||
}
|
||||
|
||||
export function canAffordGearUpgrade(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): boolean {
|
||||
return costs.every((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) >= cost.quantity);
|
||||
}
|
||||
|
||||
export function spendGearCosts(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): MaterialStack[] {
|
||||
if (!canAffordGearUpgrade(inventory, costs)) {
|
||||
const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity);
|
||||
throw new Error(missing ? `Need ${missing.quantity} ${missing.itemName}.` : "Missing gear materials.");
|
||||
}
|
||||
return inventory.flatMap((item) => {
|
||||
const cost = costs.find((entry) => entry.itemId === item.id);
|
||||
if (!cost) return [{ ...item }];
|
||||
const quantity = item.quantity - cost.quantity;
|
||||
return quantity > 0 ? [{ ...item, quantity }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function upgradeGearSlot(
|
||||
progress: GearProgress,
|
||||
inventory: readonly MaterialStack[],
|
||||
ownerId: GearOwnerId,
|
||||
slotId: GearSlotId,
|
||||
): { gearProgress: GearProgress; inventory: MaterialStack[] } {
|
||||
const currentLevel = progress[ownerId].slots[slotId].level;
|
||||
if (currentLevel >= MAX_GEAR_LEVEL) throw new Error(`${GEAR_SLOT_LABELS[slotId]} already at +${MAX_GEAR_LEVEL}.`);
|
||||
const costs = gearUpgradeCosts(ownerId, slotId, currentLevel);
|
||||
const inventoryAfter = spendGearCosts(inventory, costs);
|
||||
return {
|
||||
inventory: inventoryAfter,
|
||||
gearProgress: {
|
||||
...progress,
|
||||
[ownerId]: {
|
||||
...progress[ownerId],
|
||||
slots: {
|
||||
...progress[ownerId].slots,
|
||||
[slotId]: { level: currentLevel + 1 as GearLevel },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function gearBonusText(statId: GearStatId, level: number): string {
|
||||
if (statId === "moveSpeed") return `+${formatPercent(level * 2.5)}%`;
|
||||
if (statId === "attackCooldown" || statId === "hazardDamageTaken") return `-${level * 3}%`;
|
||||
if (statId === "stunResist") return `-${level * 8}%`;
|
||||
return `+${level * 4}%`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
}
|
||||
|
||||
// IWT2 parity: +6 through +10 return to Veteran coins while infusion costs use higher tiers.
|
||||
function upgradeDifficultySlug(level: Exclude<GearLevel, 0>): DifficultySlug {
|
||||
if (level <= 2) return "initiate";
|
||||
if (level >= 6) return "veteran";
|
||||
if (level === 3) return "veteran";
|
||||
if (level === 4) return "champion";
|
||||
return "mythic";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { HealerClassId, MemberId, PartyMember } from "../types";
|
||||
import type { AiCombatantId } from "../partyCombat";
|
||||
import { GEAR_RECIPES, GEAR_SLOT_ORDER, type GearOwnerId, type GearProgress } from "./gear";
|
||||
import { ACTIVE_INFUSIONS, activeInfusionUnlocked } from "./infusions";
|
||||
|
||||
export interface MemberGearModifiers {
|
||||
maxHealth: number;
|
||||
moveSpeed: number;
|
||||
healingPower: number;
|
||||
damage: number;
|
||||
cooldown: number;
|
||||
hazardDamageTaken: number;
|
||||
stunDuration: number;
|
||||
}
|
||||
|
||||
export type EncounterGearModifiers = Record<MemberId, MemberGearModifiers>;
|
||||
|
||||
export const EMPTY_MEMBER_GEAR_MODIFIERS: MemberGearModifiers = {
|
||||
maxHealth: 1,
|
||||
moveSpeed: 1,
|
||||
healingPower: 1,
|
||||
damage: 1,
|
||||
cooldown: 1,
|
||||
hazardDamageTaken: 1,
|
||||
stunDuration: 1,
|
||||
};
|
||||
|
||||
export function createEncounterGearModifiers(progress: GearProgress, healerClassId: HealerClassId): EncounterGearModifiers {
|
||||
return {
|
||||
aelia: modifiersForOwner(progress, healerClassId),
|
||||
brann: modifiersForOwner(progress, "brann"),
|
||||
nia: modifiersForOwner(progress, "nia"),
|
||||
orin: modifiersForOwner(progress, "orin"),
|
||||
vale: modifiersForOwner(progress, "vale"),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyGearHealth(party: PartyMember[], modifiers: EncounterGearModifiers): PartyMember[] {
|
||||
return party.map((member) => {
|
||||
const maxHp = Math.round(member.maxHp * modifiers[member.id].maxHealth);
|
||||
return { ...member, maxHp, hp: maxHp };
|
||||
});
|
||||
}
|
||||
|
||||
export function aiCombatModifiers(modifiers: EncounterGearModifiers): Record<AiCombatantId, Pick<MemberGearModifiers, "damage" | "cooldown">> {
|
||||
return {
|
||||
brann: modifiers.brann,
|
||||
nia: modifiers.nia,
|
||||
orin: modifiers.orin,
|
||||
vale: modifiers.vale,
|
||||
};
|
||||
}
|
||||
|
||||
function modifiersForOwner(progress: GearProgress, ownerId: GearOwnerId): MemberGearModifiers {
|
||||
const result = { ...EMPTY_MEMBER_GEAR_MODIFIERS };
|
||||
for (const slotId of GEAR_SLOT_ORDER) {
|
||||
const level = progress[ownerId].slots[slotId].level;
|
||||
if (level <= 0) continue;
|
||||
const statId = GEAR_RECIPES[ownerId][slotId].statId;
|
||||
if (statId === "maxHealth") result.maxHealth *= 1 + level * 0.04;
|
||||
else if (statId === "moveSpeed") result.moveSpeed *= 1 + level * 0.025;
|
||||
else if (statId === "healingPower") result.healingPower *= 1 + level * 0.04;
|
||||
else if (statId === "damage") result.damage *= 1 + level * 0.04;
|
||||
else if (statId === "attackCooldown") result.cooldown *= 1 - level * 0.03;
|
||||
else if (statId === "hazardDamageTaken") result.hazardDamageTaken *= 1 - level * 0.03;
|
||||
else if (statId === "stunResist") result.stunDuration *= Math.max(0, 1 - level * 0.08);
|
||||
}
|
||||
const ownerProgress = progress[ownerId];
|
||||
const infusion = ownerProgress.infusionAbilityId ? ACTIVE_INFUSIONS[ownerProgress.infusionAbilityId] : undefined;
|
||||
if (infusion?.ownerId === ownerId && activeInfusionUnlocked(ownerProgress)) {
|
||||
if (infusion.effectKey === "max-health") result.maxHealth *= 1.08;
|
||||
else if (infusion.effectKey === "move-speed") result.moveSpeed *= 1.08;
|
||||
else if (infusion.effectKey === "healing-power") result.healingPower *= 1.08;
|
||||
else if (infusion.effectKey === "damage") result.damage *= 1.08;
|
||||
else if (infusion.effectKey === "cooldown") result.cooldown *= 0.92;
|
||||
else if (infusion.effectKey === "hazard-shield") result.hazardDamageTaken *= 0.85;
|
||||
else if (infusion.effectKey === "stun-immune") result.stunDuration = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClassInventory } from "../healers";
|
||||
import { useGameStore } from "../store";
|
||||
import { createEncounterGearModifiers } from "./gearEffects";
|
||||
import { createDefaultGearProgress } from "./gear";
|
||||
import {
|
||||
equipActiveInfusion,
|
||||
equipPassiveInfusion,
|
||||
infusionCosts,
|
||||
passiveInfusionUnlocked,
|
||||
} from "./infusions";
|
||||
import { bossCoinDrop, type MaterialStack } from "./loot";
|
||||
|
||||
function stack(item: ReturnType<typeof bossCoinDrop>, quantity: number): MaterialStack {
|
||||
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
|
||||
}
|
||||
|
||||
describe("IWT2-style gear infusions", () => {
|
||||
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic coins", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
progress.brann.slots.weapon.level = 5;
|
||||
const costs = infusionCosts("brann", "weapon", "brann-unbreakable");
|
||||
const inventory = [
|
||||
stack(bossCoinDrop("obsidian-ram-golem", "ascendant"), 6),
|
||||
stack(bossCoinDrop("obsidian-ram-golem", "mythic"), 6),
|
||||
];
|
||||
|
||||
const result = equipActiveInfusion(progress, inventory, "brann", "weapon", "brann-unbreakable");
|
||||
expect(costs.map((cost) => cost.quantity)).toEqual([5, 5]);
|
||||
expect(result.inventory.map((item) => item.quantity)).toEqual([1, 1]);
|
||||
expect(result.gearProgress.brann.infusionAbilityId).toBe("brann-unbreakable");
|
||||
expect(progress.brann.infusionAbilityId).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects locked anchors and infusions owned by another hero", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
progress.brann.slots.chest.level = 5;
|
||||
expect(() => equipActiveInfusion(progress, [], "brann", "weapon", "brann-unbreakable")).toThrow("Selected anchor");
|
||||
expect(() => equipActiveInfusion(progress, [], "brann", "chest", "nia-quickdraw")).toThrow("does not belong");
|
||||
});
|
||||
|
||||
it("compiles active effects into encounter modifiers once", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
progress.brann.slots.weapon.level = 5;
|
||||
progress.brann.infusionAbilityId = "brann-unbreakable";
|
||||
progress.nia.slots.weapon.level = 5;
|
||||
progress.nia.infusionAbilityId = "nia-hunters-mark";
|
||||
const modifiers = createEncounterGearModifiers(progress, "priest");
|
||||
expect(modifiers.brann.stunDuration).toBe(0);
|
||||
expect(modifiers.nia.damage).toBeCloseTo(1.08 * 1.2);
|
||||
});
|
||||
|
||||
it("unlocks one free healer passive globally at +10 and applies it next encounter", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
progress.vale.slots.feet.level = 10;
|
||||
expect(passiveInfusionUnlocked(progress)).toBe(true);
|
||||
const infused = equipPassiveInfusion(progress, "priest", "deep-wells");
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused);
|
||||
expect(useGameStore.getState().maxMana).toBe(120);
|
||||
expect(useGameStore.getState().runBuffs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { RUN_BUFFS, RUN_BUFF_ORDER } from "../roguelike";
|
||||
import type { BossId, RunBuffId } from "../types";
|
||||
import {
|
||||
GEAR_RECIPES,
|
||||
GEAR_SLOT_ORDER,
|
||||
spendGearCosts,
|
||||
type ClassGearProgress,
|
||||
type GearOwnerId,
|
||||
type GearProgress,
|
||||
type GearSlotId,
|
||||
type GearUpgradeCost,
|
||||
} from "./gear";
|
||||
import { bossCoinDrop, type MaterialStack } from "./loot";
|
||||
|
||||
export const ACTIVE_INFUSION_MIN_GEAR_LEVEL = 5;
|
||||
export const PASSIVE_INFUSION_MIN_GEAR_LEVEL = 10;
|
||||
|
||||
export type InfusionEffectKey =
|
||||
| "healing-power"
|
||||
| "hazard-shield"
|
||||
| "max-health"
|
||||
| "stun-immune"
|
||||
| "cooldown"
|
||||
| "move-speed"
|
||||
| "damage";
|
||||
|
||||
export interface ActiveInfusionDefinition {
|
||||
id: string;
|
||||
ownerId: GearOwnerId;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
linkedBossId: BossId;
|
||||
effectKey: InfusionEffectKey;
|
||||
}
|
||||
|
||||
type InfusionSeed = Omit<ActiveInfusionDefinition, "ownerId">;
|
||||
|
||||
const INFUSION_SEEDS: Record<GearOwnerId, readonly InfusionSeed[]> = {
|
||||
priest: [
|
||||
{ id: "priest-sanctuary", name: "Sanctuary", icon: "✦", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
|
||||
{ id: "priest-guardian-grace", name: "Guardian Grace", icon: "✚", description: "8% more healing power.", linkedBossId: "cindermaw", effectKey: "healing-power" },
|
||||
{ id: "priest-miracle-ward", name: "Miracle Ward", icon: "◇", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
|
||||
],
|
||||
druid: [
|
||||
{ id: "druid-barkskin", name: "Barkskin", icon: "♧", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
|
||||
{ id: "druid-verdant-pulse", name: "Verdant Pulse", icon: "❈", description: "8% more healing power.", linkedBossId: "ember-mantis-duelist", effectKey: "healing-power" },
|
||||
{ id: "druid-wildstep", name: "Wildstep", icon: "⌁", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
|
||||
],
|
||||
shaman: [
|
||||
{ id: "shaman-spirit-ward", name: "Spirit Ward", icon: "◈", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
|
||||
{ id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "cinderback-ricochet", effectKey: "healing-power" },
|
||||
{ id: "shaman-windwalk", name: "Windwalk", icon: "≋", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
|
||||
],
|
||||
brann: [
|
||||
{ id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "obsidian-ram-golem", effectKey: "stun-immune" },
|
||||
{ id: "brann-bulwark", name: "Bulwark", icon: "⬡", description: "8% more maximum health.", linkedBossId: "bulldrome", effectKey: "max-health" },
|
||||
{ id: "brann-vanguard", name: "Vanguard", icon: "➶", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
|
||||
],
|
||||
nia: [
|
||||
{ id: "nia-hunters-mark", name: "Hunter's Mark", icon: "◎", description: "8% more damage.", linkedBossId: "cinderback-ricochet", effectKey: "damage" },
|
||||
{ id: "nia-quickdraw", name: "Quickdraw", icon: "➳", description: "8% faster attacks.", linkedBossId: "ember-mantis-duelist", effectKey: "cooldown" },
|
||||
{ id: "nia-decoy", name: "Decoy", icon: "♙", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
|
||||
],
|
||||
orin: [
|
||||
{ id: "orin-overcharge", name: "Overcharge", icon: "ϟ", description: "8% more damage.", linkedBossId: "cindermaw", effectKey: "damage" },
|
||||
{ id: "orin-temporal-flow", name: "Temporal Flow", icon: "◷", description: "8% faster attacks.", linkedBossId: "sandglass-scorpion", effectKey: "cooldown" },
|
||||
{ id: "orin-arcane-barrier", name: "Arcane Barrier", icon: "◇", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
|
||||
],
|
||||
vale: [
|
||||
{ id: "vale-expose", name: "Expose", icon: "†", description: "8% more damage.", linkedBossId: "ember-mantis-duelist", effectKey: "damage" },
|
||||
{ id: "vale-shadow-step", name: "Shadow Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
|
||||
{ id: "vale-vanish", name: "Vanish", icon: "◌", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
|
||||
],
|
||||
};
|
||||
|
||||
export const ACTIVE_INFUSIONS = Object.fromEntries(Object.entries(INFUSION_SEEDS).flatMap(([ownerId, choices]) =>
|
||||
choices.map((choice) => [choice.id, { ...choice, ownerId: ownerId as GearOwnerId }]),
|
||||
)) as Record<string, ActiveInfusionDefinition>;
|
||||
|
||||
const ACTIVE_INFUSIONS_BY_OWNER = Object.fromEntries(Object.entries(INFUSION_SEEDS).map(([ownerId, choices]) => [
|
||||
ownerId,
|
||||
choices.map((choice) => ACTIVE_INFUSIONS[choice.id]),
|
||||
])) as unknown as Record<GearOwnerId, readonly ActiveInfusionDefinition[]>;
|
||||
|
||||
export const PASSIVE_INFUSIONS = RUN_BUFF_ORDER.map((id) => RUN_BUFFS[id]);
|
||||
|
||||
export function infusionsForOwner(ownerId: GearOwnerId): readonly ActiveInfusionDefinition[] {
|
||||
return ACTIVE_INFUSIONS_BY_OWNER[ownerId];
|
||||
}
|
||||
|
||||
export function activeInfusionUnlocked(progress: ClassGearProgress): boolean {
|
||||
return GEAR_SLOT_ORDER.some((slotId) => progress.slots[slotId].level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
|
||||
}
|
||||
|
||||
export function passiveInfusionUnlocked(progress: GearProgress): boolean {
|
||||
return Object.values(progress).some((owner) => GEAR_SLOT_ORDER.some((slotId) => owner.slots[slotId].level >= PASSIVE_INFUSION_MIN_GEAR_LEVEL));
|
||||
}
|
||||
|
||||
export function infusionCosts(ownerId: GearOwnerId, slotId: GearSlotId, infusionId: string): GearUpgradeCost[] {
|
||||
const definition = ACTIVE_INFUSIONS[infusionId];
|
||||
if (!definition || definition.ownerId !== ownerId) return [];
|
||||
const ascendant = bossCoinDrop(definition.linkedBossId, "ascendant");
|
||||
const mythic = bossCoinDrop(GEAR_RECIPES[ownerId][slotId].primaryBossId, "mythic");
|
||||
return [
|
||||
{ itemId: ascendant.id, itemName: ascendant.name, quantity: 5 },
|
||||
{ itemId: mythic.id, itemName: mythic.name, quantity: 5 },
|
||||
];
|
||||
}
|
||||
|
||||
export function equipActiveInfusion(
|
||||
progress: GearProgress,
|
||||
inventory: readonly MaterialStack[],
|
||||
ownerId: GearOwnerId,
|
||||
slotId: GearSlotId,
|
||||
infusionId: string,
|
||||
): { gearProgress: GearProgress; inventory: MaterialStack[] } {
|
||||
const definition = ACTIVE_INFUSIONS[infusionId];
|
||||
if (!definition || definition.ownerId !== ownerId) throw new Error("Infusion does not belong to selected hero.");
|
||||
if (!activeInfusionUnlocked(progress[ownerId])) throw new Error(`Raise any ${ownerId} gear slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}.`);
|
||||
if (progress[ownerId].slots[slotId].level < ACTIVE_INFUSION_MIN_GEAR_LEVEL) throw new Error(`Selected anchor must be +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}.`);
|
||||
if (progress[ownerId].infusionAbilityId === infusionId) return { gearProgress: progress, inventory: [...inventory] };
|
||||
return {
|
||||
inventory: spendGearCosts(inventory, infusionCosts(ownerId, slotId, infusionId)),
|
||||
gearProgress: {
|
||||
...progress,
|
||||
[ownerId]: { ...progress[ownerId], infusionAbilityId: infusionId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function equipPassiveInfusion(progress: GearProgress, ownerId: GearOwnerId, passiveId: RunBuffId): GearProgress {
|
||||
if (ownerId !== "priest" && ownerId !== "druid" && ownerId !== "shaman") throw new Error("Passive infusions belong to healer gear.");
|
||||
if (!RUN_BUFF_ORDER.includes(passiveId)) throw new Error("Unknown passive infusion.");
|
||||
if (!passiveInfusionUnlocked(progress)) throw new Error(`Raise any gear slot to +${PASSIVE_INFUSION_MIN_GEAR_LEVEL}.`);
|
||||
return { ...progress, [ownerId]: { ...progress[ownerId], passiveInfusionId: passiveId } };
|
||||
}
|
||||
|
||||
export function normalizeActiveInfusionId(ownerId: GearOwnerId, value: unknown): string | null {
|
||||
return typeof value === "string" && ACTIVE_INFUSIONS[value]?.ownerId === ownerId ? value : null;
|
||||
}
|
||||
|
||||
export function normalizePassiveInfusionId(ownerId: GearOwnerId, value: unknown): RunBuffId | null {
|
||||
if (ownerId !== "priest" && ownerId !== "druid" && ownerId !== "shaman") return null;
|
||||
return typeof value === "string" && RUN_BUFF_ORDER.includes(value as RunBuffId) ? value as RunBuffId : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BOSS_ORDER } from "../bossCatalog";
|
||||
import { BOSS_DROP_TABLES, coinDropQuantity, createEmptyCollectionLog, rollBossReward } from "./loot";
|
||||
|
||||
describe("boss drop tables", () => {
|
||||
it("defines tiered coins and a pet for every shipped boss", () => {
|
||||
for (const bossId of BOSS_ORDER) {
|
||||
const table = BOSS_DROP_TABLES[bossId];
|
||||
expect(Object.keys(table.coins)).toEqual(["initiate", "veteran", "champion", "mythic", "ascendant"]);
|
||||
expect(table.pet.dropRate).toBe(1 / 500);
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls IWT2 coin quantities at exact boundaries", () => {
|
||||
expect(coinDropQuantity(() => 0)).toBe(3);
|
||||
expect(coinDropQuantity(() => 0.149999)).toBe(3);
|
||||
expect(coinDropQuantity(() => 0.15)).toBe(2);
|
||||
expect(coinDropQuantity(() => 0.499999)).toBe(2);
|
||||
expect(coinDropQuantity(() => 0.5)).toBe(1);
|
||||
});
|
||||
|
||||
it("stacks duplicate coins while collection counts remain lifetime totals", () => {
|
||||
const rolls = [0.1, 1, 0.2, 0];
|
||||
const random = () => rolls.shift() ?? 1;
|
||||
const first = rollBossReward("bulldrome", "initiate", [], createEmptyCollectionLog(), random);
|
||||
const second = rollBossReward("bulldrome", "initiate", first.inventory, first.collectionLog, random);
|
||||
expect(first.award.quantity).toBe(3);
|
||||
expect(second.award.quantity).toBe(2);
|
||||
expect(second.award.duplicate).toBe(true);
|
||||
expect(second.inventory[0].quantity).toBe(5);
|
||||
expect(second.collectionLog.dropsFound[second.award.coin.id]).toBe(5);
|
||||
expect(second.award.pet?.id).toBe("bulldrome-pet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../bossCatalog";
|
||||
import type { BossId } from "../types";
|
||||
|
||||
export type LootRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
export type DifficultySlug = "initiate" | "veteran" | "champion" | "mythic" | "ascendant";
|
||||
|
||||
export interface DifficultyDefinition {
|
||||
slug: DifficultySlug;
|
||||
name: string;
|
||||
itemLevel: number;
|
||||
rarity: LootRarity;
|
||||
glyph: string;
|
||||
coinPrefix: string;
|
||||
healthMultiplier: number;
|
||||
damageMultiplier: number;
|
||||
}
|
||||
|
||||
export interface MaterialStack {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity: LootRarity;
|
||||
itemLevel: number;
|
||||
glyph: string;
|
||||
}
|
||||
|
||||
export interface BossCoinDrop {
|
||||
kind: "coin";
|
||||
id: string;
|
||||
bossId: BossId;
|
||||
difficultySlug: DifficultySlug;
|
||||
name: string;
|
||||
rarity: LootRarity;
|
||||
itemLevel: number;
|
||||
glyph: string;
|
||||
chanceLabel: string;
|
||||
}
|
||||
|
||||
export interface BossPetDrop {
|
||||
kind: "pet";
|
||||
id: string;
|
||||
bossId: BossId;
|
||||
name: string;
|
||||
rarity: "legendary";
|
||||
glyph: string;
|
||||
dropRate: number;
|
||||
chanceLabel: string;
|
||||
}
|
||||
|
||||
export interface BossDropTable {
|
||||
bossId: BossId;
|
||||
coins: Record<DifficultySlug, BossCoinDrop>;
|
||||
pet: BossPetDrop;
|
||||
entries: readonly (BossCoinDrop | BossPetDrop)[];
|
||||
}
|
||||
|
||||
export interface CollectionLog {
|
||||
dropsFound: Record<string, number>;
|
||||
petsFound: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BossRewardAward {
|
||||
bossId: BossId;
|
||||
coin: BossCoinDrop;
|
||||
quantity: number;
|
||||
quantityAfter: number;
|
||||
duplicate: boolean;
|
||||
pet: BossPetDrop | null;
|
||||
petQuantityAfter: number;
|
||||
}
|
||||
|
||||
export const BOSS_PET_DROP_RATE = 1 / 500;
|
||||
|
||||
export const DIFFICULTIES: readonly DifficultyDefinition[] = [
|
||||
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", coinPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
|
||||
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", coinPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
|
||||
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", coinPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
|
||||
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", coinPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
|
||||
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", coinPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
|
||||
] as const;
|
||||
|
||||
export const DIFFICULTY_BY_SLUG = Object.fromEntries(
|
||||
DIFFICULTIES.map((difficulty) => [difficulty.slug, difficulty]),
|
||||
) as Record<DifficultySlug, DifficultyDefinition>;
|
||||
|
||||
const DIFFICULTY_ID_PREFIX: Record<DifficultySlug, string> = {
|
||||
initiate: "raw",
|
||||
veteran: "green",
|
||||
champion: "blue",
|
||||
mythic: "purple",
|
||||
ascendant: "orange",
|
||||
};
|
||||
|
||||
function createBossDropTable(bossId: BossId): BossDropTable {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
const coins = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
|
||||
difficulty.slug,
|
||||
{
|
||||
kind: "coin" as const,
|
||||
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${bossId}-coin`,
|
||||
bossId,
|
||||
difficultySlug: difficulty.slug,
|
||||
name: `${difficulty.coinPrefix} ${boss.name} Coin`,
|
||||
rarity: difficulty.rarity,
|
||||
itemLevel: difficulty.itemLevel,
|
||||
glyph: difficulty.glyph,
|
||||
chanceLabel: "Guaranteed 1-3",
|
||||
},
|
||||
])) as Record<DifficultySlug, BossCoinDrop>;
|
||||
const pet: BossPetDrop = {
|
||||
kind: "pet",
|
||||
id: `${bossId}-pet`,
|
||||
bossId,
|
||||
name: `${boss.name} Pet`,
|
||||
rarity: "legendary",
|
||||
glyph: boss.icon,
|
||||
dropRate: BOSS_PET_DROP_RATE,
|
||||
chanceLabel: "1 in 500",
|
||||
};
|
||||
return {
|
||||
bossId,
|
||||
coins,
|
||||
pet,
|
||||
entries: [...DIFFICULTIES.map((difficulty) => coins[difficulty.slug]), pet],
|
||||
};
|
||||
}
|
||||
|
||||
export const BOSS_DROP_TABLES = Object.fromEntries(
|
||||
BOSS_ORDER.map((bossId) => [bossId, createBossDropTable(bossId)]),
|
||||
) as Record<BossId, BossDropTable>;
|
||||
|
||||
export function bossDropTable(bossId: BossId): BossDropTable {
|
||||
return BOSS_DROP_TABLES[bossId];
|
||||
}
|
||||
|
||||
export function bossCoinDrop(bossId: BossId, difficultySlug: DifficultySlug): BossCoinDrop {
|
||||
return BOSS_DROP_TABLES[bossId].coins[difficultySlug];
|
||||
}
|
||||
|
||||
export function coinDropQuantity(random: () => number = Math.random): number {
|
||||
const roll = random();
|
||||
if (roll < 0.15) return 3;
|
||||
if (roll < 0.5) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function rollBossReward(
|
||||
bossId: BossId,
|
||||
difficultySlug: DifficultySlug,
|
||||
inventory: readonly MaterialStack[],
|
||||
collectionLog: CollectionLog,
|
||||
random: () => number = Math.random,
|
||||
): { award: BossRewardAward; inventory: MaterialStack[]; collectionLog: CollectionLog } {
|
||||
const table = bossDropTable(bossId);
|
||||
const coin = table.coins[difficultySlug];
|
||||
const quantity = coinDropQuantity(random);
|
||||
const existing = inventory.find((item) => item.id === coin.id);
|
||||
const inventoryAfter = existing
|
||||
? inventory.map((item) => item.id === coin.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
|
||||
: [...inventory.map((item) => ({ ...item })), { id: coin.id, name: coin.name, quantity, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
|
||||
const petAwarded = random() < table.pet.dropRate;
|
||||
const petQuantityAfter = (collectionLog.petsFound[table.pet.id] ?? 0) + Number(petAwarded);
|
||||
return {
|
||||
award: {
|
||||
bossId,
|
||||
coin,
|
||||
quantity,
|
||||
quantityAfter: (existing?.quantity ?? 0) + quantity,
|
||||
duplicate: Boolean(existing),
|
||||
pet: petAwarded ? table.pet : null,
|
||||
petQuantityAfter,
|
||||
},
|
||||
inventory: inventoryAfter,
|
||||
collectionLog: {
|
||||
dropsFound: {
|
||||
...collectionLog.dropsFound,
|
||||
[coin.id]: (collectionLog.dropsFound[coin.id] ?? 0) + quantity,
|
||||
},
|
||||
petsFound: petAwarded
|
||||
? { ...collectionLog.petsFound, [table.pet.id]: petQuantityAfter }
|
||||
: { ...collectionLog.petsFound },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDifficultySlug(value: unknown): DifficultySlug {
|
||||
return typeof value === "string" && value in DIFFICULTY_BY_SLUG
|
||||
? value as DifficultySlug
|
||||
: "initiate";
|
||||
}
|
||||
|
||||
export function createEmptyCollectionLog(): CollectionLog {
|
||||
return { dropsFound: {}, petsFound: {} };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "./data";
|
||||
import {
|
||||
applyRunBuffsToParty,
|
||||
bossHealthMultiplier,
|
||||
runHealingMultiplier,
|
||||
runMaxMana,
|
||||
selectRandomBossPair,
|
||||
} from "./roguelike";
|
||||
|
||||
describe("roguelike progression", () => {
|
||||
it("adds 10% base boss HP per completed round", () => {
|
||||
expect(bossHealthMultiplier(1)).toBe(1);
|
||||
expect(bossHealthMultiplier(2)).toBe(1.1);
|
||||
expect(bossHealthMultiplier(5)).toBe(1.4);
|
||||
});
|
||||
|
||||
it("stacks each persistent buff independently", () => {
|
||||
const buffs = ["vital-bloom", "vital-bloom", "deep-wells", "restoring-grace"] as const;
|
||||
const party = applyRunBuffsToParty(freshParty("priest", "Aelia"), buffs);
|
||||
|
||||
expect(party[0].maxHp).toBe(124);
|
||||
expect(party[1].maxHp).toBe(186);
|
||||
expect(runMaxMana(buffs)).toBe(120);
|
||||
expect(runHealingMultiplier(buffs)).toBe(1.15);
|
||||
});
|
||||
|
||||
it("selects a distinct pair that excludes both bosses from the prior round", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair(["bulldrome", "vexa"], () => values.shift() ?? 0);
|
||||
|
||||
expect(pair).toEqual(["cindermaw", "ember-mantis-duelist"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
expect(pair).not.toContain("bulldrome");
|
||||
expect(pair).not.toContain("vexa");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BOSS_ORDER } from "./bossCatalog";
|
||||
import type { BossId, PartyMember, RunBuffId } from "./types";
|
||||
|
||||
export interface RunBuffDefinition {
|
||||
id: RunBuffId;
|
||||
name: string;
|
||||
icon: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const RUN_BUFF_ORDER: readonly RunBuffId[] = ["vital-bloom", "deep-wells", "restoring-grace"];
|
||||
|
||||
export const RUN_BUFFS: Record<RunBuffId, RunBuffDefinition> = {
|
||||
"vital-bloom": {
|
||||
id: "vital-bloom",
|
||||
name: "Vital Bloom",
|
||||
icon: "♧",
|
||||
summary: "+12% party max HP",
|
||||
detail: "Stacks each time chosen. New round starts at full health.",
|
||||
accent: "#74d18c",
|
||||
},
|
||||
"deep-wells": {
|
||||
id: "deep-wells",
|
||||
name: "Deep Wells",
|
||||
icon: "◇",
|
||||
summary: "+20 maximum mana",
|
||||
detail: "Stacks each time chosen. New round starts with full mana.",
|
||||
accent: "#69baff",
|
||||
},
|
||||
"restoring-grace": {
|
||||
id: "restoring-grace",
|
||||
name: "Restoring Grace",
|
||||
icon: "✦",
|
||||
summary: "+15% healing done",
|
||||
detail: "Strengthens Mend, Renew, and Radiance. Stacks additively.",
|
||||
accent: "#f1d479",
|
||||
},
|
||||
};
|
||||
|
||||
export function countRunBuff(buffs: readonly RunBuffId[], buffId: RunBuffId) {
|
||||
return buffs.reduce((count, current) => count + Number(current === buffId), 0);
|
||||
}
|
||||
|
||||
export function applyRunBuffsToParty(party: PartyMember[], buffs: readonly RunBuffId[]) {
|
||||
const vitalityMultiplier = 1 + countRunBuff(buffs, "vital-bloom") * 0.12;
|
||||
return party.map((member) => {
|
||||
const maxHp = Math.round(member.maxHp * vitalityMultiplier);
|
||||
return { ...member, maxHp, hp: maxHp };
|
||||
});
|
||||
}
|
||||
|
||||
export function runMaxMana(buffs: readonly RunBuffId[]) {
|
||||
return 100 + countRunBuff(buffs, "deep-wells") * 20;
|
||||
}
|
||||
|
||||
export function runHealingMultiplier(buffs: readonly RunBuffId[]) {
|
||||
return 1 + countRunBuff(buffs, "restoring-grace") * 0.15;
|
||||
}
|
||||
|
||||
export function bossHealthMultiplier(round: number) {
|
||||
return 1 + Math.max(0, round - 1) * 0.1;
|
||||
}
|
||||
|
||||
export function selectRandomBossPair(
|
||||
excludedBossIds: readonly BossId[] = [],
|
||||
random: () => number = Math.random,
|
||||
): readonly [BossId, BossId] {
|
||||
const excluded = new Set(excludedBossIds);
|
||||
const eligibleBosses = BOSS_ORDER.filter((bossId) => !excluded.has(bossId));
|
||||
const pool = eligibleBosses.length >= 2 ? eligibleBosses : BOSS_ORDER;
|
||||
const firstIndex = Math.floor(random() * pool.length) % pool.length;
|
||||
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
|
||||
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
|
||||
}
|
||||
+81
-4
@@ -4,6 +4,8 @@ import { distance, pointToSegmentDistance } from "./geometry";
|
||||
import { barrierProtects, useGameStore } from "./store";
|
||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
|
||||
import { isInsideArena } from "./arena";
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
|
||||
describe("Disc Priest combat simulation", () => {
|
||||
beforeEach(() => {
|
||||
@@ -148,7 +150,7 @@ describe("Disc Priest combat simulation", () => {
|
||||
expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps ranged allies stable while Vale closes to melee range", () => {
|
||||
it("keeps ranged allies stable while Vale holds behind the boss", () => {
|
||||
const start = structuredClone(useGameStore.getState().partyPositions);
|
||||
const bossPosition = useGameStore.getState().bossMotion.position;
|
||||
useGameStore.getState().tick(1);
|
||||
@@ -157,7 +159,8 @@ describe("Disc Priest combat simulation", () => {
|
||||
expect(moved.brann).toEqual(start.brann);
|
||||
expect(moved.nia).toEqual(start.nia);
|
||||
expect(moved.orin).toEqual(start.orin);
|
||||
expect(distance(moved.vale, bossPosition)).toBeLessThan(distance(start.vale, bossPosition));
|
||||
expect(moved.brann[1]).toBeGreaterThan(bossPosition[1]);
|
||||
expect(moved.vale[1]).toBeLessThan(bossPosition[1]);
|
||||
});
|
||||
|
||||
it("freezes authoritative simulation while paused", () => {
|
||||
@@ -255,7 +258,7 @@ describe("Disc Priest combat simulation", () => {
|
||||
for (const memberId of exposedAtWarning) expect(hitIds).not.toContain(memberId);
|
||||
});
|
||||
|
||||
it("marks a stack target after three charges and splits 300 pounce damage", () => {
|
||||
it("marks a stack target after three charges and splits 200 pounce damage", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
||||
playerPosition: [0, 0],
|
||||
@@ -291,7 +294,7 @@ describe("Disc Priest combat simulation", () => {
|
||||
const impacted = useGameStore.getState();
|
||||
expect(impacted.bossMotion.mode).toBe("returning");
|
||||
for (const member of impacted.party) {
|
||||
expect(member.hp).toBeCloseTo(startingHp[member.id] - 42, 3);
|
||||
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
|
||||
}
|
||||
expect(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
|
||||
});
|
||||
@@ -412,6 +415,80 @@ describe("PVE dual-boss encounter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Roguelike rounds", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer(
|
||||
"priest",
|
||||
"Aelia",
|
||||
createClassInventory("priest"),
|
||||
["bulldrome", "vexa"],
|
||||
"roguelike",
|
||||
);
|
||||
useGameStore.getState().startEncounter();
|
||||
});
|
||||
|
||||
it("blocks progression for a buff, then starts round two with new bosses at 110% base HP", () => {
|
||||
const roundOne = useGameStore.getState();
|
||||
const previousBossIds = [roundOne.boss.id, roundOne.additionalBosses[0].boss.id];
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, hp: 0 },
|
||||
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
|
||||
}));
|
||||
|
||||
useGameStore.getState().tick(0.05);
|
||||
expect(useGameStore.getState().phase).toBe("intermission");
|
||||
const intermissionTime = useGameStore.getState().time;
|
||||
useGameStore.getState().tick(10);
|
||||
expect(useGameStore.getState().phase).toBe("intermission");
|
||||
expect(useGameStore.getState().round).toBe(1);
|
||||
expect(useGameStore.getState().time).toBe(intermissionTime);
|
||||
expect(useGameStore.getState().chooseRunBuff("vital-bloom")).toBe(true);
|
||||
|
||||
const roundTwo = useGameStore.getState();
|
||||
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
|
||||
expect(roundTwo.phase).toBe("combat");
|
||||
expect(roundTwo.round).toBe(2);
|
||||
expect(roundTwo.runBuffs).toEqual(["vital-bloom"]);
|
||||
expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
|
||||
expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
|
||||
expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
|
||||
expect(roundTwo.party[0].maxHp).toBe(112);
|
||||
});
|
||||
|
||||
it("rejects buff claims outside intermission", () => {
|
||||
expect(useGameStore.getState().chooseRunBuff("deep-wells")).toBe(false);
|
||||
expect(useGameStore.getState().round).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared arena boundary", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
|
||||
useGameStore.getState().startEncounter();
|
||||
});
|
||||
|
||||
it("constrains player, party, and boss positions to the same room", () => {
|
||||
useGameStore.getState().setPlayerPosition([100, 100]);
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
||||
bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 },
|
||||
partyPositions: {
|
||||
...state.partyPositions,
|
||||
brann: [50, 50],
|
||||
nia: [-50, 50],
|
||||
orin: [50, -50],
|
||||
vale: [-50, -50],
|
||||
},
|
||||
}));
|
||||
|
||||
useGameStore.getState().tick(0.1);
|
||||
const state = useGameStore.getState();
|
||||
expect(isInsideArena(state.playerPosition)).toBe(true);
|
||||
for (const position of Object.values(state.partyPositions)) expect(isInsideArena(position)).toBe(true);
|
||||
expect(isInsideArena(state.bossMotion.position)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cindermaw encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
|
||||
|
||||
+140
-26
@@ -7,12 +7,26 @@ import {
|
||||
upcomingMechanic,
|
||||
} from "./bossMechanics";
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { clampToArena, constrainBossMotion } from "./arena";
|
||||
import { cloneMotion } from "./bosses/shared";
|
||||
import { freshParty } from "./data";
|
||||
import { distance } from "./geometry";
|
||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||
import {
|
||||
RUN_BUFF_ORDER,
|
||||
RUN_BUFFS,
|
||||
applyRunBuffsToParty,
|
||||
bossHealthMultiplier,
|
||||
runHealingMultiplier,
|
||||
runMaxMana,
|
||||
selectRandomBossPair,
|
||||
} from "./roguelike";
|
||||
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
|
||||
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
|
||||
import { passiveInfusionUnlocked } from "./progression/infusions";
|
||||
import { DIFFICULTY_BY_SLUG, normalizeDifficultySlug, type DifficultySlug } from "./progression/loot";
|
||||
import type {
|
||||
ActiveCast,
|
||||
AbilityId,
|
||||
@@ -26,6 +40,8 @@ import type {
|
||||
InventoryItem,
|
||||
MemberId,
|
||||
PartyMember,
|
||||
RunBuffId,
|
||||
RunMode,
|
||||
ScenePulse,
|
||||
WorldPosition,
|
||||
} from "./types";
|
||||
@@ -50,6 +66,16 @@ export interface GameState {
|
||||
healerClassId: HealerClassId;
|
||||
playerName: string;
|
||||
phase: GamePhase;
|
||||
runMode: RunMode;
|
||||
round: number;
|
||||
runBuffs: RunBuffId[];
|
||||
draftBuffIds: RunBuffId[];
|
||||
selectedRunBuffId: RunBuffId;
|
||||
healingMultiplier: number;
|
||||
difficultySlug: DifficultySlug;
|
||||
difficultyDamageMultiplier: number;
|
||||
gearProgress: GearProgress;
|
||||
gearModifiers: EncounterGearModifiers;
|
||||
time: number;
|
||||
party: PartyMember[];
|
||||
boss: BossState;
|
||||
@@ -71,7 +97,7 @@ export interface GameState {
|
||||
playerPosition: [number, number];
|
||||
activeCast: ActiveCast | null;
|
||||
barrier: BarrierState;
|
||||
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[]) => void;
|
||||
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug) => void;
|
||||
startEncounter: () => void;
|
||||
restart: () => void;
|
||||
tick: (delta: number) => void;
|
||||
@@ -84,6 +110,8 @@ export interface GameState {
|
||||
setPaused: (paused: boolean) => void;
|
||||
togglePause: () => void;
|
||||
setPauseSelection: (selection: "resume" | "exit") => void;
|
||||
setSelectedRunBuff: (buffId: RunBuffId) => void;
|
||||
chooseRunBuff: (buffId: RunBuffId) => boolean;
|
||||
}
|
||||
|
||||
const emptyCooldowns = (): Record<AbilityId, number> => ({
|
||||
@@ -117,11 +145,13 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
|
||||
const stagger = index * 2.4;
|
||||
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
|
||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
|
||||
return motion;
|
||||
return constrainBossMotion(motion);
|
||||
}
|
||||
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number): AdditionalBossState {
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number): AdditionalBossState {
|
||||
const boss = createBossState(bossId);
|
||||
boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
|
||||
boss.hp = boss.maxHp;
|
||||
const stagger = index * 0.8;
|
||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
|
||||
if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4;
|
||||
@@ -132,7 +162,14 @@ function createEncounterBoss(bossId: BossId, index: number, count: number): Addi
|
||||
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
|
||||
const bossPosition = createBossMotionState(bossIds[0]).position;
|
||||
if (bossIds.length > 1) bossPosition[0] = 0;
|
||||
return { aelia: [0, 4.5], ...combatFormation(bossPosition) };
|
||||
const formation = combatFormation(bossPosition);
|
||||
return {
|
||||
aelia: clampToArena([0, 4.5]),
|
||||
brann: clampToArena(formation.brann),
|
||||
nia: clampToArena(formation.nia),
|
||||
orin: clampToArena(formation.orin),
|
||||
vale: clampToArena(formation.vale),
|
||||
};
|
||||
};
|
||||
|
||||
export function damageMember(member: PartyMember, amount: number): PartyMember {
|
||||
@@ -161,7 +198,12 @@ function damageMemberAt(
|
||||
time: number,
|
||||
partyCombat?: PartyCombatState,
|
||||
tankPosition?: WorldPosition,
|
||||
incomingDamageMultiplier = 1,
|
||||
gearModifiers?: EncounterGearModifiers,
|
||||
kind: "direct" | "hazard" = "direct",
|
||||
) {
|
||||
amount *= incomingDamageMultiplier;
|
||||
if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
|
||||
const protectedByTank = partyCombat && tankPosition
|
||||
? tankAuraProtects(position, tankPosition, partyCombat.tankAura, time)
|
||||
: false;
|
||||
@@ -188,11 +230,29 @@ function initialState(
|
||||
playerName = "Aelia",
|
||||
inventory: InventoryItem[] = createClassInventory(healerClassId),
|
||||
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
|
||||
runMode: RunMode = "encounter",
|
||||
round = 1,
|
||||
runBuffs: RunBuffId[] = [],
|
||||
gearProgress: GearProgress = createDefaultGearProgress(),
|
||||
requestedDifficultySlug: DifficultySlug = "initiate",
|
||||
) {
|
||||
const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug);
|
||||
const difficulty = DIFFICULTY_BY_SLUG[difficultySlug];
|
||||
const bossIds = normalizeBossIds(requestedBossIds);
|
||||
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(bossId, index, bossIds.length));
|
||||
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
|
||||
bossId,
|
||||
index,
|
||||
bossIds.length,
|
||||
bossHealthMultiplier(round) * difficulty.healthMultiplier,
|
||||
));
|
||||
const primary = encounterBosses[0];
|
||||
const party = freshParty(healerClassId, playerName);
|
||||
const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId);
|
||||
const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null;
|
||||
const effectiveRunBuffs = passiveInfusionId && !runBuffs.includes(passiveInfusionId)
|
||||
? [passiveInfusionId, ...runBuffs]
|
||||
: runBuffs;
|
||||
const party = applyGearHealth(applyRunBuffsToParty(freshParty(healerClassId, playerName), effectiveRunBuffs), gearModifiers);
|
||||
const maxMana = runMaxMana(effectiveRunBuffs);
|
||||
return {
|
||||
bossId: primary.boss.id,
|
||||
paused: false,
|
||||
@@ -200,6 +260,16 @@ function initialState(
|
||||
healerClassId,
|
||||
playerName,
|
||||
phase: "briefing" as GamePhase,
|
||||
runMode,
|
||||
round,
|
||||
runBuffs: [...runBuffs],
|
||||
draftBuffIds: [...RUN_BUFF_ORDER],
|
||||
selectedRunBuffId: RUN_BUFF_ORDER[0],
|
||||
healingMultiplier: runHealingMultiplier(effectiveRunBuffs) * gearModifiers.aelia.healingPower,
|
||||
difficultySlug,
|
||||
difficultyDamageMultiplier: difficulty.damageMultiplier,
|
||||
gearProgress,
|
||||
gearModifiers,
|
||||
time: 0,
|
||||
party,
|
||||
boss: primary.boss,
|
||||
@@ -208,8 +278,8 @@ function initialState(
|
||||
bossMotion: primary.motion,
|
||||
partyCombat: createPartyCombatState(party),
|
||||
partyDamageEvents: [] as PartyDamageEvent[],
|
||||
mana: 100,
|
||||
maxMana: 100,
|
||||
mana: maxMana,
|
||||
maxMana,
|
||||
selectedMemberId: "brann" as MemberId,
|
||||
cooldowns: emptyCooldowns(),
|
||||
globalCooldownUntil: 0,
|
||||
@@ -227,13 +297,15 @@ function initialState(
|
||||
export const useGameStore = create<GameState>((set, get) => ({
|
||||
...initialState(),
|
||||
|
||||
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome") => set(initialState(healerClassId, playerName, inventory, bossIds)),
|
||||
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => {
|
||||
set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, [], gearProgress, difficultySlug));
|
||||
},
|
||||
|
||||
startEncounter: () => {
|
||||
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
|
||||
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffs, gearProgress, difficultySlug } = get();
|
||||
const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
|
||||
set({
|
||||
...initialState(healerClassId, playerName, inventory, bossIds),
|
||||
...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffs, gearProgress, difficultySlug),
|
||||
phase: "combat",
|
||||
activeTab: "combat",
|
||||
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
|
||||
@@ -241,8 +313,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
},
|
||||
|
||||
restart: () => {
|
||||
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
|
||||
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]));
|
||||
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get();
|
||||
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, [], gearProgress, difficultySlug));
|
||||
},
|
||||
|
||||
selectMember: (selectedMemberId) => set({ selectedMemberId }),
|
||||
@@ -261,7 +333,33 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
|
||||
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
|
||||
setPauseSelection: (pauseSelection) => set({ pauseSelection }),
|
||||
setSelectedRunBuff: (selectedRunBuffId) => set((state) =>
|
||||
state.phase === "intermission" && state.draftBuffIds.includes(selectedRunBuffId)
|
||||
? { selectedRunBuffId }
|
||||
: state
|
||||
),
|
||||
chooseRunBuff: (buffId) => {
|
||||
const state = get();
|
||||
if (state.phase !== "intermission" || !state.draftBuffIds.includes(buffId)) return false;
|
||||
const runBuffs = [...state.runBuffs, buffId];
|
||||
const round = state.round + 1;
|
||||
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
|
||||
const bossIds = selectRandomBossPair(previousBossIds);
|
||||
set({
|
||||
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffs, state.gearProgress, state.difficultySlug),
|
||||
phase: "combat",
|
||||
activeTab: "combat",
|
||||
combatLog: [{
|
||||
id: Date.now(),
|
||||
time: 0,
|
||||
message: `${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
|
||||
tone: "good",
|
||||
}],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setPlayerPosition: (playerPosition) => set((state) => {
|
||||
playerPosition = clampToArena(playerPosition);
|
||||
const current = state.playerPosition;
|
||||
const partyCurrent = state.partyPositions.aelia;
|
||||
if (current[0] === playerPosition[0]
|
||||
@@ -331,9 +429,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
case "shield":
|
||||
party[selectedIndex] = {
|
||||
...party[selectedIndex],
|
||||
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36),
|
||||
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36 * state.gearModifiers.aelia.healingPower),
|
||||
};
|
||||
message = `${selected.name} gains 36 absorption.`;
|
||||
message = `${selected.name} gains ${Math.round(36 * state.gearModifiers.aelia.healingPower)} absorption.`;
|
||||
break;
|
||||
case "purify":
|
||||
{
|
||||
@@ -354,7 +452,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
|
||||
break;
|
||||
case "radiance":
|
||||
party = party.map((member) => healMember(member, 22));
|
||||
party = party.map((member) => healMember(member, 22 * state.healingMultiplier));
|
||||
message = `${ability.name} heals the full party.`;
|
||||
break;
|
||||
case "barrier":
|
||||
@@ -365,7 +463,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
|
||||
const cooldowns = {
|
||||
...state.cooldowns,
|
||||
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown : 0,
|
||||
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown * state.gearModifiers.aelia.cooldown : 0,
|
||||
};
|
||||
const pulse: ScenePulse = {
|
||||
id: state.scenePulse.id + 1,
|
||||
@@ -413,9 +511,10 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
|
||||
const target = party[targetIndex];
|
||||
if (target?.hp > 0) {
|
||||
party[targetIndex] = healMember(target, 38);
|
||||
const healing = 38 * state.healingMultiplier;
|
||||
party[targetIndex] = healMember(target, healing);
|
||||
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
|
||||
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for 38.`, "good");
|
||||
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
|
||||
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
|
||||
}
|
||||
activeCast = null;
|
||||
@@ -427,7 +526,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
let tickAt = next.renewNextTickAt;
|
||||
const lastTickAt = Math.min(time, next.renewExpiresAt);
|
||||
while (tickAt <= lastTickAt + 0.001) {
|
||||
next = healMember(next, 7);
|
||||
next = healMember(next, 7 * state.healingMultiplier);
|
||||
tickAt += 1;
|
||||
}
|
||||
next = {
|
||||
@@ -441,7 +540,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
.map((debuff) => {
|
||||
let updated = { ...debuff };
|
||||
while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
|
||||
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann);
|
||||
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers);
|
||||
updated.nextTickAt += 1;
|
||||
}
|
||||
return updated;
|
||||
@@ -454,7 +553,12 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
...(boss.hp > 0 ? [bossMotion] : []),
|
||||
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
|
||||
];
|
||||
partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime);
|
||||
partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime, undefined, {
|
||||
brann: state.gearModifiers.brann.moveSpeed,
|
||||
nia: state.gearModifiers.nia.moveSpeed,
|
||||
orin: state.gearModifiers.orin.moveSpeed,
|
||||
vale: state.gearModifiers.vale.moveSpeed,
|
||||
});
|
||||
|
||||
const encounterBosses: AdditionalBossState[] = [
|
||||
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
|
||||
@@ -463,6 +567,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
for (let index = 0; index < encounterBosses.length; index += 1) {
|
||||
const encounterBoss = encounterBosses[index];
|
||||
if (encounterBoss.boss.hp <= 0) continue;
|
||||
const partyBeforeMechanic = party;
|
||||
const mechanicResult = advanceBossMechanics({
|
||||
boss: encounterBoss.boss,
|
||||
motion: encounterBoss.motion,
|
||||
@@ -470,10 +575,14 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
partyPositions,
|
||||
time,
|
||||
delta: time - oldTime,
|
||||
damageMember: (member, amount, position, at) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann),
|
||||
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
|
||||
});
|
||||
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
|
||||
party = mechanicResult.party.map((member) => {
|
||||
const previous = partyBeforeMechanic.find((candidate) => candidate.id === member.id);
|
||||
if (!previous || member.knockedUntil <= previous.knockedUntil || member.knockedUntil <= time) return member;
|
||||
return { ...member, knockedUntil: time + (member.knockedUntil - time) * state.gearModifiers[member.id].stunDuration };
|
||||
});
|
||||
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: mechanicResult.motion };
|
||||
party = mechanicResult.party;
|
||||
for (const event of mechanicResult.events) {
|
||||
combatLog = addLog(combatLog, event.at, event.message, event.tone);
|
||||
if (event.pulseKind) {
|
||||
@@ -493,6 +602,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
positions: partyPositions,
|
||||
targets: encounterBosses,
|
||||
upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY,
|
||||
gearModifiers: aiCombatModifiers(state.gearModifiers),
|
||||
});
|
||||
partyCombat = partyCombatResult.state;
|
||||
partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24);
|
||||
@@ -507,7 +617,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
const healer = party.find((member) => member.id === "aelia")!;
|
||||
let phase: GamePhase = state.phase;
|
||||
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
||||
phase = "victory";
|
||||
phase = state.runMode === "roguelike" ? "intermission" : "victory";
|
||||
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
|
||||
} else if (tank.hp <= 0 || healer.hp <= 0) {
|
||||
phase = "defeat";
|
||||
@@ -546,6 +656,8 @@ export type GameSnapshot = Omit<GameState,
|
||||
| "setPaused"
|
||||
| "togglePause"
|
||||
| "setPauseSelection"
|
||||
| "setSelectedRunBuff"
|
||||
| "chooseRunBuff"
|
||||
>;
|
||||
|
||||
export function getGameSnapshot(): GameSnapshot {
|
||||
@@ -563,6 +675,8 @@ export function getGameSnapshot(): GameSnapshot {
|
||||
setPaused: _setPaused,
|
||||
togglePause: _togglePause,
|
||||
setPauseSelection: _setPauseSelection,
|
||||
setSelectedRunBuff: _setSelectedRunBuff,
|
||||
chooseRunBuff: _chooseRunBuff,
|
||||
...snapshot
|
||||
} = useGameStore.getState();
|
||||
return snapshot;
|
||||
|
||||
+54
-4
@@ -1,7 +1,21 @@
|
||||
export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
|
||||
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
|
||||
export type BossId = "bulldrome" | "vexa" | "cindermaw" | "ember-mantis-duelist";
|
||||
export type GamePhase = "briefing" | "combat" | "victory" | "defeat";
|
||||
export type BossId =
|
||||
| "bulldrome"
|
||||
| "vexa"
|
||||
| "cindermaw"
|
||||
| "ember-mantis-duelist"
|
||||
| "obsidian-ram-golem"
|
||||
| "cinderback-ricochet"
|
||||
| "sandglass-scorpion"
|
||||
| "cragclaw-crab"
|
||||
| "mournveil-ghost"
|
||||
| "crownshard-golem"
|
||||
| "pumpking-king-of-ghosts"
|
||||
| "blue-eyes-ultimate-dragon";
|
||||
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
|
||||
export type RunMode = "encounter" | "roguelike";
|
||||
export type RunBuffId = "vital-bloom" | "deep-wells" | "restoring-grace";
|
||||
export type BottomTab = "combat" | "map" | "pack";
|
||||
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
|
||||
export type BossMotionMode =
|
||||
@@ -19,15 +33,51 @@ export type BossMotionMode =
|
||||
| "mantis_sidestep"
|
||||
| "mantis_line_telegraph"
|
||||
| "mantis_cross_telegraph"
|
||||
| "mantis_recover";
|
||||
| "mantis_recover"
|
||||
| "ram_charge_telegraph"
|
||||
| "ram_charging"
|
||||
| "ram_quake"
|
||||
| "ram_shatter"
|
||||
| "ram_recover"
|
||||
| "cinderback_curl"
|
||||
| "cinderback_ricochet"
|
||||
| "cinderback_slam"
|
||||
| "cinderback_recover"
|
||||
| "sandglass_burrow_telegraph"
|
||||
| "sandglass_burrowing"
|
||||
| "sandglass_eruption"
|
||||
| "sandglass_hourglass"
|
||||
| "sandglass_recover"
|
||||
| "crab_scuttle_telegraph"
|
||||
| "crab_scuttling"
|
||||
| "crab_tidal_burst"
|
||||
| "crab_recover"
|
||||
| "ghost_soul_cross"
|
||||
| "ghost_soul_cross_followup"
|
||||
| "ghost_haunting"
|
||||
| "ghost_recover"
|
||||
| "golem_shockwave"
|
||||
| "golem_crownfall"
|
||||
| "golem_recover";
|
||||
export type WorldPosition = [number, number];
|
||||
|
||||
export type CircleHazardKind = "venom_pool" | "skyfall";
|
||||
export type CircleHazardKind =
|
||||
| "venom_pool"
|
||||
| "skyfall"
|
||||
| "quake"
|
||||
| "lava_pool"
|
||||
| "stinger_eruption"
|
||||
| "hourglass"
|
||||
| "tidal_burst"
|
||||
| "soul_rift"
|
||||
| "crownfall"
|
||||
| "royal_shockwave";
|
||||
|
||||
export interface CircleHazard {
|
||||
id: string;
|
||||
kind: CircleHazardKind;
|
||||
center: WorldPosition;
|
||||
innerRadius?: number;
|
||||
radius: number;
|
||||
activatesAt: number;
|
||||
expiresAt: number;
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { ABILITY_ORDER } from "./data";
|
||||
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick } from "./performance";
|
||||
import { useGameStore } from "./store";
|
||||
import type { AbilityId } from "./types";
|
||||
|
||||
function cycleRunBuff(direction: 1 | -1) {
|
||||
const store = useGameStore.getState();
|
||||
const currentIndex = store.draftBuffIds.indexOf(store.selectedRunBuffId);
|
||||
const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length;
|
||||
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
|
||||
}
|
||||
|
||||
const gamepadAbilityMap: Record<number, AbilityId> = {
|
||||
0: "purify",
|
||||
1: "shield",
|
||||
@@ -23,7 +31,9 @@ export function useGameLoop() {
|
||||
previous = now;
|
||||
accumulator += delta;
|
||||
if (accumulator >= 0.1) {
|
||||
const tickStartedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
|
||||
useGameStore.getState().tick(accumulator);
|
||||
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - tickStartedAt);
|
||||
accumulator = 0;
|
||||
}
|
||||
frame = requestAnimationFrame(loop);
|
||||
@@ -53,6 +63,14 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (store.phase === "intermission") {
|
||||
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
|
||||
if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1);
|
||||
if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1);
|
||||
if (key === "enter") store.chooseRunBuff(store.selectedRunBuffId);
|
||||
if (key === "escape") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
const numberIndex = Number(event.key) - 1;
|
||||
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
||||
store.castAbility(ABILITY_ORDER[numberIndex]);
|
||||
@@ -99,6 +117,13 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (store.phase === "intermission") {
|
||||
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1);
|
||||
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1);
|
||||
if (!repeat && token === "Button0") store.chooseRunBuff(store.selectedRunBuffId);
|
||||
if (!repeat && token === "Button1") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (repeat) return;
|
||||
if (token.startsWith("Button")) {
|
||||
const ability = gamepadAbilityMap[Number(token.slice("Button".length))];
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AppScreen } from "../frontend/types";
|
||||
import { FrontEnd } from "../components/FrontEnd";
|
||||
import { createDualScreenChannel, type DualScreenMessage, type FrontendCommand, type GameCommand } from "./dualScreenSync";
|
||||
import type { BossId } from "../game/types";
|
||||
import type { DifficultySlug } from "../game/progression/loot";
|
||||
import { useForcedThorDisplays } from "./useThorDualScreen";
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
|
||||
@@ -18,6 +19,7 @@ function screenTitle(screen: AppScreen) {
|
||||
case "saves": return "Choose hunter save";
|
||||
case "home": return "Choose expedition";
|
||||
case "profile": return "Hunter profile";
|
||||
case "gear": return "Gear upgrade";
|
||||
case "settings": return "Field settings";
|
||||
case "mode": return "Prepare encounter";
|
||||
case "game": return "Field console";
|
||||
@@ -64,8 +66,8 @@ export function BottomDisplayApp() {
|
||||
channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
|
||||
}, []);
|
||||
|
||||
const launchGame = useCallback((bossIds: readonly BossId[]) => {
|
||||
postFrontendCommand({ name: "launchGame", bossIds });
|
||||
const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug) => {
|
||||
postFrontendCommand({ name: "launchGame", bossIds, difficultySlug });
|
||||
}, [postFrontendCommand]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +117,23 @@ export function BottomDisplayApp() {
|
||||
downloadSlot: (slotId) => postFrontend({ name: "downloadSlot", slotId }),
|
||||
selectMode: (mode) => postFrontend({ name: "selectMode", mode }),
|
||||
selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }),
|
||||
selectDifficulty: (difficultySlug) => postFrontend({ name: "selectDifficulty", difficultySlug }),
|
||||
selectGearOwner: (ownerId) => postFrontend({ name: "selectGearOwner", ownerId }),
|
||||
selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }),
|
||||
selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }),
|
||||
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
|
||||
upgradeSelectedGear: () => {
|
||||
postFrontend({ name: "upgradeSelectedGear" });
|
||||
return false;
|
||||
},
|
||||
equipSelectedInfusion: () => {
|
||||
postFrontend({ name: "equipSelectedInfusion" });
|
||||
return false;
|
||||
},
|
||||
equipPassiveInfusion: (passiveId) => {
|
||||
postFrontend({ name: "equipPassiveInfusion", passiveId });
|
||||
return false;
|
||||
},
|
||||
selectHealerClass: (classId) => postFrontend({ name: "selectHealerClass", classId }),
|
||||
updateSetting: (key, value) => postFrontend({ name: "updateSetting", key, value }),
|
||||
});
|
||||
@@ -131,6 +150,11 @@ export function BottomDisplayApp() {
|
||||
selectItem: (itemId) => postCommand({ name: "selectItem", itemId }),
|
||||
setPaused: (paused) => postCommand({ name: "setPaused", paused }),
|
||||
setPauseSelection: (selection) => postCommand({ name: "setPauseSelection", selection }),
|
||||
setSelectedRunBuff: (buffId) => postCommand({ name: "setSelectedRunBuff", buffId }),
|
||||
chooseRunBuff: (buffId) => {
|
||||
postCommand({ name: "chooseRunBuff", buffId });
|
||||
return false;
|
||||
},
|
||||
});
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "authoritative-ready") {
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { FrontendSnapshot } from "../frontend/store";
|
||||
import { useFrontendStore } from "../frontend/store";
|
||||
import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller";
|
||||
import { getGameSnapshot, type GameSnapshot, useGameStore } from "../game/store";
|
||||
import type { AbilityId, BottomTab, MemberId } from "../game/types";
|
||||
import type { AbilityId, BottomTab, MemberId, RunBuffId } from "../game/types";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
|
||||
import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
|
||||
import type { DifficultySlug } from "../game/progression/loot";
|
||||
|
||||
const CHANNEL_NAME = "i-want-to-heal:thor-dual-screen:v1";
|
||||
|
||||
@@ -18,7 +20,9 @@ export type GameCommand =
|
||||
| { name: "setActiveTab"; tab: BottomTab }
|
||||
| { name: "selectItem"; itemId: string }
|
||||
| { name: "setPaused"; paused: boolean }
|
||||
| { name: "setPauseSelection"; selection: "resume" | "exit" };
|
||||
| { name: "setPauseSelection"; selection: "resume" | "exit" }
|
||||
| { name: "setSelectedRunBuff"; buffId: RunBuffId }
|
||||
| { name: "chooseRunBuff"; buffId: RunBuffId };
|
||||
|
||||
export type FrontendCommand =
|
||||
| { name: "signIn"; username: string; password: string }
|
||||
@@ -35,9 +39,17 @@ export type FrontendCommand =
|
||||
| { name: "downloadSlot"; slotId: SaveSlotId }
|
||||
| { name: "selectMode"; mode: GameModeId }
|
||||
| { name: "selectBoss"; bossId: BossId }
|
||||
| { name: "selectDifficulty"; difficultySlug: DifficultySlug }
|
||||
| { name: "selectGearOwner"; ownerId: GearOwnerId }
|
||||
| { name: "selectGearSlot"; slotId: GearSlotId }
|
||||
| { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" }
|
||||
| { name: "selectInfusion"; infusionId: string }
|
||||
| { name: "upgradeSelectedGear" }
|
||||
| { name: "equipSelectedInfusion" }
|
||||
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
||||
| { name: "selectHealerClass"; classId: HealerClassId }
|
||||
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
||||
| { name: "launchGame"; bossIds: readonly BossId[] };
|
||||
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug };
|
||||
|
||||
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
||||
|
||||
@@ -68,6 +80,8 @@ export function executeGameCommand(command: GameCommand) {
|
||||
case "selectItem": game.selectItem(command.itemId); break;
|
||||
case "setPaused": game.setPaused(command.paused); break;
|
||||
case "setPauseSelection": game.setPauseSelection(command.selection); break;
|
||||
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
|
||||
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +102,17 @@ export function executeFrontendCommand(command: FrontendCommand) {
|
||||
case "downloadSlot": frontend.downloadSlot(command.slotId); break;
|
||||
case "selectMode": frontend.selectMode(command.mode); break;
|
||||
case "selectBoss": frontend.selectBoss(command.bossId); break;
|
||||
case "selectDifficulty": frontend.selectDifficulty(command.difficultySlug); break;
|
||||
case "selectGearOwner": frontend.selectGearOwner(command.ownerId); break;
|
||||
case "selectGearSlot": frontend.selectGearSlot(command.slotId); break;
|
||||
case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break;
|
||||
case "selectInfusion": frontend.selectInfusion(command.infusionId); break;
|
||||
case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break;
|
||||
case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break;
|
||||
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
||||
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
||||
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
|
||||
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: command.bossIds })); break;
|
||||
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+252
-9
@@ -557,6 +557,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
.phase-victory { background: radial-gradient(circle at center, rgba(23, 71, 53, 0.42), rgba(3, 9, 8, 0.82)); }
|
||||
.phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); }
|
||||
.phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); }
|
||||
|
||||
.pause-overlay { position: absolute; z-index: 12; inset: 0; display: grid; place-items: center; background: rgba(2,8,7,0.76); backdrop-filter: blur(5px); pointer-events: auto; }
|
||||
@@ -1066,6 +1067,75 @@ button:focus-visible {
|
||||
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
||||
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
||||
|
||||
.buff-draft {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 3.5%;
|
||||
padding: 3.7% 4.5% 2.8%;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(232, 200, 114, 0.14), transparent 48%);
|
||||
}
|
||||
|
||||
.top-buff-draft {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
padding: 4.5% 8% 3.5%;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(232, 200, 114, 0.2), rgba(3, 9, 8, 0.96) 58%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.top-buff-draft .buff-choice-grid { gap: 3%; }
|
||||
.top-buff-draft .buff-choice-grid button { padding: 14px; }
|
||||
|
||||
.intermission-status {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8%;
|
||||
background: radial-gradient(circle at 50% 12%, rgba(232, 200, 114, 0.13), transparent 52%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intermission-status > i { color: var(--gold); font-family: "Cinzel", serif; font-size: 34px; font-style: normal; }
|
||||
.intermission-status > span { margin-top: 12px; color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.intermission-status h2 { margin: 4px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; }
|
||||
.intermission-status p { max-width: 310px; margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); }
|
||||
.intermission-status small { margin-top: 16px; color: #dce8e3; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
|
||||
.buff-draft > header { text-align: center; }
|
||||
.buff-draft > header > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.buff-draft > header h2 { margin: 2px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; }
|
||||
.buff-draft > header p { margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); }
|
||||
.buff-choice-grid { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.5%; }
|
||||
.buff-choice-grid button {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
align-content: start;
|
||||
gap: 7px 8px;
|
||||
padding: 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--buff-accent), transparent 62%);
|
||||
border-top: 3px solid var(--buff-accent);
|
||||
color: #dce8e3;
|
||||
background: linear-gradient(155deg, color-mix(in srgb, var(--buff-accent), #081310 91%), rgba(5, 13, 11, 0.94));
|
||||
text-align: left;
|
||||
}
|
||||
.buff-choice-grid button.is-controller-focused { outline: 2px solid var(--gold-strong); outline-offset: 2px; transform: translateY(-2px); }
|
||||
.buff-choice-grid button > i { grid-row: 1 / 3; width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--buff-accent); color: var(--buff-accent); font-family: "Cinzel", serif; font-size: 16px; font-style: normal; }
|
||||
.buff-choice-grid button > span { min-width: 0; display: grid; }
|
||||
.buff-choice-grid button small { color: var(--buff-accent); font-size: 6px; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.buff-choice-grid button strong { overflow: hidden; font-family: "Cinzel", serif; font-size: clamp(9px, 2cqw, 12px); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.buff-choice-grid button > b { grid-column: 1 / -1; color: #edf5f1; font-size: clamp(8px, 1.7cqw, 10px); }
|
||||
.buff-choice-grid button > p { grid-column: 1 / -1; margin: 0; color: #71867e; font-size: clamp(7px, 1.45cqw, 9px); line-height: 1.25; }
|
||||
.buff-draft > footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #6e827a; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.buff-draft > footer b { color: #dce8e3; }
|
||||
.buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); }
|
||||
|
||||
/* Map */
|
||||
|
||||
.map-panel {
|
||||
@@ -1339,6 +1409,16 @@ button:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface] .dedicated-display-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #030706;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface="top"] .top-display,
|
||||
.native-platform[data-display-surface="top"] .front-surface {
|
||||
width: 100%;
|
||||
@@ -1347,13 +1427,14 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display-root,
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display {
|
||||
.native-platform[data-display-surface="bottom"] .dedicated-display-surface,
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display {
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display {
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
@@ -1815,16 +1896,17 @@ button:focus-visible {
|
||||
.mode-hero h2 { margin: 8px 0; font-family: "Cinzel", serif; font-size: 35px; font-weight: 500; line-height: 1; }
|
||||
.mode-hero p { margin: 0; color: #98aca4; font-size: 13px; line-height: 1.45; }
|
||||
.mode-hero > b { display: block; margin-top: 15px; color: #cbd9d4; font-size: 10px; font-weight: 600; }
|
||||
.boss-picker { position: absolute; top: 102px; right: 38px; width: 310px; display: grid; gap: 7px; }
|
||||
.boss-picker { position: absolute; top: 84px; right: 38px; left: 38px; display: grid; gap: 4px; }
|
||||
.boss-picker > span { color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||
.boss-choice { min-height: 54px; display: grid; grid-template-columns: 34px 1fr 18px; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
|
||||
.boss-choice > i { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 15px; font-style: normal; }
|
||||
.boss-choice-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
|
||||
.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
|
||||
.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; }
|
||||
.boss-choice > span { display: grid; min-width: 0; }
|
||||
.boss-choice strong { font-family: "Cinzel", serif; font-size: 11px; }
|
||||
.boss-choice small { overflow: hidden; color: #72877f; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.boss-choice strong { font-family: "Cinzel", serif; font-size: 9px; }
|
||||
.boss-choice small { overflow: hidden; color: #72877f; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.boss-choice > b { color: var(--boss-accent); }
|
||||
.boss-choice.is-selected { border-color: var(--boss-accent); box-shadow: inset 3px 0 var(--boss-accent); background: color-mix(in srgb, var(--boss-accent) 10%, rgba(6,18,16,0.9)); }
|
||||
.mode-launch { position: absolute; right: 38px; bottom: 56px; width: 275px; min-height: 58px; display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-color: var(--gold) !important; color: #182019 !important; background: linear-gradient(110deg, #ffe499, #d1af54) !important; text-align: left; }
|
||||
.mode-launch { position: absolute; right: 38px; bottom: 18px; width: 275px; min-height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-color: var(--gold) !important; color: #182019 !important; background: linear-gradient(110deg, #ffe499, #d1af54) !important; text-align: left; }
|
||||
.mode-launch span { font-family: "Cinzel", serif; font-size: 13px; font-weight: 600; }
|
||||
.mode-launch small { font-size: 8px; font-weight: 700; text-transform: uppercase; opacity: 0.65; }
|
||||
.mode-surface > .front-notice { position: absolute; right: 38px; bottom: 20px; width: 360px; }
|
||||
@@ -1965,7 +2047,8 @@ button:focus-visible {
|
||||
.mode-hero h2 { margin: 3px 0; font-size: 18px; }
|
||||
.mode-hero p { font-size: 7px; }
|
||||
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
|
||||
.boss-picker { top: 54px; right: 14px; width: 36%; gap: 3px; }
|
||||
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; }
|
||||
.boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; }
|
||||
.boss-picker > span { font-size: 4px; }
|
||||
.boss-choice { min-height: 27px; grid-template-columns: 18px 1fr 9px; gap: 4px; padding: 3px 4px; }
|
||||
.boss-choice > i { width: 16px; height: 16px; font-size: 7px; }
|
||||
@@ -1976,3 +2059,163 @@ button:focus-visible {
|
||||
.mode-launch small { font-size: 4px; }
|
||||
.game-menu-button { padding: 3px 5px; font-size: 6px; }
|
||||
}
|
||||
|
||||
/* Data-driven loot and gear workshop */
|
||||
|
||||
.home-secondary-actions { grid-template-columns: repeat(3, 1fr); }
|
||||
.collection-grid { grid-template-columns: repeat(3, 1fr); gap: 9px; }
|
||||
.collection-drop { height: 154px; padding: 8px 9px; }
|
||||
.collection-drop.rarity-epic { border-top-color: #a071c1; }
|
||||
.collection-drop.rarity-legendary { border-top-color: #d79b42; }
|
||||
.collection-drop .drop-icon { width: 48px; height: 48px; margin: 2px auto 5px; font-size: 20px; }
|
||||
.collection-drop > strong { min-height: 26px; font-size: 10px; }
|
||||
.collection-drop > p { right: 9px; bottom: 7px; left: 9px; }
|
||||
|
||||
.difficulty-picker { position: absolute; left: 52px; bottom: 25px; width: 52%; display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; }
|
||||
.difficulty-picker > span { grid-column: 1 / -1; color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.difficulty-picker button { min-height: 39px; display: grid; padding: 5px 7px; text-align: left; }
|
||||
.difficulty-picker button strong { font-size: 8px; }
|
||||
.difficulty-picker button small { color: #71867e; font-size: 6px; }
|
||||
.difficulty-picker button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(96,76,27,.22); }
|
||||
.mode-loot-preview { position: absolute; right: 5.5%; bottom: 89px; left: 5.5%; display: grid; padding: 9px 12px; border-left: 2px solid var(--gold); background: rgba(69,55,19,.14); }
|
||||
.mode-loot-preview span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.mode-loot-preview b { font-size: 10px; }
|
||||
.mode-loot-preview small { color: #71867e; font-size: 7px; }
|
||||
|
||||
.gear-surface { padding: 0 28px; }
|
||||
.gear-surface .front-screen-header { grid-template-columns: 190px minmax(0, 1fr) auto auto; }
|
||||
.gear-mode-tabs { display: flex; gap: 4px; }
|
||||
.gear-mode-tabs button { padding: 6px 8px; color: #7f958c; font-size: 7px; font-weight: 700; text-transform: uppercase; }
|
||||
.gear-mode-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.2); }
|
||||
.gear-workshop-layout { height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; }
|
||||
.gear-owner-list,
|
||||
.gear-slot-list { display: grid; align-content: start; gap: 5px; }
|
||||
.gear-owner-list button,
|
||||
.gear-slot-list button { width: 100%; min-height: 43px; display: grid; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
|
||||
.gear-owner-list button { grid-template-columns: 1fr 15px; }
|
||||
.gear-slot-list button { grid-template-columns: 27px 1fr 28px; }
|
||||
.gear-owner-list button > span,
|
||||
.gear-slot-list button > span { display: grid; }
|
||||
.gear-owner-list strong,
|
||||
.gear-slot-list strong { font-size: 9px; }
|
||||
.gear-owner-list small,
|
||||
.gear-slot-list small { color: #71867e; font-size: 6px; text-transform: uppercase; }
|
||||
.gear-owner-list button > b,
|
||||
.gear-slot-list button > b { color: var(--gold); font-size: 10px; text-align: right; }
|
||||
.gear-slot-list button > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid #3d554c; color: #8fc4b1; font-style: normal; }
|
||||
.gear-owner-list button.is-selected,
|
||||
.gear-slot-list button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: rgba(72,58,21,.16); }
|
||||
.gear-preview { padding: 16px; border: 1px solid var(--line); background: radial-gradient(circle at 70% 20%, rgba(232,200,114,.1), transparent 45%), rgba(7,18,15,.84); }
|
||||
.gear-preview > span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.gear-preview h2 { margin: 8px 0; font: 500 17px "Cinzel", serif; }
|
||||
.gear-preview p { color: #81958d; font-size: 9px; line-height: 1.45; }
|
||||
.gear-stat-comparison { display: grid; grid-template-columns: 1fr 20px 1fr; align-items: center; gap: 7px; margin-top: 24px; }
|
||||
.gear-stat-comparison > span { display: grid; padding: 11px; border: 1px solid #354b43; background: rgba(4,13,11,.7); }
|
||||
.gear-stat-comparison small { color: #71867e; font-size: 6px; text-transform: uppercase; }
|
||||
.gear-stat-comparison strong { color: var(--gold-strong); font: 500 18px "Cinzel", serif; }
|
||||
.gear-stat-comparison > i { color: #60766d; font-style: normal; text-align: center; }
|
||||
.gear-infusion-preview h2 { margin-bottom: 4px; }
|
||||
.gear-infusion-options,
|
||||
.gear-passive-options { display: grid; gap: 4px; margin-top: 9px; }
|
||||
.gear-infusion-options button,
|
||||
.gear-passive-options button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; }
|
||||
.gear-infusion-options button > i,
|
||||
.gear-passive-options button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; }
|
||||
.gear-infusion-options button > span,
|
||||
.gear-passive-options button > span { min-width: 0; display: grid; }
|
||||
.gear-infusion-options button strong,
|
||||
.gear-passive-options button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gear-infusion-options button small,
|
||||
.gear-passive-options button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gear-infusion-options button > b,
|
||||
.gear-passive-options button > b { color: var(--gold); font-size: 9px; }
|
||||
.gear-infusion-options button.is-selected { border-color: var(--gold); background: rgba(72,58,21,.16); }
|
||||
.gear-infusion-options button.is-equipped,
|
||||
.gear-passive-options button.is-equipped { box-shadow: inset 3px 0 #67c89e; }
|
||||
.gear-passive-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.gear-passive-options > span { grid-column: 1 / -1; color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.gear-passive-options button { min-width: 0; grid-template-columns: 18px 1fr 10px; }
|
||||
|
||||
.gear-context { padding: 0 5.5% 18px; }
|
||||
.gear-context .context-header { margin: 0 -5.8%; }
|
||||
.gear-costs { margin-top: 16px; }
|
||||
.gear-costs > span { color: #71867e; font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.gear-costs article { min-height: 68px; display: grid; grid-template-columns: 28px 1fr 48px; align-items: center; gap: 10px; margin-top: 8px; padding: 9px 11px; border: 1px solid var(--line); background: rgba(6,17,14,.78); }
|
||||
.gear-costs article > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid currentColor; color: #ba6f68; font-style: normal; }
|
||||
.gear-costs article.is-met > i { color: #67c89e; }
|
||||
.gear-costs article > span { display: grid; }
|
||||
.gear-costs strong { font-size: clamp(9px, 2cqw, 12px); }
|
||||
.gear-costs small { color: #71867e; font-size: clamp(6px, 1.35cqw, 8px); }
|
||||
.gear-costs article > b { color: var(--gold); font-size: 12px; text-align: right; }
|
||||
.gear-upgrade-action { position: absolute; right: 5.5%; bottom: 57px; left: 5.5%; min-height: 56px; display: grid; padding: 9px 13px; border-color: var(--gold); color: #182019; background: linear-gradient(110deg, #ffe499, #d1af54); text-align: left; }
|
||||
.gear-upgrade-action:disabled { border-color: #42554e; color: #71827c; background: #13201c; }
|
||||
.gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; }
|
||||
.gear-upgrade-action small { font-size: clamp(6px, 1.35cqw, 8px); }
|
||||
|
||||
.reward-summary { display: grid; gap: 4px; margin: 8px 0; }
|
||||
.reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; }
|
||||
.reward-summary b { margin-right: 6px; color: var(--gold); }
|
||||
.reward-summary i { color: #d79b42; font-style: normal; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.home-secondary-actions { grid-template-columns: repeat(3, 1fr); }
|
||||
.collection-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.collection-drop { height: 96px; padding: 4px 5px; }
|
||||
.collection-drop .drop-icon { width: 25px; height: 25px; margin: 1px auto 2px; font-size: 10px; }
|
||||
.collection-drop > strong { min-height: 12px; font-size: 5px; }
|
||||
.collection-drop > p { display: none; }
|
||||
.collection-note { margin-top: 3px; padding: 2px 4px; }
|
||||
.profile-stats span { padding: 5px 7px; }
|
||||
.profile-stats strong { font-size: 12px; }
|
||||
.boss-log { margin-top: 5px; }
|
||||
.boss-log button { min-height: 40px; gap: 5px; margin-top: 3px; padding: 3px 6px; }
|
||||
.boss-log button > i { width: 23px; height: 23px; }
|
||||
.difficulty-picker { left: 16px; bottom: 23px; width: 58%; gap: 2px; }
|
||||
.difficulty-picker button { min-height: 27px; padding: 2px 3px; }
|
||||
.difficulty-picker button strong { font-size: 5px; }
|
||||
.difficulty-picker button small,
|
||||
.difficulty-picker > span { font-size: 4px; }
|
||||
.mode-dungeons .mode-hero { display: none; }
|
||||
.mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; }
|
||||
.mode-dungeons .boss-choice-grid { gap: 2px; }
|
||||
.mode-dungeons .boss-choice { min-width: 0; min-height: 27px; grid-template-columns: 15px 1fr 7px; gap: 2px; padding: 2px 3px; }
|
||||
.mode-dungeons .boss-choice > i { width: 14px; height: 14px; font-size: 6px; }
|
||||
.mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mode-dungeons .boss-choice small { display: none; }
|
||||
.mode-dungeons .difficulty-picker { right: 39%; bottom: 17px; left: 12px; width: auto; }
|
||||
.mode-dungeons .mode-launch { right: 12px; bottom: 17px; width: 34%; }
|
||||
.gear-surface { padding: 0 12px; }
|
||||
.gear-surface .front-screen-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
|
||||
.gear-mode-tabs { gap: 2px; }
|
||||
.gear-mode-tabs button { padding: 3px 4px; font-size: 4px; }
|
||||
.gear-workshop-layout { height: calc(100% - 53px); grid-template-columns: 27% 31% 1fr; gap: 4px; padding-top: 5px; }
|
||||
.gear-owner-list,
|
||||
.gear-slot-list { gap: 2px; }
|
||||
.gear-owner-list button,
|
||||
.gear-slot-list button { min-height: 27px; gap: 3px; padding: 2px 4px; }
|
||||
.gear-slot-list button { grid-template-columns: 16px 1fr 17px; }
|
||||
.gear-owner-list strong,
|
||||
.gear-slot-list strong { font-size: 5px; }
|
||||
.gear-owner-list small,
|
||||
.gear-slot-list small { font-size: 4px; }
|
||||
.gear-slot-list button > i { width: 14px; height: 14px; font-size: 6px; }
|
||||
.gear-preview { padding: 6px; }
|
||||
.gear-preview h2 { margin: 3px 0; font-size: 8px; }
|
||||
.gear-preview p { font-size: 5px; }
|
||||
.gear-stat-comparison { gap: 2px; margin-top: 7px; }
|
||||
.gear-stat-comparison > span { padding: 4px; }
|
||||
.gear-stat-comparison strong { font-size: 9px; }
|
||||
.gear-stat-comparison small,
|
||||
.gear-preview > span { font-size: 4px; }
|
||||
.gear-infusion-options,
|
||||
.gear-passive-options { gap: 2px; margin-top: 3px; }
|
||||
.gear-infusion-options button,
|
||||
.gear-passive-options button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
|
||||
.gear-infusion-options button > i,
|
||||
.gear-passive-options button > i { font-size: 6px; }
|
||||
.gear-infusion-options button strong,
|
||||
.gear-passive-options button strong { font-size: 4px; }
|
||||
.gear-infusion-options button small,
|
||||
.gear-passive-options button small,
|
||||
.gear-passive-options > span { font-size: 3px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user