added number match

This commit is contained in:
2025-11-19 16:58:27 +11:00
parent b5635adde1
commit cd325c4cfd
3 changed files with 1275 additions and 0 deletions

71
src/numbermatch.html Normal file
View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Number Match</title>
<link rel="stylesheet" href="numbermatchstyle.css">
</head>
<body>
<main 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 (for example, 8 and 8) or sum to ten (like 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 first for obvious 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 up to 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>
</main>
<script src="numbermatch.js" defer></script>
</body>
</html>

891
src/numbermatch.js Normal file
View File

@@ -0,0 +1,891 @@
/**
* Shuffle an array in place using the Fisher-Yates algorithm.
* @template T
* @param {T[]} array Array to shuffle.
* @returns {T[]} The shuffled array.
*/
function shuffle(array) {
for (let i = array.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
return array;
}
/**
* Core game state manager for Number Match logic.
*/
class NumberMatchGame {
/**
* @param {{ width?: number, rows?: number }} [options] Configuration object.
*/
constructor(options = {}) {
const { width = 9, rows = 6 } = options;
this.width = width;
this.initialRows = rows;
const initialState = NumberMatchGame.generateInitialState(width, rows);
this.tiles = initialState.boardTiles;
this.reserveTiles = initialState.reserveTiles;
}
/**
* Regenerate the board with a fresh puzzle.
* @returns {void}
*/
reset() {
const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows);
this.tiles = initialState.boardTiles;
this.reserveTiles = initialState.reserveTiles;
}
/**
* Get a shallow copy of the current tiles.
* @returns {(number|null)[]} Current tile values.
*/
getTiles() {
return [...this.tiles];
}
/**
* Count the remaining non-null tiles.
* @returns {number} Number of tiles left on the board.
*/
getRemainingCount() {
const boardCount = this.tiles.filter((tile) => tile !== null).length;
return boardCount;
}
/**
* Determine whether the board has been cleared.
* @returns {boolean} True when all tiles have been removed.
*/
isComplete() {
return this.tiles.every((tile) => tile === null);
}
/**
* Attempt to match and remove two indices.
* @param {number} firstIndex Index of the first tile.
* @param {number} secondIndex Index of the second tile.
* @returns {boolean} True if the match succeeded.
*/
selectPair(firstIndex, secondIndex) {
if (!this.canPair(firstIndex, secondIndex)) {
return false;
}
this.tiles[firstIndex] = null;
this.tiles[secondIndex] = null;
this.compactEmptyRows();
return true;
}
/**
* Check whether the two indices can form a valid pair.
* @param {number} firstIndex Index of the first tile.
* @param {number} secondIndex Index of the second tile.
* @returns {boolean} True if the tiles satisfy match rules and path constraints.
*/
canPair(firstIndex, secondIndex) {
if (firstIndex === secondIndex) {
return false;
}
if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) {
return false;
}
const valueA = this.tiles[firstIndex];
const valueB = this.tiles[secondIndex];
if (valueA === null || valueB === null) {
return false;
}
if (!(valueA === valueB || valueA + valueB === 10)) {
return false;
}
return this.hasClearPath(firstIndex, secondIndex);
}
/**
* Append remaining numbers to the end of the grid, respecting Add Numbers behavior.
* @returns {boolean} True when new numbers were appended.
*/
addNumbers(usesRemaining = 1) {
this.compactEmptyRows();
return this.appendRemainingTiles();
}
/**
* Collect active tiles in reading order.
* @returns {number[]} Non-null tile values.
*/
getActiveTiles() {
return this.tiles.filter((tile) => tile !== null);
}
/**
* Append remaining tiles in reading order after exhausting reserve uses.
* @returns {boolean} True when tiles were appended.
*/
appendRemainingTiles() {
const remaining = this.getActiveTiles();
if (remaining.length === 0) {
return false;
}
this.trimTrailingEmptySlots();
this.tiles.push(...remaining);
return true;
}
/**
* Remove trailing null placeholders from the board.
* @returns {void}
*/
trimTrailingEmptySlots() {
while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) {
this.tiles.pop();
}
}
/**
* Verify that a straight path between two indices is unobstructed.
* @param {number} firstIndex One tile index.
* @param {number} secondIndex The other tile index.
* @returns {boolean} True if any allowed path between the indices is clear.
*/
hasClearPath(firstIndex, secondIndex) {
const start = Math.min(firstIndex, secondIndex);
const end = Math.max(firstIndex, secondIndex);
const rowStart = Math.floor(start / this.width);
const rowEnd = Math.floor(end / this.width);
const colStart = start % this.width;
const colEnd = end % this.width;
if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) {
return true;
}
const diff = end - start;
if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) {
return true;
}
const rowDelta = rowEnd - rowStart;
const colDelta = colEnd - colStart;
if (rowDelta === colDelta && rowDelta > 0) {
if (colStart + rowDelta < this.width && this.isSegmentClear(start, end, this.width + 1)) {
return true;
}
}
if (rowDelta === -colDelta && rowDelta > 0) {
if (colStart - rowDelta >= 0 && this.isSegmentClear(start, end, this.width - 1)) {
return true;
}
}
return this.isSegmentClear(start, end, 1);
}
/**
* Remove any fully empty rows from the board to keep the grid compact.
* @returns {void}
*/
compactEmptyRows() {
let index = 0;
while (index < this.tiles.length) {
const remaining = this.tiles.length - index;
const span = Math.min(this.width, remaining);
const slice = this.tiles.slice(index, index + span);
const isEmptyRow = slice.every((value) => value === null);
if (isEmptyRow) {
this.tiles.splice(index, span);
continue;
}
index += this.width;
}
}
/**
* Verify that the indices along a stepped path are empty.
* @param {number} start Starting index (inclusive).
* @param {number} end Ending index (inclusive).
* @param {number} step Increment applied each iteration.
* @returns {boolean} True if no non-null tiles exist between start and end.
*/
isSegmentClear(start, end, step) {
for (let current = start + step; current < end; current += step) {
if (this.tiles[current] !== null) {
return false;
}
}
return true;
}
/**
* Determine whether the provided index is valid for the current tile array.
* @param {number} index Index to evaluate.
* @param {(number|null)[]} tiles Tile array.
* @returns {boolean} True when the index is within bounds.
*/
static isValidIndex(index, tiles) {
return index >= 0 && index < tiles.length;
}
/**
* Create a randomized board configuration split between visible tiles and a reserve pile.
* @param {number} width Number of columns.
* @param {number} rows Number of initial rows.
* @returns {{ boardTiles: number[], reserveTiles: number[] }} Generated tile sets.
*/
static generateInitialState(width, rows) {
const boardCapacity = width * rows;
const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2));
const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches);
const forcedAssignments = new Map();
const horizontalStarts = [];
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < width - 1; colIndex += 1) {
horizontalStarts.push(rowIndex * width + colIndex);
}
}
const shuffledStarts = shuffle(horizontalStarts);
const reserved = new Set();
let startCursor = 0;
forcedPairs.forEach((pair) => {
let startIndex = null;
while (startCursor < shuffledStarts.length) {
const candidate = shuffledStarts[startCursor];
startCursor += 1;
if (reserved.has(candidate) || reserved.has(candidate + 1)) {
continue;
}
startIndex = candidate;
break;
}
if (startIndex === null) {
return;
}
forcedAssignments.set(startIndex, pair[0]);
forcedAssignments.set(startIndex + 1, pair[1]);
reserved.add(startIndex);
reserved.add(startIndex + 1);
});
const boardTiles = new Array(boardCapacity).fill(null);
for (let index = 0; index < boardCapacity; index += 1) {
const forcedValue = forcedAssignments.get(index);
if (typeof forcedValue === "number") {
boardTiles[index] = forcedValue;
continue;
}
const rawNextForced = forcedAssignments.get(index + 1);
const nextForcedValue = typeof rawNextForced === "number" ? rawNextForced : null;
boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue);
}
const reserveTiles = NumberMatchGame.buildReserveTiles(boardTiles, boardCapacity);
return {
boardTiles,
reserveTiles
};
}
/**
* Produce a random pair of values that form a legal match.
* @returns {[number, number]} Two values that either match or sum to ten.
*/
static generatePair() {
const complementPairs = [
[1, 9],
[2, 8],
[3, 7],
[4, 6],
[5, 5]
];
const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
if (Math.random() < 0.5) {
const value = identicalValues[Math.floor(Math.random() * identicalValues.length)];
return [value, value];
}
const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)];
return [...pair];
}
/**
* Create the predetermined matches that appear on the initial board.
* @param {number} target Total matches to prepare.
* @returns {Array<[number, number]>} Ordered list of forced pairs.
*/
static getForcedPairs(target) {
const essentialPairs = [[5, 5]];
const complementPool = shuffle([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
[9, 1],
[8, 2],
[7, 3],
[6, 4]
]);
const pairs = [];
for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) {
pairs.push(essentialPairs[i]);
}
let complementIndex = 0;
while (pairs.length < target && complementIndex < complementPool.length) {
pairs.push(complementPool[complementIndex]);
complementIndex += 1;
}
while (pairs.length < target) {
pairs.push(NumberMatchGame.generatePair());
}
return shuffle(pairs);
}
/**
* Pick a filler value that will not immediately create an accessible match.
* @param {Array<number|null>} tiles Current partially filled board.
* @param {number} width Board width.
* @param {number} index Index to populate.
* @param {number|null} nextForcedValue Upcoming forced value, if one exists.
* @returns {number} Safe filler digit.
*/
static pickSafeFillerValue(tiles, width, index, nextForcedValue) {
const attempt = (respectNext) => {
const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null);
const candidates = [];
for (let value = 1; value <= 9; value += 1) {
if (!forbidden.has(value)) {
candidates.push(value);
}
}
return candidates;
};
let candidates = attempt(true);
if (candidates.length === 0) {
candidates = attempt(false);
}
if (candidates.length === 0) {
return Math.floor(Math.random() * 9) + 1;
}
return candidates[Math.floor(Math.random() * candidates.length)];
}
/**
* Collect forbidden values based on already placed neighbors and optional future constraints.
* @param {Array<number|null>} tiles Current board state.
* @param {number} width Board width.
* @param {number} index Target index.
* @param {number|null} nextForcedValue Forced value to appear next in sequence, if any.
* @returns {Set<number>} Values that should not be used at the index.
*/
static collectForbiddenValues(tiles, width, index, nextForcedValue) {
const forbidden = new Set();
const register = NumberMatchGame.registerForbiddenValue;
if (index > 0) {
register(forbidden, tiles[index - 1]);
}
const row = Math.floor(index / width);
const col = index % width;
if (row > 0) {
register(forbidden, tiles[index - width]);
if (col > 0) {
register(forbidden, tiles[index - width - 1]);
}
if (col < width - 1) {
register(forbidden, tiles[index - width + 1]);
}
}
if (typeof nextForcedValue === "number") {
register(forbidden, nextForcedValue);
}
return forbidden;
}
/**
* Register a value and its complement as forbidden for immediate placement.
* @param {Set<number>} forbidden Collection of disallowed values.
* @param {number|null|undefined} value Value to mark as forbidden.
* @returns {void}
*/
static registerForbiddenValue(forbidden, value) {
if (typeof value !== "number") {
return;
}
forbidden.add(value);
const complement = NumberMatchGame.getComplement(value);
forbidden.add(complement);
}
/**
* Compute the complement digit that forms a sum-to-ten relationship.
* @param {number} value Value between 1 and 9.
* @returns {number} Complement digit.
*/
static getComplement(value) {
if (value === 5) {
return 5;
}
const complement = 10 - value;
return Math.min(9, Math.max(1, complement));
}
/**
* Build the reserve pile so that complements exist for non-matching board values.
* @param {Array<number|null>} boardTiles Generated board.
* @param {number} boardCapacity Total board slots.
* @returns {number[]} Reserve tiles array.
*/
static buildReserveTiles(boardTiles, boardCapacity) {
const reserve = [];
boardTiles.forEach((value) => {
if (typeof value !== "number") {
return;
}
reserve.push(NumberMatchGame.getComplement(value));
});
while (reserve.length < Math.ceil(boardCapacity * 1.1)) {
const [first, second] = NumberMatchGame.generatePair();
reserve.push(first, second);
}
return shuffle(reserve);
}
}
/**
* UI controller responsible for rendering and interactions.
*/
class NumberMatchUI {
/**
* @param {NumberMatchGame} game Game state instance.
*/
constructor(game) {
this.game = game;
this.boardElement = /** @type {HTMLElement} */ (document.getElementById("board"));
this.messageElement = /** @type {HTMLElement} */ (document.getElementById("message"));
this.matchesElement = /** @type {HTMLElement} */ (document.getElementById("matchesCount"));
this.tilesRemainingElement = /** @type {HTMLElement} */ (document.getElementById("tilesRemaining"));
this.addNumbersElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersUsed"));
this.addNumbersBadgeElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersCounter"));
this.hintsElement = /** @type {HTMLElement} */ (document.getElementById("hintsUsed"));
this.addNumbersButton = /** @type {HTMLButtonElement} */ (document.getElementById("addNumbersButton"));
this.hintButton = /** @type {HTMLButtonElement} */ (document.getElementById("hintButton"));
this.newGameButton = /** @type {HTMLButtonElement} */ (document.getElementById("newGameButton"));
this.selectedIndices = [];
this.matchesCleared = 0;
this.addNumbersUsed = 0;
this.hintsUsed = 0;
this.highlightedIndices = [];
this.dragStartIndex = null;
this.isDragging = false;
}
/**
* Initialize event listeners and render the starting board.
* @returns {void}
*/
init() {
this.bindControlEvents();
this.renderBoard();
this.updateMetrics();
this.showMessage("Select two tiles that match or sum to ten.");
}
/**
* Attach event listeners to control buttons.
* @returns {void}
*/
bindControlEvents() {
this.addNumbersButton.addEventListener("click", () => {
const remainingUses = NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed;
const appended = this.game.addNumbers(remainingUses);
if (appended) {
if (remainingUses > 0) {
this.addNumbersUsed += 1;
}
this.showMessage("Numbers duplicated to the end of the grid.", "success");
this.selectedIndices = [];
this.clearHighlights();
this.renderBoard();
this.updateMetrics();
} else {
this.showMessage("No numbers remain to duplicate.", "error");
this.updateAddNumbersPrompt();
}
});
this.hintButton.addEventListener("click", () => {
const hint = this.findHintPair();
if (!hint) {
this.showMessage("No matches available. Try adding numbers.", "error");
this.updateAddNumbersPrompt();
return;
}
this.selectedIndices = [];
this.highlightedIndices = hint;
this.hintsUsed += 1;
this.renderBoard();
this.updateMetrics();
this.showMessage("Try matching the highlighted tiles.");
});
this.newGameButton.addEventListener("click", () => {
this.startNewGame();
});
}
/**
* Handle tile clicks and manage selection/matching lifecycle.
* @param {number} index Tile index that was clicked.
* @returns {void}
*/
handleTileClick(index) {
if (this.isDragging) {
return;
}
const tiles = this.game.getTiles();
this.clearHighlights();
if (tiles[index] === null) {
return;
}
if (this.selectedIndices.includes(index)) {
this.selectedIndices = this.selectedIndices.filter((item) => item !== index);
if (this.selectedIndices.length === 1) {
this.highlightCompatibleTiles(this.selectedIndices[0]);
}
this.renderBoard();
return;
}
this.selectedIndices.push(index);
if (this.selectedIndices.length < 2) {
this.highlightCompatibleTiles(index);
this.renderBoard();
return;
}
this.renderBoard();
this.clearHighlights();
const [first, second] = this.selectedIndices;
const didMatch = this.game.selectPair(first, second);
if (didMatch) {
this.matchesCleared += 1;
this.selectedIndices = [];
this.renderBoard();
this.updateMetrics();
this.showMessage("Great match!", "success");
if (this.game.isComplete()) {
this.showMessage("Board cleared! Start a new game when you are ready.", "success");
this.addNumbersButton.disabled = true;
this.hintButton.disabled = true;
this.clearHighlights();
}
return;
}
this.selectedIndices = [second];
this.highlightCompatibleTiles(second);
this.renderBoard();
}
/**
* Handle pointer down events for drag-to-match functionality.
* @param {number} index Tile index where pointer down occurred.
* @returns {void}
*/
handleTilePointerDown(index, event) {
const tiles = this.game.getTiles();
if (tiles[index] === null) {
return;
}
this.dragStartIndex = index;
this.isDragging = false;
event.target.setPointerCapture(event.pointerId);
}
/**
* Handle pointer enter events during drag.
* @param {number} index Tile index being entered.
* @returns {void}
*/
handleTilePointerEnter(index, event) {
if (this.dragStartIndex === null || this.dragStartIndex === undefined) {
return;
}
const tiles = this.game.getTiles();
if (tiles[index] === null) {
return;
}
if (index === this.dragStartIndex) {
return;
}
if (event.pressure > 0 || event.buttons > 0) {
this.isDragging = true;
const didMatch = this.game.selectPair(this.dragStartIndex, index);
if (didMatch) {
this.matchesCleared += 1;
this.selectedIndices = [];
this.clearHighlights();
this.dragStartIndex = null;
this.renderBoard();
this.updateMetrics();
this.showMessage("Great match!", "success");
if (this.game.isComplete()) {
this.showMessage("Board cleared! Start a new game when you are ready.", "success");
this.addNumbersButton.disabled = true;
this.hintButton.disabled = true;
this.clearHighlights();
}
}
}
}
/**
* Handle pointer up events to reset drag state.
* @returns {void}
*/
handleTilePointerUp() {
this.dragStartIndex = null;
setTimeout(() => {
this.isDragging = false;
}, 10);
}
/**
* Render the board based on the current game state.
* @returns {void}
*/
renderBoard() {
const tiles = this.game.getTiles();
this.boardElement.innerHTML = "";
this.boardElement.style.gridTemplateColumns = `repeat(${this.game.width}, minmax(0, 1fr))`;
tiles.forEach((value, index) => {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.index = index.toString();
if (value === null) {
tile.classList.add("tile--empty");
tile.disabled = true;
tile.setAttribute("aria-hidden", "true");
} else {
tile.textContent = String(value);
tile.addEventListener("click", () => this.handleTileClick(index));
tile.addEventListener("pointerdown", (e) => this.handleTilePointerDown(index, e));
tile.addEventListener("pointermove", (e) => this.handleTilePointerEnter(index, e));
tile.addEventListener("pointerup", () => this.handleTilePointerUp());
tile.addEventListener("pointercancel", () => this.handleTilePointerUp());
tile.style.touchAction = "none";
}
if (this.selectedIndices.includes(index)) {
tile.classList.add("tile--selected");
}
if (this.highlightedIndices.includes(index)) {
tile.classList.add("tile--hinted");
}
this.boardElement.appendChild(tile);
});
this.updateAddNumbersPrompt();
}
/**
* Update score-related UI metrics.
* @returns {void}
*/
updateMetrics() {
this.matchesElement.textContent = String(this.matchesCleared);
this.tilesRemainingElement.textContent = String(this.game.getRemainingCount());
this.addNumbersElement.textContent = `${this.addNumbersUsed}/${NumberMatchUI.ADD_NUMBERS_LIMIT}`;
this.hintsElement.textContent = String(this.hintsUsed);
this.updateAddNumbersBadge();
}
/**
* Reflect remaining Add Numbers uses in the badge.
* @returns {void}
*/
updateAddNumbersBadge() {
const remaining = Math.max(0, NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed);
if (this.addNumbersBadgeElement) {
this.addNumbersBadgeElement.textContent = String(remaining);
this.addNumbersBadgeElement.classList.toggle("board-action__badge--empty", remaining === 0);
}
}
/**
* Highlight tiles compatible with the currently selected tile.
* @param {number} baseIndex Index of the selected tile.
* @returns {void}
*/
highlightCompatibleTiles(baseIndex) {
const tiles = this.game.getTiles();
const baseValue = tiles[baseIndex];
if (baseValue === null) {
this.highlightedIndices = [];
return;
}
const matches = [];
for (let i = 0; i < tiles.length; i += 1) {
if (i === baseIndex) {
continue;
}
const value = tiles[i];
if (value === null) {
continue;
}
if (value === baseValue || value + baseValue === 10) {
if (this.game.canPair(baseIndex, i)) {
matches.push(i);
}
}
}
this.highlightedIndices = matches;
}
/**
* Clear any highlighted tile indices.
* @returns {void}
*/
clearHighlights() {
this.highlightedIndices = [];
}
/**
* Display a status message with optional styling.
* @param {string} message Message to display.
* @param {"success"|"error"|"info"} [type="info"] Message type for styling.
* @returns {void}
*/
showMessage(message, type = "info") {
this.messageElement.textContent = message;
this.messageElement.classList.remove("status__message--error", "status__message--success");
if (type === "success") {
this.messageElement.classList.add("status__message--success");
} else if (type === "error") {
this.messageElement.classList.add("status__message--error");
}
}
/**
* Reset state and begin a new puzzle.
* @returns {void}
*/
startNewGame() {
this.game.reset();
this.matchesCleared = 0;
this.addNumbersUsed = 0;
this.hintsUsed = 0;
this.selectedIndices = [];
this.dragStartIndex = null;
this.addNumbersButton.disabled = false;
this.hintButton.disabled = false;
this.clearHighlights();
this.renderBoard();
this.updateMetrics();
this.showMessage("New puzzle loaded. Good luck!", "success");
}
/**
* Determine whether any valid matches remain on the board.
* @returns {boolean} True if at least one valid pair exists.
*/
hasAvailableMoves() {
return NumberMatchUI.findFirstValidPair(this.game) !== null;
}
/**
* Update Add Numbers control state, including glow cues and disabling logic.
* @returns {void}
*/
updateAddNumbersPrompt() {
const hasNumbersRemaining = this.game.getRemainingCount() > 0;
const activeAction = hasNumbersRemaining && !this.game.isComplete();
this.addNumbersButton.disabled = !activeAction;
const noMoves = !this.hasAvailableMoves();
const shouldGlow = noMoves && hasNumbersRemaining && activeAction;
this.addNumbersButton.classList.toggle("board-action--attention", shouldGlow);
this.updateAddNumbersBadge();
}
/**
* Find a valid pair of tiles for hint functionality.
* @returns {[number, number] | null} Tuple of indices or null when no match exists.
*/
findHintPair() {
return NumberMatchUI.findFirstValidPair(this.game);
}
/**
* Compute the first valid pair of tiles available.
* @param {NumberMatchGame} game Game state instance to inspect.
* @returns {[number, number] | null} Indices representing a valid pair or null.
*/
static findFirstValidPair(game) {
const tiles = game.getTiles();
for (let i = 0; i < tiles.length; i += 1) {
if (tiles[i] === null) {
continue;
}
for (let j = i + 1; j < tiles.length; j += 1) {
if (tiles[j] === null) {
continue;
}
if (game.canPair(i, j)) {
return [i, j];
}
}
}
return null;
}
}
NumberMatchUI.ADD_NUMBERS_LIMIT = 3;
window.addEventListener("DOMContentLoaded", () => {
const game = new NumberMatchGame({ width: 9, rows: 6 });
const ui = new NumberMatchUI(game);
ui.init();
});

