refacto: Making the map zoomable on both desktop and mobile
This commit is contained in:
@@ -49,6 +49,28 @@ export const teamCooldownEndMs = { blue: 0, red: 0 };
|
|||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
let lastPointerEvent = null;
|
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 ────────────────────────────────────────────────────────────
|
// ── Playfield mask ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */
|
/** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */
|
||||||
@@ -499,21 +521,37 @@ export function draw() {
|
|||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
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.fillStyle = "#050810";
|
||||||
ctx.fillRect(0, 0, w, h);
|
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) {
|
if (playfieldImg) {
|
||||||
ctx.drawImage(playfieldImg, 0, 0, w, h);
|
ctx.drawImage(playfieldImg, 0, 0, SVG_W, SVG_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cw = w / GRID_W;
|
const cw = SVG_W / GRID_W;
|
||||||
const ch = h / GRID_H;
|
const ch = SVG_H / GRID_H;
|
||||||
|
|
||||||
// 3. Draw game tile overlays (only for exploitable tiles)
|
// Visible cell range (with 1-cell margin for partially visible tiles)
|
||||||
for (let y = 0; y < GRID_H; y++) {
|
const xMin = Math.max(0, Math.floor(panX / cw) - 1);
|
||||||
for (let x = 0; x < GRID_W; x++) {
|
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;
|
if (!isExploitable(x, y)) continue;
|
||||||
const k = cellKey(x, y);
|
const k = cellKey(x, y);
|
||||||
const meta = cellMeta(k);
|
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) {
|
for (const [key, meta] of cells) {
|
||||||
if (meta.discoveredBy !== currentTeam) continue;
|
if (meta.discoveredBy !== currentTeam) continue;
|
||||||
const [xs, ys] = key.split(",").map(Number);
|
const [xs, ys] = key.split(",").map(Number);
|
||||||
|
if (xs < xMin || xs > xMax || ys < yMin || ys > yMax) continue;
|
||||||
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
|
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
|
||||||
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
|
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
|
||||||
const rng = mulberry32(h32);
|
const rng = mulberry32(h32);
|
||||||
@@ -538,27 +577,32 @@ export function draw() {
|
|||||||
ctx.fill();
|
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.strokeStyle = "rgba(255,255,255,0.10)";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1 / zoom; // keep grid lines thin regardless of zoom
|
||||||
ctx.beginPath();
|
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;
|
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
|
||||||
ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (y + 1) * ch);
|
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;
|
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
|
||||||
ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
|
ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cursor ────────────────────────────────────────────────────────────────────
|
// ── Cursor ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function pickCell(ev) {
|
function pickCell(ev) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = Math.floor(((ev.clientX - rect.left) / rect.width) * GRID_W);
|
// Convert screen coords → map coords accounting for zoom/pan
|
||||||
const y = Math.floor(((ev.clientY - rect.top) / rect.height) * GRID_H);
|
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;
|
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
@@ -736,3 +780,193 @@ elemBonusTableEl?.addEventListener("click", (ev) => {
|
|||||||
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
|
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
|
||||||
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
||||||
canvas.addEventListener("click", onCanvasClick);
|
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 });
|
||||||
|
|||||||
@@ -953,6 +953,7 @@ canvas {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
|
touch-action: none; /* prevent browser pinch-zoom on canvas */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Burger button (hidden on desktop) ────────────────────────────────────── */
|
/* ── Burger button (hidden on desktop) ────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user