Compare commits

...

10 Commits

26 changed files with 530 additions and 212 deletions

View File

@ -14,6 +14,7 @@ export * from "./types/PlacedPiece";
export * from "./BoardBuilder"; export * from "./BoardBuilder";
export * from "./server-events/ServerError"; export * from "./server-events/ServerError";
export * from "./server-events/ServerEvent"; export * from "./server-events/ServerEvent";
export * from "./server-events/StartRoundEvent";
export * from "./server-events/UpdateLobbyEvent"; export * from "./server-events/UpdateLobbyEvent";
export * from "./client-events/ClientEvent"; export * from "./client-events/ClientEvent";
export * from "./client-events/CreateLobbyEvent"; export * from "./client-events/CreateLobbyEvent";

View File

@ -0,0 +1,22 @@
import { Socket as ServerSocket } from "socket.io";
import { Socket as ClientSocket } from "socket.io-client";
import { ServerEvent } from "./ServerEvent";
import { PieceId } from "../constants/Pieces";
export type StartRoundEvent = {
pieceIds: PieceId[];
};
export const emitStartRoundEvent = (
socket: ServerSocket,
payload: StartRoundEvent,
) => {
socket.emit(ServerEvent.START_ROUND, payload);
};
export function attachHandlerToStartRoundEvent(
socket: ClientSocket,
handler: (event: StartRoundEvent) => void,
): void {
socket.once(ServerEvent.START_ROUND, handler);
}

View File

@ -1,14 +1,22 @@
import { Socket } from "socket.io-client"; import { Socket as ServerSocket } from "socket.io";
import { Socket as ClientSocket } from "socket.io-client";
import { ServerEvent } from "./ServerEvent"; import { ServerEvent } from "./ServerEvent";
export type UpdateLobbyEvent = { export type UpdateLobbyEvent = {
playerNames: Array<string>; playerNames: Array<string>;
gameCode: string;
};
export const emitUpdateLobbyEvent = (
socket: ServerSocket,
payload: UpdateLobbyEvent,
) => {
socket.emit(ServerEvent.LOBBY_UPDATE, payload);
}; };
export function attachHandlerToUpdateLobbyEvent( export function attachHandlerToUpdateLobbyEvent(
socket: Socket, socket: ClientSocket,
handler: (event: UpdateLobbyEvent) => void, handler: (event: UpdateLobbyEvent) => void,
): () => void { ): void {
socket.on(ServerEvent.LOBBY_UPDATE, handler); socket.once(ServerEvent.LOBBY_UPDATE, handler);
return () => socket.off(ServerEvent.LOBBY_UPDATE, handler);
} }

View File

@ -63,7 +63,7 @@ export class Cell {
trackType: track.type, trackType: track.type,
}; };
}) })
.some(({ trackExternalNodes, trackType }) => { .reduce((isConnected, { trackExternalNodes, trackType }) => {
let isTrackConnected: boolean = false; let isTrackConnected: boolean = false;
trackExternalNodes trackExternalNodes
.filter((node) => node.traverseBorder() instanceof Exit) .filter((node) => node.traverseBorder() instanceof Exit)
@ -98,11 +98,15 @@ export class Cell {
} }
}); });
return isTrackConnected; return isConnected || isTrackConnected;
}); }, false);
if (!hasAnyConnection) { if (!hasAnyConnection) {
throw Error("No adjacent exit or piece available to connect to"); throw Error("No adjacent exit or piece available to connect to");
} }
} }
public removePiece() {
this.placedPiece = undefined;
}
} }

8
web/package-lock.json generated
View File

