423 lines
24 KiB
TypeScript
423 lines
24 KiB
TypeScript
import { lazy, memo, Suspense, useEffect, useRef, useState } from "react";
|
||
import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||
import { HEALER_ABILITIES } from "../game/healers";
|
||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||
import { bossRoomFor } from "../game/bossRooms";
|
||
import { tankAuraProtects } from "../game/partyCombat";
|
||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
|
||
|
||
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
|
||
GameScene.displayName = "MemoizedGameScene";
|
||
const CONVICTION_PIPS = [0, 1, 2] as const;
|
||
const MAX_CONVICTION = healerMaxResource("paladin");
|
||
|
||
function ConvictionMeter() {
|
||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||
const runMode = useGameStore((state) => state.runMode);
|
||
const encounterConviction = useGameStore((state) => state.healerMechanic.resource);
|
||
const rpgConviction = useGameStore((state) => state.rpgSpellResources.conviction);
|
||
if (healerClassId !== "paladin") return null;
|
||
const conviction = Math.max(0, Math.min(MAX_CONVICTION, runMode === "rpg-roguelike" ? rpgConviction : encounterConviction));
|
||
return (
|
||
<div
|
||
className={`top-conviction-meter ${conviction === MAX_CONVICTION ? "is-full" : ""}`}
|
||
role="meter"
|
||
aria-label="Conviction"
|
||
aria-valuemin={0}
|
||
aria-valuemax={MAX_CONVICTION}
|
||
aria-valuenow={conviction}
|
||
aria-valuetext={`${conviction} of ${MAX_CONVICTION} Conviction`}
|
||
title={`${conviction} of ${MAX_CONVICTION} Conviction`}
|
||
>
|
||
<span><b>Conviction</b><small>{conviction} / {MAX_CONVICTION}</small></span>
|
||
<span className="top-conviction-pips" aria-hidden="true">
|
||
{CONVICTION_PIPS.map((pip) => <i className={pip < conviction ? "is-filled" : ""} key={pip} />)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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);
|
||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||
return (
|
||
<div className="top-party" aria-label="Party health and player resources">
|
||
{party.map((member) => {
|
||
const health = (member.hp / member.maxHp) * 100;
|
||
const shield = (member.absorb / member.maxHp) * 100;
|
||
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
|
||
return (
|
||
<button
|
||
className={`top-party-member ${selected === member.id ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""}`}
|
||
key={member.id}
|
||
onClick={() => selectMember(member.id)}
|
||
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
|
||
>
|
||
<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">
|
||
{beaconed && <em className="beacon-pip" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}>✦</em>}
|
||
{member.healingEffects.some((effect) => effect.expiresAt > time) && <em className="renew-pip">H</em>}
|
||
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <em className="renew-pip">E{member.reactiveHeal.charges}</em>}
|
||
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
|
||
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
|
||
{barrier.kind === "spirit-link" && healerFieldContains(partyPositions[member.id], barrier, time) && <em className="barrier-pip">S</em>}
|
||
{tankAuraProtects(partyPositions[member.id], partyPositions[tankAura.sourceId], tankAura, time) && <em className="tank-aura-pip">T</em>}
|
||
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
<ConvictionMeter />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CastingBar() {
|
||
const activeCast = useGameStore((state) => state.activeCast);
|
||
const time = useGameStore((state) => state.time);
|
||
const party = useGameStore((state) => state.party);
|
||
if (!activeCast) return null;
|
||
const abilityName = HEALER_ABILITIES[activeCast.abilityId].name;
|
||
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 opponentBoss = useGameStore((state) => state.hockeyPvpOpponent.boss);
|
||
const phase = useGameStore((state) => state.phase);
|
||
const activityMode = useGameStore((state) => state.activityMode);
|
||
if (phase === "briefing") return null;
|
||
const hockeyMode = activityMode === "hockey-healing";
|
||
const blockbreakerMode = activityMode === "blockbreaker";
|
||
const aetherAssaultMode = activityMode === "aether-assault";
|
||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||
const bosses = pvpMode ? [boss, opponentBoss] : [boss, ...additionalBosses.map((entry) => entry.boss)];
|
||
return (
|
||
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}>
|
||
{bosses.map((entry, index) => <div className={`boss-bar-entry ${pvpMode && index === 1 ? "is-opponent" : ""}`} key={`${entry.id}-${index}`}>
|
||
<div className="boss-name"><span>{pvpMode ? index === 0 ? "Your target" : "Rival target" : hockeyMode ? index === 0 ? "Striker · paddle + damage" : "Frontline · party damage" : blockbreakerMode ? index === 0 ? "Breaker flank" : "Wall pressure" : aetherAssaultMode ? index === 0 ? "Arcade flank" : "Party pressure" : "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 activityMode = useGameStore((state) => state.activityMode);
|
||
const round = useGameStore((state) => state.round);
|
||
const endlessMode = useGameStore((state) => state.endlessMode);
|
||
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||
const primaryBoss = useGameStore((state) => state.boss);
|
||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||
const hockey = useGameStore((state) => state.hockey);
|
||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||
if (runMode === "rpg-roguelike") return null;
|
||
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 hockeyMode = activityMode === "hockey-healing";
|
||
const blockbreakerMode = activityMode === "blockbreaker";
|
||
const aetherAssaultMode = activityMode === "aether-assault";
|
||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode;
|
||
const briefingMode = hockeyMode
|
||
? "Endless Goal Defense"
|
||
: blockbreakerMode
|
||
? "Endless Color Break"
|
||
: aetherAssaultMode
|
||
? "Endless Arcade Assault"
|
||
: pvpMode
|
||
? `Versus ${hockeyPvp.opponentName}`
|
||
: 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"
|
||
? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : room.name
|
||
: phase === "victory"
|
||
? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken`
|
||
: hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken";
|
||
const eyebrow = phase === "briefing"
|
||
? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : `${briefingMode} · ${room.biome}`
|
||
: phase === "victory"
|
||
? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete"
|
||
: hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
|
||
const copy = phase === "briefing"
|
||
? hockeyMode
|
||
? "Defend the wide blue goal. Aim each return; the moving Pong paddle strikes it back. Party fights both bosses on enemy half. Fallen bosses are replaced."
|
||
: blockbreakerMode
|
||
? "Aim the puck into advancing five-brick rows. Matching orthogonal colors break as one combo while two bosses pressure the party. Missed pucks safely re-serve."
|
||
: aetherAssaultMode
|
||
? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses."
|
||
: pvpMode
|
||
? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly."
|
||
: definitions.map((boss) => boss.briefing).join(" ")
|
||
: phase === "victory"
|
||
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : "Five entered. Five endured."
|
||
: hockeyMode
|
||
? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.`
|
||
: blockbreakerMode
|
||
? `Run record: ${blockbreaker.bricksBroken} bricks, ${blockbreaker.score.toLocaleString()} points, and ${endlessBossKills} boss kills.`
|
||
: aetherAssaultMode
|
||
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
||
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
||
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : 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" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BlockbreakerScorePopup() {
|
||
const activityMode = useGameStore((state) => state.activityMode);
|
||
const lastBreakAt = useGameStore((state) => state.blockbreaker.lastBreakAt);
|
||
const count = useGameStore((state) => state.blockbreaker.lastBreakCount);
|
||
const award = useGameStore((state) => state.blockbreaker.lastScoreAward);
|
||
const [visibleAt, setVisibleAt] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (activityMode !== "blockbreaker" || !Number.isFinite(lastBreakAt)) {
|
||
setVisibleAt(null);
|
||
return;
|
||
}
|
||
setVisibleAt(lastBreakAt);
|
||
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastBreakAt ? null : current), 850);
|
||
return () => window.clearTimeout(timeout);
|
||
}, [activityMode, lastBreakAt]);
|
||
|
||
if (visibleAt === null) return null;
|
||
return <div className="blockbreaker-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{count} {count === 1 ? "brick" : "brick combo"}</small></div>;
|
||
}
|
||
|
||
function AetherScorePopup() {
|
||
const activityMode = useGameStore((state) => state.activityMode);
|
||
const lastKillAt = useGameStore((state) => state.aetherAssault.lastKillAt);
|
||
const award = useGameStore((state) => state.aetherAssault.lastKillScore);
|
||
const multiplier = useGameStore((state) => state.aetherAssault.multiplier);
|
||
const [visibleAt, setVisibleAt] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (activityMode !== "aether-assault" || !Number.isFinite(lastKillAt)) {
|
||
setVisibleAt(null);
|
||
return;
|
||
}
|
||
setVisibleAt(lastKillAt);
|
||
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastKillAt ? null : current), 700);
|
||
return () => window.clearTimeout(timeout);
|
||
}, [activityMode, lastKillAt]);
|
||
|
||
if (visibleAt === null) return null;
|
||
return <div className="blockbreaker-score-popup aether-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{multiplier.toFixed(2)}× streak</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-selected" : ""}
|
||
onPointerEnter={() => setPauseSelection("resume")}
|
||
onClick={() => setPaused(false)}
|
||
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
|
||
<button
|
||
className={`secondary ${selection === "exit" ? "is-controller-selected" : ""}`}
|
||
onPointerEnter={() => setPauseSelection("exit")}
|
||
onClick={exit}
|
||
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
|
||
</div>
|
||
<footer><b>↑ / ↓</b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GoalPopup() {
|
||
const activityMode = useGameStore((state) => state.activityMode);
|
||
const goalSequence = useGameStore((state) => state.hockeyPvp.goalSequence);
|
||
const lastGoalSide = useGameStore((state) => state.hockeyPvp.lastGoalSide);
|
||
const lastSeenSequence = useRef(goalSequence);
|
||
const [visibleSequence, setVisibleSequence] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (activityMode !== "hockey-healing-pvp" || goalSequence < lastSeenSequence.current) {
|
||
lastSeenSequence.current = goalSequence;
|
||
setVisibleSequence(null);
|
||
return;
|
||
}
|
||
if (goalSequence === lastSeenSequence.current) return;
|
||
lastSeenSequence.current = goalSequence;
|
||
setVisibleSequence(goalSequence);
|
||
const timeout = window.setTimeout(() => {
|
||
setVisibleSequence((current) => current === goalSequence ? null : current);
|
||
}, 1_250);
|
||
return () => window.clearTimeout(timeout);
|
||
}, [activityMode, goalSequence]);
|
||
|
||
if (visibleSequence === null) return null;
|
||
return (
|
||
<div
|
||
key={visibleSequence}
|
||
className={`goal-popup ${lastGoalSide === "local" ? "is-conceded" : "is-scored"}`}
|
||
role="status"
|
||
aria-live="assertive"
|
||
aria-atomic="true"
|
||
>
|
||
<strong>GOAL</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DampeningIndicator() {
|
||
const activityMode = useGameStore((state) => state.activityMode);
|
||
const localBossKills = useGameStore((state) => state.endlessBossKills);
|
||
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
|
||
if (activityMode !== "hockey-healing-pvp") return null;
|
||
const percent = hockeyPvpDampeningPercent(localBossKills, opponentBossKills);
|
||
return (
|
||
<div className="dampening-indicator" role="status" aria-label={`${percent}% healing dampening`}>
|
||
<span>Dampening</span><strong>{percent}%</strong>
|
||
<i><em style={{ width: `${percent}%` }} /></i>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TopScreen({
|
||
onExit,
|
||
playerAppearance,
|
||
}: {
|
||
onExit?: () => void;
|
||
playerAppearance?: CharacterAppearanceV1;
|
||
}) {
|
||
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 activityMode = useGameStore((state) => state.activityMode);
|
||
const endlessMode = useGameStore((state) => state.endlessMode);
|
||
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||
const hockeyReturns = useGameStore((state) => state.hockey.returns);
|
||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||
const time = useGameStore((state) => state.time);
|
||
const setPaused = useGameStore((state) => state.setPaused);
|
||
const rpgRun = useGameStore((state) => state.rpgRun);
|
||
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
|
||
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
|
||
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
|
||
const restart = useGameStore((state) => state.restart);
|
||
const hockeyMode = activityMode === "hockey-healing";
|
||
const blockbreakerMode = activityMode === "blockbreaker";
|
||
const aetherAssaultMode = activityMode === "aether-assault";
|
||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
||
return (
|
||
<section className="display top-display" aria-label="Main game viewport">
|
||
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
|
||
<GameScene playerAppearance={playerAppearance} />
|
||
</Suspense>
|
||
<div className="top-vignette" />
|
||
<div className="top-hud">
|
||
<CompactParty />
|
||
<BossBar />
|
||
<div className={`objective-chip ${hockeyMode || pvpMode || blockbreakerMode || aetherAssaultMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""} ${blockbreakerMode ? "is-blockbreaker" : ""} ${aetherAssaultMode ? "is-aether" : ""}`}><span>{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}–${hockeyPvp.opponentBossKills}` : endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||
<EncounterCallout />
|
||
<DampeningIndicator />
|
||
<CastingBar />
|
||
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <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>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||
</div>
|
||
<GoalPopup />
|
||
<BlockbreakerScorePopup />
|
||
<AetherScorePopup />
|
||
<PhaseOverlay />
|
||
{runMode === "rpg-roguelike" && rpgRun && (
|
||
<RpgRunOverlay
|
||
run={rpgRun}
|
||
focusedId={rpgFocusId}
|
||
onFocusChange={setRpgFocusId}
|
||
onAction={dispatchRpgAction}
|
||
onRestartRun={restart}
|
||
onExitRun={onExit}
|
||
/>
|
||
)}
|
||
<PauseOverlay onExit={onExit} />
|
||
</section>
|
||
);
|
||
}
|