1
0

580 lines
18 KiB
TypeScript
Executable File

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<string, AudioEffect | undefined> = {};
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<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);
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);
}
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 (
<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>
);
};
const App: React.FC = () => {
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(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 (
<Router basename={base}>
<Routes>
<Route element={<Table />} path="/:gameId" />
<Route element={<Table />} path="/" />
</Routes>
</Router>
);
};
export default App;