Private
Public Access
1
0

refacto: Making the map zoomable on both desktop and mobile

This commit is contained in:
gauvainboiche
2026-03-31 16:48:05 +02:00
parent d1240adbb7
commit 42e68db00b
2 changed files with 250 additions and 15 deletions

View File

@@ -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 });

View File

@@ -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) ────────────────────────────────────── */