scale by whole pixels, and make the stage small enough to matter
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 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
@@ -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
|
listener that exists because a flag is missing, and running the same arguments by hand did not reproduce
|
||||||
it either.
|
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
|
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
|
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.
|
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
|
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
|
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
|
origin and the window — a black strip along the top and a row of cut-off cells along the bottom, worst on
|
||||||
|
|||||||
+14
-4
@@ -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
|
* 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.
|
* 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
|
* ── Why it is this small ──
|
||||||
* make (30x24 custom, 504x470) with room to spare, so no difficulty ever reaches an edge.
|
*
|
||||||
|
* 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_W = 560;
|
||||||
const SCREEN_H = 600;
|
const SCREEN_H = 400;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How often to check whether the board changed size.
|
* How often to check whether the board changed size.
|
||||||
|
|||||||
+53
-10
@@ -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 { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { RfbError } from './rfb';
|
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
|
// 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.
|
// 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<number, number> = { 0: 1, 1: 2, 2: 4 };
|
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
||||||
|
|
||||||
@@ -58,11 +58,52 @@ export const MinesweeperPanel = () => {
|
|||||||
const rfbRef = useRef<RfbClient | null>(null);
|
const rfbRef = useRef<RfbClient | null>(null);
|
||||||
const buttonsRef = useRef(0);
|
const buttonsRef = useRef(0);
|
||||||
|
|
||||||
|
const boxRef = useRef<HTMLDivElement | null>(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<SessionStatus | null>(null);
|
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [busy, setBusy] = 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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setStatus(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
|
setStatus(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
|
||||||
@@ -107,6 +148,7 @@ export const MinesweeperPanel = () => {
|
|||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
}
|
}
|
||||||
|
setFrame({ width, height });
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
@@ -127,6 +169,7 @@ export const MinesweeperPanel = () => {
|
|||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
}
|
}
|
||||||
|
setFrame({ width, height });
|
||||||
rfb.requestUpdate(false);
|
rfb.requestUpdate(false);
|
||||||
},
|
},
|
||||||
onError: (rfbError) => setError(describeRfbError(rfbError)),
|
onError: (rfbError) => setError(describeRfbError(rfbError)),
|
||||||
@@ -197,7 +240,7 @@ export const MinesweeperPanel = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
||||||
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */}
|
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */}
|
||||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
|
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={PLACEHOLDER.width}
|
width={PLACEHOLDER.width}
|
||||||
@@ -206,14 +249,14 @@ export const MinesweeperPanel = () => {
|
|||||||
onMouseUp={(ev) => onPointer(ev, 'up')}
|
onMouseUp={(ev) => onPointer(ev, 'up')}
|
||||||
onMouseMove={(ev) => onPointer(ev, 'move')}
|
onMouseMove={(ev) => onPointer(ev, 'move')}
|
||||||
onContextMenu={(ev) => ev.preventDefault()}
|
onContextMenu={(ev) => ev.preventDefault()}
|
||||||
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
|
// Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than
|
||||||
// photograph of a screen rather than the screen.
|
// 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
|
// The max-* pair is only the fallback for the frame before `stage` is known — once it is, the
|
||||||
// shrinks it to fit while holding its ratio. That is the whole "centred in the panel" — the
|
// explicit size governs and cannot exceed the box, so capping one axis would only squash the
|
||||||
// flex parent does the centring and this does the scaling.
|
// other.
|
||||||
className="max-h-full max-w-full rounded-sm shadow-lg [image-rendering:pixelated]"
|
className={`rounded-sm shadow-lg [image-rendering:pixelated] ${stage ? '' : 'max-h-full max-w-full'}`}
|
||||||
style={{ cursor: connected ? 'default' : 'progress' }}
|
style={{ ...(stage ?? {}), cursor: connected ? 'default' : 'progress' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user