<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/card.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/dialog.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/kbd.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/select.html"></import-element>
<custom-element name="mine-sweeper">
<script type="module">
import {
bestTimes,
createGame,
fromUrl,
handleKey,
LEVELS,
randomSeed,
restart,
reveal,
tickClock,
toggleFlag,
} from "./minesweeper.js";
const start = fromUrl(location.search);
const game = useState(createGame(start.levelId, start.seed));
const board = useState({
levels: Object.values(LEVELS),
get best() {
return bestTimes();
},
});
function open(cell) {
if (reveal(game, cell.index)) refs.result.showModal();
}
function aim(cell) {
game.cursor = cell.index;
}
function flag(cell, event) {
event.preventDefault();
toggleFlag(game, cell.index);
}
async function keys(event) {
if (!handleKey(game, event.key)) return;
event.preventDefault();
await tick();
refs.grid.querySelector('[tabindex="0"]').focus();
}
function newGame() {
restart(game, game.levelId, randomSeed());
}
function replay() {
restart(game, game.levelId, game.seed);
}
function changeLevel(select) {
restart(game, select.value, randomSeed());
}
function playAgain() {
refs.result.close();
newGame();
}
observe(() => {
if (game.status !== "playing") return;
const timer = setInterval(() => tickClock(game), 250);
return () => clearInterval(timer);
});
</script>
<style>
:host {
--cell: min(calc(var(--spacing) * 9), calc((100vw - var(--spacing) * 16) / var(--state-game-columns)));
display: grid;
justify-items: center;
gap: calc(var(--spacing) * 5);
width: min(calc(var(--spacing) * 200), 100%);
}
header {
display: grid;
justify-items: center;
gap: calc(var(--spacing) * 1);
text-align: center;
}
h1 {
margin: 0;
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
header p {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: calc(var(--spacing) * 2);
}
.panel {
display: grid;
gap: calc(var(--spacing) * 3);
padding: calc(var(--spacing) * 3);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
box-shadow: var(--shadow-sm);
}
.counters {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing) * 3);
font-family: var(--font-mono);
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
}
.counters > span {
position: relative;
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1.5);
}
.grid {
display: grid;
gap: 1px;
}
.row {
display: flex;
gap: 1px;
}
.cell {
display: grid;
place-items: center;
width: var(--cell);
height: var(--cell);
padding: 0;
border: 0;
border-radius: var(--radius-xs);
background: color-mix(in oklab, var(--foreground) 16%, var(--background));
color: var(--foreground);
font: inherit;
font-size: calc(var(--cell) * 0.5);
font-weight: var(--font-weight-bold);
line-height: 1;
cursor: pointer;
}
.cell:hover {
background: color-mix(in oklab, var(--foreground) 24%, var(--background));
}
.cell:focus-visible {
position: relative;
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.cell:not([data-state="hidden"], [data-state="flagged"]) {
background: var(--background);
box-shadow: inset 0 0 0 1px var(--border);
cursor: default;
}
.cell[data-state="exploded"] {
background: var(--destructive);
}
.cell[data-adjacent="1"] {
color: var(--chart-2);
}
.cell[data-adjacent="2"] {
color: var(--chart-1);
}
.cell[data-adjacent="3"] {
color: var(--destructive);
}
.cell[data-adjacent="4"] {
color: var(--chart-3);
}
.cell[data-adjacent="5"],
.cell[data-adjacent="6"] {
color: var(--chart-4);
}
.status {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
text-align: center;
}
.help {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-xs);
text-align: center;
}
.board-link {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: calc(var(--spacing) * 2);
font-size: var(--text-sm);
}
.board-link a {
color: inherit;
text-underline-offset: calc(var(--spacing) * 1);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.best {
width: min(calc(var(--spacing) * 90), 100%);
}
dl {
display: grid;
grid-template-columns: 1fr auto;
gap: calc(var(--spacing) * 2);
margin: 0;
font-size: var(--text-sm);
}
dl div {
display: contents;
}
dt {
color: var(--muted-foreground);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-medium);
}
</style>
<header>
<h1>Minesweeper</h1>
<p>Open every safe square. Numbers count the mines around a square.</p>
</header>
<div class="controls">
<ui-select aria-label="Difficulty" width="36" _value="#game.levelId" onchange="changeLevel(this)">
<option repeat="#board.levels" as="level" key="id" _value="#level.id" _label="#level.label"></option>
</ui-select>
<ui-button onclick="newGame()"><u-icon name="refresh-cw"></u-icon> New game</ui-button>
</div>
<section class="panel" aria-label="Board">
<div class="counters">
<span><span aria-hidden="true">🚩</span><slot name="#game.flagsLeft"></slot><span class="sr-only">mines left to flag</span></span>
<span><u-icon name="timer"></u-icon><slot name="#game.clock"></slot><span class="sr-only">seconds</span></span>
</div>
<div class="grid" ref="grid" role="grid" aria-label="Minefield" aria-keyshortcuts="F" onkeydown="keys(event)">
<div class="row" role="row" repeat="#game.rows" as="row" key="id">
<span role="gridcell" repeat="#row.cells" as="cell" key="index">
<button
class="cell"
type="button"
_tabindex="#cell.tabindex"
_data-state="#cell.state"
_data-adjacent="#cell.adjacent"
_aria-label="#cell.label"
onfocus="aim(cell)"
onclick="open(cell)"
oncontextmenu="flag(cell, event)"><slot name="#cell.text"></slot></button>
</span>
</div>
</div>
<p class="status" role="status"><slot name="#game.message"></slot></p>
</section>
<p class="help">Arrow keys move, <ui-kbd>Enter</ui-kbd> or <ui-kbd>Space</ui-kbd> opens a square, <ui-kbd>F</ui-kbd> or right-click places a flag.</p>
<p class="board-link">
<a _href="#game.link">Board <slot name="#game.seed"></slot></a>
<ui-button size="sm" variant="outline" onclick="replay()">Replay this board</ui-button>
</p>
<ui-card class="best">
<ui-card-header>
<ui-card-title level="2">Best times</ui-card-title>
<ui-card-description>Saved in this browser.</ui-card-description>
</ui-card-header>
<ui-card-content>
<dl>
<div repeat="#board.best" as="record" key="id">
<dt><slot name="#record.label"></slot></dt>
<dd><slot name="#record.time"></slot></dd>
</div>
</dl>
</ui-card-content>
</ui-card>
<ui-dialog ref="result">
<ui-dialog-header>
<ui-dialog-title><slot name="#game.result.title"></slot></ui-dialog-title>
<ui-dialog-description><slot name="#game.result.description"></slot></ui-dialog-description>
</ui-dialog-header>
<ui-dialog-footer>
<ui-dialog-close><ui-button variant="outline">View board</ui-button></ui-dialog-close>
<ui-button onclick="playAgain()">Play again</ui-button>
</ui-dialog-footer>
</ui-dialog>
</custom-element>
import { defineStore } from "https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js";
export const LEVELS = {
easy: { id: "easy", label: "Easy", rows: 9, columns: 9, mines: 10 },
medium: { id: "medium", label: "Medium", rows: 12, columns: 12, mines: 24 },
hard: { id: "hard", label: "Hard", rows: 16, columns: 16, mines: 40 },
};
export const records = defineStore(
"example-minesweeper",
{ best: { easy: null, medium: null, hard: null } },
{ persist: "local" },
);
// A small seeded generator, so a board number always lays the same mines.
export const generator = (seed) => {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
export const randomSeed = () => Math.floor(Math.random() * 900_000) + 100_000;
export const neighbours = (index, { rows, columns }) => {
const row = Math.floor(index / columns);
const column = index % columns;
const found = [];
for (let dr = -1; dr <= 1; dr++)
for (let dc = -1; dc <= 1; dc++) {
const r = row + dr;
const c = column + dc;
if ((dr || dc) && r >= 0 && r < rows && c >= 0 && c < columns) found.push(r * columns + c);
}
return found;
};
export const layMines = (level, safe, seed) => {
const random = generator(seed);
const blocked = new Set([safe, ...neighbours(safe, level)]);
const free = Array.from({ length: level.rows * level.columns }, (_, index) => index).filter(
(index) => !blocked.has(index),
);
for (let i = free.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[free[i], free[j]] = [free[j], free[i]];
}
return free.slice(0, level.mines).sort((a, b) => a - b);
};
export const fromUrl = (search) => {
const params = new URLSearchParams(search);
const seed = Number(params.get("seed"));
return {
levelId: LEVELS[params.get("level")] ? params.get("level") : "easy",
seed: Number.isInteger(seed) && seed > 0 ? seed : randomSeed(),
};
};
const fields = (levelId, seed) => {
const level = LEVELS[levelId];
return {
levelId,
seed,
status: "ready",
started: 0,
elapsed: 0,
finalTime: 0,
newBest: false,
cursor: 0,
cells: Array.from({ length: level.rows * level.columns }, (_, index) => ({
index,
mine: false,
adjacent: 0,
open: false,
flagged: false,
exploded: false,
})),
};
};
const seconds = (ms) => `${(ms / 1000).toFixed(1)} s`;
const SYMBOLS = { flagged: "🚩", mine: "💣", exploded: "💥", wrong: "❌" };
const stateOf = (cell, over) => {
if (cell.exploded) return "exploded";
if (cell.flagged && over && !cell.mine) return "wrong";
if (cell.flagged) return "flagged";
if (over && cell.mine) return "mine";
return cell.open ? "open" : "hidden";
};
const DESCRIBE = {
hidden: () => "hidden",
flagged: () => "flagged",
wrong: () => "wrongly flagged",
mine: () => "mine",
exploded: () => "exploded mine",
open: (cell) =>
cell.adjacent ? `${cell.adjacent} ${cell.adjacent === 1 ? "mine" : "mines"} nearby` : "empty",
};
const STATUS = {
ready: (game) => `Find the ${game.level.mines} mines. Your first click is always safe.`,
playing: (game) => `${game.flagsLeft} ${game.flagsLeft === 1 ? "mine" : "mines"} left to flag.`,
won: (game) => `Cleared in ${seconds(game.finalTime)}.`,
lost: () => "Boom. You hit a mine.",
};
export const createGame = (levelId, seed) => ({
...fields(levelId, seed),
get level() {
return LEVELS[this.levelId];
},
get columns() {
return this.level.columns;
},
get over() {
return this.status === "won" || this.status === "lost";
},
get flagsLeft() {
return this.level.mines - this.cells.filter((cell) => cell.flagged).length;
},
get clock() {
return String(Math.min(this.elapsed, 999)).padStart(3, "0");
},
get message() {
return STATUS[this.status](this);
},
get link() {
return `?level=${this.levelId}&seed=${this.seed}`;
},
get rows() {
const { rows, columns } = this.level;
const over = this.over;
return Array.from({ length: rows }, (_, row) => ({
id: row,
cells: this.cells.slice(row * columns, (row + 1) * columns).map((cell) => {
const state = stateOf(cell, over);
const text =
state === "open" && cell.adjacent ? String(cell.adjacent) : (SYMBOLS[state] ?? "");
return {
index: cell.index,
state,
text,
adjacent: cell.adjacent,
tabindex: cell.index === this.cursor ? 0 : -1,
label: `Row ${row + 1}, column ${(cell.index % columns) + 1}, ${DESCRIBE[state](cell)}`,
};
}),
}));
},
get result() {
return this.status === "won"
? {
title: this.newBest ? "New best time!" : "You won!",
description: `You cleared the ${this.level.label.toLowerCase()} board in ${seconds(this.finalTime)}.`,
}
: { title: "Game over", description: "You hit a mine. Every mine is shown on the board." };
},
});
export const restart = (game, levelId, seed) => {
Object.assign(game, fields(levelId, seed));
};
export const tickClock = (game) => {
game.elapsed = Math.floor((Date.now() - game.started) / 1000);
};
const arm = (game, safe) => {
const level = game.level;
for (const index of layMines(level, safe, game.seed)) game.cells[index].mine = true;
for (const cell of game.cells)
cell.adjacent = neighbours(cell.index, level).filter((index) => game.cells[index].mine).length;
game.status = "playing";
game.started = Date.now();
};
const finish = (game, status) => {
game.finalTime = Date.now() - game.started;
game.status = status;
if (status !== "won") return;
for (const cell of game.cells) cell.flagged = cell.mine;
const best = records.best[game.levelId];
game.newBest = best === null || game.finalTime < best;
if (game.newBest) records.best[game.levelId] = game.finalTime;
};
// Returns true when this move ends the game.
export const reveal = (game, index) => {
const cell = game.cells[index];
game.cursor = index;
if (game.over || cell.flagged || cell.open) return false;
if (game.status === "ready") arm(game, index);
if (cell.mine) {
cell.exploded = true;
finish(game, "lost");
return true;
}
const queue = [index];
while (queue.length) {
const next = game.cells[queue.pop()];
if (next.open || next.flagged) continue;
next.open = true;
if (!next.adjacent) queue.push(...neighbours(next.index, game.level));
}
const hidden = game.cells.filter((item) => !item.open).length;
if (hidden !== game.level.mines) return false;
finish(game, "won");
return true;
};
export const toggleFlag = (game, index) => {
const cell = game.cells[index];
game.cursor = index;
if (game.status !== "playing" || cell.open) return;
cell.flagged = !cell.flagged;
};
const MOVES = {
ArrowLeft: (index, columns) => (index % columns ? index - 1 : index),
ArrowRight: (index, columns) => ((index + 1) % columns ? index + 1 : index),
ArrowUp: (index, columns) => (index >= columns ? index - columns : index),
ArrowDown: (index, columns, total) => (index + columns < total ? index + columns : index),
Home: (index, columns) => index - (index % columns),
End: (index, columns) => index - (index % columns) + columns - 1,
};
export const handleKey = (game, key) => {
const move = MOVES[key];
if (move) {
game.cursor = move(game.cursor, game.columns, game.cells.length);
return true;
}
if (key.toLowerCase() !== "f") return false;
toggleFlag(game, game.cursor);
return true;
};
export const bestTimes = () =>
Object.values(LEVELS).map((level) => ({
id: level.id,
label: level.label,
time: records.best[level.id] === null ? "None yet" : seconds(records.best[level.id]),
}));