Merge pull request #5 from Wenszel/dev

updated packages, refactored code, added new login page and animations
This commit is contained in:
Wiktor Smaga 2023-12-09 12:37:04 +01:00 committed by GitHub
commit 823dd0e6bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 12180 additions and 5029 deletions

View File

@ -0,0 +1,14 @@
const CONNECTION_URI = require('../credentials.js');
module.exports = function (mongoose) {
mongoose.set('useFindAndModify', false);
mongoose
.connect(CONNECTION_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => {
console.log('MongoDB Connected…');
})
.catch(err => console.error(err));
};

26
backend/config/socket.js Normal file
View File

@ -0,0 +1,26 @@
const socketManager = require('../socket/socketManager');
const registerPlayerHandlers = require('../handlers/playerHandler');
const registerRoomHandlers = require('../handlers/roomHandler');
const registerGameHandlers = require('../handlers/gameHandler');
const { sessionMiddleware, wrap } = require('../config/session');
module.exports = function (server) {
socketManager.initialize(server);
socketManager.getIO().engine.on('initial_headers', (headers, req) => {
if (req.cookieHolder) {
headers['set-cookie'] = req.cookieHolder;
delete req.cookieHolder;
}
});
socketManager.getIO().use(wrap(sessionMiddleware));
socketManager.getIO().on('connection', socket => {
registerPlayerHandlers(socket);
registerRoomHandlers(socket);
registerGameHandlers(socket);
if (socket.request.session.roomId) {
const roomId = socket.request.session.roomId.toString();
socket.join(roomId);
socket.emit('player:data', JSON.stringify(socket.request.session));
}
});
};

View File

@ -0,0 +1,30 @@
const Room = require('../models/room');
const { sendToPlayersData } = require('../socket/emits');
const getRoom = async roomId => {
return await Room.findOne({ _id: roomId }).exec();
};
const getRooms = async () => {
return await Room.find().exec();
};
const updateRoom = async room => {
return await Room.findOneAndUpdate({ _id: room._id }, room).exec();
};
const getJoinableRoom = async () => {
return await Room.findOne({ full: false, started: false }).exec();
};
const createNewRoom = data => {
const room = new Room(data);
room.save();
return room;
};
Room.watch().on('change', async data => {
sendToPlayersData(await getRoom(data.documentKey._id));
});
module.exports = { getRoom, getRooms, updateRoom, getJoinableRoom, createNewRoom };

View File