313
src/numbermatchstyle.css Normal file
View File

@@ -0,0 +1,313 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: stretch;
padding: 1.5rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.app {
display: grid;
gap: 2rem;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
width: min(1100px, 100%);
}
.app__game,
.app__info {
background-color: #1e293b;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
.app__title {
margin: 0;
font-size: 2rem;
font-weight: 700;
}
.app__subtitle {
margin: 0.25rem 0 0;
color: #94a3b8;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1.5rem 0;
}
.control-button {
background: linear-gradient(135deg, #22d3ee, #2563eb);
border: none;
border-radius: 999px;
color: #0f172a;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
padding: 0.75rem 1.5rem;
transition: transform 120ms ease, box-shadow 120ms ease;
}
.control-button:hover,
.control-button:focus {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.45);
}
.control-button:active {
transform: translateY(0);
box-shadow: none;
}
.control-button--secondary {
background: #334155;
color: #e2e8f0;
}
.status {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.status__message {
min-height: 1.5rem;
margin: 0;
color: #f8fafc;
font-weight: 600;
}
.status__message.status__message--error {
color: #f87171;
}
.status__message.status__message--success {
color: #4ade80;
}
.status__metrics {
display: flex;
flex-wrap: wrap;
gap: 1rem;
color: #cbd5f5;
}
.status__metric {
font-size: 0.95rem;
}
.board {
margin-top: 1.5rem;
display: grid;
gap: 0.5rem;
width: 100%;
}
.board-actions {
margin-top: 1.25rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.board-action {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: none;
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
cursor: pointer;
color: #0f172a;
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
position: relative;
}
.board-action:hover,
.board-action:focus {
transform: translateY(-2px);
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.35);
}
.board-action:active {
transform: translateY(0);
box-shadow: none;
}
.board-action--primary {
background: linear-gradient(135deg, #22d3ee, #2563eb);
}
.board-action--secondary {
background: #e2e8f0;
color: #0f172a;
}
.board-action__icon {
line-height: 1;
}
.board-action__badge {
position: absolute;
top: -0.35rem;
right: -0.35rem;
min-width: 1.5rem;
padding: 0.15rem 0.4rem;
border-radius: 999px;
background: #0ea5e9;
color: #0f172a;
font-size: 0.85rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 14px rgba(14, 165, 233, 0.45);
}
.board-action__badge.board-action__badge--empty {
background: #cbd5f5;
color: #475569;
box-shadow: none;
}
.board-action--attention {
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.4), 0 18px 36px rgba(8, 145, 178, 0.45);
animation: board-action-pulse 0.9s ease-in-out infinite alternate;
}
@keyframes board-action-pulse {
from {
transform: translateY(-2px) scale(1);
}
to {
transform: translateY(-3px) scale(1.05);
}
}
.tile {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 0.75rem;
border: none;
font-size: clamp(1.125rem, 4vw, 1.5rem);
font-weight: 700;
color: #0f172a;
background: linear-gradient(145deg, #f1f5f9, #cbd5f5);
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
display: flex;
justify-content: center;
align-items: center;
}
.tile:hover,
.tile:focus {
transform: translateY(-2px);
box-shadow: 0 14px 25px rgba(15, 23, 42, 0.35);
}
.tile--selected {
background: linear-gradient(145deg, #fde68a, #f97316);
box-shadow: 0 16px 35px rgba(251, 191, 36, 0.4);
}
.tile--hinted:not(.tile--selected) {
background: linear-gradient(145deg, #bfdbfe, #93c5fd);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5), 0 12px 24px rgba(37, 99, 235, 0.35);
}
.tile--empty {
background: rgba(148, 163, 184, 0.25);
border: 1px dashed rgba(148, 163, 184, 0.45);
cursor: default;
box-shadow: none;
}
.tile--empty:hover,
.tile--empty:focus {
transform: none;
}
.app__info h2,
.app__info h3 {
margin-top: 0;
color: #f8fafc;
}
.app__info ul {
padding-left: 1.25rem;
margin: 0.75rem 0 1rem;
}
.app__info li {
margin-bottom: 0.5rem;
}
.app__info p {
color: #cbd5f5;
line-height: 1.5;
}
@media (max-width: 960px) {
body {
padding: 0;
}
.app {
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem;
}
.app__info {
order: 1;
margin-top: 0;
}
.board {
gap: 0.35rem;
}
.board-actions {
margin-top: 1rem;
gap: 0.5rem;
}
.board-action {
width: 3.1rem;
height: 3.1rem;
font-size: 1.75rem;
}
.board-action__badge {
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
font-size: 0.75rem;
}
}