Merge pull request #9 from Wenszel/dev
added cypress and handled end of the game
This commit is contained in:
commit
832102c9f4
45
README.md
45
README.md
@ -1,63 +1,46 @@
|
||||
# <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
|
||||
|
||||
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.
|
||||
|
||||
\>\> Play Online here <<
|
||||
\>\> [Watch YouTube Video here](https://youtu.be/mGMnH9Nvsyw) <<
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
## Tech Stack
|
||||
|
||||
Frontend:
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Backend:
|
||||
|
||||
   
|
||||
|
||||
Tests:
|
||||
|
||||
 
|
||||
|
||||
  
|
||||
Tools:
|
||||
|
||||
    
|
||||
|
||||
## 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**.
|
||||
- Maintained code reliability by implementing unit and integration tests using **Mocha**, **Chai**, and **Jest**.
|
||||
- Hosted in a **Docker** container on **AWS EC2**.
|
||||
- Established CI/CD using **CircleCI**.
|
||||
|
||||
- Implemented E2E tests utilizing **Cypress**, addressing challenges related to [testing collaboration](https://docs.cypress.io/guides/references/trade-offs#Multiple-browsers-open-at-the-same-time) and canvas functionality in the application.
|
||||
|
||||
## 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:<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:
|
||||
|
||||
@ -72,3 +55,7 @@ node server.js
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@ -6,6 +6,7 @@ module.exports = function (mongoose) {
|
||||
.connect(CONNECTION_URI, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
dbName: 'test',
|
||||
})
|
||||
.then(() => {
|
||||
console.log('MongoDB Connected…');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 () => {
|
||||
@ -21,7 +22,7 @@ module.exports = socket => {
|
||||
};
|
||||
|
||||
const handleCreateRoom = async data => {
|
||||
createNewRoom(data);
|
||||
await createNewRoom(data);
|
||||
sendToOnePlayerRooms(socket.id, await getRooms());
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -17,9 +17,9 @@ const getJoinableRoom = async () => {
|
||||
return await Room.findOne({ full: false, started: false }).exec();
|
||||
};
|
||||
|
||||
const createNewRoom = data => {
|
||||
const createNewRoom = async data => {
|
||||
const room = new Room(data);
|
||||
room.save();
|
||||
await room.save();
|
||||
return room;
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
9
cypress.config.js
Normal file
9
cypress.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
const { defineConfig } = require('cypress');
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
},
|
||||
},
|
||||
});
|
||||
29
cypress/e2e/game.cy.js
Normal file
29
cypress/e2e/game.cy.js
Normal file
@ -0,0 +1,29 @@
|
||||
const io = require('socket.io-client');
|
||||
const socket = io.connect('http://localhost:8080', { withCredentials: true });
|
||||
const uniqName = Date.now().toString();
|
||||
|
||||
describe('game', () => {
|
||||
before(() => {
|
||||
cy.visit('http://localhost:3000');
|
||||
cy.get('[placeholder="Server Name"]').type(uniqName);
|
||||
cy.get('button:contains("Host")').click();
|
||||
const room = cy.contains(`${uniqName}`).should('exist');
|
||||
room.closest('tr').find('button:contains("Join")').click();
|
||||
const e = cy.get('[placeholder="Nickname"]').type('player1');
|
||||
e.type('{enter}');
|
||||
setTimeout(() => {
|
||||
socket.emit('room:rooms');
|
||||
socket.on('room:rooms', rooms => {
|
||||
const roomId = JSON.parse(rooms).find(r => r.name === uniqName)._id;
|
||||
socket.emit('player:login', { roomId: roomId, name: 'player2', password: '' });
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
it('starts game correctly', () => {
|
||||
socket.emit('player:ready');
|
||||
cy.get('.PrivateSwitchBase-input').click();
|
||||
cy.get('[data-testid="animated-overlay"]').should('exist');
|
||||
socket.emit('');
|
||||
});
|
||||
});
|
||||
29
cypress/e2e/login.cy.js
Normal file
29
cypress/e2e/login.cy.js
Normal file
@ -0,0 +1,29 @@
|
||||
describe('login', () => {
|
||||
it('should change color of input if the server name is invalid', () => {
|
||||
cy.visit('http://localhost:3000');
|
||||
cy.get('button:contains("Host")').click();
|
||||
cy.get('[placeholder="Server Name"]').should('have.css', 'border-color', 'rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
it('should redirect user to game when filling inputs correctly', () => {
|
||||
cy.visit('http://localhost:3000');
|
||||
const uniqName = Date.now().toString();
|
||||
cy.get('[placeholder="Server Name"]').type(uniqName);
|
||||
cy.get('.PrivateSwitchBase-input').click();
|
||||
cy.get('[placeholder="password"]').type('123456');
|
||||
cy.get('button:contains("Host")').click();
|
||||
const room = cy
|
||||
.contains(`${uniqName}`)
|
||||
.should('exist')
|
||||
.then($room => {
|
||||
cy.wrap($room).scrollIntoView();
|
||||
});
|
||||
room.closest('tr').find('button:contains("Join")').click();
|
||||
cy.get('[placeholder="Nickname"]').type('player1');
|
||||
const e = cy.get('[placeholder="Room password"]').type('123456');
|
||||
e.type('{enter}');
|
||||
cy.url().should('include', '/game');
|
||||
cy.contains('player1').should('exist');
|
||||
cy.get('canvas').should('exist');
|
||||
});
|
||||
});
|
||||
5
cypress/fixtures/example.json
Normal file
5
cypress/fixtures/example.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
25
cypress/support/commands.js
Normal file
25
cypress/support/commands.js
Normal file
@ -0,0 +1,25 @@
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
20
cypress/support/e2e.js
Normal file
20
cypress/support/e2e.js
Normal file
@ -0,0 +1,20 @@
|
||||
// ***********************************************************
|
||||
// This example support/e2e.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
2319
package-lock.json
generated
2319
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -51,6 +51,8 @@
|
||||
"proxy": "http://localhost:5000",
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2"
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"cypress": "^13.6.1",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,19 @@ const Gameboard = () => {
|
||||
setTime(data.nextMoveTime);
|
||||
setStarted(data.started);
|
||||
});
|
||||
|
||||
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 ? (
|
||||
<div className='container'>
|
||||
<Navbar
|
||||
players={players}
|
||||
@ -61,12 +73,24 @@ const Gameboard = () => {
|
||||
movingPlayer={movingPlayer}
|
||||
rolledNumber={rolledNumber}
|
||||
nowMoving={nowMoving}
|
||||
ended={winner !== null}
|
||||
/>
|
||||
<Map pawns={pawns} nowMoving={nowMoving} rolledNumber={rolledNumber} />
|
||||
</div>
|
||||
) : (
|
||||
<ReactLoading type='spinningBubbles' color='white' height={667} width={375} />
|
||||
)}
|
||||
{winner ? (
|
||||
<Overlay>
|
||||
<div className={styles.winnerContainer}>
|
||||
<img src={trophyImage} alt='winner' />
|
||||
<h1>
|
||||
1st: <span style={{ color: winner }}>{winner}</span>
|
||||
</h1>
|
||||
<button onClick={() => socket.emit('player:exit')}>Play again</button>
|
||||
</div>
|
||||
</Overlay>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
20
src/components/Gameboard/Gameboard.module.css
Normal file
20
src/components/Gameboard/Gameboard.module.css
Normal file
@ -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;
|
||||
}
|
||||
@ -14,7 +14,8 @@ const ServerListTable = ({ rooms, handleJoinClick }) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room, index) => (
|
||||
{rooms.map((room, index) => {
|
||||
return room.started ? null : (
|
||||
<tr key={index}>
|
||||
<td>{room.private ? <img src={lock} alt='private' /> : null}</td>
|
||||
<td className={styles.roomName}>{room.name}</td>
|
||||
@ -24,7 +25,8 @@ const ServerListTable = ({ rooms, handleJoinClick }) => {
|
||||
<button onClick={() => handleJoinClick(room)}>Join</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
@ -11,16 +11,17 @@ const Dice = ({ rolledNumber, nowMoving, playerColor, movingPlayer }) => {
|
||||
};
|
||||
|
||||
const isCurrentPlayer = movingPlayer === playerColor;
|
||||
const hasRolledNumber = rolledNumber !== null;
|
||||
const hasRolledNumber = rolledNumber !== null && rolledNumber !== undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{isCurrentPlayer &&
|
||||
(hasRolledNumber ? (
|
||||
{isCurrentPlayer ? (
|
||||
hasRolledNumber ? (
|
||||
<img src={images[rolledNumber - 1]} alt={rolledNumber} />
|
||||
) : (
|
||||
nowMoving && <img src={images[6]} alt='roll' onClick={handleClick} />
|
||||
))}
|
||||
) : nowMoving ? (
|
||||
<img src={images[6]} className='roll' alt='roll' onClick={handleClick} />
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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) => (
|
||||
<div className={`${styles.playerContainer} ${styles[PLAYER_COLORS[index]]}`} key={index}>
|
||||
<NameContainer player={player} time={time} />
|
||||
{started ? <Dice playerColor={PLAYER_COLORS[index]} {...diceProps} /> : null}
|
||||
{started && !ended ? <Dice playerColor={PLAYER_COLORS[index]} {...diceProps} /> : null}
|
||||
{context.color === player.color && !started ? <ReadyButton isReady={isReady} /> : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
BIN
src/images/lobby.png
Normal file
BIN
src/images/lobby.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
BIN
src/images/trophy.webp
Normal file
BIN
src/images/trophy.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
src/images/winner.png
Normal file
BIN
src/images/winner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Loading…
Reference in New Issue
Block a user