Compare commits

..

No commits in common. "3af9caff9a020a78d418cf4b9e435731911a6556" and "e7beb85302e7157314fa3db74787b43c2eddcf1a" have entirely different histories.

17 changed files with 181 additions and 587 deletions

View File

@ -11,20 +11,3 @@ export const directions: Direction[] = [
Direction.EAST, Direction.EAST,
Direction.WEST, 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

@ -13,6 +13,7 @@ const STRAIGHT_RAIL: Piece = new Piece({
}, },
], ],
}); });
const TURN_RAIL: Piece = new Piece({ const TURN_RAIL: Piece = new Piece({
useInternalTracks: false, useInternalTracks: false,
trackDefinitions: [ trackDefinitions: [
@ -165,7 +166,7 @@ const DEAD_END_STATION_ROAD: Piece = new Piece({
trackDefinitions: [ trackDefinitions: [
{ {
startPoint: Direction.EAST, startPoint: Direction.EAST,
type: TrackType.ROAD, type: TrackType.RAIL,
}, },
], ],
}); });
@ -231,6 +232,7 @@ const FOUR_WAY_WITH_ONE_RAIL: Piece = new Piece({
}, },
], ],
}); });
const FOUR_WAY_TURNING_CROSSING: Piece = new Piece({ const FOUR_WAY_TURNING_CROSSING: Piece = new Piece({
useInternalTracks: true, useInternalTracks: true,
internalNodeType: InternalNodeType.STATION, internalNodeType: InternalNodeType.STATION,
@ -292,7 +294,7 @@ const T_JUNCTION_ROAD: Piece = new Piece({
}, },
{ {
startPoint: Direction.WEST, startPoint: Direction.WEST,
type: TrackType.ROAD, type: TrackType.RAIL,
}, },
], ],
}); });
@ -314,7 +316,7 @@ const FOUR_WAY_CROSS_ROAD: Piece = new Piece({
}, },
{ {
startPoint: Direction.WEST, startPoint: Direction.WEST,
type: TrackType.ROAD, type: TrackType.RAIL,
}, },
], ],
}); });

View File

@ -11,4 +11,5 @@ export * from "./types/ExternalNode";
export * from "./types/InternalNode"; export * from "./types/InternalNode";
export * from "./types/Piece"; export * from "./types/Piece";
export * from "./types/PlacedPiece"; export * from "./types/PlacedPiece";
export * from "./types/Node";
export * from "./BoardBuilder"; export * from "./BoardBuilder";

View File

