feature/MAPG-235-basic-challenge-mode #48

Merged
balazs merged 43 commits from feature/MAPG-235-basic-challenge-mode into develop 2021-05-28 20:41:09 +02:00
14 changed files with 289 additions and 17 deletions
Showing only changes of commit d7147b30d6 - Show all commits

View File

@ -16,7 +16,7 @@ CREATE TABLE `user_in_challenge` (
`round` smallint(5) signed NOT NULL DEFAULT -1,
`score` int(10) unsigned NOT NULL DEFAULT 0,
`time_left` int(10) unsigned,
`owner` tinyint(1) NOT NULL DEFAULT 0,
`is_owner` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `challenge_id` (`challenge_id`),

View File

@ -75,6 +75,25 @@ div.mapItem>div.buttonContainer {
grid-auto-flow: column;
}
#restrictions {
balazs marked this conversation as resolved Outdated
Outdated
Review

I think some of the rules should be handled on higher level, for example font-family in mapguesser.css line 34,
margin-top and margin-bottom could be handled by classes marginTop and marginBottom, etc.

But it is fine for now because some rules would require refactoring (for example for inputs).

I think some of the rules should be handled on higher level, for example `font-family` in mapguesser.css line 34, `margin-top` and `margin-bottom` could be handled by classes `marginTop` and `marginBottom`, etc. But it is fine for now because some rules would require refactoring (for example for inputs).
margin-top: 1em;
font-family: 'Roboto', sans-serif;
}
#restrictions h3 {
font-weight: 500;
}
#restrictions input[type=checkbox] {
height: auto;
margin: 0.5em;
}
#restrictions input[type=range] {
height: 1.5em;
margin-left: 2em;
}
@media screen and (min-width: 1504px) {
#mapContainer {
grid-template-columns: auto auto auto auto;

View File

@ -1,10 +1,13 @@
'use strict';
balazs marked this conversation as resolved
Review

This file should be refactored sometime because it is very spaghetti :D

This file should be refactored sometime because it is very spaghetti :D
Review

I know, but I already put a lot of changes into this PR. I think we should refactor it in a separate story.

I know, but I already put a lot of changes into this PR. I think we should refactor it in a separate story.
Review

Yes, I meant later, it will be a bigger task.

Yes, I meant later, it will be a bigger task.
const GameType = Object.freeze({'SINGLE': 0, 'MULTI': 1, 'CHALLENGE': 2});
(function () {
var Game = {
NUMBER_OF_ROUNDS: 5,
MAX_SCORE: 1000,
type: GameType.SINGLE,
mapBounds: null,
multi: { token: null, owner: false },
rounds: [],
@ -211,6 +214,19 @@
}
},
getGameIdentifier: function() {
switch(Game.type) {
case GameType.SINGLE:
return '/game/' + mapId;
case GameType.MULTI:
return '/multiGame/' + roomId;
case GameType.CHALLENGE:
return '/challenge/' + challengeToken;
default:
return '/game/' + mapId;
}
},
prepare: function () {
var data = new FormData();
var userNames;
@ -226,7 +242,7 @@
}
document.getElementById('loading').style.visibility = 'visible';
var url = roomId ? '/multiGame/' + roomId + '/prepare.json' : '/game/' + mapId + '/prepare.json';
var url = Game.getGameIdentifier() + '/prepare.json';
MapGuesser.httpRequest('POST', url, function () {
document.getElementById('loading').style.visibility = 'hidden';
@ -269,7 +285,7 @@
}
document.getElementById('loading').style.visibility = 'visible';
MapGuesser.httpRequest('POST', '/game/' + mapId + '/initialData.json', function () {
MapGuesser.httpRequest('POST', Game.getGameIdentifier() + '/initialData.json', function () {
document.getElementById('loading').style.visibility = 'hidden';
document.getElementById('panoCover').style.visibility = 'hidden';
@ -876,6 +892,12 @@
document.getElementById("compass").style.transform = "translateY(-50%) rotate(" + heading + "deg)";
});
if(roomId !== null) {
Game.type = GameType.MULTI;
} else if(challengeToken !== null) {
Game.type = GameType.CHALLENGE;
}
if (COOKIES_CONSENT) {
Game.prepare();
}

View File

@ -85,10 +85,40 @@
window.location.href = '/multiGame/' + this.elements.roomId.value;
};
document.getElementById('challengeForm').onsubmit = function (e) {
e.preventDefault();
var url = '/challenge/create.json';
var formData = new FormData(this);
document.getElementById('loading').style.visibility = 'visible';
MapGuesser.httpRequest('POST', url, function() {
document.getElementById('loading').style.visibility = 'hidden';
if (this.response.error) {
Game.handleErrorResponse(this.response.error);
return;
}
window.location.href = '/challenge/' + this.response.challengeToken;
}, formData);
};
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('playMode').style.visibility = 'hidden';
}
document.getElementById('challengeButton').onclick = function () {
MapGuesser.showModal('challenge');
document.getElementById('createNewChallengeButton').href = '/challenge/new/' + this.dataset.mapId;
document.getElementById('playMode').style.visibility = 'hidden';
var timeLimit = document.getElementById('timeLimit').value;
document.getElementById('timeLimitLabel').innerText = 'Time limit of ' + timeLimit + ' seconds per round';
}
document.getElementById('closePlayModeButton').onclick = function () {
@ -99,6 +129,10 @@
MapGuesser.hideModal();
};
document.getElementById('closeChallengeButton').onclick = function () {
MapGuesser.hideModal();
}
var buttons = document.getElementById('mapContainer').getElementsByClassName('playButton');
for (var i = 0; i < buttons.length; i++) {
var button = buttons[i];
@ -107,6 +141,13 @@
MapGuesser.showModal('playMode');
document.getElementById('singleButton').href = '/game/' + this.dataset.mapId;
document.getElementById('multiButton').dataset.mapId = this.dataset.mapId;
document.getElementById('challengeMapId').value = this.dataset.mapId;
};
}
document.getElementById('timeLimit').oninput = function () {
var timeLimit = document.getElementById('timeLimit').value;
document.getElementById('timeLimitLabel').innerText = 'Time limit of ' + timeLimit + ' seconds per round';
document.getElementById('timerEnabled').checked = true;
}
})();

