Update 3D game 2026-07-10 21:20

This commit is contained in:
Warren H
2026-07-10 21:20:17 -04:00
parent 141ec64963
commit e0be0458aa
720 changed files with 366857 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import { lazy, Suspense, useCallback, useEffect, useRef } from "react";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import { useActionBindings, useGameLoop } from "./game/useGameLoop";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
function GameLoadingScreen() {
return (
<DualDisplayFrame
top={<section className="display top-display game-loading"><span></span><strong>Opening the Ember Vault</strong><small>Preparing world and party</small></section>}
bottom={<section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Offline save secured</small></section>}
/>
);
}
export default function App() {
useGameLoop();
const screen = useFrontendStore((state) => state.screen);
const hunter = useActiveHunter();
const settings = useFrontendStore((state) => state.settings);
const navigate = useFrontendStore((state) => state.navigate);
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 leaveGame = useCallback(() => {
updateActiveHealerInventory(useGameStore.getState().inventory);
touchActiveSave();
navigate("home");
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[]) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds);
touchActiveSave();
navigate("game");
}, [hunter, navigate, touchActiveSave]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
document.documentElement.classList.toggle("large-interface-text", settings.largeText);
document.documentElement.classList.toggle("force-reduced-motion", settings.reducedMotion);
}, [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 (
<main className="prototype-shell">
<header className="prototype-header">
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
<p>Offline-first healer roguelike <i /> build 0.2</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
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";
function HealthBar({ member }: { member: PartyMember }) {
const health = Math.max(0, (member.hp / member.maxHp) * 100);
const shield = Math.min(38, (member.absorb / member.maxHp) * 100);
const shieldLeft = Math.min(health, 100 - shield);
return (
<span className="health-bar">
<i className={health < 35 ? "is-low" : ""} style={{ width: `${health}%` }} />
{shield > 0 && <b style={{ left: `${shieldLeft}%`, width: `${shield}%` }} />}
</span>
);
}
function PartyFrame({ member }: { member: PartyMember }) {
const selected = useGameStore((state) => state.selectedMemberId === member.id);
const selectMember = useGameStore((state) => state.selectMember);
const time = useGameStore((state) => state.time);
const renewRemaining = Math.max(0, member.renewExpiresAt - time);
const knockedRemaining = Math.max(0, member.knockedUntil - time);
const barrier = useGameStore((state) => state.barrier);
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
const tankPosition = useGameStore((state) => state.partyPositions.brann);
const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
const position = useGameStore((state) => state.partyPositions[member.id]);
const protectedByBarrier = barrierProtects(position, barrier, time);
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
: null;
return (
<button
className={`party-frame ${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
onClick={() => selectMember(member.id)}
aria-pressed={selected}
>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
<span className="party-name"><strong>{member.name}</strong><em>{Math.ceil(member.hp)} / {member.maxHp}</em></span>
<HealthBar member={member} />
<small>{currentAction ?? member.className}</small>
</span>
<span className="effect-stack">
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}></i>}
{renewRemaining > 0 && <i className="effect renew-effect" title={`Renew: ${renewRemaining.toFixed(1)} seconds`}>{Math.ceil(renewRemaining)}</i>}
{member.debuffs.length > 0 && <i className="effect debuff-effect" title={`${member.debuffs[0].name} — Purify`}>!</i>}
{protectedByBarrier && <i className="effect barrier-effect" title="Barrier: 30% reduced damage">B</i>}
{protectedByTank && <i className="effect tank-aura-effect" title="Bulwark March: 30% reduced damage">T</i>}
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
</span>
</button>
);
}
function PartyList() {
const party = useGameStore((state) => state.party);
return (
<div className="party-list">
<div className="section-label"><span>Formation</span><small>Q / E target</small></div>
{party.map((member) => <PartyFrame key={member.id} member={member} />)}
</div>
);
}
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = HEALER_CLASSES[healerClassId].abilities[abilityId];
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const remaining = abilityRemaining(abilityId, time, cooldowns);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget;
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as React.CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{ability.key}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / ability.cooldown) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
function AbilityTray() {
const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId];
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const time = useGameStore((state) => state.time);
const boss = useGameStore((state) => state.boss);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
return (
<div className="ability-column">
<div className="ability-meta">
<div className="target-chip"><span>Target</span><strong>{selected.name}</strong></div>
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
</div>
<div className="ability-grid">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
</div>
<div className={`incoming-strip ${mechanic.urgent ? "is-urgent" : ""}`}>
<span className="incoming-icon"></span>
<span><small>Incoming</small><strong>{mechanic.name}</strong></span>
<b>{mechanic.remaining.toFixed(1)}s</b>
<i><em style={{ width: `${Math.min(100, (mechanic.remaining / mechanic.cycle) * 100)}%` }} /></i>
</div>
</div>
);
}
function BriefingPanel() {
const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId];
const startEncounter = useGameStore((state) => state.startEncounter);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
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(" & ");
return (
<div className="briefing-panel">
<div className="briefing-class">
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
<div className="kit-grid">
{ABILITY_ORDER.map((abilityId) => {
const ability = healer.abilities[abilityId];
return (
<div className="kit-skill" key={abilityId} style={{ "--ability-color": ability.color } as React.CSSProperties}>
<b>{ability.icon}</b><span><strong>{ability.shortName}</strong><small>{ability.description}</small></span>
</div>
);
})}
</div>
</div>
</div>
);
}
function EndPanel() {
const phase = useGameStore((state) => state.phase);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart);
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<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>
<div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button>
</div>
</div>
);
}
function CombatPanel() {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
function MapPanel() {
const playerPosition = useGameStore((state) => state.playerPosition);
const partyPositions = useGameStore((state) => state.partyPositions);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const barrier = useGameStore((state) => state.barrier);
const time = useGameStore((state) => state.time);
const phase = useGameStore((state) => state.phase);
const bossId = useGameStore((state) => state.bossId);
const bossDefinition = BOSS_DEFINITIONS[bossId];
const playerX = 120 + playerPosition[0] * 7;
const playerY = 143 + playerPosition[1] * 5.3;
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
return (
<div className="map-panel">
<div className="map-copy">
<span>Ember Vault</span>
<h2>{bossDefinition.mapTitle}</h2>
<p>Single chamber trial. {bossDefinition.mapCopy}</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Boss <i className="legend-exit" /> Entry</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Ember Vault encounter map">
<defs>
<radialGradient id="room" cx="50%" cy="45%" r="60%"><stop offset="0" stopColor="#1b302b" /><stop offset="1" stopColor="#0a1715" /></radialGradient>
</defs>
<path className="map-room" d="M120 18 C181 18 218 62 218 128 C218 190 184 231 149 242 L149 271 L91 271 L91 242 C53 229 22 190 22 128 C22 62 59 18 120 18Z" fill="url(#room)" />
<path className="map-ring" d="M120 39 C169 39 195 75 195 128 C195 181 168 219 120 219 C72 219 45 181 45 128 C45 75 71 39 120 39Z" />
<path className="map-glyph" d="M120 74 L145 119 L120 164 L95 119 Z M76 175 L120 196 L164 175" />
{Array.from({ length: 8 }, (_, index) => {
const angle = (index / 8) * Math.PI * 2;
return <circle key={index} className="map-pillar" cx={120 + Math.sin(angle) * 77} cy={128 + Math.cos(angle) * 76} r="4" />;
})}
{bossMotions.map((motion, index) => {
const bossX = 120 + motion.position[0] * 7;
const bossY = 143 + motion.position[1] * 5.3;
return <g key={`${motion.bossId}-${index}`}>
<circle className="map-boss" cx={bossX} cy={bossY} r="9" />
<path className="map-boss-arrow" d={`M${bossX} ${bossY - 15} L${bossX + 6} ${bossY - 5} L${bossX - 6} ${bossY - 5} Z`} />
</g>;
})}
<circle className="map-player-pulse" cx={playerX} cy={playerY} r="12" />
<circle className="map-player" cx={playerX} cy={playerY} r="6" />
{barrier.expiresAt > time && (
<ellipse
className="map-barrier"
cx={120 + barrier.center[0] * 7}
cy={143 + barrier.center[1] * 5.3}
rx="21"
ry="15.9"
/>
)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
<circle
key={memberId}
className={`map-ally map-ally-${memberId}`}
data-member-id={memberId}
cx={120 + partyPositions[memberId][0] * 7}
cy={143 + partyPositions[memberId][1] * 5.3}
r="4"
/>
))}
<path className="map-entry" d="M103 258 L120 245 L137 258" />
</svg>
<span className="map-state">{phase === "combat" ? "LIVE TACTICAL FEED" : "STATIC ROUTE"}</span>
</div>
</div>
);
}
function PackPanel() {
const inventory = useGameStore((state) => state.inventory);
const selectedItemId = useGameStore((state) => state.selectedItemId);
const selectItem = useGameStore((state) => state.selectItem);
const item = inventory.find((entry) => entry.id === selectedItemId) ?? inventory[0];
return (
<div className="pack-panel">
<div className="item-list">
<div className="section-label"><span>Field pack</span><small>{inventory.length} / 12</small></div>
{inventory.map((entry) => (
<button key={entry.id} onClick={() => selectItem(entry.id)} className={entry.id === item.id ? "is-selected" : ""}>
<b>{entry.icon}</b><span><strong>{entry.name}</strong><small>{entry.slot}{entry.equipped ? " · Equipped" : ""}</small></span><i>{entry.rarity[0]}</i>
</button>
))}
</div>
<article className={`item-tooltip rarity-${item.rarity.toLowerCase()}`}>
<span className="tooltip-label">Item detail · lower display</span>
<div className="tooltip-title"><b>{item.icon}</b><span><h2>{item.name}</h2><small>{item.rarity} {item.slot}</small></span></div>
<div className="tooltip-stats">{item.stats.map((stat) => <strong key={stat}>{stat}</strong>)}</div>
<p>{item.effect}</p>
<footer><span>{item.equipped ? "✓ Equipped" : "Not equipped"}</span><small>Compare: hold Y</small></footer>
</article>
</div>
);
}
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "combat", label: "Heal", icon: "✦", key: "" },
{ id: "map", label: "Map", icon: "⌁", key: "M" },
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
export function BottomScreen() {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
return (
<section className="display bottom-display" aria-label="Tactical touch display">
<header className="lower-header">
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
<nav aria-label="Lower display sections">
{tabs.map((tab) => (
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
</button>
))}
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small>
</div>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
return (
<div className="device-frame">
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
{top}
<div className="hinge" aria-hidden="true"><i /><b>AYN THOR</b><i /></div>
<div className="screen-label bottom-label"><span>Context display</span><small>620 × 540 CSS · 1240 × 1080 · 60Hz</small></div>
{bottom}
</div>
);
}
+559
View File
@@ -0,0 +1,559 @@
import { useMemo, useState } from "react";
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } 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 { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import type { BossId } from "../game/types";
import { DualDisplayFrame } from "./DualDisplayFrame";
function FocusButton({
id,
focusedId,
focus,
className = "",
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; focusedId: string; focus: (id: string) => void }) {
return (
<button
{...props}
className={`${className} ${focusedId === id ? "is-controller-focused" : ""}`}
onFocus={(event) => { focus(id); props.onFocus?.(event); }}
onPointerEnter={(event) => { focus(id); props.onPointerEnter?.(event); }}
/>
);
}
function FrontSurface({ className = "", children, bottom = false, ariaLabel }: { className?: string; children: React.ReactNode; bottom?: boolean; ariaLabel: string }) {
return (
<section className={`display front-surface ${bottom ? "bottom-display front-bottom" : "top-display front-top"} ${className}`} aria-label={ariaLabel}>
<div className="front-grain" aria-hidden="true" />
{children}
</section>
);
}
function BrandMark({ compact = false }: { compact?: boolean }) {
return (
<div className={`front-brand ${compact ? "is-compact" : ""}`}>
<span className="brand-sigil"></span>
<span><small>Healers answer the call</small><strong>I Want To Heal</strong></span>
</div>
);
}
function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function LoginScreen() {
const signIn = useFrontendStore((state) => state.signIn);
const continueOffline = useFrontendStore((state) => state.continueOffline);
const notice = useFrontendStore((state) => state.notice);
const [hunterId, setHunterId] = useState("wayfinder");
const actions = useMemo<MenuAction[]>(() => [
{ id: "sign-in", run: () => signIn(hunterId) },
{ id: "offline", run: continueOffline },
], [continueOffline, hunterId, signIn]);
const controller = useMenuController(actions);
return (
<DualDisplayFrame
top={
<FrontSurface className="login-surface" ariaLabel="I Want To Heal login">
<div className="login-aura" aria-hidden="true"><i /><b>+</b><i /></div>
<BrandMark />
<div className="login-copy">
<span>Offline-first hunter records</span>
<h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p>
</div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}>
<label htmlFor="hunter-id">Hunter ID</label>
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" />
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
<span>Sign in & sync</span><small>Online saves enabled</small>
</FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small>
</FocusButton>
</form>
{notice && <div className="front-notice">{notice}</div>}
<ControllerLegend />
</FrontSurface>
}
bottom={
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
<BrandMark compact />
<div className="offline-promise">
<span className="context-kicker">How saving works</span>
<ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Sync when ready</strong><small>Upload any slot after signing in.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
</FrontSurface>
}
/>
);
}
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
const save = slot.local;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
return (
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</span>
{save ? (
<>
<div className="slot-portrait">{save.hunterName[0]}<i></i></div>
<span className="slot-name"><strong>{save.hunterName}</strong><small>Level {save.healers[save.activeClassId].level} · {healer?.name}</small></span>
<span className="slot-location">{save.location}</span>
<span className="slot-meta"><b>{formatPlayTime(save.playSeconds)}</b><small>{formatSaveTimestamp(save.updatedAt)}</small></span>
</>
) : (
<div className="empty-slot"><b></b><strong>New hunter</strong><small>Empty offline slot</small></div>
)}
<i className="selection-chevron"></i>
</button>
);
}
function SaveScreen() {
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const selectedSlotId = useFrontendStore((state) => state.selectedSlotId);
const notice = useFrontendStore((state) => state.notice);
const selectSlot = useFrontendStore((state) => state.selectSlot);
const createSlot = useFrontendStore((state) => state.createSlot);
const playSlot = useFrontendStore((state) => state.playSlot);
const uploadSlot = useFrontendStore((state) => state.uploadSlot);
const downloadSlot = useFrontendStore((state) => state.downloadSlot);
const copySlot = useFrontendStore((state) => state.copySlot);
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
const navigate = useFrontendStore((state) => state.navigate);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | null>(null);
const [hunterName, setHunterName] = useState("");
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
const hasLocal = Boolean(selected.local);
const hasOnline = Boolean(selected.online);
const finishCreation = () => {
if (createSlot(selectedSlotId, hunterName)) {
setHunterName("");
setDialog(null);
}
};
const openCreation = () => {
setHunterName("");
setDialog("create");
};
const actions = useMemo<MenuAction[]>(() => dialog === "create"
? [
{ id: "confirm-create", run: finishCreation },
{ id: "cancel-create", run: () => setDialog(null) },
]
: dialog === "copy"
? slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => ({ id: `copy-${slot.id}`, run: () => { copySlot(selectedSlotId, slot.id); setDialog(null); } }))
: dialog === "delete"
? [
{ id: "confirm-delete", run: () => { deleteSlot(selectedSlotId); setDialog(null); } },
{ id: "cancel-delete", run: () => setDialog(null) },
]
: [
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
{ id: "copy", run: () => setDialog("copy"), enabled: hasLocal },
{ id: "delete", run: () => setDialog("delete"), enabled: hasLocal },
{ id: "back", run: () => navigate("login") },
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
const cloudStatus = !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
return (
<DualDisplayFrame
top={
<FrontSurface className={`save-surface ${dialog ? "has-dialog" : ""}`} ariaLabel="Save slots">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter records</span><h1>Choose a save</h1></div><b className={accountId ? "is-online" : ""}>{accountId ? `${accountId}` : "○ OFFLINE"}</b></header>
<div className="save-slot-grid">
{slots.map((slot) => (
<SlotCard
key={slot.id}
slot={slot}
selected={selectedSlotId === slot.id}
focused={controller.isFocused(`slot-${slot.id}`)}
onSelect={() => selectSlot(slot.id)}
onFocus={() => controller.focus(`slot-${slot.id}`)}
/>
))}
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div>
{dialog && (
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "create" ? (
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
<label htmlFor="new-hunter-name">Hunter name</label>
<input
id="new-hunter-name"
value={hunterName}
maxLength={MAX_HUNTER_NAME_LENGTH}
autoComplete="off"
autoFocus
onChange={(event) => setHunterName(event.target.value)}
onKeyDown={(event) => { if (event.key === "Escape") setDialog(null); }}
placeholder="Enter name"
/>
<small>{normalizeHunterName(hunterName).length}/{MAX_HUNTER_NAME_LENGTH}</small>
<div className="dialog-actions">
<FocusButton id="confirm-create" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit" disabled={!normalizeHunterName(hunterName)}>Create hunter</FocusButton>
<FocusButton id="cancel-create" focusedId={controller.focusedId} focus={controller.focus} type="button" onClick={() => setDialog(null)}>Cancel</FocusButton>
</div>
</form>
) : dialog === "copy" ? (
<>
<span>Copy local save</span><h2>Choose destination</h2><p>Destination local save will be overwritten. Online copies stay unchanged.</p>
<div className="dialog-actions">
{slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => (
<FocusButton key={slot.id} id={`copy-${slot.id}`} focusedId={controller.focusedId} focus={controller.focus} onClick={() => { copySlot(selectedSlotId, slot.id); setDialog(null); }}>
Slot {slot.id}<small>{slot.local ? "Overwrite" : "Empty"}</small>
</FocusButton>
))}
</div>
</>
) : (
<>
<span>Delete local save</span><h2>Erase slot {selectedSlotId}?</h2><p>Device copy will be removed. Existing online version remains available for download.</p>
<div className="dialog-actions">
<FocusButton id="confirm-delete" focusedId={controller.focusedId} focus={controller.focus} className="is-danger" onClick={() => { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local</FocusButton>
<FocusButton id="cancel-delete" focusedId={controller.focusedId} focus={controller.focus} onClick={() => setDialog(null)}>Cancel</FocusButton>
</div>
</>
)}
</div>
)}
</FrontSurface>
}
bottom={
<FrontSurface className="save-context" bottom ariaLabel="Selected save management">
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
<div className="selected-save-summary">
{selected.local ? (
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.updatedAt)}</time></span></>
) : (
<><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
)}
</div>
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
<div className="save-actions">
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small>
</FocusButton>
<div className="sync-actions">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}> Overwrite with online</FocusButton>
</div>
<div className="record-actions">
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => setDialog("copy")}>Copy save</FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => setDialog("delete")}>Delete save</FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
</div>
</div>
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div>
</FrontSurface>
}
/>
);
}
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" },
{ id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
];
function HomeScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const selectMode = useFrontendStore((state) => state.selectMode);
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
const navigate = useFrontendStore((state) => state.navigate);
const actions = useMemo<MenuAction[]>(() => [
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "dungeons", down: "roguelike-pvp" } },
{ 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" } },
...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",
down: "change-save",
},
})),
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-druid" } },
], [navigate, selectHealerClass, selectMode]);
const controller = useMenuController(actions, { columns: 2, onBack: () => navigate("saves") });
if (!hunter) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
const activeProgress = hunter.healers[hunter.activeClassId];
return (
<DualDisplayFrame
top={
<FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
<div className="mode-grid">
{HOME_MODES.map((mode) => (
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b></b>
</FocusButton>
))}
</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="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 />
</FrontSurface>
}
bottom={
<FrontSurface className="hunter-context" bottom ariaLabel="Active hunter summary">
<header className="context-header"><span>Active hunter</span><b>LOCAL AUTOSAVE</b></header>
<div className="hunter-card">
<div className="hunter-crest">{hunter.hunterName[0]}<i style={{ background: activeHealer.color }}>{activeHealer.icon}</i></div>
<span><small>Level {activeProgress.level} · {activeHealer.specialization}</small><h2>{hunter.hunterName}</h2><p>{hunter.location}</p></span>
</div>
<div className="hunter-stat-row">
<span><small>Boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
<span><small>Flawless</small><strong>{hunter.stats.flawlessClears}</strong></span>
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
</div>
<div className="healer-picker"><span>Choose healer</span><div>{HEALER_CLASS_ORDER.map((classId) => {
const healer = HEALER_CLASSES[classId];
const progress = hunter.healers[classId];
return <FocusButton key={classId} id={`class-${classId}`} focusedId={controller.focusedId} focus={controller.focus} className={classId === hunter.activeClassId ? "is-selected" : ""} aria-pressed={classId === hunter.activeClassId} onClick={() => selectHealerClass(classId)}>
<i style={{ color: healer.color }}>{healer.icon}</i><span><strong>{healer.name}</strong><small>Level {progress.level} · {progress.inventory.length} items</small></span><b>{classId === hunter.activeClassId ? "✓" : ""}</b>
</FocusButton>;
})}</div></div>
<FocusButton id="change-save" focusedId={controller.focusedId} focus={controller.focus} className="change-save" onClick={() => navigate("saves")}><span>Change save slot</span><small>Last saved {formatSaveTimestamp(hunter.updatedAt)}</small></FocusButton>
</FrontSurface>
}
/>
);
}
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 actions = useMemo<MenuAction[]>(() => [
...(hunter?.collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })) ?? []),
{ id: "back", run: () => navigate("home") },
], [hunter?.collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !collection) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length;
return (
<DualDisplayFrame
top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="collection-heading"><span><small>Boss spoils</small><h2>{collection.bossName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<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>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Every drop stays counted.</strong><small>Duplicates increase quantity instead of disappearing.</small></span></div>
</FrontSurface>
}
bottom={
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and boss list">
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
<div className="profile-stats">
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
<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) => (
<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>
</FocusButton>
))}</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>;
}
function SettingsScreen() {
const settings = useFrontendStore((state) => state.settings);
const updateSetting = useFrontendStore((state) => state.updateSetting);
const navigate = useFrontendStore((state) => state.navigate);
const notice = useFrontendStore((state) => state.notice);
const actions = useMemo<MenuAction[]>(() => [
{ id: "volume-down", run: () => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10)) },
{ id: "volume-up", run: () => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10)) },
{ id: "motion", run: () => updateSetting("reducedMotion", !settings.reducedMotion) },
{ id: "numbers", run: () => updateSetting("damageNumbers", !settings.damageNumbers) },
{ id: "text", run: () => updateSetting("largeText", !settings.largeText) },
{ id: "back", run: () => navigate("home") },
], [navigate, settings, updateSetting]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
return (
<DualDisplayFrame
top={
<FrontSurface className="settings-surface" ariaLabel="Settings">
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="settings-layout">
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
</div>
<div className="settings-save-state">{notice || "Settings write to offline storage immediately."}</div>
</FrontSurface>
}
bottom={
<FrontSurface className="controls-context" bottom ariaLabel="Controller mapping">
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
<div className="controller-map">
<div className="pad-diagram"><i></i><span><b></b></span><i></i></div>
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b></b><i className="b">B</i></span><i className="a">A</i></div>
</div>
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Start</b> Pause / menu</span></div>
<div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
/>
);
}
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
const hunter = useActiveHunter();
const modeId = useFrontendStore((state) => state.selectedMode);
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
const selectBoss = useFrontendStore((state) => state.selectBoss);
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 isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair());
if (isDungeon) return onLaunch([selectedBossId]);
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),
neighbors: {
up: index > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index < BOSS_ORDER.length - 1 ? `boss-${BOSS_ORDER[index + 1]}` : "launch",
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `boss-${BOSS_ORDER[BOSS_ORDER.length - 1]}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectedBossId]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
? [
[selectedBoss.name, selectedBoss.summary],
[selectedBoss.mechanics[0], selectedBoss.briefing],
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."],
]
: isPve
? [
["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."],
]
: [
["Draft a healing path", "Choose rites after every completed room."],
["Protect the formation", "Boss pressure changes around your build."],
["Bank collection drops", "Earned boss loot writes to active offline save."],
];
return (
<DualDisplayFrame
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="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>
)}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
{message && <div className="front-notice">{message}</div>}
</FrontSurface>
}
bottom={
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
<header className="context-header"><span>Run preparation</span><b>{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>
</FrontSurface>
}
/>
);
}
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => 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 === "settings") return <SettingsScreen />;
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
return null;
}
+906
View File
@@ -0,0 +1,906 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import * as THREE from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
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 DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.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,
nia: new URL("../../game_assets/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../../game_assets/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../../game_assets/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
};
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
aelia: { right: new URL("../../game_assets/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
brann: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
},
nia: { right: new URL("../../game_assets/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
orin: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
},
vale: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
},
};
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
aelia: "2H_Melee_Attack_Chop",
brann: "1H_Melee_Attack_Chop",
nia: "2H_Ranged_Shoot",
orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop",
};
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0
? { boss: state.boss, motion: state.bossMotion }
: state.additionalBosses[bossIndex - 1];
}
function targetBossMotion(state: GameStoreState) {
if (state.boss.hp > 0) return state.bossMotion;
return state.additionalBosses.find((entry) => entry.boss.hp > 0)?.motion ?? state.bossMotion;
}
function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) {
if (!instanceId || instanceId === `boss-0-${state.boss.id}`) return targetBossMotion(state);
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
}
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 }> = {
aelia: { right: "staff" },
brann: { right: "sword", left: "prop" },
nia: { right: "crossbow" },
orin: { right: "wand", left: "prop" },
vale: { right: "dagger", left: "dagger" },
};
const VARIANT_GRIPS: Record<Exclude<WeaponGrip, "crossbow" | "prop">, { lift: number; maxHeight: number }> = {
sword: { lift: 0.04, maxHeight: 2 },
dagger: { lift: 0.04, maxHeight: 1.4 },
staff: { lift: 0.18, maxHeight: 2.4 },
wand: { lift: 0.04, maxHeight: 1.2 },
};
function resolveRigNode(root: THREE.Object3D, authoredName: string) {
return root.getObjectByName(authoredName)
?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, ""));
}
function flattenWeaponScene(scene: THREE.Object3D) {
if (scene.children.length !== 1) return scene;
const holder = new THREE.Group();
const child = scene.children[0];
holder.scale.copy(child.scale);
child.position.set(0, 0, 0);
child.rotation.set(0, 0, 0);
child.scale.set(1, 1, 1);
scene.remove(child);
holder.add(child);
return holder;
}
function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" | "l") {
// Shields and spellbooks carry useful authored offsets, so keep their scene transform.
if (grip === "prop") return scene;
const weapon = flattenWeaponScene(scene);
if (grip === "crossbow") {
weapon.position.set(0.3381, 0.058, 0);
weapon.quaternion.set(0, 0.7071068, 0, 0.7071067);
weapon.scale.setScalar(0.7204);
return weapon;
}
const { lift, maxHeight } = VARIANT_GRIPS[grip];
const bounds = new THREE.Box3().setFromObject(weapon);
const height = bounds.max.y - bounds.min.y;
const scale = height > 0.001 ? Math.min(1, maxHeight / height) : 1;
weapon.position.set(0, lift, 0);
weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0);
weapon.scale.setScalar(scale);
return weapon;
}
function PartyCharacterModel({
memberId,
animationState,
}: {
memberId: MemberId;
animationState: MutableRefObject<ActorAnimationState>;
}) {
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const loadout = PARTY_WEAPON_URLS[memberId];
const grips = PARTY_WEAPON_GRIPS[memberId];
const rightWeapon = useGLTF(loadout.right, false, true);
const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true);
const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
const rightWeaponScene = useMemo(
() => prepareHeldWeapon(rightWeapon.scene.clone(true), grips.right, "r"),
[grips.right, rightWeapon.scene],
);
const leftWeaponScene = useMemo(
() => loadout.left ? prepareHeldWeapon(leftWeapon.scene.clone(true), grips.left ?? grips.right, "l") : null,
[grips.left, grips.right, leftWeapon.scene, loadout.left],
);
const { actions } = useAnimations(gltf.animations, actorScene);
const activeClip = useRef<string | undefined>(undefined);
useEffect(() => {
actorScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [actorScene]);
useEffect(() => {
for (const weaponScene of [rightWeaponScene, leftWeaponScene]) {
if (!weaponScene) continue;
weaponScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
// Animated hand sockets can cross a mesh's original root-space frustum.
object.frustumCulled = false;
}
});
}
}, [leftWeaponScene, rightWeaponScene]);
useFrame(() => {
const state = animationState.current;
const clipName = state === "death"
? "Death_A"
: state === "hit"
? "Hit_A"
: state === "run"
? "Running_A"
: state === "walk"
? "Walking_A"
: state === "cast"
? "Spellcasting"
: state === "attack"
? PARTY_ATTACK_CLIPS[memberId]
: "Idle";
if (activeClip.current === clipName) 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") {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = state === "death";
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
next.clampWhenFinished = false;
}
next.play();
activeClip.current = clipName;
});
return (
<>
<primitive object={actorScene} scale={PARTY_MODEL_SCALES[memberId]} />
{rightHandSlot && createPortal(<primitive object={rightWeaponScene} />, rightHandSlot)}
{leftWeaponScene && leftHandSlot && createPortal(<primitive object={leftWeaponScene} />, leftHandSlot)}
</>
);
}
function Arena() {
const columns = useMemo(() => {
return Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
}, []);
return (
<group>
<mesh position={[0, -0.45, -1]} receiveShadow>
<cylinderGeometry args={[10.5, 11.2, 0.8, 48]} />
<meshStandardMaterial color="#182420" roughness={0.92} />
</mesh>
<mesh position={[0, -0.015, -1]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<ringGeometry args={[4.8, 5.05, 64]} />
<meshBasicMaterial color="#765c32" transparent opacity={0.55} />
</mesh>
<mesh position={[0, -0.01, -1]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[2.1, 48]} />
<meshStandardMaterial color="#27342d" roughness={1} />
</mesh>
{columns.map(([x, z], index) => (
<group key={index} position={[x, 0, z - 1]}>
<mesh castShadow receiveShadow position={[0, 1.1, 0]}>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} />
</mesh>
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} />
<mesh position={[0, 2.6, 0]}>
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} />
</mesh>
</group>
))}
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
</group>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
useEffect(() => {
const start = useGameStore.getState().partyPositions[memberId];
group.current?.position.set(start[0], 0.025, start[1]);
}, [memberId]);
useFrame((_, delta) => {
if (!group.current) return;
const state = useGameStore.getState();
const target = state.partyPositions[memberId];
const dx = target[0] - group.current.position.x;
const dz = target[1] - group.current.position.z;
const moving = Math.hypot(dx, dz) > 0.015;
const movementBlend = 1 - Math.pow(0.002, delta);
group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], movementBlend);
group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], movementBlend);
const member = state.party.find((entry) => entry.id === memberId)!;
const knocked = member.knockedUntil > state.time;
const visualAction = state.partyCombat.combatants[memberId].visualAction;
const targetMotion = targetBossMotionByInstance(state, visualAction?.targetInstanceId);
const attacking = state.phase === "combat"
&& visualAction !== null
&& visualAction.endsAt > state.time;
animationState.current = member.hp <= 0
? "death"
: knocked
? "hit"
: attacking
? "attack"
: moving
? memberId === "vale" ? "run" : "walk"
: "idle";
if (!knocked && member.hp > 0 && (state.phase === "combat" || moving)) {
const faceBoss = state.phase === "combat";
const facingX = faceBoss ? targetMotion.position[0] - group.current.position.x : dx;
const facingZ = faceBoss ? targetMotion.position[1] - group.current.position.z : dz;
const targetAngle = Math.atan2(facingX, facingZ);
const angleDelta = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
Math.cos(targetAngle - group.current.rotation.y),
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
});
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} animationState={animationState} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
</mesh>
)}
</group>
);
}
function PlayerCharacter() {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const keys = useRef(new Set<string>());
const scenePulse = useGameStore((state) => state.scenePulse);
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
const setPlayerPosition = useGameStore((state) => state.setPlayerPosition);
const { camera } = useThree();
const broadcastTimer = useRef(0);
const castingUntil = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
const start = useGameStore.getState().partyPositions.aelia;
group.current?.position.set(start[0], 0.025, start[1]);
}, []);
useEffect(() => {
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
castingUntil.current = performance.now() + 700;
}
}, [scenePulse]);
useEffect(() => {
const down = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
keys.current.add(key);
if (event.repeat || !group.current) return;
const state = useGameStore.getState();
if (state.phase !== "combat" || state.paused || state.activeCast || state.party[0].hp <= 0 || state.party[0].knockedUntil > state.time) return;
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);
setPlayerPosition([group.current.position.x, group.current.position.z]);
};
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [setPlayerPosition]);
useFrame((_, delta) => {
if (!group.current) return;
let inputX = 0;
let inputZ = 0;
const state = useGameStore.getState();
const knocked = state.party[0].knockedUntil > state.time;
const player = state.party[0];
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
const gamepad = navigator.getGamepads?.()[0];
if (gamepad) {
inputX += Math.abs(gamepad.axes[0] ?? 0) > 0.18 ? gamepad.axes[0] : 0;
inputZ += Math.abs(gamepad.axes[1] ?? 0) > 0.18 ? gamepad.axes[1] : 0;
}
}
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);
group.current.rotation.y = Math.atan2(inputX, inputZ);
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
const boss = targetBossMotion(state).position;
const targetAngle = Math.atan2(boss[0] - group.current.position.x, boss[1] - group.current.position.z);
const angleDelta = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
Math.cos(targetAngle - group.current.rotation.y),
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
animationState.current = player.hp <= 0
? "death"
: knocked
? "hit"
: state.activeCast
? "cast"
: length > 0.05
? "run"
: performance.now() < castingUntil.current
? "cast"
: "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
desiredCameraPosition.set(group.current.position.x * 0.45, 5.1, group.current.position.z + 7.7);
camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta));
camera.lookAt(group.current.position.x * 0.55, 0.65, group.current.position.z - 2.8);
broadcastTimer.current += delta;
if (broadcastTimer.current > 0.15) {
setPlayerPosition([group.current.position.x, group.current.position.z]);
broadcastTimer.current = 0;
}
});
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId="aelia" animationState={animationState} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
</mesh>
)}
<pointLight color="#f7d873" intensity={0.8} distance={2.5} position={[0.45, 1.12, 0]} />
</group>
);
}
function Party() {
const party = useGameStore((state) => state.party);
const selected = useGameStore((state) => state.selectedMemberId);
return (
<>
<PlayerCharacter />
{party.slice(1).map((member) => (
<Character
key={member.id}
memberId={member.id as Exclude<MemberId, "aelia">}
selected={selected === member.id}
/>
))}
</>
);
}
function PartyFallback() {
const positions = useGameStore((state) => state.partyPositions);
return (
<>
{(Object.keys(positions) as MemberId[]).map((memberId) => (
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
<meshStandardMaterial color="#79998c" roughness={0.8} />
</mesh>
))}
</>
);
}
function BossFallback({ bossIndex }: { bossIndex: number }) {
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
if (!boss || !motion) return null;
const position = motion.position;
const bossId = boss.id;
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
function BullBoss({ bossIndex }: { bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
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(BULL_URL, false, true);
const bullScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, bullScene);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
bullScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [bullScene]);
const clipName = phase === "victory" || defeated
? "Death"
: motionMode === "telegraph"
? "Idle_Headlow"
: motionMode === "pouncing"
? "Gallop_Jump"
: motionMode === "charging" || motionMode === "returning"
? "Gallop"
: motionMode === "stacking"
? "Idle_Headlow"
: "Idle";
useEffect(() => {
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.18);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(motionMode === "charging" ? 1.3 : 1).fadeIn(0.18).play();
if (clipName === "Death") {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.18); };
}, [actions, clipName, motionMode]);
useFrame((_, delta) => {
if (!group.current) return;
const state = useGameStore.getState();
const current = encounterBossAt(state, bossIndex);
if (!current) return;
const motion = current.motion;
targetPosition.set(motion.position[0], 0.03, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (motion.mode === "stacking") {
group.current.rotation.y += (Math.PI * 2 / 5) * delta;
return;
}
let facingX = state.partyPositions.brann[0] - motion.position[0];
let facingZ = state.partyPositions.brann[1] - motion.position[1];
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
} else if (motion.mode === "returning") {
facingX = state.partyPositions.brann[0] + motion.formationOffsetX - motion.position[0];
facingZ = state.partyPositions.brann[1] - 4.25 - motion.position[1];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
const difference = Math.atan2(Math.sin(targetAngle - group.current.rotation.y), Math.cos(targetAngle - group.current.rotation.y));
group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
}
});
if (phase === "briefing") return null;
return (
<group ref={group}>
<primitive object={bullScene} scale={0.81} />
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group>
);
}
type AlternateBossKind = "vexa" | "cindermaw";
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",
light: "#bb67ff",
rotationOffset: Math.PI,
},
cindermaw: {
url: DRAGON_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Headbutt",
special: "Punch",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
},
} as const;
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
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);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
model.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
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
: motionMode === "skyfall"
? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
? config.special
: motionMode === "tethering" || motionMode === "venom_cast"
? config.attack
: config.idle;
useEffect(() => {
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).fadeIn(0.16).play();
if (phase === "victory" || defeated) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, defeated, phase]);
useFrame((_, delta) => {
if (!group.current) return;
const state = useGameStore.getState();
const current = encounterBossAt(state, 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]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
let targetAngle = Math.atan2(
state.partyPositions.brann[0] - motion.position[0],
state.partyPositions.brann[1] - motion.position[1],
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
}
const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
Math.cos(targetAngle - group.current.rotation.y),
);
group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
});
if (phase === "briefing") return null;
return (
<group ref={group}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
<pointLight color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group>
);
}
function BarrierField() {
const group = useRef<THREE.Group>(null);
const fill = useRef<THREE.MeshBasicMaterial>(null);
const innerRing = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const active = state.phase === "combat" && state.barrier.expiresAt > state.time;
group.current.visible = active;
if (!active) return;
group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]);
group.current.rotation.y = clock.elapsedTime * 0.08;
if (fill.current) fill.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 2.6) + 1) * 0.035;
if (innerRing.current) innerRing.current.opacity = 0.38 + (Math.sin(clock.elapsedTime * 3.2) + 1) * 0.12;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[3, 64]} />
<meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.92, 3.05, 64]} />
<meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
</mesh>
<mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.82, 1.9, 48]} />
<meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
</mesh>
{Array.from({ length: 8 }, (_, index) => {
const angle = (index / 8) * Math.PI * 2;
return (
<mesh
key={index}
position={[Math.sin(angle) * 2.35, 0.02, Math.cos(angle) * 2.35]}
rotation={[-Math.PI / 2, 0, angle]}
>
<ringGeometry args={[0.09, 0.16, 6]} />
<meshBasicMaterial color="#ffe89a" transparent opacity={0.62} depthWrite={false} />
</mesh>
);
})}
</group>
);
}
function TankAuraField() {
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && state.party[1].hp > 0;
group.current.visible = active;
if (!active) return;
const brann = state.partyPositions.brann;
group.current.position.set(brann[0], 0.06, brann[1]);
group.current.rotation.y = clock.elapsedTime * -0.22;
if (material.current) material.current.opacity = 0.13 + (Math.sin(clock.elapsedTime * 5) + 1) * 0.05;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[3, 48]} />
<meshBasicMaterial ref={material} color="#67bfff" transparent opacity={0.18} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.88, 3.04, 48]} />
<meshBasicMaterial color="#8fd5ff" transparent opacity={0.92} depthWrite={false} />
</mesh>
{Array.from({ length: 6 }, (_, index) => {
const angle = (index / 6) * Math.PI * 2;
return <mesh key={index} position={[Math.sin(angle) * 2.35, 0.025, Math.cos(angle) * 2.35]} rotation={[-Math.PI / 2, 0, angle]}>
<ringGeometry args={[0.08, 0.15, 4]} />
<meshBasicMaterial color="#d2efff" transparent opacity={0.8} depthWrite={false} />
</mesh>;
})}
</group>
);
}
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const group = useRef<THREE.Group>(null);
const start = useMemo(() => new THREE.Vector3(), []);
const end = useMemo(() => new THREE.Vector3(), []);
const current = useMemo(() => new THREE.Vector3(), []);
const direction = useMemo(() => new THREE.Vector3(), []);
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const action = state.partyCombat.combatants[memberId].visualAction;
const member = state.party.find((entry) => entry.id === memberId)!;
const rapid = action?.abilityId === "rapid_fire";
const active = action !== null
&& state.phase === "combat"
&& member.hp > 0
&& action.abilityId !== "overcharge"
&& state.time >= action.startedAt
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
group.current.visible = active;
if (!active || !action) return;
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
const progress = rapid
? ((state.time - action.startedAt) % 0.4) / 0.4
: Math.min(1, (state.time - action.startedAt) / projectileDuration);
const source = state.partyPositions[memberId];
const target = targetMotion.position;
start.set(source[0], 1.18, source[1]);
end.set(target[0], 1.12, target[1]);
current.copy(start).lerp(end, progress);
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
group.current.position.copy(current);
direction.subVectors(end, start).normalize();
group.current.quaternion.setFromUnitVectors(up, direction);
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
});
return (
<group ref={group} visible={false}>
{memberId === "nia" ? (
<>
<mesh>
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
<meshBasicMaterial color="#d7b477" />
</mesh>
<mesh position={[0, 0.5, 0]}>
<coneGeometry args={[0.085, 0.2, 6]} />
<meshBasicMaterial color="#f1ddaa" />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.1, 0.18, 4]} />
<meshBasicMaterial color="#70cf8e" />
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[0.19, 12, 10]} />
<meshBasicMaterial color="#bc8cff" />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.025, 6, 20]} />
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
</mesh>
</>
)}
</group>
);
}
function RangedProjectiles() {
return (
<>
<RangedProjectile memberId="nia" />
<RangedProjectile memberId="orin" />
</>
);
}
function BossActor() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "briefing") return null;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
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} />}
</Suspense>
))}</>
);
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const age = useRef(0);
const isBossFx = kind === "boss";
const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
useFrame((_, delta) => {
age.current += delta;
if (!ring.current || !material.current) return;
const progress = Math.min(1, age.current / 0.7);
ring.current.scale.setScalar(0.5 + progress * 3.6);
material.current.opacity = (1 - progress) * 0.85;
});
return (
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.5, 32]} />
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
</mesh>
);
}
function CombatFx() {
const pulse = useGameStore((state) => state.scenePulse);
if (!pulse.id) return null;
return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
}
export function GameScene() {
return (
<Canvas
shadows
dpr={[1, 1.5]}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ antialias: true, powerPreference: "high-performance" }}
>
<color attach="background" args={["#07110f"]} />
<fog attach="fog" args={["#07110f", 17, 32]} />
<hemisphereLight args={["#8ac4b5", "#15100b", 1.25]} />
<directionalLight castShadow position={[5, 10, 8]} intensity={2.2} color="#ffe2a9" shadow-mapSize={[1024, 1024]} />
<Arena />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Suspense fallback={<PartyFallback />}>
<Party />
</Suspense>
<BossActor />
<RangedProjectiles />
<CombatFx />
</Canvas>
);
}
useGLTF.preload(BULL_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);
if (loadout.left) useGLTF.preload(loadout.left, false, true);
}
+188
View File
@@ -0,0 +1,188 @@
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GameScene } from "./GameScene";
import { tankAuraProtects } from "../game/partyCombat";
function CompactParty() {
const party = useGameStore((state) => state.party);
const time = useGameStore((state) => state.time);
const selected = useGameStore((state) => state.selectedMemberId);
const barrier = useGameStore((state) => state.barrier);
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
const partyPositions = useGameStore((state) => state.partyPositions);
const selectMember = useGameStore((state) => state.selectMember);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
return (
<div className="top-party" aria-label="Party health">
{party.map((member) => {
const health = (member.hp / member.maxHp) * 100;
const shield = (member.absorb / member.maxHp) * 100;
return (
<button
className={`top-party-member ${selected === member.id ? "is-selected" : ""}`}
key={member.id}
onClick={() => selectMember(member.id)}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health`}
>
<span className="portrait-dot" style={{ background: member.color }}>{member.name[0]}</span>
<span className="top-party-copy"><strong>{member.name}</strong><small>{member.role}</small></span>
<span className={`microbar ${member.id === "aelia" ? "player-health-bar" : ""}`}>
<i style={{ width: `${health}%` }} />
{shield > 0 && <b style={{ width: `${Math.max(8, shield)}%` }} />}
</span>
{member.id === "aelia" && (
<span className="player-mana-bar" aria-label={`${Math.ceil(mana)} of ${maxMana} mana`}>
<i style={{ width: `${(mana / maxMana) * 100}%` }} />
</span>
)}
<span className="top-effects">
{member.renewExpiresAt > 0 && <em className="renew-pip">R</em>}
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && <em className="tank-aura-pip">T</em>}
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
</span>
</button>
);
})}
</div>
);
}
function CastingBar() {
const healerClassId = useGameStore((state) => state.healerClassId);
const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name;
const activeCast = useGameStore((state) => state.activeCast);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
if (!activeCast) return null;
const duration = activeCast.completesAt - activeCast.startedAt;
const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration));
const target = party.find((member) => member.id === activeCast.targetId);
return (
<div className="casting-bar" aria-label={`Casting ${abilityName} on ${target?.name ?? "ally"}`}>
<span><strong>{abilityName}</strong><small>{target?.name}</small></span>
<b>{Math.max(0, activeCast.completesAt - time).toFixed(1)}s</b>
<i><em style={{ width: `${progress * 100}%` }} /></i>
</div>
);
}
function BossBar() {
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-dual" : ""}`}>
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
</div>)}
</div>
);
}
function EncounterCallout() {
const phase = useGameStore((state) => state.phase);
const time = useGameStore((state) => state.time);
const boss = useGameStore((state) => state.boss);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase !== "combat") return null;
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
return (
<div className={`encounter-callout ${mechanic.urgent ? "is-urgent" : ""}`}>
<span>{mechanic.name}</span>
<strong>{mechanic.remaining.toFixed(1)}s</strong>
</div>
);
}
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
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 copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? "Five entered. Five endured."
: definitions.map((boss) => boss.failure).join(" ");
return (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small>
</div>
);
}
function PauseOverlay({ onExit }: { onExit?: () => void }) {
const paused = useGameStore((state) => state.paused);
const selection = useGameStore((state) => state.pauseSelection);
const setPaused = useGameStore((state) => state.setPaused);
const setPauseSelection = useGameStore((state) => state.setPauseSelection);
if (!paused) return null;
const exit = () => {
setPaused(false);
onExit?.();
};
return (
<div className="pause-overlay" role="dialog" aria-modal="true" aria-label="Game paused">
<div className="pause-panel">
<span>Encounter suspended</span>
<h1>Paused</h1>
<p>Simulation, damage, and movement are stopped.</p>
<div className="pause-actions">
<button
className={selection === "resume" ? "is-controller-focused" : ""}
onFocus={() => setPauseSelection("resume")}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>START / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>A</small></button>
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
</div>
</div>
);
}
export function TopScreen({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
<GameScene />
<div className="top-vignette" />
<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>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>}
</div>
<PhaseOverlay />
<PauseOverlay onExit={onExit} />
</section>
);
}
@@ -0,0 +1,225 @@
import { useFrame } from "@react-three/fiber";
import { useRef, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
import { CINDER_BREATH } from "../../game/bosses/cindermaw";
import { useGameStore } from "../../game/store";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion;
}
export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
const motion = motionAt(useGameStore.getState(), bossIndex);
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!warningMaterial.current) return;
warningMaterial.current.opacity = motionAt(useGameStore.getState(), bossIndex)?.mode === "charging"
? 0.34
: 0.16 + (Math.sin(clock.elapsedTime * 9) + 1) * 0.09;
});
if (!motion || phase !== "combat" || (motionMode !== "telegraph" && motionMode !== "charging")) return null;
const dx = motion.chargeEnd[0] - motion.chargeStart[0];
const dz = motion.chargeEnd[1] - motion.chargeStart[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [
(motion.chargeStart[0] + motion.chargeEnd[0]) / 2,
0.045,
(motion.chargeStart[1] + motion.chargeEnd[1]) / 2,
];
const color = motionMode === "charging" ? "#ffb04a" : "#ff4f37";
const laneWidth = BULL_CHARGE.hitRadius * 2;
return (
<>
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[laneWidth, length]} />
<meshBasicMaterial ref={warningMaterial} color={color} transparent depthWrite={false} />
</mesh>
<mesh position={[-BULL_CHARGE.hitRadius, 0.018, 0]}>
<boxGeometry args={[0.055, 0.025, length]} />
<meshBasicMaterial color={color} transparent opacity={0.9} />
</mesh>
<mesh position={[BULL_CHARGE.hitRadius, 0.018, 0]}>
<boxGeometry args={[0.055, 0.025, length]} />
<meshBasicMaterial color={color} transparent opacity={0.9} />
</mesh>
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.025, -length / 2 + ((index + 0.55) / 7) * length]}>
<boxGeometry args={[BULL_CHARGE.hitRadius, 0.028, 0.12]} />
<meshBasicMaterial color={color} transparent opacity={0.62} />
</mesh>
))}
</group>
<mesh position={[motion.chargeEnd[0], 0.055, motion.chargeEnd[1]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.05, BULL_CHARGE.hitRadius, 36]} />
<meshBasicMaterial color={color} transparent opacity={0.9} depthWrite={false} />
</mesh>
</>
);
}
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
const group = useRef<THREE.Group>(null);
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
const motion = motionAt(useGameStore.getState(), bossIndex);
if (!motion) return;
if (group.current) group.current.position.set(motion.pounceCenter[0], 0.052, motion.pounceCenter[1]);
if (!warningMaterial.current) return;
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
});
if (phase !== "combat" || motionMode !== "stacking") return null;
const radius = BULL_POUNCE.stackRadius;
return (
<group ref={group}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[radius, 48]} />
<meshBasicMaterial ref={warningMaterial} color="#ff2f37" transparent opacity={0.2} depthWrite={false} />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[radius - 0.12, radius + 0.02, 48]} />
<meshBasicMaterial color="#ff4450" transparent opacity={0.95} depthWrite={false} />
</mesh>
{STACK_DIRECTIONS.map((angle, index) => (
<group
key={index}
position={[Math.sin(angle) * (radius - 0.32), 0.045, Math.cos(angle) * (radius - 0.32)]}
rotation={[0, angle, 0]}
>
<mesh position={[0, 0, -0.18]}>
<boxGeometry args={[0.09, 0.035, 0.38]} />
<meshBasicMaterial color="#ff7278" />
</mesh>
<mesh position={[0, 0, -0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.19, 0.38, 4]} />
<meshBasicMaterial color="#ff7278" />
</mesh>
</group>
))}
</group>
);
}
export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const positions = useGameStore((state) => state.partyPositions);
if (!motion || phase !== "combat" || motion.mode !== "tethering" || motion.tetherIds.length !== 2) return null;
const [firstId, secondId] = motion.tetherIds;
const first = positions[firstId];
const second = positions[secondId];
const dx = second[0] - first[0];
const dz = second[1] - first[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const stretched = length >= motion.tetherBreakDistance * 0.8;
const color = stretched ? "#f2b7ff" : "#ae52ef";
return (
<group>
<mesh position={[(first[0] + second[0]) / 2, 0.12, (first[1] + second[1]) / 2]} rotation={[0, angle, 0]}>
<boxGeometry args={[0.1, 0.07, length]} />
<meshBasicMaterial color={color} transparent opacity={0.9} />
</mesh>
{[first, second].map((position, index) => (
<mesh key={index} position={[position[0], 0.06, position[1]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.68, 32]} />
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
</mesh>
))}
</group>
);
}
export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
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";
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]}>
<circleGeometry args={[CINDER_BREATH.range, 64, 0, CINDER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, CINDER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[CINDER_BREATH.range * 0.47, CINDER_BREATH.range * 0.49, 48, 1, -CINDER_BREATH.halfAngle, CINDER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
);
}
function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; bossIndex: number }) {
const hazards = useGameStore((state) => motionAt(state, bossIndex)?.hazards ?? EMPTY_HAZARDS);
const hazard = hazards.find((entry) => entry.id === hazardId);
const time = useGameStore((state) => state.time);
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (material.current) material.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 7) + 1) * 0.06;
});
if (!hazard) return null;
const active = time >= hazard.activatesAt;
const venom = hazard.kind === "venom_pool";
const color = venom ? "#a94ee6" : active ? "#ff642d" : "#ffc14d";
return (
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<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]}>
<ringGeometry args={[hazard.radius - 0.1, hazard.radius + 0.04, 40]} />
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
</mesh>
{!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} />
</mesh>
)}
</group>
);
}
export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const hazards = useGameStore((state) => motionAt(state, bossIndex)?.hazards ?? EMPTY_HAZARDS);
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
PounceStackIndicator,
BindingWebIndicator,
BreathConeIndicator,
CircleHazardIndicators,
];
export function BossMechanicIndicators() {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
return (
<>
{Array.from({ length: bossCount }, (_, bossIndex) => (
<group key={bossIndex}>
{BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
))}
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { MODE_COPY, selectRandomBoss, selectRandomBossPair } from "./data";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
});
it("selects a boss across the full encounter pool", () => {
expect(selectRandomBoss(() => 0)).toBe("bulldrome");
expect(selectRandomBoss(() => 0.34)).toBe("vexa");
expect(selectRandomBoss(() => 0.99)).toBe("cindermaw");
});
it("selects two distinct bosses for PVE", () => {
const values = [0, 0];
const pair = selectRandomBossPair(() => values.shift() ?? 0);
expect(pair).toEqual(["bulldrome", "vexa"]);
expect(new Set(pair)).toHaveLength(2);
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { BOSS_ORDER } from "../game/bossCatalog";
import { createClassInventory } from "../game/healers";
import type { BossId } from "../game/types";
export const DEFAULT_SETTINGS: GameSettings = {
masterVolume: 80,
reducedMotion: false,
damageNumbers: true,
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 },
],
},
];
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
"roguelike-pve": {
eyebrow: "14 hunters · randomized PVE",
title: "PVE",
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
detail: "Two bosses selected when the run begins",
status: "Playable prototype",
},
dungeons: {
eyebrow: "14 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",
status: "Playable now",
},
"roguelike-pvp": {
eyebrow: "3v3 · mirrored expeditions",
title: "Roguelike PvP",
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.",
detail: "Draft order, rival pressure, and sudden-death rules",
status: "Mode shell ready",
},
"stadium-pvp": {
eyebrow: "5v5 · objective arena",
title: "Stadium PvP",
description: "Bring a prepared loadout into short team battles where positioning, interrupts, and clutch healing decide the round.",
detail: "Best of five rounds / normalized gear",
status: "Mode shell ready",
},
};
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 {
return value
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_HUNTER_NAME_LENGTH);
}
export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: string): HunterSave {
const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required.");
return {
schemaVersion: 2,
slotId,
hunterName: normalizedName,
activeClassId: "priest",
healers: {
priest: { level: 12, 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,
updatedAt: now,
stats: {
totalBossKills: 16,
flawlessClears: 5,
alliesSaved: 143,
healingDone: 284_650,
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4 },
},
collections: structuredClone(DEFAULT_COLLECTIONS),
};
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, value); },
};
}
describe("SaveRepository", () => {
it("creates exactly three offline-first slot views with timestamps", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(2, "Seraphine");
const slots = repository.list(null);
expect(slots.map((slot) => slot.id)).toEqual([1, 2, 3]);
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
expect(slots[1].local?.hunterName).toBe("Seraphine");
expect(slots[1].online).toBeNull();
});
it("copies a local save into another slot without sharing nested state", () => {
let now = "2026-07-10T12:00:00.000Z";
const repository = new SaveRepository(memoryStorage(), () => now);
repository.create(1, "Aelia");
now = "2026-07-10T13:00:00.000Z";
repository.copyLocal(1, 3);
repository.updateLocal(3, (save) => ({
...save,
healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } },
}));
const slots = repository.list(null);
expect(slots[0].local?.healers.druid.level).toBe(1);
expect(slots[2].local?.healers.druid.level).toBe(99);
expect(slots[2].local?.slotId).toBe(3);
});
it("uploads local state and can later overwrite it with the online version", () => {
let now = "2026-07-10T12:00:00.000Z";
const repository = new SaveRepository(memoryStorage(), () => now);
repository.create(1, "Aelia");
now = "2026-07-10T13:00:00.000Z";
repository.upload(1, "healer@example.com");
repository.updateLocal(1, (save) => ({
...save,
healers: { ...save.healers, priest: { ...save.healers.priest, level: 40 } },
}));
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);
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?.updatedAt).toBe(now);
});
it("deletes only the local copy so the online record can restore it", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(1, "Aelia");
repository.upload(1, "healer");
repository.deleteLocal(1);
const slot = repository.list("healer")[0];
expect(slot.local).toBeNull();
expect(slot.online).not.toBeNull();
});
it("keeps class progression and inventories independent under one hunter name", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(1, "Aelia");
repository.updateLocal(1, (save) => ({
...save,
activeClassId: "druid",
healers: {
...save.healers,
druid: { level: 8, inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }] },
},
}));
const save = repository.list(null)[0].local!;
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.druid.inventory).toHaveLength(4);
expect(save.healers.priest.inventory).toHaveLength(4);
});
it("migrates schema v1 saves into Priest progress without losing the hunter name", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Legacy");
const legacy = { ...created, schemaVersion: 1, level: 27 } as Record<string, unknown>;
delete legacy.activeClassId;
delete legacy.healers;
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.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest");
expect(migrated.healers.priest.level).toBe(27);
expect(migrated.healers.druid.level).toBe(1);
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
});
it("adds newly shipped bosses to existing schema v2 collection logs", () => {
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 migrated = repository.list(null)[0].local!;
expect(migrated.collections.some((boss) => boss.bossId === "vexa")).toBe(true);
});
});
+199
View File
@@ -0,0 +1,199 @@
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
import { createClassInventory } from "../game/healers";
import type { HealerClassId } from "../game/types";
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
export interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
const LOCAL_KEY = "i-want-to-heal:saves:local:v1";
const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`;
const SLOT_IDS: SaveSlotId[] = [1, 2, 3];
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
}
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
schemaVersion: 1;
level: number;
}
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 normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<HunterSave> & Partial<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;
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") },
},
location: legacy.location,
playSeconds: legacy.playSeconds,
updatedAt: legacy.updatedAt,
stats: legacy.stats,
collections: normalizeCollections(legacy.collections),
};
}
function parseSaveMap(raw: string | null): SaveMap {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return {};
return Object.fromEntries(Object.entries(parsed).flatMap(([id, value]) => {
const save = normalizeSave(value);
return save ? [[id, save]] : [];
})) as SaveMap;
} catch {
return {};
}
}
function cloneSave(save: HunterSave): HunterSave {
return structuredClone(save);
}
export class SaveRepository {
constructor(
private readonly storage: StorageAdapter = browserStorage(),
private readonly now: () => string = () => new Date().toISOString(),
) {}
list(accountId: string | null): SaveSlotState[] {
const local = this.read(LOCAL_KEY);
const online = accountId ? this.read(CLOUD_KEY(accountId)) : {};
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
}
create(slotId: SaveSlotId, hunterName: string): HunterSave {
const save = createHunterSave(slotId, this.now(), hunterName);
this.setLocal(save);
return save;
}
touch(slotId: SaveSlotId): HunterSave | null {
return this.updateLocal(slotId, (save) => ({ ...save, updatedAt: this.now() }));
}
updateLocal(slotId: SaveSlotId, update: (save: HunterSave) => HunterSave): HunterSave | null {
const saves = this.read(LOCAL_KEY);
const source = saves[slotId];
if (!source) return null;
const next = { ...update(cloneSave(source)), slotId, updatedAt: this.now() };
saves[slotId] = next;
this.write(LOCAL_KEY, saves);
return next;
}
deleteLocal(slotId: SaveSlotId): void {
const saves = this.read(LOCAL_KEY);
delete saves[slotId];
this.write(LOCAL_KEY, saves);
}
copyLocal(sourceId: SaveSlotId, targetId: SaveSlotId): HunterSave | null {
const saves = this.read(LOCAL_KEY);
const source = saves[sourceId];
if (!source || sourceId === targetId) return null;
const copy = { ...cloneSave(source), slotId: targetId, updatedAt: this.now() };
saves[targetId] = copy;
this.write(LOCAL_KEY, saves);
return copy;
}
upload(slotId: SaveSlotId, accountId: string): HunterSave | null {
const local = this.read(LOCAL_KEY)[slotId];
if (!local) return null;
const cloud = this.read(CLOUD_KEY(accountId));
const uploaded = { ...cloneSave(local), updatedAt: this.now() };
cloud[slotId] = uploaded;
this.write(CLOUD_KEY(accountId), cloud);
this.setLocal(uploaded);
return uploaded;
}
download(slotId: SaveSlotId, accountId: string): HunterSave | null {
const cloud = this.read(CLOUD_KEY(accountId))[slotId];
if (!cloud) return null;
const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() };
this.setLocal(downloaded);
return downloaded;
}
private setLocal(save: HunterSave): void {
const saves = this.read(LOCAL_KEY);
saves[save.slotId] = save;
this.write(LOCAL_KEY, saves);
}
private read(key: string): SaveMap {
return parseSaveMap(this.storage.getItem(key));
}
private write(key: string, saves: SaveMap): void {
this.storage.setItem(key, JSON.stringify(saves));
}
}
export function formatSaveTimestamp(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Unknown time";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
export function formatPlayTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
+173
View File
@@ -0,0 +1,173 @@
import { create } from "zustand";
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
import { SaveRepository } from "./saveRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
const repository = new SaveRepository();
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
function loadSettings(): GameSettings {
try {
const saved = localStorage.getItem(SETTINGS_KEY);
return saved ? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) } : DEFAULT_SETTINGS;
} catch {
return DEFAULT_SETTINGS;
}
}
function persistSettings(settings: GameSettings) {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
// Settings remain active for this session when storage is unavailable.
}
}
interface FrontendState {
screen: AppScreen;
accountId: string | null;
slots: SaveSlotState[];
selectedSlotId: SaveSlotId;
activeSlotId: SaveSlotId | null;
selectedMode: GameModeId;
selectedBossId: BossId;
settings: GameSettings;
notice: string;
signIn: (accountId: string) => void;
continueOffline: () => void;
signOut: () => void;
navigate: (screen: AppScreen) => void;
selectSlot: (slotId: SaveSlotId) => void;
createSlot: (slotId: SaveSlotId, hunterName: string) => boolean;
playSlot: (slotId: SaveSlotId) => void;
deleteSlot: (slotId: SaveSlotId) => void;
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
uploadSlot: (slotId: SaveSlotId) => void;
downloadSlot: (slotId: SaveSlotId) => void;
selectMode: (mode: GameModeId) => void;
selectBoss: (bossId: BossId) => void;
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;
clearNotice: () => void;
}
function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): HunterSave | null {
return slots.find((slot) => slot.id === activeSlotId)?.local ?? null;
}
export const useFrontendStore = create<FrontendState>((set, get) => ({
screen: "login",
accountId: null,
slots: repository.list(null),
selectedSlotId: 1,
activeSlotId: null,
selectedMode: "roguelike-pve",
selectedBossId: "bulldrome",
settings: loadSettings(),
notice: "",
signIn: (rawAccountId) => {
const accountId = rawAccountId.trim() || "wayfinder";
set({ accountId, slots: repository.list(accountId), screen: "saves", notice: `Online sync connected as ${accountId}.` });
},
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
navigate: (screen) => set({ screen, notice: "" }),
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
createSlot: (slotId, rawHunterName) => {
const hunterName = normalizeHunterName(rawHunterName);
if (!hunterName) {
set({ notice: "Enter a hunter name before creating the save." });
return false;
}
repository.create(slotId, hunterName);
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
return true;
},
playSlot: (slotId) => {
const local = repository.touch(slotId);
if (!local) return;
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: repository.list(state.accountId), screen: "home", notice: "Offline save loaded." }));
},
deleteSlot: (slotId) => {
repository.deleteLocal(slotId);
set((state) => ({
slots: repository.list(state.accountId),
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
}));
},
copySlot: (sourceId, targetId) => {
const copy = repository.copyLocal(sourceId, targetId);
if (!copy) return;
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
},
uploadSlot: (slotId) => {
const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before syncing online." });
const uploaded = repository.upload(slotId, accountId);
set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." });
},
downloadSlot: (slotId) => {
const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
const downloaded = repository.download(slotId, accountId);
set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." });
},
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
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.` });
},
updateActiveHealerInventory: (inventory) => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
repository.updateLocal(activeSlotId, (save) => ({
...save,
healers: {
...save.healers,
[save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) },
},
}));
set({ slots: repository.list(accountId) });
},
updateSetting: (key, value) => set((state) => {
const settings = { ...state.settings, [key]: value };
persistSettings(settings);
return { settings, notice: "Settings saved offline." };
}),
touchActiveSave: () => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
repository.touch(activeSlotId);
set({ slots: repository.list(accountId) });
},
recordBossVictory: (bossName) => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
repository.updateLocal(activeSlotId, (save) => {
const bossKills = { ...save.stats.bossKills, [bossName]: (save.stats.bossKills[bossName] ?? 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),
};
});
set({ slots: repository.list(accountId), notice: `${bossName} clear saved offline.` });
},
clearNotice: () => set({ notice: "" }),
}));
export function useActiveHunter(): HunterSave | null {
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
}
+59
View File
@@ -0,0 +1,59 @@
import type { HealerClassId, InventoryItem } from "../game/types";
export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "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";
count: number;
}
export interface BossCollection {
bossId: string;
bossName: string;
defeated: boolean;
drops: CollectionDrop[];
}
export interface HunterStats {
totalBossKills: number;
flawlessClears: number;
alliesSaved: number;
healingDone: number;
bossKills: Record<string, number>;
}
export interface HealerProgress {
level: number;
inventory: InventoryItem[];
}
export interface HunterSave {
schemaVersion: 2;
slotId: SaveSlotId;
hunterName: string;
activeClassId: HealerClassId;
healers: Record<HealerClassId, HealerProgress>;
location: string;
playSeconds: number;
updatedAt: string;
stats: HunterStats;
collections: BossCollection[];
}
export interface SaveSlotState {
id: SaveSlotId;
local: HunterSave | null;
online: HunterSave | null;
}
export interface GameSettings {
masterVolume: number;
reducedMotion: boolean;
damageNumbers: boolean;
largeText: boolean;
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { useGameStore } from "./store";
import type { BossId } from "./types";
interface BattleResult {
phase: ReturnType<typeof useGameStore.getState>["phase"];
time: number;
damageBySource: ReturnType<typeof useGameStore.getState>["partyCombat"]["combatants"];
}
function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds = 200): BattleResult {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), bossIds);
useGameStore.getState().startEncounter();
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
const state = useGameStore.getState();
return { phase: state.phase, time: state.time, damageBySource: state.partyCombat.combatants };
}
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
const result = simulateControlledBattle([first, second]);
expect(result.phase).toBe("victory");
expect(result.time).toBeGreaterThan(70);
expect(result.time).toBeLessThan(110);
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId].damageDone).toBeGreaterThan(0);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { BossId } from "./types";
export interface BossDefinition {
id: BossId;
name: string;
title: string;
trial: string;
icon: string;
accent: string;
summary: string;
briefing: string;
failure: string;
mapTitle: string;
mapCopy: string;
mechanics: readonly [string, string];
maxHp: number;
}
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
bulldrome: {
id: "bulldrome",
name: "Bulldrome",
title: "The Cinder Bull",
trial: "Trial I · Healer Initiation",
icon: "♜",
accent: "#e2744e",
summary: "Charges marked lanes and crushes grouped targets.",
briefing: "Keep formation alive. Sidestep the charge lane, then stack tightly for the Bull's pounce.",
failure: "Protect Brann and the healer. Purify Ember Brand before it burns through formation.",
mapTitle: "Hall of the Bull",
mapCopy: "Keep Brann between formation and Bull. Move clear when the charge lane turns red.",
mechanics: ["Bull Charge", "Crushing Pounce"],
maxHp: 500,
},
vexa: {
id: "vexa",
name: "Vexa",
title: "The Webmother",
trial: "Trial II · Tangled Remedy",
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"],
maxHp: 535,
},
cindermaw: {
id: "cindermaw",
name: "Cindermaw",
title: "The Sky Tyrant",
trial: "Trial III · Ashen 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"],
maxHp: 410,
},
};
+399
View File
@@ -0,0 +1,399 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
export const BULL_CHARGE = {
firstAt: 7,
repeatDelay: 4,
telegraphDuration: 1.8,
distance: 13.5,
speed: 10.5,
hitRadius: 1.35,
damage: 18,
knockdownDuration: 0.75,
aiClearance: 1.9,
aiEvadeSpeed: 3.4,
} as const;
export const BULL_POUNCE = {
afterCharges: 3,
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 300,
leapDuration: 0.55,
} as const;
export const BOSS_PERIODIC_MECHANICS = {
melee: { firstAt: 2, interval: 2.5, damage: 15 },
nova: { firstAt: 9, interval: 12, damage: 13 },
brand: { firstAt: 5, interval: 9, duration: 7, tickDamage: 6 },
} as const;
const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
export function createBossState(bossId: BossId = "bulldrome"): BossState {
if (bossId === "vexa") return createVexaState();
if (bossId === "cindermaw") return createCindermawState();
const definition = BOSS_DEFINITIONS.bulldrome;
return {
id: "bulldrome",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
brandCount: 0,
};
}
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
if (bossId === "vexa") return createVexaMotion();
if (bossId === "cindermaw") return createCindermawMotion();
return {
...createBaseMotion("bulldrome"),
mode: "holding",
position: [0, -8.2],
chargeStart: [0, -8.2],
chargeEnd: [0, 5.5],
chargeTargetId: "nia",
chargeHitIds: [],
phaseEndsAt: 0,
nextChargeAt: BULL_CHARGE.firstAt,
chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia",
pounceCenter: [0, 4.5],
pounceCount: 0,
};
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
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)),
];
}
function livingMember(party: PartyMember[], memberId: MemberId) {
return party.find((member) => member.id === memberId && member.hp > 0);
}
function chooseTarget(party: PartyMember[], order: readonly MemberId[], startIndex: number): MemberId {
for (let offset = 0; offset < order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (livingMember(party, candidate)) return candidate;
}
return order[0];
}
function advanceMotionMechanics(
source: BossMotionState,
party: PartyMember[],
partyPositions: Record<MemberId, WorldPosition>,
time: number,
delta: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let motion: BossMotionState = {
...source,
position: [source.position[0], source.position[1]],
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
chargeHitIds: [...source.chargeHitIds],
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
};
let updatedParty = party;
if (motion.mode === "holding") {
const tank = partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * delta);
if (time >= motion.nextChargeAt) {
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
const target = partyPositions[targetId];
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
mode: "telegraph",
chargeStart: [motion.position[0], motion.position[1]],
chargeEnd: chargeEndpoint(motion.position, target),
chargeTargetId: targetId,
chargeHitIds: [],
phaseEndsAt: time + BULL_CHARGE.telegraphDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: motion.chargeCount + 1,
chargesSincePounce: motion.chargesSincePounce + 1,
};
events.push({
at: time,
message: `Bulldrome lines up a charge on ${targetName}.`,
tone: "danger",
pulseKind: "charge",
targetId,
});
}
} else if (motion.mode === "telegraph" && time >= motion.phaseEndsAt) {
const chargeDuration = distance(motion.chargeStart, motion.chargeEnd) / BULL_CHARGE.speed;
motion = { ...motion, mode: "charging", phaseEndsAt: time + chargeDuration };
events.push({ at: time, message: "Bulldrome charges! Clear the marked lane.", tone: "danger" });
} else if (motion.mode === "charging") {
const previousPosition: WorldPosition = [motion.position[0], motion.position[1]];
motion.position = moveToward(motion.position, motion.chargeEnd, BULL_CHARGE.speed * delta);
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(partyPositions[member.id], previousPosition, motion.position) > BULL_CHARGE.hitRadius) {
return member;
}
motion.chargeHitIds = [...motion.chargeHitIds, member.id];
events.push({
at: time,
message: `${member.name} is knocked down by the charge.`,
tone: "danger",
pulseKind: "charge",
targetId: member.id,
});
return {
...damageMember(member, BULL_CHARGE.damage, partyPositions[member.id], time),
knockedUntil: time + BULL_CHARGE.knockdownDuration,
};
});
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
}
} else if (motion.mode === "returning") {
const tank = partyPositions.brann;
const returnPoint: WorldPosition = [tank[0] + motion.formationOffsetX, tank[1] - 4.25];
motion.position = moveToward(motion.position, returnPoint, 4.4 * delta);
if (distance(motion.position, returnPoint) < 0.12) {
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
position: returnPoint,
mode: "stacking",
phaseEndsAt: time + BULL_POUNCE.stackDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
chargesSincePounce: 0,
pounceTargetId: targetId,
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]],
pounceCount: motion.pounceCount + 1,
};
events.push({
at: time,
message: `Bulldrome marks ${targetName}. Stack inside the circle!`,
tone: "danger",
pulseKind: "pounce",
targetId,
});
} else {
motion = {
...motion,
position: returnPoint,
mode: "holding",
nextChargeAt: time + BULL_CHARGE.repeatDelay,
};
events.push({ at: time, message: "Bulldrome returns to Brann and paws at the stone." });
}
}
} else if (motion.mode === "stacking") {
motion.pounceCenter = [partyPositions[motion.pounceTargetId][0], partyPositions[motion.pounceTargetId][1]];
if (time >= motion.phaseEndsAt) {
const targetName = updatedParty.find((member) => member.id === motion.pounceTargetId)?.name ?? motion.pounceTargetId;
motion = {
...motion,
mode: "pouncing",
chargeStart: [motion.position[0], motion.position[1]],
chargeEnd: [motion.pounceCenter[0], motion.pounceCenter[1]],
phaseEndsAt: time + BULL_POUNCE.leapDuration,
};
events.push({ at: time, message: `Bulldrome leaps at ${targetName}!`, tone: "danger" });
}
} else if (motion.mode === "pouncing") {
const leapDistance = distance(motion.chargeStart, motion.chargeEnd);
const leapSpeed = Math.max(12, leapDistance / BULL_POUNCE.leapDuration);
motion.position = moveToward(motion.position, motion.chargeEnd, leapSpeed * delta);
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
const stackedIds = updatedParty
.filter((member) => member.hp > 0 && distance(partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius)
.map((member) => member.id);
const sharedDamage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
updatedParty = updatedParty.map((member) => stackedIds.includes(member.id)
? damageMember(member, sharedDamage, partyPositions[member.id], time)
: member);
motion = {
...motion,
position: [motion.chargeEnd[0], motion.chargeEnd[1]],
mode: "returning",
phaseEndsAt: 0,
};
events.push({
at: time,
message: `Bulldrome pounces for ${Math.round(sharedDamage)} damage across ${stackedIds.length} stacked allies.`,
tone: "danger",
pulseKind: "pounce",
targetId: motion.pounceTargetId,
});
}
}
return { motion, party: updatedParty };
}
function resolvePeriodicMechanics(
boss: BossState,
motion: BossMotionState,
party: PartyMember[],
partyPositions: Record<MemberId, WorldPosition>,
time: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let updatedParty = party;
while (boss.nextMeleeAt <= time) {
if (motion.mode === "holding") {
const tankIndex = updatedParty.findIndex((member) => member.id === "brann");
updatedParty[tankIndex] = damageMember(
updatedParty[tankIndex],
BOSS_PERIODIC_MECHANICS.melee.damage,
partyPositions.brann,
boss.nextMeleeAt,
);
}
boss.nextMeleeAt += BOSS_PERIODIC_MECHANICS.melee.interval;
}
while (boss.nextNovaAt <= time) {
updatedParty = updatedParty.map((member) => damageMember(
member,
BOSS_PERIODIC_MECHANICS.nova.damage,
partyPositions[member.id],
boss.nextNovaAt,
));
events.push({
at: boss.nextNovaAt,
message: "Cinder Nova strikes the party.",
tone: "danger",
pulseKind: "boss",
});
boss.nextNovaAt += BOSS_PERIODIC_MECHANICS.nova.interval;
}
while (boss.nextBrandAt <= time) {
const targetId = CHARGE_TARGET_ORDER[boss.brandCount % CHARGE_TARGET_ORDER.length];
const targetIndex = updatedParty.findIndex((member) => member.id === targetId);
if (updatedParty[targetIndex].hp > 0) {
const appliedAt = boss.nextBrandAt;
updatedParty[targetIndex] = {
...updatedParty[targetIndex],
debuffs: [
...updatedParty[targetIndex].debuffs,
{
id: `brand-${boss.brandCount}`,
name: "Ember Brand",
expiresAt: appliedAt + BOSS_PERIODIC_MECHANICS.brand.duration,
nextTickAt: appliedAt + 1,
tickDamage: BOSS_PERIODIC_MECHANICS.brand.tickDamage,
},
],
};
events.push({
at: appliedAt,
message: `Ember Brand afflicts ${updatedParty[targetIndex].name}.`,
tone: "danger",
pulseKind: "debuff",
targetId,
});
}
boss.brandCount += 1;
boss.nextBrandAt += BOSS_PERIODIC_MECHANICS.brand.interval;
}
return updatedParty;
}
function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
const events: BossMechanicEvent[] = [];
const motionResult = advanceMotionMechanics(
context.motion,
context.party,
context.partyPositions,
context.time,
context.delta,
context.damageMember,
events,
);
const party = resolvePeriodicMechanics(
boss,
motionResult.motion,
motionResult.party,
context.partyPositions,
context.time,
context.damageMember,
events,
);
return { boss, motion: motionResult.motion, party, events };
}
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
return advanceBulldromeMechanics(context);
}
export function handleBossDispel(
bossId: BossId,
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
) {
if (bossId === "vexa" && debuffNames.includes("Widow Venom")) {
return {
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
};
}
return { motion, message: "Harmful magic removed." };
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
}
if (motion.mode === "charging") {
return { name: "Charge active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
}
if (motion.mode === "stacking") {
const names: Record<MemberId, string> = { aelia: "Aelia", brann: "Brann", nia: "Nia", orin: "Orin", vale: "Vale" };
return { name: `Stack on ${names[motion.pounceTargetId]}`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_POUNCE.stackDuration, urgent: true };
}
if (motion.mode === "pouncing") {
return { name: "Bulldrome Pounce", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 0.75, urgent: true };
}
const candidates: Array<{ name: string; remaining: number; cycle: number }> = [
{ name: "Cinder Nova", remaining: Math.max(0, boss.nextNovaAt - time), cycle: BOSS_PERIODIC_MECHANICS.nova.interval },
{ name: "Ember Brand", remaining: Math.max(0, boss.nextBrandAt - time), cycle: BOSS_PERIODIC_MECHANICS.brand.interval },
];
if (motion.mode === "holding" && Number.isFinite(motion.nextChargeAt)) {
candidates.push({ name: "Bull Charge", remaining: Math.max(0, motion.nextChargeAt - time), cycle: BULL_CHARGE.firstAt + 1 });
}
let next = candidates[0];
for (let index = 1; index < candidates.length; index += 1) {
if (candidates[index].remaining < next.remaining) next = candidates[index];
}
return { ...next, urgent: next.remaining < 2.5 };
}
+161
View File
@@ -0,0 +1,161 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointInCone } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDER_BREATH = {
firstAt: 6,
telegraphDuration: 2,
sweepDuration: 3.2,
range: 10.5,
halfAngle: Math.PI / 7,
sweepArc: Math.PI * 0.95,
tickDamage: 9,
tickInterval: 0.45,
} as const;
export const CINDER_SKYFALL = {
warning: 2,
stagger: 0.9,
radius: 1.8,
damage: 30,
fireDuration: 5,
} as const;
const SKYFALL_TARGETS: readonly MemberId[][] = [
["nia", "orin", "vale"],
["aelia", "brann", "orin"],
["vale", "nia", "aelia"],
];
export function createCindermawState(): BossState {
const definition = BOSS_DEFINITIONS.cindermaw;
return {
id: "cindermaw",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createCindermawMotion(): BossMotionState {
return { ...createBaseMotion("cindermaw"), position: [0, -2.8], nextMechanicAt: CINDER_BREATH.firstAt };
}
export function advanceCindermawMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.9 * context.delta);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
const startAngle = aimedAngle - direction * CINDER_BREATH.sweepArc * 0.5;
motion = {
...motion,
mode: "breath_telegraph",
phaseStartedAt: context.time,
phaseEndsAt: context.time + CINDER_BREATH.telegraphDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
breathAngle: startAngle,
breathStartAngle: startAngle,
breathEndAngle: startAngle + direction * CINDER_BREATH.sweepArc,
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: "Cindermaw draws a sweeping breath. 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) => {
const activatesAt = context.time + CINDER_SKYFALL.warning + index * CINDER_SKYFALL.stagger;
return {
id: `skyfall-${motion.mechanicCount}-${index}`,
kind: "skyfall" as const,
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
radius: CINDER_SKYFALL.radius,
activatesAt,
expiresAt: activatesAt + CINDER_SKYFALL.fireDuration,
damage: CINDER_SKYFALL.damage,
nextDamageAt: {},
resolved: false,
hitIds: [],
};
});
motion = {
...motion,
mode: "skyfall",
phaseStartedAt: context.time,
phaseEndsAt: hazards[hazards.length - 1].activatesAt + 0.5,
nextMechanicAt: Number.POSITIVE_INFINITY,
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] });
}
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "breath_sweeping",
phaseStartedAt: context.time,
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Searing Sweep 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;
party = party.map((member) => {
if (member.hp <= 0) return member;
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, CINDER_BREATH.halfAngle, CINDER_BREATH.range);
if (!exposed) {
motion.mechanicNextDamageAt[member.id] = context.time;
return member;
}
let next = member;
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
while (tickAt <= context.time + 0.001) {
next = context.damageMember(next, CINDER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
tickAt += CINDER_BREATH.tickInterval;
}
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 });
}
return next;
});
if (context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
} else if (motion.mode === "skyfall" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.6, 17, context.damageMember);
return { boss, motion, party, events };
}
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_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 };
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 };
}
+139
View File
@@ -0,0 +1,139 @@
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicEvent } from "./types";
export function createBaseMotion(bossId: BossId): BossMotionState {
return {
bossId,
formationOffsetX: 0,
mode: "holding",
position: [0, -8.2],
chargeStart: [0, -8.2],
chargeEnd: [0, 5.5],
chargeTargetId: "nia",
chargeHitIds: [],
phaseEndsAt: 0,
nextChargeAt: Number.POSITIVE_INFINITY,
chargeCount: 0,
chargesSincePounce: 0,
pounceTargetId: "aelia",
pounceCenter: [0, 4.5],
pounceCount: 0,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: 0,
phaseStartedAt: 0,
mechanicHitIds: [],
mechanicNextDamageAt: {},
tetherIds: [],
tetherBreakDistance: 0,
breathAngle: 0,
breathStartAngle: 0,
breathEndAngle: 0,
hazards: [],
};
}
export function cloneMotion(source: BossMotionState): BossMotionState {
return {
...source,
position: [source.position[0], source.position[1]],
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
chargeHitIds: [...source.chargeHitIds],
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
mechanicHitIds: [...source.mechanicHitIds],
mechanicNextDamageAt: { ...source.mechanicNextDamageAt },
tetherIds: [...source.tetherIds],
hazards: source.hazards.map((hazard) => ({
...hazard,
center: [hazard.center[0], hazard.center[1]],
nextDamageAt: { ...hazard.nextDamageAt },
hitIds: [...hazard.hitIds],
})),
};
}
export function chooseLivingTarget(
party: PartyMember[],
order: readonly MemberId[],
startIndex: number,
) {
for (let offset = 0; offset < order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (party.some((member) => member.id === candidate && member.hp > 0)) return candidate;
}
return order[0];
}
export function applyMelee(
boss: BossState,
motion: BossMotionState,
party: PartyMember[],
positions: Record<MemberId, WorldPosition>,
time: number,
interval: number,
amount: number,
damageMember: BossMechanicContext["damageMember"],
) {
while (boss.nextMeleeAt <= time) {
if (motion.mode === "holding") {
const tankIndex = party.findIndex((member) => member.id === "brann");
if (tankIndex >= 0 && party[tankIndex].hp > 0) {
party[tankIndex] = damageMember(party[tankIndex], amount, positions.brann, boss.nextMeleeAt);
}
}
boss.nextMeleeAt += interval;
}
}
export function resolveCircleHazards(
motion: BossMotionState,
party: PartyMember[],
positions: Record<MemberId, WorldPosition>,
time: number,
damageMember: BossMechanicContext["damageMember"],
events: BossMechanicEvent[],
) {
let updatedParty = party;
for (const hazard of motion.hazards) {
if (time < hazard.activatesAt || time >= hazard.expiresAt) continue;
const newlyHit: MemberId[] = [];
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0) return member;
const inside = distance(positions[member.id], hazard.center) <= hazard.radius;
if (!inside) {
delete hazard.nextDamageAt[member.id];
return member;
}
if (!hazard.hitIds.includes(member.id)) {
hazard.hitIds.push(member.id);
newlyHit.push(member.id);
}
if (hazard.kind !== "venom_pool") {
return hazard.resolved || hazard.hitIds.includes(member.id) && !newlyHit.includes(member.id)
? member
: damageMember(member, hazard.damage, positions[member.id], time);
}
let next = member;
let tickAt = hazard.nextDamageAt[member.id] ?? time;
while (tickAt <= time + 0.001) {
next = damageMember(next, hazard.damage, positions[member.id], tickAt);
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" });
}
if (newlyHit.length || hazard.kind === "skyfall" && time >= hazard.activatesAt) hazard.resolved = true;
}
motion.hazards = motion.hazards.filter((hazard) => hazard.expiresAt > time);
return updatedParty;
}
export function memberName(party: PartyMember[], memberId: MemberId) {
return party.find((member) => member.id === memberId)?.name ?? memberId;
}
+33
View File
@@ -0,0 +1,33 @@
import type { BossMotionState, BossState, MemberId, PartyMember, PulseKind, WorldPosition } from "../types";
export interface BossMechanicEvent {
at: number;
message: string;
tone?: "danger" | "neutral";
pulseKind?: PulseKind;
targetId?: MemberId;
}
export interface BossMechanicResult {
boss: BossState;
motion: BossMotionState;
party: PartyMember[];
events: BossMechanicEvent[];
}
export interface BossMechanicContext {
boss: BossState;
motion: BossMotionState;
party: PartyMember[];
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number) => PartyMember;
}
export interface UpcomingMechanic {
name: string;
remaining: number;
cycle: number;
urgent: boolean;
}
+161
View File
@@ -0,0 +1,161 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { distance, moveToward } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const VEXA_TETHER = {
firstAt: 6,
duration: 4.5,
breakDistance: 6.8,
failureDamage: 24,
rootDuration: 1.4,
} as const;
export const VEXA_VENOM = {
duration: 10,
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
} as const;
const TETHER_PAIRS: readonly (readonly [MemberId, MemberId])[] = [
["brann", "vale"],
["nia", "orin"],
["aelia", "nia"],
];
const VENOM_TARGETS: readonly MemberId[][] = [
["nia", "vale"],
["orin", "brann"],
["aelia", "vale"],
];
export function createVexaState(): BossState {
const definition = BOSS_DEFINITIONS.vexa;
return {
id: "vexa",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.5,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createVexaMotion(): BossMotionState {
return { ...createBaseMotion("vexa"), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
}
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
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 (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const pair = TETHER_PAIRS[Math.floor(motion.mechanicCount / 2) % TETHER_PAIRS.length];
const livingPair = pair.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
if (livingPair.length === 2) {
motion = {
...motion,
mode: "tethering",
phaseStartedAt: context.time,
phaseEndsAt: context.time + VEXA_TETHER.duration,
nextMechanicAt: Number.POSITIVE_INFINITY,
tetherIds: [...livingPair],
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] });
}
} else {
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
const targets = targetSet.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
party = party.map((member) => targets.includes(member.id)
? {
...member,
debuffs: [...member.debuffs, {
id: `widow-venom-${motion.mechanicCount}-${member.id}`,
name: "Widow Venom",
expiresAt: context.time + VEXA_VENOM.duration,
nextTickAt: context.time + 1,
tickDamage: VEXA_VENOM.tickDamage,
}],
}
: member);
motion = {
...motion,
mode: "venom_cast",
phaseStartedAt: context.time,
phaseEndsAt: context.time + VEXA_VENOM.castDuration,
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] });
}
} else if (motion.mode === "tethering") {
const [first, second] = motion.tetherIds;
if (!first || !second || distance(context.partyPositions[first], context.partyPositions[second]) >= motion.tetherBreakDistance) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
events.push({ at: context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
} else if (context.time >= motion.phaseEndsAt) {
party = party.map((member) => motion.tetherIds.includes(member.id)
? {
...context.damageMember(member, VEXA_TETHER.failureDamage, context.partyPositions[member.id], context.time),
knockedUntil: context.time + VEXA_TETHER.rootDuration,
}
: member);
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
events.push({ at: context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
}
} else if (motion.mode === "venom_cast" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.7, 13, context.damageMember);
return { boss, motion, party, events };
}
export function dropVexaVenomPool(
motion: BossMotionState,
memberId: MemberId,
center: [number, number],
time: number,
) {
const next = cloneMotion(motion);
next.hazards.push({
id: `venom-pool-${memberId}-${time.toFixed(2)}`,
kind: "venom_pool",
center: [center[0], center[1]],
radius: VEXA_VENOM.poolRadius,
activatesAt: time + 0.25,
expiresAt: time + VEXA_VENOM.poolDuration,
damage: VEXA_VENOM.poolDamage,
tickInterval: 1,
nextDamageAt: {},
resolved: false,
hitIds: [],
});
return next;
}
export function upcomingVexaMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "tethering") return { name: "Break Binding Web", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_TETHER.duration, urgent: true };
if (motion.mode === "venom_cast") return { name: "Move, then Purify", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_VENOM.castDuration, urgent: true };
const nextIsTether = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsTether ? "Binding Web" : "Venom Purge", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+17
View File
@@ -0,0 +1,17 @@
import { HEALER_CLASSES } from "./healers";
import type { AbilityId, HealerClassId, PartyMember } from "./types";
export const ABILITIES = HEALER_CLASSES.priest.abilities;
export const ABILITY_ORDER: AbilityId[] = ["mend", "renew", "shield", "purify", "radiance", "barrier"];
export function freshParty(classId: HealerClassId = "priest", playerName = "Aelia"): PartyMember[] {
const healer = HEALER_CLASSES[classId];
return [
{ id: "aelia", name: playerName, className: healer.specialization, role: "Healer", color: healer.color, maxHp: 100, hp: 100, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "brann", name: "Brann", className: "Knight", role: "Tank", color: "#69a8dd", maxHp: 150, hp: 150, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "nia", name: "Nia", className: "Ranger", role: "Damage", color: "#74c987", maxHp: 94, hp: 94, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "orin", name: "Orin", className: "Mage", role: "Damage", color: "#b17ee6", maxHp: 86, hp: 86, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
{ id: "vale", name: "Vale", className: "Rogue", role: "Damage", color: "#d97171", maxHp: 92, hp: 92, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] },
];
}
+96
View File
@@ -0,0 +1,96 @@
import type { WorldPosition } from "./types";
export function distance(a: WorldPosition, b: WorldPosition) {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
export function moveToward(
current: WorldPosition,
target: WorldPosition,
maximumDistance: number,
): WorldPosition {
const dx = target[0] - current[0];
const dz = target[1] - current[1];
const length = Math.hypot(dx, dz);
if (length <= maximumDistance || length < 0.0001) return [target[0], target[1]];
return [current[0] + (dx / length) * maximumDistance, current[1] + (dz / length) * maximumDistance];
}
export function pointToSegmentDistance(
point: WorldPosition,
start: WorldPosition,
end: WorldPosition,
) {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const lengthSquared = dx * dx + dz * dz;
if (lengthSquared === 0) return distance(point, start);
const projection = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
);
return Math.hypot(
point[0] - (start[0] + projection * dx),
point[1] - (start[1] + projection * dz),
);
}
export function pointOutsideLane(
point: WorldPosition,
start: WorldPosition,
end: WorldPosition,
clearance: number,
preferredSide: -1 | 1,
): WorldPosition {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const lengthSquared = dx * dx + dz * dz;
if (lengthSquared < 0.0001) return [point[0], point[1]];
const length = Math.sqrt(lengthSquared);
const projection = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
);
const nearestX = start[0] + projection * dx;
const nearestZ = start[1] + projection * dz;
const normalX = -dz / length;
const normalZ = dx / length;
const signedDistance = (point[0] - nearestX) * normalX + (point[1] - nearestZ) * normalZ;
const side = Math.abs(signedDistance) > 0.01 ? Math.sign(signedDistance) : preferredSide;
return [nearestX + normalX * clearance * side, nearestZ + normalZ * clearance * side];
}
export function angleTo(origin: WorldPosition, target: WorldPosition) {
return Math.atan2(target[0] - origin[0], target[1] - origin[1]);
}
export function angularDistance(first: number, second: number) {
return Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second)));
}
export function pointInCone(
point: WorldPosition,
origin: WorldPosition,
facingAngle: number,
halfAngle: number,
range: number,
) {
return distance(point, origin) <= range && angularDistance(angleTo(origin, point), facingAngle) <= halfAngle;
}
export function pointOutsideCircle(
point: WorldPosition,
center: WorldPosition,
clearance: number,
fallbackAngle: number,
): WorldPosition {
const dx = point[0] - center[0];
const dz = point[1] - center[1];
const length = Math.hypot(dx, dz);
if (length < 0.001) {
return [center[0] + Math.sin(fallbackAngle) * clearance, center[1] + Math.cos(fallbackAngle) * clearance];
}
return [center[0] + (dx / length) * clearance, center[1] + (dz / length) * clearance];
}
+97
View File
@@ -0,0 +1,97 @@
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" },
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" },
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" },
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" },
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" },
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" },
};
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
return { ...bindings[id], ...definition };
}
export const HEALER_CLASS_ORDER: HealerClassId[] = ["priest", "druid", "shaman"];
export const HEALER_CLASSES: Record<HealerClassId, HealerClassDefinition> = {
priest: {
id: "priest",
name: "Priest",
specialization: "Discipline Priest",
icon: "✦",
color: "#e8c872",
resourceName: "Grace",
description: "Direct healing, protective shields, cleansing, and a damage-reducing sanctuary.",
abilities: {
mend: ability("mend", { name: "Mend", shortName: "Mend", cooldown: 0, castTime: 0.5, mana: 5, icon: "+", description: "Cast for 0.5 seconds to heal the selected ally for 38 health. No cooldown.", color: "#fff0bd" }),
renew: ability("renew", { name: "Renew", shortName: "Renew", cooldown: 0, mana: 7, icon: "✣", description: "Heal selected ally for 7 health every second for 8 seconds. No cooldown.", color: "#71df9c" }),
shield: ability("shield", { name: "Aegis Shield", shortName: "Shield", cooldown: 10, mana: 8, icon: "◇", description: "Give selected ally a 36-point damage shield.", color: "#6fc6ff" }),
purify: ability("purify", { name: "Purify", shortName: "Purify", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally.", color: "#b58cff" }),
radiance: ability("radiance", { name: "Radiance", shortName: "Radiance", cooldown: 14, mana: 12, icon: "☀", description: "Heal every party member for 22 health.", color: "#ffd66b" }),
barrier: ability("barrier", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 3m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }),
},
},
druid: {
id: "druid",
name: "Druid",
specialization: "Restoration Druid",
icon: "❧",
color: "#79d36f",
resourceName: "Mana",
description: "Placeholder nature kit built around regeneration, bark wards, and restorative growth.",
abilities: {
mend: ability("mend", { name: "Healing Touch", shortName: "Heal Touch", cooldown: 0, castTime: 0.5, mana: 5, icon: "❦", description: "Placeholder: cast a focused nature heal for 38 health.", color: "#b8ef8b" }),
renew: ability("renew", { name: "Rejuvenation", shortName: "Rejuvenate", cooldown: 0, mana: 7, icon: "☘", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#63d77e" }),
shield: ability("shield", { name: "Ironbark", shortName: "Ironbark", cooldown: 10, mana: 8, icon: "♧", description: "Placeholder: grant the selected ally 36 absorption.", color: "#a6c76b" }),
purify: ability("purify", { name: "Nature's Cure", shortName: "Nature Cure", cooldown: 3, mana: 5, icon: "✤", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#8de2b2" }),
radiance: ability("radiance", { name: "Wild Growth", shortName: "Wild Growth", cooldown: 14, mana: 12, icon: "✾", description: "Placeholder: heal every party member for 22 health.", color: "#d1ed73" }),
barrier: ability("barrier", { name: "Grove Ward", shortName: "Grove Ward", cooldown: 60, mana: 10, icon: "◌", description: "Placeholder: grow an 8-second protective grove that reduces damage by 30%.", color: "#70bc72" }),
},
},
shaman: {
id: "shaman",
name: "Shaman",
specialization: "Restoration Shaman",
icon: "ϟ",
color: "#65b9ed",
resourceName: "Mana",
description: "Placeholder elemental kit using tides, earth wards, cleansing, and spirit protection.",
abilities: {
mend: ability("mend", { name: "Healing Wave", shortName: "Heal Wave", cooldown: 0, castTime: 0.5, mana: 5, icon: "≈", description: "Placeholder: cast a focused water heal for 38 health.", color: "#8fdcf2" }),
renew: ability("renew", { name: "Riptide", shortName: "Riptide", cooldown: 0, mana: 7, icon: "≋", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#54c9c5" }),
shield: ability("shield", { name: "Earth Shield", shortName: "Earth Shield", cooldown: 10, mana: 8, icon: "⬡", description: "Placeholder: grant the selected ally 36 absorption.", color: "#d2b66c" }),
purify: ability("purify", { name: "Cleanse Spirit", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#9aaef5" }),
radiance: ability("radiance", { name: "Chain Heal", shortName: "Chain Heal", cooldown: 14, mana: 12, icon: "⌁", description: "Placeholder: heal every party member for 22 health.", color: "#6ee2db" }),
barrier: ability("barrier", { name: "Spirit Link", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Placeholder: place an 8-second spirit field that reduces damage by 30%.", color: "#9d8cf2" }),
},
},
};
const CLASS_INVENTORIES: Record<HealerClassId, InventoryItem[]> = {
priest: [
{ id: "priest-censer", name: "Censer of First Light", slot: "Main Hand", rarity: "Rare", icon: "♰", stats: ["+12 Grace", "+8% Mend healing"], effect: "Mend restores 2 mana when it lands on an ally below 50% health.", equipped: true },
{ id: "priest-vestment", name: "Ashwoven Vestment", slot: "Chest", rarity: "Uncommon", icon: "♜", stats: ["+18 Armor", "+6 Spirit"], effect: "Renew ticks have a 10% chance to extend Aegis Shield by 4 absorption.", equipped: true },
{ id: "priest-phial", name: "Moonwater Phial", slot: "Consumable", rarity: "Common", icon: "⚗", stats: ["Restores 40 mana"], effect: "Single use. Cannot be used during this prototype encounter.", equipped: false },
{ id: "priest-sigil", name: "Sigil of Quiet Resolve", slot: "Trinket", rarity: "Rare", icon: "◈", stats: ["+10% Purify range", "+5 Haste"], effect: "Purify grants its target 8 absorption when it removes Ember Brand.", equipped: false },
],
druid: [
{ id: "druid-branch", name: "Verdant Branch", slot: "Main Hand", rarity: "Common", icon: "❧", stats: ["+5 Spirit"], effect: "Placeholder Druid starter weapon.", equipped: true },
{ id: "druid-hide", name: "Mossbound Hide", slot: "Chest", rarity: "Common", icon: "♧", stats: ["+10 Armor"], effect: "Placeholder Druid starter armor.", equipped: true },
{ id: "druid-seed", name: "Dreamseed", slot: "Trinket", rarity: "Uncommon", icon: "•", stats: ["+3 Haste"], effect: "Placeholder Druid trinket.", equipped: false },
],
shaman: [
{ id: "shaman-totem", name: "Raincall Totem", slot: "Main Hand", rarity: "Common", icon: "ϟ", stats: ["+5 Spirit"], effect: "Placeholder Shaman starter focus.", equipped: true },
{ id: "shaman-mail", name: "Tideworn Mail", slot: "Chest", rarity: "Common", icon: "▧", stats: ["+12 Armor"], effect: "Placeholder Shaman starter armor.", equipped: true },
{ id: "shaman-stone", name: "Whispering Stone", slot: "Trinket", rarity: "Uncommon", icon: "◇", stats: ["+3 Haste"], effect: "Placeholder Shaman trinket.", equipped: false },
],
};
export function createClassInventory(classId: HealerClassId): InventoryItem[] {
return structuredClone(CLASS_INVENTORIES[classId]);
}
export function healerClass(classId: HealerClassId): HealerClassDefinition {
return HEALER_CLASSES[classId];
}
+211
View File
@@ -0,0 +1,211 @@
import { BULL_CHARGE } from "./bossMechanics";
import { CINDER_BREATH } from "./bosses/cindermaw";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
export type AiMemberId = Exclude<MemberId, "aelia">;
export interface PartyBehaviorContext {
memberId: AiMemberId;
current: WorldPosition;
formationTarget: WorldPosition;
bossMotion: BossMotionState;
partyPositions: Record<MemberId, WorldPosition>;
time: number;
}
export interface PartyBehaviorDecision {
target: WorldPosition;
speed: number;
}
export interface PartyBehavior {
id: string;
decide: (context: PartyBehaviorContext) => PartyBehaviorDecision | null;
}
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
brann: [-0.55, 0],
nia: [0.45, 0.4],
orin: [0.45, -0.4],
vale: [0, 0.65],
};
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
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],
};
}
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 }) => {
if (bossMotion.mode !== "stacking") return null;
const offset = STACK_OFFSETS[memberId];
return {
target: memberId === bossMotion.pounceTargetId
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
speed: 3.4,
};
},
};
export const breakTetherBehavior: PartyBehavior = {
id: "break-tether",
decide: ({ memberId, current, bossMotion, partyPositions }) => {
if (bossMotion.mode !== "tethering" || !bossMotion.tetherIds.includes(memberId)) return null;
const otherId = bossMotion.tetherIds.find((id) => id !== memberId);
if (!otherId) return null;
const other = partyPositions[otherId];
const dx = current[0] - other[0];
const dz = current[1] - other[1];
const length = Math.hypot(dx, dz);
const fallback = EVADE_SIDES[memberId] * Math.PI * 0.5;
const target = length < 0.01
? [current[0] + Math.sin(fallback) * bossMotion.tetherBreakDistance, current[1] + Math.cos(fallback) * bossMotion.tetherBreakDistance] as WorldPosition
: [current[0] + (dx / length) * bossMotion.tetherBreakDistance, current[1] + (dz / length) * bossMotion.tetherBreakDistance] as WorldPosition;
return { target: clampToArena(target), speed: 3.7 };
},
};
export const evadeChargeBehavior: PartyBehavior = {
id: "evade-charge",
decide: ({ memberId, current, formationTarget, bossMotion }) => {
if (bossMotion.mode !== "telegraph" && bossMotion.mode !== "charging") return null;
const { chargeStart, chargeEnd } = bossMotion;
const currentUnsafe = pointToSegmentDistance(current, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
const formationUnsafe = pointToSegmentDistance(formationTarget, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
if (!currentUnsafe && !formationUnsafe) return null;
// Derive from the stable formation slot so the target cannot flip sides as the member moves.
const evadeTarget = pointOutsideLane(
formationTarget,
chargeStart,
chargeEnd,
BULL_CHARGE.aiClearance,
EVADE_SIDES[memberId],
);
return { target: clampToArena(evadeTarget), speed: BULL_CHARGE.aiEvadeSpeed };
},
};
export const avoidBreathBehavior: PartyBehavior = {
id: "avoid-breath",
decide: ({ memberId, bossMotion }) => {
if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
const side = EVADE_SIDES[memberId];
const safeAngle = bossMotion.breathAngle + side * (CINDER_BREATH.halfAngle + Math.PI * 0.42);
const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4;
return {
target: clampToArena([
bossMotion.position[0] + Math.sin(safeAngle) * radius,
bossMotion.position[1] + Math.cos(safeAngle) * radius,
]),
speed: 4.1,
};
},
};
export const avoidCircleHazardsBehavior: PartyBehavior = {
id: "avoid-circle-hazards",
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
for (let index = 0; index < bossMotion.hazards.length; index += 1) {
const hazard = bossMotion.hazards[index];
if (hazard.expiresAt <= time || hazard.activatesAt - time > 2.2) continue;
const clearance = hazard.radius + 0.55;
const currentUnsafe = Math.hypot(current[0] - hazard.center[0], current[1] - hazard.center[1]) < clearance;
const formationUnsafe = Math.hypot(formationTarget[0] - hazard.center[0], formationTarget[1] - hazard.center[1]) < clearance;
if (!currentUnsafe && !formationUnsafe) continue;
const source = formationUnsafe ? formationTarget : current;
const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
return { target: clampToArena(pointOutsideCircle(source, hazard.center, clearance, fallbackAngle)), speed: 4 };
}
return null;
},
};
export const maintainFormationBehavior: PartyBehavior = {
id: "maintain-formation",
decide: ({ formationTarget, bossMotion, memberId }) => {
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
},
};
export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
breakTetherBehavior,
stackForPounceBehavior,
evadeChargeBehavior,
avoidBreathBehavior,
avoidCircleHazardsBehavior,
maintainFormationBehavior,
];
export function updatePartyPositions(
current: Record<MemberId, WorldPosition>,
bossMotionOrMotions: BossMotionState | readonly BossMotionState[],
party: PartyMember[],
time: number,
delta: number,
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
) {
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
const activeMotions = bossMotions;
const next: Record<MemberId, WorldPosition> = {
aelia: [current.aelia[0], current.aelia[1]],
brann: [current.brann[0], current.brann[1]],
nia: [current.nia[0], current.nia[1]],
orin: [current.orin[0], current.orin[1]],
vale: [current.vale[0], current.vale[1]],
};
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,
];
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];
const member = party[index + 1];
if (!member || member.hp <= 0 || member.knockedUntil > time) continue;
for (const behavior of behaviors) {
let handled = false;
for (const bossMotion of activeMotions) {
const decision = behavior.decide({
memberId,
current: next[memberId],
formationTarget: formation[memberId],
bossMotion,
partyPositions: next,
time,
});
if (!decision) continue;
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
handled = true;
break;
}
if (handled) break;
}
}
return next;
}
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { freshParty } from "./data";
import {
advancePartyCombat,
createPartyCombatState,
PARTY_ABILITY_LOADOUTS,
tankAuraProtects,
type AiCombatantId,
type PartyAbilityId,
type PartyCombatTarget,
} from "./partyCombat";
import type { BossId, MemberId, PartyMember, WorldPosition } from "./types";
const BASE_POSITIONS: Record<MemberId, WorldPosition> = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
interface SimulationOptions {
duration?: number;
movingIds?: readonly AiCombatantId[];
activeIds?: readonly AiCombatantId[];
targetPositions?: readonly WorldPosition[];
stopOnVictory?: boolean;
upcomingMechanicRemaining?: number;
}
function createTargets(bossIds: readonly BossId[], positions?: readonly WorldPosition[]): PartyCombatTarget[] {
return bossIds.map((bossId, index) => {
const motion = createBossMotionState(bossId);
motion.position = positions?.[index]
? [positions[index][0], positions[index][1]]
: bossIds.length > 1
? [index === 0 ? -2.4 : 2.4, -4]
: [0, -4];
return { instanceId: `sim-${index}-${bossId}`, boss: createBossState(bossId), motion };
});
}
function simulate(bossIds: readonly BossId[], options: SimulationOptions = {}) {
const activeIds = options.activeIds ?? ["brann", "nia", "orin", "vale"];
const party = freshParty().map((member) => member.id === "aelia" || activeIds.includes(member.id as AiCombatantId)
? member
: { ...member, hp: 0 });
let combat = createPartyCombatState(party);
const targets = createTargets(bossIds, options.targetPositions);
const damageBySource: Partial<Record<AiCombatantId, number>> = {};
const usedAbilities = new Set<PartyAbilityId>();
const secondaryEvents: PartyAbilityId[] = [];
const step = 0.1;
let time = 0;
while (time < (options.duration ?? 180)) {
const nextTime = Number((time + step).toFixed(4));
const oldPositions = structuredClone(BASE_POSITIONS);
const positions = structuredClone(BASE_POSITIONS);
for (const memberId of options.movingIds ?? []) oldPositions[memberId] = [positions[memberId][0] - 0.1, positions[memberId][1]];
const result = advancePartyCombat(combat, {
oldTime: time,
time: nextTime,
party,
oldPositions,
positions,
targets,
upcomingMechanicRemaining: options.upcomingMechanicRemaining ?? Number.POSITIVE_INFINITY,
});
combat = result.state;
for (const actor of Object.values(combat.combatants)) {
if (actor.visualAction) usedAbilities.add(actor.visualAction.abilityId);
}
for (const event of result.events) {
const target = targets.find((entry) => entry.instanceId === event.targetInstanceId)!;
target.boss.hp = Math.max(0, target.boss.hp - event.amount);
damageBySource[event.sourceId] = (damageBySource[event.sourceId] ?? 0) + event.amount;
if (event.secondary) secondaryEvents.push(event.abilityId);
}
time = nextTime;
if (options.stopOnVictory !== false && targets.every((target) => target.boss.hp <= 0)) break;
}
return { time, targets, combat, damageBySource, usedAbilities, secondaryEvents };
}
describe("party ability combat", () => {
it("defines five unique ability slots for every AI party class", () => {
for (const loadout of Object.values(PARTY_ABILITY_LOADOUTS)) {
expect(loadout).toHaveLength(5);
expect(new Set(loadout).size).toBe(5);
}
});
it("does not damage a boss before an ability impact lands", () => {
const result = simulate(["bulldrome"], { duration: 0.1, stopOnVictory: false });
expect(result.targets[0].boss.hp).toBe(result.targets[0].boss.maxHp);
expect(Object.values(result.damageBySource)).toHaveLength(0);
});
it("reduces ranged damage while moving without reducing it to zero", () => {
const stationary = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], stopOnVictory: false });
const moving = simulate(["bulldrome"], { duration: 30, activeIds: ["nia", "orin"], movingIds: ["nia", "orin"], stopOnVictory: false });
const stationaryDamage = (stationary.damageBySource.nia ?? 0) + (stationary.damageBySource.orin ?? 0);
const movingDamage = (moving.damageBySource.nia ?? 0) + (moving.damageBySource.orin ?? 0);
expect(movingDamage).toBeGreaterThan(0);
expect(movingDamage).toBeLessThan(stationaryDamage * 0.7);
});
it("lets Vale cleave only when both bosses are inside melee radius", () => {
const clustered = simulate(["vexa", "cindermaw"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
const separated = simulate(["vexa", "cindermaw"], {
duration: 30,
activeIds: ["vale"],
targetPositions: [[0, -4], [7, -4]],
stopOnVictory: false,
});
expect(clustered.secondaryEvents.length).toBeGreaterThan(0);
expect(separated.secondaryEvents).toHaveLength(0);
expect(clustered.damageBySource.vale).toBeGreaterThan(separated.damageBySource.vale ?? 0);
});
it("activates Brann's moving six-second Bulwark aura before incoming damage", () => {
const result = simulate(["bulldrome"], { duration: 0.1, activeIds: ["brann"], stopOnVictory: false, upcomingMechanicRemaining: 1 });
expect(result.combat.combatants.brann.visualAction?.abilityId).toBe("bulwark_march");
expect(result.combat.tankAura.expiresAt).toBe(6);
expect(tankAuraProtects([2.9, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(true);
expect(tankAuraProtects([3.1, 0], [0, 0], result.combat.tankAura, 5.9)).toBe(false);
expect(tankAuraProtects([0, 0], [0, 0], result.combat.tankAura, 6)).toBe(false);
});
it("uses every button in each five-ability DPS rotation during a full fight", () => {
const result = simulate(["bulldrome", "vexa"]);
const movementPhase = simulate(["bulldrome"], { duration: 5, activeIds: ["nia"], movingIds: ["nia"], stopOnVictory: false });
const usedAbilities = new Set([...result.usedAbilities, ...movementPhase.usedAbilities]);
for (const memberId of ["nia", "orin", "vale"] as const) {
expect([...usedAbilities]).toEqual(expect.arrayContaining([...PARTY_ABILITY_LOADOUTS[memberId]]));
}
});
});
describe("dual-boss damage simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
const result = simulate([first, second]);
expect(result.targets.every((target) => target.boss.hp <= 0)).toBe(true);
expect(result.time).toBeGreaterThan(35);
expect(result.time).toBeLessThan(150);
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId]).toBeGreaterThan(0);
});
});
+394
View File
@@ -0,0 +1,394 @@
import { distance } from "./geometry";
import type { BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
export type AiCombatantId = Exclude<MemberId, "aelia">;
export type PartyAbilityId =
| "sword_slash" | "shield_slam" | "revenge" | "sweeping_guard" | "bulwark_march"
| "quick_shot" | "aimed_shot" | "rapid_fire" | "kill_shot" | "deadeye"
| "arcane_bolt" | "ember_lance" | "arcane_burst" | "comet" | "overcharge"
| "quick_cut" | "twin_fang" | "backstab" | "fan_of_blades" | "blade_flurry";
export const PARTY_ABILITY_NAMES: Record<PartyAbilityId, string> = {
sword_slash: "Sword Slash",
shield_slam: "Shield Slam",
revenge: "Revenge",
sweeping_guard: "Sweeping Guard",
bulwark_march: "Bulwark March",
quick_shot: "Quick Shot",
aimed_shot: "Aimed Shot",
rapid_fire: "Rapid Fire",
kill_shot: "Kill Shot",
deadeye: "Deadeye",
arcane_bolt: "Arcane Bolt",
ember_lance: "Ember Lance",
arcane_burst: "Arcane Burst",
comet: "Comet",
overcharge: "Overcharge",
quick_cut: "Quick Cut",
twin_fang: "Twin Fang",
backstab: "Backstab",
fan_of_blades: "Fan of Blades",
blade_flurry: "Blade Flurry",
};
export const PARTY_ABILITY_LOADOUTS: Record<AiCombatantId, readonly PartyAbilityId[]> = {
brann: ["sword_slash", "shield_slam", "revenge", "sweeping_guard", "bulwark_march"],
nia: ["quick_shot", "aimed_shot", "rapid_fire", "kill_shot", "deadeye"],
orin: ["arcane_bolt", "ember_lance", "arcane_burst", "comet", "overcharge"],
vale: ["quick_cut", "twin_fang", "backstab", "fan_of_blades", "blade_flurry"],
};
export interface PartyCombatTarget {
instanceId: string;
boss: BossState;
motion: BossMotionState;
}
export interface PartyDamageEvent {
id: number;
at: number;
sourceId: AiCombatantId;
abilityId: PartyAbilityId;
targetInstanceId: string;
amount: number;
secondary: boolean;
}
export interface PartyCombatAction {
abilityId: PartyAbilityId;
targetInstanceId: string;
startedAt: number;
completesAt: number;
impactTimes: number[];
nextImpactIndex: number;
baseDamage: number;
requiresStationary: boolean;
multiplier: number;
}
export interface PartyVisualAction {
abilityId: PartyAbilityId;
targetInstanceId: string;
startedAt: number;
impactAt: number;
endsAt: number;
}
export interface PartyCombatantState {
id: AiCombatantId;
readyAt: number;
resource: number;
points: number;
cooldowns: Partial<Record<PartyAbilityId, number>>;
activeAction: PartyCombatAction | null;
visualAction: PartyVisualAction | null;
overchargeStacks: number;
bladeFlurryUntil: number;
revengeReadyUntil: number;
lastHp: number;
damageDone: number;
}
export interface TankAuraState {
expiresAt: number;
radius: number;
damageReduction: number;
}
export interface PartyCombatState {
combatants: Record<AiCombatantId, PartyCombatantState>;
tankAura: TankAuraState;
nextEventId: number;
}
export interface PartyCombatContext {
oldTime: number;
time: number;
party: PartyMember[];
oldPositions: Record<MemberId, WorldPosition>;
positions: Record<MemberId, WorldPosition>;
targets: PartyCombatTarget[];
upcomingMechanicRemaining: number;
}
interface AbilitySpec {
id: PartyAbilityId;
duration: number;
impactOffsets: number[];
damage: number;
gcd: number;
cooldown?: number;
requiresStationary?: boolean;
}
const EMPTY_AURA: TankAuraState = { expiresAt: 0, radius: 3, damageReduction: 0.3 };
const RANGED_IDS: readonly AiCombatantId[] = ["nia", "orin"];
const VALE_CLEAVE_RADIUS = 3.6;
const VALE_MELEE_RANGE = 3.65;
const BRANN_MELEE_RANGE = 4.8;
const PARTY_DAMAGE_SCALE = 1.65;
function combatant(id: AiCombatantId, hp: number): PartyCombatantState {
return {
id,
readyAt: 0,
resource: id === "vale" ? 100 : 0,
points: 0,
cooldowns: {},
activeAction: null,
visualAction: null,
overchargeStacks: 0,
bladeFlurryUntil: 0,
revengeReadyUntil: 0,
lastHp: hp,
damageDone: 0,
};
}
export function createPartyCombatState(party: PartyMember[]): PartyCombatState {
const hp = (id: AiCombatantId) => party.find((member) => member.id === id)?.hp ?? 0;
return {
combatants: {
brann: combatant("brann", hp("brann")),
nia: combatant("nia", hp("nia")),
orin: combatant("orin", hp("orin")),
vale: combatant("vale", hp("vale")),
},
tankAura: { ...EMPTY_AURA },
nextEventId: 1,
};
}
function cloneCombatant(source: PartyCombatantState): PartyCombatantState {
return {
...source,
cooldowns: { ...source.cooldowns },
activeAction: source.activeAction ? { ...source.activeAction, impactTimes: [...source.activeAction.impactTimes] } : null,
visualAction: source.visualAction ? { ...source.visualAction } : null,
};
}
function isReady(actor: PartyCombatantState, abilityId: PartyAbilityId, at: number) {
return (actor.cooldowns[abilityId] ?? 0) <= at + 0.001;
}
function moving(id: AiCombatantId, context: PartyCombatContext) {
const elapsed = Math.max(0.001, context.time - context.oldTime);
return distance(context.oldPositions[id], context.positions[id]) / elapsed > 0.35;
}
function livingTargets(targets: PartyCombatTarget[]) {
return targets.filter((target) => target.boss.hp > 0);
}
function targetsInRange(source: WorldPosition, targets: PartyCombatTarget[], range: number) {
return livingTargets(targets).filter((target) => distance(source, target.motion.position) <= range);
}
function targetFor(id: AiCombatantId, context: PartyCombatContext, range = Number.POSITIVE_INFINITY) {
const eligible = targetsInRange(context.positions[id], context.targets, range);
if (!eligible.length) return undefined;
if (id === "orin" && eligible.length > 1) return eligible[1];
if (RANGED_IDS.includes(id)) return eligible[0];
let nearest = eligible[0];
for (let index = 1; index < eligible.length; index += 1) {
if (distance(context.positions[id], eligible[index].motion.position) < distance(context.positions[id], nearest.motion.position)) nearest = eligible[index];
}
return nearest;
}
function partyNeedsBulwark(context: PartyCombatContext) {
const brann = context.party.find((member) => member.id === "brann");
const lowMembers = context.party.filter((member) => member.hp > 0 && member.hp / member.maxHp < 0.6).length;
return context.upcomingMechanicRemaining <= 2 || (brann?.hp ?? 0) / Math.max(1, brann?.maxHp ?? 1) < 0.7 || lowMembers >= 2;
}
function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean, context: PartyCombatContext): AbilitySpec | null {
const source = context.positions[actor.id];
const rangedTarget = targetFor(actor.id, context);
if (actor.id === "nia") {
if (!rangedTarget) return null;
if (rangedTarget.boss.hp / rangedTarget.boss.maxHp <= 0.25 && isReady(actor, "kill_shot", at)) return { id: "kill_shot", duration: 0.45, impactOffsets: [0.24], damage: 7, gcd: 1.1, cooldown: 10 };
if (!isMoving && isReady(actor, "rapid_fire", at)) return { id: "rapid_fire", duration: 2, impactOffsets: [0.4, 0.8, 1.2, 1.6], damage: 2, gcd: 2, cooldown: 9, requiresStationary: true };
if (!isMoving && actor.resource >= 50) return { id: "deadeye", duration: 1, impactOffsets: [1], damage: 10, gcd: 1.1, requiresStationary: true };
if (!isMoving) return { id: "aimed_shot", duration: 1.5, impactOffsets: [1.5], damage: 4, gcd: 1.5, requiresStationary: true };
return { id: "quick_shot", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.15 };
}
if (actor.id === "orin") {
if (!rangedTarget) return null;
if (!isMoving && actor.overchargeStacks === 0 && isReady(actor, "overcharge", at)) return { id: "overcharge", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 20 };
if (!isMoving && isReady(actor, "comet", at)) return { id: "comet", duration: 2, impactOffsets: [2], damage: 9, gcd: 2, cooldown: 12, requiresStationary: true };
if (!isMoving && actor.points >= 3) return { id: "arcane_burst", duration: 1, impactOffsets: [1], damage: 8, gcd: 1.1, requiresStationary: true };
if (isReady(actor, "ember_lance", at)) return { id: "ember_lance", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.1, cooldown: 4 };
if (isMoving) return null;
return { id: "arcane_bolt", duration: 1.4, impactOffsets: [1.4], damage: 3, gcd: 1.4, requiresStationary: true };
}
if (actor.id === "vale") {
const nearby = targetsInRange(source, context.targets, VALE_CLEAVE_RADIUS);
const target = targetFor(actor.id, context, VALE_MELEE_RANGE);
if (!target) return null;
if (nearby.length > 1 && actor.bladeFlurryUntil <= at && actor.resource >= 20 && isReady(actor, "blade_flurry", at)) return { id: "blade_flurry", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 15 };
if (nearby.length > 1 && actor.resource >= 35 && isReady(actor, "fan_of_blades", at)) return { id: "fan_of_blades", duration: 0.65, impactOffsets: [0.4], damage: 3, gcd: 1.1, cooldown: 6 };
if (actor.points >= 3 && actor.resource >= 25) return { id: "backstab", duration: 0.7, impactOffsets: [0.45], damage: 5, gcd: 1.1 };
if (nearby.length > 1 && actor.resource >= 30) return { id: "twin_fang", duration: 0.65, impactOffsets: [0.4], damage: 3, gcd: 1.1 };
return { id: "quick_cut", duration: 0.55, impactOffsets: [0.32], damage: 2, gcd: 1.1 };
}
const target = targetFor(actor.id, context, BRANN_MELEE_RANGE);
if (!target) return null;
if (partyNeedsBulwark(context) && isReady(actor, "bulwark_march", at)) return { id: "bulwark_march", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 30 };
if (actor.revengeReadyUntil > at && isReady(actor, "revenge", at)) return { id: "revenge", duration: 0.6, impactOffsets: [0.35], damage: 3, gcd: 1.1, cooldown: 5 };
if (isReady(actor, "shield_slam", at)) return { id: "shield_slam", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 6 };
if (targetsInRange(source, context.targets, BRANN_MELEE_RANGE).length > 1 && isReady(actor, "sweeping_guard", at)) return { id: "sweeping_guard", duration: 0.65, impactOffsets: [0.4], damage: 2, gcd: 1.1, cooldown: 4 };
return { id: "sword_slash", duration: 0.6, impactOffsets: [0.35], damage: 1, gcd: 1.2 };
}
function applyStartCosts(actor: PartyCombatantState, spec: AbilitySpec, at: number, state: PartyCombatState) {
if (spec.cooldown) actor.cooldowns[spec.id] = at + spec.cooldown;
if (spec.id === "quick_shot") actor.resource = Math.min(100, actor.resource + 10);
if (spec.id === "aimed_shot") actor.resource = Math.min(100, actor.resource + 15);
if (spec.id === "deadeye") actor.resource -= 50;
if (spec.id === "arcane_bolt") actor.points = Math.min(3, actor.points + 1);
if (spec.id === "arcane_burst") actor.points = 0;
if (spec.id === "overcharge") actor.overchargeStacks = 3;
if (spec.id === "quick_cut") actor.points = Math.min(5, actor.points + 1);
if (spec.id === "twin_fang") actor.resource -= 30;
if (spec.id === "backstab") { actor.resource -= 25; actor.points = Math.max(0, actor.points - 3); }
if (spec.id === "fan_of_blades") actor.resource -= 35;
if (spec.id === "blade_flurry") { actor.resource -= 20; actor.bladeFlurryUntil = at + 8; }
if (spec.id === "bulwark_march") state.tankAura.expiresAt = at + 6;
}
function startAction(actor: PartyCombatantState, spec: AbilitySpec, target: PartyCombatTarget, at: number, state: PartyCombatState) {
applyStartCosts(actor, spec, at, state);
let multiplier = 1;
if (actor.id === "orin" && spec.damage > 0 && actor.overchargeStacks > 0) {
multiplier = 1.25;
actor.overchargeStacks -= 1;
}
actor.readyAt = at + spec.gcd;
actor.activeAction = {
abilityId: spec.id,
targetInstanceId: target.instanceId,
startedAt: at,
completesAt: at + spec.duration,
impactTimes: spec.impactOffsets.map((offset) => at + offset),
nextImpactIndex: 0,
baseDamage: spec.damage,
requiresStationary: spec.requiresStationary ?? false,
multiplier,
};
actor.visualAction = {
abilityId: spec.id,
targetInstanceId: target.instanceId,
startedAt: at,
impactAt: spec.impactOffsets.length ? at + spec.impactOffsets[0] : at + spec.duration,
endsAt: at + Math.max(spec.duration, spec.gcd),
};
}
function damageTarget(
state: PartyCombatState,
actor: PartyCombatantState,
action: PartyCombatAction,
target: PartyCombatTarget,
amount: number,
at: number,
secondary: boolean,
events: PartyDamageEvent[],
) {
if (target.boss.hp <= 0 || amount <= 0) return;
const dealt = Math.min(target.boss.hp, amount * action.multiplier * PARTY_DAMAGE_SCALE);
target.boss.hp -= dealt;
actor.damageDone += dealt;
events.push({ id: state.nextEventId++, at, sourceId: actor.id, abilityId: action.abilityId, targetInstanceId: target.instanceId, amount: dealt, secondary });
}
function resolveImpact(state: PartyCombatState, actor: PartyCombatantState, action: PartyCombatAction, at: number, context: PartyCombatContext, targets: PartyCombatTarget[], events: PartyDamageEvent[]) {
const source = context.positions[actor.id];
const range = actor.id === "vale" ? VALE_MELEE_RANGE : actor.id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
let target = targets.find((entry) => entry.instanceId === action.targetInstanceId && entry.boss.hp > 0 && distance(source, entry.motion.position) <= range);
target ??= targetFor(actor.id, { ...context, targets }, range);
if (!target) return;
if (action.abilityId === "fan_of_blades" || action.abilityId === "sweeping_guard") {
const radius = action.abilityId === "fan_of_blades" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE;
for (const nearby of targetsInRange(source, targets, radius)) damageTarget(state, actor, action, nearby, action.baseDamage, at, nearby.instanceId !== target.instanceId, events);
return;
}
damageTarget(state, actor, action, target, action.baseDamage, at, false, events);
const secondaryTargets = targetsInRange(source, targets, actor.id === "vale" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE).filter((entry) => entry.instanceId !== target!.instanceId);
if (action.abilityId === "twin_fang") {
for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, 2, at, true, events);
} else if (actor.id === "vale" && actor.bladeFlurryUntil > at && !["fan_of_blades", "blade_flurry"].includes(action.abilityId)) {
for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, action.baseDamage * 0.5, at, true, events);
}
}
export function advancePartyCombat(source: PartyCombatState, context: PartyCombatContext) {
const state: PartyCombatState = {
combatants: {
brann: cloneCombatant(source.combatants.brann),
nia: cloneCombatant(source.combatants.nia),
orin: cloneCombatant(source.combatants.orin),
vale: cloneCombatant(source.combatants.vale),
},
tankAura: { ...source.tankAura },
nextEventId: source.nextEventId,
};
const targets = context.targets.map((target) => ({ ...target, boss: { ...target.boss } }));
const events: PartyDamageEvent[] = [];
const elapsed = context.time - context.oldTime;
for (const id of ["brann", "nia", "orin", "vale"] as const) {
const actor = state.combatants[id];
const member = context.party.find((entry) => entry.id === id);
if (!member || member.hp <= 0) continue;
if (member.knockedUntil > context.time) {
actor.activeAction = null;
actor.readyAt = Math.max(actor.readyAt, member.knockedUntil);
continue;
}
if (id === "vale") actor.resource = Math.min(100, actor.resource + 12 * elapsed);
if (id === "brann" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5;
actor.lastHp = member.hp;
const isMoving = moving(id, context);
if (isMoving && actor.activeAction?.requiresStationary) {
actor.activeAction = null;
actor.readyAt = Math.max(actor.readyAt, context.oldTime + 0.2);
if (actor.visualAction) actor.visualAction.endsAt = context.oldTime;
}
for (let safety = 0; safety < 12; safety += 1) {
const action = actor.activeAction;
if (action) {
while (action.nextImpactIndex < action.impactTimes.length && action.impactTimes[action.nextImpactIndex] <= context.time + 0.001) {
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], context, targets, events);
action.nextImpactIndex += 1;
}
if (action.completesAt > context.time + 0.001) break;
actor.activeAction = null;
continue;
}
const startAt = Math.max(context.oldTime, actor.readyAt);
if (startAt > context.time + 0.001) break;
const spec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
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);
if (!target) { actor.readyAt = context.time + 0.1; break; }
startAction(actor, spec, target, startAt, state);
}
}
return { state, events };
}
export function tankAuraProtects(position: WorldPosition, tankPosition: WorldPosition, aura: TankAuraState, time: number) {
return aura.expiresAt > time && distance(position, tankPosition) <= aura.radius;
}
+468
View File
@@ -0,0 +1,468 @@
import { beforeEach, describe, expect, it } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
describe("Disc Priest combat simulation", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
useGameStore.getState().startEncounter();
});
it("resolves Mend after a 0.5 second cast with no cooldown", () => {
const party = useGameStore.getState().party.map((member) =>
member.id === "nia" ? { ...member, hp: 40 } : member,
);
useGameStore.setState({ party });
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
useGameStore.getState().tick(0.4);
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.hp).toBe(40);
useGameStore.getState().tick(0.11);
const state = useGameStore.getState();
expect(state.party.find((member) => member.id === "nia")?.hp).toBe(78);
expect(state.activeCast).toBeNull();
expect(state.cooldowns.mend).toBe(0);
expect(state.castAbility("mend")).toBe(true);
});
it("uses the rebalanced Priest mana costs", () => {
expect(Object.fromEntries(Object.entries(HEALER_CLASSES.priest.abilities).map(([id, ability]) => [id, ability.mana]))).toEqual({
mend: 5,
renew: 7,
shield: 8,
purify: 5,
radiance: 12,
barrier: 10,
});
});
it("uses a three-second Purify cooldown for every healer class", () => {
expect(HEALER_CLASSES.priest.abilities.purify.cooldown).toBe(3);
expect(HEALER_CLASSES.druid.abilities.purify.cooldown).toBe(3);
expect(HEALER_CLASSES.shaman.abilities.purify.cooldown).toBe(3);
});
it("configures placeholder healer kits and class-owned inventory", () => {
const inventory = createClassInventory("druid");
useGameStore.getState().configureHealer("druid", "Aelia", inventory);
const state = useGameStore.getState();
expect(state.healerClassId).toBe("druid");
expect(state.party[0].className).toBe("Restoration Druid");
expect(state.party[0].name).toBe("Aelia");
expect(state.inventory).toEqual(inventory);
expect(HEALER_CLASSES.druid.abilities.mend.name).toBe("Healing Touch");
expect(HEALER_CLASSES.shaman.abilities.radiance.name).toBe("Chain Heal");
});
it("ticks Renew once per second for eight seconds", () => {
const party = useGameStore.getState().party.map((member) =>
member.id === "brann" ? { ...member, hp: 70 } : member,
);
useGameStore.setState({ party });
useGameStore.getState().castAbility("renew");
for (let index = 0; index < 8; index += 1) useGameStore.getState().tick(1);
const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
expect(brann.renewExpiresAt).toBe(0);
expect(brann.hp).toBeGreaterThan(70);
});
it("gives Renew no individual cooldown", () => {
expect(useGameStore.getState().castAbility("renew")).toBe(true);
expect(useGameStore.getState().cooldowns.renew).toBe(0);
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().castAbility("renew")).toBe(true);
expect(useGameStore.getState().cooldowns.renew).toBe(0);
});
it("blocks all abilities during the shared 0.5 second global cooldown", () => {
expect(useGameStore.getState().castAbility("renew")).toBe(true);
expect(useGameStore.getState().castAbility("shield")).toBe(false);
useGameStore.getState().tick(0.49);
expect(useGameStore.getState().castAbility("shield")).toBe(false);
useGameStore.getState().tick(0.02);
expect(useGameStore.getState().castAbility("shield")).toBe(true);
});
it("uses blue absorption before health", () => {
useGameStore.getState().castAbility("shield");
useGameStore.getState().tick(2);
const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
expect(brann.hp).toBe(150);
expect(brann.absorb).toBe(21);
});
it("Purify removes Ember Brand from selected ally", () => {
useGameStore.getState().tick(2);
useGameStore.getState().tick(2);
useGameStore.getState().tick(1.1);
const branded = useGameStore.getState().party.find((member) => member.id === "nia")!;
expect(branded.debuffs).toHaveLength(1);
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.debuffs).toHaveLength(0);
});
it("Radiance heals every living party member", () => {
useGameStore.setState({
party: useGameStore.getState().party.map((member) => ({ ...member, hp: member.hp - 30 })),
});
useGameStore.getState().castAbility("radiance");
for (const member of useGameStore.getState().party) {
expect(member.hp).toBe(member.maxHp - 8);
}
});
it("reduces damage by 30% for party members inside Barrier", () => {
useGameStore.getState().setPlayerPosition([0, -3.7]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextNovaAt: 999, nextBrandAt: 999 },
bossMotion: { ...state.bossMotion, nextChargeAt: 999 },
}));
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
useGameStore.getState().tick(2);
const state = useGameStore.getState();
expect(state.party.find((member) => member.id === "brann")?.hp).toBeCloseTo(139.5, 4);
expect(barrierProtects(state.partyPositions.brann, state.barrier, state.time)).toBe(true);
expect(state.cooldowns.barrier).toBe(60);
});
it("expires Barrier after eight seconds", () => {
useGameStore.getState().castAbility("barrier");
const barrier = useGameStore.getState().barrier;
expect(barrierProtects(barrier.center, barrier, 7.99)).toBe(true);
expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
});
it("keeps ranged allies stable while Vale closes to melee range", () => {
const start = structuredClone(useGameStore.getState().partyPositions);
const bossPosition = useGameStore.getState().bossMotion.position;
useGameStore.getState().tick(1);
const moved = useGameStore.getState().partyPositions;
expect(moved.aelia).toEqual(start.aelia);
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));
});
it("freezes authoritative simulation while paused", () => {
useGameStore.getState().tick(1);
const before = useGameStore.getState();
useGameStore.getState().setPaused(true);
useGameStore.getState().tick(5);
const paused = useGameStore.getState();
expect(paused.time).toBe(before.time);
expect(paused.party).toEqual(before.party);
expect(paused.boss.hp).toBe(before.boss.hp);
expect(paused.castAbility("renew")).toBe(false);
});
it("telegraphs, executes, and recovers from a Bull charge", () => {
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
const telegraph = useGameStore.getState().bossMotion;
expect(telegraph.mode).toBe("telegraph");
expect(telegraph.chargeTargetId).toBe("nia");
const midpoint: [number, number] = [
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
];
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, nia: midpoint },
party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member),
}));
while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes("nia"); step += 1) {
useGameStore.getState().tick(0.05);
}
const hitState = useGameStore.getState();
expect(hitState.bossMotion.chargeHitIds).toContain("nia");
expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
useGameStore.getState().tick(0.1);
}
const recovered = useGameStore.getState();
expect(recovered.bossMotion.mode).toBe("holding");
expect(recovered.bossMotion.nextChargeAt).toBeGreaterThan(recovered.time);
});
it("moves every mobile AI party member out of the charge lane before impact", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
}));
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
useGameStore.getState().tick(0.1);
}
const warning = useGameStore.getState().bossMotion;
const exposedAtWarning = (["brann", "nia", "orin", "vale"] as const).filter((memberId) =>
pointToSegmentDistance(
useGameStore.getState().partyPositions[memberId],
warning.chargeStart,
warning.chargeEnd,
) <= BULL_CHARGE.hitRadius,
);
expect(exposedAtWarning).toContain(warning.chargeTargetId);
while (useGameStore.getState().bossMotion.mode === "telegraph") {
useGameStore.getState().tick(0.1);
}
const charge = useGameStore.getState();
for (const memberId of exposedAtWarning) {
expect(pointToSegmentDistance(
charge.partyPositions[memberId],
charge.bossMotion.chargeStart,
charge.bossMotion.chargeEnd,
)).toBeGreaterThan(BULL_CHARGE.hitRadius);
}
while (useGameStore.getState().bossMotion.mode === "charging") {
useGameStore.getState().tick(0.05);
}
const hitIds = useGameStore.getState().bossMotion.chargeHitIds;
for (const memberId of exposedAtWarning) expect(hitIds).not.toContain(memberId);
});
it("marks a stack target after three charges and splits 300 pounce damage", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
playerPosition: [0, 0],
partyPositions: {
aelia: [0, 0],
brann: [0, 0],
nia: [0, 0],
orin: [0, 0],
vale: [0, 0],
},
bossMotion: {
...state.bossMotion,
mode: "returning",
position: [0, -4.25],
chargesSincePounce: 3,
},
}));
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
expect(useGameStore.getState().bossMotion.pounceTargetId).toBe("aelia");
expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
useGameStore.getState().tick(0.1);
}
expect(useGameStore.getState().bossMotion.mode).toBe("pouncing");
for (let step = 0; step < 20 && useGameStore.getState().bossMotion.mode === "pouncing"; step += 1) {
useGameStore.getState().tick(0.05);
}
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(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
});
});
describe("Vexa encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "vexa");
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
});
it("lets modular party behavior spread tethered allies until Binding Web snaps", () => {
while (useGameStore.getState().bossMotion.mode !== "tethering") useGameStore.getState().tick(0.1);
const tether = useGameStore.getState().bossMotion;
expect(tether.tetherIds).toEqual(["brann", "vale"]);
const [first, second] = tether.tetherIds;
expect(Math.hypot(
useGameStore.getState().partyPositions[first][0] - useGameStore.getState().partyPositions[second][0],
useGameStore.getState().partyPositions[first][1] - useGameStore.getState().partyPositions[second][1],
)).toBeLessThan(tether.tetherBreakDistance);
for (let step = 0; step < 50 && useGameStore.getState().bossMotion.mode === "tethering"; step += 1) {
useGameStore.getState().tick(0.1);
}
expect(useGameStore.getState().bossMotion.mode).toBe("holding");
expect(useGameStore.getState().bossMotion.tetherIds).toEqual([]);
});
it("drops a persistent venom pool when Widow Venom is purified", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
}));
useGameStore.getState().tick(0.05);
const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
expect(poisoned).toBeDefined();
useGameStore.getState().selectMember(poisoned.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
const state = useGameStore.getState();
expect(state.party.find((member) => member.id === poisoned.id)?.debuffs).toEqual([]);
expect(state.bossMotion.hazards).toHaveLength(1);
expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
});
it("repeatedly damages the player while they remain in a venom pool", () => {
useGameStore.getState().setPlayerPosition([0, 0]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: {
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
nextMechanicAt: 999,
},
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.3);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(1);
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
});
it("follows Brann when the tank is displaced", () => {
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, brann: [3, 1] },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
const startX = useGameStore.getState().bossMotion.position[0];
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().bossMotion.position[0]).toBeGreaterThan(startX);
});
});
describe("PVE dual-boss encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["vexa", "cindermaw"]);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, nextMeleeAt: 999 } })),
}));
});
it("runs two distinct bosses concurrently and requires both to fall", () => {
const initial = useGameStore.getState();
expect(initial.boss.id).toBe("vexa");
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["cindermaw"]);
expect(initial.bossMotion.position[0]).toBeLessThan(initial.additionalBosses[0].motion.position[0]);
useGameStore.getState().tick(1);
const damaged = useGameStore.getState();
const primaryDamage = damaged.partyDamageEvents
.filter((event) => event.targetInstanceId === `boss-0-${damaged.boss.id}`)
.reduce((sum, event) => sum + event.amount, 0);
const secondaryDamage = damaged.partyDamageEvents
.filter((event) => event.targetInstanceId === damaged.additionalBosses[0].instanceId)
.reduce((sum, event) => sum + event.amount, 0);
expect(primaryDamage + secondaryDamage).toBeGreaterThan(0);
expect(damaged.boss.hp).toBeCloseTo(damaged.boss.maxHp - primaryDamage, 4);
expect(damaged.additionalBosses[0].boss.hp).toBeCloseTo(damaged.additionalBosses[0].boss.maxHp - secondaryDamage, 4);
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 1 } })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.getState().tick(1);
expect(useGameStore.getState().phase).toBe("victory");
});
});
describe("Cindermaw encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
});
it("rotates Searing Sweep while AI party behavior moves to safe flanks", () => {
while (useGameStore.getState().bossMotion.mode !== "breath_telegraph") useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossMotion.breathEndAngle).not.toBe(useGameStore.getState().bossMotion.breathStartAngle);
for (let step = 0; step < 80 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
useGameStore.getState().tick(0.1);
}
const hitIds = useGameStore.getState().bossMotion.mechanicHitIds;
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hitIds).not.toContain(memberId);
});
it("creates three staggered Skyfall impacts and moves AI clear", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
}));
useGameStore.getState().tick(0.05);
const warning = useGameStore.getState();
expect(warning.bossMotion.mode).toBe("skyfall");
expect(warning.bossMotion.hazards).toHaveLength(3);
expect(warning.bossMotion.hazards[1].activatesAt).toBeGreaterThan(warning.bossMotion.hazards[0].activatesAt);
for (let step = 0; step < 45; step += 1) useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossMotion.hazards).toHaveLength(3);
for (const hazard of useGameStore.getState().bossMotion.hazards) {
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hazard.hitIds).not.toContain(memberId);
}
});
it("deals repeated Searing Sweep damage until the exposed player moves out", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
bossMotion: {
...state.bossMotion,
mode: "breath_sweeping",
position: [0, -2.8],
phaseStartedAt: state.time,
phaseEndsAt: state.time + 3,
breathAngle: 0,
breathStartAngle: 0,
breathEndAngle: 0,
mechanicHitIds: [],
mechanicNextDamageAt: {},
},
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.1);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.5);
const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBeLessThan(startingHp);
expect(secondTickHp).toBeLessThan(firstTickHp);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
});
});
+537
View File
@@ -0,0 +1,537 @@
import { create } from "zustand";
import {
advanceBossMechanics,
createBossMotionState,
createBossState,
handleBossDispel,
upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
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 type {
ActiveCast,
AbilityId,
BarrierState,
BossMotionState,
BossState,
BossId,
BottomTab,
GamePhase,
HealerClassId,
InventoryItem,
MemberId,
PartyMember,
ScenePulse,
WorldPosition,
} from "./types";
export interface CombatLogEntry {
id: number;
time: number;
message: string;
tone: "good" | "danger" | "neutral";
}
export interface AdditionalBossState {
instanceId: string;
boss: BossState;
motion: BossMotionState;
}
interface GameState {
bossId: BossId;
paused: boolean;
pauseSelection: "resume" | "exit";
healerClassId: HealerClassId;
playerName: string;
phase: GamePhase;
time: number;
party: PartyMember[];
boss: BossState;
additionalBosses: AdditionalBossState[];
partyPositions: Record<MemberId, WorldPosition>;
bossMotion: BossMotionState;
partyCombat: PartyCombatState;
partyDamageEvents: PartyDamageEvent[];
mana: number;
maxMana: number;
selectedMemberId: MemberId;
cooldowns: Record<AbilityId, number>;
globalCooldownUntil: number;
activeTab: BottomTab;
selectedItemId: string;
inventory: InventoryItem[];
combatLog: CombatLogEntry[];
scenePulse: ScenePulse;
playerPosition: [number, number];
activeCast: ActiveCast | null;
barrier: BarrierState;
configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[]) => void;
startEncounter: () => void;
restart: () => void;
tick: (delta: number) => void;
castAbility: (abilityId: AbilityId) => boolean;
selectMember: (memberId: MemberId) => void;
cycleMember: (direction: 1 | -1) => void;
setActiveTab: (tab: BottomTab) => void;
selectItem: (itemId: string) => void;
setPlayerPosition: (position: [number, number]) => void;
setPaused: (paused: boolean) => void;
togglePause: () => void;
setPauseSelection: (selection: "resume" | "exit") => void;
}
const emptyCooldowns = (): Record<AbilityId, number> => ({
mend: 0,
renew: 0,
shield: 0,
purify: 0,
radiance: 0,
barrier: 0,
});
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const BARRIER_RADIUS = 3;
export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 2);
return unique.length ? unique : ["bulldrome"];
};
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
const motion = cloneMotion(createBossMotionState(bossId));
const offset = count > 1 ? (index === 0 ? -2.65 : 2.65) : 0;
motion.formationOffsetX = offset;
motion.position[0] += offset;
motion.chargeStart[0] += offset;
motion.chargeEnd[0] += offset;
motion.pounceCenter[0] += offset;
const stagger = index * 2.4;
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
return motion;
}
function createEncounterBoss(bossId: BossId, index: number, count: number): AdditionalBossState {
const boss = createBossState(bossId);
const stagger = index * 0.8;
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4;
if (Number.isFinite(boss.nextBrandAt)) boss.nextBrandAt += index * 2.4;
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
}
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) };
};
export function damageMember(member: PartyMember, amount: number): PartyMember {
const absorbed = Math.min(member.absorb, amount);
return {
...member,
absorb: Math.max(0, member.absorb - absorbed),
hp: Math.max(0, member.hp - (amount - absorbed)),
};
}
export function healMember(member: PartyMember, amount: number): PartyMember {
if (member.hp <= 0) return member;
return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
}
export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
}
function damageMemberAt(
member: PartyMember,
amount: number,
position: WorldPosition,
barrier: BarrierState,
time: number,
partyCombat?: PartyCombatState,
tankPosition?: WorldPosition,
) {
const protectedByTank = partyCombat && tankPosition
? tankAuraProtects(position, tankPosition, partyCombat.tankAura, time)
: false;
const reduction = Math.max(
barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
);
return damageMember(member, amount * (1 - reduction));
}
function addLog(
log: CombatLogEntry[],
time: number,
message: string,
tone: CombatLogEntry["tone"] = "neutral",
): CombatLogEntry[] {
const next = [{ id: Date.now() + Math.random(), time, message, tone }, ...log];
return next.slice(0, 12);
}
function initialState(
healerClassId: HealerClassId = "priest",
playerName = "Aelia",
inventory: InventoryItem[] = createClassInventory(healerClassId),
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
) {
const bossIds = normalizeBossIds(requestedBossIds);
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(bossId, index, bossIds.length));
const primary = encounterBosses[0];
const party = freshParty(healerClassId, playerName);
return {
bossId: primary.boss.id,
paused: false,
pauseSelection: "resume" as const,
healerClassId,
playerName,
phase: "briefing" as GamePhase,
time: 0,
party,
boss: primary.boss,
additionalBosses: encounterBosses.slice(1),
partyPositions: freshPartyPositions(bossIds),
bossMotion: primary.motion,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [] as PartyDamageEvent[],
mana: 100,
maxMana: 100,
selectedMemberId: "brann" as MemberId,
cooldowns: emptyCooldowns(),
globalCooldownUntil: 0,
activeTab: "combat" as BottomTab,
selectedItemId: inventory[0]?.id ?? "",
inventory: structuredClone(inventory),
combatLog: [] as CombatLogEntry[],
scenePulse: { id: 0, kind: "mend" as const },
playerPosition: [0, 4.5] as [number, number],
activeCast: null as ActiveCast | null,
barrier: { center: [0, 4.5], expiresAt: 0 } as BarrierState,
};
}
export const useGameStore = create<GameState>((set, get) => ({
...initialState(),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome") => set(initialState(healerClassId, playerName, inventory, bossIds)),
startEncounter: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({
...initialState(healerClassId, playerName, inventory, bossIds),
phase: "combat",
activeTab: "combat",
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
});
},
restart: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses } = get();
set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]));
},
selectMember: (selectedMemberId) => set({ selectedMemberId }),
cycleMember: (direction) => {
const { party, selectedMemberId } = get();
const living = party.filter((member) => member.hp > 0);
if (!living.length) return;
const currentIndex = living.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + direction + living.length) % living.length;
set({ selectedMemberId: living[nextIndex].id });
},
setActiveTab: (activeTab) => set({ activeTab }),
selectItem: (selectedItemId) => set({ selectedItemId }),
setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
setPauseSelection: (pauseSelection) => set({ pauseSelection }),
setPlayerPosition: (playerPosition) => set((state) => ({
playerPosition,
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
})),
castAbility: (abilityId) => {
const state = get();
if (state.phase !== "combat") return false;
if (state.paused) return false;
if (state.activeCast) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
const selected = state.party[selectedIndex];
if (state.cooldowns[abilityId] > state.time + 0.01) return false;
if (state.globalCooldownUntil > state.time + 0.001) return false;
if (state.mana < ability.mana) {
set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
return false;
}
if (ability.targeting === "ally" && (!selected || selected.hp <= 0)) return false;
if (abilityId === "purify" && selected.debuffs.length === 0) {
set({ combatLog: addLog(state.combatLog, state.time, `${selected.name} has nothing to ${ability.name}.`) });
return false;
}
if (abilityId === "mend") {
set({
activeCast: {
abilityId: "mend",
targetId: selected.id,
startedAt: state.time,
completesAt: state.time + (ability.castTime ?? 0.5),
},
mana: Math.max(0, state.mana - ability.mana),
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
});
return true;
}
let party = state.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
let message = ability.name;
let barrier = state.barrier;
let bossMotion = state.bossMotion;
let additionalBosses = state.additionalBosses;
switch (abilityId) {
case "renew":
party[selectedIndex] = {
...party[selectedIndex],
renewExpiresAt: state.time + 8,
renewNextTickAt: state.time + 1,
};
message = `${ability.name} placed on ${selected.name}.`;
break;
case "shield":
party[selectedIndex] = {
...party[selectedIndex],
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36),
};
message = `${selected.name} gains 36 absorption.`;
break;
case "purify":
{
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
if (state.boss.id === "vexa") {
const dispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
bossMotion = dispel.motion;
}
additionalBosses = state.additionalBosses.map((entry) => {
if (entry.boss.id !== "vexa") return entry;
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
return { ...entry, motion: dispel.motion };
});
message = dispelledNames.includes("Widow Venom")
? "Widow Venom purged. A venom pool forms where the target stood."
: `${dispelledNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
}
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
break;
case "radiance":
party = party.map((member) => healMember(member, 22));
message = `${ability.name} heals the full party.`;
break;
case "barrier":
barrier = { center: [...state.partyPositions.aelia], expiresAt: state.time + 8 };
message = `${ability.name} protects a 3m circle for 8 seconds.`;
break;
}
const cooldowns = {
...state.cooldowns,
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown : 0,
};
const pulse: ScenePulse = {
id: state.scenePulse.id + 1,
kind: abilityId,
targetId: abilityId === "barrier" ? "aelia" : selected?.id,
};
set({
party,
cooldowns,
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
barrier,
bossMotion,
additionalBosses,
mana: Math.max(0, state.mana - ability.mana),
combatLog: addLog(state.combatLog, state.time, message, "good"),
scenePulse: pulse,
});
return true;
},
tick: (delta) => {
const state = get();
if (state.phase !== "combat" || state.paused || delta <= 0) return;
const oldTime = state.time;
const time = oldTime + Math.min(delta, 2);
let party = state.party.map((member) => ({ ...member, debuffs: member.debuffs.map((debuff) => ({ ...debuff })) }));
let boss = { ...state.boss };
let bossMotion = { ...state.bossMotion };
let additionalBosses = state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss },
motion: cloneMotion(entry.motion),
}));
let partyPositions = state.partyPositions;
let combatLog = state.combatLog;
let pulse = state.scenePulse;
let activeCast = state.activeCast ? { ...state.activeCast } : null;
let partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents;
const barrier = state.barrier;
if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
const target = party[targetIndex];
if (target?.hp > 0) {
party[targetIndex] = healMember(target, 38);
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for 38.`, "good");
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
}
activeCast = null;
}
party = party.map((member) => {
let next = member;
if (next.renewExpiresAt > oldTime && next.renewNextTickAt <= time) {
let tickAt = next.renewNextTickAt;
const lastTickAt = Math.min(time, next.renewExpiresAt);
while (tickAt <= lastTickAt + 0.001) {
next = healMember(next, 7);
tickAt += 1;
}
next = {
...next,
renewExpiresAt: time >= next.renewExpiresAt ? 0 : next.renewExpiresAt,
renewNextTickAt: time >= next.renewExpiresAt ? 0 : tickAt,
};
}
const activeDebuffs = next.debuffs
.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);
updated.nextTickAt += 1;
}
return updated;
})
.filter((debuff) => debuff.expiresAt > time);
return { ...next, debuffs: activeDebuffs };
});
const livingMotions = [
...(boss.hp > 0 ? [bossMotion] : []),
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
];
partyPositions = updatePartyPositions(state.partyPositions, livingMotions, party, time, time - oldTime);
const encounterBosses: AdditionalBossState[] = [
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
...additionalBosses,
];
for (let index = 0; index < encounterBosses.length; index += 1) {
const encounterBoss = encounterBosses[index];
if (encounterBoss.boss.hp <= 0) continue;
const mechanicResult = advanceBossMechanics({
boss: encounterBoss.boss,
motion: encounterBoss.motion,
party,
partyPositions,
time,
delta: time - oldTime,
damageMember: (member, amount, position, at) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann),
});
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) {
pulse = { id: pulse.id + 1, kind: event.pulseKind, targetId: event.targetId };
}
}
}
const mechanicRemaining = encounterBosses
.filter((entry) => entry.boss.hp > 0)
.map((entry) => upcomingMechanic(entry.boss, entry.motion, time).remaining);
const partyCombatResult = advancePartyCombat(partyCombat, {
oldTime,
time,
party,
oldPositions: state.partyPositions,
positions: partyPositions,
targets: encounterBosses,
upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY,
});
partyCombat = partyCombatResult.state;
partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24);
for (const event of partyCombatResult.events) {
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
}
boss = encounterBosses[0].boss;
bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!;
let phase: GamePhase = state.phase;
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
phase = "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";
combatLog = addLog(combatLog, time, tank.hp <= 0 ? `Brann falls. ${boss.name} breaks formation.` : `${healer.name} falls. Healing ends.`, "danger");
}
set({
time,
party,
boss,
additionalBosses,
partyCombat,
partyDamageEvents,
partyPositions,
bossMotion,
phase,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
activeCast,
combatLog,
scenePulse: pulse,
});
},
}));
export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) {
return Math.max(0, cooldowns[abilityId] - time);
}
export { upcomingMechanic };
export function upcomingEncounterMechanic(state: Pick<GameState, "boss" | "bossMotion" | "additionalBosses" | "time">) {
const candidates = [
...(state.boss.hp > 0 ? [{ boss: state.boss, motion: state.bossMotion }] : []),
...state.additionalBosses.filter((entry) => entry.boss.hp > 0),
].map((entry) => upcomingMechanic(entry.boss, entry.motion, state.time));
return candidates.reduce((next, candidate) => candidate.remaining < next.remaining ? candidate : next, candidates[0]);
}
+155
View File
@@ -0,0 +1,155 @@
export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
export type BossId = "bulldrome" | "vexa" | "cindermaw";
export type GamePhase = "briefing" | "combat" | "victory" | "defeat";
export type BottomTab = "combat" | "map" | "pack";
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall";
export type BossMotionMode =
| "holding"
| "telegraph"
| "charging"
| "returning"
| "stacking"
| "pouncing"
| "tethering"
| "venom_cast"
| "breath_telegraph"
| "breath_sweeping"
| "skyfall";
export type WorldPosition = [number, number];
export type CircleHazardKind = "venom_pool" | "skyfall";
export interface CircleHazard {
id: string;
kind: CircleHazardKind;
center: WorldPosition;
radius: number;
activatesAt: number;
expiresAt: number;
damage: number;
tickInterval?: number;
nextDamageAt: Partial<Record<MemberId, number>>;
resolved: boolean;
hitIds: MemberId[];
}
export interface Debuff {
id: string;
name: string;
expiresAt: number;
nextTickAt: number;
tickDamage: number;
}
export interface PartyMember {
id: MemberId;
name: string;
className: string;
role: "Healer" | "Tank" | "Damage";
color: string;
maxHp: number;
hp: number;
absorb: number;
renewExpiresAt: number;
renewNextTickAt: number;
knockedUntil: number;
debuffs: Debuff[];
}
export interface BossState {
id: BossId;
name: string;
maxHp: number;
hp: number;
nextMeleeAt: number;
nextNovaAt: number;
nextBrandAt: number;
brandCount: number;
}
export interface BossMotionState {
bossId: BossId;
formationOffsetX: number;
mode: BossMotionMode;
position: WorldPosition;
chargeStart: WorldPosition;
chargeEnd: WorldPosition;
chargeTargetId: MemberId;
chargeHitIds: MemberId[];
phaseEndsAt: number;
nextChargeAt: number;
chargeCount: number;
chargesSincePounce: number;
pounceTargetId: MemberId;
pounceCenter: WorldPosition;
pounceCount: number;
nextMechanicAt: number;
mechanicCount: number;
phaseStartedAt: number;
mechanicHitIds: MemberId[];
mechanicNextDamageAt: Partial<Record<MemberId, number>>;
tetherIds: MemberId[];
tetherBreakDistance: number;
breathAngle: number;
breathStartAngle: number;
breathEndAngle: number;
hazards: CircleHazard[];
}
export interface AbilityDefinition {
id: AbilityId;
name: string;
shortName: string;
key: string;
gamepad: string;
cooldown: number;
castTime?: number;
mana: number;
icon: string;
description: string;
targeting: "enemy" | "ally" | "party";
color: string;
}
export interface ActiveCast {
abilityId: "mend";
targetId: MemberId;
startedAt: number;
completesAt: number;
}
export interface BarrierState {
center: WorldPosition;
expiresAt: number;
}
export interface ScenePulse {
id: number;
kind: PulseKind;
targetId?: MemberId;
}
export interface InventoryItem {
id: string;
name: string;
slot: string;
rarity: "Common" | "Uncommon" | "Rare";
icon: string;
stats: string[];
effect: string;
equipped: boolean;
}
export type HealerClassId = "priest" | "druid" | "shaman";
export interface HealerClassDefinition {
id: HealerClassId;
name: string;
specialization: string;
icon: string;
color: string;
resourceName: string;
description: string;
abilities: Record<AbilityId, AbilityDefinition>;
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useRef } from "react";
import { ABILITY_ORDER } from "./data";
import { useGameStore } from "./store";
import type { AbilityId } from "./types";
const gamepadAbilityMap: Record<number, AbilityId> = {
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
};
export function useGameLoop() {
useEffect(() => {
let frame = 0;
let previous = performance.now();
let accumulator = 0;
const loop = (now: number) => {
const delta = Math.min((now - previous) / 1000, 0.25);
previous = now;
accumulator += delta;
if (accumulator >= 0.1) {
useGameStore.getState().tick(accumulator);
accumulator = 0;
}
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, []);
}
export function useActionBindings(enabled = true, onExit?: () => void) {
const exitRef = useRef(onExit);
exitRef.current = onExit;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return;
if (event.repeat) return;
const store = useGameStore.getState();
const key = event.key.toLowerCase();
if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false);
if (key === "arrowup") store.setPauseSelection("resume");
if (key === "arrowdown") store.setPauseSelection("exit");
if (key === "enter") {
if (store.pauseSelection === "resume") store.setPaused(false);
else exitRef.current?.();
}
return;
}
const numberIndex = Number(event.key) - 1;
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
store.castAbility(ABILITY_ORDER[numberIndex]);
return;
}
switch (key) {
case "q":
store.cycleMember(-1);
break;
case "e":
store.cycleMember(1);
break;
case "m":
store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
break;
case "i":
store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack");
break;
case "enter":
if (store.phase === "briefing") store.startEncounter();
if (store.phase === "victory" || store.phase === "defeat") store.restart();
break;
case "escape":
if (store.phase === "combat") store.setPaused(true);
else exitRef.current?.();
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enabled]);
useEffect(() => {
let frame = 0;
let previousButtons: boolean[] = [];
const poll = () => {
const gamepad = navigator.getGamepads?.()[0];
if (gamepad && enabled) {
const buttons = gamepad.buttons.map((button) => button.pressed);
const store = useGameStore.getState();
if (store.paused) {
if (buttons[12] && !previousButtons[12]) store.setPauseSelection("resume");
if (buttons[13] && !previousButtons[13]) store.setPauseSelection("exit");
if ((buttons[1] && !previousButtons[1]) || (buttons[9] && !previousButtons[9])) store.setPaused(false);
if (buttons[0] && !previousButtons[0]) {
if (store.pauseSelection === "resume") store.setPaused(false);
else exitRef.current?.();
}
} else {
for (const [button, ability] of Object.entries(gamepadAbilityMap)) {
const index = Number(button);
if (buttons[index] && !previousButtons[index]) store.castAbility(ability);
}
if (buttons[12] && !previousButtons[12]) store.cycleMember(-1);
if (buttons[13] && !previousButtons[13]) store.cycleMember(1);
if (buttons[8] && !previousButtons[8]) store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
if (buttons[9] && !previousButtons[9]) {
if (store.phase === "briefing") store.startEncounter();
if (store.phase === "victory" || store.phase === "defeat") store.restart();
if (store.phase === "combat") store.setPaused(true);
}
}
previousButtons = buttons;
} else {
previousButtons = [];
}
frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
return () => cancelAnimationFrame(frame);
}, [enabled]);
}
+112
View File
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface MenuAction {
id: string;
run: () => void;
enabled?: boolean;
neighbors?: Partial<Record<"up" | "down" | "left" | "right", string>>;
}
interface MenuControllerOptions {
columns?: number;
onBack?: () => void;
}
type Direction = "up" | "down" | "left" | "right";
function nextIndex(index: number, direction: Direction, count: number, columns: number) {
const delta = direction === "left" ? -1 : direction === "right" ? 1 : direction === "up" ? -columns : columns;
return (index + delta + count) % count;
}
export function useMenuController(actions: MenuAction[], options: MenuControllerOptions = {}) {
const enabled = actions.filter((action) => action.enabled !== false);
const [focusedId, setFocusedId] = useState(enabled[0]?.id ?? "");
const actionsRef = useRef(enabled);
const backRef = useRef(options.onBack);
const columnsRef = useRef(options.columns ?? 1);
actionsRef.current = enabled;
backRef.current = options.onBack;
columnsRef.current = options.columns ?? 1;
useEffect(() => {
if (!actionsRef.current.some((action) => action.id === focusedId)) {
setFocusedId(actionsRef.current[0]?.id ?? "");
}
}, [actions, focusedId]);
const move = useCallback((direction: Direction) => {
const current = actionsRef.current;
if (!current.length) return;
const index = Math.max(0, current.findIndex((action) => action.id === focusedId));
const neighborId = current[index]?.neighbors?.[direction];
if (neighborId && current.some((action) => action.id === neighborId)) {
setFocusedId(neighborId);
return;
}
setFocusedId(current[nextIndex(index, direction, current.length, columnsRef.current)].id);
}, [focusedId]);
const confirm = useCallback(() => {
actionsRef.current.find((action) => action.id === focusedId)?.run();
}, [focusedId]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat || event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
const key = event.key.toLowerCase();
if (["arrowup", "w"].includes(key)) move("up");
else if (["arrowdown", "s"].includes(key)) move("down");
else if (["arrowleft", "a"].includes(key)) move("left");
else if (["arrowright", "d"].includes(key)) move("right");
else if (key === "enter" || key === " ") confirm();
else if (key === "escape" || key === "backspace") backRef.current?.();
else return;
event.preventDefault();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [confirm, move]);
useEffect(() => {
let frame = 0;
let previous: boolean[] = [];
let heldDirection: Direction | null = null;
let nextRepeatAt = 0;
const poll = (now: number) => {
const gamepad = navigator.getGamepads?.()[0];
if (!gamepad) {
previous = [];
heldDirection = null;
} else {
const pressed = gamepad.buttons.map((button) => button.pressed);
const stickX = Math.abs(gamepad.axes[0] ?? 0) >= 0.55 ? gamepad.axes[0] : 0;
const stickY = Math.abs(gamepad.axes[1] ?? 0) >= 0.55 ? gamepad.axes[1] : 0;
const direction: Direction | null = pressed[12] || stickY < 0 ? "up"
: pressed[13] || stickY > 0 ? "down"
: pressed[14] || stickX < 0 ? "left"
: pressed[15] || stickX > 0 ? "right"
: null;
if (direction && (direction !== heldDirection || now >= nextRepeatAt)) {
move(direction);
nextRepeatAt = direction === heldDirection ? now + 120 : now + 360;
heldDirection = direction;
} else if (!direction) {
heldDirection = null;
}
if (pressed[0] && !previous[0]) confirm();
if (pressed[1] && !previous[1]) backRef.current?.();
previous = pressed;
}
frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
return () => cancelAnimationFrame(frame);
}, [confirm, move]);
return {
focusedId,
focus: setFocusedId,
isFocused: (id: string) => id === focusedId,
};
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+1810
View File
File diff suppressed because it is too large Load Diff