add JSDoc types, fix timer CSS classes, safe storage writes
This commit is contained in:
parent
aa1865d734
commit
a1be788d36
@ -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 };
|
||||
|
||||
@ -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 (0–100).
|
||||
*/
|
||||
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 };
|
||||
})();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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); // 0–1
|
||||
const base = 40 + Math.round(effort * 45); // 40–85
|
||||
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); // 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub for real shape comparison (future).
|
||||
* drawnPoints: [{x,y}, ...]
|
||||
* referencePolygon: [{x,y}, ...] (normalised 0–1 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 0–1 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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user