diff --git a/.env.example b/.env.example index ac43791..4130876 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,7 @@ MAIL_PORT=2500 GOOGLE_OAUTH_CLIENT_ID=your_google_oauth_client_id GOOGLE_OAUTH_CLIENT_SECRET=your_google_oauth_client_secret GOOGLE_ANALITICS_ID=your_google_analytics_id +MULTI_INTERNAL_HOST=multi +MULTI_INTERNAL_PORT=5000 +MULTI_WS_HOST=mapguesser-dev.ch +MULTI_WS_PORT=8090 diff --git a/.gitignore b/.gitignore index 9bdf82a..2cdd4fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env installed vendor +node_modules diff --git a/.vscode/launch.json b/.vscode/launch.json index eafbd3f..aa7ba88 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,6 +9,14 @@ "pathMappings": { "/var/www/mapguesser": "${workspaceRoot}", } + }, + { + "name": "Listen for NodeJS Inspector in Docker", + "type": "node", + "request": "attach", + "port": 9229, + "localRoot": "${workspaceFolder}", + "remoteRoot": "/var/www/mapguesser" } ] } diff --git a/composer.json b/composer.json index b542b88..752c079 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "require": { "vlucas/phpdotenv": "^4.1", "symfony/console": "^5.1", - "phpmailer/phpmailer": "^6.1" + "phpmailer/phpmailer": "^6.1", + "fzaninotto/faker": "^1.9" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index c4da58a..c736b8e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34563bfc619f47473b2e37a5639dd63e", + "content-hash": "b71c0ffc0761a6b90f6242346b735a09", "packages": [ + { + "name": "fzaninotto/faker", + "version": "v1.9.2", + "source": { + "type": "git", + "url": "https://github.com/fzaninotto/Faker.git", + "reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/848d8125239d7dbf8ab25cb7f054f1a630e68c2e", + "reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "ext-intl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7", + "squizlabs/php_codesniffer": "^2.9.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/fzaninotto/Faker/issues", + "source": "https://github.com/fzaninotto/Faker/tree/v1.9.2" + }, + "abandoned": true, + "time": "2020-12-11T09:56:16+00:00" + }, { "name": "phpmailer/phpmailer", "version": "v6.1.6", @@ -2710,5 +2765,5 @@ "prefer-lowest": false, "platform": [], "platform-dev": [], - "plugin-api-version": "1.1.0" + "plugin-api-version": "2.0.0" } diff --git a/database/migrations/structure/20210318_2136_multiplayer.sql b/database/migrations/structure/20210318_2136_multiplayer.sql new file mode 100644 index 0000000..66887ad --- /dev/null +++ b/database/migrations/structure/20210318_2136_multiplayer.sql @@ -0,0 +1,9 @@ +CREATE TABLE `multi_rooms` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `room_id` varchar(6) NOT NULL, + `state` text NOT NULL, + `members` text NOT NULL, + `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `room_id` (`room_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; diff --git a/docker-compose.yml b/docker-compose.yml index ee933bc..16a2306 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,23 @@ version: '3' services: app: - build: ./docker + build: + context: ./docker + dockerfile: Dockerfile-app ports: - 80:80 volumes: - .:/var/www/mapguesser - links: - - 'mariadb' - - 'mail' + multi: + build: + context: ./docker + dockerfile: Dockerfile-multi + ports: + - 5000:5000 + - 8090:8090 + - 9229:9229 + volumes: + - .:/var/www/mapguesser mariadb: image: mariadb:10.3 ports: diff --git a/docker/Dockerfile b/docker/Dockerfile-app similarity index 100% rename from docker/Dockerfile rename to docker/Dockerfile-app diff --git a/docker/Dockerfile-multi b/docker/Dockerfile-multi new file mode 100644 index 0000000..5092616 --- /dev/null +++ b/docker/Dockerfile-multi @@ -0,0 +1,16 @@ +FROM ubuntu:focal + +ENV DEBIAN_FRONTEND noninteractive + +# Install necessary packages +RUN apt update --fix-missing +RUN apt install -y curl build-essential + +# Install Node.js and required packages +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - +RUN apt install -y nodejs + +VOLUME /var/www/mapguesser +WORKDIR /var/www/mapguesser + +ENTRYPOINT /usr/bin/node --inspect=0.0.0.0:9229 multi diff --git a/multi/index.js b/multi/index.js new file mode 100644 index 0000000..eb529be --- /dev/null +++ b/multi/index.js @@ -0,0 +1,292 @@ +'use strict'; + +process.title = 'mapguesser-multi'; + +class State { + static OPEN = 1; + static PLACE_RECEIVED = 2; + static GUESS_SENT = 3; +} + +class MultiGame { + constructor() { + this.rooms = new Map(); + } + + cleanupRooms() { + this.rooms.forEach(function (room, roomId) { + var lastValidDate = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000); + + if (room.updated < lastValidDate) { + this.rooms.delete(roomId); + } + }); + } + + connectToRoom(roomId, token, connection) { + if (!this.rooms.has(roomId) || !this.rooms.get(roomId).members.has(token)) { + return; + } + + var room = this.rooms.get(roomId) + var member = room.members.get(token); + member.connection = connection; + + this._sendInitialData(room, member); + } + + createRoom(roomId) { + this.rooms.set(roomId, { members: new Map(), rounds: [], currentRound: -1, updated: new Date() }); + } + + joinRoom(roomId, token, userName) { + if (!this.rooms.has(roomId)) { + console.error('Room does not exist!') + return; + } + + var room = this.rooms.get(roomId); + room.updated = new Date(); + + if (room.members.has(token)) { + return; + } + + var data = { userName: userName }; + var self = this; + room.members.forEach(function (member) { + self._sendToMember(member, 'member_joined', data); + }); + + room.members.set(token, { userName: userName, state: State.OPEN, connection: null }); + } + + startGame(roomId, places) { + if (!this.rooms.has(roomId)) { + //TODO: send something back + console.log('Room does not exist!') + return; + } + + var room = this.rooms.get(roomId); + room.updated = new Date(); + + var rounds = []; + places.forEach(function (place) { + rounds.push({ place: place, results: new Map() }) + }); + + room.rounds = rounds; + + this.nextRound(roomId, 0); + } + + guess(roomId, token, guessPosition, distance, score) { + if (!this.rooms.has(roomId)) { + //TODO: send something back + console.log('Room does not exist!') + return; + } + + var room = this.rooms.get(roomId); + room.updated = new Date(); + + var round = room.rounds[room.currentRound]; + var member = this.rooms.get(roomId).members.get(token); + + this._sendResultsUntilNow(room, member); + + round.results.set(member.userName, { guessPosition: guessPosition, distance: distance, score: score }); + member.state = State.GUESS_SENT; + + this._broadcastGuess(room, member.userName, guessPosition, distance, score); + } + + nextRound(roomId, currentRound) { + if (!this.rooms.has(roomId)) { + //TODO: send something back + console.log('Room does not exist!') + return; + } + + var room = this.rooms.get(roomId); + room.updated = new Date(); + + room.currentRound = currentRound; + + var round = room.rounds[room.currentRound]; + + var data = {}; + data.place = { panoId: round.place.panoId, pov: round.place.pov }; + + if (room.currentRound > 0) { + data.result = { position: room.rounds[room.currentRound - 1].place.position }; + } + + var self = this; + room.members.forEach(function (member) { + self._sendToMember(member, 'new_round', data); + + member.state = State.PLACE_RECEIVED; + }); + } + + _sendInitialData(room, member) { + var data = {}; + + if (room.currentRound >= 0) { + data.place = room.rounds[room.currentRound].place; + } + + data.history = []; + for (var i = 0; i < room.currentRound; ++i) { + var round = room.rounds[i]; + var result; + if (round.results.has(member.userName)) { + result = round.results.get(member.userName); + } else { + result = { guessPosition: null, distance: null, score: 0 }; + } + + data.history.push({ + position: round.place.position, + guessPosition: result.guessPosition, + distance: result.distance, + score: result.score + }); + } + + data.members = []; + room.members.forEach(function (currentMember) { + data.members.push({ userName: currentMember.userName, me: member === currentMember }); + }); + + this._sendToMember(member, 'initialize', data); + } + + _sendResultsUntilNow(room, member) { + if (member.state !== State.GUESS_SENT) { + return; + } + + var round = room.rounds[room.currentRound]; + + var results = []; + round.results.forEach(function (result, userName) { + results.push({ userName: userName, guessPosition: result.guessPosition, distance: result.distance, score: result.score }); + }); + + this._sendToMember(member, 'results', results); + } + + _broadcastGuess(room, userName, guessPosition, distance, score) { + var data = { userName: userName, guessPosition: guessPosition, distance: distance, score: score }; + + room.members.forEach(function (member) { + if (!member.state !== State.GUESS_SENT) { + return; + } + + this._sendToMember(member, 'guess', data); + }); + } + + _sendToMember(member, type, data) { + if (!member.connection) { + return; + } + + if (member.connection.readyState !== ws.OPEN) { + member.connection = null; + return; + } + + member.connection.send(JSON.stringify({ type: type, data: data })); + } +} + +require('dotenv').config(); + +var + net = require('net'), + ws = require('ws'); + +var multiGame = new MultiGame(); + +//TODO: following should be in a separate class/function + +var tcpServer = net.createServer(function (socket) { + socket.on('data', function (data) { + try { + data = JSON.parse(data); + } catch (e) { + console.error('Cannot parse data: ' + data); + return; + } + + switch (data.func) { + case 'create_room': + multiGame.createRoom(data.args.roomId); + + break; + + case 'join_room': + multiGame.joinRoom(data.args.roomId, data.args.token, data.args.userName); + + break; + + case 'start_game': + multiGame.startGame(data.args.roomId, data.args.places); + + break + + case 'guess': + multiGame.guess(data.args.roomId, data.args.token, data.args.guessPosition, data.args.distance, data.args.score); + + break; + + case 'next_round': + multiGame.nextRound(data.args.roomId, data.args.currentRound); + + break; + } + + socket.write('OK'); + socket.end(); + }); +}); +tcpServer.on('listening', function () { + console.log('[INFO] TCP server started'); +}); +tcpServer.listen(process.env.MULTI_INTERNAL_PORT); + +var wsServer = new ws.Server({ port: process.env.MULTI_WS_PORT }); +wsServer.on('connection', function (connection, request) { + console.log('[INFO] New WS connection: ' + request.connection.remoteAddress); + + connection.on('message', function (data) { + try { + data = JSON.parse(data); + } catch (e) { + console.error('Cannot parse data: ' + data); + return; + } + + switch (data.func) { + case 'connect_to_room': + multiGame.connectToRoom(data.args.roomId, data.args.token, connection); + break; + } + }); + + connection.on('close', function () { + console.log('[INFO] WS connection ended: ' + request.connection.remoteAddress); + }); +}); +wsServer.on('listening', function () { + console.log('[INFO] WS server started'); +}); + +setInterval(function () { + multiGame.cleanupRooms(); +}, 24 * 60 * 60 * 1000); diff --git a/multi/package-lock.json b/multi/package-lock.json new file mode 100644 index 0000000..33779f0 --- /dev/null +++ b/multi/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "mapguesser-multi", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, + "ws": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.4.tgz", + "integrity": "sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==" + } + } +} diff --git a/multi/package.json b/multi/package.json new file mode 100644 index 0000000..132a9a0 --- /dev/null +++ b/multi/package.json @@ -0,0 +1,13 @@ +{ + "name": "mapguesser-multi", + "version": "", + "description": "MapGuesser Application - Multiplayer", + "main": "index.js", + "dependencies": { + "dotenv": "^8.2.0", + "ws": "^7.4.4" + }, + "scripts": {}, + "author": "Pőcze Bence ", + "license": "GNU AGPL 3.0" +} diff --git a/public/static/css/game.css b/public/static/css/game.css index 9887cc7..439676a 100644 --- a/public/static/css/game.css +++ b/public/static/css/game.css @@ -102,6 +102,15 @@ display: none; } +#startMultiGameButton { + display: none; +} + +#players > p { + font-size: 14px; + font-weight: bold; +} + @media screen and (max-width: 599px) { #mapName { display: none; diff --git a/public/static/js/game.js b/public/static/js/game.js index 0cb0616..7f1016f 100644 --- a/public/static/js/game.js +++ b/public/static/js/game.js @@ -5,6 +5,8 @@ NUMBER_OF_ROUNDS: 5, MAX_SCORE: 1000, + mapBounds: null, + multi: { token: null, owner: false }, rounds: [], scoreSum: 0, panoId: null, @@ -15,8 +17,190 @@ adaptGuess: false, googleLink: null, - initialize: function () { + MultiConnector: { + connection: null, + reconnectCounter: 0, + + connect: function () { + if (Game.MultiConnector.connection && Game.MultiConnector.connection.readyState !== WebSocket.CLOSED) { + return; + } + + Game.MultiConnector.connection = new WebSocket((MapGuesser.isSecure ? 'wss' : 'ws') + '://' + multiUrl); + + Game.MultiConnector.connection.onopen = function () { + document.getElementById('loading').style.visibility = 'hidden'; + + Game.MultiConnector.reconnectCounter = 0; + + Game.MultiConnector.connection.send(JSON.stringify({ func: 'connect_to_room', args: { roomId: roomId, token: Game.multi.token } })); + }; + + Game.MultiConnector.connection.onclose = Game.MultiConnector.noConnection; + + Game.MultiConnector.connection.onerror = function (event) { + console.error('WebSocket error in Game.MultiConnector:', event); + }; + + Game.MultiConnector.connection.onmessage = function (message) { + var json; + + try { + json = JSON.parse(message.data); + } catch (e) { + console.error('Cannot parse message!'); + console.error(message.data); + return; + } + + switch (json.type) { + case 'initialize': + Game.MultiConnector.initialize(json.data); + break; + + case 'member_joined': + Game.MultiConnector.memberJoined(json.data); + break; + + case 'new_round': + Game.MultiConnector.newRound(json.data); + break; + + case 'results': + //TODO + break; + + case 'guess': + //TODO + break; + } + }; + }, + + noConnection: function () { + if (Game.MultiConnector.reconnectCounter === 2) { + console.error('Could not reconnect WebSocket for Game.MultiConnector...') + } + + setTimeout(function () { + Game.MultiConnector.reconnectCounter++; + + console.log('Reconnecting WebSocket for Game.MultiConnector... ' + Game.MultiConnector.reconnectCounter); + Game.MultiConnector.connect(); + }, 1000 + Math.min(Game.MultiConnector.reconnectCounter * 500, 9000)); + }, + + initialize: function (data) { + if (data.history) { + for (var i = 0; i < data.history.length; ++i) { + var round = data.history[i]; + Game.rounds.push({ position: round.position, guessPosition: round.guessPosition, realMarker: null, guessMarker: null, line: null }); + Game.addRealGuessPair(round.position, round.guessPosition, true); + Game.scoreSum += round.score; + } + + document.getElementById('currentRound').innerHTML = String(Game.rounds.length) + '/' + String(Game.NUMBER_OF_ROUNDS); + document.getElementById('currentScoreSum').innerHTML = String(Game.scoreSum) + '/' + String(Game.rounds.length * Game.MAX_SCORE); + } + + if (data.place) { + Game.panoId = data.place.panoId; + Game.pov = data.place.pov; + + document.getElementById('panoCover').style.visibility = 'hidden'; + MapGuesser.hideModal(); + + Game.startNewRound(); + } + + document.getElementById('loading').style.visibility = 'hidden'; + + var div = document.getElementById('players'); + + for (var i = 0; i < data.members.length; ++i) { + var member = data.members[i]; + + var p = document.createElement('p'); + p.innerHTML = member.userName + (member.me ? ' (me)' : ''); + div.appendChild(p); + } + }, + + memberJoined: function (data) { + var div = document.getElementById('players'); + + var p = document.createElement('p'); + p.innerHTML = data.userName; + div.appendChild(p); + }, + + newRound: function (data) { + //TODO: workaround until results are not sent + if (Game.adaptGuess) { + document.getElementById('guess').classList.remove('adapt'); + } + + if (Game.rounds.length === Game.NUMBER_OF_ROUNDS) { + Game.reset(); + } + + // if player didn't guess - TODO: show everything on a map + if (data.result && Game.rounds.length > 0 && !Game.rounds[Game.rounds.length - 1].position) { + Game.rounds[Game.rounds.length - 1].position = data.result.position; + Game.addRealGuessPair(data.result.position, null); + } + + Game.panoId = data.place.panoId; + Game.pov = data.place.pov; + + MapGuesser.hideModal(); + Game.resetRound(); + Game.startNewRound(); + } + }, + + prepare: function () { + var data = new FormData(); + var userNames; + + if (roomId) { + var userNames = localStorage.userNames ? JSON.parse(localStorage.userNames) : {}; + if (!userNames.hasOwnProperty(roomId)) { + userNames[roomId] = prompt('Your name: '); + localStorage.userNames = JSON.stringify(userNames); + } + + data.append('userName', userNames[roomId]); + } + document.getElementById('loading').style.visibility = 'visible'; + + var url = roomId ? '/multiGame/' + roomId + '/prepare.json' : '/game/' + mapId + '/prepare.json'; + MapGuesser.httpRequest('POST', url, function () { + document.getElementById('loading').style.visibility = 'hidden'; + + document.getElementById('mapName').innerHTML = this.response.mapName; + Game.mapBounds = this.response.bounds; + + Game.initialize(); + + if (roomId) { + Game.multi.token = this.response.token; + Game.multi.owner = this.response.owner; + + MapGuesser.showModal('multi'); + if (Game.multi.owner) { + document.getElementById('startMultiGameButton').style.display = 'block'; + } + + document.getElementById('loading').style.visibility = 'visible'; + + Game.MultiConnector.connect(); + } + }, data); + }, + + initialize: function () { document.getElementById('panoCover').style.visibility = 'visible'; document.getElementById('currentRound').innerHTML = '1/' + String(Game.NUMBER_OF_ROUNDS); document.getElementById('currentScoreSum').innerHTML = '0/0'; @@ -24,9 +208,16 @@ Game.map.setOptions({ draggableCursor: 'crosshair' }); - Game.map.fitBounds(mapBounds); + Game.map.fitBounds(Game.mapBounds); - MapGuesser.httpRequest('GET', '/game/' + mapId + '/initialData.json', function () { + if (roomId) { + // if it is multiplayer mode, data is sent via WS + return; + } + + document.getElementById('loading').style.visibility = 'visible'; + + MapGuesser.httpRequest('POST', '/game/' + mapId + '/initialData.json', function () { document.getElementById('loading').style.visibility = 'hidden'; document.getElementById('panoCover').style.visibility = 'hidden'; @@ -63,8 +254,10 @@ for (var i = 0; i < Game.rounds.length; ++i) { var round = Game.rounds[i]; - if (round.realMarker && round.guessMarker && round.line) { + if (round.realMarker) { round.realMarker.setMap(null); + } + if (round.guessMarker) { round.guessMarker.setMap(null); round.line.setMap(null); } @@ -96,8 +289,10 @@ var lastRound = Game.rounds[Game.rounds.length - 1]; lastRound.realMarker.setVisible(false); - lastRound.guessMarker.setVisible(false); - lastRound.line.setVisible(false); + if (lastRound.guessMarker) { + lastRound.guessMarker.setVisible(false); + lastRound.line.setVisible(false); + } } document.getElementById('panoCover').style.visibility = 'hidden'; @@ -108,7 +303,12 @@ Game.map.setOptions({ draggableCursor: 'crosshair' }); - Game.map.fitBounds(mapBounds); + Game.map.fitBounds(Game.mapBounds); + + if (roomId) { + // if it is multiplayer mode, data is sent via WS + return; + } Game.startNewRound(); }, @@ -124,8 +324,14 @@ handleErrorResponse: function (error) { // for the time being we only handle the "no_session_found" error and reset the game - MapGuesser.httpRequest('GET', '/game/' + mapId + '/json', function () { - mapBounds = this.response.bounds; + if (roomId) { + //TODO: better error message + alert('Your session is invalid, please start multiplayer again!') + return; + } + + MapGuesser.httpRequest('GET', '/game/' + mapId + '/prepare.json', function () { + Game.mapBounds = this.response.bounds; Game.reset(); }); @@ -160,7 +366,8 @@ data.append('lat', String(guessPosition.lat)); data.append('lng', String(guessPosition.lng)); - MapGuesser.httpRequest('POST', '/game/' + mapId + '/guess.json', function () { + var url = roomId ? '/multiGame/' + roomId + '/guess.json' : '/game/' + mapId + '/guess.json'; + MapGuesser.httpRequest('POST', url, function () { if (this.response.error) { Game.handleErrorResponse(this.response.error); return; @@ -204,6 +411,10 @@ Game.panoId = this.response.place.panoId; Game.pov = this.response.place.pov; } else { + if (!Game.multi.owner) { + //TODO: "waiting for" disabled button + document.getElementById('continueButton').style.display = 'none'; + } Game.panoId = null; Game.pov = null; } @@ -233,6 +444,10 @@ window.open('https://www.google.com/maps/search/?api=1&query=' + this.getPosition().toUrlValue(), '_blank'); }); + if (!guessPosition) { + return; + } + round.guessMarker = new google.maps.Marker({ map: Game.map, visible: !hidden, @@ -304,7 +519,10 @@ scoreInfo.children[0].style.display = 'none'; scoreInfo.children[1].style.display = 'block'; document.getElementById('showSummaryButton').style.display = null; - document.getElementById('startNewGameButton').style.display = 'block'; + + if (Game.multi.owner) { + document.getElementById('startNewGameButton').style.display = 'block'; + } var resultBounds = new google.maps.LatLngBounds(); @@ -325,12 +543,17 @@ fontWeight: '500', text: String(i + 1) }); + round.realMarker.setVisible(true); - round.guessMarker.setVisible(true); - round.line.setVisible(true); + if (round.guessMarker) { + round.guessMarker.setVisible(true); + round.line.setVisible(true); + } resultBounds.extend(round.position); - resultBounds.extend(round.guessPosition); + if (round.guessMarker) { + resultBounds.extend(round.guessPosition); + } } Game.map.fitBounds(resultBounds); @@ -378,13 +601,7 @@ } }; - MapGuesser.sessionAvailableHooks.reinitializeGame = function () { - MapGuesser.httpRequest('GET', '/game/' + mapId + '/json', function () { - mapBounds = this.response.bounds; - - Game.initialize(); - }); - }; + MapGuesser.sessionAvailableHooks.reinitializeGame = Game.prepare; if (!('ontouchstart' in document.documentElement)) { Game.adaptGuess = true; @@ -445,7 +662,9 @@ Game.rewriteGoogleLink(); }); - Game.initialize(); + if (COOKIES_CONSENT) { + Game.prepare(); + } document.getElementById('showGuessButton').onclick = function () { this.style.visibility = 'hidden'; @@ -462,7 +681,19 @@ } document.getElementById('continueButton').onclick = function () { - Game.resetRound(); + if (roomId) { + if (!Game.multi.owner) { + return; + } + + document.getElementById('loading').style.visibility = 'visible'; + + MapGuesser.httpRequest('POST', '/multiGame/' + roomId + '/nextRound.json', function () { + document.getElementById('loading').style.visibility = 'hidden'; + }); + } else { + Game.resetRound(); + } } document.getElementById('showSummaryButton').onclick = function () { @@ -470,6 +701,31 @@ } document.getElementById('startNewGameButton').onclick = function () { - Game.reset(); + if (roomId) { + if (!Game.multi.owner) { + return; + } + + document.getElementById('loading').style.visibility = 'visible'; + + MapGuesser.httpRequest('POST', '/multiGame/' + roomId + '/initialData.json', function () { + document.getElementById('loading').style.visibility = 'hidden'; + }); + } else { + Game.reset(); + } + } + + document.getElementById('startMultiGameButton').onclick = function () { + if (!roomId || !Game.multi.owner) { + return; + } + + MapGuesser.hideModal(); + document.getElementById('loading').style.visibility = 'visible'; + + MapGuesser.httpRequest('POST', '/multiGame/' + roomId + '/initialData.json', function () { + document.getElementById('loading').style.visibility = 'hidden'; + }); } })(); diff --git a/public/static/js/mapguesser.js b/public/static/js/mapguesser.js index 8af9ffe..89a79a9 100644 --- a/public/static/js/mapguesser.js +++ b/public/static/js/mapguesser.js @@ -1,4 +1,5 @@ var MapGuesser = { + isSecure: window.location.protocol === 'https:', cookiesAgreed: false, sessionAvailableHooks: {}, diff --git a/public/static/js/maps.js b/public/static/js/maps.js index 394c6c8..6d4199f 100644 --- a/public/static/js/maps.js +++ b/public/static/js/maps.js @@ -73,4 +73,40 @@ window.onresize = function () { Maps.calculateDescriptionDivHeights(); }; + + document.getElementById('multiForm').onsubmit = function (e) { + e.preventDefault(); + + var roomId = this.elements.roomId.value; + if (roomId.length !== 6) { + return; + } + + window.location.href = '/multiGame/' + this.elements.roomId.value; + }; + + document.getElementById('multiButton').onclick = function () { + MapGuesser.showModal('multi'); + document.getElementById('createNewRoomButton').href = '/multiGame/new/' + this.dataset.mapId; + document.getElementById('multiForm').elements.roomId.select(); + } + + document.getElementById('closePlayModeButton').onclick = function () { + MapGuesser.hideModal(); + }; + + document.getElementById('closeMultiButton').onclick = function () { + MapGuesser.hideModal(); + }; + + var buttons = document.getElementById('mapContainer').getElementsByClassName('playButton'); + for (var i = 0; i < buttons.length; i++) { + var button = buttons[i]; + + button.onclick = function () { + MapGuesser.showModal('playMode'); + document.getElementById('singleButton').href = '/game/' + this.dataset.mapId; + document.getElementById('multiButton').dataset.mapId = this.dataset.mapId; + }; + } })(); diff --git a/scripts/install.sh b/scripts/install.sh index 2ba224f..5aeabf0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,6 +9,9 @@ if [ -f ${ROOT_DIR}/installed ]; then exit 1 fi +echo "Installing NPM packages..." +(cd ${ROOT_DIR}/multi && npm install) + echo "Installing Yarn packages..." (cd ${ROOT_DIR}/public/static && yarn install) diff --git a/scripts/update.sh b/scripts/update.sh index 2bd04b6..f82d649 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -11,6 +11,9 @@ else (cd ${ROOT_DIR} && composer install --dev) fi +echo "Installing NPM packages..." +(cd ${ROOT_DIR}/multi && npm install) + echo "Installing Yarn packages..." (cd ${ROOT_DIR}/public/static && yarn install) diff --git a/src/Cli/MaintainDatabaseCommand.php b/src/Cli/MaintainDatabaseCommand.php index ccb5b59..b622204 100644 --- a/src/Cli/MaintainDatabaseCommand.php +++ b/src/Cli/MaintainDatabaseCommand.php @@ -5,6 +5,7 @@ use MapGuesser\Database\Query\Modify; use MapGuesser\Database\Query\Select; use MapGuesser\Interfaces\Database\IResultSet; use MapGuesser\PersistentData\PersistentDataManager; +use MapGuesser\Repository\MultiRoomRepository; use MapGuesser\Repository\UserConfirmationRepository; use MapGuesser\Repository\UserPasswordResetterRepository; use MapGuesser\Repository\UserRepository; @@ -22,6 +23,8 @@ class MaintainDatabaseCommand extends Command private UserPasswordResetterRepository $userPasswordResetterRepository; + private MultiRoomRepository $multiRoomRepository; + public function __construct() { parent::__construct(); @@ -30,6 +33,7 @@ class MaintainDatabaseCommand extends Command $this->userRepository = new UserRepository(); $this->userConfirmationRepository = new UserConfirmationRepository(); $this->userPasswordResetterRepository = new UserPasswordResetterRepository(); + $this->multiRoomRepository = new MultiRoomRepository(); } public function configure(): void @@ -43,6 +47,7 @@ class MaintainDatabaseCommand extends Command try { $this->deleteInactiveExpiredUsers(); $this->deleteExpiredPasswordResetters(); + $this->deleteExpiredRooms(); $this->deleteExpiredSessions(); } catch (\Exception $e) { $output->writeln('Maintenance failed!'); @@ -89,6 +94,13 @@ class MaintainDatabaseCommand extends Command } } + private function deleteExpiredRooms(): void + { + foreach ($this->multiRoomRepository->getAllExpired() as $multiRoom) { + $this->pdm->deleteFromDb($multiRoom); + } + } + private function deleteExpiredSessions(): void { //TODO: model may be used for sessions too diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index 8149d7c..bd9a281 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -1,20 +1,37 @@ request = $request; + $this->pdm = new PersistentDataManager(); + $this->multiConnector = new MultiConnector(); + $this->multiRoomRepository = new MultiRoomRepository(); $this->mapRepository = new MapRepository(); } @@ -22,18 +39,49 @@ class GameController { $mapId = (int) $this->request->query('mapId'); - return new HtmlContent('game', $this->prepareGame($mapId)); + return new HtmlContent('game', ['mapId' => $mapId]); } - public function getGameJson(): IContent + public function getNewMultiGame(): IRedirect { $mapId = (int) $this->request->query('mapId'); + $map = $this->mapRepository->getById($mapId); + $roomId = bin2hex(random_bytes(3)); + $token = $this->getMultiToken($roomId); - return new JsonContent($this->prepareGame($mapId)); + $room = new MultiRoom(); + $room->setRoomId($roomId); + $room->setStateArray([ + 'mapId' => $mapId, + 'area' => $map->getArea(), + 'rounds' => [], + 'currentRound' => -1 + ]); + $room->setMembersArray(['owner' => $token, 'all' => []]); + $room->setUpdatedDate(new DateTime()); + + $this->pdm->saveToDb($room); + + $this->multiConnector->sendMessage('create_room', ['roomId' => $roomId]); + + return new Redirect( + \Container::$routeCollection + ->getRoute('multiGame') + ->generateLink(['roomId' => $roomId]), + IRedirect::TEMPORARY + ); } - private function prepareGame(int $mapId): array + public function getMultiGame() { + $roomId = $this->request->query('roomId'); + + return new HtmlContent('game', ['roomId' => $roomId]); + } + + public function prepareGame(int $mapId): IContent + { + $mapId = (int) $this->request->query('mapId'); $map = $this->mapRepository->getById($mapId); $session = $this->request->session(); @@ -46,6 +94,73 @@ class GameController ]); } - return ['mapId' => $mapId, 'mapName' => $map->getName(), 'bounds' => $map->getBounds()->toArray()]; + return new JsonContent([ + 'mapId' => $mapId, + 'mapName' => $map->getName(), + 'bounds' => $map->getBounds()->toArray() + ]); + } + + public function prepareMultiGame(): IContent + { + $roomId = $this->request->query('roomId'); + $userName = $this->request->post('userName'); + if (empty($userName)) { + $faker = Factory::create(); + $userName = $faker->userName; + } + + $room = $this->multiRoomRepository->getByRoomId($roomId); + $state = $room->getStateArray(); + $map = $this->mapRepository->getById($state['mapId']); + $token = $this->getMultiToken($roomId); + + $members = $room->getMembersArray(); + + if (!in_array($token, $members['all'])) { + if ($state['currentRound'] >= 0) { + return new JsonContent(['error' => 'game_already_started']); + } + + $members['all'][] = $token; + } + + $room->setMembersArray($members); + $room->setUpdatedDate(new DateTime()); + + $this->pdm->saveToDb($room); + + $this->multiConnector->sendMessage('join_room', [ + 'roomId' => $roomId, + 'token' => $token, + 'userName' => $userName + ]); + + return new JsonContent([ + 'roomId' => $roomId, + 'token' => $token, + 'owner' => $members['owner'] == $token, + 'mapId' => $state['mapId'], + 'mapName' => $map->getName(), + 'bounds' => $map->getBounds()->toArray() + ]); + } + + private function getMultiToken(string $roomId, bool $forceNew = false) + { + $session = $this->request->session(); + + if (!($multiState = $session->get('multiState')) || $multiState['roomId'] !== $roomId) { + $token = bin2hex(random_bytes(16)); + + $session->set('multiState', [ + 'roomId' => $roomId, + 'token' => $token + ]); + } else { + $token = $multiState['token']; + } + + return $token; } } diff --git a/src/Controller/GameFlowController.php b/src/Controller/GameFlowController.php index e5bdc9b..8a02fbe 100644 --- a/src/Controller/GameFlowController.php +++ b/src/Controller/GameFlowController.php @@ -1,9 +1,13 @@ request = $request; + $this->pdm = new PersistentDataManager(); + $this->multiConnector = new MultiConnector(); + $this->multiRoomRepository = new MultiRoomRepository(); $this->placeRepository = new PlaceRepository(); } - public function getInitialData(): IContent + public function initialData(): IContent { $mapId = (int) $this->request->query('mapId'); $session = $this->request->session(); @@ -32,6 +45,7 @@ class GameFlowController if (!isset($state['currentRound']) || $state['currentRound'] == -1 || $state['currentRound'] >= static::NUMBER_OF_ROUNDS) { $this->startNewGame($state, $mapId); + $session->set('state', $state); } $response = []; @@ -56,7 +70,45 @@ class GameFlowController return new JsonContent($response); } - public function evaluateGuess(): IContent + public function multiInitialData(): IContent + { + $roomId = $this->request->query('roomId'); + $session = $this->request->session(); + + if (!($multiState = $session->get('multiState')) || $multiState['roomId'] !== $roomId) { + return new JsonContent(['error' => 'no_session_found']); + } + + $room = $this->multiRoomRepository->getByRoomId($roomId); + $state = $room->getStateArray(); + $members = $room->getMembersArray(); + + if ($members['owner'] !== $multiState['token']) { + return new JsonContent(['error' => 'not_owner_of_room']); + } + + if ($state['currentRound'] == -1 || $state['currentRound'] >= static::NUMBER_OF_ROUNDS - 1) { + $this->startNewGame($state, $state['mapId']); + $room->setStateArray($state); + $room->setUpdatedDate(new DateTime()); + $this->pdm->saveToDb($room); + } + + $places = []; + foreach ($state['rounds'] as $round) { + $places[] = [ + 'position' => $round['position']->toArray(), + 'panoId' => $round['panoId'], + 'pov' => $round['pov']->toArray() + ]; + } + + $this->multiConnector->sendMessage('start_game', ['roomId' => $roomId, 'places' => $places]); + + return new JsonContent(['ok' => true]); + } + + public function guess(): IContent { $mapId = (int) $this->request->query('mapId'); $session = $this->request->session(); @@ -66,28 +118,23 @@ class GameFlowController } $last = $state['rounds'][$state['currentRound']]; - - $position = $last['position']; $guessPosition = new Position((float) $this->request->post('lat'), (float) $this->request->post('lng')); - - $distance = $this->calculateDistance($position, $guessPosition); - $score = $this->calculateScore($distance, $state['area']); + $result = $this->evalueteGuess($last['position'], $guessPosition, $state['area']); $last['guessPosition'] = $guessPosition; - $last['distance'] = $distance; - $last['score'] = $score; - - $state['rounds'][$state['currentRound']] = $last; - $state['currentRound'] += 1; + $last['distance'] = $result['distance']; + $last['score'] = $result['score']; $response = [ 'result' => [ - 'position' => $position->toArray(), - 'distance' => $distance, - 'score' => $score + 'position' => $last['position']->toArray(), + 'distance' => $result['distance'], + 'score' => $result['score'] ] ]; + $state['rounds'][$state['currentRound']] = $last; + $state['currentRound'] += 1; if ($state['currentRound'] < static::NUMBER_OF_ROUNDS) { $next = $state['rounds'][$state['currentRound']]; @@ -102,7 +149,79 @@ class GameFlowController return new JsonContent($response); } - private function startNewGame(array &$state, int $mapId) + public function multiGuess(): IContent + { + $roomId = $this->request->query('roomId'); + $session = $this->request->session(); + + if (!($multiState = $session->get('multiState')) || $multiState['roomId'] !== $roomId) { + return new JsonContent(['error' => 'no_session_found']); + } + + $room = $this->multiRoomRepository->getByRoomId($roomId); + $state = $room->getStateArray(); + + $last = $state['rounds'][$state['currentRound']]; + $guessPosition = new Position((float) $this->request->post('lat'), (float) $this->request->post('lng')); + $result = $this->evalueteGuess($last['position'], $guessPosition, $state['area']); + + $this->multiConnector->sendMessage('guess', [ + 'roomId' => $roomId, + 'token' => $multiState['token'], + 'guess' => $guessPosition->toArray(), + 'distance' => $result['distance'], + 'score' => $result['score'] + ]); + + $response = [ + 'result' => [ + 'position' => $last['position']->toArray(), + 'distance' => $result['distance'], + 'score' => $result['score'] + ] + ]; + + return new JsonContent($response); + } + + public function multiNextRound(): IContent + { + $roomId = $this->request->query('roomId'); + $session = $this->request->session(); + + if (!($multiState = $session->get('multiState')) || $multiState['roomId'] !== $roomId) { + return new JsonContent(['error' => 'no_session_found']); + } + + $room = $this->multiRoomRepository->getByRoomId($roomId); + $state = $room->getStateArray(); + $members = $room->getMembersArray(); + + if ($members['owner'] !== $multiState['token']) { + return new JsonContent(['error' => 'not_owner_of_room']); + } + + $state['currentRound'] += 1; + if ($state['currentRound'] < static::NUMBER_OF_ROUNDS) { + $this->multiConnector->sendMessage('next_round', ['roomId' => $roomId, 'currentRound' => $state['currentRound']]); + } + + $room->setStateArray($state); + $room->setUpdatedDate(new DateTime()); + $this->pdm->saveToDb($room); + + return new JsonContent(['ok' => true]); + } + + private function evalueteGuess(Position $realPosition, Position $guessPosition, float $area) + { + $distance = $this->calculateDistance($realPosition, $guessPosition); + $score = $this->calculateScore($distance, $area); + + return ['distance' => $distance, 'score' => $score]; + } + + private function startNewGame(array &$state, int $mapId): void { $places = $this->placeRepository->getRandomNForMapWithValidPano($mapId, static::NUMBER_OF_ROUNDS); @@ -117,8 +236,6 @@ class GameFlowController 'pov' => $place->getPov() ]; } - - $this->request->session()->set('state', $state); } private function calculateDistance(Position $realPosition, Position $guessPosition): float diff --git a/src/Multi/MultiConnector.php b/src/Multi/MultiConnector.php new file mode 100644 index 0000000..d74535e --- /dev/null +++ b/src/Multi/MultiConnector.php @@ -0,0 +1,24 @@ + $func, + 'args' => $args + ]); + + $connection = fsockopen($_ENV['MULTI_INTERNAL_HOST'], $_ENV['MULTI_INTERNAL_PORT']); + fwrite($connection, $message); + $response = ''; + while (!feof($connection)) { + $response .= fgets($connection); + } + fclose($connection); + + if ($response !== 'OK') { + throw new \Exception('Sending message failed with response: ' . $response); + } + } +} diff --git a/src/PersistentData/Model/MultiRoom.php b/src/PersistentData/Model/MultiRoom.php new file mode 100644 index 0000000..9cc137b --- /dev/null +++ b/src/PersistentData/Model/MultiRoom.php @@ -0,0 +1,88 @@ +roomId = $roomId; + } + + public function setStateArray(array $state): void + { + $this->state = $state; + } + + public function setMembersArray(array $members): void + { + $this->members = $members; + } + + public function setState(string $state): void + { + $this->state = unserialize($state); + } + + public function setMembers(string $members): void + { + $this->members = unserialize($members); + } + + public function setUpdatedDate(DateTime $updated): void + { + $this->updated = $updated; + } + + public function setUpdated(string $updated): void + { + $this->updated = new DateTime($updated); + } + + public function getRoomId(): string + { + return $this->roomId; + } + + public function getStateArray(): array + { + return $this->state; + } + + public function getState(): string + { + return serialize($this->state); + } + + public function getMembersArray(): array + { + return $this->members; + } + + public function getMembers(): string + { + return serialize($this->members); + } + + public function getUpdatedDate(): DateTime + { + return $this->updated; + } + + public function getUpdated(): string + { + return $this->updated->format('Y-m-d H:i:s'); + } +} diff --git a/src/Repository/MultiRoomRepository.php b/src/Repository/MultiRoomRepository.php new file mode 100644 index 0000000..86c2590 --- /dev/null +++ b/src/Repository/MultiRoomRepository.php @@ -0,0 +1,38 @@ +pdm = new PersistentDataManager(); + } + + public function getById(int $id): ?MultiRoom + { + return $this->pdm->selectFromDbById($id, MultiRoom::class); + } + + public function getByRoomId(string $roomId): ?MultiRoom + { + $select = new Select(\Container::$dbConnection); + $select->where('room_id', '=', $roomId); + + return $this->pdm->selectFromDb($select, MultiRoom::class); + } + + public function getAllExpired(): Generator + { + $select = new Select(\Container::$dbConnection); + $select->where('updated', '<', (new DateTime('-7 day'))->format('Y-m-d H:i:s')); + + yield from $this->pdm->selectMultipleFromDb($select, MultiRoom::class); + } +} diff --git a/views/game.php b/views/game.php index c772f29..10acef6 100644 --- a/views/game.php +++ b/views/game.php @@ -5,10 +5,19 @@ @extends(templates/layout_full) +@section(pagemodal) + +@endsection + @section(subheader) - Round Score + Round Score @endsection @section(main) @@ -50,7 +59,8 @@ @section(pageScript) @endsection diff --git a/views/maps.php b/views/maps.php index d7b7df1..52822ab 100644 --- a/views/maps.php +++ b/views/maps.php @@ -5,6 +5,32 @@ TODO: condition! @extends(templates/layout_normal) +@section(pagemodal) + + +@endsection + @section(main)
@@ -35,15 +61,15 @@ TODO: condition!

- -
- Play this map +
+ + Edit -
- - Play this map - + + + +
diff --git a/web.php b/web.php index 7357324..215cc95 100644 --- a/web.php +++ b/web.php @@ -50,9 +50,17 @@ Container::$routeCollection->group('account', function (MapGuesser\Routing\Route //Container::$routeCollection->get('maps', 'maps', [MapGuesser\Controller\MapsController::class, 'getMaps']); Container::$routeCollection->group('game', function (MapGuesser\Routing\RouteCollection $routeCollection) { $routeCollection->get('game', '{mapId}', [MapGuesser\Controller\GameController::class, 'getGame']); - $routeCollection->get('game-json', '{mapId}/json', [MapGuesser\Controller\GameController::class, 'getGameJson']); - $routeCollection->get('initialData-json', '{mapId}/initialData.json', [MapGuesser\Controller\GameFlowController::class, 'getInitialData']); - $routeCollection->post('guess-json', '{mapId}/guess.json', [MapGuesser\Controller\GameFlowController::class, 'evaluateGuess']); + $routeCollection->post('game.prepare-json', '{mapId}/prepare.json', [MapGuesser\Controller\GameController::class, 'prepareGame']); + $routeCollection->post('game.initialData-json', '{mapId}/initialData.json', [MapGuesser\Controller\GameFlowController::class, 'initialData']); + $routeCollection->post('game.guess-json', '{mapId}/guess.json', [MapGuesser\Controller\GameFlowController::class, 'guess']); +}); +Container::$routeCollection->group('multiGame', function (MapGuesser\Routing\RouteCollection $routeCollection) { + $routeCollection->get('multiGame.new', 'new/{mapId}', [MapGuesser\Controller\GameController::class, 'getNewMultiGame']); + $routeCollection->get('multiGame', '{roomId}', [MapGuesser\Controller\GameController::class, 'getMultiGame']); + $routeCollection->post('multiGame.prepare-json', '{roomId}/prepare.json', [MapGuesser\Controller\GameController::class, 'prepareMultiGame']); + $routeCollection->post('multiGame.initialData-json', '{roomId}/initialData.json', [MapGuesser\Controller\GameFlowController::class, 'multiInitialData']); + $routeCollection->post('multiGame.nextRound-json', '{roomId}/nextRound.json', [MapGuesser\Controller\GameFlowController::class, 'multiNextRound']); + $routeCollection->post('multiGame.guess-json', '{roomId}/guess.json', [MapGuesser\Controller\GameFlowController::class, 'multiGuess']); }); Container::$routeCollection->group('admin', function (MapGuesser\Routing\RouteCollection $routeCollection) { $routeCollection->get('admin.mapEditor', 'mapEditor/{mapId?}', [MapGuesser\Controller\MapAdminController::class, 'getMapEditor']);