@ -13,7 +13,7 @@
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.18.119", "@types/node": "^17.0.29",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"interface": "file:../interface", "interface": "file:../interface",
@ -4202,9 +4202,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "16.18.119", "version": "17.0.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.119.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.29.tgz",
"integrity": "sha512-ia7V9a2FnhUFfetng4/sRPBMTwHZUkPFY736rb1cg9AgG7MZdR97q7/nLR9om+sq5f1la9C857E0l/nrI0RiFQ==", "integrity": "sha512-tx5jMmMFwx7wBwq/V7OohKDVb/JwJU5qCVkeLMh1//xycAJ/ESuw9aJ9SEtlCZDYi2pBfe4JkisSoAtbOsBNAA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node-forge": { "node_modules/@types/node-forge": {

View File

@ -8,7 +8,7 @@
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.18.119", "@types/node": "^17.0.29",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"interface": "file:../interface", "interface": "file:../interface",

BIN
web/public/clean-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
web/public/commit-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
web/public/factory-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
web/public/house-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,19 +1,55 @@
import React from "react"; import React, { useCallback, useState } from "react";
import { Socket } from "socket.io-client"; import { Socket } from "socket.io-client";
import LandingPage from "./pages/landing/LandingPage"; import LandingPage from "./pages/landing/LandingPage";
import GamePage from "./pages/game/GamePage"; import GamePage from "./pages/game/GamePage";
import LobbyPage from "./pages/lobby/LobbyPage";
import {
attachHandlerToStartRoundEvent,
attachHandlerToUpdateLobbyEvent,
PieceId,
} from "interface";
export interface AppProps { export interface AppProps {
socket: Socket; socket: Socket;
} }
const App = (props: AppProps) => { const App = (props: AppProps) => {
const renderedPage: string = "LANDING"; const { socket } = props;
const [renderedPage, setRenderedPage] = useState("LANDING");
const [initialPlayerNames, setInitialPlayerNames] = useState([] as string[]);
const [gameCode, setGameCode] = useState("");
const [initialPieceIds, setInitialPieceIds] = useState([] as PieceId[]);
const setupNextPageTransition = useCallback(() => {
if (renderedPage === "LANDING") {
attachHandlerToUpdateLobbyEvent(socket, (event) => {
setInitialPlayerNames(event.playerNames);
setGameCode(event.gameCode);
setRenderedPage("LOBBY");
});
} else if (renderedPage === "LOBBY") {
attachHandlerToStartRoundEvent(socket, (event) => {
setInitialPieceIds(event.pieceIds);
setRenderedPage("GAME");
});
}
}, [renderedPage]);
setupNextPageTransition();
return ( return (
<div className="app"> <div className="app">
{renderedPage === "LANDING" && <LandingPage {...props} />} {renderedPage === "LANDING" && <LandingPage {...props} />}
{renderedPage === "GAME" && <GamePage />} {renderedPage === "LOBBY" && (
<LobbyPage
{...props}
initialPlayerNames={initialPlayerNames}
gameCode={gameCode}
/>
)}
{renderedPage === "GAME" && (
<GamePage initialPieceIds={initialPieceIds} />
)}
</div> </div>
); );
}; };

View File

@ -12,5 +12,6 @@ h1 {
.right-panel { .right-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 50px;
} }
} }

View File

