refactor: flood-fill logic

This commit is contained in:
ayo 2026-07-02 21:54:09 +02:00
parent a1742e3a58
commit 76e95d20da
2 changed files with 80 additions and 33 deletions

View file

@ -741,7 +741,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
let row = Math.floor(Math.random() * setting.rows) let row = Math.floor(Math.random() * setting.rows)
let col = Math.floor(Math.random() * setting.cols) let col = Math.floor(Math.random() * setting.cols)
const transferMineToCell = grid.rows[row].cells[col] const transferMineToCell = grid.rows[row].cells[col]
if (isMine(transferMineToCell) || isNeighbor(cell, transferMineToCell)) { if (isMine(transferMineToCell) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) {
continue continue
} else { } else {
mines.add(mineKey(row, col)) mines.add(mineKey(row, col))
@ -792,20 +792,58 @@ const Minesweeper = function(appId, version, hooks = undefined) {
cell.appendChild(spanElement) cell.appendChild(spanElement)
} }
function handleEmpty(cell) { /**
updateCellValue(cell, ' ') * Reveal a single known-non-mine cell. Returns true when the cell is blank
let cellRow = cell.parentNode.rowIndex * (no adjacent mines), which is the signal to keep flooding from it.
let cellCol = cell.cellIndex * @param {HTMLTableCellElement} cell
setStatus(cell, 'empty') */
//Reveal all adjacent cells as they do not have a mine function revealSafeCell(cell) {
for (let y = Math.max(cellRow-1,0); y <= Math.min(cellRow+1, setting.rows - 1); y++) { // A cell can be re-opened via chording, so only count the first reveal
const rows = grid.rows[y] const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
if (!rows) continue cell.className = 'clicked'
for(let x = Math.max(cellCol-1,0); x <= Math.min(cellCol+1, setting.cols - 1); x++) { setStatus(cell, 'clicked')
//Recursive Call if (!wasOpen) revealedSafeCount++
const cell = rows.cells[x]
if (cell && !isOpen(cell)) { const mineCount = countMinesAround(cell)
clickCell(cell) if (mineCount == 0) {
updateCellValue(cell, ' ')
setStatus(cell, 'empty')
return true
}
updateCellValue(cell, mineCount.toString())
const dataValue = document.createAttribute('data-value')
dataValue.value = mineCount.toString()
cell.setAttributeNode(dataValue)
return false
}
/**
* Iteratively reveal the connected region of blank cells, starting from a
* cell that has already been revealed as blank. Uses an explicit queue with
* isOpen() as the visited guard instead of recursing back through clickCell,
* so there are no redundant per-cell checks and no deep call stack.
* @param {HTMLTableCellElement} startCell
*/
function handleEmpty(startCell) {
const queue = [startCell]
while (queue.length) {
const cell = queue.shift()
const cellRow = cell.parentNode.rowIndex
const cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
const rows = grid.rows[i]
if (!rows) continue
for (let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
const neighbor = rows.cells[j]
if (!neighbor || neighbor === cell) continue
if (isFlagged(neighbor)) {
setBusy()
continue
}
if (isOpen(neighbor)) continue
// blank cells have no adjacent mines, so every neighbor here is safe
if (revealSafeCell(neighbor)) queue.push(neighbor)
} }
} }
} }
@ -814,31 +852,19 @@ const Minesweeper = function(appId, version, hooks = undefined) {
function openCell(cell) { function openCell(cell) {
if (grid.getAttribute('game-status') != 'active') return if (grid.getAttribute('game-status') != 'active') return
// A cell can be re-opened via chording, so only count the first reveal
const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
cell.className='clicked'
setStatus(cell, 'clicked')
firstClick = false firstClick = false
if (isMine(cell)) { if (isMine(cell)) {
cell.className = 'clicked'
setStatus(cell, 'clicked')
revealMines() revealMines()
flagsDisplay.innerHTML = '&#128561;' flagsDisplay.innerHTML = '&#128561;'
grid.setAttribute('game-status', 'over') grid.setAttribute('game-status', 'over')
} else { return
if (!wasOpen) revealedSafeCount++
const mineCount = countMinesAround(cell)
if (mineCount==0) {
handleEmpty(cell)
} else {
updateCellValue(cell, mineCount.toString())
const dataValue = document.createAttribute('data-value')
dataValue.value = mineCount.toString()
cell.setAttributeNode(dataValue)
}
//Count and display the number of adjacent mines
checkLevelCompletion()
} }
if (revealSafeCell(cell)) handleEmpty(cell)
checkLevelCompletion()
} }
} }

View file

@ -155,4 +155,25 @@ describe('Minesweeper board', () => {
leftClick(grid.rows[0].cells[2]) leftClick(grid.rows[0].cells[2])
expect(grid.getAttribute('game-status')).toBe('done') expect(grid.getAttribute('game-status')).toBe('done')
}) })
it('flood fill stops at a flagged cell and never reveals it', () => {
// 1x4 board with the mine pinned to the last column.
vi.spyOn(Math, 'random').mockReturnValue(0.99)
const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' })
// Flag a safe cell sitting between the blank region and the mine.
// (full press + release so the internal right-button flag resets)
const flag = grid.rows[0].cells[2]
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
flag.dispatchEvent(new MouseEvent('mouseup', { button: 2, bubbles: true }))
expect(flag.getAttribute('data-status')).toBe('flagged')
// Click the far blank cell; the cascade must stop at the flag.
leftClick(grid.rows[0].cells[0])
expect(grid.rows[0].cells[0].getAttribute('data-status')).toBe('empty')
expect(flag.getAttribute('data-status')).toBe('flagged')
// A safe cell is still hidden (behind the flag), so it is not a win.
expect(grid.getAttribute('game-status')).toBe('active')
})
}) })