diff --git a/interface/constants/Direction.ts b/interface/constants/Direction.ts index 6bc4347..d44deda 100644 --- a/interface/constants/Direction.ts +++ b/interface/constants/Direction.ts @@ -11,3 +11,20 @@ export const directions: Direction[] = [ Direction.EAST, Direction.WEST, ]; + +export function rotateDirection( + initialDirection: Direction, + rotationAngle: 0 | 90 | 180 | 270, +): Direction { + const angleToDirectionMap: Record = { + [Direction.NORTH]: 0, + [Direction.EAST]: 90, + [Direction.SOUTH]: 180, + [Direction.WEST]: 270, + }; + const finalAngle = + (360 + angleToDirectionMap[initialDirection] - rotationAngle) % 360; + return (Object.keys(angleToDirectionMap) as Direction[]).find( + (direction) => angleToDirectionMap[direction] === finalAngle, + )!; +} diff --git a/interface/constants/Pieces.ts b/interface/constants/Pieces.ts index fc12356..e4bddc3 100644 --- a/interface/constants/Pieces.ts +++ b/interface/constants/Pieces.ts @@ -13,7 +13,6 @@ const STRAIGHT_RAIL: Piece = new Piece({ }, ], }); - const TURN_RAIL: Piece = new Piece({ useInternalTracks: false, trackDefinitions: [ @@ -166,7 +165,7 @@ const DEAD_END_STATION_ROAD: Piece = new Piece({ trackDefinitions: [ { startPoint: Direction.EAST, - type: TrackType.RAIL, + type: TrackType.ROAD, }, ], }); @@ -232,7 +231,6 @@ const FOUR_WAY_WITH_ONE_RAIL: Piece = new Piece({ }, ], }); - const FOUR_WAY_TURNING_CROSSING: Piece = new Piece({ useInternalTracks: true, internalNodeType: InternalNodeType.STATION, @@ -294,7 +292,7 @@ const T_JUNCTION_ROAD: Piece = new Piece({ }, { startPoint: Direction.WEST, - type: TrackType.RAIL, + type: TrackType.ROAD, }, ], }); @@ -316,7 +314,7 @@ const FOUR_WAY_CROSS_ROAD: Piece = new Piece({ }, { startPoint: Direction.WEST, - type: TrackType.RAIL, + type: TrackType.ROAD, }, ], }); diff --git a/interface/index.ts b/interface/index.ts index 0cf1028..66f3650 100644 --- a/interface/index.ts +++ b/interface/index.ts @@ -11,5 +11,4 @@ export * from "./types/ExternalNode"; export * from "./types/InternalNode"; export * from "./types/Piece"; export * from "./types/PlacedPiece"; -export * from "./types/Node"; export * from "./BoardBuilder"; diff --git a/interface/types/Cell.ts b/interface/types/Cell.ts index 8dc8236..6412cc5 100644 --- a/interface/types/Cell.ts +++ b/interface/types/Cell.ts @@ -1,10 +1,20 @@ import { CellType } from "../constants/CellType"; import { Direction } from "../constants/Direction"; +import { ExitType } from "../constants/ExitType"; +import { PieceId, pieceMap } from "../constants/Pieces"; +import { Exit } from "./Exit"; import { ExternalNode } from "./ExternalNode"; +import { InternalNode } from "./InternalNode"; +import { Piece } from "./Piece"; +import { PlacedPiece } from "./PlacedPiece"; export class Cell { public readonly externalNodes: Map; public readonly cellType: CellType; + public placedPiece?: { + piece: PlacedPiece; + id: PieceId; + }; constructor(cellType: CellType) { this.externalNodes = new Map([ @@ -21,4 +31,78 @@ export class Cell { 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, + }; + }) + .some(({ 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 isTrackConnected; + }); + + if (!hasAnyConnection) { + throw Error("No adjacent exit or piece available to connect to"); + } + } } diff --git a/interface/types/Node.ts b/interface/types/Node.ts deleted file mode 100644 index fa5bbe0..0000000 --- a/interface/types/Node.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ExternalNode } from "./ExternalNode"; -import { InternalNode } from "./InternalNode"; - -export type Node = ExternalNode | InternalNode; diff --git a/interface/types/Piece.ts b/interface/types/Piece.ts index dae249a..953c04d 100644 --- a/interface/types/Piece.ts +++ b/interface/types/Piece.ts @@ -1,9 +1,8 @@ -import { Direction } from "../constants/Direction"; +import { Direction, rotateDirection } from "../constants/Direction"; import { TrackType } from "../constants/TrackType"; import { Cell } from "./Cell"; import { InternalNode } from "./InternalNode"; import { PlacedPiece } from "./PlacedPiece"; -import { Node } from "./Node"; import { InternalNodeType } from "../constants/InternalNodeType"; export interface PieceProps { @@ -66,6 +65,33 @@ export class Piece { } } + 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( @@ -74,8 +100,8 @@ export class Piece { nodes: { firstNode: cell.getNodeAt(track.joinedPoints.firstPoint), secondNode: - track.joinedPoints.secondPoint instanceof Node - ? (track.joinedPoints.secondPoint as Node) + track.joinedPoints.secondPoint instanceof InternalNode + ? (track.joinedPoints.secondPoint as InternalNode) : cell.getNodeAt(track.joinedPoints.secondPoint as Direction), }, type: track.type, diff --git a/interface/types/PlacedPiece.ts b/interface/types/PlacedPiece.ts index 7582a42..cdbc1f0 100644 --- a/interface/types/PlacedPiece.ts +++ b/interface/types/PlacedPiece.ts @@ -1,22 +1,25 @@ +import { Direction } from "../constants/Direction"; import { TrackType } from "../constants/TrackType"; import { Cell } from "./Cell"; +import { ExternalNode } from "./ExternalNode"; import { InternalNode } from "./InternalNode"; -import { Node } from "./Node"; export class PlacedPiece { tracks: Set<{ nodes: { - firstNode: Node; - secondNode: Node; + firstNode: ExternalNode; + secondNode: ExternalNode | InternalNode; }; type: TrackType; }>; internalNode?: InternalNode; cell: Cell; - constructor( tracks: Set<{ - nodes: { firstNode: Node; secondNode: Node }; + nodes: { + firstNode: ExternalNode; + secondNode: ExternalNode | InternalNode; + }; type: TrackType; }>, internalNodes: InternalNode | undefined, @@ -26,4 +29,13 @@ export class PlacedPiece { this.internalNode = internalNodes; this.cell = cell; } + + public findTrackForDirection(direction: Direction) { + return Array.from(this.tracks).find( + (track) => + track.nodes.firstNode.direction === direction || + (track.nodes.secondNode instanceof ExternalNode && + track.nodes.secondNode.direction === direction), + ); + } } diff --git a/web/public/rotate-icon.png b/web/public/rotate-icon.png new file mode 100644 index 0000000..5af53ca Binary files /dev/null and b/web/public/rotate-icon.png differ diff --git a/web/src/pages/game/GamePage.tsx b/web/src/pages/game/GamePage.tsx index 3b42ea0..4b06b3d 100644 --- a/web/src/pages/game/GamePage.tsx +++ b/web/src/pages/game/GamePage.tsx @@ -1,16 +1,33 @@ -import React from "react"; +import React, { useState } from "react"; import GameBoard from "./components/GameBoard"; import DiceSet from "./components/DiceSet"; import "./GamePage.scss"; +import { PieceId } from "interface"; const GamePage = () => { + const getRandomPieceId = () => { + const randomId = Math.floor(Math.random() * Object.keys(PieceId).length); + return Object.values(PieceId)[randomId]; + }; + + const [dice, setDice] = useState( + [1, 2, 3, 4].map(() => { + return { + pieceId: getRandomPieceId(), + isSelected: false, + isDisabled: false, + rotation: 0, + }; + }), + ); + return (

Game Page Title

- +
- +
diff --git a/web/src/pages/game/components/BoardCell.scss b/web/src/pages/game/components/BoardCell.scss new file mode 100644 index 0000000..1250a30 --- /dev/null +++ b/web/src/pages/game/components/BoardCell.scss @@ -0,0 +1,141 @@ +.cell { + width: 100px; + height: 100px; + border: 2px solid black; + border-radius: 10%; + + .piece { + border-radius: 10%; + position: relative; + + &:nth-child(2) { + top: -20px; + } + + &:nth-child(3) { + top: -40px; + } + + &.rotate-90 { + transform: rotate(-90deg); + } + + &.rotate-180 { + transform: rotate(180deg); + } + + &.rotate-270 { + transform: rotate(90deg); + } + } + + &.house { + background-color: blue; + } + + &.university { + background-color: orange; + } + + &.factory { + background-color: grey; + } + + .exit: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)); + } + } + + .exit: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)); + } + } + + .exit: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)); + } + } + + .exit: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%)); + } + } +} + +.exit { + position: relative; + width: 20px; + height: 20px; + + &:has(.exit-north) { + left: 50%; + transform: translate(calc(-50% - 1px), -100%); + } + + &:has(.exit-south) { + left: 50%; + top: 100%; + transform: translate(calc(-50% - 1px), 0%); + } + + &:has(.exit-east) { + left: 100%; + top: 50%; + transform: rotate(90deg) translate(-50%, 0); + } + + &:has(.exit-west) { + left: 0%; + top: 50%; + transform: rotate(90deg) translate(-50%, 100%); + } + + &:has(.exit-north), &:has(.exit-south) { + + .exit:has(.exit-east) { + left: 100%; + transform: rotate(90deg) translate(-150%, 0); + } + } + + &:has(.exit-north), &:has(.exit-south) { + + .exit:has(.exit-west) { + top: 50%; + transform: rotate(90deg) translate(-150%, 100%); + } + } +} \ No newline at end of file diff --git a/web/src/pages/game/components/BoardCell.tsx b/web/src/pages/game/components/BoardCell.tsx new file mode 100644 index 0000000..3170438 --- /dev/null +++ b/web/src/pages/game/components/BoardCell.tsx @@ -0,0 +1,84 @@ +import { Cell, directions, Exit, PieceId } from "interface"; +import "./BoardCell.scss"; +import { useState } from "react"; + +export interface BoardCellProps { + cell: Cell; + dice: { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }[]; + setDice: React.Dispatch< + React.SetStateAction< + { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }[] + > + >; + refreshBoardRender: () => void; +} + +const BoardCell = (props: BoardCellProps) => { + const { cell, dice, setDice, refreshBoardRender } = props; + const [pieceRotationAngle, setPieceRotationAngle] = useState(0); + const handleBoardCellClick = () => { + const selectedDie = dice.find((die) => die.isSelected); + if (!selectedDie) return; + try { + cell.placePiece( + selectedDie.pieceId, + selectedDie.rotation as 0 | 90 | 180 | 270, + ); + } catch (error) { + console.log(error); + return; + } + setDice( + dice.map((die) => { + return die !== selectedDie + ? die + : { + ...selectedDie, + isSelected: false, + isDisabled: true, + }; + }), + ); + setPieceRotationAngle(selectedDie.rotation); + refreshBoardRender(); + }; + + return ( +
+ {directions.map((direction) => { + const traversedNode = cell.getNodeAt(direction).traverseBorder(); + const isExit = traversedNode instanceof Exit; + if (!isExit) return; + const className = + `exit-${direction.toLowerCase()}` + + ` exit-${(traversedNode as Exit).type.toLowerCase()}`; + return ( +
+
+
+ ); + })} + {cell.placedPiece && ( + + )} +
+ ); +}; + +export default BoardCell; diff --git a/web/src/pages/game/components/DiceSet.scss b/web/src/pages/game/components/DiceSet.scss index 22145b0..87776c6 100644 --- a/web/src/pages/game/components/DiceSet.scss +++ b/web/src/pages/game/components/DiceSet.scss @@ -1,17 +1,28 @@ +.dice-set-actions { + display: flex; + flex-direction: row; + padding: 50px 50px 0; + width: max-content; + margin: auto; + + img { + border: 3px solid green; + box-shadow: 0 0 10px green; + border-radius: 20%; + padding: 5px; + width: 80px; + height: 80px; + margin-right: 10px; + + &.icon-inverted { + transform: scaleX(-1); + } + } +} + .dice-set { display: grid; grid-template-columns: repeat(2, auto); gap: 20px; padding: 50px; - - .dice { - height: 150px; - width: 150px; - border: 2px solid black; - border-radius: 10%; - - img{ - border-radius: 10%; - } - } } \ No newline at end of file diff --git a/web/src/pages/game/components/DiceSet.tsx b/web/src/pages/game/components/DiceSet.tsx index c81e7d1..1779190 100644 --- a/web/src/pages/game/components/DiceSet.tsx +++ b/web/src/pages/game/components/DiceSet.tsx @@ -1,21 +1,76 @@ import { PieceId } from "interface"; import "./DiceSet.scss"; +import Die from "./Die"; +import React from "react"; -const DiceSet = () => { - const getRandomPieceId = () => { - const randomId = Math.floor(Math.random() * Object.keys(PieceId).length); - return Object.values(PieceId)[randomId]; +export interface DiceSetProps { + dice: { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }[]; + setDice: React.Dispatch< + React.SetStateAction< + { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }[] + > + >; +} +const DiceSet = (props: DiceSetProps) => { + const { dice, setDice } = props; + const handleDieClick = (die: { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }) => { + if (die.isDisabled) return; + const newDiceState = dice.map((oldDie) => { + const isSelected = die === oldDie; + return { + ...oldDie, + isSelected: isSelected, + }; + }); + setDice(newDiceState); + }; + + const handleRotateButton = (rotation: number) => { + const newDiceState = dice.map((die) => { + if (!die.isSelected) return die; + const rotationAngle = (die.rotation + rotation + 360) % 360; + return { + ...die, + rotation: rotationAngle, + }; + }); + setDice(newDiceState); }; - const displayedPieceIds = [1, 2, 3, 4].map(getRandomPieceId); return ( -
- {displayedPieceIds.map((pieceId) => ( -
- -
- ))} -
+ +
+ handleRotateButton(-90)} + > + handleRotateButton(90)} + > +
+
+ {dice.map((die) => ( + + ))} +
+
); }; diff --git a/web/src/pages/game/components/Die.scss b/web/src/pages/game/components/Die.scss new file mode 100644 index 0000000..b3f4195 --- /dev/null +++ b/web/src/pages/game/components/Die.scss @@ -0,0 +1,32 @@ +.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); +} \ No newline at end of file diff --git a/web/src/pages/game/components/Die.tsx b/web/src/pages/game/components/Die.tsx new file mode 100644 index 0000000..ba8474f --- /dev/null +++ b/web/src/pages/game/components/Die.tsx @@ -0,0 +1,35 @@ +import { PieceId } from "interface"; +import "./Die.scss"; + +interface DieProps { + die: { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }; + handleDieClick: (die: { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }) => void; +} + +const Die = (props: DieProps) => { + const { die, handleDieClick } = props; + const { pieceId, isSelected, isDisabled, rotation } = die; + const className = + "dice" + + (isSelected ? " selected" : "") + + (isDisabled ? " disabled" : "") + + ` rotate-${rotation}`; + + return ( +
handleDieClick(die)}> + +
+ ); +}; + +export default Die; diff --git a/web/src/pages/game/components/GameBoard.scss b/web/src/pages/game/components/GameBoard.scss index 4d6cc7b..a11648f 100644 --- a/web/src/pages/game/components/GameBoard.scss +++ b/web/src/pages/game/components/GameBoard.scss @@ -7,121 +7,4 @@ grid-template-columns: repeat(7, auto); gap: auto; padding: 50px; - - .cell { - width: 100px; - height: 100px; - border: 2px solid black; - border-radius: 10%; - - &.house { - background-color: blue; - } - - &.university { - background-color: orange; - } - - &.factory { - background-color: grey; - } - - .exit: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)); - } - } - - .exit: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)); - } - } - - .exit: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)); - } - } - - .exit: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%)); - } - } - } } - -.exit { - position: relative; - width: 20px; - height: 20px; - - &:has(.exit-north) { - left: 50%; - transform: translate(calc(-50% - 1px), -100%); - } - - &:has(.exit-south) { - left: 50%; - top: 100%; - transform: translate(calc(-50% - 1px), 0%); - } - - &:has(.exit-east) { - left: 100%; - top: 50%; - transform: rotate(90deg) translate(-50%, 0); - } - - &:has(.exit-west) { - left: 0%; - top: 50%; - transform: rotate(90deg) translate(-50%, 100%); - } - - &:has(.exit-north), &:has(.exit-south) { - + .exit:has(.exit-east) { - left: 100%; - transform: rotate(90deg) translate(-150%, 0); - } - } - - &:has(.exit-north), &:has(.exit-south) { - + .exit:has(.exit-west) { - top: 50%; - transform: rotate(90deg) translate(-150%, 100%); - } - } -} \ No newline at end of file diff --git a/web/src/pages/game/components/GameBoard.tsx b/web/src/pages/game/components/GameBoard.tsx index e3dabf0..0997041 100644 --- a/web/src/pages/game/components/GameBoard.tsx +++ b/web/src/pages/game/components/GameBoard.tsx @@ -1,28 +1,44 @@ -import { buildBoard, Cell, directions, Exit } from "interface"; +import { buildBoard, PieceId } from "interface"; import "./GameBoard.scss"; +import { useState } from "react"; +import BoardCell from "./BoardCell"; -const GameBoard = () => { - const board: Cell[][] = buildBoard(); +export interface GameBoardProps { + dice: { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }[]; + setDice: React.Dispatch< + React.SetStateAction< + { + pieceId: PieceId; + isSelected: boolean; + isDisabled: boolean; + rotation: number; + }[] + > + >; +} + +const GameBoard = (props: GameBoardProps) => { + const [board, setBoard] = useState(buildBoard()); + const [id, setId] = useState(1); + const refreshBoardRender = () => { + setBoard(board); + setId(id + 1); + }; return ( -
+
{board.flatMap((row) => row.map((cell) => ( -
- {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 ( -
-
-
- ); - })} -
+ )), )}