import { useEffect, useRef, useState } from "react"; import { useFrame } from "@react-three/fiber"; import type { Group } from "three"; import { playerEnteredManastormPortal } from "../game/manastormPortalActivation"; import { MANASTORM_BUBBLE_DEFAULT_RADIUS } from "../game/manastormClientOverlay"; import { advanceManastormSession } from "../game/manastormSession"; import { useManastormStore } from "../game/manastormStore"; import { useGameStore } from "../game/store"; import { requestOnlineInteraction } from "../game/onlineSessionRuntime"; const STORM_PARTICLES = [ [-0.9, 1.4, 0.4], [0.6, 1.1, -0.8], [0.2, 2.6, 0.9], [-1.1, 2.3, -0.5], [1.2, 2, 0.1], [-0.4, 3.2, -0.7], [0.8, 3.45, 0.5], [0.1, 1.8, -1.15], [-0.65, 2.85, 0.7], [1, 2.9, -0.35], ] as const; const NEXT_WAVE_COUNTDOWN_SECONDS = 3; export function ManastormPortal() { const gameMode = useGameStore((state) => state.gameMode); const status = useManastormStore((state) => state.status); const encounter = useManastormStore((state) => state.currentEncounter); const stormRef = useRef(null); const wasInsideRef = useRef(false); const transitionLatchedRef = useRef(false); const transitionStartedAtRef = useRef(null); const displayedCountdownRef = useRef(null); const displayedBubbleDistanceRef = useRef(null); const [reducedMotion, setReducedMotion] = useState(false); const position = encounter?.portalPosition; const visible = gameMode === "manastorm" && Boolean(position) && (status === "awaiting-loot" || status === "portal-ready"); const ready = status === "portal-ready"; useEffect(() => { const query = window.matchMedia?.("(prefers-reduced-motion: reduce)"); if (!query) return; const update = () => setReducedMotion(query.matches); update(); query.addEventListener?.("change", update); return () => query.removeEventListener?.("change", update); }, []); useEffect(() => { transitionLatchedRef.current = false; transitionStartedAtRef.current = null; displayedCountdownRef.current = null; displayedBubbleDistanceRef.current = null; useManastormStore.getState().setNextWaveCountdown(null); useManastormStore.getState().setSafetyBubble({ active: false }); wasInsideRef.current = ready && position ? playerEnteredManastormPortal(position, useGameStore.getState().playerPosition) : false; }, [encounter?.id, position, ready]); useFrame((_, delta) => { if (stormRef.current && visible && !reducedMotion) { stormRef.current.rotation.y += Math.min(delta, 0.05) * (ready ? 0.9 : 0.25); } if (!ready || !position) return; if (transitionLatchedRef.current && transitionStartedAtRef.current !== null) { const elapsedSeconds = (Date.now() - transitionStartedAtRef.current) / 1_000; const remaining = Math.max(0, Math.ceil(NEXT_WAVE_COUNTDOWN_SECONDS - elapsedSeconds)); if (displayedCountdownRef.current !== remaining) { displayedCountdownRef.current = remaining; useManastormStore.getState().setNextWaveCountdown(remaining); } const player = useGameStore.getState().playerPosition; const distance = Math.hypot( player[0] - position[0], player[1] - position[1], player[2] - position[2], ); const distanceRemaining = Math.max( 0, Math.round((MANASTORM_BUBBLE_DEFAULT_RADIUS - distance) * 10) / 10, ); if (displayedBubbleDistanceRef.current !== distanceRemaining) { displayedBubbleDistanceRef.current = distanceRemaining; useManastormStore.getState().setSafetyBubble({ active: true, radius: MANASTORM_BUBBLE_DEFAULT_RADIUS, distanceRemaining, }); } if (remaining <= 0) { useManastormStore.getState().setNextWaveCountdown(null); useManastormStore.getState().setSafetyBubble({ active: false }); advanceManastormSession(); } return; } const inside = playerEnteredManastormPortal( position, useGameStore.getState().playerPosition, ); if (inside && !wasInsideRef.current) { if (requestOnlineInteraction("portal")) { wasInsideRef.current = true; return; } transitionLatchedRef.current = true; transitionStartedAtRef.current = Date.now(); displayedCountdownRef.current = NEXT_WAVE_COUNTDOWN_SECONDS; useManastormStore.getState().setNextWaveCountdown(NEXT_WAVE_COUNTDOWN_SECONDS); } wasInsideRef.current = inside; }); if (!visible || !position) return null; const activeColor = ready ? "#9ef7ff" : "#798098"; const stormColor = ready ? "#b776ff" : "#596071"; return ( {STORM_PARTICLES.map((particle, index) => ( ))} {ready ? : null} ); }