import { lazy, Suspense, useCallback, useEffect, useRef } from "react"; import packageJson from "../package.json"; import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { FrontEnd } from "./components/FrontEnd"; import { useActiveHunter, useFrontendStore } from "./frontend/store"; import { getHockeyPvpNetworkSnapshot, useGameStore } from "./game/store"; import type { BossId } from "./game/types"; import type { DifficultySlug } from "./game/progression/loot"; import { useActionBindings } from "./game/useGameLoop"; import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen"; import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync"; import { startSaveSyncCoordinator } from "./frontend/saveSync"; import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp"; import { onlineRepository } from "./frontend/onlineRepository"; const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery }))); function GameLoadingScreen() { return ( Opening the Ember VaultPreparing world and party…} bottom={
IHLoading field consoleOffline save secured
} /> ); } function MainApp() { useForcedThorDisplays(); useAuthoritativeDualScreenSync(); const screen = useFrontendStore((state) => state.screen); const accountId = useFrontendStore((state) => state.accountId); const hunter = useActiveHunter(); const settings = useFrontendStore((state) => state.settings); const navigate = useFrontendStore((state) => state.navigate); const touchActiveSave = useFrontendStore((state) => state.touchActiveSave); const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory); const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat); const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat); const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat); const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult); const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill); const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat); const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat); const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards); const rewardedBossInstances = useRef(new Set()); const screenRef = useRef(screen); screenRef.current = screen; const leaveGame = useCallback(() => { const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState(); const game = useGameStore.getState(); // RPG Roguelike equipment belongs only to its current run. Never leak it // into the hunter's permanent inventory when leaving the expedition. if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory); touchActiveSave(); navigate("home"); if (accountId && activeSlotId) void uploadSlot(activeSlotId); }, [navigate, touchActiveSave, updateActiveHealerInventory]); const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { if (!hunter) return; const progress = hunter.healers[hunter.activeClassId]; const selectedMode = useFrontendStore.getState().selectedMode; const runMode = selectedMode === "roguelike-pve" ? "rpg-roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : selectedMode === "hockey-healing" ? "hockey-healing" : selectedMode === "hockey-healing-pvp" ? "hockey-healing-pvp" : selectedMode === "blockbreaker" ? "blockbreaker" : selectedMode === "aether-assault" ? "aether-assault" : "encounter"; const launchDifficulty = runMode !== "encounter" ? "initiate" : requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug; rewardedBossInstances.current.clear(); clearRecentRewards(); useGameStore.getState().configureHealer( hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty, hockeyPvpMatch, ); touchActiveSave(); navigate("game"); }, [clearRecentRewards, hunter, navigate, touchActiveSave]); useEffect(() => { const onDualScreenLaunch = (event: Event) => { const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail; if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.hockeyPvpMatch); else launchGame(detail); }; window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); }, [launchGame]); useEffect(() => { window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame); return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame); }, [leaveGame]); useEffect(() => { if (!accountId) return; return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId)); }, [accountId]); useEffect(() => { if (screen !== "game") return; let stopped = false; let exchangeActive = false; const exchange = async () => { if (stopped || exchangeActive) return; const state = useGameStore.getState(); if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return; const snapshot = getHockeyPvpNetworkSnapshot(); if (!snapshot) return; exchangeActive = true; try { const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot); if (!stopped && result.opponentSnapshot) { useGameStore.getState().applyHockeyPvpRemoteSnapshot( result.opponentSnapshot, result.hostSnapshot?.puck, ); } } catch { // Last authoritative snapshot remains playable through short network gaps. } finally { exchangeActive = false; } }; void exchange(); const timer = window.setInterval(() => { void exchange(); }, 120); return () => { stopped = true; window.clearInterval(timer); }; }, [screen]); useActionBindings(screen === "game", leaveGame); useEffect(() => { document.documentElement.classList.toggle("large-interface-text", settings.largeText); document.documentElement.classList.toggle("force-reduced-motion", settings.reducedMotion); }, [settings.largeText, settings.reducedMotion]); useEffect(() => { return useGameStore.subscribe((state, previousState) => { const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing" || previousState.phase === "intermission" && state.phase === "combat" || previousState.phase === "victory" && state.phase === "combat" && state.endlessMode; if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat" || previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) { rewardedBossInstances.current.clear(); } if (startedFreshEncounter) clearRecentRewards(); if (screenRef.current !== "game") return; if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") { recordRoguelikeDefeat(state.round); } if (state.runMode === "rpg-roguelike" && (state.phase === "defeat" || state.phase === "victory") && state.phase !== previousState.phase) { recordRoguelikeDefeat(Math.max(1, state.rpgRun?.bossesDefeated ?? 0)); } if (state.runMode === "rogue-trials" && state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") { recordRogueTrialsEndlessDefeat(state.endlessBossKills); } if (state.runMode === "hockey-healing" && state.phase === "defeat" && previousState.phase !== "defeat") { recordHockeyHealingDefeat(state.hockey.returns, state.time); } if (state.runMode === "hockey-healing-pvp" && (state.phase === "victory" || state.phase === "defeat") && state.phase !== previousState.phase) { recordHockeyPvpResult(state.phase === "victory"); } if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") { recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score); } if (state.runMode === "aether-assault" && state.phase === "defeat" && previousState.phase !== "defeat") { recordAetherAssaultDefeat(state.aetherAssault.score, state.aetherAssault.wave, state.time); } // RPG rewards are generated inside the run reducer. Permanent boss loot // here would duplicate its chest and break run-only progression. if (state.runMode === "rpg-roguelike") return; const bossCount = 1 + state.additionalBosses.length; if (state.boss.hp <= 0 && previousState.boss.hp > 0) { const primaryInstanceId = state.bossInstanceId; if (!rewardedBossInstances.current.has(primaryInstanceId)) { if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId); const defeatedBefore = (state.round - 1) * bossCount; const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; recordBossVictory(state.boss.id, rewardDifficulty); if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill(); } } for (let index = 0; index < state.additionalBosses.length; index += 1) { const entry = state.additionalBosses[index]; const previous = previousState.additionalBosses[index]; const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0); if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue; if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId); const defeatedBefore = (state.round - 1) * bossCount + index + 1; const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; recordBossVictory(entry.boss.id, rewardDifficulty); } }); }, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]); return (
THOR / DUAL DISPLAYI Want To Heal

Offline-first healer roguelike v{packageJson.version}

{screen === "game" ? }>} bottom={} /> : }
); } export default function App() { if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") { return ; } return ; }