From 42e68db00b885f356295b7198adb8a5a06038069 Mon Sep 17 00:00:00 2001 From: gauvainboiche Date: Tue, 31 Mar 2026 16:48:05 +0200 Subject: [PATCH] refacto: Making the map zoomable on both desktop and mobile --- public/src/game.js | 264 ++++++++++++++++++++++++++++++++++++++++++--- public/style.css | 1 + 2 files changed, 250 insertions(+), 15 deletions(-) diff --git a/public/src/game.js b/public/src/game.js index e3e1b04..0699759 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -49,6 +49,28 @@ export const teamCooldownEndMs = { blue: 0, red: 0 }; let rafId = 0; let lastPointerEvent = null; +// ── 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). */ +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. */ +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)); +} + +export function resetZoom() { zoom = 1; panX = 0; panY = 0; } + // ── Playfield mask ──────────────────────────────────────────────────────────── /** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */ @@ -499,21 +521,37 @@ export function draw() { const w = canvas.width; const h = canvas.height; - // 1. Dark base fill (shows in areas outside the SVG paths) + // viewport in map-space + const vw = SVG_W / zoom; + const vh = SVG_H / zoom; + + ctx.save(); + + // 1. Dark base fill ctx.fillStyle = "#050810"; ctx.fillRect(0, 0, w, h); - // 2. Draw the SVG playfield as the background layer + // 2. Apply zoom/pan transform: map panX..panX+vw → 0..w + ctx.scale(zoom, zoom); + ctx.translate(-panX, -panY); + + // 3. Draw the SVG playfield as the background layer if (playfieldImg) { - ctx.drawImage(playfieldImg, 0, 0, w, h); + ctx.drawImage(playfieldImg, 0, 0, SVG_W, SVG_H); } - const cw = w / GRID_W; - const ch = h / GRID_H; + const cw = SVG_W / GRID_W; + const ch = SVG_H / GRID_H; - // 3. Draw game tile overlays (only for exploitable tiles) - for (let y = 0; y < GRID_H; y++) { - for (let x = 0; x < GRID_W; x++) { + // Visible cell range (with 1-cell margin for partially visible tiles) + const xMin = Math.max(0, Math.floor(panX / cw) - 1); + const xMax = Math.min(GRID_W - 1, Math.ceil((panX + vw) / cw) + 1); + const yMin = Math.max(0, Math.floor(panY / ch) - 1); + const yMax = Math.min(GRID_H - 1, Math.ceil((panY + vh) / ch) + 1); + + // 4. Draw game tile overlays (only visible exploitable tiles) + for (let y = yMin; y <= yMax; y++) { + for (let x = xMin; x <= xMax; x++) { if (!isExploitable(x, y)) continue; const k = cellKey(x, y); const meta = cellMeta(k); @@ -524,10 +562,11 @@ export function draw() { } } - // 4. Draw planet dots on own discovered tiles + // 5. Draw planet dots on own discovered tiles for (const [key, meta] of cells) { if (meta.discoveredBy !== currentTeam) continue; const [xs, ys] = key.split(",").map(Number); + if (xs < xMin || xs > xMax || ys < yMin || ys > yMax) continue; if (!isExploitable(xs, ys) || !meta.hasPlanet) continue; const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337); const rng = mulberry32(h32); @@ -538,27 +577,32 @@ export function draw() { ctx.fill(); } - // 5. Grid lines between adjacent exploitable tiles + // 6. Grid lines between adjacent exploitable tiles (visible range only) ctx.strokeStyle = "rgba(255,255,255,0.10)"; - ctx.lineWidth = 1; + ctx.lineWidth = 1 / zoom; // keep grid lines thin regardless of zoom ctx.beginPath(); - for (let x = 1; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) { + for (let x = Math.max(1, xMin); x <= Math.min(GRID_W - 1, xMax); x++) for (let y = yMin; y <= yMax; y++) { if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue; ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (y + 1) * ch); } - for (let y = 1; y < GRID_H; y++) for (let x = 0; x < GRID_W; x++) { + for (let y = Math.max(1, yMin); y <= Math.min(GRID_H - 1, yMax); y++) for (let x = xMin; x <= xMax; x++) { if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue; ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch); } ctx.stroke(); + + ctx.restore(); } // ── Cursor ──────────────────────────────────────────────────────────────────── function pickCell(ev) { const rect = canvas.getBoundingClientRect(); - const x = Math.floor(((ev.clientX - rect.left) / rect.width) * GRID_W); - const y = Math.floor(((ev.clientY - rect.top) / rect.height) * GRID_H); + // 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 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; return { x, y }; } @@ -736,3 +780,193 @@ elemBonusTableEl?.addEventListener("click", (ev) => { canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); }); canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; }); canvas.addEventListener("click", onCanvasClick); + +// ── Zoom (mouse wheel) ─────────────────────────────────────────────────────── + +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 oldZoom = zoom; + // Negative deltaY = scroll up = zoom in + zoom *= 1 - ev.deltaY * ZOOM_SPEED; + zoom = Math.max(MIN_ZOOM, 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); + clampPan(); + + draw(); + refreshCursorFromLast(); +}, { passive: false }); + +// ── Pan (mouse drag) ───────────────────────────────────────────────────────── + +let _dragging = false; +let _dragStartX = 0; +let _dragStartY = 0; +let _panStartX = 0; +let _panStartY = 0; + +canvas.addEventListener("mousedown", (ev) => { + if (ev.button !== 0) return; // left-click only + _dragging = true; + _dragStartX = ev.clientX; + _dragStartY = ev.clientY; + _panStartX = panX; + _panStartY = panY; +}); + +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); + panX = _panStartX - dx; + panY = _panStartY - dy; + clampPan(); + draw(); +}); + +window.addEventListener("mouseup", () => { _dragging = false; }); + +// Suppress click events that are actually end-of-drag +let _suppressClick = false; +canvas.addEventListener("mousedown", (ev) => { + _suppressClick = false; + const sx = ev.clientX, sy = ev.clientY; + const up = () => { + window.removeEventListener("mouseup", up); + // If cursor moved more than 4px it was a drag, not a click + // (handled via capture-phase click suppression below) + }; + const move = (mev) => { + if (Math.hypot(mev.clientX - sx, mev.clientY - sy) > 4) _suppressClick = true; + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", () => { + window.removeEventListener("mousemove", move); + }, { once: true }); +}); + +canvas.addEventListener("click", (ev) => { + if (_suppressClick) { ev.stopImmediatePropagation(); _suppressClick = false; } +}, true); // capture phase — runs before the game click handler + +// ── Touch: pinch-to-zoom + pan ─────────────────────────────────────────────── + +let _touches = []; // active touch cache +let _pinchDist0 = null; // initial distance between two fingers +let _pinchZoom0 = 1; // zoom at pinch start +let _pinchMid0 = null; // midpoint at pinch start (map-space) +let _touchPanX0 = 0; +let _touchPanY0 = 0; +let _tapStartX = 0; // tap detection +let _tapStartY = 0; +let _tapStartTime = 0; +let _wasTap = false; + +function touchDist(a, b) { return Math.hypot(a.clientX - b.clientX, a.clientY - b.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, + }; +} + +canvas.addEventListener("touchstart", (ev) => { + ev.preventDefault(); + _touches = [...ev.touches]; + _wasTap = false; + if (_touches.length === 1) { + _dragStartX = _touches[0].clientX; + _dragStartY = _touches[0].clientY; + _panStartX = panX; + _panStartY = panY; + _tapStartX = _touches[0].clientX; + _tapStartY = _touches[0].clientY; + _tapStartTime = Date.now(); + _wasTap = true; + } + if (_touches.length === 2) { + _pinchDist0 = touchDist(_touches[0], _touches[1]); + _pinchZoom0 = zoom; + const midCx = (_touches[0].clientX + _touches[1].clientX) / 2; + const midCy = (_touches[0].clientY + _touches[1].clientY) / 2; + _pinchMid0 = screenToMap(midCx, midCy); + _touchPanX0 = panX; + _touchPanY0 = panY; + } +}, { passive: false }); + +canvas.addEventListener("touchmove", (ev) => { + ev.preventDefault(); + _touches = [...ev.touches]; + + // If finger moved significantly, it's not a tap anymore + if (_wasTap && _touches.length === 1) { + const dist = Math.hypot(_touches[0].clientX - _tapStartX, _touches[0].clientY - _tapStartY); + if (dist > 8) _wasTap = false; + } else { + _wasTap = false; + } + + 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); + panX = _panStartX - dx; + panY = _panStartY - dy; + clampPan(); + draw(); + } + + if (_touches.length === 2 && _pinchDist0) { + const newDist = touchDist(_touches[0], _touches[1]); + zoom = Math.max(MIN_ZOOM, 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); + clampPan(); + draw(); + } +}, { passive: false }); + +canvas.addEventListener("touchend", (ev) => { + ev.preventDefault(); + + // Detect tap: single finger, short duration, minimal movement + if (_wasTap && ev.changedTouches.length === 1 && ev.touches.length === 0) { + const elapsed = Date.now() - _tapStartTime; + if (elapsed < 300) { + const t = ev.changedTouches[0]; + // Synthesise a click event so onCanvasClick fires + const clickEv = new MouseEvent("click", { + clientX: t.clientX, clientY: t.clientY, + bubbles: true, cancelable: true, + }); + canvas.dispatchEvent(clickEv); + } + } + _wasTap = false; + + _touches = [...ev.touches]; + if (_touches.length < 2) { _pinchDist0 = null; } + if (_touches.length === 1) { + _dragStartX = _touches[0].clientX; + _dragStartY = _touches[0].clientY; + _panStartX = panX; + _panStartY = panY; + } +}, { passive: false }); diff --git a/public/style.css b/public/style.css index a88132a..1786205 100644 --- a/public/style.css +++ b/public/style.css @@ -953,6 +953,7 @@ canvas { width: 100%; height: 100%; image-rendering: pixelated; + touch-action: none; /* prevent browser pinch-zoom on canvas */ } /* ── Burger button (hidden on desktop) ────────────────────────────────────── */