import React, { useState, useEffect, useContext } from "react"; import { useParams } from "react-router-dom"; import useWebSocket, { ReadyState } from "react-use-websocket"; import Paper from "@mui/material/Paper"; import Button from "@mui/material/Button"; import { GlobalContext, GlobalContextType } from "./GlobalContext"; import { PlayerList } from "./PlayerList"; import { Chat } from "./Chat"; import { Board } from "./Board"; import { Actions } from "./Actions"; import { ws_base, base } from "./Common"; import { GameOrder } from "./GameOrder"; import { Activities } from "./Activities"; import { SelectPlayer } from "./SelectPlayer"; import { PlayersStatus } from "./PlayersStatus"; import { ViewCard } from "./ViewCard"; import { ChooseCard } from "./ChooseCard"; import { Hand } from "./Hand"; import { Trade } from "./Trade"; import { Winner } from "./Winner"; import { HouseRules } from "./HouseRules"; import { Dice } from "./Dice"; import { assetsPath } from "./Common"; import { Session, Room } from "./GlobalContext"; // history replaced by react-router's useNavigate import "./RoomView.css"; import equal from "fast-deep-equal"; import itsYourTurnAudio from "./assets/its-your-turn.mp3"; import robberAudio from "./assets/robber.mp3"; import knightsAudio from "./assets/the-knights-who-say-ni.mp3"; import volcanoAudio from "./assets/volcano-eruption.mp3"; import { ConnectionStatus } from "./ConnectionStatus"; import NameSetter from "./NameSetter"; const audioFiles: Record = { "its-your-turn.mp3": itsYourTurnAudio, "robber.mp3": robberAudio, "the-knights-who-say-ni.mp3": knightsAudio, "volcano-eruption.mp3": volcanoAudio, }; type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean }; const audioEffects: Record = {}; const loadAudio = (src: string) => { const audio = document.createElement("audio") as AudioEffect; audio.src = audioFiles[src]; audio.setAttribute("preload", "auto"); audio.setAttribute("controls", "none"); audio.style.display = "none"; document.body.appendChild(audio); audio.load(); audio.addEventListener("error", (e) => console.error("Audio load error:", e, audio.src)); return audio; }; type RoomProps = { session: Session; setSession: React.Dispatch>; setError: React.Dispatch>; }; const RoomView = (props: RoomProps) => { const { session, setSession, setError } = props; const [socketUrl, setSocketUrl] = useState(null); const { roomName = "default" } = useParams<{ roomName: string }>(); const [reconnectAttempt, setReconnectAttempt] = useState(0); const [name, setName] = useState(""); const [warning, setWarning] = useState(undefined); const [loaded, setLoaded] = useState(false); type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record }; type PrivateType = { name?: string; color?: string; turnNotice?: string }; const [dice, setDice] = useState(undefined); const [state, setState] = useState(undefined); const [color, setColor] = useState(undefined); const [priv, setPriv] = useState(undefined); const [turn, setTurn] = useState(undefined); const [buildActive, setBuildActive] = useState(false); const [tradeActive, setTradeActive] = useState(false); const [cardActive, setCardActive] = useState(undefined); const [houseRulesActive, setHouseRulesActive] = useState(false); const [winnerDismissed, setWinnerDismissed] = useState(false); const [count, setCount] = useState(0); const [audio, setAudio] = useState( localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false ); const [animations, setAnimations] = useState( localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false ); const [volume, setVolume] = useState( localStorage.getItem("volume") ? parseFloat(localStorage.getItem("volume") as string) : 0.5 ); const fields = ["id", "state", "color", "name", "private", "dice", "turn"]; const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, { onOpen: () => { console.log("app - WebSocket connection opened."); if (reconnectAttempt > 0) { console.log("app - WebSocket reconnected after connection loss, refreshing room state"); } sendJsonMessage({ type: "get", fields }); setReconnectAttempt(0); }, onClose: () => { console.log("app - WebSocket connection closed."); setReconnectAttempt((prev) => prev + 1); }, onError: (event: Event) => { console.error("app - WebSocket error observed:", event); // If we get a WebSocket error, it might be due to invalid room ID // Reset the room state to force recreation console.log("app - WebSocket error, clearing room state to force refresh"); setSocketUrl(null); }, shouldReconnect: (closeEvent) => { // Don't reconnect if the room doesn't exist (4xx errors) if (closeEvent.code >= 4000 && closeEvent.code < 5000) { console.log("app - WebSocket closed with client error, not reconnecting"); return false; } return true; }, reconnectInterval: 5000, // Retry every 5 seconds onReconnectStop: (numAttempts) => { console.log(`Stopped reconnecting after ${numAttempts} attempts`); }, share: true, }); useEffect(() => { const socketUrl = `${ws_base}/${roomName}`; console.log("app - connecting to", socketUrl); setSocketUrl(socketUrl); }, [roomName, session.id]); useEffect(() => { if (!lastJsonMessage || !session) { return; } const data: any = lastJsonMessage; switch (data.type) { case "ping": // Respond to server ping immediately to maintain connection console.log("room-view - Received ping from server, sending pong"); sendJsonMessage({ type: "pong" }); break; case "error": console.error(`room-view - error`, data.error); setError(data.data.error || JSON.stringify(data)); break; case "warning": console.warn(`room-view - warning`, data.warning); setWarning(data.warning); setTimeout(() => { setWarning(""); }, 3000); break; case "game-update": if (!loaded) { setLoaded(true); } console.log(`app - message - ${data.type}`, data.update); if ("private" in data.update && !equal(priv, data.update.private)) { const priv = data.update.private; if (priv.name !== name) { setName(priv.name); // Mirror the name into the shared session so consumers that read // `session.name` (eg. MediaAgent) will see the name and can act // (for example, initiate the media join). try { setSession((s) => (s ? { ...s, name: priv.name } : s)); } catch (e) { console.warn("Failed to set session name from private payload", e); } } if (priv.color !== color) { setColor(priv.color); } setPriv(priv); } if ("name" in data.update) { if (data.update.name) { setName(data.update.name); // Also update the session object so components using session.name // immediately observe the change. try { setSession((s) => (s ? { ...s, name: data.update.name } : s)); } catch (e) { console.warn("Failed to set session name from name payload", e); } } else { setWarning(""); setError(""); setPriv(undefined); } } if ("state" in data.update && data.update.state !== state) { if (data.update.state !== "winner" && winnerDismissed) { setWinnerDismissed(false); } setState(data.update.state); } if ("dice" in data.update && !equal(data.update.dice, dice)) { setDice(data.update.dice); } if ("turn" in data.update && !equal(data.update.turn, turn)) { setTurn(data.update.turn); } if ("color" in data.update && data.update.color !== color) { setColor(data.update.color); } break; default: break; } }, [lastJsonMessage, session]); useEffect(() => { if (state === "volcano") { if (audio) { if (!audioEffects.volcano) { audioEffects.volcano = loadAudio("volcano-eruption.mp3"); audioEffects.volcano.volume = volume * volume; } else { if (!audioEffects.volcano.hasPlayed && audioEffects.volcano.readyState >= 2) { audioEffects.volcano.hasPlayed = true; audioEffects.volcano.play().catch((e) => console.error("Audio play failed:", e)); } } } else { // Audio disabled -> stop any currently playing volcano effect if (audioEffects.volcano) { try { audioEffects.volcano.pause(); audioEffects.volcano.currentTime = 0; } catch (e) { /* ignore */ } audioEffects.volcano.hasPlayed = false; } } } else { if (audioEffects.volcano) { audioEffects.volcano.hasPlayed = false; } } }, [state, volume]); useEffect(() => { // When audio is enabled we may create/play effects; when disabled ensure // any existing effects are stopped and reset. if (audio) { if (turn && turn.color === color && state !== "room") { if (!audioEffects.yourTurn) { audioEffects.yourTurn = loadAudio("its-your-turn.mp3"); audioEffects.yourTurn.volume = volume * volume; } else { if (!audioEffects.yourTurn.hasPlayed && audioEffects.yourTurn.readyState >= 2) { audioEffects.yourTurn.hasPlayed = true; audioEffects.yourTurn.play().catch((e) => console.error("Audio play failed:", e)); } } } else if (turn) { if (audioEffects.yourTurn) { audioEffects.yourTurn.hasPlayed = false; } } if (turn && turn.roll === 7) { if (!audioEffects.robber) { audioEffects.robber = loadAudio("robber.mp3"); audioEffects.robber.volume = volume * volume; } else { if (!audioEffects.robber.hasPlayed && audioEffects.robber.readyState >= 2) { audioEffects.robber.hasPlayed = true; audioEffects.robber.play().catch((e) => console.error("Audio play failed:", e)); } } } else if (turn) { if (audioEffects.robber) { audioEffects.robber.hasPlayed = false; } } if (turn && turn.actions && turn.actions.indexOf("playing-knight") !== -1) { if (!audioEffects.knights) { audioEffects.knights = loadAudio("the-knights-who-say-ni.mp3"); audioEffects.knights.volume = volume * volume; } else { if (!audioEffects.knights.hasPlayed && audioEffects.knights.readyState >= 2) { audioEffects.knights.hasPlayed = true; audioEffects.knights.play().catch((e) => console.error("Audio play failed:", e)); } } } else if (turn && turn.actions && turn.actions.indexOf("playing-knight") === -1) { if (audioEffects.knights) { audioEffects.knights.hasPlayed = false; } } } else { // audio disabled: stop any currently playing effects and reset their state const stopIfPlaying = (ae?: AudioEffect) => { if (!ae) return; try { ae.pause(); ae.currentTime = 0; } catch (e) { /* ignore */ } ae.hasPlayed = false; }; stopIfPlaying(audioEffects.yourTurn); stopIfPlaying(audioEffects.robber); stopIfPlaying(audioEffects.knights); } }, [state, turn, color, volume]); useEffect(() => { for (const key in audioEffects) { if (audioEffects[key]) { try { audioEffects[key]!.volume = volume * volume; } catch (e) { /* ignore */ } } } }, [volume]); if (readyState !== ReadyState.OPEN || !session) { return ; } return (
{!name ? ( ) : ( <>
{dice && dice.length && (
{dice.length === 1 &&
Volcano roll!
} {dice.length === 2 &&
Current roll
}
{dice.length === 2 && }
)}
{priv && priv.turnNotice && (
{priv.turnNotice}
)} {warning && (
{warning}
)} {state === "normal" && } {color && state === "game-order" && } {!winnerDismissed && } {houseRulesActive && }
{name !== "" && volume !== undefined && (
Audio effects
{" "} { const value = !audio; localStorage.setItem("audio", JSON.stringify(value)); setAudio(value); }} />
Sound effects volume
{" "} { const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100; localStorage.setItem("volume", alpha.toString()); setVolume(alpha); }} />
Animations
{" "} { const value = !animations; localStorage.setItem("animations", JSON.stringify(value)); setAnimations(value); }} />
)} {name && } {tradeActive && } {name !== "" && } {/* name !== "" && */} {loaded && ( )}
)}
); }; export { RoomView };