HTML+JS+CSS制作一个数独游戏

发布于:2025-07-10 ⋅ 阅读:(17) ⋅ 点赞:(0)

闲来无事,用HTML+JS+CSS制作了一个数独游戏消遣。

1、游戏的界面:

2、游戏的玩法:

3、游戏结束时弹出提示框

下面是由戏的全部代码。其中HTML负责UI构造,CSS负责UI的显示,JS包含了游戏的全部逻辑。

1、HTML

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>数独游戏</title>
  <link rel="stylesheet" href="sudoku_style.css" />
</head>
<body>
  <h1>数独游戏</h1>
  <div class="sudoku-container">
    <table id="sudoku-board"></table>
    <div class="buttons">
      <button id="solve-btn">新题</button>
      <button id="reset-btn">重置</button>
      <button id="answer-btn">答案</button>
    </div>
  </div>

  <script src="sudoku_script.js"></script>
</body>
</html>

2、CSS

body {
  font-family: Arial, sans-serif;
  text-align: center;
  background-color: #f4f4f4;
}

h1 {
  margin-top: 30px;
}

.sudoku-container {
  display: inline-block;
  margin-top: 20px;
  padding: 20px;
  background: beige;
  border-radius: 10px;
  box-shadow: 0 0 15px rgba(0,0,0,0.1);
}

#sudoku-board {
  border-collapse: collapse;
  margin: 0 auto;
}

#sudoku-board td {
  width: 40px;
  height: 40px;
  text-align: center;
  vertical-align: middle;
  border: 1px solid #999;
  font-size: 18px;
  cursor: pointer;
  transition: background 0.2s ease;
  position: relative;
  padding: 0;
}

.sudoku-mini-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  width: 100%;
  height: 100%;
}

.mini-cell {
  font-size: 11px;
  color: #222;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  user-select: none;
  cursor: pointer;
  transition: background 0.2s, color 0.2s;
}

.mini-cell.gray {
  color: #bbb;
  cursor: not-allowed;
}

.mini-cell.black {
  color: #222;
}

.mini-cell.yellow {
  color: #ff0;
}

.mini-cell:hover:not(.gray) {
  background: #e0e0e0;
}

.sudoku-cell-fixed {
  font-size: 24px;
  font-weight: bold;
  color: #1976d2;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

.sudoku-cell-user {
  font-size: 24px;
  font-weight: bold;
  color: #388e3c;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

.sudoku-mini-grid {
  width: 100%;
  height: 100%;
  pointer-events: auto;
}


#sudoku-board tr:nth-child(3n) td {
  border-bottom: 2px solid #000;
}

#sudoku-board td:nth-child(3n) {
  border-right: 2px solid #000;
}

#sudoku-board tr:first-child td {
  border-top: 2px solid #000;
}

#sudoku-board td:first-child {
  border-left: 2px solid #000;
}

#sudoku-board .preset {
  background-color: #e0e0e0;
  font-weight: bold;
}

#sudoku-board .user-input {
  background-color: #fff;
}

#sudoku-board .error {
  color: red;
}

.buttons {
  margin-top: 20px;
}

.buttons button {
  margin: 0 10px;
  padding: 10px 20px;
  font-size: 16px;
  cursor: pointer;
  background-color: #4CAF50;
  color: white;
  border: none;
  border-radius: 5px;
  transition: background 0.2s ease;
}

.buttons button:hover {
  background-color: #45a049;
}

.buttons button:active {
  background-color: #3e8e41;
}

.sudoku-cell-fixed.red, .sudoku-cell-user.red { 
  color: red; 
}

3、JS

const boardElement = document.getElementById("sudoku-board");
const solveBtn = document.getElementById("solve-btn");
const resetBtn = document.getElementById("reset-btn");
const answerBtn = document.getElementById("answer-btn");

