1
0

Trying to get it functional again

This commit is contained in:
James Ketr 2025-09-23 19:08:23 -07:00
parent b87d400bf7
commit 39de15a7ab
37 changed files with 41 additions and 5636 deletions

8
Dockerfile.dev Normal file
View File

@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /server
# For dev, we install in container, but to speed up, perhaps copy package and install
# But since volumes mount, just run npm install in command
CMD ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]

View File

@ -18,6 +18,7 @@ WORKDIR /
# Copy built server # Copy built server
COPY --from=builder /server/dist ./server/dist COPY --from=builder /server/dist ./server/dist
COPY --from=builder /server/node_modules ./server/node_modules
COPY server/package*.json /server/ COPY server/package*.json /server/
WORKDIR /server WORKDIR /server

View File

@ -2,7 +2,7 @@
"name": "peddlers-client", "name": "peddlers-client",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"proxy": "http://peddlers-of-ketran:8930", "proxy": "http://peddlers-server:8930",
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",

View File

@ -1,257 +0,0 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import equal from "fast-deep-equal";
import "./Actions.css";
import { PlayerName } from './PlayerName.js';
import { GlobalContext } from "./GlobalContext.js";
const Actions = ({
tradeActive, setTradeActive,
buildActive, setBuildActive,
houseRulesActive, setHouseRulesActive
}) => {
const { ws, gameId, name } = useContext(GlobalContext);
const [state, setState] = useState('lobby');
const [color, setColor] = useState(undefined);
const [priv, setPriv] = useState(undefined);
const [turn, setTurn] = useState({});
const [edit, setEdit] = useState(name);
const [active, setActive] = useState(0);
const [players, setPlayers] = useState({});
const [alive, setAlive] = useState(0);
const fields = useMemo(() => [
'state', 'turn', 'private', 'active', 'color', 'players'
], []);
const onWsMessage = (event) => {
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 => 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 = useCallback((data) => {
if (!ws) {
console.warn(`No socket`);
} else {
ws.send(JSON.stringify(data));
}
}, [ws]);
const buildClicked = () => {
setBuildActive(!buildActive);
};
useEffect(() => {
let count = 0;
for (let key in players) {
if (players[key].live) {
count++;
}
}
setAlive(count);
}, [players, setAlive]);
const setName = (update) => {
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'),
discards = { wheat: 0, brick: 0, sheep: 0, stone: 0, wood: 0 };
for (let i = 0; i < nodes.length; i++) {
discards[nodes[i].getAttribute("data-type")]++;
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 = () => {
/* sendMessage({ type: 'house-rules' }); */
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>
</> }
{ /* inLobby &&
<Button onClick={quitClick}>Quit</Button>
*/ }
</div>
</Paper>
);
}
export { Actions };

View File

@ -1,229 +0,0 @@
import React, { useState, useContext, useMemo, useEffect, useRef } from "react";
import equal from "fast-deep-equal";
import "./Activities.css";
import { PlayerColor } from './PlayerColor.js';
import { Dice } from './Dice.js';
import { GlobalContext } from "./GlobalContext.js";
const Activity = ({ 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 = () => {
const { ws } = useContext(GlobalContext);
const [activities, setActivities] = useState([]);
const [turn, setTurn] = useState();
const [color, setColor] = useState();
const [players, setPlayers] = useState({});
const [timestamp, setTimestamp] = useState(0);
const [state, setState] = useState('');
const fields = useMemo(() => [
'activities', 'turn', 'players', 'timestamp', 'color',
'state'
], []);
const requestUpdate = (fields) => {
let request;
if (!Array.isArray(fields)) {
request = [ fields ];
} else {
request = fields;
}
ws.send(JSON.stringify({
type: 'get',
fields: request
}));
};
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
const ignoring = [], processing = [];
for (let 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 ('state' in data.update
&& data.update.state !== state) {
requestUpdate('turn');
setState(data.update.state);
}
if ('activities' in data.update
&& !equal(data.update.activities, activities)) {
setActivities(data.update.activities);
}
if ('turn' in data.update
&& !equal(data.update.turn, turn)) {
setTurn(data.update.turn);
}
if ('players' in data.update
&& !equal(data.update.players, players)) {
setPlayers(data.update.players);
}
if ('timestamp' in data.update
&& data.update.timestamp !== timestamp) {
setTimestamp(data.update.timestamp);
}
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 => 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 (!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);
let discarders = [], mustDiscard = false;
for (let 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>);
}
let list = activities
.filter((activity, index) =>
activities.length - 1 === index || timestamp - activity.date < 11000);
list = list.map((activity, index) => {
return <Activity keep={list.length - 1 === index} key={activity.date} activity={activity}/>;
});
let who;
if (turn && turn.select) {
const selecting = [];
for (let 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};

View File

@ -1,567 +0,0 @@
import React, { useState, useCallback, useEffect, useRef } from "react";
import {
BrowserRouter as Router,
Route,
Routes,
useParams
} from "react-router-dom";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import { GlobalContext } from "./GlobalContext.js";
//import { PingPong } from "./PingPong.js";
import { PlayerList } from "./PlayerList.js";
import { Chat } from "./Chat.js";
import { Board } from "./Board.js";
import { Actions } from "./Actions.js";
import { base, gamesPath } from './Common.js';
import { GameOrder } from "./GameOrder.js";
import { Activities } from "./Activities.js";
import { SelectPlayer } from "./SelectPlayer.js";
import { PlayersStatus } from "./PlayersStatus.js";
import { ViewCard } from "./ViewCard.js";
import { ChooseCard } from "./ChooseCard.js";
import { Hand } from "./Hand.js";
import { Trade } from "./Trade.js";
import { Winner } from "./Winner.js";
import { HouseRules } from "./HouseRules.js";
import { Dice } from "./Dice.js";
import { assetsPath } from "./Common.js";
//import { Sheep } from "./Sheep.js";
import history from "./history.js";
import "./App.css";
import equal from "fast-deep-equal";
/*
const Pip = () => {
<div className="Pip"
style={{
backgroundImage: `url(${assetsPath}/gfx/pip-numbers.png)`,
backgroundPositionX: `${100. * (pip.order % 6) / 5.}%`,
backgroundPositionY: `${100 * Math.floor(pip.order / 6) / 5.}%`
}}
><div className="Pip-Shape" /></div>
}
*/
let audioEffects = {
};
const loadAudio = (src) => {
const audio = document.createElement("audio");
audio.src = `${assetsPath}/${src}`;
audio.setAttribute("preload", "auto");
audio.setAttribute("controls", "none");
audio.style.display = "none";
document.body.appendChild(audio);
audio.play();
audio.hasPlayed = true;
return audio;
}
const Table = () => {
const params = useParams();
const [ gameId, setGameId ] = useState(params.gameId ? params.gameId : undefined);
const [ ws, setWs ] = useState(); /* tracks full websocket lifetime */
const [connection, setConnection] = useState(undefined); /* set after ws is in OPEN */
const [retryConnection, setRetryConnection] = useState(true); /* set when connection should be re-established */
const [ name, setName ] = useState("");
const [ error, setError ] = useState(undefined);
const [ warning, setWarning ] = useState(undefined);
const [loaded, setLoaded] = useState(false);
const [dice, setDice] = useState(undefined);
const [state, setState] = useState(undefined);
const [color, setColor] = useState(undefined);
const [priv, setPriv] = useState(undefined);
const [turn, setTurn] = useState(undefined);
const [buildActive, setBuildActive] = useState(false);
const [tradeActive, setTradeActive] = useState(false);
const [cardActive, setCardActive] = useState(undefined);
const [houseRulesActive, setHouseRulesActive] = useState(undefined);
const [winnerDismissed, setWinnerDismissed] = useState(undefined);
const [global, setGlobal] = useState({});
const [count, setCount] = useState(0);
const [audio, setAudio] = useState(
localStorage.getItem('audio') ?
localStorage.getItem('audio') : false);
const [animations, setAnimations] = useState(
localStorage.getItem('animations') || false);
const [volume, setVolume] = useState(
localStorage.getItem('volume') ?
parseFloat(localStorage.getItem('volume')) : 0.5);
const fields = [ 'id', 'state', 'color', 'name', 'private', 'dice', 'turn' ];
const onWsOpen = (event) => {
console.log(`ws: open`);
setError("");
/* We do not set the socket as connected until the 'open' message
* comes through */
setConnection(ws);
/* Request a full game-update
* We only need gameId and name for App.js, however in the event
* of a network disconnect, we need to refresh the entire game
* state on reload so all bound components reflect the latest
* state */
event.target.send(JSON.stringify({
type: 'game-update'
}));
event.target.send(JSON.stringify({
type: 'get',
fields
}));
};
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
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(() => {
console.log(`todo: stack warnings in a window and have them disappear one at a time.`);
console.log(`app - clearing warning`);
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) {
console.log(`App - setting name (via private): ${priv.name}`);
setName(priv.name);
}
if (priv.color !== color) {
console.log(`App - setting color (via private): ${priv.color}`);
setColor(priv.color);
}
setPriv(priv);
}
if ('name' in data.update) {
if (data.update.name) {
console.log(`App - setting name: ${data.update.name}`);
setName(data.update.name);
} else {
setWarning("");
setError("");
setPriv(undefined);
}
}
if ('id' in data.update && data.update.id !== gameId) {
console.log(`App - setting gameId ${data.update.id}`);
setGameId(data.update.id);
}
if ('state' in data.update && data.update.state !== state) {
console.log(`App - setting game state: ${data.update.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) {
console.log(`App - setting color: ${color}`);
setColor(data.update.color);
}
break;
default:
break;
}
};
const sendUpdate = (update) => {
ws.send(JSON.stringify(update));
};
const cbResetConnection = useCallback(() => {
let timer = 0;
function reset() {
timer = 0;
setRetryConnection(true);
};
return _ => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(reset, 5000);
};
}, [setRetryConnection]);
const resetConnection = cbResetConnection();
if (global.ws !== connection
|| global.name !== name
|| global.gameId !== gameId) {
console.log(`board - (app) - setting global`, global, {
ws: connection,
name,
gameId
});
setGlobal({
ws: connection,
name,
gameId
});
}
const onWsError = (event) => {
console.error(`ws: error`, event);
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 = (event) => {
const error = `Connection to Ketr Ketran game was lost. ` +
`Attempting to reconnect...`;
console.warn(`ws: close`);
setError(error);
setGlobal(Object.assign({}, global, { ws: undefined }));
setWs(undefined); /* clear the socket */
setConnection(undefined); /* clear the connection */
resetConnection();
};
/* callback refs are used to provide correct state reference
* in the callback handlers, while also preventing rebinding
* of event handlers on every render */
const refWsOpen = useRef(onWsOpen);
useEffect(() => { refWsOpen.current = onWsOpen; });
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
const refWsClose = useRef(onWsClose);
useEffect(() => { refWsClose.current = onWsClose; });
const refWsError = useRef(onWsError);
useEffect(() => { refWsError.current = onWsError; });
/* This effect is responsible for triggering a new game load if a
* game id is not provided in the URL. If the game is provided
* in the URL, the backend will create a new game if necessary
* during the WebSocket connection sequence.
*
* This should be the only HTTP request made from the game.
*/
useEffect(() => {
if (gameId) {
console.log(`Game in use ${gameId}`)
return;
}
console.log(`Requesting new game.`);
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.`;
console.error(error);
setError(error);
throw new Error(error);
}
return res.json();
}).then((update) => {
if (update.id !== gameId) {
console.log(`Game available: ${update.id}`);
history.push(`${gamesPath}/${update.id}`);
setGameId(update.id);
}
}).catch((error) => {
console.error(error);
});
}, [ gameId, setGameId ]);
/* Once a game id is known, create the sole WebSocket connection
* to the backend. This WebSocket is then shared with any component
* that performs game state updates. Those components should
* bind to the 'message:game-update' WebSocket event and parse
* their update information from those messages
*/
useEffect(() => {
if (!gameId) {
return;
}
const unbind = () => {
console.log(`table - unbind`);
}
console.log(`table - bind`);
if (!ws && !connection && retryConnection) {
let loc = window.location, 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}`;
console.log(`Attempting WebSocket connection to ${new_uri}`);
setWs(new WebSocket(new_uri));
setConnection(undefined);
setRetryConnection(false);
setCount(count + 1);
return unbind;
}
if (!ws) {
return unbind;
}
const cbOpen = e => refWsOpen.current(e);
const cbMessage = e => refWsMessage.current(e);
const cbClose = e => refWsClose.current(e);
const cbError = e => 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
]);
console.log(`board - (app) - Render with ws: ${ws ? '!' : ''}NULL, connection: ${connection ? '!' : ''}NULL`);
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 (let 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/>
<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={(e) => {
const value = !audio;
localStorage.setItem('audio', 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 = e.currentTarget.value / 100;
localStorage.setItem('volume', alpha);
setVolume(alpha);
}}/>
<div>Animations</div> <input type="checkbox" id="animations"
name="animations" defaultChecked={animations ? true : false} onInput={(e) => {
const value = !animations;
localStorage.setItem('animations', value);
setAnimations(value)
}} />
</Paper>}
{ name !== "" && <PlayerList/> }
<Trade {...{tradeActive, setTradeActive}}/>
{ name !== "" && <Chat/> }
{ /* name !== "" && <VideoFeeds/> */ }
{ loaded && <Actions {
...{buildActive, setBuildActive,
tradeActive, setTradeActive,
houseRulesActive, setHouseRulesActive
}}/> }
</div>
</div>
</GlobalContext.Provider>;
};
const App = () => {
const [playerId, setPlayerId] = useState(undefined);
const [error, setError] = useState(undefined);
useEffect(() => {
if (playerId) {
return;
}
window.fetch(`${base}/api/v1/games/`, {
method: 'GET',
cache: 'no-cache',
credentials: 'same-origin', /* include cookies */
headers: {
'Content-Type': 'application/json'
},
}).then((res) => {
if (res.status >= 400) {
const error = `Unable to connect to Ketr Ketran game server! ` +
`Try refreshing your browser in a few seconds.`;
console.error(error);
setError(error);
}
console.log(res.headers);
return res.json();
}).then((data) => {
setPlayerId(data.player);
}).catch((error) => {
});
}, [playerId, setPlayerId]);
if (!playerId) {
return <>{ error }</>;
}
return (
<Router>
<Routes>
<Route exact element={<Table/>} path={`${base}/:gameId`}/>
<Route exact element={<Table/>} path={`${base}`}/>
</Routes>
</Router>
);
}
export default App;

View File

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -9,7 +9,7 @@ import { PlayerList } from "./PlayerList";
import { Chat } from "./Chat"; import { Chat } from "./Chat";
import { Board } from "./Board"; import { Board } from "./Board";
import { Actions } from "./Actions"; import { Actions } from "./Actions";
import { base, gamesPath } from "./Common"; import { base } from "./Common";
import { GameOrder } from "./GameOrder"; import { GameOrder } from "./GameOrder";
import { Activities } from "./Activities"; import { Activities } from "./Activities";
import { SelectPlayer } from "./SelectPlayer"; import { SelectPlayer } from "./SelectPlayer";

View File

@ -1,88 +0,0 @@
import React, { useEffect, useState, useCallback } from "react";
import { assetsPath } from "./Common.js";
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 => {
// Use useRef for mutable variables that we want to persist
// without triggering a re-render on their change
const requestRef = React.useRef();
const animate = time => {
callback(time)
requestRef.current = requestAnimationFrame(animate);
}
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []); // Make sure the effect runs only once
}
const Bird = ({ origin, 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 = React.useRef();
useAnimationFrame(time => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
setTime(deltaTime);
} else {
previousTimeRef.current = time;
}
});
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, setCell, speed, rotation, setDirection]);
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 = ({count, style}) => {
const [birds, setBirds] = useState([]);
useEffect(() => {
const tmp = [];
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, setBirds]);
return <div className="Flock" style={style}>{ birds }</div>;
};
export { Bird, Flock };

View File

@ -1,867 +0,0 @@
import React, { useEffect, useState, useContext, useRef, useMemo } from "react";
import equal from "fast-deep-equal";
import { assetsPath } from "./Common.js";
import "./Board.css";
import { GlobalContext } from "./GlobalContext.js";
import { Flock } from "./Bird.js";
import { Herd } from "./Sheep.js";
const rows = [3, 4, 5, 4, 3, 2]; /* The final row of 2 is to place roads and corners */
const
hexRatio = 1.1547,
tileWidth = 67,
tileHalfWidth = tileWidth * 0.5,
tileHeight = tileWidth * hexRatio,
tileHalfHeight = tileHeight * 0.5,
radius = tileHeight * 2,
borderOffsetX = 86, /* ~1/10th border image width... hand tuned */
borderOffsetY = 3;
/* Actual sizing */
const
tileImageWidth = 90, /* Based on hand tuned and image width */
tileImageHeight = tileImageWidth/hexRatio,
borderImageWidth = (2 + 2/3) * tileImageWidth, /* 2.667 * .Tile.width */
borderImageHeight = borderImageWidth * 0.29; /* 0.29 * .Border.height */
const showTooltip = () => {
document.querySelector('.Board .Tooltip').style.display = 'flex';
};
const clearTooltip = () => {
document.querySelector('.Board .Tooltip').style.display = 'none';
};
const Board = ({ animations }) => {
const { ws } = useContext(GlobalContext);
const board = useRef();
const [transform, setTransform] = useState(1.);
const [pipElements, setPipElements] = useState(<></>);
const [borderElements, setBorderElements] = useState(<></>);
const [tileElements, setTileElements] = useState(<></>);
const [cornerElements, setCornerElements] = useState(<></>);
const [roadElements, setRoadElements] = useState(<></>);
const [ signature, setSignature ] = useState("");
const [ generated, setGenerated ] = useState("");
const [ robber, setRobber ] = useState(-1);
const [ robberName, setRobberName ] = useState([]);
const [ pips, setPips ] = useState();
const [ pipOrder, setPipOrder ] = useState();
const [ borders, setBorders ] = useState();
const [ borderOrder, setBorderOrder ] = useState();
const [animationSeeds, setAnimationSeeds] = useState();
const [ tiles, setTiles ] = useState();
const [ tileOrder, setTileOrder ] = useState([]);
const [ placements, setPlacements ] = useState(undefined);
const [ turn, setTurn ] = useState({});
const [ state, setState ] = useState("");
const [ color, setColor ] = useState("");
const [ rules, setRules ] = useState({});
const [ longestRoadLength, setLongestRoadLength ] = useState(0);
const fields = useMemo(() => [
'signature', 'robber', 'robberName',
'pips', 'pipOrder', 'borders', 'borderOrder', 'tiles', 'tileOrder',
'placements', 'turn', 'state', 'color', 'longestRoadLength',
'rules', 'animationSeeds'
], []);
console.log(`board - ws`, ws);
const onWsMessage = (event) => {
if (ws && ws !== event.target) {
console.error(`Disconnect occur?`);
}
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
console.log(`board - game update`, data.update);
if ('robber' in data.update && data.update.robber !== robber) {
setRobber(data.update.robber);
}
if ('robberName' in data.update
&& data.update.robberName !== robberName) {
setRobberName(data.update.robberName);
}
if ('state' in data.update && data.update.state !== state) {
setState(data.update.state);
}
if ('rules' in data.update
&& !equal(data.update.rules, rules)) {
setRules(data.update.rules);
}
if ('color' in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ('longestRoadLength' in data.update
&& data.update.longestRoadLength !== longestRoadLength) {
setLongestRoadLength(data.update.longestRoadLength);
}
if ('turn' in data.update) {
if (!equal(data.update.turn, turn)) {
console.log(`board - turn`, data.update.turn);
setTurn(data.update.turn);
}
}
if ('placements' in data.update && !equal(data.update.placements, placements)) {
console.log(`board - placements`, data.update.placements);
setPlacements(data.update.placements);
}
/* The following are only updated if there is a new game
* signature */
if ('pipOrder' in data.update && !equal(data.update.pipOrder, pipOrder)) {
console.log(`board - setting new pipOrder`);
setPipOrder(data.update.pipOrder);
}
if ('borderOrder' in data.update && !equal(data.update.borderOrder, borderOrder)) {
console.log(`board - setting new borderOrder`);
setBorderOrder(data.update.borderOrder);
}
if ('animationSeeds' in data.update && !equal(data.update.animationSeeds, animationSeeds)) {
console.log(`board - setting new animationSeeds`);
setAnimationSeeds(data.update.animationSeeds);
}
if ('tileOrder' in data.update && !equal(data.update.tileOrder, tileOrder)) {
console.log(`board - setting new tileOrder`);
setTileOrder(data.update.tileOrder);
}
if (data.update.signature !== signature) {
console.log(`board - setting new signature`);
setSignature(data.update.signature);
}
/* This is permanent static data from the server -- do not update
* once set */
if ('pips' in data.update && !pips) {
console.log(`board - setting new static pips`);
setPips(data.update.pips);
}
if ('tiles' in data.update && !tiles) {
console.log(`board - setting new static tiles`);
setTiles(data.update.tiles);
}
if ('borders' in data.update && !borders) {
console.log(`board - setting new static borders`);
setBorders(data.update.borders);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!ws) { return; }
console.log('board - bind');
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
return () => {
console.log('board - unbind');
ws.removeEventListener('message', cbMessage);
}
}, [ws]);
useEffect(() => {
if (!ws) { return; }
ws.send(JSON.stringify({
type: 'get',
fields
}));
}, [ws, fields]);
useEffect(() => {
const boardBox = board.current.querySelector('.BoardBox');
if (boardBox) {
console.log(`board - setting transform scale to ${transform}`);
boardBox.style.transform = `scale(${transform})`;
}
}, [transform]);
const onResize = () => {
if (!board.current) {
return;
}
/* Adjust the 'transform: scale' for the BoardBox
* so the board fills the Board
*
* The board is H tall, and H * hexRatio wide */
const width = board.current.offsetWidth,
height = board.current.offsetHeight;
let _transform;
if (height * hexRatio > width) {
_transform = width / (450. * hexRatio);
} else {
_transform = height / (450.);
}
if (transform !== _transform) {
setTransform(_transform);
}
};
const refOnResize = useRef(onResize);
useEffect(() => { refOnResize.current = onResize; });
useEffect(() => {
const cbOnResize = e => refOnResize.current(e);
window.addEventListener('resize', cbOnResize);
return () => {
window.removeEventListener('resize', cbOnResize);
}
}, [refOnResize]);
onResize();
useEffect(() => {
if (!ws) {
return;
}
console.log(`Generating static corner data... should only occur once per reload or socket reconnect.`);
const onCornerClicked = (event, corner) => {
let type;
if (event.currentTarget.getAttribute('data-type') === 'settlement') {
type = 'place-city';
} else {
type = 'place-settlement';
}
ws.send(JSON.stringify({
type, index: corner.index
}));
};
const Corner = ({corner}) => {
return <div className="Corner"
onMouseMove={(e) => {
if (e.shiftPressed) {
const tooltip = document.querySelector('.Board .Tooltip');
tooltip.innerHTML = `<pre>${corner}</pre>`;
showTooltip();
}
}}
onClick={(event) => { onCornerClicked(event, corner) }}
data-index={corner.index}
style={{
top: `${corner.top}px`,
left: `${corner.left}px`
}}
><div className="Corner-Shape"/></div>;
};
const generateCorners = () => {
let row = 0, rowCount = 0;
let y = -8 + 0.5 * tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth,
x = -tileHalfHeight -(rows[row] - 1) * 0.5 * tileHeight;
let index = 0;
const corners = [];
let corner;
for (let i = 0; i < 21; i++) {
if (row > 2 && rowCount === 0) {
corner = {
index: index++,
top: y-0.5*tileHalfHeight,
left: x-tileHalfHeight
};
corners.push(<Corner key={`corner-${index}}`} corner={corner}/>);
}
corner = {
index: index++,
top: y,
left: x
};
corners.push(<Corner key={`corner-${index}}`} corner={corner}/>);
corner = {
index: index++,
top: y-0.5*tileHalfHeight,
left: x+tileHalfHeight
};
corners.push(<Corner key={`corner-${index}}`} corner={corner}/>);
if (++rowCount === rows[row]) {
corner = {
index: index++,
top: y,
left: x+2.*tileHalfHeight
};
corners.push(<Corner key={`corner-${index}}`} corner={corner}/>);
if (row > 2) {
corner = {
index: index++,
top: y-0.5*tileHalfHeight,
left: x+3.*tileHalfHeight
};
corners.push(<Corner key={`corner-${index}}`} corner={corner}/>);
}
row++;
rowCount = 0;
y += tileHeight - 10.5;
x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight;
} else {
x += tileHeight;
}
}
return corners;
};
setCornerElements(generateCorners());
}, [ws, setCornerElements]);
useEffect(() => {
if (!ws) {
return;
}
console.log(`Generating static road data... should only occur once per reload or socket reconnect.`);
const Road = ({road}) => {
const onRoadClicked = (road) => {
console.log(`Road clicked: ${road.index}`);
if (!ws) { console.error(`board - onRoadClicked - ws is NULL`); return; }
ws.send(JSON.stringify({
type: 'place-road', index: road.index
}));
};
return <div className="Road"
onClick={() => { onRoadClicked(road) }}
data-index={road.index}
style={{
transform: `translate(-50%, -50%) rotate(${road.angle}deg)`,
top: `${road.top}px`,
left: `${road.left}px`
}}
><div className="Road-Shape"/></div>;
};
const generateRoads = () => {
let row = 0, rowCount = 0;
let y = -2.5 + tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth,
x = -tileHalfHeight -(rows[row] - 1) * 0.5 * tileHeight;
let index = 0;
let road;
const corners = [];
for (let i = 0; i < 21; i++) {
const lastRow = row === rows.length - 1;
if (row > 2 && rowCount === 0) {
road = {
index: index++,
angle: -60,
top: y-0.5*tileHalfHeight,
left: x-tileHalfHeight
};
corners.push(<Road key={`road-${index}}`} road={road}/>);
}
road = {
index: index++,
angle: 240,
top: y,
left: x
};
corners.push(<Road key={`road-${index}}`} road={road}/>);
road = {
index: index++,
angle: -60,
top: y-0.5*tileHalfHeight,
left: x+tileHalfHeight
};
corners.push(<Road key={`road-${index}}`} road={road}/>);
if (!lastRow) {
road = {
index: index++,
angle: 0,
top: y,
left: x
};
corners.push(<Road key={`road-${index}}`} road={road}/>);
}
if (++rowCount === rows[row]) {
if (!lastRow) {
road = {
index: index++,
angle: 0,
top: y,
left: x+2.*tileHalfHeight
};
corners.push(<Road key={`road-${index}}`} road={road}/>);
}
if (row > 2) {
road = {
index: index++,
angle: 60,
top: y-0.5*tileHalfHeight,
left: x+3.*tileHalfHeight
};
corners.push(<Road key={`road-${index}}`} road={road}/>);
}
row++;
rowCount = 0;
y += tileHeight - 10.5;
x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight;
} else {
x += tileHeight;
}
}
return corners;
}
setRoadElements(generateRoads());
}, [ws, setRoadElements]);
/* Generate Pip, Tile, and Border elements */
useEffect(() => {
if (!ws) {
return;
}
console.log(`board - Generate pip, border, and tile elements`);
const Pip = ({pip, className}) => {
const onPipClicked = (pip) => {
if (!ws) { console.error(`board - sendPlacement - ws is NULL`); return; }
ws.send(JSON.stringify({
type: 'place-robber', index: pip.index
}));
};
return <div className={`Pip ${className}`}
onClick={() => { onPipClicked(pip) }}
data-roll={pip.roll}
data-index={pip.index}
style={{
top: `${pip.top}px`,
left: `${pip.left}px`,
backgroundImage: `url(${assetsPath}/gfx/pip-numbers.png)`,
backgroundPositionX: `${ 100. * (pip.order % 6) / 5.}%`,
backgroundPositionY: `${ 100 * Math.floor(pip.order / 6) / 5. }%`
}}
><div className="Pip-Shape"/></div>;
}
const generatePips = function (pipOrder) {
let row = 0, rowCount = 0;
let y = tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth,
x = -(rows[row] - 1) * 0.5 * tileHeight;
let index = 0;
let pip;
return pipOrder.map(order => {
let volcano = false;
pip = {
roll: pips[order].roll,
index: index++,
top: y,
left: x,
order: order
};
if ('volcano' in rules
&& rules[`volcano`].enabled
&& pip.roll === 7) {
pip.order = pips.findIndex(
pip => pip.roll === rules[`volcano`].number);
pip.roll = rules[`volcano`].number;
volcano = true;
}
const div = <Pip className={volcano ? "Volcano" : ""}
pip={pip}
key={`pip-${order}`}
/>;
if (++rowCount === rows[row]) {
row++;
rowCount = 0;
y += tileWidth;
x = - (rows[row] - 1) * 0.5 * tileHeight;
} else {
x += tileHeight;
}
return div;
});
};
const Tile = ({tile}) => {
const style = {
top: `${tile.top}px`,
left: `${tile.left}px`,
width: `${tileImageWidth}px`,
height: `${tileImageHeight}px`,
backgroundImage: `url(${assetsPath}/gfx/tiles-${tile.type}.png)`,
backgroundPositionY: `-${tile.card * tileHeight}px`
};
if (tile.type === 'volcano') {
style.transform = `rotate(-90deg)`;
style.top = `${tile.top + 6}px`;
style.transformOrigin = '0% 50%';
}
return <div className="Tile"
data-index={tile.index}
style={{...style}}
><div className="Tile-Shape"/></div>;
};
const generateTiles = function (tileOrder, animationSeeds) {
let row = 0, rowCount = 0;
let y = tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth,
x = -(rows[row] - 1) * 0.5 * tileHeight;
let index = 0;
return tileOrder.map(order => {
const tile = Object.assign({},
tiles[order],
{ index: index++, left: x, top: y});
const volcanoActive = 'volcano' in rules
&& rules[`volcano`].enabled;
if ('tiles-start-facing-down' in rules
&& rules[`tiles-start-facing-down`].enabled
&& state !== 'normal'
&& state !== 'volcano'
&& state !== 'winner'
&& (!volcanoActive || tile.type !== 'desert')) {
tile.type = 'jungle';
tile.card = 0;
}
if (volcanoActive
&& tile.type === 'desert') {
tile.type = 'volcano';
tile.card = 0;
}
let div;
if (tile.type === 'wheat') {
div = <div key={`tile-${order}`}>
{ animations &&
<Flock count={Math.floor(1 + animationSeeds[index] * 2)}
style={{
top: `${tile.top - tileImageHeight * 0.5}px`,
left: `${tile.left - tileImageWidth * 0.5}px`,
width: `${tileImageWidth}px`,
height: `${tileImageHeight}px`
}}/> } <Tile
tile={tile}
/></div>;
} else if (tile.type === 'sheep') {
div = <div key={`tile-${order}`}>
{ animations &&
<Herd count={Math.floor(1 + animationSeeds[index] * 4)}
style={{
top: `${tile.top - tileImageHeight * 0.5}px`,
left: `${tile.left - tileImageWidth * 0.5}px`,
width: `${tileImageWidth}px`,
height: `${tileImageHeight}px`
}} />
}<Tile
tile={tile}
/></div>;
} else {
div = <Tile
key={`tile-${order}`}
tile={tile}
/>;
};
if (++rowCount === rows[row]) {
row++;
rowCount = 0;
y += tileWidth;
x = - (rows[row] - 1) * 0.5 * tileHeight;
} else {
x += tileHeight;
}
return div;
});
};
const calculateBorderSlot = (side, e) => {
const borderBox = document.querySelector('.Borders').getBoundingClientRect();
let angle = (360 + Math.floor(90 + Math.atan2(e.pageY - borderBox.top, e.pageX - borderBox.left) * 180 / Math.PI)) % 360 - (side * 60);
if (angle > 180) {
angle = angle - 360;
}
let slot = 0;
if (angle > -20 && angle < 5) {
slot = 1;
} else if (angle > 5) {
slot = 2;
}
return slot;
}
const mouseEnter = (border, side, e) => {
const slot = calculateBorderSlot(side, e);
if (!border[slot]) {
clearTooltip();
return;
}
const tooltip = document.querySelector('.Board .Tooltip');
tooltip.textContent = border[slot] === 'bank'
? '3 of one kind for 1 resource' :
`2 ${border[slot]} for 1 resource`;
tooltip.style.top = `${e.pageY}px`;
tooltip.style.left = `${e.pageX + 16}px`;
showTooltip();
};
const mouseMove = (border, side, e) => {
const slot = calculateBorderSlot(side, e);
if (!border[slot]) {
clearTooltip();
return;
}
const tooltip = document.querySelector('.Board .Tooltip');
tooltip.textContent = border[slot] === 'bank'
? '3 of one kind for 1 resource' :
`2 ${border[slot]} for 1 resource`;
tooltip.style.top = `${e.pageY}px`;
tooltip.style.left = `${e.pageX + 16}px`;
showTooltip();
}
const mouseLeave = (border, e) => {
clearTooltip();
};
const generateBorders = function(borderOrder) {
const sides = 6;
let side = -1;
return borderOrder.map(order => {
const border = borders[order];
side++;
let x = + Math.sin(Math.PI - side / sides * 2. * Math.PI) * radius,
y = Math.cos(Math.PI - side / sides * 2. * Math.PI) * radius;
let prev = (order === 0) ? 6 : order;
const file = `borders-${order+1}.${prev}.png`;
const value = side;
return <div
key={`border-${order}`}
className="Border"
border={border}
onMouseEnter={(e) => { mouseEnter(border, value, e) }}
onMouseMove={(e) => { mouseMove(border, value, e) }}
onMouseLeave={mouseLeave}
style={{
width: `${borderImageWidth}px`,
height: `${borderImageHeight}px`,
top: `${y}px`,
left: `${x}px`,
transform: `rotate(${side*(360/sides)}deg) translate(${borderOffsetX}px, ${borderOffsetY}px) scale(-1, -1)`,
backgroundImage: `url(${assetsPath}/gfx/${file} )`
}}
/>;
});
};
if (borders && borderOrder) {
console.log(`board - Generate board - borders`);
setBorderElements(generateBorders(borderOrder));
}
if (tiles && tileOrder && animationSeeds) {
console.log(`board - Generate board - tiles`);
setTileElements(generateTiles(tileOrder, animationSeeds));
}
/* Regenerate pips every time; it uses ws */
if (pips && pipOrder) {
console.log(`board - Generate board - pips`);
setPipElements(generatePips(pipOrder));
}
if (signature && signature !== generated) {
console.log(`board - Regnerating for ${signature}`);
setGenerated(signature);
}
}, [
signature, generated,
pips, pipOrder, borders, borderOrder, tiles, tileOrder,
animationSeeds,
ws, state, rules, animations
]);
/* Re-render turn info after every render */
useEffect(() => {
if (!turn) { return; }
let nodes = document.querySelectorAll('.Active');
for (let i = 0; i < nodes.length; i++) {
nodes[i].classList.remove('Active');
}
if (turn.roll) {
nodes = document.querySelectorAll(`.Pip[data-roll="${turn.roll}"]`);
for (let i = 0; i < nodes.length; i++) {
const index = nodes[i].getAttribute('data-index');
if (index !== null) {
const tile = document.querySelector(`.Tile[data-index="${index}"]`);
if (tile) {
tile.classList.add('Active');
}
}
nodes[i].classList.add('Active');
}
}
});
/* Re-render placements after every render */
useEffect(() => {
if (!placements) { return; }
/* Set color and type based on placement data from the server */
placements.corners.forEach((corner, index) => {
const el = document.querySelector(`.Corner[data-index="${index}"]`);
if (!el) { return; }
if (turn.volcano === index) {
el.classList.add('Lava');
} else {
el.classList.remove('Lava');
}
if (!corner.color) {
el.removeAttribute('data-color');
el.removeAttribute('data-type');
} else {
el.setAttribute('data-color', corner.color);
el.setAttribute('data-type', corner.type);
}
});
placements.roads.forEach((road, index) => {
const el = document.querySelector(`.Road[data-index="${index}"]`);
if (!el) { return; }
if (!road.color) {
el.removeAttribute('data-color');
} else {
if (road.longestRoad) {
if (road.longestRoad === longestRoadLength) {
el.classList.add('LongestRoad');
} else {
el.classList.remove('LongestRoad');
}
el.setAttribute('data-longest', road.longestRoad);
} else {
el.removeAttribute('data-longest');
}
el.setAttribute('data-color', road.color);
}
});
/* Clear all 'Option' targets */
let nodes = document.querySelectorAll(`.Option`);
for (let i = 0; i < nodes.length; i++) {
nodes[i].classList.remove('Option');
}
/* Add 'Option' based on turn.limits */
if (turn && turn.limits) {
if (turn.limits['roads']) {
turn.limits['roads'].forEach(index => {
const el = document.querySelector(`.Road[data-index="${index}"]`);
if (!el) { return; }
el.classList.add('Option');
el.setAttribute('data-color', turn.color);
});
}
if (turn.limits['corners']) {
turn.limits['corners'].forEach(index => {
const el = document.querySelector(`.Corner[data-index="${index}"]`);
if (!el) { return; }
el.classList.add('Option');
el.setAttribute('data-color', turn.color);
});
}
if (turn.limits['tiles']) {
turn.limits['tiles'].forEach(index => {
const el = document.querySelector(`.Tile[data-index="${index}"]`);
if (!el) { return; }
el.classList.add('Option');
});
}
if (turn.limits['pips']) {
turn.limits['pips'].forEach(index => {
const el = document.querySelector(`.Pip[data-index="${index}"]`);
if (!el) { return; }
el.classList.add('Option');
});
}
}
/* Clear the robber */
nodes = document.querySelectorAll(`.Pip.Robber`);
for (let i = 0; i < nodes.length; i++) {
nodes[i].classList.remove('Robber');
[ 'Robert', 'Roberta', 'Velocirobber' ].forEach(robberName =>
nodes[i].classList.remove(robberName)
);
}
/* Place the robber */
if (robber !== undefined) {
const el = document.querySelector(`.Pip[data-index="${robber}"]`);
if (el) {
el.classList.add('Robber');
el.classList.add(robberName);
}
}
});
const canAction = (action) => {
return (turn && Array.isArray(turn.actions) && turn.actions.indexOf(action) !== -1);
};
const canRoad = (canAction('place-road')
&& turn.color === color
&& (state === 'initial-placement' || state === 'normal'));
const canCorner = ((canAction('place-settlement') || canAction('place-city'))
&& turn.color === color
&& (state === 'initial-placement' || state === 'normal'));
const canPip = (canAction('place-robber')
&& turn.color === color
&& (state === 'initial-placement' || state === 'normal'));
console.log(`board - tile elements`, tileElements);
return (
<div className="Board" ref={board}>
<div className="Tooltip">tooltip</div>
<div className="BoardBox">
<div className="Borders" disabled>
{ borderElements }
</div>
<div className="Tiles" disabled>
{ tileElements }
</div>
<div className="Pips" disabled={!canPip}>
{ pipElements }
</div>
<div className="Corners" disabled={!canCorner}>
{ cornerElements }
</div>
<div className="Roads" disabled={!canRoad}>
{ roadElements }
</div>
</div>
</div>
);
};
export { Board };

View File

@ -1,51 +0,0 @@
import React from "react";
import "./BoardPieces.css";
import { useStyles } from './Styles.js';
const Road = ({ color, onClick }) => {
const classes = useStyles();
return <div className="Road" onClick={() => onClick('road')}><div className={['Shape', classes[color]].join(' ')}/></div>;
}
const Settlement = ({ color, onClick }) => {
const classes = useStyles();
return <div className="Settlement" onClick={() => onClick('settlement')}><div className={['Shape', classes[color]].join(' ')}/></div>;
}
const City = ({ color, onClick }) => {
const classes = useStyles();
return <div className="City" onClick={() => onClick('city')}><div className={['Shape', classes[color]].join(' ')}/></div>;
}
const BoardPieces = ({ player, onClick }) => {
if (!player) {
return <></>;
}
const color = player.color;
const roads = [];
for (let i = 0; i < player.roads; i++) {
roads.push(<Road onClick={onClick} key={`road-${i}`} color={color}/>);
}
const settlements = [];
for (let i = 0; i < player.settlements; i++) {
settlements.push(<Settlement onClick={onClick} key={`settlement-${i}`} color={color}/>);
}
const cities = [];
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 };

View File

@ -1,202 +0,0 @@
import React, { useState, useEffect, useContext, useRef, useCallback, useMemo } from "react";
import Paper from '@material-ui/core/Paper';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Moment from 'react-moment';
import TextField from '@material-ui/core/TextField';
import 'moment-timezone';
import equal from "fast-deep-equal";
import "./Chat.css";
import { PlayerColor } from './PlayerColor.js';
import { Resource } from './Resource.js';
import { Dice } from './Dice.js';
import { GlobalContext } from "./GlobalContext.js";
const Chat = () => {
const [lastTop, setLastTop] = useState(0);
const [autoScroll, setAutoScroll] = useState(true);
const [latest, setLatest] = useState('');
const [scrollTime, setScrollTime] = useState(0);
const [chat, setChat] = useState([]);
const [startTime, setStartTime] = useState(0);
const { ws, name } = useContext(GlobalContext);
const fields = useMemo(() => [
'chat', 'startTime'
], []);
const onWsMessage = (event) => {
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 => 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) => {
if (event.key === "Enter") {
if (!autoScroll) {
setAutoScroll(true);
}
ws.send(JSON.stringify({ type: 'chat', message: event.target.value }));
event.target.value = "";
}
}, [ws, setAutoScroll, autoScroll]);
const chatScroll = (event) => {
const chatList = event.target,
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"),
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 = '';
}
let 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 };

View File

@ -1,142 +0,0 @@
import React, { useState, useCallback, useEffect, useMemo, useRef,
useContext } from "react";
import equal from "fast-deep-equal";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import "./ChooseCard.css";
import {Resource} from './Resource.js';
import {GlobalContext} from './GlobalContext.js';
const ChooseCard = () => {
const { ws } = useContext(GlobalContext);
const [turn, setTurn] = useState(undefined);
const [color, setColor] = useState(undefined);
const [state, setState] = useState(undefined);
const [cards, setCards] = useState([]);
const fields = useMemo(() => [
'turn', 'color', 'state'
], []);
const onWsMessage = (event) => {
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 => 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((event) => {
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 in turn.select) {
count = turn.select[color];
} else {
count = 0;
}
}
}
const selectCard = useCallback((event) => {
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;
}
let tmp = [];
for (let i = 0; i < selected.length; i++) {
tmp.push(selected[i].getAttribute('data-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;
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};

View File

@ -1,23 +0,0 @@
import React from "react";
import "./Dice.css";
import { assetsPath } from './Common.js';
const Dice = ({ pips }) => {
let name;
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 };

View File

@ -1,108 +0,0 @@
import React, { useState, useEffect, useContext, useRef, useMemo } from "react";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import equal from "fast-deep-equal";
import { Dice } from "./Dice.js";
import { PlayerColor } from "./PlayerColor.js";
import "./GameOrder.css";
import { GlobalContext } from "./GlobalContext.js";
const GameOrder = () => {
const { ws } = useContext(GlobalContext);
const [players, setPlayers] = useState({});
const [color, setColor] = useState(undefined);
const fields = useMemo(() => [
'players', 'color'
], []);
const onWsMessage = (event) => {
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 => 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) => {
ws.send(JSON.stringify(data));
}
const rollClick = (event) => {
sendMessage({ type: 'roll' });
}
let playerElements = [], hasRolled = true;
for (let 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;
});
playerElements = 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">
{ playerElements }
</div>
<Button disabled={hasRolled} onClick={rollClick}>Roll Dice</Button>
</Paper>
</div>
);
};
export { GameOrder };

View File

@ -1,178 +0,0 @@
import React, { useContext, useState, useMemo, useRef, useEffect } from "react";
import equal from "fast-deep-equal";
import { Resource } from './Resource.js';
import { Placard } from './Placard.js';
import { GlobalContext } from './GlobalContext.js';
import { assetsPath } from "./Common.js";
import "./Hand.css";
const Development = ({type, card, onClick}) => {
return (
<div className={`Development ${card.played ? 'Selected' : ''}`}
onClick={onClick}
style={{
backgroundImage:`url(${assetsPath}/gfx/card-${type}.png)`
}}/>
);
};
const Hand = ({buildActive, setBuildActive, setCardActive}) => {
const { ws } = useContext(GlobalContext);
const [priv, setPriv] = useState(undefined);
const [color, setColor] = useState(undefined);
const [turn, setTurn] = useState(undefined);
const [longestRoad, setLongestRoad] = useState(undefined);
const [largestArmy, setLargestArmy] = useState(undefined);
const [development, setDevelopment] = useState([]);
const [mostPorts, setMostPorts] = useState(undefined);
const [mostDeveloped, setMostDeveloped] = useState(undefined);
const [selected, setSelected] = useState(0);
const fields = useMemo(() => [
'private', 'turn', 'color', 'longestRoad', 'largestArmy', 'mostPorts', 'mostDeveloped'
], []);
const onWsMessage = (event) => {
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 => 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) => {
setCardActive(card);
}
const stacks = {};
priv.development.forEach(card =>
(card.type in stacks)
? stacks[card.type].push(card)
: stacks[card.type] = [card]);
const development = [];
for (let type in stacks) {
const cards = stacks[type]
.sort((A, B) => {
if (A.played) {
return -1;
}
if (B.played) {
return +1;
}
return B.turn - A.turn; /* Put playable cards on top */
}).map(card => <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 = (event) => {
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 };

View File

@ -1,426 +0,0 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import equal from "fast-deep-equal";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import Switch from '@material-ui/core/Switch';
import "./HouseRules.css";
import { GlobalContext } from "./GlobalContext.js";
import { Placard } from "./Placard.js";
/* Volcano based on https://www.ultraboardgames.com/catan/the-volcano.php */
const Volcano = ({ ws, rules, field, disabled }) => {
const init = (Math.random() > 0.5)
? Math.floor(8 + Math.random() * 5) /* Do not include 7 */
: Math.floor(2 + Math.random() * 5); /* Do not include 7 */
const [number, setNumber] =
useState((field in rules && 'number' in rules[field]) ? rules[field].number : init);
const [gold, setGold] =
useState((field in rules && 'gold' in rules[field]) ? rules[field].gold : false);
console.log(`house-rules - ${field} - `, rules[field]);
useEffect(() => {
if (field in rules) {
setGold('gold' in rules[field] ? rules[field].gold : true);
setNumber('number' in rules[field] ? rules[field].number : init);
let update = false;
if (!('gold' in rules[field])) {
rules[field].gold = true;
update = true;
}
if (!('number' in rules[field])) {
rules[field].number = init;
update = true;
}
if (update) {
ws.send(JSON.stringify({
type: 'rules',
rules: rules
}));
}
}
}, [rules, field, init, ws]);
const toggleGold = () => {
rules[field].gold = !gold;
rules[field].number = number;
setGold(rules[field].gold);
ws.send(JSON.stringify({
type: 'rules',
rules: rules
}));
};
const update = (delta) => {
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>&nbsp;/&nbsp;
<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={(e) => 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>;
}
const VictoryPoints = ({ ws, rules, field }) => {
const minVP = 10;
const [points, setPoints] = useState(rules[field].points || minVP);
console.log(`house-rules - ${field} - `, rules[field]);
if (!(field in rules)) {
rules[field] = {
points: minVP
}
};
if (rules[field].points && rules[field].points !== points) {
setPoints(rules[field].points);
}
const update = (value) => {
let 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>&nbsp;/&nbsp;
< button onClick = {() => update(-1)}> down</button>
</div>;
}
const NotImplemented = () => {
return <div>Not yet implemented.</div>;
}
/*
The Jungle
Setup
On any normally assembled map, replace all deserts with jungles. Select an extra number token with the value of 3, 4, 5, 9, 10, or 11. (All players must agree on which number to use.) Place this number token on the jungle hex. The Robber begins the game on the jungle hex.
Special rules
The Robber may be played on the jungle hex when a player has robber control.
However, when the number on an unblocked jungle tile is rolled, adjacent players may explore the jungle and make discoveries that aid them in developing their principalities. Each adjacent settlement will receive one discovery, while each adjacent city will receive two discoveries.
Discoveries are represented by Discovery Counters, instead of resource cards. Discovery Counters do not count against a player's hand limit of resources when a 7 is rolled. Discovery Counters can not be stolen by the Robber, can not be claimed by a Monopoly, can not be earned through a Year of Plenty, and may not be used in any trades.
Discovery Counters may be used to aid the purchase of development cards only. Each Discovery Counter can be used to replace any one of the three resources needed to purchase a card. Up to three Discovery Counters may be used on each card purchase. Any combination of Discovery Counters and the three usual resources may be used. For example, a player may purchase a development card with one ore and two Discovery Counters. Similarly, a development card could be purchased with one wool, one grain, and one Discovery Counter.
*/
const HouseRules = ({ houseRulesActive, setHouseRulesActive }) => {
const { ws } = useContext(GlobalContext);
const [rules, setRules] = useState(undefined);
const [state, setState] = useState(undefined);
const [ruleElements, setRuleElements] = useState([]);
const fields = useMemo(() => [
'state', 'rules'
], []);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
console.log(`house-rules - game update`, data.update);
if ('state' in data.update && data.update.state !== state) {
setState(data.update.state);
}
if ('rules' in data.update
&& !equal(data.update.rules, rules)) {
console.log(`house-rules - setting house rules to `,
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 => 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((event) => {
setHouseRulesActive(false);
}, [setHouseRulesActive]);
console.log(`house-rules - render - `, { rules });
const setRule = useCallback((event, key) => {
if (!(key in rules)) {
rules[key] = { enabled: false };
}
rules[key].enabled = !rules[key].enabled;
console.log(`house-rules - set ${key} - ${rules[key].enabled}`);
setRules(Object.assign({}, rules));
ws.send(JSON.stringify({
type: 'rules',
rules
}));
}, [ws, rules]);
useEffect(() => {
/* https://icebreaker.com/games/catan-1/feature/catan-house-rules */
setRuleElements([{
title: `Why you play so slowf`,
key: `slowest-turn`,
description: `The player with the longest turn idle time (longer than 2 minutes) so far loses 2VP.`,
element: <Placard type="longest-turn"/>,
implemented: false
}, {
title: `You are so developed`,
key: `most-developed`,
description:
`The player with the most development cards (more than 4) receives 2VP.`,
element: <Placard type="most-developed" />,
implemented: true
}, {
title: `Another round of port`,
key: `port-of-call`,
description:
`The player with the most harbor ports (more than 2) receives 2VP.`,
element: <Placard type="port-of-call" />,
implemented: true
}, {
title: `More victory points`,
key: `victory-points`,
description: `Customize how many Victory Points are required to win.`,
element: <VictoryPoints {...{
ws, rules,
field: `victory-points`
}} />,
implemented: true
}, {
title: `Tiles start facing down`,
key: `tiles-start-facing-down`,
description: `Resource tiles start upside-down while placing starting settlements.`,
element: <div>Once all players have placed their initial settlements
and roads, the tiles are flipped and you discover what the
resources are.</div>,
implemented: true
}, {
title: `Bribery`,
key: `bribery`,
description: `Dissuade enemies from robbing you by offering resources voluntarily.`,
element: <NotImplemented {...{
ws, rules,
field: `bribery`
}} />,
}, {
title: `King of the Hill`,
key: `king-of-the-hill`,
description: `Keep your lead for one full turn after you reach max victory points.`,
element: <NotImplemented {...{
ws, rules,
field: `king-of-the-hill`
}} />,
}, {
title: `Everyone gets one re-roll`,
key: `everyone-gets-one-reroll`,
description: `Each player gets one chance re-roll at any point.`,
element: <NotImplemented {...{
ws, rules,
field: `everyone-gets-one-reroll`
}} />,
}, {
title: `The Bridge`,
key: `the-bridge`,
description: `Build a super-bridge across one resource tile.`,
element: <NotImplemented {...{
ws, rules,
field: `the-bridge`
}} />,
}, {
title: `Discard desert`,
key: `discard-desert`,
description: `Scrap the desert in favour of an additional resource tile.`,
element: <NotImplemented {...{
ws, rules,
field: `discard-desert`
}} />,
}, {
title: `Roll double, roll again`,
key: `roll-double-roll-again`,
description: `Roll again if you roll two of the same number.`,
element: < div>If you roll doubles, players get those resources and
then you must roll again.</div >,
implemented: true,
}, {
title: `Twelve and Two are synonyms`,
key: `twelve-and-two-are-synonyms`,
description: `If twelve is rolled, two scores as well. And vice-versa.`,
element: < div>If you roll a twelve or two, resources are triggered
for both.</div >,
implemented: true,
}, {
title: `Robin Hood robber`,
key: `robin-hood-robber`,
description: `Robbers can't steal from players with two or less victory points.`,
element: <></>,
implemented: true
}, {
title: `Crime and Punishment`,
key: `crime-and-punishment`,
description: `Change how the robber works to make Catan more or less competitive.`,
element: <NotImplemented {...{
ws, rules,
field: `crime-and-punishment`
}} />,
}, {
title: `Credit? Debt? You bebt!`,
key: `credit`,
description: `Trade with resources you don't have.`,
element: <NotImplemented {...{
ws, rules,
field: `credit`
}} />,
}, {
title: `Volcanoes are a lava fun!`,
key: `volcano`,
description: `A volcano is on the island! Let the lava flow!`,
element: <Volcano {
...{
ws, rules,
field: `volcano`,
disabled: state !== 'lobby'
}
} />,
implemented: true,
}, {
title: `Don't keep paying those soldiers!`,
key: `mercenaries`,
description: `Disband a soldier and pick two resources to receive as tribute. If you no longer have the Longest Army, you lose it.`,
element: <NotImplemented {...{
ws, rules,
field: `credit`
}} />,
} ]
.filter(item => item.implemented)
.sort((A, B) => {
if (A.implemented && B.implemented) {
return A.title.localeCompare();
}
if (A.implemented) {
return -1;
}
if (B.implemented) {
return +1;
}
return A.title.localeCompare();
})
.map(item => {
const disabled = (state !== 'lobby' || !item.implemented),
defaultChecked = rules
&& (item.key in rules)
? rules[item.key].enabled
: false;
console.log(`house-rules - ${item.key} - `,
{ rules, defaultChecked, disabled });
return <div className="HouseRule"
data-enabled={
rules
&& item.key in rules
&& rules[item.key].enabled}
data-disabled={disabled}
key={item.key}
data-key={item.key}>
<div className="HouseSelector">
<div><b>{item.title}</b>: {item.description}</div>
<Switch
size={'small'}
className="RuleSwitch"
checked={defaultChecked}
id={item.key}
onChange={(e) => setRule(e, item.key)}
{...{ disabled }} />
</div>
{ defaultChecked && item.element }
</div>
}));
}, [rules, setRules, setRuleElements, state, ws, setRule ]);
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 };

