Full refactor to TypeScript
This commit is contained in:
parent
b553cdc656
commit
b87d400bf7
@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets": [ "@babel/env", "@babel/preset-react" ],
|
||||
"plugins": [ "@babel/plugin-proposal-class-properties" ]
|
||||
}
|
52
client/.eslintrc.js
Normal file
52
client/.eslintrc.js
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
];
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
337
client/src/Actions.tsx
Normal file
337
client/src/Actions.tsx
Normal file
@ -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<ActionsProps> = ({
|
||||
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<string>("lobby");
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [priv, setPriv] = useState<PrivateData | undefined>(undefined);
|
||||
const [turn, setTurn] = useState<TurnData>({});
|
||||
const [edit, setEdit] = useState<string | undefined>(name);
|
||||
const [active, setActive] = useState<number>(0);
|
||||
const [players, setPlayers] = useState<Record<string, PlayerData>>({});
|
||||
const [alive, setAlive] = useState<number>(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<string, unknown>) => {
|
||||
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<string, number> = { 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 <Paper className="Actions" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper className="Actions">
|
||||
{edit === "" && <PlayerName name={name} setName={setName} />}
|
||||
<div className="Buttons">
|
||||
{name && alive === 1 && <Button onClick={resetGame}>Reset game</Button>}
|
||||
{name && inLobby && (
|
||||
<>
|
||||
<Button disabled={color && active >= 2 ? false : true} onClick={startClick}>
|
||||
Start game
|
||||
</Button>
|
||||
<Button disabled={color ? false : true} onClick={newTableClick}>
|
||||
New table
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{name && !color && (
|
||||
<Button disabled={color ? true : false} onClick={changeNameClick}>
|
||||
Change name
|
||||
</Button>
|
||||
)}
|
||||
{name && color && inLobby && (
|
||||
<Button disabled={color ? false : true} onClick={houseRulesClick}>
|
||||
House Rules
|
||||
</Button>
|
||||
)}
|
||||
{name && !inLobby && (
|
||||
<>
|
||||
<Button disabled={disableRoll} onClick={rollClick}>
|
||||
Roll Dice
|
||||
</Button>
|
||||
<Button
|
||||
disabled={volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled || !haveResources}
|
||||
onClick={tradeClick}
|
||||
>
|
||||
Trade
|
||||
</Button>
|
||||
<Button
|
||||
disabled={volcanoActive || placeRoad || robberActions || !isTurn || !hasRolled || !haveResources}
|
||||
onClick={buildClicked}
|
||||
>
|
||||
Build
|
||||
</Button>
|
||||
<Button disabled={!(turn && turn.roll === 7 && priv && priv.mustDiscard > 0)} onClick={discardClick}>
|
||||
Discard
|
||||
</Button>
|
||||
{name && color && (
|
||||
<Button disabled={color ? false : true} onClick={houseRulesClick}>
|
||||
House Rules
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={disableDone} onClick={passClick}>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { Actions };
|
295
client/src/Activities.tsx
Normal file
295
client/src/Activities.tsx
Normal file
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
type ActivityProps = {
|
||||
keep: boolean;
|
||||
activity: ActivityData;
|
||||
};
|
||||
|
||||
const Activity: React.FC<ActivityProps> = ({ 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]}
|
||||
<b>{sum}</b>: <Dice pips={dice[2]} />, <Dice pips={dice[4]} />
|
||||
{dice[5]}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<>
|
||||
{dice[1]}
|
||||
<Dice pips={dice[2]} />
|
||||
{dice[5]}
|
||||
</>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
message = activity.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{display && (
|
||||
<div className={`Activity ${animation}`}>
|
||||
<PlayerColor color={activity.color} />
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Activities: React.FC = () => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [activities, setActivities] = useState<ActivityData[]>([]);
|
||||
const [turn, setTurn] = useState<TurnData | undefined>(undefined);
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [players, setPlayers] = useState<Record<string, PlayerData>>({});
|
||||
const [timestamp, setTimestamp] = useState<number>(0);
|
||||
const [state, setState] = useState<string>("");
|
||||
|
||||
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<string, unknown> };
|
||||
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<string, PlayerData>);
|
||||
}
|
||||
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(
|
||||
<div key={name} className="Requirement">
|
||||
{name} must discard <b>{player.mustDiscard}</b> cards.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list: React.ReactElement[] = activities
|
||||
.filter((activity, index) => activities.length - 1 === index || timestamp - activity.date < 11000)
|
||||
.map((activity, index, filtered) => {
|
||||
return <Activity keep={filtered.length - 1 === index} key={activity.date} activity={activity} />;
|
||||
});
|
||||
|
||||
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) => (
|
||||
<div className="Who" key={index}>
|
||||
<PlayerColor color={player.color} />
|
||||
{player.name}
|
||||
{index !== selecting.length - 1 ? ", " : ""}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
if (isTurn) {
|
||||
who = "You";
|
||||
} else {
|
||||
if (!turn || !turn.name) {
|
||||
who = "Everyone";
|
||||
} else {
|
||||
who = (
|
||||
<>
|
||||
<PlayerColor color={turn.color} />
|
||||
{turn.name}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Activities">
|
||||
{list}
|
||||
{normalPlay && !mustDiscard && mustPlaceRobber && <div className="Requirement">{who} must move the Robber.</div>}
|
||||
|
||||
{placement && (
|
||||
<div className="Requirement">
|
||||
{who} must place a {placeRoad ? "road" : "settlement"}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mustStealResource && <div className="Requirement">{who} must select a player to steal from.</div>}
|
||||
|
||||
{rollForOrder && <div className="Requirement">{who} must roll for game order.</div>}
|
||||
|
||||
{rollForVolcano && <div className="Requirement">{who} must roll for Volcano devastation!</div>}
|
||||
|
||||
{selectResources && <div className="Requirement">{who} must select resources!</div>}
|
||||
|
||||
{normalPlay && mustDiscard && <> {discarders} </>}
|
||||
|
||||
{!isTurn && normalPlay && turn && (
|
||||
<div>
|
||||
It is <PlayerColor color={turn.color} /> {turn.name}
|
||||
{"'"}s turn.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTurn && normalPlay && turn && (
|
||||
<div className="Go">
|
||||
<PlayerColor color={turn.color} /> It is your turn.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Activities };
|
579
client/src/App.tsx
Executable file
579
client/src/App.tsx
Executable file
@ -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<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;
|
91
client/src/Bird.tsx
Normal file
91
client/src/Bird.tsx
Normal file
@ -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<number | null>(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<number | undefined>();
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`Bird`}
|
||||
style={{
|
||||
top: `${50 + 100 * radius * Math.sin((2 * Math.PI * (180 + angle)) / 360)}%`,
|
||||
left: `${50 + 100 * radius * Math.cos((2 * Math.PI * (180 + angle)) / 360)}%`,
|
||||
width: `${size * 64}px`,
|
||||
height: `${size * 64}px`,
|
||||
backgroundImage: `url(${assetsPath}/gfx/birds.png)`,
|
||||
backgroundPositionX: `${(100 * direction) / 11}%`,
|
||||
backgroundPositionY: `${(100 * cell) / 3}%`,
|
||||
transformOrigin: `50% 50%`,
|
||||
transform: `translate(-50%, -50%) rotate(${angle % 30}deg)`,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Flock: React.FC<{ count: number; style?: React.CSSProperties }> = ({ count, style }) => {
|
||||
const [birds, setBirds] = useState<React.ReactNode[]>([]);
|
||||
useEffect(() => {
|
||||
const tmp: React.ReactNode[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const scalar = Math.random();
|
||||
tmp.push(<Bird speed={2000 + 250 * scalar} size={0.2 + scalar * 0.25} radius={0.1 + scalar * 0.35} key={i} />);
|
||||
}
|
||||
setBirds(tmp);
|
||||
}, [count]);
|
||||
return (
|
||||
<div className="Flock" style={style}>
|
||||
{birds}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Bird, Flock };
|
1018
client/src/Board.tsx
Normal file
1018
client/src/Board.tsx
Normal file
File diff suppressed because it is too large
Load Diff
84
client/src/BoardPieces.tsx
Normal file
84
client/src/BoardPieces.tsx
Normal file
@ -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<RoadProps> = ({ color, onClick }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className="Road" onClick={() => onClick("road")}>
|
||||
<div className={["Shape", classes[color]].join(" ")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettlementProps {
|
||||
color: string;
|
||||
onClick: (type: string) => void;
|
||||
}
|
||||
|
||||
const Settlement: React.FC<SettlementProps> = ({ color, onClick }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className="Settlement" onClick={() => onClick("settlement")}>
|
||||
<div className={["Shape", classes[color]].join(" ")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CityProps {
|
||||
color: string;
|
||||
onClick: (type: string) => void;
|
||||
}
|
||||
|
||||
const City: React.FC<CityProps> = ({ color, onClick }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className="City" onClick={() => onClick("city")}>
|
||||
<div className={["Shape", classes[color]].join(" ")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoardPiecesProps {
|
||||
player: any;
|
||||
onClick?: (type: string) => void;
|
||||
}
|
||||
|
||||
const BoardPieces: React.FC<BoardPiecesProps> = ({ player, onClick }) => {
|
||||
if (!player) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const color = player.color;
|
||||
|
||||
const roads: React.ReactElement[] = [];
|
||||
for (let i = 0; i < player.roads; i++) {
|
||||
roads.push(<Road onClick={onClick!} key={`road-${i}`} color={color} />);
|
||||
}
|
||||
const settlements: React.ReactElement[] = [];
|
||||
for (let i = 0; i < player.settlements; i++) {
|
||||
settlements.push(<Settlement onClick={onClick!} key={`settlement-${i}`} color={color} />);
|
||||
}
|
||||
const cities: React.ReactElement[] = [];
|
||||
for (let i = 0; i < player.cities; i++) {
|
||||
cities.push(<City onClick={onClick!} key={`city-${i}`} color={color} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="BoardPieces" data-active={onClick !== undefined}>
|
||||
<div className="Cities">{cities}</div>
|
||||
<div className="Settlements">{settlements}</div>
|
||||
<div className="Roads">{roads}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BoardPieces };
|
253
client/src/Chat.tsx
Normal file
253
client/src/Chat.tsx
Normal file
@ -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<number>(0);
|
||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
||||
const [latest, setLatest] = useState<number>(0);
|
||||
const [scrollTime, setScrollTime] = useState<number>(0);
|
||||
const [chat, setChat] = useState<ChatMessage[]>([]);
|
||||
const [startTime, setStartTime] = useState<number>(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<HTMLInputElement>) => {
|
||||
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<HTMLUListElement>) => {
|
||||
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 = <div key={`line-${index}`}>{item.message}</div>;
|
||||
} 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 (
|
||||
<div key={`line-${index}`}>
|
||||
{dice[1]}
|
||||
<Dice pips={dice[2]} />,
|
||||
<Dice pips={dice[4]} />
|
||||
{dice[5]}
|
||||
{period}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={`line-${index}`}>
|
||||
{dice[1]}
|
||||
<Dice pips={dice[2]} />
|
||||
{dice[5]}
|
||||
{period}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 label={true} count={count} type={resource[4]} disabled />
|
||||
{resource[5]}
|
||||
{message}
|
||||
</>
|
||||
);
|
||||
start = resource[1];
|
||||
} else {
|
||||
message = (
|
||||
<>
|
||||
{start}
|
||||
{message}
|
||||
</>
|
||||
);
|
||||
start = "";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={`line-${index}`}>
|
||||
{message}
|
||||
{period}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem key={`msg-${item.date}-${index}`} className={item.color ? "" : "System"}>
|
||||
{item.color && <PlayerColor color={item.color} />}
|
||||
<ListItemText
|
||||
primary={message}
|
||||
secondary={
|
||||
item.color && <Moment fromNow trim date={item.date > Date.now() ? Date.now() : item.date} interval={1000} />
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
});
|
||||
|
||||
if (chat.length && chat[chat.length - 1].date !== latest) {
|
||||
setLatest(chat[chat.length - 1].date);
|
||||
setAutoScroll(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="Chat">
|
||||
<List className="ChatList" id="ChatList" onScroll={chatScroll}>
|
||||
{messages}
|
||||
</List>
|
||||
<TextField
|
||||
className="ChatInput"
|
||||
disabled={!name}
|
||||
onKeyPress={chatKeyPress}
|
||||
label={
|
||||
startTime !== 0 && (
|
||||
<>
|
||||
Game duration:{" "}
|
||||
<Moment tz={"Etc/GMT"} format="h:mm:ss" trim durationFromNow interval={1000} date={startTime} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { Chat };
|
165
client/src/ChooseCard.tsx
Normal file
165
client/src/ChooseCard.tsx
Normal file
@ -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<any>(undefined);
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [state, setState] = useState<string | undefined>(undefined);
|
||||
const [cards, setCards] = useState<string[]>([]);
|
||||
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 <Resource key={type} type={type} count={count} onClick={selectCard} />;
|
||||
});
|
||||
|
||||
let title: React.ReactElement;
|
||||
switch (turn.active) {
|
||||
case "monopoly":
|
||||
title = (
|
||||
<>
|
||||
<b>Monopoly</b>! Tap the resource type you want everyone to give you!
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "year-of-plenty":
|
||||
title = (
|
||||
<>
|
||||
<b>Year of Plenty</b>! Tap the two resources you want to receive from the bank!
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "volcano":
|
||||
title = (
|
||||
<>
|
||||
<b>Volcano has minerals</b>! Tap the {count} resources you want to receive from the bank!
|
||||
</>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
title = <>Unknown card type {turn.active}.</>;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ChooseCard">
|
||||
<Paper>
|
||||
<div className="Title">{title}</div>
|
||||
<div style={{ display: "flex", flexDirection: "row", justifyContent: "center" }}>{resources}</div>
|
||||
<div className="Actions">
|
||||
<Button disabled={cards.length !== count} onClick={selectResources}>
|
||||
submit
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChooseCard };
|
@ -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 };
|
25
client/src/Common.ts
Normal file
25
client/src/Common.ts
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: any = null;
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(this, args);
|
||||
}, ms);
|
||||
} as T;
|
||||
};
|
||||
|
||||
// Prefer an explicit API base provided via environment variable. 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 };
|
35
client/src/Dice.tsx
Normal file
35
client/src/Dice.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import "./Dice.css";
|
||||
import { assetsPath } from "./Common";
|
||||
|
||||
type DiceProps = {
|
||||
pips: number | string;
|
||||
};
|
||||
|
||||
const Dice: React.FC<DiceProps> = ({ 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 <img alt={name} className="Dice" src={`${assetsPath}/dice-six-faces-${name}.svg`} />;
|
||||
};
|
||||
|
||||
export { Dice };
|
132
client/src/GameOrder.tsx
Normal file
132
client/src/GameOrder.tsx
Normal file
@ -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<string | undefined>(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) => (
|
||||
<div className="GameOrderPlayer" key={`player-${item.color}`}>
|
||||
<PlayerColor color={item.color} />
|
||||
<div>{item.name}</div>
|
||||
{item.orderRoll !== 0 && (
|
||||
<>
|
||||
rolled <Dice pips={item.orderRoll} />. {item.orderStatus}
|
||||
</>
|
||||
)}
|
||||
{item.orderRoll === 0 && <>has not rolled yet. {item.orderStatus}</>}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="GameOrder">
|
||||
<Paper>
|
||||
<div className="Title">Game Order</div>
|
||||
<div className="PlayerList">{playerJSX}</div>
|
||||
<Button disabled={hasRolled} onClick={rollClick}>
|
||||
Roll Dice
|
||||
</Button>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { GameOrder };
|
@ -1,12 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
const global = {
|
||||
gameId: undefined,
|
||||
ws: undefined,
|
||||
name: "",
|
||||
chat: []
|
||||
};
|
||||
|
||||
const GlobalContext = createContext(global);
|
||||
|
||||
export { GlobalContext, global };
|
19
client/src/GlobalContext.ts
Normal file
19
client/src/GlobalContext.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type GlobalContextType = {
|
||||
gameId?: string | undefined;
|
||||
ws?: WebSocket | undefined;
|
||||
name?: string;
|
||||
chat?: Array<unknown>;
|
||||
};
|
||||
|
||||
const global: GlobalContextType = {
|
||||
gameId: undefined,
|
||||
ws: undefined,
|
||||
name: "",
|
||||
chat: []
|
||||
};
|
||||
|
||||
const GlobalContext = createContext<GlobalContextType>(global);
|
||||
|
||||
export { GlobalContext, global };
|
192
client/src/Hand.tsx
Normal file
192
client/src/Hand.tsx
Normal file
@ -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<DevelopmentProps> = ({ type, card, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className={`Development ${card.played ? "Selected" : ""}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
backgroundImage: `url(${assetsPath}/gfx/card-${type}.png)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface HandProps {
|
||||
buildActive: boolean;
|
||||
setBuildActive: (active: boolean) => void;
|
||||
setCardActive: (card: any) => void;
|
||||
}
|
||||
|
||||
const Hand: React.FC<HandProps> = ({ buildActive, setBuildActive, setCardActive }) => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [priv, setPriv] = useState<any>(undefined);
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [turn, setTurn] = useState<any>(undefined);
|
||||
const [longestRoad, setLongestRoad] = useState<string | undefined>(undefined);
|
||||
const [largestArmy, setLargestArmy] = useState<string | undefined>(undefined);
|
||||
const [development, setDevelopment] = useState<React.ReactElement[]>([]);
|
||||
const [mostPorts, setMostPorts] = useState<string | undefined>(undefined);
|
||||
const [mostDeveloped, setMostDeveloped] = useState<string | undefined>(undefined);
|
||||
const [selected, setSelected] = useState<number>(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) => (
|
||||
<Development
|
||||
onClick={() => cardClicked(card)}
|
||||
card={card}
|
||||
key={`${type}-${card.card}`}
|
||||
type={`${type}-${card.card}`}
|
||||
/>
|
||||
));
|
||||
development.push(
|
||||
<div key={type} className="Stack">
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="Hand">
|
||||
{
|
||||
<div className="CardsSelected" style={selected === 0 ? { display: "none" } : {}}>
|
||||
{selected} cards selected
|
||||
</div>
|
||||
}
|
||||
<div className="CardGroup">
|
||||
<Resource type="wood" count={priv.wood} onClick={cardSelected} />
|
||||
<Resource type="wheat" count={priv.wheat} onClick={cardSelected} />
|
||||
<Resource type="stone" count={priv.stone} onClick={cardSelected} />
|
||||
<Resource type="brick" count={priv.brick} onClick={cardSelected} />
|
||||
<Resource type="sheep" count={priv.sheep} onClick={cardSelected} />
|
||||
</div>
|
||||
<div className="CardGroup">{development}</div>
|
||||
{mostDeveloped && mostDeveloped === color && <Placard type="most-developed" />}
|
||||
{mostPorts && mostPorts === color && <Placard type="port-of-call" />}
|
||||
{longestRoad && longestRoad === color && <Placard type="longest-road" />}
|
||||
{largestArmy && largestArmy === color && <Placard type="largest-army" />}
|
||||
<Placard className="BuildCard" {...{ buildActive, setBuildActive }} disabled={!turn || !turn.roll} type={color} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Hand };
|
378
client/src/HouseRules.tsx
Normal file
378
client/src/HouseRules.tsx
Normal file
@ -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<VolcanoProps> = ({ 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<number>(field in rules && "number" in rules[field] ? rules[field].number : init);
|
||||
const [gold, setGold] = useState<boolean>(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 (
|
||||
<div className="Volcano">
|
||||
<div>
|
||||
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!
|
||||
</div>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
<div>The presence of the Robber on the Volcano does not prevent the Volcano from erupting.</div>
|
||||
<div>
|
||||
Roll {number} and the Volcano erupts!
|
||||
<button onClick={() => update(+1)}>up</button> /
|
||||
<button onClick={() => update(-1)}> down</button>
|
||||
</div>
|
||||
<div className="HouseSelector">
|
||||
<div>
|
||||
<b>Volcanoes have gold!</b>: Volcano can produce resources when its number is rolled.
|
||||
</div>
|
||||
<div>
|
||||
<Switch size={"small"} className="RuleSwitch" checked={gold} onChange={() => toggleGold()} {...{ disabled }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface VictoryPointsProps {
|
||||
ws: WebSocket | null;
|
||||
rules: any;
|
||||
field: string;
|
||||
}
|
||||
|
||||
const VictoryPoints: React.FC<VictoryPointsProps> = ({ ws, rules, field }) => {
|
||||
const minVP = 10;
|
||||
const [points, setPoints] = useState<number>(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 (
|
||||
<div className="VictoryPoints">
|
||||
{points} points.
|
||||
<button onClick={() => update(+1)}>up</button> /
|
||||
<button onClick={() => update(-1)}> down</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HouseRulesProps {
|
||||
houseRulesActive: boolean;
|
||||
setHouseRulesActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive }) => {
|
||||
const { ws, name } = useContext(GlobalContext);
|
||||
const [rules, setRules] = useState<any>({});
|
||||
const [state, setState] = useState<any>({});
|
||||
const [ruleElements, setRuleElements] = useState<React.ReactElement[]>([]);
|
||||
|
||||
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<HTMLInputElement>, 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: (
|
||||
<Volcano ws={ws} rules={rules} field={"volcano"} disabled={!state["volcano"] || !state["volcano"].enabled} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "victory-points",
|
||||
label: "Victory Points",
|
||||
defaultChecked: false,
|
||||
element: <VictoryPoints ws={ws} rules={rules} field={"victory-points"} />,
|
||||
},
|
||||
{
|
||||
key: "tiles-start-facing-down",
|
||||
label: "Tiles Start Facing Down",
|
||||
defaultChecked: false,
|
||||
element: <div>Once all players have placed their initial settlements and roads, the tiles are flipped and you discover what the resources are.</div>,
|
||||
},
|
||||
{
|
||||
key: "most-developed",
|
||||
label: "Most Developed",
|
||||
defaultChecked: false,
|
||||
element: <Placard type="most-developed" />,
|
||||
},
|
||||
{
|
||||
key: "most-ports",
|
||||
label: "Most Ports",
|
||||
defaultChecked: false,
|
||||
element: <Placard type="port-of-call" />,
|
||||
},
|
||||
{
|
||||
key: "longest-road",
|
||||
label: "Longest Road",
|
||||
defaultChecked: true,
|
||||
element: <Placard type="longest-road" />,
|
||||
},
|
||||
{
|
||||
key: "largest-army",
|
||||
label: "Largest Army",
|
||||
defaultChecked: true,
|
||||
element: <Placard type="largest-army" />,
|
||||
},
|
||||
{
|
||||
key: "slowest-turn",
|
||||
label: "Why you play so slowf",
|
||||
defaultChecked: false,
|
||||
element: <Placard type="longest-turn" />,
|
||||
},
|
||||
{
|
||||
key: "roll-double-roll-again",
|
||||
label: "Roll double, roll again",
|
||||
defaultChecked: false,
|
||||
element: <div>If you roll doubles, players get those resources and then you must roll again.</div>,
|
||||
},
|
||||
{
|
||||
key: "twelve-and-two-are-synonyms",
|
||||
label: "Twelve and Two are synonyms",
|
||||
defaultChecked: false,
|
||||
element: <div>If you roll a twelve or two, resources are triggered for both.</div>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div key={item.key} className="HouseSelector">
|
||||
<div>
|
||||
<Switch
|
||||
size={"small"}
|
||||
className="RuleSwitch"
|
||||
checked={checked}
|
||||
id={item.key}
|
||||
onChange={(e) => setRule(e, item.key)}
|
||||
{...{ disabled: !name }}
|
||||
/>
|
||||
<label htmlFor={item.key}>{item.label}</label>
|
||||
</div>
|
||||
{checked && item.element}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
);
|
||||
}, [rules, setRules, setRuleElements, state, ws, setRule, name]);
|
||||
|
||||
if (!houseRulesActive) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="HouseRules">
|
||||
<Paper>
|
||||
<div className="Title">House Rules</div>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>{ruleElements}</div>
|
||||
<Button onClick={dismissClicked}>Close</Button>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { HouseRules };
|
455
client/src/MediaControl.tsx
Normal file
455
client/src/MediaControl.tsx
Normal file
@ -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 <audio> */
|
||||
const Video: React.FC<VideoProps> = ({ srcObject, local, ...props }) => {
|
||||
const refVideo = useRef<HTMLVideoElement>(null);
|
||||
useEffect(() => {
|
||||
if (!refVideo.current) {
|
||||
return;
|
||||
}
|
||||
const ref = refVideo.current;
|
||||
if (debug) console.log("media-control - video <video> bind");
|
||||
ref.srcObject = srcObject;
|
||||
if (local) {
|
||||
ref.muted = true;
|
||||
}
|
||||
return () => {
|
||||
if (debug) console.log("media-control - <video> unbind");
|
||||
if (ref) {
|
||||
ref.srcObject = undefined;
|
||||
}
|
||||
};
|
||||
}, [srcObject, local]);
|
||||
return <video ref={refVideo} {...props} />;
|
||||
};
|
||||
|
||||
interface MediaAgentProps {
|
||||
setPeers: (peers: any) => void;
|
||||
}
|
||||
|
||||
const MediaAgent: React.FC<MediaAgentProps> = ({ setPeers }) => {
|
||||
const { name, ws } = useContext(GlobalContext);
|
||||
const [peers] = useState<any>({});
|
||||
const [stream, setStream] = useState<MediaStream | undefined>(undefined);
|
||||
|
||||
const onTrack = useCallback(
|
||||
(event: any) => {
|
||||
const connection = event.target;
|
||||
console.log("media-agent - ontrack", event);
|
||||
for (let peer in peers) {
|
||||
if (peers[peer].connection === connection) {
|
||||
console.log(`media-agent - ontrack - remote ${peer} stream assigned.`);
|
||||
/* Revive the stream for this peer */
|
||||
const obj = {
|
||||
...peers[peer],
|
||||
dead: false,
|
||||
attributes: {
|
||||
...peers[peer].attributes,
|
||||
srcObject: connection.streams[0],
|
||||
},
|
||||
};
|
||||
peers[peer] = obj;
|
||||
setPeers(Object.assign({}, peers));
|
||||
}
|
||||
}
|
||||
},
|
||||
[peers, setPeers]
|
||||
);
|
||||
const refOnTrack = useRef(onTrack);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(data: any) => {
|
||||
if (ws) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
},
|
||||
[ws]
|
||||
);
|
||||
|
||||
const onWsMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
const addPeer = (config: any) => {
|
||||
console.log("media-agent - Signaling server said to add peer:", config);
|
||||
|
||||
if (!stream) {
|
||||
console.log(`media-agent - No local media stream`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peer_id = config.peer_id;
|
||||
if (peer_id in peers) {
|
||||
if (!peers[peer_id].dead) {
|
||||
/* This is normal when peers are added by other connecting
|
||||
* peers through the signaling server */
|
||||
console.log(`media-agent - addPeer - ${peer_id} already in peers`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* Even if reviving, allocate a new Object so <MediaControl> will
|
||||
* have its peer state change and trigger an update from
|
||||
* <PlayerList> */
|
||||
const peer: any = {
|
||||
name: peer_id,
|
||||
hasAudio: config.hasAudio,
|
||||
hasVideo: config.hasVideo,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
if (peer_id in peers) {
|
||||
peer.muted = peers[peer_id].muted;
|
||||
peer.videoOn = peers[peer_id].videoOn;
|
||||
console.log(`media-agent - addPeer - reviving dead peer ${peer_id}`, peer);
|
||||
} else {
|
||||
peer.muted = false;
|
||||
peer.videoOn = true;
|
||||
}
|
||||
peers[peer_id] = peer;
|
||||
|
||||
console.log(`media-agent - addPeer - remote`, peers);
|
||||
setPeers(Object.assign({}, peers));
|
||||
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: "turns:ketrenos.com:5349",
|
||||
username: "ketra",
|
||||
credential: "ketran",
|
||||
},
|
||||
/*
|
||||
{
|
||||
urls: "turn:numb.viagenie.ca",
|
||||
username: "james_viagenie@ketrenos.com",
|
||||
credential: "1!viagenie"
|
||||
}
|
||||
*/
|
||||
],
|
||||
});
|
||||
peer.connection = connection;
|
||||
|
||||
connection.addEventListener("connectionstatechange", (event) => {
|
||||
console.log(`media-agent - connectionstatechange - `, connection.connectionState, event);
|
||||
});
|
||||
|
||||
connection.addEventListener("negotiationneeded", (event) => {
|
||||
console.log(`media-agent - negotiationneeded - `, connection.connectionState, event);
|
||||
});
|
||||
|
||||
connection.addEventListener("icecandidateerror", (event) => {
|
||||
if (event.errorCode === 701) {
|
||||
if (connection.iceGatheringState === "gathering") {
|
||||
console.log(`media-agent - Unable to reach host: ${event.url}`);
|
||||
} else {
|
||||
console.error(`media-agent - icecandidateerror - `, event.errorCode, event.url, event.errorText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (!event.candidate) {
|
||||
console.log(`media-agent - icecanditate - gathering is complete: ${connection.connectionState}`);
|
||||
return;
|
||||
}
|
||||
/* If a srflx candidate was found, notify that the STUN server works! */
|
||||
if (event.candidate.type === "srflx") {
|
||||
console.log("media-agent - The STUN server is reachable!");
|
||||
console.log(`media-agent - Your Public IP Address is: ${event.candidate.address}`);
|
||||
}
|
||||
|
||||
/* If a relay candidate was found, notify that the TURN server works! */
|
||||
if (event.candidate.type === "relay") {
|
||||
console.log("media-agent - The TURN server is reachable !");
|
||||
}
|
||||
|
||||
console.log(`media-agent - onicecandidate - `, event.candidate);
|
||||
|
||||
sendMessage({
|
||||
type: "relayICECandidate",
|
||||
config: {
|
||||
peer_id,
|
||||
candidate: event.candidate,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
connection.ontrack = (e) => refOnTrack.current(e);
|
||||
|
||||
/* Add our local stream */
|
||||
stream.getTracks().forEach((track) => connection.addTrack(track, stream));
|
||||
|
||||
/* Only one side of the peer connection should create the
|
||||
* offer, the signaling server picks one to be the offerer.
|
||||
* The other user will get a 'sessionDescription' event and will
|
||||
* create an offer, then send back an answer 'sessionDescription'
|
||||
* to us
|
||||
*/
|
||||
if (config.should_create_offer) {
|
||||
if (debug) console.log(`media-agent - Creating RTC offer to ` + `${peer_id}`);
|
||||
return connection
|
||||
.createOffer()
|
||||
.then((local_description) => {
|
||||
if (debug) console.log(`media-agent - Local offer ` + `description is: `, local_description);
|
||||
return connection
|
||||
.setLocalDescription(local_description)
|
||||
.then(() => {
|
||||
sendMessage({
|
||||
type: "relaySessionDescription",
|
||||
config: {
|
||||
peer_id,
|
||||
session_description: local_description,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`media-agent - Error creating offer: `, error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`media-agent - Error creating offer: `, error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removePeer = (config: any) => {
|
||||
console.log("media-agent - Signaling server said to remove peer:", config);
|
||||
const peer_id = config.peer_id;
|
||||
if (peer_id in peers) {
|
||||
peers[peer_id].dead = true;
|
||||
setPeers(Object.assign({}, peers));
|
||||
}
|
||||
};
|
||||
|
||||
const sessionDescription = (config: any) => {
|
||||
console.log("media-agent - Remote description received: ", config);
|
||||
const peer_id = config.peer_id;
|
||||
const peer = peers[peer_id];
|
||||
const remote_description = config.session_description;
|
||||
|
||||
if (debug) console.log(`media-agent - Remote description ` + `is: `, remote_description);
|
||||
|
||||
return peer.connection
|
||||
.setRemoteDescription(remote_description)
|
||||
.then(() => {
|
||||
if (remote_description.type === "offer") {
|
||||
if (debug) console.log(`media-agent - Creating answer to ` + `${peer_id}`);
|
||||
return peer.connection
|
||||
.createAnswer()
|
||||
.then((local_description) => {
|
||||
if (debug) console.log(`media-agent - Local answer ` + `description is: `, local_description);
|
||||
return peer.connection
|
||||
.setLocalDescription(local_description)
|
||||
.then(() => {
|
||||
sendMessage({
|
||||
type: "relaySessionDescription",
|
||||
config: {
|
||||
peer_id,
|
||||
session_description: local_description,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`media-agent - Error creating answer: `, error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`media-agent - Error creating answer: `, error);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`media-agent - Error setting remote description: `, error);
|
||||
});
|
||||
};
|
||||
|
||||
const iceCandidate = (config: any) => {
|
||||
const peer = peers[config.peer_id];
|
||||
if (peer) {
|
||||
peer.connection.addIceCandidate(new RTCIceCandidate(config.candidate)).catch((error) => {
|
||||
console.error(`media-agent - Error adding ICE candidate: `, error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "addPeer":
|
||||
addPeer(data.config);
|
||||
break;
|
||||
case "removePeer":
|
||||
removePeer(data.config);
|
||||
break;
|
||||
case "sessionDescription":
|
||||
sessionDescription(data.config);
|
||||
break;
|
||||
case "iceCandidate":
|
||||
iceCandidate(data.config);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[peers, setPeers, stream, sendMessage, refOnTrack]
|
||||
);
|
||||
|
||||
const refOnWsMessage = useRef(onWsMessage);
|
||||
useEffect(() => {
|
||||
refOnWsMessage.current = onWsMessage;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
const cbMessage = (e: MessageEvent) => refOnWsMessage.current(e);
|
||||
ws.addEventListener("message", cbMessage);
|
||||
return () => {
|
||||
ws.removeEventListener("message", cbMessage);
|
||||
};
|
||||
}, [ws, refOnWsMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: true,
|
||||
video: true,
|
||||
})
|
||||
.then((mediaStream) => {
|
||||
console.log("media-agent - Local media stream obtained");
|
||||
setStream(mediaStream);
|
||||
sendMessage({
|
||||
type: "join",
|
||||
config: {
|
||||
name,
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("media-agent - Error accessing media devices.", error);
|
||||
});
|
||||
}, [name, sendMessage]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
interface MediaControlProps {
|
||||
isSelf: boolean;
|
||||
peer: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className }) => {
|
||||
const [media, setMedia] = useState<any>(undefined);
|
||||
const [muted, setMuted] = useState<boolean | undefined>(undefined);
|
||||
const [videoOn, setVideoOn] = useState<boolean | undefined>(undefined);
|
||||
const [target, setTarget] = useState<any>();
|
||||
const [frame, setFrame] = useState<any>({
|
||||
translate: [0, 0],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (peer && peer.name) {
|
||||
setTarget(document.querySelector(`.MediaControl[data-peer="${peer.name}"]`));
|
||||
}
|
||||
}, [setTarget, peer]);
|
||||
|
||||
/* local state is used to trigger re-renders, and the global
|
||||
* state is kept up to date in the peers object so re-assignment
|
||||
* of sessions doesn't kill the peer or change the mute/video states */
|
||||
useEffect(() => {
|
||||
if (!peer) {
|
||||
setMedia(undefined);
|
||||
return;
|
||||
}
|
||||
setMuted(peer.muted);
|
||||
setVideoOn(peer.videoOn);
|
||||
setMedia(peer);
|
||||
}, [peer, setMedia, setMuted, setVideoOn]);
|
||||
|
||||
console.log(`media-control - render`);
|
||||
|
||||
const toggleMute = (event: React.MouseEvent) => {
|
||||
if (debug) console.log(`media-control - toggleMute - ${peer.name}`, !muted);
|
||||
peer.muted = !muted;
|
||||
setMuted(peer.muted);
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const toggleVideo = (event: React.MouseEvent) => {
|
||||
if (debug) console.log(`media-control - toggleVideo - ${peer.name}`, !videoOn);
|
||||
peer.videoOn = !videoOn;
|
||||
setVideoOn(peer.videoOn);
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onDrag = (e: any) => {
|
||||
frame.translate = e.translate;
|
||||
setFrame({ ...frame });
|
||||
};
|
||||
|
||||
const onDragEnd = (e: any) => {
|
||||
console.log(e);
|
||||
};
|
||||
|
||||
if (!peer) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const hasAudio = peer.hasAudio;
|
||||
const hasVideo = peer.hasVideo;
|
||||
|
||||
return (
|
||||
<div className={`MediaControl ${className || ""}`} data-peer={peer.name}>
|
||||
<Moveable target={target} draggable={true} onDrag={onDrag} onDragEnd={onDragEnd} />
|
||||
<div className="MediaControlInner">
|
||||
{hasVideo && videoOn && (
|
||||
<Video srcObject={peer.attributes.srcObject} autoPlay muted={isSelf} className="Video" />
|
||||
)}
|
||||
{hasVideo && !videoOn && (
|
||||
<div className="VideoOff">
|
||||
<VideocamOff />
|
||||
</div>
|
||||
)}
|
||||
{hasAudio && muted && (
|
||||
<div className="AudioOff">
|
||||
<VolumeOff />
|
||||
</div>
|
||||
)}
|
||||
{hasAudio && !muted && (
|
||||
<div className="AudioOn">
|
||||
<VolumeUp />
|
||||
</div>
|
||||
)}
|
||||
<div className="Controls">
|
||||
{hasAudio && <button onClick={toggleMute}>{muted ? <MicOff /> : <Mic />}</button>}
|
||||
{hasVideo && <button onClick={toggleVideo}>{videoOn ? <Videocam /> : <VideocamOff />}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { MediaControl, MediaAgent };
|
46
client/src/PingPong.tsx
Normal file
46
client/src/PingPong.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { useState, useContext, useEffect, useRef } from "react";
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
import "./PingPong.css";
|
||||
|
||||
const PingPong: React.FC = () => {
|
||||
const [count, setCount] = useState<number>(0);
|
||||
const global = useContext(GlobalContext);
|
||||
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data as string);
|
||||
switch (data.type) {
|
||||
case "ping":
|
||||
if (global.ws) {
|
||||
global.ws.send(JSON.stringify({ type: "pong", timestamp: data.ping }));
|
||||
}
|
||||
setCount(count + 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
const refWsMessage = useRef(onWsMessage);
|
||||
|
||||
useEffect(() => {
|
||||
refWsMessage.current = onWsMessage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!global.ws) {
|
||||
return;
|
||||
}
|
||||
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
|
||||
global.ws.addEventListener("message", cbMessage);
|
||||
return () => {
|
||||
global.ws.removeEventListener("message", cbMessage);
|
||||
};
|
||||
}, [global.ws, refWsMessage]);
|
||||
|
||||
return (
|
||||
<div className="PingPong">
|
||||
Game {global.gameId}: {global.name} {global.ws ? "has socket" : "no socket"} {count} pings
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { PingPong };
|
105
client/src/Placard.tsx
Normal file
105
client/src/Placard.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useContext, useCallback } from "react";
|
||||
import "./Placard.css";
|
||||
import { assetsPath } from "./Common";
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
type PlacardProps = {
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
count?: number;
|
||||
buildActive?: boolean;
|
||||
setBuildActive?: (b: boolean) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Placard: React.FC<PlacardProps> = ({ type, disabled, count, buildActive, setBuildActive, className }) => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const sendMessage = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
ws.send(JSON.stringify(data));
|
||||
},
|
||||
[ws]
|
||||
);
|
||||
|
||||
const dismissClicked = () => {
|
||||
setBuildActive && setBuildActive(false);
|
||||
};
|
||||
|
||||
const buildClicked = () => {
|
||||
if (!type || !type.match(/^l.*/)) {
|
||||
if (!buildActive) {
|
||||
setBuildActive && setBuildActive(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const roadClicked = () => {
|
||||
sendMessage({ type: "buy-road" });
|
||||
setBuildActive && setBuildActive(false);
|
||||
};
|
||||
const settlementClicked = () => {
|
||||
sendMessage({ type: "buy-settlement" });
|
||||
setBuildActive && setBuildActive(false);
|
||||
};
|
||||
const cityClicked = () => {
|
||||
sendMessage({ type: "buy-city" });
|
||||
setBuildActive && setBuildActive(false);
|
||||
};
|
||||
const developmentClicked = () => {
|
||||
sendMessage({ type: "buy-development" });
|
||||
setBuildActive && setBuildActive(false);
|
||||
};
|
||||
|
||||
if (!type) return <></>;
|
||||
|
||||
let t = type;
|
||||
if (type === "B") t = "blue";
|
||||
else if (type === "O") t = "orange";
|
||||
else if (type === "R") t = "red";
|
||||
else if (type === "W") t = "white";
|
||||
|
||||
let buttons: React.ReactNode = <></>;
|
||||
if (!disabled && buildActive) {
|
||||
switch (t) {
|
||||
case "orange":
|
||||
case "red":
|
||||
case "white":
|
||||
case "blue":
|
||||
buttons = (
|
||||
<>
|
||||
<div onClick={dismissClicked} />
|
||||
<div onClick={roadClicked} />
|
||||
<div onClick={settlementClicked} />
|
||||
<div onClick={cityClicked} />
|
||||
<div onClick={developmentClicked} />
|
||||
<div onClick={dismissClicked} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
buttons = <></>;
|
||||
}
|
||||
}
|
||||
|
||||
const style = { backgroundImage: `url(${assetsPath}/gfx/placard-${t}.png)` };
|
||||
|
||||
if (!disabled) {
|
||||
return (
|
||||
<div
|
||||
className={`Placard${buildActive ? " Selected" : ""} ${className || ""}`}
|
||||
onClick={buildClicked}
|
||||
data-type={t}
|
||||
style={style}
|
||||
>
|
||||
{buttons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`Placard${buildActive ? " Selected" : ""} ${className || ""}`} data-type={t} style={style}>
|
||||
{count && <div className="Right">{count}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Placard };
|
13
client/src/PlayerColor.tsx
Normal file
13
client/src/PlayerColor.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import "./PlayerColor.css";
|
||||
import { useStyles } from "./Styles";
|
||||
|
||||
type PlayerColorProps = { color?: string };
|
||||
|
||||
const PlayerColor: React.FC<PlayerColorProps> = ({ color }) => {
|
||||
const classes = useStyles();
|
||||
return <Avatar className={["PlayerColor", color ? classes[color] : ""].join(" ")} />;
|
||||
};
|
||||
|
||||
export { PlayerColor };
|
189
client/src/PlayerList.tsx
Normal file
189
client/src/PlayerList.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import List from "@mui/material/List";
|
||||
|
||||
import "./PlayerList.css";
|
||||
import { PlayerColor } from "./PlayerColor";
|
||||
import { MediaAgent, MediaControl } from "./MediaControl";
|
||||
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
const PlayerList: React.FC = () => {
|
||||
const { ws, name } = useContext(GlobalContext);
|
||||
const [players, setPlayers] = useState<{ [key: string]: any }>({});
|
||||
const [unselected, setUneslected] = useState<string[]>([]);
|
||||
const [state, setState] = useState<string>("lobby");
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [peers, setPeers] = useState<{ [key: string]: any }>({});
|
||||
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`player-list - game update`, data.update);
|
||||
|
||||
if ("unselected" in data.update) {
|
||||
setUneslected(data.update.unselected);
|
||||
}
|
||||
|
||||
if ("players" in data.update) {
|
||||
let found = false;
|
||||
for (const key in data.update.players) {
|
||||
if (data.update.players[key].name === name) {
|
||||
found = true;
|
||||
setColor(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
setColor(undefined);
|
||||
}
|
||||
setPlayers(data.update.players);
|
||||
}
|
||||
|
||||
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: ["state", "players", "unselected"],
|
||||
})
|
||||
);
|
||||
}, [ws]);
|
||||
|
||||
const toggleSelected = (key: string) => {
|
||||
ws!.send(
|
||||
JSON.stringify({
|
||||
type: "set",
|
||||
field: "color",
|
||||
value: color === key ? "" : key,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const playerElements: React.ReactElement[] = [];
|
||||
|
||||
const inLobby = state === "lobby";
|
||||
const sortedPlayers: any[] = [];
|
||||
|
||||
for (const key in players) {
|
||||
sortedPlayers.push(players[key]);
|
||||
}
|
||||
|
||||
const sortPlayers = (A: any, B: any) => {
|
||||
/* active player first */
|
||||
if (A.name === name) {
|
||||
return -1;
|
||||
}
|
||||
if (B.name === name) {
|
||||
return +1;
|
||||
}
|
||||
|
||||
/* Sort active players first */
|
||||
if (A.name && !B.name) {
|
||||
return -1;
|
||||
}
|
||||
if (B.name && !A.name) {
|
||||
return +1;
|
||||
}
|
||||
|
||||
/* Ohterwise, sort by color */
|
||||
return A.color.localeCompare(B.color);
|
||||
};
|
||||
|
||||
sortedPlayers.sort(sortPlayers);
|
||||
|
||||
/* Array of just names... */
|
||||
unselected.sort((A, B) => {
|
||||
/* active player first */
|
||||
if (A === name) {
|
||||
return -1;
|
||||
}
|
||||
if (B === name) {
|
||||
return +1;
|
||||
}
|
||||
/* Then sort alphabetically */
|
||||
return A.localeCompare(B);
|
||||
});
|
||||
|
||||
const videoClass = sortedPlayers.length <= 2 ? "Medium" : "Small";
|
||||
|
||||
sortedPlayers.forEach((player) => {
|
||||
const playerName = player.name;
|
||||
const selectable = inLobby && (player.status === "Not active" || color === player.color);
|
||||
playerElements.push(
|
||||
<div
|
||||
data-selectable={selectable}
|
||||
data-selected={player.color === color}
|
||||
className="PlayerEntry"
|
||||
onClick={() => {
|
||||
inLobby && selectable && toggleSelected(player.color);
|
||||
}}
|
||||
key={`player-${player.color}`}
|
||||
>
|
||||
<div>
|
||||
<PlayerColor color={player.color} />
|
||||
<div className="Name">{playerName ? playerName : "Available"}</div>
|
||||
{playerName && !player.live && <div className="NoNetwork"></div>}
|
||||
</div>
|
||||
{playerName && player.live && (
|
||||
<MediaControl className={videoClass} peer={peers[playerName]} isSelf={player.color === color} />
|
||||
)}
|
||||
{!playerName && <div></div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const waiting = unselected.map((player) => {
|
||||
return (
|
||||
<div className={player === name ? "Self" : ""} key={player}>
|
||||
<div>{player}</div>
|
||||
<MediaControl className={"Small"} peer={peers[player]} isSelf={name === player} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper className={`PlayerList ${videoClass}`}>
|
||||
<MediaAgent setPeers={setPeers} />
|
||||
<List className="PlayerSelector">{playerElements}</List>
|
||||
{unselected && unselected.length !== 0 && (
|
||||
<div className="Unselected">
|
||||
<div>In lobby</div>
|
||||
<div>{waiting}</div>
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { PlayerList };
|
43
client/src/PlayerName.tsx
Normal file
43
client/src/PlayerName.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React, { useState } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import "./PlayerName.css";
|
||||
|
||||
type PlayerNameProps = {
|
||||
name?: string;
|
||||
setName: (s: string) => void;
|
||||
};
|
||||
|
||||
const PlayerName: React.FC<PlayerNameProps> = ({ name, setName }) => {
|
||||
const [edit, setEdit] = useState<string | undefined>(name);
|
||||
|
||||
const sendName = () => {
|
||||
setName(edit ?? "");
|
||||
};
|
||||
|
||||
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEdit(event.target.value);
|
||||
};
|
||||
|
||||
const nameKeyPress = (event: React.KeyboardEvent) => {
|
||||
if ((event as React.KeyboardEvent<HTMLInputElement>).key === "Enter") {
|
||||
setName(edit ? edit : name ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="PlayerName">
|
||||
<TextField
|
||||
className="nameInput"
|
||||
onChange={nameChange}
|
||||
onKeyPress={nameKeyPress}
|
||||
label="Enter your name"
|
||||
variant="outlined"
|
||||
value={edit}
|
||||
/>
|
||||
<Button onClick={sendName}>Set</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { PlayerName };
|
247
client/src/PlayersStatus.tsx
Normal file
247
client/src/PlayersStatus.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import React, { useContext, useState, useMemo, useRef, useEffect } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
import "./PlayersStatus.css";
|
||||
import { BoardPieces } from "./BoardPieces";
|
||||
import { Resource } from "./Resource";
|
||||
import { PlayerColor } from "./PlayerColor";
|
||||
import { Placard } from "./Placard";
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
interface PlayerProps {
|
||||
player: any;
|
||||
onClick?: () => void;
|
||||
reverse?: boolean;
|
||||
color: string;
|
||||
largestArmy: string | undefined;
|
||||
isSelf?: boolean;
|
||||
longestRoad: string | undefined;
|
||||
mostPorts: string | undefined;
|
||||
mostDeveloped: string | undefined;
|
||||
}
|
||||
|
||||
const Player: React.FC<PlayerProps> = ({
|
||||
player,
|
||||
onClick,
|
||||
reverse,
|
||||
color,
|
||||
largestArmy,
|
||||
isSelf,
|
||||
longestRoad,
|
||||
mostPorts,
|
||||
mostDeveloped,
|
||||
}) => {
|
||||
if (!player) {
|
||||
return <>You are an observer.</>;
|
||||
}
|
||||
|
||||
const developmentCards = player.unplayed ? (
|
||||
<Resource label={true} type={"progress-back"} count={player.unplayed} disabled />
|
||||
) : undefined;
|
||||
const resourceCards = player.resources ? (
|
||||
<Resource label={true} type={"resource-back"} count={player.resources} disabled />
|
||||
) : undefined;
|
||||
const armyCards = player.army ? <Resource label={true} type={"army-1"} count={player.army} disabled /> : undefined;
|
||||
let points: React.ReactElement = <></>;
|
||||
if (player.points && reverse) {
|
||||
points = (
|
||||
<>
|
||||
<b>{player.points}</b>
|
||||
<Resource type={"progress-back"} count={player.points} disabled />
|
||||
</>
|
||||
);
|
||||
} else if (player.points) {
|
||||
points = (
|
||||
<>
|
||||
<Resource type={"progress-back"} count={player.points} disabled />
|
||||
<b>{player.points}</b>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const mostPortsPlacard =
|
||||
mostPorts && mostPorts === color ? <Placard disabled type="port-of-call" count={player.ports} /> : undefined;
|
||||
|
||||
const mostDevelopedPlacard =
|
||||
mostDeveloped && mostDeveloped === color ? (
|
||||
<Placard disabled type="most-developed" count={player.developmentCards} />
|
||||
) : undefined;
|
||||
|
||||
const longestRoadPlacard =
|
||||
longestRoad && longestRoad === color ? (
|
||||
<Placard disabled type="longest-road" count={player.longestRoad} />
|
||||
) : undefined;
|
||||
|
||||
const largestArmyPlacard =
|
||||
largestArmy && largestArmy === color ? <Placard disabled type="largest-army" count={player.army} /> : undefined;
|
||||
|
||||
return (
|
||||
<div className="Player">
|
||||
<div className="Who">
|
||||
<PlayerColor color={color} />
|
||||
{player.name}
|
||||
</div>
|
||||
<div className="What">
|
||||
{isSelf && <div className="LongestRoad">Longest road: {player.longestRoad ? player.longestRoad : 0}</div>}
|
||||
<div className="Points">{points}</div>
|
||||
{(largestArmy ||
|
||||
longestRoad ||
|
||||
armyCards ||
|
||||
resourceCards ||
|
||||
developmentCards ||
|
||||
mostPorts ||
|
||||
mostDeveloped) && (
|
||||
<>
|
||||
<div className="Has">
|
||||
{!reverse && (
|
||||
<>
|
||||
{mostDevelopedPlacard}
|
||||
{mostPortsPlacard}
|
||||
{largestArmyPlacard}
|
||||
{longestRoadPlacard}
|
||||
{!largestArmyPlacard && armyCards}
|
||||
{developmentCards}
|
||||
{resourceCards}
|
||||
</>
|
||||
)}
|
||||
{reverse && (
|
||||
<>
|
||||
{resourceCards}
|
||||
{developmentCards}
|
||||
{!largestArmyPlacard && armyCards}
|
||||
{longestRoadPlacard}
|
||||
{largestArmyPlacard}
|
||||
{mostPortsPlacard}
|
||||
{mostDevelopedPlacard}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${onClick ? "Normal" : "Shrunken"}`}>
|
||||
<BoardPieces onClick={onClick} player={player} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlayersStatusProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const PlayersStatus: React.FC<PlayersStatusProps> = ({ active }) => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [players, setPlayers] = useState<any>(undefined);
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const [largestArmy, setLargestArmy] = useState<string | undefined>(undefined);
|
||||
const [longestRoad, setLongestRoad] = useState<string | undefined>(undefined);
|
||||
const [mostPorts, setMostPorts] = useState<string | undefined>(undefined);
|
||||
const [mostDeveloped, setMostDeveloped] = useState<string | undefined>(undefined);
|
||||
const fields = useMemo(() => ["players", "color", "longestRoad", "largestArmy", "mostPorts", "mostDeveloped"], []);
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`players-status - 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);
|
||||
}
|
||||
if ("longestRoad" in data.update && data.update.longestRoad !== longestRoad) {
|
||||
setLongestRoad(data.update.longestRoad);
|
||||
}
|
||||
if ("largestArmy" in data.update && data.update.largestArmy !== 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]);
|
||||
|
||||
if (!players) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const buildItem = () => {
|
||||
console.log(`player-status - build-item`);
|
||||
};
|
||||
|
||||
let elements: React.ReactElement;
|
||||
if (active) {
|
||||
elements = (
|
||||
<Player
|
||||
player={players[color!]}
|
||||
onClick={buildItem}
|
||||
reverse
|
||||
largestArmy={largestArmy}
|
||||
longestRoad={longestRoad}
|
||||
mostPorts={mostPorts}
|
||||
mostDeveloped={mostDeveloped}
|
||||
isSelf={active}
|
||||
key={`PlayerStatus-${color}`}
|
||||
color={color!}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
elements = (
|
||||
<>
|
||||
{Object.getOwnPropertyNames(players)
|
||||
.filter((key) => color !== key)
|
||||
.map((key) => {
|
||||
return (
|
||||
<Player
|
||||
player={players[key]}
|
||||
largestArmy={largestArmy}
|
||||
longestRoad={longestRoad}
|
||||
mostPorts={mostPorts}
|
||||
mostDeveloped={mostDeveloped}
|
||||
key={`PlayerStatus-${key}}`}
|
||||
color={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={`PlayersStatus ${active ? "ActivePlayer" : ""}`}>{elements}</div>;
|
||||
};
|
||||
|
||||
export { PlayersStatus };
|
55
client/src/Resource.tsx
Normal file
55
client/src/Resource.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import "./Resource.css";
|
||||
import { assetsPath } from "./Common";
|
||||
|
||||
type ResourceProps = {
|
||||
type: string;
|
||||
disabled?: boolean;
|
||||
available?: number;
|
||||
count?: number;
|
||||
label?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
const Resource: React.FC<ResourceProps> = ({ type, disabled, available, count, label, onClick }) => {
|
||||
const array = new Array(Number(count ? count : 0));
|
||||
const click = (event: React.MouseEvent) => {
|
||||
if (!disabled) {
|
||||
(event.target as HTMLElement).classList.toggle("Selected");
|
||||
}
|
||||
if (onClick) onClick(event);
|
||||
};
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<div
|
||||
className={`Resource ${count === 0 ? "None" : ""}`}
|
||||
data-type={type}
|
||||
onClick={click}
|
||||
style={{ backgroundImage: `url(${assetsPath}/gfx/card-${type}.png)` }}
|
||||
>
|
||||
{available !== undefined && <div className="Left">{available}</div>}
|
||||
<div className="Right">{count}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{array.length > 0 && (
|
||||
<div className="Stack">
|
||||
{React.Children.map(array, () => (
|
||||
<div
|
||||
className="Resource"
|
||||
data-type={type}
|
||||
onClick={click}
|
||||
style={{ backgroundImage: `url(${assetsPath}/gfx/card-${type}.png)` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { Resource };
|
94
client/src/SelectPlayer.tsx
Normal file
94
client/src/SelectPlayer.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
import { PlayerColor } from "./PlayerColor";
|
||||
|
||||
import "./SelectPlayer.css";
|
||||
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
const SelectPlayer: React.FC = () => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [turn, setTurn] = useState<any>(undefined);
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
const fields = useMemo(() => ["turn", "color"], []);
|
||||
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`select-players - 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);
|
||||
}
|
||||
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 playerClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
ws!.send(
|
||||
JSON.stringify({
|
||||
type: "steal-resource",
|
||||
color: event.currentTarget.getAttribute("data-color"),
|
||||
})
|
||||
);
|
||||
},
|
||||
[ws]
|
||||
);
|
||||
|
||||
if (!color || !turn || turn.color !== color || !turn.limits || !turn.limits.players) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const list = turn.limits.players.map((item: any) => (
|
||||
<div className="SelectPlayerItem" onClick={playerClick} data-color={item.color} key={`player-${item.color}`}>
|
||||
<PlayerColor color={item.color} />
|
||||
<div>{item.name}</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="SelectPlayer">
|
||||
<Paper>
|
||||
<div className="Title">Select Player to Steal From</div>
|
||||
<div className="SelectPlayerList">{list}</div>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { SelectPlayer };
|
105
client/src/Sheep.tsx
Normal file
105
client/src/Sheep.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { assetsPath } from "./Common";
|
||||
import "./Sheep.css";
|
||||
|
||||
const sheepSteps = 12;
|
||||
|
||||
const useAnimationFrame = (callback: (t: number) => void) => {
|
||||
const requestRef = useRef<number | null>(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 Sheep: React.FC<{ radius: number; speed: number; size: number; style?: React.CSSProperties }> = ({
|
||||
radius,
|
||||
speed,
|
||||
size,
|
||||
style,
|
||||
}) => {
|
||||
const [time, setTime] = useState(0);
|
||||
const [direction, setDirection] = useState(Math.random() * 2 * Math.PI);
|
||||
const [y, setY] = useState((Math.random() - 0.5) * radius);
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [x, setX] = useState((Math.random() - 0.5) * radius);
|
||||
const previousTimeRef = useRef<number | undefined>();
|
||||
|
||||
useAnimationFrame((t) => {
|
||||
if (previousTimeRef.current !== undefined) {
|
||||
const deltaTime = t - previousTimeRef.current;
|
||||
previousTimeRef.current = t;
|
||||
setTime(deltaTime);
|
||||
} else {
|
||||
previousTimeRef.current = t;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alpha = time / speed;
|
||||
const sheepSpeed = 0.05;
|
||||
if (alpha > 1.0) alpha = 0.1;
|
||||
let newX = x + sheepSpeed * Math.sin(direction) * alpha;
|
||||
let newY = y + sheepSpeed * Math.cos(direction) * alpha;
|
||||
if (Math.sqrt(newX * newX + newY * newY) > Math.sqrt(radius * radius)) {
|
||||
let newDirection = direction + Math.PI + 0.5 * (Math.random() - 0.5) * Math.PI;
|
||||
while (newDirection >= 2 * Math.PI) newDirection -= 2 * Math.PI;
|
||||
while (newDirection <= -2 * Math.PI) newDirection += 2 * Math.PI;
|
||||
setDirection(newDirection);
|
||||
newX += sheepSpeed * Math.sin(newDirection) * alpha;
|
||||
newY += sheepSpeed * Math.cos(newDirection) * alpha;
|
||||
}
|
||||
setX(newX);
|
||||
setY(newY);
|
||||
setFrame(frame + sheepSteps * alpha);
|
||||
}, [time, speed]);
|
||||
|
||||
const cell = Math.floor(frame) % sheepSteps;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`Sheep`}
|
||||
style={{
|
||||
zIndex: `${Math.ceil(50 * y)}`,
|
||||
top: `${Math.floor(50 + 50 * y)}%`,
|
||||
left: `${Math.floor(50 + 50 * x)}%`,
|
||||
width: `${size * 60}px`,
|
||||
height: `${size * 52}px`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundImage: `url(${assetsPath}/gfx/sheep.png)`,
|
||||
backgroundPositionX: `${(100.0 * cell) / (sheepSteps - 1)}%`,
|
||||
transformOrigin: `50% 50%`,
|
||||
transform: `translate(-50%, -50%) scale(${Math.sin(direction) > 0 ? +1 : -1}, 1)`,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Herd: React.FC<{ count: number; style?: React.CSSProperties }> = ({ count, style }) => {
|
||||
const [sheep, setSheep] = useState<React.ReactNode[]>([]);
|
||||
useEffect(() => {
|
||||
const tmp: React.ReactNode[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const scalar = Math.random();
|
||||
tmp.push(<Sheep speed={1000 + 500 * scalar} size={0.25} radius={0.8} key={i} />);
|
||||
}
|
||||
setSheep(tmp);
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<div className="Herd" style={style}>
|
||||
{sheep}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Sheep, Herd };
|
@ -1,7 +1,9 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { orange,lightBlue, red, grey } from '@material-ui/core/colors';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { orange, lightBlue, red, grey } from '@mui/material/colors';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const useStyles = makeStyles((theme: any) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
'& > *': {
|
||||
@ -26,5 +28,4 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
export { useStyles };
|
640
client/src/Trade.tsx
Normal file
640
client/src/Trade.tsx
Normal file
@ -0,0 +1,640 @@
|
||||
import React, { useState, useCallback, useEffect, useContext, useMemo, useRef } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Button from "@mui/material/Button";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
|
||||
import { Resource } from "./Resource";
|
||||
import { PlayerColor } from "./PlayerColor";
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
import "./Trade.css";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
interface Resources {
|
||||
wheat: number;
|
||||
brick: number;
|
||||
wood: number;
|
||||
stone: number;
|
||||
sheep: number;
|
||||
}
|
||||
|
||||
interface TradeItem {
|
||||
negotiator: boolean;
|
||||
self: boolean;
|
||||
name: string;
|
||||
color: string | undefined;
|
||||
valid: boolean;
|
||||
gets: any[];
|
||||
gives: any[];
|
||||
offerRejected: any;
|
||||
canSubmit?: boolean;
|
||||
}
|
||||
|
||||
const empty: Resources = {
|
||||
wheat: 0,
|
||||
brick: 0,
|
||||
wood: 0,
|
||||
stone: 0,
|
||||
sheep: 0,
|
||||
};
|
||||
|
||||
const Trade: React.FC = () => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [gives, setGives] = useState<Resources>(Object.assign({}, empty));
|
||||
const [gets, setGets] = useState<Resources>(Object.assign({}, empty));
|
||||
const [turn, setTurn] = useState<any>(undefined);
|
||||
const [priv, setPriv] = useState<any>(undefined);
|
||||
const [players, setPlayers] = useState<any>(undefined);
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
|
||||
const fields = useMemo(() => ["turn", "players", "private", "color"], []);
|
||||
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`trade - game-update: `, data.update);
|
||||
if ("turn" in data.update && !equal(turn, data.update.turn)) {
|
||||
setTurn(data.update.turn);
|
||||
}
|
||||
if ("players" in data.update && !equal(players, data.update.players)) {
|
||||
setPlayers(data.update.players);
|
||||
}
|
||||
if ("private" in data.update && !equal(priv, data.update.private)) {
|
||||
setPriv(data.update.private);
|
||||
}
|
||||
if ("color" in data.update && color !== data.update.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 transfer = useCallback(
|
||||
(type: string, direction: string) => {
|
||||
if (direction === "give") {
|
||||
/* give clicked */
|
||||
if (gets[type as keyof Resources]) {
|
||||
gets[type as keyof Resources]--;
|
||||
gives[type as keyof Resources] = 0;
|
||||
} else {
|
||||
if (gives[type as keyof Resources] < priv[type]) {
|
||||
gives[type as keyof Resources]++;
|
||||
}
|
||||
gets[type as keyof Resources] = 0;
|
||||
}
|
||||
} else if (direction === "get") {
|
||||
/* get clicked */
|
||||
if (gives[type as keyof Resources]) {
|
||||
gives[type as keyof Resources]--;
|
||||
gets[type as keyof Resources] = 0;
|
||||
} else {
|
||||
if (gets[type as keyof Resources] < 15) {
|
||||
gets[type as keyof Resources]++;
|
||||
}
|
||||
gives[type as keyof Resources] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
setGets({ ...gets });
|
||||
setGives({ ...gives });
|
||||
},
|
||||
[setGets, setGives, gets, gives, priv]
|
||||
);
|
||||
|
||||
const createTransfer = useCallback(
|
||||
(resource: string) => {
|
||||
return (
|
||||
<div key={resource} className="Transfer">
|
||||
<Resource
|
||||
onClick={() => transfer(resource, "get")}
|
||||
label={true}
|
||||
type={resource}
|
||||
disabled
|
||||
count={gets[resource as keyof Resources]}
|
||||
/>
|
||||
<div className="Direction">
|
||||
{gets[resource as keyof Resources] === gives[resource as keyof Resources] ? (
|
||||
""
|
||||
) : gets[resource as keyof Resources] > gives[resource as keyof Resources] ? (
|
||||
<ArrowDownwardIcon />
|
||||
) : (
|
||||
<ArrowUpwardIcon />
|
||||
)}
|
||||
</div>
|
||||
<Resource
|
||||
onClick={() => transfer(resource, "give")}
|
||||
label={true}
|
||||
type={resource}
|
||||
disabled
|
||||
available={priv ? priv[resource] - gives[resource as keyof Resources] : undefined}
|
||||
count={gives[resource as keyof Resources]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[gives, gets, transfer, priv]
|
||||
);
|
||||
|
||||
const sendTrade = useCallback(
|
||||
(action: string, offer: any) => {
|
||||
if (ws) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "trade",
|
||||
action,
|
||||
offer,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[ws]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (priv && priv.gives) {
|
||||
const _gives: Partial<Resources> = {};
|
||||
priv.gives.forEach((give: any) => (_gives[give.type as keyof Resources] = give.count));
|
||||
setGives(Object.assign({}, empty, _gives));
|
||||
}
|
||||
if (priv && priv.gets) {
|
||||
const _gets: Partial<Resources> = {};
|
||||
priv.gets.forEach((get: any) => (_gets[get.type as keyof Resources] = get.count));
|
||||
setGets(Object.assign({}, empty, _gets));
|
||||
}
|
||||
}, [setGets, setGives, priv]);
|
||||
|
||||
const agreeClicked = useCallback(
|
||||
(offer: any) => {
|
||||
const trade = {
|
||||
gives: offer.gets.slice(),
|
||||
gets: offer.gives.slice(),
|
||||
};
|
||||
const _gives: Partial<Resources> = {},
|
||||
_gets: Partial<Resources> = {};
|
||||
console.log(gives, gets);
|
||||
trade.gives.forEach((give: any) => (_gives[give.type as keyof Resources] = give.count));
|
||||
trade.gets.forEach((get: any) => (_gets[get.type as keyof Resources] = get.count));
|
||||
sendTrade("offer", trade);
|
||||
console.log(_gives, _gets);
|
||||
setGives(Object.assign({}, empty, _gives));
|
||||
setGets(Object.assign({}, empty, _gets));
|
||||
},
|
||||
[setGives, setGets, gives, gets, sendTrade]
|
||||
);
|
||||
|
||||
if (!priv || !turn || !turn.actions || turn.actions.indexOf("trade") === -1) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const transfers = ["brick", "wood", "wheat", "sheep", "stone"].map((resource) => {
|
||||
return createTransfer(resource);
|
||||
});
|
||||
|
||||
priv.offerRejected = priv.offerRejected ? priv.offerRejected : {};
|
||||
|
||||
const canMeetOffer = (player: any, offer: any) => {
|
||||
if (offer.gets.length === 0 || offer.gives.length === 0) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < offer.gets.length; i++) {
|
||||
const get = offer.gets[i];
|
||||
if (offer.name === "The bank") {
|
||||
const _gives: any[] = [],
|
||||
_gets: any[] = [];
|
||||
for (const type in gives) {
|
||||
if (gives[type as keyof Resources] > 0) {
|
||||
_gives.push({ type, count: gives[type as keyof Resources] });
|
||||
}
|
||||
}
|
||||
for (const type in gets) {
|
||||
if (gets[type as keyof Resources] > 0) {
|
||||
_gets.push({ type, count: gets[type as keyof Resources] });
|
||||
}
|
||||
}
|
||||
|
||||
if (_gives.length !== 1 || _gets.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_gives[0].count < get.count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (get.type !== "bank") {
|
||||
if (gives[get.type as keyof Resources] < get.count) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (_gets[0].count !== 1) {
|
||||
return false;
|
||||
}
|
||||
} else if (player[get.type] < get.count) {
|
||||
console.log(`cannot meet count`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isCompatibleOffer = (player: any, offer: any) => {
|
||||
let valid =
|
||||
player.gets &&
|
||||
player.gives &&
|
||||
offer.gets &&
|
||||
offer.gives &&
|
||||
player.gets.length === offer.gives.length &&
|
||||
player.gives.length === offer.gets.length;
|
||||
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
player.gets.forEach((get: any) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
valid =
|
||||
offer.gives.find((item: any) => (item.type === get.type || item.type === "*") && item.count === get.count) !==
|
||||
undefined;
|
||||
});
|
||||
|
||||
if (valid)
|
||||
player.gives.forEach((give: any) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
valid =
|
||||
offer.gets.find(
|
||||
(item: any) => (item.type === give.type || item.type === "bank") && item.count === give.count
|
||||
) !== undefined;
|
||||
});
|
||||
return valid;
|
||||
};
|
||||
|
||||
const isTurn = turn && turn.color === color ? true : false;
|
||||
|
||||
const offerClicked = () => {
|
||||
const trade = {
|
||||
gives: [] as any[],
|
||||
gets: [] as any[],
|
||||
};
|
||||
for (const key in gives) {
|
||||
if (gives[key as keyof Resources] !== 0) {
|
||||
trade.gives.push({ type: key, count: gives[key as keyof Resources] });
|
||||
}
|
||||
}
|
||||
for (const key in gets) {
|
||||
if (gets[key as keyof Resources] !== 0) {
|
||||
trade.gets.push({ type: key, count: gets[key as keyof Resources] });
|
||||
}
|
||||
}
|
||||
sendTrade("offer", trade);
|
||||
};
|
||||
|
||||
const cancelOffer = (offer: any) => {
|
||||
sendTrade("cancel", offer);
|
||||
};
|
||||
|
||||
const acceptClicked = (offer: any) => {
|
||||
if (offer.name === "The bank") {
|
||||
sendTrade("accept", Object.assign({}, { name: offer.name, gives: trade.gets, gets: trade.gives }));
|
||||
} else if (offer.self) {
|
||||
sendTrade("accept", offer);
|
||||
} else {
|
||||
sendTrade("accept", Object.assign({}, offer, { gives: offer.gets, gets: offer.gives }));
|
||||
}
|
||||
};
|
||||
|
||||
// cancelClicked was unused; use cancelOffer when needed. Keep function removed.
|
||||
|
||||
/* Player has rejected the active player's bid or active player rejected
|
||||
* the other player's bid */
|
||||
const rejectClicked = (trade: any) => {
|
||||
sendTrade("reject", trade);
|
||||
};
|
||||
|
||||
/* Create list of active trades */
|
||||
const activeTrades: any[] = [];
|
||||
for (const colorKey in players) {
|
||||
const item = players[colorKey],
|
||||
name = item.name;
|
||||
item.offerRejected = item.offerRejected ? item.offerRejected : {};
|
||||
if (item.status !== "Active") {
|
||||
continue;
|
||||
}
|
||||
/* Only list players with an offer, unless it is the active player (see
|
||||
* that you haven't submitted an offer) or the current turn player,
|
||||
* or the player explicitly rejected the player's offer */
|
||||
if (
|
||||
turn.name !== name &&
|
||||
priv.name !== name &&
|
||||
!(colorKey in priv.offerRejected) &&
|
||||
(!item.gets || item.gets.length === 0 || !item.gives || item.gives.length === 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tmp: TradeItem = {
|
||||
negotiator: turn.name === name,
|
||||
self: priv.name === name,
|
||||
name: name,
|
||||
color: colorKey,
|
||||
valid: false,
|
||||
gets: item.gets ? item.gets : [],
|
||||
gives: item.gives ? item.gives : [],
|
||||
offerRejected: item.offerRejected,
|
||||
};
|
||||
|
||||
tmp.canSubmit = !!(tmp.gets.length && tmp.gives.length);
|
||||
|
||||
activeTrades.push(tmp);
|
||||
}
|
||||
|
||||
activeTrades.sort((A: any, B: any) => {
|
||||
if (A.negotiator) {
|
||||
return -1;
|
||||
}
|
||||
if (B.negotiator) {
|
||||
return +1;
|
||||
}
|
||||
if (A.self) {
|
||||
return -1;
|
||||
}
|
||||
if (B.self) {
|
||||
return +1;
|
||||
}
|
||||
return A.name.localeCompare(B.name);
|
||||
});
|
||||
|
||||
const trade = { gives: [] as any[], gets: [] as any[] };
|
||||
for (const type in gives) {
|
||||
if (gives[type as keyof Resources]) {
|
||||
trade.gets.push({ type, count: gives[type as keyof Resources] });
|
||||
}
|
||||
}
|
||||
for (const type in gets) {
|
||||
if (gets[type as keyof Resources]) {
|
||||
trade.gives.push({ type, count: gets[type as keyof Resources] });
|
||||
}
|
||||
}
|
||||
|
||||
const isOfferSubmitted = isCompatibleOffer(priv, trade),
|
||||
isNegiatorSubmitted = turn && turn.offer && isCompatibleOffer(priv, turn.offer),
|
||||
isOfferValid = trade.gives.length && trade.gets.length ? true : false;
|
||||
|
||||
if (isTurn && priv && priv.banks) {
|
||||
priv.banks.forEach((bank: string) => {
|
||||
const count = bank === "bank" ? 3 : 2;
|
||||
activeTrades.push({
|
||||
name: `The bank`,
|
||||
color: undefined,
|
||||
gives: [{ count: 1, type: "*" }],
|
||||
gets: [{ count: count, type: bank }],
|
||||
valid: false,
|
||||
offerRejected: {},
|
||||
});
|
||||
});
|
||||
|
||||
activeTrades.push({
|
||||
name: `The bank`,
|
||||
color: undefined,
|
||||
gives: [{ count: 1, type: "*" }],
|
||||
gets: [{ count: 4, type: "bank" }],
|
||||
valid: false,
|
||||
offerRejected: {},
|
||||
});
|
||||
}
|
||||
|
||||
if (isTurn) {
|
||||
activeTrades.forEach((offer: any) => {
|
||||
if (offer.name === "The bank") {
|
||||
/* offer has to be the second parameter for the bank to match */
|
||||
offer.valid = isCompatibleOffer({ gives: trade.gets, gets: trade.gives }, offer);
|
||||
} else {
|
||||
offer.valid = !(turn.color in offer.offerRejected) && canMeetOffer(priv, offer);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const found = activeTrades.find((item: any) => item.name === turn.name);
|
||||
if (found) {
|
||||
found.valid = !(color! in found.offerRejected) && canMeetOffer(priv, found);
|
||||
}
|
||||
}
|
||||
|
||||
const tradeElements = activeTrades.map((item: any, index: number) => {
|
||||
const youRejectedOffer = color! in item.offerRejected;
|
||||
let youWereRejected;
|
||||
if (isTurn) {
|
||||
youWereRejected = item.color && item.color in priv.offerRejected;
|
||||
} else {
|
||||
youWereRejected = Object.getOwnPropertyNames(priv.offerRejected).length !== 0;
|
||||
}
|
||||
|
||||
const isNewOffer = item.self && !isOfferSubmitted;
|
||||
|
||||
let isSameOffer;
|
||||
const isBank = item.name === "The bank";
|
||||
|
||||
if (isTurn) {
|
||||
isSameOffer = isCompatibleOffer(trade, { gets: item.gives, gives: item.gets });
|
||||
} else {
|
||||
isSameOffer = turn.offer && isCompatibleOffer(priv, turn.offer);
|
||||
}
|
||||
|
||||
let source;
|
||||
if (item.self) {
|
||||
/* Order direction is reversed for self */
|
||||
source = {
|
||||
name: item.name,
|
||||
color: item.color,
|
||||
gets: trade.gives,
|
||||
gives: trade.gets,
|
||||
};
|
||||
} else {
|
||||
source = item;
|
||||
}
|
||||
const _gets = source.gets.length
|
||||
? source.gets.map((get: any, index: number) => {
|
||||
if (get.type === "bank") {
|
||||
return (
|
||||
<span key={`get-bank-${index}`}>
|
||||
<b>{get.count}</b> of any resource{" "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <Resource key={`get-${get.type}-${index}`} disabled label type={get.type} count={get.count} />;
|
||||
})
|
||||
: "nothing";
|
||||
const _gives = source.gives.length
|
||||
? source.gives.map((give: any, index: number) => {
|
||||
if (give.type === "*") {
|
||||
return (
|
||||
<span key={`give-bank-${index}`}>
|
||||
<b>1</b> of any resource{" "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <Resource key={`give-${give.type}-${index}`} disabled label type={give.type} count={give.count} />;
|
||||
})
|
||||
: "nothing";
|
||||
|
||||
return (
|
||||
<div className="TradeLine" key={`player-${item.name}-${index}`}>
|
||||
<PlayerColor color={item.color} />
|
||||
<div className="TradeText">
|
||||
{item.self && (
|
||||
<>
|
||||
{(_gets !== "nothing" || _gives !== "nothing") && (
|
||||
<span>
|
||||
You want {_gets} and will give {_gives}.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{youWereRejected && !isNewOffer && <span>{turn.name} rejected your offer.</span>}
|
||||
|
||||
{!youWereRejected && _gets === "nothing" && _gives === "nothing" && (
|
||||
<span>You have not made a trade offer.</span>
|
||||
)}
|
||||
|
||||
{!isTurn &&
|
||||
isSameOffer &&
|
||||
!youWereRejected &&
|
||||
isOfferValid &&
|
||||
_gets !== "nothing" &&
|
||||
_gives !== "nothing" && (
|
||||
<span style={{ fontWeight: "bold" }}>Your submitted offer agrees with {turn.name}'s terms.</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!item.self && (
|
||||
<>
|
||||
{(!isTurn || !isSameOffer || isBank) &&
|
||||
!youRejectedOffer &&
|
||||
_gets !== "nothing" &&
|
||||
_gives !== "nothing" && (
|
||||
<span>
|
||||
{item.name} wants {_gets} and will give {_gives}.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isBank && (
|
||||
<>
|
||||
{isTurn &&
|
||||
!isSameOffer &&
|
||||
isOfferValid &&
|
||||
!youRejectedOffer &&
|
||||
_gets !== "nothing" &&
|
||||
_gives !== "nothing" && <span style={{ fontWeight: "bold" }}>This is a counter offer.</span>}
|
||||
|
||||
{isTurn && isSameOffer && !youRejectedOffer && _gets !== "nothing" && _gives !== "nothing" && (
|
||||
<span>{item.name} will meet your terms.</span>
|
||||
)}
|
||||
|
||||
{(!isTurn || !youWereRejected) && (_gets === "nothing" || _gives === "nothing") && (
|
||||
<span>{item.name} has not submitted a trade offer.</span>
|
||||
)}
|
||||
|
||||
{youRejectedOffer && <span>You rejected {item.name}'s offer.</span>}
|
||||
|
||||
{isTurn && youWereRejected && <span>{item.name} rejected your offer.</span>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="TradeActions">
|
||||
{!item.self && isTurn && (
|
||||
<Button disabled={!item.valid} onClick={() => acceptClicked(item)}>
|
||||
accept
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isTurn && item.color === turn.color && (
|
||||
<Button disabled={!item.valid || isNegiatorSubmitted} onClick={() => agreeClicked(item)}>
|
||||
agree
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.name !== "The bank" && !item.self && (isTurn || item.name === turn.name) && (
|
||||
<Button
|
||||
disabled={!item.gets.length || !item.gives.length || youRejectedOffer}
|
||||
onClick={() => rejectClicked(item)}
|
||||
>
|
||||
reject
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.self && (
|
||||
<Button disabled={isOfferSubmitted || !isOfferValid} onClick={offerClicked}>
|
||||
Offer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.self && (
|
||||
<Button disabled onClick={() => cancelOffer(item)}>
|
||||
cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="Trade">
|
||||
<Paper>
|
||||
<div className="PlayerList">{tradeElements}</div>
|
||||
{priv.resources === 0 && (
|
||||
<div>
|
||||
<b>You have no resources to participate in this trade.</b>
|
||||
</div>
|
||||
)}
|
||||
{priv.resources !== 0 && (
|
||||
<div className="Transfers">
|
||||
<div className="GiveGet">
|
||||
<div>Get</div>
|
||||
<div>Give</div>
|
||||
<div>Have</div>
|
||||
</div>
|
||||
{transfers}
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Trade };
|
255
client/src/ViewCard.tsx
Normal file
255
client/src/ViewCard.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import React, { useEffect, useContext, useMemo, useRef, useState, useCallback } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
import "./ViewCard.css";
|
||||
import { Resource } from "./Resource";
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface ViewCardProps {
|
||||
cardActive: any;
|
||||
setCardActive: (card: any) => void;
|
||||
}
|
||||
|
||||
const ViewCard: React.FC<ViewCardProps> = ({ cardActive, setCardActive }) => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [priv, setPriv] = useState<any>(undefined);
|
||||
const [turns, setTurns] = useState<number>(0);
|
||||
const [rules, setRules] = useState<any>({});
|
||||
const fields = useMemo(() => ["private", "turns", "rules"], []);
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data as string);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`view-card - game update`);
|
||||
if ("private" in data.update && !equal(data.update.private, priv)) {
|
||||
setPriv(data.update.private);
|
||||
}
|
||||
if ("turns" in data.update && data.update.turns !== turns) {
|
||||
setTurns(data.update.turns);
|
||||
}
|
||||
if ("rules" in data.update && !equal(data.update.rules, 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 playCard = useCallback(() => {
|
||||
if (ws) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "play-card",
|
||||
card: cardActive,
|
||||
})
|
||||
);
|
||||
}
|
||||
setCardActive(undefined);
|
||||
}, [ws, cardActive, setCardActive]);
|
||||
|
||||
const close = () => {
|
||||
setCardActive(undefined);
|
||||
};
|
||||
|
||||
if (!cardActive) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const capitalize = (string: string) => {
|
||||
if (string === "vp") {
|
||||
return "Victory Point";
|
||||
}
|
||||
if (string === "army") {
|
||||
return "Knight";
|
||||
}
|
||||
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
};
|
||||
|
||||
let description: React.ReactElement, lookup: string;
|
||||
if (cardActive.type === "progress") {
|
||||
lookup = `${cardActive.type}-${cardActive.card}`;
|
||||
} else {
|
||||
lookup = cardActive.type;
|
||||
}
|
||||
|
||||
const points = "victory-points" in rules && rules["victory-points"].enabled ? rules["victory-points"].points : 0;
|
||||
|
||||
let cardName = "";
|
||||
switch (lookup) {
|
||||
case "army":
|
||||
cardName = "Knight";
|
||||
description = (
|
||||
<>
|
||||
<div>
|
||||
When played, you <b>must</b> move the robber.
|
||||
</div>
|
||||
<div>
|
||||
Steal <b>1</b> resource card from the owner of an adjacent settlement or city.
|
||||
</div>
|
||||
<div>You may only play one development card during your turn -- either one knight or one progress card.</div>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "vp":
|
||||
cardName = `Victory Point: ${capitalize(cardActive.card)}`;
|
||||
description = (
|
||||
<>
|
||||
<div>
|
||||
<b>1</b> victory point.
|
||||
</div>
|
||||
<div>
|
||||
You only reveal your victory point cards when the game is over, either when you or an opponent reaches{" "}
|
||||
<b>{points}+</b> victory points on their turn and declares victory!
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "progress-road-1":
|
||||
case "progress-road-2":
|
||||
cardName = "Road Building";
|
||||
description = (
|
||||
<>
|
||||
<div>
|
||||
Play <b>2</b> new roads as if you had just built them.
|
||||
</div>
|
||||
<div>
|
||||
This is still limited by the number of roads you have. If you do not have enough roads remaining, or if there
|
||||
are no valid road building locations, the number of roads you can place will be reduced.
|
||||
</div>
|
||||
<div>
|
||||
You currently have <b>{priv?.roads}</b> roads remaining.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "progress-monopoly":
|
||||
cardName = "Monopoly";
|
||||
description = (
|
||||
<>
|
||||
<div>
|
||||
When you play this card, you will select <b>1</b> type of resource. All other players must give you all their
|
||||
resource cards of that type.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "progress-year-of-plenty":
|
||||
cardName = "Year of Plenty";
|
||||
description = (
|
||||
<>
|
||||
<div>
|
||||
Take any <b>2</b> resources from the bank. Add them to your hand. They can be
|
||||
<b>2</b> of the same resource or <b>1</b> of two differ resources.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
description = <>Unknown card type {lookup}</>;
|
||||
break;
|
||||
}
|
||||
|
||||
let canPlay = false;
|
||||
if (cardActive.type === "vp") {
|
||||
let points = priv?.points || 0;
|
||||
priv?.development?.forEach((item: any) => {
|
||||
if (item.type === "vp") {
|
||||
points++;
|
||||
}
|
||||
});
|
||||
canPlay = points >= points;
|
||||
if (!canPlay && !cardActive.played) {
|
||||
description = (
|
||||
<>
|
||||
{description}
|
||||
<div>
|
||||
You do not have enough victory points to play this card yet. You can currently reach <b>{points}</b> points.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
canPlay = cardActive.turn < turns;
|
||||
if (!canPlay) {
|
||||
description = (
|
||||
<>
|
||||
{description}
|
||||
<div>You can not play this card until your next turn.</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (canPlay) {
|
||||
canPlay = priv?.playedCard !== turns;
|
||||
if (!canPlay) {
|
||||
description = (
|
||||
<>
|
||||
{description}
|
||||
<div>You have already played a development card this turn.</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cardActive.played) {
|
||||
description = (
|
||||
<>
|
||||
{description}
|
||||
<div>You have already played this card.</div>
|
||||
</>
|
||||
);
|
||||
canPlay = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ViewCard">
|
||||
<Paper>
|
||||
<div className="Title">{cardName}</div>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<Resource type={`${cardActive.type}-${cardActive.card}`} disabled count={1} />
|
||||
<div className="Description">{description}</div>
|
||||
</div>
|
||||
{!cardActive.played && (
|
||||
<Button disabled={!canPlay} onClick={playCard}>
|
||||
play
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={close}>close</Button>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ViewCard };
|
238
client/src/Winner.tsx
Normal file
238
client/src/Winner.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
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 "./Winner.css";
|
||||
|
||||
import { Resource } from "./Resource";
|
||||
import { PlayerColor } from "./PlayerColor";
|
||||
import { GlobalContext } from "./GlobalContext";
|
||||
|
||||
interface WinnerProps {
|
||||
winnerDismissed: boolean;
|
||||
setWinnerDismissed: (dismissed: boolean) => void;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const Winner: React.FC<WinnerProps> = ({ winnerDismissed, setWinnerDismissed }) => {
|
||||
const { ws } = useContext(GlobalContext);
|
||||
const [winner, setWinner] = useState<any>(undefined);
|
||||
const [state, setState] = useState<string | undefined>(undefined);
|
||||
const fields = useMemo(() => ["winner", "state"], []);
|
||||
const onWsMessage = (event: MessageEvent) => {
|
||||
const data: { type: string; update: any } = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "game-update":
|
||||
console.log(`winner - game update`, data.update);
|
||||
if ("winner" in data.update && !equal(data.update.winner, winner)) {
|
||||
setWinner(data.update.winner);
|
||||
}
|
||||
if ("state" in data.update && data.update.state !== state) {
|
||||
if (data.update.state !== "winner") {
|
||||
setWinner(undefined);
|
||||
}
|
||||
setWinnerDismissed(false);
|
||||
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 quitClicked = useCallback(() => {
|
||||
if (!winnerDismissed) {
|
||||
setWinnerDismissed(true);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "goto-lobby",
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [ws, winnerDismissed, setWinnerDismissed]);
|
||||
|
||||
if (!winner || winnerDismissed) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let losers = [];
|
||||
for (const key in winner.players) {
|
||||
if (key === winner.color || winner.players[key].status === "Not active") {
|
||||
continue;
|
||||
}
|
||||
losers.push(winner.players[key]);
|
||||
}
|
||||
|
||||
const turnCount = Math.floor(winner.turns / (losers.length + 1));
|
||||
|
||||
losers = losers.map((player: any) => {
|
||||
const averageSeconds = Math.floor(player.totalTime / turnCount / 1000),
|
||||
average = `${Math.floor(averageSeconds / 60)}m:${averageSeconds % 60}s`;
|
||||
return (
|
||||
<div key={player.color}>
|
||||
<PlayerColor color={player.color} /> {player.name} finished with {player.points} victory points.
|
||||
{Number(player.potential) !== 0 && (
|
||||
<>
|
||||
They had <b>{player.potential}</b> unplayed Victory Point card(s).
|
||||
</>
|
||||
)}
|
||||
Their average turn time was {average}.
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
let robber;
|
||||
let max = 0;
|
||||
|
||||
let playerStolen: any = {};
|
||||
const stats = winner.stolen;
|
||||
for (const player in stats) {
|
||||
if (player === "total" || player === "player") {
|
||||
continue;
|
||||
}
|
||||
if (player === "robber") {
|
||||
robber = <></>;
|
||||
for (const type in stats.robber.stole) {
|
||||
if (type === "total") {
|
||||
continue;
|
||||
}
|
||||
const count = stats.robber.stole[type];
|
||||
robber = (
|
||||
<>
|
||||
{robber}
|
||||
<Resource label={true} type={type} count={count} disabled />
|
||||
</>
|
||||
);
|
||||
}
|
||||
robber = (
|
||||
<div>
|
||||
Throughout the game, the robber blocked <b>{stats.robber.stole.total}</b> resources:
|
||||
<div className="ThiefStole">{robber}</div>
|
||||
</div>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats[player].stolen.total < max) {
|
||||
continue;
|
||||
}
|
||||
if (stats[player].stolen.total > max) {
|
||||
max = stats[player].stolen.total;
|
||||
playerStolen = {
|
||||
robber: stats[player].stolen.robber,
|
||||
player: stats[player].stolen.player,
|
||||
element: <></>,
|
||||
};
|
||||
}
|
||||
|
||||
let stolen;
|
||||
for (const type in stats[player].stolen) {
|
||||
if (["total", "robber", "player"].indexOf(type) !== -1) {
|
||||
continue;
|
||||
}
|
||||
if (!stolen) {
|
||||
stolen = <></>;
|
||||
}
|
||||
const count = stats[player].stolen[type];
|
||||
stolen = (
|
||||
<>
|
||||
{stolen}
|
||||
<Resource label={true} type={type} count={count} disabled />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (stolen) {
|
||||
playerStolen.element = (
|
||||
<div key={player}>
|
||||
<PlayerColor color={player} /> {winner.players[player].name}
|
||||
<div className="PlayerStolen">{stolen}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!robber) {
|
||||
robber = <div>The robber never blocked any resources from anyone!</div>;
|
||||
}
|
||||
|
||||
const averageSeconds = Math.floor(winner.totalTime / turnCount / 1000),
|
||||
average = `${Math.floor(averageSeconds / 60)}m:${averageSeconds % 60}s`;
|
||||
|
||||
const seconds = winner.elapsedTime / 1000,
|
||||
h = Math.floor(seconds / (60 * 60)),
|
||||
m = Math.floor((seconds % (60 * 60)) / 60),
|
||||
s = Math.floor((seconds % (60 * 60)) % 60);
|
||||
const totalTime = `${h}h:${m}m:${s}s`;
|
||||
|
||||
const vpType: string[] = ["market", "university", "library", "palace"];
|
||||
const selectedVpType = vpType[Math.floor(vpType.length * Math.random())];
|
||||
|
||||
return (
|
||||
<div className="Winner">
|
||||
<Paper>
|
||||
<div className="Title">
|
||||
{winner.name} has won with {winner.points} victory points!
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<Resource type={`vp-${selectedVpType}`} disabled count={1} />
|
||||
<div className="Description">
|
||||
<div>
|
||||
Congratulations, <b>{winner.name}</b>!
|
||||
</div>
|
||||
<div>
|
||||
<PlayerColor color={winner.color} /> {winner.name} won the game with <b>{winner.points}</b> Victory Points
|
||||
after {turnCount} game turns.
|
||||
{Number(winner.potential) !== 0 && (
|
||||
<>
|
||||
They had <b>{winner.potential}</b> unplayed Victory Point card(s).
|
||||
</>
|
||||
)}
|
||||
Their average turn time was {average}.
|
||||
</div>
|
||||
{losers}
|
||||
<div>The game took {totalTime}.</div>
|
||||
{robber}
|
||||
{max !== 0 && (
|
||||
<>
|
||||
<div>
|
||||
The robber stole {playerStolen.robber} and other players stole {playerStolen.player} resources from:
|
||||
</div>
|
||||
<div className="PlayerStolenList">{playerStolen.element}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={quitClicked}>Go back to Lobby</Button>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Winner };
|
13
client/src/global.d.ts
vendored
Normal file
13
client/src/global.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
declare module '*.css';
|
||||
|
||||
declare module 'Trade' {
|
||||
const Trade: unknown;
|
||||
export default Trade;
|
||||
}
|
||||
|
||||
declare module '*.svg';
|
||||
|
||||
declare module "./Trade" {
|
||||
const Trade: unknown;
|
||||
export default Trade;
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
// Run our app under the /base URL.
|
||||
const history = createBrowserHistory({
|
||||
// basename: process.env.PUBLIC_URL
|
||||
});/*,
|
||||
push = history.push;
|
||||
|
||||
history.push = (path) => {
|
||||
const base = new URL(document.querySelector("base") ? document.querySelector("base").href : "");
|
||||
push(base.pathname + path);
|
||||
};*/
|
||||
|
||||
export default history;
|
21
client/src/index.tsx
Normal file
21
client/src/index.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log("DEVELOPMENT mode!");
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (rootEl) {
|
||||
const root = ReactDOM.createRoot(rootEl);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
reportWebVitals();
|
@ -1,27 +0,0 @@
|
||||
|
||||
Game Order
|
||||
---
|
||||
|
||||
If game-order active, show Game Order dialog.
|
||||
|
||||
Game Order dialog:
|
||||
|
||||
* List all active players
|
||||
* List each active player's roll
|
||||
* Sort order as player roll comes in
|
||||
* Message indicates what is going on
|
||||
|
||||
Roll dice
|
||||
|
||||
R 6
|
||||
O 5
|
||||
W 5
|
||||
B 3
|
||||
|
||||
O 6
|
||||
W 6
|
||||
|
||||
O 3
|
||||
W 5
|
||||
|
||||
Final order: R W O B
|
@ -1,5 +1,6 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const reportWebVitals = (onPerfEntry?: (metric: any) => void) => {
|
||||
if (onPerfEntry && typeof onPerfEntry === 'function') {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
@ -10,4 +11,4 @@ const reportWebVitals = onPerfEntry => {
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
export default reportWebVitals;
|
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires, no-undef, @typescript-eslint/no-explicit-any */
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
module.exports = function(app: any) {
|
||||
const base = process.env.PUBLIC_URL;
|
||||
console.log(`http-proxy-middleware ${base}`);
|
||||
app.use(createProxyMiddleware(
|
||||
@ -9,4 +10,4 @@ module.exports = function(app) {
|
||||
target: 'http://localhost:8930',
|
||||
changeOrigin: true,
|
||||
}));
|
||||
};
|
||||
};
|
@ -2,4 +2,4 @@
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom';
|
8
client/tsconfig.app.json
Normal file
8
client/tsconfig.app.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
17
client/tsconfig.json
Normal file
17
client/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "build"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user