niklas #2
@ -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 = (() => {
|
const Countries = (() => {
|
||||||
|
/** @type {Country[]} */
|
||||||
const COUNTRIES_DATA = [
|
const COUNTRIES_DATA = [
|
||||||
{
|
{
|
||||||
"name": "Switzerland",
|
name: "Switzerland",
|
||||||
"hint": "Alpine country in Central Europe",
|
hint: "Alpine country in Central Europe",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Bern", "x": 48, "y": 58 },
|
{ name: "Bern", x: 48, y: 58 },
|
||||||
{ "name": "Zürich", "x": 58, "y": 38 },
|
{ name: "Zürich", x: 58, y: 38 },
|
||||||
{ "name": "Geneva", "x": 22, "y": 72 }
|
{ name: "Geneva", x: 22, y: 72 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Norway",
|
name: "Norway",
|
||||||
"hint": "Scandinavian country with long coastline",
|
hint: "Scandinavian country with long coastline",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Oslo", "x": 55, "y": 72 },
|
{ name: "Oslo", x: 55, y: 72 },
|
||||||
{ "name": "Bergen", "x": 32, "y": 60 },
|
{ name: "Bergen", x: 32, y: 60 },
|
||||||
{ "name": "Tromsø", "x": 62, "y": 18 }
|
{ name: "Tromsø", x: 62, y: 18 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Italy",
|
name: "Italy",
|
||||||
"hint": "Boot-shaped peninsula in Southern Europe",
|
hint: "Boot-shaped peninsula in Southern Europe",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Rome", "x": 52, "y": 58 },
|
{ name: "Rome", x: 52, y: 58 },
|
||||||
{ "name": "Milan", "x": 42, "y": 22 },
|
{ name: "Milan", x: 42, y: 22 },
|
||||||
{ "name": "Naples", "x": 58, "y": 72 }
|
{ name: "Naples", x: 58, y: 72 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Japan",
|
name: "Japan",
|
||||||
"hint": "Island nation in East Asia",
|
hint: "Island nation in East Asia",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Tokyo", "x": 72, "y": 48 },
|
{ name: "Tokyo", x: 72, y: 48 },
|
||||||
{ "name": "Osaka", "x": 58, "y": 58 },
|
{ name: "Osaka", x: 58, y: 58 },
|
||||||
{ "name": "Sapporo", "x": 70, "y": 22 }
|
{ name: "Sapporo", x: 70, y: 22 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Brazil",
|
name: "Brazil",
|
||||||
"hint": "Largest country in South America",
|
hint: "Largest country in South America",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Brasília", "x": 58, "y": 52 },
|
{ name: "Brasília", x: 58, y: 52 },
|
||||||
{ "name": "São Paulo", "x": 60, "y": 68 },
|
{ name: "São Paulo", x: 60, y: 68 },
|
||||||
{ "name": "Manaus", "x": 38, "y": 38 }
|
{ name: "Manaus", x: 38, y: 38 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Australia",
|
name: "Australia",
|
||||||
"hint": "Continent and country in the Southern Hemisphere",
|
hint: "Continent and country in the Southern Hemisphere",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Canberra", "x": 72, "y": 72 },
|
{ name: "Canberra", x: 72, y: 72 },
|
||||||
{ "name": "Sydney", "x": 78, "y": 68 },
|
{ name: "Sydney", x: 78, y: 68 },
|
||||||
{ "name": "Perth", "x": 22, "y": 65 }
|
{ name: "Perth", x: 22, y: 65 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "France",
|
name: "France",
|
||||||
"hint": "Western Europe, roughly hexagonal shape",
|
hint: "Western Europe, roughly hexagonal shape",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Paris", "x": 50, "y": 32 },
|
{ name: "Paris", x: 50, y: 32 },
|
||||||
{ "name": "Lyon", "x": 58, "y": 55 },
|
{ name: "Lyon", x: 58, y: 55 },
|
||||||
{ "name": "Marseille", "x": 58, "y": 72 }
|
{ name: "Marseille", x: 58, y: 72 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "India",
|
name: "India",
|
||||||
"hint": "Large peninsula in South Asia",
|
hint: "Large peninsula in South Asia",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "New Delhi", "x": 46, "y": 28 },
|
{ name: "New Delhi", x: 46, y: 28 },
|
||||||
{ "name": "Mumbai", "x": 32, "y": 55 },
|
{ name: "Mumbai", x: 32, y: 55 },
|
||||||
{ "name": "Chennai", "x": 52, "y": 72 }
|
{ name: "Chennai", x: 52, y: 72 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Canada",
|
name: "Canada",
|
||||||
"hint": "Second largest country in the world",
|
hint: "Second largest country in the world",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Ottawa", "x": 62, "y": 52 },
|
{ name: "Ottawa", x: 62, y: 52 },
|
||||||
{ "name": "Vancouver", "x": 22, "y": 55 },
|
{ name: "Vancouver", x: 22, y: 55 },
|
||||||
{ "name": "Toronto", "x": 60, "y": 58 }
|
{ name: "Toronto", x: 60, y: 58 },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Germany",
|
name: "Germany",
|
||||||
"hint": "Central European country",
|
hint: "Central European country",
|
||||||
"cities": [
|
cities: [
|
||||||
{ "name": "Berlin", "x": 58, "y": 28 },
|
{ name: "Berlin", x: 58, y: 28 },
|
||||||
{ "name": "Munich", "x": 48, "y": 68 },
|
{ name: "Munich", x: 48, y: 68 },
|
||||||
{ "name": "Hamburg", "x": 42, "y": 18 }
|
{ name: "Hamburg", x: 42, y: 18 },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @type {Country[]} */
|
||||||
let _data = [];
|
let _data = [];
|
||||||
|
|
||||||
async function loadCountries() {
|
/**
|
||||||
if (_data.length) return _data;
|
* Load country data into memory. Safe to call multiple times.
|
||||||
_data = COUNTRIES_DATA;
|
* Returns a Promise for future compatibility with a real API fetch.
|
||||||
return _data;
|
* @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) {
|
function getRandomCountries(count = 3) {
|
||||||
const shuffled = [..._data].sort(() => Math.random() - 0.5);
|
return [..._data].sort(() => Math.random() - 0.5).slice(0, count);
|
||||||
return shuffled.slice(0, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get city list for a specific country by name.
|
||||||
|
* @param {string} countryName
|
||||||
|
* @returns {City[]}
|
||||||
|
*/
|
||||||
function getCities(countryName) {
|
function getCities(countryName) {
|
||||||
const c = _data.find(c => c.name === countryName);
|
const country = _data.find((c) => c.name === countryName);
|
||||||
return c ? c.cities : [];
|
return country ? country.cities : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loadCountries, getRandomCountries, getCities };
|
return { loadCountries, getRandomCountries, getCities };
|
||||||
|
|||||||
@ -1,22 +1,31 @@
|
|||||||
// drawing.js — canvas drawing module
|
// drawing.js — canvas drawing module
|
||||||
|
|
||||||
const Drawing = (() => {
|
const Drawing = (() => {
|
||||||
let canvas, ctx;
|
/** @type {HTMLCanvasElement} */
|
||||||
let isDrawing = false;
|
let canvas;
|
||||||
let points = []; // [{x, y}, ...]
|
/** @type {CanvasRenderingContext2D} */
|
||||||
let cities = []; // [{name, x, y}, ...] (% coords)
|
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_COLOR = "#1a7fc4";
|
||||||
const STROKE_WIDTH = 2.5;
|
const STROKE_WIDTH = 2.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the drawing module on a canvas element.
|
||||||
|
* @param {HTMLCanvasElement} canvasEl
|
||||||
|
*/
|
||||||
function init(canvasEl) {
|
function init(canvasEl) {
|
||||||
canvas = canvasEl;
|
canvas = canvasEl;
|
||||||
ctx = canvas.getContext("2d");
|
ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
// Pointer events (mouse + touch)
|
canvas.addEventListener("pointerdown", onDown);
|
||||||
canvas.addEventListener("pointerdown", onDown);
|
canvas.addEventListener("pointermove", onMove);
|
||||||
canvas.addEventListener("pointermove", onMove);
|
canvas.addEventListener("pointerup", onUp);
|
||||||
canvas.addEventListener("pointerup", onUp);
|
|
||||||
canvas.addEventListener("pointerleave", onUp);
|
canvas.addEventListener("pointerleave", onUp);
|
||||||
canvas.style.touchAction = "none";
|
canvas.style.touchAction = "none";
|
||||||
|
|
||||||
@ -24,6 +33,7 @@ const Drawing = (() => {
|
|||||||
window.addEventListener("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;
|
if (!canvas) return;
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
@ -33,14 +43,17 @@ const Drawing = (() => {
|
|||||||
_redraw();
|
_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a pointer event to canvas-local coordinates.
|
||||||
|
* @param {PointerEvent} e
|
||||||
|
* @returns {{ x: number, y: number }}
|
||||||
|
*/
|
||||||
function _pos(e) {
|
function _pos(e) {
|
||||||
const r = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
return {
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
x: (e.clientX - r.left),
|
|
||||||
y: (e.clientY - r.top),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} e */
|
||||||
function onDown(e) {
|
function onDown(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDrawing = true;
|
isDrawing = true;
|
||||||
@ -50,6 +63,7 @@ const Drawing = (() => {
|
|||||||
ctx.moveTo(p.x, p.y);
|
ctx.moveTo(p.x, p.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} e */
|
||||||
function onMove(e) {
|
function onMove(e) {
|
||||||
if (!isDrawing) return;
|
if (!isDrawing) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -63,54 +77,55 @@ const Drawing = (() => {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} e */
|
||||||
function onUp(e) {
|
function onUp(e) {
|
||||||
if (!isDrawing) return;
|
if (!isDrawing) return;
|
||||||
isDrawing = false;
|
isDrawing = false;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear the canvas and redraw city markers. */
|
||||||
function clear() {
|
function clear() {
|
||||||
points = [];
|
points = [];
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const w = canvas.getBoundingClientRect().width;
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
const h = canvas.getBoundingClientRect().height;
|
ctx.clearRect(0, 0, width, height);
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
_drawCities();
|
_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) {
|
function setCities(cityList) {
|
||||||
cities = cityList || [];
|
cities = cityList || [];
|
||||||
_drawCities();
|
_drawCities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render all city markers with labels. */
|
||||||
function _drawCities() {
|
function _drawCities() {
|
||||||
if (!ctx || !cities.length) return;
|
if (!ctx || !cities.length) return;
|
||||||
const w = canvas.getBoundingClientRect().width;
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
const h = canvas.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
cities.forEach(city => {
|
cities.forEach((city) => {
|
||||||
const cx = (city.x / 100) * w;
|
const cx = (city.x / 100) * width;
|
||||||
const cy = (city.y / 100) * h;
|
const cy = (city.y / 100) * height;
|
||||||
|
|
||||||
// Dot
|
// Dot
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
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.fill();
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Label
|
// White pill label background
|
||||||
ctx.font = "bold 11px 'DM Sans', sans-serif";
|
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 textW = ctx.measureText(city.name).width + 10;
|
||||||
const textH = 16;
|
const textH = 16;
|
||||||
const tx = cx;
|
const tx = cx;
|
||||||
const ty = cy - 14;
|
const ty = cy - 14;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
||||||
@ -120,10 +135,12 @@ const Drawing = (() => {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
ctx.fillStyle = "#0b1f2a";
|
ctx.fillStyle = "#0b1f2a";
|
||||||
|
ctx.textAlign = "center";
|
||||||
ctx.fillText(city.name, tx, ty + 4);
|
ctx.fillText(city.name, tx, ty + 4);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redraw the full stroke from stored points. */
|
||||||
function _redraw() {
|
function _redraw() {
|
||||||
_drawCities();
|
_drawCities();
|
||||||
if (!points.length) return;
|
if (!points.length) return;
|
||||||
@ -132,17 +149,18 @@ const Drawing = (() => {
|
|||||||
ctx.lineWidth = STROKE_WIDTH;
|
ctx.lineWidth = STROKE_WIDTH;
|
||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
ctx.lineCap = "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();
|
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() {
|
/** Remove event listeners and clean up. */
|
||||||
window.removeEventListener("resize", _resize);
|
function destroy() { window.removeEventListener("resize", _resize); }
|
||||||
}
|
|
||||||
|
|
||||||
return { init, clear, setCities, getPoints, destroy };
|
return { init, clear, setCities, getPoints, destroy };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
const TOTAL_ROUNDS = 3;
|
const TOTAL_ROUNDS = 3;
|
||||||
const ROUND_DURATION = 60; // seconds
|
const ROUND_DURATION = 60; // seconds
|
||||||
|
|
||||||
|
/** @type {import('./countries.js').Country[]} */
|
||||||
let roundCountries = [];
|
let roundCountries = [];
|
||||||
let currentRound = 0;
|
let currentRound = 0;
|
||||||
|
/** @type {number[]} */
|
||||||
let scores = [];
|
let scores = [];
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
let timeLeft = ROUND_DURATION;
|
let timeLeft = ROUND_DURATION;
|
||||||
@ -15,10 +17,13 @@ const elCountryHint = document.getElementById("country-hint");
|
|||||||
const elRoundNum = document.getElementById("round-num");
|
const elRoundNum = document.getElementById("round-num");
|
||||||
const elTimerNum = document.getElementById("timer-num");
|
const elTimerNum = document.getElementById("timer-num");
|
||||||
const elTimerBar = document.getElementById("timer-bar");
|
const elTimerBar = document.getElementById("timer-bar");
|
||||||
|
const elTimerWrap = document.querySelector(".game-timer");
|
||||||
const elBtnClear = document.getElementById("btn-clear");
|
const elBtnClear = document.getElementById("btn-clear");
|
||||||
const elBtnSubmit = document.getElementById("btn-submit");
|
const elBtnSubmit = document.getElementById("btn-submit");
|
||||||
|
|
||||||
// ── Init
|
// ── Init
|
||||||
|
|
||||||
|
/** Load countries and start the first round. */
|
||||||
async function initGame() {
|
async function initGame() {
|
||||||
await Countries.loadCountries();
|
await Countries.loadCountries();
|
||||||
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
|
roundCountries = Countries.getRandomCountries(TOTAL_ROUNDS);
|
||||||
@ -28,66 +33,61 @@ async function initGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Round
|
// ── Round
|
||||||
|
|
||||||
|
/** Set up UI and timer for the current round. */
|
||||||
function startRound() {
|
function startRound() {
|
||||||
const country = roundCountries[currentRound];
|
const country = roundCountries[currentRound];
|
||||||
|
|
||||||
// Update UI
|
|
||||||
elRoundNum.textContent = currentRound + 1;
|
elRoundNum.textContent = currentRound + 1;
|
||||||
elCountryName.textContent = country.name;
|
elCountryName.textContent = country.name;
|
||||||
elCountryHint.textContent = country.hint || "";
|
elCountryHint.textContent = country.hint || "";
|
||||||
|
|
||||||
// Round pips
|
|
||||||
if (typeof window.updateRoundPips === "function") {
|
if (typeof window.updateRoundPips === "function") {
|
||||||
window.updateRoundPips(currentRound + 1);
|
window.updateRoundPips(currentRound + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset canvas placeholder
|
|
||||||
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
document.getElementById("canvas-wrap")?.classList.remove("has-drawing");
|
||||||
|
|
||||||
// Cities on canvas
|
|
||||||
Drawing.clear();
|
Drawing.clear();
|
||||||
Drawing.setCities(country.cities || []);
|
Drawing.setCities(country.cities || []);
|
||||||
|
|
||||||
// Timer
|
|
||||||
timeLeft = ROUND_DURATION;
|
timeLeft = ROUND_DURATION;
|
||||||
updateTimerUI();
|
updateTimerUI();
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
timerInterval = setInterval(tickTimer, 1000);
|
timerInterval = setInterval(tickTimer, 1000);
|
||||||
|
|
||||||
// Button state
|
elBtnSubmit.disabled = false;
|
||||||
elBtnSubmit.disabled = false;
|
|
||||||
elBtnSubmit.textContent = currentRound < TOTAL_ROUNDS - 1
|
elBtnSubmit.textContent = currentRound < TOTAL_ROUNDS - 1
|
||||||
? "Submit & Next Round →"
|
? "Submit & Next Round →"
|
||||||
: "Submit & See Results →";
|
: "Submit & See Results →";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decrement timer by one second and auto-submit when time runs out. */
|
||||||
function tickTimer() {
|
function tickTimer() {
|
||||||
timeLeft--;
|
timeLeft--;
|
||||||
updateTimerUI();
|
updateTimerUI();
|
||||||
if (timeLeft <= 0) {
|
if (timeLeft <= 0) {
|
||||||
clearInterval(timerInterval);
|
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() {
|
function updateTimerUI() {
|
||||||
elTimerNum.textContent = timeLeft;
|
elTimerNum.textContent = timeLeft;
|
||||||
const pct = (timeLeft / ROUND_DURATION) * 100;
|
elTimerBar.style.width = `${(timeLeft / ROUND_DURATION) * 100}%`;
|
||||||
elTimerBar.style.width = pct + "%";
|
|
||||||
|
|
||||||
// Colour shift
|
elTimerWrap.classList.toggle("timer--danger", timeLeft <= 10);
|
||||||
if (timeLeft <= 10) {
|
elTimerWrap.classList.toggle("timer--warning", timeLeft > 10 && timeLeft <= 20);
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
function submitRound(auto = false) {
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
elBtnSubmit.disabled = true;
|
elBtnSubmit.disabled = true;
|
||||||
@ -96,15 +96,12 @@ function submitRound(auto = false) {
|
|||||||
const score = Scoring.calculateScore(points);
|
const score = Scoring.calculateScore(points);
|
||||||
scores.push(score);
|
scores.push(score);
|
||||||
|
|
||||||
// Update sidebar score row
|
|
||||||
if (typeof window.updateScoreDisplay === "function") {
|
if (typeof window.updateScoreDisplay === "function") {
|
||||||
window.updateScoreDisplay(currentRound, score);
|
window.updateScoreDisplay(currentRound, score);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash score feedback
|
|
||||||
showScoreFeedback(score);
|
showScoreFeedback(score);
|
||||||
|
|
||||||
const delay = auto ? 400 : 1200;
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (currentRound < TOTAL_ROUNDS - 1) {
|
if (currentRound < TOTAL_ROUNDS - 1) {
|
||||||
currentRound++;
|
currentRound++;
|
||||||
@ -112,45 +109,47 @@ function submitRound(auto = false) {
|
|||||||
} else {
|
} else {
|
||||||
finishGame();
|
finishGame();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, auto ? 400 : 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Briefly display the score grade overlay on the canvas.
|
||||||
|
* @param {number} score
|
||||||
|
*/
|
||||||
function showScoreFeedback(score) {
|
function showScoreFeedback(score) {
|
||||||
const grade = Scoring.getGrade(score);
|
const grade = Scoring.getGrade(score);
|
||||||
const el = document.getElementById("score-feedback");
|
const el = document.getElementById("score-feedback");
|
||||||
el.textContent = `${score}% ${grade.label}`;
|
el.textContent = `${score}% ${grade.label}`;
|
||||||
el.style.color = grade.color;
|
el.style.color = grade.color;
|
||||||
el.style.opacity = "1";
|
el.style.opacity = "1";
|
||||||
el.style.transform = "translateY(0)";
|
el.style.transform = "translateY(0)";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
el.style.opacity = "0";
|
el.style.opacity = "0";
|
||||||
el.style.transform = "translateY(-10px)";
|
el.style.transform = "translateY(-10px)";
|
||||||
}, 900);
|
}, 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist game state, update leaderboard, and navigate to results. */
|
||||||
function finishGame() {
|
function finishGame() {
|
||||||
const totalScore = scores.reduce((a, b) => a + b, 0);
|
const totalScore = scores.reduce((sum, s) => sum + s, 0);
|
||||||
const state = {
|
const state = {
|
||||||
currentRound: TOTAL_ROUNDS,
|
currentRound: TOTAL_ROUNDS,
|
||||||
scores,
|
scores,
|
||||||
totalScore,
|
totalScore,
|
||||||
countries: roundCountries.map(c => c.name),
|
countries: roundCountries.map((c) => c.name),
|
||||||
};
|
};
|
||||||
Storage.saveGameState(state);
|
Storage.saveGameState(state);
|
||||||
|
|
||||||
// Save to leaderboard
|
|
||||||
Storage.saveLeaderboard({
|
Storage.saveLeaderboard({
|
||||||
name: Storage.getPlayerName(),
|
name: Storage.getPlayerName(),
|
||||||
totalScore,
|
totalScore,
|
||||||
scores,
|
scores,
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
location.href = "results.html";
|
location.href = "results.html";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events
|
// ── Events
|
||||||
elBtnClear.addEventListener("click", () => Drawing.clear());
|
elBtnClear.addEventListener("click", () => Drawing.clear());
|
||||||
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
elBtnSubmit.addEventListener("click", () => submitRound(false));
|
||||||
|
|
||||||
// ── Boot
|
// ── 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", () => {
|
document.getElementById("reg-btn")?.addEventListener("click", () => {
|
||||||
const lobbyInput = document.getElementById("username");
|
const lobbyInput = /** @type {HTMLInputElement|null} */ (document.getElementById("username"));
|
||||||
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
|
const lobbyName = lobbyInput ? lobbyInput.value.trim() : "";
|
||||||
if (lobbyName) {
|
|
||||||
Storage.saveLobbyName(lobbyName);
|
if (lobbyName) {
|
||||||
window.location.href = "lobby.html";
|
Storage.saveLobbyName(lobbyName);
|
||||||
} else {
|
window.location.href = "lobby.html";
|
||||||
lobbyInput?.focus();
|
} else {
|
||||||
}
|
lobbyInput?.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scroll reveal — animate elements into view as they enter the viewport
|
||||||
const reveals = document.querySelectorAll(".reveal");
|
const reveals = document.querySelectorAll(".reveal");
|
||||||
const io = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
entry.target.classList.add("visible");
|
entry.target.classList.add("visible");
|
||||||
io.unobserve(entry.target);
|
observer.unobserve(entry.target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ threshold: 0.12 },
|
{ threshold: 0.12 },
|
||||||
);
|
);
|
||||||
|
|
||||||
reveals.forEach((el) => {
|
reveals.forEach((el) => observer.observe(el));
|
||||||
io.observe(el);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
// lobby.js
|
// lobby.js — lobby page logic
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const input = document.getElementById("username");
|
const input = document.getElementById("username");
|
||||||
const btn = document.getElementById("btn-start");
|
const btn = document.getElementById("btn-start");
|
||||||
const errMsg = document.getElementById("name-error");
|
const errMsg = document.getElementById("name-error");
|
||||||
|
|
||||||
// Show lobby name
|
// Display the current lobby name
|
||||||
const lobbyNameEl = document.getElementById("lobby-name-display");
|
const lobbyNameEl = document.getElementById("lobby-name-display");
|
||||||
if (lobbyNameEl) {
|
if (lobbyNameEl) {
|
||||||
lobbyNameEl.textContent = Storage.getLobbyName();
|
lobbyNameEl.textContent = Storage.getLobbyName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the username input and navigate to the game.
|
||||||
|
* Shows an inline error message on invalid input.
|
||||||
|
*/
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
errMsg.textContent = "Please enter a username to continue.";
|
errMsg.textContent = "Please enter a username to continue.";
|
||||||
input.classList.add("input--error");
|
input.classList.add("input--error");
|
||||||
@ -25,21 +30,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
input.focus();
|
input.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage.savePlayerName(name);
|
Storage.savePlayerName(name);
|
||||||
Storage.clearGameState();
|
Storage.clearGameState();
|
||||||
location.href = "game.html";
|
location.href = "game.html";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear validation state on every keystroke
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
errMsg.textContent = "";
|
errMsg.textContent = "";
|
||||||
input.classList.remove("input--error");
|
input.classList.remove("input--error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow form submission via Enter key
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") btn.click();
|
if (e.key === "Enter") btn.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pre-fill if returning player
|
// Pre-fill username if the player has played before
|
||||||
const existing = Storage.getPlayerName();
|
const existing = Storage.getPlayerName();
|
||||||
if (existing && existing !== "Anonymous") {
|
if (existing && existing !== "Anonymous") {
|
||||||
input.value = existing;
|
input.value = existing;
|
||||||
|
|||||||
@ -3,38 +3,44 @@
|
|||||||
const Scoring = (() => {
|
const Scoring = (() => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate score from drawn path points.
|
* Calculate a score from the drawn path points.
|
||||||
* MVP: fake score based on number of points drawn (effort-based).
|
* NOTE: Currently uses an effort-based approximation.
|
||||||
* TODO: replace with real compareShapes() using polygon overlap.
|
* 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) {
|
function calculateScore(drawnPoints) {
|
||||||
if (!drawnPoints || drawnPoints.length < 10) return 0;
|
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 effort = Math.min(drawnPoints.length / 300, 1); // 0–1
|
const base = 40 + Math.round(effort * 45); // 40–85
|
||||||
const base = 40 + Math.round(effort * 45); // 40–85
|
const jitter = Math.round((Math.random() - 0.5) * 14); // ±7
|
||||||
const jitter = Math.round((Math.random() - 0.5) * 14); // ±7
|
return Math.max(0, Math.min(100, base + jitter));
|
||||||
const score = Math.max(0, Math.min(100, base + jitter));
|
|
||||||
return score;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stub for real shape comparison (future).
|
* Compare a drawn polygon against a reference polygon.
|
||||||
* drawnPoints: [{x,y}, ...]
|
* Stub — reserved for future IoU / Hausdorff implementation.
|
||||||
* referencePolygon: [{x,y}, ...] (normalised 0–1 coords)
|
* @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) {
|
function compareShapes(_drawnPoints, _referencePolygon) {
|
||||||
// TODO: implement IoU (Intersection over Union) or
|
// TODO: implement real shape comparison
|
||||||
// Hausdorff distance for polygon comparison.
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a numeric score to a letter grade with colour.
|
||||||
|
* @param {number} score
|
||||||
|
* @returns {{ label: string, color: string }}
|
||||||
|
*/
|
||||||
function getGrade(score) {
|
function getGrade(score) {
|
||||||
if (score >= 90) return { label: "S", color: "#f0b429" };
|
if (score >= 90) return { label: "S", color: "#f0b429" };
|
||||||
if (score >= 75) return { label: "A", color: "#41b869" };
|
if (score >= 75) return { label: "A", color: "#41b869" };
|
||||||
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
if (score >= 60) return { label: "B", color: "#1a7fc4" };
|
||||||
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
if (score >= 40) return { label: "C", color: "#7a9aaa" };
|
||||||
return { label: "D", color: "#e05c5c" };
|
return { label: "D", color: "#e05c5c" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { calculateScore, compareShapes, getGrade };
|
return { calculateScore, compareShapes, getGrade };
|
||||||
|
|||||||
@ -2,32 +2,46 @@
|
|||||||
|
|
||||||
const Storage = (() => {
|
const Storage = (() => {
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
PLAYER_NAME: "gd_playerName",
|
PLAYER_NAME: "gd_playerName",
|
||||||
LOBBY_NAME: "gd_lobbyName",
|
LOBBY_NAME: "gd_lobbyName",
|
||||||
GAME_STATE: "gd_gameState",
|
GAME_STATE: "gd_gameState",
|
||||||
LEADERBOARD: "gd_leaderboard",
|
LEADERBOARD: "gd_leaderboard",
|
||||||
};
|
};
|
||||||
|
|
||||||
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() {
|
/** @param {string} name */
|
||||||
return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous";
|
function savePlayerName(name) { _set(KEYS.PLAYER_NAME, name.trim()); }
|
||||||
}
|
|
||||||
|
|
||||||
function saveLobbyName(name) {
|
/** @returns {string} */
|
||||||
localStorage.setItem(KEYS.LOBBY_NAME, name.trim());
|
function getPlayerName() { return localStorage.getItem(KEYS.PLAYER_NAME) || "Anonymous"; }
|
||||||
}
|
|
||||||
|
|
||||||
function getLobbyName() {
|
/** @param {string} name */
|
||||||
return localStorage.getItem(KEYS.LOBBY_NAME) || "My Lobby";
|
function saveLobbyName(name) { _set(KEYS.LOBBY_NAME, name.trim()); }
|
||||||
}
|
|
||||||
|
|
||||||
function saveGameState(state) {
|
/** @returns {string} */
|
||||||
localStorage.setItem(KEYS.GAME_STATE, JSON.stringify(state));
|
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() {
|
function getGameState() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
|
return JSON.parse(localStorage.getItem(KEYS.GAME_STATE)) || null;
|
||||||
@ -36,18 +50,22 @@ const Storage = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearGameState() {
|
function clearGameState() { localStorage.removeItem(KEYS.GAME_STATE); }
|
||||||
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) {
|
function saveLeaderboard(entry) {
|
||||||
const board = getLeaderboard();
|
const board = getLeaderboard();
|
||||||
board.push(entry);
|
board.push(entry);
|
||||||
board.sort((a, b) => b.totalScore - a.totalScore);
|
board.sort((a, b) => b.totalScore - a.totalScore);
|
||||||
const top20 = board.slice(0, 20);
|
_set(KEYS.LEADERBOARD, JSON.stringify(board.slice(0, 20)));
|
||||||
localStorage.setItem(KEYS.LEADERBOARD, JSON.stringify(top20));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ name: string, totalScore: number, scores: number[], date: string }[]}
|
||||||
|
*/
|
||||||
function getLeaderboard() {
|
function getLeaderboard() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
|
return JSON.parse(localStorage.getItem(KEYS.LEADERBOARD)) || [];
|
||||||
@ -56,20 +74,12 @@ const Storage = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLeaderboard() {
|
function clearLeaderboard() { localStorage.removeItem(KEYS.LEADERBOARD); }
|
||||||
localStorage.removeItem(KEYS.LEADERBOARD);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
savePlayerName,
|
savePlayerName, getPlayerName,
|
||||||
getPlayerName,
|
saveLobbyName, getLobbyName,
|
||||||
saveLobbyName,
|
saveGameState, getGameState, clearGameState,
|
||||||
getLobbyName,
|
saveLeaderboard, getLeaderboard, clearLeaderboard,
|
||||||
saveGameState,
|
|
||||||
getGameState,
|
|
||||||
clearGameState,
|
|
||||||
saveLeaderboard,
|
|
||||||
getLeaderboard,
|
|
||||||
clearLeaderboard,
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -112,6 +112,15 @@
|
|||||||
transition: width 1s linear, background .4s ease;
|
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 */
|
||||||
.canvas-area {
|
.canvas-area {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user