@ -2,32 +2,129 @@ import React, { useState } from "react";
import GameBoard from "./components/GameBoard"; import GameBoard from "./components/GameBoard";
import DiceSet from "./components/DiceSet"; import DiceSet from "./components/DiceSet";
import "./GamePage.scss"; import "./GamePage.scss";
import { PieceId } from "interface"; import { buildBoard, PieceId } from "interface";
import { DieViewProps } from "./types/DieViewProps";
import { PlacePieceActionStack } from "./types/PlacePieceActionStack";
const GamePage = () => { export interface GamePageProps {
const getRandomPieceId = () => { initialPieceIds: PieceId[];
const randomId = Math.floor(Math.random() * Object.keys(PieceId).length); }
return Object.values(PieceId)[randomId];
};
const [dice, setDice] = useState( const GamePage = (props: GamePageProps) => {
[1, 2, 3, 4].map(() => { const { initialPieceIds } = props;
const [pieceIds] = useState(initialPieceIds);
const getDiceSet: () => DieViewProps[] = () =>
pieceIds.map((pieceId) => {
return { return {
pieceId: getRandomPieceId(), pieceId: pieceId,
isSelected: false, isSelected: false,
isDisabled: false, isDisabled: false,
isSpecial: false,
rotation: 0,
};
});
const [dice, setDice] = useState(getDiceSet());
const [specialDice, setSpecialDice] = useState(
[
PieceId.P03,
PieceId.P08,
PieceId.P13,
PieceId.P14,
PieceId.P15,
PieceId.P19,
].map((pieceId) => {
return {
pieceId: pieceId,
isSelected: false,
isDisabled: false,
isSpecial: true,
rotation: 0, rotation: 0,
}; };
}), }),
); );
const [specialDieUsedInRound, setSpecialDieUsedInRound] = useState(false);
const modifyDieState = (
matcher: (die: DieViewProps) => boolean,
newStateComputer: (die: DieViewProps) => Partial<DieViewProps>,
) => {
setDice(
dice.map((die) => {
if (!matcher(die)) return die;
return {
...die,
...newStateComputer(die),
};
}),
);
setSpecialDice(
specialDice.map((die) => {
if (!matcher(die)) return die;
return {
...die,
...newStateComputer(die),
};
}),
);
};
const fetchDie = (matcher: (die: DieViewProps) => boolean) => {
return dice.concat(specialDice).find((die) => matcher(die));
};
const [board, setBoard] = useState(buildBoard());
const [id, setId] = useState(1);
const refreshBoardRender = () => {
setBoard(board);
setId(id + 1);
};
const [placePieceActionStack, setPlacePieceActionStack] = useState(
new PlacePieceActionStack(),
);
const resetBoard = () => {
placePieceActionStack.resetActions(board);
setBoard(board);
modifyDieState(
() => true,
() => {
return { isSelected: false, isDisabled: false };
},
);
setId(id + 1);
};
const commitBoard = () => {
if (dice.some((die) => !die.isDisabled)) return;
placePieceActionStack.commitActions();
setDice(getDiceSet());
setSpecialDieUsedInRound(false);
};
return ( return (
<React.Fragment> <React.Fragment>
<h1>Game Page Title</h1> <h1>Game Page Title</h1>
<div className="game-panel"> <div className="game-panel" id={id.toString()}>
<GameBoard dice={dice} setDice={setDice} /> <GameBoard
<div className="rigth-panel"> modifyDieState={modifyDieState}
<DiceSet dice={dice} setDice={setDice} /> fetchDie={fetchDie}
setSpecialDieUsedInRound={setSpecialDieUsedInRound}
refreshBoardRender={refreshBoardRender}
board={board}
placePieceActionStack={placePieceActionStack}
setPlacePieceActionStack={setPlacePieceActionStack}
/>
<div className="right-panel">
<DiceSet
dice={dice}
specialDice={specialDice}
modifyDieState={modifyDieState}
specialDieUsedInRound={specialDieUsedInRound}
resetBoard={resetBoard}
commitBoard={commitBoard}
/>
</div> </div>
</div> </div>
</React.Fragment> </React.Fragment>

View File

@ -3,18 +3,12 @@
height: 100px; height: 100px;
border: 2px solid black; border: 2px solid black;
border-radius: 10%; border-radius: 10%;
position: relative;
.piece { .piece {
border-radius: 10%; border-radius: 10%;
position: relative; position: absolute;
top: 0;
&:nth-child(2) {
top: -20px;
}
&:nth-child(3) {
top: -40px;
}
&.rotate-90 { &.rotate-90 {
transform: rotate(-90deg); transform: rotate(-90deg);
@ -29,19 +23,23 @@
} }
} }
&.house { .cell-type-icon {
background-color: blue; width: 25px;
height: 25px;
padding: 2px;
margin: 2px;
position: absolute;
top: 0;
left: 0;
}
} }
&.university { .cell .exit {
background-color: orange; position: absolute;
} width: 20px;
height: 20px;
&.factory { &:has(.exit-road) {
background-color: grey;
}
.exit:has(.exit-road) {
border: solid black; border: solid black;
border-width: 0 1px; border-width: 0 1px;
@ -55,21 +53,7 @@
} }
} }
.exit:has(.exit-road) { &:has(.exit-rail) {
border: solid black;
border-width: 0 1px;
.exit-road {
margin: auto;
width: 0;
height: 100%;
border: dashed black;
border-width: 0 1px;
transform: translate(calc(50% - 1px));
}
}
.exit:has(.exit-rail) {
border-width: 0 1px; border-width: 0 1px;
.exit-rail { .exit-rail {
@ -82,7 +66,7 @@
} }
} }
.exit:has(.exit-ambivalent) { &:has(.exit-ambivalent) {
border-width: 0 1px; border-width: 0 1px;
border-style: dotted; border-style: dotted;
@ -95,47 +79,30 @@
transform: translate(calc(50%)); transform: translate(calc(50%));
} }
} }
&:has(.exit-north), &:has(.exit-south) {
left: 50%;
} }
.exit {
position: relative;
width: 20px;
height: 20px;
&:has(.exit-north) { &:has(.exit-north) {
left: 50%;
transform: translate(calc(-50% - 1px), -100%); transform: translate(calc(-50% - 1px), -100%);
} }
&:has(.exit-south) { &:has(.exit-south) {
left: 50%;
top: 100%; top: 100%;
transform: translate(calc(-50% - 1px), 0%); transform: translate(calc(-50% - 1px), 0%);
} }
&:has(.exit-east), &:has(.exit-west) {
top: 50%;
}
&:has(.exit-east) { &:has(.exit-east) {
left: 100%; left: 100%;
top: 50%;
transform: rotate(90deg) translate(-50%, 0); transform: rotate(90deg) translate(-50%, 0);
} }
&:has(.exit-west) { &:has(.exit-west) {
left: 0%; left: 0%;
top: 50%;
transform: rotate(90deg) translate(-50%, 100%); transform: rotate(90deg) translate(-50%, 100%);
} }
&:has(.exit-north), &:has(.exit-south) {
+ .exit:has(.exit-east) {
left: 100%;
transform: rotate(90deg) translate(-150%, 0);
}
}
&:has(.exit-north), &:has(.exit-south) {
+ .exit:has(.exit-west) {
top: 50%;
transform: rotate(90deg) translate(-150%, 100%);
}
}
} }