View File

@ -1,711 +0,0 @@
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.js";
const debug = true;
/* Proxy object so we can pass in srcObject to <audio> */
const Video = ({ srcObject, local, ...props }) => {
const refVideo = useRef(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} />;
}
const MediaAgent = ({setPeers}) => {
const { name, ws } = useContext(GlobalContext);
const [ peers ] = useState({});
const [stream, setStream] = useState(undefined);
const onTrack = useCallback((event) => {
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.`);
Object.assign(peers[peer].attributes, {
srcObject: event.streams[0]
});
/* Trigger update of MediaControl now that a stream is available */
setPeers(Object.assign({}, peers));
}
}
}, [peers, setPeers]);
const refOnTrack = useRef(onTrack);
const sendMessage = useCallback((data) => {
ws.send(JSON.stringify(data));
}, [ws]);
const onWsMessage = useCallback((event) => {
const addPeer = (config) => {
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 = {
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({
configuration: {
offerToReceiveAudio: true,
offerToReceiveVideo: true
},
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.hostcandidate, 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 */
connection.addStream(stream.media);
/* 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
}
});
if (debug) console.log(`media-agent - Offer ` +
`setLocalDescription succeeded`);
})
.catch((error) => {
console.error(`media-agent - Offer setLocalDescription failed!`);
});
})
.catch((error) => {
console.log(`media-agente - Error sending offer: `, error);
});
}
}
const sessionDescription = ({ peer_id, session_description }) => {
const peer = peers[peer_id];
if (!peer) {
console.error(`media-agent - sessionDescription - ` +
`No peer for ${peer_id}`);
return;
}
const { connection } = peer;
const desc = new RTCSessionDescription(session_description);
return connection.setRemoteDescription(desc, () => {
if (debug) console.log(`media-agent - sessionDescription - ` +
`setRemoteDescription succeeded`);
if (session_description.type === "offer") {
if (debug) console.log(`media-agent - sessionDescription - ` +
`Creating answer`);
connection.createAnswer((local_description) => {
if (debug) console.log(`media-agent - sessionDescription - ` +
`Answer description is: `, local_description);
connection.setLocalDescription(local_description, () => {
sendMessage({
type: 'relaySessionDescription',
config: {
peer_id,
session_description: local_description
}
});
if (debug) console.log(`media-agent - sessionDescription ` +
`- Answer setLocalDescription succeeded`);
}, () => {
console.error(`media-agent - sessionDescription - ` +
`Answer setLocalDescription failed!`);
});
}, (error) => {
console.error(error);
});
}
}, (error) => {
console.log(`media-agent - sessionDescription - ` +
`setRemoteDescription error: `, error);
});
};
const removePeer = ({peer_id}) => {
console.log(`media-agent - removePeer - Signaling server said to ` +
`remove peer ${peer_id}`);
if (peer_id in peers) {
if (peers[peer_id].connection) {
peers[peer_id].connection.close();
peers[peer_id].connection = undefined;
}
}
/* To maintain mute/videoOn states, we don't remove the peer but
* instead mark it as dead */
peers[peer_id].dead = true;
if (debug) console.log(`media-agent - removePeer`, peers);
setPeers(Object.assign({}, peers));
};
const iceCandidate = ({ peer_id, candidate }) => {
/**
* The offerer will send a number of ICE Candidate blobs to the
* answerer so they can begin trying to find the best path to one
* another on the net.
*/
const peer = peers[peer_id];
if (!peer) {
console.error(`media-agent - iceCandidate - No peer for ` +
`${peer_id}`, peers);
return;
}
peer.connection.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => {
if (debug) console.log(`media-agent - iceCandidate - ` +
`Successfully added Ice Candidate for ${peer_id}`);
})
.catch((error) => {
console.error(error, peer, candidate);
});
};
const data = JSON.parse(event.data);
if (data.type in [ 'addPeer', 'removePeer',
'iceCandidate', 'sessionDescription' ]) {
console.log(`media-agent - message - ${data.type}`, peers);
}
switch (data.type) {
case 'addPeer': addPeer(data.data); break;
case 'removePeer': removePeer(data.data); break;
case 'iceCandidate': iceCandidate(data.data); break;
case 'sessionDescription': sessionDescription(data.data); break;
default: break;
}
}, [ peers, setPeers, stream, refOnTrack, sendMessage ]);
const refWsMessage = useRef(onWsMessage);
const onWsClose = (event) => {
console.log(`media-agent - ${name} Disconnected from signaling server`);
/* Tear down all of our peer connections and remove all the
* media divs when we disconnect */
for (let peer_id in peers) {
if (peers[peer_id].local) {
continue;
}
peers[peer_id].connection.close();
peers[peer_id].connection = undefined;
}
for (let id in peers) {
peers[id].dead = true;
peers[id].connection = undefined;
}
if (debug) console.log(`media-agent - close`, peers);
setPeers(Object.assign({}, peers));
}
const refWsClose = useRef(onWsClose);
useEffect(() => {
refWsMessage.current = onWsMessage;
refWsClose.current = onWsClose;
refOnTrack.current = onTrack;
});
useEffect(() => {
if (!ws) {
return;
}
console.log(`media-control - Binding to WebSocket`);
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
const cbClose = e => refWsClose.current(e);
ws.addEventListener('close', cbClose);
return () => {
ws.removeEventListener('message', cbMessage);
ws.removeEventListener('close', cbClose);
}
}, [ws, refWsMessage, refWsClose ]);
useEffect(() => {
console.log(`media-control - WebSocket or Stream changed`);
const join = () => {
sendMessage({
type: 'join',
config: {
hasAudio: stream.audio,
hasVideo: stream.video
}
});
}
if (ws && stream) {
console.log(`media-conterol - issuing join request`);
for (let peer in peers) {
if (peers[peer].local && peers[peer].dead) {
/* Allocate a new Object so <MediaControl> will trigger */
peers[peer] = Object.assign({}, peers[peer]);
delete peers[peer].dead;
setPeers(Object.assign({}, peers));
}
}
join();
}
}, [ws, stream, peers, setPeers, sendMessage]);
useEffect(() => {
if (!name) {
return;
}
let update = false;
if (stream) {
if (!(name in peers)) {
update = true;
peers[name] = {
name: name,
local: true,
muted: true,
videoOn: false,
hasVideo: stream.video,
hasAudio: stream.audio,
attributes: {
local: true,
srcObject: stream.media
}
};
}
}
/* Renaming the local connection requires the peer to be deleted
* and re-established with the signaling server */
for (let key in peers) {
if (peers[key].local && key !== name) {
delete peers[key];
update = true;
}
}
if (update) {
if (debug) console.log(`media-agent - Setting global peers`, peers);
setPeers(Object.assign({}, peers));
}
}, [peers, name, setPeers, stream]);
useEffect(() => {
if (!ws || !name) {
return;
}
const setup_local_media = () => {
/* Ask user for permission to use the computers microphone and/or camera,
* attach it to an <audio> or <video> tag if they give us access. */
console.log(`media-agent - Requesting access to local ` +
`audio / video inputs`);
/* See Dummy Tracks for more ideas...
* https://blog.mozilla.org/webrtc/warm-up-with-replacetrack/
*/
navigator.getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia);
return navigator.mediaDevices
.getUserMedia({audio: true, video: true})
.then((media) => {
return {
media: media,
audio: true,
video: true
};
})
.catch((error) => {
console.log(`media-agent - Access to audio and video ` +
`failed. Trying just audio.`);
return navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((media) => {
return {
media: media,
audio: true,
video: false
};
})
.catch((error) => {
console.log(`media-agent - Access to audio ` +
`failed.`);
return {
media: undefined,
audio: false,
video: false
};
});
})
.then((context) => { /* user accepted access to a/v */
sendMessage({type: 'media-status',
video: context.video,
audio: context.audio
});
if (context.video) {
console.log("media-agent - Access granted to audio/video");
context.media.getVideoTracks().forEach((track) => {
track.applyConstraints({
"video": {
"width": {
"min": 160,
"max": 320
},
"height": {
"min": 120,
"max": 240
}
}
});
});
return context;
}
const black = ({ width = 640, height = 480 } = {}) => {
const canvas = Object.assign(document.createElement("canvas"), {
width, height
});
canvas.getContext('2d').fillRect(0, 0, width, height);
const stream = canvas.captureStream();
return Object.assign(stream.getVideoTracks()[0], {
enabled: true
});
}
const silence = () => {
const ctx = new AudioContext(), oscillator = ctx.createOscillator();
const dst = oscillator.connect(ctx.createMediaStreamDestination());
oscillator.start();
return Object.assign(dst.stream.getAudioTracks()[0], {
enabled: true
});
}
if (context.audio) {
console.log("media-agent - Access granted to audio");
let black = ({ width = 640, height = 480 } = {}) => {
let canvas = Object.assign(document.createElement("canvas"), {
width, height
});
canvas.getContext('2d').fillRect(0, 0, width, height);
let stream = canvas.captureStream();
return Object.assign(stream.getVideoTracks()[0], {
enabled: true
});
}
context.media = new MediaStream([context.media, black()]);
return context;
}
context.media = new MediaStream([black(), silence()]);
return context;
});
};
let abort = false;
if (!stream) {
if (debug) console.log(`media-agent - WebSocket open request. ` +
`Attempting to create local media.`);
setup_local_media().then((context) => {
/* once the user has given us access to their
* microphone/camcorder, join the channel and start peering up */
if (abort) {
console.log(`media-agent - aborting setting local media`);
} else {
setStream(context);
}
}).catch((error) => { /* user denied access to a/v */
console.error(error);
console.log("media-agent - Access denied for audio/video");
});
}
return () => {
abort = true;
if (!stream) {
console.log(`media-agent - abort media setup!`);
}
};
}, [ws, setStream, stream, name, sendMessage]);
return <></>;
}
const MediaControl = ({isSelf, peer, className}) => {
const [media, setMedia] = useState(undefined);
const [muted, setMuted] = useState(undefined);
const [videoOn, setVideoOn] = useState(undefined);
const [target, setTarget] = useState();
const [frame, setFrame] = useState({
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) => {
if (debug) console.log(`media-control - toggleMute - ${peer.name}`,
!muted);
peer.muted = !muted;
setMuted(peer.muted);
event.stopPropagation();
}
const toggleVideo = (event) => {
if (debug) console.log(`media-control - toggleVideo - ${peer.name}`,
!videoOn);
peer.videoOn = !videoOn;
if (peer.videoOn) {
const video = document.querySelector(`video[data-id="${media.name}"`);
if (video) {
video.play();
}
}
setVideoOn(peer.videoOn);
event.stopPropagation();
}
useEffect(() => {
if (!media || media.dead || !peer) {
return;
}
if (media.attributes.srcObject) {
console.log(`media-control - audio enable - ${peer.name}:${!muted}`);
media.attributes.srcObject.getAudioTracks().forEach((track) => {
track.enabled = media.hasAudio && !muted;
});
}
}); /* run after every render to hit when ontrack has received and set
* the stream //, [media, muted]); */
useEffect(() => {
if (!media || media.dead || !peer) {
return;
}
if (media.attributes.srcObject) {
console.log(`media-control - video enable - ${peer.name}:${videoOn}`);
media.attributes.srcObject.getVideoTracks().forEach((track) => {
track.enabled = media.hasVideo && videoOn;
});
}
}); /* run after every render to hit when ontrack has received and set
* the stream //, [media, videoOn]); */
const isValid = media && !media.dead,
colorAudio = (isValid && media.hasAudio) ? 'primary' : 'disabled',
colorVideo = (isValid && media.hasVideo) ? 'primary' : 'disabled';
if (!peer) {
return <></>;
}
return <>
<div className={`MediaControlSpacer ${className}`}/>
<div className={`MediaControl ${className}`} data-peer={peer.name}>
<div className="Controls" >
{ isSelf && <div onTouchStart={toggleMute} onClick={toggleMute}>
{ muted && <MicOff color={colorAudio}/> }
{!muted && <Mic color={colorAudio}/> }
</div> }
{ !isSelf && <div onTouchStart={toggleMute} onClick={toggleMute}>
{muted && <VolumeOff color={colorAudio}/> }
{!muted && <VolumeUp color={colorAudio}/> }
</div> }
<div onTouchStart={toggleVideo} onClick={toggleVideo}>
{ !videoOn && <VideocamOff color={colorVideo}/> }
{videoOn && <Videocam color={colorVideo}/> }
</div>
</div>
{ isValid && <>
<Moveable
pinchable={true}
draggable={true}
target={target}
resizable={true}
keepRatio={true}
throttleResize={0}
hideDefaultLines={false}
edge={true}
zoom={1}
origin={false}
onDragStart={e => {
e.set(frame.translate);
}}
onDrag={e => {
frame.translate = e.beforeTranslate;
}}
onResizeStart={e => {
e.setOrigin(["%", "%"]);
e.dragStart && e.dragStart.set(frame.translate);
}}
onResize={e => {
const { translate, rotate, transformOrigin } = frame;
e.target.style.width = `${e.width}px`;
e.target.style.height = `${e.height}px`;
e.target.style.transform = `translate(${translate[0]}px, ${translate[1]}px)`;
}}
onRender={e => {
const { translate, rotate, transformOrigin } = frame;
//e.target.style.transformOrigin = transformOrigin;
e.target.style.transform = `translate(${translate[0]}px, ${translate[1]}px)`;
}}
/><Video className="Video"
data-id={media.name}
autoPlay='autoplay'
{...media.attributes}/>
</> }
{ !isValid && <video className="Video"></video> }
</div>
</>;
};
export { MediaControl, MediaAgent };

View File

@ -1,41 +0,0 @@
import React, { useState, useContext, useEffect, useRef } from "react";
import { GlobalContext} from "./GlobalContext.js";
import "./PingPong.css";
const PingPong = () => {
const [ count, setCount ] = useState(0);
const global = useContext(GlobalContext);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'ping':
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 => 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 };

View File

@ -1,96 +0,0 @@
import React, { useContext, useCallback } from "react";
import { assetsPath } from './Common.js';
import { GlobalContext } from "./GlobalContext.js";
import "./Placard.css";
const Placard = ({type, disabled, count, buildActive, setBuildActive}) => {
const { ws } = useContext(GlobalContext);
const sendMessage = useCallback((data) => {
ws.send(JSON.stringify(data));
}, [ws]);
const dismissClicked = () => {
setBuildActive(false);
}
const buildClicked = () => {
if (!type.match(/^l.*/)) { /* longest / largest ... */
if (!buildActive) {
setBuildActive(true);
}
}
};
const roadClicked = () => {
sendMessage({ type: 'buy-road'});
setBuildActive(false);
};
const settlementClicked = () => {
sendMessage({ type: 'buy-settlement'});
setBuildActive(false);
};
const cityClicked = () => {
sendMessage({ type: 'buy-city'});
setBuildActive(false);
};
const developmentClicked = () => {
sendMessage({ type: 'buy-development'});
setBuildActive(false);
};
if (!type) {
return <></>;
}
if (type === 'B') { type = 'blue'; }
else if (type === 'O') { type = 'orange'; }
else if (type === 'R') { type = 'red'; }
else if (type === 'W') { type = 'white'; }
let buttons;
if (!disabled && buildActive) {
switch (type) {
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 = <></>;
break;
}
}
if (!disabled) {
return <div className={`Placard${buildActive ? ' Selected' : ''}`}
onClick={buildClicked}
disabled={disabled}
data-type={type}
style={{
backgroundImage:`url(${assetsPath}/gfx/placard-${type}.png)`
}}
>{buttons}</div>
} else {
return <div className={`Placard${buildActive ? ' Selected' : ''}`}
disabled={disabled}
data-type={type}
style={{
backgroundImage:`url(${assetsPath}/gfx/placard-${type}.png)`
}}>{ count && <div className="Right">{count}</div> }</div>
}
};
export {Placard};

View File

@ -1,17 +0,0 @@
import React from "react";
import Avatar from '@material-ui/core/Avatar';
import { useStyles } from './Styles.js';
import "./PlayerColor.css";
const PlayerColor = ({ color }) => {
const classes = useStyles();
return (
<Avatar className={['PlayerColor', classes[color]].join(' ')}/>
);
};
export { PlayerColor };

View File

@ -1,13 +1,12 @@
import React from "react"; import React from "react";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import "./PlayerColor.css"; import "./PlayerColor.css";
import { useStyles } from "./Styles"; import { styles } from "./Styles";
type PlayerColorProps = { color?: string }; type PlayerColorProps = { color?: string };
const PlayerColor: React.FC<PlayerColorProps> = ({ color }) => { const PlayerColor: React.FC<PlayerColorProps> = ({ color }) => {
const classes = useStyles(); return <Avatar sx={color ? styles[color] : {}} className="PlayerColor" />;
return <Avatar className={["PlayerColor", color ? classes[color] : ""].join(" ")} />;
}; };
export { PlayerColor }; export { PlayerColor };

View File

@ -1,177 +0,0 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import Paper from '@material-ui/core/Paper';
import List from '@material-ui/core/List';
import "./PlayerList.css";
import { PlayerColor } from './PlayerColor.js';
import { MediaAgent, MediaControl } from "./MediaControl.js";
import { GlobalContext } from "./GlobalContext.js";
const PlayerList = () => {
const { ws, name } = useContext(GlobalContext);
const [players, setPlayers] = useState({});
const [unselected, setUneslected] = useState([]);
const [state, setState] = useState('lobby');
const [color, setColor] = useState(undefined);
const [peers, setPeers] = useState({});
const onWsMessage = (event) => {
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 (let 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 => 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) => {
ws.send(JSON.stringify({
type: 'set',
field: 'color',
value: color === key ? "" : key
}));
}
const playerElements = [];
const inLobby = state === 'lobby';
const sortedPlayers = [];
for (let key in players) {
sortedPlayers.push(players[key]);
}
const sortPlayers = (A, B) => {
/* 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 name = 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">{name ? name : 'Available' }</div>
{ name && !player.live && <div className="NoNetwork"></div> }
</div>
{ name && player.live && <MediaControl className={videoClass} peer={peers[name]}
isSelf={player.color === color}/> }
{ !name && <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 };

View File

@ -1,37 +0,0 @@
import React, { useState } from "react";
import "./PlayerName.css";
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
const PlayerName = ({ name, setName }) => {
const [edit, setEdit] = useState(name);
const sendName = () => {
setName(edit);
}
const nameChange = (event) => {
setEdit(event.target.value);
}
const nameKeyPress = (event) => {
if (event.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 };

View File

@ -1,211 +0,0 @@
import React, { useContext, useState, useMemo, useRef, useEffect } from "react";
import equal from "fast-deep-equal";
import "./PlayersStatus.css";
import { BoardPieces } from './BoardPieces.js';
import { Resource } from './Resource.js';
import { PlayerColor } from './PlayerColor.js';
import { Placard } from './Placard.js';
import { GlobalContext } from './GlobalContext.js';
const Player = ({ 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 = <></>;
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
active={false}
type='port-of-call'
count={player.ports}
/> : undefined;
const mostDevelopedPlacard = mostDeveloped && mostDeveloped === color ?
<Placard
disabled
active={false}
type='most-developed'
count={player.developmentCards}
/> : undefined;
const longestRoadPlacard = longestRoad && longestRoad === color ?
<Placard
disabled
active={false}
type='longest-road'
count={player.longestRoad}
/> : undefined;
const largestArmyPlacard = largestArmy && largestArmy === color ?
<Placard
disabled
active={false}
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>
};
const PlayersStatus = ({ active }) => {
const { ws } = useContext(GlobalContext);
const [players, setPlayers] = useState(undefined);
const [color, setColor] = useState(undefined);
const [largestArmy, setLargestArmy] = useState(undefined);
const [longestRoad, setLongestRoad] = useState(undefined);
const [mostPorts, setMostPorts] = useState(undefined);
const [mostDeveloped, setMostDeveloped] = useState(undefined);
const fields = useMemo(() => [
'players', 'color', 'longestRoad', 'largestArmy', 'mostPorts', 'mostDeveloped'
], []);
const onWsMessage = (event) => {
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 => 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;
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 };

View File

@ -1,45 +0,0 @@
import React from "react";
import "./Resource.css";
import { assetsPath } from './Common.js';
const Resource = ({ type, disabled, available, count, label, onClick }) => {
const array = new Array(Number(count ? count : 0));
const click = (event) => {
if (!disabled) {
event.target.classList.toggle('Selected');
}
if (onClick) {
onClick(event);
}
};
if (label) {
return <div className={`Resource ${count === 0 ? 'None' : ''}`}
disabled={disabled}
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, i => (
<div className="Resource"
data-type={type}
disabled={disabled}
onClick={click}
style={{backgroundImage:`url(${assetsPath}/gfx/card-${type}.png)`}}>
</div>
)) }
</div>
}
</>
);
};
export { Resource };

View File

@ -1,84 +0,0 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import Paper from '@material-ui/core/Paper';
import equal from "fast-deep-equal";
import { PlayerColor } from "./PlayerColor.js";
import "./SelectPlayer.css";
import { GlobalContext } from "./GlobalContext.js";
const SelectPlayer = () => {
const { ws } = useContext(GlobalContext);
const [turn, setTurn] = useState(undefined);
const [color, setColor] = useState(undefined);
const fields = useMemo(() => [
'turn', 'color'
], []);
const onWsMessage = (event) => {
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 => 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) => {
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 =>
<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 };

View File

@ -1,108 +0,0 @@
import React, { useEffect, useState } from "react";
import { assetsPath } from "./Common.js";
import "./Sheep.css";
const
sheepSteps = 12;
const useAnimationFrame = callback => {
// Use useRef for mutable variables that we want to persist
// without triggering a re-render on their change
const requestRef = React.useRef();
const animate = time => {
callback(time)
requestRef.current = requestAnimationFrame(animate);
}
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []); // Make sure the effect runs only once
}
const Sheep = ({ 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 = React.useRef();
useAnimationFrame(time => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
previousTimeRef.current = time;
setTime(deltaTime);
} else {
previousTimeRef.current = time;
}
});
useEffect(() => {
let alpha = time / speed;
const sheepSpeed = 0.05;
if (alpha > 1.0) {
alpha = 0.1;
}
let newX = x + sheepSpeed * Math.sin(direction) * alpha,
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, setDirection]);
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. * cell / (sheepSteps - 1)}%`,
transformOrigin: `50% 50%`,
transform: `translate(-50%, -50%) scale(${Math.sin(direction) > 0 ? +1 : -1}, 1)`,
...style
}}
></div>;
};
const Herd = ({count, style}) => {
const [sheep, setSheep] = useState([]);
useEffect(() => {
const tmp = [];
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, setSheep]);
return <div className="Herd" style={style}>{ sheep }</div>;
};
export { Sheep, Herd };

View File

@ -1,31 +1,25 @@
import { makeStyles } from '@mui/styles'; import { makeStyles } from '@mui/styles';
import { orange, lightBlue, red, grey } from '@mui/material/colors'; import { orange, lightBlue, red, grey } from '@mui/material/colors';
/* eslint-disable @typescript-eslint/no-explicit-any */ const styles = {
const useStyles = makeStyles((theme: any) => ({
root: {
display: 'flex',
'& > *': {
margin: theme.spacing(1),
},
},
R: { R: {
color: theme.palette.getContrastText(red[500]), color: '#fff',
backgroundColor: red[500], backgroundColor: red[500],
}, },
O: { O: {
color: theme.palette.getContrastText(orange[500]), color: '#000',
backgroundColor: orange[500], backgroundColor: orange[500],
}, },
W: { W: {
color: theme.palette.getContrastText(grey[50]), color: '#000',
backgroundColor: grey[50], backgroundColor: grey[50],
}, },
B: { B: {
color: theme.palette.getContrastText(lightBlue[500]), color: '#fff',
backgroundColor: lightBlue[500], backgroundColor: lightBlue[500],
}, },
})); };
export { useStyles }; const useStyles = makeStyles(styles);
export { styles, useStyles };

View File

@ -1,527 +0,0 @@
import React, { useState, useCallback, useEffect, useContext, useMemo,
useRef } from "react";
import equal from "fast-deep-equal";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import {Resource} from './Resource.js';
import {PlayerColor} from './PlayerColor.js';
import { GlobalContext } from "./GlobalContext.js";
import "./Trade.css";
const empty = {
wheat: 0,
brick: 0,
wood: 0,
stone: 0,
sheep: 0
};
const Trade = () => {
const { ws } = useContext(GlobalContext);
const [gives, setGives] = useState(Object.assign({}, empty));
const [gets, setGets] = useState(Object.assign({}, empty));
const [turn, setTurn] = useState(undefined);
const [priv, setPriv] = useState(undefined);
const [players, setPlayers] = useState(undefined);
const [color, setColor] = useState(undefined);
const fields = useMemo(() => [
'turn', 'players', 'private', 'color'
], []);
const onWsMessage = (event) => {
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 => 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, direction) => {
if (direction === 'give') { /* give clicked */
if (gets[type]) {
gets[type]--;
gives[type] = 0;
} else {
if (gives[type] < priv[type]) {
gives[type]++;
}
gets[type] = 0;
}
} else if (direction === 'get') { /* get clicked */
if (gives[type]) {
gives[type]--;
gets[type] = 0;
} else {
if (gets[type] < 15) {
gets[type]++;
}
gives[type] = 0;
}
}
setGets({...gets});
setGives({...gives});
}, [setGets, setGives, gets, gives, priv]);
const createTransfer = useCallback(resource => {
return <div key={resource} className="Transfer">
<Resource
onClick={() => transfer(resource, 'get')}
label={true}
type={resource}
disabled
count={gets[resource]}/>
<div className="Direction">{ gets[resource] === gives[resource] ? '' : (gets[resource] > gives[resource] ? <ArrowDownwardIcon/> : <ArrowUpwardIcon/>)}</div>
<Resource
onClick={() => transfer(resource, 'give')}
label={true}
type={resource}
disabled
available={priv ? priv[resource] - gives[resource] : undefined}
count={gives[resource]}/>
</div>;
}, [ gives, gets, transfer, priv]);
const sendTrade = useCallback((action, offer) => {
ws.send(JSON.stringify({
type: 'trade',
action,
offer
}));
}, [ws]);
useEffect(() => {
if (priv && priv.gives) {
const _gives = {};
priv.gives.forEach(give => _gives[give.type] = give.count);
setGives(Object.assign({}, empty, _gives));
}
if (priv && priv.gets) {
const _gets = {};
priv.gets.forEach(get => _gets[get.type] = get.count);
setGets(Object.assign({}, empty, _gets));
}
}, [ setGets, setGives, priv ]);
const agreeClicked = useCallback((offer) => {
const trade = {
gives: offer.gets.slice(),
gets: offer.gives.slice()
};
let _gives = {}, _gets = {};
console.log(gives, gets);
trade.gives.forEach(give => _gives[give.type] = give.count);
trade.gets.forEach(get => _gets[get.type] = 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, offer) => {
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 = [], _gets = [];
for (let type in gives) {
if (gives[type] > 0) {
_gives.push({ type, count: gives[type] });
}
}
for (let type in gets) {
if (gets[type] > 0) {
_gets.push({ type, count: gets[type] });
}
}
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] < 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, offer) => {
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 => {
if (!valid) {
return;
}
valid = offer.gives.find(item =>
(item.type === get.type || item.type === '*') &&
item.count === get.count) !== undefined;
});
if (valid) player.gives.forEach(give => {
if (!valid) {
return;
}
valid = offer.gets.find(item =>
(item.type === give.type || item.type === 'bank') &&
item.count === give.count) !== undefined;
});
return valid;
};
const isTurn = (turn && turn.color === color) ? true : false;
const offerClicked = (event) => {
const trade = {
gives: [],
gets: []
};
for (let key in gives) {
if (gives[key] !== 0) {
trade.gives.push({type: key, count: gives[key]});
}
}
for (let key in gets) {
if (gets[key] !== 0) {
trade.gets.push({type: key, count: gets[key]});
}
}
sendTrade('offer', trade);
}
const cancelOffer = (offer) => {
sendTrade('cancel', offer);
}
const acceptClicked = (offer) => {
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 }));
}
};
const cancelClicked = (event) => {
sendTrade('cancel');
}
/* Player has rejected the active player's bid or active player rejected
* the other player's bid */
const rejectClicked = (trade) => {
sendTrade('reject', trade);
}
/* Create list of active trades */
const activeTrades = [];
for (let color in players) {
const item = players[color],
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
&& !(color in priv.offerRejected)
&& (!item.gets || item.gets.length === 0 || !item.gives || item.gives.length === 0)) {
continue;
}
const tmp = {
negotiator: turn.name === name,
self: priv.name === name,
name: name,
color: color,
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, B) => {
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: [], gets: []};
for (let type in gives) {
if (gives[type]) {
trade.gets.push({ type, count: gives[type]});
}
}
for (let type in gets) {
if (gets[type]) {
trade.gives.push({ type, count: gets[type]});
}
}
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 => {
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 => {
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 => item.name === turn.name);
if (found) {
found.valid = !(color in found.offerRejected) && canMeetOffer(priv, found);
}
}
const tradeElements = activeTrades.map((item, index) => {
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, index) => {
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, index) => {
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 };

View File

@ -1,193 +0,0 @@
import React, {useEffect, useContext, useMemo, useRef, useState,
useCallback} from "react";
import equal from "fast-deep-equal";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import "./ViewCard.css";
import { Resource } from './Resource.js';
import { GlobalContext } from "./GlobalContext.js";
const ViewCard = ({cardActive, setCardActive}) => {
const { ws } = useContext(GlobalContext);
const [priv, setPriv] = useState(undefined);
const [turns, setTurns] = useState(0);
const [rules, setRules] = useState({});
const fields = useMemo(() => [
'private', 'turns', 'rules'
], []);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
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 => 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((event) => {
ws.send(JSON.stringify({
type: 'play-card',
card: cardActive
}));
setCardActive(undefined);
}, [ws, cardActive, setCardActive]);
const close = (event) => {
setCardActive(undefined);
};
if (!cardActive) {
return <></>;
}
const capitalize = (string) => {
if (string === 'vp') {
return 'Victory Point';
}
if (string === 'army') {
return 'Knight';
}
return string.charAt(0).toUpperCase() + string.slice(1);
};
let description, lookup;
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;
priv.development.forEach(item => {
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};

View File

@ -1,199 +0,0 @@
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import equal from "fast-deep-equal";
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import "./Winner.css";
import {Resource} from './Resource.js';
import {PlayerColor} from './PlayerColor.js';
import { GlobalContext } from "./GlobalContext.js";
const Winner = ({ winnerDismissed, setWinnerDismissed }) => {
const { ws } = useContext(GlobalContext);
const [winner, setWinner] = useState(undefined);
const [state, setState] = useState(undefined);
const fields = useMemo(() => [
'winner', 'state'
], []);
const onWsMessage = (event) => {
const data = 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 => 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((event) => {
if (!winnerDismissed) {
setWinnerDismissed(true);
ws.send(JSON.stringify({
type: 'goto-lobby'
}));
}
}, [ws, winnerDismissed, setWinnerDismissed]);
if (!winner || winnerDismissed) {
return <></>;
}
let losers = [];
for (let 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 => {
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 = {};
const stats = winner.stolen;
for (let player in stats) {
if (player === 'total' || player === 'player') {
continue;
}
if (player === 'robber') {
robber = <></>;
for (let 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 (let 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`;
let vpType = ['market', 'university', 'library', 'palace'];
vpType = 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-${vpType}`} 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 };

View File

@ -1,21 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
if (process.env.NODE_ENV !== 'production') {
console.log('DEVELOPMENT mode!');
}
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { ThemeProvider, createTheme } from '@mui/material/styles';
import "./index.css"; import "./index.css";
import App from "./App"; import App from "./App";
import reportWebVitals from "./reportWebVitals"; import reportWebVitals from "./reportWebVitals";
@ -13,7 +14,9 @@ if (rootEl) {
const root = ReactDOM.createRoot(rootEl); const root = ReactDOM.createRoot(rootEl);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <ThemeProvider theme={createTheme()}>
<App />
</ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );
} }

View File

@ -3,6 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
hostname: peddlers-client
container_name: peddlers-client container_name: peddlers-client
working_dir: /client working_dir: /client
volumes: volumes:

View File

@ -1,5 +1,6 @@
services: services:
peddlers-of-ketran: peddlers-of-ketran:
hostname: peddlers-server
build: build:
context: . context: .
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
@ -7,12 +8,11 @@ services:
- ./server:/server:rw - ./server:/server:rw
- ./db:/db:rw - ./db:/db:rw
#- ./server/node_modules:app/server/node_modules:rw #- ./server/node_modules:app/server/node_modules:rw
command: ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm run start:dev"] command: ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]
ports: ports:
- 8930:8930 - 8930:8930
environment: environment:
- NODE_ENV=development - NODE_ENV=development
tty: true
networks: networks:
- peddlers-network - peddlers-network

12
launch.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Launch script for Peddlers of Ketran
# Set PRODUCTION=1 for production mode, PRODUCTION=0 or unset for development mode
if [ "$PRODUCTION" = "1" ]; then
echo "Launching in PRODUCTION mode..."
docker compose --profile prod up
else
echo "Launching in DEVELOPMENT mode..."
docker compose --profile dev up
fi