Trimming the stage alone does nothing, and that is worth writing down: the scale is snapped, so at 3x the game is 1512 device pixels wide whether the stage is 560 or 512. Shrinking the surround only pays when it crosses a step. So both halves land together. ── the stage ── 560x400 down to 512x352, which is expert (504x342, measured) plus four pixels a side. There is nothing left to give — intermediate is 342 tall too, so anything shorter clips a board people actually play. ── half steps from 2x ── Not a retreat from whole-number scaling. The unit that has to stay regular is the CELL, not the pixel: this is a 16px grid, so at 3.5x a cell is exactly 56 screen pixels — as even as 3x or 4x — and only sub-cell pixels alternate between three and four. Below 2x that alternation is a third of a pixel rather than a seventh, so whole numbers still apply there. The case that started all this, a fractional devicePixelRatio at nominal 1:1, put the CELL boundaries at 26.4px, which is a different thing entirely. Together, on a 1800x1400 device-pixel panel: 3.0x becomes 3.5x, and the game goes from 1512 to 1764 device pixels wide. Known cost, and it is in the README: the Extras > Auto Options dialog is 328 wide and opens 25px right of the game, so it needs a 554-wide stage to fit whole from beginner. It cannot be closed from the panel anyway — no window manager, no title bar, no OK button — so that is the thing to fix before this number is revisited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
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';
|
|
import { RfbClient } from './rfb';
|
|
|
|
type SessionStatus = {
|
|
installed: boolean;
|
|
running: boolean;
|
|
};
|
|
|
|
// The whole framebuffer is the picture, and the sidecar keeps the game centred in it. So there is no
|
|
// crop here, no board geometry to fetch, and nothing that has to be re-read when the difficulty changes
|
|
// — the canvas is whatever size the server said at handshake and stays that size for the session.
|
|
//
|
|
// It used to crop to the window's rectangle at the origin, which needed the geometry polled every four
|
|
// seconds and was wrong twice over: the panel resized under the player mid-game, and wine puts the
|
|
// window at 3,29 rather than 0,0, so a crop from the origin showed a black strip on top and cut a row
|
|
// of cells off the bottom. That was the "expert gets cut off at the bottom" report.
|
|
//
|
|
// 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: 512, height: 352 };
|
|
|
|
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
|
|
|
// Plugin panels have no localisation mechanism yet — `officerdev`'s `useTranslation`/`panelCatalogs`
|
|
// (merged by `I18nBridge`) is platform-internal and not exported from the barrel, and
|
|
// `scripts/i18n/scan-hardcoded.ts` explicitly does not scan `plugins/`. So these stay plain English
|
|
// sentences rather than catalog keys, same as every other plugin's user-facing strings.
|
|
const describeRfbError = (error: RfbError): string => {
|
|
switch (error.code) {
|
|
case 'connectionFailed':
|
|
return 'The connection to the Minesweeper session failed.';
|
|
case 'refused':
|
|
return 'The VNC server refused the connection.';
|
|
case 'authUnsupported':
|
|
return 'The VNC server wants authentication, which this client does not implement.';
|
|
case 'handshakeRejected':
|
|
return 'The VNC server rejected the handshake.';
|
|
case 'unknownMessage':
|
|
return `The VNC server sent an unknown message type (${error.type}).`;
|
|
case 'unknownEncoding':
|
|
return `The VNC server used encoding ${error.encoding}, which this client did not ask for.`;
|
|
}
|
|
};
|
|
|
|
export const MinesweeperPanel = () => {
|
|
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
|
|
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
|
|
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
|
|
// unstable, its mount effect re-ran every render, and each run set state and caused another render:
|
|
// an endless fetch loop that starved the socket and left the panel saying "connecting…" forever.
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const rfbRef = useRef<RfbClient | null>(null);
|
|
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 [error, setError] = useState<string | null>(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. Computed in DEVICE pixels, so a 2x display gets twice the detail rather
|
|
* than the same picture twice the size — and allowed to scale UP past the framebuffer's own size,
|
|
* without which a wide panel leaves the canvas sitting at a fraction of the pixels available to it.
|
|
*
|
|
* ── Why the scale is snapped, and to what ──
|
|
*
|
|
* This is a 16px-per-cell bitmap from 1995. At an arbitrary scale a cell is 26.4 screen pixels, so the
|
|
* grid lines themselves land unevenly 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. A fractional devicePixelRatio produces this at what looks
|
|
* like 1:1, which is the case that is easiest to miss.
|
|
*
|
|
* HALF steps are allowed from 2x up, and that is not a compromise on the above. The unit that has to
|
|
* stay regular is the CELL, not the pixel: at 3.5x a cell is exactly 56 screen pixels, so the grid is
|
|
* as even as it is at 3x or 4x, and only sub-cell pixels alternate between three and four. Below 2x
|
|
* that alternation is a third of the pixel rather than a seventh, so it stays whole-number there.
|
|
*
|
|
* The steps are what make the size quantised — at 3x the game is the same width whether the stage is
|
|
* 512 or 560 wide. Shrinking the stage is only worth anything when it crosses a step, which is why
|
|
* these two changes landed together.
|
|
*/
|
|
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 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 2) / 2;
|
|
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<SessionStatus>('/minesweeper/status'));
|
|
} catch {
|
|
setError('Could not reach the server.');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
const start = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
setStatus(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
|
|
} catch {
|
|
setError('The session would not start. Is wine-minesweeper installed on the server?');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!status?.running) return;
|
|
|
|
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
|
|
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
|
|
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
|
|
// Plugin relay, not a platform websocket provider: `/api/plugin-ws/minesweeper` rather than the old
|
|
// `/api/minesweeper/ws`. Same byte relay onto x11vnc, mounted by the plugin instead of the shell.
|
|
const url = `${scheme}://${window.location.host}/api/plugin-ws/minesweeper?token=${encodeURIComponent(token)}`;
|
|
|
|
const rfb = new RfbClient(url, {
|
|
// No offscreen buffer: with the crop gone the visible canvas IS the framebuffer, so a second
|
|
// canvas would only add a full blit per frame to arrive at the same pixels.
|
|
onReady: (width, height) => {
|
|
const canvas = canvasRef.current;
|
|
if (canvas) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
setFrame({ width, height });
|
|
setConnected(true);
|
|
setError(null);
|
|
},
|
|
onFrame: (image, x, y) => {
|
|
canvasRef.current?.getContext('2d')?.putImageData(image, x, y);
|
|
},
|
|
onCopyRect: (from, to) => {
|
|
// Same canvas as source and destination, which is what CopyRect is: the 2D context takes a
|
|
// snapshot of the bitmap first, so an overlapping copy is well defined rather than smeared.
|
|
const canvas = canvasRef.current;
|
|
const context = canvas?.getContext('2d');
|
|
if (!canvas || !context) return;
|
|
context.drawImage(canvas, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
|
|
},
|
|
onResize: (width, height) => {
|
|
const canvas = canvasRef.current;
|
|
if (canvas) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
setFrame({ width, height });
|
|
rfb.requestUpdate(false);
|
|
},
|
|
onError: (rfbError) => setError(describeRfbError(rfbError)),
|
|
onClose: () => setConnected(false),
|
|
});
|
|
|
|
rfbRef.current = rfb;
|
|
return () => {
|
|
rfb.close();
|
|
rfbRef.current = null;
|
|
setConnected(false);
|
|
};
|
|
}, [status?.running]);
|
|
|
|
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const rect = ev.currentTarget.getBoundingClientRect();
|
|
const scaleX = ev.currentTarget.width / rect.width;
|
|
const scaleY = ev.currentTarget.height / rect.height;
|
|
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
|
|
};
|
|
|
|
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
|
|
const rfb = rfbRef.current;
|
|
if (!rfb) return;
|
|
ev.preventDefault();
|
|
|
|
if (kind !== 'move') {
|
|
const bit = BUTTON_MASK[ev.button] ?? 0;
|
|
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
|
}
|
|
const { x, y } = toFramebuffer(ev);
|
|
rfb.pointer(x, y, buttonsRef.current);
|
|
};
|
|
|
|
if (status && !status.installed) {
|
|
return (
|
|
<Centred>
|
|
<Bomb className="size-8 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
|
|
{/* The package name sits at the end so the sentence is one key rather than two around an
|
|
inline element — see docs/localization.md on the fragmentation that produces. */}
|
|
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
|
It needs wine, a 32-bit prefix, and this package beside the platform: <code>wine-minesweeper</code>
|
|
</p>
|
|
</Centred>
|
|
);
|
|
}
|
|
|
|
if (status && !status.running) {
|
|
return (
|
|
<Centred>
|
|
<Bomb className="size-8 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
|
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
<button
|
|
type="button"
|
|
onClick={start}
|
|
disabled={busy}
|
|
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
|
>
|
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
|
{busy ? 'Starting' : 'Start it'}
|
|
</button>
|
|
</Centred>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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. */}
|
|
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={PLACEHOLDER.width}
|
|
height={PLACEHOLDER.height}
|
|
onMouseDown={(ev) => onPointer(ev, 'down')}
|
|
onMouseUp={(ev) => onPointer(ev, 'up')}
|
|
onMouseMove={(ev) => onPointer(ev, 'move')}
|
|
onContextMenu={(ev) => ev.preventDefault()}
|
|
// 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.
|
|
//
|
|
// 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' }}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span>{connected ? 'connected' : 'connecting…'}</span>
|
|
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
|
<RefreshCw className="size-3" /> refresh
|
|
</button>
|
|
</div>
|
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Centred = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
|
|
);
|