Compare commits

...

43 Commits
linter ... main

Author SHA1 Message Date
MiguelMLorente 10949f30df Use server response to consume dice as defined by the backend 2025-02-09 15:54:08 +01:00
MiguelMLorente 09bd13f772 Create page transition from lobby to in-game 2025-02-09 15:54:08 +01:00
MiguelMLorente 0b5684f2b9 Fix commit and reset actions crossing pointers by creating an action stack (will be used to send data to backend) 2025-02-09 15:54:08 +01:00
MiguelMLorente 7c4a6f5603 Fix commit and reset actions crossing pointers by creating an action stack (will be used to send data to backend) 2025-02-09 15:52:36 +01:00
MiguelMLorente 0a0ca65e3b Introduce clean and commit action buttons 2025-02-09 15:52:04 +01:00
MiguelMLorente 86d5137857 Implement special dice usage and layout 2025-02-09 15:52:04 +01:00
MiguelMLorente b72a74789c Add icons for special cells and fix piece placement issues 2025-02-09 15:52:04 +01:00
MiguelMLorente 45cbf885a3 Merge pull request 'lobby-page-layout: Crate base lobby page layout' (#11) from lobby-page-layout into main
Reviewed-on: #11
2025-02-09 13:49:33 +00:00
MiguelMLorente 00c365de84 Add transition to lobby page 2025-02-09 14:38:08 +01:00
MiguelMLorente 4f9950c6a9 Create landing page base layout 2025-02-08 16:23:55 +01:00
MiguelMLorente 98e62180dc Merge branch 'landing-page-layout' 2024-12-04 23:13:27 +01:00
MiguelMLorente 2191a2fdc4 Remove duplicate socket connection 2024-12-04 22:55:40 +01:00
Laura Valera fae67ce2a2 Add User Name and store it 2024-12-04 19:06:07 +01:00
MiguelMLorente 3af9caff9a Merge pull request 'Enable piece placement in game board' (#8) from game-page into main
Reviewed-on: #8
2024-12-03 22:45:47 +00:00
MiguelMLorente 5fc02aa6a6 Enable pieces rotation in dice set and board 2024-12-03 23:03:28 +01:00
MiguelMLorente 035b7da335 Disallow piece placement when piece is not connected to adjacent piece or exit 2024-12-03 18:09:04 +01:00
MiguelMLorente b1d57ebfcd Disallow placing piece in places with invalid adjacent connections 2024-12-03 13:46:52 +01:00
MiguelMLorente 1ab3db8f67 Enable placing pieces in the game board 2024-12-03 12:29:10 +01:00
MiguelMLorente e7beb85302 Merge pull request 'Include dice views, images, and supporting types' (#7) from game-page into main
Reviewed-on: #7
2024-12-01 21:22:10 +00:00
MiguelMLorente 835ce2c62a Set up dice view from modelled pieces 2024-12-01 22:19:42 +01:00
MiguelMLorente 39d1ec9a77 Add the remaining pieces to the set 2024-12-01 16:28:17 +01:00
MiguelMLorente 406e4d2f75 Re-structure pieces builder and add include first pieces batch 2024-12-01 12:30:59 +01:00
MiguelMLorente 7354586ef5 Created piece and placed piece abstractions 2024-11-30 22:31:16 +01:00
MiguelMLorente 163ca86734 Create dice view to present all available dice to place in the board 2024-11-30 21:53:42 +01:00
MiguelMLorente af133bae8b Add images of pieces 2024-11-30 19:41:20 +01:00
MiguelMLorente d72984851e Merge pull request 'Merge game page board display into main branch' (#6) from game-page into main
Reviewed-on: #6
2024-11-30 11:31:31 +00:00
MiguelMLorente 0945336463 Implement board view based on board builder 2024-11-24 15:07:10 +01:00
DavidPerea 88627bb9a7 Add functionality to "Join Lobby" button 2024-11-24 12:26:50 +01:00
MiguelMLorente e9305893cb Create board object builder and supporting classes and types 2024-11-23 22:50:07 +01:00
MiguelMLorente 4497a0a2f2 Created baseline for game page grid layout 2024-11-23 15:02:09 +01:00
Laura Valera c75b3ef1c2 Create player on conection and store it 2024-11-22 20:42:04 +01:00
MiguelMLorente 6a222640be Add typed subscriber to update lobby events 2024-11-22 18:38:01 +01:00
MiguelMLorente d3ecc52cf6 Added client event types for lobby creation 2024-11-21 23:31:56 +01:00
MiguelMLorente 4b22c5ead8 Merge linter changes into branch 2024-11-21 23:09:56 +01:00
Laura Valera 0fc8a7383f add client id logging on connect 2024-11-21 22:59:50 +01:00
MiguelMLorente d78b47fb07 Add eslint to web module 2024-11-21 22:58:47 +01:00
MiguelMLorente 695ae069d5 Pass linter on server build and start 2024-11-21 22:58:17 +01:00
MiguelMLorente a1eaab6534 Created linter in interface module 2024-11-21 22:57:28 +01:00
MiguelMLorente ec6526deb5 Merge pull request 'Introduce eslint' (#4) from linter into main
Reviewed-on: #4
2024-11-21 21:34:11 +00:00
DavidPerea 97de40a396 New username input + buttons (LandingPage) 2024-11-19 19:08:31 +01:00
Laura Valera 391a1c91a7 add client id logging on connect 2024-11-18 20:44:01 +01:00
MiguelMLorente 3f77a32e70 Create players service and inject it into app service 2024-11-17 18:58:05 +01:00
MiguelMLorente 03c51e1d34 Introduce interface types for events for lobby creation and joining 2024-11-17 16:36:13 +01:00
82 changed files with 2522 additions and 105 deletions

1
.gitignore vendored
View File

@ -142,6 +142,7 @@ dist
# production
/build
*/build/
# misc
.DS_Store

41
app/package-lock.json generated
View File

@ -33,6 +33,7 @@
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"socket.io": "^4.8.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
@ -45,9 +46,22 @@
"../interface": {
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@eslint/js": "^8.0.0",
"@types/node": "^22.9.0",
"typescript": "^5.6.3"
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.12.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0"
}
},
"node_modules/@ampproject/remapping": {
@ -1725,6 +1739,24 @@
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/platform-socket.io/node_modules/socket.io": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz",
"integrity": "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@ -8008,9 +8040,10 @@
}
},
"node_modules/socket.io": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz",
"integrity": "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==",
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",

View File

@ -43,6 +43,7 @@
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"socket.io": "^4.8.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",

View File

@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { AppService } from './app.service';
import { PlayerService } from './players/player.service';
@Module({
imports: [],
providers: [AppService],
providers: [AppService, PlayerService],
})
export class AppModule {}

View File

@ -1,21 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import {
ConnectedSocket,
OnGatewayConnection,
MessageBody,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
import { PlayerService } from './players/player.service';
import { Socket } from 'socket.io';
import { ClientEvent, CreateLobbyEvent, JoinLobbyEvent } from 'interface';
@WebSocketGateway({ cors: true })
@Injectable()
export class AppService {
export class AppService implements OnGatewayConnection {
private readonly logger = new Logger(AppService.name);
@SubscribeMessage('example-request')
handleCustomEvent(@MessageBody() data: string): unknown {
this.logger.debug(`Received request in backend with data: ${data}`);
return {
event: 'example-response',
data: `Replying from backend, received data: ${data}`,
};
constructor(private readonly playerService: PlayerService) {}
handleConnection(client: Socket) {
this.playerService.createPlayer(client.id);
this.logger.log(client.id);
}
@SubscribeMessage(ClientEvent.CREATE_LOBBY)
handleCreateLobby(
@ConnectedSocket() client: Socket,
@MessageBody() event: CreateLobbyEvent,
) {
this.playerService.addUserName(client.id, event.userName);
this.logger.log('Se ha creado un lobby');
}
@SubscribeMessage(ClientEvent.JOIN_LOBBY)
handleJoinLobby(
@ConnectedSocket() client: Socket,
@MessageBody() event: JoinLobbyEvent,
) {
this.playerService.addUserName(client.id, event.userName);
this.logger.log('Te has unido a un lobby');
}
}

View File

@ -0,0 +1,19 @@
import { Injectable, Logger } from '@nestjs/common';
import { Player } from './player';
@Injectable()
export class PlayerService {
private readonly logger = new Logger(PlayerService.name);
private readonly players: Map<string, Player> = new Map();
createPlayer(socketId: string) {
const player: Player = new Player(socketId);
this.players.set(socketId, player);
this.logger.log([...this.players.entries()]);
}
addUserName(socketId: string, userName: string) {
this.players.get(socketId).userName = userName;
this.logger.log([...this.players.entries()]);
}
}

View File

@ -0,0 +1,7 @@
export class Player {
socketId: string;
userName?: string;
constructor(socketId: string) {
this.socketId = socketId;
}
}

View File

@ -13,5 +13,7 @@ module.exports = {
jest: true,
},
ignorePatterns: [".eslintrc.js", "dist/"],
rules: {},
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
},
};

115
interface/BoardBuilder.ts Normal file
View File

@ -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;
}

View File

@ -0,0 +1,5 @@
export enum ClientEvent {
CREATE_LOBBY = "create-lobby",
JOIN_LOBBY = "join-lobby",
START_GAME = "start-game",
}

View File

@ -0,0 +1,13 @@
import { Socket } from "socket.io-client";
import { ClientEvent } from "./ClientEvent";
export type CreateLobbyEvent = {
userName: string;
};
export function handleCreateLobby(
socket: Socket,
payload: CreateLobbyEvent,
): void {
socket.emit(ClientEvent.CREATE_LOBBY, payload);
}

View File

@ -0,0 +1,11 @@
import { Socket } from "socket.io-client";
import { ClientEvent } from "./ClientEvent";
export type JoinLobbyEvent = {
userName: string;
lobbyId: string;
};
export function handleJoinLobby(socket: Socket, payload: JoinLobbyEvent): void {
socket.emit(ClientEvent.JOIN_LOBBY, payload);
}

View File

@ -0,0 +1,6 @@
export enum CellType {
NORMAL = "NORMAL",
HOUSE = "HOUSE",
FACTORY = "FACTORY",
UNIVERSITY = "UNIVERSITY",
}

View File

@ -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, 0 | 90 | 180 | 270> = {
[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,
)!;
}

View File

@ -0,0 +1,5 @@
export enum ExitType {
RAIL = "RAIL",
ROAD = "ROAD",
AMBIVALENT = "AMBIVALENT",
}

View File

@ -0,0 +1,4 @@
export enum InternalNodeType {
NONE = "NONE",
STATION = "STATION",
}

View File

@ -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, Piece> = {
[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,
};

View File

@ -1 +1,21 @@
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";
export * from "./client-events/JoinLobbyEvent";

View File

@ -8,6 +8,11 @@
"name": "interface",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@eslint/js": "^8.0.0",
"@types/node": "^22.9.0",
@ -237,27 +242,47 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==",
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
"integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true,
"dependencies": {
"undici-types": "~6.19.8"
}
},
"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",
@ -281,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": {
@ -310,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"
@ -328,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"
},
@ -356,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": {
@ -370,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",
@ -399,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"
@ -427,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": {
@ -464,6 +489,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@ -544,6 +582,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -621,6 +668,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -640,7 +709,6 @@
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -674,6 +742,49 @@
"node": ">=6.0.0"
}
},
"node_modules/engine.io": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz",
"integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
"integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -1122,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": {
@ -1362,6 +1473,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -1382,7 +1514,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
@ -1392,6 +1523,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -1519,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,
@ -1677,6 +1826,62 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -1754,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": {
@ -1839,11 +2044,221 @@
}
}
},
"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",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"node_modules/uri-js": {
"version": "4.4.1",
@ -1855,6 +2270,28 @@
"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",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -1888,6 +2325,35 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -21,5 +21,10 @@
"globals": "^15.12.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0"
},
"dependencies": {
"uuid": "^11.0.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
}
}

