forked from phenom/healer-man
165 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|