View File

@ -8,14 +8,22 @@ use MapGuesser\Response\JsonContent;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Interfaces\Response\IRedirect;
use MapGuesser\Multi\MultiConnector;
use MapGuesser\PersistentData\Model\Challenge;
use MapGuesser\PersistentData\Model\MultiRoom;
use MapGuesser\PersistentData\Model\PlaceInChallenge;
use MapGuesser\PersistentData\Model\UserInChallenge;
use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Repository\ChallengeRepository;
use MapGuesser\Repository\MapRepository;
use MapGuesser\Repository\MultiRoomRepository;
use MapGuesser\Repository\PlaceRepository;
use MapGuesser\Repository\UserInChallengeRepository;
use MapGuesser\Response\Redirect;
class GameController
{
const NUMBER_OF_ROUNDS = 5;
private IRequest $request;
private PersistentDataManager $pdm;
@ -26,6 +34,12 @@ class GameController
private MapRepository $mapRepository;
private PlaceRepository $placeRepository;
private ChallengeRepository $challengeRepository;
private UserInChallengeRepository $userInChallengeRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
@ -33,6 +47,9 @@ class GameController
$this->multiConnector = new MultiConnector();
$this->multiRoomRepository = new MultiRoomRepository();
$this->mapRepository = new MapRepository();
$this->placeRepository = new PlaceRepository();
$this->challengeRepository = new ChallengeRepository();
$this->userInChallengeRepository = new UserInChallengeRepository();
}
public function getGame(): IContent
@ -79,6 +96,69 @@ class GameController
return new HtmlContent('game', ['roomId' => $roomId]);
}
public function getChallenge(): IContent
{
$challengeToken = $this->request->query('challengeToken');
return new HtmlContent('game', ['challengeToken' => $challengeToken]);
}
public function createNewChallenge(): IContent
{
// create Challenge
$challengeToken = rand();
$challenge = new Challenge();
$challenge->setToken($challengeToken);
$challenge->setCreatedDate(new DateTime());
if($this->request->post('timerEnabled') !== null && $this->request->post('timeLimit') !== null) {
balazs marked this conversation as resolved Outdated
Outdated
Review

Maybe a do..while would be better here because the token calculation should not be repeated.

        do {
            // if a challenge with the same token already exists
            $challengeToken = rand();
        } while ($this->challengeRepository->getByToken($challengeToken));
Maybe a do..while would be better here because the token calculation should not be repeated. ```php do { // if a challenge with the same token already exists $challengeToken = rand(); } while ($this->challengeRepository->getByToken($challengeToken)); ```

My eye is twitching when variables are used outside of the scope of {}, but it's different in PHP, so it can be refactored.

My eye is twitching when variables are used outside of the scope of {}, but it's different in PHP, so it can be refactored.
$challenge->setTimerSec($this->request->post('timeLimit'));
}
if($this->request->post('noMove') !== null) {
Outdated
Review

I think rand() should be called with explicit arguments, otherwise a number is returned between 0 and getrandmax() and getrandmax() is platform-dependent.

On the other hand maybe the token could be generated as the room ID for multiplayer. Then a fixed length string would be generated - I used bin2hex(random_bytes(3)) for room ID. I guess it was intentional to use integer indexes in the DB, it could be be more efficient but I already used string indexes for other purpose.

I think rand() should be called with explicit arguments, otherwise a number is returned between 0 and getrandmax() and getrandmax() is platform-dependent. On the other hand maybe the token could be generated as the room ID for multiplayer. Then a fixed length string would be generated - I used bin2hex(random_bytes(3)) for room ID. I guess it was intentional to use integer indexes in the DB, it could be be more efficient but I already used string indexes for other purpose.

Good point. However I don't think it would cause any problems. I thought it would be better to use 4 byte length integers instead of 3 bytes for more possible ids, because challenges are normally not getting deleted.

Good point. However I don't think it would cause any problems. I thought it would be better to use 4 byte length integers instead of 3 bytes for more possible ids, because challenges are normally not getting deleted.

I've read your comment again, and I see that bin2hex returns a string. Yes I thought it would perform better just to use an integer instead.
I can also see now, that on Windows the getrandmax() is only 32767, which is far too small. I am going to replace it with mt_rand() if that's fine for you.

I've read your comment again, and I see that bin2hex returns a string. Yes I thought it would perform better just to use an integer instead. I can also see now, that on Windows the getrandmax() is only 32767, which is far too small. I am going to replace it with mt_rand() if that's fine for you.
$challenge->setNoMove(true);
}
if($this->request->post('noPan') !== null) {
$challenge->setNoPan(true);
}
if($this->request->post('noZoom') !== null) {
$challenge->setNoZoom(true);
}
$this->pdm->saveToDb($challenge);
// save owner/creator
$session = $this->request->session();
$userId = $session->get('userId');
$userInChallenge = new UserInChallenge();
$userInChallenge->setUserId($userId);
$userInChallenge->setChallenge($challenge);
$userInChallenge->setTimeLeft($challenge->getTimerSec());
$userInChallenge->setIsOwner(true);
$this->pdm->saveToDb($userInChallenge);
// select places
$mapId = (int) $this->request->post('mapId');
$map = $this->mapRepository->getById($mapId);
$places = $this->placeRepository->getRandomNPlaces($mapId, static::NUMBER_OF_ROUNDS, $userId);
$order = 1;
foreach ($places as $place) {
$placeInChallenge = new PlaceInChallenge();
$placeInChallenge->setPlace($place);
$placeInChallenge->setChallenge($challenge);
$placeInChallenge->setOrder($order++);
$this->pdm->saveToDb($placeInChallenge);
}
return new JsonContent(['challengeToken' => dechex($challengeToken)]);
}
public function prepareGame(): IContent
{
$mapId = (int) $this->request->query('mapId');
@ -154,6 +234,28 @@ class GameController
]);
}
public function prepareChallenge(): IContent
{
$challengeToken_str = $this->request->query('challengeToken');
$session = $this->request->session();
$userId = $session->get('userId');
$challenge = $this->challengeRepository->getByTokenStr($challengeToken_str);
if(!$this->userInChallengeRepository->isUserParticipatingInChallenge($userId, $challenge)) {
$userInChallenge = new UserInChallenge();
$userInChallenge->setUserId($userId);
$userInChallenge->setTimeLeft($challenge->getTimerSec());
}
$map = $this->mapRepository->getByChallenge($challenge);
return new JsonContent([
'mapId' => $map->getId(),
'mapName' => $map->getName(),
'bounds' => $map->getBounds()->toArray()
]);
}
private function getMultiToken(string $roomId): string
{
$session = $this->request->session();

View File

@ -323,6 +323,10 @@ class Select
$value = $this->generateColumn($value);
});
if(count($columns) > 0 && $columns[0] == Utils::backtick('id')) {
$columns[0] = Utils::backtick($this->table) . '.' . Utils::backtick('id');
}
return implode(',', $columns);
}