View File

@ -0,0 +1,4 @@
export enum ServerError {
CREATE_LOBBY_ERROR = "create-lobby-error",
JOIN_LOBBY_ERROR = "join-lobby-error",
}

View File

@ -0,0 +1,4 @@
export enum ServerEvent {
LOBBY_UPDATE = "lobby-update",
START_ROUND = "start-round",
}

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

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

22
interface/types/Border.ts Normal file
View File

@ -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");
}
}

112
interface/types/Cell.ts Normal file
View File

@ -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<Direction, ExternalNode>;
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;
}
}

9
interface/types/Exit.ts Normal file
View File

@ -0,0 +1,9 @@
import { ExitType } from "../constants/ExitType";
export class Exit {
public readonly type: ExitType;
constructor(type: ExitType) {
this.type = type;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

115
interface/types/Piece.ts Normal file
View File

@ -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<Track>;
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,
);
}
}

View File

@ -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),
);
}
}

View File

@ -12,6 +12,13 @@ module.exports = {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js", "dist/"],
rules: {},
ignorePatterns: [".eslintrc.js", "dist/", "node_modules/", "build/"],
rules: {
"prettier/prettier": [
"error",
{
endOfLine: "auto",
},
],
},
};

13
web/package-lock.json generated
View File

@ -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",
@ -37,6 +37,11 @@
"../interface": {
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@eslint/js": "^8.0.0",
"@types/node": "^22.9.0",
@ -4197,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": {

View File

@ -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",

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

BIN
web/public/pieces/P01.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
web/public/pieces/P02.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/public/pieces/P03.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
web/public/pieces/P04.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
web/public/pieces/P05.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
web/public/pieces/P06.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
web/public/pieces/P07.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/public/pieces/P08.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
web/public/pieces/P09.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/public/pieces/P10.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
web/public/pieces/P11.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
web/public/pieces/P12.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/public/pieces/P13.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
web/public/pieces/P14.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
web/public/pieces/P15.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
web/public/pieces/P16.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
web/public/pieces/P17.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
web/public/pieces/P18.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
web/public/pieces/P19.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
web/public/pieces/P20.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
web/public/pieces/P21.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
web/public/pieces/P22.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,3 +1,3 @@
@use "node_modules/@picocss/pico/scss/pico" with (
$theme-color: "pumpkin"
);
);

