1
0

475 lines
17 KiB
TypeScript

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<string, string> = {
"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<string, AudioEffect | undefined> = {};
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<React.SetStateAction<Session | null>>;
setError: React.Dispatch<React.SetStateAction<string | null>>;
};
const RoomView = (props: RoomProps) => {
const { session, setSession, setError } = props;
const [socketUrl, setSocketUrl] = useState<string | null>(null);
const { roomName = "default" } = useParams<{ roomName: string }>();
const [reconnectAttempt, setReconnectAttempt] = useState<number>(0);
const [name, setName] = useState<string>("");
const [warning, setWarning] = useState<string | undefined>(undefined);
const [loaded, setLoaded] = useState<boolean>(false);
type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record<string, number> };
type PrivateType = { name?: string; color?: string; turnNotice?: string };
const [dice, setDice] = useState<number[] | undefined>(undefined);
const [state, setState] = useState<string | undefined>(undefined);
const [color, setColor] = useState<string | undefined>(undefined);
const [priv, setPriv] = useState<PrivateType | undefined>(undefined);
const [turn, setTurn] = useState<Turn | undefined>(undefined);
const [buildActive, setBuildActive] = useState<boolean>(false);
const [tradeActive, setTradeActive] = useState<boolean>(false);
const [cardActive, setCardActive] = useState<unknown>(undefined);
const [houseRulesActive, setHouseRulesActive] = useState<boolean>(false);
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
const [count, setCount] = useState<number>(0);
const [audio, setAudio] = useState<boolean>(
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
);
const [animations, setAnimations] = useState<boolean>(
localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false
);
const [volume, setVolume] = useState<number>(
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 <ConnectionStatus readyState={readyState} reconnectAttempt={reconnectAttempt} />;
}
return (
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage, readyState }}>
<div className="RoomView">
{!name ? (
<NameSetter />
) : (
<>
<div className="ActivitiesBox">
<Activities />
{dice && dice.length && (
<div className="DiceRoll">
{dice.length === 1 && <div>Volcano roll!</div>}
{dice.length === 2 && <div>Current roll</div>}
<div>
<Dice pips={dice[0]} />
{dice.length === 2 && <Dice pips={dice[1]} />}
</div>
</div>
)}
</div>
<div className="Game">
<div className="Dialogs">
{priv && priv.turnNotice && (
<div className="Dialog TurnNoticeDialog">
<Paper className="TurnNotice">
<div>{priv.turnNotice}</div>
<Button
onClick={() => {
sendJsonMessage({ type: "turn-notice" });
}}
>
dismiss
</Button>
</Paper>
</div>
)}
{warning && (
<div className="Dialog WarningDialog">
<Paper className="Warning">
<div>{warning}</div>
<Button
onClick={() => {
setWarning("");
}}
>
dismiss
</Button>
</Paper>
</div>
)}
{state === "normal" && <SelectPlayer />}
{color && state === "game-order" && <GameOrder />}
{!winnerDismissed && <Winner {...{ winnerDismissed, setWinnerDismissed }} />}
{houseRulesActive && <HouseRules {...{ houseRulesActive, setHouseRulesActive }} />}
<ViewCard {...{ cardActive, setCardActive }} />
<ChooseCard />
</div>
<Board animations={animations} />
<PlayersStatus active={false} />
<PlayersStatus active={true} />
<Hand {...{ buildActive, setBuildActive, setCardActive }} />
</div>
<div className="Sidebar">
{name !== "" && volume !== undefined && (
<Paper className="Volume">
<div>Audio effects</div>{" "}
<input
type="checkbox"
id="audio"
name="audio"
defaultChecked={audio ? true : false}
onInput={() => {
const value = !audio;
localStorage.setItem("audio", JSON.stringify(value));
setAudio(value);
}}
/>
<div>Sound effects volume</div>{" "}
<input
type="range"
id="volume"
name="volume"
value={volume * 100}
min="0"
max="100"
onInput={(e) => {
const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100;
localStorage.setItem("volume", alpha.toString());
setVolume(alpha);
}}
/>
<div>Animations</div>{" "}
<input
type="checkbox"
id="animations"
name="animations"
defaultChecked={animations ? true : false}
onInput={() => {
const value = !animations;
localStorage.setItem("animations", JSON.stringify(value));
setAnimations(value);
}}
/>
</Paper>
)}
{name && <PlayerList />}
{tradeActive && <Trade />}
{name !== "" && <Chat />}
{/* name !== "" && <VideoFeeds/> */}
{loaded && (
<Actions
{...{
buildActive,
setBuildActive,
tradeActive,
setTradeActive,
houseRulesActive,
setHouseRulesActive,
}}
/>
)}
</div>
</>
)}
</div>
</GlobalContext.Provider>
);
};
export { RoomView };