let originalBoard = [
  [5, 3, null, null, 7, null, null, null, null],
  [6, null, null, 1, 9, 5, null, null, null],
  [null, 9, 8, null, null, null, null, 6, null],
  [8, null, null, null, 6, null, null, null, 3],
  [4, null, null, 8, null, 3, null, null, 1],
  [7, null, null, null, 2, null, null, null, 6],
  [null, 6, null, null, null, null, 2, 8, null],
  [null, null, null, 4, 1, 9, null, null, 5],
  [null, null, null, null, 8, null, null, 7, 9]
];

let currentBoard = JSON.parse(JSON.stringify(originalBoard));
// 保存被标红的格子,格式如:'row,col'
let redCells = new Set();
// 计时相关
let timerStart = null;
let timerUsed = 0;

function getCandidates(row, col) {
  if (currentBoard[row][col]) return [];
  let candidates = [];
  for (let num = 1; num <= 9; num++) {
    if (isValidCell(row, col, num)) candidates.push(num);
  }
  return candidates;
}

function drawBoard() {
  boardElement.innerHTML = "";
  for (let row = 0; row < 9; row++) {
    const tr = document.createElement("tr");
    for (let col = 0; col < 9; col++) {
      const td = document.createElement("td");
      td.dataset.row = row;
      td.dataset.col = col;
      // 已确定数字
      if (currentBoard[row][col]) {
        const isPreset = originalBoard[row][col];
        let cellClass = isPreset ? 'sudoku-cell-fixed' : 'sudoku-cell-user';
        if (redCells.has(row + ',' + col)) {
          cellClass += ' red';
        }
        td.innerHTML = `<div class="${cellClass}">${currentBoard[row][col]}</div>`;
        // 右键取消
        td.oncontextmenu = function(e) {
          e.preventDefault();
          if (!isPreset) {
            currentBoard[row][col] = null;
            redCells.delete(row + ',' + col);
            drawBoard();
          }
        };
        // 左键在红色与非红色之间切换
        td.onclick = function(e) {
          if (e.button === 0) {
            if (!redCells.has(row + ',' + col)) {
              redCells.add(row + ',' + col);
              drawBoard();
            } else {
              redCells.delete(row + ',' + col);
              drawBoard();
            }
          }
        };
      } else {
        // 未确定,渲染9小格
        const miniGrid = document.createElement('div');
        miniGrid.className = 'sudoku-mini-grid';
        for (let k = 1; k <= 9; k++) {
          const miniCell = document.createElement('div');
          miniCell.className = 'mini-cell';
          miniCell.textContent = k;
          // 判断是否可选
          if (isValidCell(row, col, k)) {
            miniCell.classList.add('black');
            // 中键点击确定该数字
            miniCell.onmousedown = function(e) {
              if (e.button === 1) { // 中键
                e.preventDefault();
                // 仅在首次确定未确定格时启动计时
                if (currentBoard[row][col] === null && timerStart === null) {
                  timerStart = Date.now();
                }
                currentBoard[row][col] = k;
                drawBoard();
              }
            };
          } else {
            miniCell.classList.add('gray');
          }
          miniGrid.appendChild(miniCell);
        }
        td.appendChild(miniGrid);
      }
      tr.appendChild(td);
    }
    boardElement.appendChild(tr);
  }
  // 检查是否全部填满
  let allFilled = true;
  for (let row = 0; row < 9; row++) {
    for (let col = 0; col < 9; col++) {
      if (!currentBoard[row][col]) allFilled = false;
    }
  }
  if (allFilled) {
    if (timerStart !== null) {
      timerUsed = Math.round((Date.now() - timerStart) / 1000);
      setTimeout(() => { alert(`恭喜您解决了本题,共计耗时${timerUsed}秒!`); timerStart = null; }, 100);
    }
  }
}