View File

@ -1,8 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import App from "./App";
test("Renders hello world", () => {
render(<App />);
expect(screen.getByText("Hello World! Front")).toBeInTheDocument();
});

View File

@ -1,22 +1,57 @@
import React from "react";
import { io } from "socket.io-client";
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";
function App() {
const socket = io("http://localhost:3010");
const emitData = () =>
console.log(socket.emit("example-request", "custom-request"));
socket.on("example-response", (data) =>
console.log(`Received response in front end with data: ${data}`),
);
return (
<div className="App">
<header className="App-header">
<button onClick={emitData}>Emit Data</button>
Hello World! Front
</header>
</div>
);
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 (
<div className="app">
{renderedPage === "LANDING" && <LandingPage {...props} />}
{renderedPage === "LOBBY" && (
<LobbyPage
{...props}
initialPlayerNames={initialPlayerNames}
gameCode={gameCode}
/>
)}
{renderedPage === "GAME" && (
<GamePage initialPieceIds={initialPieceIds} />
)}
</div>
);
};
export default App;

View File

@ -2,12 +2,14 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./App.scss";
import { io } from "socket.io-client";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
const socket = io("http://localhost:3010");
root.render(
<React.StrictMode>
<App />
<App socket={socket} />
</React.StrictMode>,
);

