Compare commits

..

1 Commits

Author SHA1 Message Date
Pau Costa Ferrer 7c07591c75 docs: minor adjustements and comments on techDesign 2024-11-14 22:42:30 +00:00
87 changed files with 177 additions and 5529 deletions

1
.gitignore vendored
View File

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

52
app/package-lock.json generated
View File

@ -14,7 +14,6 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.4.7",
"@nestjs/websockets": "^10.4.7",
"interface": "file:../interface",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
@ -33,7 +32,6 @@
"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",
@ -43,27 +41,6 @@
"typescript": "^5.1.3"
}
},
"../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-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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@ -1739,24 +1716,6 @@
"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",
@ -5312,10 +5271,6 @@
"node": ">=12.0.0"
}
},
"node_modules/interface": {
"resolved": "../interface",
"link": true
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -8040,10 +7995,9 @@
}
},
"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==",
"dev": true,
"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",

View File

@ -6,12 +6,12 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "npm run lint && nest build",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "npm run build && nest start --watch",
"start": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\" --fix",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
@ -24,7 +24,6 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.4.7",
"@nestjs/websockets": "^10.4.7",
"interface": "file:../interface",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
@ -43,7 +42,6 @@
"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,9 +1,8 @@
import { Module } from '@nestjs/common';
import { AppService } from './app.service';
import { PlayerService } from './players/player.service';
@Module({
imports: [],
providers: [AppService, PlayerService],
providers: [AppService],
})
export class AppModule {}

View File

