Files
healer-man/src/scene/ManastormPortal.tsx
T
2026-08-20 14:05:50 -04:00

165 lines
6.6 KiB
TypeScript

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<Group>(null);
const wasInsideRef = useRef(false);
const transitionLatchedRef = useRef(false);
const transitionStartedAtRef = useRef<number | null>(null);
const displayedCountdownRef = useRef<number | null>(null);
const displayedBubbleDistanceRef = useRef<number | null>(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 (
<group name="manastorm-snowglobe" position={position}>
<mesh position={[0, 0.28, 0]} castShadow receiveShadow>
<cylinderGeometry args={[2.35, 2.7, 0.56, 48]} />
<meshStandardMaterial color="#17182d" metalness={0.72} roughness={0.24} emissive={activeColor} emissiveIntensity={ready ? 0.22 : 0.04} />
</mesh>
<mesh position={[0, 0.6, 0]}>
<torusGeometry args={[2.08, 0.18, 18, 64]} />
<meshStandardMaterial color={activeColor} emissive={activeColor} emissiveIntensity={ready ? 1.8 : 0.16} />
</mesh>
<mesh position={[0, 2.65, 0]} castShadow>
<sphereGeometry args={[2.18, 48, 32]} />
<meshPhysicalMaterial
color={activeColor}
transparent
opacity={ready ? 0.2 : 0.11}
roughness={0.08}
metalness={0.08}
transmission={0.62}
thickness={0.25}
emissive={activeColor}
emissiveIntensity={ready ? 0.35 : 0.04}
/>
</mesh>
<group ref={stormRef} position={[0, 0.25, 0]}>
<mesh position={[0, 2.25, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.22, 0.14, 14, 64]} />
<meshBasicMaterial color={stormColor} transparent opacity={ready ? 0.85 : 0.24} />
</mesh>
<mesh position={[0, 2.4, 0]} rotation={[0.45, 0.1, 0.7]}>
<torusGeometry args={[0.88, 0.1, 12, 56]} />
<meshBasicMaterial color={activeColor} transparent opacity={ready ? 0.8 : 0.18} />
</mesh>
{STORM_PARTICLES.map((particle, index) => (
<mesh key={index} position={particle}>
<sphereGeometry args={[index % 3 === 0 ? 0.09 : 0.055, 10, 8]} />
<meshBasicMaterial color={index % 2 ? activeColor : stormColor} transparent opacity={ready ? 0.95 : 0.25} />
</mesh>
))}
</group>
{ready ? <pointLight position={[0, 2.5, 0]} color={activeColor} intensity={3.2} distance={13} /> : null}
</group>
);
}