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
|
||||
|
||||
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/).
|
||||
|
||||
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
|
||||
|
||||
|
||||
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>
|
||||
|
||||
<script src="scripts/storage.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>
|
||||
<script type="module" src="scripts/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<nav class="nav" aria-label="Main navigation">
|
||||
<a href="#hero" class="nav__link">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>
|
||||
</div>
|
||||
</header>
|
||||
@ -165,11 +165,6 @@
|
||||
<input id="username" type="text" placeholder="myLobby" />
|
||||
</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">
|
||||
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>
|
||||
@ -204,7 +199,6 @@
|
||||
|
||||
</div><!-- /wrap -->
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/index.js"></script>
|
||||
<script type="module" src="scripts/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -89,7 +89,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/leaderboard.js"></script>
|
||||
<script type="module" src="scripts/leaderboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -114,7 +114,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/lobby.js"></script>
|
||||
<script type="module" src="scripts/lobby.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
355
frontend/package-lock.json
generated
355
frontend/package-lock.json
generated
@ -1,179 +1,180 @@
|
||||
{
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz",
|
||||
"integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.14",
|
||||
"@biomejs/cli-darwin-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.14",
|
||||
"@biomejs/cli-linux-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-x64-musl": "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",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fs2026-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz",
|
||||
"integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.14",
|
||||
"@biomejs/cli-darwin-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64": "2.3.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.14",
|
||||
"@biomejs/cli-linux-x64": "2.3.14",
|
||||
"@biomejs/cli-linux-x64-musl": "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",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz",
|
||||
"integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz",
|
||||
"integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz",
|
||||
"integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "commonjs",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"convert:geojson": "node scripts/tools/convert-geojson-outlines.js",
|
||||
"format": "biome format --write",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome lint --write"
|
||||
|
||||
@ -96,8 +96,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="scripts/storage.js"></script>
|
||||
<script src="scripts/scoring.js"></script>
|
||||
<script src="scripts/results.js"></script>
|
||||
<script type="module" src="scripts/results.js"></script>
|
||||
</body>
|
||||
</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
|
||||
*/
|
||||
|
||||
const Countries = (() => {
|
||||
/** @type {Country[]} */
|
||||
const COUNTRIES_DATA = [
|
||||
{
|
||||
name: "Switzerland",
|
||||
hint: "Alpine country in Central Europe",
|
||||
cities: [
|
||||
{ name: "Bern", x: 48, y: 58 },
|
||||
{ name: "Zürich", x: 58, y: 38 },
|
||||
{ name: "Geneva", x: 22, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Norway",
|
||||
hint: "Scandinavian country with long coastline",
|
||||
cities: [
|
||||
{ name: "Oslo", x: 55, y: 72 },
|
||||
{ name: "Bergen", x: 32, y: 60 },
|
||||
{ name: "Tromsø", x: 62, y: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Italy",
|
||||
hint: "Boot-shaped peninsula in Southern Europe",
|
||||
cities: [
|
||||
{ name: "Rome", x: 52, y: 58 },
|
||||
{ name: "Milan", x: 42, y: 22 },
|
||||
{ name: "Naples", x: 58, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Japan",
|
||||
hint: "Island nation in East Asia",
|
||||
cities: [
|
||||
{ name: "Tokyo", x: 72, y: 48 },
|
||||
{ name: "Osaka", x: 58, y: 58 },
|
||||
{ name: "Sapporo", x: 70, y: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Brazil",
|
||||
hint: "Largest country in South America",
|
||||
cities: [
|
||||
{ name: "Brasília", x: 58, y: 52 },
|
||||
{ name: "São Paulo", x: 60, y: 68 },
|
||||
{ name: "Manaus", x: 38, y: 38 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Australia",
|
||||
hint: "Continent and country in the Southern Hemisphere",
|
||||
cities: [
|
||||
{ name: "Canberra", x: 72, y: 72 },
|
||||
{ name: "Sydney", x: 78, y: 68 },
|
||||
{ name: "Perth", x: 22, y: 65 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "France",
|
||||
hint: "Western Europe, roughly hexagonal shape",
|
||||
cities: [
|
||||
{ name: "Paris", x: 50, y: 32 },
|
||||
{ name: "Lyon", x: 58, y: 55 },
|
||||
{ name: "Marseille", x: 58, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "India",
|
||||
hint: "Large peninsula in South Asia",
|
||||
cities: [
|
||||
{ name: "New Delhi", x: 46, y: 28 },
|
||||
{ name: "Mumbai", x: 32, y: 55 },
|
||||
{ name: "Chennai", x: 52, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Canada",
|
||||
hint: "Second largest country in the world",
|
||||
cities: [
|
||||
{ name: "Ottawa", x: 62, y: 52 },
|
||||
{ name: "Vancouver", x: 22, y: 55 },
|
||||
{ name: "Toronto", x: 60, y: 58 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Germany",
|
||||
hint: "Central European country",
|
||||
cities: [
|
||||
{ name: "Berlin", x: 58, y: 28 },
|
||||
{ name: "Munich", x: 48, y: 68 },
|
||||
{ name: "Hamburg", x: 42, y: 18 },
|
||||
],
|
||||
},
|
||||
];
|
||||
/** @type {Country[]} */
|
||||
const COUNTRIES_DATA = [
|
||||
{
|
||||
name: "Switzerland",
|
||||
hint: "Alpine country in Central Europe",
|
||||
cities: [
|
||||
{ name: "Bern", x: 48, y: 58 },
|
||||
{ name: "Zürich", x: 58, y: 38 },
|
||||
{ name: "Geneva", x: 22, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Norway",
|
||||
hint: "Scandinavian country with long coastline",
|
||||
cities: [
|
||||
{ name: "Oslo", x: 55, y: 72 },
|
||||
{ name: "Bergen", x: 32, y: 60 },
|
||||
{ name: "Tromsø", x: 62, y: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Italy",
|
||||
hint: "Boot-shaped peninsula in Southern Europe",
|
||||
cities: [
|
||||
{ name: "Rome", x: 52, y: 58 },
|
||||
{ name: "Milan", x: 42, y: 22 },
|
||||
{ name: "Naples", x: 58, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Japan",
|
||||
hint: "Island nation in East Asia",
|
||||
cities: [
|
||||
{ name: "Tokyo", x: 72, y: 48 },
|
||||
{ name: "Osaka", x: 58, y: 58 },
|
||||
{ name: "Sapporo", x: 70, y: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Brazil",
|
||||
hint: "Largest country in South America",
|
||||
cities: [
|
||||
{ name: "Brasília", x: 58, y: 52 },
|
||||
{ name: "São Paulo", x: 60, y: 68 },
|
||||
{ name: "Manaus", x: 38, y: 38 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Australia",
|
||||
hint: "Continent and country in the Southern Hemisphere",
|
||||
cities: [
|
||||
{ name: "Canberra", x: 72, y: 72 },
|
||||
{ name: "Sydney", x: 78, y: 68 },
|
||||
{ name: "Perth", x: 22, y: 65 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "France",
|
||||
hint: "Western Europe, roughly hexagonal shape",
|
||||
cities: [
|
||||
{ name: "Paris", x: 50, y: 32 },
|
||||
{ name: "Lyon", x: 58, y: 55 },
|
||||
{ name: "Marseille", x: 58, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "India",
|
||||
hint: "Large peninsula in South Asia",
|
||||
cities: [
|
||||
{ name: "New Delhi", x: 46, y: 28 },
|
||||
{ name: "Mumbai", x: 32, y: 55 },
|
||||
{ name: "Chennai", x: 52, y: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Canada",
|
||||
hint: "Second largest country in the world",
|
||||
cities: [
|
||||
{ name: "Ottawa", x: 62, y: 52 },
|
||||
{ name: "Vancouver", x: 22, y: 55 },
|
||||
{ name: "Toronto", x: 60, y: 58 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Germany",
|
||||
hint: "Central European country",
|
||||
cities: [
|
||||
{ name: "Berlin", x: 58, y: 28 },
|
||||
{ name: "Munich", x: 48, y: 68 },
|
||||
{ name: "Hamburg", x: 42, y: 18 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {Country[]} */
|
||||
let _data = [];
|
||||
/** @type {Country[]} */
|
||||
let data = [];
|
||||
|
||||
/**
|
||||
* Load country data into memory. Safe to call multiple times.
|
||||
* Returns a Promise for future compatibility with a real API fetch.
|
||||
* @returns {Promise<Country[]>}
|
||||
*/
|
||||
function loadCountries() {
|
||||
if (!_data.length) _data = COUNTRIES_DATA;
|
||||
return Promise.resolve(_data);
|
||||
}
|
||||
/**
|
||||
* Load country data into memory. Safe to call multiple times.
|
||||
* Returns a Promise for future compatibility with a real API fetch.
|
||||
* @returns {Promise<Country[]>}
|
||||
*/
|
||||
export function loadCountries() {
|
||||
if (!data.length) data = COUNTRIES_DATA;
|
||||
return Promise.resolve(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a random subset of countries.
|
||||
* @param {number} count
|
||||
* @returns {Country[]}
|
||||
*/
|
||||
function getRandomCountries(count = 3) {
|
||||
return [..._data].sort(() => Math.random() - 0.5).slice(0, count);
|
||||
}
|
||||
/**
|
||||
* Return a random subset of countries.
|
||||
* @param {number} count
|
||||
* @returns {Country[]}
|
||||
*/
|
||||
export function getRandomCountries(count = 3) {
|
||||
return [...data].sort(() => Math.random() - 0.5).slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get city list for a specific country by name.
|
||||
* @param {string} countryName
|
||||
* @returns {City[]}
|
||||
*/
|
||||
function getCities(countryName) {
|
||||
const country = _data.find((c) => c.name === countryName);
|
||||
return country ? country.cities : [];
|
||||
}
|
||||
|
||||
return { loadCountries, getRandomCountries, getCities };
|
||||
})();
|
||||
/**
|
||||
* Get city list for a specific country by name.
|
||||
* @param {string} countryName
|
||||
* @returns {City[]}
|
||||
*/
|
||||
export function getCities(countryName) {
|
||||
const country = data.find((c) => c.name === countryName);
|
||||
return country ? country.cities : [];
|
||||
}
|
||||
|
||||
@ -1,172 +1,172 @@
|
||||
// drawing.js — canvas drawing module
|
||||
|
||||
const Drawing = (() => {
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let canvas;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let ctx;
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let canvas;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let ctx;
|
||||
|
||||
let isDrawing = false;
|
||||
/** @type {{ x: number, y: number }[]} */
|
||||
let points = [];
|
||||
/** @type {{ name: string, x: number, y: number }[]} */
|
||||
let cities = [];
|
||||
let isDrawing = false;
|
||||
/** @type {{ x: number, y: number }[]} */
|
||||
let points = [];
|
||||
/** @type {{ name: string, x: number, y: number }[]} */
|
||||
let cities = [];
|
||||
|
||||
const STROKE_COLOR = "#1a7fc4";
|
||||
const STROKE_WIDTH = 2.5;
|
||||
const STROKE_COLOR = "#1a7fc4";
|
||||
const STROKE_WIDTH = 2.5;
|
||||
|
||||
/**
|
||||
* Initialise the drawing module on a canvas element.
|
||||
* @param {HTMLCanvasElement} canvasEl
|
||||
*/
|
||||
function init(canvasEl) {
|
||||
canvas = canvasEl;
|
||||
ctx = canvas.getContext("2d");
|
||||
/**
|
||||
* Initialise the drawing module on a canvas element.
|
||||
* @param {HTMLCanvasElement} canvasEl
|
||||
*/
|
||||
export function init(canvasEl) {
|
||||
canvas = canvasEl;
|
||||
ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointerleave", onUp);
|
||||
canvas.style.touchAction = "none";
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointerleave", onUp);
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
_resize();
|
||||
window.addEventListener("resize", _resize);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
}
|
||||
|
||||
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
||||
function _resize() {
|
||||
if (!canvas) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
canvas.width = width * window.devicePixelRatio;
|
||||
canvas.height = height * window.devicePixelRatio;
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
_redraw();
|
||||
}
|
||||
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
canvas.width = width * window.devicePixelRatio;
|
||||
canvas.height = height * window.devicePixelRatio;
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a pointer event to canvas-local coordinates.
|
||||
* @param {PointerEvent} e
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
function _pos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
/**
|
||||
* Convert a pointer event to canvas-local coordinates.
|
||||
* @param {PointerEvent} e
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
function pos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onDown(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const p = _pos(e);
|
||||
points.push(p);
|
||||
/** @param {PointerEvent} e */
|
||||
function onDown(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const p = pos(e);
|
||||
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.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.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();
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onUp(e) {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
e.preventDefault();
|
||||
}
|
||||
// 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;
|
||||
|
||||
/** Clear the canvas and redraw city markers. */
|
||||
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).
|
||||
*/
|
||||
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.save();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = STROKE_COLOR;
|
||||
ctx.lineWidth = STROKE_WIDTH;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
points.forEach((p, i) =>
|
||||
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y),
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.roundRect(tx - textW / 2, ty - textH / 2 - 1, textW, textH, 4);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
/**
|
||||
* Return a copy of the current drawn points.
|
||||
* @returns {{ x: number, y: number }[]}
|
||||
*/
|
||||
function getPoints() {
|
||||
return [...points];
|
||||
}
|
||||
ctx.fillStyle = "#0b1f2a";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(city.name, tx, ty + 4);
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove event listeners and clean up. */
|
||||
function destroy() {
|
||||
window.removeEventListener("resize", _resize);
|
||||
}
|
||||
/** Redraw the full stroke from stored points. */
|
||||
function redraw() {
|
||||
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 ROUND_DURATION = 60; // seconds
|
||||
@ -11,29 +17,45 @@ let scores = [];
|
||||
let timerInterval = null;
|
||||
let timeLeft = ROUND_DURATION;
|
||||
|
||||
// ── DOM refs
|
||||
const elCountryName = document.getElementById("country-name");
|
||||
const elCountryHint = document.getElementById("country-hint");
|
||||
const elRoundNum = document.getElementById("round-num");
|
||||
const elTimerNum = document.getElementById("timer-num");
|
||||
const elTimerBar = document.getElementById("timer-bar");
|
||||
const elTimerWrap = document.querySelector(".game-timer");
|
||||
const elBtnClear = document.getElementById("btn-clear");
|
||||
const elBtnSubmit = document.getElementById("btn-submit");
|
||||
let elCountryName;
|
||||
let elCountryHint;
|
||||
let elRoundNum;
|
||||
let elTimerNum;
|
||||
let elTimerBar;
|
||||
let elTimerWrap;
|
||||
let elBtnClear;
|
||||
let elBtnSubmit;
|
||||
|
||||
// ── Init
|
||||
|
||||
/** Load countries and start the first round. */
|
||||
/** Initialise DOM refs, drawing, events, countries, and the first round. */
|
||||
async function initGame() {
|
||||
await Countries.loadCountries();
|
||||
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
|
||||
elCountryName = document.getElementById("country-name");
|
||||
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;
|
||||
scores = [];
|
||||
startRound();
|
||||
}
|
||||
|
||||
// ── Round
|
||||
|
||||
/** Set up UI and timer for the current round. */
|
||||
function startRound() {
|
||||
const country = roundCountries[currentRound];
|
||||
@ -42,14 +64,12 @@ function startRound() {
|
||||
elCountryName.textContent = country.name;
|
||||
elCountryHint.textContent = country.hint || "";
|
||||
|
||||
if (typeof window.updateRoundPips === "function") {
|
||||
window.updateRoundPips(currentRound + 1);
|
||||
}
|
||||
updateRoundPips(currentRound + 1);
|
||||
|
||||
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
||||
|
||||
Drawing.clear();
|
||||
Drawing.setCities(country.cities || []);
|
||||
setCities(country.cities || []);
|
||||
clear();
|
||||
|
||||
timeLeft = ROUND_DURATION;
|
||||
updateTimerUI();
|
||||
@ -96,14 +116,11 @@ function submitRound(auto = false) {
|
||||
clearInterval(timerInterval);
|
||||
elBtnSubmit.disabled = true;
|
||||
|
||||
const points = Drawing.getPoints();
|
||||
const score = Scoring.calculateScore(points);
|
||||
const points = getPoints();
|
||||
const score = calculateScore(points);
|
||||
scores.push(score);
|
||||
|
||||
if (typeof window.updateScoreDisplay === "function") {
|
||||
window.updateScoreDisplay(currentRound, score);
|
||||
}
|
||||
|
||||
updateScoreDisplay(currentRound, score);
|
||||
showScoreFeedback(score);
|
||||
|
||||
setTimeout(
|
||||
@ -124,7 +141,7 @@ function submitRound(auto = false) {
|
||||
* @param {number} score
|
||||
*/
|
||||
function showScoreFeedback(score) {
|
||||
const grade = Scoring.getGrade(score);
|
||||
const grade = getGrade(score);
|
||||
const el = document.getElementById("score-feedback");
|
||||
el.textContent = `${score}% ${grade.label}`;
|
||||
el.style.color = grade.color;
|
||||
@ -137,7 +154,7 @@ function showScoreFeedback(score) {
|
||||
}
|
||||
|
||||
/** Persist game state, update leaderboard, and navigate to results. */
|
||||
function finishGame() {
|
||||
async function finishGame() {
|
||||
const totalScore = scores.reduce((sum, s) => sum + s, 0);
|
||||
const state = {
|
||||
currentRound: TOTAL_ROUNDS,
|
||||
@ -145,19 +162,43 @@ function finishGame() {
|
||||
totalScore,
|
||||
countries: roundCountries.map((c) => c.name),
|
||||
};
|
||||
Storage.saveGameState(state);
|
||||
Storage.saveLeaderboard({
|
||||
name: Storage.getPlayerName(),
|
||||
totalScore,
|
||||
scores,
|
||||
date: new Date().toISOString(),
|
||||
});
|
||||
saveGameState(state);
|
||||
|
||||
try {
|
||||
await submitScore({
|
||||
lobbyName: getLobbyName(),
|
||||
playerName: getPlayerName(),
|
||||
totalScore,
|
||||
scores,
|
||||
countries: state.countries,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Could not submit score to backend.", error);
|
||||
}
|
||||
|
||||
location.href = "results.html";
|
||||
}
|
||||
|
||||
// ── Events
|
||||
elBtnClear.addEventListener("click", () => Drawing.clear());
|
||||
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
||||
/** @param {number} round */
|
||||
function updateRoundPips(round) {
|
||||
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);
|
||||
|
||||
@ -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.
|
||||
* 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} */ (
|
||||
document.getElementById("username")
|
||||
);
|
||||
const button = /** @type {HTMLButtonElement|null} */ (
|
||||
document.getElementById("reg-btn")
|
||||
);
|
||||
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
|
||||
|
||||
if (lobbyName) {
|
||||
Storage.saveLobbyName(lobbyName);
|
||||
window.location.href = "lobby.html";
|
||||
} else {
|
||||
if (!lobbyName) {
|
||||
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 observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@ -32,4 +46,6 @@ const observer = new IntersectionObserver(
|
||||
{ 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", () => {
|
||||
const currentPlayer = Storage.getPlayerName();
|
||||
const currentPlayer = getPlayerName();
|
||||
const body = document.getElementById("lb-body");
|
||||
|
||||
/**
|
||||
@ -16,9 +19,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Render the leaderboard from localStorage data. */
|
||||
function render() {
|
||||
const board = Storage.getLeaderboard();
|
||||
/** Render the leaderboard from backend data. */
|
||||
async function render() {
|
||||
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 = "";
|
||||
|
||||
if (!board.length) {
|
||||
@ -36,7 +52,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
board.forEach((entry, index) => {
|
||||
const rank = index + 1;
|
||||
const isYou = entry.name === currentPlayer;
|
||||
const playerName = entry.playerName || entry.name || "Anonymous";
|
||||
const isYou = playerName === currentPlayer;
|
||||
const date = entry.date
|
||||
? new Date(entry.date).toLocaleDateString("en-CH", {
|
||||
day: "2-digit",
|
||||
@ -51,7 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
row.innerHTML = `
|
||||
<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-date hide-sm">${date}</span>
|
||||
<span class="lb-score${rank <= 3 ? ` ${scoreClasses[rank - 1]}` : ""}">${entry.totalScore}</span>
|
||||
@ -59,11 +76,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
body.appendChild(row);
|
||||
});
|
||||
|
||||
// Show the current player's latest score bar
|
||||
const latest = board.find((entry) => entry.name === currentPlayer);
|
||||
const latest = board.find(
|
||||
(entry) => (entry.playerName || entry.name) === currentPlayer,
|
||||
);
|
||||
if (latest) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
const input = document.getElementById("username");
|
||||
@ -8,15 +16,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Display the current lobby name
|
||||
const lobbyNameEl = document.getElementById("lobby-name-display");
|
||||
if (lobbyNameEl) {
|
||||
lobbyNameEl.textContent = Storage.getLobbyName();
|
||||
lobbyNameEl.textContent = getLobbyName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the username input and navigate to the game.
|
||||
* Shows an inline error message on invalid input.
|
||||
*/
|
||||
btn.addEventListener("click", () => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const name = input.value.trim();
|
||||
const lobbyName = getLobbyName();
|
||||
|
||||
if (!name) {
|
||||
errMsg.textContent = "Please enter a username to continue.";
|
||||
@ -31,9 +40,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage.savePlayerName(name);
|
||||
Storage.clearGameState();
|
||||
location.href = "game.html";
|
||||
try {
|
||||
btn.disabled = true;
|
||||
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
|
||||
@ -48,7 +65,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
// Pre-fill username if the player has played before
|
||||
const existing = Storage.getPlayerName();
|
||||
const existing = getPlayerName();
|
||||
if (existing && existing !== "Anonymous") {
|
||||
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", () => {
|
||||
const state = Storage.getGameState();
|
||||
const name = Storage.getPlayerName();
|
||||
const state = getGameState();
|
||||
const name = getPlayerName();
|
||||
|
||||
document.getElementById("results-player").textContent = name;
|
||||
|
||||
@ -14,7 +17,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const { scores, totalScore, countries } = state;
|
||||
|
||||
// Determine emoji and title based on average score
|
||||
const avg = totalScore / 3;
|
||||
let emoji = "🌍";
|
||||
let title = "Not bad!";
|
||||
@ -36,12 +38,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("results-title").textContent = title;
|
||||
document.getElementById("total-score").textContent = totalScore;
|
||||
|
||||
// Grade badge
|
||||
const grade = Scoring.getGrade(avg);
|
||||
const grade = getGrade(avg);
|
||||
const gradeEl = document.getElementById("total-grade");
|
||||
gradeEl.textContent = `Grade ${grade.label}`;
|
||||
|
||||
// Round breakdown rows
|
||||
const rowsContainer = document.getElementById("round-rows");
|
||||
(scores || []).forEach((score, index) => {
|
||||
const row = document.createElement("div");
|
||||
@ -56,14 +56,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
rowsContainer.appendChild(row);
|
||||
});
|
||||
|
||||
// Animate score bars on next frame so CSS transition fires
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll(".round-row__bar").forEach((bar) => {
|
||||
bar.style.width = `${bar.dataset.target}%`;
|
||||
});
|
||||
});
|
||||
|
||||
// Country tags
|
||||
const tagsContainer = document.getElementById("countries-row");
|
||||
(countries || []).forEach((country) => {
|
||||
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.
|
||||
* NOTE: Currently uses an effort-based approximation.
|
||||
* TODO: Replace with real polygon comparison (IoU or Hausdorff distance).
|
||||
* @param {{ x: number, y: number }[]} drawnPoints
|
||||
* @returns {number} Score between 0 and 100.
|
||||
*/
|
||||
function calculateScore(drawnPoints) {
|
||||
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
||||
/**
|
||||
* Calculate a score from the drawn path points.
|
||||
* NOTE: Currently uses an effort-based approximation.
|
||||
* TODO: Replace with real polygon comparison (IoU or Hausdorff distance).
|
||||
* @param {{ x: number, y: number }[]} drawnPoints
|
||||
* @returns {number} Score between 0 and 100.
|
||||
*/
|
||||
export function calculateScore(drawnPoints) {
|
||||
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
||||
|
||||
const effort = Math.min(drawnPoints.length / 300, 1); // 0–1
|
||||
const base = 40 + Math.round(effort * 45); // 40–85
|
||||
const jitter = Math.round((Math.random() - 0.5) * 14); // ±7
|
||||
return Math.max(0, Math.min(100, base + jitter));
|
||||
}
|
||||
const effort = Math.min(drawnPoints.length / 300, 1); // 0-1
|
||||
const base = 40 + Math.round(effort * 45); // 40-85
|
||||
const jitter = Math.round((Math.random() - 0.5) * 14); // +/-7
|
||||
return Math.max(0, Math.min(100, base + jitter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a drawn polygon against a reference polygon.
|
||||
* Stub — reserved for future IoU / Hausdorff implementation.
|
||||
* @param {{ x: number, y: number }[]} _drawnPoints
|
||||
* @param {{ x: number, y: number }[]} _referencePolygon - Normalised 0–1 coords.
|
||||
* @returns {number} Score between 0 and 100.
|
||||
*/
|
||||
function compareShapes(_drawnPoints, _referencePolygon) {
|
||||
// TODO: implement real shape comparison
|
||||
return 0;
|
||||
}
|
||||
/**
|
||||
* Compare a drawn polygon against a reference polygon.
|
||||
* Stub - reserved for future IoU / Hausdorff implementation.
|
||||
* @param {{ x: number, y: number }[]} _drawnPoints
|
||||
* @param {{ x: number, y: number }[]} _referencePolygon - Normalised 0-1 coords.
|
||||
* @returns {number} Score between 0 and 100.
|
||||
*/
|
||||
export function compareShapes(_drawnPoints, _referencePolygon) {
|
||||
// TODO: implement real shape comparison
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric score to a letter grade with colour.
|
||||
* @param {number} score
|
||||
* @returns {{ label: string, color: string }}
|
||||
*/
|
||||
function getGrade(score) {
|
||||
if (score >= 90) return { label: "S", color: "#f0b429" };
|
||||
if (score >= 75) return { label: "A", color: "#41b869" };
|
||||
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||
return { label: "D", color: "#e05c5c" };
|
||||
}
|
||||
|
||||
return { calculateScore, compareShapes, getGrade };
|
||||
})();
|
||||
/**
|
||||
* Map a numeric score to a letter grade with colour.
|
||||
* @param {number} score
|
||||
* @returns {{ label: string, color: string }}
|
||||
*/
|
||||
export function getGrade(score) {
|
||||
if (score >= 90) return { label: "S", color: "#f0b429" };
|
||||
if (score >= 75) return { label: "A", color: "#41b869" };
|
||||
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||
return { label: "D", color: "#e05c5c" };
|
||||
}
|
||||
|
||||
@ -1,105 +1,90 @@
|
||||
// storage.js — localStorage helpers
|
||||
// storage.js - localStorage helpers
|
||||
|
||||
const Storage = (() => {
|
||||
const KEYS = {
|
||||
PLAYER_NAME: "gd_playerName",
|
||||
LOBBY_NAME: "gd_lobbyName",
|
||||
GAME_STATE: "gd_gameState",
|
||||
LEADERBOARD: "gd_leaderboard",
|
||||
};
|
||||
const KEYS = {
|
||||
PLAYER_NAME: "gd_playerName",
|
||||
LOBBY_NAME: "gd_lobbyName",
|
||||
GAME_STATE: "gd_gameState",
|
||||
LEADERBOARD: "gd_leaderboard",
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely write a value to localStorage.
|
||||
* Silently fails if storage quota is exceeded or unavailable.
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
*/
|
||||
function _set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
console.warn(`Storage: could not write key "${key}"`);
|
||||
}
|
||||
/**
|
||||
* Safely write a value to localStorage.
|
||||
* Silently fails if storage quota is exceeded or unavailable.
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
*/
|
||||
function setItem(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
console.warn(`Storage: could not write key "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function savePlayerName(name) {
|
||||
_set(KEYS.PLAYER_NAME, name.trim());
|
||||
/** @param {string} name */
|
||||
export function savePlayerName(name) {
|
||||
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} */
|
||||
function getPlayerName() {
|
||||
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
|
||||
export function clearGameState() {
|
||||
localStorage.removeItem(KEYS.GAME_STATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
function saveLobbyName(name) {
|
||||
_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,
|
||||
};
|
||||
})();
|
||||
export function clearLeaderboard() {
|
||||
localStorage.removeItem(KEYS.LEADERBOARD);
|
||||
}
|
||||
|
||||
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