// 生成唯一解数独新题
solveBtn.addEventListener("click", async () => {
  // 生成唯一解数独
  let puzzle;
  do {
    puzzle = generateSudokuPuzzle();
  } while (!puzzle || countSolutions(puzzle) !== 1);
  originalBoard = puzzle;
  currentBoard = JSON.parse(JSON.stringify(originalBoard));
  redCells.clear();
  timerStart = null;
  drawBoard();
});

// 生成完整解
// 随机生成一个完整的数独解(9x9的填满且合法的盘面)
function generateFullSolution() {
  // 创建一个9x9的空棋盘,所有格子初始为null
  let board = Array.from({ length: 9 }, () => Array(9).fill(null));

  // 递归回溯填充函数,从左上角(0,0)开始
  function fill(row, col) {
    // 如果行号越界,说明已填满整盘,返回true
    if (row === 9) return true;

    // 计算下一个要填的格子的行列号
    let nextRow = col === 8 ? row + 1 : row;
    let nextCol = col === 8 ? 0 : col + 1;

    // 1~9随机顺序尝试,增加解的多样性
    let nums = [1,2,3,4,5,6,7,8,9].sort(() => Math.random() - 0.5);
    for (let num of nums) {
      // 判断num是否可以填入当前格(不违反数独规则)
      if (isValidForBoard(board, row, col, num)) {
        board[row][col] = num; // 填入数字
        // 递归填下一个格子,若成功则整盘可解
        if (fill(nextRow, nextCol)) return true;
        board[row][col] = null; // 回溯,撤销填入
      }
    }
    // 1~9都不行,说明此路不通,返回false
    return false;
  }

  // 从(0,0)开始填盘
  fill(0,0);
  return board; // 返回填好的完整解
}


// 随机挖空,生成题目
function generateSudokuPuzzle() {
  let solution = generateFullSolution();
  let puzzle = JSON.parse(JSON.stringify(solution));
  // 随机顺序挖空
  let cells = [];
  for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) cells.push([r,c]);
  cells = cells.sort(() => Math.random() - 0.5);
  for (let i = 0; i < 60; i++) { // 最多挖60个空
    let [r,c] = cells[i];
    let backup = puzzle[r][c];
    puzzle[r][c] = null;
    // 挖空后如果解不唯一,撤回
    if (countSolutions(puzzle) !== 1) puzzle[r][c] = backup;
  }
  return puzzle;
}

// 判断唯一解
function countSolutions(board) {
  let count = 0;
  let b = JSON.parse(JSON.stringify(board));
  function dfs() {
    for (let r = 0; r < 9; r++) {
      for (let c = 0; c < 9; c++) {
        if (b[r][c] === null) {
          for (let num = 1; num <= 9; num++) {
            if (isValidForBoard(b, r, c, num)) {
              b[r][c] = num;
              dfs();
              b[r][c] = null;
              if (count > 1) return;
            }
          }
          return;
        }
      }
    }
    count++;
  }
  dfs();
  return count;
}

function isValidForBoard(board, row, col, num) {
  for (let i = 0; i < 9; i++) {
    if (board[row][i] === num || board[i][col] === num) return false;
  }
  const boxRow = Math.floor(row / 3) * 3;
  const boxCol = Math.floor(col / 3) * 3;
  for (let r = 0; r < 3; r++) {
    for (let c = 0; c < 3; c++) {
      if (board[boxRow + r][boxCol + c] === num) return false;
    }
  }
  return true;
}


function markErrors() {
  clearErrorMarks();
  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      const cell = boardElement.rows[r].cells[c];
      if (!isValidCell(r, c, currentBoard[r][c])) {
        cell.classList.add("error");
      }
    }
  }
}

function isValidCell(row, col, num) {
  for (let i = 0; i < 9; i++) {
    if ((i !== col && currentBoard[row][i] === num) ||
        (i !== row && currentBoard[i][col] === num)) {
      return false;
    }
  }
  const boxRow = Math.floor(row / 3) * 3;
  const boxCol = Math.floor(col / 3) * 3;
  for (let r = 0; r < 3; r++) {
    for (let c = 0; c < 3; c++) {
      const x = boxRow + r;
      const y = boxCol + c;
      if (x !== row && y !== col && currentBoard[x][y] === num) {
        return false;
      }
    }
  }
  return true;
}