@ -1,41 +1,14 @@
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';
import { MessageBody, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
@WebSocketGateway({ cors: true })
@Injectable()
export class AppService implements OnGatewayConnection {
export class AppService {
private readonly logger = new Logger(AppService.name);
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');
@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}`};
}
}

View File

@ -3,7 +3,6 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT ?? 3010;
await app.listen(port);
await app.listen(process.env.PORT ?? 3010);
}
bootstrap();

View File

@ -1,19 +0,0 @@
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

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

View File

@ -108,16 +108,20 @@ Upon completing the seven rounds, players will be notified of the points they ea
## 4. Out of scope (for V1)
- Private lobbies, where an admin can invite players by user id
<!--Games are private by default as you need the code to join, and there will be no listing of all lobbies,simply being able to kick players out should suffice.-->
- Kick player out from a lobby
- Games history, saving to database
- Playing against bots, with either algorithmic behaviour or AI
- Drawing pieces, instead of placing them
- Timed rounds
## 5. High-level proposal
### 5.1. Server-client communications
Server and client need to communicate asynchronously, as many actions (such as submitting piece placements) need to wait for other players to finish their respective actions. For this reason, they will communicate through web-sockets. The following requests will be needed:
Server and client need to communicate asynchronously, as many actions (such as submitting piece placements) need to wait for other players to finish their respective actions. For this reason, they will communicate through web-sockets. The following requests <!-- best to talk about events as there will be no requests in the traditional REST sense --> will be needed:
<!-- *I think a request "originator" or direction would also be useful in this table, for exaple the game start event originates from the server, after all the players in the lobby have indicated that they are ready* -->
| Request type | Inputs | Outputs | Notes |
| ----------------------- | ------------------------------------- | ----------------------- | ----------------------------------------------------------------------- |
@ -155,7 +159,7 @@ The systems will contain the following modules, with submodules for specific ite
- Manages the room state transitions, from lobby to in-game to ended
- Manages the rounds transitions
- Administrates quests
- Piece placement module
- Piece placement module <!--we have to be careful with what we inject with in this module, ideally nothing, as we wont have DI in react-->
- Verification logic on piece placement (shared between front and back-end)
- Special cells and pieces consumption (shared also)
- Manages piece placement action stackings to enable undo actions (front only)
@ -169,7 +173,7 @@ The systems will contain the following modules, with submodules for specific ite
### 6.1. Modellisation of the users and games
```
```ts
var usersMap = Map<socketId: string, userId: string>
var users = Map<userId: string, User>
User = {
@ -199,8 +203,8 @@ Game = {
| 1. Cell<br/> 2. Piece <br/> 3. External node <br/> 4. Border <br/> 5. Track <br/> 6. Internal node <br/> 7. Exit <br/> 8. Internal node | <img src="./samples/modellisation-schema.jpg" width=300px height=320px/> |
**Enum types**
```
<!-- string enums are much more human readable if we later on decide to store games on a database-->
```ts
TrackType = "RAIL" | "ROAD"
Direction = "NORTH" | "SOUTH" | "EAST" | "WEST"
ExitType = "RAIL" | "ROAD" | "AMBIVALENT"
@ -210,7 +214,7 @@ CellType = "NORMAL" | "STATION" | "FACTORY" | "UNIVERSITY"
**Internal types**
```
```ts
Exit = { type: ExitType }
absctract Node = { cell: Cell }
ExternalNode extends Node = {
@ -228,7 +232,7 @@ Border = Pair<ExternalNode, ExternalNode | Exit> // Joins two nodes from adjacen
**Main types**
```
```ts
Cell = {
externalNodes: Set<ExternalNode>,
type: CellType,
@ -258,10 +262,10 @@ Given a Board object:
- If there are some tracks, for each of them find the external nodes that they can connect to. Traverse through the internal nodes when necessary. Note: internal nodes might be dead ends in some cases.
- For each accessible external node (not the original), get the cell's border and traverse it:
- If it's an exit:
- If it's not AMBIVALENT, pop it from the original set and add it to this sub-grid's set.
- If it's not `AMBIVALENT`, pop it from the original set and add it to this sub-grid's set.
- Then stop (regardless of the type of exit)
- If it's another cell, execute this process recursively starting from the new cell's starting external node.
- In order to avoid the algorithm to stack overflow on looped grids, every time an external node is accessed, consume it by adding it to a global ban-list.
- In order to avoid the algorithm to stack overflow on looped grids, every time an external node is accessed, consume it by adding it to a global visited-node-list.
#### 6.2.3. Calculating longest road
@ -278,47 +282,49 @@ For each border in a board, we will verify:
### 6.3. Game transitions
## 7. Implementation plan
- Home page
- Manage user connection
- Create join page
- Page frame
- User name input box
- Create lobby button and request
- Join lobby button, input box, and request
- Create lobby page
- Create players view
- Create start game button
- Create request handlers
- Create lobby handler
- Join lobby handler
- Broadcasting in lobby
- Game start handler
- Game play
- Game page
- Create board view
- Create piece set view
- Create piece focus (and rotation) view
- Create quests view
- Create piece placement animations
- Create piece placement validator
- Create special cells logic
- Create undo action button
- Create submit button
- Create submit handler
- Create quests checker and broadcasting
- One quest checker will be needed per quest type
- Create quests info display in web
- Create round info broadcasted
- Game end
- Game end page
- Create boards view
- Create points view
- Create winner view
- Create focus board animations
- Create points calculation logic
- Create quests counter
- Create longest track pathfinder
- Create connected exits pathfinder
- Create dead-end tracks counter
- Create house and center cells counter
- [ ] Design phase
- [x] First design document Draft
- [ ] Migrate tasks to kanban
- [ ] Home page
- [ ] Manage user connection
- [ ] Create join page
- [ ] Page frame
- [ ] User name input box
- [ ] Create lobby button and request
- [ ] Join lobby button, input box, and request
- [ ] Create lobby page
- [ ] Create players view
- [ ] Create start game button
- [ ] Create request handlers
- [ ] Create lobby handler
- [ ] Join lobby handler
- [ ] Broadcasting in lobby
- [ ] Game start handler
- [ ] Game play
- [ ] Game page
- [ ] Create board view
- [ ] Create piece set view
- [ ] Create piece focus (and rotation) view
- [ ] Create quests view
- [ ] Create piece placement animations
- [ ] Create piece placement validator
- [ ] Create special cells logic
- [ ] Create undo action button
- [ ] Create submit button
- [ ] Create submit handler
- [ ] Create quests checker and broadcasting
- [ ] One quest checker will be needed per quest type
- [ ] Create quests info display in web
- [ ] Create round info broadcasted
- [ ] Game end
- [ ] Game end page
- [ ] Create boards view
- [ ] Create points view
- [ ] Create winner view
- [ ] Create focus board animations
- [ ] Create points calculation logic
- [ ] Create quests counter
- [ ] Create longest track pathfinder
- [ ] Create connected exits pathfinder
- [ ] Create dead-end tracks counter
- [ ] Create house and center cells counter

View File

@ -1,19 +0,0 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint/eslint-plugin"],
extends: ["plugin:@typescript-eslint/strict", "plugin:prettier/recommended"],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js", "dist/"],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
},
};

View File

@ -1,115 +0,0 @@
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

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

View File

@ -1,13 +0,0 @@
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

@ -1,11 +0,0 @@
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

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

View File

@ -1,30 +0,0 @@
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

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

View File

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

View File

@ -1,410 +0,0 @@
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,4 +0,0 @@
export enum TrackType {
RAIL = "RAIL",
ROAD = "ROAD",
}

View File

@ -1,21 +0,0 @@
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";

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +0,0 @@
{
"name": "interface",
"version": "1.0.0",
"description": "A collection of common functions for the trains and roads project.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "npm run lint && tsc",
"lint": "eslint \"*.ts\" \"*/**/*.ts\" --fix"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@eslint/js": "^8.0.0",
"@types/node": "^22.9.0",
"@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"
},
"dependencies": {
"uuid": "^11.0.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
}
}

View File

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

View File

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

View File

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

View File

@ -1,22 +0,0 @@
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);
}

View File

@ -1,110 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
"experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
"declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -1,22 +0,0 @@
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");
}
}

View File

@ -1,112 +0,0 @@
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;
}
}

View File

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

View File

@ -1,29 +0,0 @@
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

@ -1,12 +0,0 @@
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;
}
}

View File

@ -1,115 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,24 +0,0 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint/eslint-plugin"],
extends: ["plugin:@typescript-eslint/strict", "plugin:prettier/recommended"],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js", "dist/", "node_modules/", "build/"],
rules: {
"prettier/prettier": [
"error",
{
endOfLine: "auto",
},
],
},
};

960
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,15 +3,13 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@picocss/pico": "^2.0.6",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.29",
"@types/node": "^16.18.119",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"interface": "file:../interface",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
@ -22,10 +20,9 @@
},
"scripts": {
"start": "react-scripts start",
"build": "npm run lint && react-scripts build",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint \"*/**/*.tsx\" \"*/**/*.ts\" --fix"
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
@ -44,13 +41,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@eslint/js": "^8.0.0",
"@typescript-eslint/parser": "^8.15.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"typescript-eslint": "^8.15.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,3 +1,12 @@
@use "node_modules/@picocss/pico/scss/pico" with (
$theme-color: "pumpkin"
);
body {
font-size: 20px;
header > button {
background-color: aliceblue;
padding: 10px;
margin-bottom: 10px;
border: 1px solid black;
border-radius: 10%;
display: block;
}
}

8
web/src/App.test.tsx Normal file
View File

@ -0,0 +1,8 @@
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,57 +1,21 @@
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";
import React from 'react';
import { io } from "socket.io-client";
export interface AppProps {
socket: Socket;
}
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}`));
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 className="App">
<header className="App-header">
<button onClick={emitData}>
Emit Data
</button>
Hello World! Front
</header>
</div>
);
};
}
export default App;

View File

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

View File

@ -1,17 +0,0 @@
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

@ -1,134 +0,0 @@
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

@ -1,108 +0,0 @@
.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

@ -1,85 +0,0 @@
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

@ -1,40 +0,0 @@
.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

@ -1,80 +0,0 @@
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

@ -1,37 +0,0 @@
.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

@ -1,25 +0,0 @@
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

@ -1,10 +0,0 @@
.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

@ -1,49 +0,0 @@
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

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

View File

@ -1,32 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,50 +0,0 @@
.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

@ -1,39 +0,0 @@
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;

View File

@ -2,4 +2,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
import '@testing-library/jest-dom';