View File

@ -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;
}
}

View File

@ -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<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 (
<React.Fragment>
<h1>Game Page Title</h1>
<div className="game-panel" id={id.toString()}>
<GameBoard
modifyDieState={modifyDieState}
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>
</React.Fragment>
);
};
export default GamePage;

View File

@ -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%);
}
}

View File

@ -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<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 {
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 (
<div className={"cell"} onClick={handleBoardCellClick}>
{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 (
<div className="exit">
<div className={className} />
</div>
);
})}
{cell.placedPiece && (
<img
className={`piece rotate-${pieceRotationAngle}`}
src={`pieces/${cell.placedPiece.id}.jpeg`}
></img>
)}
{cell.cellType !== CellType.NORMAL && (
<img
className="cell-type-icon"
src={`./${cell.cellType.toLowerCase()}-icon.png`}
></img>
)}
</div>
);
};
export default BoardCell;

View File

@ -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%;
}

View File

@ -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<DieViewProps>,
) => 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 (
<React.Fragment>
<div className="dice-set-actions">
<img
src="./rotate-icon.png"
onClick={() => handleRotateButton(-90)}
></img>
<img
src="./rotate-icon.png"
className="icon-inverted"
onClick={() => handleRotateButton(90)}
></img>
<img src="./clean-icon.png" onClick={resetBoard}></img>
<img src="./commit-icon.png" onClick={commitBoard}></img>
</div>
<div className="dice-set">
{dice.map((die) => (
<Die die={die} handleDieClick={handleDieClick} />
))}
</div>
<div className="special-dice-set">
{specialDice.map((die) => (
<Die die={die} handleDieClick={handleDieClick} />
))}
</div>
</React.Fragment>
);
};
export default DiceSet;

