add php backend
This commit is contained in:
parent
4259762281
commit
f9413da3de
16
README.md
16
README.md
@ -18,7 +18,13 @@ The game supports single-player sessions with a local leaderboard tracking top s
|
|||||||
|
|
||||||
## How to start
|
## How to start
|
||||||
|
|
||||||
Open the `frontend/index.html` file in a browser. That's it.
|
From the project root, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php serve.php
|
||||||
|
```
|
||||||
|
Then open `http://localhost:8000/` in a browser.
|
||||||
|
You can override the port, but the default will be `8000`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -95,7 +101,13 @@ The drawing part will likely be handled by WebGL and a canvas.
|
|||||||
|
|
||||||
Your browser must support **WebGL**. If you are uncertain, [check this website](https://get.webgl.org/).
|
Your browser must support **WebGL**. If you are uncertain, [check this website](https://get.webgl.org/).
|
||||||
|
|
||||||
Open the `frontend/index.html` file in a browser. That's it.
|
Start the app from the project root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php serve.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:8000/` in a browser.
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
|
|||||||
7
backend/data/lobbies.json
Normal file
7
backend/data/lobbies.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"Test": {
|
||||||
|
"name": "Test",
|
||||||
|
"players": [],
|
||||||
|
"leaderboard": []
|
||||||
|
}
|
||||||
|
}
|
||||||
238
backend/index.php
Normal file
238
backend/index.php
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . 'data';
|
||||||
|
$dataFile = $dataDir . DIRECTORY_SEPARATOR . 'lobbies.json';
|
||||||
|
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($dataFile)) {
|
||||||
|
file_put_contents($dataFile, "{}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(array $payload, int $status = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_body(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || trim($raw) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_name(mixed $value, int $maxLength = 40): string
|
||||||
|
{
|
||||||
|
$name = trim((string) $value);
|
||||||
|
$name = preg_replace('/\s+/', ' ', $name) ?? '';
|
||||||
|
return substr($name, 0, $maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_scores(mixed $value): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice(array_map(static function ($score): int {
|
||||||
|
return max(0, min(100, (int) $score));
|
||||||
|
}, $value), 0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_countries(mixed $value): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice(array_map(static function ($country): string {
|
||||||
|
return clean_name($country, 80);
|
||||||
|
}, $value), 0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function with_lobbies(callable $callback, bool $write = false): mixed
|
||||||
|
{
|
||||||
|
global $dataFile;
|
||||||
|
|
||||||
|
$handle = fopen($dataFile, 'c+');
|
||||||
|
if ($handle === false) {
|
||||||
|
respond(['ok' => false, 'error' => 'Could not open data file.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($handle, $write ? LOCK_EX : LOCK_SH);
|
||||||
|
rewind($handle);
|
||||||
|
$raw = stream_get_contents($handle);
|
||||||
|
$lobbies = json_decode($raw ?: '{}', true);
|
||||||
|
if (!is_array($lobbies)) {
|
||||||
|
$lobbies = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $callback($lobbies);
|
||||||
|
|
||||||
|
if ($write) {
|
||||||
|
rewind($handle);
|
||||||
|
ftruncate($handle, 0);
|
||||||
|
fwrite($handle, json_encode($lobbies, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||||
|
fflush($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($handle, LOCK_UN);
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_lobby(array &$lobbies, string $lobbyName): array
|
||||||
|
{
|
||||||
|
if (!isset($lobbies[$lobbyName]) || !is_array($lobbies[$lobbyName])) {
|
||||||
|
$lobbies[$lobbyName] = [
|
||||||
|
'name' => $lobbyName,
|
||||||
|
'players' => [],
|
||||||
|
'leaderboard' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lobbies[$lobbyName]['players'] = array_values($lobbies[$lobbyName]['players'] ?? []);
|
||||||
|
$lobbies[$lobbyName]['leaderboard'] = array_values($lobbies[$lobbyName]['leaderboard'] ?? []);
|
||||||
|
|
||||||
|
return $lobbies[$lobbyName];
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$body = read_body();
|
||||||
|
|
||||||
|
if ($action === 'createLobby') {
|
||||||
|
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||||
|
if ($lobbyName === '') {
|
||||||
|
respond(['ok' => false, 'error' => 'Lobby name is required.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName): array {
|
||||||
|
return ensure_lobby($lobbies, $lobbyName);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
respond(['ok' => true, 'lobby' => ['name' => $lobby['name']]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'joinLobby') {
|
||||||
|
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||||
|
$playerName = clean_name($body['playerName'] ?? '', 24);
|
||||||
|
if ($lobbyName === '' || $playerName === '') {
|
||||||
|
respond(['ok' => false, 'error' => 'Lobby name and player name are required.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName): array {
|
||||||
|
ensure_lobby($lobbies, $lobbyName);
|
||||||
|
if (!in_array($playerName, $lobbies[$lobbyName]['players'], true)) {
|
||||||
|
$lobbies[$lobbyName]['players'][] = $playerName;
|
||||||
|
}
|
||||||
|
return $lobbies[$lobbyName];
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
respond(['ok' => true, 'lobby' => ['name' => $lobby['name'], 'players' => $lobby['players']]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'leaveLobby') {
|
||||||
|
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||||
|
$playerName = clean_name($body['playerName'] ?? '', 24);
|
||||||
|
|
||||||
|
with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName): void {
|
||||||
|
if ($lobbyName === '' || $playerName === '' || !isset($lobbies[$lobbyName])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lobbies[$lobbyName]['players'] = array_values(array_filter(
|
||||||
|
$lobbies[$lobbyName]['players'] ?? [],
|
||||||
|
static fn ($name): bool => $name !== $playerName,
|
||||||
|
));
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
respond(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'submitScore') {
|
||||||
|
$lobbyName = clean_name($body['lobbyName'] ?? '');
|
||||||
|
$playerName = clean_name($body['playerName'] ?? '', 24);
|
||||||
|
if ($lobbyName === '' || $playerName === '') {
|
||||||
|
respond(['ok' => false, 'error' => 'Lobby name and player name are required.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'playerName' => $playerName,
|
||||||
|
'totalScore' => max(0, min(300, (int) ($body['totalScore'] ?? 0))),
|
||||||
|
'scores' => clean_scores($body['scores'] ?? []),
|
||||||
|
'countries' => clean_countries($body['countries'] ?? []),
|
||||||
|
'date' => gmdate('c'),
|
||||||
|
];
|
||||||
|
|
||||||
|
with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName, $entry): void {
|
||||||
|
ensure_lobby($lobbies, $lobbyName);
|
||||||
|
if (!in_array($playerName, $lobbies[$lobbyName]['players'], true)) {
|
||||||
|
$lobbies[$lobbyName]['players'][] = $playerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lobbies[$lobbyName]['leaderboard'][] = $entry;
|
||||||
|
usort(
|
||||||
|
$lobbies[$lobbyName]['leaderboard'],
|
||||||
|
static fn (array $a, array $b): int => ($b['totalScore'] ?? 0) <=> ($a['totalScore'] ?? 0),
|
||||||
|
);
|
||||||
|
$lobbies[$lobbyName]['leaderboard'] = array_slice($lobbies[$lobbyName]['leaderboard'], 0, 20);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
respond(['ok' => true, 'entry' => $entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'getLeaderboard') {
|
||||||
|
$lobbyName = clean_name($_GET['lobbyName'] ?? '');
|
||||||
|
if ($lobbyName === '') {
|
||||||
|
respond(['ok' => false, 'error' => 'Lobby name is required.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaderboard = with_lobbies(static function (array &$lobbies) use ($lobbyName): array {
|
||||||
|
return $lobbies[$lobbyName]['leaderboard'] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
respond(['ok' => true, 'leaderboard' => $leaderboard]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'getLobby') {
|
||||||
|
$lobbyName = clean_name($_GET['lobbyName'] ?? '');
|
||||||
|
if ($lobbyName === '') {
|
||||||
|
respond(['ok' => false, 'error' => 'Lobby name is required.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName): array {
|
||||||
|
if (!isset($lobbies[$lobbyName])) {
|
||||||
|
return ['name' => $lobbyName, 'players' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $lobbies[$lobbyName]['name'] ?? $lobbyName,
|
||||||
|
'players' => array_values($lobbies[$lobbyName]['players'] ?? []),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
respond(['ok' => true, 'lobby' => $lobby]);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(['ok' => false, 'error' => 'Unknown action.'], 404);
|
||||||
@ -142,44 +142,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="scripts/storage.js"></script>
|
<script type="module" src="scripts/game.js"></script>
|
||||||
<script src="scripts/countries.js"></script>
|
|
||||||
<script src="scripts/scoring.js"></script>
|
|
||||||
<script src="scripts/drawing.js"></script>
|
|
||||||
<script>
|
|
||||||
// Init drawing on canvas element
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
Drawing.init(document.getElementById("draw-canvas"));
|
|
||||||
|
|
||||||
// Show player name
|
|
||||||
document.getElementById("player-name-display").textContent = Storage.getPlayerName();
|
|
||||||
|
|
||||||
// Track drawing for placeholder hide
|
|
||||||
const wrap = document.getElementById("canvas-wrap");
|
|
||||||
document.getElementById("draw-canvas").addEventListener("pointerdown", () => {
|
|
||||||
wrap.classList.add("has-drawing");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update round pips when round changes (game.js calls this)
|
|
||||||
window.updateRoundPips = function(round) {
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
|
||||||
const pip = document.getElementById(`pip-${i}`);
|
|
||||||
pip.className = "round-pip" +
|
|
||||||
(i < round ? " done" : "") +
|
|
||||||
(i === round ? " active" : "");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update score display in sidebar
|
|
||||||
window.updateScoreDisplay = function(roundIdx, score) {
|
|
||||||
const el = document.getElementById(`score-r${roundIdx + 1}`);
|
|
||||||
if (el) {
|
|
||||||
el.textContent = score + "%";
|
|
||||||
el.classList.add("filled");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script src="scripts/game.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
<nav class="nav" aria-label="Main navigation">
|
<nav class="nav" aria-label="Main navigation">
|
||||||
<a href="#hero" class="nav__link">Play</a>
|
<a href="#hero" class="nav__link">Play</a>
|
||||||
<a href="#about" class="nav__link">How to play</a>
|
<a href="#about" class="nav__link">How to play</a>
|
||||||
<a href="#register" class="nav__cta">Login / Register</a>
|
<a href="#register" class="nav__cta">Create lobby</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -165,11 +165,6 @@
|
|||||||
<input id="username" type="text" placeholder="myLobby" />
|
<input id="username" type="text" placeholder="myLobby" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input id="password" type="password" placeholder="1234" autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn--primary btn--full" id="reg-btn">
|
<button type="button" class="btn btn--primary btn--full" id="reg-btn">
|
||||||
Create game
|
Create game
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
@ -204,7 +199,6 @@
|
|||||||
|
|
||||||
</div><!-- /wrap -->
|
</div><!-- /wrap -->
|
||||||
|
|
||||||
<script src="scripts/storage.js"></script>
|
<script type="module" src="scripts/index.js"></script>
|
||||||
<script src="scripts/index.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -89,7 +89,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="scripts/storage.js"></script>
|
<script type="module" src="scripts/leaderboard.js"></script>
|
||||||
<script src="scripts/leaderboard.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -114,7 +114,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="scripts/storage.js"></script>
|
<script type="module" src="scripts/lobby.js"></script>
|
||||||
<script src="scripts/lobby.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
355
frontend/package-lock.json
generated
355
frontend/package-lock.json
generated
@ -1,179 +1,180 @@
|
|||||||
{
|
{
|
||||||
"name": "fs2026-frontend",
|
"name": "fs2026-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fs2026-frontend",
|
"name": "fs2026-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"type": "module",
|
||||||
"@biomejs/biome": "2.3.14"
|
"devDependencies": {
|
||||||
}
|
"@biomejs/biome": "2.3.14"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/biome": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/biome": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz",
|
||||||
"dev": true,
|
"integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"bin": {
|
"license": "MIT OR Apache-2.0",
|
||||||
"biome": "bin/biome"
|
"bin": {
|
||||||
},
|
"biome": "bin/biome"
|
||||||
"engines": {
|
},
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
},
|
"node": ">=14.21.3"
|
||||||
"funding": {
|
},
|
||||||
"type": "opencollective",
|
"funding": {
|
||||||
"url": "https://opencollective.com/biome"
|
"type": "opencollective",
|
||||||
},
|
"url": "https://opencollective.com/biome"
|
||||||
"optionalDependencies": {
|
},
|
||||||
"@biomejs/cli-darwin-arm64": "2.3.14",
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-x64": "2.3.14",
|
"@biomejs/cli-darwin-arm64": "2.3.14",
|
||||||
"@biomejs/cli-linux-arm64": "2.3.14",
|
"@biomejs/cli-darwin-x64": "2.3.14",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.3.14",
|
"@biomejs/cli-linux-arm64": "2.3.14",
|
||||||
"@biomejs/cli-linux-x64": "2.3.14",
|
"@biomejs/cli-linux-arm64-musl": "2.3.14",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.3.14",
|
"@biomejs/cli-linux-x64": "2.3.14",
|
||||||
"@biomejs/cli-win32-arm64": "2.3.14",
|
"@biomejs/cli-linux-x64-musl": "2.3.14",
|
||||||
"@biomejs/cli-win32-x64": "2.3.14"
|
"@biomejs/cli-win32-arm64": "2.3.14",
|
||||||
}
|
"@biomejs/cli-win32-x64": "2.3.14"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==",
|
||||||
"arm64"
|
"cpu": [
|
||||||
],
|
"arm64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"darwin"
|
"os": [
|
||||||
],
|
"darwin"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==",
|
||||||
"x64"
|
"cpu": [
|
||||||
],
|
"x64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"darwin"
|
"os": [
|
||||||
],
|
"darwin"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==",
|
||||||
"arm64"
|
"cpu": [
|
||||||
],
|
"arm64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"linux"
|
"os": [
|
||||||
],
|
"linux"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==",
|
||||||
"arm64"
|
"cpu": [
|
||||||
],
|
"arm64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"linux"
|
"os": [
|
||||||
],
|
"linux"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==",
|
||||||
"x64"
|
"cpu": [
|
||||||
],
|
"x64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"linux"
|
"os": [
|
||||||
],
|
"linux"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==",
|
||||||
"x64"
|
"cpu": [
|
||||||
],
|
"x64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"linux"
|
"os": [
|
||||||
],
|
"linux"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==",
|
||||||
"arm64"
|
"cpu": [
|
||||||
],
|
"arm64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"win32"
|
"os": [
|
||||||
],
|
"win32"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
},
|
}
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
},
|
||||||
"version": "2.3.14",
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz",
|
"version": "2.3.14",
|
||||||
"integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz",
|
||||||
"cpu": [
|
"integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==",
|
||||||
"x64"
|
"cpu": [
|
||||||
],
|
"x64"
|
||||||
"dev": true,
|
],
|
||||||
"license": "MIT OR Apache-2.0",
|
"dev": true,
|
||||||
"optional": true,
|
"license": "MIT OR Apache-2.0",
|
||||||
"os": [
|
"optional": true,
|
||||||
"win32"
|
"os": [
|
||||||
],
|
"win32"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.21.3"
|
"engines": {
|
||||||
}
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,10 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "",
|
"author": "",
|
||||||
"type": "commonjs",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"convert:geojson": "node scripts/tools/convert-geojson-outlines.js",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"lint:fix": "biome lint --write"
|
"lint:fix": "biome lint --write"
|
||||||
|
|||||||
@ -96,8 +96,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="scripts/storage.js"></script>
|
<script type="module" src="scripts/results.js"></script>
|
||||||
<script src="scripts/scoring.js"></script>
|
|
||||||
<script src="scripts/results.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
57
frontend/scripts/api.js
Normal file
57
frontend/scripts/api.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// api.js - small backend client for the static frontend
|
||||||
|
|
||||||
|
const BASE_URL = `${window.location.origin}/backend/index.php`;
|
||||||
|
|
||||||
|
async function request(action, payload = null) {
|
||||||
|
const options = payload
|
||||||
|
? {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const response = await fetch(`${BASE_URL}?action=${action}`, options);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || "Backend request failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(action, params = {}) {
|
||||||
|
const query = new URLSearchParams({ action, ...params });
|
||||||
|
return fetch(`${BASE_URL}?${query.toString()}`)
|
||||||
|
.then((response) => response.json().then((data) => ({ response, data })))
|
||||||
|
.then(({ response, data }) => {
|
||||||
|
if (!response.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || "Backend request failed.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLobby(lobbyName) {
|
||||||
|
return request("createLobby", { lobbyName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinLobby(lobbyName, playerName) {
|
||||||
|
return request("joinLobby", { lobbyName, playerName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveLobby(lobbyName, playerName) {
|
||||||
|
return request("leaveLobby", { lobbyName, playerName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitScore(payload) {
|
||||||
|
return request("submitScore", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeaderboard(lobbyName) {
|
||||||
|
return get("getLeaderboard", { lobbyName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLobby(lobbyName) {
|
||||||
|
return get("getLobby", { lobbyName });
|
||||||
|
}
|
||||||
@ -5,132 +5,128 @@
|
|||||||
* @typedef {{ name: string, hint: string, cities: City[] }} Country
|
* @typedef {{ name: string, hint: string, cities: City[] }} Country
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const Countries = (() => {
|
/** @type {Country[]} */
|
||||||
/** @type {Country[]} */
|
const COUNTRIES_DATA = [
|
||||||
const COUNTRIES_DATA = [
|
{
|
||||||
{
|
name: "Switzerland",
|
||||||
name: "Switzerland",
|
hint: "Alpine country in Central Europe",
|
||||||
hint: "Alpine country in Central Europe",
|
cities: [
|
||||||
cities: [
|
{ name: "Bern", x: 48, y: 58 },
|
||||||
{ name: "Bern", x: 48, y: 58 },
|
{ name: "Zürich", x: 58, y: 38 },
|
||||||
{ name: "Zürich", x: 58, y: 38 },
|
{ name: "Geneva", x: 22, y: 72 },
|
||||||
{ name: "Geneva", x: 22, y: 72 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Norway",
|
||||||
name: "Norway",
|
hint: "Scandinavian country with long coastline",
|
||||||
hint: "Scandinavian country with long coastline",
|
cities: [
|
||||||
cities: [
|
{ name: "Oslo", x: 55, y: 72 },
|
||||||
{ name: "Oslo", x: 55, y: 72 },
|
{ name: "Bergen", x: 32, y: 60 },
|
||||||
{ name: "Bergen", x: 32, y: 60 },
|
{ name: "Tromsø", x: 62, y: 18 },
|
||||||
{ name: "Tromsø", x: 62, y: 18 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Italy",
|
||||||
name: "Italy",
|
hint: "Boot-shaped peninsula in Southern Europe",
|
||||||
hint: "Boot-shaped peninsula in Southern Europe",
|
cities: [
|
||||||
cities: [
|
{ name: "Rome", x: 52, y: 58 },
|
||||||
{ name: "Rome", x: 52, y: 58 },
|
{ name: "Milan", x: 42, y: 22 },
|
||||||
{ name: "Milan", x: 42, y: 22 },
|
{ name: "Naples", x: 58, y: 72 },
|
||||||
{ name: "Naples", x: 58, y: 72 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Japan",
|
||||||
name: "Japan",
|
hint: "Island nation in East Asia",
|
||||||
hint: "Island nation in East Asia",
|
cities: [
|
||||||
cities: [
|
{ name: "Tokyo", x: 72, y: 48 },
|
||||||
{ name: "Tokyo", x: 72, y: 48 },
|
{ name: "Osaka", x: 58, y: 58 },
|
||||||
{ name: "Osaka", x: 58, y: 58 },
|
{ name: "Sapporo", x: 70, y: 22 },
|
||||||
{ name: "Sapporo", x: 70, y: 22 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Brazil",
|
||||||
name: "Brazil",
|
hint: "Largest country in South America",
|
||||||
hint: "Largest country in South America",
|
cities: [
|
||||||
cities: [
|
{ name: "Brasília", x: 58, y: 52 },
|
||||||
{ name: "Brasília", x: 58, y: 52 },
|
{ name: "São Paulo", x: 60, y: 68 },
|
||||||
{ name: "São Paulo", x: 60, y: 68 },
|
{ name: "Manaus", x: 38, y: 38 },
|
||||||
{ name: "Manaus", x: 38, y: 38 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Australia",
|
||||||
name: "Australia",
|
hint: "Continent and country in the Southern Hemisphere",
|
||||||
hint: "Continent and country in the Southern Hemisphere",
|
cities: [
|
||||||
cities: [
|
{ name: "Canberra", x: 72, y: 72 },
|
||||||
{ name: "Canberra", x: 72, y: 72 },
|
{ name: "Sydney", x: 78, y: 68 },
|
||||||
{ name: "Sydney", x: 78, y: 68 },
|
{ name: "Perth", x: 22, y: 65 },
|
||||||
{ name: "Perth", x: 22, y: 65 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "France",
|
||||||
name: "France",
|
hint: "Western Europe, roughly hexagonal shape",
|
||||||
hint: "Western Europe, roughly hexagonal shape",
|
cities: [
|
||||||
cities: [
|
{ name: "Paris", x: 50, y: 32 },
|
||||||
{ name: "Paris", x: 50, y: 32 },
|
{ name: "Lyon", x: 58, y: 55 },
|
||||||
{ name: "Lyon", x: 58, y: 55 },
|
{ name: "Marseille", x: 58, y: 72 },
|
||||||
{ name: "Marseille", x: 58, y: 72 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "India",
|
||||||
name: "India",
|
hint: "Large peninsula in South Asia",
|
||||||
hint: "Large peninsula in South Asia",
|
cities: [
|
||||||
cities: [
|
{ name: "New Delhi", x: 46, y: 28 },
|
||||||
{ name: "New Delhi", x: 46, y: 28 },
|
{ name: "Mumbai", x: 32, y: 55 },
|
||||||
{ name: "Mumbai", x: 32, y: 55 },
|
{ name: "Chennai", x: 52, y: 72 },
|
||||||
{ name: "Chennai", x: 52, y: 72 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Canada",
|
||||||
name: "Canada",
|
hint: "Second largest country in the world",
|
||||||
hint: "Second largest country in the world",
|
cities: [
|
||||||
cities: [
|
{ name: "Ottawa", x: 62, y: 52 },
|
||||||
{ name: "Ottawa", x: 62, y: 52 },
|
{ name: "Vancouver", x: 22, y: 55 },
|
||||||
{ name: "Vancouver", x: 22, y: 55 },
|
{ name: "Toronto", x: 60, y: 58 },
|
||||||
{ name: "Toronto", x: 60, y: 58 },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "Germany",
|
||||||
name: "Germany",
|
hint: "Central European country",
|
||||||
hint: "Central European country",
|
cities: [
|
||||||
cities: [
|
{ name: "Berlin", x: 58, y: 28 },
|
||||||
{ name: "Berlin", x: 58, y: 28 },
|
{ name: "Munich", x: 48, y: 68 },
|
||||||
{ name: "Munich", x: 48, y: 68 },
|
{ name: "Hamburg", x: 42, y: 18 },
|
||||||
{ name: "Hamburg", x: 42, y: 18 },
|
],
|
||||||
],
|
},
|
||||||
},
|
];
|
||||||
];
|
|
||||||
|
|
||||||
/** @type {Country[]} */
|
/** @type {Country[]} */
|
||||||
let _data = [];
|
let data = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load country data into memory. Safe to call multiple times.
|
* Load country data into memory. Safe to call multiple times.
|
||||||
* Returns a Promise for future compatibility with a real API fetch.
|
* Returns a Promise for future compatibility with a real API fetch.
|
||||||
* @returns {Promise<Country[]>}
|
* @returns {Promise<Country[]>}
|
||||||
*/
|
*/
|
||||||
function loadCountries() {
|
export function loadCountries() {
|
||||||
if (!_data.length) _data = COUNTRIES_DATA;
|
if (!data.length) data = COUNTRIES_DATA;
|
||||||
return Promise.resolve(_data);
|
return Promise.resolve(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a random subset of countries.
|
* Return a random subset of countries.
|
||||||
* @param {number} count
|
* @param {number} count
|
||||||
* @returns {Country[]}
|
* @returns {Country[]}
|
||||||
*/
|
*/
|
||||||
function getRandomCountries(count = 3) {
|
export function getRandomCountries(count = 3) {
|
||||||
return [..._data].sort(() => Math.random() - 0.5).slice(0, count);
|
return [...data].sort(() => Math.random() - 0.5).slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get city list for a specific country by name.
|
* Get city list for a specific country by name.
|
||||||
* @param {string} countryName
|
* @param {string} countryName
|
||||||
* @returns {City[]}
|
* @returns {City[]}
|
||||||
*/
|
*/
|
||||||
function getCities(countryName) {
|
export function getCities(countryName) {
|
||||||
const country = _data.find((c) => c.name === countryName);
|
const country = data.find((c) => c.name === countryName);
|
||||||
return country ? country.cities : [];
|
return country ? country.cities : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loadCountries, getRandomCountries, getCities };
|
|
||||||
})();
|
|
||||||
|
|||||||
@ -1,172 +1,172 @@
|
|||||||
// drawing.js — canvas drawing module
|
// drawing.js — canvas drawing module
|
||||||
|
|
||||||
const Drawing = (() => {
|
/** @type {HTMLCanvasElement} */
|
||||||
/** @type {HTMLCanvasElement} */
|
let canvas;
|
||||||
let canvas;
|
/** @type {CanvasRenderingContext2D} */
|
||||||
/** @type {CanvasRenderingContext2D} */
|
let ctx;
|
||||||
let ctx;
|
|
||||||
|
|
||||||
let isDrawing = false;
|
let isDrawing = false;
|
||||||
/** @type {{ x: number, y: number }[]} */
|
/** @type {{ x: number, y: number }[]} */
|
||||||
let points = [];
|
let points = [];
|
||||||
/** @type {{ name: string, x: number, y: number }[]} */
|
/** @type {{ name: string, x: number, y: number }[]} */
|
||||||
let cities = [];
|
let cities = [];
|
||||||
|
|
||||||
const STROKE_COLOR = "#1a7fc4";
|
const STROKE_COLOR = "#1a7fc4";
|
||||||
const STROKE_WIDTH = 2.5;
|
const STROKE_WIDTH = 2.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise the drawing module on a canvas element.
|
* Initialise the drawing module on a canvas element.
|
||||||
* @param {HTMLCanvasElement} canvasEl
|
* @param {HTMLCanvasElement} canvasEl
|
||||||
*/
|
*/
|
||||||
function init(canvasEl) {
|
export function init(canvasEl) {
|
||||||
canvas = canvasEl;
|
canvas = canvasEl;
|
||||||
ctx = canvas.getContext("2d");
|
ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", onDown);
|
canvas.addEventListener("pointerdown", onDown);
|
||||||
canvas.addEventListener("pointermove", onMove);
|
canvas.addEventListener("pointermove", onMove);
|
||||||
canvas.addEventListener("pointerup", onUp);
|
canvas.addEventListener("pointerup", onUp);
|
||||||
canvas.addEventListener("pointerleave", onUp);
|
canvas.addEventListener("pointerleave", onUp);
|
||||||
canvas.style.touchAction = "none";
|
canvas.style.touchAction = "none";
|
||||||
|
|
||||||
_resize();
|
resize();
|
||||||
window.addEventListener("resize", _resize);
|
window.addEventListener("resize", resize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
||||||
function _resize() {
|
function resize() {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
canvas.width = width * window.devicePixelRatio;
|
canvas.width = width * window.devicePixelRatio;
|
||||||
canvas.height = height * window.devicePixelRatio;
|
canvas.height = height * window.devicePixelRatio;
|
||||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||||
_redraw();
|
redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a pointer event to canvas-local coordinates.
|
* Convert a pointer event to canvas-local coordinates.
|
||||||
* @param {PointerEvent} e
|
* @param {PointerEvent} e
|
||||||
* @returns {{ x: number, y: number }}
|
* @returns {{ x: number, y: number }}
|
||||||
*/
|
*/
|
||||||
function _pos(e) {
|
function pos(e) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {PointerEvent} e */
|
/** @param {PointerEvent} e */
|
||||||
function onDown(e) {
|
function onDown(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDrawing = true;
|
isDrawing = true;
|
||||||
const p = _pos(e);
|
const p = pos(e);
|
||||||
points.push(p);
|
points.push(p);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} e */
|
||||||
|
function onMove(e) {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const p = pos(e);
|
||||||
|
points.push(p);
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.strokeStyle = STROKE_COLOR;
|
||||||
|
ctx.lineWidth = STROKE_WIDTH;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} e */
|
||||||
|
function onUp(e) {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
isDrawing = false;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the canvas and redraw city markers. */
|
||||||
|
export function clear() {
|
||||||
|
points = [];
|
||||||
|
if (!ctx) return;
|
||||||
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
drawCities();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set city markers to display on the canvas.
|
||||||
|
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0–100).
|
||||||
|
*/
|
||||||
|
export function setCities(cityList) {
|
||||||
|
cities = cityList || [];
|
||||||
|
drawCities();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render all city markers with labels. */
|
||||||
|
function drawCities() {
|
||||||
|
if (!ctx || !cities.length) return;
|
||||||
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
cities.forEach((city) => {
|
||||||
|
const cx = (city.x / 100) * width;
|
||||||
|
const cy = (city.y / 100) * height;
|
||||||
|
|
||||||
|
// Dot
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(p.x, p.y);
|
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||||||
}
|
ctx.fillStyle = "rgba(240,180,40,0.9)";
|
||||||
|
ctx.fill();
|
||||||
/** @param {PointerEvent} e */
|
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||||
function onMove(e) {
|
ctx.lineWidth = 1.5;
|
||||||
if (!isDrawing) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const p = _pos(e);
|
|
||||||
points.push(p);
|
|
||||||
ctx.lineTo(p.x, p.y);
|
|
||||||
ctx.strokeStyle = STROKE_COLOR;
|
|
||||||
ctx.lineWidth = STROKE_WIDTH;
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {PointerEvent} e */
|
// White pill label background
|
||||||
function onUp(e) {
|
ctx.font = "bold 11px 'DM Sans', sans-serif";
|
||||||
if (!isDrawing) return;
|
const textW = ctx.measureText(city.name).width + 10;
|
||||||
isDrawing = false;
|
const textH = 16;
|
||||||
e.preventDefault();
|
const tx = cx;
|
||||||
}
|
const ty = cy - 14;
|
||||||
|
|
||||||
/** Clear the canvas and redraw city markers. */
|
ctx.save();
|
||||||
function clear() {
|
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
||||||
points = [];
|
|
||||||
if (!ctx) return;
|
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
_drawCities();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set city markers to display on the canvas.
|
|
||||||
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0–100).
|
|
||||||
*/
|
|
||||||
function setCities(cityList) {
|
|
||||||
cities = cityList || [];
|
|
||||||
_drawCities();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render all city markers with labels. */
|
|
||||||
function _drawCities() {
|
|
||||||
if (!ctx || !cities.length) return;
|
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
|
||||||
|
|
||||||
cities.forEach((city) => {
|
|
||||||
const cx = (city.x / 100) * width;
|
|
||||||
const cy = (city.y / 100) * height;
|
|
||||||
|
|
||||||
// Dot
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = "rgba(240,180,40,0.9)";
|
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// White pill label background
|
|
||||||
ctx.font = "bold 11px 'DM Sans', sans-serif";
|
|
||||||
const textW = ctx.measureText(city.name).width + 10;
|
|
||||||
const textH = 16;
|
|
||||||
const tx = cx;
|
|
||||||
const ty = cy - 14;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(tx - textW / 2, ty - textH / 2 - 1, textW, textH, 4);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.fillStyle = "#0b1f2a";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText(city.name, tx, ty + 4);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redraw the full stroke from stored points. */
|
|
||||||
function _redraw() {
|
|
||||||
_drawCities();
|
|
||||||
if (!points.length) return;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = STROKE_COLOR;
|
ctx.roundRect(tx - textW / 2, ty - textH / 2 - 1, textW, textH, 4);
|
||||||
ctx.lineWidth = STROKE_WIDTH;
|
ctx.fill();
|
||||||
ctx.lineJoin = "round";
|
ctx.restore();
|
||||||
ctx.lineCap = "round";
|
|
||||||
points.forEach((p, i) =>
|
|
||||||
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y),
|
|
||||||
);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
ctx.fillStyle = "#0b1f2a";
|
||||||
* Return a copy of the current drawn points.
|
ctx.textAlign = "center";
|
||||||
* @returns {{ x: number, y: number }[]}
|
ctx.fillText(city.name, tx, ty + 4);
|
||||||
*/
|
});
|
||||||
function getPoints() {
|
}
|
||||||
return [...points];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove event listeners and clean up. */
|
/** Redraw the full stroke from stored points. */
|
||||||
function destroy() {
|
function redraw() {
|
||||||
window.removeEventListener("resize", _resize);
|
drawCities();
|
||||||
}
|
if (!points.length) return;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = STROKE_COLOR;
|
||||||
|
ctx.lineWidth = STROKE_WIDTH;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
return { init, clear, setCities, getPoints, destroy };
|
/**
|
||||||
})();
|
* Return a copy of the current drawn points.
|
||||||
|
* @returns {{ x: number, y: number }[]}
|
||||||
|
*/
|
||||||
|
export function getPoints() {
|
||||||
|
return [...points];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove event listeners and clean up. */
|
||||||
|
export function destroy() {
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
// game.js — round management, timer, submit
|
// game.js - round management, timer, submit
|
||||||
|
|
||||||
|
import { submitScore } from "./api.js";
|
||||||
|
import { getRandomCountries, loadCountries } from "./countries.js";
|
||||||
|
import { clear, getPoints, init as initDrawing, setCities } from "./drawing.js";
|
||||||
|
import { calculateScore, getGrade } from "./scoring.js";
|
||||||
|
import { getLobbyName, getPlayerName, saveGameState } from "./storage.js";
|
||||||
|
|
||||||
const TOTAL_ROUNDS = 3;
|
const TOTAL_ROUNDS = 3;
|
||||||
const ROUND_DURATION = 60; // seconds
|
const ROUND_DURATION = 60; // seconds
|
||||||
@ -11,29 +17,45 @@ let scores = [];
|
|||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
let timeLeft = ROUND_DURATION;
|
let timeLeft = ROUND_DURATION;
|
||||||
|
|
||||||
// ── DOM refs
|
let elCountryName;
|
||||||
const elCountryName = document.getElementById("country-name");
|
let elCountryHint;
|
||||||
const elCountryHint = document.getElementById("country-hint");
|
let elRoundNum;
|
||||||
const elRoundNum = document.getElementById("round-num");
|
let elTimerNum;
|
||||||
const elTimerNum = document.getElementById("timer-num");
|
let elTimerBar;
|
||||||
const elTimerBar = document.getElementById("timer-bar");
|
let elTimerWrap;
|
||||||
const elTimerWrap = document.querySelector(".game-timer");
|
let elBtnClear;
|
||||||
const elBtnClear = document.getElementById("btn-clear");
|
let elBtnSubmit;
|
||||||
const elBtnSubmit = document.getElementById("btn-submit");
|
|
||||||
|
|
||||||
// ── Init
|
/** Initialise DOM refs, drawing, events, countries, and the first round. */
|
||||||
|
|
||||||
/** Load countries and start the first round. */
|
|
||||||
async function initGame() {
|
async function initGame() {
|
||||||
await Countries.loadCountries();
|
elCountryName = document.getElementById("country-name");
|
||||||
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
|
elCountryHint = document.getElementById("country-hint");
|
||||||
|
elRoundNum = document.getElementById("round-num");
|
||||||
|
elTimerNum = document.getElementById("timer-num");
|
||||||
|
elTimerBar = document.getElementById("timer-bar");
|
||||||
|
elTimerWrap = document.querySelector(".game-timer");
|
||||||
|
elBtnClear = document.getElementById("btn-clear");
|
||||||
|
elBtnSubmit = document.getElementById("btn-submit");
|
||||||
|
|
||||||
|
const canvas = document.getElementById("draw-canvas");
|
||||||
|
const wrap = document.getElementById("canvas-wrap");
|
||||||
|
initDrawing(canvas);
|
||||||
|
|
||||||
|
document.getElementById("player-name-display").textContent = getPlayerName();
|
||||||
|
canvas.addEventListener("pointerdown", () => {
|
||||||
|
wrap.classList.add("has-drawing");
|
||||||
|
});
|
||||||
|
|
||||||
|
elBtnClear.addEventListener("click", () => clear());
|
||||||
|
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
||||||
|
|
||||||
|
await loadCountries();
|
||||||
|
roundCountries = getRandomCountries(TOTAL_ROUNDS);
|
||||||
currentRound = 0;
|
currentRound = 0;
|
||||||
scores = [];
|
scores = [];
|
||||||
startRound();
|
startRound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Round
|
|
||||||
|
|
||||||
/** Set up UI and timer for the current round. */
|
/** Set up UI and timer for the current round. */
|
||||||
function startRound() {
|
function startRound() {
|
||||||
const country = roundCountries[currentRound];
|
const country = roundCountries[currentRound];
|
||||||
@ -42,14 +64,12 @@ function startRound() {
|
|||||||
elCountryName.textContent = country.name;
|
elCountryName.textContent = country.name;
|
||||||
elCountryHint.textContent = country.hint || "";
|
elCountryHint.textContent = country.hint || "";
|
||||||
|
|
||||||
if (typeof window.updateRoundPips === "function") {
|
updateRoundPips(currentRound + 1);
|
||||||
window.updateRoundPips(currentRound + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
||||||
|
|
||||||
Drawing.clear();
|
setCities(country.cities || []);
|
||||||
Drawing.setCities(country.cities || []);
|
clear();
|
||||||
|
|
||||||
timeLeft = ROUND_DURATION;
|
timeLeft = ROUND_DURATION;
|
||||||
updateTimerUI();
|
updateTimerUI();
|
||||||
@ -96,14 +116,11 @@ function submitRound(auto = false) {
|
|||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
elBtnSubmit.disabled = true;
|
elBtnSubmit.disabled = true;
|
||||||
|
|
||||||
const points = Drawing.getPoints();
|
const points = getPoints();
|
||||||
const score = Scoring.calculateScore(points);
|
const score = calculateScore(points);
|
||||||
scores.push(score);
|
scores.push(score);
|
||||||
|
|
||||||
if (typeof window.updateScoreDisplay === "function") {
|
updateScoreDisplay(currentRound, score);
|
||||||
window.updateScoreDisplay(currentRound, score);
|
|
||||||
}
|
|
||||||
|
|
||||||
showScoreFeedback(score);
|
showScoreFeedback(score);
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
@ -124,7 +141,7 @@ function submitRound(auto = false) {
|
|||||||
* @param {number} score
|
* @param {number} score
|
||||||
*/
|
*/
|
||||||
function showScoreFeedback(score) {
|
function showScoreFeedback(score) {
|
||||||
const grade = Scoring.getGrade(score);
|
const grade = getGrade(score);
|
||||||
const el = document.getElementById("score-feedback");
|
const el = document.getElementById("score-feedback");
|
||||||
el.textContent = `${score}% ${grade.label}`;
|
el.textContent = `${score}% ${grade.label}`;
|
||||||
el.style.color = grade.color;
|
el.style.color = grade.color;
|
||||||
@ -137,7 +154,7 @@ function showScoreFeedback(score) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Persist game state, update leaderboard, and navigate to results. */
|
/** Persist game state, update leaderboard, and navigate to results. */
|
||||||
function finishGame() {
|
async function finishGame() {
|
||||||
const totalScore = scores.reduce((sum, s) => sum + s, 0);
|
const totalScore = scores.reduce((sum, s) => sum + s, 0);
|
||||||
const state = {
|
const state = {
|
||||||
currentRound: TOTAL_ROUNDS,
|
currentRound: TOTAL_ROUNDS,
|
||||||
@ -145,19 +162,43 @@ function finishGame() {
|
|||||||
totalScore,
|
totalScore,
|
||||||
countries: roundCountries.map((c) => c.name),
|
countries: roundCountries.map((c) => c.name),
|
||||||
};
|
};
|
||||||
Storage.saveGameState(state);
|
saveGameState(state);
|
||||||
Storage.saveLeaderboard({
|
|
||||||
name: Storage.getPlayerName(),
|
try {
|
||||||
totalScore,
|
await submitScore({
|
||||||
scores,
|
lobbyName: getLobbyName(),
|
||||||
date: new Date().toISOString(),
|
playerName: getPlayerName(),
|
||||||
});
|
totalScore,
|
||||||
|
scores,
|
||||||
|
countries: state.countries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not submit score to backend.", error);
|
||||||
|
}
|
||||||
|
|
||||||
location.href = "results.html";
|
location.href = "results.html";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events
|
/** @param {number} round */
|
||||||
elBtnClear.addEventListener("click", () => Drawing.clear());
|
function updateRoundPips(round) {
|
||||||
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const pip = document.getElementById(`pip-${i}`);
|
||||||
|
pip.className = `round-pip${i < round ? " done" : ""}${
|
||||||
|
i === round ? " active" : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} roundIdx
|
||||||
|
* @param {number} score
|
||||||
|
*/
|
||||||
|
function updateScoreDisplay(roundIdx, score) {
|
||||||
|
const el = document.getElementById(`score-r${roundIdx + 1}`);
|
||||||
|
if (el) {
|
||||||
|
el.textContent = `${score}%`;
|
||||||
|
el.classList.add("filled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Boot
|
|
||||||
window.addEventListener("DOMContentLoaded", initGame);
|
window.addEventListener("DOMContentLoaded", initGame);
|
||||||
|
|||||||
@ -1,24 +1,38 @@
|
|||||||
// index.js — landing page logic
|
// index.js - landing page logic
|
||||||
|
|
||||||
|
import { createLobby } from "./api.js";
|
||||||
|
import { saveLobbyName } from "./storage.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the "Create game" button click.
|
* Handle the "Create game" button click.
|
||||||
* Validates the lobby name, persists it, and navigates to the lobby page.
|
* Validates the lobby name, persists it, and navigates to the lobby page.
|
||||||
*/
|
*/
|
||||||
document.getElementById("reg-btn")?.addEventListener("click", () => {
|
document.getElementById("reg-btn")?.addEventListener("click", async () => {
|
||||||
const lobbyInput = /** @type {HTMLInputElement|null} */ (
|
const lobbyInput = /** @type {HTMLInputElement|null} */ (
|
||||||
document.getElementById("username")
|
document.getElementById("username")
|
||||||
);
|
);
|
||||||
|
const button = /** @type {HTMLButtonElement|null} */ (
|
||||||
|
document.getElementById("reg-btn")
|
||||||
|
);
|
||||||
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
|
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
|
||||||
|
|
||||||
if (lobbyName) {
|
if (!lobbyName) {
|
||||||
Storage.saveLobbyName(lobbyName);
|
|
||||||
window.location.href = "lobby.html";
|
|
||||||
} else {
|
|
||||||
lobbyInput?.focus();
|
lobbyInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
await createLobby(lobbyName);
|
||||||
|
saveLobbyName(lobbyName);
|
||||||
|
window.location.href = "lobby.html";
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : "Could not create lobby.");
|
||||||
|
if (button) button.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scroll reveal — animate elements into view as they enter the viewport
|
// Scroll reveal - animate elements into view as they enter the viewport
|
||||||
const reveals = document.querySelectorAll(".reveal");
|
const reveals = document.querySelectorAll(".reveal");
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@ -32,4 +46,6 @@ const observer = new IntersectionObserver(
|
|||||||
{ threshold: 0.12 },
|
{ threshold: 0.12 },
|
||||||
);
|
);
|
||||||
|
|
||||||
reveals.forEach((el) => observer.observe(el));
|
reveals.forEach((el) => {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
// leaderboard.js — leaderboard page logic
|
// leaderboard.js - leaderboard page logic
|
||||||
|
|
||||||
|
import { getLeaderboard } from "./api.js";
|
||||||
|
import { getLobbyName, getPlayerName } from "./storage.js";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const currentPlayer = Storage.getPlayerName();
|
const currentPlayer = getPlayerName();
|
||||||
const body = document.getElementById("lb-body");
|
const body = document.getElementById("lb-body");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,9 +19,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
.replace(/>/g, ">");
|
.replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the leaderboard from localStorage data. */
|
/** Render the leaderboard from backend data. */
|
||||||
function render() {
|
async function render() {
|
||||||
const board = Storage.getLeaderboard();
|
const lobbyName = getLobbyName();
|
||||||
|
let board = [];
|
||||||
|
try {
|
||||||
|
const data = await getLeaderboard(lobbyName);
|
||||||
|
board = data.leaderboard || [];
|
||||||
|
} catch (_error) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="lb-empty">
|
||||||
|
<p class="lb-empty__text">Could not load the leaderboard. Make sure the backend is running.</p>
|
||||||
|
<a href="lobby.html" class="btn btn--primary btn--sm">Back to lobby</a>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
|
|
||||||
if (!board.length) {
|
if (!board.length) {
|
||||||
@ -36,7 +52,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
board.forEach((entry, index) => {
|
board.forEach((entry, index) => {
|
||||||
const rank = index + 1;
|
const rank = index + 1;
|
||||||
const isYou = entry.name === currentPlayer;
|
const playerName = entry.playerName || entry.name || "Anonymous";
|
||||||
|
const isYou = playerName === currentPlayer;
|
||||||
const date = entry.date
|
const date = entry.date
|
||||||
? new Date(entry.date).toLocaleDateString("en-CH", {
|
? new Date(entry.date).toLocaleDateString("en-CH", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@ -51,7 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="lb-rank">${rank <= 3 ? `<span class="lb-medal">${medals[rank - 1]}</span>` : rank}</span>
|
<span class="lb-rank">${rank <= 3 ? `<span class="lb-medal">${medals[rank - 1]}</span>` : rank}</span>
|
||||||
<span class="lb-name${isYou ? " is-you" : ""}">${escHtml(entry.name)}</span>
|
<span class="lb-name${isYou ? " is-you" : ""}">${escHtml(playerName)}</span>
|
||||||
<span class="lb-rounds hide-sm">${escHtml(rounds)}</span>
|
<span class="lb-rounds hide-sm">${escHtml(rounds)}</span>
|
||||||
<span class="lb-date hide-sm">${date}</span>
|
<span class="lb-date hide-sm">${date}</span>
|
||||||
<span class="lb-score${rank <= 3 ? ` ${scoreClasses[rank - 1]}` : ""}">${entry.totalScore}</span>
|
<span class="lb-score${rank <= 3 ? ` ${scoreClasses[rank - 1]}` : ""}">${entry.totalScore}</span>
|
||||||
@ -59,11 +76,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show the current player's latest score bar
|
const latest = board.find(
|
||||||
const latest = board.find((entry) => entry.name === currentPlayer);
|
(entry) => (entry.playerName || entry.name) === currentPlayer,
|
||||||
|
);
|
||||||
if (latest) {
|
if (latest) {
|
||||||
document.getElementById("your-bar").style.display = "flex";
|
document.getElementById("your-bar").style.display = "flex";
|
||||||
document.getElementById("your-bar-name").textContent = latest.name;
|
document.getElementById("your-bar-name").textContent =
|
||||||
|
latest.playerName || latest.name;
|
||||||
document.getElementById("your-bar-score").textContent = latest.totalScore;
|
document.getElementById("your-bar-score").textContent = latest.totalScore;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,12 @@
|
|||||||
// lobby.js — lobby page logic
|
// lobby.js - lobby page logic
|
||||||
|
|
||||||
|
import { joinLobby } from "./api.js";
|
||||||
|
import {
|
||||||
|
clearGameState,
|
||||||
|
getLobbyName,
|
||||||
|
getPlayerName,
|
||||||
|
savePlayerName,
|
||||||
|
} from "./storage.js";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const input = document.getElementById("username");
|
const input = document.getElementById("username");
|
||||||
@ -8,15 +16,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// Display the current lobby name
|
// Display the current lobby name
|
||||||
const lobbyNameEl = document.getElementById("lobby-name-display");
|
const lobbyNameEl = document.getElementById("lobby-name-display");
|
||||||
if (lobbyNameEl) {
|
if (lobbyNameEl) {
|
||||||
lobbyNameEl.textContent = Storage.getLobbyName();
|
lobbyNameEl.textContent = getLobbyName();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the username input and navigate to the game.
|
* Validate the username input and navigate to the game.
|
||||||
* Shows an inline error message on invalid input.
|
* Shows an inline error message on invalid input.
|
||||||
*/
|
*/
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", async () => {
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
|
const lobbyName = getLobbyName();
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
errMsg.textContent = "Please enter a username to continue.";
|
errMsg.textContent = "Please enter a username to continue.";
|
||||||
@ -31,9 +40,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage.savePlayerName(name);
|
try {
|
||||||
Storage.clearGameState();
|
btn.disabled = true;
|
||||||
location.href = "game.html";
|
await joinLobby(lobbyName, name);
|
||||||
|
savePlayerName(name);
|
||||||
|
clearGameState();
|
||||||
|
location.href = "game.html";
|
||||||
|
} catch (error) {
|
||||||
|
errMsg.textContent =
|
||||||
|
error instanceof Error ? error.message : "Could not join lobby.";
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear validation state on every keystroke
|
// Clear validation state on every keystroke
|
||||||
@ -48,7 +65,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Pre-fill username if the player has played before
|
// Pre-fill username if the player has played before
|
||||||
const existing = Storage.getPlayerName();
|
const existing = getPlayerName();
|
||||||
if (existing && existing !== "Anonymous") {
|
if (existing && existing !== "Anonymous") {
|
||||||
input.value = existing;
|
input.value = existing;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
// results.js — results page logic
|
// results.js - results page logic
|
||||||
|
|
||||||
|
import { getGrade } from "./scoring.js";
|
||||||
|
import { getGameState, getPlayerName } from "./storage.js";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const state = Storage.getGameState();
|
const state = getGameState();
|
||||||
const name = Storage.getPlayerName();
|
const name = getPlayerName();
|
||||||
|
|
||||||
document.getElementById("results-player").textContent = name;
|
document.getElementById("results-player").textContent = name;
|
||||||
|
|
||||||
@ -14,7 +17,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const { scores, totalScore, countries } = state;
|
const { scores, totalScore, countries } = state;
|
||||||
|
|
||||||
// Determine emoji and title based on average score
|
|
||||||
const avg = totalScore / 3;
|
const avg = totalScore / 3;
|
||||||
let emoji = "🌍";
|
let emoji = "🌍";
|
||||||
let title = "Not bad!";
|
let title = "Not bad!";
|
||||||
@ -36,12 +38,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
document.getElementById("results-title").textContent = title;
|
document.getElementById("results-title").textContent = title;
|
||||||
document.getElementById("total-score").textContent = totalScore;
|
document.getElementById("total-score").textContent = totalScore;
|
||||||
|
|
||||||
// Grade badge
|
const grade = getGrade(avg);
|
||||||
const grade = Scoring.getGrade(avg);
|
|
||||||
const gradeEl = document.getElementById("total-grade");
|
const gradeEl = document.getElementById("total-grade");
|
||||||
gradeEl.textContent = `Grade ${grade.label}`;
|
gradeEl.textContent = `Grade ${grade.label}`;
|
||||||
|
|
||||||
// Round breakdown rows
|
|
||||||
const rowsContainer = document.getElementById("round-rows");
|
const rowsContainer = document.getElementById("round-rows");
|
||||||
(scores || []).forEach((score, index) => {
|
(scores || []).forEach((score, index) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
@ -56,14 +56,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
rowsContainer.appendChild(row);
|
rowsContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animate score bars on next frame so CSS transition fires
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.querySelectorAll(".round-row__bar").forEach((bar) => {
|
document.querySelectorAll(".round-row__bar").forEach((bar) => {
|
||||||
bar.style.width = `${bar.dataset.target}%`;
|
bar.style.width = `${bar.dataset.target}%`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Country tags
|
|
||||||
const tagsContainer = document.getElementById("countries-row");
|
const tagsContainer = document.getElementById("countries-row");
|
||||||
(countries || []).forEach((country) => {
|
(countries || []).forEach((country) => {
|
||||||
const tag = document.createElement("span");
|
const tag = document.createElement("span");
|
||||||
|
|||||||
@ -1,46 +1,42 @@
|
|||||||
// scoring.js — accuracy calculation
|
// scoring.js - accuracy calculation
|
||||||
|
|
||||||
const Scoring = (() => {
|
/**
|
||||||
/**
|
* Calculate a score from the drawn path points.
|
||||||
* Calculate a score from the drawn path points.
|
* NOTE: Currently uses an effort-based approximation.
|
||||||
* NOTE: Currently uses an effort-based approximation.
|
* TODO: Replace with real polygon comparison (IoU or Hausdorff distance).
|
||||||
* TODO: Replace with real polygon comparison (IoU or Hausdorff distance).
|
* @param {{ x: number, y: number }[]} drawnPoints
|
||||||
* @param {{ x: number, y: number }[]} drawnPoints
|
* @returns {number} Score between 0 and 100.
|
||||||
* @returns {number} Score between 0 and 100.
|
*/
|
||||||
*/
|
export function calculateScore(drawnPoints) {
|
||||||
function calculateScore(drawnPoints) {
|
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
||||||
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
|
||||||
|
|
||||||
const effort = Math.min(drawnPoints.length / 300, 1); // 0–1
|
const effort = Math.min(drawnPoints.length / 300, 1); // 0-1
|
||||||
const base = 40 + Math.round(effort * 45); // 40–85
|
const base = 40 + Math.round(effort * 45); // 40-85
|
||||||
const jitter = Math.round((Math.random() - 0.5) * 14); // ±7
|
const jitter = Math.round((Math.random() - 0.5) * 14); // +/-7
|
||||||
return Math.max(0, Math.min(100, base + jitter));
|
return Math.max(0, Math.min(100, base + jitter));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare a drawn polygon against a reference polygon.
|
* Compare a drawn polygon against a reference polygon.
|
||||||
* Stub — reserved for future IoU / Hausdorff implementation.
|
* Stub - reserved for future IoU / Hausdorff implementation.
|
||||||
* @param {{ x: number, y: number }[]} _drawnPoints
|
* @param {{ x: number, y: number }[]} _drawnPoints
|
||||||
* @param {{ x: number, y: number }[]} _referencePolygon - Normalised 0–1 coords.
|
* @param {{ x: number, y: number }[]} _referencePolygon - Normalised 0-1 coords.
|
||||||
* @returns {number} Score between 0 and 100.
|
* @returns {number} Score between 0 and 100.
|
||||||
*/
|
*/
|
||||||
function compareShapes(_drawnPoints, _referencePolygon) {
|
export function compareShapes(_drawnPoints, _referencePolygon) {
|
||||||
// TODO: implement real shape comparison
|
// TODO: implement real shape comparison
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a numeric score to a letter grade with colour.
|
* Map a numeric score to a letter grade with colour.
|
||||||
* @param {number} score
|
* @param {number} score
|
||||||
* @returns {{ label: string, color: string }}
|
* @returns {{ label: string, color: string }}
|
||||||
*/
|
*/
|
||||||
function getGrade(score) {
|
export function getGrade(score) {
|
||||||
if (score >= 90) return { label: "S", color: "#f0b429" };
|
if (score >= 90) return { label: "S", color: "#f0b429" };
|
||||||
if (score >= 75) return { label: "A", color: "#41b869" };
|
if (score >= 75) return { label: "A", color: "#41b869" };
|
||||||
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
||||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||||
return { label: "D", color: "#e05c5c" };
|
return { label: "D", color: "#e05c5c" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { calculateScore, compareShapes, getGrade };
|
|
||||||
})();
|
|
||||||
|
|||||||
@ -1,105 +1,90 @@
|
|||||||
// storage.js — localStorage helpers
|
// storage.js - localStorage helpers
|
||||||
|
|
||||||
const Storage = (() => {
|
const KEYS = {
|
||||||
const KEYS = {
|
PLAYER_NAME: "gd_playerName",
|
||||||
PLAYER_NAME: "gd_playerName",
|
LOBBY_NAME: "gd_lobbyName",
|
||||||
LOBBY_NAME: "gd_lobbyName",
|
GAME_STATE: "gd_gameState",
|
||||||
GAME_STATE: "gd_gameState",
|
LEADERBOARD: "gd_leaderboard",
|
||||||
LEADERBOARD: "gd_leaderboard",
|
};
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely write a value to localStorage.
|
* Safely write a value to localStorage.
|
||||||
* Silently fails if storage quota is exceeded or unavailable.
|
* Silently fails if storage quota is exceeded or unavailable.
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
* @param {string} value
|
* @param {string} value
|
||||||
*/
|
*/
|
||||||
function _set(key, value) {
|
function setItem(key, value) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn(`Storage: could not write key "${key}"`);
|
console.warn(`Storage: could not write key "${key}"`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} name */
|
/** @param {string} name */
|
||||||
function savePlayerName(name) {
|
export function savePlayerName(name) {
|
||||||
_set(KEYS.PLAYER_NAME, name.trim());
|
setItem(KEYS.PLAYER_NAME, name.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
export function getPlayerName() {
|
||||||
|
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} name */
|
||||||
|
export function saveLobbyName(name) {
|
||||||
|
setItem(KEYS.LOBBY_NAME, name.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
export function getLobbyName() {
|
||||||
|
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ scores: number[], totalScore: number, countries: string[] }} state
|
||||||
|
*/
|
||||||
|
export function saveGameState(state) {
|
||||||
|
setItem(KEYS.GAME_STATE, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ scores: number[], totalScore: number, countries: string[] } | null}
|
||||||
|
*/
|
||||||
|
export function getGameState() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @returns {string} */
|
export function clearGameState() {
|
||||||
function getPlayerName() {
|
localStorage.removeItem(KEYS.GAME_STATE);
|
||||||
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an entry to the local fallback leaderboard and keep the top 20.
|
||||||
|
* @param {{ name: string, totalScore: number, scores: number[], date: string }} entry
|
||||||
|
*/
|
||||||
|
export function saveLeaderboard(entry) {
|
||||||
|
const board = getLeaderboard();
|
||||||
|
board.push(entry);
|
||||||
|
board.sort((a, b) => b.totalScore - a.totalScore);
|
||||||
|
setItem(KEYS.LEADERBOARD, JSON.stringify(board.slice(0, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
|
||||||
|
*/
|
||||||
|
export function getLeaderboard() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} name */
|
export function clearLeaderboard() {
|
||||||
function saveLobbyName(name) {
|
localStorage.removeItem(KEYS.LEADERBOARD);
|
||||||
_set(KEYS.LOBBY_NAME, name.trim());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns {string} */
|
|
||||||
function getLobbyName() {
|
|
||||||
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ scores: number[], totalScore: number, countries: string[] }} state
|
|
||||||
*/
|
|
||||||
function saveGameState(state) {
|
|
||||||
_set(KEYS.GAME_STATE, JSON.stringify(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {{ scores: number[], totalScore: number, countries: string[] } | null}
|
|
||||||
*/
|
|
||||||
function getGameState() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearGameState() {
|
|
||||||
localStorage.removeItem(KEYS.GAME_STATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an entry to the leaderboard and keep the top 20.
|
|
||||||
* @param {{ name: string, totalScore: number, scores: number[], date: string }} entry
|
|
||||||
*/
|
|
||||||
function saveLeaderboard(entry) {
|
|
||||||
const board = getLeaderboard();
|
|
||||||
board.push(entry);
|
|
||||||
board.sort((a, b) => b.totalScore - a.totalScore);
|
|
||||||
_set(KEYS.LEADERBOARD, JSON.stringify(board.slice(0, 20)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
|
|
||||||
*/
|
|
||||||
function getLeaderboard() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLeaderboard() {
|
|
||||||
localStorage.removeItem(KEYS.LEADERBOARD);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
savePlayerName,
|
|
||||||
getPlayerName,
|
|
||||||
saveLobbyName,
|
|
||||||
getLobbyName,
|
|
||||||
saveGameState,
|
|
||||||
getGameState,
|
|
||||||
clearGameState,
|
|
||||||
saveLeaderboard,
|
|
||||||
getLeaderboard,
|
|
||||||
clearLeaderboard,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|||||||
38
router.php
Normal file
38
router.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
|
$root = __DIR__;
|
||||||
|
|
||||||
|
if (str_starts_with($uriPath, '/backend/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$frontendPath = $uriPath === '/' ? '/index.html' : $uriPath;
|
||||||
|
$file = realpath($root . '/frontend' . $frontendPath);
|
||||||
|
$frontendRoot = realpath($root . '/frontend');
|
||||||
|
|
||||||
|
if (
|
||||||
|
$file !== false
|
||||||
|
&& $frontendRoot !== false
|
||||||
|
&& str_starts_with($file, $frontendRoot)
|
||||||
|
&& is_file($file)
|
||||||
|
) {
|
||||||
|
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
$contentTypes = [
|
||||||
|
'css' => 'text/css; charset=utf-8',
|
||||||
|
'html' => 'text/html; charset=utf-8',
|
||||||
|
'js' => 'text/javascript; charset=utf-8',
|
||||||
|
'json' => 'application/json; charset=utf-8',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
];
|
||||||
|
|
||||||
|
header('Content-Type: ' . ($contentTypes[$extension] ?? 'application/octet-stream'));
|
||||||
|
readfile($file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Not found\n";
|
||||||
22
serve.php
Normal file
22
serve.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$host = 'localhost';
|
||||||
|
$port = $argv[1] ?? '8000';
|
||||||
|
$port = preg_match('/^\d{2,5}$/', $port) === 1 ? $port : '8000';
|
||||||
|
$root = __DIR__;
|
||||||
|
$router = __DIR__ . DIRECTORY_SEPARATOR . 'router.php';
|
||||||
|
|
||||||
|
echo "GeoDraw is running at http://{$host}:{$port}/\n";
|
||||||
|
echo "Press Ctrl+C to stop the server.\n\n";
|
||||||
|
|
||||||
|
passthru(
|
||||||
|
escapeshellarg(PHP_BINARY)
|
||||||
|
. ' -S '
|
||||||
|
. escapeshellarg("{$host}:{$port}")
|
||||||
|
. ' -t '
|
||||||
|
. escapeshellarg($root)
|
||||||
|
. ' '
|
||||||
|
. escapeshellarg($router),
|
||||||
|
);
|
||||||
Loading…
x
Reference in New Issue
Block a user