@ -1,86 +1,31 @@
const Room = require('../schemas/room');
const { getPawnPositionAfterMove } = require('../utils/functions');
const { getRoom, updateRoom } = require('../controllers/roomController');
const { sendToPlayersRolledNumber } = require('../socket/emits');
const { rollDice, isMoveValid } = require('./handlersFunctions');
module.exports = (io, socket) => {
module.exports = socket => {
const req = socket.request;
const handleMovePawn = async pawnId => {
const room = await getRoom();
const room = await getRoom(req.session.roomId);
const pawn = room.getPawn(pawnId);
if (isMoveValid(pawn, room)) {
const newPositionOfMovedPawn = getPawnPositionAfterMove(room.rolledNumber, pawn);
if (isMoveValid(req.session, pawn, room)) {
const newPositionOfMovedPawn = pawn.getPositionAfterMove(room.rolledNumber);
room.changePositionOfPawn(pawn, newPositionOfMovedPawn);
room.beatPawns(newPositionOfMovedPawn, req.session.color);
handleChangeOfPlayer(room);
room.changeMovingPlayer();
await updateRoom(room);
}
};
const handleRollDice = async () => {
const rolledNumber = rollDice();
const room = await updateRoom({ rolledNumber: rolledNumber });
if (!canPlayerMove(room, rolledNumber)) {
handleChangeOfPlayer(room);
}
};
const rollDice = () => {
const rolledNumber = Math.ceil(Math.random() * 6);
sendToPlayersRolledNumber(rolledNumber);
return rolledNumber;
};
const canPlayerMove = (room, rolledNumber) => {
const playerPawns = room.getPlayerPawns(req.session.color);
for (const pawn of playerPawns) {
if (pawn.canMove(rolledNumber)) return true;
}
return false;
};
const isMoveValid = (pawn, room) => {
if (req.session.color !== pawn.color) {
return false;
}
if (req.session.playerId !== room.getCurrentlyMovingPlayer()._id.toString()) {
return false;
}
return true;
};
const handleChangeOfPlayer = async room => {
sendToPlayersRolledNumber(req.session.roomId, rolledNumber);
const room = await updateRoom({ _id: req.session.roomId, rolledNumber: rolledNumber });
const player = room.getPlayer(req.session.playerId);
if (!player.canMove(room, rolledNumber)) {
room.changeMovingPlayer();
room.timeoutID = setTimeout(makeRandomMove, 15000, room);
await updateRoom(room);
};
const makeRandomMove = async room => {
if (room.rolledNumber === null) room.rolledNumber = rollDice();
const pawnsThatCanMove = room.getPawnsThatCanMove()
if (pawnsThatCanMove.length > 0) {
const randomPawn = pawnsThatCanMove[Math.floor(Math.random() * pawnsThatCanMove.length)];
room.movePawn(randomPawn);
}
await handleChangeOfPlayer(room);
};
Room.watch().on('change', async () => {
sendToPlayersData(await getRoom());
});
const getRoom = async () => {
return await Room.findOne({ _id: req.session.roomId }).exec();
};
const updateRoom = async room => {
return await Room.findOneAndUpdate({ _id: req.session.roomId }, room).exec();
};
const sendToPlayersRolledNumber = rolledNumber => {
io.to(req.session.roomId).emit('game:roll', rolledNumber);
};
const sendToPlayersData = room => {
io.to(req.session.roomId).emit('room:data', JSON.stringify(room));
};
socket.on('game:roll', handleRollDice);

View File

@ -0,0 +1,35 @@
const { sendToPlayersRolledNumber } = require('../socket/emits');
const rollDice = () => {
const rolledNumber = Math.ceil(Math.random() * 6);
return rolledNumber;
};
const makeRandomMove = async roomId => {
const { updateRoom, getRoom } = require('../controllers/roomController');
const room = await getRoom(roomId);
if (room.rolledNumber === null) {
room.rolledNumber = rollDice();
sendToPlayersRolledNumber(room._id.toString(), room.rolledNumber);
}
const pawnsThatCanMove = room.getPawnsThatCanMove();
if (pawnsThatCanMove.length > 0) {
const randomPawn = pawnsThatCanMove[Math.floor(Math.random() * pawnsThatCanMove.length)];
room.movePawn(randomPawn);
}
room.changeMovingPlayer();
await updateRoom(room);
};
const isMoveValid = (session, pawn, room) => {
if (session.color !== pawn.color) {
return false;
}
if (session.playerId !== room.getCurrentlyMovingPlayer()._id.toString()) {
return false;
}
return true;
};
module.exports = { rollDice, makeRandomMove, isMoveValid };

View File

@ -1,121 +1,48 @@
const RoomModel = require('../schemas/room');
const { getRoom, updateRoom } = require('../controllers/roomController');
const { colors } = require('../utils/constants');
const { getStartPositions } = require('../utils/functions');
module.exports = (io, socket) => {
module.exports = socket => {
const req = socket.request;
// Function responsible for adding a player to an existing room or creating a new one
const login = data => {
// When new player login to game we are looking for not full and not started room to put player there
RoomModel.findOne({ full: false, started: false }, function (err, room) {
if (room) {
// If there is one adds player to it
const handleLogin = async data => {
const room = await getRoom(data.roomId);
if (room.isFull()) return socket.emit('error:changeRoom');
if (room.started) return socket.emit('error:changeRoom');
if (room.private && room.password !== data.password) return socket.emit('error:wrongPassword');
addPlayerToExistingRoom(room, data);
} else {
// If not creates new room and add player to it
createNewRoom(data);
}
});
};
// Function responsible for changing the player's readiness
const ready = () => {
const { roomId, playerId } = req.session;
// Finds player room
RoomModel.findOne({ _id: roomId }, function (err, room) {
if (err) return err;
// Finds index of player in players array
const index = room.players.findIndex(player => player._id.toString() == playerId.toString());
// Changes player's readiness to the opposite
room.players[index].ready = !room.players[index].ready;
// If two players are ready starts game by setting the room properties
if (room.players.filter(player => player.ready).length >= 2) {
room.started = true;
room.nextMoveTime = Date.now() + 15000;
room.players.forEach(player => (player.ready = true));
room.players[0].nowMoving = true;
const handleReady = async () => {
const room = await getRoom(req.session.roomId);
room.getPlayer(req.session.playerId).changeReadyStatus();
if (room.canStartGame()) {
room.startGame();
}
RoomModel.findOneAndUpdate(
{
_id: roomId,
},
room,
err => {
if (err) return err;
// Sends to all players in room game data
io.to(roomId.toString()).emit('room:data', JSON.stringify(room));
}
);
});
await updateRoom(room);
};
socket.on('player:login', login);
socket.on('player:ready', ready);
const addPlayerToExistingRoom = async (room, data) => {
room.addPlayer(data.name);
if (room.isFull()) {
room.startGame();
}
await updateRoom(room);
reloadSession(room);
};
function createNewRoom(data) {
const room = new RoomModel({
createDate: new Date(),
players: [
{
name: data.name,
color: colors[0],
},
],
pawns: getStartPositions(),
});
// Saves new room to database
room.save().then(() => {
// Since it is not bound to an HTTP request, the session must be manually reloaded and saved
const reloadSession = room => {
req.session.reload(err => {
if (err) return socket.disconnect();
// Saving session data
req.session.roomId = room._id.toString();
req.session.playerId = room.players[0]._id.toString();
req.session.color = room.players[0].color;
req.session.playerId = room.players[room.players.length - 1]._id.toString();
req.session.color = colors[room.players.length - 1];
req.session.save();
// Sending data to the user, after which player will be redirected to the game
socket.join(room._id.toString());
socket.emit('player:data', JSON.stringify(req.session));
});
});
}
};
function addPlayerToExistingRoom(room, data) {
// Adding a new user to the room
room.players.push({
name: data.name,
ready: false,
color: colors[room.players.length],
});
let updatedRoom = { players: room.players };
// Checking if the room is full
if (room.players.length === 4) {
// Changes the properties of the room to the state to start the game
updatedRoom = {
...updatedRoom,
full: true,
started: true,
nextMoveTime: Date.now() + 15000,
pawns: getStartPositions(),
};
updatedRoom.players.forEach(player => (player.ready = true));
updatedRoom.players[0].nowMoving = true;
}
// Updates a room in the database
RoomModel.findOneAndUpdate({ _id: room._id }, updatedRoom).then(() => {
// Since it is not bound to an HTTP request, the session must be manually reloaded and saved
req.session.reload(err => {
if (err) return socket.disconnect();
// Saving session data
req.session.roomId = room._id.toString();
req.session.playerId = updatedRoom.players[updatedRoom.players.length - 1]._id.toString();
req.session.color = colors[updatedRoom.players.length - 1];
req.session.save();
socket.join(room._id.toString());
// Sending data to the user, after which player will be redirected to the game
socket.emit('player:data', JSON.stringify(req.session));
});
});
}
socket.on('player:login', handleLogin);
socket.on('player:ready', handleReady);
};

View File

@ -1,35 +1,31 @@
const RoomModel = require('../schemas/room');
const { getRooms, getRoom, updateRoom, createNewRoom } = require('../controllers/roomController');
const { sendToOnePlayerRooms, sendToOnePlayerData } = require('../socket/emits');
module.exports = (io, socket) => {
module.exports = socket => {
const req = socket.request;
const getData = () => {
RoomModel.findOne({ _id: req.session.roomId }, function (err, room) {
if (!room) return err;
const handleGetData = async () => {
const room = await getRoom(req.session.roomId);
// Handle the situation when the server crashes and any player reconnects after the time has expired
// Typically, the responsibility for changing players is managed by gameHandler.js.
if (room.nextMoveTime <= Date.now()) {
changeCurrentMovingPlayer();
} else {
io.to(req.session.roomId.toString()).emit('room:data', JSON.stringify(room));
room.changeMovingPlayer();
await updateRoom(room);
}
});
sendToOnePlayerData(socket.id, room);
};
socket.on('room:data', getData);
function changeCurrentMovingPlayer() {
RoomModel.findOne({ _id: req.session.roomId }, function (err, room) {
if (!room) return err;
const index = room.players.findIndex(player => player.nowMoving === true);
const roomSize = room.players.length;
room.players[index].nowMoving = false;
if (index + 1 === roomSize) {
room.players[0].nowMoving = true;
} else {
room.players[index + 1].nowMoving = true;
}
room.nextMoveTime = Date.now() + 15000;
RoomModel.findOneAndUpdate({ _id: req.session.roomId }, room, function (err, updatedRoom) {
io.to(req.session.roomId).emit('room:data', JSON.stringify(updatedRoom));
});
});
}
const handleGetAllRooms = async () => {
let rooms = await getRooms();
sendToOnePlayerRooms(socket.id, rooms);
};
const handleCreateRoom = async data => {
createNewRoom(data);
socket.to(socket.id).emit('room:created');
};
socket.on('room:data', handleGetData);
socket.on('room:rooms', handleGetAllRooms);
socket.on('room:create', handleCreateRoom);
};

82
backend/models/pawn.js Normal file
View File

@ -0,0 +1,82 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const PawnSchema = new Schema({
color: String,
basePos: Number,
position: Number,
});
PawnSchema.methods.canMove = function (rolledNumber) {
if (this.position === this.basePos && (rolledNumber === 6 || rolledNumber === 1)) {
return true;
}
// (if player's pawn is near finish line) if the move does not go beyond the win line
if (this.position !== this.getPositionAfterMove(rolledNumber) && this.position !== this.basePos) {
return true;
}
return false;
};
PawnSchema.methods.getPositionAfterMove = function (rolledNumber) {
const { position, color } = this;
switch (color) {
case 'red':
if (position + rolledNumber <= 73) {
if (position >= 0 && position <= 3) {
return 16;
} else if (position <= 66 && position + rolledNumber >= 67) {
return position + rolledNumber + 1;
} else {
return position + rolledNumber;
}
} else {
return position;
}
case 'blue':
if (position + rolledNumber <= 79) {
if (position >= 4 && position <= 7) {
return 55;
} else if (position <= 67 && position + rolledNumber > 67) {
return position + rolledNumber - 52;
} else if (position <= 53 && position + rolledNumber >= 54) {
return position + rolledNumber + 20;
} else {
return position + rolledNumber;
}
} else {
return position;
}
case 'green':
if (position + rolledNumber <= 85) {
if (position >= 8 && position <= 11) {
return 42;
} else if (position <= 67 && position + rolledNumber > 67) {
return position + rolledNumber - 52;
} else if (position <= 40 && position + rolledNumber >= 41) {
return position + rolledNumber + 39;
} else {
return position + rolledNumber;
}
} else {
return position;
}
case 'yellow':
if (position + rolledNumber <= 85) {
if (position >= 12 && position <= 15) {
return 29;
} else if (position <= 67 && position + rolledNumber > 67) {
return position + rolledNumber - 52;
} else if (position <= 27 && position + rolledNumber >= 28) {
return position + rolledNumber + 58;
} else {
return position + rolledNumber;
}
} else {
return position;
}
}
};
module.exports = PawnSchema;

25
backend/models/player.js Normal file
View File

@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const PlayerSchema = new Schema({
sessionID: String,
name: String,
color: String,
ready: { type: Boolean, default: false },
nowMoving: { type: Boolean, default: false },
});
PlayerSchema.methods.changeReadyStatus = function () {
this.ready = !this.ready;
};
PlayerSchema.methods.canMove = function (room, rolledNumber) {
const playerPawns = room.getPlayerPawns(this.color);
for (const pawn of playerPawns) {
if (pawn.canMove(rolledNumber)) return true;
}
return false;
};
module.exports = PlayerSchema;

View File

@ -1,18 +1,37 @@
const mongoose = require('mongoose');
const { getPawnPositionAfterMove } = require('../utils/functions');
const Schema = mongoose.Schema;
const { colors } = require('../utils/constants');
const { makeRandomMove } = require('../handlers/handlersFunctions');
const PawnSchema = require('./pawn');
const PlayerSchema = require('./player');
const RoomSchema = new Schema({
createDate: Date,
const RoomSchema = new mongoose.Schema({
name: String,
private: { type: Boolean, default: false },
password: String,
createDate: { type: Date, default: Date.now },
started: { type: Boolean, default: false },
full: { type: Boolean, default: false },
nextMoveTime: Number,
timeoutID: Number,
rolledNumber: Number,
players: [PlayerSchema],
pawns: [PawnSchema],
pawns: {
type: [PawnSchema],
default: () => {
const startPositions = [];
for (let i = 0; i < 16; i++) {
let pawn = {};
pawn.basePos = i;
pawn.position = i;
if (i < 4) pawn.color = colors[0];
else if (i < 8) pawn.color = colors[1];
else if (i < 12) pawn.color = colors[2];
else if (i < 16) pawn.color = colors[3];
startPositions.push(pawn);
}
return startPositions;
},
},
});
RoomSchema.methods.beatPawns = function (position, attackingPawnColor) {
@ -36,10 +55,11 @@ RoomSchema.methods.changeMovingPlayer = function () {
this.nextMoveTime = Date.now() + 15000;
this.rolledNumber = null;
if (this.timeoutID) clearTimeout(this.timeoutID);
this.timeoutID = setTimeout(makeRandomMove, 15000, this._id.toString());
};
RoomSchema.methods.movePawn = function (pawn) {
const newPositionOfMovedPawn = getPawnPositionAfterMove(this.rolledNumber, pawn);
const newPositionOfMovedPawn = pawn.getPositionAfterMove(this.rolledNumber);
this.changePositionOfPawn(pawn, newPositionOfMovedPawn);
this.beatPawns(newPositionOfMovedPawn, pawn.color);
};
@ -48,13 +68,46 @@ RoomSchema.methods.getPawnsThatCanMove = function () {
const movingPlayer = this.getCurrentlyMovingPlayer();
const playerPawns = this.getPlayerPawns(movingPlayer.color);
return playerPawns.filter(pawn => pawn.canMove(this.rolledNumber));
}
};
RoomSchema.methods.changePositionOfPawn = function (pawn, newPosition) {
const pawnIndex = this.getPawnIndex(pawn._id);
this.pawns[pawnIndex].position = newPosition;
};
RoomSchema.methods.canStartGame = function () {
return this.players.filter(player => player.ready).length >= 2;
};
RoomSchema.methods.startGame = function () {
this.started = true;
this.nextMoveTime = Date.now() + 15000;
this.players.forEach(player => (player.ready = true));
this.players[0].nowMoving = true;
this.timeoutID = setTimeout(makeRandomMove, 15000, this._id.toString());
};
RoomSchema.methods.isFull = function () {
if (this.players.length === 4) {
this.full = true;
}
return this.full;
};
RoomSchema.methods.getPlayer = function (playerId) {
return this.players.find(player => player._id.toString() === playerId.toString());
};
RoomSchema.methods.addPlayer = function (name, id) {
if (this.full) return;
this.players.push({
sessionID: id,
name: name,
ready: false,
color: colors[this.players.length],
});
};
RoomSchema.methods.getPawnIndex = function (pawnId) {
return this.pawns.findIndex(pawn => pawn._id.toString() === pawnId.toString());
};
@ -71,6 +124,6 @@ RoomSchema.methods.getCurrentlyMovingPlayer = function () {
return this.players.find(player => player.nowMoving === true);
};
const RoomModel = mongoose.model('Room', RoomSchema);
const Room = mongoose.model('Room', RoomSchema);
module.exports = RoomModel;
module.exports = Room;

1887
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,5 +9,13 @@
"express-session": "^1.17.1",
"mongoose": "^5.12.0",
"socket.io": "^4.5.1"
},
"devDependencies": {
"chai": "^4.3.10",
"mocha": "^10.2.0",
"socket.io-client": "^4.7.2"
},
"scripts": {
"test": "mocha tests/**/*.js"
}
}

View File

@ -1,24 +0,0 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { getPawnPositionAfterMove } = require('../utils/functions');
const PawnSchema = new Schema({
color: String,
basePos: Number,
position: Number,
});
PawnSchema.methods.canMove = function (rolledNumber) {
if (this.position === this.basePos && (rolledNumber === 6 || rolledNumber === 1)) {
return true;
}
// (if player's pawn is near finish line) if the move does not go beyond the win line
if (this.position !== getPawnPositionAfterMove(rolledNumber, this) && this.position !== this.basePos) {
return true;
}
return false;
};
module.exports = PawnSchema;

View File

@ -1,12 +0,0 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const PlayerSchema = new Schema({
name: String,
color: String,
ready: { type: Boolean, default: false },
nowMoving: { type: Boolean, default: false },
});
module.exports = PlayerSchema;

View File

@ -1,13 +1,11 @@
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const { sessionMiddleware, wrap } = require('./controllers/serverController');
const registerPlayerHandlers = require('./handlers/playerHandler');
const registerRoomHandlers = require('./handlers/roomHandler');
const registerGameHandlers = require('./handlers/gameHandler');
const PORT = 8080;
const mongoose = require('mongoose');
const CONNECTION_URI = require('./credentials.js');
const { sessionMiddleware } = require('./config/session');
const PORT = 8080;
const app = express();
app.use(cookieParser());
@ -26,64 +24,10 @@ app.use(
);
app.use(sessionMiddleware);
mongoose.set('useFindAndModify', false);
mongoose
.connect(CONNECTION_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => {
console.log('MongoDB Connected…');
})
.catch(err => console.error(err));
const server = app.listen(PORT);
const server = app.listen(PORT, () => {
console.log('Server runs on port ' + PORT);
});
const io = require('socket.io')(server, {
cors: {
origin: 'http://localhost:3000',
credentials: true,
},
allowRequest: (req, callback) => {
const fakeRes = {
getHeader() {
return [];
},
setHeader(key, values) {
req.cookieHolder = values[0];
},
writeHead() {},
};
sessionMiddleware(req, fakeRes, () => {
if (req.session) {
fakeRes.writeHead();
req.session.save();
}
callback(null, true);
});
},
});
io.engine.on('initial_headers', (headers, req) => {
if (req.cookieHolder) {
headers['set-cookie'] = req.cookieHolder;
delete req.cookieHolder;
}
});
io.use(wrap(sessionMiddleware));
io.on('connection', socket => {
registerPlayerHandlers(io, socket);
registerRoomHandlers(io, socket);
registerGameHandlers(io, socket);
if (socket.request.session.roomId) {
const roomId = socket.request.session.roomId.toString();
socket.join(roomId);
socket.emit('player:data', JSON.stringify(socket.request.session));
io.to(roomId).emit('player joined');
}
});
require('./config/database')(mongoose);
require('./config/socket')(server);
if (process.env.NODE_ENV === 'production') {
app.use(express.static('/app/build'));
@ -91,3 +35,5 @@ if (process.env.NODE_ENV === 'production') {
res.sendFile('/app/build/index.html');
});
}
module.exports = { server };

19
backend/socket/emits.js Normal file
View File

@ -0,0 +1,19 @@
const socketManager = require('./socketManager');
const sendToPlayersRolledNumber = (id, rolledNumber) => {
socketManager.getIO().to(id).emit('game:roll', rolledNumber);
};
const sendToPlayersData = room => {
socketManager.getIO().to(room._id.toString()).emit('room:data', JSON.stringify(room));
};
const sendToOnePlayerData = (id, room) => {
socketManager.getIO().to(id).emit('room:data', JSON.stringify(room));
};
const sendToOnePlayerRooms = (id, rooms) => {
socketManager.getIO().to(id).emit('room:rooms', JSON.stringify(rooms));
};
module.exports = { sendToPlayersData, sendToPlayersRolledNumber, sendToOnePlayerData, sendToOnePlayerRooms };

View File

@ -0,0 +1,39 @@
const { sessionMiddleware } = require('../config/session');
const socketManager = {
io: null,
initialize(server) {
this.io = require('socket.io')(server, {
cors: {
origin: 'http://localhost:3000',
credentials: true,
},
allowRequest: (req, callback) => {
const fakeRes = {
getHeader() {
return [];
},
setHeader(key, values) {
req.cookieHolder = values[0];
},
writeHead() {},
};
sessionMiddleware(req, fakeRes, () => {
if (req.session) {
fakeRes.writeHead();
req.session.save();
}
callback(null, true);
});
},
});
},
getIO() {
if (!this.io) {
throw new Error('Socket.io not initialized');
}
return this.io;
},
};
module.exports = socketManager;

View File

@ -0,0 +1,123 @@
const { io } = require('socket.io-client');
const { expect } = require('chai');
const { server } = require('../../server');
const mongoose = require('mongoose');
const CONNECTION_URI = require('../../credentials.js');
const socketURL = 'http://localhost:8080';
const options = {
transports: ['websocket'],
'force new connection': true,
};
describe('Testing player socket handlers', function () {
let firstPlayer, secondPlayer;
before(async function () {
await mongoose.connect(CONNECTION_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
firstPlayer = io.connect(socketURL, options);
secondPlayer = io.connect(socketURL, options);
await assertDatabaseIsClear();
});
const assertDatabaseIsClear = async () => {
const collectionInfo = await mongoose.connection.db.listCollections({ name: 'rooms' }).next();
if (collectionInfo) await mongoose.connection.collections.rooms.drop();
};
beforeEach(function (done) {
firstPlayer.off('room:data');
secondPlayer.off('room:data');
firstPlayer.off('player:data');
secondPlayer.off('player:data');
done();
});
after(function (done) {
if (firstPlayer.connected) {
firstPlayer.disconnect();
}
if (secondPlayer.connected) {
secondPlayer.disconnect();
}
server.close();
assertDatabaseIsClear();
done();
});
it('should return credentials when joining room', function (done) {
firstPlayer.emit('player:login', { name: 'test1' });
firstPlayer.on('player:data', data => {
data = JSON.parse(data);
expect(data).to.have.property('roomId');
expect(data).to.have.property('playerId');
expect(data).to.have.property('color');
expect(data.color).to.equal('red');
done();
});
});
it('should correctly join player to room', function (done) {
firstPlayer.emit('room:data');
firstPlayer.on('room:data', data => {
data = JSON.parse(data);
expect(data.players[0].name).to.equal('test1');
done();
});
});
it('should correctly join player to existing room', function (done) {
secondPlayer.emit('player:login', { name: 'test2' });
secondPlayer.on('player:data', data => {
data = JSON.parse(data);
expect(data.color).to.equal('blue');
secondPlayer.emit('room:data');
});
secondPlayer.on('room:data', data => {
data = JSON.parse(data);
expect(data.players[1].name).to.equal('test2');
done();
});
});
it('should correctly change player ready status to true', function (done) {
firstPlayer.emit('player:ready');
firstPlayer.on('room:data', data => {
data = JSON.parse(data);
const player = data.players.find(player => player.name === 'test1');
expect(player.ready).to.equal(true);
done();
});
});
it('should correctly change player ready status to false', function (done) {
firstPlayer.emit('player:ready');
firstPlayer.on('room:data', data => {
data = JSON.parse(data);
const player = data.players.find(player => player.name === 'test1');
expect(player.ready).to.equal(false);
done();
});
});
it('should correctly change second player ready status to true', function (done) {
secondPlayer.emit('player:ready');
secondPlayer.on('room:data', data => {
data = JSON.parse(data);
const player = data.players.find(player => player.name === 'test2');
expect(player.ready).to.equal(true);
done();
});
});
it('should start game', function (done) {
firstPlayer.emit('player:ready');
firstPlayer.on('room:data', data => {
data = JSON.parse(data);
expect(data.started).to.equal(true);
done();
});
});
});

View File

@ -0,0 +1,71 @@
const { expect } = require('chai');
const RoomModel = require('../../schemas/room');
const { getPawnPositionAfterMove, getStartPositions } = require('../../utils/functions');
describe('Testing room model methods', function () {
const room = new RoomModel();
beforeEach(function () {
room.players = [];
room.pawns = getStartPositions();
});
it('should correctly beat pawn', function () {
room.addPlayer('test1', 'red');
room.addPlayer('test2', 'blue');
room.pawns.forEach(pawn => {
pawn.position = getPawnPositionAfterMove(1, pawn);
});
room.beatPawns(16, 'green');
room.pawns.forEach(pawn => {
if (pawn.color != 'red') {
expect(pawn.position).to.not.equal(pawn.basePos);
} else {
expect(pawn.position).to.equal(pawn.basePos);
}
});
});
it('should correctly beat multiple pawns', function () {
room.pawns[0].position = 16;
room.pawns[1].position = 16;
room.beatPawns(16, 'green');
room.pawns.forEach(pawn => {
expect(pawn.position).to.equal(pawn.basePos);
});
});
it('should correctly change moving player from last to first', function () {
room.addPlayer('test1', 'red');
room.addPlayer('test2', 'blue');
room.players[1].nowMoving = true;
room.changeMovingPlayer();
expect(room.players[0].nowMoving).to.equal(true);
});
it('should correctly change moving player from first to second', function () {
room.addPlayer('test1', 'red');
room.addPlayer('test2', 'blue');
room.players[0].nowMoving = true;
room.changeMovingPlayer();
expect(room.players[1].nowMoving).to.equal(true);
});
it('should correctly returns pawns that can move', function () {
room.addPlayer('test1', 'red');
room.addPlayer('test2', 'blue');
room.players[0].nowMoving = true;
room.pawns[0].position = 16;
room.rolledNumber = 2;
const pawnsThatCanMove = room.getPawnsThatCanMove();
expect(pawnsThatCanMove.length).to.equal(1);
});
it('should given rolled 6 correctly returns pawns that can move', function () {
room.addPlayer('test1', 'red');
room.addPlayer('test2', 'blue');
room.players[0].nowMoving = true;
room.pawns[0].position = 16;
room.rolledNumber = 6;
const pawnsThatCanMove = room.getPawnsThatCanMove();
expect(pawnsThatCanMove.length).to.equal(4);
});
});

12956
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,21 +3,24 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.3",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.8.3",
"axios": "^0.21.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@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",
"axios": "^1.6.2",
"prop-types": "^15.8.1",
"react": "^17.0.1",
"react-beforeunload": "^2.4.0",
"react-dom": "^17.0.1",
"react": "^18.2.0",
"react-beforeunload": "^2.6.0",
"react-dom": "^18.2.0",
"react-loading": "^2.0.3",
"react-router-dom": "^5.2.0",
"react-router-dom": "^6.20.1",
"react-scripts": "^5.0.1",
"socket.io": "^4.5.1",
"socket.io-client": "^4.5.1",
"web-vitals": "^1.1.0"
"react-transition-group": "^4.4.5",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2",
"web-vitals": "^3.5.0"
},
"scripts": {
"start": "react-scripts start",

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState, createContext } from 'react';
import { io } from 'socket.io-client';
import { BrowserRouter as Router, Route, Redirect, Switch } from 'react-router-dom';
import Gameboard from './components/Gameboard';
import NameInput from './components/NameInput';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import ReactLoading from 'react-loading';
import Gameboard from './components/Gameboard/Gameboard';
import LoginPage from './components/LoginPage/LoginPage';
export const PlayerDataContext = createContext();
export const SocketContext = createContext();
@ -12,13 +12,14 @@ function App() {
const [playerData, setPlayerData] = useState();
const [playerSocket, setPlayerSocket] = useState();
const [redirect, setRedirect] = useState();
useEffect(() => {
const socket = io('http://localhost:8080', { withCredentials: true });
socket.on('player:data', data => {
data = JSON.parse(data);
setPlayerData(data);
data.roomId != null ? setRedirect(true) : setRedirect(false);
if (data.roomId != null) {
setRedirect(true);
}
});
setPlayerSocket(socket);
}, []);
@ -26,25 +27,47 @@ function App() {
return (
<SocketContext.Provider value={playerSocket}>
<Router>
{redirect ? <Redirect to='/game' /> : <Redirect to='/login' />}
<Switch>
<Route exact path='/'>
LOADING...
</Route>
<Route path='/login'>
<NameInput />
</Route>
<Route path='/game'>
{playerData ? (
<Routes>
<Route
exact
path='/'
Component={() => {
if (redirect) {
return <Navigate to='/game' />;
} else if (playerSocket) {
return <LoginPage />;
} else {
return <ReactLoading type='spinningBubbles' color='white' height={667} width={375} />;
}
}}
></Route>
<Route
path='/login'
Component={() => {
if (redirect) {
return <Navigate to='/game' />;
} else if (playerSocket) {
return <LoginPage />;
} else {
return <ReactLoading type='spinningBubbles' color='white' height={667} width={375} />;
}
}}
></Route>
<Route
path='/game'
Component={() => {
if (playerData) {
return (
<PlayerDataContext.Provider value={playerData}>
<Gameboard />
</PlayerDataContext.Provider>
) : null}
<a href='https://www.flaticon.com/free-icons/hand' title='hand icons'>
Hand icons created by berkahicon - Flaticon
</a>
</Route>
</Switch>
);
} else {
return <Navigate to='/login' />;
}
}}
></Route>
</Routes>
</Router>
</SocketContext.Provider>
);

View File

@ -1,24 +1,28 @@
import React, { useState, 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';
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] = useState([one, two, three, four, five, six, roll]);
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 ? (

View File

@ -1,17 +1,16 @@
import React, { useState, useEffect, useContext, useCallback } from 'react';
import React, { useState, useEffect, useContext } from 'react';
import ReactLoading from 'react-loading';
import { PlayerDataContext, SocketContext } from '../App';
import Map from './game-board-components/Map';
import Navbar from './Navbar';
import { PlayerDataContext, SocketContext } from '../../App';
import Map from './Map/Map';
import Navbar from '../Navbar/Navbar';
const Gameboard = () => {
// Context data
const socket = useContext(SocketContext);
const context = useContext(PlayerDataContext);
// Render data
const [pawns, setPawns] = useState([]);
const [players, setPlayers] = useState([]);
// Game logic data
const [rolledNumber, setRolledNumber] = useState(null);
const [time, setTime] = useState();
const [isReady, setIsReady] = useState();
@ -19,22 +18,12 @@ const Gameboard = () => {
const [started, setStarted] = useState(false);
const [movingPlayer, setMovingPlayer] = useState('red');
const checkWin = useCallback(() => {
// Player wins when all pawns with same color are inside end base
if (pawns.filter(pawn => pawn.color === 'red' && pawn.position === 73).length === 4) {
alert('Red Won');
} else if (pawns.filter(pawn => pawn.color === 'blue' && pawn.position === 79).length === 4) {
alert('Blue Won');
} else if (pawns.filter(pawn => pawn.color === 'green' && pawn.position === 85).length === 4) {
alert('Green Won');
} else if (pawns.filter(pawn => pawn.color === 'yellow' && pawn.position === 91).length === 4) {
alert('Yellow Won');
}
}, [pawns]);
useEffect(() => {
socket.emit('room:data', context.roomId);
socket.on('room:data', data => {
data = JSON.parse(data);
if (data.players == null) return;
// Filling navbar with empty player nick container
while (data.players.length !== 4) {
data.players.push({ name: '...' });
@ -50,7 +39,6 @@ const Gameboard = () => {
setMovingPlayer(nowMovingPlayer.color);
}
const currentPlayer = data.players.find(player => player._id === context.playerId);
checkWin();
setIsReady(currentPlayer.ready);
setRolledNumber(data.rolledNumber);
setPlayers(data.players);
@ -58,17 +46,16 @@ const Gameboard = () => {
setTime(data.nextMoveTime);
setStarted(data.started);
});
}, []);
}, [socket]);
// Callback to handle dice rolling between dice and map component
const rolledNumberCallback = number => {
setRolledNumber(number);
};
return (
<>
{players ? (
<>
{(players[0] && !started) || (time && started) ? (
<div className='container'>
<Navbar
players={players}
started={started}
@ -80,7 +67,7 @@ const Gameboard = () => {
rolledNumberCallback={rolledNumberCallback}
/>
<Map pawns={pawns} nowMoving={nowMoving} rolledNumber={rolledNumber} />
</>
</div>
) : (
<ReactLoading type='spinningBubbles' color='white' height={667} width={375} />
)}

View File

@ -0,0 +1,111 @@
import React, { useEffect, useRef, useState, useContext } from 'react';
import { PlayerDataContext, SocketContext } from '../../../App';
import mapImage from '../../../images/map.jpg';
import positions from '../positions';
import pawnImages from '../../../constants/pawnImages';
import canPawnMove from './canPawnMove';
import getPositionAfterMove from './getPositionAfterMove';
const Map = ({ pawns, nowMoving, rolledNumber }) => {
const player = useContext(PlayerDataContext);
const socket = useContext(SocketContext);
const canvasRef = useRef(null);
const [hintPawn, setHintPawn] = useState();
const paintPawn = (context, x, y, color) => {
const touchableArea = new Path2D();
touchableArea.arc(x, y, 12, 0, 2 * Math.PI);
const image = new Image();
image.src = pawnImages[color];
// image.onload = function () {
context.drawImage(image, x - 17, y - 14, 35, 30);
return touchableArea;
};
const handleCanvasClick = event => {
if (hintPawn) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect(),
cursorX = event.clientX - rect.left,
cursorY = event.clientY - rect.top;
for (const pawn of pawns) {
if (ctx.isPointInPath(pawn.touchableArea, cursorX, cursorY)) {
socket.emit('game:move', pawn._id);
}
}
setHintPawn(null);
}
};
const handleMouseMove = event => {
if (nowMoving && rolledNumber) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect(),
x = event.clientX - rect.left,
y = event.clientY - rect.top;
canvas.style.cursor = 'default';
for (const pawn of pawns) {
if (pawn.touchableArea) {
if (
ctx.isPointInPath(pawn.touchableArea, x, y) &&
player.color === pawn.color &&
canPawnMove(pawn, rolledNumber)
) {
const pawnPosition = getPositionAfterMove(pawn, rolledNumber);
if (pawnPosition) {
canvas.style.cursor = 'pointer';
setHintPawn({ id: pawn._id, position: pawnPosition, color: 'grey' });
break;
}
} else {
setHintPawn(null);
}
} else {
setHintPawn(null);
}
}
} else {
setHintPawn(null);
}
};
useEffect(() => {
const rerenderCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = new Image();
image.src = mapImage;
image.onload = function () {
ctx.drawImage(image, 0, 0);
pawns.forEach((pawn, index) => {
pawns[index].touchableArea = paintPawn(
ctx,
positions[pawn.position].x,
positions[pawn.position].y,
pawn.color
);
});
if (hintPawn) {
paintPawn(ctx, positions[hintPawn.position].x, positions[hintPawn.position].y, hintPawn.color);
}
};
};
rerenderCanvas();
}, [hintPawn, pawns]);
return (
<canvas
className='canvas-container'
width={460}
height={460}
ref={canvasRef}
onClick={handleCanvasClick}
onMouseMove={handleMouseMove}
/>
);
};
export default Map;

View File

@ -0,0 +1,27 @@
const canPawnMove = (pawn, rolledNumber) => {
// If is in base
if ((rolledNumber === 1 || rolledNumber === 6) && pawn.position === pawn.basePos) {
return true;
// Other situations: pawn is on map or pawn is in end positions
} else if (pawn.position !== pawn.basePos) {
switch (pawn.color) {
case 'red':
if (pawn.position + rolledNumber <= 73) return true;
break;
case 'blue':
if (pawn.position + rolledNumber <= 79) return true;
break;
case 'green':
if (pawn.position + rolledNumber <= 85) return true;
break;
case 'yellow':
if (pawn.position + rolledNumber <= 91) return true;
break;
default:
return false;
}
} else {
return false;
}
};
export default canPawnMove;

View File

@ -1,19 +1,4 @@
const { colors } = require('./constants');
function getStartPositions() {
const startPositions = [];
for (let i = 0; i < 16; i++) {
let pawn = {};
pawn.basePos = i;
pawn.position = i;
if (i < 4) pawn.color = colors[0];
else if (i < 8) pawn.color = colors[1];
else if (i < 12) pawn.color = colors[2];
else if (i < 16) pawn.color = colors[3];
startPositions.push(pawn);
}
return startPositions;
}
function getPawnPositionAfterMove(rolledNumber, pawn) {
const getPositionAfterMove = (pawn, rolledNumber) => {
const { position, color } = pawn;
switch (color) {
case 'red':
@ -70,6 +55,9 @@ function getPawnPositionAfterMove(rolledNumber, pawn) {
} else {
return position;
}
default:
return position;
}
}
module.exports = { getStartPositions, getPawnPositionAfterMove };
};
export default getPositionAfterMove;

View File

@ -0,0 +1,35 @@
.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

@ -0,0 +1,57 @@
import React, { useState, useContext, useEffect } from 'react';
import './AddServer.css';
import Switch from '@mui/material/Switch';
import { SocketContext } from '../../../App';
const AddServer = () => {
const socket = useContext(SocketContext);
const [isPrivate, setIsPrivate] = useState(false);
const [serverName, setServerName] = useState('');
const [password, setPassword] = useState('');
useEffect(() => {
socket.on('room:created', () => {
socket.emit('room:rooms');
});
}, [socket]);
const handleButtonClick = e => {
e.preventDefault();
socket.emit('room:create', {
name: serverName,
private: isPrivate,
password: password,
});
};
return (
<div className='lp-container'>
<div className='title-container'>
<h1>Host A Server</h1>
</div>
<div className='content-container'>
<form>
<input
type='text'
value={serverName}
onChange={e => setServerName(e.target.value)}
placeholder='Server Name'
/>
<div className='private-container'>
<p>Private</p>
<Switch checked={isPrivate} color='primary' onChange={() => setIsPrivate(!isPrivate)} />
</div>
<input
type='text'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='password'
disabled={!isPrivate}
/>
<button onClick={handleButtonClick}>Host</button>
</form>
</div>
</div>
);
};
export default AddServer;

View File

@ -0,0 +1,53 @@
.login-page-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
height: 50%;
width: 100%;
}
.lp-container {
margin: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 500px;
padding: 20px;
color: white;
}
.title-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 100%;
height: 40px;
border: 1px solid white;
border-radius: 2px;
transform: scaleX(1.02);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
padding-left: 10px;
text-align: center;
}
.title-container > h1 {
width: 100%;
margin: 0;
padding: 0;
}
.content-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 10px;
background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border-left: 1px solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
}

View File

@ -0,0 +1,15 @@
import './LoginPage.css';
import AddServer from './AddServer/AddServer';
import ServerList from './ServerList/ServerList';
const LoginPage = () => {
return (
<>
<div className='login-page-container'>
<ServerList />
<AddServer />
</div>
</>
);
};
export default LoginPage;

View File

@ -0,0 +1,70 @@
.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

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

View File

@ -0,0 +1,51 @@
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

@ -0,0 +1,77 @@
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

@ -1,25 +0,0 @@
import React, { useState, useContext } from "react";
import { SocketContext } from "../App";
const NameInput = () => {
const socket = useContext(SocketContext);
const [inputValue, setInputValue] = useState("");
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
const handleButtonClick = () => {
socket.emit("player:login", { name: inputValue });
};
return (
<div>
<input
placeholder="Enter name"
type="text"
onChange={handleInputChange}
/>
<input type="submit" onClick={handleButtonClick} />
</div>
);
};
export default NameInput;

View File

@ -1,32 +0,0 @@
.red {
position: relative;
left: 176px;
}
.yellow {
position: relative;
flex-direction: row-reverse;
right: 170px;
}
.blue {
position: relative;
right: 28px;
top: 538px;
}
.green {
position: relative;
flex-direction: row-reverse;
top: 538px;
left: 36px;
}
.player-container {
display: flex;
}
.dice-container {
margin-left: 20px;
margin-right: 20px;
width: 50px;
height: 50px;
}
.roll {
cursor: pointer;
}

View File

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

View File

@ -0,0 +1,63 @@
const keyframes = [];
const steps = 86;
let count = 0;
let s = 'polygon(50% 50%, 50% 0%, 50% 0%';
for (let i = 50; i <= 100; i += 5) {
s += `, ${i}% 0%`;
handle();
}
for (let i = 0; i <= 100; i += 5) {
s += `, 100% ${i}%`;
handle();
}
for (let i = 100; i >= 0; i -= 5) {
s += `, ${i}% 100%`;
handle();
}
for (let i = 100; i >= 0; i -= 5) {
s += `, 0% ${i}%`;
handle();
}
for (let i = 0; i <= 50; i += 5) {
s += `, ${i}% 0%`;
handle();
}
function handle() {
const percentage = (count / steps) * 100;
let step;
if (percentage <= 75 && percentage >= 73) {
step = `${percentage}% {
background-color: orange;
clip-path: ${s})
}`;
} else if (percentage > 97.5 && percentage < 100) {
step = `${percentage}% {
background-color: red;
clip-path: ${s})
}`;
} else if (percentage > 0 && percentage < 2.5) {
step = `${percentage}% {
background-color: green;
clip-path: ${s})
}`;
} else {
step = `${percentage}% {
clip-path: ${s})
}`;
}
keyframes.push(step);
count++;
}
document.styleSheets[0].insertRule(
`
@keyframes timerAnimation {
${keyframes.join('\n')}
}
`,
document.styleSheets[0].cssRules.length
);

View File

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import AnimatedOverlay from './AnimatedOverlay/AnimatedOverlay';
const NameContainer = ({ player, time }) => {
return (
<div
className='name-container'
style={player.ready ? { backgroundColor: player.color } : { backgroundColor: 'lightgrey' }}
>
<p>{player.name}</p>
{player.nowMoving ? <AnimatedOverlay time={time} /> : null}
</div>
);
};
NameContainer.propTypes = {
player: PropTypes.object,
time: PropTypes.number,
};
export default NameContainer;

View File

@ -0,0 +1,55 @@
.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,13 +1,15 @@
import React from 'react';
import Dice from './game-board-components/Dice';
import NameContainer from './navbar-components/NameContainer';
import ReadyButton from './navbar-components/ReadyButton';
import Dice from '../Gameboard/Dice/Dice';
import NameContainer from './NameContainer/NameContainer';
import ReadyButton from './ReadyButton/ReadyButton';
import './Navbar.css';
import { useContext } from 'react';
import { PlayerDataContext } from '../../App';
const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, rolledNumberCallback, movingPlayer }) => {
const context = useContext(PlayerDataContext);
const colors = ['red', 'blue', 'green', 'yellow'];
return (
<div className='navbar-container'>
<>
{players.map((player, index) => (
<div className={`player-container ${colors[index]}`} key={index}>
<NameContainer player={player} time={time} />
@ -18,10 +20,10 @@ const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, roll
color={colors[index]}
rolledNumberCallback={rolledNumberCallback}
/>
{context.color !== player.color || started ? null : <ReadyButton isReady={isReady} />}
</div>
))}
{started ? null : <ReadyButton isReady={isReady} />}
</div>
</>
);
};
export default Navbar;

View File

@ -1,17 +1,17 @@
import React, { useState, useContext, useEffect } from 'react';
import { SocketContext } from '../../App';
import Switch from '@material-ui/core/Switch';
import React, { useState, useContext } from 'react';
import { SocketContext } from '../../../App';
import Switch from '@mui/material/Switch';
import '../Navbar.css';
import '../NameContainer/AnimatedOverlay/TimerAnimation';
const ReadyButton = ({ isReady }) => {
const socket = useContext(SocketContext);
const [checked, setChecked] = useState();
const [checked, setChecked] = useState(isReady);
const handleCheckboxChange = () => {
socket.emit('player:ready');
setChecked(!checked);
};
useEffect(() => {
setChecked(isReady);
});
return (
<div className='ready-container'>
<Switch onChange={handleCheckboxChange} checked={checked || false} />

View File

@ -1,230 +0,0 @@
import React, { useEffect, useRef, useState, useContext, useCallback } from 'react';
import { PlayerDataContext, SocketContext } from '../../App';
import positions from './positions';
import bluePawn from '../../images/pawns/blue-pawn.png';
import greenPawn from '../../images/pawns/green-pawn.png';
import yellowPawn from '../../images/pawns/yellow-pawn.png';
import redPawn from '../../images/pawns/red-pawn.png';
import greyPawn from '../../images/pawns/grey-pawn.png';
const Map = ({ pawns, nowMoving, rolledNumber }) => {
const context = useContext(PlayerDataContext);
const socket = useContext(SocketContext);
const [hintPawn, setHintPawn] = useState();
const paintPawn = (context, x, y, color) => {
const circle = new Path2D();
circle.arc(x, y, 12, 0, 2 * Math.PI);
const image = new Image();
switch (color) {
case 'green':
image.src = greenPawn;
break;
case 'blue':
image.src = bluePawn;
break;
case 'red':
image.src = redPawn;
break;
case 'yellow':
image.src = yellowPawn;
break;
case 'grey':
image.src = greyPawn;
break;
}
context.drawImage(image, x - 17, y - 14, 35, 30);
return circle;
};
const canvasRef = useRef(null);
// Return true when pawn can move
const checkIfPawnCanMove = useCallback(
pawn => {
// If is in base
if ((rolledNumber === 1 || rolledNumber === 6) && pawn.position === pawn.basePos) {
return true;
// Other situations: pawn is on map or pawn is in end positions
} else if (pawn.position !== pawn.basePos) {
switch (pawn.color) {
case 'red':
if (pawn.position + rolledNumber <= 73) return true;
break;
case 'blue':
if (pawn.position + rolledNumber <= 79) return true;
break;
case 'green':
if (pawn.position + rolledNumber <= 85) return true;
break;
case 'yellow':
if (pawn.position + rolledNumber <= 91) return true;
break;
default:
return false;
}
} else {
return false;
}
},
[rolledNumber]
);
const handleCanvasClick = event => {
// If hint pawn exist it means that pawn can move
if (hintPawn) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect(),
x = event.clientX - rect.left,
y = event.clientY - rect.top;
for (const pawn of pawns) {
if (ctx.isPointInPath(pawn.circle, x, y)) {
socket.emit('game:move', pawn._id);
}
}
setHintPawn(null);
}
};
const getHintPawnPosition = pawn => {
// Based on color (because specific color have specific base and end positions)
let { position } = pawn;
switch (context.color) {
case 'red':
// When in base
if (position >= 0 && position <= 3) {
return 16;
// Next to end
} else if (position <= 66 && position + rolledNumber >= 67) {
return position + rolledNumber + 1; // 1 is difference between last position on map and first on end
// Normal move
} else {
return position + rolledNumber;
}
case 'blue':
// When in base
if (position >= 4 && position <= 7) {
return 55;
// Next to red base
} else if (position <= 67 && position + rolledNumber > 67) {
return position + rolledNumber - 52;
// Next to base
} else if (position <= 53 && position + rolledNumber >= 54) {
return position + rolledNumber + 20;
// Normal move
} else {
return position + rolledNumber;
}
case 'green':
// When in base
if (position >= 8 && position <= 11) {
return 42;
// Next to red base
} else if (position <= 67 && position + rolledNumber > 67) {
return position + rolledNumber - 52;
// Next to base
} else if (position <= 40 && position + rolledNumber >= 41) {
return position + rolledNumber + 39;
// Normal move
} else {
return position + rolledNumber;
}
case 'yellow':
// When in base
if (position >= 12 && position <= 15) {
return 29;
// Next to red base
} else if (position <= 67 && position + rolledNumber > 67) {
return position + rolledNumber - 52;
// Next to base
} else if (position <= 27 && position + rolledNumber >= 28) {
return position + rolledNumber + 58;
// Normal move
} else {
return position + rolledNumber;
}
default:
return position;
}
};
const handleMouseMove = event => {
if (nowMoving && rolledNumber) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// Gets x and y cords of mouse on canvas
const rect = canvas.getBoundingClientRect(),
x = event.clientX - rect.left,
y = event.clientY - rect.top;
canvas.style.cursor = 'default';
for (const pawn of pawns) {
if (pawn.circle) {
/*
This condition checks if mouse location is:
1) on pawn
2) is color of pawn same as player's
3) if pawn can move
And then sets cursor to pointer and paints hint pawn - where will be pawn after click
*/
if (
ctx.isPointInPath(pawn.circle, x, y) &&
context.color === pawn.color &&
checkIfPawnCanMove(pawn)
) {
const pawnPosition = getHintPawnPosition(pawn);
// Checks if pawn can make a move
if (pawnPosition) {
canvas.style.cursor = 'pointer';
setHintPawn({ id: pawn._id, position: pawnPosition, color: 'grey' });
break;
}
} else {
setHintPawn(null);
}
}
}
}
};
const rerenderCanvas = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = new Image();
image.src = 'https://img-9gag-fun.9cache.com/photo/a8GdpYZ_460s.jpg';
image.onload = function () {
ctx.drawImage(image, 0, 0);
pawns.forEach((pawn, index) => {
pawns[index].circle = paintPawn(
ctx,
positions[pawn.position].x,
positions[pawn.position].y,
pawn.color
);
});
if (hintPawn) {
paintPawn(ctx, positions[hintPawn.position].x, positions[hintPawn.position].y, hintPawn.color);
}
};
}, [checkIfPawnCanMove, context.color, hintPawn, nowMoving, pawns, rolledNumber]);
// Rerender canvas when pawns have changed
useEffect(() => {
rerenderCanvas();
}, [hintPawn, pawns, rerenderCanvas]);
useEffect(() => {
socket.on('game:move', () => {
setHintPawn(null);
});
socket.on('game:roll', () => {
setHintPawn(null);
});
}, [socket]);
return (
<canvas
className='canvas-container'
width={460}
height={460}
ref={canvasRef}
onClick={handleCanvasClick}
onMouseMove={handleMouseMove}
/>
);
};
export default Map;

View File

@ -1,58 +0,0 @@
import React, { useState, useEffect, useContext } from 'react';
import PropTypes from 'prop-types';
import { SocketContext } from '../../App';
/*
Component responsible for:
- displaying the player's name
- informing players about the readiness of other players by changing the color of container from gray to the player's color
- counting time to the end of the move
Props:
- player (object):
The player to whom the container belongs
Player's properties used in this component:
- ready (boolean):
is the player ready for the start of the game, if so, change color from gray to the player's color
when the game is started all players are ready not matter if they clicked ready button before
- nowMoving (boolean) is this player move now, if true display timer
- name (string)
- time (number) - time remaining until the move is made in milliseconds
*/
const NameContainer = ({ player, time }) => {
const [remainingTime, setRemainingTime] = useState();
const socket = useContext(SocketContext);
// Function responsible for counting down to the end of time every second
const countdown = () => {
setRemainingTime(Math.ceil((time - Date.now()) / 1000));
};
useEffect(() => {
// Starts the countdown from the beginning if the server returned information about skipping the turn
socket.on('game:skip', () => {
setRemainingTime(15);
});
setRemainingTime(Math.ceil((time - Date.now()) / 1000));
const interval = setInterval(countdown, 1000);
return () => clearInterval(interval);
}, [countdown]);
return (
<div
className='name-container'
style={player.ready ? { backgroundColor: player.color } : { backgroundColor: 'lightgrey' }}
>
<p>{player.name}</p>
{player.nowMoving ? <div className='timer'> {remainingTime} </div> : null}
</div>
);
};
NameContainer.propTypes = {
player: PropTypes.object,
time: PropTypes.number,
};
export default NameContainer;

View File

@ -0,0 +1,15 @@
import bluePawn from '../images/pawns/blue-pawn.png';
import greenPawn from '../images/pawns/green-pawn.png';
import redPawn from '../images/pawns/red-pawn.png';
import yellowPawn from '../images/pawns/yellow-pawn.png';
import greyPawn from '../images/pawns/grey-pawn.png';
const pawnImages = {
green: greenPawn,
blue: bluePawn,
red: redPawn,
yellow: yellowPawn,
grey: greyPawn,
};
export default pawnImages;

117
src/constants/positions.js Normal file
View File

@ -0,0 +1,117 @@
const positions = [
// Red base
{ x: 67, y: 67 }, // 0
{ x: 67, y: 116 },
{ x: 117, y: 67 },
{ x: 117, y: 116 },
// Blue base
{ x: 67, y: 343 },
{ x: 67, y: 392 },
{ x: 117, y: 343 },
{ x: 117, y: 392 },
// Green base
{ x: 343, y: 343 },
{ x: 392, y: 392 },
{ x: 392, y: 343 }, // 10
{ x: 343, y: 392 },
// Yellow base
{ x: 343, y: 67 },
{ x: 392, y: 116 },
{ x: 392, y: 67 },
{ x: 343, y: 116 },
// Map - starting from red field
{ x: 45, y: 200 },
{ x: 76, y: 200 },
{ x: 107, y: 200 },
{ x: 138, y: 200 },
{ x: 169, y: 200 }, // 20
{ x: 200, y: 169 },
{ x: 200, y: 138 },
{ x: 200, y: 107 },
{ x: 200, y: 76 },
{ x: 200, y: 45 },
{ x: 200, y: 14 },
// Top
{ x: 230, y: 14 },
{ x: 261, y: 14 },
{ x: 261, y: 45 },
{ x: 261, y: 76 }, // 30
{ x: 261, y: 107 },
{ x: 261, y: 138 },
{ x: 261, y: 169 },
{ x: 291, y: 200 },
{ x: 321, y: 200 },
{ x: 352, y: 200 },
{ x: 383, y: 200 },
{ x: 414, y: 200 },
{ x: 445, y: 200 },
// Right
{ x: 445, y: 230 }, // 40
{ x: 445, y: 261 },
{ x: 414, y: 261 },
{ x: 383, y: 261 },
{ x: 352, y: 261 },
{ x: 321, y: 261 },
{ x: 291, y: 261 },
{ x: 261, y: 291 },
{ x: 261, y: 322 },
{ x: 261, y: 353 },
{ x: 261, y: 384 }, // 50
{ x: 261, y: 414 },
{ x: 261, y: 445 },
// Bottom
{ x: 230, y: 445 },
{ x: 200, y: 445 },
{ x: 200, y: 414 },
{ x: 200, y: 384 },
{ x: 200, y: 353 },
{ x: 200, y: 322 },
{ x: 200, y: 291 },
{ x: 169, y: 261 }, // 60
{ x: 138, y: 261 },
{ x: 107, y: 261 },
{ x: 76, y: 261 },
{ x: 45, y: 261 },
{ x: 15, y: 261 },
// Left
{ x: 15, y: 231 }, // 66
// One behind red base
{ x: 15, y: 200 }, //67
// Red end
{ x: 45, y: 231 }, // 68
{ x: 76, y: 231 },
{ x: 107, y: 231 },
{ x: 138, y: 231 },
{ x: 169, y: 231 },
{ x: 200, y: 231 }, // 73
// Blue end
{ x: 231, y: 414 }, // 74
{ x: 231, y: 384 },
{ x: 231, y: 353 },
{ x: 231, y: 322 },
{ x: 231, y: 291 },
{ x: 231, y: 260 }, // 79
// Green end
{ x: 414, y: 231 }, // 80
{ x: 383, y: 231 },
{ x: 352, y: 231 },
{ x: 321, y: 231 },
{ x: 290, y: 231 },
{ x: 259, y: 231 }, // 85
// Yellow base
{ x: 230, y: 45 }, // 86
{ x: 230, y: 76 },
{ x: 230, y: 107 },
{ x: 230, y: 138 },
{ x: 230, y: 169 },
{ x: 230, y: 200 }, // 91
];
export default positions;

11
src/hooks/useInput.js Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
src/images/map.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 408 KiB

View File

@ -6,13 +6,16 @@ body {
rgba(0, 138, 255, 1) 16%,
rgba(9, 9, 121, 1) 81%
);
overflow: hidden;
}
#root {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
.canvas-container {
margin: 10px;
height: 100vh;
width: 100vw;
}
canvas {
border-radius: 15px;
border: 2px solid black;
@ -25,12 +28,14 @@ canvas {
display: flex;
flex-direction: row;
}
.navbar-container > div {
margin-right: 10px;
}
.name-container {
width: 100px;
height: 50px;
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;
@ -49,9 +54,33 @@ canvas {
height: 20px;
border-radius: 5px;
}
.overlay {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0.9;
animation: timerAnimation 15s linear infinite;
transition-duration: 15s;
}
#root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container {
display: grid;
align-items: center;
justify-items: center;
grid-template-columns: 230px 230px;
grid-template-rows: 50px 250px 250px 50px;
}
.canvas-container {
place-self: center;
grid-column: 1 / span 2;
grid-row: 2 / span 2;
}

View File

@ -1,11 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container);
root.render(<App />);