Files
i-want-to-heal-mmo/src/components/TopScreen.tsx
T
2026-07-12 23:20:15 -04:00

211 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
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-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
{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 runMode = useGameStore((state) => state.runMode);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const briefingMode = runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
if (phase === "combat") return null;
const title = phase === "briefing"
? room.name
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}`
: 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 round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
<GameScene />
</Suspense>
<div className="top-vignette" />
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>}
</div>
<PhaseOverlay />
<PauseOverlay onExit={onExit} />
</section>
);
}