View File

@ -1,63 +1,58 @@
import { Cell, directions, Exit, PieceId } from "interface"; import { Cell, CellType, directions, Exit, PieceId } from "interface";
import "./BoardCell.scss"; import "./BoardCell.scss";
import { useState } from "react"; import { useState } from "react";
import { DieViewProps } from "../types/DieViewProps";
export interface BoardCellProps { export interface BoardCellProps {
cell: Cell; cell: Cell;
dice: {
pieceId: PieceId;
isSelected: boolean;
isDisabled: boolean;
rotation: number;
}[];
setDice: React.Dispatch<
React.SetStateAction<
{
pieceId: PieceId;
isSelected: boolean;
isDisabled: boolean;
rotation: number;
}[]
>
>;
refreshBoardRender: () => void; refreshBoardRender: () => void;
modifyDieState: (
matcher: (die: DieViewProps) => boolean,
newStateComputer: (die: DieViewProps) => Partial<DieViewProps>,
) => void;
fetchDie: (
matcher: (die: DieViewProps) => boolean,
) => DieViewProps | undefined;
setSpecialDieUsedInRound: React.Dispatch<React.SetStateAction<boolean>>;
placePieceActionHandler: (pieceId: PieceId, rotation: number) => void;
} }
const BoardCell = (props: BoardCellProps) => { const BoardCell = (props: BoardCellProps) => {
const { cell, dice, setDice, refreshBoardRender } = props; const {
cell,
refreshBoardRender,
modifyDieState,
fetchDie,
setSpecialDieUsedInRound,
placePieceActionHandler,
} = props;
const [pieceRotationAngle, setPieceRotationAngle] = useState(0); const [pieceRotationAngle, setPieceRotationAngle] = useState(0);
const handleBoardCellClick = () => { const handleBoardCellClick = () => {
const selectedDie = dice.find((die) => die.isSelected); const selectedDie = fetchDie((die) => die.isSelected);
if (!selectedDie) return; if (!selectedDie) return;
try { try {
cell.placePiece( placePieceActionHandler(selectedDie.pieceId, selectedDie.rotation);
selectedDie.pieceId,
selectedDie.rotation as 0 | 90 | 180 | 270,
);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
return; return;
} }
setDice( modifyDieState(
dice.map((die) => { (die) => die === selectedDie,
return die !== selectedDie () => {
? die return {
: {
...selectedDie,
isSelected: false, isSelected: false,
isDisabled: true, isDisabled: true,
}; };
}), },
); );
if (selectedDie.isSpecial) setSpecialDieUsedInRound(true);
// Set rotation to the piece in the board, not the die
setPieceRotationAngle(selectedDie.rotation); setPieceRotationAngle(selectedDie.rotation);
refreshBoardRender(); refreshBoardRender();
}; };
return ( return (
<div <div className={"cell"} onClick={handleBoardCellClick}>
className={"cell " + cell.cellType.toLowerCase()}
onClick={handleBoardCellClick}
>
{directions.map((direction) => { {directions.map((direction) => {
const traversedNode = cell.getNodeAt(direction).traverseBorder(); const traversedNode = cell.getNodeAt(direction).traverseBorder();
const isExit = traversedNode instanceof Exit; const isExit = traversedNode instanceof Exit;
@ -77,6 +72,12 @@ const BoardCell = (props: BoardCellProps) => {
src={`pieces/${cell.placedPiece.id}.jpeg`} src={`pieces/${cell.placedPiece.id}.jpeg`}
></img> ></img>
)} )}
{cell.cellType !== CellType.NORMAL && (
<img
className="cell-type-icon"
src={`./${cell.cellType.toLowerCase()}-icon.png`}
></img>
)}
</div> </div>
); );
}; };

View File

@ -1,22 +1,25 @@
.dice-set-actions { .dice-set-actions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: 50px 50px 0;
width: max-content; width: max-content;
margin: auto; margin: auto auto 50px auto;
img { img {
border: 3px solid green; border: 3px solid green;
box-shadow: 0 0 10px green; box-shadow: 0 0 10px green;
border-radius: 20%; border-radius: 20%;
padding: 5px; padding: 5px;
width: 80px; width: 70px;
height: 80px; height: 70px;
margin-right: 10px; margin-right: 10px;
&.icon-inverted { &.icon-inverted {
transform: scaleX(-1); transform: scaleX(-1);
} }
&:last-child {
margin-right: 0;
}
} }
} }
@ -24,5 +27,14 @@
display: grid; display: grid;
grid-template-columns: repeat(2, auto); grid-template-columns: repeat(2, auto);
gap: 20px; gap: 20px;
padding: 50px; margin: 0 0 50px 0;
}
.special-dice-set {
display: grid;
grid-template-columns: repeat(3, auto);
margin: 0 0 50px 0;
column-gap: 12%;
row-gap: 20px;
width: 100%;
} }

