mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
1072 lines
32 KiB
JavaScript
1072 lines
32 KiB
JavaScript
/**
|
|
* 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.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;
|
|
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();
|
|
const restored = this.restoreState();
|
|
this.renderBoard();
|
|
this.updateMetrics();
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
this.persistState();
|
|
} 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.persistState();
|
|
});
|
|
|
|
this.newGameButton.addEventListener("click", () => {
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
this.persistState();
|
|
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();
|
|
}
|
|
this.persistState();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
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 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
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();
|
|
});
|