diff --git a/client/.babelrc b/client/.babelrc deleted file mode 100644 index 6e867f9..0000000 --- a/client/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": [ "@babel/env", "@babel/preset-react" ], - "plugins": [ "@babel/plugin-proposal-class-properties" ] -} diff --git a/client/.eslintrc.js b/client/.eslintrc.js new file mode 100644 index 0000000..a35a9d3 --- /dev/null +++ b/client/.eslintrc.js @@ -0,0 +1,52 @@ +module.exports = { + parser: '@typescript-eslint/parser', + env: { + browser: true, + node: true, + es2021: true, + jest: true + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + settings: { + react: { + version: 'detect', + }, + }, + plugins: ['@typescript-eslint', 'react'], + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:@typescript-eslint/recommended' + ], + rules: { + 'react/prop-types': 'off', + // During incremental migration we relax some rules so legacy JS/TS files + // don't block CI. We'll re-enable stricter rules as files are converted. + 'no-undef': 'off', + 'no-console': 'off', + 'react/react-in-jsx-scope': 'off', + '@typescript-eslint/no-extra-semi': 'off', + '@typescript-eslint/no-empty-function': 'off' + } +}; + +// During incremental migration we allow legacy .js files more leeway. +// Disable some TypeScript-specific and strict React rules for .js files +// so the production build isn't blocked while we convert sources. +module.exports.overrides = [ + { + files: ["**/*.js"], + rules: { + "@typescript-eslint/no-extra-semi": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-loss-of-precision": "off", + "react/no-unescaped-entities": "off" + } + } +]; diff --git a/client/package.json b/client/package.json index 9eaa7d1..6d25933 100644 --- a/client/package.json +++ b/client/package.json @@ -1,38 +1,51 @@ { - "name": "peddlers-of-ketran", - "version": "0.1.0", + "name": "peddlers-client", + "version": "1.0.0", "private": true, - "proxy": "http://localhost:8930", + "proxy": "http://peddlers-of-ketran:8930", "dependencies": { - "@emotion/react": "^11.8.1", - "@emotion/styled": "^11.8.1", - "@material-ui/core": "^4.12.3", - "@material-ui/lab": "^4.0.0-alpha.60", - "@mui/icons-material": "^5.4.4", - "@mui/material": "^5.4.4", - "@mui/utils": "^5.4.4", - "@testing-library/jest-dom": "^5.16.1", - "@testing-library/react": "^12.1.2", - "@testing-library/user-event": "^13.5.0", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.13.7", + "@mui/material": "^5.13.7", + "@mui/styles": "^5.13.3", + "@mui/utils": "^5.13.7", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3", + "ajv": "^8.12.0", "fast-deep-equal": "^3.1.3", "http-proxy-middleware": "^2.0.3", "moment": "^2.29.1", "moment-timezone": "^0.5.34", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-moment": "^1.1.1", "react-movable": "^3.0.4", "react-moveable": "^0.31.1", - "react-router-dom": "^6.2.1", - "react-scripts": "5.0.0", + "react-router-dom": "^6.14.1", + "react-scripts": "5.0.1", "socket.io-client": "^4.4.1", - "web-vitals": "^2.1.2" + "web-vitals": "^2.1.4" }, "scripts": { "start": "HTTPS=true react-scripts start", "build": "export $(cat ../.env | xargs) && react-scripts build", "test": "export $(cat ../.env | xargs) && react-scripts test", - "eject": "export $(cat ../.env | xargs) && react-scripts eject" + "eject": "export $(cat ../.env | xargs) && react-scripts eject", + "type-check": "tsc --project tsconfig.json --noEmit", + "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}' --max-warnings=0", + "lint:fix": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix" + }, + "lint-staged": { + "src/**/*.{js,jsx,ts,tsx}": [ + "npm run lint:fix" + ] + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } }, "eslintConfig": { "extends": [ @@ -51,5 +64,10 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", + "typescript": "^5.3.3" } } diff --git a/client/src/Actions.tsx b/client/src/Actions.tsx new file mode 100644 index 0000000..0e27b12 --- /dev/null +++ b/client/src/Actions.tsx @@ -0,0 +1,337 @@ +import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react"; +import Paper from "@mui/material/Paper"; +import Button from "@mui/material/Button"; +import equal from "fast-deep-equal"; + +import "./Actions.css"; +import { PlayerName } from "./PlayerName"; +import { GlobalContext } from "./GlobalContext"; + +type LocalGlobalContext = { + ws?: WebSocket | null; + gameId?: string | null; + name?: string | undefined; +}; + +type PrivateData = { + orderRoll?: boolean; + resources?: number; + mustDiscard?: number; +}; + +type TurnData = { + roll?: number; + color?: string; + active?: string; + actions?: string[]; + select?: unknown; + robberInAction?: boolean; +}; + +type PlayerData = { + live?: boolean; +}; + +type ActionsProps = { + tradeActive: boolean; + setTradeActive: (b: boolean) => void; + buildActive: boolean; + setBuildActive: (b: boolean) => void; + houseRulesActive: boolean; + setHouseRulesActive: (b: boolean) => void; +}; + +const Actions: React.FC = ({ + tradeActive, + setTradeActive, + buildActive, + setBuildActive, + houseRulesActive, + setHouseRulesActive, +}) => { + const ctx = useContext(GlobalContext) as LocalGlobalContext; + const ws = ctx.ws ?? null; + const gameId = ctx.gameId ?? null; + const name = ctx.name ?? undefined; + const [state, setState] = useState("lobby"); + const [color, setColor] = useState(undefined); + const [priv, setPriv] = useState(undefined); + const [turn, setTurn] = useState({}); + const [edit, setEdit] = useState(name); + const [active, setActive] = useState(0); + const [players, setPlayers] = useState>({}); + const [alive, setAlive] = useState(0); + + const fields = useMemo(() => ["state", "turn", "private", "active", "color", "players"], []); + + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`actions - game update`, data.update); + if ("private" in data.update && !equal(data.update.private, priv)) { + setPriv(data.update.private); + } + if ("state" in data.update && data.update.state !== state) { + setState(data.update.state); + } + if ("color" in data.update && data.update.color !== color) { + setColor(data.update.color); + } + if ("name" in data.update && data.update.name !== edit) { + setEdit(data.update.name); + } + if ("turn" in data.update && !equal(data.update.turn, turn)) { + setTurn(data.update.turn); + } + if ("active" in data.update && data.update.active !== active) { + setActive(data.update.active); + } + + if ("players" in data.update && !equal(data.update.players, players)) { + setPlayers(data.update.players); + } + break; + default: + break; + } + }; + + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage as EventListener); + return () => { + ws.removeEventListener("message", cbMessage as EventListener); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send(JSON.stringify({ type: "get", fields })); + }, [ws, fields]); + + const sendMessage = useCallback( + (data: Record) => { + if (!ws) { + console.warn(`No socket`); + } else { + ws.send(JSON.stringify(data)); + } + }, + [ws] + ); + + const buildClicked = () => { + setBuildActive(!buildActive); + }; + + useEffect(() => { + let count = 0; + for (const key in players) { + // players entries are dynamic; guard access + const p = players[key]; + if (p && p.live) { + count++; + } + } + setAlive(count); + }, [players, setAlive]); + + const setName = (update: string) => { + if (update !== name) { + sendMessage({ type: "player-name", name: update }); + } + setEdit(name); + if (buildActive) setBuildActive(false); + }; + + const changeNameClick = () => { + setEdit(""); + if (buildActive) setBuildActive(false); + }; + + const discardClick = () => { + const nodes = document.querySelectorAll(".Hand .Resource.Selected"); + const discards: Record = { wheat: 0, brick: 0, sheep: 0, stone: 0, wood: 0 }; + for (let i = 0; i < nodes.length; i++) { + const t = nodes[i].getAttribute("data-type") || ""; + discards[t] = (discards[t] || 0) + 1; + nodes[i].classList.remove("Selected"); + } + sendMessage({ type: "discard", discards }); + if (buildActive) setBuildActive(false); + }; + + const newTableClick = () => { + sendMessage({ type: "shuffle" }); + if (buildActive) setBuildActive(false); + }; + + const tradeClick = () => { + if (!tradeActive) { + setTradeActive(true); + sendMessage({ type: "trade" }); + } else { + setTradeActive(false); + sendMessage({ type: "trade", action: "cancel", offer: undefined }); + } + if (buildActive) setBuildActive(false); + }; + + const rollClick = () => { + sendMessage({ type: "roll" }); + if (buildActive) setBuildActive(false); + }; + const passClick = () => { + sendMessage({ type: "pass" }); + if (buildActive) setBuildActive(false); + }; + const houseRulesClick = () => { + setHouseRulesActive(!houseRulesActive); + }; + const startClick = () => { + sendMessage({ type: "set", field: "state", value: "game-order" }); + if (buildActive) setBuildActive(false); + }; + const resetGame = () => { + sendMessage({ type: "clear-game" }); + if (buildActive) setBuildActive(false); + }; + + if (!gameId) { + return ; + } + + const inLobby = state === "lobby", + inGame = state === "normal", + inGameOrder = state === "game-order", + hasGameOrderRolled = priv && priv.orderRoll ? true : false, + hasRolled = turn && turn.roll ? true : false, + isTurn = turn && turn.color === color ? true : false, + robberActions = turn && turn.robberInAction, + haveResources = priv ? priv.resources !== 0 : false, + volcanoActive = state === "volcano", + placement = state === "initial-placement" || (turn && turn.active === "road-building"), + placeRoad = + placement && + turn && + turn.actions && + (turn.actions.indexOf("place-road") !== -1 || + turn.actions.indexOf("place-city") !== -1 || + turn.actions.indexOf("place-settlement") !== -1); + + if (tradeActive && (!turn || !turn.actions || turn.actions.indexOf("trade"))) { + setTradeActive(false); + } else if (!tradeActive && turn && turn.actions && turn.actions.indexOf("trade") !== -1) { + setTradeActive(true); + } + + let disableRoll = false; + if (robberActions) { + disableRoll = true; + } + if (turn && turn.select) { + disableRoll = true; + } + if (inGame && !isTurn) { + disableRoll = true; + } + if (inGame && hasRolled) { + disableRoll = true; + } + if (volcanoActive && (!isTurn || hasRolled)) { + disableRoll = true; + } + if (volcanoActive && isTurn && turn && !turn.select) { + disableRoll = false; + } + if (inGameOrder && hasGameOrderRolled) { + disableRoll = true; + } + if (placement) { + disableRoll = true; + } + + console.log("actions - ", { + disableRoll, + robberActions, + turn, + inGame, + isTurn, + hasRolled, + volcanoActive, + inGameOrder, + hasGameOrderRolled, + }); + + const disableDone = volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled; + + return ( + + {edit === "" && } +
+ {name && alive === 1 && } + {name && inLobby && ( + <> + + + + )} + {name && !color && ( + + )} + {name && color && inLobby && ( + + )} + {name && !inLobby && ( + <> + + + + + {name && color && ( + + )} + + + )} +
+
+ ); +}; + +export { Actions }; diff --git a/client/src/Activities.tsx b/client/src/Activities.tsx new file mode 100644 index 0000000..74bc83c --- /dev/null +++ b/client/src/Activities.tsx @@ -0,0 +1,295 @@ +import React, { useState, useContext, useMemo, useEffect, useRef } from "react"; +import equal from "fast-deep-equal"; +import "./Activities.css"; +import { PlayerColor } from "./PlayerColor"; +import { Dice } from "./Dice"; +import { GlobalContext } from "./GlobalContext"; + +type ActivityData = { + message: string; + color: string; + date: number; +}; + +type PlayerData = { + name: string; + mustDiscard?: number; +}; + +type TurnData = { + color?: string; + name?: string; + placedRobber?: boolean; + robberInAction?: boolean; + active?: string; + actions?: string[]; + select?: Record; +}; + +type ActivityProps = { + keep: boolean; + activity: ActivityData; +}; + +const Activity: React.FC = ({ keep, activity }) => { + const [animation, setAnimation] = useState("open"); + const [display, setDisplay] = useState(true); + + const hide = async (ms) => { + await new Promise((r) => setTimeout(r, ms)); + setAnimation("close"); + await new Promise((r) => setTimeout(r, 1000)); + setDisplay(false); + }; + + if (display && !keep) { + setTimeout(() => { + hide(10000); + }, 0); + } + + let message; + /* If the date is in the future, set it to now */ + const dice = activity.message.match(/^(.*rolled )([1-6])(, ([1-6]))?(.*)$/); + if (dice) { + if (dice[4]) { + const sum = parseInt(dice[2]) + parseInt(dice[4]); + message = ( + <> + {dice[1]} + {sum}: , + {dice[5]} + + ); + } else { + message = ( + <> + {dice[1]} + + {dice[5]} + + ); + } + } else { + message = activity.message; + } + + return ( + <> + {display && ( +
+ + {message} +
+ )} + + ); +}; + +const Activities: React.FC = () => { + const { ws } = useContext(GlobalContext); + const [activities, setActivities] = useState([]); + const [turn, setTurn] = useState(undefined); + const [color, setColor] = useState(undefined); + const [players, setPlayers] = useState>({}); + const [timestamp, setTimestamp] = useState(0); + const [state, setState] = useState(""); + + const fields = useMemo(() => ["activities", "turn", "players", "timestamp", "color", "state"], []); + const requestUpdate = (fields: string | string[]) => { + let request: string[]; + if (!Array.isArray(fields)) { + request = [fields]; + } else { + request = fields; + } + ws?.send( + JSON.stringify({ + type: "get", + fields: request, + }) + ); + }; + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) as { type: string; update?: Record }; + switch (data.type) { + case "game-update": { + const ignoring: string[] = [], + processing: string[] = []; + if (data.update) { + for (const field in data.update) { + if (fields.indexOf(field) === -1) { + ignoring.push(field); + } else { + processing.push(field); + } + } + } + console.log(`activities - game update`, data.update); + console.log(`activities - ignoring ${ignoring.join(",")}`); + console.log(`activities - processing ${processing.join(",")}`); + + if (data.update && "state" in data.update && data.update.state !== state) { + requestUpdate("turn"); + setState(data.update.state as string); + } + if (data.update && "activities" in data.update && !equal(data.update.activities, activities)) { + setActivities(data.update.activities as ActivityData[]); + } + if (data.update && "turn" in data.update && !equal(data.update.turn, turn)) { + setTurn(data.update.turn as TurnData); + } + if (data.update && "players" in data.update && !equal(data.update.players, players)) { + setPlayers(data.update.players as Record); + } + if (data.update && "timestamp" in data.update && data.update.timestamp !== timestamp) { + setTimestamp(data.update.timestamp as number); + } + if (data.update && "color" in data.update && data.update.color !== color) { + setColor(data.update.color as string); + } + break; + } + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage as EventListener); + return () => { + ws.removeEventListener("message", cbMessage as EventListener); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + if (!timestamp) { + return <>; + } + + const isTurn = turn && turn.color === color ? true : false, + normalPlay = ["initial-placement", "normal", "volcano"].indexOf(state) !== -1, + mustPlaceRobber = turn && !turn.placedRobber && turn.robberInAction, + placement = state === "initial-placement" || (turn && turn.active === "road-building"), + placeRoad = placement && turn && turn.actions && turn.actions.indexOf("place-road") !== -1, + mustStealResource = turn && turn.actions && turn.actions.indexOf("steal-resource") !== -1, + rollForVolcano = state === "volcano" && turn && !turn.select, + rollForOrder = state === "game-order", + selectResources = turn && turn.actions && turn.actions.indexOf("select-resources") !== -1; + + console.log(`activities - `, state, turn, activities); + + const discarders: React.ReactElement[] = []; + let mustDiscard = false; + for (const key in players) { + const player = players[key]; + if (!player.mustDiscard) { + continue; + } + mustDiscard = true; + const name = color === key ? "You" : player.name; + discarders.push( +
+ {name} must discard {player.mustDiscard} cards. +
+ ); + } + + const list: React.ReactElement[] = activities + .filter((activity, index) => activities.length - 1 === index || timestamp - activity.date < 11000) + .map((activity, index, filtered) => { + return ; + }); + + let who: string | React.ReactElement; + if (turn && turn.select) { + const selecting: { color: string; name: string }[] = []; + for (const key in turn.select) { + selecting.push({ + color: key, + name: color === key ? "You" : players[key]?.name || "", + }); + } + who = ( + <> + {selecting.map((player, index) => ( +
+ + {player.name} + {index !== selecting.length - 1 ? ", " : ""} +
+ ))} + + ); + } else { + if (isTurn) { + who = "You"; + } else { + if (!turn || !turn.name) { + who = "Everyone"; + } else { + who = ( + <> + + {turn.name} + + ); + } + } + } + + return ( +
+ {list} + {normalPlay && !mustDiscard && mustPlaceRobber &&
{who} must move the Robber.
} + + {placement && ( +
+ {who} must place a {placeRoad ? "road" : "settlement"}. +
+ )} + + {mustStealResource &&
{who} must select a player to steal from.
} + + {rollForOrder &&
{who} must roll for game order.
} + + {rollForVolcano &&
{who} must roll for Volcano devastation!
} + + {selectResources &&
{who} must select resources!
} + + {normalPlay && mustDiscard && <> {discarders} } + + {!isTurn && normalPlay && turn && ( +
+ It is {turn.name} + {"'"}s turn. +
+ )} + + {isTurn && normalPlay && turn && ( +
+ It is your turn. +
+ )} +
+ ); +}; + +export { Activities }; diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100755 index 0000000..4f110c9 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,579 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom"; + +import Paper from "@mui/material/Paper"; +import Button from "@mui/material/Button"; + +import { GlobalContext } from "./GlobalContext"; +import { PlayerList } from "./PlayerList"; +import { Chat } from "./Chat"; +import { Board } from "./Board"; +import { Actions } from "./Actions"; +import { base, gamesPath } from "./Common"; +import { GameOrder } from "./GameOrder"; +import { Activities } from "./Activities"; +import { SelectPlayer } from "./SelectPlayer"; +import { PlayersStatus } from "./PlayersStatus"; +import { ViewCard } from "./ViewCard"; +import { ChooseCard } from "./ChooseCard"; +import { Hand } from "./Hand"; +import { Trade } from "./Trade"; +import { Winner } from "./Winner"; +import { HouseRules } from "./HouseRules"; +import { Dice } from "./Dice"; +import { assetsPath } from "./Common"; + +// history replaced by react-router's useNavigate +import "./App.css"; +import equal from "fast-deep-equal"; + +type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean }; +const audioEffects: Record = {}; + +const loadAudio = (src: string) => { + const audio = document.createElement("audio") as AudioEffect; + audio.src = `${assetsPath}/${src}`; + audio.setAttribute("preload", "auto"); + audio.setAttribute("controls", "none"); + audio.style.display = "none"; + document.body.appendChild(audio); + void audio.play(); + audio.hasPlayed = true; + return audio; +}; + +const Table: React.FC = () => { + const params = useParams(); + const navigate = useNavigate(); + const [gameId, setGameId] = useState(params.gameId ? (params.gameId as string) : undefined); + const [ws, setWs] = useState(undefined); /* tracks full websocket lifetime */ + const [connection, setConnection] = useState(undefined); /* set after ws is in OPEN */ + const [retryConnection, setRetryConnection] = + useState(true); /* set when connection should be re-established */ + const [name, setName] = useState(""); + const [error, setError] = useState(undefined); + const [warning, setWarning] = useState(undefined); + const [loaded, setLoaded] = useState(false); + + type Turn = { color?: string; roll?: number; actions?: string[]; select?: Record }; + type PrivateType = { name?: string; color?: string; turnNotice?: string }; + + const [dice, setDice] = useState(undefined); + const [state, setState] = useState(undefined); + const [color, setColor] = useState(undefined); + const [priv, setPriv] = useState(undefined); + const [turn, setTurn] = useState(undefined); + const [buildActive, setBuildActive] = useState(false); + const [tradeActive, setTradeActive] = useState(false); + const [cardActive, setCardActive] = useState(undefined); + const [houseRulesActive, setHouseRulesActive] = useState(false); + const [winnerDismissed, setWinnerDismissed] = useState(false); + const [global, setGlobal] = useState>({}); + const [count, setCount] = useState(0); + const [audio, setAudio] = useState( + localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false + ); + const [animations, setAnimations] = useState( + localStorage.getItem("animations") ? JSON.parse(localStorage.getItem("animations") as string) : false + ); + const [volume, setVolume] = useState( + localStorage.getItem("volume") ? parseFloat(localStorage.getItem("volume") as string) : 0.5 + ); + const fields = ["id", "state", "color", "name", "private", "dice", "turn"]; + + const onWsOpen = (event: Event) => { + console.log(`ws: open`); + setError(""); + + setConnection(ws); + const sock = event.target as WebSocket; + sock.send(JSON.stringify({ type: "game-update" })); + sock.send(JSON.stringify({ type: "get", fields })); + }; + + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data as string); + switch (data.type) { + case "error": + console.error(`App - error`, data.error); + setError(data.error); + break; + case "warning": + console.warn(`App - warning`, data.warning); + setWarning(data.warning); + setTimeout(() => { + setWarning(""); + }, 3000); + break; + case "game-update": + if (!loaded) { + setLoaded(true); + } + console.log(`app - message - ${data.type}`, data.update); + + if ("private" in data.update && !equal(priv, data.update.private)) { + const priv = data.update.private; + if (priv.name !== name) { + setName(priv.name); + } + if (priv.color !== color) { + setColor(priv.color); + } + setPriv(priv); + } + + if ("name" in data.update) { + if (data.update.name) { + setName(data.update.name); + } else { + setWarning(""); + setError(""); + setPriv(undefined); + } + } + if ("id" in data.update && data.update.id !== gameId) { + setGameId(data.update.id); + } + if ("state" in data.update && data.update.state !== state) { + if (data.update.state !== "winner" && winnerDismissed) { + setWinnerDismissed(false); + } + setState(data.update.state); + } + if ("dice" in data.update && !equal(data.update.dice, dice)) { + setDice(data.update.dice); + } + if ("turn" in data.update && !equal(data.update.turn, turn)) { + setTurn(data.update.turn); + } + if ("color" in data.update && data.update.color !== color) { + setColor(data.update.color); + } + break; + default: + break; + } + }; + + const sendUpdate = (update: unknown) => { + if (ws) ws.send(JSON.stringify(update)); + }; + + const cbResetConnection = useCallback(() => { + let timer: number | null = null; + function reset() { + timer = null; + setRetryConnection(true); + } + return () => { + if (timer) { + clearTimeout(timer); + } + timer = window.setTimeout(reset, 5000); + }; + }, [setRetryConnection]); + + const resetConnection = cbResetConnection(); + + if (global.ws !== connection || global.name !== name || global.gameId !== gameId) { + setGlobal({ + ws: connection, + name, + gameId, + }); + } + + const onWsError = () => { + const error = + `Connection to Ketr Ketran game server failed! ` + `Connection attempt will be retried every 5 seconds.`; + setError(error); + setGlobal(Object.assign({}, global, { ws: undefined })); + setWs(undefined); /* clear the socket */ + setConnection(undefined); /* clear the connection */ + resetConnection(); + }; + + const onWsClose = () => { + const error = `Connection to Ketr Ketran game was lost. ` + `Attempting to reconnect...`; + setError(error); + setGlobal(Object.assign({}, global, { ws: undefined })); + setWs(undefined); /* clear the socket */ + setConnection(undefined); /* clear the connection */ + resetConnection(); + }; + + const refWsOpen = useRef<(e: Event) => void>(() => {}); + useEffect(() => { + refWsOpen.current = onWsOpen; + }, [onWsOpen]); + const refWsMessage = useRef<(e: MessageEvent) => void>(() => {}); + useEffect(() => { + refWsMessage.current = onWsMessage; + }, [onWsMessage]); + const refWsClose = useRef<(e: CloseEvent) => void>(() => {}); + useEffect(() => { + refWsClose.current = onWsClose; + }, [onWsClose]); + const refWsError = useRef<(e: Event) => void>(() => {}); + useEffect(() => { + refWsError.current = onWsError; + }, [onWsError]); + + useEffect(() => { + if (gameId) { + return; + } + + window + .fetch(`${base}/api/v1/games/`, { + method: "POST", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + if (res.status >= 400) { + const error = + `Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`; + setError(error); + throw new Error(error); + } + return res.json(); + }) + .then((update) => { + if (update.id !== gameId) { + navigate(`/${update.id}`); + setGameId(update.id); + } + }) + .catch((error) => { + console.error(error); + }); + }, [gameId, setGameId]); + + useEffect(() => { + if (!gameId) { + return; + } + + const unbind = () => { + console.log(`table - unbind`); + }; + + if (!ws && !connection && retryConnection) { + const loc = window.location; + let new_uri = ""; + if (loc.protocol === "https:") { + new_uri = "wss"; + } else { + new_uri = "ws"; + } + new_uri = `${new_uri}://${loc.host}${base}/api/v1/games/ws/${gameId}?${count}`; + setWs(new WebSocket(new_uri)); + setConnection(undefined); + setRetryConnection(false); + setCount(count + 1); + return unbind; + } + + if (!ws) { + return unbind; + } + + const cbOpen = (e: Event) => refWsOpen.current(e); + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + const cbClose = (e: CloseEvent) => refWsClose.current(e); + const cbError = (e: Event) => refWsError.current(e); + + ws.addEventListener("open", cbOpen); + ws.addEventListener("close", cbClose); + ws.addEventListener("error", cbError); + ws.addEventListener("message", cbMessage); + + return () => { + unbind(); + ws.removeEventListener("open", cbOpen); + ws.removeEventListener("close", cbClose); + ws.removeEventListener("error", cbError); + ws.removeEventListener("message", cbMessage); + }; + }, [ + ws, + setWs, + connection, + setConnection, + retryConnection, + setRetryConnection, + gameId, + refWsOpen, + refWsMessage, + refWsClose, + refWsError, + count, + setCount, + ]); + + useEffect(() => { + if (state === "volcano") { + if (!audioEffects.volcano) { + audioEffects.volcano = loadAudio("volcano-eruption.mp3"); + audioEffects.volcano.volume = volume * volume; + } else { + if (!audioEffects.volcano.hasPlayed) { + audioEffects.volcano.hasPlayed = true; + audioEffects.volcano.play(); + } + } + } else { + if (audioEffects.volcano) { + audioEffects.volcano.hasPlayed = false; + } + } + }, [state, volume]); + + useEffect(() => { + if (turn && turn.color === color && state !== "lobby") { + if (!audioEffects.yourTurn) { + audioEffects.yourTurn = loadAudio("its-your-turn.mp3"); + audioEffects.yourTurn.volume = volume * volume; + } else { + if (!audioEffects.yourTurn.hasPlayed) { + audioEffects.yourTurn.hasPlayed = true; + audioEffects.yourTurn.play(); + } + } + } else if (turn) { + if (audioEffects.yourTurn) { + audioEffects.yourTurn.hasPlayed = false; + } + } + + if (turn && turn.roll === 7) { + if (!audioEffects.robber) { + audioEffects.robber = loadAudio("robber.mp3"); + audioEffects.robber.volume = volume * volume; + } else { + if (!audioEffects.robber.hasPlayed) { + audioEffects.robber.hasPlayed = true; + audioEffects.robber.play(); + } + } + } else if (turn) { + if (audioEffects.robber) { + audioEffects.robber.hasPlayed = false; + } + } + + if (turn && turn.actions && turn.actions.indexOf("playing-knight") !== -1) { + if (!audioEffects.knights) { + audioEffects.knights = loadAudio("the-knights-who-say-ni.mp3"); + audioEffects.knights.volume = volume * volume; + } else { + if (!audioEffects.knights.hasPlayed) { + audioEffects.knights.hasPlayed = true; + audioEffects.knights.play(); + } + } + } else if (turn && turn.actions && turn.actions.indexOf("playing-knight") === -1) { + if (audioEffects.knights) { + audioEffects.knights.hasPlayed = false; + } + } + }, [state, turn, color, volume]); + + useEffect(() => { + for (const key in audioEffects) { + audioEffects[key].volume = volume * volume; + } + }, [volume]); + + return ( + + {/* */} +
+
+ + {dice && dice.length && ( +
+ {dice.length === 1 &&
Volcano roll!
} + {dice.length === 2 &&
Current roll
} +
+ + {dice.length === 2 && } +
+
+ )} +
+
+
+ {error && ( +
+ +
{error}
+ +
+
+ )} + + {priv && priv.turnNotice && ( +
+ +
{priv.turnNotice}
+ +
+
+ )} + {warning && ( +
+ +
{warning}
+ +
+
+ )} + {state === "normal" && } + {color && state === "game-order" && } + + {!winnerDismissed && } + {houseRulesActive && } + + +
+ + + + + +
+
+ {name !== "" && volume !== undefined && ( + +
Audio effects
{" "} + { + const value = !audio; + localStorage.setItem("audio", JSON.stringify(value)); + setAudio(value); + }} + /> +
Sound effects volume
{" "} + { + const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100; + + localStorage.setItem("volume", alpha.toString()); + setVolume(alpha); + }} + /> +
Animations
{" "} + { + const value = !animations; + localStorage.setItem("animations", JSON.stringify(value)); + setAnimations(value); + }} + /> +
+ )} + {name !== "" && } + {/* Trade is an untyped JS component; assert its type to avoid `any` */} + {(() => { + const TradeComponent = Trade as unknown as React.ComponentType<{ + tradeActive: boolean; + setTradeActive: (v: boolean) => void; + }>; + return ; + })()} + {name !== "" && } + {/* name !== "" && */} + {loaded && ( + + )} +
+
+
+ ); +}; + +const App: React.FC = () => { + const [playerId, setPlayerId] = useState(undefined); + const [error, setError] = useState(undefined); + + useEffect(() => { + if (playerId) { + return; + } + window + .fetch(`${base}/api/v1/games/`, { + method: "GET", + cache: "no-cache", + credentials: "same-origin" /* include cookies */, + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + if (res.status >= 400) { + const error = + `Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`; + setError(error); + } + return res.json(); + }) + .then((data) => { + setPlayerId(data.player); + }) + .catch(() => {}); + }, [playerId, setPlayerId]); + + if (!playerId) { + return <>{error}; + } + + return ( + + + } path="/:gameId" /> + } path="/" /> + + + ); +}; + +export default App; diff --git a/client/src/Bird.tsx b/client/src/Bird.tsx new file mode 100644 index 0000000..14861a5 --- /dev/null +++ b/client/src/Bird.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState, useRef } from "react"; +import { assetsPath } from "./Common"; +import "./Bird.css"; + +const birdAngles = 12; +const frames = [0, 0, 1, 2, 3, 3, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + +const useAnimationFrame = (callback: (t: number) => void) => { + const requestRef = useRef(null); + const animate = (time: number) => { + callback(time); + requestRef.current = requestAnimationFrame(animate); + }; + useEffect(() => { + requestRef.current = requestAnimationFrame(animate); + return () => { + if (requestRef.current !== null) { + cancelAnimationFrame(requestRef.current); + } + }; + }, []); +}; + +const Bird: React.FC<{ radius: number; speed: number; size: number; style?: React.CSSProperties }> = ({ + radius, + speed, + size, + style, +}) => { + const [time, setTime] = useState(0); + const [angle, setAngle] = useState(Math.random() * 360); + const [rotation] = useState((Math.PI * 2 * radius) / 5); + const [direction, setDirection] = useState(Math.floor((birdAngles * (angle ? angle : 0)) / 360)); + const [cell, setCell] = useState(0); + const previousTimeRef = useRef(); + + useAnimationFrame((t) => { + if (previousTimeRef.current !== undefined) { + const deltaTime = t - previousTimeRef.current; + setTime(deltaTime); + } else { + previousTimeRef.current = t; + } + }); + + useEffect(() => { + const alpha = (time % speed) / speed; + const frame = Math.floor(frames.length * alpha); + const newAngle = (angle + rotation) % 360; + setAngle(newAngle); + setCell(frames[Math.floor(frame)]); + setDirection(Math.floor((birdAngles * newAngle) / 360)); + }, [time, speed, rotation]); + + return ( +
+ ); +}; + +const Flock: React.FC<{ count: number; style?: React.CSSProperties }> = ({ count, style }) => { + const [birds, setBirds] = useState([]); + useEffect(() => { + const tmp: React.ReactNode[] = []; + for (let i = 0; i < count; i++) { + const scalar = Math.random(); + tmp.push(); + } + setBirds(tmp); + }, [count]); + return ( +
+ {birds} +
+ ); +}; + +export { Bird, Flock }; diff --git a/client/src/Board.tsx b/client/src/Board.tsx new file mode 100644 index 0000000..d60c7ec --- /dev/null +++ b/client/src/Board.tsx @@ -0,0 +1,1018 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import React, { useEffect, useState, useContext, useRef, useMemo } from "react"; +import equal from "fast-deep-equal"; +import { assetsPath } from "./Common"; +import "./Board.css"; +import { GlobalContext } from "./GlobalContext"; +import { Flock } from "./Bird"; +import { Herd } from "./Sheep"; + +type BoardProps = { + animations: boolean; +}; + +type CornerData = { + index: number; + x: number; + y: number; + // Add other properties as needed +}; + +type RoadData = { + index: number; + x: number; + y: number; + // Add other properties as needed +}; + +type PipData = { + order: number; + x: number; + y: number; + // Add other properties as needed +}; + +type TileData = { + order: number; + x: number; + y: number; + // Add other properties as needed +}; + +type TurnData = { + // Define based on usage +}; + +type RulesData = { + // Define based on usage +}; + +type PlacementData = Record; + +type CornerProps = { + corner: CornerData; +}; + +type RoadProps = { + road: RoadData; +}; + +type PipProps = { + pip: PipData; + className?: string; +}; + +type TileProps = { + tile: TileData; +}; + +const rows = [3, 4, 5, 4, 3, 2]; /* The final row of 2 is to place roads and corners */ + +const hexRatio = 1.1547, // eslint-disable-line @typescript-eslint/no-loss-of-precision + tileWidth = 67, + tileHalfWidth = tileWidth * 0.5, + tileHeight = tileWidth * hexRatio, + tileHalfHeight = tileHeight * 0.5, + radius = tileHeight * 2, + borderOffsetX = 86 /* ~1/10th border image width... hand tuned */, + borderOffsetY = 3; + +/* Actual sizing */ +const tileImageWidth = 90 /* Based on hand tuned and image width */, + tileImageHeight = tileImageWidth / hexRatio, + borderImageWidth = (2 + 2 / 3) * tileImageWidth /* 2.667 * .Tile.width */, + borderImageHeight = borderImageWidth * 0.29; /* 0.29 * .Border.height */ + +const showTooltip = () => { + const tooltip = document.querySelector(".Board .Tooltip") as HTMLElement; + if (tooltip) tooltip.style.display = "flex"; +}; + +const clearTooltip = () => { + const tooltip = document.querySelector(".Board .Tooltip") as HTMLElement; + if (tooltip) tooltip.style.display = "none"; +}; + +const Board: React.FC = ({ animations }) => { + const { ws } = useContext(GlobalContext); + const board = useRef(); + const [transform, setTransform] = useState(1); + const [pipElements, setPipElements] = useState([]); + const [borderElements, setBorderElements] = useState([]); + const [tileElements, setTileElements] = useState([]); + const [cornerElements, setCornerElements] = useState([]); + const [roadElements, setRoadElements] = useState([]); + const [signature, setSignature] = useState(""); + const [generated, setGenerated] = useState(""); + const [robber, setRobber] = useState(-1); + const [robberName, setRobberName] = useState([]); + const [pips, setPips] = useState(undefined); // Keep as any for now, complex structure + const [pipOrder, setPipOrder] = useState(undefined); + const [borders, setBorders] = useState(undefined); + const [borderOrder, setBorderOrder] = useState(undefined); + const [animationSeeds, setAnimationSeeds] = useState(undefined); + const [tiles, setTiles] = useState(undefined); + const [tileOrder, setTileOrder] = useState([]); + const [placements, setPlacements] = useState(undefined); + const [turn, setTurn] = useState({}); + const [state, setState] = useState(""); + const [color, setColor] = useState(""); + const [rules, setRules] = useState({}); + const [longestRoadLength, setLongestRoadLength] = useState(0); + const fields = useMemo( + () => [ + "signature", + "robber", + "robberName", + "pips", + "pipOrder", + "borders", + "borderOrder", + "tiles", + "tileOrder", + "placements", + "turn", + "state", + "color", + "longestRoadLength", + "rules", + "animationSeeds", + ], + [] + ); + + console.log(`board - ws`, ws); + + const onWsMessage = (event) => { + if (ws && ws !== event.target) { + console.error(`Disconnect occur?`); + } + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`board - game update`, data.update); + if ("robber" in data.update && data.update.robber !== robber) { + setRobber(data.update.robber); + } + + if ("robberName" in data.update && data.update.robberName !== robberName) { + setRobberName(data.update.robberName); + } + + if ("state" in data.update && data.update.state !== state) { + setState(data.update.state); + } + + if ("rules" in data.update && !equal(data.update.rules, rules)) { + setRules(data.update.rules); + } + + if ("color" in data.update && data.update.color !== color) { + setColor(data.update.color); + } + + if ("longestRoadLength" in data.update && data.update.longestRoadLength !== longestRoadLength) { + setLongestRoadLength(data.update.longestRoadLength); + } + + if ("turn" in data.update) { + if (!equal(data.update.turn, turn)) { + console.log(`board - turn`, data.update.turn); + setTurn(data.update.turn); + } + } + + if ("placements" in data.update && !equal(data.update.placements, placements)) { + console.log(`board - placements`, data.update.placements); + setPlacements(data.update.placements); + } + + /* The following are only updated if there is a new game + * signature */ + + if ("pipOrder" in data.update && !equal(data.update.pipOrder, pipOrder)) { + console.log(`board - setting new pipOrder`); + setPipOrder(data.update.pipOrder); + } + + if ("borderOrder" in data.update && !equal(data.update.borderOrder, borderOrder)) { + console.log(`board - setting new borderOrder`); + setBorderOrder(data.update.borderOrder); + } + + if ("animationSeeds" in data.update && !equal(data.update.animationSeeds, animationSeeds)) { + console.log(`board - setting new animationSeeds`); + setAnimationSeeds(data.update.animationSeeds); + } + + if ("tileOrder" in data.update && !equal(data.update.tileOrder, tileOrder)) { + console.log(`board - setting new tileOrder`); + setTileOrder(data.update.tileOrder); + } + + if (data.update.signature !== signature) { + console.log(`board - setting new signature`); + setSignature(data.update.signature); + } + + /* This is permanent static data from the server -- do not update + * once set */ + if ("pips" in data.update && !pips) { + console.log(`board - setting new static pips`); + setPips(data.update.pips); + } + if ("tiles" in data.update && !tiles) { + console.log(`board - setting new static tiles`); + setTiles(data.update.tiles); + } + if ("borders" in data.update && !borders) { + console.log(`board - setting new static borders`); + setBorders(data.update.borders); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + console.log("board - bind"); + const cbMessage = (e) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + console.log("board - unbind"); + ws.removeEventListener("message", cbMessage); + }; + }, [ws]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + useEffect(() => { + const boardBox = board.current.querySelector(".BoardBox"); + if (boardBox) { + console.log(`board - setting transform scale to ${transform}`); + boardBox.style.transform = `scale(${transform})`; + } + }, [transform]); + + const onResize = () => { + if (!board.current) { + return; + } + + /* Adjust the 'transform: scale' for the BoardBox + * so the board fills the Board + * + * The board is H tall, and H * hexRatio wide */ + const width = board.current.offsetWidth, + height = board.current.offsetHeight; + let _transform; + if (height * hexRatio > width) { + _transform = width / (450 * hexRatio); // eslint-disable-line @typescript-eslint/no-loss-of-precision + } else { + _transform = height / 450; // eslint-disable-line @typescript-eslint/no-loss-of-precision + } + + if (transform !== _transform) { + setTransform(_transform); + } + }; + + const refOnResize = useRef(onResize); + useEffect(() => { + refOnResize.current = onResize; + }); + useEffect(() => { + const cbOnResize = (e) => refOnResize.current(e); + window.addEventListener("resize", cbOnResize); + return () => { + window.removeEventListener("resize", cbOnResize); + }; + }, [refOnResize]); + + onResize(); + + useEffect(() => { + if (!ws) { + return; + } + + console.log(`Generating static corner data... should only occur once per reload or socket reconnect.`); + const onCornerClicked = (event, corner) => { + let type; + if (event.currentTarget.getAttribute("data-type") === "settlement") { + type = "place-city"; + } else { + type = "place-settlement"; + } + ws.send( + JSON.stringify({ + type, + index: corner.index, + }) + ); + }; + const Corner: React.FC = ({ corner }) => { + return ( +
{ + if (e.shiftPressed) { + const tooltip = document.querySelector(".Board .Tooltip") as HTMLElement; + tooltip.innerHTML = `
${corner}
`; + showTooltip(); + } + }} + onClick={(event) => { + onCornerClicked(event, corner); + }} + data-index={corner.index} + style={{ + top: `${corner.top}px`, + left: `${corner.left}px`, + }} + > +
+
+ ); + }; + + const generateCorners = () => { + let row = 0, + rowCount = 0; + let y = -8 + 0.5 * tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, + x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight; + let index = 0; + const corners = []; + let corner; + + for (let i = 0; i < 21; i++) { + if (row > 2 && rowCount === 0) { + corner = { + index: index++, + top: y - 0.5 * tileHalfHeight, + left: x - tileHalfHeight, + }; + corners.push(); + } + + corner = { + index: index++, + top: y, + left: x, + }; + corners.push(); + + corner = { + index: index++, + top: y - 0.5 * tileHalfHeight, + left: x + tileHalfHeight, + }; + corners.push(); + + if (++rowCount === rows[row]) { + corner = { + index: index++, + top: y, + left: x + 2 * tileHalfHeight, + }; + corners.push(); + + if (row > 2) { + corner = { + index: index++, + top: y - 0.5 * tileHalfHeight, + left: x + 3 * tileHalfHeight, + }; + corners.push(); + } + + row++; + rowCount = 0; + y += tileHeight - 10.5; + x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight; + } else { + x += tileHeight; + } + } + return corners; + }; + + setCornerElements(generateCorners()); + }, [ws, setCornerElements]); + + useEffect(() => { + if (!ws) { + return; + } + + console.log(`Generating static road data... should only occur once per reload or socket reconnect.`); + const Road: React.FC = ({ road }) => { + const onRoadClicked = (road) => { + console.log(`Road clicked: ${road.index}`); + if (!ws) { + console.error(`board - onRoadClicked - ws is NULL`); + return; + } + ws.send( + JSON.stringify({ + type: "place-road", + index: road.index, + }) + ); + }; + + return ( +
{ + onRoadClicked(road); + }} + data-index={road.index} + style={{ + transform: `translate(-50%, -50%) rotate(${road.angle}deg)`, + top: `${road.top}px`, + left: `${road.left}px`, + }} + > +
+
+ ); + }; + + const generateRoads = () => { + let row = 0, + rowCount = 0; + let y = -2.5 + tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, + x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight; + + let index = 0; + let road; + + const corners = []; + + for (let i = 0; i < 21; i++) { + const lastRow = row === rows.length - 1; + if (row > 2 && rowCount === 0) { + road = { + index: index++, + angle: -60, + top: y - 0.5 * tileHalfHeight, + left: x - tileHalfHeight, + }; + corners.push(); + } + + road = { + index: index++, + angle: 240, + top: y, + left: x, + }; + corners.push(); + + road = { + index: index++, + angle: -60, + top: y - 0.5 * tileHalfHeight, + left: x + tileHalfHeight, + }; + corners.push(); + + if (!lastRow) { + road = { + index: index++, + angle: 0, + top: y, + left: x, + }; + corners.push(); + } + + if (++rowCount === rows[row]) { + if (!lastRow) { + road = { + index: index++, + angle: 0, + top: y, + left: x + 2 * tileHalfHeight, + }; + corners.push(); + } + + if (row > 2) { + road = { + index: index++, + angle: 60, + top: y - 0.5 * tileHalfHeight, + left: x + 3 * tileHalfHeight, + }; + corners.push(); + } + + row++; + rowCount = 0; + y += tileHeight - 10.5; + x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight; + } else { + x += tileHeight; + } + } + return corners; + }; + setRoadElements(generateRoads()); + }, [ws, setRoadElements]); + + /* Generate Pip, Tile, and Border elements */ + useEffect(() => { + if (!ws) { + return; + } + console.log(`board - Generate pip, border, and tile elements`); + const Pip: React.FC = ({ pip, className }) => { + const onPipClicked = (pip) => { + if (!ws) { + console.error(`board - sendPlacement - ws is NULL`); + return; + } + ws.send( + JSON.stringify({ + type: "place-robber", + index: pip.index, + }) + ); + }; + + return ( +
{ + onPipClicked(pip); + }} + data-roll={pip.roll} + data-index={pip.index} + style={{ + top: `${pip.top}px`, + left: `${pip.left}px`, + backgroundImage: `url(${assetsPath}/gfx/pip-numbers.png)`, + backgroundPositionX: `${(100 * (pip.order % 6)) / 5}%`, // eslint-disable-line @typescript-eslint/no-loss-of-precision + backgroundPositionY: `${(100 * Math.floor(pip.order / 6)) / 5}%`, + }} + > +
+
+ ); + }; + + const generatePips = function (pipOrder) { + let row = 0, + rowCount = 0; + let y = tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, + x = -(rows[row] - 1) * 0.5 * tileHeight; + let index = 0; + let pip; + return pipOrder.map((order) => { + let volcano = false; + pip = { + roll: pips[order].roll, + index: index++, + top: y, + left: x, + order: order, + }; + if ("volcano" in rules && rules[`volcano`].enabled && pip.roll === 7) { + pip.order = pips.findIndex((pip) => pip.roll === rules[`volcano`].number); + pip.roll = rules[`volcano`].number; + volcano = true; + } + const div = ; + + if (++rowCount === rows[row]) { + row++; + rowCount = 0; + y += tileWidth; + x = -(rows[row] - 1) * 0.5 * tileHeight; + } else { + x += tileHeight; + } + + return div; + }); + }; + + const Tile: React.FC = ({ tile }) => { + const style: React.CSSProperties = { + top: `${tile.top}px`, + left: `${tile.left}px`, + width: `${tileImageWidth}px`, + height: `${tileImageHeight}px`, + backgroundImage: `url(${assetsPath}/gfx/tiles-${tile.type}.png)`, + backgroundPositionY: `-${tile.card * tileHeight}px`, + }; + + if (tile.type === "volcano") { + style.transform = `rotate(-90deg)`; + style.top = `${tile.top + 6}px`; + style.transformOrigin = "0% 50%"; + } + + return ( +
+
+
+ ); + }; + + const generateTiles = function (tileOrder, animationSeeds) { + let row = 0, + rowCount = 0; + let y = tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, + x = -(rows[row] - 1) * 0.5 * tileHeight; + let index = 0; + return tileOrder.map((order) => { + const tile = Object.assign({}, tiles[order], { index: index++, left: x, top: y }); + const volcanoActive = "volcano" in rules && rules[`volcano`].enabled; + + if ( + "tiles-start-facing-down" in rules && + rules[`tiles-start-facing-down`].enabled && + state !== "normal" && + state !== "volcano" && + state !== "winner" && + (!volcanoActive || tile.type !== "desert") + ) { + tile.type = "jungle"; + tile.card = 0; + } + if (volcanoActive && tile.type === "desert") { + tile.type = "volcano"; + tile.card = 0; + } + let div; + + if (tile.type === "wheat") { + div = ( +
+ {animations && ( + + )}{" "} + +
+ ); + } else if (tile.type === "sheep") { + div = ( +
+ {animations && ( + + )} + +
+ ); + } else { + div = ; + } + + if (++rowCount === rows[row]) { + row++; + rowCount = 0; + y += tileWidth; + x = -(rows[row] - 1) * 0.5 * tileHeight; + } else { + x += tileHeight; + } + return div; + }); + }; + + const calculateBorderSlot = (side, e) => { + const borderBox = document.querySelector(".Borders").getBoundingClientRect(); + let angle = + ((360 + Math.floor(90 + (Math.atan2(e.pageY - borderBox.top, e.pageX - borderBox.left) * 180) / Math.PI)) % + 360) - + side * 60; + if (angle > 180) { + angle = angle - 360; + } + let slot = 0; + if (angle > -20 && angle < 5) { + slot = 1; + } else if (angle > 5) { + slot = 2; + } + return slot; + }; + + const mouseEnter = (border, side, e) => { + const slot = calculateBorderSlot(side, e); + if (!border[slot]) { + clearTooltip(); + return; + } + const tooltip = document.querySelector(".Board .Tooltip") as HTMLElement; + tooltip.textContent = + border[slot] === "bank" ? "3 of one kind for 1 resource" : `2 ${border[slot]} for 1 resource`; + tooltip.style.top = `${e.pageY}px`; + tooltip.style.left = `${e.pageX + 16}px`; + showTooltip(); + }; + + const mouseMove = (border, side, e) => { + const slot = calculateBorderSlot(side, e); + if (!border[slot]) { + clearTooltip(); + return; + } + const tooltip = document.querySelector(".Board .Tooltip") as HTMLElement; + tooltip.textContent = + border[slot] === "bank" ? "3 of one kind for 1 resource" : `2 ${border[slot]} for 1 resource`; + tooltip.style.top = `${e.pageY}px`; + tooltip.style.left = `${e.pageX + 16}px`; + showTooltip(); + }; + + const mouseLeave = () => { + clearTooltip(); + }; + + const generateBorders = function (borderOrder) { + const sides = 6; + let side = -1; + return borderOrder.map((order) => { + const border = borders[order]; + side++; + const x = +Math.sin(Math.PI - (side / sides) * 2 * Math.PI) * radius, + y = Math.cos(Math.PI - (side / sides) * 2 * Math.PI) * radius; + const prev = order === 0 ? 6 : order; + const file = `borders-${order + 1}.${prev}.png`; + const value = side; + return ( +
{ + mouseEnter(border, value, e); + }} + onMouseMove={(e) => { + mouseMove(border, value, e); + }} + onMouseLeave={mouseLeave} + style={{ + width: `${borderImageWidth}px`, + height: `${borderImageHeight}px`, + top: `${y}px`, + left: `${x}px`, + transform: `rotate(${ + side * (360 / sides) + }deg) translate(${borderOffsetX}px, ${borderOffsetY}px) scale(-1, -1)`, + backgroundImage: `url(${assetsPath}/gfx/${file} )`, + }} + /> + ); + }); + }; + + if (borders && borderOrder) { + console.log(`board - Generate board - borders`); + setBorderElements(generateBorders(borderOrder)); + } + + if (tiles && tileOrder && animationSeeds) { + console.log(`board - Generate board - tiles`); + setTileElements(generateTiles(tileOrder, animationSeeds)); + } + + /* Regenerate pips every time; it uses ws */ + if (pips && pipOrder) { + console.log(`board - Generate board - pips`); + setPipElements(generatePips(pipOrder)); + } + + if (signature && signature !== generated) { + console.log(`board - Regnerating for ${signature}`); + setGenerated(signature); + } + }, [ + signature, + generated, + pips, + pipOrder, + borders, + borderOrder, + tiles, + tileOrder, + animationSeeds, + ws, + state, + rules, + animations, + ]); + + /* Re-render turn info after every render */ + useEffect(() => { + if (!turn) { + return; + } + let nodes = document.querySelectorAll(".Active"); + for (let i = 0; i < nodes.length; i++) { + nodes[i].classList.remove("Active"); + } + if (turn.roll) { + nodes = document.querySelectorAll(`.Pip[data-roll="${turn.roll}"]`); + for (let i = 0; i < nodes.length; i++) { + const index = nodes[i].getAttribute("data-index"); + if (index !== null) { + const tile = document.querySelector(`.Tile[data-index="${index}"]`); + if (tile) { + tile.classList.add("Active"); + } + } + nodes[i].classList.add("Active"); + } + } + }); + + /* Re-render placements after every render */ + useEffect(() => { + if (!placements) { + return; + } + /* Set color and type based on placement data from the server */ + placements.corners.forEach((corner, index) => { + const el = document.querySelector(`.Corner[data-index="${index}"]`); + if (!el) { + return; + } + if (turn.volcano === index) { + el.classList.add("Lava"); + } else { + el.classList.remove("Lava"); + } + if (!corner.color) { + el.removeAttribute("data-color"); + el.removeAttribute("data-type"); + } else { + el.setAttribute("data-color", corner.color); + el.setAttribute("data-type", corner.type); + } + }); + placements.roads.forEach((road, index) => { + const el = document.querySelector(`.Road[data-index="${index}"]`); + if (!el) { + return; + } + if (!road.color) { + el.removeAttribute("data-color"); + } else { + if (road.longestRoad) { + if (road.longestRoad === longestRoadLength) { + el.classList.add("LongestRoad"); + } else { + el.classList.remove("LongestRoad"); + } + el.setAttribute("data-longest", road.longestRoad); + } else { + el.removeAttribute("data-longest"); + } + el.setAttribute("data-color", road.color); + } + }); + + /* Clear all 'Option' targets */ + let nodes = document.querySelectorAll(`.Option`); + for (let i = 0; i < nodes.length; i++) { + nodes[i].classList.remove("Option"); + } + + /* Add 'Option' based on turn.limits */ + if (turn && turn.limits) { + if (turn.limits["roads"]) { + turn.limits["roads"].forEach((index) => { + const el = document.querySelector(`.Road[data-index="${index}"]`); + if (!el) { + return; + } + el.classList.add("Option"); + el.setAttribute("data-color", turn.color); + }); + } + if (turn.limits["corners"]) { + turn.limits["corners"].forEach((index) => { + const el = document.querySelector(`.Corner[data-index="${index}"]`); + if (!el) { + return; + } + el.classList.add("Option"); + el.setAttribute("data-color", turn.color); + }); + } + if (turn.limits["tiles"]) { + turn.limits["tiles"].forEach((index) => { + const el = document.querySelector(`.Tile[data-index="${index}"]`); + if (!el) { + return; + } + el.classList.add("Option"); + }); + } + if (turn.limits["pips"]) { + turn.limits["pips"].forEach((index) => { + const el = document.querySelector(`.Pip[data-index="${index}"]`); + if (!el) { + return; + } + el.classList.add("Option"); + }); + } + } + + /* Clear the robber */ + nodes = document.querySelectorAll(`.Pip.Robber`); + for (let i = 0; i < nodes.length; i++) { + nodes[i].classList.remove("Robber"); + ["Robert", "Roberta", "Velocirobber"].forEach((robberName) => nodes[i].classList.remove(robberName)); + } + + /* Place the robber */ + if (robber !== undefined) { + const el = document.querySelector(`.Pip[data-index="${robber}"]`); + if (el) { + el.classList.add("Robber"); + el.classList.add(robberName); + } + } + }); + + const canAction = (action) => { + return turn && Array.isArray(turn.actions) && turn.actions.indexOf(action) !== -1; + }; + + const canRoad = + canAction("place-road") && turn.color === color && (state === "initial-placement" || state === "normal"); + + const canCorner = + (canAction("place-settlement") || canAction("place-city")) && + turn.color === color && + (state === "initial-placement" || state === "normal"); + + const canPip = + canAction("place-robber") && turn.color === color && (state === "initial-placement" || state === "normal"); + + console.log(`board - tile elements`, tileElements); + return ( +
+
tooltip
+
+
+ {borderElements} +
+
+ {tileElements} +
+
+ {pipElements} +
+
+ {cornerElements} +
+
+ {roadElements} +
+
+
+ ); +}; + +export { Board }; diff --git a/client/src/BoardPieces.tsx b/client/src/BoardPieces.tsx new file mode 100644 index 0000000..62a16fa --- /dev/null +++ b/client/src/BoardPieces.tsx @@ -0,0 +1,84 @@ +import React from "react"; + +import "./BoardPieces.css"; +import { useStyles } from "./Styles"; + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +interface RoadProps { + color: string; + onClick: (type: string) => void; +} + +const Road: React.FC = ({ color, onClick }) => { + const classes = useStyles(); + return ( +
onClick("road")}> +
+
+ ); +}; + +interface SettlementProps { + color: string; + onClick: (type: string) => void; +} + +const Settlement: React.FC = ({ color, onClick }) => { + const classes = useStyles(); + return ( +
onClick("settlement")}> +
+
+ ); +}; + +interface CityProps { + color: string; + onClick: (type: string) => void; +} + +const City: React.FC = ({ color, onClick }) => { + const classes = useStyles(); + return ( +
onClick("city")}> +
+
+ ); +}; + +interface BoardPiecesProps { + player: any; + onClick?: (type: string) => void; +} + +const BoardPieces: React.FC = ({ player, onClick }) => { + if (!player) { + return <>; + } + + const color = player.color; + + const roads: React.ReactElement[] = []; + for (let i = 0; i < player.roads; i++) { + roads.push(); + } + const settlements: React.ReactElement[] = []; + for (let i = 0; i < player.settlements; i++) { + settlements.push(); + } + const cities: React.ReactElement[] = []; + for (let i = 0; i < player.cities; i++) { + cities.push(); + } + + return ( +
+
{cities}
+
{settlements}
+
{roads}
+
+ ); +}; + +export { BoardPieces }; diff --git a/client/src/Chat.tsx b/client/src/Chat.tsx new file mode 100644 index 0000000..eecb99f --- /dev/null +++ b/client/src/Chat.tsx @@ -0,0 +1,253 @@ +import React, { useState, useEffect, useContext, useRef, useCallback, useMemo } from "react"; +import Paper from "@mui/material/Paper"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemText from "@mui/material/ListItemText"; +import Moment from "react-moment"; +import TextField from "@mui/material/TextField"; +import "moment-timezone"; +import equal from "fast-deep-equal"; + +import "./Chat.css"; +import { PlayerColor } from "./PlayerColor"; +import { Resource } from "./Resource"; +import { Dice } from "./Dice"; +import { GlobalContext } from "./GlobalContext"; + +interface ChatMessage { + message: string; + date: number; + color?: string; + normalChat?: boolean; +} + +const Chat: React.FC = () => { + const [lastTop, setLastTop] = useState(0); + const [autoScroll, setAutoScroll] = useState(true); + const [latest, setLatest] = useState(0); + const [scrollTime, setScrollTime] = useState(0); + const [chat, setChat] = useState([]); + const [startTime, setStartTime] = useState(0); + + const { ws, name } = useContext(GlobalContext); + const fields = useMemo(() => ["chat", "startTime"], []); + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`chat - game update`); + if (data.update.chat && !equal(data.update.chat, chat)) { + console.log(`chat - game update - ${data.update.chat.length} lines`); + setChat(data.update.chat); + } + if (data.update.startTime && data.update.startTime !== startTime) { + setStartTime(data.update.startTime); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + ws.removeEventListener("message", cbMessage); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + const chatKeyPress = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + if (!autoScroll) { + setAutoScroll(true); + } + + if (ws) { + ws.send(JSON.stringify({ type: "chat", message: (event.target as HTMLInputElement).value })); + (event.target as HTMLInputElement).value = ""; + } + } + }, + [ws, setAutoScroll, autoScroll] + ); + + const chatScroll = (event: React.UIEvent) => { + const chatList = event.target as HTMLUListElement, + fromBottom = Math.round(Math.abs(chatList.scrollHeight - chatList.offsetHeight - chatList.scrollTop)); + + /* If scroll is within 20 pixels of the bottom, turn on auto-scroll */ + const shouldAutoscroll = fromBottom < 20; + + if (shouldAutoscroll !== autoScroll) { + setAutoScroll(shouldAutoscroll); + } + + /* If the list should not auto scroll, then cache the current + * top of the list and record when we did this so we honor + * the auto-scroll for at least 500ms */ + if (!shouldAutoscroll) { + const target = Math.round(chatList.scrollTop); + if (target !== lastTop) { + setLastTop(target); + setScrollTime(Date.now()); + } + } + }; + + useEffect(() => { + const chatList = document.getElementById("ChatList") as HTMLUListElement, + currentTop = Math.round(chatList.scrollTop); + + if (autoScroll) { + /* Auto-scroll to the bottom of the chat window */ + const target = Math.round(chatList.scrollHeight - chatList.offsetHeight); + if (currentTop !== target) { + chatList.scrollTop = target; + } + return; + } + + /* Maintain current position in scrolled view if the user hasn't + * been scrolling in the past 0.5s */ + if (Date.now() - scrollTime > 500 && currentTop !== lastTop) { + chatList.scrollTop = lastTop; + } + }); + + const messages = chat.map((item, index) => { + let message; + /* Do not perform extra parsing on player-generated + * messages */ + if (item.normalChat) { + message =
{item.message}
; + } else { + const punctuation = item.message.match(/(\.+$)/); + let period; + if (punctuation) { + period = punctuation[1]; + } else { + period = ""; + } + const lines = item.message.split("."); + message = lines + .filter((line) => line.trim() !== "") + .map((line, index) => { + /* If the date is in the future, set it to now */ + const dice = line.match(/^(.*rolled )([1-6])(, ([1-6]))?(.*)$/); + if (dice) { + if (dice[4]) { + return ( +
+ {dice[1]} + , + + {dice[5]} + {period} +
+ ); + } else { + return ( +
+ {dice[1]} + + {dice[5]} + {period} +
+ ); + } + } + + let start = line, + message; + while (start) { + const resource = start.match(/^(.*)(([0-9]+) (wood|sheep|wheat|stone|brick),?)(.*)$/); + if (resource) { + const count = resource[3] ? parseInt(resource[3]) : 1; + message = ( + <> + + {resource[5]} + {message} + + ); + start = resource[1]; + } else { + message = ( + <> + {start} + {message} + + ); + start = ""; + } + } + return ( +
+ {message} + {period} +
+ ); + }); + } + + return ( + + {item.color && } + Date.now() ? Date.now() : item.date} interval={1000} /> + } + /> + + ); + }); + + if (chat.length && chat[chat.length - 1].date !== latest) { + setLatest(chat[chat.length - 1].date); + setAutoScroll(true); + } + + return ( + + + {messages} + + + Game duration:{" "} + + + ) + } + variant="outlined" + /> + + ); +}; + +export { Chat }; diff --git a/client/src/ChooseCard.tsx b/client/src/ChooseCard.tsx new file mode 100644 index 0000000..84f654f --- /dev/null +++ b/client/src/ChooseCard.tsx @@ -0,0 +1,165 @@ +import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from "react"; +import equal from "fast-deep-equal"; + +import Paper from "@mui/material/Paper"; +import Button from "@mui/material/Button"; +import "./ChooseCard.css"; +import { Resource } from "./Resource"; +import { GlobalContext } from "./GlobalContext"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const ChooseCard: React.FC = () => { + const { ws } = useContext(GlobalContext); + const [turn, setTurn] = useState(undefined); + const [color, setColor] = useState(undefined); + const [state, setState] = useState(undefined); + const [cards, setCards] = useState([]); + const fields = useMemo(() => ["turn", "color", "state"], []); + + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`choose-card - game-update: `, data.update); + if ("turn" in data.update && !equal(turn, data.update.turn)) { + setTurn(data.update.turn); + } + if ("color" in data.update && data.update.color !== color) { + setColor(data.update.color); + } + if ("state" in data.update && data.update.state !== state) { + setState(data.update.state); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + ws.removeEventListener("message", cbMessage); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + const selectResources = useCallback(() => { + if (!ws) return; + ws.send( + JSON.stringify({ + type: "select-resources", + cards, + }) + ); + }, [ws, cards]); + + let count = 0; + if (turn && turn.actions && turn.actions.indexOf("select-resources") !== -1) { + if (turn.active) { + if (turn.color === color) { + count = turn.active === "monopoly" ? 1 : 2; + } + } + + if (state === "volcano") { + if (!turn.select) { + count = 0; + } else if (color && color in turn.select) { + count = turn.select[color]; + } else { + count = 0; + } + } + } + + const selectCard = useCallback(() => { + const selected = document.querySelectorAll(".ChooseCard .Selected"); + if (selected.length > count) { + for (let i = 0; i < selected.length; i++) { + selected[i].classList.remove("Selected"); + } + setCards([]); + return; + } + + const tmp: string[] = []; + for (let i = 0; i < selected.length; i++) { + const type = selected[i].getAttribute("data-type"); + if (type) tmp.push(type); + } + setCards(tmp); + }, [setCards, count]); + + if (count === 0) { + return <>; + } + + const resources = ["wheat", "brick", "stone", "sheep", "wood"].map((type) => { + return ; + }); + + let title: React.ReactElement; + switch (turn.active) { + case "monopoly": + title = ( + <> + Monopoly! Tap the resource type you want everyone to give you! + + ); + break; + case "year-of-plenty": + title = ( + <> + Year of Plenty! Tap the two resources you want to receive from the bank! + + ); + break; + case "volcano": + title = ( + <> + Volcano has minerals! Tap the {count} resources you want to receive from the bank! + + ); + break; + default: + title = <>Unknown card type {turn.active}.; + break; + } + + return ( +
+ +
{title}
+
{resources}
+
+ +
+
+
+ ); +}; + +export { ChooseCard }; diff --git a/client/src/Common.js b/client/src/Common.js deleted file mode 100644 index 21a79f3..0000000 --- a/client/src/Common.js +++ /dev/null @@ -1,18 +0,0 @@ - -function debounce(fn, ms) { - let timer; - return _ => { - clearTimeout(timer) - timer = setTimeout(_ => { - timer = null - fn.apply(this, arguments) - }, ms) - }; -}; - -const base = process.env.PUBLIC_URL; - -const assetsPath = `${base}/assets`; -const gamesPath = `${base}`; - -export { base, debounce, assetsPath, gamesPath }; \ No newline at end of file diff --git a/client/src/Common.ts b/client/src/Common.ts new file mode 100644 index 0000000..af9aa19 --- /dev/null +++ b/client/src/Common.ts @@ -0,0 +1,25 @@ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function debounce void>(fn: T, ms: number): T { + let timer: any = null; + return function(...args: Parameters) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + fn.apply(this, args); + }, ms); + } as T; +}; + +// Prefer an explicit API base provided via environment variable. This allows +// the client running in a container to talk to the server by docker service +// name (e.g. http://peddlers-of-ketran:8930) while still working when run on +// the host where PUBLIC_URL may be appropriate. +const envApiBase = process.env.REACT_APP_API_BASE; +const publicBase = process.env.PUBLIC_URL || ''; + +const base = envApiBase || publicBase; +const assetsPath = `${publicBase}/assets`; +const gamesPath = `${base}`; + +export { base, debounce, assetsPath, gamesPath }; \ No newline at end of file diff --git a/client/src/Dice.tsx b/client/src/Dice.tsx new file mode 100644 index 0000000..c5a7af5 --- /dev/null +++ b/client/src/Dice.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import "./Dice.css"; +import { assetsPath } from "./Common"; + +type DiceProps = { + pips: number | string; +}; + +const Dice: React.FC = ({ pips }) => { + let name: string; + switch (pips.toString()) { + case "1": + name = "one"; + break; + case "2": + name = "two"; + break; + case "3": + name = "three"; + break; + case "4": + name = "four"; + break; + case "5": + name = "five"; + break; + default: + case "6": + name = "six"; + break; + } + return {name}; +}; + +export { Dice }; diff --git a/client/src/GameOrder.tsx b/client/src/GameOrder.tsx new file mode 100644 index 0000000..e488021 --- /dev/null +++ b/client/src/GameOrder.tsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useContext, useRef, useMemo } from "react"; +import Paper from "@mui/material/Paper"; +import Button from "@mui/material/Button"; +import equal from "fast-deep-equal"; + +import { Dice } from "./Dice"; +import { PlayerColor } from "./PlayerColor"; + +import "./GameOrder.css"; + +import { GlobalContext } from "./GlobalContext"; + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +interface PlayerItem { + name: string; + color: string; + order: number; + orderRoll: number; + orderStatus: string; +} + +const GameOrder: React.FC = () => { + const { ws } = useContext(GlobalContext); + const [players, setPlayers] = useState<{ [key: string]: any }>({}); + const [color, setColor] = useState(undefined); + const fields = useMemo(() => ["players", "color"], []); + + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`GameOrder game-update: `, data.update); + if ("players" in data.update && !equal(players, data.update.players)) { + setPlayers(data.update.players); + } + if ("color" in data.update && data.update.color !== color) { + setColor(data.update.color); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + ws.removeEventListener("message", cbMessage); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + const sendMessage = (data: any) => { + ws!.send(JSON.stringify(data)); + }; + + const rollClick = () => { + sendMessage({ type: "roll" }); + }; + + let hasRolled = true; + const playerElements: PlayerItem[] = []; + for (const key in players) { + const item = players[key], + name = item.name; + if (!name) { + continue; + } + if (!item.orderRoll) { + item.orderRoll = 0; + } + if (key === color) { + hasRolled = item.orderRoll !== 0; + } + playerElements.push({ name, color: key, ...item }); + } + + playerElements.sort((A, B) => { + if (A.order === B.order) { + if (A.orderRoll === B.orderRoll) { + return A.name.localeCompare(B.name); + } + return B.orderRoll - A.orderRoll; + } + return B.order - A.order; + }); + + const playerJSX = playerElements.map((item) => ( +
+ +
{item.name}
+ {item.orderRoll !== 0 && ( + <> + rolled . {item.orderStatus} + + )} + {item.orderRoll === 0 && <>has not rolled yet. {item.orderStatus}} +
+ )); + + return ( +
+ +
Game Order
+
{playerJSX}
+ +
+
+ ); +}; + +export { GameOrder }; diff --git a/client/src/GlobalContext.js b/client/src/GlobalContext.js deleted file mode 100644 index 479ac8a..0000000 --- a/client/src/GlobalContext.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from "react"; - -const global = { - gameId: undefined, - ws: undefined, - name: "", - chat: [] -}; - -const GlobalContext = createContext(global); - -export { GlobalContext, global }; \ No newline at end of file diff --git a/client/src/GlobalContext.ts b/client/src/GlobalContext.ts new file mode 100644 index 0000000..b5058f1 --- /dev/null +++ b/client/src/GlobalContext.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; + +export type GlobalContextType = { + gameId?: string | undefined; + ws?: WebSocket | undefined; + name?: string; + chat?: Array; +}; + +const global: GlobalContextType = { + gameId: undefined, + ws: undefined, + name: "", + chat: [] +}; + +const GlobalContext = createContext(global); + +export { GlobalContext, global }; diff --git a/client/src/Hand.tsx b/client/src/Hand.tsx new file mode 100644 index 0000000..1f6275e --- /dev/null +++ b/client/src/Hand.tsx @@ -0,0 +1,192 @@ +import React, { useContext, useState, useMemo, useRef, useEffect } from "react"; +import equal from "fast-deep-equal"; + +import { Resource } from "./Resource"; +import { Placard } from "./Placard"; +import { GlobalContext } from "./GlobalContext"; +import { assetsPath } from "./Common"; +import "./Hand.css"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface DevelopmentProps { + type: string; + card: any; + onClick: () => void; +} + +const Development: React.FC = ({ type, card, onClick }) => { + return ( +
+ ); +}; + +interface HandProps { + buildActive: boolean; + setBuildActive: (active: boolean) => void; + setCardActive: (card: any) => void; +} + +const Hand: React.FC = ({ buildActive, setBuildActive, setCardActive }) => { + const { ws } = useContext(GlobalContext); + const [priv, setPriv] = useState(undefined); + const [color, setColor] = useState(undefined); + const [turn, setTurn] = useState(undefined); + const [longestRoad, setLongestRoad] = useState(undefined); + const [largestArmy, setLargestArmy] = useState(undefined); + const [development, setDevelopment] = useState([]); + const [mostPorts, setMostPorts] = useState(undefined); + const [mostDeveloped, setMostDeveloped] = useState(undefined); + const [selected, setSelected] = useState(0); + + const fields = useMemo( + () => ["private", "turn", "color", "longestRoad", "largestArmy", "mostPorts", "mostDeveloped"], + [] + ); + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`hand - game-update: `, data.update); + if ("private" in data.update && !equal(priv, data.update.private)) { + setPriv(data.update.private); + } + if ("turn" in data.update && !equal(turn, data.update.turn)) { + setTurn(data.update.turn); + } + if ("color" in data.update && color !== data.update.color) { + setColor(data.update.color); + } + if ("longestRoad" in data.update && longestRoad !== data.update.longestRoad) { + setLongestRoad(data.update.longestRoad); + } + if ("largestArmy" in data.update && largestArmy !== data.update.largestArmy) { + setLargestArmy(data.update.largestArmy); + } + if ("mostDeveloped" in data.update && data.update.mostDeveloped !== mostDeveloped) { + setMostDeveloped(data.update.mostDeveloped); + } + if ("mostPorts" in data.update && data.update.mostPorts !== mostPorts) { + setMostPorts(data.update.mostPorts); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + ws.removeEventListener("message", cbMessage); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + useEffect(() => { + if (!priv) { + return; + } + + const cardClicked = (card: any) => { + setCardActive(card); + }; + + const stacks: { [key: string]: any[] } = {}; + priv.development.forEach((card: any) => + card.type in stacks ? stacks[card.type].push(card) : (stacks[card.type] = [card]) + ); + + const development: React.ReactElement[] = []; + for (const type in stacks) { + const cards = stacks[type] + .sort((A: any, B: any) => { + if (A.played) { + return -1; + } + if (B.played) { + return +1; + } + return B.turn - A.turn; /* Put playable cards on top */ + }) + .map((card: any) => ( + cardClicked(card)} + card={card} + key={`${type}-${card.card}`} + type={`${type}-${card.card}`} + /> + )); + development.push( +
+ {cards} +
+ ); + } + setDevelopment(development); + }, [priv, setDevelopment, setCardActive]); + + useEffect(() => { + const count = document.querySelectorAll(".Hand .CardGroup .Resource.Selected"); + if (count.length !== selected) { + setSelected(count.length); + } + }, [setSelected, selected, turn]); + + if (!priv) { + return <>; + } + + const cardSelected = () => { + const count = document.querySelectorAll(".Hand .CardGroup .Resource.Selected"); + setSelected(count.length); + }; + + return ( +
+ { +
+ {selected} cards selected +
+ } +
+ + + + + +
+
{development}
+ {mostDeveloped && mostDeveloped === color && } + {mostPorts && mostPorts === color && } + {longestRoad && longestRoad === color && } + {largestArmy && largestArmy === color && } + +
+ ); +}; + +export { Hand }; diff --git a/client/src/HouseRules.tsx b/client/src/HouseRules.tsx new file mode 100644 index 0000000..6804a19 --- /dev/null +++ b/client/src/HouseRules.tsx @@ -0,0 +1,378 @@ +import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react"; +import equal from "fast-deep-equal"; + +import Paper from "@mui/material/Paper"; +import Button from "@mui/material/Button"; +import Switch from "@mui/material/Switch"; + +import "./HouseRules.css"; + +import { GlobalContext } from "./GlobalContext"; +import { Placard } from "./Placard"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface VolcanoProps { + ws: WebSocket | null; + rules: any; + field: string; + disabled: boolean; +} + +/* Volcano based on https://www.ultraboardgames.com/catan/the-volcano.php */ +const Volcano: React.FC = ({ ws, rules, field, disabled }) => { + const init = + Math.random() > 0.5 + ? Math.floor(8 + Math.random() * 5) /* Do not include 7 */ + : Math.floor(2 + Math.random() * 5); /* Do not include 7 */ + const [number, setNumber] = useState(field in rules && "number" in rules[field] ? rules[field].number : init); + const [gold, setGold] = useState(field in rules && "gold" in rules[field] ? rules[field].gold : false); + + console.log(`house-rules - ${field} - `, rules[field]); + + useEffect(() => { + if (field in rules) { + setGold("gold" in rules[field] ? rules[field].gold : true); + setNumber("number" in rules[field] ? rules[field].number : init); + let update = false; + if (!("gold" in rules[field])) { + rules[field].gold = true; + update = true; + } + if (!("number" in rules[field])) { + rules[field].number = init; + update = true; + } + + if (update && ws) { + ws.send( + JSON.stringify({ + type: "rules", + rules: rules, + }) + ); + } + } + }, [rules, field, init, ws]); + + const toggleGold = () => { + if (!ws) return; + rules[field].gold = !gold; + rules[field].number = number; + setGold(rules[field].gold); + + ws.send( + JSON.stringify({ + type: "rules", + rules: rules, + }) + ); + }; + + const update = (delta: number) => { + if (!ws) return; + let value = number + delta; + if (value < 2 || value > 12) { + return; + } + /* Number to trigger Volcano cannot be 7 */ + if (value === 7) { + value = delta > 0 ? 8 : 6; + } + setNumber(value); + rules[field].gold = gold; + rules[field].number = value; + ws.send( + JSON.stringify({ + type: "rules", + rules: rules, + }) + ); + }; + + return ( +
+
+ The Volcano replaces the Desert. When the Volcano erupts, roll a die to determine the direction the lava will + flow. One of the six intersections on the Volcano tile will be affected. If there is a settlement on the selected + intersection, it is destroyed! +
+
+ Remove it from the board (its owner may rebuild it later). If a city is located there, it is reduced to a + settlement! Replace the city with a settlement of its owner's color. If he has no settlements remaining, the + city is destroyed instead. +
+
The presence of the Robber on the Volcano does not prevent the Volcano from erupting.
+
+ Roll {number} and the Volcano erupts! +  /  + +
+
+
+ Volcanoes have gold!: Volcano can produce resources when its number is rolled. +
+
+ toggleGold()} {...{ disabled }} /> +
+
+
+ Volcanoes tend to be rich in valuable minerals such as gold or gems. Each settlement that is adjacent to the + Volcano when it erupts may produce any one of the five resources it's owner desires. +
+
+ Each city adjacent to the Volcano may produce any two resources. This resource production is taken before the + results of the volcano eruption are resolved. Note that while the Robber can not prevent the Volcano from + erupting, he does prevent any player from producing resources from the Volcano hex if he has been placed there. +
+
+ ); +}; + +interface VictoryPointsProps { + ws: WebSocket | null; + rules: any; + field: string; +} + +const VictoryPoints: React.FC = ({ ws, rules, field }) => { + const minVP = 10; + const [points, setPoints] = useState(rules[field].points || minVP); + console.log(`house-rules - ${field} - `, rules[field]); + + if (!(field in rules)) { + rules[field] = { + points: minVP, + }; + } + + if (rules[field].points && rules[field].points !== points) { + setPoints(rules[field].points); + } + + const update = (value: number) => { + if (!ws) return; + const points = (rules[field].points || minVP) + value; + if (points < minVP) { + return; + } + if (points !== rules[field].points) { + setPoints(points); + rules[field].points = points; + ws.send( + JSON.stringify({ + type: "rules", + rules: rules, + }) + ); + } + }; + + return ( +
+ {points} points. +  /  + +
+ ); +}; + +interface HouseRulesProps { + houseRulesActive: boolean; + setHouseRulesActive: React.Dispatch>; +} + +const HouseRules: React.FC = ({ houseRulesActive }) => { + const { ws, name } = useContext(GlobalContext); + const [rules, setRules] = useState({}); + const [state, setState] = useState({}); + const [ruleElements, setRuleElements] = useState([]); + + const fields = useMemo(() => ["rules"], []); + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "game-update": + console.log(`house-rules - game-update: `, data.update); + if ("rules" in data.update && !equal(rules, data.update.rules)) { + setRules(data.update.rules); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + ws.removeEventListener("message", cbMessage); + }; + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { + return; + } + ws.send( + JSON.stringify({ + type: "get", + fields, + }) + ); + }, [ws, fields]); + + const dismissClicked = useCallback(() => { + if (!ws) return; + ws.send( + JSON.stringify({ + type: "house-rules", + active: false, + }) + ); + }, [ws]); + + const setRule = useCallback( + (event: React.ChangeEvent, key: string) => { + if (!ws) return; + const checked = event.target.checked; + console.log(`house-rules - set rule ${key} to ${checked}`); + rules[key].enabled = checked; + setRules({ ...rules }); + ws.send( + JSON.stringify({ + type: "rules", + rules: rules, + }) + ); + }, + [rules, ws] + ); + + useEffect(() => { + const ruleList = [ + { + key: "volcano", + label: "Volcano", + defaultChecked: false, + element: ( + + ), + }, + { + key: "victory-points", + label: "Victory Points", + defaultChecked: false, + element: , + }, + { + key: "tiles-start-facing-down", + label: "Tiles Start Facing Down", + defaultChecked: false, + element:
Once all players have placed their initial settlements and roads, the tiles are flipped and you discover what the resources are.
, + }, + { + key: "most-developed", + label: "Most Developed", + defaultChecked: false, + element: , + }, + { + key: "most-ports", + label: "Most Ports", + defaultChecked: false, + element: , + }, + { + key: "longest-road", + label: "Longest Road", + defaultChecked: true, + element: , + }, + { + key: "largest-army", + label: "Largest Army", + defaultChecked: true, + element: , + }, + { + key: "slowest-turn", + label: "Why you play so slowf", + defaultChecked: false, + element: , + }, + { + key: "roll-double-roll-again", + label: "Roll double, roll again", + defaultChecked: false, + element:
If you roll doubles, players get those resources and then you must roll again.
, + }, + { + key: "twelve-and-two-are-synonyms", + label: "Twelve and Two are synonyms", + defaultChecked: false, + element:
If you roll a twelve or two, resources are triggered for both.
, + }, + { + key: "robin-hood-robber", + label: "Robin Hood robber", + defaultChecked: false, + element: <>, + }, + ]; + + setRuleElements( + ruleList.map((item) => { + const defaultChecked = item.defaultChecked; + if (!(item.key in rules)) { + rules[item.key] = { + enabled: defaultChecked, + }; + } + const checked = rules[item.key].enabled; + if (checked !== state[item.key]) { + setState({ ...state, [item.key]: checked }); + } + + return ( +
+
+ setRule(e, item.key)} + {...{ disabled: !name }} + /> + +
+ {checked && item.element} +
+ ); + }) + ); + }, [rules, setRules, setRuleElements, state, ws, setRule, name]); + + if (!houseRulesActive) { + return <>; + } + + return ( +
+ +
House Rules
+
{ruleElements}
+ +
+
+ ); +}; + +export { HouseRules }; diff --git a/client/src/MediaControl.tsx b/client/src/MediaControl.tsx new file mode 100644 index 0000000..9394d36 --- /dev/null +++ b/client/src/MediaControl.tsx @@ -0,0 +1,455 @@ +import React, { useState, useEffect, useRef, useCallback, useContext } from "react"; +import Moveable from "react-moveable"; + +import "./MediaControl.css"; + +import VolumeOff from "@mui/icons-material/VolumeOff"; +import VolumeUp from "@mui/icons-material/VolumeUp"; +import MicOff from "@mui/icons-material/MicOff"; +import Mic from "@mui/icons-material/Mic"; +import VideocamOff from "@mui/icons-material/VideocamOff"; +import Videocam from "@mui/icons-material/Videocam"; + +import { GlobalContext } from "./GlobalContext"; +const debug = true; + +/* eslint-disable */ + +interface VideoProps { + srcObject: MediaStream | undefined; + local?: boolean; + [key: string]: any; +} + +/* Proxy object so we can pass in srcObject to