diff --git a/app/package-lock.json b/app/package-lock.json index 85c409c..a6c152e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -48,7 +48,8 @@ "license": "ISC", "dependencies": { "socket.io": "^4.8.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "uuid": "^11.0.3" }, "devDependencies": { "@eslint/js": "^8.0.0", diff --git a/interface/.eslintrc.js b/interface/.eslintrc.js index 9700d32..5b53304 100644 --- a/interface/.eslintrc.js +++ b/interface/.eslintrc.js @@ -13,5 +13,7 @@ module.exports = { jest: true, }, ignorePatterns: [".eslintrc.js", "dist/"], - rules: {}, + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + }, }; diff --git a/interface/BoardBuilder.ts b/interface/BoardBuilder.ts new file mode 100644 index 0000000..e2e79c7 --- /dev/null +++ b/interface/BoardBuilder.ts @@ -0,0 +1,115 @@ +import { CellType } from "./constants/CellType"; +import { Direction } from "./constants/Direction"; +import { ExitType } from "./constants/ExitType"; +import { Cell } from "./types/Cell"; +import { Exit } from "./types/Exit"; + +const boardSize = 7; +const universityLocations = [ + [2, 0], + [4, 1], + [3, 3], +]; +const factoryLocations = [ + [0, 0], + [5, 2], + [4, 6], +]; +const houseLocations = [ + [0, 2], + [1, 5], + [2, 4], + [5, 4], +]; +const mapPosition = (position: number[], type: CellType) => { + return { + row: position[0], + col: position[1], + type: type, + }; +}; +const specialUniversityCells = universityLocations.map((position) => + mapPosition(position, CellType.UNIVERSITY), +); +const specialFactoryCells = factoryLocations.map((position) => + mapPosition(position, CellType.FACTORY), +); +const specialHouseCells = houseLocations.map((position) => + mapPosition(position, CellType.HOUSE), +); +const specialCells = specialUniversityCells + .concat(specialFactoryCells) + .concat(specialHouseCells); + +const specialExitIndexes1 = [1, 5]; +const specialExitIndexes2 = [3]; + +function createBoard(): Cell[][] { + const indexes = Array.from(Array(boardSize).keys()); + return indexes.map((rowIndex) => + indexes.map((colIndex) => { + const specialCell = specialCells.find( + (specialCell) => + specialCell.row === rowIndex && specialCell.col === colIndex, + ); + return new Cell(specialCell ? specialCell.type : CellType.NORMAL); + }), + ); +} + +function connectAdjacentCells(board: Cell[][]) { + const indexes = Array.from(Array(boardSize).keys()); + for (const rowIndex of indexes.slice(0, -1)) { + for (const colIndex of indexes) { + const cell = board[rowIndex][colIndex]; + const bottomCell = board[rowIndex + 1][colIndex]; + cell + .getNodeAt(Direction.SOUTH) + .linkToNode(bottomCell.getNodeAt(Direction.NORTH)); + } + } + + for (const rowIndex of indexes) { + for (const colIndex of indexes.slice(0, -1)) { + const cell = board[rowIndex][colIndex]; + const rightCell = board[rowIndex][colIndex + 1]; + cell + .getNodeAt(Direction.EAST) + .linkToNode(rightCell.getNodeAt(Direction.WEST)); + } + } +} + +function addExits(board: Cell[][]) { + // Add exits to top row + board[0].forEach((cell, colIndex) => { + let exitType = ExitType.AMBIVALENT; + if (specialExitIndexes1.includes(colIndex)) exitType = ExitType.ROAD; + if (specialExitIndexes2.includes(colIndex)) exitType = ExitType.RAIL; + cell.getNodeAt(Direction.NORTH).linkToNode(new Exit(exitType)); + }); + + // Add exits to bottom row + board[boardSize - 1].forEach((cell, colIndex) => { + let exitType = ExitType.AMBIVALENT; + if (specialExitIndexes1.includes(colIndex)) exitType = ExitType.ROAD; + if (specialExitIndexes2.includes(colIndex)) exitType = ExitType.RAIL; + cell.getNodeAt(Direction.SOUTH).linkToNode(new Exit(exitType)); + }); + + // Add exits to left and right columns + board.forEach((row, rowIndex) => { + let exitType = ExitType.AMBIVALENT; + if (specialExitIndexes1.includes(rowIndex)) exitType = ExitType.RAIL; + if (specialExitIndexes2.includes(rowIndex)) exitType = ExitType.ROAD; + row[0].getNodeAt(Direction.WEST).linkToNode(new Exit(exitType)); + row[boardSize - 1].getNodeAt(Direction.EAST).linkToNode(new Exit(exitType)); + }); +} + +export function buildBoard(): Cell[][] { + const board: Cell[][] = createBoard(); + connectAdjacentCells(board); + addExits(board); + return board; +} diff --git a/interface/constants/CellType.ts b/interface/constants/CellType.ts new file mode 100644 index 0000000..a22a68e --- /dev/null +++ b/interface/constants/CellType.ts @@ -0,0 +1,6 @@ +export enum CellType { + NORMAL = "NORMAL", + HOUSE = "HOUSE", + FACTORY = "FACTORY", + UNIVERSITY = "UNIVERSITY", +} diff --git a/interface/constants/Direction.ts b/interface/constants/Direction.ts new file mode 100644 index 0000000..d44deda --- /dev/null +++ b/interface/constants/Direction.ts @@ -0,0 +1,30 @@ +export enum Direction { + NORTH = "NORTH", + SOUTH = "SOUTH", + EAST = "EAST", + WEST = "WEST", +} + +export const directions: Direction[] = [ + Direction.NORTH, + Direction.SOUTH, + Direction.EAST, + Direction.WEST, +]; + +export function rotateDirection( + initialDirection: Direction, + rotationAngle: 0 | 90 | 180 | 270, +): Direction { + const angleToDirectionMap: Record = { + [Direction.NORTH]: 0, + [Direction.EAST]: 90, + [Direction.SOUTH]: 180, + [Direction.WEST]: 270, + }; + const finalAngle = + (360 + angleToDirectionMap[initialDirection] - rotationAngle) % 360; + return (Object.keys(angleToDirectionMap) as Direction[]).find( + (direction) => angleToDirectionMap[direction] === finalAngle, + )!; +} diff --git a/interface/constants/ExitType.ts b/interface/constants/ExitType.ts new file mode 100644 index 0000000..2a6bf4c --- /dev/null +++ b/interface/constants/ExitType.ts @@ -0,0 +1,5 @@ +export enum ExitType { + RAIL = "RAIL", + ROAD = "ROAD", + AMBIVALENT = "AMBIVALENT", +} diff --git a/interface/constants/InternalNodeType.ts b/interface/constants/InternalNodeType.ts new file mode 100644 index 0000000..708179d --- /dev/null +++ b/interface/constants/InternalNodeType.ts @@ -0,0 +1,4 @@ +export enum InternalNodeType { + NONE = "NONE", + STATION = "STATION", +} diff --git a/interface/constants/Pieces.ts b/interface/constants/Pieces.ts new file mode 100644 index 0000000..e4bddc3 --- /dev/null +++ b/interface/constants/Pieces.ts @@ -0,0 +1,410 @@ +import { Piece } from "../types/Piece"; +import { Direction } from "./Direction"; +import { InternalNodeType } from "./InternalNodeType"; +import { TrackType } from "./TrackType"; + +const STRAIGHT_RAIL: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + endPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + ], +}); +const TURN_RAIL: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.SOUTH, + endPoint: Direction.EAST, + type: TrackType.RAIL, + }, + ], +}); +const FOUR_WAY_CROSS_RAIL: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.NONE, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + type: TrackType.RAIL, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.RAIL, + }, + ], +}); +const T_JUNCTION_RAIL: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.NONE, + trackDefinitions: [ + { + startPoint: Direction.WEST, + type: TrackType.RAIL, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + type: TrackType.RAIL, + }, + ], +}); +const DEAD_END_STATION_RAIL: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + ], +}); +const TURN_RAIL_TO_ROAD: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + ], +}); +const T_JUNCTION_WITH_ROAD_ON_SIDE: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + ], +}); +const FOUR_WAY_WITH_ONE_ROAD: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.RAIL, + }, + ], +}); +const T_JUNCTION_WITH_ROAD_AT_CENTER: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + ], +}); +const STRAIGHT_TRACK_CHANGE: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.WEST, + type: TrackType.RAIL, + }, + ], +}); +const DEAD_END_STATION_ROAD: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + ], +}); +const T_JUNCTION_WITH_RAIL_AT_CENTER: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const FOUR_WAY_PERPENDICULAR_CROSSING: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + ], +}); +const FOUR_WAY_WITH_ONE_RAIL: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.ROAD, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const FOUR_WAY_TURNING_CROSSING: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.STATION, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.ROAD, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.WEST, + type: TrackType.RAIL, + }, + ], +}); +const LEVEL_CROSSING: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + endPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + endPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const STRAIGHT_ROAD: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.EAST, + endPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const T_JUNCTION_ROAD: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.NONE, + trackDefinitions: [ + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.ROAD, + }, + { + startPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const FOUR_WAY_CROSS_ROAD: Piece = new Piece({ + useInternalTracks: true, + internalNodeType: InternalNodeType.NONE, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + type: TrackType.ROAD, + }, + { + startPoint: Direction.EAST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.SOUTH, + type: TrackType.ROAD, + }, + { + startPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const DOUBLE_TURN_ROAD: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + endPoint: Direction.WEST, + type: TrackType.ROAD, + }, + { + startPoint: Direction.EAST, + endPoint: Direction.SOUTH, + type: TrackType.ROAD, + }, + ], +}); +const TURN_ROAD: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + endPoint: Direction.WEST, + type: TrackType.ROAD, + }, + ], +}); +const DOUBLE_TURN_RAIL: Piece = new Piece({ + useInternalTracks: false, + trackDefinitions: [ + { + startPoint: Direction.NORTH, + endPoint: Direction.WEST, + type: TrackType.RAIL, + }, + { + startPoint: Direction.EAST, + endPoint: Direction.SOUTH, + type: TrackType.RAIL, + }, + ], +}); + +export enum PieceId { + P01 = "P01", + P02 = "P02", + P03 = "P03", + P04 = "P04", + P05 = "P05", + P06 = "P06", + P07 = "P07", + P08 = "P08", + P09 = "P09", + P10 = "P10", + P11 = "P11", + P12 = "P12", + P13 = "P13", + P14 = "P14", + P15 = "P15", + P16 = "P16", + P17 = "P17", + P18 = "P18", + P19 = "P19", + P20 = "P20", + P21 = "P21", + P22 = "P22", +} + +export const pieceMap: Record = { + [PieceId.P01]: STRAIGHT_RAIL, + [PieceId.P02]: TURN_RAIL, + [PieceId.P03]: FOUR_WAY_CROSS_RAIL, + [PieceId.P04]: T_JUNCTION_RAIL, + [PieceId.P05]: DEAD_END_STATION_RAIL, + [PieceId.P06]: TURN_RAIL_TO_ROAD, + [PieceId.P07]: T_JUNCTION_WITH_ROAD_ON_SIDE, + [PieceId.P08]: FOUR_WAY_WITH_ONE_ROAD, + [PieceId.P09]: T_JUNCTION_WITH_ROAD_AT_CENTER, + [PieceId.P10]: STRAIGHT_TRACK_CHANGE, + [PieceId.P11]: DEAD_END_STATION_ROAD, + [PieceId.P12]: T_JUNCTION_WITH_RAIL_AT_CENTER, + [PieceId.P13]: FOUR_WAY_PERPENDICULAR_CROSSING, + [PieceId.P14]: FOUR_WAY_WITH_ONE_RAIL, + [PieceId.P15]: FOUR_WAY_TURNING_CROSSING, + [PieceId.P16]: LEVEL_CROSSING, + [PieceId.P17]: STRAIGHT_ROAD, + [PieceId.P18]: T_JUNCTION_ROAD, + [PieceId.P19]: FOUR_WAY_CROSS_ROAD, + [PieceId.P20]: DOUBLE_TURN_ROAD, + [PieceId.P21]: TURN_ROAD, + [PieceId.P22]: DOUBLE_TURN_RAIL, +}; diff --git a/interface/index.ts b/interface/index.ts index 11effb9..cacd699 100644 --- a/interface/index.ts +++ b/interface/index.ts @@ -1,6 +1,20 @@ +export * from "./constants/CellType"; +export * from "./constants/Direction"; +export * from "./constants/ExitType"; +export * from "./constants/InternalNodeType"; +export * from "./constants/Pieces"; export * from "./constants/TrackType"; +export * from "./types/Border"; +export * from "./types/Cell"; +export * from "./types/Exit"; +export * from "./types/ExternalNode"; +export * from "./types/InternalNode"; +export * from "./types/Piece"; +export * from "./types/PlacedPiece"; +export * from "./BoardBuilder"; export * from "./server-events/ServerError"; export * from "./server-events/ServerEvent"; +export * from "./server-events/StartRoundEvent"; export * from "./server-events/UpdateLobbyEvent"; export * from "./client-events/ClientEvent"; export * from "./client-events/CreateLobbyEvent"; diff --git a/interface/package-lock.json b/interface/package-lock.json index c689021..5727a79 100644 --- a/interface/package-lock.json +++ b/interface/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "socket.io": "^4.8.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "uuid": "^11.0.3" }, "devDependencies": { "@eslint/js": "^8.0.0", @@ -271,17 +272,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", - "integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz", + "integrity": "sha512-HU1KAdW3Tt8zQkdvNoIijfWDMvdSweFYm4hWh+KwhPstv+sCmWb89hCIP8msFm9N1R/ooh9honpSuvqKWlYy3w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.15.0", - "@typescript-eslint/type-utils": "8.15.0", - "@typescript-eslint/utils": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0", + "@typescript-eslint/scope-manager": "8.17.0", + "@typescript-eslint/type-utils": "8.17.0", + "@typescript-eslint/utils": "8.17.0", + "@typescript-eslint/visitor-keys": "8.17.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -305,16 +306,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz", - "integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.17.0.tgz", + "integrity": "sha512-Drp39TXuUlD49F7ilHHCG7TTg8IkA+hxCuULdmzWYICxGXvDXmDmWEjJYZQYgf6l/TFfYNE167m7isnc3xlIEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.15.0", - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/typescript-estree": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0", + "@typescript-eslint/scope-manager": "8.17.0", + "@typescript-eslint/types": "8.17.0", + "@typescript-eslint/typescript-estree": "8.17.0", + "@typescript-eslint/visitor-keys": "8.17.0", "debug": "^4.3.4" }, "engines": { @@ -334,14 +335,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz", - "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.17.0.tgz", + "integrity": "sha512-/ewp4XjvnxaREtqsZjF4Mfn078RD/9GmiEAtTeLQ7yFdKnqwTOgRMSvFz4et9U5RiJQ15WTGXPLj89zGusvxBg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0" + "@typescript-eslint/types": "8.17.0", + "@typescript-eslint/visitor-keys": "8.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -352,14 +353,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz", - "integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.17.0.tgz", + "integrity": "sha512-q38llWJYPd63rRnJ6wY/ZQqIzPrBCkPdpIsaCfkR3Q4t3p6sb422zougfad4TFW9+ElIFLVDzWGiGAfbb/v2qw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.15.0", - "@typescript-eslint/utils": "8.15.0", + "@typescript-eslint/typescript-estree": "8.17.0", + "@typescript-eslint/utils": "8.17.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -380,9 +381,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz", - "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.17.0.tgz", + "integrity": "sha512-gY2TVzeve3z6crqh2Ic7Cr+CAv6pfb0Egee7J5UAVWCpVvDI/F71wNfolIim4FE6hT15EbpZFVUj9j5i38jYXA==", "dev": true, "license": "MIT", "engines": { @@ -394,14 +395,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz", - "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.17.0.tgz", + "integrity": "sha512-JqkOopc1nRKZpX+opvKqnM3XUlM7LpFMD0lYxTqOTKQfCWAmxw45e3qlOCsEqEB2yuacujivudOFpCnqkBDNMw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0", + "@typescript-eslint/types": "8.17.0", + "@typescript-eslint/visitor-keys": "8.17.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -423,16 +424,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz", - "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.17.0.tgz", + "integrity": "sha512-bQC8BnEkxqG8HBGKwG9wXlZqg37RKSMY7v/X8VEWD8JG2JuTHuNK0VFvMPMUKQcbk6B+tf05k+4AShAEtCtJ/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.15.0", - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/typescript-estree": "8.15.0" + "@typescript-eslint/scope-manager": "8.17.0", + "@typescript-eslint/types": "8.17.0", + "@typescript-eslint/typescript-estree": "8.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -451,13 +452,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz", - "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.17.0.tgz", + "integrity": "sha512-1Hm7THLpO6ww5QU6H/Qp+AusUUl+z/CAm3cNZZ0jQvon9yicgO7Rwd+/WWRpMKLYV6p2UvdbR27c86rzCPpreg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/types": "8.17.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1232,9 +1233,9 @@ } }, "node_modules/globals": { - "version": "15.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz", - "integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==", + "version": "15.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.13.0.tgz", + "integrity": "sha512-49TewVEz0UxZjr1WYYsWpPrhyC/B/pA8Bq0fUmet2n+eR7yn0IvNzNaoBwnK6mdkzcN+se7Ez9zUgULTz2QH4g==", "dev": true, "license": "MIT", "engines": { @@ -1667,9 +1668,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "license": "MIT", "peer": true, @@ -1958,9 +1959,9 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", - "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, "license": "MIT", "engines": { @@ -2043,6 +2044,217 @@ } } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", + "integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/type-utils": "8.15.0", + "@typescript-eslint/utils": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz", + "integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz", + "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz", + "integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/utils": "8.15.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz", + "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz", + "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz", + "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz", + "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.15.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -2058,6 +2270,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/interface/package.json b/interface/package.json index bc27f0e..7ffb7e4 100644 --- a/interface/package.json +++ b/interface/package.json @@ -23,6 +23,7 @@ "typescript-eslint": "^8.15.0" }, "dependencies": { + "uuid": "^11.0.3", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1" } diff --git a/interface/server-events/StartRoundEvent.ts b/interface/server-events/StartRoundEvent.ts new file mode 100644 index 0000000..7b870ec --- /dev/null +++ b/interface/server-events/StartRoundEvent.ts @@ -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); +} diff --git a/interface/server-events/UpdateLobbyEvent.ts b/interface/server-events/UpdateLobbyEvent.ts index 90f696a..9cbbb79 100644 --- a/interface/server-events/UpdateLobbyEvent.ts +++ b/interface/server-events/UpdateLobbyEvent.ts @@ -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"; export type UpdateLobbyEvent = { playerNames: Array; + gameCode: string; +}; + +export const emitUpdateLobbyEvent = ( + socket: ServerSocket, + payload: UpdateLobbyEvent, +) => { + socket.emit(ServerEvent.LOBBY_UPDATE, payload); }; export function attachHandlerToUpdateLobbyEvent( - socket: Socket, + socket: ClientSocket, handler: (event: UpdateLobbyEvent) => void, -): () => void { - socket.on(ServerEvent.LOBBY_UPDATE, handler); - return () => socket.off(ServerEvent.LOBBY_UPDATE, handler); +): void { + socket.once(ServerEvent.LOBBY_UPDATE, handler); } diff --git a/interface/types/Border.ts b/interface/types/Border.ts new file mode 100644 index 0000000..f5618c9 --- /dev/null +++ b/interface/types/Border.ts @@ -0,0 +1,22 @@ +import { Exit } from "./Exit"; +import { ExternalNode } from "./ExternalNode"; + +export class Border { + private readonly firstNode: ExternalNode; + private readonly secondNode: ExternalNode | Exit; + + constructor(firstNode: ExternalNode, secondNode: ExternalNode | Exit) { + this.firstNode = firstNode; + this.secondNode = secondNode; + } + + public traverseFrom(node: ExternalNode): ExternalNode | Exit { + if (node === this.firstNode) { + return this.secondNode; + } + if (node === this.secondNode) { + return this.firstNode; + } + throw Error("Unable to traverse border"); + } +} diff --git a/interface/types/Cell.ts b/interface/types/Cell.ts new file mode 100644 index 0000000..cd12587 --- /dev/null +++ b/interface/types/Cell.ts @@ -0,0 +1,112 @@ +import { CellType } from "../constants/CellType"; +import { Direction } from "../constants/Direction"; +import { ExitType } from "../constants/ExitType"; +import { PieceId, pieceMap } from "../constants/Pieces"; +import { Exit } from "./Exit"; +import { ExternalNode } from "./ExternalNode"; +import { InternalNode } from "./InternalNode"; +import { Piece } from "./Piece"; +import { PlacedPiece } from "./PlacedPiece"; + +export class Cell { + public readonly externalNodes: Map; + public readonly cellType: CellType; + public placedPiece?: { + piece: PlacedPiece; + id: PieceId; + }; + + constructor(cellType: CellType) { + this.externalNodes = new Map([ + [Direction.NORTH, new ExternalNode(this, Direction.NORTH)], + [Direction.SOUTH, new ExternalNode(this, Direction.SOUTH)], + [Direction.EAST, new ExternalNode(this, Direction.EAST)], + [Direction.WEST, new ExternalNode(this, Direction.WEST)], + ]); + this.cellType = cellType; + } + + public getNodeAt(direction: Direction): ExternalNode { + const node = this.externalNodes.get(direction); + if (!node) throw Error(`Could not find node at ${direction}`); + return node!; + } + + /** + * @param rotation in degrees counter-clockwise + */ + public placePiece(pieceId: PieceId, rotation: 0 | 90 | 180 | 270) { + if (this.placedPiece !== undefined) return; + const piece: Piece = pieceMap[pieceId].rotate(rotation); + + this.validatePiecePlacement(piece); + + this.placedPiece = { + piece: piece.toPlacedPiece(this), + id: pieceId, + }; + } + + private validatePiecePlacement(piece: Piece) { + const hasAnyConnection = Array.from(piece.tracks) + .map((track) => { + const trackExternalNodes = [ + this.getNodeAt(track.joinedPoints.firstPoint), + ]; + if (!(track.joinedPoints.secondPoint instanceof InternalNode)) { + trackExternalNodes.push( + this.getNodeAt(track.joinedPoints.secondPoint), + ); + } + return { + trackExternalNodes: trackExternalNodes, + trackType: track.type, + }; + }) + .reduce((isConnected, { trackExternalNodes, trackType }) => { + let isTrackConnected: boolean = false; + trackExternalNodes + .filter((node) => node.traverseBorder() instanceof Exit) + .forEach((node) => { + const exitType = (node.traverseBorder() as Exit).type; + isTrackConnected = true; + if ( + exitType !== ExitType.AMBIVALENT && + exitType.toString() !== trackType.toString() + ) { + throw Error( + `Unable to place piece, invalid exit type at direction ${node.direction}`, + ); + } + }); + + trackExternalNodes + .filter((node) => node.traverseBorder() instanceof ExternalNode) + .forEach((node) => { + const adjacentExternalNode = node.traverseBorder() as ExternalNode; + const adjacentTrack = + adjacentExternalNode.cell.placedPiece?.piece.findTrackForDirection( + adjacentExternalNode.direction, + ); + if (adjacentTrack !== undefined) { + isTrackConnected = true; + if (adjacentTrack.type !== trackType) { + throw Error( + `Unable to place piece next to another due to conflicting track types at ${node.direction}`, + ); + } + } + }); + + return isConnected || isTrackConnected; + }, false); + + if (!hasAnyConnection) { + throw Error("No adjacent exit or piece available to connect to"); + } + } + + public removePiece() { + this.placedPiece = undefined; + } +} diff --git a/interface/types/Exit.ts b/interface/types/Exit.ts new file mode 100644 index 0000000..38fe187 --- /dev/null +++ b/interface/types/Exit.ts @@ -0,0 +1,9 @@ +import { ExitType } from "../constants/ExitType"; + +export class Exit { + public readonly type: ExitType; + + constructor(type: ExitType) { + this.type = type; + } +} diff --git a/interface/types/ExternalNode.ts b/interface/types/ExternalNode.ts new file mode 100644 index 0000000..f23d312 --- /dev/null +++ b/interface/types/ExternalNode.ts @@ -0,0 +1,29 @@ +import { Direction } from "../constants/Direction"; +import { Border } from "./Border"; +import { Cell } from "./Cell"; +import { Exit } from "./Exit"; + +export class ExternalNode { + public readonly direction: Direction; + private border?: Border; + public readonly cell: Cell; + + constructor(cell: Cell, direction: Direction) { + this.cell = cell; + this.direction = direction; + } + + public linkToNode(other: ExternalNode | Exit) { + this.border = new Border(this, other); + if (other instanceof ExternalNode) { + other.border = this.border; + } + } + + public traverseBorder(): ExternalNode | Exit { + if (!this.border) { + throw Error(`Missing border for node`); + } + return this.border.traverseFrom(this); + } +} diff --git a/interface/types/InternalNode.ts b/interface/types/InternalNode.ts new file mode 100644 index 0000000..e2e4cc6 --- /dev/null +++ b/interface/types/InternalNode.ts @@ -0,0 +1,12 @@ +import { v4 as uuidv4 } from "uuid"; +import { InternalNodeType } from "../constants/InternalNodeType"; + +export class InternalNode { + id: string; + type: InternalNodeType; + + constructor(type: InternalNodeType) { + this.id = uuidv4(); + this.type = type; + } +} diff --git a/interface/types/Piece.ts b/interface/types/Piece.ts new file mode 100644 index 0000000..953c04d --- /dev/null +++ b/interface/types/Piece.ts @@ -0,0 +1,115 @@ +import { Direction, rotateDirection } from "../constants/Direction"; +import { TrackType } from "../constants/TrackType"; +import { Cell } from "./Cell"; +import { InternalNode } from "./InternalNode"; +import { PlacedPiece } from "./PlacedPiece"; +import { InternalNodeType } from "../constants/InternalNodeType"; + +export interface PieceProps { + readonly useInternalTracks: boolean; + readonly internalNodeType?: InternalNodeType; + readonly trackDefinitions: { + readonly startPoint: Direction; + readonly endPoint?: Direction; + readonly type: TrackType; + }[]; +} + +type Track = { + readonly joinedPoints: { + readonly firstPoint: Direction; + readonly secondPoint: Direction | InternalNode; + }; + readonly type: TrackType; +}; + +export class Piece { + readonly tracks: Set; + readonly internalNode?: InternalNode; + + constructor(pieceProps: PieceProps) { + if (pieceProps.useInternalTracks) { + if (!pieceProps.internalNodeType) { + throw Error( + "Expected to find internal node type when useInternalTracks is set", + ); + } + this.internalNode = new InternalNode(pieceProps.internalNodeType); + this.tracks = new Set( + pieceProps.trackDefinitions.map((trackDefinition) => { + return { + joinedPoints: { + firstPoint: trackDefinition.startPoint, + secondPoint: this.internalNode!, + }, + type: trackDefinition.type, + }; + }), + ); + } else { + this.internalNode = undefined; + this.tracks = new Set( + pieceProps.trackDefinitions.map((trackDefinition) => { + if (!trackDefinition.endPoint) { + throw Error("Missing end point for non-internal track"); + } + return { + joinedPoints: { + firstPoint: trackDefinition.startPoint, + secondPoint: trackDefinition.endPoint, + }, + type: trackDefinition.type, + }; + }), + ); + } + } + + rotate(rotation: 0 | 90 | 180 | 270): Piece { + return new Piece({ + useInternalTracks: this.internalNode !== undefined, + internalNodeType: this.internalNode?.type, + trackDefinitions: Array.from(this.tracks).map((track) => { + if (track.joinedPoints.secondPoint instanceof InternalNode) { + return { + type: track.type, + startPoint: rotateDirection( + track.joinedPoints.firstPoint, + rotation, + ), + }; + } else { + return { + type: track.type, + startPoint: rotateDirection( + track.joinedPoints.firstPoint, + rotation, + ), + endPoint: rotateDirection(track.joinedPoints.secondPoint, rotation), + }; + } + }), + }); + } + + toPlacedPiece(cell: Cell) { + return new PlacedPiece( + new Set( + Array.from(this.tracks).map((track) => { + return { + nodes: { + firstNode: cell.getNodeAt(track.joinedPoints.firstPoint), + secondNode: + track.joinedPoints.secondPoint instanceof InternalNode + ? (track.joinedPoints.secondPoint as InternalNode) + : cell.getNodeAt(track.joinedPoints.secondPoint as Direction), + }, + type: track.type, + }; + }), + ), + this.internalNode, + cell, + ); + } +} diff --git a/interface/types/PlacedPiece.ts b/interface/types/PlacedPiece.ts new file mode 100644 index 0000000..cdbc1f0 --- /dev/null +++ b/interface/types/PlacedPiece.ts @@ -0,0 +1,41 @@ +import { Direction } from "../constants/Direction"; +import { TrackType } from "../constants/TrackType"; +import { Cell } from "./Cell"; +import { ExternalNode } from "./ExternalNode"; +import { InternalNode } from "./InternalNode"; + +export class PlacedPiece { + tracks: Set<{ + nodes: { + firstNode: ExternalNode; + secondNode: ExternalNode | InternalNode; + }; + type: TrackType; + }>; + internalNode?: InternalNode; + cell: Cell; + constructor( + tracks: Set<{ + nodes: { + firstNode: ExternalNode; + secondNode: ExternalNode | InternalNode; + }; + type: TrackType; + }>, + internalNodes: InternalNode | undefined, + cell: Cell, + ) { + this.tracks = tracks; + this.internalNode = internalNodes; + this.cell = cell; + } + + public findTrackForDirection(direction: Direction) { + return Array.from(this.tracks).find( + (track) => + track.nodes.firstNode.direction === direction || + (track.nodes.secondNode instanceof ExternalNode && + track.nodes.secondNode.direction === direction), + ); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index ec0ff54..6028a80 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", - "@types/node": "^16.18.119", + "@types/node": "^17.0.29", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "interface": "file:../interface", @@ -39,7 +39,8 @@ "license": "ISC", "dependencies": { "socket.io": "^4.8.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "uuid": "^11.0.3" }, "devDependencies": { "@eslint/js": "^8.0.0", @@ -4201,9 +4202,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "16.18.119", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.119.tgz", - "integrity": "sha512-ia7V9a2FnhUFfetng4/sRPBMTwHZUkPFY736rb1cg9AgG7MZdR97q7/nLR9om+sq5f1la9C857E0l/nrI0RiFQ==", + "version": "17.0.29", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.29.tgz", + "integrity": "sha512-tx5jMmMFwx7wBwq/V7OohKDVb/JwJU5qCVkeLMh1//xycAJ/ESuw9aJ9SEtlCZDYi2pBfe4JkisSoAtbOsBNAA==", "license": "MIT" }, "node_modules/@types/node-forge": { diff --git a/web/package.json b/web/package.json index f240e25..b5e64b2 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", - "@types/node": "^16.18.119", + "@types/node": "^17.0.29", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "interface": "file:../interface", diff --git a/web/public/clean-icon.png b/web/public/clean-icon.png new file mode 100644 index 0000000..c06ba90 Binary files /dev/null and b/web/public/clean-icon.png differ diff --git a/web/public/commit-icon.png b/web/public/commit-icon.png new file mode 100644 index 0000000..d254d8d Binary files /dev/null and b/web/public/commit-icon.png differ diff --git a/web/public/factory-icon.png b/web/public/factory-icon.png new file mode 100644 index 0000000..e7c3ad8 Binary files /dev/null and b/web/public/factory-icon.png differ diff --git a/web/public/house-icon.png b/web/public/house-icon.png new file mode 100644 index 0000000..6d5b496 Binary files /dev/null and b/web/public/house-icon.png differ diff --git a/web/public/pieces/P01.jpeg b/web/public/pieces/P01.jpeg new file mode 100644 index 0000000..bd398d8 Binary files /dev/null and b/web/public/pieces/P01.jpeg differ diff --git a/web/public/pieces/P02.jpeg b/web/public/pieces/P02.jpeg new file mode 100644 index 0000000..405b0ff Binary files /dev/null and b/web/public/pieces/P02.jpeg differ diff --git a/web/public/pieces/P03.jpeg b/web/public/pieces/P03.jpeg new file mode 100644 index 0000000..1c75d63 Binary files /dev/null and b/web/public/pieces/P03.jpeg differ diff --git a/web/public/pieces/P04.jpeg b/web/public/pieces/P04.jpeg new file mode 100644 index 0000000..6b8ee6b Binary files /dev/null and b/web/public/pieces/P04.jpeg differ diff --git a/web/public/pieces/P05.jpeg b/web/public/pieces/P05.jpeg new file mode 100644 index 0000000..dbcb402 Binary files /dev/null and b/web/public/pieces/P05.jpeg differ diff --git a/web/public/pieces/P06.jpeg b/web/public/pieces/P06.jpeg new file mode 100644 index 0000000..1df0872 Binary files /dev/null and b/web/public/pieces/P06.jpeg differ diff --git a/web/public/pieces/P07.jpeg b/web/public/pieces/P07.jpeg new file mode 100644 index 0000000..1558f92 Binary files /dev/null and b/web/public/pieces/P07.jpeg differ diff --git a/web/public/pieces/P08.jpeg b/web/public/pieces/P08.jpeg new file mode 100644 index 0000000..46ab5e2 Binary files /dev/null and b/web/public/pieces/P08.jpeg differ diff --git a/web/public/pieces/P09.jpeg b/web/public/pieces/P09.jpeg new file mode 100644 index 0000000..bee659c Binary files /dev/null and b/web/public/pieces/P09.jpeg differ diff --git a/web/public/pieces/P10.jpeg b/web/public/pieces/P10.jpeg new file mode 100644 index 0000000..95cf7f6 Binary files /dev/null and b/web/public/pieces/P10.jpeg differ diff --git a/web/public/pieces/P11.jpeg b/web/public/pieces/P11.jpeg new file mode 100644 index 0000000..2836848 Binary files /dev/null and b/web/public/pieces/P11.jpeg differ diff --git a/web/public/pieces/P12.jpeg b/web/public/pieces/P12.jpeg new file mode 100644 index 0000000..b78e4e2 Binary files /dev/null and b/web/public/pieces/P12.jpeg differ diff --git a/web/public/pieces/P13.jpeg b/web/public/pieces/P13.jpeg new file mode 100644 index 0000000..cb273a9 Binary files /dev/null and b/web/public/pieces/P13.jpeg differ diff --git a/web/public/pieces/P14.jpeg b/web/public/pieces/P14.jpeg new file mode 100644 index 0000000..8631851 Binary files /dev/null and b/web/public/pieces/P14.jpeg differ diff --git a/web/public/pieces/P15.jpeg b/web/public/pieces/P15.jpeg new file mode 100644 index 0000000..767ceab Binary files /dev/null and b/web/public/pieces/P15.jpeg differ diff --git a/web/public/pieces/P16.jpeg b/web/public/pieces/P16.jpeg new file mode 100644 index 0000000..c7442cf Binary files /dev/null and b/web/public/pieces/P16.jpeg differ diff --git a/web/public/pieces/P17.jpeg b/web/public/pieces/P17.jpeg new file mode 100644 index 0000000..6d9ef32 Binary files /dev/null and b/web/public/pieces/P17.jpeg differ diff --git a/web/public/pieces/P18.jpeg b/web/public/pieces/P18.jpeg new file mode 100644 index 0000000..e6e9d38 Binary files /dev/null and b/web/public/pieces/P18.jpeg differ diff --git a/web/public/pieces/P19.jpeg b/web/public/pieces/P19.jpeg new file mode 100644 index 0000000..75577e8 Binary files /dev/null and b/web/public/pieces/P19.jpeg differ diff --git a/web/public/pieces/P20.jpeg b/web/public/pieces/P20.jpeg new file mode 100644 index 0000000..f82c543 Binary files /dev/null and b/web/public/pieces/P20.jpeg differ diff --git a/web/public/pieces/P21.jpeg b/web/public/pieces/P21.jpeg new file mode 100644 index 0000000..a3c8ee3 Binary files /dev/null and b/web/public/pieces/P21.jpeg differ diff --git a/web/public/pieces/P22.jpeg b/web/public/pieces/P22.jpeg new file mode 100644 index 0000000..3033940 Binary files /dev/null and b/web/public/pieces/P22.jpeg differ diff --git a/web/public/rotate-icon.png b/web/public/rotate-icon.png new file mode 100644 index 0000000..5af53ca Binary files /dev/null and b/web/public/rotate-icon.png differ diff --git a/web/public/university-icon.png b/web/public/university-icon.png new file mode 100644 index 0000000..e55f06a Binary files /dev/null and b/web/public/university-icon.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index f02aa60..93d4015 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,55 @@ -import React from "react"; +import React, { useCallback, useState } from "react"; import { Socket } from "socket.io-client"; import LandingPage from "./pages/landing/LandingPage"; +import GamePage from "./pages/game/GamePage"; +import LobbyPage from "./pages/lobby/LobbyPage"; +import { + attachHandlerToStartRoundEvent, + attachHandlerToUpdateLobbyEvent, + PieceId, +} from "interface"; export interface AppProps { socket: Socket; } const App = (props: AppProps) => { + 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 (
- + {renderedPage === "LANDING" && } + {renderedPage === "LOBBY" && ( + + )} + {renderedPage === "GAME" && ( + + )}
); }; diff --git a/web/src/pages/game/GamePage.scss b/web/src/pages/game/GamePage.scss new file mode 100644 index 0000000..87b1eba --- /dev/null +++ b/web/src/pages/game/GamePage.scss @@ -0,0 +1,17 @@ +h1 { + text-align: center; + padding: 10px; + margin: 0; +} + +.game-panel { + user-select: none; /* Standard syntax */ + display: flex; + flex-direction: row; + + .right-panel { + display: flex; + flex-direction: column; + padding: 50px; + } +} diff --git a/web/src/pages/game/GamePage.tsx b/web/src/pages/game/GamePage.tsx new file mode 100644 index 0000000..c66ee9e --- /dev/null +++ b/web/src/pages/game/GamePage.tsx @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import GameBoard from "./components/GameBoard"; +import DiceSet from "./components/DiceSet"; +import "./GamePage.scss"; +import { buildBoard, PieceId } from "interface"; +import { DieViewProps } from "./types/DieViewProps"; +import { PlacePieceActionStack } from "./types/PlacePieceActionStack"; + +export interface GamePageProps { + initialPieceIds: PieceId[]; +} + +const GamePage = (props: GamePageProps) => { + const { initialPieceIds } = props; + + const [pieceIds] = useState(initialPieceIds); + + const getDiceSet: () => DieViewProps[] = () => + pieceIds.map((pieceId) => { + return { + pieceId: pieceId, + isSelected: 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, + }; + }), + ); + const [specialDieUsedInRound, setSpecialDieUsedInRound] = useState(false); + + const modifyDieState = ( + matcher: (die: DieViewProps) => boolean, + newStateComputer: (die: DieViewProps) => Partial, + ) => { + 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 ( + +

Game Page Title

+
+ +
+ +
+
+
+ ); +}; + +export default GamePage; diff --git a/web/src/pages/game/components/BoardCell.scss b/web/src/pages/game/components/BoardCell.scss new file mode 100644 index 0000000..d1e612c --- /dev/null +++ b/web/src/pages/game/components/BoardCell.scss @@ -0,0 +1,108 @@ +.cell { + width: 100px; + height: 100px; + border: 2px solid black; + border-radius: 10%; + position: relative; + + .piece { + border-radius: 10%; + position: absolute; + top: 0; + + &.rotate-90 { + transform: rotate(-90deg); + } + + &.rotate-180 { + transform: rotate(180deg); + } + + &.rotate-270 { + transform: rotate(90deg); + } + } + + .cell-type-icon { + width: 25px; + height: 25px; + padding: 2px; + margin: 2px; + position: absolute; + top: 0; + left: 0; + } +} + +.cell .exit { + position: absolute; + width: 20px; + height: 20px; + + &:has(.exit-road) { + 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)); + } + } + + &:has(.exit-rail) { + border-width: 0 1px; + + .exit-rail { + margin: auto; + width: 0; + height: 100%; + border: solid black; + border-width: 0 1px; + transform: translate(calc(50% - 1px)); + } + } + + &:has(.exit-ambivalent) { + border-width: 0 1px; + border-style: dotted; + + .exit-ambivalent { + margin: auto; + width: 0; + height: 100%; + border: dotted black; + border-width: 0 1px 0 0; + transform: translate(calc(50%)); + } + } + &:has(.exit-north), &:has(.exit-south) { + left: 50%; + } + + &:has(.exit-north) { + transform: translate(calc(-50% - 1px), -100%); + } + + &:has(.exit-south) { + top: 100%; + transform: translate(calc(-50% - 1px), 0%); + } + + &:has(.exit-east), &:has(.exit-west) { + top: 50%; + } + + &:has(.exit-east) { + left: 100%; + transform: rotate(90deg) translate(-50%, 0); + } + + &:has(.exit-west) { + left: 0%; + transform: rotate(90deg) translate(-50%, 100%); + } +} \ No newline at end of file diff --git a/web/src/pages/game/components/BoardCell.tsx b/web/src/pages/game/components/BoardCell.tsx new file mode 100644 index 0000000..426020b --- /dev/null +++ b/web/src/pages/game/components/BoardCell.tsx @@ -0,0 +1,85 @@ +import { Cell, CellType, directions, Exit, PieceId } from "interface"; +import "./BoardCell.scss"; +import { useState } from "react"; +import { DieViewProps } from "../types/DieViewProps"; + +export interface BoardCellProps { + cell: Cell; + refreshBoardRender: () => void; + modifyDieState: ( + matcher: (die: DieViewProps) => boolean, + newStateComputer: (die: DieViewProps) => Partial, + ) => void; + fetchDie: ( + matcher: (die: DieViewProps) => boolean, + ) => DieViewProps | undefined; + setSpecialDieUsedInRound: React.Dispatch>; + placePieceActionHandler: (pieceId: PieceId, rotation: number) => void; +} + +const BoardCell = (props: BoardCellProps) => { + const { + cell, + refreshBoardRender, + modifyDieState, + fetchDie, + setSpecialDieUsedInRound, + placePieceActionHandler, + } = props; + const [pieceRotationAngle, setPieceRotationAngle] = useState(0); + const handleBoardCellClick = () => { + const selectedDie = fetchDie((die) => die.isSelected); + if (!selectedDie) return; + try { + placePieceActionHandler(selectedDie.pieceId, selectedDie.rotation); + } catch (error) { + console.log(error); + return; + } + modifyDieState( + (die) => die === selectedDie, + () => { + return { + isSelected: false, + isDisabled: true, + }; + }, + ); + if (selectedDie.isSpecial) setSpecialDieUsedInRound(true); + // Set rotation to the piece in the board, not the die + setPieceRotationAngle(selectedDie.rotation); + refreshBoardRender(); + }; + + return ( +
+ {directions.map((direction) => { + const traversedNode = cell.getNodeAt(direction).traverseBorder(); + const isExit = traversedNode instanceof Exit; + if (!isExit) return; + const className = + `exit-${direction.toLowerCase()}` + + ` exit-${(traversedNode as Exit).type.toLowerCase()}`; + return ( +
+
+
+ ); + })} + {cell.placedPiece && ( + + )} + {cell.cellType !== CellType.NORMAL && ( + + )} +
+ ); +}; + +export default BoardCell; diff --git a/web/src/pages/game/components/DiceSet.scss b/web/src/pages/game/components/DiceSet.scss new file mode 100644 index 0000000..114a733 --- /dev/null +++ b/web/src/pages/game/components/DiceSet.scss @@ -0,0 +1,40 @@ +.dice-set-actions { + display: flex; + flex-direction: row; + width: max-content; + margin: auto auto 50px auto; + + img { + border: 3px solid green; + box-shadow: 0 0 10px green; + border-radius: 20%; + padding: 5px; + width: 70px; + height: 70px; + margin-right: 10px; + + &.icon-inverted { + transform: scaleX(-1); + } + + &:last-child { + margin-right: 0; + } + } +} + +.dice-set { + display: grid; + grid-template-columns: repeat(2, auto); + gap: 20px; + 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%; +} \ No newline at end of file diff --git a/web/src/pages/game/components/DiceSet.tsx b/web/src/pages/game/components/DiceSet.tsx new file mode 100644 index 0000000..bf0b5a2 --- /dev/null +++ b/web/src/pages/game/components/DiceSet.tsx @@ -0,0 +1,80 @@ +import "./DiceSet.scss"; +import Die from "./Die"; +import React from "react"; +import { DieViewProps } from "../types/DieViewProps"; + +export interface DiceSetProps { + dice: DieViewProps[]; + specialDice: DieViewProps[]; + modifyDieState: ( + matcher: (die: DieViewProps) => boolean, + newStateComputer: (die: DieViewProps) => Partial, + ) => void; + specialDieUsedInRound: boolean; + resetBoard: () => void; + commitBoard: () => void; +} +const DiceSet = (props: DiceSetProps) => { + const { + dice, + specialDice, + modifyDieState, + specialDieUsedInRound, + resetBoard, + commitBoard, + } = props; + const handleDieClick = (clickedDie: DieViewProps) => { + if (clickedDie.isDisabled) return; + const isSpecialDie = clickedDie.isSpecial; + if (isSpecialDie && specialDieUsedInRound) return; + modifyDieState( + () => true, + (die) => { + return { + isSelected: die === clickedDie, + }; + }, + ); + }; + + const handleRotateButton = (rotation: number) => { + modifyDieState( + (die) => die.isSelected, + (die) => { + return { + rotation: (die.rotation + rotation + 360) % 360, + }; + }, + ); + }; + + return ( + +
+ handleRotateButton(-90)} + > + handleRotateButton(90)} + > + + +
+
+ {dice.map((die) => ( + + ))} +
+
+ {specialDice.map((die) => ( + + ))} +
+
+ ); +}; + +export default DiceSet; diff --git a/web/src/pages/game/components/Die.scss b/web/src/pages/game/components/Die.scss new file mode 100644 index 0000000..675582d --- /dev/null +++ b/web/src/pages/game/components/Die.scss @@ -0,0 +1,37 @@ +.dice { + height: 150px; + width: 150px; + border: 2px solid black; + border-radius: 10%; + + img{ + border-radius: 10%; + } + + &.selected { + box-shadow: 0 0 20px green; + } + + &.disabled { + box-shadow: 0 0 20px grey; + } + + &.rotate-90 { + transform: rotate(-90deg); + } + + &.rotate-180 { + transform: rotate(180deg); + } + + &.rotate-270 { + transform: rotate(90deg); + } + + transition: transform 0.5s cubic-bezier(.47,1.64,.41,.8); +} + +.special-dice-set .dice { + height: 80px; + width: 80px; +} \ No newline at end of file diff --git a/web/src/pages/game/components/Die.tsx b/web/src/pages/game/components/Die.tsx new file mode 100644 index 0000000..c039c39 --- /dev/null +++ b/web/src/pages/game/components/Die.tsx @@ -0,0 +1,25 @@ +import "./Die.scss"; +import { DieViewProps } from "../types/DieViewProps"; + +interface DieProps { + die: DieViewProps; + handleDieClick: (die: DieViewProps) => void; +} + +const Die = (props: DieProps) => { + const { die, handleDieClick } = props; + const { pieceId, isSelected, isDisabled, rotation } = die; + const className = + "dice" + + (isSelected ? " selected" : "") + + (isDisabled ? " disabled" : "") + + ` rotate-${rotation}`; + + return ( +
handleDieClick(die)}> + +
+ ); +}; + +export default Die; diff --git a/web/src/pages/game/components/GameBoard.scss b/web/src/pages/game/components/GameBoard.scss new file mode 100644 index 0000000..a11648f --- /dev/null +++ b/web/src/pages/game/components/GameBoard.scss @@ -0,0 +1,10 @@ +.game-board { + height: 850px; + width: 850px; + min-height: 850px; + min-width: 850px; + display: grid; + grid-template-columns: repeat(7, auto); + gap: auto; + padding: 50px; +} diff --git a/web/src/pages/game/components/GameBoard.tsx b/web/src/pages/game/components/GameBoard.tsx new file mode 100644 index 0000000..94a94f2 --- /dev/null +++ b/web/src/pages/game/components/GameBoard.tsx @@ -0,0 +1,49 @@ +import { Cell } from "interface"; +import "./GameBoard.scss"; +import BoardCell from "./BoardCell"; +import { DieViewProps } from "../types/DieViewProps"; +import { PlacePieceActionStack } from "../types/PlacePieceActionStack"; +import { PlacePieceAction } from "../types/PlacePieceAction"; + +export interface GameBoardProps { + modifyDieState: ( + matcher: (die: DieViewProps) => boolean, + newStateComputer: (die: DieViewProps) => Partial, + ) => void; + fetchDie: ( + matcher: (die: DieViewProps) => boolean, + ) => DieViewProps | undefined; + setSpecialDieUsedInRound: React.Dispatch>; + refreshBoardRender: () => void; + board: Cell[][]; + placePieceActionStack: PlacePieceActionStack; + setPlacePieceActionStack: React.Dispatch< + React.SetStateAction + >; +} + +const GameBoard = (props: GameBoardProps) => { + const { board, placePieceActionStack, setPlacePieceActionStack } = props; + + return ( +
+ {board.flatMap((row, rowIndex) => + row.map((cell, colIndex) => ( + { + placePieceActionStack.executeAction( + new PlacePieceAction(pieceId, rotation, rowIndex, colIndex), + board, + ); + setPlacePieceActionStack(placePieceActionStack); + }} + /> + )), + )} +
+ ); +}; + +export default GameBoard; diff --git a/web/src/pages/game/types/DieViewProps.ts b/web/src/pages/game/types/DieViewProps.ts new file mode 100644 index 0000000..71914ed --- /dev/null +++ b/web/src/pages/game/types/DieViewProps.ts @@ -0,0 +1,9 @@ +import { PieceId } from "interface"; + +export interface DieViewProps { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + readonly isSpecial: boolean; + rotation: number; +} diff --git a/web/src/pages/game/types/PlacePieceAction.ts b/web/src/pages/game/types/PlacePieceAction.ts new file mode 100644 index 0000000..6fe46d3 --- /dev/null +++ b/web/src/pages/game/types/PlacePieceAction.ts @@ -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(); + } +} diff --git a/web/src/pages/game/types/PlacePieceActionStack.ts b/web/src/pages/game/types/PlacePieceActionStack.ts new file mode 100644 index 0000000..4a6841f --- /dev/null +++ b/web/src/pages/game/types/PlacePieceActionStack.ts @@ -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 = []; + } +} diff --git a/web/src/pages/lobby/LobbyPage.scss b/web/src/pages/lobby/LobbyPage.scss new file mode 100644 index 0000000..5a935f7 --- /dev/null +++ b/web/src/pages/lobby/LobbyPage.scss @@ -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; + } + } +} diff --git a/web/src/pages/lobby/LobbyPage.tsx b/web/src/pages/lobby/LobbyPage.tsx new file mode 100644 index 0000000..a336d29 --- /dev/null +++ b/web/src/pages/lobby/LobbyPage.tsx @@ -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 ( +
+
+

Trains And Roads

+
+
+ {playerNames.map((name) => ( +
{name}
+ ))} +
+ +
+
Game code: {gameCode}
+
+
+ ); +}; + +export default LobbyPage;