@johnmorrisdotca/jirai 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/deduce.d.ts +8 -0
- package/dist/deduce.js +85 -0
- package/dist/draw-entry.d.ts +3 -0
- package/dist/draw-entry.js +2 -0
- package/dist/draw.d.ts +4 -0
- package/dist/draw.js +26 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +3 -0
- package/dist/element.d.ts +14 -0
- package/dist/element.js +28 -0
- package/dist/enumerate.d.ts +6 -0
- package/dist/enumerate.js +83 -0
- package/dist/flood.d.ts +2 -0
- package/dist/flood.js +18 -0
- package/dist/game.d.ts +8 -0
- package/dist/game.js +61 -0
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +72 -0
- package/dist/grid.d.ts +7 -0
- package/dist/grid.js +46 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/jirai.constants.d.ts +62 -0
- package/dist/jirai.constants.js +24 -0
- package/dist/jirai.types.d.ts +66 -0
- package/dist/jirai.types.js +1 -0
- package/dist/keep.d.ts +7 -0
- package/dist/keep.js +47 -0
- package/dist/mount.d.ts +7 -0
- package/dist/mount.js +295 -0
- package/dist/orthogonal.d.ts +16 -0
- package/dist/orthogonal.js +39 -0
- package/dist/play-entry.d.ts +3 -0
- package/dist/play-entry.js +2 -0
- package/dist/random.d.ts +4 -0
- package/dist/random.js +20 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +17 -0
- package/dist/react.types.d.ts +3 -0
- package/dist/react.types.js +1 -0
- package/dist/shape.d.ts +5 -0
- package/dist/shape.js +33 -0
- package/dist/strings.d.ts +70 -0
- package/dist/strings.js +17 -0
- package/dist/style.d.ts +2 -0
- package/dist/style.js +22 -0
- package/dist/ui.types.d.ts +41 -0
- package/dist/ui.types.js +1 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +10 -0
- package/package.json +103 -4
- package/src/deduce.test.ts +47 -0
- package/src/deduce.ts +66 -0
- package/src/draw-entry.ts +3 -0
- package/src/draw.ts +28 -0
- package/src/element-define.ts +2 -0
- package/src/element.ts +24 -0
- package/src/enumerate.ts +60 -0
- package/src/flood.ts +12 -0
- package/src/game.test.ts +172 -0
- package/src/game.ts +55 -0
- package/src/generate.ts +57 -0
- package/src/grid.ts +42 -0
- package/src/index.ts +14 -0
- package/src/jirai.constants.ts +26 -0
- package/src/jirai.types.ts +56 -0
- package/src/keep.ts +41 -0
- package/src/mount.ts +176 -0
- package/src/orthogonal.ts +49 -0
- package/src/play-entry.ts +3 -0
- package/src/random.ts +20 -0
- package/src/react.tsx +17 -0
- package/src/react.types.ts +3 -0
- package/src/shape.test.ts +56 -0
- package/src/shape.ts +32 -0
- package/src/strings.ts +18 -0
- package/src/style.ts +22 -0
- package/src/ui.types.ts +22 -0
- package/src/worker.ts +7 -0
package/src/mount.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { activeCells } from "./shape.ts";
|
|
2
|
+
import { hintFor } from "./deduce.ts";
|
|
3
|
+
import { boardModel } from "./draw.ts";
|
|
4
|
+
import { newGame, play, visibleGame, withBoard } from "./game.ts";
|
|
5
|
+
import { MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
|
|
6
|
+
import { validCell } from "./grid.ts";
|
|
7
|
+
import { gameFromProgress, gameProgress } from "./keep.ts";
|
|
8
|
+
import { JIRAI_STYLE } from "./style.ts";
|
|
9
|
+
import { words } from "./strings.ts";
|
|
10
|
+
import type { Board, Game, Move, Settings } from "./jirai.types.ts";
|
|
11
|
+
import type { DrawOptions, JiraiMount, MountOptions } from "./ui.types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One playable board in any element. The engine owns the rules, this module
|
|
15
|
+
* owns presses, focus, the clock and the worker. Destroy removes only its own
|
|
16
|
+
* root, so two mounted boards never take each other's listeners or children.
|
|
17
|
+
*/
|
|
18
|
+
export function mountJirai(host: HTMLElement, options: MountOptions = {}): JiraiMount {
|
|
19
|
+
const doc = host.ownerDocument;
|
|
20
|
+
let draw: DrawOptions = { material: "ivory", pieces: "flags", language: "en", ...options };
|
|
21
|
+
const game = options.progress === undefined ? newGame(options.settings) : gameFromProgress(options.progress);
|
|
22
|
+
if (game === null) throw new Error("Invalid saved Minesweeper game.");
|
|
23
|
+
let current: Game = game;
|
|
24
|
+
let focus = activeCells(game.settings)[0]!, marking = false, hint: number | null = null;
|
|
25
|
+
let worker: Worker | null = null, pending = false, disposed = false;
|
|
26
|
+
let started: number | null = null, elapsed = 0;
|
|
27
|
+
const root = doc.createElement("div"); root.className = "jr-root";
|
|
28
|
+
const style = doc.createElement("style"); style.textContent = JIRAI_STYLE;
|
|
29
|
+
const stats = doc.createElement("div"); stats.className = "jr-stats";
|
|
30
|
+
const scroll = doc.createElement("div"); scroll.className = "jr-scroll";
|
|
31
|
+
const board = doc.createElement("div"); board.className = "jr-board"; board.setAttribute("role", "group");
|
|
32
|
+
const status = doc.createElement("p"); status.className = "jr-status"; status.setAttribute("role", "status");
|
|
33
|
+
const controls = doc.createElement("div"); controls.className = "jr-controls";
|
|
34
|
+
const help = doc.createElement("p"); help.className = "jr-help";
|
|
35
|
+
const dialog = doc.createElement("dialog"); dialog.className = "jr-dialog";
|
|
36
|
+
const close = doc.createElement("button"); close.className = "jr-button jr-close"; close.type = "button";
|
|
37
|
+
dialog.append(close);
|
|
38
|
+
scroll.append(board); root.append(style, stats, scroll, status, controls, help); host.append(root, dialog);
|
|
39
|
+
let lastOpener: HTMLElement | null = null;
|
|
40
|
+
const leaveDialog = (): void => { root.append(stats, scroll, status, controls, help); lastOpener?.focus(); };
|
|
41
|
+
dialog.addEventListener("close", leaveDialog);
|
|
42
|
+
close.addEventListener("click", () => dialog.close());
|
|
43
|
+
function stopWorker(): void { worker?.terminate(); worker = null; pending = false; }
|
|
44
|
+
function clock(): number { return elapsed + (started === null ? 0 : Math.floor((Date.now() - started) / 1000)); }
|
|
45
|
+
function report(next: Game): void {
|
|
46
|
+
const before = current.status;
|
|
47
|
+
current = next; hint = null;
|
|
48
|
+
if (started === null && current.board !== null && current.status === STATUSES.playing) started = Date.now();
|
|
49
|
+
if (current.status === STATUSES.won || current.status === STATUSES.lost) { elapsed = clock(); started = null; }
|
|
50
|
+
const detail = structuredClone(current);
|
|
51
|
+
host.dispatchEvent(new CustomEvent("jirai-change", { detail })); options.onChange?.(detail);
|
|
52
|
+
if (before !== current.status && (current.status === STATUSES.won || current.status === STATUSES.lost)) {
|
|
53
|
+
host.dispatchEvent(new CustomEvent("jirai-finish", { detail })); options.onFinish?.(detail);
|
|
54
|
+
}
|
|
55
|
+
render();
|
|
56
|
+
}
|
|
57
|
+
function failed(error: Error): void { stopWorker(); render(); status.textContent = words(draw.language).error; options.onError?.(error); }
|
|
58
|
+
function make(move: Move): void {
|
|
59
|
+
if (disposed || pending) return;
|
|
60
|
+
if (move.kind === MOVES.reveal && current.board === null && current.marks[move.cell] !== MARKS.flag && validCell(current.settings, move.cell)) {
|
|
61
|
+
pending = true; render();
|
|
62
|
+
try {
|
|
63
|
+
worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
|
64
|
+
worker.onmessage = (event: MessageEvent<{ board?: Board; error?: string }>) => {
|
|
65
|
+
if (disposed) return;
|
|
66
|
+
stopWorker();
|
|
67
|
+
if (event.data.board === undefined) { failed(new Error(event.data.error ?? "Generation failed.")); return; }
|
|
68
|
+
try { report(play(withBoard(current, event.data.board), move)); } catch (error) { failed(error as Error); }
|
|
69
|
+
};
|
|
70
|
+
worker.onerror = () => failed(new Error("The board worker could not load. Serve the package over HTTP, and allow workers in your content security policy."));
|
|
71
|
+
worker.postMessage({ settings: current.settings, first: move.cell });
|
|
72
|
+
} catch (error) { failed(error as Error); }
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try { const next = play(current, move); if (next !== current) report(next); } catch (error) { failed(error as Error); }
|
|
76
|
+
}
|
|
77
|
+
function showHint(): void {
|
|
78
|
+
if (pending || current.status !== STATUSES.playing) return;
|
|
79
|
+
const result = hintFor(visibleGame(current));
|
|
80
|
+
const safe = result.safe.find((cell) => current.marks[cell] !== MARKS.open);
|
|
81
|
+
const mine = result.mines.find((cell) => current.marks[cell] !== MARKS.flag);
|
|
82
|
+
const highlighted = safe ?? mine ?? null;
|
|
83
|
+
if (highlighted !== null && !current.helped) report({ ...current, helped: true });
|
|
84
|
+
hint = highlighted;
|
|
85
|
+
render();
|
|
86
|
+
const say = words(draw.language);
|
|
87
|
+
status.textContent = hint === null ? say.hintNone : `${safe !== undefined ? say.hintSafe : say.hintMine} ${result.reason === "none" ? "" : say[result.reason]}`;
|
|
88
|
+
}
|
|
89
|
+
function renderStats(): void {
|
|
90
|
+
const say = words(draw.language);
|
|
91
|
+
const values = [[say.mines, current.settings.mines - current.marks.filter((m) => m === MARKS.flag).length], [say.time, `${Math.floor(clock() / 60)}:${String(clock() % 60).padStart(2,"0")}`], [say.moves, current.moves.length]];
|
|
92
|
+
stats.replaceChildren(...values.map(([label, value]) => {
|
|
93
|
+
const part = doc.createElement("span"); part.className = "jr-stat";
|
|
94
|
+
const name = doc.createElement("small"); name.textContent = String(label);
|
|
95
|
+
const number = doc.createElement("strong"); number.textContent = String(value);
|
|
96
|
+
part.append(name, number); return part;
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
function render(): void {
|
|
100
|
+
if (disposed) return;
|
|
101
|
+
const say = words(draw.language);
|
|
102
|
+
root.dataset.material = draw.material; root.dataset.grid = current.settings.grid; root.dataset.shape = current.settings.shape ?? "rectangle"; root.lang = draw.language ?? "en";
|
|
103
|
+
board.setAttribute("aria-label", say.board); board.setAttribute("aria-busy", String(pending));
|
|
104
|
+
const model = boardModel(current, { ...draw, hint });
|
|
105
|
+
const active = doc.activeElement instanceof HTMLElement && board.contains(doc.activeElement);
|
|
106
|
+
board.style.aspectRatio = `${model.width} / ${model.height}`;
|
|
107
|
+
board.style.minWidth = `${model.width * 27}px`;
|
|
108
|
+
board.replaceChildren(...model.cells.map((cell) => {
|
|
109
|
+
const button = doc.createElement("button"); button.type = "button"; button.className = "jr-cell";
|
|
110
|
+
button.dataset.cell = String(cell.cell); button.dataset.kind = cell.kind; button.dataset.hint = String(cell.hint);
|
|
111
|
+
button.dataset.number = cell.kind === MARKS.open ? cell.text : "";
|
|
112
|
+
button.setAttribute("aria-label", cell.label); button.tabIndex = cell.cell === focus ? 0 : -1;
|
|
113
|
+
button.disabled = pending; button.textContent = cell.text;
|
|
114
|
+
button.style.left = `${cell.x / model.width * 100}%`; button.style.top = `${cell.y / model.height * 100}%`;
|
|
115
|
+
button.style.width = `${cell.width / model.width * 100}%`; button.style.height = `${cell.height / model.height * 100}%`;
|
|
116
|
+
return button;
|
|
117
|
+
}));
|
|
118
|
+
if (active) board.querySelector<HTMLButtonElement>(`[data-cell="${focus}"]`)?.focus({ preventScroll: true });
|
|
119
|
+
renderStats(); status.textContent = pending ? say.checking : say[current.status]; help.textContent = say.help; close.textContent = say.close;
|
|
120
|
+
controls.replaceChildren();
|
|
121
|
+
if (options.controls === false) { stats.hidden = true; status.hidden = true; help.hidden = true; return; }
|
|
122
|
+
const button = (text: string, action: () => void): HTMLButtonElement => {
|
|
123
|
+
const made = doc.createElement("button"); made.type = "button"; made.className = "jr-button"; made.textContent = text;
|
|
124
|
+
made.addEventListener("click", action); controls.append(made); return made;
|
|
125
|
+
};
|
|
126
|
+
button(say.restart, () => handle.restart());
|
|
127
|
+
const mark = button(marking ? say.mark : say.reveal, () => { marking = !marking; render(); }); mark.setAttribute("aria-pressed", String(marking));
|
|
128
|
+
const hinted = button(say.hint, showHint); hinted.disabled = pending || current.status !== STATUSES.playing;
|
|
129
|
+
if (!dialog.open) button(say.view, () => { lastOpener = doc.activeElement as HTMLElement; dialog.append(stats, scroll, status, controls); dialog.showModal(); render(); });
|
|
130
|
+
}
|
|
131
|
+
function cellOf(event: Event): number | null {
|
|
132
|
+
const target = event.target instanceof Element ? event.target.closest<HTMLButtonElement>("[data-cell]") : null;
|
|
133
|
+
return target === null ? null : Number(target.dataset.cell);
|
|
134
|
+
}
|
|
135
|
+
let hold: ReturnType<typeof setTimeout> | null = null, held = false, origin = { x: 0, y: 0 };
|
|
136
|
+
const cancelHold = (): void => { if (hold !== null) clearTimeout(hold); hold = null; };
|
|
137
|
+
board.addEventListener("pointerdown", (event) => {
|
|
138
|
+
held = false; cancelHold(); const cell = cellOf(event); if (cell === null) return;
|
|
139
|
+
focus = cell; origin = { x: event.clientX, y: event.clientY };
|
|
140
|
+
if (event.pointerType !== "mouse") hold = setTimeout(() => { held = true; make({ kind: MOVES.mark, cell }); }, 450);
|
|
141
|
+
});
|
|
142
|
+
board.addEventListener("pointermove", (event) => { if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancelHold(); });
|
|
143
|
+
board.addEventListener("pointerup", cancelHold); board.addEventListener("pointercancel", cancelHold);
|
|
144
|
+
board.addEventListener("click", (event) => { const cell = cellOf(event); if (cell !== null && !held) { focus = cell; make({ kind: marking ? MOVES.mark : MOVES.reveal, cell }); } held = false; });
|
|
145
|
+
board.addEventListener("contextmenu", (event) => { event.preventDefault(); cancelHold(); const cell = cellOf(event); if (cell !== null && !held) { focus = cell; make({ kind: MOVES.mark, cell }); } held = false; });
|
|
146
|
+
board.addEventListener("keydown", (event) => {
|
|
147
|
+
const cell = cellOf(event); if (cell === null) return;
|
|
148
|
+
const x = cell % current.settings.width, y = Math.floor(cell / current.settings.width);
|
|
149
|
+
const shifts: Record<string, number> = { ArrowLeft: x > 0 ? -1 : 0, ArrowRight: x < current.settings.width - 1 ? 1 : 0, ArrowUp: y > 0 ? -current.settings.width : 0, ArrowDown: y < current.settings.height - 1 ? current.settings.width : 0 };
|
|
150
|
+
if (event.key in shifts) { event.preventDefault(); const step = shifts[event.key]!;
|
|
151
|
+
let next = cell + step;
|
|
152
|
+
while (step !== 0 && next >= 0 && next < current.marks.length && !validCell(current.settings, next)) {
|
|
153
|
+
if (Math.abs(step) === 1 && Math.floor(next / current.settings.width) !== y) break;
|
|
154
|
+
next += step;
|
|
155
|
+
}
|
|
156
|
+
if (validCell(current.settings, next) && (Math.abs(step) !== 1 || Math.floor(next / current.settings.width) === y)) focus = next;
|
|
157
|
+
render(); board.querySelector<HTMLButtonElement>(`[data-cell="${focus}"]`)?.focus(); }
|
|
158
|
+
else if (event.key === " " || event.key.toLowerCase() === "f") { event.preventDefault(); focus = cell; make({ kind: MOVES.mark, cell }); }
|
|
159
|
+
else if (event.key === "Enter") { event.preventDefault(); focus = cell; make({ kind: MOVES.reveal, cell }); }
|
|
160
|
+
});
|
|
161
|
+
const timer = setInterval(() => { if (started !== null && !disposed) renderStats(); }, 1000);
|
|
162
|
+
const handle: JiraiMount = {
|
|
163
|
+
game: () => structuredClone(current), progress: () => gameProgress(current), play: (cell, mark = false) => make({ kind: mark ? MOVES.mark : MOVES.reveal, cell }), hint: showHint,
|
|
164
|
+
restart: () => { if (disposed) return; stopWorker(); elapsed = 0; started = null; report(newGame(current.settings)); },
|
|
165
|
+
load: (settings: Settings, progress?: string) => {
|
|
166
|
+
if (disposed) return;
|
|
167
|
+
const restored = progress === undefined ? newGame(settings) : gameFromProgress(progress);
|
|
168
|
+
if (restored === null) throw new Error("Invalid saved Minesweeper game.");
|
|
169
|
+
stopWorker(); elapsed = 0; started = null; focus = activeCells(restored.settings)[0]!; report(restored);
|
|
170
|
+
},
|
|
171
|
+
set: (changes) => { draw = { ...draw, ...changes }; render(); },
|
|
172
|
+
destroy: () => { disposed = true; stopWorker(); cancelHold(); clearInterval(timer); if (dialog.open) dialog.close(); root.remove(); dialog.remove(); },
|
|
173
|
+
};
|
|
174
|
+
if (current.status === STATUSES.playing) started = Date.now();
|
|
175
|
+
render(); return handle;
|
|
176
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { deduce } from "./deduce.ts";
|
|
2
|
+
import { newGame, visibleGame } from "./game.ts";
|
|
3
|
+
import { makeBoard } from "./generate.ts";
|
|
4
|
+
import { neighbours, validSettings } from "./grid.ts";
|
|
5
|
+
import { gameFromProgress, gameProgress } from "./keep.ts";
|
|
6
|
+
import type { Board, GenerationOptions, Game, Settings } from "./jirai.types.ts";
|
|
7
|
+
|
|
8
|
+
/** Identifies the four-neighbour rules and progress-code format. */
|
|
9
|
+
export const ORTHOGONAL_VARIANT = "orthogonal" as const;
|
|
10
|
+
export type OrthogonalSettings = Omit<Settings, "grid">;
|
|
11
|
+
|
|
12
|
+
/** Starts a game under four-neighbour rules. */
|
|
13
|
+
export function newOrthogonalGame(settings: OrthogonalSettings): Game {
|
|
14
|
+
return newGame({ ...settings, grid: ORTHOGONAL_VARIANT });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Deals a board whose clues count only up, down, left and right. */
|
|
18
|
+
export function makeOrthogonalBoard(
|
|
19
|
+
settings: OrthogonalSettings,
|
|
20
|
+
first: number,
|
|
21
|
+
options?: GenerationOptions,
|
|
22
|
+
): Board {
|
|
23
|
+
return makeBoard({ ...settings, grid: ORTHOGONAL_VARIANT }, first, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Returns the adjacent cells under the orthogonal rules. */
|
|
27
|
+
export function orthogonalNeighbours(settings: OrthogonalSettings, cell: number): number[] {
|
|
28
|
+
const complete = { ...settings, grid: ORTHOGONAL_VARIANT } as Settings;
|
|
29
|
+
if (!validSettings(complete)) throw new RangeError("Invalid orthogonal settings.");
|
|
30
|
+
return neighbours(complete, cell);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Gives a clue-only deduction; flags remain notes and cannot influence the result. */
|
|
34
|
+
export function orthogonalHint(game: Game) {
|
|
35
|
+
if (game.settings.grid !== ORTHOGONAL_VARIANT) throw new RangeError("Expected an orthogonal game.");
|
|
36
|
+
return deduce(visibleGame(game));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Encodes an orthogonal game with an explicit variant marker and version 2. */
|
|
40
|
+
export function encodeOrthogonalGame(game: Game): string {
|
|
41
|
+
if (game.settings.grid !== ORTHOGONAL_VARIANT) throw new RangeError("Expected an orthogonal game.");
|
|
42
|
+
return gameProgress(game);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Restores only version 2 orthogonal progress records. */
|
|
46
|
+
export function decodeOrthogonalGame(code: string): Game | null {
|
|
47
|
+
const game = gameFromProgress(code);
|
|
48
|
+
return game?.settings.grid === ORTHOGONAL_VARIANT ? game : null;
|
|
49
|
+
}
|
package/src/random.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Mulberry32, pinned by the replay tests: changing it would change every kept board. */
|
|
2
|
+
export function seededRandom(seed: number): () => number {
|
|
3
|
+
let state = seed >>> 0;
|
|
4
|
+
return () => {
|
|
5
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
6
|
+
let t = state;
|
|
7
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
8
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
9
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Fisher–Yates on a copy. A shuffle never changes the list given. */
|
|
13
|
+
export function shuffled<T>(items: readonly T[], random: () => number): T[] {
|
|
14
|
+
const out = [...items];
|
|
15
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
16
|
+
const j = Math.floor(random() * (i + 1));
|
|
17
|
+
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { mountJirai } from "./mount.ts";
|
|
3
|
+
import type { JiraiProps } from "./react.types.ts";
|
|
4
|
+
|
|
5
|
+
/** The plain-DOM game as a React component. Options are read on mount; give it a new key to load a new board. */
|
|
6
|
+
export function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }: JiraiProps) {
|
|
7
|
+
const host = useRef<HTMLDivElement>(null);
|
|
8
|
+
const latest = useRef({ onChange, onFinish, onError });
|
|
9
|
+
useEffect(() => { latest.current = { onChange, onFinish, onError }; });
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (host.current === null) return;
|
|
12
|
+
const board = mountJirai(host.current, { settings, progress, material, pieces, language, controls,
|
|
13
|
+
onChange: (game) => latest.current.onChange?.(game), onFinish: (game) => latest.current.onFinish?.(game), onError: (error) => latest.current.onError?.(error) });
|
|
14
|
+
return () => board.destroy();
|
|
15
|
+
}, []);
|
|
16
|
+
return <div ref={host} {...element} />;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { activeCell, activeCells } from "./shape.ts";
|
|
3
|
+
import { DEFAULT_SETTINGS, GRIDS } from "./jirai.constants.ts";
|
|
4
|
+
import { neighboursOf, validSettings } from "./grid.ts";
|
|
5
|
+
import { makeBoard, isSolvable } from "./generate.ts";
|
|
6
|
+
import { newGame, play, visibleGame } from "./game.ts";
|
|
7
|
+
import { boardModel } from "./draw.ts";
|
|
8
|
+
import { gameFromProgress, gameProgress } from "./keep.ts";
|
|
9
|
+
import type { Settings } from "./jirai.types.ts";
|
|
10
|
+
|
|
11
|
+
describe("shaped minefields", () => {
|
|
12
|
+
for (const shape of ["heart", "star", "hexagon"] as const) for (const grid of [GRIDS.square, GRIDS.orthogonal, GRIDS.hex]) {
|
|
13
|
+
it(`${shape}/${grid} omits cells and retains reciprocal neighbours and proved deals`, () => {
|
|
14
|
+
const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, mines: 12, shape, grid };
|
|
15
|
+
for (const outside of [-1, 289, 1.5, NaN]) expect(activeCell(settings, outside)).toBe(false);
|
|
16
|
+
const cells = activeCells(settings), adjacent = neighboursOf(settings);
|
|
17
|
+
expect(cells.length).toBeGreaterThan(40); expect(cells.length).toBeLessThan(289);
|
|
18
|
+
for (const c of cells) for (const n of adjacent[c]!) {
|
|
19
|
+
expect(activeCell(settings, n)).toBe(true); expect(adjacent[n]).toContain(c);
|
|
20
|
+
}
|
|
21
|
+
const first = cells[Math.floor(cells.length / 2)]!;
|
|
22
|
+
for (let seed = 1; seed <= 12; seed += 1) {
|
|
23
|
+
const board = makeBoard({ ...settings, seed }, first);
|
|
24
|
+
expect(board.mines.filter(Boolean)).toHaveLength(12); expect(isSolvable(board)).toBe(true);
|
|
25
|
+
for (let c = 0; c < 289; c += 1) if (!activeCell(settings, c)) {
|
|
26
|
+
expect(board.mines[c]).toBe(false); expect(board.clues[c]).toBe(-2); expect(adjacent[c]).toEqual([]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
it("never draws or lets the player act on cut-out cells, and restores the shape", () => {
|
|
32
|
+
const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, shape: "heart", mines: 12 };
|
|
33
|
+
let game = newGame(settings);
|
|
34
|
+
const absent = Array.from({ length: 289 }, (_, c) => c).find(c => !activeCell(settings, c))!;
|
|
35
|
+
expect(play(game, { kind: "reveal", cell: absent })).toBe(game);
|
|
36
|
+
expect(play(game, { kind: "mark", cell: absent })).toBe(game);
|
|
37
|
+
expect(visibleGame(game).clues[absent]).toBe(-2);
|
|
38
|
+
expect(boardModel(game).cells.some(c => c.cell === absent)).toBe(false);
|
|
39
|
+
game = play(game, { kind: "reveal", cell: activeCells(settings)[50]! });
|
|
40
|
+
expect(gameFromProgress(gameProgress(game))?.settings.shape).toBe("heart");
|
|
41
|
+
});
|
|
42
|
+
it("wins when every active safe cell opens, without requiring absent cells", () => {
|
|
43
|
+
const settings: Settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, shape: "star", mines: 1, noGuess: false };
|
|
44
|
+
let game = newGame(settings);
|
|
45
|
+
const first = activeCells(settings)[10]!;
|
|
46
|
+
game = play(game, { kind: "reveal", cell: first });
|
|
47
|
+
for (const c of activeCells(settings)) if (!game.board!.mines[c]) game = play(game, { kind: "reveal", cell: c });
|
|
48
|
+
expect(game.status).toBe("won");
|
|
49
|
+
});
|
|
50
|
+
it("rejects unknown outlines, tiny silhouettes, wrap cut-outs and excess mines", () => {
|
|
51
|
+
expect(validSettings({ ...DEFAULT_SETTINGS, shape: "toString" })).toBe(false);
|
|
52
|
+
expect(validSettings({ ...DEFAULT_SETTINGS, width: 5, height: 5, shape: "heart" })).toBe(false);
|
|
53
|
+
expect(validSettings({ ...DEFAULT_SETTINGS, shape: "heart", grid: "wrap" })).toBe(false);
|
|
54
|
+
expect(validSettings({ ...DEFAULT_SETTINGS, width: 9, height: 9, shape: "star", mines: 70 })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Settings } from "./jirai.types.ts";
|
|
2
|
+
export const SHAPES = ["rectangle", "heart", "star", "hexagon"] as const;
|
|
3
|
+
/** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
|
|
4
|
+
export function activeCell(settings: Settings, cell: number): boolean {
|
|
5
|
+
if (!Number.isInteger(cell) || cell < 0 || cell >= settings.width * settings.height) return false;
|
|
6
|
+
const col = cell % settings.width, row = Math.floor(cell / settings.width);
|
|
7
|
+
const x = 2 * (col + .5) / settings.width - 1, y = 2 * (row + .5) / settings.height - 1;
|
|
8
|
+
switch (settings.shape ?? "rectangle") {
|
|
9
|
+
case "heart": {
|
|
10
|
+
const hx = x * 1.15, hy = -y * 1.25 + .15;
|
|
11
|
+
return (hx * hx + hy * hy - 1) ** 3 - hx * hx * hy ** 3 <= 0;
|
|
12
|
+
}
|
|
13
|
+
case "star": {
|
|
14
|
+
// A filled five-point polygon, never a decorative overlay on hidden square cells.
|
|
15
|
+
const vertices = Array.from({ length: 10 }, (_, i) => {
|
|
16
|
+
const angle = -Math.PI / 2 + i * Math.PI / 5, radius = i % 2 ? .48 : 1;
|
|
17
|
+
return [Math.cos(angle) * radius, Math.sin(angle) * radius] as const;
|
|
18
|
+
});
|
|
19
|
+
let inside = false;
|
|
20
|
+
for (let i = 0, j = 9; i < 10; j = i++) {
|
|
21
|
+
const [xi, yi] = vertices[i]!, [xj, yj] = vertices[j]!;
|
|
22
|
+
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
|
|
23
|
+
}
|
|
24
|
+
return inside;
|
|
25
|
+
}
|
|
26
|
+
case "hexagon": return settings.grid === "hex" ? Math.abs(x + y) <= 1 : Math.abs(x) <= 1 - .5 * Math.abs(y);
|
|
27
|
+
default: return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function activeCells(settings: Settings): number[] {
|
|
31
|
+
return Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter(cell => activeCell(settings, cell));
|
|
32
|
+
}
|
package/src/strings.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Language } from "./jirai.types.ts";
|
|
2
|
+
export const STRINGS = {
|
|
3
|
+
en: {
|
|
4
|
+
board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
|
|
5
|
+
ready: "Open a cell to begin. Your first cell is safe.", playing: "Read the numbers. Open every safe cell.", won: "Every safe cell is open. Well played.", lost: "A mine. Try the same board again, or start another.",
|
|
6
|
+
checking: "Finding a board that can be solved without guessing…", restart: "Start over", hint: "Hint", reveal: "Open", mark: "Mark", mines: "Mines left", time: "Time", moves: "Moves", clear: "Cleared", view: "Just the board", close: "Close", hintNone: "No certain move proved. This position may need a guess.", hintSafe: "This cell is safe.", hintMine: "This cell contains a mine.", count: "The neighbouring count settles it.", overlap: "Compare the overlapping clues.", total: "The total mine count settles it.", enumeration: "Every consistent arrangement gives the same answer.",
|
|
7
|
+
help: "Tap to open; right-click or hold to mark. Use the arrows to move, Enter to open, F or Space to mark. Tap an open number to clear its neighbours when the flags match.",
|
|
8
|
+
error: "A verified board was not found. Try another seed, an opening elsewhere, or fewer mines.",
|
|
9
|
+
},
|
|
10
|
+
ja: {
|
|
11
|
+
board: "地雷の盤", covered: "未開封", flag: "旗", question: "不明", empty: "空き", mine: "地雷", wrong: "間違った旗",
|
|
12
|
+
ready: "マスを開けて始めます。最初のマスは安全です。", playing: "数字を手がかりに、安全なマスをすべて開けましょう。", won: "安全なマスがすべて開きました。クリア!", lost: "地雷でした。同じ盤で再挑戦できます。",
|
|
13
|
+
checking: "推測なしで解ける盤を探しています…", restart: "やり直す", hint: "ヒント", reveal: "開く", mark: "印を付ける", mines: "残りの地雷", time: "時間", moves: "手数", clear: "開いたマス", view: "盤だけ", close: "閉じる", hintNone: "確実な手は見つかりませんでした。推測が必要かもしれません。", hintSafe: "このマスは安全です。", hintMine: "このマスには地雷があります。", count: "周囲の数字から確定できます。", overlap: "重なった手がかりを比べましょう。", total: "地雷の総数から確定できます。", enumeration: "可能な配置すべてで同じ答えになります。",
|
|
14
|
+
help: "タップで開く、右クリックか長押しで印を付けます。矢印キーで移動、Enterで開く、FかSpaceで印を付けます。旗の数が合う数字をタップすると周囲が開きます。",
|
|
15
|
+
error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
|
|
16
|
+
},
|
|
17
|
+
} as const;
|
|
18
|
+
export function words(language: Language = "en"): typeof STRINGS["en"] | typeof STRINGS["ja"] { return STRINGS[language]; }
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Scoped to one mounted board. Its host supplies the font and may override every colour. */
|
|
2
|
+
export const JIRAI_STYLE = `
|
|
3
|
+
.jr-root{--jr-ground:#a98954;--jr-cover:#fbf8f1;--jr-open:#efe8d8;--jr-line:#cfc6b2;--jr-ink:#1f2320;--jr-accent:#2f7a4f;color:var(--jr-ui-ink,var(--jr-ink));font:inherit;position:relative}
|
|
4
|
+
.jr-root[data-material=wood]{--jr-ground:#ae804a;--jr-cover:#e0bb7e;--jr-open:#c69d63;--jr-line:#936e40;--jr-ink:#352c20;background-image:repeating-linear-gradient(4deg,transparent 0 8px,#4b2b0c08 9px 10px)}
|
|
5
|
+
.jr-root[data-material=slate]{--jr-ground:#252e32;--jr-cover:#455359;--jr-open:#303c41;--jr-line:#62747a;--jr-ink:#f3f0e5;--jr-accent:#b7d298}
|
|
6
|
+
.jr-scroll{overflow:auto;padding:12px;border:1px solid var(--jr-line);border-radius:14px;background:var(--jr-ground);overscroll-behavior:contain}
|
|
7
|
+
.jr-board{position:relative;width:100%;min-width:240px;margin:auto;isolation:isolate}
|
|
8
|
+
.jr-cell{position:absolute;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border:1px solid var(--jr-line);border-radius:4px;background:var(--jr-cover);color:var(--jr-ink);font:700 clamp(12px,2vw,22px)/1 ui-monospace,monospace;box-shadow:inset 0 2px 1px #fff7,inset 0 -2px 1px #0001;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:manipulation;padding:0}
|
|
9
|
+
.jr-root[data-grid=hex] .jr-cell{clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);border-radius:0}
|
|
10
|
+
.jr-cell[data-kind=open]{background:var(--jr-open);box-shadow:none}
|
|
11
|
+
.jr-cell[data-number="1"]{color:#326ca0}.jr-cell[data-number="2"]{color:#3d763c}.jr-cell[data-number="3"]{color:#a34438}.jr-cell[data-number="4"]{color:#704b97}.jr-cell[data-number="5"]{color:#8c542f}.jr-cell[data-number="6"]{color:#1c777b}
|
|
12
|
+
.jr-root[data-material=slate] .jr-cell[data-kind=open]{color:#e8e6bd}
|
|
13
|
+
.jr-cell[data-kind=flag]{color:var(--jr-accent)}.jr-cell[data-kind=question]{color:#8a702d}
|
|
14
|
+
.jr-cell[data-kind=mine],.jr-cell[data-kind=wrong]{background:#ead4c5;color:#873e32}.jr-cell[data-kind=exploded]{background:#a34d3c;color:white}
|
|
15
|
+
.jr-cell[data-hint=true]{background:#d4e5bc!important;color:#243d24!important;box-shadow:inset 0 0 0 3px #577b3f}
|
|
16
|
+
.jr-cell:focus-visible{outline:none;box-shadow:inset 0 0 0 3px #c0802e;z-index:2}.jr-cell:hover{filter:brightness(.97)}
|
|
17
|
+
.jr-stats{display:flex;gap:18px;flex-wrap:wrap;font-variant-numeric:tabular-nums;margin:0 0 14px}.jr-stat{display:flex;gap:7px;align-items:baseline}.jr-stat small{opacity:.7;font-size:12px}.jr-stat strong{font-size:20px;font-weight:500}
|
|
18
|
+
.jr-status{font-size:14px;line-height:1.5;min-height:1.5em;margin:12px 0}.jr-help{font-size:12px;line-height:1.65;opacity:.7}
|
|
19
|
+
.jr-controls{display:flex;flex-wrap:wrap;gap:8px}.jr-button{font:inherit;font-size:13px;border:1px solid var(--jr-line);border-radius:999px;min-height:44px;padding:0 14px;background:var(--jr-ui-surface,var(--jr-cover));color:var(--jr-ui-ink,var(--jr-ink));border-color:var(--jr-ui-line,var(--jr-line));cursor:pointer}.jr-button:disabled{opacity:.5;cursor:wait}.jr-button[aria-pressed=true]{background:var(--jr-accent);color:white}.jr-button:focus-visible{outline:2px solid var(--jr-accent);outline-offset:3px}
|
|
20
|
+
.jr-dialog{border:0;border-radius:20px;padding:24px;max-width:min(96vw,1000px);width:850px;background:var(--surface,#fbf8f1);color:var(--ink,#1f2320);--jr-ui-ink:var(--ink,#1f2320);--jr-ui-surface:var(--surface,#fbf8f1);--jr-ui-line:var(--rule,#ddd6c6);box-sizing:border-box}.jr-dialog::backdrop{background:#172019bb}.jr-dialog .jr-board{max-height:75vh}.jr-close{float:right;margin-bottom:10px}
|
|
21
|
+
@media(prefers-reduced-motion:no-preference){.jr-cell{transition:background .12s}}
|
|
22
|
+
`;
|
package/src/ui.types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Game, Language, Material, Pieces, Settings } from "./jirai.types.ts";
|
|
2
|
+
export type DrawOptions = { material?: Material; pieces?: Pieces; language?: Language; hint?: number | null };
|
|
3
|
+
export type CellModel = { cell: number; x: number; y: number; width: number; height: number; label: string; text: string; kind: string; hint: boolean };
|
|
4
|
+
export type BoardModel = { width: number; height: number; cells: CellModel[] };
|
|
5
|
+
export type MountOptions = DrawOptions & {
|
|
6
|
+
settings?: Settings;
|
|
7
|
+
progress?: string;
|
|
8
|
+
controls?: boolean;
|
|
9
|
+
onChange?: (game: Game) => void;
|
|
10
|
+
onFinish?: (game: Game) => void;
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
};
|
|
13
|
+
export type JiraiMount = {
|
|
14
|
+
game: () => Game;
|
|
15
|
+
progress: () => string;
|
|
16
|
+
play: (cell: number, mark?: boolean) => void;
|
|
17
|
+
hint: () => void;
|
|
18
|
+
restart: () => void;
|
|
19
|
+
load: (settings: Settings, progress?: string) => void;
|
|
20
|
+
set: (options: DrawOptions) => void;
|
|
21
|
+
destroy: () => void;
|
|
22
|
+
};
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { makeBoard } from "./generate.ts";
|
|
2
|
+
import type { Settings } from "./jirai.types.ts";
|
|
3
|
+
// A separate entry: importing the engine or renderer never creates a worker.
|
|
4
|
+
self.addEventListener("message", (event: MessageEvent<{ settings: Settings; first: number }>) => {
|
|
5
|
+
try { self.postMessage({ board: makeBoard(event.data.settings, event.data.first) }); }
|
|
6
|
+
catch (error) { self.postMessage({ error: error instanceof Error ? error.message : String(error) }); }
|
|
7
|
+
});
|