From f9413da3de03126e5ed99073848ec94a825c65bc Mon Sep 17 00:00:00 2001 From: LucaJakob Date: Mon, 1 Jun 2026 08:40:23 +0200 Subject: [PATCH 1/4] add php backend --- README.md | 16 +- backend/data/lobbies.json | 7 + backend/index.php | 238 +++++++++++++++++++++ frontend/game.html | 40 +--- frontend/index.html | 12 +- frontend/leaderboard.html | 3 +- frontend/lobby.html | 3 +- frontend/package-lock.json | 355 ++++++++++++++++---------------- frontend/package.json | 3 +- frontend/results.html | 4 +- frontend/scripts/api.js | 57 +++++ frontend/scripts/countries.js | 246 +++++++++++----------- frontend/scripts/drawing.js | 308 +++++++++++++-------------- frontend/scripts/game.js | 123 +++++++---- frontend/scripts/index.js | 32 ++- frontend/scripts/leaderboard.js | 39 +++- frontend/scripts/lobby.js | 31 ++- frontend/scripts/results.js | 16 +- frontend/scripts/scoring.js | 80 ++++--- frontend/scripts/storage.js | 179 ++++++++-------- router.php | 38 ++++ serve.php | 22 ++ 22 files changed, 1124 insertions(+), 728 deletions(-) create mode 100644 backend/data/lobbies.json create mode 100644 backend/index.php create mode 100644 frontend/scripts/api.js create mode 100644 router.php create mode 100644 serve.php diff --git a/README.md b/README.md index 130a30b..afea277 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,13 @@ The game supports single-player sessions with a local leaderboard tracking top s ## How to start -Open the `frontend/index.html` file in a browser. That's it. +From the project root, run: + +```powershell +php serve.php +``` +Then open `http://localhost:8000/` in a browser. +You can override the port, but the default will be `8000`. --- @@ -95,7 +101,13 @@ The drawing part will likely be handled by WebGL and a canvas. Your browser must support **WebGL**. If you are uncertain, [check this website](https://get.webgl.org/). -Open the `frontend/index.html` file in a browser. That's it. +Start the app from the project root: + +```powershell +php serve.php +``` + +Then open `http://localhost:8000/` in a browser. ### Backend diff --git a/backend/data/lobbies.json b/backend/data/lobbies.json new file mode 100644 index 0000000..472acdf --- /dev/null +++ b/backend/data/lobbies.json @@ -0,0 +1,7 @@ +{ + "Test": { + "name": "Test", + "players": [], + "leaderboard": [] + } +} diff --git a/backend/index.php b/backend/index.php new file mode 100644 index 0000000..3dda880 --- /dev/null +++ b/backend/index.php @@ -0,0 +1,238 @@ + false, 'error' => 'Could not open data file.'], 500); + } + + flock($handle, $write ? LOCK_EX : LOCK_SH); + rewind($handle); + $raw = stream_get_contents($handle); + $lobbies = json_decode($raw ?: '{}', true); + if (!is_array($lobbies)) { + $lobbies = []; + } + + $result = $callback($lobbies); + + if ($write) { + rewind($handle); + ftruncate($handle, 0); + fwrite($handle, json_encode($lobbies, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + fflush($handle); + } + + flock($handle, LOCK_UN); + fclose($handle); + + return $result; +} + +function ensure_lobby(array &$lobbies, string $lobbyName): array +{ + if (!isset($lobbies[$lobbyName]) || !is_array($lobbies[$lobbyName])) { + $lobbies[$lobbyName] = [ + 'name' => $lobbyName, + 'players' => [], + 'leaderboard' => [], + ]; + } + + $lobbies[$lobbyName]['players'] = array_values($lobbies[$lobbyName]['players'] ?? []); + $lobbies[$lobbyName]['leaderboard'] = array_values($lobbies[$lobbyName]['leaderboard'] ?? []); + + return $lobbies[$lobbyName]; +} + +$action = $_GET['action'] ?? ''; +$body = read_body(); + +if ($action === 'createLobby') { + $lobbyName = clean_name($body['lobbyName'] ?? ''); + if ($lobbyName === '') { + respond(['ok' => false, 'error' => 'Lobby name is required.'], 400); + } + + $lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName): array { + return ensure_lobby($lobbies, $lobbyName); + }, true); + + respond(['ok' => true, 'lobby' => ['name' => $lobby['name']]]); +} + +if ($action === 'joinLobby') { + $lobbyName = clean_name($body['lobbyName'] ?? ''); + $playerName = clean_name($body['playerName'] ?? '', 24); + if ($lobbyName === '' || $playerName === '') { + respond(['ok' => false, 'error' => 'Lobby name and player name are required.'], 400); + } + + $lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName): array { + ensure_lobby($lobbies, $lobbyName); + if (!in_array($playerName, $lobbies[$lobbyName]['players'], true)) { + $lobbies[$lobbyName]['players'][] = $playerName; + } + return $lobbies[$lobbyName]; + }, true); + + respond(['ok' => true, 'lobby' => ['name' => $lobby['name'], 'players' => $lobby['players']]]); +} + +if ($action === 'leaveLobby') { + $lobbyName = clean_name($body['lobbyName'] ?? ''); + $playerName = clean_name($body['playerName'] ?? '', 24); + + with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName): void { + if ($lobbyName === '' || $playerName === '' || !isset($lobbies[$lobbyName])) { + return; + } + + $lobbies[$lobbyName]['players'] = array_values(array_filter( + $lobbies[$lobbyName]['players'] ?? [], + static fn ($name): bool => $name !== $playerName, + )); + }, true); + + respond(['ok' => true]); +} + +if ($action === 'submitScore') { + $lobbyName = clean_name($body['lobbyName'] ?? ''); + $playerName = clean_name($body['playerName'] ?? '', 24); + if ($lobbyName === '' || $playerName === '') { + respond(['ok' => false, 'error' => 'Lobby name and player name are required.'], 400); + } + + $entry = [ + 'playerName' => $playerName, + 'totalScore' => max(0, min(300, (int) ($body['totalScore'] ?? 0))), + 'scores' => clean_scores($body['scores'] ?? []), + 'countries' => clean_countries($body['countries'] ?? []), + 'date' => gmdate('c'), + ]; + + with_lobbies(static function (array &$lobbies) use ($lobbyName, $playerName, $entry): void { + ensure_lobby($lobbies, $lobbyName); + if (!in_array($playerName, $lobbies[$lobbyName]['players'], true)) { + $lobbies[$lobbyName]['players'][] = $playerName; + } + + $lobbies[$lobbyName]['leaderboard'][] = $entry; + usort( + $lobbies[$lobbyName]['leaderboard'], + static fn (array $a, array $b): int => ($b['totalScore'] ?? 0) <=> ($a['totalScore'] ?? 0), + ); + $lobbies[$lobbyName]['leaderboard'] = array_slice($lobbies[$lobbyName]['leaderboard'], 0, 20); + }, true); + + respond(['ok' => true, 'entry' => $entry]); +} + +if ($action === 'getLeaderboard') { + $lobbyName = clean_name($_GET['lobbyName'] ?? ''); + if ($lobbyName === '') { + respond(['ok' => false, 'error' => 'Lobby name is required.'], 400); + } + + $leaderboard = with_lobbies(static function (array &$lobbies) use ($lobbyName): array { + return $lobbies[$lobbyName]['leaderboard'] ?? []; + }); + + respond(['ok' => true, 'leaderboard' => $leaderboard]); +} + +if ($action === 'getLobby') { + $lobbyName = clean_name($_GET['lobbyName'] ?? ''); + if ($lobbyName === '') { + respond(['ok' => false, 'error' => 'Lobby name is required.'], 400); + } + + $lobby = with_lobbies(static function (array &$lobbies) use ($lobbyName): array { + if (!isset($lobbies[$lobbyName])) { + return ['name' => $lobbyName, 'players' => []]; + } + + return [ + 'name' => $lobbies[$lobbyName]['name'] ?? $lobbyName, + 'players' => array_values($lobbies[$lobbyName]['players'] ?? []), + ]; + }); + + respond(['ok' => true, 'lobby' => $lobby]); +} + +respond(['ok' => false, 'error' => 'Unknown action.'], 404); diff --git a/frontend/game.html b/frontend/game.html index 9aaec39..90f225f 100644 --- a/frontend/game.html +++ b/frontend/game.html @@ -142,44 +142,6 @@ - - - - - - + diff --git a/frontend/index.html b/frontend/index.html index 40a0efc..90b005d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -28,7 +28,7 @@ @@ -165,11 +165,6 @@ -
- - -
- + diff --git a/frontend/package.json b/frontend/package.json index a757b0c..a0d6134 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "scripts": { "format": "biome format --write", "lint": "biome lint", - "lint:fix": "biome lint --write" + "lint:fix": "biome lint --write", + "test:scoring": "node scripts/scoring.test.mjs" }, "devDependencies": { "@biomejs/biome": "2.3.14" diff --git a/frontend/scripts/countries.js b/frontend/scripts/countries.js index 8fb9af3..9bc8a28 100644 --- a/frontend/scripts/countries.js +++ b/frontend/scripts/countries.js @@ -1,115 +1,127 @@ -// countries.js — country data and helpers +// countries.js - country data and helpers /** + * @typedef {{ name: string, lon: number, lat: number }} CitySource * @typedef {{ name: string, x: number, y: number }} City - * @typedef {{ name: string, hint: string, cities: City[] }} Country + * @typedef {{ minLon: number, maxLon: number, minLat: number, maxLat: number }} Bounds + * @typedef {{ minX: number, maxX: number, minY: number, maxY: number }} ProjectedBounds + * @typedef {{ padding: number, scale: number, xOffset: number, yOffset: number }} Projection + * @typedef {{ type: string, geoBounds?: Bounds, projectedBounds?: ProjectedBounds, bounds?: Bounds, projection?: Projection, rings: { x: number, y: number }[][] }} CountryOutline + * @typedef {{ name: string, hint: string, file: string, cities: CitySource[] }} CountrySource + * @typedef {{ name: string, hint: string, cities: City[], outline: CountryOutline }} Country */ -/** @type {Country[]} */ -const COUNTRIES_DATA = [ - { - name: "Switzerland", - hint: "Alpine country in Central Europe", - cities: [ - { name: "Bern", x: 48, y: 58 }, - { name: "Zürich", x: 58, y: 38 }, - { name: "Geneva", x: 22, y: 72 }, - ], - }, - { - name: "Norway", - hint: "Scandinavian country with long coastline", - cities: [ - { name: "Oslo", x: 55, y: 72 }, - { name: "Bergen", x: 32, y: 60 }, - { name: "Tromsø", x: 62, y: 18 }, - ], - }, - { - name: "Italy", - hint: "Boot-shaped peninsula in Southern Europe", - cities: [ - { name: "Rome", x: 52, y: 58 }, - { name: "Milan", x: 42, y: 22 }, - { name: "Naples", x: 58, y: 72 }, - ], - }, - { - name: "Japan", - hint: "Island nation in East Asia", - cities: [ - { name: "Tokyo", x: 72, y: 48 }, - { name: "Osaka", x: 58, y: 58 }, - { name: "Sapporo", x: 70, y: 22 }, - ], - }, - { - name: "Brazil", - hint: "Largest country in South America", - cities: [ - { name: "Brasília", x: 58, y: 52 }, - { name: "São Paulo", x: 60, y: 68 }, - { name: "Manaus", x: 38, y: 38 }, - ], - }, - { - name: "Australia", - hint: "Continent and country in the Southern Hemisphere", - cities: [ - { name: "Canberra", x: 72, y: 72 }, - { name: "Sydney", x: 78, y: 68 }, - { name: "Perth", x: 22, y: 65 }, - ], - }, - { - name: "France", - hint: "Western Europe, roughly hexagonal shape", - cities: [ - { name: "Paris", x: 50, y: 32 }, - { name: "Lyon", x: 58, y: 55 }, - { name: "Marseille", x: 58, y: 72 }, - ], - }, - { - name: "India", - hint: "Large peninsula in South Asia", - cities: [ - { name: "New Delhi", x: 46, y: 28 }, - { name: "Mumbai", x: 32, y: 55 }, - { name: "Chennai", x: 52, y: 72 }, - ], - }, - { - name: "Canada", - hint: "Second largest country in the world", - cities: [ - { name: "Ottawa", x: 62, y: 52 }, - { name: "Vancouver", x: 22, y: 55 }, - { name: "Toronto", x: 60, y: 58 }, - ], - }, - { - name: "Germany", - hint: "Central European country", - cities: [ - { name: "Berlin", x: 58, y: 28 }, - { name: "Munich", x: 48, y: 68 }, - { name: "Hamburg", x: 42, y: 18 }, - ], - }, -]; +const MAX_MERCATOR_LAT = 85.05112878; /** @type {Country[]} */ let data = []; /** - * Load country data into memory. Safe to call multiple times. - * Returns a Promise for future compatibility with a real API fetch. + * Load country data and outlines into memory. Safe to call multiple times. * @returns {Promise} */ -export function loadCountries() { - if (!data.length) data = COUNTRIES_DATA; - return Promise.resolve(data); +export async function loadCountries() { + if (data.length) return data; + + const countries = await loadCountrySources(); + data = await Promise.all( + countries.map(async (country) => { + const response = await fetch( + new URL(`../data/outlines/${country.file}`, import.meta.url), + ); + if (!response.ok) { + throw new Error(`Could not load outline for ${country.name}.`); + } + + const outlineData = await response.json(); + const outline = outlineData.outline; + return { + name: country.name, + hint: country.hint, + cities: projectCities(country.cities, outline), + outline, + }; + }), + ); + return data; +} + +/** + * Load and validate country metadata from managed JSON. + * @returns {Promise} + */ +async function loadCountrySources() { + const response = await fetch(new URL("../data/countries.json", import.meta.url)); + if (!response.ok) { + throw new Error("Could not load country metadata."); + } + + const countries = await response.json(); + if (!Array.isArray(countries)) { + throw new Error("Country metadata must be an array."); + } + + return countries.map(validateCountrySource); +} + +/** + * @param {unknown} value + * @param {number} index + * @returns {CountrySource} + */ +function validateCountrySource(value, index) { + if (!value || typeof value !== "object") { + throw new Error(`Country metadata entry ${index + 1} must be an object.`); + } + + const country = /** @type {Record} */ (value); + if (typeof country.name !== "string" || country.name.trim() === "") { + throw new Error(`Country metadata entry ${index + 1} is missing a name.`); + } + if (typeof country.file !== "string" || country.file.trim() === "") { + throw new Error(`Country metadata entry ${country.name} is missing a file.`); + } + if (!Array.isArray(country.cities)) { + throw new Error(`Country metadata entry ${country.name} is missing cities.`); + } + + return { + name: country.name, + file: country.file, + hint: typeof country.hint === "string" ? country.hint : "", + cities: country.cities.map((city, cityIndex) => + validateCitySource(city, country.name, cityIndex), + ), + }; +} + +/** + * @param {unknown} value + * @param {string} countryName + * @param {number} index + * @returns {CitySource} + */ +function validateCitySource(value, countryName, index) { + if (!value || typeof value !== "object") { + throw new Error(`City ${index + 1} for ${countryName} must be an object.`); + } + + const city = /** @type {Record} */ (value); + if (typeof city.name !== "string" || city.name.trim() === "") { + throw new Error(`City ${index + 1} for ${countryName} is missing a name.`); + } + if (typeof city.lon !== "number" || !Number.isFinite(city.lon)) { + throw new Error(`${city.name} for ${countryName} is missing a numeric lon.`); + } + if (typeof city.lat !== "number" || !Number.isFinite(city.lat)) { + throw new Error(`${city.name} for ${countryName} is missing a numeric lat.`); + } + + return { + name: city.name, + lon: city.lon, + lat: city.lat, + }; } /** @@ -130,3 +142,86 @@ export function getCities(countryName) { const country = data.find((c) => c.name === countryName); return country ? country.cities : []; } + +/** + * @param {CitySource[]} cities + * @param {CountryOutline} outline + * @returns {City[]} + */ +function projectCities(cities, outline) { + const { bounds, geoBounds, projectedBounds, projection } = outline; + if (projectedBounds && projection) { + return cities.map((city) => { + const projected = mercatorProject(city.lon, city.lat); + return { + name: city.name, + x: clampPercent(projectProjectedX(projected.x, projectedBounds, projection)), + y: clampPercent(projectProjectedY(projected.y, projectedBounds, projection)), + }; + }); + } + + if (!bounds && !geoBounds) return []; + const fallbackBounds = bounds || geoBounds; + if (!fallbackBounds) return []; + const lonSpan = fallbackBounds.maxLon - fallbackBounds.minLon; + const latSpan = fallbackBounds.maxLat - fallbackBounds.minLat; + + return cities.map((city) => ({ + name: city.name, + x: clampPercent( + lonSpan === 0 ? 50 : ((city.lon - fallbackBounds.minLon) / lonSpan) * 100, + ), + y: clampPercent( + latSpan === 0 + ? 50 + : (1 - (city.lat - fallbackBounds.minLat) / latSpan) * 100, + ), + })); +} + +/** + * @param {number} lon + * @param {number} lat + * @returns {{ x: number, y: number }} + */ +function mercatorProject(lon, lat) { + const clampedLat = Math.max( + -MAX_MERCATOR_LAT, + Math.min(MAX_MERCATOR_LAT, lat), + ); + const lonRad = (lon * Math.PI) / 180; + const latRad = (clampedLat * Math.PI) / 180; + + return { + x: lonRad, + y: Math.log(Math.tan(Math.PI / 4 + latRad / 2)), + }; +} + +/** + * @param {number} x + * @param {ProjectedBounds} bounds + * @param {Projection} projection + */ +function projectProjectedX(x, bounds, projection) { + return projection.scale === 0 + ? 50 + : projection.xOffset + (x - bounds.minX) * projection.scale; +} + +/** + * @param {number} y + * @param {ProjectedBounds} bounds + * @param {Projection} projection + */ +function projectProjectedY(y, bounds, projection) { + return projection.scale === 0 + ? 50 + : projection.yOffset + (bounds.maxY - y) * projection.scale; +} + +/** @param {number} value */ +function clampPercent(value) { + return Math.max(0, Math.min(100, Math.round(value * 100) / 100)); +} diff --git a/frontend/scripts/drawing.js b/frontend/scripts/drawing.js index b36a810..ba0d232 100644 --- a/frontend/scripts/drawing.js +++ b/frontend/scripts/drawing.js @@ -7,9 +7,13 @@ let ctx; let isDrawing = false; /** @type {{ x: number, y: number }[]} */ -let points = []; +let currentStroke = []; +/** @type {{ x: number, y: number }[][]} */ +let strokes = []; /** @type {{ name: string, x: number, y: number }[]} */ let cities = []; +/** @type {{ rings: { x: number, y: number }[][] } | null} */ +let referenceOutline = null; const STROKE_COLOR = "#1a7fc4"; const STROKE_WIDTH = 2.5; @@ -57,7 +61,8 @@ function onDown(e) { e.preventDefault(); isDrawing = true; const p = pos(e); - points.push(p); + currentStroke = [p]; + strokes.push(currentStroke); ctx.beginPath(); ctx.moveTo(p.x, p.y); } @@ -67,7 +72,7 @@ function onMove(e) { if (!isDrawing) return; e.preventDefault(); const p = pos(e); - points.push(p); + currentStroke.push(p); ctx.lineTo(p.x, p.y); ctx.strokeStyle = STROKE_COLOR; ctx.lineWidth = STROKE_WIDTH; @@ -80,12 +85,15 @@ function onMove(e) { function onUp(e) { if (!isDrawing) return; isDrawing = false; + currentStroke = []; e.preventDefault(); } /** Clear the canvas and redraw city markers. */ export function clear() { - points = []; + strokes = []; + currentStroke = []; + referenceOutline = null; if (!ctx) return; const { width, height } = canvas.getBoundingClientRect(); ctx.clearRect(0, 0, width, height); @@ -104,11 +112,10 @@ export function setCities(cityList) { /** Render all city markers with labels. */ function drawCities() { if (!ctx || !cities.length) return; - const { width, height } = canvas.getBoundingClientRect(); + const viewport = getCanvasViewport(); cities.forEach((city) => { - const cx = (city.x / 100) * width; - const cy = (city.y / 100) * height; + const { x: cx, y: cy } = toCanvasPoint(city, viewport); // Dot ctx.beginPath(); @@ -139,31 +146,138 @@ function drawCities() { }); } -/** Redraw the full stroke from stored points. */ +/** Redraw all stored strokes. */ function redraw() { drawCities(); - if (!points.length) return; - ctx.beginPath(); - ctx.strokeStyle = STROKE_COLOR; - ctx.lineWidth = STROKE_WIDTH; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - points.forEach((p, i) => { - if (i === 0) { - ctx.moveTo(p.x, p.y); - } else { - ctx.lineTo(p.x, p.y); - } - }); - ctx.stroke(); + for (const stroke of strokes) { + if (!stroke.length) continue; + ctx.beginPath(); + ctx.strokeStyle = STROKE_COLOR; + ctx.lineWidth = STROKE_WIDTH; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + stroke.forEach((p, i) => { + if (i === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + ctx.stroke(); + } + drawReferenceOutline(); } /** - * Return a copy of the current drawn points. + * Return a flattened copy of all drawn points. * @returns {{ x: number, y: number }[]} */ export function getPoints() { - return [...points]; + return strokes.flat(); +} + +/** + * Return drawn points in the same 0-100 space used by country outlines. + * @returns {{ x: number, y: number }[]} + */ +export function getNormalizedPoints() { + return getNormalizedRings().flat(); +} + +/** + * Return drawn strokes in the same 0-100 space used by country outlines. + * @returns {{ x: number, y: number }[][]} + */ +export function getNormalizedRings() { + if (!canvas) return []; + const viewport = getCanvasViewport(); + if (viewport.size === 0) return []; + + return strokes + .map((stroke) => stroke.map((point) => toNormalizedPoint(point, viewport))) + .filter((stroke) => stroke.length > 1); +} + +/** + * Show the reference outline over the player's drawing. + * @param {{ rings: { x: number, y: number }[][] } | null} outline + */ +export function showReferenceOutline(outline) { + referenceOutline = outline; + redraw(); +} + +/** Draw the current reference outline if one is set. */ +function drawReferenceOutline() { + if (!ctx || !referenceOutline?.rings?.length) return; + const viewport = getCanvasViewport(); + if (viewport.size === 0) return; + + ctx.save(); + ctx.strokeStyle = "rgba(224,92,92,0.95)"; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.setLineDash([8, 5]); + + for (const ring of referenceOutline.rings) { + if (!ring.length) continue; + ctx.beginPath(); + ring.forEach((point, index) => { + const { x, y } = toCanvasPoint(point, viewport); + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); + } + + ctx.restore(); +} + +/** + * Return the square drawing viewport used for normalized 0-100 country space. + * @returns {{ x: number, y: number, size: number }} + */ +function getCanvasViewport() { + const { width, height } = canvas.getBoundingClientRect(); + const size = Math.min(width, height); + return { + x: (width - size) / 2, + y: (height - size) / 2, + size, + }; +} + +/** + * @param {{ x: number, y: number }} point + * @param {{ x: number, y: number, size: number }} viewport + * @returns {{ x: number, y: number }} + */ +function toCanvasPoint(point, viewport) { + return { + x: viewport.x + (point.x / 100) * viewport.size, + y: viewport.y + (point.y / 100) * viewport.size, + }; +} + +/** + * @param {{ x: number, y: number }} point + * @param {{ x: number, y: number, size: number }} viewport + * @returns {{ x: number, y: number }} + */ +function toNormalizedPoint(point, viewport) { + return { + x: clampPercent(((point.x - viewport.x) / viewport.size) * 100), + y: clampPercent(((point.y - viewport.y) / viewport.size) * 100), + }; +} + +/** @param {number} value */ +function clampPercent(value) { + return Math.max(0, Math.min(100, Math.round(value * 100) / 100)); } /** Remove event listeners and clean up. */ diff --git a/frontend/scripts/game.js b/frontend/scripts/game.js index aa418b7..b4a863c 100644 --- a/frontend/scripts/game.js +++ b/frontend/scripts/game.js @@ -2,7 +2,13 @@ import { submitScore } from "./api.js"; import { getRandomCountries, loadCountries } from "./countries.js"; -import { clear, getPoints, init as initDrawing, setCities } from "./drawing.js"; +import { + clear, + getNormalizedRings, + init as initDrawing, + setCities, + showReferenceOutline, +} from "./drawing.js"; import { calculateScore, getGrade } from "./scoring.js"; import { getLobbyName, getPlayerName, saveGameState } from "./storage.js"; @@ -25,6 +31,8 @@ let elTimerBar; let elTimerWrap; let elBtnClear; let elBtnSubmit; +let elBtnNext; +let roundSubmitted = false; /** Initialise DOM refs, drawing, events, countries, and the first round. */ async function initGame() { @@ -36,6 +44,7 @@ async function initGame() { elTimerWrap = document.querySelector(".game-timer"); elBtnClear = document.getElementById("btn-clear"); elBtnSubmit = document.getElementById("btn-submit"); + elBtnNext = document.getElementById("btn-next"); const canvas = document.getElementById("draw-canvas"); const wrap = document.getElementById("canvas-wrap"); @@ -48,6 +57,7 @@ async function initGame() { elBtnClear.addEventListener("click", () => clear()); elBtnSubmit.addEventListener("click", () => submitRound(false)); + elBtnNext.addEventListener("click", goToNextRound); await loadCountries(); roundCountries = getRandomCountries(TOTAL_ROUNDS); @@ -67,9 +77,11 @@ function startRound() { updateRoundPips(currentRound + 1); document.getElementById("canvas-wrap")?.classList.remove("has-drawing"); + hideScoreFeedback(); setCities(country.cities || []); clear(); + roundSubmitted = false; timeLeft = ROUND_DURATION; updateTimerUI(); @@ -77,10 +89,12 @@ function startRound() { timerInterval = setInterval(tickTimer, 1000); elBtnSubmit.disabled = false; - elBtnSubmit.textContent = - currentRound < TOTAL_ROUNDS - 1 - ? "Submit & Next Round →" - : "Submit & See Results →"; + elBtnSubmit.hidden = false; + elBtnSubmit.textContent = "Submit"; + elBtnClear.disabled = false; + elBtnNext.disabled = true; + elBtnNext.textContent = + currentRound < TOTAL_ROUNDS - 1 ? "Next Round →" : "See Results →"; } /** Decrement timer by one second and auto-submit when time runs out. */ @@ -109,35 +123,51 @@ function updateTimerUI() { } /** - * Submit the current round, record the score, and advance or finish. + * Submit the current round, record the score, and reveal the reference outline. * @param {boolean} [auto=false] - True when triggered by timer expiry. */ function submitRound(auto = false) { - clearInterval(timerInterval); - elBtnSubmit.disabled = true; + if (roundSubmitted) return; - const points = getPoints(); - const score = calculateScore(points); + clearInterval(timerInterval); + roundSubmitted = true; + elBtnSubmit.disabled = true; + elBtnClear.disabled = true; + + const country = roundCountries[currentRound]; + const rings = getNormalizedRings(); + const score = calculateScore(rings, country.outline); scores.push(score); + showReferenceOutline(country.outline); updateScoreDisplay(currentRound, score); showScoreFeedback(score); - setTimeout( - () => { - if (currentRound < TOTAL_ROUNDS - 1) { - currentRound++; - startRound(); - } else { - finishGame(); - } - }, - auto ? 400 : 1200, - ); + elBtnNext.disabled = false; + elBtnNext.focus(); + if (auto) { + elBtnNext.textContent = + currentRound < TOTAL_ROUNDS - 1 + ? "Time's up - Next Round" + : "Time's up - See Results"; + } +} + +/** Move to the next round or finish the game after a submitted round. */ +function goToNextRound() { + if (!roundSubmitted) return; + + elBtnNext.disabled = true; + if (currentRound < TOTAL_ROUNDS - 1) { + currentRound++; + startRound(); + } else { + finishGame(); + } } /** - * Briefly display the score grade overlay on the canvas. + * Display the score grade overlay on the canvas until the next round starts. * @param {number} score */ function showScoreFeedback(score) { @@ -147,10 +177,13 @@ function showScoreFeedback(score) { el.style.color = grade.color; el.style.opacity = "1"; el.style.transform = "translateY(0)"; - setTimeout(() => { - el.style.opacity = "0"; - el.style.transform = "translateY(-10px)"; - }, 900); +} + +/** Hide the score grade overlay. */ +function hideScoreFeedback() { + const el = document.getElementById("score-feedback"); + el.style.opacity = "0"; + el.style.transform = "translateY(-10px)"; } /** Persist game state, update leaderboard, and navigate to results. */ diff --git a/frontend/scripts/scoring.js b/frontend/scripts/scoring.js index 5e7dc0f..b222952 100644 --- a/frontend/scripts/scoring.js +++ b/frontend/scripts/scoring.js @@ -1,31 +1,69 @@ // scoring.js - accuracy calculation +const GRID_SIZE = 96; +const MIN_DRAWN_POINTS = 10; +const MIN_FILLED_CELLS = 8; + /** - * 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 + * @typedef {{ x: number, y: number }} Point + * @typedef {{ rings: Point[][] }} CountryOutline + */ + +/** + * Calculate a deterministic shape score from a drawn polygon and reference outline. + * @param {Point[] | Point[][]} drawnShape - Points or rings normalized to 0-100 canvas space. + * @param {CountryOutline | undefined | null} referenceOutline * @returns {number} Score between 0 and 100. */ -export function calculateScore(drawnPoints) { - if (!drawnPoints || drawnPoints.length < 10) return 0; - - const effort = Math.min(drawnPoints.length / 300, 1); // 0-1 - const base = 40 + Math.round(effort * 45); // 40-85 - const jitter = Math.round((Math.random() - 0.5) * 14); // +/-7 - return Math.max(0, Math.min(100, base + jitter)); +export function calculateScore(drawnShape, referenceOutline) { + return compareShapes(drawnShape, referenceOutline); } /** - * 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. + * Compare a drawn polygon against one or more reference rings using rasterized IoU. + * @param {Point[] | Point[][]} drawnShape - Points or rings normalized to 0-100 canvas space. + * @param {CountryOutline | undefined | null} referenceOutline * @returns {number} Score between 0 and 100. */ -export function compareShapes(_drawnPoints, _referencePolygon) { - // TODO: implement real shape comparison - return 0; +export function compareShapes(drawnShape, referenceOutline) { + if (!referenceOutline?.rings?.length) return 0; + + const drawnRings = normalizeDrawnRings(drawnShape); + const referenceRings = referenceOutline.rings.filter(isValidReferenceRing).map(closeRing); + if (!drawnRings.length || !referenceRings.length) return 0; + + let intersection = 0; + let union = 0; + let drawnCells = 0; + + for (let row = 0; row < GRID_SIZE; row++) { + for (let col = 0; col < GRID_SIZE; col++) { + const point = cellCenter(col, row); + const inDrawn = isInsideAnyRing(point, drawnRings); + const inReference = isInsideAnyRing(point, referenceRings); + + if (inDrawn) drawnCells++; + if (inDrawn && inReference) intersection++; + if (inDrawn || inReference) union++; + } + } + + if (drawnCells < MIN_FILLED_CELLS || union === 0) return 0; + return Math.round((intersection / union) * 100); +} + +/** + * @param {Point[] | Point[][]} drawnShape + * @returns {Point[][]} + */ +function normalizeDrawnRings(drawnShape) { + if (!Array.isArray(drawnShape) || !drawnShape.length) return []; + if (isPoint(drawnShape[0])) { + return isValidRing(drawnShape) ? [closeRing(drawnShape)] : []; + } + return drawnShape + .filter(isValidRing) + .map(closeRing); } /** @@ -40,3 +78,79 @@ export function getGrade(score) { if (score >= 40) return { label: "C", color: "#7a9aaa" }; return { label: "D", color: "#e05c5c" }; } + +/** + * @param {number} col + * @param {number} row + * @returns {Point} + */ +function cellCenter(col, row) { + return { + x: ((col + 0.5) / GRID_SIZE) * 100, + y: ((row + 0.5) / GRID_SIZE) * 100, + }; +} + +/** + * @param {Point} point + * @param {Point[][]} rings + */ +function isInsideAnyRing(point, rings) { + return rings.some((ring) => pointInRing(point, ring)); +} + +/** @param {unknown} value */ +function isPoint(value) { + return ( + !!value && + typeof value === "object" && + Number.isFinite(value.x) && + Number.isFinite(value.y) + ); +} + +/** + * @param {Point} point + * @param {Point[]} ring + */ +function pointInRing(point, ring) { + let inside = false; + + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const a = ring[i]; + const b = ring[j]; + const intersects = + a.y > point.y !== b.y > point.y && + point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x; + + if (intersects) inside = !inside; + } + + return inside; +} + +/** @param {Point[]} ring */ +function isValidRing(ring) { + return ( + Array.isArray(ring) && + ring.length >= MIN_DRAWN_POINTS && + ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y)) + ); +} + +/** @param {Point[]} ring */ +function isValidReferenceRing(ring) { + return ( + Array.isArray(ring) && + ring.length >= 4 && + ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y)) + ); +} + +/** @param {Point[]} ring */ +function closeRing(ring) { + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first.x === last.x && first.y === last.y) return ring; + return [...ring, { ...first }]; +} diff --git a/frontend/scripts/scoring.test.mjs b/frontend/scripts/scoring.test.mjs new file mode 100644 index 0000000..eb0bb61 --- /dev/null +++ b/frontend/scripts/scoring.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { calculateScore } from "./scoring.js"; + +const square = [ + { x: 20, y: 20 }, + { x: 40, y: 20 }, + { x: 60, y: 20 }, + { x: 80, y: 20 }, + { x: 80, y: 40 }, + { x: 80, y: 60 }, + { x: 80, y: 80 }, + { x: 60, y: 80 }, + { x: 40, y: 80 }, + { x: 20, y: 80 }, + { x: 20, y: 60 }, + { x: 20, y: 40 }, + { x: 20, y: 20 }, +]; + +const shiftedSquare = square.map((point) => ({ + x: Math.min(100, point.x + 12), + y: point.y, +})); + +const smallSquare = square.map((point) => ({ + x: 35 + (point.x - 20) * 0.5, + y: 35 + (point.y - 20) * 0.5, +})); + +const secondIsland = [ + { x: 5, y: 5 }, + { x: 10, y: 5 }, + { x: 15, y: 5 }, + { x: 15, y: 10 }, + { x: 15, y: 15 }, + { x: 10, y: 15 }, + { x: 5, y: 15 }, + { x: 5, y: 10 }, + { x: 5, y: 7 }, + { x: 5, y: 5 }, + { x: 7, y: 5 }, + { x: 10, y: 5 }, + { x: 5, y: 5 }, +]; + +const firstIsland = [ + { x: 5, y: 70 }, + { x: 12, y: 70 }, + { x: 20, y: 70 }, + { x: 20, y: 77 }, + { x: 20, y: 85 }, + { x: 12, y: 85 }, + { x: 5, y: 85 }, + { x: 5, y: 78 }, + { x: 5, y: 74 }, + { x: 5, y: 70 }, + { x: 8, y: 70 }, + { x: 12, y: 70 }, + { x: 5, y: 70 }, +]; + +const tinyNoise = [ + { x: 90, y: 90 }, + { x: 91, y: 90 }, + { x: 91, y: 91 }, +]; + +assert.equal(calculateScore(square, { rings: [square] }), 100); +assert.equal(calculateScore([], { rings: [square] }), 0); +assert.equal(calculateScore(square.slice(0, 4), { rings: [square] }), 0); +assert.ok(calculateScore(shiftedSquare, { rings: [square] }) < 100); +assert.ok(calculateScore(smallSquare, { rings: [square] }) < 60); +assert.ok(calculateScore(square, { rings: [square, secondIsland] }) >= 95); +assert.ok( + calculateScore([square, secondIsland], { rings: [square, secondIsland] }) >= + 95, +); +assert.equal( + calculateScore([tinyNoise, square], { rings: [square] }), + 100, +); +assert.ok( + calculateScore([firstIsland, secondIsland], { + rings: [firstIsland, secondIsland], + }) > 90, +); +assert.ok( + calculateScore([firstIsland, secondIsland], { + rings: [ + [ + { x: 5, y: 5 }, + { x: 20, y: 5 }, + { x: 20, y: 85 }, + { x: 5, y: 85 }, + { x: 5, y: 5 }, + ], + ], + }) < 50, +); + +console.log("scoring tests passed"); diff --git a/frontend/styles/game.css b/frontend/styles/game.css index fe07931..2e4db1a 100644 --- a/frontend/styles/game.css +++ b/frontend/styles/game.css @@ -141,10 +141,14 @@ grid-template-columns: 1fr 220px; gap: 16px; min-height: 0; + align-items: start; } .canvas-wrap { position: relative; + justify-self: center; + width: min(100%, 68vh); + aspect-ratio: 1; background: var(--white); border: 1.5px solid var(--line); border-radius: var(--r-xl); @@ -284,7 +288,7 @@ } .canvas-wrap { - min-height: 340px; + width: min(100%, 72vh); } .game-topbar { @@ -315,7 +319,7 @@ } .canvas-wrap { - min-height: 280px; + width: 100%; } .game-controls { @@ -334,6 +338,6 @@ font-size: 1.6rem; } .canvas-wrap { - min-height: 240px; + min-height: 0; } } diff --git a/tools/convert-geojson-outlines.js b/tools/convert-geojson-outlines.js index c0b2e2f..1075f40 100644 --- a/tools/convert-geojson-outlines.js +++ b/tools/convert-geojson-outlines.js @@ -4,7 +4,9 @@ import { mkdir, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import shapefile from "shapefile"; -const DEFAULT_TOLERANCE = 1.5; +const DEFAULT_TOLERANCE = 0.3; +const DEFAULT_PADDING = 6; +const MAX_MERCATOR_LAT = 85.05112878; const SHAPEFILE_BASENAME = "ne_10m_admin_0_countries"; const GAME_COUNTRIES = [ "Switzerland", @@ -18,14 +20,22 @@ const GAME_COUNTRIES = [ "Canada", "Germany", ]; +const COUNTRY_OPTIONS = { + France: { keepLargestRingOnly: true }, + Norway: { keepLargestRingOnly: true }, +}; function usage() { return `Usage: node convert-geojson-outlines.js [options] Options: - --tolerance Douglas-Peucker tolerance in 0..100 units. Default: 1.5 + --tolerance Douglas-Peucker tolerance in 0..100 units. Default: 0.3 + --padding Padding on each side in 0..100 units. Default: 6 --max-points Simplify each ring until it has at most this many points. + --min-ring-area-ratio + Drop rings smaller than this ratio of the largest ring. Default: 0.001 + --mainland-only Keep only the largest ring for every country. --include-holes Include inner rings from Polygon/MultiPolygon geometries. --pretty Write indented JSON instead of compact JSON. `; @@ -35,7 +45,10 @@ function parseArgs(argv) { const positionals = []; const options = { tolerance: DEFAULT_TOLERANCE, + padding: DEFAULT_PADDING, maxPoints: null, + minRingAreaRatio: 0.001, + mainlandOnly: false, includeHoles: false, pretty: false, }; @@ -45,8 +58,14 @@ function parseArgs(argv) { if (arg === "--tolerance") { options.tolerance = Number(argv[++i]); + } else if (arg === "--padding") { + options.padding = Number(argv[++i]); } else if (arg === "--max-points") { options.maxPoints = Number(argv[++i]); + } else if (arg === "--min-ring-area-ratio") { + options.minRingAreaRatio = Number(argv[++i]); + } else if (arg === "--mainland-only") { + options.mainlandOnly = true; } else if (arg === "--include-holes") { options.includeHoles = true; } else if (arg === "--pretty") { @@ -65,11 +84,27 @@ function parseArgs(argv) { if (!Number.isFinite(options.tolerance) || options.tolerance < 0) { throw new Error("--tolerance must be a number greater than or equal to 0."); } + if ( + !Number.isFinite(options.padding) || + options.padding < 0 || + options.padding >= 50 + ) { + throw new Error("--padding must be a number greater than or equal to 0 and less than 50."); + } if ( options.maxPoints !== null && (!Number.isInteger(options.maxPoints) || options.maxPoints < 4) ) { - throw new Error("--max-points must be an integer greater than or equal to 4."); + throw new Error( + "--max-points must be an integer greater than or equal to 4.", + ); + } + if ( + !Number.isFinite(options.minRingAreaRatio) || + options.minRingAreaRatio < 0 || + options.minRingAreaRatio > 1 + ) { + throw new Error("--min-ring-area-ratio must be a number between 0 and 1."); } return { @@ -85,7 +120,10 @@ function stripNulls(value) { function cleanProperties(properties) { return Object.fromEntries( - Object.entries(properties || {}).map(([key, value]) => [key, stripNulls(value)]), + Object.entries(properties || {}).map(([key, value]) => [ + key, + stripNulls(value), + ]), ); } @@ -137,7 +175,47 @@ function cleanRing(ring) { return ring.filter(isValidCoordinate).map(([lon, lat]) => [lon, lat]); } -function getBounds(rings) { +function mercatorProject([lon, lat]) { + const clampedLat = Math.max( + -MAX_MERCATOR_LAT, + Math.min(MAX_MERCATOR_LAT, lat), + ); + const lonRad = (lon * Math.PI) / 180; + const latRad = (clampedLat * Math.PI) / 180; + + return [ + lonRad, + Math.log(Math.tan(Math.PI / 4 + latRad / 2)), + ]; +} + +function ringArea(ring) { + if (!Array.isArray(ring) || ring.length < 4) return 0; + let area = 0; + for (let i = 0; i < ring.length; i++) { + const [x1, y1] = ring[i]; + const [x2, y2] = ring[(i + 1) % ring.length]; + area += x1 * y2 - x2 * y1; + } + return Math.abs(area / 2); +} + +function filterGameplayRings(rings, options) { + const ranked = rings + .map((ring) => ({ ring, area: ringArea(ring) })) + .filter(({ area }) => area > 0) + .sort((a, b) => b.area - a.area); + + if (!ranked.length) return rings; + if (options.mainlandOnly || options.keepLargestRingOnly) { + return [ranked[0].ring]; + } + + const minArea = ranked[0].area * options.minRingAreaRatio; + return ranked.filter(({ area }) => area >= minArea).map(({ ring }) => ring); +} + +function getGeoBounds(rings) { let minLon = Infinity; let maxLon = -Infinity; let minLat = Infinity; @@ -159,18 +237,64 @@ function getBounds(rings) { return { minLon, maxLon, minLat, maxLat }; } +function getProjectedBounds(rings) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + for (const ring of rings) { + for (const [x, y] of ring) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + + if (![minX, maxX, minY, maxY].every(Number.isFinite)) { + throw new Error("Could not calculate projected bounds from geometry."); + } + + return { minX, maxX, minY, maxY }; +} + function roundCoord(value) { return Math.round(value * 100) / 100; } -function normalizePoint([lon, lat], bounds) { - const lonSpan = bounds.maxLon - bounds.minLon; - const latSpan = bounds.maxLat - bounds.minLat; +function roundMeta(value) { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function createProjection(bounds, padding) { + const xSpan = bounds.maxX - bounds.minX; + const ySpan = bounds.maxY - bounds.minY; + const usableSize = 100 - padding * 2; + const maxSpan = Math.max(xSpan, ySpan); + const scale = maxSpan === 0 ? 0 : usableSize / maxSpan; + const width = xSpan * scale; + const height = ySpan * scale; return { - x: roundCoord(lonSpan === 0 ? 50 : ((lon - bounds.minLon) / lonSpan) * 100), + padding, + scale, + xOffset: (100 - width) / 2, + yOffset: (100 - height) / 2, + }; +} + +function normalizePoint([x, y], bounds, projection) { + return { + x: roundCoord( + projection.scale === 0 + ? 50 + : projection.xOffset + (x - bounds.minX) * projection.scale, + ), y: roundCoord( - latSpan === 0 ? 50 : (1 - (lat - bounds.minLat) / latSpan) * 100, + projection.scale === 0 + ? 50 + : projection.yOffset + (bounds.maxY - y) * projection.scale, ), }; } @@ -205,7 +329,8 @@ function squaredDistanceToSegment(point, start, end) { 0, Math.min( 1, - ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy), + ((point.x - start.x) * dx + (point.y - start.y) * dy) / + (dx * dx + dy * dy), ), ); const projection = { @@ -224,7 +349,11 @@ function douglasPeucker(points, tolerance) { const lastIndex = points.length - 1; for (let i = 1; i < lastIndex; i++) { - const distance = squaredDistanceToSegment(points[i], points[0], points[lastIndex]); + const distance = squaredDistanceToSegment( + points[i], + points[0], + points[lastIndex], + ); if (distance > maxDistance) { maxDistance = distance; splitIndex = i; @@ -259,17 +388,25 @@ function normalizeGeometry(geometry, options) { throw new Error("No Polygon or MultiPolygon geometry found."); } - const rings = extractRings(geometry, options.includeHoles) - .map(cleanRing) - .filter((ring) => ring.length >= 4); + const geoRings = filterGameplayRings( + extractRings(geometry, options.includeHoles) + .map(cleanRing) + .filter((ring) => ring.length >= 4), + options, + ); - if (!rings.length) { + if (!geoRings.length) { throw new Error("No valid polygon rings found."); } - const bounds = getBounds(rings); - const normalizedRings = rings - .map((ring) => ring.map((point) => normalizePoint(point, bounds))) + const projectedRings = geoRings.map((ring) => ring.map(mercatorProject)); + const geoBounds = getGeoBounds(geoRings); + const projectedBounds = getProjectedBounds(projectedRings); + const projection = createProjection(projectedBounds, options.padding); + const normalizedRings = projectedRings + .map((ring) => + ring.map((point) => normalizePoint(point, projectedBounds, projection)), + ) .map(closeRing) .map((ring) => simplifyRing(ring, options.tolerance, options.maxPoints)) .filter((ring) => ring.length >= 4); @@ -279,8 +416,20 @@ function normalizeGeometry(geometry, options) { } return { - type: geometry.type, - bounds, + type: geoRings.length > 1 ? "MultiPolygon" : "Polygon", + geoBounds, + projectedBounds: { + minX: roundMeta(projectedBounds.minX), + maxX: roundMeta(projectedBounds.maxX), + minY: roundMeta(projectedBounds.minY), + maxY: roundMeta(projectedBounds.maxY), + }, + projection: { + padding: roundMeta(projection.padding), + scale: roundMeta(projection.scale), + xOffset: roundMeta(projection.xOffset), + yOffset: roundMeta(projection.yOffset), + }, rings: normalizedRings, }; } @@ -288,7 +437,10 @@ function normalizeGeometry(geometry, options) { async function findShapefile(inputDir) { const entries = await readdir(inputDir, { withFileTypes: true }); const shpFiles = entries - .filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".shp") + .filter( + (entry) => + entry.isFile() && path.extname(entry.name).toLowerCase() === ".shp", + ) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); @@ -305,7 +457,9 @@ async function findShapefile(inputDir) { } async function readTargetFeatures(shpPath, targets) { - const source = await shapefile.open(shpPath, undefined, { encoding: "utf-8" }); + const source = await shapefile.open(shpPath, undefined, { + encoding: "utf-8", + }); const features = new Map(); while (true) { @@ -327,7 +481,17 @@ async function readTargetFeatures(shpPath, targets) { return features; } -async function writeCountryOutline(outputDir, countryName, feature, sourceFile, options) { +async function writeCountryOutline( + outputDir, + countryName, + feature, + sourceFile, + options, +) { + const countryOptions = { + ...options, + ...(COUNTRY_OPTIONS[countryName] || {}), + }; const output = { source: sourceFile, country: { @@ -337,7 +501,7 @@ async function writeCountryOutline(outputDir, countryName, feature, sourceFile, continent: feature.properties.CONTINENT, subregion: feature.properties.SUBREGION, }, - outline: normalizeGeometry(feature.geometry, options), + outline: normalizeGeometry(feature.geometry, countryOptions), }; const fileName = `${slugify(countryName)}.json`; const json = JSON.stringify(output, null, options.pretty ? 2 : 0); @@ -351,7 +515,9 @@ async function main() { const sourceFile = path.basename(shpPath); const targets = new Set(GAME_COUNTRIES); const features = await readTargetFeatures(shpPath, targets); - const missing = GAME_COUNTRIES.filter((countryName) => !features.has(countryName)); + const missing = GAME_COUNTRIES.filter( + (countryName) => !features.has(countryName), + ); if (missing.length) { throw new Error(`Missing country feature(s): ${missing.join(", ")}`); -- 2.30.2