add JSDoc types, fix timer CSS classes, safe storage writes

This commit is contained in:
pengniklas 2026-05-22 16:42:25 +02:00
parent aa1865d734
commit a1be788d36
8 changed files with 307 additions and 231 deletions

View File

@ -1,115 +1,135 @@
// countries.js — country data helpers (inline, no fetch needed)
// countries.js — country data and helpers
/**
* @typedef {{ name: string, x: number, y: number }} City
* @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: "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: "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: "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: "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: "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: "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: "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: "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: "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 }
]
}
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 = [];
async function loadCountries() {
if (_data.length) return _data;
_data = COUNTRIES_DATA;
return _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);
}
/**
* Return a random subset of countries.
* @param {number} count
* @returns {Country[]}
*/
function getRandomCountries(count = 3) {
const shuffled = [..._data].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
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 c = _data.find(c => c.name === countryName);
return c ? c.cities : [];
const country = _data.find((c) => c.name === countryName);
return country ? country.cities : [];
}
return { loadCountries, getRandomCountries, getCities };

View File

@ -1,22 +1,31 @@
// drawing.js — canvas drawing module
const Drawing = (() => {
let canvas, ctx;
let isDrawing = false;
let points = []; // [{x, y}, ...]
let cities = []; // [{name, x, y}, ...] (% coords)
/** @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 = [];
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");
// Pointer events (mouse + touch)
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointerleave", onUp);
canvas.style.touchAction = "none";
@ -24,6 +33,7 @@ const Drawing = (() => {
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();
@ -33,14 +43,17 @@ const Drawing = (() => {
_redraw();
}
/**
* Convert a pointer event to canvas-local coordinates.
* @param {PointerEvent} e
* @returns {{ x: number, y: number }}
*/
function _pos(e) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left),
y: (e.clientY - r.top),
};
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;
@ -50,6 +63,7 @@ const Drawing = (() => {
ctx.moveTo(p.x, p.y);
}
/** @param {PointerEvent} e */
function onMove(e) {
if (!isDrawing) return;
e.preventDefault();
@ -63,54 +77,55 @@ const Drawing = (() => {
ctx.stroke();
}
/** @param {PointerEvent} e */
function onUp(e) {
if (!isDrawing) return;
isDrawing = false;
e.preventDefault();
}
/** Clear the canvas and redraw city markers. */
function clear() {
points = [];
if (!ctx) return;
const w = canvas.getBoundingClientRect().width;
const h = canvas.getBoundingClientRect().height;
ctx.clearRect(0, 0, w, h);
const { width, height } = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, width, height);
_drawCities();
}
/**
* Set city markers to display on the canvas.
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0100).
*/
function setCities(cityList) {
cities = cityList || [];
_drawCities();
}
/** Render all city markers with labels. */
function _drawCities() {
if (!ctx || !cities.length) return;
const w = canvas.getBoundingClientRect().width;
const h = canvas.getBoundingClientRect().height;
const { width, height } = canvas.getBoundingClientRect();
cities.forEach(city => {
const cx = (city.x / 100) * w;
const cy = (city.y / 100) * h;
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.fillStyle = "rgba(240,180,40,0.9)";
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 1.5;
ctx.lineWidth = 1.5;
ctx.stroke();
// Label
// White pill label background
ctx.font = "bold 11px 'DM Sans', sans-serif";
ctx.fillStyle = "#0b1f2a";
ctx.textAlign = "center";
// White pill background
const textW = ctx.measureText(city.name).width + 10;
const textH = 16;
const tx = cx;
const ty = cy - 14;
const tx = cx;
const ty = cy - 14;
ctx.save();
ctx.fillStyle = "rgba(255,255,255,0.88)";
@ -120,10 +135,12 @@ const Drawing = (() => {
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;
@ -132,17 +149,18 @@ const Drawing = (() => {
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) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
ctx.stroke();
}
function getPoints() {
return [...points];
}
/**
* Return a copy of the current drawn points.
* @returns {{ x: number, y: number }[]}
*/
function getPoints() { return [...points]; }
function destroy() {
window.removeEventListener("resize", _resize);
}
/** Remove event listeners and clean up. */
function destroy() { window.removeEventListener("resize", _resize); }
return { init, clear, setCities, getPoints, destroy };
})();

View File

@ -3,8 +3,10 @@
const TOTAL_ROUNDS = 3;
const ROUND_DURATION = 60; // seconds
/** @type {import('./countries.js').Country[]} */
let roundCountries = [];
let currentRound = 0;
/** @type {number[]} */
let scores = [];
let timerInterval = null;
let timeLeft = ROUND_DURATION;
@ -15,10 +17,13 @@ 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");
// ── Init
/** Load countries and start the first round. */
async function initGame() {
await Countries.loadCountries();
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
@ -28,66 +33,61 @@ async function initGame() {
}
// ── Round
/** Set up UI and timer for the current round. */
function startRound() {
const country = roundCountries[currentRound];
// Update UI
elRoundNum.textContent = currentRound + 1;
elCountryName.textContent = country.name;
elCountryHint.textContent = country.hint || "";
// Round pips
if (typeof window.updateRoundPips === "function") {
window.updateRoundPips(currentRound + 1);
}
// Reset canvas placeholder
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
// Cities on canvas
Drawing.clear();
Drawing.setCities(country.cities || []);
// Timer
timeLeft = ROUND_DURATION;
updateTimerUI();
clearInterval(timerInterval);
timerInterval = setInterval(tickTimer, 1000);
// Button state
elBtnSubmit.disabled = false;
elBtnSubmit.disabled = false;
elBtnSubmit.textContent = currentRound < TOTAL_ROUNDS - 1
? "Submit & Next Round →"
: "Submit & See Results →";
}
/** Decrement timer by one second and auto-submit when time runs out. */
function tickTimer() {
timeLeft--;
updateTimerUI();
if (timeLeft <= 0) {
clearInterval(timerInterval);
submitRound(true); // auto-submit
submitRound(true);
}
}
/**
* Sync timer bar width and apply urgency CSS classes.
* Uses `.timer--warning` and `.timer--danger` instead of inline styles.
*/
function updateTimerUI() {
elTimerNum.textContent = timeLeft;
const pct = (timeLeft / ROUND_DURATION) * 100;
elTimerBar.style.width = pct + "%";
elTimerNum.textContent = timeLeft;
elTimerBar.style.width = `${(timeLeft / ROUND_DURATION) * 100}%`;
// Colour shift
if (timeLeft <= 10) {
elTimerBar.style.background = "#e05c5c";
elTimerNum.style.color = "#e05c5c";
} else if (timeLeft <= 20) {
elTimerBar.style.background = "#f0b429";
elTimerNum.style.color = "#f0b429";
} else {
elTimerBar.style.background = "";
elTimerNum.style.color = "";
}
elTimerWrap.classList.toggle("timer--danger", timeLeft <= 10);
elTimerWrap.classList.toggle("timer--warning", timeLeft > 10 && timeLeft <= 20);
}
/**
* Submit the current round, record the score, and advance or finish.
* @param {boolean} [auto=false] - True when triggered by timer expiry.
*/
function submitRound(auto = false) {
clearInterval(timerInterval);
elBtnSubmit.disabled = true;
@ -96,15 +96,12 @@ function submitRound(auto = false) {
const score = Scoring.calculateScore(points);
scores.push(score);
// Update sidebar score row
if (typeof window.updateScoreDisplay === "function") {
window.updateScoreDisplay(currentRound, score);
}
// Flash score feedback
showScoreFeedback(score);
const delay = auto ? 400 : 1200;
setTimeout(() => {
if (currentRound < TOTAL_ROUNDS - 1) {
currentRound++;
@ -112,45 +109,47 @@ function submitRound(auto = false) {
} else {
finishGame();
}
}, delay);
}, auto ? 400 : 1200);
}
/**
* Briefly display the score grade overlay on the canvas.
* @param {number} score
*/
function showScoreFeedback(score) {
const grade = Scoring.getGrade(score);
const el = document.getElementById("score-feedback");
el.textContent = `${score}% ${grade.label}`;
el.style.color = grade.color;
el.style.opacity = "1";
el.style.transform = "translateY(0)";
const grade = Scoring.getGrade(score);
const el = document.getElementById("score-feedback");
el.textContent = `${score}% ${grade.label}`;
el.style.color = grade.color;
el.style.opacity = "1";
el.style.transform = "translateY(0)";
setTimeout(() => {
el.style.opacity = "0";
el.style.opacity = "0";
el.style.transform = "translateY(-10px)";
}, 900);
}
/** Persist game state, update leaderboard, and navigate to results. */
function finishGame() {
const totalScore = scores.reduce((a, b) => a + b, 0);
const totalScore = scores.reduce((sum, s) => sum + s, 0);
const state = {
currentRound: TOTAL_ROUNDS,
scores,
totalScore,
countries: roundCountries.map(c => c.name),
countries: roundCountries.map((c) => c.name),
};
Storage.saveGameState(state);
// Save to leaderboard
Storage.saveLeaderboard({
name: Storage.getPlayerName(),
totalScore,
scores,
date: new Date().toISOString(),
});
location.href = "results.html";
}
// ── Events
elBtnClear.addEventListener("click", () => Drawing.clear());
elBtnClear.addEventListener("click", () => Drawing.clear());
elBtnSubmit.addEventListener("click", () => submitRound(false));
// ── Boot

View File

@ -1,27 +1,33 @@
// index.js — landing page logic
/**
* Handle the "Create game" button click.
* Validates the lobby name, persists it, and navigates to the lobby page.
*/
document.getElementById("reg-btn")?.addEventListener("click", () => {
const lobbyInput = document.getElementById("username");
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
if (lobbyName) {
Storage.saveLobbyName(lobbyName);
window.location.href = "lobby.html";
} else {
lobbyInput?.focus();
}
const lobbyInput = /** @type {HTMLInputElement|null} */ (document.getElementById("username"));
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
if (lobbyName) {
Storage.saveLobbyName(lobbyName);
window.location.href = "lobby.html";
} else {
lobbyInput?.focus();
}
});
// Scroll reveal — animate elements into view as they enter the viewport
const reveals = document.querySelectorAll(".reveal");
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
io.unobserve(entry.target);
}
});
},
{ threshold: 0.12 },
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.12 },
);
reveals.forEach((el) => {
io.observe(el);
});
reveals.forEach((el) => observer.observe(el));