View File

@ -4,7 +4,7 @@ use DateTime;
class Challenge extends Model
{
protected static string $table = 'challenge';
protected static string $table = 'challenges';
protected static array $fields = ['token', 'timer_sec', 'no_move', 'no_pan', 'no_zoom', 'created'];
@ -12,13 +12,13 @@ class Challenge extends Model
private int $token;
private int $timerSec;
private ?int $timerSec = null;
private bool $noMove;
private bool $noMove = false;
private bool $noPan;
private bool $noPan = false;
private bool $noZoom;
private bool $noZoom = false;
private DateTime $created;
@ -27,9 +27,11 @@ class Challenge extends Model
$this->token = $token;
}
public function setTimerSec(int $timerSec): void
public function setTimerSec(?int $timerSec): void
{
$this->timerSec = $timerSec;
if(isset($timerSec)) {
$this->timerSec = $timerSec;
}
}
public function setNoMove(bool $noMove): void
@ -62,7 +64,7 @@ class Challenge extends Model
return $this->token;
}
public function getTimerSec(): int
public function getTimerSec(): ?int
{
return $this->timerSec;
}

View File

@ -16,13 +16,13 @@ class UserInChallenge extends Model
private ?int $challengeId = null;
private int $round;
private int $round = -1;
private int $score;
private int $score = 0;
private int $timeLeft;
private ?int $timeLeft = null;
private bool $isOwner;
private bool $isOwner = false;
public function setUser(User $user): void
{
@ -54,9 +54,11 @@ class UserInChallenge extends Model
$this->score = $score;
}
public function setTimeLeft(int $timeLeft): void
public function setTimeLeft(?int $timeLeft): void
{
$this->timeLeft = $timeLeft;
if(isset($timeLeft)) {
$this->timeLeft = $timeLeft;
}
}
public function setIsOwner(bool $isOwner): void

