diff --git a/public/src/game.js b/public/src/game.js index 7e853e1..2ead9b6 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -87,25 +87,48 @@ function tileAlpha(key) { // ── Zoom / Pan state ────────────────────────────────────────────────────────── -const MIN_ZOOM = 1; const MAX_ZOOM = 12; const ZOOM_SPEED = 0.001; // wheel delta multiplier -/** Current zoom level (1 = whole map fits in canvas). */ +/** Returns the minimum zoom that fits the entire map in the canvas (1:1 tile ratio). */ +function minZoom() { return Math.min(canvas.width, canvas.height) / SVG_W; } + +/** Current zoom level. */ let zoom = 1; /** Top-left offset in *map-space* coordinates (0..1000). */ let panX = 0; let panY = 0; -/** Clamp panX/panY so the viewport stays inside the map. */ +/** + * Clamp panX/panY so the viewport stays within reachable map bounds. + * When the whole map fits in a canvas axis, center it and lock pan on that axis. + * When the map is larger than the canvas, constrain pan to keep the map filling the axis. + */ function clampPan() { - const vw = SVG_W / zoom; - const vh = SVG_H / zoom; - panX = Math.max(0, Math.min(panX, SVG_W - vw)); - panY = Math.max(0, Math.min(panY, SVG_H - vh)); + const vw = canvas.width / zoom; + const vh = canvas.height / zoom; + // X axis + if (vw >= SVG_W) { + panX = (SVG_W - vw) / 2; // center horizontally, no pan + } else { + panX = Math.max(0, Math.min(panX, SVG_W - vw)); + } + // Y axis + if (vh >= SVG_H) { + panY = (SVG_H - vh) / 2; // center vertically, no pan + } else { + panY = Math.max(0, Math.min(panY, SVG_H - vh)); + } } -export function resetZoom() { zoom = 1; panX = 0; panY = 0; } +/** Resets zoom to fit the entire map and centers it. */ +export function resetZoom() { + zoom = minZoom(); + const vw = canvas.width / zoom; + const vh = canvas.height / zoom; + panX = (SVG_W - vw) / 2; + panY = (SVG_H - vh) / 2; +} // ── Playfield mask ──────────────────────────────────────────────────────────── @@ -822,9 +845,9 @@ export function draw() { const w = canvas.width; const h = canvas.height; - // viewport in map-space - const vw = SVG_W / zoom; - const vh = SVG_H / zoom; + // viewport in map-space (canvas may be non-square) + const vw = canvas.width / zoom; + const vh = canvas.height / zoom; ctx.save(); @@ -918,15 +941,14 @@ const mapAnimTextByType = { function triggerMapAnimation(type, x, y) { const cw = SVG_W / GRID_W; const ch = SVG_H / GRID_H; - const rect = canvas.getBoundingClientRect(); - // Top-centre of the cell in screen pixels, relative to the canvas element - const screenX = ((x * cw + cw / 2 - panX) * zoom / SVG_W) * rect.width; - const screenY = ((y * ch - panY) * zoom / SVG_H) * rect.height; + // Top-centre of the cell in canvas pixels (= CSS px since canvas buffer matches display size) + const screenX = (x * cw + cw / 2 - panX) * zoom; + const screenY = (y * ch - panY) * zoom; - // One cell height in screen pixels (travel distance) - const cellScreenH = (ch * zoom / SVG_H) * rect.height; - const fontSize = Math.max(8, (cw * zoom / SVG_W) * rect.width); + // One cell height and width in screen pixels + const cellScreenH = ch * zoom; + const fontSize = Math.max(8, cw * zoom); const el = document.createElement("div"); el.className = "mapAnimFloat"; @@ -953,8 +975,8 @@ function triggerMapAnimation(type, x, y) { function pickCell(ev) { const rect = canvas.getBoundingClientRect(); // Convert screen coords → map coords accounting for zoom/pan - const mapX = (ev.clientX - rect.left) / rect.width * (SVG_W / zoom) + panX; - const mapY = (ev.clientY - rect.top) / rect.height * (SVG_H / zoom) + panY; + const mapX = (ev.clientX - rect.left) / rect.width * (canvas.width / zoom) + panX; + const mapY = (ev.clientY - rect.top) / rect.height * (canvas.height / zoom) + panY; const x = Math.floor(mapX / (SVG_W / GRID_W)); const y = Math.floor(mapY / (SVG_H / GRID_H)); if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null; @@ -1134,7 +1156,7 @@ async function onCanvasClick(ev) { // ── Not yet revealed: first click = spend user action to reveal ── if (!meta) { if (cooldownActive()) { - hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC."; + hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après la fin du cycle de réinitialisation."; return; } try { @@ -1142,7 +1164,7 @@ async function onCanvasClick(ev) { if (res.status === 429) { actionsRemaining = 0; updateActionsDisplay(); - hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC."; + hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après la fin du cycle de réinitialisation."; return; } if (res.status === 410) { @@ -1345,17 +1367,17 @@ canvas.addEventListener("wheel", (ev) => { ev.preventDefault(); const rect = canvas.getBoundingClientRect(); // Cursor position in map-space before zoom - const mx = (ev.clientX - rect.left) / rect.width * (SVG_W / zoom) + panX; - const my = (ev.clientY - rect.top) / rect.height * (SVG_H / zoom) + panY; + const mx = (ev.clientX - rect.left) / rect.width * (canvas.width / zoom) + panX; + const my = (ev.clientY - rect.top) / rect.height * (canvas.height / zoom) + panY; const oldZoom = zoom; // Negative deltaY = scroll up = zoom in zoom *= 1 - ev.deltaY * ZOOM_SPEED; - zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); + zoom = Math.max(minZoom(), Math.min(MAX_ZOOM, zoom)); // Adjust pan so the map point under the cursor stays in place - panX = mx - (ev.clientX - rect.left) / rect.width * (SVG_W / zoom); - panY = my - (ev.clientY - rect.top) / rect.height * (SVG_H / zoom); + panX = mx - (ev.clientX - rect.left) / rect.width * (canvas.width / zoom); + panY = my - (ev.clientY - rect.top) / rect.height * (canvas.height / zoom); clampPan(); draw(); @@ -1382,8 +1404,8 @@ canvas.addEventListener("mousedown", (ev) => { window.addEventListener("mousemove", (ev) => { if (!_dragging) return; const rect = canvas.getBoundingClientRect(); - const dx = (ev.clientX - _dragStartX) / rect.width * (SVG_W / zoom); - const dy = (ev.clientY - _dragStartY) / rect.height * (SVG_H / zoom); + const dx = (ev.clientX - _dragStartX) / rect.width * (canvas.width / zoom); + const dy = (ev.clientY - _dragStartY) / rect.height * (canvas.height / zoom); panX = _panStartX - dx; panY = _panStartY - dy; clampPan(); @@ -1433,8 +1455,8 @@ function touchDist(a, b) { return Math.hypot(a.clientX - b.clientX, a.clientY - function screenToMap(cx, cy) { const rect = canvas.getBoundingClientRect(); return { - mx: (cx - rect.left) / rect.width * (SVG_W / zoom) + panX, - my: (cy - rect.top) / rect.height * (SVG_H / zoom) + panY, + mx: (cx - rect.left) / rect.width * (canvas.width / zoom) + panX, + my: (cy - rect.top) / rect.height * (canvas.height / zoom) + panY, }; } @@ -1478,8 +1500,8 @@ canvas.addEventListener("touchmove", (ev) => { if (_touches.length === 1) { // Single-finger pan const rect = canvas.getBoundingClientRect(); - const dx = (_touches[0].clientX - _dragStartX) / rect.width * (SVG_W / zoom); - const dy = (_touches[0].clientY - _dragStartY) / rect.height * (SVG_H / zoom); + const dx = (_touches[0].clientX - _dragStartX) / rect.width * (canvas.width / zoom); + const dy = (_touches[0].clientY - _dragStartY) / rect.height * (canvas.height / zoom); panX = _panStartX - dx; panY = _panStartY - dy; clampPan(); @@ -1488,14 +1510,14 @@ canvas.addEventListener("touchmove", (ev) => { if (_touches.length === 2 && _pinchDist0) { const newDist = touchDist(_touches[0], _touches[1]); - zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, _pinchZoom0 * (newDist / _pinchDist0))); + zoom = Math.max(minZoom(), Math.min(MAX_ZOOM, _pinchZoom0 * (newDist / _pinchDist0))); // Keep the original midpoint anchored const midCx = (_touches[0].clientX + _touches[1].clientX) / 2; const midCy = (_touches[0].clientY + _touches[1].clientY) / 2; const rect = canvas.getBoundingClientRect(); - panX = _pinchMid0.mx - (midCx - rect.left) / rect.width * (SVG_W / zoom); - panY = _pinchMid0.my - (midCy - rect.top) / rect.height * (SVG_H / zoom); + panX = _pinchMid0.mx - (midCx - rect.left) / rect.width * (canvas.width / zoom); + panY = _pinchMid0.my - (midCy - rect.top) / rect.height * (canvas.height / zoom); clampPan(); draw(); } @@ -1528,3 +1550,40 @@ canvas.addEventListener("touchend", (ev) => { _panStartY = panY; } }, { passive: false }); + +// ── Canvas resize ───────────────────────────────────────────────────────────── + +/** + * Syncs the canvas pixel buffer to its CSS display size, clamps zoom/pan, + * and redraws. Called once on boot (via requestAnimationFrame) and on resize. + */ +let _resizeInitDone = false; +export function resizeCanvas() { + const w = canvas.offsetWidth; + const h = canvas.offsetHeight; + if (w === 0 || h === 0) return; + canvas.width = w; + canvas.height = h; + if (!_resizeInitDone) { + // First call: fit the full map and center it + _resizeInitDone = true; + zoom = minZoom(); + const vw = canvas.width / zoom; + const vh = canvas.height / zoom; + panX = (SVG_W - vw) / 2; + panY = (SVG_H - vh) / 2; + } else { + // Subsequent calls (window resize): preserve user zoom/pan, only enforce minimum + zoom = Math.max(minZoom(), Math.min(MAX_ZOOM, zoom)); + clampPan(); + } + draw(); +} + +window.addEventListener("resize", () => { + resizeCanvas(); + refreshCursorFromLast(); +}); + +// Initial resize after first layout pass +requestAnimationFrame(resizeCanvas); diff --git a/public/style.css b/public/style.css index e484e9a..8bb8376 100644 --- a/public/style.css +++ b/public/style.css @@ -1147,19 +1147,15 @@ button:hover { filter: brightness(1.2); } -/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */ +/* ── Galaxy: fills remaining viewport space, map maintains 1:1 tile ratio via JS ── */ .galaxyMain { - /* Fit the tallest square that fits in the viewport without overlapping the info column */ - --map-size: min(100vh, calc(100vw - 340px)); position: relative; - width: var(--map-size); - height: var(--map-size); - flex-shrink: 0; - flex-grow: 0; + flex: 1 1 0; + min-width: 0; + height: 100vh; background: #050810; overflow: hidden; - align-self: flex-start; } canvas { @@ -1248,11 +1244,10 @@ canvas { display: flex; } - /* Galaxy scales to viewport width (square) */ + /* Galaxy fills the full screen on mobile */ .galaxyMain { - --map-size: 100vw; - width: 100vw; - height: 100vw; + width: 100%; + height: 100dvh; } /* Scoreboard wraps nicely on narrow screens */