View File

@ -1,55 +1,51 @@
import { PieceId } from "interface";
import "./DiceSet.scss"; import "./DiceSet.scss";
import Die from "./Die"; import Die from "./Die";
import React from "react"; import React from "react";
import { DieViewProps } from "../types/DieViewProps";
export interface DiceSetProps { export interface DiceSetProps {
dice: { dice: DieViewProps[];
pieceId: PieceId; specialDice: DieViewProps[];
isSelected: boolean; modifyDieState: (
isDisabled: boolean; matcher: (die: DieViewProps) => boolean,
rotation: number; newStateComputer: (die: DieViewProps) => Partial<DieViewProps>,
}[]; ) => void;
setDice: React.Dispatch< specialDieUsedInRound: boolean;
React.SetStateAction< resetBoard: () => void;
{ commitBoard: () => void;
pieceId: PieceId;
isSelected: boolean;
isDisabled: boolean;
rotation: number;
}[]
>
>;
} }
const DiceSet = (props: DiceSetProps) => { const DiceSet = (props: DiceSetProps) => {
const { dice, setDice } = props; const {
const handleDieClick = (die: { dice,
pieceId: PieceId; specialDice,
isSelected: boolean; modifyDieState,
isDisabled: boolean; specialDieUsedInRound,
rotation: number; resetBoard,
}) => { commitBoard,
if (die.isDisabled) return; } = props;
const newDiceState = dice.map((oldDie) => { const handleDieClick = (clickedDie: DieViewProps) => {
const isSelected = die === oldDie; if (clickedDie.isDisabled) return;
const isSpecialDie = clickedDie.isSpecial;
if (isSpecialDie && specialDieUsedInRound) return;
modifyDieState(
() => true,
(die) => {
return { return {
...oldDie, isSelected: die === clickedDie,
isSelected: isSelected,
}; };
}); },
setDice(newDiceState); );
}; };
const handleRotateButton = (rotation: number) => { const handleRotateButton = (rotation: number) => {
const newDiceState = dice.map((die) => { modifyDieState(
if (!die.isSelected) return die; (die) => die.isSelected,
const rotationAngle = (die.rotation + rotation + 360) % 360; (die) => {
return { return {
...die, rotation: (die.rotation + rotation + 360) % 360,
rotation: rotationAngle,
}; };
}); },
setDice(newDiceState); );
}; };
return ( return (
@ -64,12 +60,19 @@ const DiceSet = (props: DiceSetProps) => {
className="icon-inverted" className="icon-inverted"
onClick={() => handleRotateButton(90)} onClick={() => handleRotateButton(90)}
></img> ></img>
<img src="./clean-icon.png" onClick={resetBoard}></img>
<img src="./commit-icon.png" onClick={commitBoard}></img>
</div> </div>
<div className="dice-set"> <div className="dice-set">
{dice.map((die) => ( {dice.map((die) => (
<Die die={die} handleDieClick={handleDieClick} /> <Die die={die} handleDieClick={handleDieClick} />
))} ))}
</div> </div>
<div className="special-dice-set">
{specialDice.map((die) => (
<Die die={die} handleDieClick={handleDieClick} />
))}
</div>
</React.Fragment> </React.Fragment>
); );
}; };

View File

@ -30,3 +30,8 @@
transition: transform 0.5s cubic-bezier(.47,1.64,.41,.8); transition: transform 0.5s cubic-bezier(.47,1.64,.41,.8);
} }
.special-dice-set .dice {
height: 80px;
width: 80px;
}

