fix: Playground surface on screen

This commit is contained in:
gauvainboiche
2026-04-02 22:05:55 +02:00
parent 0b56e0deb0
commit 0cc1d66791
2 changed files with 102 additions and 48 deletions
+93 -34
View File
@@ -87,25 +87,48 @@ function tileAlpha(key) {
// ── Zoom / Pan state ────────────────────────────────────────────────────────── // ── Zoom / Pan state ──────────────────────────────────────────────────────────
const MIN_ZOOM = 1;
const MAX_ZOOM = 12; const MAX_ZOOM = 12;
const ZOOM_SPEED = 0.001; // wheel delta multiplier 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; let zoom = 1;
/** Top-left offset in *map-space* coordinates (0..1000). */ /** Top-left offset in *map-space* coordinates (0..1000). */
let panX = 0; let panX = 0;
let panY = 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() { function clampPan() {
const vw = SVG_W / zoom; const vw = canvas.width / zoom;
const vh = SVG_H / 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)); 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)); 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 ──────────────────────────────────────────────────────────── // ── Playfield mask ────────────────────────────────────────────────────────────
@@ -822,9 +845,9 @@ export function draw() {
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
// viewport in map-space // viewport in map-space (canvas may be non-square)
const vw = SVG_W / zoom; const vw = canvas.width / zoom;
const vh = SVG_H / zoom; const vh = canvas.height / zoom;
ctx.save(); ctx.save();
@@ -918,15 +941,14 @@ const mapAnimTextByType = {
function triggerMapAnimation(type, x, y) { function triggerMapAnimation(type, x, y) {
const cw = SVG_W / GRID_W; const cw = SVG_W / GRID_W;
const ch = SVG_H / GRID_H; const ch = SVG_H / GRID_H;
const rect = canvas.getBoundingClientRect();
// Top-centre of the cell in screen pixels, relative to the canvas element // Top-centre of the cell in canvas pixels (= CSS px since canvas buffer matches display size)
const screenX = ((x * cw + cw / 2 - panX) * zoom / SVG_W) * rect.width; const screenX = (x * cw + cw / 2 - panX) * zoom;
const screenY = ((y * ch - panY) * zoom / SVG_H) * rect.height; const screenY = (y * ch - panY) * zoom;
// One cell height in screen pixels (travel distance) // One cell height and width in screen pixels
const cellScreenH = (ch * zoom / SVG_H) * rect.height; const cellScreenH = ch * zoom;
const fontSize = Math.max(8, (cw * zoom / SVG_W) * rect.width); const fontSize = Math.max(8, cw * zoom);
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "mapAnimFloat"; el.className = "mapAnimFloat";
@@ -953,8 +975,8 @@ function triggerMapAnimation(type, x, y) {
function pickCell(ev) { function pickCell(ev) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
// Convert screen coords → map coords accounting for zoom/pan // Convert screen coords → map coords accounting for zoom/pan
const mapX = (ev.clientX - rect.left) / rect.width * (SVG_W / zoom) + panX; const mapX = (ev.clientX - rect.left) / rect.width * (canvas.width / zoom) + panX;
const mapY = (ev.clientY - rect.top) / rect.height * (SVG_H / zoom) + panY; const mapY = (ev.clientY - rect.top) / rect.height * (canvas.height / zoom) + panY;
const x = Math.floor(mapX / (SVG_W / GRID_W)); const x = Math.floor(mapX / (SVG_W / GRID_W));
const y = Math.floor(mapY / (SVG_H / GRID_H)); 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;
@@ -1134,7 +1156,7 @@ async function onCanvasClick(ev) {
// ── Not yet revealed: first click = spend user action to reveal ── // ── Not yet revealed: first click = spend user action to reveal ──
if (!meta) { if (!meta) {
if (cooldownActive()) { 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; return;
} }
try { try {
@@ -1142,7 +1164,7 @@ async function onCanvasClick(ev) {
if (res.status === 429) { if (res.status === 429) {
actionsRemaining = 0; actionsRemaining = 0;
updateActionsDisplay(); 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; return;
} }
if (res.status === 410) { if (res.status === 410) {
@@ -1345,17 +1367,17 @@ canvas.addEventListener("wheel", (ev) => {
ev.preventDefault(); ev.preventDefault();
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
// Cursor position in map-space before zoom // Cursor position in map-space before zoom
const mx = (ev.clientX - rect.left) / rect.width * (SVG_W / zoom) + panX; const mx = (ev.clientX - rect.left) / rect.width * (canvas.width / zoom) + panX;
const my = (ev.clientY - rect.top) / rect.height * (SVG_H / zoom) + panY; const my = (ev.clientY - rect.top) / rect.height * (canvas.height / zoom) + panY;
const oldZoom = zoom; const oldZoom = zoom;
// Negative deltaY = scroll up = zoom in // Negative deltaY = scroll up = zoom in
zoom *= 1 - ev.deltaY * ZOOM_SPEED; 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 // Adjust pan so the map point under the cursor stays in place
panX = mx - (ev.clientX - rect.left) / rect.width * (SVG_W / zoom); panX = mx - (ev.clientX - rect.left) / rect.width * (canvas.width / zoom);
panY = my - (ev.clientY - rect.top) / rect.height * (SVG_H / zoom); panY = my - (ev.clientY - rect.top) / rect.height * (canvas.height / zoom);
clampPan(); clampPan();
draw(); draw();
@@ -1382,8 +1404,8 @@ canvas.addEventListener("mousedown", (ev) => {
window.addEventListener("mousemove", (ev) => { window.addEventListener("mousemove", (ev) => {
if (!_dragging) return; if (!_dragging) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const dx = (ev.clientX - _dragStartX) / rect.width * (SVG_W / zoom); const dx = (ev.clientX - _dragStartX) / rect.width * (canvas.width / zoom);
const dy = (ev.clientY - _dragStartY) / rect.height * (SVG_H / zoom); const dy = (ev.clientY - _dragStartY) / rect.height * (canvas.height / zoom);
panX = _panStartX - dx; panX = _panStartX - dx;
panY = _panStartY - dy; panY = _panStartY - dy;
clampPan(); clampPan();
@@ -1433,8 +1455,8 @@ function touchDist(a, b) { return Math.hypot(a.clientX - b.clientX, a.clientY -
function screenToMap(cx, cy) { function screenToMap(cx, cy) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
return { return {
mx: (cx - rect.left) / rect.width * (SVG_W / zoom) + panX, mx: (cx - rect.left) / rect.width * (canvas.width / zoom) + panX,
my: (cy - rect.top) / rect.height * (SVG_H / zoom) + panY, my: (cy - rect.top) / rect.height * (canvas.height / zoom) + panY,
}; };
} }
@@ -1478,8 +1500,8 @@ canvas.addEventListener("touchmove", (ev) => {
if (_touches.length === 1) { if (_touches.length === 1) {
// Single-finger pan // Single-finger pan
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const dx = (_touches[0].clientX - _dragStartX) / rect.width * (SVG_W / zoom); const dx = (_touches[0].clientX - _dragStartX) / rect.width * (canvas.width / zoom);
const dy = (_touches[0].clientY - _dragStartY) / rect.height * (SVG_H / zoom); const dy = (_touches[0].clientY - _dragStartY) / rect.height * (canvas.height / zoom);
panX = _panStartX - dx; panX = _panStartX - dx;
panY = _panStartY - dy; panY = _panStartY - dy;
clampPan(); clampPan();
@@ -1488,14 +1510,14 @@ canvas.addEventListener("touchmove", (ev) => {
if (_touches.length === 2 && _pinchDist0) { if (_touches.length === 2 && _pinchDist0) {
const newDist = touchDist(_touches[0], _touches[1]); 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 // Keep the original midpoint anchored
const midCx = (_touches[0].clientX + _touches[1].clientX) / 2; const midCx = (_touches[0].clientX + _touches[1].clientX) / 2;
const midCy = (_touches[0].clientY + _touches[1].clientY) / 2; const midCy = (_touches[0].clientY + _touches[1].clientY) / 2;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
panX = _pinchMid0.mx - (midCx - rect.left) / rect.width * (SVG_W / zoom); panX = _pinchMid0.mx - (midCx - rect.left) / rect.width * (canvas.width / zoom);
panY = _pinchMid0.my - (midCy - rect.top) / rect.height * (SVG_H / zoom); panY = _pinchMid0.my - (midCy - rect.top) / rect.height * (canvas.height / zoom);
clampPan(); clampPan();
draw(); draw();
} }
@@ -1528,3 +1550,40 @@ canvas.addEventListener("touchend", (ev) => {
_panStartY = panY; _panStartY = panY;
} }
}, { passive: false }); }, { 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);
+7 -12
View File
@@ -1147,19 +1147,15 @@ button:hover {
filter: brightness(1.2); 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 { .galaxyMain {
/* Fit the tallest square that fits in the viewport without overlapping the info column */
--map-size: min(100vh, calc(100vw - 340px));
position: relative; position: relative;
width: var(--map-size); flex: 1 1 0;
height: var(--map-size); min-width: 0;
flex-shrink: 0; height: 100vh;
flex-grow: 0;
background: #050810; background: #050810;
overflow: hidden; overflow: hidden;
align-self: flex-start;
} }
canvas { canvas {
@@ -1248,11 +1244,10 @@ canvas {
display: flex; display: flex;
} }
/* Galaxy scales to viewport width (square) */ /* Galaxy fills the full screen on mobile */
.galaxyMain { .galaxyMain {
--map-size: 100vw; width: 100%;
width: 100vw; height: 100dvh;
height: 100vw;
} }
/* Scoreboard wraps nicely on narrow screens */ /* Scoreboard wraps nicely on narrow screens */