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

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
"name": "fs2026-frontend",
"version": "1.0.0",
"license": "ISC",
"type": "module",
"devDependencies": {
"@biomejs/biome": "2.3.14"
}

View File

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

View File

@ -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
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,7 +5,6 @@
* @typedef {{ name: string, hint: string, cities: City[] }} Country
*/
const Countries = (() => {
/** @type {Country[]} */
const COUNTRIES_DATA = [
{
@ -101,16 +100,16 @@ const Countries = (() => {
];
/** @type {Country[]} */
let _data = [];
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);
export function loadCountries() {
if (!data.length) data = COUNTRIES_DATA;
return Promise.resolve(data);
}
/**
@ -118,8 +117,8 @@ const Countries = (() => {
* @param {number} count
* @returns {Country[]}
*/
function getRandomCountries(count = 3) {
return [..._data].sort(() => Math.random() - 0.5).slice(0, count);
export function getRandomCountries(count = 3) {
return [...data].sort(() => Math.random() - 0.5).slice(0, count);
}
/**
@ -127,10 +126,7 @@ const Countries = (() => {
* @param {string} countryName
* @returns {City[]}
*/
function getCities(countryName) {
const country = _data.find((c) => c.name === countryName);
export function getCities(countryName) {
const country = data.find((c) => c.name === countryName);
return country ? country.cities : [];
}
return { loadCountries, getRandomCountries, getCities };
})();

View File

@ -1,6 +1,5 @@
// drawing.js — canvas drawing module
const Drawing = (() => {
/** @type {HTMLCanvasElement} */
let canvas;
/** @type {CanvasRenderingContext2D} */
@ -19,7 +18,7 @@ const Drawing = (() => {
* Initialise the drawing module on a canvas element.
* @param {HTMLCanvasElement} canvasEl
*/
function init(canvasEl) {
export function init(canvasEl) {
canvas = canvasEl;
ctx = canvas.getContext("2d");
@ -29,18 +28,18 @@ const Drawing = (() => {
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() {
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();
redraw();
}
/**
@ -48,7 +47,7 @@ const Drawing = (() => {
* @param {PointerEvent} e
* @returns {{ x: number, y: number }}
*/
function _pos(e) {
function pos(e) {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
@ -57,7 +56,7 @@ const Drawing = (() => {
function onDown(e) {
e.preventDefault();
isDrawing = true;
const p = _pos(e);
const p = pos(e);
points.push(p);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
@ -67,7 +66,7 @@ const Drawing = (() => {
function onMove(e) {
if (!isDrawing) return;
e.preventDefault();
const p = _pos(e);
const p = pos(e);
points.push(p);
ctx.lineTo(p.x, p.y);
ctx.strokeStyle = STROKE_COLOR;
@ -85,25 +84,25 @@ const Drawing = (() => {
}
/** Clear the canvas and redraw city markers. */
function clear() {
export function clear() {
points = [];
if (!ctx) return;
const { width, height } = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, width, height);
_drawCities();
drawCities();
}
/**
* Set city markers to display on the canvas.
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0100).
*/
function setCities(cityList) {
export function setCities(cityList) {
cities = cityList || [];
_drawCities();
drawCities();
}
/** Render all city markers with labels. */
function _drawCities() {
function drawCities() {
if (!ctx || !cities.length) return;
const { width, height } = canvas.getBoundingClientRect();
@ -141,17 +140,21 @@ const Drawing = (() => {
}
/** Redraw the full stroke from stored points. */
function _redraw() {
_drawCities();
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) =>
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y),
);
points.forEach((p, i) => {
if (i === 0) {
ctx.moveTo(p.x, p.y);
} else {
ctx.lineTo(p.x, p.y);
}
});
ctx.stroke();
}
@ -159,14 +162,11 @@ const Drawing = (() => {
* Return a copy of the current drawn points.
* @returns {{ x: number, y: number }[]}
*/
function getPoints() {
export function getPoints() {
return [...points];
}
/** Remove event listeners and clean up. */
function destroy() {
window.removeEventListener("resize", _resize);
export function destroy() {
window.removeEventListener("resize", resize);
}
return { init, clear, setCities, getPoints, destroy };
})();

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 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(),
saveGameState(state);
try {
await submitScore({
lobbyName: getLobbyName(),
playerName: getPlayerName(),
totalScore,
scores,
date: new Date().toISOString(),
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);

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.
* 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);
});

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", () => {
const currentPlayer = Storage.getPlayerName();
const currentPlayer = getPlayerName();
const body = document.getElementById("lb-body");
/**
@ -16,9 +19,22 @@ document.addEventListener("DOMContentLoaded", () => {
.replace(/>/g, "&gt;");
}
/** 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;
}
}

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", () => {
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();
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;
}

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", () => {
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");

View File

@ -1,6 +1,5 @@
// 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.
@ -8,23 +7,23 @@ const Scoring = (() => {
* @param {{ x: number, y: number }[]} drawnPoints
* @returns {number} Score between 0 and 100.
*/
function calculateScore(drawnPoints) {
export function calculateScore(drawnPoints) {
if (!drawnPoints || drawnPoints.length < 10) return 0;
const effort = Math.min(drawnPoints.length / 300, 1); // 01
const base = 40 + Math.round(effort * 45); // 4085
const jitter = Math.round((Math.random() - 0.5) * 14); // ±7
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.
* Stub - reserved for future IoU / Hausdorff implementation.
* @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.
*/
function compareShapes(_drawnPoints, _referencePolygon) {
export function compareShapes(_drawnPoints, _referencePolygon) {
// TODO: implement real shape comparison
return 0;
}
@ -34,13 +33,10 @@ const Scoring = (() => {
* @param {number} score
* @returns {{ label: string, color: string }}
*/
function getGrade(score) {
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" };
}
return { calculateScore, compareShapes, getGrade };
})();

View File

@ -1,6 +1,5 @@
// storage.js localStorage helpers
// storage.js - localStorage helpers
const Storage = (() => {
const KEYS = {
PLAYER_NAME: "gd_playerName",
LOBBY_NAME: "gd_lobbyName",
@ -14,7 +13,7 @@ const Storage = (() => {
* @param {string} key
* @param {string} value
*/
function _set(key, value) {
function setItem(key, value) {
try {
localStorage.setItem(key, value);
} catch {
@ -23,36 +22,36 @@ const Storage = (() => {
}
/** @param {string} name */
function savePlayerName(name) {
_set(KEYS.PLAYER_NAME, name.trim());
export function savePlayerName(name) {
setItem(KEYS.PLAYER_NAME, name.trim());
}
/** @returns {string} */
function getPlayerName() {
export function getPlayerName() {
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
}
/** @param {string} name */
function saveLobbyName(name) {
_set(KEYS.LOBBY_NAME, name.trim());
export function saveLobbyName(name) {
setItem(KEYS.LOBBY_NAME, name.trim());
}
/** @returns {string} */
function getLobbyName() {
export 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));
export function saveGameState(state) {
setItem(KEYS.GAME_STATE, JSON.stringify(state));
}
/**
* @returns {{ scores: number[], totalScore: number, countries: string[] } | null}
*/
function getGameState() {
export function getGameState() {
try {
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
} catch {
@ -60,25 +59,25 @@ const Storage = (() => {
}
}
function clearGameState() {
export function clearGameState() {
localStorage.removeItem(KEYS.GAME_STATE);
}
/**
* Add an entry to the leaderboard and keep the top 20.
* Add an entry to the local fallback leaderboard and keep the top 20.
* @param {{ name: string, totalScore: number, scores: number[], date: string }} entry
*/
function saveLeaderboard(entry) {
export 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)));
setItem(KEYS.LEADERBOARD, JSON.stringify(board.slice(0, 20)));
}
/**
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
*/
function getLeaderboard() {
export function getLeaderboard() {
try {
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
} catch {
@ -86,20 +85,6 @@ const Storage = (() => {
}
}
function clearLeaderboard() {
export 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),
);