SOURCE / PINNED RELEASE
Made of little things.
Shiftfield
- Release
- 5a170c505f6b…
- Author-recorded commit
- a2e23e0171aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd
Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/* Minesweeper DX — playable board UI (Phase 2.5 integration).
*
* Renders the GameEngine's 10×10 world as a DOM grid and wires input:
* left-click / Enter → reveal
* right-click / Space → flag/anchor toggle (never detonates): places a
* flag on HIDDEN ground, removes it from a flagged
* tile; revealed tiles reject the attempt.
* Long-press toggle is PRIMARY-BUTTON-ONLY: a
* right-hold flags at press (contextmenu) and must
* not be re-toggled at release.
* long-press (touch) → flag/anchor toggle
* arrow keys → move focus
*
* Targeted abilities (radar): when `pendingTarget` is armed, the NEXT click
* (or Enter) on the board fires the ability at the clicked tile instead of
* revealing — it never fires at the previously focused tile. Right-click or
* long-press (flagging) cancels the arm.
*
* Pure UI shell over GameEngine (chaos + world). It holds no game rules:
* ice/fire rendering reads GameEngine state, and every mutation goes through
* engine methods, then refresh() + the onSync callback for HUD/overlays.
*/
import { GRID_SIZE } from './engine.js';
import { GameEngine } from './game.js';
import type { FxEvent } from './juice.js';
export type SyncFn = (game: GameEngine) => void;
/** Snapshot of the board used to derive juice events from engine diffs —
* the engine stays juice-agnostic; the render sync point observes it. */
interface FxSnapshot {
world: unknown;
cells: string;
mines: string;
exploded: boolean;
/** Shifter countdown at last paint — a countdown STEP toward <=3 turns
* raises the shift-warning beat (ruling 2026-10-28). */
shiftIn: number;
/** One char per tile: live proximity digit on REVEALED safe ground, '.'
* elsewhere. Digit changes between paints pulse that tile (the shift's
* legible afterimage — mines are hidden, the numbers are not). */
nums: string;
}
/** B1 cap (audit telegraph_depth6_bug_audit_2026-10-28): max .cell-count-pulse
* cells per paint. A wide-open board's full shift flips 50–80 proximity digits;
* the pulse is capped to this many leading-edge tiles (ranked in diffFx).
* Tests assert against this symbol, never a duplicated literal. */
export const COUNT_PULSE_CAP = 24;
export class Board {
game: GameEngine;
readonly root: HTMLElement;
/** Tile radar and other targeted abilities resolve against this. */
focusedTile: [number, number] = [0, 0];
/**
* Targeted-ability hook: when set, the NEXT board click (or Enter) resolves
* through this callback with the clicked tile instead of revealing, then
* clears itself. Armed by the Radar button; cancelled by flagging, rebuild,
* or re-arming with another ability. Return false to keep the arm (e.g. the
* ability was unavailable and the player should retarget).
*/
pendingTarget: ((x: number, y: number) => boolean) | null = null;
/** Fired after every effective player action (after refresh + onSync):
* main.ts uses it to check §4.1 run-state transitions (clear → DECISION,
* death → SUMMARY) at the exact action points. */
onAction: SyncFn | null = null;
/** Phase 4 juice: derived engine observations (reveal spread, flags,
* mine shifts, detonations) fire here from refresh(). Optional — the board
* works with no listener attached. */
onFx: ((ev: FxEvent) => void) | null = null;
/** Last-rendered engine fingerprint (see FxSnapshot); null ⇒ reseed. */
private fxSnap: FxSnapshot | null = null;
/** Cells whose proximity digit changed on the observed shift — the paint
* loop adds .cell-count-pulse to them (ruling 2026-10-28). */
private pulseCells: number[] = [];
/** cell key → animation delay (ms) for the flood-fade reveal wave. */
private revealFx = new Map<string, number>();
/** P5: last written (classes|wave) signature per cell key. Equal sig ⇒
* equal textContent/aria-label (all inputs encoded in the classes), so the
* whole cell paint is skipped. Cleared in rebuild() — fresh elements. */
private paintCache = new Map<string, string>();
/** true only inside afterAction(): fx events derive from PLAYER actions.
* Direct engine pokes + refresh() from QA/tests reseed the fingerprint
* silently — tests mutate the engine and re-render without ever clicking,
* and a mid-flight reveal animation would corrupt geometry assertions. */
private inAction = false;
/** setFocus()-style chrome refreshes must not cancel a running reveal
* wave (mousedown focuses the cell BEFORE click fires afterAction, so a
* seed refresh lands between waves — classes ride through it). */
private keepJuiceOnce = false;
private readonly onSync: SyncFn;
/** Engine-init hook: called with EVERY engine Board creates (constructor +
* newRun) so the host can push Workshop levels and hook clear-credits onto
* a fresh engine before its first DOM render. */
private readonly onEngine: SyncFn;
constructor(container: HTMLElement, onSync: SyncFn = () => {}, onEngine: SyncFn = () => {}) {
this.onSync = onSync;
this.onEngine = onEngine;
this.game = new GameEngine(0, undefined, randomSeed());
this.onEngine(this.game);
this.root = document.createElement('div');
this.root.className = 'board';
container.append(this.root);
this.rebuild();
// Input listeners are DELEGATED on this.root and resolve cells from the
// event target, so they survive rebuild()'s innerHTML swap. Bind ONCE,
// here: binding inside rebuild() stacked a second set per rebuild, making
// every right-click fire flag() N times (place→remove = flag vanishes,
// N turns) — N grew with each advance()/newRun().
this.bindInput();
// B1/D4 (audit 2026-10-28): ONE delegated animationend on the root strips
// .cell-count-pulse when the pulse ends — the old per-cell `once` listeners
// attached one new listener per pulsing cell per shift (the
// stacked_listener_fix.md lesson). The animationName guard is load-bearing:
// reveal waves, the mine-reveal stagger, and ice/fire glows also bubble
// animationend to this root. Delegation survives rebuild()'s innerHTML
// swap; bound ONCE, here.
this.root.addEventListener('animationend', (e) => {
if (e.animationName === 'cell-count-pulse') {
(e.target as HTMLElement).classList.remove('cell-count-pulse');
}
});
}
// ------------------------------- start / reset ---------------------------
/** Fresh run at depth 0 (after death or manual reset). */
newRun(): void {
this.game = new GameEngine(0, undefined, randomSeed());
this.onEngine(this.game);
this.rebuild();
}
/** Rebuild the DOM grid from the current engine (also after advance()).
* Input stays bound: listeners delegate on this.root (see constructor). */
rebuild(): void {
this.focusedTile = [0, 0];
this.pendingTarget = null;
this.fxSnap = null; // fresh DOM grid ⇒ reseed the fingerprint silently
this.revealFx.clear();
this.paintCache.clear(); // P5: fresh elements ⇒ nothing written yet
this.root.innerHTML = this.buildGrid();
this.refresh();
}
clear(): void {
this.root.remove();
}
// ------------------------------- grid build ------------------------------
private buildGrid(): string {
const cells: string[] = [];
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
cells.push(
`<span class="cell" role="button" tabindex="-1" data-x="${x}" data-y="${y}" data-cell="${x},${y}"></span>`,
);
}
}
return cells.join('');
}
private cellAt(x: number, y: number): HTMLElement | null {
return this.root.querySelector<HTMLElement>(`.cell[data-x="${x}"][data-y="${y}"]`);
}
// ------------------------------ input wiring -----------------------------
private bindInput(): void {
this.root.addEventListener('click', (e) => {
e.preventDefault();
const cell = this.cellFrom(e.target);
if (!cell) return;
const x = Number(cell.dataset.x);
const y = Number(cell.dataset.y);
this.setFocus(cell);
if (this.pendingTarget) {
// A targeted ability is armed: spend it on THIS tile, not the last
// focused one. Resolving never reveals the tile.
if (this.pendingTarget(x, y)) this.pendingTarget = null;
} else {
this.game.reveal(x, y);
}
this.afterAction();
});
// Long press (touch) → flag; a quick tap falls through to click.
// PRIMARY BUTTON ONLY: a right-button press already flagged the tile via
// contextmenu at press time — running the long-press toggle on its release
// would REMOVE that flag (and arm the click suppressor below, eating the
// player's next reveal). Track the button and gate on it.
let pressStart: number | null = null;
let pressCell: HTMLElement | null = null;
let pressPrimary = false;
// THE ONE FLAG PER GESTURE latch (mobile long-press fix 2026-10-29):
// mobile browsers (Android Chrome, iOS Safari, WebViews) fire a NATIVE
// `contextmenu` in the MIDDLE of a touch long-hold — the contextmenu
// handler below already placed the flag at press time. The pointerup
// long-press toggle must recognize that commit and NOT flag again: a
// second flag() was the classic place→remove toggle, so the flag vanished
// exactly when the finger released and two turns burned (player-reported
// on device; workaround was sliding off the tile). `committedFlag` holds
// the tile flagged by THIS gesture (either path); both paths consult it,
// so the dedupe also covers platforms that fire contextmenu late or never.
let committedFlag: { x: number; y: number } | null = null;
this.root.addEventListener('pointerdown', (e) => {
pressPrimary = e.button === 0;
pressStart = performance.now();
pressCell = this.cellFrom(e.target);
committedFlag = null; // fresh gesture: clean slate
});
this.root.addEventListener('pointercancel', () => {
// Gesture stolen (scroll, pull-to-refresh, system menu): nothing pending.
pressStart = null;
pressCell = null;
});
this.root.addEventListener('pointerup', (e) => {
if (pressStart === null || pressCell === null) return;
const primary = pressPrimary && e.button === 0;
const dt = performance.now() - pressStart;
const upCell = this.cellFrom(e.target);
const sameCell =
upCell !== null &&
upCell.dataset.x === pressCell.dataset.x &&
upCell.dataset.y === pressCell.dataset.y; // compare BOTH axes (x-only let a same-column release toggle another row)
pressStart = null;
if (!primary || !sameCell || dt < 400) return;
const x = Number(pressCell.dataset.x);
const y = Number(pressCell.dataset.y);
pressCell = null;
const spent = committedFlag !== null && committedFlag.x === x && committedFlag.y === y;
committedFlag = null;
if (!spent) {
this.setFocus(this.cellAt(x, y));
// Flagging is a different intent than targeting: disarm.
this.pendingTarget = null;
this.game.flag(x, y);
this.afterAction();
}
// The gesture is spent either way: a synthesized post-long-press click
// must never reveal. (Android Chrome usually skips that click entirely;
// a `once` suppressor that never fires is harmless.)
const suppress = (ev: MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
};
this.root.addEventListener('click', suppress, { once: true, capture: true });
});
// Right-click → flag (desktop input mapping) AND the mobile long-press
// flag: mobile fires this natively mid-hold, so the flag lands at press
// time — the long one flag per gesture latch below makes the eventual
// pointerup recognize it instead of toggling the flag off again.
this.root.addEventListener('contextmenu', (e) => {
e.preventDefault();
const cell = this.cellFrom(e.target);
if (!cell) return;
const x = Number(cell.dataset.x);
const y = Number(cell.dataset.y);
if (committedFlag !== null && committedFlag.x === x && committedFlag.y === y) return;
committedFlag = { x, y };
this.setFocus(cell);
this.pendingTarget = null;
this.game.flag(x, y);
this.afterAction();
});
// Keyboard: arrows move focus; Enter reveals; Space flags.
this.root.addEventListener('keydown', (e) => {
const cell = this.cellFrom(e.target) ?? this.cellAt(...this.focusedTile);
if (!cell) return;
const x = Number(cell.dataset.x);
const y = Number(cell.dataset.y);
const move: Record<string, [number, number]> = {
ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1],
};
if (move[e.key]) {
e.preventDefault();
const g = this.game.world;
const nx = g.wrapX(x + move[e.key][0]);
const ny = g.wrapY(y + move[e.key][1]);
this.setFocus(this.cellAt(nx, ny));
} else if (e.key === 'Escape') {
e.preventDefault();
if (this.pendingTarget) {
this.pendingTarget = null;
this.refresh();
this.onSync(this.game);
}
} else if (e.key === 'Enter') {
e.preventDefault();
if (this.pendingTarget) {
if (this.pendingTarget(x, y)) this.pendingTarget = null;
} else {
this.game.reveal(x, y);
}
this.afterAction();
} else if (e.key === ' ') {
e.preventDefault();
this.pendingTarget = null;
this.game.flag(x, y);
this.afterAction();
}
});
}
private cellFrom(target: EventTarget | null): HTMLElement | null {
const el = target as HTMLElement | null;
return el?.dataset && el.dataset.x !== undefined ? el : null;
}
private setFocus(cell: HTMLElement | null): void {
if (!cell) return;
this.focusedTile = [Number(cell.dataset.x), Number(cell.dataset.y)];
cell.focus({ preventScroll: true });
this.keepJuiceOnce = true;
this.refresh();
}
private afterAction(): void {
this.inAction = true;
try {
this.refresh();
} finally {
this.inAction = false;
}
this.onSync(this.game);
this.onAction?.(this.game);
}
// ------------------------------- render refresh --------------------------
refresh(): void {
const g = this.game;
const dead = g.isDead();
// Phase 4: derive juice events + the flood-fade stagger BEFORE painting,
// then fire them after the DOM is in sync (particle origins need live
// cell rects, and the animation classes must ride the new classNames).
const events = this.inAction ? this.diffFx() : (this.seedFx(), []);
const keepJuice = this.keepJuiceOnce;
this.keepJuiceOnce = false;
this.root.classList.toggle('board-targeting', this.pendingTarget !== null);
for (const child of Array.from(this.root.children) as HTMLElement[]) {
const x = Number(child.dataset.x);
const y = Number(child.dataset.y);
const t = g.tile(x, y);
const cls = ['cell', `cell-${t.state.toLowerCase()}`];
if (g.iceAt(x, y)) cls.push('cell-ice');
if (g.fireAt(x, y)) cls.push('cell-fire');
if (x === this.focusedTile[0] && y === this.focusedTile[1]) cls.push('cell-focused');
if (dead && t.hasMine) cls.push('cell-mine-reveal');
const key = `${x},${y}`;
let wave = this.revealFx.get(key);
if (wave === undefined && keepJuice && child.style.getPropertyValue('--juice-delay')) {
// carry the running wave through this chrome-only refresh
wave = Number(child.style.getPropertyValue('--juice-delay').replace('ms', '')) || 0;
}
if (wave !== undefined) {
cls.push('juice-reveal');
child.style.setProperty('--juice-delay', `${wave}ms`);
} else if (!keepJuice && child.style.getPropertyValue('--juice-delay')) {
child.style.removeProperty('--juice-delay');
}
// Proximity number: REVEALED safe tiles show their live 8-neighbor
// (toroidal) mine count. Read from the engine every refresh — never
// cached — so mine shifts at depth >= 6 update the numbers on tiles
// that were revealed long ago. Flagged/revealed-mine cells keep their
// glyph overlays (::after/::before) and show no number.
let count = 0;
if (t.state === 'REVEALED' && !t.hasMine) {
count = g.world.adjacentMineCount(x, y);
if (count > 0) cls.push(`cell-num-${count}`);
}
// P5 perf: state/ice/fire/count are all encoded in the class string,
// so an identical (classes|wave) signature ⇒ identical textContent and
// aria-label too — skip all three DOM writes (100-cell full-board
// className rewrite per action was invalidating the whole grid for
// 1-2 changed tiles). rebuild() clears the cache (fresh elements).
const sig = `${cls.join(' ')}|${wave ?? ''}`;
if (this.paintCache.get(key) !== sig) {
this.paintCache.set(key, sig);
child.className = cls.join(' ');
child.textContent = count > 0 ? String(count) : '';
child.setAttribute(
'aria-label',
`${t.state}${g.iceAt(x, y) ? ' iced' : ''}${g.fireAt(x, y) ? ' burning' : ''}` +
`${count > 0 ? ` ${count} adjacent mines` : ''} ${x} ${y}`,
);
}
}
this.revealFx.clear();
// Count-pulse (ruling 2026-10-28): highlight tiles whose proximity digit
// changed under an already-revealed tile — the shift's readable afterimage
// without ever exposing a hidden mine. B1/D4 (audit 2026-10-28): the list
// is capped + ranked in diffFx (COUNT_PULSE_CAP), the glow rides a
// compositable ::before overlay (styles.css), and ONE delegated
// animationend bound in the constructor strips the class — no per-cell
// listeners. A later className repaint simply drops it, which is harmless.
if (this.pulseCells.length > 0) {
for (const i of this.pulseCells) {
const el = this.root.children[i] as HTMLElement | undefined;
if (el) el.classList.add('cell-count-pulse');
}
this.pulseCells = [];
}
if (this.onFx) {
const emit = this.onFx;
for (const ev of events) {
try {
emit(ev);
} catch {
/* juice is decoration — a listener failure never breaks the board */
}
}
}
}
// ------------------------------ fx diffing --------------------------------
/** Quietly (re)capture the fingerprint without producing events. */
private seedFx(): void {
const g = this.game;
g.absorbed = null; // a reseed must not leak an absorbed-blast fx later
this.pulseCells = [];
let cells = '';
let ids = '';
let nums = '';
for (let i = 0; i < g.world.tiles.length; i++) {
const t = g.world.tiles[i];
cells +=
t.state === 'REVEALED' ? 'r' : t.state === 'FLAGGED' ? 'f' : t.hasMine ? 'M' : 'h';
nums +=
t.state === 'REVEALED' && !t.hasMine ? String(g.world.adjacentMineCount(t.coord[0], t.coord[1])) : '.';
if (t.hasMine) ids += `${i},`;
}
this.fxSnap = {
world: g.world, cells, mines: ids, exploded: g.isDead(),
shiftIn: g.nextShiftIn, nums,
};
this.revealFx.clear();
}
/** Compare the live engine against the last-rendered fingerprint and turn
* the delta into juice events + reveal-wave stagger delays. A world-object
* swap (advance(), a QA-injected engine) is a RESEED, never a diff — a new
* board must not fire fake shift/boom events. */
private diffFx(): FxEvent[] {
const g = this.game;
const tiles = g.world.tiles;
const W = g.world.width;
let cells = '';
let mines = '';
let nums = '';
for (let i = 0; i < tiles.length; i++) {
const t = tiles[i];
cells +=
t.state === 'REVEALED' ? 'r' : t.state === 'FLAGGED' ? 'f' : t.hasMine ? 'M' : 'h';
nums +=
t.state === 'REVEALED' && !t.hasMine ? String(g.world.adjacentMineCount(t.coord[0], t.coord[1])) : '.';
if (t.hasMine) mines += `${i},`;
}
const snap = this.fxSnap;
const next: FxSnapshot = {
world: g.world,
cells,
mines,
exploded: g.isDead(),
shiftIn: g.nextShiftIn,
nums,
};
if (!snap || snap.world !== g.world) {
this.fxSnap = next;
return [];
}
const events: FxEvent[] = [];
// cell state transitions
if (snap.cells !== cells) {
const newRevealed: number[] = [];
let flagAdd = -1;
let flagMine = false;
let flagRemove = -1;
for (let i = 0; i < cells.length; i++) {
const a = snap.cells[i];
const b = cells[i];
if (a === b) continue;
if (b === 'r' && (a === 'h' || a === 'M')) newRevealed.push(i);
else if (b === 'f' && flagAdd < 0) {
flagAdd = i;
flagMine = tiles[i].hasMine;
} else if (a === 'f' && b !== 'f' && flagRemove < 0) flagRemove = i;
}
if (newRevealed.length > 0) {
// stagger by toroidal Chebyshev distance from the EARLIEST opened
// tile (the flood origin) — the wave visibly radiates from the click.
const o = newRevealed[0];
const ox = o % W;
const oy = (o - ox) / W;
const H = g.world.height;
this.revealFx.clear();
for (const i of newRevealed) {
const x = i % W;
const y = (i - x) / W;
const dx = Math.min(Math.abs(x - ox), W - Math.abs(x - ox));
const dy = Math.min(Math.abs(y - oy), H - Math.abs(y - oy));
this.revealFx.set(`${x},${y}`, Math.min(480, dx * 50 + dy * 50));
}
const cx = o % W;
events.push({
kind: 'reveal',
cell: `${cx},${oy}`,
count: newRevealed.length,
});
}
if (flagAdd >= 0) {
const x = flagAdd % W;
events.push({ kind: 'flag', cell: `${x},${(flagAdd - x) / W}`, mine: flagMine });
}
if (flagRemove >= 0) {
const x = flagRemove % W;
events.push({ kind: 'unflag', cell: `${x},${(flagRemove - x) / W}` });
}
}
// mine layout: a UNIFORM displacement of every mine = a toroidal shift
const shift = snap.mines !== next.mines ? this.shiftVector(snap.mines, next.mines) : null;
if (shift) events.push({ kind: 'shift', dx: shift[0], dy: shift[1] });
// Count-pulse cells: proximity digits that CHANGED while the tile itself
// stayed revealed (ruling 2026-10-28) — the shift's legible afterimage.
// B1 cap (audit 2026-10-28): a wide-open board's full shift flips 50–80
// digits; one pulsing paint-animated cell per flip reintroduced the P3
// paint-storm pattern. Keep the leading-edge subset — the CAP cells
// furthest along the wind, where mines ARRIVE, are the most informative
// afterimage; paint cost stays bounded. Ties resolve by index.
this.pulseCells = [];
if (snap.nums !== next.nums) {
const changed: number[] = [];
for (let i = 0; i < next.nums.length; i++) {
if (
snap.nums[i] !== next.nums[i] &&
snap.cells[i] === 'r' && cells[i] === 'r'
) changed.push(i);
}
if (changed.length > COUNT_PULSE_CAP) {
const [sdx, sdy] = shift ?? [0, 0];
const proj = (i: number) => (i % W) * sdx + Math.floor(i / W) * sdy;
changed.sort((a, b) => proj(b) - proj(a) || a - b);
changed.length = COUNT_PULSE_CAP;
}
this.pulseCells = changed;
}
// Shift-warning beat: the countdown STEPPED into its final 3 turns
// (only a live telegraph, never while the Stabilizer holds it).
if (
g.telegraph && g.stabilizedFor === 0 &&
next.shiftIn > 0 && next.shiftIn <= 3 && next.shiftIn !== snap.shiftIn
) {
events.push({
kind: 'shift-warning',
dx: g.telegraph[0],
dy: g.telegraph[1],
turns: next.shiftIn,
});
}
// first explosion observation (a shielded absorb clears explosion in the
// same commit, so `exploded` only latches on a FATAL blast)
if (!snap.exploded && next.exploded) {
let at = '4,4';
for (let i = 0; i < tiles.length; i++) {
if (tiles[i].hasMine && tiles[i].state === 'REVEALED') {
const x = i % W;
at = `${x},${(i - x) / W}`;
break;
}
}
events.push({ kind: 'boom', cell: at, cause: String(g.explosionCause()), shielded: false });
}
// Shield-ABSORBED detonations: the explosion clears inside the same
// commit(), so `exploded` never latches and no boom diff surfaces — the
// engine hands us the record explicitly so the shielded boom (fx ring,
// muted sound, status line) still fires. Without this the absorb was
// invisible: fire sat frozen and charges vanished with zero feedback.
if (g.absorbed) {
events.push({
kind: 'boom',
cell: `${g.absorbed.x},${g.absorbed.y}`,
cause: String(g.absorbed.cause),
shielded: true,
});
g.absorbed = null;
}
this.fxSnap = next;
return events;
}
/** Old vs new mine index sets → the uniform shift vector, or null when the
* change is not a clean whole-field slide (e.g. first-click relocation). */
private shiftVector(prev: string, next: string): [number, number] | null {
const g = this.game;
const W = g.world.width;
const H = g.world.height;
const parse = (s: string): number[] =>
s ? s.slice(0, -1).split(',').map(Number) : [];
const a = parse(prev);
const bSet = new Set(parse(next));
if (a.length === 0 || a.length !== bSet.size) return null;
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue;
let ok = true;
for (const i of a) {
const x = i % W;
const y = (i - x) / W;
const nx = (x + dx + W) % W;
const ny = (y + dy + H) % H;
if (!bSet.has(ny * W + nx)) {
ok = false;
break;
}
}
if (ok) return [dx, dy];
}
}
return null;
}
}
function randomSeed(): number {
return Math.floor(Math.random() * 0x1_0000_0000);
}