View File

@ -28,6 +28,13 @@ class ChallengeRepository
return $this->pdm->selectFromDb($select, Challenge::class);
}
public function getByTokenStr(string $token_str): ?Challenge
{
$token = hexdec($token_str);
return $this->getByToken($token);
}
public function getAllByParticipant(User $user): Generator
{
$select = new Select(\Container::$dbConnection);

View File

@ -1,5 +1,7 @@
<?php namespace MapGuesser\Repository;
use MapGuesser\Database\Query\Select;
use MapGuesser\PersistentData\Model\Challenge;
use MapGuesser\PersistentData\Model\Map;
use MapGuesser\PersistentData\PersistentDataManager;
@ -16,4 +18,15 @@ class MapRepository
{
return $this->pdm->selectFromDbById($mapId, Map::class);
}
public function getByChallenge(Challenge $challenge): ?Map
{
$select = new Select(\Container::$dbConnection);
$select->innerJoin('places', ['maps', 'id'], '=', ['places', 'map_id']);
$select->innerJoin('place_in_challenge', ['places', 'id'], '=', ['place_in_challenge', 'place_id']);
$select->where('challenge_id', '=', $challenge->getId());
$select->limit(1);
return $this->pdm->selectFromDb($select, Map::class);
}
}

View File

@ -31,4 +31,13 @@ class UserInChallengeRepository
yield from $this->pdm->selectMultipleFromDb($select, UserInChallenge::class);
}
public function isUserParticipatingInChallenge(int $userId, Challenge $challenge): bool
{
$select = new Select(\Container::$dbConnection, 'user_in_challenge');
$select->where('user_id', '=', $userId);
$select->where('challenge_id', '=', $challenge->getId());
return $select->count();
}
}

View File

