add php backend

This commit is contained in:
Luca Jakob 2026-06-01 08:40:23 +02:00
parent 4259762281
commit f9413da3de
22 changed files with 1124 additions and 728 deletions

View File

@ -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

View File

@ -0,0 +1,7 @@
{
"Test": {
"name": "Test",
"players": [],
"leaderboard": []
}
}

238
backend/index.php Normal file
View 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);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"
} }
} }
}
} }

View File

@ -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"

View File

@ -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
View 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 });
}

View File

@ -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 };
})();

View File

@ -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 (0100).
*/
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 (0100).
*/
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);
}

View File

@ -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);

View File

@ -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);
});

View File

@ -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, "&gt;"); .replace(/>/g, "&gt;");
} }
/** 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;
} }
} }

View File

@ -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;
} }

View File

@ -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");

View File

@ -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); // 01 const effort = Math.min(drawnPoints.length / 300, 1); // 0-1
const base = 40 + Math.round(effort * 45); // 4085 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 01 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 };
})();

View File

@ -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
View 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
View 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),
);