Add Number Match game page and update sitemap

This commit is contained in:
2025-11-19 17:25:00 +11:00
parent e89477862e
commit c21a9d4d92
4 changed files with 687 additions and 3 deletions

498
src/numbermatch.html Normal file
View File

@@ -0,0 +1,498 @@
<!--
METADATA:{
"title": "Number Match - Litruv",
"metaDescription": "Play Number Match right on Litruv's site. Pair numbers that match or add to ten and clear the board.",
"csp": "script-src 'self' 'unsafe-inline';",
"ogTitle": "Number Match - Litruv",
"ogDescription": "Clear the board by pairing numbers that match or add to ten.",
"ogUrl": "https://lit.ruv.wtf/numbermatch.html",
"twitterTitle": "Number Match - Litruv",
"twitterDescription": "Clear the board by pairing numbers that match or add to ten.",
"twitterUrl": "https://lit.ruv.wtf/numbermatch.html",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.numbermatch-page {
margin-top: 2.5rem;
padding: 0 1rem 3.5rem;
color: #1f2933;
}
.numbermatch-page .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.numbermatch-page .app {
display: grid;
gap: 2.5rem;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
max-width: 1100px;
margin: 0 auto;
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 4px 6px 0 var(--shadow-light);
padding: 2rem 2.25rem;
position: relative;
transform-origin: center;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.numbermatch-page .app__game {
transform: none;
}
.numbermatch-page .app__info {
transform: rotate(0.55deg);
}
.numbermatch-page .app__game:hover {
transform: scale(1.01);
box-shadow: 6px 9px 0 var(--shadow-light);
}
.numbermatch-page .app__info:hover {
transform: rotate(0deg) scale(1.01);
box-shadow: 6px 9px 0 var(--shadow-light);
}
.numbermatch-page .app__title {
margin: 0;
font-size: 2rem;
text-shadow: 1px 1px 0 #fff;
color: #111827;
}
.numbermatch-page .app__subtitle {
margin: 0.5rem 0 0;
color: #364152;
font-size: 1rem;
}
.numbermatch-page .controls {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin: 2rem 0 1.5rem;
}
.numbermatch-page .control-button {
border: 2px solid var(--post-border);
background: var(--accent);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
padding: 0.65rem 1.75rem;
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
font-family: inherit;
}
.numbermatch-page .control-button:hover,
.numbermatch-page .control-button:focus {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-page .control-button:active {
transform: translateY(0);
box-shadow: 2px 2px 0 var(--shadow-light);
}
.numbermatch-page .control-button--secondary {
background: var(--main-bg);
color: var(--main-fg);
}
.numbermatch-page .status {
display: flex;
flex-direction: column;
gap: 0.85rem;
background: rgba(36, 36, 36, 0.04);
border: 1px dashed rgba(0, 0, 0, 0.12);
padding: 1rem 1.25rem;
border-radius: 12px;
}
.numbermatch-page .status__message {
min-height: 1.5rem;
margin: 0;
font-weight: 600;
color: #1f2933;
}
.numbermatch-page .status__message.status__message--error {
color: #be123c;
}
.numbermatch-page .status__message.status__message--success {
color: #15803d;
}
.numbermatch-page .status__metrics {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.95rem;
color: #364152;
}
.numbermatch-page .status__metric strong {
color: #111827;
}
.numbermatch-page .board {
margin-top: 1.75rem;
display: grid;
gap: 0.6rem;
width: 100%;
}
.numbermatch-page .board-actions {
margin-top: 1.75rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.85rem;
}
.numbermatch-page .board-action {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: 2px solid var(--post-border);
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
cursor: pointer;
color: var(--main-bg);
background: var(--accent);
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
position: relative;
}
.numbermatch-page .board-action:hover,
.numbermatch-page .board-action:focus {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-page .board-action:active {
transform: translateY(0);
box-shadow: 2px 2px 0 var(--shadow-light);
}
.numbermatch-page .board-action--secondary {
background: var(--post-bg);
color: #111827;
}
.numbermatch-page .board-action__badge {
position: absolute;
top: -0.45rem;
right: -0.45rem;
min-width: 1.5rem;
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: var(--like);
color: #fff;
font-size: 0.85rem;
font-weight: 700;
box-shadow: 0 6px 12px rgba(219, 39, 119, 0.35);
}
.numbermatch-page .board-action__badge.board-action__badge--empty {
background: rgba(0, 0, 0, 0.12);
color: #111827;
box-shadow: none;
}
.numbermatch-page .board-action--attention {
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18), 0 12px 24px rgba(37, 99, 235, 0.3);
animation: numbermatch-attention 0.95s ease-in-out infinite alternate;
}
@keyframes numbermatch-attention {
from {
transform: translateY(-2px) scale(1);
}
to {
transform: translateY(-3px) scale(1.05);
}
}
.numbermatch-page .tile {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 14px;
border: 1.5px solid var(--post-border);
font-size: clamp(1.1rem, 4vw, 1.55rem);
font-weight: 700;
color: #111827;
background: linear-gradient(165deg, #fffef4 0%, #f7f0b7 100%);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 2px 3px 0 rgba(0, 0, 0, 0.15);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
position: relative;
}
.numbermatch-page .tile:hover,
.numbermatch-page .tile:focus {
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0, 0, 0, 0.18);
}
.numbermatch-page .tile--selected {
background: linear-gradient(160deg, #fde68a 0%, #fb923c 100%);
box-shadow: 4px 6px 0 rgba(251, 191, 36, 0.4);
}
.numbermatch-page .tile--hinted:not(.tile--selected) {
background: linear-gradient(160deg, #bfdbfe 0%, #93c5fd 100%);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.35), 4px 6px 0 rgba(37, 99, 235, 0.25);
}
.numbermatch-page .tile--empty {
background: rgba(0, 0, 0, 0.05);
border: 1.5px dashed rgba(0, 0, 0, 0.25);
cursor: default;
box-shadow: none;
}
.numbermatch-page .tile--empty:hover,
.numbermatch-page .tile--empty:focus {
transform: none;
}
.numbermatch-page .app__info h2,
.numbermatch-page .app__info h3 {
margin-top: 0;
color: #111827;
text-shadow: 1px 1px 0 #fff;
}
.numbermatch-page .app__info p,
.numbermatch-page .app__info li {
color: #364152;
line-height: 1.6;
}
.numbermatch-page .app__info ul {
padding-left: 1.25rem;
margin: 0.75rem 0 1.5rem;
}
.numbermatch-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.numbermatch-dialog-backdrop.is-visible {
opacity: 1;
pointer-events: all;
}
.numbermatch-dialog {
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 6px 8px 0 var(--shadow-light);
padding: 1.75rem 2rem;
max-width: 420px;
width: calc(100% - 2rem);
transform: rotate(-1deg);
position: relative;
}
.numbermatch-dialog h2 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1.35rem;
color: #111827;
}
.numbermatch-dialog p {
margin: 0 0 1.5rem;
color: #374151;
line-height: 1.5;
}
.numbermatch-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.numbermatch-dialog-button {
border: 2px solid var(--post-border);
padding: 0.55rem 1.35rem;
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
background: var(--post-bg);
color: #111827;
}
.numbermatch-dialog-button:hover,
.numbermatch-dialog-button:focus-visible {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-dialog-button--confirm {
background: var(--accent);
color: #fff;
}
.numbermatch-dialog-button--danger {
background: var(--like);
color: #fff;
}
@media (max-width: 1023px) {
.numbermatch-page .app {
grid-template-columns: minmax(0, 1fr);
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
transform: rotate(0deg);
}
}
@media (max-width: 640px) {
.numbermatch-page {
padding: 0 0 3rem;
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
padding: 1.5rem;
}
.numbermatch-page .board {
gap: 0.4rem;
}
.numbermatch-page .board-action {
width: 3.1rem;
height: 3.1rem;
font-size: 1.8rem;
}
.numbermatch-page .board-action__badge {
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
font-size: 0.75rem;
}
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_DEFER -->
<script src="numbermatch.js" defer></script>
<!-- END_SCRIPTS_DEFER -->
<div class="numbermatch-page">
<div class="app">
<section class="app__game" aria-label="Number Match board and controls">
<header class="app__header">
<h1 class="app__title">Number Match</h1>
<p class="app__subtitle">Clear the board by pairing numbers that match or add to ten.</p>
</header>
<div class="controls" role="group" aria-label="Game controls">
<button id="newGameButton" type="button" class="control-button control-button--secondary">New Game</button>
</div>
<section class="status" aria-live="polite">
<p id="message" class="status__message" role="status"></p>
<div class="status__metrics">
<span class="status__metric">
Matches cleared: <strong id="matchesCount">0</strong>
</span>
<span class="status__metric">
Tiles remaining: <strong id="tilesRemaining">0</strong>
</span>
<span class="status__metric">
Add Numbers used: <strong id="addNumbersUsed">0/3</strong>
</span>
<span class="status__metric">
Hints used: <strong id="hintsUsed">0</strong>
</span>
</div>
</section>
<section class="board" id="board" aria-label="Number grid"></section>
<div class="board-actions">
<button id="addNumbersButton" type="button" class="board-action board-action--primary" aria-label="Add numbers">
<span aria-hidden="true" class="board-action__icon">+</span>
<span id="addNumbersCounter" class="board-action__badge" aria-hidden="true">3</span>
<span class="sr-only">Add numbers</span>
</button>
<button id="hintButton" type="button" class="board-action board-action--secondary" aria-label="Hint">
<span aria-hidden="true">?</span>
<span class="sr-only">Hint</span>
</button>
</div>
</section>
<aside class="app__info" aria-labelledby="info-heading">
<h2 id="info-heading">How To Play</h2>
<p>Select two tiles that are either identical (like 8 and 8) or sum to ten (for example, 3 and 7). Clear every number to win.</p>
<h3>Match Rules</h3>
<ul>
<li>Paths can run horizontally, vertically, diagonally, or wrap in reading order.</li>
<li>Every cell between selections must be empty along the chosen path.</li>
<li>Wrap-around allows the end of a row to connect to the start of the next when the path is clear.</li>
</ul>
<h3>Strategy &amp; Tips</h3>
<ul>
<li>Scan rows and columns for classic pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.</li>
<li>Use diagonal and wrap-around matches to reach blocked numbers.</li>
<li><em>Add Numbers</em> unlocks fresh tiles in three batches—save it for when the board runs dry.</li>
</ul>
<h3>Frequently Asked Questions</h3>
<p><strong>Which numbers appear?</strong> Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.</p>
<p><strong>What counts as wrap-around?</strong> Reading order connections across a row break, provided every intervening cell is empty.</p>
</aside>
</div>
</div>
<div class="numbermatch-dialog-backdrop" id="newGameDialogBackdrop" hidden>
<div class="numbermatch-dialog" id="newGameDialog" role="dialog" aria-modal="true" aria-labelledby="newGameDialogTitle" aria-describedby="newGameDialogDescription" tabindex="-1">
<h2 id="newGameDialogTitle">Start a fresh puzzle?</h2>
<p id="newGameDialogDescription">Your current board and progress will be replaced with a new layout. You can always reload the page to resume later.</p>
<div class="numbermatch-dialog-actions">
<button type="button" class="numbermatch-dialog-button" id="cancelNewGameButton">Keep playing</button>
<button type="button" class="numbermatch-dialog-button numbermatch-dialog-button--danger" id="confirmNewGameButton">New game</button>
</div>
</div>
</div>

View File

@@ -26,5 +26,6 @@
<a href="https://mates.dev" target="_blank">MatesMedia</a>
<a href="https://lit.ruv.wtf/materials/" target="_blank">UE Material Snippets</a>
<a href="https://lit.ruv.wtf/docs/" target="_blank">Documentation</a>
<a href="https://lit.ruv.wtf/docs/numbermatch/">Number Match</a>
</div>
</header>

View File

@@ -508,6 +508,11 @@ class NumberMatchUI {
this.addNumbersButton = /** @type {HTMLButtonElement} */ (document.getElementById("addNumbersButton"));
this.hintButton = /** @type {HTMLButtonElement} */ (document.getElementById("hintButton"));
this.newGameButton = /** @type {HTMLButtonElement} */ (document.getElementById("newGameButton"));
this.newGameDialogBackdrop = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialogBackdrop"));
this.newGameDialog = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialog"));
this.newGameConfirmButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("confirmNewGameButton"));
this.newGameCancelButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("cancelNewGameButton"));
this.previouslyFocusedElement = null;
this.selectedIndices = [];
this.matchesCleared = 0;
@@ -524,9 +529,16 @@ class NumberMatchUI {
*/
init() {
this.bindControlEvents();
const restored = this.restoreState();
this.renderBoard();
this.updateMetrics();
this.showMessage("Select two tiles that match or sum to ten.");
this.hintButton.disabled = this.game.isComplete();
if (restored) {
this.showMessage("Welcome back! Your last board was restored.", "success");
} else {
this.showMessage("Select two tiles that match or sum to ten.");
}
this.persistState();
}
/**
@@ -546,6 +558,7 @@ class NumberMatchUI {
this.clearHighlights();
this.renderBoard();
this.updateMetrics();
this.persistState();
} else {
this.showMessage("No numbers remain to duplicate.", "error");
this.updateAddNumbersPrompt();
@@ -565,11 +578,36 @@ class NumberMatchUI {
this.renderBoard();
this.updateMetrics();
this.showMessage("Try matching the highlighted tiles.");
this.persistState();
});
this.newGameButton.addEventListener("click", () => {
this.startNewGame();
this.openNewGameDialog();
});
if (this.newGameDialogBackdrop && this.newGameConfirmButton && this.newGameCancelButton) {
this.newGameConfirmButton.addEventListener("click", () => {
this.closeNewGameDialog();
this.startNewGame();
});
this.newGameCancelButton.addEventListener("click", () => {
this.closeNewGameDialog();
});
this.newGameDialogBackdrop.addEventListener("mousedown", (event) => {
if (event.target === this.newGameDialogBackdrop) {
this.closeNewGameDialog();
}
});
this.newGameDialogBackdrop.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.closeNewGameDialog();
}
});
}
}
/**
@@ -620,6 +658,7 @@ class NumberMatchUI {
this.hintButton.disabled = true;
this.clearHighlights();
}
this.persistState();
return;
}
@@ -677,6 +716,7 @@ class NumberMatchUI {
this.hintButton.disabled = true;
this.clearHighlights();
}
this.persistState();
}
}
}
@@ -825,6 +865,49 @@ class NumberMatchUI {
this.renderBoard();
this.updateMetrics();
this.showMessage("New puzzle loaded. Good luck!", "success");
this.hintButton.disabled = false;
this.persistState();
}
/**
* Display the custom new game confirmation dialog.
* @returns {void}
*/
openNewGameDialog() {
if (!this.newGameDialogBackdrop || !this.newGameDialog || !this.newGameConfirmButton) {
this.startNewGame();
return;
}
this.previouslyFocusedElement = document.activeElement;
this.newGameDialogBackdrop.hidden = false;
requestAnimationFrame(() => {
this.newGameDialogBackdrop.classList.add("is-visible");
this.newGameDialogBackdrop.setAttribute("aria-hidden", "false");
this.newGameDialog.focus({ preventScroll: true });
this.newGameConfirmButton.focus({ preventScroll: true });
});
}
/**
* Hide the custom new game dialog and restore focus.
* @returns {void}
*/
closeNewGameDialog() {
if (!this.newGameDialogBackdrop) {
return;
}
this.newGameDialogBackdrop.classList.remove("is-visible");
this.newGameDialogBackdrop.setAttribute("aria-hidden", "true");
setTimeout(() => {
if (this.newGameDialogBackdrop) {
this.newGameDialogBackdrop.hidden = true;
}
}, 180);
if (this.previouslyFocusedElement instanceof HTMLElement) {
this.previouslyFocusedElement.focus({ preventScroll: true });
}
}
/**
@@ -850,8 +933,105 @@ class NumberMatchUI {
this.updateAddNumbersBadge();
}
/**
* Persist the current board and score metrics to localStorage.
* @returns {void}
*/
persistState() {
try {
const storage = window.localStorage;
if (!storage) {
return;
}
const state = {
version: NumberMatchUI.STORAGE_VERSION,
width: this.game.width,
tiles: this.game.getTiles(),
reserveTiles: Array.isArray(this.game.reserveTiles) ? [...this.game.reserveTiles] : undefined,
matchesCleared: this.matchesCleared,
addNumbersUsed: this.addNumbersUsed,
hintsUsed: this.hintsUsed,
timestamp: Date.now()
};
storage.setItem(NumberMatchUI.STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[NumberMatch] Failed to persist state", error);
}
}
/**
* Attempt to restore board and metrics from localStorage.
* @returns {boolean} True when a valid state was restored.
*/
restoreState() {
try {
const storage = window.localStorage;
if (!storage) {
return false;
}
const raw = storage.getItem(NumberMatchUI.STORAGE_KEY);
if (!raw) {
return false;
}
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== NumberMatchUI.STORAGE_VERSION) {
return false;
}
if (parsed.width !== this.game.width || !Array.isArray(parsed.tiles)) {
return false;
}
const tiles = parsed.tiles.map((value) => {
if (value === null) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
});
if (tiles.length === 0 || tiles.every((value) => value === undefined)) {
return false;
}
this.game.tiles = tiles;
if (Array.isArray(parsed.reserveTiles)) {
this.game.reserveTiles = parsed.reserveTiles
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
}
const numericOrDefault = (value, fallback, clampMin = null, clampMax = null) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
let result = numeric;
if (clampMin !== null) {
result = Math.max(clampMin, result);
}
if (clampMax !== null) {
result = Math.min(clampMax, result);
}
return result;
};
this.matchesCleared = numericOrDefault(parsed.matchesCleared, 0, 0, Number.MAX_SAFE_INTEGER);
this.addNumbersUsed = numericOrDefault(parsed.addNumbersUsed, 0, 0, NumberMatchUI.ADD_NUMBERS_LIMIT);
this.hintsUsed = numericOrDefault(parsed.hintsUsed, 0, 0, Number.MAX_SAFE_INTEGER);
this.selectedIndices = [];
this.highlightedIndices = [];
this.dragStartIndex = null;
return true;
} catch (error) {
console.error("[NumberMatch] Failed to restore state", error);
return false;
}
}
/**
* Find a valid pair of tiles for hint functionality.
NumberMatchUI.STORAGE_KEY = "numbermatch-state-v1";
NumberMatchUI.STORAGE_VERSION = 1;
* @returns {[number, number] | null} Tuple of indices or null when no match exists.
*/
findHintPair() {

View File

@@ -13,6 +13,11 @@
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/numbermatch.html</loc>
<lastmod>2025-11-19T00:00:00+00:00</lastmod>
<priority>0.70</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/docs/sitemap.xml</loc>
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
@@ -20,7 +25,7 @@
</url>
<url>
<loc>https://lit.ruv.wtf/privacy.html</loc>
<lastmod>2025-02-09T22:00:52+00:00</lastmod> <!-- Consider updating this to the actual modification date -->
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
<priority>0.50</priority>
</url>
</urlset>