@ -84,5 +84,6 @@
var multiUrl = '<?= $_ENV['MULTI_WS_URL'] ?>';
var roomId = <?= isset($roomId) ? '\'' . $roomId . '\'' : 'null' ?>;
var mapId = <?= isset($mapId) ? '\'' . $mapId . '\'' : 'null' ?>;
var challengeToken = <?= isset($challengeToken) ? '\'' . $challengeToken . '\'' : 'null' ?>;
</script>
@endsection

View File

@ -11,6 +11,8 @@ TODO: condition!
<a id="singleButton" class="button fullWidth marginTop" href="" title="Single player">Single player</a>
<p class="bold center marginTop marginBottom">OR</p>
<button id="multiButton" class="fullWidth green" data-map-id="">Multiplayer (beta)</button>
<p class="bold center marginTop marginBottom">OR</p>
<button id="challengeButton" class="fullWidth yellow" data-map-id="" data-timer="">Challenge (gamma)</button>
<div class="right">
Review

Gamma :D

Gamma :D
<button id="closePlayModeButton" class="gray marginTop" type="button">Close</button>
</div>
@ -29,6 +31,47 @@ TODO: condition!
</div>
</form>
</div>
<div id="challenge" class="modal">
<h2>Challenge (gamma)</h2>
<form id="challengeForm" class="marginTop" method="get">
<!--
<div class="inputWithButton">
<input type="text" name="challengeToken" placeholder="Challenge to enter" required minlength="6" maxlength="6">
</div>
<p class="bold center marginTop marginBottom">OR</p>
-->
<div id="restrictions">
<h3>Optional restrictions</h3>
<div>
<div>
<input type="checkbox" id="timerEnabled" name="timerEnabled" value="timerEnabled" />
<label id="timeLimitLabel" for="timerEnabled">Time limit measured in seconds</label>
</div>
<div>
<input type="range" id="timeLimit" name="timeLimit" min="10" max="600" value="120" />
</div>
</div>
<div>
<input type="checkbox" id="noMove" name="noMove" value="noMove" />
<label for="noMove">No movement allowed</label>
</div>
<div>
<input type="checkbox" id="noPan" name="noPan" value="noPan" />
<label for="noPan">No camera rotation allowed</label>
</div>
<div>
<input type="checkbox" id="noZoom" name="noZoom" value="noZoom" />
<label for="noMove">No zoom allowed</label>
</div>
<input type="hidden" name="mapId" id="challengeMapId" />
</div>
<button id="createNewChallengeButton" type="submit" class="button fullWidth green" href="" title="Create new challenge">Create new challenge</button>
<div class="right">
balazs marked this conversation as resolved Outdated
Outdated
Review
<label for="noZoom">
```html <label for="noZoom"> ```
<button id="closeChallengeButton" class="gray marginTop" type="button">Close</button>
</div>
</form>
</div>
@endsection
@section(main)

View File

@ -62,6 +62,13 @@ Container::$routeCollection->group('multiGame', function (MapGuesser\Routing\Rou
$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('challenge', function (MapGuesser\Routing\RouteCollection $routeCollection) {
$routeCollection->post('challenge.create', 'create.json', [\MapGuesser\Controller\GameController::class, 'createNewChallenge']);
$routeCollection->get('challenge', '{challengeToken}', [MapGuesser\Controller\GameController::class, 'getChallenge']);
$routeCollection->post('challenge.prepare-json', '{challengeToken}/prepare.json', [MapGuesser\Controller\GameController::class, 'prepareChallenge']);
$routeCollection->post('challenge.initialData-json', '{challengeToken}/initialData.json', [MapGuesser\Controller\GameFlowController::class, 'challengeInitialData']);
$routeCollection->post('challenge.guess-json', '{challengeToken}/guess.json', [MapGuesser\Controller\GameFlowController::class, 'challengeGuess']);
});
Container::$routeCollection->group('admin', function (MapGuesser\Routing\RouteCollection $routeCollection) {
$routeCollection->get('admin.mapEditor', 'mapEditor/{mapId?}', [MapGuesser\Controller\MapAdminController::class, 'getMapEditor']);
$routeCollection->get('admin.place', 'place.json/{placeId}', [MapGuesser\Controller\MapAdminController::class, 'getPlace']);