View File

@ -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;
}

View File

@ -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 (
<div className={className} onClick={() => handleDieClick(die)}>
<img src={`pieces/${pieceId}.jpeg`}></img>
</div>
);
};
export default Die;

View File

@ -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;
}

View File

@ -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<DieViewProps>,
) => void;
fetchDie: (
matcher: (die: DieViewProps) => boolean,
) => DieViewProps | undefined;
setSpecialDieUsedInRound: React.Dispatch<React.SetStateAction<boolean>>;
refreshBoardRender: () => void;
board: Cell[][];
placePieceActionStack: PlacePieceActionStack;
setPlacePieceActionStack: React.Dispatch<
React.SetStateAction<PlacePieceActionStack>
>;
}
const GameBoard = (props: GameBoardProps) => {
const { board, placePieceActionStack, setPlacePieceActionStack } = props;
return (
<div className="game-board">
{board.flatMap((row, rowIndex) =>
row.map((cell, colIndex) => (
<BoardCell
{...props}
cell={cell}
placePieceActionHandler={(pieceId, rotation) => {
placePieceActionStack.executeAction(
new PlacePieceAction(pieceId, rotation, rowIndex, colIndex),
board,
);
setPlacePieceActionStack(placePieceActionStack);
}}
/>
)),
)}
</div>
);
};
export default GameBoard;

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,55 @@
import {
CreateLobbyEvent,
handleCreateLobby,
handleJoinLobby,
JoinLobbyEvent,
} from "interface";
import React, { ChangeEvent } from "react";
import { Socket } from "socket.io-client";
export interface LandingPageProps {
socket: Socket;
}
const LandingPage = (props: LandingPageProps) => {
const { socket } = props;
const createLobbyPayload: CreateLobbyEvent = { userName: "" };
const joinLobbyPayload: JoinLobbyEvent = { userName: "", lobbyId: "" };
const registerUsername = (event: ChangeEvent<HTMLInputElement>) => {
createLobbyPayload.userName = event.target.value;
joinLobbyPayload.userName = event.target.value;
};
const registerLobbyId = (event: ChangeEvent<HTMLInputElement>) =>
(joinLobbyPayload.lobbyId = event.target.value);
return (
<React.Fragment>
<div className="landing-page-title">
<h1>Trains And Roads</h1>
</div>
<div className="user-name-input">
<input placeholder="Enter username" onChange={registerUsername}></input>
</div>
<div className="create-lobby-button">
<button onClick={() => handleCreateLobby(socket, createLobbyPayload)}>
Create Lobby
</button>
</div>
<div className="join-lobby">
<input
className="lobby-id-input"
placeholder="Enter Lobby Id"
onChange={registerLobbyId}
></input>
<button
className="join-lobby-button secondary"
onClick={() => handleJoinLobby(socket, joinLobbyPayload)}
>
Join Lobby
</button>
</div>
</React.Fragment>
);
};
export default LandingPage;

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;