Restructuring
This commit is contained in:
parent
5acf71b2a3
commit
45e01d5e89
@ -1,593 +1,127 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Paper, Typography } from "@mui/material";
|
||||
|
||||
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 } 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 { Session } from "./GlobalContext";
|
||||
import "./App.css";
|
||||
import equal from "fast-deep-equal";
|
||||
import { base } from "./Common";
|
||||
import { Box } from "@mui/material";
|
||||
import { BrowserRouter as Router, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { ReadyState } from "react-use-websocket";
|
||||
import { ConnectionStatus } from "./ConnectionStatus";
|
||||
import { RoomView } from "./RoomView";
|
||||
import { sessionApi } from "./api-client";
|
||||
|
||||
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';
|
||||
console.log(`Peddlers of Ketran Build: ${import.meta.env.VITE_APP_POK_BUILD}`);
|
||||
|
||||
const audioFiles: Record<string, string> = {
|
||||
'its-your-turn.mp3': itsYourTurnAudio,
|
||||
'robber.mp3': robberAudio,
|
||||
'the-knights-who-say-ni.mp3': knightsAudio,
|
||||
'volcano-eruption.mp3': volcanoAudio,
|
||||
};
|
||||
interface LoadingProps {
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
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];
|
||||
console.log("Loading audio:", audio.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));
|
||||
audio.addEventListener('canplay', () => console.log("Audio can play:", audio.src));
|
||||
return audio;
|
||||
};
|
||||
|
||||
const Table: React.FC = () => {
|
||||
const params = useParams();
|
||||
const Loading = (props: LoadingProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [gameId, setGameId] = useState<string | undefined>(params.gameId ? (params.gameId as string) : undefined);
|
||||
const [ws, setWs] = useState<WebSocket | undefined>(undefined); /* tracks full websocket lifetime */
|
||||
const [connection, setConnection] = useState<WebSocket | undefined>(undefined); /* set after ws is in OPEN */
|
||||
const [retryConnection, setRetryConnection] =
|
||||
useState<boolean>(true); /* set when connection should be re-established */
|
||||
const [name, setName] = useState<string>("");
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [warning, setWarning] = useState<string | undefined>(undefined);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const { setError } = props;
|
||||
|
||||
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 [global, setGlobal] = useState<Record<string, unknown>>({});
|
||||
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 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);
|
||||
useEffect(() => {
|
||||
const createRoom = async () => {
|
||||
try {
|
||||
const room = await sessionApi.createRoom();
|
||||
console.log(`Loading - created room`, room);
|
||||
navigate(`${base}/${room.name}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||
console.error("Failed to create room:", errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
timer = window.setTimeout(reset, 5000);
|
||||
};
|
||||
}, [setRetryConnection]);
|
||||
|
||||
const resetConnection = cbResetConnection();
|
||||
|
||||
if (global.ws !== connection || global.name !== name || global.gameId !== gameId) {
|
||||
setGlobal({
|
||||
ws: connection,
|
||||
name,
|
||||
gameId,
|
||||
sendJsonMessage: sendUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
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, sendJsonMessage: 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, sendJsonMessage: 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.readyState >= 2) {
|
||||
audioEffects.volcano.hasPlayed = true;
|
||||
audioEffects.volcano.play().catch((e) => console.error("Audio play failed:", e));
|
||||
}
|
||||
}
|
||||
} 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.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;
|
||||
}
|
||||
}
|
||||
}, [state, turn, color, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const key in audioEffects) {
|
||||
audioEffects[key].volume = volume * volume;
|
||||
}
|
||||
}, [volume]);
|
||||
createRoom();
|
||||
}, [setError]);
|
||||
|
||||
return (
|
||||
<GlobalContext.Provider value={global}>
|
||||
{/* <PingPong/> */}
|
||||
<div className="Table">
|
||||
<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">
|
||||
{error && (
|
||||
<div className="Dialog ErrorDialog">
|
||||
<Paper className="Error">
|
||||
<div>{error}</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
dismiss
|
||||
</Button>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{priv && priv.turnNotice && (
|
||||
<div className="Dialog TurnNoticeDialog">
|
||||
<Paper className="TurnNotice">
|
||||
<div>{priv.turnNotice}</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
sendUpdate({ 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 />}
|
||||
{/* 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 <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
|
||||
})()}
|
||||
{name !== "" && <Chat />}
|
||||
{/* name !== "" && <VideoFeeds/> */}
|
||||
{loaded && (
|
||||
<Actions
|
||||
{...{ buildActive, setBuildActive, tradeActive, setTradeActive, houseRulesActive, setHouseRulesActive }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlobalContext.Provider>
|
||||
<Paper sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ddddff" }}>
|
||||
<Typography>Loading...</Typography>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const App = () => {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionRetryAttempt, setSessionRetryAttempt] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (playerId) {
|
||||
if (error) {
|
||||
setTimeout(() => setError(null), 5000);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
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]);
|
||||
console.log(`App - sessionId`, session.id);
|
||||
}, [session]);
|
||||
|
||||
if (!playerId) {
|
||||
return <>{error}</>;
|
||||
}
|
||||
const getSession = useCallback(async () => {
|
||||
try {
|
||||
const session = await sessionApi.getCurrent();
|
||||
console.log(`App - got sessionId`, session.id);
|
||||
setSession(session);
|
||||
setSessionRetryAttempt(0);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||
console.error("Failed to get session:", errorMessage);
|
||||
setError(errorMessage);
|
||||
|
||||
// Schedule retry after 5 seconds
|
||||
setSessionRetryAttempt((prev) => prev + 1);
|
||||
setTimeout(() => {
|
||||
getSession(); // Retry
|
||||
}, 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
return;
|
||||
}
|
||||
getSession();
|
||||
}, [session, getSession]);
|
||||
|
||||
return (
|
||||
<Router basename={base}>
|
||||
<Routes>
|
||||
<Route element={<Table />} path="/:gameId" />
|
||||
<Route element={<Table />} path="/" />
|
||||
</Routes>
|
||||
</Router>
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 1, sm: 2 },
|
||||
// maxWidth: { xs: "100%", sm: 800 },
|
||||
margin: "0 auto",
|
||||
height: "100vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{!session && (
|
||||
<ConnectionStatus
|
||||
readyState={sessionRetryAttempt > 0 ? ReadyState.CLOSED : ReadyState.CONNECTING}
|
||||
reconnectAttempt={sessionRetryAttempt}
|
||||
/>
|
||||
)}
|
||||
{session && (
|
||||
<Router
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route element={<RoomView {...{ setError, session, setSession }} />} path={`${base}/:roomName`} />
|
||||
<Route element={<Loading {...{ setError }} />} path={`${base}`} />
|
||||
</Routes>
|
||||
</Router>
|
||||
)}
|
||||
{error && (
|
||||
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
|
||||
<Typography color="red">{error}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,86 +1,57 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: any = null;
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(this, args);
|
||||
}, ms);
|
||||
} as T;
|
||||
};
|
||||
|
||||
// Prefer an explicit API/base provided via environment variable. Different
|
||||
// deployments and scripts historically used different variable names
|
||||
// (VITE_API_BASE, VITE_BASEPATH, PUBLIC_URL). Try them in a sensible order
|
||||
// so the client correctly computes its `base` (router basename and asset
|
||||
// prefix) regardless of which one is defined.
|
||||
|
||||
// Defensive handling: some env consumers or docker-compose YAML authors may
|
||||
// accidentally include literal quotes when setting env vars (for example,
|
||||
// `VITE_API_BASE=""`). That results in the string `""` being present at
|
||||
// runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so
|
||||
// an accidental quoted-empty value becomes an empty string.
|
||||
const candidateEnvVars = [
|
||||
import.meta.env.VITE_API_BASE,
|
||||
// Some deployments (server-side) set VITE_BASEPATH (note the case).
|
||||
import.meta.env.VITE_BASEPATH,
|
||||
// Older scripts or build systems sometimes populate PUBLIC_URL.
|
||||
import.meta.env.PUBLIC_URL,
|
||||
];
|
||||
|
||||
let rawEnvApiBase = '';
|
||||
const candidateEnvVars = [import.meta.env.VITE_API_BASE, import.meta.env.VITE_BASEPATH, import.meta.env.PUBLIC_URL];
|
||||
let rawEnvApiBase = "";
|
||||
for (const candidate of candidateEnvVars) {
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
if (typeof candidate === "string" && candidate.trim() !== "") {
|
||||
rawEnvApiBase = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let envApiBase = typeof rawEnvApiBase === 'string' ? rawEnvApiBase.trim() : '';
|
||||
let envApiBase = typeof rawEnvApiBase === "string" ? rawEnvApiBase.trim() : "";
|
||||
|
||||
// If someone set the literal value '""' or "''", treat it as empty.
|
||||
if (envApiBase === '""' || envApiBase === "''") {
|
||||
envApiBase = '';
|
||||
envApiBase = "";
|
||||
}
|
||||
|
||||
// Remove surrounding single or double quotes if present.
|
||||
if ((envApiBase.startsWith('"') && envApiBase.endsWith('"')) ||
|
||||
(envApiBase.startsWith("'") && envApiBase.endsWith("'"))) {
|
||||
if (
|
||||
(envApiBase.startsWith('"') && envApiBase.endsWith('"')) ||
|
||||
(envApiBase.startsWith("'") && envApiBase.endsWith("'"))
|
||||
) {
|
||||
envApiBase = envApiBase.slice(1, -1);
|
||||
}
|
||||
|
||||
const publicBase = import.meta.env.BASE_URL || '';
|
||||
const publicBase = import.meta.env.BASE_URL || "";
|
||||
|
||||
// Normalize base: treat '/' as empty, and strip any trailing slash so
|
||||
// constructing `${base}/api/...` never produces a protocol-relative
|
||||
// URL like `//api/...` which the browser resolves to `https://api/...`.
|
||||
let baseCandidate = envApiBase || publicBase || '';
|
||||
if (baseCandidate === '/') {
|
||||
baseCandidate = '';
|
||||
let baseCandidate = envApiBase || publicBase || "";
|
||||
if (baseCandidate === "/") {
|
||||
baseCandidate = "";
|
||||
}
|
||||
// Remove trailing slash if present (but keep leading slash for path bases).
|
||||
if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
|
||||
baseCandidate = baseCandidate.replace(/\/+$/, '');
|
||||
if (baseCandidate.length > 1 && baseCandidate.endsWith("/")) {
|
||||
baseCandidate = baseCandidate.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
// Runtime safeguard: when the app is opened at a URL that does not include
|
||||
// the configured base path (for example, dev server serving at `/` while
|
||||
// VITE_BASEPATH is `/ketr.ketran`), React Router's <Router basename="...">
|
||||
// will refuse to render because the current pathname doesn't start with the
|
||||
// basename. In that situation prefer to fall back to an empty basename so
|
||||
// the client still renders correctly in local/dev setups.
|
||||
// basename. In that situation throw an error!
|
||||
try {
|
||||
if (typeof window !== 'undefined' && baseCandidate) {
|
||||
const pathname = window.location && window.location.pathname ? window.location.pathname : '';
|
||||
// Accept either exact prefix or prefix followed by a slash
|
||||
if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + '/'))) {
|
||||
// Mismatch: fallback to empty base so router can match the URL.
|
||||
// Keep a console message to aid debugging in browsers.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'; falling back to ''`);
|
||||
baseCandidate = '';
|
||||
if (typeof window !== "undefined" && baseCandidate) {
|
||||
const pathname = window.location && window.location.pathname ? window.location.pathname : "";
|
||||
if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + "/"))) {
|
||||
// Mismatch: FAIL!
|
||||
throw Error(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -97,4 +68,6 @@ const base = baseCandidate;
|
||||
const assetsPath = base;
|
||||
const gamesPath = `${base}`;
|
||||
|
||||
export { base, debounce, assetsPath, gamesPath };
|
||||
const ws_base: string = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${base}/ws`;
|
||||
|
||||
export { base, ws_base, assetsPath, gamesPath };
|
||||
|
94
client/src/ConnectionStatus.tsx
Normal file
94
client/src/ConnectionStatus.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Typography, CircularProgress, Paper, LinearProgress } from "@mui/material";
|
||||
import { ReadyState } from "react-use-websocket";
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
readyState: ReadyState;
|
||||
reconnectAttempt?: number;
|
||||
}
|
||||
|
||||
const ConnectionStatus: React.FC<ConnectionStatusProps> = ({ readyState, reconnectAttempt = 0 }) => {
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
// Start countdown when connection is closed and we're attempting to reconnect
|
||||
useEffect(() => {
|
||||
if (readyState === ReadyState.CLOSED && reconnectAttempt > 0) {
|
||||
setCountdown(5);
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [readyState, reconnectAttempt]);
|
||||
|
||||
const getConnectionStatusText = () => {
|
||||
switch (readyState) {
|
||||
case ReadyState.CONNECTING:
|
||||
return reconnectAttempt > 0 ? `Reconnecting... (attempt ${reconnectAttempt})` : "Connecting to server...";
|
||||
case ReadyState.OPEN:
|
||||
return "Connected";
|
||||
case ReadyState.CLOSING:
|
||||
return "Disconnecting...";
|
||||
case ReadyState.CLOSED:
|
||||
if (reconnectAttempt > 0 && countdown > 0) {
|
||||
return `Connection lost. Retrying in ${countdown}s... (attempt ${reconnectAttempt})`;
|
||||
}
|
||||
return reconnectAttempt > 0 ? "Reconnecting..." : "Disconnected";
|
||||
case ReadyState.UNINSTANTIATED:
|
||||
return "Initializing...";
|
||||
default:
|
||||
return "Unknown connection state";
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionColor = () => {
|
||||
switch (readyState) {
|
||||
case ReadyState.OPEN:
|
||||
return "success.main";
|
||||
case ReadyState.CONNECTING:
|
||||
return "info.main";
|
||||
case ReadyState.CLOSED:
|
||||
return "error.main";
|
||||
case ReadyState.CLOSING:
|
||||
return "warning.main";
|
||||
default:
|
||||
return "text.secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowProgress =
|
||||
readyState === ReadyState.CONNECTING || (readyState === ReadyState.CLOSED && reconnectAttempt > 0);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
m: 2,
|
||||
width: "fit-content",
|
||||
backgroundColor: readyState === ReadyState.CLOSED ? "#ffebee" : "background.paper",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{shouldShowProgress && <CircularProgress size={20} />}
|
||||
<Typography variant="h6" color={getConnectionColor()}>
|
||||
{getConnectionStatusText()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{readyState === ReadyState.CLOSED && reconnectAttempt > 0 && countdown > 0 && (
|
||||
<Box sx={{ mt: 2, width: "100%" }}>
|
||||
<LinearProgress variant="determinate" value={((5 - countdown) / 5) * 100} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConnectionStatus };
|
@ -1,20 +1,135 @@
|
||||
import { createContext } from 'react';
|
||||
import { createContext } from "react";
|
||||
|
||||
export type GlobalContextType = {
|
||||
gameId?: string | undefined;
|
||||
ws?: WebSocket | null | undefined;
|
||||
roomName?: string;
|
||||
name?: string;
|
||||
sendJsonMessage?: (message: any) => void;
|
||||
chat?: Array<unknown>;
|
||||
};
|
||||
|
||||
const global: GlobalContextType = {
|
||||
gameId: undefined,
|
||||
ws: undefined,
|
||||
roomName: undefined,
|
||||
name: "",
|
||||
chat: []
|
||||
chat: [],
|
||||
};
|
||||
|
||||
const GlobalContext = createContext<GlobalContextType>(global);
|
||||
|
||||
/**
|
||||
* RoomModel
|
||||
* @description Core room model used across components
|
||||
*/
|
||||
export interface RoomModel {
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/**
|
||||
* Private
|
||||
* @default false
|
||||
*/
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomCreateData
|
||||
* @description Data for room creation
|
||||
*/
|
||||
export interface RoomCreateData {
|
||||
/** Name */
|
||||
name: string;
|
||||
/**
|
||||
* Private
|
||||
* @default false
|
||||
*/
|
||||
private?: boolean;
|
||||
}
|
||||
/**
|
||||
* RoomCreateRequest
|
||||
* @description Request for creating a room
|
||||
*/
|
||||
export interface RoomCreateRequest {
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "room_create";
|
||||
data: RoomCreateData;
|
||||
}
|
||||
/**
|
||||
* RoomCreateResponse
|
||||
* @description Response for room creation
|
||||
*/
|
||||
export interface RoomCreateResponse {
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "room_created";
|
||||
data: RoomModel;
|
||||
}
|
||||
/**
|
||||
* RoomListItem
|
||||
* @description Room item for list responses
|
||||
*/
|
||||
export interface RoomListItem {
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
}
|
||||
/**
|
||||
* RoomModel
|
||||
* @description Core room model used across components
|
||||
*/
|
||||
export interface RoomModel {
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/**
|
||||
* Private
|
||||
* @default false
|
||||
*/
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionResponse
|
||||
* @description Session response model
|
||||
*/
|
||||
export interface SessionResponse {
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Lobbies */
|
||||
lobbies: RoomModel[];
|
||||
/**
|
||||
* Protected
|
||||
* @default false
|
||||
*/
|
||||
protected?: boolean;
|
||||
/**
|
||||
* Has Media
|
||||
* @default false
|
||||
*/
|
||||
has_media?: boolean;
|
||||
/** Bot Run Id */
|
||||
bot_run_id?: string | null;
|
||||
/** Bot Provider Id */
|
||||
bot_provider_id?: string | null;
|
||||
/** Bot Instance Id */
|
||||
bot_instance_id?: string | null;
|
||||
}
|
||||
|
||||
// Re-export types from api-client for backwards compatibility
|
||||
export type Room = RoomModel;
|
||||
|
||||
// Extended Session type that allows name to be null initially (before user sets it)
|
||||
export type Session = Omit<SessionResponse, "name"> & {
|
||||
name: string | null;
|
||||
has_media?: boolean; // Whether this session provides audio/video streams
|
||||
};
|
||||
|
||||
export { GlobalContext, global };
|
||||
|
162
client/src/NameSetter.tsx
Normal file
162
client/src/NameSetter.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import React, { useState, KeyboardEvent, useRef } from "react";
|
||||
import { Input, Button, Box, Typography, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions } from "@mui/material";
|
||||
import { Session } from "./GlobalContext";
|
||||
|
||||
interface NameSetterProps {
|
||||
session: Session;
|
||||
sendJsonMessage: (message: any) => void;
|
||||
onNameSet?: () => void;
|
||||
initialName?: string;
|
||||
initialPassword?: string;
|
||||
}
|
||||
|
||||
const NameSetter: React.FC<NameSetterProps> = ({
|
||||
session,
|
||||
sendJsonMessage,
|
||||
onNameSet,
|
||||
initialName = "",
|
||||
initialPassword = "",
|
||||
}) => {
|
||||
const [editName, setEditName] = useState<string>(initialName);
|
||||
const [editPassword, setEditPassword] = useState<string>(initialPassword);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(!session.name);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setName = (name: string) => {
|
||||
setIsSubmitting(true);
|
||||
sendJsonMessage({
|
||||
type: "set_name",
|
||||
data: { name, password: editPassword ? editPassword : undefined },
|
||||
});
|
||||
if (onNameSet) {
|
||||
onNameSet();
|
||||
}
|
||||
setShowDialog(false);
|
||||
setIsSubmitting(false);
|
||||
setEditName("");
|
||||
setEditPassword("");
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (passwordInputRef.current) {
|
||||
passwordInputRef.current.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newName = editName.trim();
|
||||
if (!newName || (session?.name && session.name === newName)) {
|
||||
return;
|
||||
}
|
||||
setName(newName);
|
||||
};
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setEditName(session.name || "");
|
||||
setEditPassword("");
|
||||
setShowDialog(true);
|
||||
// Focus the name input when dialog opens
|
||||
setTimeout(() => {
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setShowDialog(false);
|
||||
setEditName("");
|
||||
setEditPassword("");
|
||||
};
|
||||
|
||||
const hasNameChanged = editName.trim() !== (session.name || "");
|
||||
const canSubmit = editName.trim() && hasNameChanged && !isSubmitting;
|
||||
|
||||
return (
|
||||
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
{session.name && !showDialog && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography>You are logged in as: {session.name}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={handleOpenDialog}>
|
||||
Change Name
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Dialog for name change */}
|
||||
<Dialog open={showDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{session.name ? "Change Your Name" : "Enter Your Name"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{session.name
|
||||
? "Enter a new name to change your current name."
|
||||
: "Enter your name to join the lobby."
|
||||
}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
You can optionally set a password to reserve this name; supply it again to takeover the name from another client.
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
inputRef={nameInputRef}
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e): void => {
|
||||
setEditName(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
placeholder="Your name"
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
inputRef={passwordInputRef}
|
||||
type="password"
|
||||
value={editPassword}
|
||||
onChange={(e): void => setEditPassword(e.target.value)}
|
||||
onKeyDown={handlePasswordKeyDown}
|
||||
placeholder="Optional password"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Tooltip title="Optional: choose a short password to reserve this name. Keep it secret.">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
color={hasNameChanged ? "primary" : "inherit"}
|
||||
>
|
||||
{isSubmitting ? "Changing..." : (session.name ? "Change Name" : "Join")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NameSetter;
|
@ -1,191 +1,216 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import List from "@mui/material/List";
|
||||
|
||||
import "./PlayerList.css";
|
||||
import { PlayerColor } from "./PlayerColor";
|
||||
import { MediaAgent, MediaControl, Session } from "./MediaControl";
|
||||
import { MediaControl, MediaAgent, Peer } from "./MediaControl";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Session, Room } from "./GlobalContext";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
type Player = {
|
||||
name: string;
|
||||
session_id: string;
|
||||
live: boolean;
|
||||
local: boolean /* Client side variable */;
|
||||
protected?: boolean;
|
||||
has_media?: boolean; // Whether this Player provides audio/video streams
|
||||
bot_run_id?: string;
|
||||
bot_provider_id?: string;
|
||||
bot_instance_id?: string; // For bot instances
|
||||
muted?: boolean;
|
||||
video_on?: boolean;
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
type PlayerListProps = {
|
||||
socketUrl: string;
|
||||
session: Session;
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
interface PlayerListProps {
|
||||
socketUrl?: string;
|
||||
session?: Session;
|
||||
}
|
||||
const PlayerList: React.FC<PlayerListProps> = (props: PlayerListProps) => {
|
||||
const { socketUrl, session, roomId } = props;
|
||||
const [Players, setPlayers] = useState<Player[] | null>(null);
|
||||
const [peers, setPeers] = useState<Record<string, Peer>>({});
|
||||
|
||||
const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
|
||||
const { ws, name, sendJsonMessage } = useContext(GlobalContext);
|
||||
const [players, setPlayers] = useState<{ [key: string]: any }>({});
|
||||
const [unselected, setUneslected] = useState<string[]>([]);
|
||||
const [state, setState] = useState<string>("lobby");
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [peers, setPeers] = useState<{ [key: string]: any }>({});
|
||||
const sortPlayers = useCallback(
|
||||
(A: any, B: any) => {
|
||||
if (!session) {
|
||||
return 0;
|
||||
}
|
||||
/* active Player first */
|
||||
if (A.name === session.name) {
|
||||
return -1;
|
||||
}
|
||||
if (B.name === session.name) {
|
||||
return +1;
|
||||
}
|
||||
/* Sort active Players first */
|
||||
if (A.name && !B.name) {
|
||||
return -1;
|
||||
}
|
||||
if (B.name && !A.name) {
|
||||
return +1;
|
||||
}
|
||||
/* Otherwise, sort by color */
|
||||
if (A.color && B.color) {
|
||||
return A.color.localeCompare(B.color);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
[session]
|
||||
);
|
||||
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`player-list - game update`, data.update);
|
||||
|
||||
if ("unselected" in data.update) {
|
||||
setUneslected(data.update.unselected);
|
||||
// Use the WebSocket hook for room events with automatic reconnection
|
||||
const { sendJsonMessage } = useWebSocket(socketUrl, {
|
||||
share: true,
|
||||
shouldReconnect: (closeEvent) => true, // Auto-reconnect on connection loss
|
||||
reconnectInterval: 5000,
|
||||
onMessage: (event: MessageEvent) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const message = JSON.parse(event.data);
|
||||
const data: any = message.data;
|
||||
switch (message.type) {
|
||||
case "room_state": {
|
||||
type RoomStateData = {
|
||||
participants: Player[];
|
||||
};
|
||||
const room_state = data as RoomStateData;
|
||||
console.log(`Players - room_state`, room_state.participants);
|
||||
room_state.participants.forEach((Player) => {
|
||||
Player.local = Player.session_id === session.id;
|
||||
});
|
||||
room_state.participants.sort(sortPlayers);
|
||||
setPlayers(room_state.participants);
|
||||
// Initialize peers with remote mute/video state
|
||||
setPeers((prevPeers) => {
|
||||
const updated: Record<string, Peer> = { ...prevPeers };
|
||||
room_state.participants.forEach((Player) => {
|
||||
// Only update remote peers, never overwrite local peer object
|
||||
if (!Player.local && updated[Player.session_id]) {
|
||||
updated[Player.session_id] = {
|
||||
...updated[Player.session_id],
|
||||
muted: Player.muted ?? false,
|
||||
video_on: Player.video_on ?? true,
|
||||
};
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if ("players" in data.update) {
|
||||
let found = false;
|
||||
for (const key in data.update.players) {
|
||||
if (data.update.players[key].name === name) {
|
||||
found = true;
|
||||
setColor(key);
|
||||
break;
|
||||
case "update_name": {
|
||||
// Update local session name immediately
|
||||
if (data && typeof data.name === "string") {
|
||||
session.name = data.name;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "peer_state_update": {
|
||||
// Update peer state in peers, but do not override local mute
|
||||
setPeers((prevPeers) => {
|
||||
const updated = { ...prevPeers };
|
||||
const peerId = data.peer_id;
|
||||
if (peerId && updated[peerId]) {
|
||||
updated[peerId] = {
|
||||
...updated[peerId],
|
||||
muted: data.muted,
|
||||
video_on: data.video_on,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
setColor(undefined);
|
||||
}
|
||||
setPlayers(data.update.players);
|
||||
return updated;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if ("state" in data.update && data.update.state !== state) {
|
||||
setState(data.update.state);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
const refWsMessage = useRef(onWsMessage);
|
||||
|
||||
useEffect(() => {
|
||||
refWsMessage.current = onWsMessage;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
|
||||
ws.addEventListener("message", cbMessage);
|
||||
return () => {
|
||||
ws.removeEventListener("message", cbMessage);
|
||||
};
|
||||
}, [ws, refWsMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sendJsonMessage) {
|
||||
if (Players !== null) {
|
||||
return;
|
||||
}
|
||||
sendJsonMessage({
|
||||
type: "get",
|
||||
fields: ["state", "players", "unselected"],
|
||||
type: "list_Players",
|
||||
});
|
||||
}, [ws]);
|
||||
|
||||
const toggleSelected = (key: string) => {
|
||||
ws!.send(
|
||||
JSON.stringify({
|
||||
type: "set",
|
||||
field: "color",
|
||||
value: color === key ? "" : key,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const playerElements: React.ReactElement[] = [];
|
||||
|
||||
const inLobby = state === "lobby";
|
||||
const sortedPlayers: any[] = [];
|
||||
|
||||
for (const key in players) {
|
||||
sortedPlayers.push(players[key]);
|
||||
}
|
||||
|
||||
const sortPlayers = (A: any, B: any) => {
|
||||
/* active player first */
|
||||
if (A.name === name) {
|
||||
return -1;
|
||||
}
|
||||
if (B.name === name) {
|
||||
return +1;
|
||||
}
|
||||
|
||||
/* Sort active players first */
|
||||
if (A.name && !B.name) {
|
||||
return -1;
|
||||
}
|
||||
if (B.name && !A.name) {
|
||||
return +1;
|
||||
}
|
||||
|
||||
/* Ohterwise, sort by color */
|
||||
return A.color.localeCompare(B.color);
|
||||
};
|
||||
|
||||
sortedPlayers.sort(sortPlayers);
|
||||
|
||||
/* Array of just names... */
|
||||
unselected.sort((A, B) => {
|
||||
/* active player first */
|
||||
if (A === name) {
|
||||
return -1;
|
||||
}
|
||||
if (B === name) {
|
||||
return +1;
|
||||
}
|
||||
/* Then sort alphabetically */
|
||||
return A.localeCompare(B);
|
||||
});
|
||||
|
||||
const videoClass = sortedPlayers.length <= 2 ? "Medium" : "Small";
|
||||
|
||||
sortedPlayers.forEach((player) => {
|
||||
const playerName = player.name;
|
||||
const selectable = inLobby && (player.status === "Not active" || color === player.color);
|
||||
playerElements.push(
|
||||
<div
|
||||
data-selectable={selectable}
|
||||
data-selected={player.color === color}
|
||||
className="PlayerEntry"
|
||||
onClick={() => {
|
||||
inLobby && selectable && toggleSelected(player.color);
|
||||
}}
|
||||
key={`player-${player.color}`}
|
||||
>
|
||||
<div>
|
||||
<PlayerColor color={player.color} />
|
||||
<div className="Name">{playerName ? playerName : "Available"}</div>
|
||||
{playerName && !player.live && <div className="NoNetwork"></div>}
|
||||
</div>
|
||||
{playerName && player.live && (
|
||||
<MediaControl className={videoClass} peer={peers[playerName]} isSelf={player.color === color} />
|
||||
)}
|
||||
{!playerName && <div></div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const waiting = unselected.map((player) => {
|
||||
return (
|
||||
<div className={player === name ? "Self" : ""} key={player}>
|
||||
<div>{player}</div>
|
||||
<MediaControl className={"Small"} peer={peers[player]} isSelf={name === player} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [Players, sendJsonMessage]);
|
||||
|
||||
return (
|
||||
<Paper className={`PlayerList ${videoClass}`}>
|
||||
{socketUrl && session && <MediaAgent {...{ socketUrl, setPeers, peers, session }} />}
|
||||
<List className="PlayerSelector">{playerElements}</List>
|
||||
{unselected && unselected.length !== 0 && (
|
||||
<div className="Unselected">
|
||||
<div>In lobby</div>
|
||||
<div>{waiting}</div>
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
<Box sx={{ position: "relative", width: "100%" }}>
|
||||
<Paper
|
||||
className={`PlayerList Medium`}
|
||||
sx={{
|
||||
maxWidth: { xs: "100%", sm: 500 },
|
||||
p: { xs: 1, sm: 2 },
|
||||
m: { xs: 0, sm: 2 },
|
||||
}}
|
||||
>
|
||||
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
|
||||
<List className="PlayerSelector">
|
||||
{Players?.map((Player) => (
|
||||
<Box
|
||||
key={Player.session_id}
|
||||
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
|
||||
className={`PlayerEntry ${Player.local ? "PlayerSelf" : ""}`}
|
||||
>
|
||||
<Box>
|
||||
<Box style={{ display: "flex-wrap", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Box style={{ display: "flex-wrap", alignItems: "center" }}>
|
||||
<div className="Name">{Player.name ? Player.name : Player.session_id}</div>
|
||||
{Player.protected && (
|
||||
<div
|
||||
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
|
||||
title="This name is protected with a password"
|
||||
>
|
||||
🔒
|
||||
</div>
|
||||
)}
|
||||
{Player.bot_instance_id && (
|
||||
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
|
||||
🤖
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{Player.name && !Player.live && <div className="NoNetwork"></div>}
|
||||
</Box>
|
||||
{Player.name && Player.live && peers[Player.session_id] && (Player.local || Player.has_media !== false) ? (
|
||||
<MediaControl
|
||||
className="Medium"
|
||||
key={Player.session_id}
|
||||
peer={peers[Player.session_id]}
|
||||
isSelf={Player.local}
|
||||
sendJsonMessage={Player.local ? sendJsonMessage : undefined}
|
||||
remoteAudioMuted={peers[Player.session_id].muted}
|
||||
remoteVideoOff={peers[Player.session_id].video_on === false}
|
||||
/>
|
||||
) : Player.name && Player.live && Player.has_media === false ? (
|
||||
<div
|
||||
className="Video fade-in"
|
||||
style={{
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
💬 Chat Only
|
||||
</div>
|
||||
) : (
|
||||
<video className="Video"></video>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
434
client/src/RoomView.tsx
Normal file
434
client/src/RoomView.tsx
Normal file
@ -0,0 +1,434 @@
|
||||
import React, { useState, useEffect } 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 } 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 "./App.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";
|
||||
|
||||
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];
|
||||
console.log("Loading audio:", audio.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));
|
||||
audio.addEventListener("canplay", () => console.log("Audio can play:", audio.src));
|
||||
return audio;
|
||||
};
|
||||
|
||||
type RoomProps = {
|
||||
session: Session;
|
||||
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
};
|
||||
|
||||
const RoomView: React.FC<RoomProps> = (props: RoomProps) => {
|
||||
const { session, setSession, setError } = props;
|
||||
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
||||
const { roomName = "default" } = useParams<{ roomName: string }>();
|
||||
const [creatingRoom, setCreatingRoom] = useState<boolean>(false);
|
||||
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 [global, setGlobal] = useState<Record<string, unknown>>({});
|
||||
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 "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 ("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, setError, setSession]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("app - WebSocket connection status: ", readyState);
|
||||
}, [readyState]);
|
||||
|
||||
if (global.name !== name || global.roomName !== roomName) {
|
||||
setGlobal({
|
||||
name,
|
||||
roomName,
|
||||
sendJsonMessage,
|
||||
});
|
||||
}
|
||||
|
||||
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.readyState >= 2) {
|
||||
audioEffects.volcano.hasPlayed = true;
|
||||
audioEffects.volcano.play().catch((e) => console.error("Audio play failed:", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (audioEffects.volcano) {
|
||||
audioEffects.volcano.hasPlayed = false;
|
||||
}
|
||||
}
|
||||
}, [state, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}, [state, turn, color, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const key in audioEffects) {
|
||||
audioEffects[key].volume = volume * volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
return (
|
||||
<GlobalContext.Provider value={global}>
|
||||
<div className="Room">
|
||||
{readyState !== ReadyState.OPEN || !session ? (
|
||||
<ConnectionStatus readyState={readyState} reconnectAttempt={reconnectAttempt} />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
<PlayerList socketUrl={socketUrl} session={session} roomId={roomName} />
|
||||
{/* 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 <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
|
||||
})()}
|
||||
{name !== "" && <Chat />}
|
||||
{/* name !== "" && <VideoFeeds/> */}
|
||||
{loaded && (
|
||||
<Actions
|
||||
{...{
|
||||
buildActive,
|
||||
setBuildActive,
|
||||
tradeActive,
|
||||
setTradeActive,
|
||||
houseRulesActive,
|
||||
setHouseRulesActive,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlobalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { RoomView };
|
45
client/src/api-client.ts
Normal file
45
client/src/api-client.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Session, Room } from "./GlobalContext";
|
||||
import { base } from "./Common";
|
||||
const sessionApi = {
|
||||
getCurrent: async (): Promise<Session> => {
|
||||
const response = await fetch(`${base}/api/v1/games/`, {
|
||||
method: "GET",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin", /* include cookies */
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to connect to Ketr Ketran game server! Try refreshing your browser in a few seconds.`);
|
||||
}
|
||||
const session : any = await response.json();
|
||||
if (!session.id) {
|
||||
session.id = session.player;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
createRoom: async (name?: string): Promise<Room> => {
|
||||
const response = await fetch(`${base}/api/v1/games/${name || ''}`, {
|
||||
method: "POST",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch session: ${response.statusText}`);
|
||||
}
|
||||
const room : Room = await response.json();
|
||||
if (!room.name) {
|
||||
room.name = room.id;
|
||||
}
|
||||
if (!room.id) {
|
||||
room.id = room.name;
|
||||
}
|
||||
|
||||
return room;
|
||||
}
|
||||
}
|
||||
export { sessionApi };
|
File diff suppressed because it is too large
Load Diff
20
server/routes/games/constants.ts
Normal file
20
server/routes/games/constants.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const MAX_SETTLEMENTS = 5;
|
||||
export const MAX_CITIES = 4;
|
||||
export const MAX_ROADS = 15;
|
||||
|
||||
export const types: string[] = [ 'wheat', 'stone', 'sheep', 'wood', 'brick' ];
|
||||
|
||||
export const debug = {
|
||||
audio: false,
|
||||
get: true,
|
||||
set: true,
|
||||
update: false,
|
||||
road: false
|
||||
};
|
||||
|
||||
export const all = `[ all ]`;
|
||||
export const info = `[ info ]`;
|
||||
export const todo = `[ todo ]`;
|
||||
|
||||
export const SEND_THROTTLE_MS = 50;
|
||||
export const INCOMING_GET_BATCH_MS = 20;
|
@ -1,25 +1,132 @@
|
||||
export type ResourceKey = "wood" | "brick" | "sheep" | "wheat" | "stone";
|
||||
|
||||
export type ResourceMap = Partial<Record<ResourceKey, number>> & { [k: string]: any };
|
||||
|
||||
export interface Player {
|
||||
name?: string;
|
||||
color?: string;
|
||||
order: number;
|
||||
orderRoll?: number;
|
||||
position?: string;
|
||||
orderStatus?: string;
|
||||
tied?: boolean;
|
||||
roads?: number;
|
||||
settlements?: number;
|
||||
cities?: number;
|
||||
longestRoad?: number;
|
||||
mustDiscard?: number;
|
||||
sheep?: number;
|
||||
wheat?: number;
|
||||
stone?: number;
|
||||
brick?: number;
|
||||
wood?: number;
|
||||
points?: number;
|
||||
resources?: number;
|
||||
lastActive?: number;
|
||||
live?: boolean;
|
||||
status?: string;
|
||||
developmentCards?: number;
|
||||
development?: DevelopmentCard[];
|
||||
[key: string]: any; // allow incremental fields until fully typed
|
||||
}
|
||||
|
||||
export interface CornerPlacement {
|
||||
color?: string;
|
||||
type?: "settlement" | "city";
|
||||
walking?: boolean;
|
||||
longestRoad?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id?: string | number;
|
||||
placements?: any;
|
||||
rules?: any;
|
||||
state?: string;
|
||||
robber?: number;
|
||||
players?: Player[];
|
||||
export interface RoadPlacement {
|
||||
color?: string;
|
||||
walking?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Placements {
|
||||
corners: CornerPlacement[];
|
||||
roads: RoadPlacement[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
name?: string;
|
||||
color?: string;
|
||||
actions?: string[];
|
||||
limits?: any;
|
||||
roll?: number;
|
||||
volcano?: number | null | undefined;
|
||||
free?: boolean;
|
||||
freeRoads?: number;
|
||||
select?: Record<string, number>;
|
||||
active?: string;
|
||||
robberInAction?: boolean;
|
||||
placedRobber?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DevelopmentCard {
|
||||
card?: number | string;
|
||||
type?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id?: string | number;
|
||||
id: string;
|
||||
userId?: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
ws?: any; // WebSocket instance; keep as any to avoid dependency on ws types
|
||||
player?: Player;
|
||||
live?: boolean;
|
||||
lastActive?: number;
|
||||
keepAlive?: any;
|
||||
_initialSnapshotSent?: boolean;
|
||||
_getBatch?: { fields: Set<string>; timer?: any };
|
||||
_pendingMessage?: any;
|
||||
_pendingTimeout?: any;
|
||||
resources?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface OfferItem {
|
||||
type: string; // 'bank' or resource key or other
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
gets: OfferItem[];
|
||||
gives: OfferItem[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: string;
|
||||
developmentCards: DevelopmentCard[];
|
||||
players: Record<string, Player>;
|
||||
sessions: Record<string, Session>;
|
||||
unselected?: any[];
|
||||
active?: number;
|
||||
rules?: any;
|
||||
step?: number;
|
||||
placements: Placements;
|
||||
turn: Turn;
|
||||
pipOrder?: number[];
|
||||
tileOrder?: number[];
|
||||
borderOrder?: number[];
|
||||
tiles?: any[];
|
||||
pips?: any[];
|
||||
dice?: number[];
|
||||
chat?: any[];
|
||||
activities?: any[];
|
||||
playerOrder?: string[];
|
||||
state?: string;
|
||||
robber?: number;
|
||||
robberName?: string;
|
||||
turns?: number;
|
||||
longestRoad?: string | false;
|
||||
longestRoadLength?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user