Merge pull request #6 from Wenszel/dev

added frontend tests and edited readme
This commit is contained in:
Wiktor Smaga 2023-12-13 21:26:39 +01:00 committed by GitHub
commit da596e552f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1875 additions and 569 deletions

View File

@ -1,13 +1,58 @@
# Online multiplayer Ludo # <center>Online Multiplayer Ludo Game</center>
\>\> [Play Online here](www.github.com/wenszel/mern-ludo) <<
\>\> [Watch YouTube Video here](www.github.com/wenszel/mern-ludo) <<
## Table of content
- [About](#about)
- [Architecture](#architecture)
- [Key Features and Challenges](#key-features-and-challenges)
- [Tech Stack](#tech-stack)
- [Installation](#installation)
- [Screenshots](#screenshots)
## About ## About
Ludo Online is a multiplayer web-based implementation of the classic board game Ludo, built using the MERN stack and integrated with SocketIO for real-time communication.
## Architecture
![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/architecture.png?raw=true)
## Tech Stack
Frontend:
![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) ![React Router](https://img.shields.io/badge/React_Router-CA4245?style=for-the-badge&logo=react-router&logoColor=white)
![CSS3](https://img.shields.io/badge/css3-%231572B6.svg?style=for-the-badge&logo=css3&logoColor=white) ![HTML5](https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white) ![MUI](https://img.shields.io/badge/MUI-%230081CB.svg?style=for-the-badge&logo=mui&logoColor=white)
Backend:
![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) ![Express.js](https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) ![Socket.io](https://img.shields.io/badge/Socket.io-black?style=for-the-badge&logo=socket.io&badgeColor=010101) ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)
Tests:
![Mocha](https://img.shields.io/badge/-mocha-%238D6748?style=for-the-badge&logo=mocha&logoColor=white) ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white)
Tools:
![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white) ![CircleCI](https://img.shields.io/badge/circle%20ci-%23161616.svg?style=for-the-badge&logo=circleci&logoColor=white) ![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white) ![Jira](https://img.shields.io/badge/jira-%230A0FFF.svg?style=for-the-badge&logo=jira&logoColor=white)
**Ludo** is a strategy board game for two to four players, in which the players race their four tokens from start to finish according to the rolls of a single die. Like other cross and circle games, Ludo is derived from the Indian game Pachisi, but simpler. The game and its variations are popular in many countries and under various names. [Read more](https://en.wikipedia.org/wiki/Ludo_(board_game)) ## Key Features and Challenges
- Maintained session consistency with **Express Session** and **MongoDB**.
- Enabled real-time communication via **WebSocket** and **SocketIO**.
- Ensured code reliability with testing using **Mocha**, **Chai**, and **Jest**.
- Hosted in a **Docker** container on **AWS EC2**.
- Established CI/CD using **CircleCI**.
## Interface
![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/readme1.png?raw=true)
## Installation ## Installation
Play this game [here](https://smaga-wiktor-ludo.herokuapp.com) 1. Download this repository
2. Generate your own [mongoDB atlas](https://www.mongodb.com) credential URL. It should looks like this:
```
mongodb+srv://madmin:<password>@clustername.mongodb.net/<dbname>?retryWrites=true&w=majority
```
3. Add this URL to the /backend/credentials.js file
4. Perform these commands in the main directory:
``` ```
npm i npm i
npm start npm start
@ -16,16 +61,6 @@ npm i
node server.js node server.js
``` ```
## Technologies ## Screenshots
### Backend
- Node.js ![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/readme1.png?raw=true)
- Express
- Express-session
- MongoDB, Mongoose
- MongoDB sessions store
- SocketIO
### Frontend
- React
- Axios
- Material UI
- Canvas

View File

@ -1,4 +1,4 @@
const { getRoom, updateRoom } = require('../controllers/roomController'); const { getRoom, updateRoom } = require('../services/roomService');
const { sendToPlayersRolledNumber } = require('../socket/emits'); const { sendToPlayersRolledNumber } = require('../socket/emits');
const { rollDice, isMoveValid } = require('./handlersFunctions'); const { rollDice, isMoveValid } = require('./handlersFunctions');

View File

@ -6,7 +6,7 @@ const rollDice = () => {
}; };
const makeRandomMove = async roomId => { const makeRandomMove = async roomId => {
const { updateRoom, getRoom } = require('../controllers/roomController'); const { updateRoom, getRoom } = require('../services/roomService');
const room = await getRoom(roomId); const room = await getRoom(roomId);
if (room.rolledNumber === null) { if (room.rolledNumber === null) {
room.rolledNumber = rollDice(); room.rolledNumber = rollDice();

View File

@ -1,4 +1,4 @@
const { getRoom, updateRoom } = require('../controllers/roomController'); const { getRoom, updateRoom } = require('../services/roomService');
const { colors } = require('../utils/constants'); const { colors } = require('../utils/constants');
module.exports = socket => { module.exports = socket => {

View File

@ -1,4 +1,4 @@
const { getRooms, getRoom, updateRoom, createNewRoom } = require('../controllers/roomController'); const { getRooms, getRoom, updateRoom, createNewRoom } = require('../services/roomService');
const { sendToOnePlayerRooms, sendToOnePlayerData } = require('../socket/emits'); const { sendToOnePlayerRooms, sendToOnePlayerData } = require('../socket/emits');
module.exports = socket => { module.exports = socket => {
@ -16,13 +16,13 @@ module.exports = socket => {
}; };
const handleGetAllRooms = async () => { const handleGetAllRooms = async () => {
let rooms = await getRooms(); const rooms = await getRooms();
sendToOnePlayerRooms(socket.id, rooms); sendToOnePlayerRooms(socket.id, rooms);
}; };
const handleCreateRoom = async data => { const handleCreateRoom = async data => {
createNewRoom(data); createNewRoom(data);
socket.to(socket.id).emit('room:created'); sendToOnePlayerRooms(socket.id, await getRooms());
}; };
socket.on('room:data', handleGetData); socket.on('room:data', handleGetData);

640
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,6 @@
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.20", "@mui/material": "^5.14.20",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"axios": "^1.6.2", "axios": "^1.6.2",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@ -50,5 +48,9 @@
"last 1 safari version" "last 1 safari version"
] ]
}, },
"proxy": "http://localhost:5000" "proxy": "http://localhost:5000",
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2"
}
} }

View File

@ -1,38 +0,0 @@
import React, { useEffect, useContext } from 'react';
import { SocketContext } from '../../../App';
import one from '../../../images/dice/1.png';
import two from '../../../images/dice/2.png';
import three from '../../../images/dice/3.png';
import four from '../../../images/dice/4.png';
import five from '../../../images/dice/5.png';
import six from '../../../images/dice/6.png';
import roll from '../../../images/dice/roll.png';
const Dice = ({ rolledNumberCallback, rolledNumber, nowMoving, color, movingPlayer }) => {
const socket = useContext(SocketContext);
const images = [one, two, three, four, five, six, roll];
const handleRoll = () => {
socket.emit('game:roll');
};
useEffect(() => {
socket.on('game:roll', number => {
rolledNumberCallback(number);
});
}, [socket, rolledNumberCallback]);
return (
<div className={`dice-container dice-${color}`}>
{movingPlayer === color ? (
rolledNumber ? (
<img src={images[rolledNumber - 1]} alt={rolledNumber} width='100' height='100' />
) : nowMoving ? (
<img src={images[6]} className='roll' alt='roll' width='100' height='100' onClick={handleRoll} />
) : null
) : null}
</div>
);
};
export default Dice;

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import ReactLoading from 'react-loading'; import ReactLoading from 'react-loading';
import { PlayerDataContext, SocketContext } from '../../App'; import { PlayerDataContext, SocketContext } from '../../App';
import useSocketData from '../../hooks/useSocketData';
import Map from './Map/Map'; import Map from './Map/Map';
import Navbar from '../Navbar/Navbar'; import Navbar from '../Navbar/Navbar';
@ -11,7 +12,7 @@ const Gameboard = () => {
const [pawns, setPawns] = useState([]); const [pawns, setPawns] = useState([]);
const [players, setPlayers] = useState([]); const [players, setPlayers] = useState([]);
const [rolledNumber, setRolledNumber] = useState(null); const [rolledNumber, setRolledNumber] = useSocketData('game:roll');
const [time, setTime] = useState(); const [time, setTime] = useState();
const [isReady, setIsReady] = useState(); const [isReady, setIsReady] = useState();
const [nowMoving, setNowMoving] = useState(false); const [nowMoving, setNowMoving] = useState(false);
@ -48,10 +49,6 @@ const Gameboard = () => {
}); });
}, [socket]); }, [socket]);
const rolledNumberCallback = number => {
setRolledNumber(number);
};
return ( return (
<> <>
{(players[0] && !started) || (time && started) ? ( {(players[0] && !started) || (time && started) ? (
@ -64,7 +61,6 @@ const Gameboard = () => {
movingPlayer={movingPlayer} movingPlayer={movingPlayer}
rolledNumber={rolledNumber} rolledNumber={rolledNumber}
nowMoving={nowMoving} nowMoving={nowMoving}
rolledNumberCallback={rolledNumberCallback}
/> />
<Map pawns={pawns} nowMoving={nowMoving} rolledNumber={rolledNumber} /> <Map pawns={pawns} nowMoving={nowMoving} rolledNumber={rolledNumber} />
</div> </div>

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useContext } from 'react';
import { PlayerDataContext, SocketContext } from '../../../App'; import { PlayerDataContext, SocketContext } from '../../../App';
import mapImage from '../../../images/map.jpg'; import mapImage from '../../../images/map.jpg';
import positions from '../positions'; import positionMapCoords from '../positions';
import pawnImages from '../../../constants/pawnImages'; import pawnImages from '../../../constants/pawnImages';
import canPawnMove from './canPawnMove'; import canPawnMove from './canPawnMove';
import getPositionAfterMove from './getPositionAfterMove'; import getPositionAfterMove from './getPositionAfterMove';
@ -14,18 +14,19 @@ const Map = ({ pawns, nowMoving, rolledNumber }) => {
const [hintPawn, setHintPawn] = useState(); const [hintPawn, setHintPawn] = useState();
const paintPawn = (context, x, y, color) => { const paintPawn = (context, pawn) => {
const { x, y } = positionMapCoords[pawn.position];
const touchableArea = new Path2D(); const touchableArea = new Path2D();
touchableArea.arc(x, y, 12, 0, 2 * Math.PI); touchableArea.arc(x, y, 12, 0, 2 * Math.PI);
const image = new Image(); const image = new Image();
image.src = pawnImages[color]; image.src = pawnImages[pawn.color];
// image.onload = function () { image.onload = function () {
context.drawImage(image, x - 17, y - 14, 35, 30); context.drawImage(image, x - 17, y - 15, 35, 30);
};
return touchableArea; return touchableArea;
}; };
const handleCanvasClick = event => { const handleCanvasClick = event => {
if (hintPawn) {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect(), const rect = canvas.getBoundingClientRect(),
@ -33,15 +34,14 @@ const Map = ({ pawns, nowMoving, rolledNumber }) => {
cursorY = event.clientY - rect.top; cursorY = event.clientY - rect.top;
for (const pawn of pawns) { for (const pawn of pawns) {
if (ctx.isPointInPath(pawn.touchableArea, cursorX, cursorY)) { if (ctx.isPointInPath(pawn.touchableArea, cursorX, cursorY)) {
socket.emit('game:move', pawn._id); if (canPawnMove(pawn, rolledNumber)) socket.emit('game:move', pawn._id);
} }
} }
setHintPawn(null); setHintPawn(null);
}
}; };
const handleMouseMove = event => { const handleMouseMove = event => {
if (nowMoving && rolledNumber) { if (!nowMoving || !rolledNumber) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect(), const rect = canvas.getBoundingClientRect(),
@ -49,7 +49,6 @@ const Map = ({ pawns, nowMoving, rolledNumber }) => {
y = event.clientY - rect.top; y = event.clientY - rect.top;
canvas.style.cursor = 'default'; canvas.style.cursor = 'default';
for (const pawn of pawns) { for (const pawn of pawns) {
if (pawn.touchableArea) {
if ( if (
ctx.isPointInPath(pawn.touchableArea, x, y) && ctx.isPointInPath(pawn.touchableArea, x, y) &&
player.color === pawn.color && player.color === pawn.color &&
@ -58,19 +57,13 @@ const Map = ({ pawns, nowMoving, rolledNumber }) => {
const pawnPosition = getPositionAfterMove(pawn, rolledNumber); const pawnPosition = getPositionAfterMove(pawn, rolledNumber);
if (pawnPosition) { if (pawnPosition) {
canvas.style.cursor = 'pointer'; canvas.style.cursor = 'pointer';
if (hintPawn && hintPawn.id === pawn._id) return;
setHintPawn({ id: pawn._id, position: pawnPosition, color: 'grey' }); setHintPawn({ id: pawn._id, position: pawnPosition, color: 'grey' });
break; return;
}
}
} }
} else {
setHintPawn(null); setHintPawn(null);
}
} else {
setHintPawn(null);
}
}
} else {
setHintPawn(null);
}
}; };
useEffect(() => { useEffect(() => {
@ -82,15 +75,10 @@ const Map = ({ pawns, nowMoving, rolledNumber }) => {
image.onload = function () { image.onload = function () {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
pawns.forEach((pawn, index) => { pawns.forEach((pawn, index) => {
pawns[index].touchableArea = paintPawn( pawns[index].touchableArea = paintPawn(ctx, pawn);
ctx,
positions[pawn.position].x,
positions[pawn.position].y,
pawn.color
);
}); });
if (hintPawn) { if (hintPawn) {
paintPawn(ctx, positions[hintPawn.position].x, positions[hintPawn.position].y, hintPawn.color); paintPawn(ctx, hintPawn);
} }
}; };
}; };

View File

@ -0,0 +1,14 @@
import ReactLoading from 'react-loading';
const withLoading = Component => {
return function WithLoading({ isLoading, ...props }) {
if (!isLoading) {
return <Component {...props} />;
}
return (
<ReactLoading type='spinningBubbles' color='white' height={50} width={50} />
);
};
};
export default withLoading;

View File

@ -1,35 +0,0 @@
.refresh {
display: flex;
margin-left: auto;
justify-content: center;
align-items: center;
width: 40px;
height: 100%;
border: 1px solid white;
}
.refresh > img {
width: 20px;
height: 20px;
cursor: pointer;
}
form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.private-container {
margin-left: 10px;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
input:disabled {
background-color: black;
color: #999;
border: 1px solid #ddd;
}

View File

@ -1,56 +1,50 @@
import React, { useState, useContext, useEffect } from 'react'; import React, { useState, useContext } from 'react';
import './AddServer.css';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import { SocketContext } from '../../../App'; import { SocketContext } from '../../../App';
import WindowLayout from '../WindowLayout/WindowLayout';
import useInput from '../../../hooks/useInput';
import styles from './AddServer.module.css';
const AddServer = () => { const AddServer = () => {
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
const [serverName, setServerName] = useState(''); const [isIncorrect, setIsIncorrect] = useState(false);
const [password, setPassword] = useState(''); const serverName = useInput('');
const password = useInput('');
useEffect(() => {
socket.on('room:created', () => {
socket.emit('room:rooms');
});
}, [socket]);
const handleButtonClick = e => { const handleButtonClick = e => {
e.preventDefault(); e.preventDefault();
if (!serverName.value) setIsIncorrect(true);
else
socket.emit('room:create', { socket.emit('room:create', {
name: serverName, name: serverName.value,
password: password.value,
private: isPrivate, private: isPrivate,
password: password,
}); });
}; };
return ( return (
<div className='lp-container'> <WindowLayout
<div className='title-container'> title='Host A Server'
<h1>Host A Server</h1> content={
</div> <form className={styles.formContainer}>
<div className='content-container'>
<form>
<input <input
type='text' type='text'
value={serverName}
onChange={e => setServerName(e.target.value)}
placeholder='Server Name' placeholder='Server Name'
{...serverName}
style={{
border: isIncorrect ? '1px solid red' : '1px solid white',
}}
/> />
<div className='private-container'> <div className={styles.privateContainer}>
<p>Private</p> <label>Private</label>
<Switch checked={isPrivate} color='primary' onChange={() => setIsPrivate(!isPrivate)} /> <Switch checked={isPrivate} color='primary' onChange={() => setIsPrivate(!isPrivate)} />
</div> </div>
<input <input type='text' placeholder='password' disabled={!isPrivate} {...password} />
type='text'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='password'
disabled={!isPrivate}
/>
<button onClick={handleButtonClick}>Host</button> <button onClick={handleButtonClick}>Host</button>
</form> </form>
</div> }
</div> />
); );
}; };

View File

@ -0,0 +1,15 @@
.formContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.privateContainer {
margin-left: 10px;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}

View File

@ -0,0 +1,82 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SocketContext } from '../../../App';
import AddServer from './AddServer';
const mockSocket = {
emit: jest.fn(),
};
describe('AddServer component', () => {
it('should renders without crashing', () => {
render(
<SocketContext.Provider value={mockSocket}>
<AddServer />
</SocketContext.Provider>
);
expect(screen.getByText('Host A Server')).toBeInTheDocument();
});
it('should handles form submission with valid data when private', () => {
render(
<SocketContext.Provider value={mockSocket}>
<AddServer />
</SocketContext.Provider>
);
const serverNameInput = screen.getByPlaceholderText('Server Name');
fireEvent.change(serverNameInput, { target: { value: 'Test Server' } });
const privateSwitch = screen.getByRole('checkbox');
fireEvent.click(privateSwitch);
const passwordInput = screen.getByPlaceholderText('password');
fireEvent.change(passwordInput, { target: { value: 'TestPassword' } });
const hostButton = screen.getByText('Host');
fireEvent.click(hostButton);
expect(mockSocket.emit).toHaveBeenCalledWith('room:create', {
name: 'Test Server',
password: 'TestPassword',
private: true,
});
});
it('should handles form submission with valid data when not private', () => {
render(
<SocketContext.Provider value={mockSocket}>
<AddServer />
</SocketContext.Provider>
);
const serverNameInput = screen.getByPlaceholderText('Server Name');
fireEvent.change(serverNameInput, { target: { value: 'Test Server' } });
const hostButton = screen.getByText('Host');
fireEvent.click(hostButton);
expect(mockSocket.emit).toHaveBeenCalledWith('room:create', {
name: 'Test Server',
password: '',
private: false,
});
});
it('should handles form submission with missing server name', () => {
render(
<SocketContext.Provider value={mockSocket}>
<AddServer />
</SocketContext.Provider>
);
const hostButton = screen.getByText('Host');
fireEvent.click(hostButton);
expect(mockSocket.emit).not.toHaveBeenCalled();
const serverNameInput = screen.getByPlaceholderText('Server Name');
expect(serverNameInput).toHaveStyle('border: 1px solid red');
});
});

View File

@ -0,0 +1,66 @@
import React, { useContext, useEffect, useState } from 'react';
import { SocketContext } from '../../../App';
import refresh from '../../../images/login-page/refresh.png';
import NameInput from '../NameInput/NameInput';
import Overlay from '../../Overlay/Overlay';
import WindowLayout from '../WindowLayout/WindowLayout';
import ServersTable from './ServersTable/ServersTable';
import withLoading from '../../HOC/withLoading';
import useSocketData from '../../../hooks/useSocketData';
import styles from './JoinServer.module.css';
const JoinServer = () => {
const socket = useContext(SocketContext);
const [rooms, setRooms] = useSocketData('room:rooms');
const [joining, setJoining] = useState(false);
const [clickedRoom, setClickedRoom] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
socket.emit('room:rooms');
socket.on('room:rooms', () => {
setIsLoading(false);
});
}, [socket]);
const getRooms = () => {
setRooms([]);
socket.emit('room:rooms');
};
const handleJoinClick = room => {
setClickedRoom(room);
setJoining(true);
};
const ServersTableWithLoading = withLoading(ServersTable);
return (
<>
<WindowLayout
title='Join A Server'
titleComponent={
<div className={styles.refresh}>
<img src={refresh} alt='refresh' onClick={getRooms} />
</div>
}
content={
<div className={styles.serversTableContainer}>
<ServersTableWithLoading
isLoading={isLoading}
rooms={rooms}
handleJoinClick={handleJoinClick}
/>
</div>
}
/>
{joining ? (
<Overlay handleOverlayClose={() => setJoining(false)}>
<NameInput roomId={clickedRoom._id} isRoomPrivate={clickedRoom.private} />
</Overlay>
) : null}
</>
);
};
export default JoinServer;

View File

@ -0,0 +1,20 @@
.serversTableContainer {
display: flex;
height: 500px;
overflow: scroll;
width: 100%;
}
.refresh {
display: flex;
margin-left: auto;
justify-content: center;
align-items: center;
width: 40px;
height: 100%;
border: 1px solid white;
}
.refresh > img {
width: 20px;
height: 20px;
cursor: pointer;
}

View File

@ -0,0 +1,33 @@
import lock from '../../../../images/login-page/lock.png';
import styles from './ServersTable.module.css';
const ServerListTable = ({ rooms, handleJoinClick }) => {
return (
<table className={styles.rooms}>
<thead>
<tr>
<th className={styles.firstColumn}></th>
<th>Server</th>
<th>#/#</th>
<th>Status</th>
<th className={styles.lastColumn}></th>
</tr>
</thead>
<tbody>
{rooms.map((room, index) => (
<tr key={index}>
<td>{room.private ? <img src={lock} alt='private' /> : null}</td>
<td className={styles.roomName}>{room.name}</td>
<td>{`${room.players.length}/4`}</td>
<td>{room.isStarted ? 'started' : 'waiting'}</td>
<td className={styles.lastColumn}>
<button onClick={() => handleJoinClick(room)}>Join</button>
</td>
</tr>
))}
</tbody>
</table>
);
};
export default ServerListTable;

View File

@ -0,0 +1,40 @@
.roomName {
max-width: 150px;
overflow: hidden;
text-align: left !important;
}
.rooms > thead > tr :nth-child(2) {
text-align: left;
}
.rooms > tbody > tr > td > img {
margin-right: 5px;
width: 20px;
height: 20px;
}
.rooms > th {
padding: 8px;
text-align: center;
height: 50px;
}
.rooms > tbody > tr > td {
padding: 4px;
text-align: center;
height: 50px;
}
.rooms > tbody > tr > td {
max-height: 50px;
height: 10px;
}
.rooms {
border-collapse: collapse;
width: 100%;
}
.lastColumn {
width: 70px;
}
.firstColumn {
width: 40px;
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServersTable from './ServersTable';
const mockRooms = [
{ _id: '1', name: 'Room 1', private: false, players: [], isStarted: false },
{ _id: '2', name: 'Room 2', private: true, players: [], isStarted: true },
];
describe('ServersTable component', () => {
it('should renders without crashing', () => {
render(<ServersTable rooms={mockRooms} handleJoinClick={() => {}} />);
expect(screen.getByText('Server')).toBeInTheDocument();
expect(screen.getByText('#/#')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
});
it('should renders the list of rooms', () => {
render(<ServersTable rooms={mockRooms} handleJoinClick={() => {}} />);
expect(screen.getByText('Room 1')).toBeInTheDocument();
expect(screen.getByText('Room 2')).toBeInTheDocument();
});
it('should handles join click for each room', () => {
const handleJoinClick = jest.fn();
render(<ServersTable rooms={mockRooms} handleJoinClick={handleJoinClick} />);
const joinButtons = screen.getAllByText('Join');
fireEvent.click(joinButtons[0]);
expect(handleJoinClick).toHaveBeenCalledWith(mockRooms[0]);
});
});

View File

@ -1,14 +1,13 @@
import './LoginPage.css';
import AddServer from './AddServer/AddServer'; import AddServer from './AddServer/AddServer';
import ServerList from './ServerList/ServerList'; import JoinServer from './JoinServer/JoinServer';
import styles from './LoginPage.module.css';
const LoginPage = () => { const LoginPage = () => {
return ( return (
<> <div className={styles.container}>
<div className='login-page-container'> <JoinServer />
<ServerList />
<AddServer /> <AddServer />
</div> </div>
</>
); );
}; };

View File

@ -0,0 +1,8 @@
.container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
height: 50%;
width: 100%;
}

View File

@ -0,0 +1,19 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import LoginPage from './LoginPage';
jest.mock('./JoinServer/JoinServer', () => () => <div data-testid="join-server" />);
jest.mock('./AddServer/AddServer', () => () => <div data-testid="add-server" />);
describe('LoginPage component', () => {
it('should renders JoinServer component ', () => {
render(<LoginPage />);
expect(screen.getByTestId('join-server')).toBeInTheDocument();
});
it('should renders AddServer component', () => {
render(<LoginPage />);
expect(screen.getByTestId('add-server')).toBeInTheDocument();
});
});

View File

@ -1,70 +0,0 @@
.name-input-container {
display: flex;
flex-direction: column;
padding: 10px 20px 60px 20px;
width: 300px;
background: radial-gradient(circle, rgba(0, 138, 255, 1) 5%, rgba(9, 9, 121, 1) 81%);
border: 1px solid white;
border-radius: 8px;
margin: 20px;
}
.name-input-container > button {
margin-top: 5px;
text-align: center;
width: 100px;
align-self: center;
}
.name-input-container > input {
margin-top: 10px;
}
.name-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1;
}
input,
button {
padding: 0;
border: none;
outline: none;
box-sizing: border-box;
}
input {
width: 100%;
padding: 12px;
font-size: 16px;
border-radius: 8px;
color: white;
border: 1px solid #ccc;
background-color: rgba(0, 0, 0, 0.2);
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
}
input:focus {
color: black;
border-color: #4a90e2;
background-color: #fff;
}
button {
padding: 12px 20px;
font-size: 16px;
border-radius: 8px;
border: none;
color: #fff;
background-color: rgba(0, 0, 0, 0.4);
cursor: pointer;
transition: background-color 0.3s ease-in-out;
}
button:hover {
background-color: rgba(0, 0, 0, 1);
}

View File

@ -1,8 +1,8 @@
import React, { useState, useContext, useEffect, useCallback } from 'react'; import React, { useState, useContext, useEffect, useCallback } from 'react';
import { SocketContext } from '../../../App'; import { SocketContext } from '../../../App';
import useInput from '../../../hooks/useInput'; import useInput from '../../../hooks/useInput';
import './NameInput.css'; import useKeyPress from '../../../hooks/useKeyPress';
import Overlay from '../../Overlay/Overlay'; import styles from './NameInput.module.css';
const NameInput = ({ isRoomPrivate, roomId }) => { const NameInput = ({ isRoomPrivate, roomId }) => {
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
@ -10,41 +10,31 @@ const NameInput = ({ isRoomPrivate, roomId }) => {
const password = useInput(''); const password = useInput('');
const [isPasswordWrong, setIsPasswordWrong] = useState(false); const [isPasswordWrong, setIsPasswordWrong] = useState(false);
const handleButtonClick = useCallback(() => { const handleButtonClick = () => {
socket.emit('player:login', { name: nickname.value, password: password.value, roomId: roomId }); socket.emit('player:login', { name: nickname.value, password: password.value, roomId: roomId });
}, [socket, nickname.value, password.value, roomId]); };
useKeyPress('Enter', handleButtonClick);
useEffect(() => { useEffect(() => {
socket.on('error:wrongPassword', () => { socket.on('error:wrongPassword', () => {
setIsPasswordWrong(true); setIsPasswordWrong(true);
}); });
const keyDownHandler = event => { }, [socket]);
if (event.key === 'Enter') {
event.preventDefault();
handleButtonClick();
}
};
document.addEventListener('keydown', keyDownHandler);
return () => {
document.removeEventListener('keydown', keyDownHandler);
};
}, [socket, handleButtonClick]);
return ( return (
<div className='name-overlay'> <div className={styles.container} style={{ height: isRoomPrivate ? '100px' : '50px' }}>
<div className='name-input-container' style={{ height: isRoomPrivate ? '100px' : '50px' }}> <input placeholder='Nickname' type='text' {...nickname} />
<input placeholder='Nickname' type='text' onChange={nickname.onChange} />
{isRoomPrivate ? ( {isRoomPrivate ? (
<input <input
placeholder='Room password' placeholder='Room password'
type='text' type='text'
onChange={password.onChange} {...password}
style={{ backgroundColor: isPasswordWrong ? 'red' : null }} style={{ backgroundColor: isPasswordWrong ? 'red' : null }}
/> />
) : null} ) : null}
<button onClick={handleButtonClick}>JOIN</button> <button onClick={handleButtonClick}>JOIN</button>
</div> </div>
</div>
); );
}; };

View File

@ -0,0 +1,20 @@
.container {
display: flex;
flex-direction: column;
padding: 10px 20px 60px 20px;
width: 300px;
background: radial-gradient(circle, rgba(0, 138, 255, 1) 5%, rgba(9, 9, 121, 1) 81%);
border: 1px solid white;
border-radius: 8px;
margin: 20px;
z-index: 2;
}
.container > button {
margin-top: 5px;
text-align: center;
width: 100px;
align-self: center;
}
.container > input {
margin-top: 10px;
}

View File

@ -0,0 +1,89 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import NameInput from './NameInput';
import { SocketContext } from '../../../App';
const mockSocket = {
on: jest.fn(),
emit: jest.fn(),
};
describe('NameInput component', () => {
it('should renders password field when room is private', () => {
render(
<SocketContext.Provider value={mockSocket}>
<NameInput isRoomPrivate={true} />
</SocketContext.Provider>
);
expect(screen.getByPlaceholderText('Room password')).toBeInTheDocument();
});
it('should not renders password field when room is not private', () => {
render(
<SocketContext.Provider value={mockSocket}>
<NameInput isRoomPrivate={false} />
</SocketContext.Provider>
);
expect(screen.queryByPlaceholderText('Room password')).not.toBeInTheDocument();
});
it('should handles input change', () => {
render(
<SocketContext.Provider value={mockSocket}>
<NameInput isRoomPrivate={false} />
</SocketContext.Provider>
);
const nicknameInput = screen.getByPlaceholderText('Nickname');
fireEvent.change(nicknameInput, { target: { value: 'TestName' } });
expect(nicknameInput.value).toBe('TestName');
});
it('should handles password change', () => {
render(
<SocketContext.Provider value={mockSocket}>
<NameInput isRoomPrivate={true} />
</SocketContext.Provider>
);
const passwordInput = screen.getByPlaceholderText('Room password');
fireEvent.change(passwordInput, { target: { value: 'TestPassword' } });
expect(passwordInput.value).toBe('TestPassword');
});
it('should handles button click', () => {
render(
<SocketContext.Provider value={mockSocket}>
<NameInput isRoomPrivate={true} roomId={123} />
</SocketContext.Provider>
);
const nicknameInput = screen.getByPlaceholderText('Nickname');
fireEvent.change(nicknameInput, { target: { value: 'TestName' } });
const passwordInput = screen.getByPlaceholderText('Room password');
fireEvent.change(passwordInput, { target: { value: 'TestPassword' } });
const button = screen.getByText('JOIN');
fireEvent.click(button);
expect(mockSocket.emit).toHaveBeenCalledWith('player:login', {
name: 'TestName',
password: 'TestPassword',
roomId: 123,
});
});
it('should handles Enter key press', () => {
render(
<SocketContext.Provider value={mockSocket}>
<NameInput isRoomPrivate={true} roomId={123} />
</SocketContext.Provider>
);
const nicknameInput = screen.getByPlaceholderText('Nickname');
fireEvent.change(nicknameInput, { target: { value: 'TestName' } });
const passwordInput = screen.getByPlaceholderText('Room password');
fireEvent.change(passwordInput, { target: { value: 'TestPassword' } });
fireEvent.keyDown(nicknameInput, { key: 'Enter' });
expect(mockSocket.emit).toHaveBeenCalledWith('player:login', {
name: 'TestName',
password: 'TestPassword',
roomId: 123,
});
});
});

View File

@ -1,51 +0,0 @@
th {
text-align: left;
}
img {
margin-right: 5px;
width: 20px;
height: 20px;
}
th,
td {
padding: 8px;
text-align: left;
height: 50px;
}
tr {
max-height: 50px;
}
table {
border-collapse: collapse;
width: 100%;
}
.server-container {
display: flex;
height: 500px;
overflow: scroll;
}
.room-name {
max-width: 150px;
overflow: hidden;
}
/* Firefox */
* {
scrollbar-width: auto;
scrollbar-color: #ffffff rgba(0, 0, 0, 0.1);
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
background: rgba(0, 0, 0, 0);
width: 10px;
}
*::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar-thumb {
background-color: #ffffff;
border-radius: 10px;
}

View File

@ -1,77 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { SocketContext } from '../../../App';
import lock from '../../../images/login-page/lock.png';
import refresh from '../../../images/login-page/refresh.png';
import ReactLoading from 'react-loading';
import './ServerList.css';
import NameInput from '../NameInput/NameInput';
const ServerList = () => {
const socket = useContext(SocketContext);
const [rooms, setRooms] = useState([]);
const [joining, setJoining] = useState(false);
const [clickedRoom, setClickedRoom] = useState(null);
useEffect(() => {
socket.emit('room:rooms');
socket.on('room:rooms', data => {
data = JSON.parse(data);
setRooms(data);
});
}, [socket]);
const getRooms = () => {
setRooms(null);
socket.emit('room:rooms');
};
const handleJoinClick = room => {
setClickedRoom(room);
setJoining(true);
};
return (
<div className='lp-container'>
<div className='title-container'>
<h1>Server List</h1>
<div className='refresh'>
<img src={refresh} alt='refresh' onClick={getRooms}></img>
</div>
</div>
<div className='server-container content-container'>
{rooms ? (
<table className='rooms'>
<thead>
<tr>
<th></th>
<th>Server</th>
<th>#/#</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{rooms.map((room, index) => (
<tr key={index}>
<td>{room.private ? <img src={lock} alt='private' /> : null}</td>
<td className='room-name'>{room.name}</td>
<td>{`${room.players.length}/4`}</td>
<td>{room.isStarted ? 'started' : 'waiting'}</td>
<td>
<button onClick={() => handleJoinClick(room)}>Join</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ alignSelf: 'center' }}>
<ReactLoading type='spinningBubbles' color='white' height={50} width={50} />
</div>
)}
</div>
{joining ? <NameInput roomId={clickedRoom._id} isRoomPrivate={clickedRoom.private} /> : null}
</div>
);
};
export default ServerList;

View File

@ -0,0 +1,15 @@
import styles from './WindowLayout.module.css';
const WindowLayout = ({ title, titleComponent, content }) => {
return (
<div className={styles.container}>
<div className={styles.title}>
<h1>{title}</h1>
{titleComponent}
</div>
<div className={styles.content}>{content}</div>
</div>
);
};
export default WindowLayout;

View File

@ -1,25 +1,14 @@
.login-page-container { .container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
height: 50%;
width: 100%;
}
.lp-container {
margin: 50px; margin: 50px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 500px; width: 500px;
padding: 20px;
color: white; color: white;
} }
.title-container { .title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
@ -34,17 +23,21 @@
text-align: center; text-align: center;
} }
.title-container > h1 { .title > h1 {
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.content-container { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center;
width: 100%; width: 100%;
padding: 10px; padding-left: 5px;
padding-right: 5px;
padding-top: 10px;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border-left: 1px solid black; border-left: 1px solid black;

View File

@ -0,0 +1,28 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import WindowLayout from './WindowLayout';
jest.mock('./WindowLayout', () => ({ title, titleComponent, content }) => (
<div data-testid='mocked-window-layout'>
<div data-testid='mocked-title'>{title}</div>
<div data-testid='mocked-title-component'>{titleComponent}</div>
<div data-testid='mocked-content'>{content}</div>
</div>
));
describe('WindowLayout component', () => {
it('should render without crashing', () => {
render(
<WindowLayout
title='Test Title'
titleComponent={<div>Test Title Component</div>}
content={<div>Test Content</div>}
/>
);
expect(screen.getByTestId('mocked-window-layout')).toBeInTheDocument();
expect(screen.getByTestId('mocked-title')).toHaveTextContent('Test Title');
expect(screen.getByTestId('mocked-title-component')).toHaveTextContent('Test Title Component');
expect(screen.getByTestId('mocked-content')).toHaveTextContent('Test Content');
});
});

View File

@ -0,0 +1,28 @@
import React, { useContext } from 'react';
import { SocketContext } from '../../../App';
import images from '../../../constants/diceImages';
import styles from './Dice.module.css';
const Dice = ({ rolledNumber, nowMoving, playerColor, movingPlayer }) => {
const socket = useContext(SocketContext);
const handleClick = () => {
socket.emit('game:roll');
};
const isCurrentPlayer = movingPlayer === playerColor;
const hasRolledNumber = rolledNumber !== null;
return (
<div className={styles.container}>
{isCurrentPlayer &&
(hasRolledNumber ? (
<img src={images[rolledNumber - 1]} alt={rolledNumber} />
) : (
nowMoving && <img src={images[6]} alt='roll' onClick={handleClick} />
))}
</div>
);
};
export default Dice;

View File

@ -0,0 +1,11 @@
.container {
margin-left: 20px;
margin-right: 20px;
width: 50px;
height: 50px;
}
.container > img {
width: 100%;
height: 100%;
cursor: pointer;
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Dice from './Dice';
import { SocketContext } from '../../../App';
const mockSocket = {
emit: jest.fn(),
};
describe('Dice component', () => {
let props;
const MOVING_PLAYER = 'blue';
const NOT_MOVING_PLAYER = 'red';
const THIS_PLAYER_MOVING = true;
beforeEach(() => {
props = {
rolledNumber: null,
nowMoving: false,
playerColor: '',
movingPlayer: '',
};
});
it('should render correct rolledNumber next to moving player', () => {
props.rolledNumber = 5;
props.movingPlayer = MOVING_PLAYER;
props.playerColor = MOVING_PLAYER;
render(<Dice {...props} />);
expect(screen.queryByAltText(props.rolledNumber)).toBeInTheDocument();
});
it('should not render rolledNumber next to not moving player', () => {
props.rolledNumber = 5;
props.movingPlayer = MOVING_PLAYER;
props.playerColor = NOT_MOVING_PLAYER;
render(<Dice {...props} />);
expect(screen.queryByAltText(props.rolledNumber)).not.toBeInTheDocument();
});
it('should render roll icon next to moving player', () => {
props.rolledNumber = null;
props.movingPlayer = MOVING_PLAYER;
props.playerColor = MOVING_PLAYER;
props.nowMoving = THIS_PLAYER_MOVING;
render(<Dice {...props} />);
expect(screen.queryByAltText('roll')).toBeInTheDocument();
});
it('should not render roll icon next to not moving player', () => {
props.rolledNumber = null;
props.movingPlayer = MOVING_PLAYER;
props.playerColor = MOVING_PLAYER;
props.nowMoving = !THIS_PLAYER_MOVING;
render(<Dice {...props} />);
expect(screen.queryByAltText('roll')).not.toBeInTheDocument();
});
it('should send data on click', () => {
props.rolledNumber = null;
props.movingPlayer = MOVING_PLAYER;
props.playerColor = MOVING_PLAYER;
props.nowMoving = THIS_PLAYER_MOVING;
render(
<SocketContext.Provider value={mockSocket}>
<Dice {...props} />
</SocketContext.Provider>
);
const dice = screen.getByAltText('roll');
fireEvent.click(dice);
expect(mockSocket.emit).toHaveBeenCalledWith('game:roll');
});
});

View File

@ -1,19 +1,12 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import './TimerAnimation.js'; import './TimerAnimation.js';
const AnimatedOverlay = ({ time }) => { const AnimatedOverlay = ({ time }) => {
const animationDelay = useMemo(() => 15 - Math.ceil((time - Date.now()) / 1000), [time]); const animationDelay = useMemo(() => 15 - Math.ceil((time - Date.now()) / 1000), [time]);
return ( return (
<CSSTransition <CSSTransition in={true} timeout={0} style={{ animationDelay: `-${animationDelay}s` }} unmountOnExit>
in={true} <div className='overlay' data-testid='animated-overlay'></div>
timeout={0}
classNames='overlay'
style={{ animationDelay: `-${animationDelay}s` }}
unmountOnExit
>
<div className='overlay'></div>
</CSSTransition> </CSSTransition>
); );
}; };

View File

@ -0,0 +1,10 @@
.overlay {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0.9;
animation: timerAnimation 15s linear infinite;
transition-duration: 15s;
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import AnimatedOverlay from './AnimatedOverlay';
describe('AnimatedOverlay component', () => {
it('renders without crashing', () => {
render(<AnimatedOverlay time={0} />);
});
it('applies animation delay based on time prop', () => {
const timeNow = Date.now();
const time = timeNow + 5000;
render(<AnimatedOverlay time={time} />);
const overlay = screen.getByTestId('animated-overlay');
const expectedDelay = 15 - Math.ceil((time - timeNow) / 1000);
expect(overlay).toHaveStyle({ animationDelay: `-${expectedDelay}s` });
});
});

View File

@ -4,20 +4,20 @@ const steps = 86;
let count = 0; let count = 0;
let s = 'polygon(50% 50%, 50% 0%, 50% 0%'; let s = 'polygon(50% 50%, 50% 0%, 50% 0%';
for (let i = 50; i <= 100; i += 5) { for (let i = 50; i < 100; i += 5) {
s += `, ${i}% 0%`; s += `, ${i}% 0%`;
handle(); handle();
} }
for (let i = 0; i <= 100; i += 5) { for (let i = 0; i < 100; i += 5) {
s += `, 100% ${i}%`; s += `, 100% ${i}%`;
handle(); handle();
} }
for (let i = 100; i >= 0; i -= 5) { for (let i = 100; i > 0; i -= 5) {
s += `, ${i}% 100%`; s += `, ${i}% 100%`;
handle(); handle();
} }
for (let i = 100; i >= 0; i -= 5) { for (let i = 100; i > 0; i -= 5) {
s += `, 0% ${i}%`; s += `, 0% ${i}%`;
handle(); handle();
} }
@ -52,7 +52,7 @@ function handle() {
keyframes.push(step); keyframes.push(step);
count++; count++;
} }
if (document && document.styleSheets && document.styleSheets[0]) {
document.styleSheets[0].insertRule( document.styleSheets[0].insertRule(
` `
@keyframes timerAnimation { @keyframes timerAnimation {
@ -61,3 +61,4 @@ document.styleSheets[0].insertRule(
`, `,
document.styleSheets[0].cssRules.length document.styleSheets[0].cssRules.length
); );
}

View File

@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import AnimatedOverlay from './AnimatedOverlay/AnimatedOverlay'; import AnimatedOverlay from './AnimatedOverlay/AnimatedOverlay';
import styles from './NameContainer.module.css';
const NameContainer = ({ player, time }) => { const NameContainer = ({ player, time }) => {
return ( return (
<div <div className={styles.container} style={{ backgroundColor: player.ready ? player.color : 'lightgrey' }}>
className='name-container'
style={player.ready ? { backgroundColor: player.color } : { backgroundColor: 'lightgrey' }}
>
<p>{player.name}</p> <p>{player.name}</p>
{player.nowMoving ? <AnimatedOverlay time={time} /> : null} {player.nowMoving ? <AnimatedOverlay time={time} /> : null}
</div> </div>
@ -17,6 +15,7 @@ const NameContainer = ({ player, time }) => {
NameContainer.propTypes = { NameContainer.propTypes = {
player: PropTypes.object, player: PropTypes.object,
time: PropTypes.number, time: PropTypes.number,
testId: PropTypes.string,
}; };
export default NameContainer; export default NameContainer;

View File

@ -0,0 +1,13 @@
.container {
position: relative;
min-width: 100px;
min-height: 50px;
display: flex;
justify-content: center;
align-items: center;
border: 2px solid white;
border-radius: 5px;
color: white;
font-weight: bold;
text-align: center;
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NameContainer from './NameContainer';
import { NOT_READY_COLOR } from '../../../constants/colors';
jest.mock('./AnimatedOverlay/AnimatedOverlay.jsx', () => () => {
return <mock-animated-overlay data-testid='animated-overlay' />;
});
describe('NameContainer component', () => {
let player;
let time;
beforeEach(() => {
player = {
name: 'TestPlayer',
ready: false,
color: 'blue',
nowMoving: false,
};
time = 0;
});
it('renders without crashing', () => {
render(<NameContainer player={player} time={time} />);
});
it('renders player name', () => {
render(<NameContainer player={player} time={time} />);
expect(screen.getByText(player.name)).toBeInTheDocument();
});
it('applies grey color when player is not ready', () => {
player.ready = false;
render(<NameContainer player={player} time={time} testId='name-container' />);
const container = screen.getByText(player.name).closest('div');
expect(container).toHaveStyle({ backgroundColor: NOT_READY_COLOR });
});
it('applies player colors as background when player is ready', () => {
player.ready = true;
render(<NameContainer player={player} time={time} testId='name-container' />);
const container = screen.getByText(player.name).closest('div');
expect(container).toHaveStyle({ backgroundColor: player.color });
});
it('renders AnimatedOverlay when player is nowMoving', () => {
const movingPlayer = { ...player, nowMoving: true };
render(<NameContainer player={movingPlayer} time={time} />);
expect(screen.getByTestId('animated-overlay')).toBeInTheDocument();
});
it('does not render AnimatedOverlay when player is not nowMoving', () => {
render(<NameContainer player={player} time={time} />);
expect(screen.queryByTestId('animated-overlay')).toBeNull();
});
});

View File

@ -1,55 +0,0 @@
.dice-container {
margin-left: 20px;
margin-right: 20px;
width: 50px;
height: 50px;
}
.roll {
cursor: pointer;
}
.ready-container {
display: flex;
width: 300px;
justify-content: center;
align-items: center;
flex-direction: column;
flex-flow: row-reverse;
background-color: grey;
border-radius: 10px;
border: 2px solid white;
}
.ready-container > label {
margin-left: 10px;
margin-right: 10px;
width: 100px;
color: white;
}
.player-container {
display: flex;
align-items: center;
flex-direction: row;
width: 100%;
}
.red {
margin-bottom: 50px;
grid-column: 1;
grid-row: 1;
}
.yellow {
margin-bottom: 50px;
flex-flow: row-reverse;
grid-column: 2;
grid-row: 1;
}
.blue {
margin-top: 50px;
grid-column: 1;
grid-row: 4;
}
.green {
margin-top: 50px;
flex-flow: row-reverse;
grid-column: 2;
grid-row: 4;
}
/* Styl dla overlay */

View File

@ -1,29 +1,32 @@
import React from 'react'; import React from 'react';
import Dice from '../Gameboard/Dice/Dice'; import Dice from './Dice/Dice';
import NameContainer from './NameContainer/NameContainer'; import NameContainer from './NameContainer/NameContainer';
import ReadyButton from './ReadyButton/ReadyButton'; import ReadyButton from './ReadyButton/ReadyButton';
import './Navbar.css'; import { PLAYER_COLORS } from '../../constants/colors';
import { useContext } from 'react'; import { useContext } from 'react';
import { PlayerDataContext } from '../../App'; import { PlayerDataContext } from '../../App';
const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, rolledNumberCallback, movingPlayer }) => { import styles from './Navbar.module.css';
const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movingPlayer }) => {
const context = useContext(PlayerDataContext); const context = useContext(PlayerDataContext);
const colors = ['red', 'blue', 'green', 'yellow'];
const diceProps = {
rolledNumber,
nowMoving,
movingPlayer,
};
return ( return (
<> <>
{players.map((player, index) => ( {players.map((player, index) => (
<div className={`player-container ${colors[index]}`} key={index}> <div className={`${styles.playerContainer} ${styles[PLAYER_COLORS[index]]}`} key={index}>
<NameContainer player={player} time={time} /> <NameContainer player={player} time={time} />
<Dice {started ? <Dice playerColor={PLAYER_COLORS[index]} {...diceProps} /> : null}
movingPlayer={movingPlayer} {context.color === player.color && !started ? <ReadyButton isReady={isReady} /> : null}
rolledNumber={rolledNumber}
nowMoving={nowMoving}
color={colors[index]}
rolledNumberCallback={rolledNumberCallback}
/>
{context.color !== player.color || started ? null : <ReadyButton isReady={isReady} />}
</div> </div>
))} ))}
</> </>
); );
}; };
export default Navbar; export default Navbar;

View File

@ -0,0 +1,28 @@
.playerContainer {
display: flex;
align-items: center;
flex-direction: row;
width: 100%;
}
.red {
margin-bottom: 50px;
grid-column: 1;
grid-row: 1;
}
.yellow {
margin-bottom: 50px;
flex-flow: row-reverse;
grid-column: 2;
grid-row: 1;
}
.blue {
margin-top: 50px;
grid-column: 1;
grid-row: 4;
}
.green {
margin-top: 50px;
flex-flow: row-reverse;
grid-column: 2;
grid-row: 4;
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Navbar from './Navbar';
import { PlayerDataContext } from '../../App';
const mockPlayers = [
{ name: 'Player1', color: 'red' },
{ name: 'Player2', color: 'blue' },
];
const mockPlayerData = {
color: 'red',
};
jest.mock('./NameContainer/NameContainer.jsx', () => () => {
return <mock-name-container data-testid='name-container' />;
});
jest.mock('./ReadyButton/ReadyButton.jsx', () => () => {
return <mock-ready-button data-testid='ready-button' />;
});
jest.mock('./Dice/Dice.jsx', () => () => {
return <mock-dice data-testid='dice-container' />;
});
const setup = props => {
props.players = mockPlayers;
return render(
<PlayerDataContext.Provider value={mockPlayerData}>
<Navbar {...props} />
</PlayerDataContext.Provider>
);
};
describe('Navbar component', () => {
it('should render NameContainer for each player', () => {
setup({
started: true,
});
expect(screen.getAllByTestId('name-container')).toHaveLength(mockPlayers.length);
});
it('should render Dice when started is true', () => {
setup({
started: true,
});
expect(screen.getAllByTestId('dice-container')).toHaveLength(mockPlayers.length);
});
it('should not render ReadyButton when started is true', () => {
setup({
started: true,
});
expect(screen.queryByTestId('ready-button')).toBeNull();
});
it('should render ReadyButton when started is false', () => {
setup({
started: false,
});
expect(screen.getByTestId('ready-button')).toBeInTheDocument();
});
it('does not render Dice when started is false', () => {
setup({
started: false,
});
expect(screen.queryByTestId('dice-container')).toBeNull();
});
});

View File

@ -1,8 +1,7 @@
import React, { useState, useContext } from 'react'; import React, { useState, useContext } from 'react';
import { SocketContext } from '../../../App'; import { SocketContext } from '../../../App';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import '../Navbar.css'; import styles from './ReadyButton.module.css';
import '../NameContainer/AnimatedOverlay/TimerAnimation';
const ReadyButton = ({ isReady }) => { const ReadyButton = ({ isReady }) => {
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
@ -13,7 +12,7 @@ const ReadyButton = ({ isReady }) => {
setChecked(!checked); setChecked(!checked);
}; };
return ( return (
<div className='ready-container'> <div className={styles.container}>
<Switch onChange={handleCheckboxChange} checked={checked || false} /> <Switch onChange={handleCheckboxChange} checked={checked || false} />
<label>{checked ? 'I want to play' : 'Im waiting'}</label> <label>{checked ? 'I want to play' : 'Im waiting'}</label>
</div> </div>

View File

@ -0,0 +1,17 @@
.container {
display: flex;
justify-content: center;
align-items: center;
margin: 10px;
flex-direction: column;
flex-flow: row-reverse;
background-color: grey;
border-radius: 10px;
border: 2px solid white;
}
.container > label {
margin-left: 10px;
margin-right: 10px;
width: 100px;
color: white;
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import ReadyButton from './ReadyButton';
import { SocketContext } from '../../../App';
const mockSocket = {
emit: jest.fn(),
};
describe('ReadyButton component', () => {
it('renders without crashing', () => {
render(
<SocketContext.Provider value={mockSocket}>
<ReadyButton isReady={false} />
</SocketContext.Provider>
);
});
it('emits "player:ready" event and toggles switch on change', () => {
render(
<SocketContext.Provider value={mockSocket}>
<ReadyButton isReady={false} />
</SocketContext.Provider>
);
const switchElement = screen.getByRole('checkbox');
fireEvent.click(switchElement);
expect(mockSocket.emit).toHaveBeenCalledWith('player:ready');
expect(switchElement).toBeChecked();
});
it('displays correct label when switch is checked', () => {
render(
<SocketContext.Provider value={mockSocket}>
<ReadyButton isReady={true} />
</SocketContext.Provider>
);
const labelElement = screen.getByText('I want to play');
expect(labelElement).toBeInTheDocument();
});
it('displays correct label when switch is not checked', () => {
render(
<SocketContext.Provider value={mockSocket}>
<ReadyButton isReady={false} />
</SocketContext.Provider>
);
const labelElement = screen.getByText('Im waiting');
expect(labelElement).toBeInTheDocument();
});
});

View File

@ -0,0 +1,9 @@
import styles from './Overlay.module.css';
import useKeyPress from '../../hooks/useKeyPress';
const Overlay = ({ children, handleOverlayClose }) => {
useKeyPress('Escape', handleOverlayClose);
return <div className={styles.container}>{children}</div>;
};
export default Overlay;

View File

@ -0,0 +1,13 @@
.container {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1;
cursor: pointer;
}

View File

@ -0,0 +1,34 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render } from '@testing-library/react';
import Overlay from './Overlay';
import userEvent from '@testing-library/user-event';
describe('Overlay component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders Overlay component', () => {
const { container } = render(<Overlay handleOverlayClose={() => {}} />);
expect(container).toBeInTheDocument();
});
it('renders children inside Overlay', () => {
const { getByTestId } = render(
<Overlay handleOverlayClose={() => {}}>
<div data-testid='test-child' />
</Overlay>
);
expect(getByTestId('test-child')).toBeInTheDocument();
});
it('calls handleOverlayClose on Escape key press', async () => {
const handleOverlayCloseMock = jest.fn();
render(<Overlay handleOverlayClose={handleOverlayCloseMock} />);
await userEvent.type(document.body, '{Escape}');
expect(handleOverlayCloseMock).toHaveBeenCalled();
});
});

2
src/constants/colors.js Normal file
View File

@ -0,0 +1,2 @@
export const NOT_READY_COLOR = 'lightgrey';
export const PLAYER_COLORS = ['red', 'blue', 'green', 'yellow'];

View File

@ -0,0 +1,11 @@
import one from '../images/dice/1.png';
import two from '../images/dice/2.png';
import three from '../images/dice/3.png';
import four from '../images/dice/4.png';
import five from '../images/dice/5.png';
import six from '../images/dice/6.png';
import roll from '../images/dice/roll.png';
const diceImages = [one, two, three, four, five, six, roll];
export default diceImages;

View File

@ -1,4 +1,4 @@
const positions = [ const positionMapCoords = [
// Red base // Red base
{ x: 67, y: 67 }, // 0 { x: 67, y: 67 }, // 0
{ x: 67, y: 116 }, { x: 67, y: 116 },
@ -114,4 +114,4 @@ const positions = [
{ x: 230, y: 200 }, // 91 { x: 230, y: 200 }, // 91
]; ];
export default positions; export default positionMapCoords;

View File

@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
export default function useInput({ initialValue }) { export default function useInput(initialValue = '') {
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const handleChange = e => { const handleChange = e => {
setValue(e.target.value); setValue(e.target.value);
}; };
return { return {
value, value: value,
onChange: handleChange, onChange: handleChange,
}; };
} }

15
src/hooks/useKeyPress.js Normal file
View File

@ -0,0 +1,15 @@
import { useEffect } from 'react';
export default function useKeyPress(targetKey, callback) {
const keyPressHandler = ({ key }) => {
if (key === targetKey) {
callback();
}
};
useEffect(() => {
window.addEventListener('keydown', keyPressHandler);
return () => {
window.removeEventListener('keydown', keyPressHandler);
};
}, [keyPressHandler]);
}

View File

@ -0,0 +1,19 @@
import { useState, useContext } from 'react';
import { SocketContext } from '../App';
const useSocketData = port => {
const socket = useContext(SocketContext);
const [data, setData] = useState(null);
socket.on(port, res => {
let parsedData;
try {
parsedData = JSON.parse(res);
} catch (error) {
parsedData = res;
}
setData(parsedData);
});
return [data, setData];
};
export default useSocketData;

BIN
src/images/architecture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@ -20,28 +20,11 @@ canvas {
border-radius: 15px; border-radius: 15px;
border: 2px solid black; border: 2px solid black;
} }
.dice-container > img {
width: 50px;
height: 50px;
}
.navbar-container { .navbar-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
.name-container {
position: relative;
min-width: 100px;
min-height: 50px;
display: flex;
justify-content: center;
align-items: center;
border: 2px solid white;
border-radius: 5px;
color: white;
font-weight: bold;
text-align: center;
}
.timer { .timer {
background-color: darkblue; background-color: darkblue;
color: white; color: white;
@ -84,3 +67,70 @@ canvas {
grid-column: 1 / span 2; grid-column: 1 / span 2;
grid-row: 2 / span 2; grid-row: 2 / span 2;
} }
input,
button {
padding: 0;
border: none;
outline: none;
box-sizing: border-box;
}
input {
width: 100%;
padding: 12px;
font-size: 16px;
border-radius: 8px;
color: white;
border: 1px solid #ccc;
background-color: rgba(0, 0, 0, 0.2);
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
}
input:disabled {
background-color: black;
color: #999;
border: 1px solid #ddd;
}
input:focus {
color: black;
border-color: #4a90e2;
background-color: #fff;
}
button {
padding: 12px 20px;
font-size: 16px;
border-radius: 8px;
border: none;
color: #fff;
background-color: rgba(0, 0, 0, 0.4);
cursor: pointer;
transition: background-color 0.3s ease-in-out;
}
button:hover {
background-color: rgba(0, 0, 0, 1);
}
/* Firefox */
* {
scrollbar-width: auto;
scrollbar-color: #ffffff rgba(0, 0, 0, 0.1);
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
background: rgba(0, 0, 0, 0);
width: 10px;
}
*::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar-thumb {
background-color: #ffffff;
border-radius: 10px;
}