View File

@ -1,18 +1,23 @@
// lobby.js
// lobby.js — lobby page logic
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("username");
const btn = document.getElementById("btn-start");
const errMsg = document.getElementById("name-error");
// Show lobby name
// Display the current lobby name
const lobbyNameEl = document.getElementById("lobby-name-display");
if (lobbyNameEl) {
lobbyNameEl.textContent = Storage.getLobbyName();
}
/**
* Validate the username input and navigate to the game.
* Shows an inline error message on invalid input.
*/
btn.addEventListener("click", () => {
const name = input.value.trim();
if (!name) {
errMsg.textContent = "Please enter a username to continue.";
input.classList.add("input--error");
@ -25,21 +30,24 @@ document.addEventListener("DOMContentLoaded", () => {
input.focus();
return;
}
Storage.savePlayerName(name);
Storage.clearGameState();
location.href = "game.html";
});
// Clear validation state on every keystroke
input.addEventListener("input", () => {
errMsg.textContent = "";
input.classList.remove("input--error");
});
// Allow form submission via Enter key
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") btn.click();
});
// Pre-fill if returning player
// Pre-fill username if the player has played before
const existing = Storage.getPlayerName();
if (existing && existing !== "Anonymous") {
input.value = existing;

View File

@ -3,38 +3,44 @@
const Scoring = (() => {
/**
* Calculate score from drawn path points.
* MVP: fake score based on number of points drawn (effort-based).
* TODO: replace with real compareShapes() using polygon overlap.
* 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;
// Fake scoring: reward effort + randomness so it feels real
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 score = Math.max(0, Math.min(100, base + jitter));
return score;
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
return Math.max(0, Math.min(100, base + jitter));
}
/**
* Stub for real shape comparison (future).
* drawnPoints: [{x,y}, ...]
* referencePolygon: [{x,y}, ...] (normalised 01 coords)
* 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 01 coords.
* @returns {number} Score between 0 and 100.
*/
function compareShapes(_drawnPoints, _referencePolygon) {
// TODO: implement IoU (Intersection over Union) or
// Hausdorff distance for polygon comparison.
// 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 { label: "D", color: "#e05c5c" };
}
return { calculateScore, compareShapes, getGrade };

View File

@ -2,32 +2,46 @@
const Storage = (() => {
const KEYS = {
PLAYER_NAME: "gd_playerName",
LOBBY_NAME: "gd_lobbyName",
GAME_STATE: "gd_gameState",
LEADERBOARD: "gd_leaderboard",
PLAYER_NAME: "gd_playerName",
LOBBY_NAME: "gd_lobbyName",
GAME_STATE: "gd_gameState",
LEADERBOARD: "gd_leaderboard",
};
function savePlayerName(name) {
localStorage.setItem(KEYS.PLAYER_NAME, name.trim());
/**
* 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}"`);
}
}
function getPlayerName() {
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
}
/** @param {string} name */
function savePlayerName(name) { _set(KEYS.PLAYER_NAME, name.trim()); }
function saveLobbyName(name) {
localStorage.setItem(KEYS.LOBBY_NAME, name.trim());
}
/** @returns {string} */
function getPlayerName() { return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous"; }
function getLobbyName() {
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
}
/** @param {string} name */
function saveLobbyName(name) { _set(KEYS.LOBBY_NAME, name.trim()); }
function saveGameState(state) {
localStorage.setItem(KEYS.GAME_STATE, JSON.stringify(state));
}
/** @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;
@ -36,18 +50,22 @@ const Storage = (() => {
}
}
function clearGameState() {
localStorage.removeItem(KEYS.GAME_STATE);
}
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);
const top20 = board.slice(0, 20);
localStorage.setItem(KEYS.LEADERBOARD, JSON.stringify(top20));
_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)) || [];
@ -56,20 +74,12 @@ const Storage = (() => {
}
}
function clearLeaderboard() {
localStorage.removeItem(KEYS.LEADERBOARD);
}
function clearLeaderboard() { localStorage.removeItem(KEYS.LEADERBOARD); }
return {
savePlayerName,
getPlayerName,
saveLobbyName,
getLobbyName,
saveGameState,
getGameState,
clearGameState,
saveLeaderboard,
getLeaderboard,
clearLeaderboard,
savePlayerName, getPlayerName,
saveLobbyName, getLobbyName,
saveGameState, getGameState, clearGameState,
saveLeaderboard, getLeaderboard, clearLeaderboard,
};
})();

View File

@ -112,6 +112,15 @@
transition: width 1s linear, background .4s ease;
}
/* Timer urgency states — applied via JS classList */
.timer--warning .timer-bar,
.timer--warning .timer-num { color: var(--gold); }
.timer--warning .timer-bar { background: var(--gold); }
.timer--danger .timer-bar,
.timer--danger .timer-num { color: var(--danger); }
.timer--danger .timer-bar { background: var(--danger); }
/* ── Canvas area */
.canvas-area {
display: grid;