From 26c8dd3e1ad9cefc34796b9df383c16104dbb280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 21:26:10 +0000 Subject: [PATCH] scale by whole pixels, and make the stage small enough to matter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same problem: the picture was sharp everywhere and wrong overall. ── whole-number scaling ── The canvas is now sized to the largest WHOLE number of device pixels per source pixel that fits the panel, measured with a ResizeObserver and the device pixel ratio. Below 1x it takes a unit fraction instead, so the rows that survive are evenly spaced rather than dropped in clumps. This is a 16px-per-cell bitmap from 1995. At a fractional scale one cell gets three screen pixels and its neighbour gets two, the grid goes uneven and the digits wobble — which reads as "not quite right" rather than as blur, and `image-rendering: pixelated` does not help because unevenness is not blur. A fractional devicePixelRatio produces exactly this at what looks like 1:1. It is also allowed to scale UP past the framebuffer's own size, which was the other half of it: `max-w-full` only ever shrinks, so on a wide panel the canvas sat at its intrinsic size using a fraction of the pixels available. ── a smaller stage ── 800x600 down to 560x400. The stage is what gets scaled to fill the panel, so every pixel of empty surround is panel the game does not get, and the whole- number steps are far apart: in an 1866-device-pixel panel, 800 wide lands on 2x and 560 wide lands on 3x. Same panel, half again as much game. 560x400 is expert (504x342, measured on this machine) plus a margin. A custom board bigger than the stage is now clipped at the right and bottom rather than shrunk; `centre` clamps at zero so the menu bar stays reachable. Widen the two constants if that turns out to be the wrong trade. Verified: beginner centres at 204,93 on the new stage, which is exact. Co-Authored-By: Claude Opus 5 Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b --- README.md | 10 ++++++- sidecar/session.ts | 18 +++++++++--- web/MinesweeperPanel.tsx | 63 +++++++++++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b78c4fa..8c0ce9c 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,19 @@ The check that matters is `ss -lntp` against the **running** process. Reading th listener that exists because a flag is missing, and running the same arguments by hand did not reproduce it either. -**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 800x600 +**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 560x400 whatever the difficulty, `sidecar/session.ts` keeps the window in the middle of it, and the panel draws the framebuffer entire — scaled to fit, never cropped. So changing difficulty moves the board rather than resizing the panel, and the surround is teal (`-solid`) rather than the root's black. +**The stage is small because the panel scales it by whole numbers.** Pixel art at a fractional scale gets +uneven cells — some three screen pixels wide, their neighbours two — which reads as "not quite right" +rather than as blur, and `image-rendering: pixelated` does not help because unevenness is not blur. So the +panel picks the largest whole number of DEVICE pixels per source pixel and every pixel of empty stage is +panel the game does not get: in an 1866-device-pixel panel, 800 wide lands on 2x and 560 wide lands on 3x. +560x400 is expert (504x342) plus a margin. A custom board bigger than that is clipped at the right and +bottom rather than shrunk, with the menu bar kept reachable. + It used to park the window at the origin and have the client crop to the window's size, polled every four seconds. That resized the panel under the player, and it was off by the 3x29 wine puts between the root origin and the window — a black strip along the top and a row of cut-off cells along the bottom, worst on diff --git a/sidecar/session.ts b/sidecar/session.ts index a6ad7e9..01b1042 100644 --- a/sidecar/session.ts +++ b/sidecar/session.ts @@ -47,11 +47,21 @@ export type SessionStatus = { * player mid-game, and it was wrong by the 3x29 wine puts between the root origin and the window, which * showed as a black strip on top and a row of cut-off cells at the bottom. * - * 800x600 is generous on beginner and that is deliberate: it holds the largest board Minesweeper X can - * make (30x24 custom, 504x470) with room to spare, so no difficulty ever reaches an edge. + * ── Why it is this small ── + * + * The stage is what gets scaled to fill the panel, so every pixel of empty surround is panel the game + * does not get. The client scales by whole numbers only — fractional scaling makes a 16px grid uneven — + * so the size here decides which step it lands on, and the steps are far apart. In a 1866-device-pixel + * panel: 800 wide gives 2x, 560 wide gives 3x. Same panel, half again as much game. + * + * 560x400 is expert (504x342) plus a margin, which is what the three standard difficulties need. A + * CUSTOM board larger than this is clipped at the right and bottom edges rather than shrunk — `centre` + * clamps at zero so the menu bar stays reachable. That is the trade: the sizes people actually play are + * as large as they can be, and the one that is not is still playable. Widen these two numbers if that + * turns out to be the wrong way round. */ -const SCREEN_W = 800; -const SCREEN_H = 600; +const SCREEN_W = 560; +const SCREEN_H = 400; /** * How often to check whether the board changed size. diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index 4c98e8f..0ff465e 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import type { RfbError } from './rfb'; @@ -20,7 +20,7 @@ type SessionStatus = { // // Only a placeholder until `onReady`, which sizes the canvas from the real framebuffer. Drift with the // sidecar's stage costs one frame of the wrong aspect ratio and nothing else. -const PLACEHOLDER = { width: 800, height: 600 }; +const PLACEHOLDER = { width: 560, height: 400 }; const BUTTON_MASK: Record = { 0: 1, 1: 2, 2: 4 }; @@ -58,11 +58,52 @@ export const MinesweeperPanel = () => { const rfbRef = useRef(null); const buttonsRef = useRef(0); + const boxRef = useRef(null); + + const [frame, setFrame] = useState<{ width: number; height: number } | null>(null); + const [box, setBox] = useState<{ width: number; height: number; dpr: number } | null>(null); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [connected, setConnected] = useState(false); const [busy, setBusy] = useState(false); + // Measured rather than assumed, because the scale below has to be exact and a panel is resizable. + // Re-attached when the session starts: until then this branch of the tree is not rendered and the ref + // is null, so a mount-only observer would watch nothing for the rest of the session. + useEffect(() => { + const node = boxRef.current; + if (!node) return; + const observer = new ResizeObserver(([entry]) => { + const rect = entry?.contentRect; + if (rect) setBox({ width: rect.width, height: rect.height, dpr: window.devicePixelRatio || 1 }); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [status?.running]); + + /** + * How big to draw the stage — and the answer is always a WHOLE number of screen pixels per source + * pixel, or a whole number of source pixels per screen pixel. + * + * This is a 16px-per-cell bitmap from 1995. At a fractional scale some cells get three screen pixels + * and their neighbours get two, so the grid goes uneven and the digits wobble: sharp everywhere and + * wrong overall, which is what "not quite right" looks like on pixel art. `image-rendering: pixelated` + * does not save you — it removes the blur, not the unevenness. + * + * Computed in DEVICE pixels, so a 2x display gets 2x the detail rather than the same picture twice as + * large. And it is allowed to scale UP past the framebuffer's own size, which is the part that was + * missing: on a 1x display the canvas sat at 800 CSS px in a panel twice that wide, using a quarter of + * the pixels available to it. + */ + const stage = useMemo(() => { + if (!frame || !box || box.width <= 0 || box.height <= 0) return null; + const fit = Math.min((box.width * box.dpr) / frame.width, (box.height * box.dpr) / frame.height); + // Below 1 there is nothing to floor to, so take a unit fraction: the rows that survive are then + // evenly spaced instead of dropped in clumps. + const scale = fit >= 1 ? Math.floor(fit) : 1 / Math.ceil(1 / fit); + return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr }; + }, [frame, box]); + const refresh = useCallback(async () => { try { setStatus(await clientRef.current.get('/minesweeper/status')); @@ -107,6 +148,7 @@ export const MinesweeperPanel = () => { canvas.width = width; canvas.height = height; } + setFrame({ width, height }); setConnected(true); setError(null); }, @@ -127,6 +169,7 @@ export const MinesweeperPanel = () => { canvas.width = width; canvas.height = height; } + setFrame({ width, height }); rfb.requestUpdate(false); }, onError: (rfbError) => setError(describeRfbError(rfbError)), @@ -197,7 +240,7 @@ export const MinesweeperPanel = () => { return (
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */} -
+
{ onMouseUp={(ev) => onPointer(ev, 'up')} onMouseMove={(ev) => onPointer(ev, 'move')} onContextMenu={(ev) => ev.preventDefault()} - // Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a - // photograph of a screen rather than the screen. + // Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than + // the screen. It is half of what keeps this sharp; `stage` above is the other half. // - // max-w and max-h together, and no explicit width: a canvas is a replaced element, so CSS - // shrinks it to fit while holding its ratio. That is the whole "centred in the panel" — the - // flex parent does the centring and this does the scaling. - className="max-h-full max-w-full rounded-sm shadow-lg [image-rendering:pixelated]" - style={{ cursor: connected ? 'default' : 'progress' }} + // The max-* pair is only the fallback for the frame before `stage` is known — once it is, the + // explicit size governs and cannot exceed the box, so capping one axis would only squash the + // other. + className={`rounded-sm shadow-lg [image-rendering:pixelated] ${stage ? '' : 'max-h-full max-w-full'}`} + style={{ ...(stage ?? {}), cursor: connected ? 'default' : 'progress' }} />