Compare commits

..

50 Commits

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
MiguelMLorente 56705d3574 Add eslint to web module 2024-11-20 23:29:21 +01:00
MiguelMLorente 250325d8d2 Pass linter on server build and start 2024-11-19 23:33:22 +01:00
MiguelMLorente d906cf486b Created linter in interface module 2024-11-19 23:25:52 +01: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
MiguelMLorente dc24f62f64 Merge pull request 'feat: adds common logic module' (#3) from local-npm-module into main
Reviewed-on: #3
2024-11-16 19:57:57 +00:00
MiguelMLorente f1c284915e Moved to interface and create real enum type 2024-11-16 20:57:06 +01:00
Pau Costa Ferrer 3f49d880c8 feat: adds common logic module 2024-11-16 20:01:08 +01:00
MiguelMLorente e7447436a6 Include basic styles using pico-css library 2024-11-15 16:07:30 +01:00
87 changed files with 5532 additions and 180 deletions

1
.gitignore vendored
View File

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

52
app/package-lock.json generated
View File

@ -14,6 +14,7 @@
"@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"
},
@ -32,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",
@ -41,6 +43,27 @@
"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",
@ -1716,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",
@ -5271,6 +5312,10 @@
"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",
@ -7995,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

@ -6,12 +6,12 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"build": "npm run lint && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start --watch",
"start": "npm run build && nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
@ -24,6 +24,7 @@
"@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"
},
@ -42,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,14 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import { MessageBody, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
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

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

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

@ -108,20 +108,16 @@ 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 <!-- 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* -->
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:
| Request type | Inputs | Outputs | Notes |
| ----------------------- | ------------------------------------- | ----------------------- | ----------------------------------------------------------------------- |
@ -159,7 +155,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 <!--we have to be careful with what we inject with in this module, ideally nothing, as we wont have DI in react-->
- Piece placement module
- 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)
@ -173,7 +169,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 = {
@ -203,8 +199,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"
@ -214,7 +210,7 @@ CellType = "NORMAL" | "STATION" | "FACTORY" | "UNIVERSITY"
**Internal types**
```ts
```
Exit = { type: ExitType }
absctract Node = { cell: Cell }
ExternalNode extends Node = {
@ -232,7 +228,7 @@ Border = Pair<ExternalNode, ExternalNode | Exit> // Joins two nodes from adjacen
**Main types**
```ts
```
Cell = {
externalNodes: Set<ExternalNode>,
type: CellType,
@ -262,10 +258,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 visited-node-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 ban-list.
#### 6.2.3. Calculating longest road
@ -282,49 +278,47 @@ For each border in a board, we will verify:
### 6.3. Game transitions
## 7. Implementation plan
- [ ] 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
- 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

19
interface/.eslintrc.js Normal file
View File

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

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

@ -0,0 +1,4 @@
export enum TrackType {
RAIL = "RAIL",
ROAD = "ROAD",
}

21
interface/index.ts Normal file
View File

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

2371
interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
interface/package.json Normal file
View File

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

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

110
interface/tsconfig.json Normal file
View File

@ -0,0 +1,110 @@
{
"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. */
}
}

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

24
web/.eslintrc.js Normal file
View File

@ -0,0 +1,24 @@
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,13 +3,15 @@
"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": "^16.18.119",
"@types/node": "^17.0.29",
"@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",
@ -20,9 +22,10 @@
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"build": "npm run lint && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"lint": "eslint \"*/**/*.tsx\" \"*/**/*.ts\" --fix"
},
"eslintConfig": {
"extends": [
@ -41,5 +44,13 @@
"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"
}
}

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,12 +1,3 @@
body {
font-size: 20px;
header > button {
background-color: aliceblue;
padding: 10px;
margin-bottom: 10px;
border: 1px solid black;
border-radius: 10%;
display: block;
}
}
@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,21 +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

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

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";