View File

@ -1,19 +1,9 @@
import { PieceId } from "interface";
import "./Die.scss"; import "./Die.scss";
import { DieViewProps } from "../types/DieViewProps";
interface DieProps { interface DieProps {
die: { die: DieViewProps;
pieceId: PieceId; handleDieClick: (die: DieViewProps) => void;
isSelected: boolean;
isDisabled: boolean;
rotation: number;
};
handleDieClick: (die: {
pieceId: PieceId;
isSelected: boolean;
isDisabled: boolean;
rotation: number;
}) => void;
} }
const Die = (props: DieProps) => { const Die = (props: DieProps) => {

View File

@ -1,43 +1,44 @@
import { buildBoard, PieceId } from "interface"; import { Cell } from "interface";
import "./GameBoard.scss"; import "./GameBoard.scss";
import { useState } from "react";
import BoardCell from "./BoardCell"; import BoardCell from "./BoardCell";
import { DieViewProps } from "../types/DieViewProps";
import { PlacePieceActionStack } from "../types/PlacePieceActionStack";
import { PlacePieceAction } from "../types/PlacePieceAction";
export interface GameBoardProps { export interface GameBoardProps {
dice: { modifyDieState: (
pieceId: PieceId; matcher: (die: DieViewProps) => boolean,
isSelected: boolean; newStateComputer: (die: DieViewProps) => Partial<DieViewProps>,
isDisabled: boolean; ) => void;
rotation: number; fetchDie: (
}[]; matcher: (die: DieViewProps) => boolean,
setDice: React.Dispatch< ) => DieViewProps | undefined;
React.SetStateAction< setSpecialDieUsedInRound: React.Dispatch<React.SetStateAction<boolean>>;
{ refreshBoardRender: () => void;
pieceId: PieceId; board: Cell[][];
isSelected: boolean; placePieceActionStack: PlacePieceActionStack;
isDisabled: boolean; setPlacePieceActionStack: React.Dispatch<
rotation: number; React.SetStateAction<PlacePieceActionStack>
}[]
>
>; >;
} }
const GameBoard = (props: GameBoardProps) => { const GameBoard = (props: GameBoardProps) => {
const [board, setBoard] = useState(buildBoard()); const { board, placePieceActionStack, setPlacePieceActionStack } = props;
const [id, setId] = useState(1);
const refreshBoardRender = () => {
setBoard(board);
setId(id + 1);
};
return ( return (
<div className="game-board" id={id.toString()}> <div className="game-board">
{board.flatMap((row) => {board.flatMap((row, rowIndex) =>
row.map((cell) => ( row.map((cell, colIndex) => (
<BoardCell <BoardCell
{...props} {...props}
cell={cell} cell={cell}
refreshBoardRender={refreshBoardRender} placePieceActionHandler={(pieceId, rotation) => {
placePieceActionStack.executeAction(
new PlacePieceAction(pieceId, rotation, rowIndex, colIndex),
board,
);
setPlacePieceActionStack(placePieceActionStack);
}}
/> />
)), )),
)} )}

View File

@ -0,0 +1,9 @@
import { PieceId } from "interface";
export interface DieViewProps {
pieceId: PieceId;
isSelected: boolean;
isDisabled: boolean;
readonly isSpecial: boolean;
rotation: number;
}

View File

@ -0,0 +1,32 @@
import { Cell, PieceId } from "interface";
export class PlacePieceAction {
pieceId: PieceId;
rotation: number;
cell: {
row: number;
col: number;
};
constructor(pieceId: PieceId, rotation: number, row: number, col: number) {
this.pieceId = pieceId;
this.rotation = rotation;
this.cell = {
row: row,
col: col,
};
}
do(board: Cell[][]) {
const cell = board[this.cell.row][this.cell.col];
cell.placePiece(this.pieceId, this.rotation as 0 | 90 | 180 | 270);
}
undo(board: Cell[][]) {
const cell = board[this.cell.row][this.cell.col];
if (!cell.placedPiece || cell.placedPiece.id !== this.pieceId) {
throw Error("Un-doing action error");
}
cell.removePiece();
}
}

View File

@ -0,0 +1,40 @@
import { Cell } from "interface";
import { PlacePieceAction } from "./PlacePieceAction";
export class PlacePieceActionStack {
readonly committedActions: PlacePieceAction[] = [];
inProgressActions: PlacePieceAction[] = [];
executeAction(action: PlacePieceAction, board: Cell[][]) {
action.do(board);
this.inProgressActions.push(action);
}
undoLastAction(board: Cell[][]) {
const lastAction = this.inProgressActions.pop();
if (!lastAction) {
throw Error("No in progress action to undo");
}
lastAction.undo(board);
}
resetActions(board: Cell[][]) {
try {
while (true) {
this.undoLastAction(board);
}
} catch (error) {
if (
!(error instanceof Error) ||
error.message !== "No in progress action to undo"
) {
throw error;
}
}
}
commitActions() {
this.committedActions.push(...this.inProgressActions);
this.inProgressActions = [];
}
}

View File

@ -0,0 +1,50 @@
.lobby-page {
height: 100%;
width: 100%;
padding: 2%;
margin: 0 auto;
text-align: center;
background: linear-gradient(-45deg, #03444a, #00a8a8, #f1bc52, #ff8f4b);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
height: 100vh;
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.lobby-page-body {
margin: auto;
width: 20%;
min-width: 300px;
.lobby-user-name {
margin: 0 auto 10px;
padding: 10px;
width: 60%;
border: 2px solid cyan;
border-radius: 10px;
}
.game-code {
margin: 0 auto 10px;
padding: 10px;
width: 60%;
border: 2px solid blue;
border-radius: 10px;
}
.start-game-button {
margin: 30px 0;
}
}
}

View File

@ -0,0 +1,39 @@
import { useState } from "react";
import "./LobbyPage.scss";
import { Socket } from "socket.io-client";
import { attachHandlerToUpdateLobbyEvent, ClientEvent } from "interface";
export interface LobbyPageProps {
initialPlayerNames: string[];
gameCode: string;
socket: Socket;
}
const LobbyPage = (props: LobbyPageProps) => {
const { initialPlayerNames, gameCode, socket } = props;
const [playerNames, setPlayerNames] = useState(initialPlayerNames);
attachHandlerToUpdateLobbyEvent(socket, (event) => {
setPlayerNames(event.playerNames);
});
const startGame = () => socket.emit(ClientEvent.START_GAME);
return (
<div className="lobby-page">
<div className="landing-page-title">
<h1>Trains And Roads</h1>
</div>
<div className="lobby-page-body">
{playerNames.map((name) => (
<article className="lobby-user-name">{name}</article>
))}
<div className="start-game-button">
<button onClick={startGame}>Start Game</button>
</div>
<article className="game-code">Game code: {gameCode}</article>
</div>
</div>
);
};
export default LobbyPage;