2025-01-14 08:49:41 +00:00
|
|
|
const PARSE_RANGE_START = "0"
|
|
|
|
const PARSE_RANGE_END = "9"
|
|
|
|
|
|
|
|
const VALID_RANGE_START = "1"
|
|
|
|
const VALID_RANGE_END = "9"
|
|
|
|
|
|
|
|
const SUDOKU_GRID_SIZE = 9 * 9
|
|
|
|
|
|
|
|
const SUDOKU_VALUES = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param min {number}
|
|
|
|
* @param max {number}
|
|
|
|
* @returns {number}
|
|
|
|
*/
|
|
|
|
function randInt(min, max) {
|
|
|
|
const minCeil = Math.ceil(min)
|
|
|
|
const maxFloor = Math.floor(max)
|
|
|
|
return Math.trunc(Math.random() * (maxFloor - minCeil + 1)) + minCeil
|
|
|
|
}
|
|
|
|
|
|
|
|
class SudokuPuzzle extends EventTarget {
|
2025-01-16 23:50:36 +00:00
|
|
|
/**
|
2025-01-16 23:59:02 +00:00
|
|
|
* undefined is an empty cell, null is a cell that was cleared
|
2025-01-16 23:50:36 +00:00
|
|
|
* @typedef {(number|null|undefined)[]} SudokuState
|
|
|
|
*/
|
|
|
|
|
2025-01-14 08:49:41 +00:00
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @type {SudokuState[]}
|
2025-01-14 08:49:41 +00:00
|
|
|
*/
|
|
|
|
#state = []
|
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @type {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
*/
|
2025-01-16 21:45:47 +00:00
|
|
|
#activeState = new Array(SUDOKU_GRID_SIZE)
|
2025-01-14 08:49:41 +00:00
|
|
|
|
2025-01-14 22:03:24 +00:00
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @returns {SudokuState}
|
2025-01-16 23:55:31 +00:00
|
|
|
* @readonly
|
2025-01-14 22:03:24 +00:00
|
|
|
*/
|
2025-01-14 08:49:41 +00:00
|
|
|
get grid() {
|
|
|
|
const gridValue = [...this.#activeState]
|
|
|
|
Object.freeze(gridValue)
|
|
|
|
return gridValue
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param start {string} Either "generate" for a random sudoku or numbers to prefill into the grid (0 is empty)
|
|
|
|
*/
|
|
|
|
constructor(start) {
|
|
|
|
super()
|
|
|
|
|
|
|
|
if (start === "generate") {
|
|
|
|
this.applyState(this.#generateRandomState())
|
|
|
|
} else if (start) {
|
|
|
|
this.applyState(SudokuPuzzle.#stateFromString(start))
|
|
|
|
} else {
|
|
|
|
this.applyState(Array.from({ length: SUDOKU_GRID_SIZE }))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param serializedState {string}
|
2025-01-16 23:50:36 +00:00
|
|
|
* @returns {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
*/
|
|
|
|
static #stateFromString(serializedState) {
|
|
|
|
let cellIndex = 0
|
|
|
|
|
|
|
|
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
|
|
|
|
|
|
|
|
for (const char of serializedState) {
|
|
|
|
if (PARSE_RANGE_START <= char && char <= PARSE_RANGE_END) {
|
|
|
|
if (VALID_RANGE_START <= char && char <= PARSE_RANGE_END) {
|
|
|
|
newState[cellIndex] = Number.parseInt(char, 10)
|
|
|
|
}
|
|
|
|
cellIndex++
|
|
|
|
}
|
|
|
|
|
|
|
|
if (cellIndex >= newState.length) {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return newState
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param stateToSample {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
* @returns {undefined|number}
|
|
|
|
*/
|
|
|
|
#sampleRandomEmptyField(stateToSample = this.#activeState) {
|
|
|
|
const iters = [...stateToSample.entries().filter(([_, v]) => v == null)]
|
|
|
|
if (iters.length > 0) {
|
|
|
|
return iters[randInt(0, iters.length - 1)][0]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param states {...SudokuState[]}
|
2025-01-14 08:49:41 +00:00
|
|
|
*/
|
|
|
|
static collapseStates(...states) {
|
|
|
|
return Array.from({ length: SUDOKU_GRID_SIZE }, (_, i) => {
|
|
|
|
for (let stateIndex = states.length - 1; stateIndex >= 0; stateIndex--) {
|
|
|
|
if (states[stateIndex][i] !== undefined) {
|
|
|
|
return states[stateIndex][i]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param slot {number}
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param state {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
* @return {Set<number>}
|
|
|
|
*/
|
|
|
|
#boxGetValues(slot, state = this.#activeState) {
|
|
|
|
const containedValues = new Set()
|
|
|
|
const left = Math.trunc(Math.trunc(slot % 9) / 3) * 3
|
|
|
|
const top = Math.trunc(Math.trunc(slot / 9) / 3) * (9 * 3)
|
|
|
|
|
|
|
|
for (let y = 0; y <= 27; y += 9) {
|
|
|
|
for (let x = 0; x < 3; x++) {
|
|
|
|
const positionIndex = top + y + left + x
|
|
|
|
|
|
|
|
if (positionIndex !== slot) {
|
|
|
|
containedValues.add(state[positionIndex])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return containedValues
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param slot {number}
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param state {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
* @return {Set<number>}
|
|
|
|
*/
|
|
|
|
#crossGetValues(slot, state = this.#activeState) {
|
|
|
|
const containedValues = new Set()
|
|
|
|
const left = slot % 9
|
|
|
|
|
|
|
|
for (let leftIndex = slot - left; leftIndex < slot; leftIndex++) {
|
|
|
|
if (state[leftIndex] != null) {
|
|
|
|
containedValues.add(state[leftIndex])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (let rightIndex = slot - left + 9; rightIndex > slot; rightIndex--) {
|
|
|
|
if (state[rightIndex] != null) {
|
|
|
|
containedValues.add(state[rightIndex])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (let topIndex = left; topIndex < slot; topIndex += 9) {
|
|
|
|
if (state[topIndex] != null) {
|
|
|
|
containedValues.add(state[topIndex])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (
|
|
|
|
let bottomIndex = SUDOKU_GRID_SIZE - (9 - left);
|
|
|
|
bottomIndex > slot;
|
|
|
|
bottomIndex -= 9
|
|
|
|
) {
|
|
|
|
if (state[bottomIndex] != null) {
|
|
|
|
containedValues.add(state[bottomIndex])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return containedValues
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param slot {number}
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param state {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
* @return {Set<number>}
|
|
|
|
*/
|
|
|
|
getValues(slot, state = this.#activeState) {
|
|
|
|
return this.#boxGetValues(slot, state).union(
|
|
|
|
this.#crossGetValues(slot, state),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param value {number}
|
|
|
|
* @param slot {number}
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param state {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
* @return boolean
|
|
|
|
*/
|
|
|
|
isValueValidForSlot(value, slot, state = this.#activeState) {
|
|
|
|
return !this.#boxGetValues(slot, state)
|
|
|
|
.union(this.#crossGetValues(slot, state))
|
|
|
|
.has(value)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param newState {SudokuState}
|
2025-01-14 08:49:41 +00:00
|
|
|
*/
|
|
|
|
fillRandomFieldInState(newState = Array.from({ length: SUDOKU_GRID_SIZE })) {
|
|
|
|
const inProgressState = SudokuPuzzle.collapseStates(
|
|
|
|
this.#activeState,
|
|
|
|
newState,
|
|
|
|
)
|
|
|
|
|
|
|
|
const cell = this.#sampleRandomEmptyField(inProgressState)
|
|
|
|
|
|
|
|
if (cell === undefined) return newState
|
|
|
|
|
|
|
|
const availableValues = [
|
|
|
|
...SUDOKU_VALUES.difference(this.getValues(cell, inProgressState)),
|
|
|
|
]
|
|
|
|
|
|
|
|
newState[cell] = availableValues[randInt(0, availableValues.length - 1)]
|
|
|
|
return newState
|
|
|
|
}
|
|
|
|
|
2025-01-14 22:03:24 +00:00
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @returns {SudokuState}
|
2025-01-14 22:03:24 +00:00
|
|
|
*/
|
2025-01-14 08:49:41 +00:00
|
|
|
#generateRandomState() {
|
|
|
|
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
|
|
|
|
for (let i = 0; i < 17; i++) {
|
|
|
|
this.fillRandomFieldInState(newState)
|
|
|
|
}
|
|
|
|
|
|
|
|
return newState
|
|
|
|
}
|
|
|
|
|
2025-01-14 22:03:24 +00:00
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @returns {SudokuState}
|
2025-01-14 22:03:24 +00:00
|
|
|
*/
|
2025-01-14 08:49:41 +00:00
|
|
|
get baseState() {
|
|
|
|
return this.#state[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
popState() {
|
|
|
|
if (this.#state.length <= 1) return
|
|
|
|
|
|
|
|
this.#state.pop()
|
|
|
|
this.#activeState = SudokuPuzzle.collapseStates(...this.#state)
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent("state-changed", { detail: { value: this } }),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-01-14 22:03:24 +00:00
|
|
|
/**
|
|
|
|
*
|
2025-01-16 23:50:36 +00:00
|
|
|
* @param newState {SudokuState}
|
2025-01-14 22:03:24 +00:00
|
|
|
*/
|
2025-01-14 08:49:41 +00:00
|
|
|
applyState(newState) {
|
|
|
|
this.#state.push(newState)
|
|
|
|
this.#activeState = SudokuPuzzle.collapseStates(...this.#state)
|
|
|
|
this.dispatchEvent(
|
|
|
|
new CustomEvent("state-changed", { detail: { value: this } }),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class SudokuHost extends HTMLElement {
|
|
|
|
static observedAttributes = ["size", "puzzle", "readonly"]
|
|
|
|
/**
|
|
|
|
* @type {?ShadowRoot}
|
|
|
|
*/
|
|
|
|
#root
|
|
|
|
/**
|
|
|
|
* @type {?HTMLLinkElement}
|
|
|
|
*/
|
|
|
|
#styling
|
|
|
|
/**
|
|
|
|
* @type {?HTMLElement}
|
|
|
|
*/
|
|
|
|
#container
|
|
|
|
/**
|
|
|
|
* @type {HTMLElement[]}
|
|
|
|
*/
|
|
|
|
#cellGrid
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @type {SudokuPuzzle}
|
|
|
|
*/
|
|
|
|
#activePuzzle
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @type {Map<number, HTMLElement>}
|
|
|
|
*/
|
|
|
|
#selectedCells = new Map()
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @type {Map<string, (event: KeyboardEvent) => void>}
|
|
|
|
*/
|
|
|
|
#keyProcessors = new Map()
|
|
|
|
|
|
|
|
get isActive() {
|
|
|
|
return this.matches(":hover")
|
|
|
|
}
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
super()
|
|
|
|
|
|
|
|
const cellValueProcessor = (event) => {
|
|
|
|
if (this.#selectedCells.size === 0) return
|
|
|
|
|
|
|
|
const cellValue = Number.parseInt(event.key, 10)
|
|
|
|
|
|
|
|
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
|
|
|
|
|
|
|
|
const baseState = this.#activePuzzle.baseState
|
|
|
|
|
|
|
|
for (const [index] of this.#selectedCells) {
|
|
|
|
if (baseState[index] == null) {
|
|
|
|
newState[index] = cellValue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#activePuzzle.applyState(newState)
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let i = 1; i <= 9; i++) {
|
|
|
|
this.#keyProcessors.set(i.toString(), cellValueProcessor)
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#keyProcessors.set("d", () => {
|
|
|
|
const newState = Array.from({ length: SUDOKU_GRID_SIZE })
|
|
|
|
|
|
|
|
for (const [index] of this.#selectedCells) {
|
|
|
|
newState[index] = null
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#activePuzzle.applyState(newState)
|
|
|
|
})
|
|
|
|
|
|
|
|
this.#keyProcessors.set("a", () => {
|
|
|
|
const generator = () => {
|
|
|
|
const newState = this.#activePuzzle.fillRandomFieldInState()
|
|
|
|
|
|
|
|
if (newState.some((v) => v != null)) {
|
|
|
|
this.#activePuzzle.applyState(newState)
|
|
|
|
|
|
|
|
if (
|
|
|
|
this.#activePuzzle.grid.includes(null) ||
|
|
|
|
this.#activePuzzle.grid.includes(undefined)
|
|
|
|
) {
|
|
|
|
requestAnimationFrame(generator)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
requestAnimationFrame(generator)
|
|
|
|
})
|
|
|
|
|
|
|
|
this.#keyProcessors.set("u", () => {
|
|
|
|
this.#activePuzzle.popState()
|
|
|
|
})
|
|
|
|
|
|
|
|
this.#keyProcessors.set("r", () => {
|
|
|
|
this.#activePuzzle.applyState(this.#activePuzzle.fillRandomFieldInState())
|
|
|
|
})
|
|
|
|
|
|
|
|
this.#keyProcessors.set("m", () => {
|
2025-01-16 23:50:36 +00:00
|
|
|
for (const [, cell] of this.#selectedCells) {
|
2025-01-14 08:49:41 +00:00
|
|
|
cell.classList.toggle("marked")
|
|
|
|
cell.classList.remove("selected")
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#selectedCells.clear()
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
syncCellsToSudoku() {
|
|
|
|
const puzzleGrid = this.#activePuzzle.grid
|
|
|
|
const baseGrid = this.#activePuzzle.baseState
|
|
|
|
for (let i = 0; i < SUDOKU_GRID_SIZE; i++) {
|
|
|
|
this.#cellGrid[i].innerHTML = puzzleGrid[i] ?? ""
|
|
|
|
|
|
|
|
if (baseGrid[i] != null) {
|
|
|
|
this.#cellGrid[i].classList.add("initial")
|
|
|
|
} else if (puzzleGrid[i] != null) {
|
|
|
|
if (!this.#activePuzzle.isValueValidForSlot(puzzleGrid[i], i)) {
|
|
|
|
this.#cellGrid[i].classList.add("invalid")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#selectCell(i, cell) {
|
|
|
|
if (this.hasAttribute("readonly") || !this.isActive) return
|
|
|
|
this.#selectedCells.set(i, cell)
|
|
|
|
cell.classList.add("selected")
|
|
|
|
}
|
|
|
|
|
|
|
|
#deselectCell(i, cell) {
|
|
|
|
if (this.hasAttribute("readonly") || !this.isActive) return
|
|
|
|
this.#selectedCells.delete(i)
|
|
|
|
cell.classList.remove("selected")
|
|
|
|
}
|
|
|
|
|
|
|
|
#keyHandler(event) {
|
|
|
|
if (this.hasAttribute("readonly") || !this.isActive) return
|
|
|
|
const key = event.key
|
|
|
|
this.#keyProcessors.get(key)?.(event)
|
|
|
|
}
|
|
|
|
|
|
|
|
connectedCallback() {
|
|
|
|
this.#root = this.attachShadow({ mode: "open" })
|
|
|
|
|
|
|
|
this.#styling = document.createElement("link")
|
|
|
|
this.#styling.rel = "stylesheet"
|
|
|
|
this.#styling.href = "sudoku.rewrite.css"
|
|
|
|
this.#root.appendChild(this.#styling)
|
|
|
|
|
|
|
|
this.#container = document.createElement("div")
|
|
|
|
this.#container.classList.add("sudoku")
|
|
|
|
this.#root.appendChild(this.#container)
|
|
|
|
|
|
|
|
if (this.hasAttribute("size")) {
|
|
|
|
this.#container.style.fontSize = `${this.getAttribute("size")}px`
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#cellGrid = Array.from({ length: SUDOKU_GRID_SIZE }, (_, i) => {
|
|
|
|
const cell = document.createElement("div")
|
|
|
|
|
|
|
|
cell.classList.add("sudoku-field")
|
|
|
|
cell.innerHTML = " "
|
|
|
|
|
|
|
|
cell.addEventListener("click", () => {
|
|
|
|
this.#selectCell(i, cell)
|
|
|
|
})
|
|
|
|
|
|
|
|
cell.addEventListener("contextmenu", (event) => {
|
|
|
|
event.preventDefault()
|
|
|
|
this.#deselectCell(i, cell)
|
|
|
|
})
|
|
|
|
|
|
|
|
this.#container.appendChild(cell)
|
|
|
|
|
|
|
|
return cell
|
|
|
|
})
|
|
|
|
|
|
|
|
document.addEventListener("keydown", this.#keyHandler.bind(this))
|
|
|
|
|
|
|
|
this.#activePuzzle = new SudokuPuzzle(this.getAttribute("puzzle"))
|
|
|
|
this.syncCellsToSudoku()
|
|
|
|
this.#activePuzzle.addEventListener(
|
|
|
|
"state-changed",
|
|
|
|
this.syncCellsToSudoku.bind(this),
|
|
|
|
)
|
|
|
|
console.log(this.#activePuzzle)
|
|
|
|
}
|
|
|
|
|
|
|
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
|
|
if (name === "size" && this.#container) {
|
|
|
|
this.#container.style.fontSize = `${newValue}px`
|
|
|
|
}
|
|
|
|
if (name === "readonly") {
|
|
|
|
if (this.#container) {
|
|
|
|
this.#container.classList.toggle("readonly", newValue !== null)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (newValue) {
|
|
|
|
this.#selectedCells.clear()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
customElements.define("my-sudoku", SudokuHost)
|