@ -1,20 +1,10 @@
import { CellType } from "../constants/CellType"; import { CellType } from "../constants/CellType";
import { Direction } from "../constants/Direction"; 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 { ExternalNode } from "./ExternalNode";
import { InternalNode } from "./InternalNode";
import { Piece } from "./Piece";
import { PlacedPiece } from "./PlacedPiece";
export class Cell { export class Cell {
public readonly externalNodes: Map<Direction, ExternalNode>; public readonly externalNodes: Map<Direction, ExternalNode>;
public readonly cellType: CellType; public readonly cellType: CellType;
public placedPiece?: {
piece: PlacedPiece;
id: PieceId;
};
constructor(cellType: CellType) { constructor(cellType: CellType) {
this.externalNodes = new Map([ this.externalNodes = new Map([
@ -31,78 +21,4 @@ export class Cell {
if (!node) throw Error(`Could not find node at ${direction}`); if (!node) throw Error(`Could not find node at ${direction}`);
return node!; 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");
}
}
} }

4
interface/types/Node.ts Normal file
View File

@ -0,0 +1,4 @@
import { ExternalNode } from "./ExternalNode";
import { InternalNode } from "./InternalNode";
export type Node = ExternalNode | InternalNode;

View File

@ -1,8 +1,9 @@
import { Direction, rotateDirection } from "../constants/Direction"; import { Direction } from "../constants/Direction";
import { TrackType } from "../constants/TrackType"; import { TrackType } from "../constants/TrackType";
import { Cell } from "./Cell"; import { Cell } from "./Cell";
import { InternalNode } from "./InternalNode"; import { InternalNode } from "./InternalNode";
import { PlacedPiece } from "./PlacedPiece"; import { PlacedPiece } from "./PlacedPiece";
import { Node } from "./Node";
import { InternalNodeType } from "../constants/InternalNodeType"; import { InternalNodeType } from "../constants/InternalNodeType";
export interface PieceProps { export interface PieceProps {
@ -65,33 +66,6 @@ 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) { toPlacedPiece(cell: Cell) {
return new PlacedPiece( return new PlacedPiece(
new Set( new Set(
@ -100,8 +74,8 @@ export class Piece {
nodes: { nodes: {
firstNode: cell.getNodeAt(track.joinedPoints.firstPoint), firstNode: cell.getNodeAt(track.joinedPoints.firstPoint),
secondNode: secondNode:
track.joinedPoints.secondPoint instanceof InternalNode track.joinedPoints.secondPoint instanceof Node
? (track.joinedPoints.secondPoint as InternalNode) ? (track.joinedPoints.secondPoint as Node)
: cell.getNodeAt(track.joinedPoints.secondPoint as Direction), : cell.getNodeAt(track.joinedPoints.secondPoint as Direction),
}, },
type: track.type, type: track.type,

View File

@ -1,25 +1,22 @@
import { Direction } from "../constants/Direction";
import { TrackType } from "../constants/TrackType"; import { TrackType } from "../constants/TrackType";
import { Cell } from "./Cell"; import { Cell } from "./Cell";
import { ExternalNode } from "./ExternalNode";
import { InternalNode } from "./InternalNode"; import { InternalNode } from "./InternalNode";
import { Node } from "./Node";
export class PlacedPiece { export class PlacedPiece {
tracks: Set<{ tracks: Set<{
nodes: { nodes: {
firstNode: ExternalNode; firstNode: Node;
secondNode: ExternalNode | InternalNode; secondNode: Node;
}; };
type: TrackType; type: TrackType;
}>; }>;
internalNode?: InternalNode; internalNode?: InternalNode;
cell: Cell; cell: Cell;
constructor( constructor(
tracks: Set<{ tracks: Set<{
nodes: { nodes: { firstNode: Node; secondNode: Node };
firstNode: ExternalNode;
secondNode: ExternalNode | InternalNode;
};
type: TrackType; type: TrackType;
}>, }>,
internalNodes: InternalNode | undefined, internalNodes: InternalNode | undefined,
@ -29,13 +26,4 @@ export class PlacedPiece {
this.internalNode = internalNodes; this.internalNode = internalNodes;
this.cell = cell; 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),
);
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,33 +1,16 @@
import React, { useState } from "react"; import React from "react";
import GameBoard from "./components/GameBoard"; import GameBoard from "./components/GameBoard";
import DiceSet from "./components/DiceSet"; import DiceSet from "./components/DiceSet";
import "./GamePage.scss"; import "./GamePage.scss";
import { PieceId } from "interface";
const GamePage = () => { 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 ( return (
<React.Fragment> <React.Fragment>
<h1>Game Page Title</h1> <h1>Game Page Title</h1>
<div className="game-panel"> <div className="game-panel">
<GameBoard dice={dice} setDice={setDice} /> <GameBoard />
<div className="rigth-panel"> <div className="rigth-panel">
<DiceSet dice={dice} setDice={setDice} /> <DiceSet />
</div> </div>
</div> </div>
</React.Fragment> </React.Fragment>

View File

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

View File

@ -1,84 +0,0 @@
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 (
<div
className={"cell " + cell.cellType.toLowerCase()}
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>
)}
</div>
);
};
export default BoardCell;

View File

@ -1,28 +1,17 @@
.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 { .dice-set {
display: grid; display: grid;
grid-template-columns: repeat(2, auto); grid-template-columns: repeat(2, auto);
gap: 20px; gap: 20px;
padding: 50px; padding: 50px;
.dice {
height: 150px;
width: 150px;
border: 2px solid black;
border-radius: 10%;
img{
border-radius: 10%;
}
}
} }

View File

@ -1,76 +1,21 @@
import { PieceId } from "interface"; import { PieceId } from "interface";
import "./DiceSet.scss"; import "./DiceSet.scss";
import Die from "./Die";
import React from "react";
export interface DiceSetProps { const DiceSet = () => {
dice: { const getRandomPieceId = () => {
pieceId: PieceId; const randomId = Math.floor(Math.random() * Object.keys(PieceId).length);
isSelected: boolean; return Object.values(PieceId)[randomId];
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 ( 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>
</div>
<div className="dice-set"> <div className="dice-set">
{dice.map((die) => ( {displayedPieceIds.map((pieceId) => (
<Die die={die} handleDieClick={handleDieClick} /> <div className="dice">
<img src={`pieces/${pieceId}.jpeg`}></img>
</div>
))} ))}
</div> </div>
</React.Fragment>
); );
}; };

View File

@ -1,32 +0,0 @@
.dice {
height: 150px;
width: 150px;
border: 2px solid black;
border-radius: 10%;
img{
border-radius: 10%;
}
&.selected {
box-shadow: 0 0 20px green;
}
&.disabled {
box-shadow: 0 0 20px grey;
}
&.rotate-90 {
transform: rotate(-90deg);
}
&.rotate-180 {
transform: rotate(180deg);
}
&.rotate-270 {
transform: rotate(90deg);
}
transition: transform 0.5s cubic-bezier(.47,1.64,.41,.8);
}

View File

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

View File

@ -7,4 +7,121 @@
grid-template-columns: repeat(7, auto); grid-template-columns: repeat(7, auto);
gap: auto; gap: auto;
padding: 50px; 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%);
}
}
} }

View File

@ -1,44 +1,28 @@
import { buildBoard, PieceId } from "interface"; import { buildBoard, Cell, directions, Exit } from "interface";
import "./GameBoard.scss"; import "./GameBoard.scss";
import { useState } from "react";
import BoardCell from "./BoardCell";
export interface GameBoardProps { const GameBoard = () => {
dice: { const board: Cell[][] = buildBoard();
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 ( return (
<div className="game-board" id={id.toString()}> <div className="game-board">
{board.flatMap((row) => {board.flatMap((row) =>
row.map((cell) => ( row.map((cell) => (
<BoardCell <div className={"cell " + cell.cellType.toLowerCase()}>
{...props} {directions.map((direction) => {
cell={cell} const traversedNode = cell.getNodeAt(direction).traverseBorder();
refreshBoardRender={refreshBoardRender} 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>
);
})}
</div>
)), )),
)} )}
</div> </div>