mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
numbermatch moved
This commit is contained in:
891
website/numbermatch.js
Normal file
891
website/numbermatch.js
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user