function clearErrorMarks() {
  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      const cell = boardElement.rows[r].cells[c];
      cell.classList.remove("error");
    }
  }
}

resetBtn.addEventListener("click", () => {
  currentBoard = JSON.parse(JSON.stringify(originalBoard));
  redCells.clear();
  drawBoard();
});

answerBtn.addEventListener("click", () => {
  let solution = solveSudoku(JSON.parse(JSON.stringify(originalBoard)));
  if (!solution) {
    return;
  }
  currentBoard = solution;
  redCells.clear();
  timerStart = null;
  drawBoard();
});

function updateBoardUI(board) {
  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      const cell = boardElement.rows[r].cells[c];
      cell.textContent = board[r][c];
    }
  }
}

// 求解器部分(回溯算法)
function solveSudoku(board) {
  function isValid(row, col, num) {
    for (let i = 0; i < 9; i++) {
      if (board[row][i] === num || board[i][col] === num) return false;
    }
    const boxRow = Math.floor(row / 3) * 3;
    const boxCol = Math.floor(col / 3) * 3;
    for (let r = 0; r < 3; r++) {
      for (let c = 0; c < 3; c++) {
        if (board[boxRow + r][boxCol + c] === num) return false;
      }
    }
    return true;
  }

  function backtrack() {
    for (let row = 0; row < 9; row++) {
      for (let col = 0; col < 9; col++) {
        if (board[row][col] === null) {
          for (let num = 1; num <= 9; num++) {
            if (isValid(row, col, num)) {
              board[row][col] = num;
              if (backtrack()) return true;
              board[row][col] = null;
            }
          }
          return false;
        }
      }
    }
    return true;
  }

  if (backtrack()) {
    return board;
  }
  return false;
}

function isValidSudoku(board) {
  const rows = Array.from({ length: 9 }, () => new Set());
  const cols = Array.from({ length: 9 }, () => new Set());
  const boxes = Array.from({ length: 9 }, () => new Set());

  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      const val = board[r][c];
      if (val === null) continue;
      const boxIndex = Math.floor(r / 3) * 3 + Math.floor(c / 3);
      if (rows[r].has(val) || cols[c].has(val) || boxes[boxIndex].has(val)) {
        return false;
      }
      rows[r].add(val);
      cols[c].add(val);
      boxes[boxIndex].add(val);
    }
  }
  return true;
}

drawBoard();

本文代码在CSDN的C知道生成的代码框架基础上改进和增加功能而成。

眼睛花,补充个辅助函数,将唯一可用的数字高亮显示。UI中加上按钮调用这个函数结合css就可以高亮单元格中唯一可用的数字:

/**
 * 指定(row, col)中如果只有唯一可用的数字,将其高亮
 * @param {number} row 行号(0-8)
 * @param {number} col 列号(0-8)
 * @returns {HTMLElement|null} 唯一的可用数字所在 mini-cell元素,否则null
 */
function getUniqNumberCell(row, col) {
  // 获取对应td
  const tr = boardElement.querySelectorAll('tr')[row];
  if (!tr) return null;
  const td = tr.querySelectorAll('td')[col];

  if (!td) return null;
  // 找到mini-grid
  const miniGrid = td.querySelector('.sudoku-mini-grid');

  if (!miniGrid) return null;
  // 找到所有可用数字所在mini-cell。参见drawBoard(),可用数字所在mini-cell类名为black
  const blackCells = miniGrid.querySelectorAll('.mini-cell.black');
  if (blackCells.length === 1) {
    return blackCells[0];
  }
  return null;
}


网站公告

今日签到

点亮在社区的每一天
去签到