diff --git a/README.md b/README.md index f547e85..2132a4b 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,33 @@ -#
Online Multiplayer Ludo Game
+#
Online Multiplayer Ludo Game
+\>\> [Play Online here](www.github.com/wenszel/mern-ludo) << -\>\> [Play Online here](www.github.com/wenszel/mern-ludo) << +\>\> [Watch YouTube Video here](www.github.com/wenszel/mern-ludo) << -\>\> [Watch YouTube Video here](www.github.com/wenszel/mern-ludo) << +## Table of content -## Table of content +- [About](#about) -- [About](#about) +- [Architecture](#architecture) -- [Architecture](#architecture) +- [Key Features and Challenges](#key-features-and-challenges) -- [Key Features and Challenges](#key-features-and-challenges) +- [Tech Stack](#tech-stack) -- [Tech Stack](#tech-stack) +- [Installation](#installation) -- [Installation](#installation) +- [Screenshots](#screenshots) -- [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 + +## 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) @@ -34,22 +38,23 @@ Tests: 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) -## Key Features and Challenges +## 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**. +- Ensured code reliability with testing using **Mocha**, **Chai**, and **Jest**. - Hosted in a **Docker** container on **AWS EC2**. - Established CI/CD using **CircleCI**. +## Installation -## Installation 1. Download this repository 2. Generate your own [mongoDB atlas](https://www.mongodb.com) credential URL. It should looks like this: ``` mongodb+srv://madmin:@clustername.mongodb.net/?retryWrites=true&w=majority ``` + 3. Add this URL to the /backend/credentials.js file 4. Perform these commands in the main directory: @@ -61,6 +66,10 @@ npm i node server.js ``` -## Screenshots +## Screenshots -![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/readme1.png?raw=true) \ No newline at end of file +![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/readme1.png?raw=true) + +![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/lobby.png?raw=true) + +![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/winner.png?raw=true) diff --git a/backend/handlers/gameHandler.js b/backend/handlers/gameHandler.js index b66348a..88728d3 100644 --- a/backend/handlers/gameHandler.js +++ b/backend/handlers/gameHandler.js @@ -1,5 +1,5 @@ const { getRoom, updateRoom } = require('../services/roomService'); -const { sendToPlayersRolledNumber } = require('../socket/emits'); +const { sendToPlayersRolledNumber, sendWinner } = require('../socket/emits'); const { rollDice, isMoveValid } = require('./handlersFunctions'); module.exports = socket => { @@ -7,12 +7,18 @@ module.exports = socket => { const handleMovePawn = async pawnId => { const room = await getRoom(req.session.roomId); + if (room.winner) return; const pawn = room.getPawn(pawnId); if (isMoveValid(req.session, pawn, room)) { const newPositionOfMovedPawn = pawn.getPositionAfterMove(room.rolledNumber); room.changePositionOfPawn(pawn, newPositionOfMovedPawn); room.beatPawns(newPositionOfMovedPawn, req.session.color); room.changeMovingPlayer(); + const winner = room.getWinner(); + if (winner) { + room.endGame(winner); + sendWinner(room._id.toString(), winner); + } await updateRoom(room); } }; diff --git a/backend/handlers/handlersFunctions.js b/backend/handlers/handlersFunctions.js index 74e5ed5..7c4942b 100644 --- a/backend/handlers/handlersFunctions.js +++ b/backend/handlers/handlersFunctions.js @@ -1,4 +1,4 @@ -const { sendToPlayersRolledNumber } = require('../socket/emits'); +const { sendToPlayersRolledNumber, sendWinner } = require('../socket/emits'); const rollDice = () => { const rolledNumber = Math.ceil(Math.random() * 6); @@ -8,6 +8,7 @@ const rollDice = () => { const makeRandomMove = async roomId => { const { updateRoom, getRoom } = require('../services/roomService'); const room = await getRoom(roomId); + if (room.winner) return; if (room.rolledNumber === null) { room.rolledNumber = rollDice(); sendToPlayersRolledNumber(room._id.toString(), room.rolledNumber); @@ -19,6 +20,11 @@ const makeRandomMove = async roomId => { room.movePawn(randomPawn); } room.changeMovingPlayer(); + const winner = room.getWinner(); + if (winner) { + room.endGame(winner); + sendWinner(room._id.toString(), winner); + } await updateRoom(room); }; diff --git a/backend/handlers/playerHandler.js b/backend/handlers/playerHandler.js index 3ff80c9..93869e1 100644 --- a/backend/handlers/playerHandler.js +++ b/backend/handlers/playerHandler.js @@ -12,6 +12,14 @@ module.exports = socket => { addPlayerToExistingRoom(room, data); }; + const handleExit = async () => { + req.session.reload(err => { + if (err) return socket.disconnect(); + req.session.destroy(); + socket.emit('redirect'); + }); + }; + const handleReady = async () => { const room = await getRoom(req.session.roomId); room.getPlayer(req.session.playerId).changeReadyStatus(); @@ -45,4 +53,5 @@ module.exports = socket => { socket.on('player:login', handleLogin); socket.on('player:ready', handleReady); + socket.on('player:exit', handleExit); }; diff --git a/backend/handlers/roomHandler.js b/backend/handlers/roomHandler.js index e8a638c..396ed21 100644 --- a/backend/handlers/roomHandler.js +++ b/backend/handlers/roomHandler.js @@ -1,5 +1,5 @@ const { getRooms, getRoom, updateRoom, createNewRoom } = require('../services/roomService'); -const { sendToOnePlayerRooms, sendToOnePlayerData } = require('../socket/emits'); +const { sendToOnePlayerRooms, sendToOnePlayerData, sendWinner } = require('../socket/emits'); module.exports = socket => { const req = socket.request; @@ -13,6 +13,7 @@ module.exports = socket => { await updateRoom(room); } sendToOnePlayerData(socket.id, room); + if (room.winner) sendWinner(socket.id, room.winner); }; const handleGetAllRooms = async () => { diff --git a/backend/models/room.js b/backend/models/room.js index 8c3f25a..47fd2f7 100644 --- a/backend/models/room.js +++ b/backend/models/room.js @@ -15,6 +15,7 @@ const RoomSchema = new mongoose.Schema({ timeoutID: Number, rolledNumber: Number, players: [PlayerSchema], + winner: { type: String, default: null }, pawns: { type: [PawnSchema], default: () => { @@ -45,6 +46,7 @@ RoomSchema.methods.beatPawns = function (position, attackingPawnColor) { }; RoomSchema.methods.changeMovingPlayer = function () { + if (this.winner) return; const playerIndex = this.players.findIndex(player => player.nowMoving === true); this.players[playerIndex].nowMoving = false; if (playerIndex + 1 === this.players.length) { @@ -87,6 +89,31 @@ RoomSchema.methods.startGame = function () { this.timeoutID = setTimeout(makeRandomMove, 15000, this._id.toString()); }; +RoomSchema.methods.endGame = function (winner) { + this.timeoutID = null; + this.rolledNumber = null; + this.nextMoveTime = null; + this.players.map(player => (player.nowMoving = false)); + this.winner = winner; + this.save(); +}; + +RoomSchema.methods.getWinner = function () { + if (this.pawns.filter(pawn => pawn.color === 'red' && pawn.position === 73).length === 4) { + return 'red'; + } + if (this.pawns.filter(pawn => pawn.color === 'blue' && pawn.position === 79).length === 4) { + return 'blue'; + } + if (this.pawns.filter(pawn => pawn.color === 'green' && pawn.position === 85).length === 4) { + return 'green'; + } + if (this.pawns.filter(pawn => pawn.color === 'yellow' && pawn.position === 91).length === 4) { + return 'yellow'; + } + return null; +}; + RoomSchema.methods.isFull = function () { if (this.players.length === 4) { this.full = true; diff --git a/backend/socket/emits.js b/backend/socket/emits.js index e0d6eb7..36e5de4 100644 --- a/backend/socket/emits.js +++ b/backend/socket/emits.js @@ -16,4 +16,14 @@ const sendToOnePlayerRooms = (id, rooms) => { socketManager.getIO().to(id).emit('room:rooms', JSON.stringify(rooms)); }; -module.exports = { sendToPlayersData, sendToPlayersRolledNumber, sendToOnePlayerData, sendToOnePlayerRooms }; +const sendWinner = (id, winner) => { + socketManager.getIO().to(id).emit('game:winner', winner); +}; + +module.exports = { + sendToPlayersData, + sendToPlayersRolledNumber, + sendToOnePlayerData, + sendToOnePlayerRooms, + sendWinner, +}; diff --git a/src/components/Gameboard/Gameboard.jsx b/src/components/Gameboard/Gameboard.jsx index 99384ac..eb1e1c0 100644 --- a/src/components/Gameboard/Gameboard.jsx +++ b/src/components/Gameboard/Gameboard.jsx @@ -4,11 +4,13 @@ import { PlayerDataContext, SocketContext } from '../../App'; import useSocketData from '../../hooks/useSocketData'; import Map from './Map/Map'; import Navbar from '../Navbar/Navbar'; +import Overlay from '../Overlay/Overlay'; +import styles from './Gameboard.module.css'; +import trophyImage from '../../images/trophy.webp'; const Gameboard = () => { const socket = useContext(SocketContext); const context = useContext(PlayerDataContext); - const [pawns, setPawns] = useState([]); const [players, setPlayers] = useState([]); @@ -20,6 +22,8 @@ const Gameboard = () => { const [movingPlayer, setMovingPlayer] = useState('red'); + const [winner, setWinner] = useState(null); + useEffect(() => { socket.emit('room:data', context.roomId); socket.on('room:data', data => { @@ -47,11 +51,18 @@ const Gameboard = () => { setTime(data.nextMoveTime); setStarted(data.started); }); - }, [socket]); + + socket.on('game:winner', winner => { + setWinner(winner); + }); + socket.on('redirect', () => { + window.location.reload(); + }); + }, [socket, context.playerId, context.roomId, setRolledNumber]); return ( <> - {(players[0] && !started) || (time && started) ? ( + {pawns.length === 16 ? (
{ movingPlayer={movingPlayer} rolledNumber={rolledNumber} nowMoving={nowMoving} + ended={winner !== null} />
) : ( )} + {winner ? ( + +
+ winner +

+ 1st: {winner} +

+ +
+
+ ) : null} ); }; diff --git a/src/components/Gameboard/Gameboard.module.css b/src/components/Gameboard/Gameboard.module.css new file mode 100644 index 0000000..25d9f47 --- /dev/null +++ b/src/components/Gameboard/Gameboard.module.css @@ -0,0 +1,20 @@ +.winnerContainer { + display: flex; + flex-direction: column; + padding: 40px; + width: 300px; + height: 250px; + background: radial-gradient(circle, rgba(0, 138, 255, 1) 5%, rgba(9, 9, 121, 1) 81%); + color: white; + border: 1px solid white; + border-radius: 8px; + margin: 20px; + z-index: 2; + justify-content: center; + text-align: center; + cursor: default; +} +.winnerContainer > button { + align-self: center; + width: 200px; +} diff --git a/src/components/LoginPage/JoinServer/ServersTable/ServersTable.jsx b/src/components/LoginPage/JoinServer/ServersTable/ServersTable.jsx index d6b9987..8a62ff7 100644 --- a/src/components/LoginPage/JoinServer/ServersTable/ServersTable.jsx +++ b/src/components/LoginPage/JoinServer/ServersTable/ServersTable.jsx @@ -14,17 +14,19 @@ const ServerListTable = ({ rooms, handleJoinClick }) => { - {rooms.map((room, index) => ( - - {room.private ? private : null} - {room.name} - {`${room.players.length}/4`} - {room.isStarted ? 'started' : 'waiting'} - - - - - ))} + {rooms.map((room, index) => { + return room.started ? null : ( + + {room.private ? private : null} + {room.name} + {`${room.players.length}/4`} + {room.isStarted ? 'started' : 'waiting'} + + + + + ); + })} ); diff --git a/src/components/Navbar/Navbar.jsx b/src/components/Navbar/Navbar.jsx index 608b6e0..38f5d43 100644 --- a/src/components/Navbar/Navbar.jsx +++ b/src/components/Navbar/Navbar.jsx @@ -7,7 +7,7 @@ import { useContext } from 'react'; import { PlayerDataContext } from '../../App'; import styles from './Navbar.module.css'; -const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movingPlayer }) => { +const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movingPlayer, ended }) => { const context = useContext(PlayerDataContext); const diceProps = { @@ -21,7 +21,7 @@ const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movi {players.map((player, index) => (
- {started ? : null} + {started && !ended ? : null} {context.color === player.color && !started ? : null}
))} diff --git a/src/images/lobby.png b/src/images/lobby.png new file mode 100644 index 0000000..5bce494 Binary files /dev/null and b/src/images/lobby.png differ diff --git a/src/images/trophy.webp b/src/images/trophy.webp new file mode 100644 index 0000000..59dde76 Binary files /dev/null and b/src/images/trophy.webp differ diff --git a/src/images/winner.png b/src/images/winner.png new file mode 100644 index 0000000..7438ee7 Binary files /dev/null and b/src/images/winner.png differ