Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>1–6</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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user