import React, { useState, useCallback, useEffect, useRef } from "react"; import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom"; import Paper from "@mui/material/Paper"; import Button from "@mui/material/Button"; import { GlobalContext } from "./GlobalContext"; import { PlayerList } from "./PlayerList"; import { Chat } from "./Chat"; import { Board } from "./Board"; import { Actions } from "./Actions"; import { base, gamesPath } 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"; // history replaced by react-router's useNavigate import "./App.css"; import equal from "fast-deep-equal"; type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean }; const audioEffects: Record = {}; const loadAudio = (src: string) => { const audio = document.createElement("audio") as AudioEffect; audio.src = `${assetsPath}/${src}`; audio.setAttribute("preload", "auto"); audio.setAttribute("controls", "none"); audio.style.display = "none"; document.body.appendChild(audio); void audio.play(); audio.hasPlayed = true; return audio; }; const Table: React.FC = () => { const params = useParams(); const navigate = useNavigate(); const [gameId, setGameId] = useState(params.gameId ? (params.gameId as string) : undefined); const [ws, setWs] = useState(undefined); /* tracks full websocket lifetime */ const [connection, setConnection] = useState(undefined); /* set after ws is in OPEN */ const [retryConnection, setRetryConnection] = useState(true); /* set when connection should be re-established */ const [name, setName] = useState(""); const [error, setError] = useState(undefined); 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 [global, setGlobal] = useState>({}); 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 onWsOpen = (event: Event) => { console.log(`ws: open`); setError(""); setConnection(ws); const sock = event.target as WebSocket; sock.send(JSON.stringify({ type: "game-update" })); sock.send(JSON.stringify({ type: "get", fields })); }; const onWsMessage = (event: MessageEvent) => { const data = JSON.parse(event.data as string); switch (data.type) { case "error": console.error(`App - error`, data.error); setError(data.error); break; case "warning": console.warn(`App - 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); } if (priv.color !== color) { setColor(priv.color); } setPriv(priv); } if ("name" in data.update) { if (data.update.name) { setName(data.update.name); } else { setWarning(""); setError(""); setPriv(undefined); } } if ("id" in data.update && data.update.id !== gameId) { setGameId(data.update.id); } 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; } }; const sendUpdate = (update: unknown) => { if (ws) ws.send(JSON.stringify(update)); }; const cbResetConnection = useCallback(() => { let timer: number | null = null; function reset() { timer = null; setRetryConnection(true); } return () => { if (timer) { clearTimeout(timer); } timer = window.setTimeout(reset, 5000); }; }, [setRetryConnection]); const resetConnection = cbResetConnection(); if (global.ws !== connection || global.name !== name || global.gameId !== gameId) { setGlobal({ ws: connection, name, gameId, }); } const onWsError = () => { const error = `Connection to Ketr Ketran game server failed! ` + `Connection attempt will be retried every 5 seconds.`; setError(error); setGlobal(Object.assign({}, global, { ws: undefined })); setWs(undefined); /* clear the socket */ setConnection(undefined); /* clear the connection */ resetConnection(); }; const onWsClose = () => { const error = `Connection to Ketr Ketran game was lost. ` + `Attempting to reconnect...`; setError(error); setGlobal(Object.assign({}, global, { ws: undefined })); setWs(undefined); /* clear the socket */ setConnection(undefined); /* clear the connection */ resetConnection(); }; const refWsOpen = useRef<(e: Event) => void>(() => {}); useEffect(() => { refWsOpen.current = onWsOpen; }, [onWsOpen]); const refWsMessage = useRef<(e: MessageEvent) => void>(() => {}); useEffect(() => { refWsMessage.current = onWsMessage; }, [onWsMessage]); const refWsClose = useRef<(e: CloseEvent) => void>(() => {}); useEffect(() => { refWsClose.current = onWsClose; }, [onWsClose]); const refWsError = useRef<(e: Event) => void>(() => {}); useEffect(() => { refWsError.current = onWsError; }, [onWsError]); useEffect(() => { if (gameId) { return; } window .fetch(`${base}/api/v1/games/`, { method: "POST", cache: "no-cache", credentials: "same-origin", headers: { "Content-Type": "application/json", }, }) .then((res) => { if (res.status >= 400) { const error = `Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`; setError(error); throw new Error(error); } return res.json(); }) .then((update) => { if (update.id !== gameId) { navigate(`/${update.id}`); setGameId(update.id); } }) .catch((error) => { console.error(error); }); }, [gameId, setGameId]); useEffect(() => { if (!gameId) { return; } const unbind = () => { console.log(`table - unbind`); }; if (!ws && !connection && retryConnection) { const loc = window.location; let new_uri = ""; if (loc.protocol === "https:") { new_uri = "wss"; } else { new_uri = "ws"; } new_uri = `${new_uri}://${loc.host}${base}/api/v1/games/ws/${gameId}?${count}`; setWs(new WebSocket(new_uri)); setConnection(undefined); setRetryConnection(false); setCount(count + 1); return unbind; } if (!ws) { return unbind; } const cbOpen = (e: Event) => refWsOpen.current(e); const cbMessage = (e: MessageEvent) => refWsMessage.current(e); const cbClose = (e: CloseEvent) => refWsClose.current(e); const cbError = (e: Event) => refWsError.current(e); ws.addEventListener("open", cbOpen); ws.addEventListener("close", cbClose); ws.addEventListener("error", cbError); ws.addEventListener("message", cbMessage); return () => { unbind(); ws.removeEventListener("open", cbOpen); ws.removeEventListener("close", cbClose); ws.removeEventListener("error", cbError); ws.removeEventListener("message", cbMessage); }; }, [ ws, setWs, connection, setConnection, retryConnection, setRetryConnection, gameId, refWsOpen, refWsMessage, refWsClose, refWsError, count, setCount, ]); useEffect(() => { if (state === "volcano") { if (!audioEffects.volcano) { audioEffects.volcano = loadAudio("volcano-eruption.mp3"); audioEffects.volcano.volume = volume * volume; } else { if (!audioEffects.volcano.hasPlayed) { audioEffects.volcano.hasPlayed = true; audioEffects.volcano.play(); } } } else { if (audioEffects.volcano) { audioEffects.volcano.hasPlayed = false; } } }, [state, volume]); useEffect(() => { if (turn && turn.color === color && state !== "lobby") { if (!audioEffects.yourTurn) { audioEffects.yourTurn = loadAudio("its-your-turn.mp3"); audioEffects.yourTurn.volume = volume * volume; } else { if (!audioEffects.yourTurn.hasPlayed) { audioEffects.yourTurn.hasPlayed = true; audioEffects.yourTurn.play(); } } } 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.hasPlayed = true; audioEffects.robber.play(); } } } 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.hasPlayed = true; audioEffects.knights.play(); } } } else if (turn && turn.actions && turn.actions.indexOf("playing-knight") === -1) { if (audioEffects.knights) { audioEffects.knights.hasPlayed = false; } } }, [state, turn, color, volume]); useEffect(() => { for (const key in audioEffects) { audioEffects[key].volume = volume * volume; } }, [volume]); return ( {/* */}
{dice && dice.length && (
{dice.length === 1 &&
Volcano roll!
} {dice.length === 2 &&
Current roll
}
{dice.length === 2 && }
)}
{error && (
{error}
)} {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 !== "" && } {/* Trade is an untyped JS component; assert its type to avoid `any` */} {(() => { const TradeComponent = Trade as unknown as React.ComponentType<{ tradeActive: boolean; setTradeActive: (v: boolean) => void; }>; return ; })()} {name !== "" && } {/* name !== "" && */} {loaded && ( )}
); }; const App: React.FC = () => { const [playerId, setPlayerId] = useState(undefined); const [error, setError] = useState(undefined); useEffect(() => { if (playerId) { return; } window .fetch(`${base}/api/v1/games/`, { method: "GET", cache: "no-cache", credentials: "same-origin" /* include cookies */, headers: { "Content-Type": "application/json", }, }) .then((res) => { if (res.status >= 400) { const error = `Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`; setError(error); } return res.json(); }) .then((data) => { setPlayerId(data.player); }) .catch(() => {}); }, [playerId, setPlayerId]); if (!playerId) { return <>{error}; } return ( } path="/:gameId" /> } path="/" /> ); }; export default App;