diff options
| author | Zhineng Li <[email protected]> | 2026-04-30 10:33:23 +0800 |
|---|---|---|
| committer | Zhineng Li <[email protected]> | 2026-04-30 10:34:20 +0800 |
| commit | 68c9b7560642c802ca3bfe6d7e0f7a8c54412c40 (patch) | |
| tree | a262f6e37f4991cc5c8d88b50993c58af722e70c /js/wordsearch.js | |
| parent | 6f2282ca7c13fdb43f21e8f52ac20235ff7e4ded (diff) | |
| download | word-search-game-68c9b7560642c802ca3bfe6d7e0f7a8c54412c40.tar.gz word-search-game-68c9b7560642c802ca3bfe6d7e0f7a8c54412c40.zip | |
keyboard & touch navigation, configurable settings, and refactor
- keyboard support: arrow keys or `hjkl` to move cursor, Space to start
selection, Enter to confirm, Escape to cancel
- configurable settings: word placement directions, grid size, cell
size, colors, fonts, debug mode, and more via `GameSettings`
- modernize CSS (logical properties, grid layout) and HTML semantics
- refactor JavaScript code around single-responsibility principles
Diffstat (limited to 'js/wordsearch.js')
| -rw-r--r-- | js/wordsearch.js | 1253 |
1 files changed, 759 insertions, 494 deletions
diff --git a/js/wordsearch.js b/js/wordsearch.js index 5a3915f..c3390af 100644 --- a/js/wordsearch.js +++ b/js/wordsearch.js @@ -1,609 +1,874 @@ -(function(){ - 'use strict'; - - // Extend the element method - Element.prototype.wordSearch = function(settings) { - return new WordSearch(this, settings); +// @ts-check +/** + * @typedef {object} GridPoint + * @property {number} row + * @property {number} col + */ + +/** + * @typedef {object} Cell + * @property {number} row + * @property {number} col + * @property {string} letter + */ + +/** + * @typedef {object} GameSettings + * @property {Direction[]} directions - The allowed word placement directions (e.g., 'RIGHT', 'DOWN', 'DIAG_RIGHT', 'DIAG_LEFT'). + * @property {number} gridSize - The size of the game grid (e.g., 15 for a 15x15 grid). + * @property {number} cellSize - The size of each cell in pixels. + * @property {number} minCanvasWidth - The minimum width of the canvas in pixels. + * @property {number} canvasViewportGutter - The minimum distance in pixels between the canvas and the viewport edge. + * @property {string[]} words - The list of words to be placed in the grid. + * @property {number} fontWeight - The font weight for the letters (e.g., 600). + * @property {number} fontSize - The font size for the letters in pixels. + * @property {string} fontFamily - The font family for the letters (e.g., 'system-ui, -apple-system, sans-serif'). + * @property {object} colors - An object defining the color scheme for the game. + * @property {string} colors.cell - The background color of unselected cells. + * @property {string} colors.selectedCell - The background color of currently selected cells. + * @property {string} colors.foundCell - The background color of cells that are part of found words. + * @property {string} colors.stroke - The color of the grid lines. + * @property {string} colors.focusStroke - The color of the focus indicator around the current cursor cell when the canvas is focused. + * @property {string} colors.text - The color of the letters in the cells. + * @property {boolean} debug - If true, empty cells will be rendered as blank instead of filled with random letters, which can help with debugging word placement. + */ + +const DIRECTIONS = { + RIGHT: [0, 1], + DOWN: [1, 0], + DIAG_RIGHT: [1, 1], + DIAG_LEFT: [1, -1], +}; + +/** + * @typedef {keyof typeof DIRECTIONS} Direction + */ + +class Linguist { + /** + * Normalizes a word for grid placement. + * + * The normalization process includes: + * 1. Unicode normalization (NFKD) to decompose characters into their base forms. + * 2. Removal of diacritical marks (accents). + * 3. Removal of whitespace characters. + * 4. Conversion to uppercase. + * + * @param {string} word + * @returns {string} + */ + static normalize(word) { + return word + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, '') + .toUpperCase(); } /** - * Word seach + * Detects the Unicode range for different languages. * - * @param {Element} wrapWl the game's wrap element - * @param {Array} settings - * constructor + * @param {string} firstLetter + * @returns {[number, number]} A tuple containing the lower and upper bounds of the Unicode range for the detected language. */ - function WordSearch(wrapEl, settings) { - this.wrapEl = wrapEl; + static detectLanguageRange(firstLetter) { + if (!firstLetter) { + return [65, 90]; + } - // Add `.ws-area` to wrap element - this.wrapEl.classList.add('ws-area'); + const code = firstLetter.charCodeAt(0); - //Words solved. - this.solved = 0; + // Latin + // See: https://www.unicode.org/charts/PDF/U0000.pdf + // See: https://www.unicode.org/charts/PDF/U0080.pdf + if (code >= 0x0000 && code <= 0x00ff) { + return [65, 90]; + } - // Default settings - var default_settings = { - 'directions': ['W', 'N', 'WN', 'EN'], - 'gridSize': 10, - 'words': ['one', 'two', 'three', 'four', 'five'], - 'wordsList' : [], - 'debug': false + // Cyrillic + // See: https://www.unicode.org/charts/PDF/U0400.pdf + if (code >= 0x0400 && code <= 0x04ff) { + return [1040, 1071]; } - this.settings = Object.merge(settings, default_settings); - // Check the words' length if it is overflow the grid - if (this.parseWords(this.settings.gridSize)) { - // Add words into the matrix data - var isWorked = false; + // Hebrew + // See: https://unicode.org/charts/PDF/U0590.pdf + if (code >= 0x0590 && code <= 0x05ff) { + return [1488, 1514]; + } - while (isWorked == false) { - // initialize the application - this.initialize(); + // Arabic + // See: https://www.unicode.org/charts/PDF/U0600.pdf + if (code >= 0x0600 && code <= 0x06ff) { + return [1569, 1610]; + } - isWorked = this.addWords(); - } + // Hiragana + // See: https://www.unicode.org/charts/PDF/U3040.pdf + if (code >= 0x3040 && code <= 0x309f) { + return [12353, 12438]; + } - // Fill up the remaining blank items - if (!this.settings.debug) { - this.fillUpFools(); - } + // Katakana + // See: https://www.unicode.org/charts/PDF/U30A0.pdf + if (code >= 0x30a0 && code <= 0x30ff) { + return [12449, 12538]; + } - // Draw the matrix into wrap element - this.drawmatrix(); + // CJK Unified Ideographs + // See: https://www.unicode.org/charts/PDF/U4E00.pdf + if (code >= 0x4e00 && code <= 0x9fff) { + return [19968, 40959]; } + + // Latin + return [65, 90]; } +} +class Random { /** - * Parse words - * @param {Number} Max size - * @return {Boolean} + * @param {number} min + * @param {number} max + * @returns {number} A random integer in [min, max]. */ - WordSearch.prototype.parseWords = function(maxSize) { - var itWorked = true; - - for (var i = 0; i < this.settings.words.length; i++) { - // Convert all the letters to upper case - this.settings.wordsList[i] = this.settings.words[i].trim(); - this.settings.words[i] = removeDiacritics(this.settings.wordsList[i].trim().toUpperCase()); - - var word = this.settings.words[i]; - if (word.length > maxSize) { - alert('The length of word `' + word + '` is overflow the gridSize.'); - console.error('The length of word `' + word + '` is overflow the gridSize.'); - itWorked = false; - } + static integer(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } +} + +class State { + /** + * The game grid represented as a 2D array of cells. + * + * @type {Cell[][]} + */ + grid = []; + + /** + * The currently selected cells during an active selection. + * + * @type {GridPoint[]} + */ + selection = []; + + /** + * A set of numeric cell keys (`row * gridSize + col`) that are part of found words. + * + * @type {Set<number>} + */ + founds = new Set(); + + /** + * A set of normalized words that have been found by the player. + * + * @type {Set<string>} + */ + solvedWords = new Set(); + + /** + * The starting point of the current selection, or `null` if no selection is active. + * + * @type {GridPoint|null} + */ + anchor = null; + + /** + * The current position of the cursor. + * + * @type {GridPoint} + */ + cursor = { row: 0, col: 0 }; + + /** + * Whether the user is currently dragging to select cells. + * + * @type {boolean} + */ + dragging = false; + + /** + * Whether the canvas currently has focus, which affects keyboard navigation and selection. + * + * @type {boolean} + */ + canvasHasFocus = false; +} + +class Canvas { + #mount; + #settings; + #logicalBoardSize; + + /** + * The canvas element. + * + * @type {HTMLCanvasElement} + */ + #canvas = document.createElement('canvas'); + + /** + * The 2D rendering context for the canvas. + * + * @type {CanvasRenderingContext2D|null} + */ + #ctx = this.#canvas.getContext('2d'); + + /** + * Initializes the canvas for the word search game. + * + * @param {Element} element - The DOM element to mount the canvas into. + * @param {GameSettings} settings - The game settings, including grid size, cell size, and colors. + */ + constructor(element, settings) { + this.#mount = element; + this.#settings = settings; + this.#logicalBoardSize = settings.gridSize * settings.cellSize; + this.#configureCanvas(); + } + + #configureCanvas() { + this.#canvas.setAttribute('aria-label', 'Word search game board'); + this.#canvas.setAttribute('role', 'img'); + this.#canvas.tabIndex = 0; + this.#styleCanvas(); + + this.#mount.innerHTML = ''; + this.#mount.appendChild(this.#canvas); + + this.#resize(); + } + + #styleCanvas() { + this.#canvas.style.cursor = 'crosshair'; + this.#canvas.style.display = 'block'; + this.#canvas.style.touchAction = 'none'; + this.#canvas.style.boxShadow = `0 0 0 1px ${this.#settings.colors.stroke}`; + } + + #resize() { + const dpr = window.devicePixelRatio || 1; + const maxWidth = Math.min( + this.#logicalBoardSize, + Math.max(this.#settings.minCanvasWidth, window.innerWidth - this.#settings.canvasViewportGutter) + ); + + this.#canvas.style.width = `${maxWidth}px`; + this.#canvas.style.height = `${maxWidth}px`; + this.#canvas.width = Math.floor(this.#logicalBoardSize * dpr); + this.#canvas.height = Math.floor(this.#logicalBoardSize * dpr); + + if (!this.#ctx) { + throw new Error('Failed to get 2D context from canvas.'); } - return itWorked; + this.#ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + resize() { + this.#resize(); } /** - * Put the words into the matrix + * Resolve the grid cell coordinates from the given client (mouse/touch) coordinates. + * + * @param {number} clientX + * @param {number} clientY + * @returns {GridPoint|null} */ - WordSearch.prototype.addWords = function() { - var keepGoing = true, - counter = 0, - isWorked = true; - - while (keepGoing) { - // Getting random direction - var dir = this.settings.directions[Math.rangeInt(this.settings.directions.length - 1)], - result = this.addWord(this.settings.words[counter], dir), - isWorked = true; - - if (result == false) { - keepGoing = false; - isWorked = false; - } + toGridCell(clientX, clientY) { + const { gridSize } = this.#settings; + const rect = this.#canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + if (x < 0 || y < 0 || x > rect.width || y > rect.height) { + return null; + } - counter++; - if (counter >= this.settings.words.length) { - keepGoing = false; - } - } + const col = Math.floor((x / rect.width) * gridSize); + const row = Math.floor((y / rect.height) * gridSize); + + if (row < 0 || col < 0 || row >= gridSize || col >= gridSize) { + return null; + } - return isWorked; + return { row, col }; } /** - * Add word into the matrix + * Render the current state of the game onto the canvas. * - * @param {String} word - * @param {Number} direction + * @param {State} state - The current state of the game, including the grid, selection, and found words. */ - WordSearch.prototype.addWord = function(word, direction) { - var itWorked = true, - directions = { - 'W': [0, 1], // Horizontal (From left to right) - 'N': [1, 0], // Vertical (From top to bottom) - 'WN': [1, 1], // From top left to bottom right - 'EN': [1, -1] // From top right to bottom left - }, - row, col; // y, x + render(state) { + if (!this.#ctx) { + return; + } - switch (direction) { - case 'W': // Horizontal (From left to right) - var row = Math.rangeInt(this.settings.gridSize - 1), - col = Math.rangeInt(this.settings.gridSize - word.length); - break; + const { + cellSize, + gridSize, + colors, + fontWeight, + fontSize, + fontFamily, + } = this.#settings; + const boardSize = this.#logicalBoardSize; + const ctx = this.#ctx; - case 'N': // Vertical (From top to bottom) - var row = Math.rangeInt(this.settings.gridSize - word.length), - col = Math.rangeInt(this.settings.gridSize - 1); - break; + ctx.clearRect(0, 0, boardSize, boardSize); + ctx.fillStyle = colors.cell; + ctx.fillRect(0, 0, boardSize, boardSize); - case 'WN': // From top left to bottom right - var row = Math.rangeInt(this.settings.gridSize - word.length), - col = Math.rangeInt(this.settings.gridSize - word.length); - break; + ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; - case 'EN': // From top right to bottom left - var row = Math.rangeInt(this.settings.gridSize - word.length), - col = Math.rangeInt(word.length - 1, this.settings.gridSize - 1); - break; + const selection = new Set(state.selection.map(c => c.row * gridSize + c.col)); - default: - var error = 'UNKNOWN DIRECTION ' + direction + '!'; - alert(error); - console.log(error); - break; - } + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + const x = col * cellSize; + const y = row * cellSize; + const key = row * gridSize + col; - // Add words to the matrix - for (var i = 0; i < word.length; i++) { - var newRow = row + i * directions[direction][0], - newCol = col + i * directions[direction][1]; + ctx.fillStyle = + state.founds.has(key) ? colors.foundCell : + selection.has(key) ? colors.selectedCell : colors.cell; - // The letter on the board - var origin = this.matrix[newRow][newCol].letter; + ctx.fillRect(x, y, cellSize, cellSize); - if (origin == '.' || origin == word[i]) { - this.matrix[newRow][newCol].letter = word[i]; - } else { - itWorked = false; + ctx.strokeStyle = colors.stroke; + ctx.strokeRect(x + 0.5, y + 0.5, cellSize - 1, cellSize - 1); + + ctx.fillStyle = colors.text; + ctx.fillText(state.grid[row][col].letter, x + cellSize / 2, y + cellSize / 2); } } - return itWorked; + // If the canvas has focus, draw a focus indicator around the current cursor cell. + if (state.canvasHasFocus) { + const { row, col } = state.cursor; + ctx.strokeStyle = colors.focusStroke; + ctx.lineWidth = 2; + ctx.strokeRect(col * cellSize + 2, row * cellSize + 2, cellSize - 4, cellSize - 4); + ctx.lineWidth = 1; + } } + get canvas() { + return this.#canvas; + } +} + +class Handler { + #canvas; + #gridSize; + #state; + #callbacks; + #render; + /** - * Initialize the application + * @param {Canvas} canvas + * @param {number} gridSize + * @param {State} state + * @param {() => void} render - A function to call to re-render the canvas after state changes. + * @param {object} [callbacks] + * @param {() => void} [callbacks.onCanvasFocus] + * @param {() => void} [callbacks.onCanvasBlur] + * @param {() => void} [callbacks.onKeyboardStart] + * @param {() => void} [callbacks.onKeyboardCancel] + * @param {(selection: GridPoint[]) => void} [callbacks.onSelection] */ - WordSearch.prototype.initialize = function() { - /** - * Letter matrix - * - * param {Array} - */ - this.matrix = []; + constructor(canvas, gridSize, state, render, callbacks = {}) { + this.#canvas = canvas; + this.#gridSize = gridSize; + this.#state = state; + this.#render = render; + this.#callbacks = callbacks; + this.#registerEvents(); + } - /** - * Selection from - * @Param {Object} - */ - this.selectFrom = null; + #registerEvents() { + const canvas = this.#canvas.canvas; + + canvas.addEventListener('pointerdown', this.#onPointerDown); + canvas.addEventListener('keydown', this.#onKeyDown); + canvas.addEventListener('focus', this.#onFocus); + canvas.addEventListener('blur', this.#onBlur); + window.addEventListener('pointermove', this.#onPointerMove); + window.addEventListener('pointerup', this.#onPointerUp); + window.addEventListener('pointercancel', this.#onPointerCancel); + window.addEventListener('resize', this.#onResize); + } - /** - * Selected items - */ - this.selected = []; + #onFocus = () => { + this.#state.canvasHasFocus = true; + this.#callbacks.onCanvasFocus?.(); + this.#render(); + }; - this.initmatrix(this.settings.gridSize); - } + #onBlur = () => { + this.#state.canvasHasFocus = false; + this.#callbacks.onCanvasBlur?.(); + this.#render(); + }; /** - * Fill default items into the matrix - * @param {Number} size Grid size + * @param {KeyboardEvent} event */ - WordSearch.prototype.initmatrix = function(size) { - for (var row = 0; row < size; row++) { - for (var col = 0; col < size; col++) { - var item = { - letter: '.', // Default value - row: row, - col: col - } + #onKeyDown = (event) => { + let { row, col } = this.#state.cursor; + let handled = true; + + switch (event.key) { + case 'ArrowUp': + case 'k': + row = Math.max(0, row - 1); + break; + case 'ArrowDown': + case 'j': + row = Math.min(this.#gridSize - 1, row + 1); + break; + case 'ArrowLeft': + case 'h': + col = Math.max(0, col - 1); + break; + case 'ArrowRight': + case 'l': + col = Math.min(this.#gridSize - 1, col + 1); + break; + case ' ': + case 'Spacebar': + this.#startKeyboardSelection(); + break; + case 'Enter': + this.#finishKeyboardSelection(); + break; + case 'Escape': + this.#state.anchor = null; + this.#state.selection = []; + this.#callbacks.onKeyboardCancel?.(); + break; + default: + handled = false; + break; + } - if (!this.matrix[row]) { - this.matrix[row] = []; - } + this.#state.cursor = { row, col }; - this.matrix[row][col] = item; - } + if (this.#state.anchor) { + this.#state.selection = this.#getSelectionCells(this.#state.anchor, this.#state.cursor); + } + + if (handled) { + this.#render(); + event.preventDefault(); } + }; + + #startKeyboardSelection() { + this.#state.anchor = { ...this.#state.cursor }; + this.#callbacks.onKeyboardStart?.(); + } + + #finishKeyboardSelection() { + if (!this.#state.anchor) { + this.#startKeyboardSelection(); + return; + } + this.#callbacks.onSelection?.(this.#state.selection); + this.#state.anchor = null; + this.#state.selection = []; } /** - * Draw the matrix + * @param {PointerEvent} event */ - WordSearch.prototype.drawmatrix = function() { - for (var row = 0; row < this.settings.gridSize; row++) { - // New row - var divEl = document.createElement('div'); - divEl.setAttribute('class', 'ws-row'); - this.wrapEl.appendChild(divEl); - - for (var col = 0; col < this.settings.gridSize; col++) { - var cvEl = document.createElement('canvas'); - cvEl.setAttribute('class', 'ws-col'); - cvEl.setAttribute('width', 40); - cvEl.setAttribute('height', 40); - - // Fill text in middle center - var x = cvEl.width / 2, - y = cvEl.height / 2; - - var ctx = cvEl.getContext('2d'); - ctx.font = '400 28px Calibri'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = '#333'; // Text color - ctx.fillText(this.matrix[row][col].letter, x, y); - - // Add event listeners - cvEl.addEventListener('mousedown', this.onMousedown(this.matrix[row][col])); - cvEl.addEventListener('mouseover', this.onMouseover(this.matrix[row][col])); - cvEl.addEventListener('mouseup', this.onMouseup()); - - divEl.appendChild(cvEl); - } + #onPointerDown = (event) => { + const start = this.#canvas.toGridCell(event.clientX, event.clientY); + if (!start) { + return; } - } + + this.#canvas.canvas.focus({ preventScroll: true }); + this.#state.dragging = true; + this.#state.anchor = start; + this.#state.cursor = { ...start }; + this.#state.selection = []; + + this.#canvas.canvas.setPointerCapture(event.pointerId); + this.#render(); + }; /** - * Fill up the remaining items + * @param {PointerEvent} event */ - WordSearch.prototype.fillUpFools = function() { - var rangeLanguage = searchLanguage(this.settings.words[0].split('')[0]); - for (var row = 0; row < this.settings.gridSize; row++) { - for (var col = 0; col < this.settings.gridSize; col++) { - if (this.matrix[row][col].letter == '.') { - // Math.rangeInt(65, 90) => A ~ Z - this.matrix[row][col].letter = String.fromCharCode(Math.rangeInt(rangeLanguage[0], rangeLanguage[1])); - } - } + #onPointerMove = (event) => { + if (!this.#state.dragging || !this.#state.anchor) { + return; + } + + const current = this.#canvas.toGridCell(event.clientX, event.clientY); + if (!current) { + return; } - } + + this.#state.cursor = current; + this.#state.selection = this.#getSelectionCells(this.#state.anchor, this.#state.cursor); + this.#render(); + }; /** - * Returns matrix items - * @param rowFrom - * @param colFrom - * @param rowTo - * @param colTo - * @return {Array} + * @param {PointerEvent} event */ - WordSearch.prototype.getItems = function(rowFrom, colFrom, rowTo, colTo) { - var items = []; + #onPointerUp = (event) => { + if (!this.#state.dragging) { + return; + } + + this.#state.dragging = false; + + if (this.#canvas.canvas.hasPointerCapture(event.pointerId)) { + this.#canvas.canvas.releasePointerCapture(event.pointerId); + } + + if (this.#state.selection.length > 0) { + this.#callbacks.onSelection?.(this.#state.selection); + } - if ( rowFrom === rowTo || colFrom === colTo || Math.abs(rowTo - rowFrom) == Math.abs(colTo - colFrom) ) { - var shiftY = (rowFrom === rowTo) ? 0 : (rowTo > rowFrom) ? 1 : -1, - shiftX = (colFrom === colTo) ? 0 : (colTo > colFrom) ? 1 : -1, - row = rowFrom, - col = colFrom; + this.#state.anchor = null; + this.#state.selection = []; + this.#render(); + }; - items.push(this.getItem(row, col)); - do { + #onPointerCancel = () => { + this.#state.dragging = false; + this.#state.anchor = null; + this.#state.selection = []; + this.#render(); + }; + + #onResize = () => { + this.#canvas.resize(); + this.#render(); + }; + + /** + * Returns all grid points along the straight line from `from` to `to`, + * inclusive of both endpoints. Returns an empty array if the path is not + * horizontal, vertical, or diagonal. + * + * @param {GridPoint} from - The starting grid position. + * @param {GridPoint} to - The ending grid position. + * @returns {GridPoint[]} The points along the path, or `[]` if not a straight line. + */ + #getSelectionCells(from, to) { + const items = []; + const isDiag = Math.abs(to.row - from.row) === Math.abs(to.col - from.col); + + if (from.row === to.row || from.col === to.col || isDiag) { + const shiftY = from.row === to.row ? 0 : to.row > from.row ? 1 : -1; + const shiftX = from.col === to.col ? 0 : to.col > from.col ? 1 : -1; + + let row = from.row; + let col = from.col; + + items.push({ row, col }); + + while (row !== to.row || col !== to.col) { row += shiftY; col += shiftX; - items.push(this.getItem(row, col)); - } while( row !== rowTo || col !== colTo ); + items.push({ row, col }); + } } return items; } +} + +class WordSearch { + static #directions = DIRECTIONS; /** - * Returns matrix item - * @param {Number} row - * @param {Number} col - * @return {*} + * @type {GameSettings} */ - WordSearch.prototype.getItem = function(row, col) { - return (this.matrix[row] ? this.matrix[row][col] : undefined); - } + static #defaultSettings = { + directions: ['RIGHT', 'DOWN', 'DIAG_RIGHT', 'DIAG_LEFT'], + gridSize: 15, + cellSize: 36, + minCanvasWidth: 260, + canvasViewportGutter: 32, + words: [], + fontWeight: 600, + fontSize: 18, + fontFamily: 'system-ui, -apple-system, sans-serif', + colors: { + cell: 'oklch(98.5% 0.001 106.423)', + selectedCell: 'oklch(95.6% 0.045 203.388)', + foundCell: 'oklch(96.2% 0.059 95.617)', + stroke: 'oklch(92.3% 0.003 48.717)', + focusStroke: 'oklch(78.9% 0.154 211.53)', + text: 'oklch(26.8% 0.007 34.298)', + }, + debug: false, + }; + + #container; + #settings; + #normalizedWords; + #state = new State(); /** - * Clear the exist highlights + * @type {Canvas|null} */ - WordSearch.prototype.clearHighlight = function() { - var selectedEls = document.querySelectorAll('.ws-selected'); - for (var i = 0; i < selectedEls.length; i++) { - selectedEls[i].classList.remove('ws-selected'); - } - } + #canvas = null; /** - * Lookup if the wordlist contains the selected - * @param {Array} selected + * @param {Partial<GameSettings> & { container?: string | Element }} [options] */ - WordSearch.prototype.lookup = function(selected) { - var words = ['']; - - for (var i = 0; i < selected.length; i++) { - words[0] += selected[i].letter; + constructor(options = {}) { + const { container, ...settings } = options; + + if (typeof container === 'string') { + const el = document.getElementById(container); + if (!el) throw new Error(`No element found with id "${container}".`); + this.#container = el; + } else if (container instanceof Element) { + this.#container = container; + } else { + throw new Error('`container` must be an element id string or an HTMLElement.'); } - words.push(words[0].split('').reverse().join('')); - if (this.settings.words.indexOf(words[0]) > -1 || - this.settings.words.indexOf(words[1]) > -1) { - for (var i = 0; i < selected.length; i++) { - var row = selected[i].row + 1, - col = selected[i].col + 1, - el = document.querySelector('.ws-area .ws-row:nth-child(' + row + ') .ws-col:nth-child(' + col + ')'); + this.#settings = { + ...WordSearch.#defaultSettings, + ...settings, + colors: { + ...WordSearch.#defaultSettings.colors, + ...(settings.colors && typeof settings.colors === 'object' ? settings.colors : {}), + }, + }; - el.classList.add('ws-found'); - } + this.#settings.words = this.#settings.words + .map(word => String(word || '').trim()) + .filter(word => word.length > 0); - //Cross word off list. - var wordList = document.querySelector(".ws-words"); - var wordListItems = wordList.getElementsByTagName("li"); - for(var i=0; i<wordListItems.length; i++){ - if(words[0] == removeDiacritics(wordListItems[i].innerHTML.toUpperCase())){ - if(wordListItems[i].innerHTML != "<del>"+wordListItems[i].innerHTML+"</del>") { //Check the word is never found - wordListItems[i].innerHTML = "<del>"+wordListItems[i].innerHTML+"</del>"; - //Increment solved words. - this.solved++; - } - - - } + this.#normalizedWords = this.#settings.words.map(Linguist.normalize); + + if (!this.#validateWords()) return; + + this.#setupBoardState(); + this.#initializeCanvas(); + queueMicrotask(() => this.#updateStatus('game-started')); + } + + #validateWords() { + for (let i = 0; i < this.#normalizedWords.length; i++) { + if (this.#normalizedWords[i].length > this.#settings.gridSize) { + const error = `The length of word "${this.#normalizedWords[i]}" exceeds gridSize.`; + alert(error); + console.error(error); + return false; } + } + return true; + } - //Game over? - if(this.solved == this.settings.words.length){ - this.gameOver(); + #setupBoardState() { + let placed = false; + for (let attempts = 0; !placed && attempts < 300; attempts++) { + this.#initializeGrid(); + placed = this.#placeAllWords(); + } + + if (!placed) { + throw new Error('Unable to place all words in the grid after multiple attempts.'); + } + + if (!this.#settings.debug) { + this.#fillBlankCells(); + } + } + + #initializeGrid() { + this.#state.grid = Array.from({ length: this.#settings.gridSize }, (_, row) => + Array.from({ length: this.#settings.gridSize }, (__, col) => ({ + row, + col, + letter: '', + })) + ); + } + + #placeAllWords() { + for (const word of this.#normalizedWords) { + let placed = false; + for (let tries = 0; tries < 150 && !placed; tries++) { + const direction = this.#settings.directions[ + Random.integer(0, this.#settings.directions.length - 1) + ]; + placed = this.#placeWord(word, direction); + } + if (!placed) { + return false; } } + return true; } /** - * Game Over + * @param {string} word + * @param {Direction} direction + * @returns {boolean} */ - WordSearch.prototype.gameOver = function() { - //Create overlay. - var overlay = document.createElement("div"); - overlay.setAttribute("id", "ws-game-over-outer"); - overlay.setAttribute("class", "ws-game-over-outer"); - this.wrapEl.parentNode.appendChild(overlay); - - //Create overlay content. - var overlay = document.getElementById("ws-game-over-outer"); - overlay.innerHTML = "<div class='ws-game-over-inner' id='ws-game-over-inner'>"+ - "<div class='ws-game-over' id='ws-game-over'>"+ - "<h2>Congratulations!</h2>"+ - "<p>You've found all of the words!</p>"+ - "</div>"+ - "</div>"; + #placeWord(word, direction) { + const step = WordSearch.#directions[direction]; + if (!step) { + return false; + } + + const [startRow, startCol] = this.#randomStart(word, direction); + + for (let i = 0; i < word.length; i++) { + const r = startRow + i * step[0]; + const c = startCol + i * step[1]; + const l = this.#state.grid[r][c].letter; + if (l !== '' && l !== word[i]) { + return false; + } + } + + for (let i = 0; i < word.length; i++) { + const r = startRow + i * step[0]; + const c = startCol + i * step[1]; + this.#state.grid[r][c].letter = word[i]; + } + + return true; } /** - * Mouse event - Mouse down - * @param {Object} item + * @param {string} word + * @param {Direction} direction + * @returns {[number, number]} */ - WordSearch.prototype.onMousedown = function(item) { - var _this = this; - return function() { - _this.selectFrom = item; + #randomStart(word, direction) { + const max = this.#settings.gridSize; + const len = word.length; + + switch (direction) { + case 'RIGHT': + return [Random.integer(0, max - 1), Random.integer(0, max - len)]; + case 'DOWN': + return [Random.integer(0, max - len), Random.integer(0, max - 1)]; + case 'DIAG_RIGHT': + return [Random.integer(0, max - len), Random.integer(0, max - len)]; + default: + return [Random.integer(0, max - len), Random.integer(len - 1, max - 1)]; + } + } + + #fillBlankCells() { + const first = this.#normalizedWords[0]?.[0] ?? 'A'; + const [lo, hi] = Linguist.detectLanguageRange(first); + + for (const row of this.#state.grid) { + for (const cell of row) { + if (cell.letter === '') { + cell.letter = String.fromCharCode(Random.integer(lo, hi)); + } + } + } + } + + #initializeCanvas() { + this.#canvas = new Canvas(this.#container, this.#settings); + + new Handler(this.#canvas, this.#settings.gridSize, this.#state, () => this.#render(), { + onCanvasFocus: () => this.#updateStatus('board-focused'), + onKeyboardStart: () => this.#updateStatus('keyboard-started'), + onKeyboardCancel: () => this.#updateStatus('keyboard-cancelled'), + onSelection: (selection) => this.#lookupSelection(selection), + }); + + this.#render(); + } + + #render() { + if (this.#canvas) { + this.#canvas.render(this.#state); } } /** - * Mouse event - Mouse move - * @param {Object} + * @param {GridPoint[]} selection + * @returns {void} */ - WordSearch.prototype.onMouseover = function(item) { - var _this = this; - return function() { - if (_this.selectFrom) { - _this.selected = _this.getItems(_this.selectFrom.row, _this.selectFrom.col, item.row, item.col); + #lookupSelection(selection) { + const cells = selection.map(({ row, col }) => this.#state.grid[row][col]); + + if (this.#settings.debug && cells.some(cell => cell.letter === '')) { + return; + } - _this.clearHighlight(); + const letters = cells.map(c => c.letter).join(''); + const reversed = [...letters].reverse().join(''); - for (var i = 0; i < _this.selected.length; i ++) { - var current = _this.selected[i], - row = current.row + 1, - col = current.col + 1, - el = document.querySelector('.ws-area .ws-row:nth-child(' + row + ') .ws-col:nth-child(' + col + ')'); + const foundWord = + this.#normalizedWords.includes(letters) ? letters : + this.#normalizedWords.includes(reversed) ? reversed : null; - el.className += ' ws-selected'; - } - } + if (!foundWord || this.#state.solvedWords.has(foundWord)) { + return; + } + + this.#state.solvedWords.add(foundWord); + + for (const cell of cells) { + this.#state.founds.add(cell.row * this.#settings.gridSize + cell.col); + } + + const index = this.#normalizedWords.indexOf(foundWord); + const displayWord = index === -1 ? foundWord : this.#settings.words[index]; + + this.#updateStatus('word-found', { + normalizedWord: foundWord, + displayWord, + solvedCount: this.#state.solvedWords.size, + totalWords: this.#normalizedWords.length, + }); + + if (this.#state.solvedWords.size === this.#normalizedWords.length) { + this.#completeGame(); } } /** - * Mouse event - Mouse up + * @param {string} name + * @param {object} data */ - WordSearch.prototype.onMouseup = function() { - var _this = this; - return function() { - _this.selectFrom = null; - _this.clearHighlight(); - _this.lookup(_this.selected); - _this.selected = []; - } - } - -})(); -//-----------------------------Remove accent for latin/hebrew letters---------------------------------------------------// -var defaultDiacriticsRemovalMap = [{ - 'base': "A", - 'letters': /(A|Ⓐ|A|À|Á|Â|Ầ|Ấ|Ẫ|Ẩ|Ã|Ā|Ă|Ằ|Ắ|Ẵ|Ẳ|Ȧ|Ǡ|Ä|Ǟ|Ả|Å|Ǻ|Ǎ|Ȁ|Ȃ|Ạ|Ậ|Ặ|Ḁ|Ą|Ⱥ|Ɐ|[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F])/g -}, { - 'base': "AA", - 'letters': /(Ꜳ|[\uA732])/g -}, { - 'base': "AE", - 'letters': /(Æ|Ǽ|Ǣ|[\u00C6\u01FC\u01E2])/g -}, { - 'base': "AO", - 'letters': /(Ꜵ|[\uA734])/g -}, { - 'base': "AU", - 'letters': /(Ꜷ|[\uA736])/g -}, { - 'base': "AV", - 'letters': /(Ꜹ|Ꜻ|[\uA738\uA73A])/g -}, { - 'base': "AY", - 'letters': /(Ꜽ|[\uA73C])/g -}, { - 'base': "B", - 'letters': /(B|Ⓑ|B|Ḃ|Ḅ|Ḇ|Ƀ|Ƃ|Ɓ|[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181])/g -}, { - 'base': "C", - 'letters': /(C|Ⓒ|C|Ć|Ĉ|Ċ|Č|Ç|Ḉ|Ƈ|Ȼ|Ꜿ|[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E])/g -}, { - 'base': "D", - 'letters': /(D|Ⓓ|D|Ḋ|Ď|Ḍ|Ḑ|Ḓ|Ḏ|Đ|Ƌ|Ɗ|Ɖ|Ꝺ|Ð|[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779\u00D0])/g -}, { - 'base': "DZ", - 'letters': /(DZ|DŽ|[\u01F1\u01C4])/g -}, { - 'base': "Dz", - 'letters': /(Dz|Dž|[\u01F2\u01C5])/g -}, { - 'base': "E", - 'letters': /(E|Ⓔ|E|È|É|Ê|Ề|Ế|Ễ|Ể|Ẽ|Ē|Ḕ|Ḗ|Ĕ|Ė|Ë|Ẻ|Ě|Ȅ|Ȇ|Ẹ|Ệ|Ȩ|Ḝ|Ę|Ḙ|Ḛ|Ɛ|Ǝ|[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E])/g -}, { - 'base': "F", - 'letters': /(F|Ⓕ|F|Ḟ|Ƒ|Ꝼ|[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B])/g -}, { - 'base': "G", - 'letters': /(G|Ⓖ|G|Ǵ|Ĝ|Ḡ|Ğ|Ġ|Ǧ|Ģ|Ǥ|Ɠ|Ꞡ|Ᵹ|Ꝿ|[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E])/g -}, { - 'base': "H", - 'letters': /(H|Ⓗ|H|Ĥ|Ḣ|Ḧ|Ȟ|Ḥ|Ḩ|Ḫ|Ħ|Ⱨ|Ⱶ|Ɥ|[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D])/g -}, { - 'base': "I", - 'letters': /(I|Ⓘ|I|Ì|Í|Î|Ĩ|Ī|Ĭ|İ|Ï|Ḯ|Ỉ|Ǐ|Ȉ|Ȋ|Ị|Į|Ḭ|Ɨ|[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197])/g -}, { - 'base': "J", - 'letters': /(J|Ⓙ|J|Ĵ|Ɉ|[\u004A\u24BF\uFF2A\u0134\u0248])/g -}, { - 'base': "K", - 'letters': /(K|Ⓚ|K|Ḱ|Ǩ|Ḳ|Ķ|Ḵ|Ƙ|Ⱪ|Ꝁ|Ꝃ|Ꝅ|Ꞣ|[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2])/g -}, { - 'base': "L", - 'letters': /(L|Ⓛ|L|Ŀ|Ĺ|Ľ|Ḷ|Ḹ|Ļ|Ḽ|Ḻ|Ł|Ƚ|Ɫ|Ⱡ|Ꝉ|Ꝇ|Ꞁ|[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780])/g -}, { - 'base': "LJ", - 'letters': /(LJ|[\u01C7])/g -}, { - 'base': "Lj", - 'letters': /(Lj|[\u01C8])/g -}, { - 'base': "M", - 'letters': /(M|Ⓜ|M|Ḿ|Ṁ|Ṃ|Ɱ|Ɯ|[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C])/g -}, { - 'base': "N", - 'letters': /(N|Ⓝ|N|Ǹ|Ń|Ñ|Ṅ|Ň|Ṇ|Ņ|Ṋ|Ṉ|Ƞ|Ɲ|Ꞑ|Ꞥ|Ŋ|[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4\u014A])/g -}, { - 'base': "NJ", - 'letters': /(NJ|[\u01CA])/g -}, { - 'base': "Nj", - 'letters': /(Nj|[\u01CB])/g -}, { - 'base': "O", - 'letters': /(O|Ⓞ|O|Ò|Ó|Ô|Ồ|Ố|Ỗ|Ổ|Õ|Ṍ|Ȭ|Ṏ|Ō|Ṑ|Ṓ|Ŏ|Ȯ|Ȱ|Ö|Ȫ|Ỏ|Ő|Ǒ|Ȍ|Ȏ|Ơ|Ờ|Ớ|Ỡ|Ở|Ợ|Ọ|Ộ|Ǫ|Ǭ|Ø|Ǿ|Ɔ|Ɵ|Ꝋ|Ꝍ|[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C])/g -}, { - 'base': "OE", - 'letters': /(Œ|[\u0152])/g -}, { - 'base': "OI", - 'letters': /(Ƣ|[\u01A2])/g -}, { - 'base': "OO", - 'letters': /(Ꝏ|[\uA74E])/g -}, { - 'base': "OU", - 'letters': /(Ȣ|[\u0222])/g -}, { - 'base': "P", - 'letters': /(P|Ⓟ|P|Ṕ|Ṗ|Ƥ|Ᵽ|Ꝑ|Ꝓ|Ꝕ|[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754])/g -}, { - 'base': "Q", - 'letters': /(Q|Ⓠ|Q|Ꝗ|Ꝙ|Ɋ|[\u0051\u24C6\uFF31\uA756\uA758\u024A])/g -}, { - 'base': "R", - 'letters': /(R|Ⓡ|R|Ŕ|Ṙ|Ř|Ȑ|Ȓ|Ṛ|Ṝ|Ŗ|Ṟ|Ɍ|Ɽ|Ꝛ|Ꞧ|Ꞃ|[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782])/g -}, { - 'base': "S", - 'letters': /(S|Ⓢ|S|ẞ|Ś|Ṥ|Ŝ|Ṡ|Š|Ṧ|Ṣ|Ṩ|Ș|Ş|Ȿ|Ꞩ|Ꞅ|[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784])/g -}, { - 'base': "T", - 'letters': /(T|Ⓣ|T|Ṫ|Ť|Ṭ|Ț|Ţ|Ṱ|Ṯ|Ŧ|Ƭ|Ʈ|Ⱦ|Ꞇ|[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786])/g -}, { - 'base': "TH", - 'letters': /(Þ|[\u00DE])/g -}, { - 'base': "TZ", - 'letters': /(Ꜩ|[\uA728])/g -}, { - 'base': "U", - 'letters': /(U|Ⓤ|U|Ù|Ú|Û|Ũ|Ṹ|Ū|Ṻ|Ŭ|Ü|Ǜ|Ǘ|Ǖ|Ǚ|Ủ|Ů|Ű|Ǔ|Ȕ|Ȗ|Ư|Ừ|Ứ|Ữ|Ử|Ự|Ụ|Ṳ|Ų|Ṷ|Ṵ|Ʉ|[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244])/g -}, { - 'base': "V", - 'letters': /(V|Ⓥ|V|Ṽ|Ṿ|Ʋ|Ꝟ|Ʌ|[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245])/g -}, { - 'base': "VY", - 'letters': /(Ꝡ|[\uA760])/g -}, { - 'base': "W", - 'letters': /(W|Ⓦ|W|Ẁ|Ẃ|Ŵ|Ẇ|Ẅ|Ẉ|Ⱳ|[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72])/g -}, { - 'base': "X", - 'letters': /(X|Ⓧ|X|Ẋ|Ẍ|[\u0058\u24CD\uFF38\u1E8A\u1E8C])/g -}, { - 'base': "Y", - 'letters': /(Y|Ⓨ|Y|Ỳ|Ý|Ŷ|Ỹ|Ȳ|Ẏ|Ÿ|Ỷ|Ỵ|Ƴ|Ɏ|Ỿ|[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE])/g -}, { - 'base': "Z", - 'letters': /(Z|Ⓩ|Z|Ź|Ẑ|Ż|Ž|Ẓ|Ẕ|Ƶ|Ȥ|Ɀ|Ⱬ|Ꝣ|[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762])/g -}, { - 'base': "", //delete Niqqud in Hebrew - 'letters': /[\u0591-\u05C7]/g -}] - -function removeDiacritics(str) { - for (var i = 0; i < defaultDiacriticsRemovalMap.length; i++) { - str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base); - } - return str; + #updateStatus(name, data = {}) { + this.#container.dispatchEvent( + new CustomEvent('wordsearch:status', { bubbles: true, detail: { name, ...data } }) + ); + } + + #completeGame() { + this.#updateStatus('game-completed'); + } + + get container() { + return this.#container; + } + + get words() { + return this.#settings.words.slice(); + } + + get normalizedWords() { + return this.#normalizedWords.slice(); + } } -//------------------------------Search language--------------------------------------------------// -// Determine what letters injected on grid -function searchLanguage(firstLetter) -{ - codefirstLetter = firstLetter.charCodeAt(); - var codeLetter = [65,90]; - if((codefirstLetter>=65) && (codefirstLetter<=90)) { // Latin - return codeLetter = [65,90]; - } - if((codefirstLetter>=1488) && (codefirstLetter<=1514)) { //Hebrew א -> ת - return codeLetter = [1488,1514]; - } - if((codefirstLetter>=913) && (codefirstLetter<=937)) { //Greek Α -> Ω - return codeLetter = [913,929]; //930 is blank - } - if((codefirstLetter>=1040) && (codefirstLetter<=1071)) { //Cyrillic А -> Я - return codeLetter = [1040,1071]; //930 is blank - } - if((codefirstLetter>=1569) && (codefirstLetter<=1610)) { //Arab - return codeLetter = [1569,1594]; //Between 1595 and 1600, no letter - } - if((codefirstLetter>=19969) && (codefirstLetter<=40891)) { //Chinese - return codeLetter = [19969,40891]; - } - if((codefirstLetter>=12354) && (codefirstLetter<=12436)) { //Japan Hiragana - return codeLetter = [12388,12418]; //Only no small letter - } - console.log("Letter not detected : "+firstLetter+":"+codefirstLetter); - return codeLetter; - - -}
\ No newline at end of file + +export { WordSearch }; |
