new rpg mode
This commit is contained in:
+113
-11
@@ -3,15 +3,19 @@ import packageJson from "../package.json";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { FrontEnd } from "./components/FrontEnd";
|
||||
import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||
import { useGameStore } from "./game/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 (
|
||||
@@ -22,10 +26,11 @@ function GameLoadingScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
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);
|
||||
@@ -34,36 +39,65 @@ export default function App() {
|
||||
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<string>());
|
||||
const screenRef = useRef(screen);
|
||||
screenRef.current = screen;
|
||||
const leaveGame = useCallback(() => {
|
||||
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
||||
updateActiveHealerInventory(useGameStore.getState().inventory);
|
||||
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) => {
|
||||
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" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
|
||||
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);
|
||||
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 } | readonly BossId[]>).detail;
|
||||
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
|
||||
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);
|
||||
@@ -75,6 +109,44 @@ export default function App() {
|
||||
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(() => {
|
||||
@@ -97,9 +169,31 @@ export default function App() {
|
||||
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
|
||||
recordRoguelikeDefeat(state.round);
|
||||
}
|
||||
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
|
||||
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;
|
||||
@@ -108,6 +202,7 @@ export default function App() {
|
||||
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) {
|
||||
@@ -121,7 +216,7 @@ export default function App() {
|
||||
recordBossVictory(entry.boss.id, rewardDifficulty);
|
||||
}
|
||||
});
|
||||
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
|
||||
}, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
@@ -130,8 +225,15 @@ export default function App() {
|
||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||
</header>
|
||||
{screen === "game"
|
||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
|
||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
|
||||
: <FrontEnd onLaunch={launchGame} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") {
|
||||
return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
|
||||
}
|
||||
return <MainApp />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user