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 (
Conviction{conviction} / {MAX_CONVICTION}
); } 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 (
{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 ( ); })}
); } 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 (
{abilityName}{target?.name} {Math.max(0, activeCast.completesAt - time).toFixed(1)}s
); } 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 (
1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}> {bosses.map((entry, index) =>
{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"}{entry.name}{Math.ceil((entry.hp / entry.maxHp) * 100)}%
)}
); } 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 (
{mechanic.name} {mechanic.remaining.toFixed(1)}s
); } 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 ; 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 (
{eyebrow}

{title}

{copy}

{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"}
); } 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(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
+{award.toLocaleString()}{count} {count === 1 ? "brick" : "brick combo"}
; } 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(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
+{award.toLocaleString()}{multiplier.toFixed(2)}× streak
; } 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 (
Encounter suspended

Paused

Simulation, damage, and movement are stopped.

↑ / ↓ Choose {DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER Confirm
); } 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(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 (
GOAL
); } 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 (
Dampening{percent}%
); } 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 (
}>
{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"}{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"}
WASD Move{aetherAssaultMode ? " + auto-fire" : ""} Q / E Target 1–6 Cast
{onExit && }
{runMode === "rpg-roguelike" && rpgRun && ( )}
); }