Pygame 小游戏------数独
项目概述
本文通过 Pygame 实现一个功能完整的数独游戏(Sudoku)。
游戏随机生成合法数独题目,玩家通过鼠标点击选格、键盘输入数字来完成填写,支持三种难度等级,并提供实时错误高亮、关联格着色、计时、提示等功能。填满所有格子且无误即为通关。其中:
- 难度分级:简单(挖空 36 格)/ 中等(挖空 46 格)/ 困难(挖空 54 格),点击顶部按钮切换,立即生成新题。
- 选格交互:鼠标点击选中格子,选中格高亮蓝色;同行、同列、同宫格自动浅蓝着色;与选中格数字相同的其他格子也会同步高亮,方便排除法填写。
- 错误提示:输入与正确答案不符时,数字即时变红,帮助玩家识别冲突。
- 提示功能:点击"提示"按钮,自动填入当前选中格的正确答案。
- 计时器:游戏启动即开始计时,完成时停止并在胜利弹窗中展示用时。
- 键盘操作:数字键 1--9 输入;Delete / Backspace / 0 清除;方向键在格子间移动;R(通过新游戏按钮)重新开始。
- 胜利判定:所有格填满且全部正确时触发胜利遮罩,展示完成提示与用时。
游戏实现

初始化与基础设置
游戏启动时初始化 Pygame 并定义窗口尺寸、网格参数和颜色常量。
python
pygame.init()
W, H = 540, 680
CELL = 56
GRID_X, GRID_Y = 27, 100
FPS = 60
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("数独")
clock = pygame.time.Clock()
游戏窗口为 540×680 像素,上方 100 像素留给标题与功能按钮,下方 9×9 的棋盘每格 56×56 像素,底部留出计时与说明文字区域。
颜色定义
python
C_BG = (248, 248, 252) # 整体浅灰背景
C_GRID_BG = (255, 255, 255) # 棋盘白色底
C_LINE_THIN = (200, 200, 215) # 细网格线
C_LINE_FAT = (80, 80, 110) # 粗宫格线
C_SELECT = (200, 220, 255) # 选中格高亮蓝
C_SAME_NUM = (225, 235, 255) # 同行/列/宫高亮
C_GIVEN = (40, 40, 80) # 题目预设数字(深蓝近黑)
C_INPUT = (60, 100, 200) # 玩家输入正确数字(蓝色)
C_ERROR = (220, 50, 50) # 输入错误数字(红色)
C_WIN = (60, 180, 100) # 胜利弹窗绿色
颜色体系以冷色调为主,区分"题目给定"与"玩家输入"的视觉层次,错误提示以红色醒目标出,不干扰正常状态的阅读感。
字体加载
python
CHINESE_FONT_PATH = r"C:/Windows/Fonts/simsun.ttc"
FONT_NUM = pygame.font.Font(CHINESE_FONT_PATH, 32) # 格内数字
FONT_SM = pygame.font.Font(CHINESE_FONT_PATH, 17) # 小说明文字
FONT_BTN = pygame.font.Font(CHINESE_FONT_PATH, 16) # 按钮文字
FONT_TL = pygame.font.Font(CHINESE_FONT_PATH, 28) # 标题
数独生成算法
数独生成分两步:先用回溯算法生成一个完整合法解,再随机挖空指定数量的格子作为题目。
合法性验证 is_valid
python
def is_valid(board, row, col, num):
if num in board[row]: return False
if num in [board[r][col] for r in range(9)]: return False
br, bc = (row // 3) * 3, (col // 3) * 3
for r in range(br, br + 3):
for c in range(bc, bc + 3):
if board[r][c] == num: return False
return True
依次检查同行、同列、同宫格三个约束,任一冲突立即返回 False,保证填入的每个数字合法。
回溯求解 solve
python
def solve(board):
for r in range(9):
for c in range(9):
if board[r][c] == 0:
nums = list(range(1, 10))
random.shuffle(nums)
for n in nums:
if is_valid(board, r, c, n):
board[r][c] = n
if solve(board): return True
board[r][c] = 0
return False
return True
标准回溯:逐格扫描,遇到空格则随机打乱 1--9 的顺序后逐一尝试;若某格所有候选数均无法成立则回退。对候选数 random.shuffle 是关键------正是这一步保证每次生成的完整解都不同,从而产生随机题目。
题目生成 generate_puzzle
python
def generate_puzzle(remove_count):
board = [[0] * 9 for _ in range(9)]
solve(board)
solution = copy.deepcopy(board)
cells = [(r, c) for r in range(9) for c in range(9)]
random.shuffle(cells)
for r, c in cells[:remove_count]:
board[r][c] = 0
return board, solution
生成完整解后保留一份深拷贝作为 solution(用于错误校验和提示功能),再随机选取 remove_count 个格子置零,形成玩家看到的题目。solution 贯穿整个游戏生命周期,是所有校验逻辑的基准。
核心交互逻辑
状态变量
python
board, solution, given = None, None, None
selected = None
errors = [[False] * 9 for _ in range(9)]
won = False
start_time = 0
elapsed = 0
given 是与 board 等大的布尔矩阵,记录哪些格子是题目预设(不可修改)。errors 同样是布尔矩阵,驱动红色字体的显示。selected 存储当前选中格的 (row, col),为 None 时表示无选中状态。
新游戏 new_game
python
def new_game():
remove = DIFFICULTIES[diff_keys[current_diff]]
board, solution = generate_puzzle(remove)
given = [[board[r][c] != 0 for c in range(9)] for r in range(9)]
selected = None
errors = [[False] * 9 for _ in range(9)]
won = False
start_time = time.time()
elapsed = 0
切换难度或点击"新游戏"时调用,重置所有状态并重新生成题目,计时器同步清零。
错误校验 check_errors
python
def check_errors():
all_filled = True
for r in range(9):
for c in range(9):
if board[r][c] == 0:
all_filled = False
errors[r][c] = False
else:
errors[r][c] = (board[r][c] != solution[r][c])
if all_filled and not any(errors[r][c] for r in range(9) for c in range(9)):
won = True
每次玩家输入数字后立即调用。逐格比对 board 与 solution,空格不标记错误;若全格填满且无误,则将 won 置为 True,触发胜利流程。
键盘输入处理
python
if event.type == pygame.KEYDOWN and selected and not won:
r, c = selected
if not given[r][c]:
if event.key in range(pygame.K_1, pygame.K_9 + 1):
board[r][c] = event.key - pygame.K_0
check_errors()
elif event.key in (pygame.K_0, pygame.K_DELETE, pygame.K_BACKSPACE):
board[r][c] = 0
errors[r][c] = False
# 方向键移动选中格
if event.key == pygame.K_UP and r > 0: selected = (r - 1, c)
if event.key == pygame.K_DOWN and r < 8: selected = (r + 1, c)
if event.key == pygame.K_LEFT and c > 0: selected = (r, c - 1)
if event.key == pygame.K_RIGHT and c < 8: selected = (r, c + 1)
given[r][c] 保护预设格不被修改。数字键通过 event.key - pygame.K_0 直接转换为整数值写入棋盘;Delete / Backspace / 0 均执行清除操作,同时重置该格的错误状态。方向键移动选中格,使键盘党无需触碰鼠标也能流畅操作。
绘制方法
关联格高亮
python
if selected:
sr, sc = selected
sv = board[sr][sc]
for r in range(9):
for c in range(9):
rx = GRID_X + c * CELL
ry = GRID_Y + r * CELL
if r == sr and c == sc:
pygame.draw.rect(screen, C_SELECT, (rx, ry, CELL, CELL))
elif r == sr or c == sc or (r // 3 == sr // 3 and c // 3 == sc // 3):
pygame.draw.rect(screen, C_SAME_NUM, (rx, ry, CELL, CELL))
elif sv and board[r][c] == sv:
pygame.draw.rect(screen, (210, 230, 210), (rx, ry, CELL, CELL))
高亮分三层:选中格自身(深蓝)→ 同行/列/宫(浅蓝)→ 相同数字(浅绿)。三者互不覆盖,优先级由 if/elif 保证。这是数独 UI 的核心辅助功能,大幅降低玩家的视觉搜索负担。
数字渲染
python
for r in range(9):
for c in range(9):
v = board[r][c]
if v:
color = C_GIVEN if given[r][c] else (C_ERROR if errors[r][c] else C_INPUT)
t = FONT_NUM.render(str(v), True, color)
cx = GRID_X + c * CELL + CELL // 2
cy = GRID_Y + r * CELL + CELL // 2
screen.blit(t, t.get_rect(center=(cx, cy)))
三色区分:深蓝近黑(题目预设)→ 蓝色(玩家正确输入)→ 红色(输入有误)。t.get_rect(center=...) 确保数字精确居中于格子内,无论字形宽窄都不偏移。
网格线绘制
python
for i in range(10):
lw = 3 if i % 3 == 0 else 1
lc = C_LINE_FAT if i % 3 == 0 else C_LINE_THIN
pygame.draw.line(screen, lc, (GRID_X + i * CELL, GRID_Y),
(GRID_X + i * CELL, GRID_Y + 9 * CELL), lw)
pygame.draw.line(screen, lc, (GRID_X, GRID_Y + i * CELL),
(GRID_X + 9 * CELL, GRID_Y + i * CELL), lw)
i % 3 == 0 的线条加粗(3px)并加深颜色,将 9×9 棋盘自然分割为 9 个 3×3 宫格,与标准数独印刷风格一致。
计时显示
python
mins = int(elapsed) // 60
secs = int(elapsed) % 60
t_str = f"⏱ {mins:02d}:{secs:02d}"
tt = FONT_SM.render(t_str, True, C_TIME)
screen.blit(tt, tt.get_rect(centerx=W // 2, y=GRID_Y + 9 * CELL + 12))
elapsed 在每帧由 time.time() - start_time 更新,胜利后停止更新,定格记录完成用时。:02d 格式化保证分秒始终两位显示。
胜利弹窗
python
if won:
ov = pygame.Surface((W, H), pygame.SRCALPHA)
ov.fill((0, 0, 0, 100))
screen.blit(ov, (0, 0))
box = pygame.Rect(W // 2 - 160, H // 2 - 70, 320, 160)
pygame.draw.rect(screen, (240, 255, 240), box, border_radius=16)
pygame.draw.rect(screen, C_WIN, box, 3, border_radius=16)
wt = FONT_TL.render("🎉 完成!", True, C_WIN)
screen.blit(wt, wt.get_rect(centerx=W // 2, centery=H // 2 - 20))
mt = FONT_SM.render(f"用时 {mins:02d}:{secs:02d}", True, C_TITLE)
screen.blit(mt, mt.get_rect(centerx=W // 2, centery=H // 2 + 20))
胜利时先绘制半透明黑色全屏遮罩(alpha=100),再在中央绘制带圆角、绿色描边的弹窗,展示完成文字与用时。pygame.SRCALPHA Surface 配合 fill 中的第四个 alpha 分量,是 Pygame 中实现半透明遮罩的标准做法。
绘制顺序为:背景 → 按钮 → 高亮格 → 数字 → 网格线 → 计时 → 胜利遮罩,严格保证层次正确。
主循环 main:
python
while True:
mx, my = pygame.mouse.get_pos()
if not won:
elapsed = time.time() - start_time
# 事件处理 ...
# 绘制 ...
pygame.display.flip()
clock.tick(FPS)
以固定 60 FPS 驱动主循环,保证界面响应流畅。数独无需像贪吃蛇那样用帧率控制游戏节奏,因此全程维持恒定帧率即可。
全部代码
python
"""
数独 --- 随机生成 + 错误提示 + 难度分级
操作:鼠标点击选格,键盘数字输入,Delete/0清除
难度:Easy(36空) / Medium(46空) / Hard(54空)
"""
import pygame
import sys
import random
import copy
import time
pygame.init()
W, H = 540, 680
CELL = 56
GRID_X, GRID_Y = 27, 100
FPS = 60
C_BG = (248, 248, 252)
C_GRID_BG = (255, 255, 255)
C_LINE_THIN = (200, 200, 215)
C_LINE_FAT = (80, 80, 110)
C_SELECT = (200, 220, 255)
C_SAME_NUM = (225, 235, 255)
C_GIVEN = (40, 40, 80)
C_INPUT = (60, 100, 200)
C_ERROR = (220, 50, 50)
C_BTN = (70, 90, 160)
C_BTN_TXT = (255, 255, 255)
C_TITLE = (40, 40, 80)
C_TIME = (100, 100, 140)
C_WIN = (60, 180, 100)
CHINESE_FONT_PATH = r"C:/Windows/Fonts/simsun.ttc"
FONT_NUM = pygame.font.Font(CHINESE_FONT_PATH, 32)
FONT_SM = pygame.font.Font(CHINESE_FONT_PATH, 17)
FONT_BTN = pygame.font.Font(CHINESE_FONT_PATH, 16)
FONT_TL = pygame.font.Font(CHINESE_FONT_PATH, 28)
DIFFICULTIES = {"简单": 36, "中等": 46, "困难": 54}
# ── 数独生成 ──────────────────────────────────────────────────────────
def is_valid(board, row, col, num):
if num in board[row]: return False
if num in [board[r][col] for r in range(9)]: return False
br, bc = (row//3)*3, (col//3)*3
for r in range(br, br+3):
for c in range(bc, bc+3):
if board[r][c] == num: return False
return True
def solve(board):
for r in range(9):
for c in range(9):
if board[r][c] == 0:
nums = list(range(1,10))
random.shuffle(nums)
for n in nums:
if is_valid(board, r, c, n):
board[r][c] = n
if solve(board): return True
board[r][c] = 0
return False
return True
def generate_puzzle(remove_count):
board = [[0]*9 for _ in range(9)]
solve(board)
solution = copy.deepcopy(board)
cells = [(r,c) for r in range(9) for c in range(9)]
random.shuffle(cells)
for r,c in cells[:remove_count]:
board[r][c] = 0
return board, solution
# ── 主循环 ────────────────────────────────────────────────────────────
def main():
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("数独")
clock = pygame.time.Clock()
diff_keys = list(DIFFICULTIES.keys())
current_diff = 1 # 中等
board, solution, given = None, None, None
selected = None
errors = [[False]*9 for _ in range(9)]
won = False
start_time = 0
elapsed = 0
def new_game():
nonlocal board, solution, given, selected, errors, won, start_time, elapsed
remove = DIFFICULTIES[diff_keys[current_diff]]
board, solution = generate_puzzle(remove)
given = [[board[r][c] != 0 for c in range(9)] for r in range(9)]
selected = None
errors = [[False]*9 for _ in range(9)]
won = False
start_time = time.time()
elapsed = 0
new_game()
def check_errors():
nonlocal errors, won
all_filled = True
for r in range(9):
for c in range(9):
if board[r][c] == 0:
all_filled = False
errors[r][c] = False
else:
errors[r][c] = (board[r][c] != solution[r][c])
if all_filled and not any(errors[r][c] for r in range(9) for c in range(9)):
won = True
def cell_at(mx, my):
gx = mx - GRID_X
gy = my - GRID_Y
if 0 <= gx < 9*CELL and 0 <= gy < 9*CELL:
return gy // CELL, gx // CELL
return None
while True:
mx, my = pygame.mouse.get_pos()
if not won:
elapsed = time.time() - start_time
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit(); sys.exit()
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
# 难度按钮
for i, dk in enumerate(diff_keys):
bx = 27 + i * 100
br = pygame.Rect(bx, 58, 90, 32)
if br.collidepoint(mx, my):
nonlocal_ci = i
current_diff_ref = [current_diff]
current_diff_ref[0] = i
# 用闭包技巧
globals()['_cd'] = i
break
# 新游戏按钮
ng_btn = pygame.Rect(360, 58, 80, 32)
if ng_btn.collidepoint(mx, my):
new_game()
# 提示按钮
hint_btn = pygame.Rect(445, 58, 70, 32)
if hint_btn.collidepoint(mx, my) and selected and not given[selected[0]][selected[1]]:
r, c = selected
board[r][c] = solution[r][c]
errors[r][c] = False
check_errors()
# 选格
cell = cell_at(mx, my)
if cell:
selected = cell
if event.type == pygame.KEYDOWN and selected and not won:
r, c = selected
if not given[r][c]:
if event.key in range(pygame.K_1, pygame.K_9+1):
board[r][c] = event.key - pygame.K_0
check_errors()
elif event.key in (pygame.K_0, pygame.K_DELETE, pygame.K_BACKSPACE):
board[r][c] = 0
errors[r][c] = False
# 方向键移动
if event.key == pygame.K_UP and r > 0: selected = (r-1, c)
if event.key == pygame.K_DOWN and r < 8: selected = (r+1, c)
if event.key == pygame.K_LEFT and c > 0: selected = (r, c-1)
if event.key == pygame.K_RIGHT and c < 8: selected = (r, c+1)
# 难度切换(外部变量技巧)
if '_cd' in globals():
current_diff = globals()['_cd']
del globals()['_cd']
new_game()
# ── 绘制 ──────────────────────────────────────────────────────
screen.fill(C_BG)
title = FONT_TL.render("数 独", True, C_TITLE)
screen.blit(title, title.get_rect(centerx=W//2, y=14))
# 难度按钮
for i, dk in enumerate(diff_keys):
bx = 27 + i * 100
br = pygame.Rect(bx, 58, 90, 32)
col = (40, 60, 130) if i == current_diff else C_BTN
pygame.draw.rect(screen, col, br, border_radius=8)
t = FONT_BTN.render(dk, True, C_BTN_TXT)
screen.blit(t, t.get_rect(center=br.center))
# 新游戏
ng_btn = pygame.Rect(360, 58, 80, 32)
pygame.draw.rect(screen, (100, 160, 80), ng_btn, border_radius=8)
screen.blit(FONT_BTN.render("新游戏", True, C_BTN_TXT),
FONT_BTN.render("新游戏", True, C_BTN_TXT).get_rect(center=ng_btn.center))
# 提示
hint_btn = pygame.Rect(445, 58, 70, 32)
pygame.draw.rect(screen, (180, 120, 40), hint_btn, border_radius=8)
screen.blit(FONT_BTN.render("提示", True, C_BTN_TXT),
FONT_BTN.render("提示", True, C_BTN_TXT).get_rect(center=hint_btn.center))
# 网格背景
grid_rect = pygame.Rect(GRID_X, GRID_Y, 9*CELL, 9*CELL)
pygame.draw.rect(screen, C_GRID_BG, grid_rect)
# 高亮选中格周围同数字
if selected:
sr, sc = selected
sv = board[sr][sc]
for r in range(9):
for c in range(9):
rx = GRID_X + c*CELL
ry = GRID_Y + r*CELL
if r == sr and c == sc:
pygame.draw.rect(screen, C_SELECT, (rx,ry,CELL,CELL))
elif r == sr or c == sc or (r//3==sr//3 and c//3==sc//3):
pygame.draw.rect(screen, C_SAME_NUM, (rx,ry,CELL,CELL))
elif sv and board[r][c] == sv:
pygame.draw.rect(screen, (210,230,210), (rx,ry,CELL,CELL))
# 数字
for r in range(9):
for c in range(9):
v = board[r][c]
if v:
color = C_GIVEN if given[r][c] else (C_ERROR if errors[r][c] else C_INPUT)
t = FONT_NUM.render(str(v), True, color)
cx = GRID_X + c*CELL + CELL//2
cy = GRID_Y + r*CELL + CELL//2
screen.blit(t, t.get_rect(center=(cx,cy)))
# 网格线
for i in range(10):
lw = 3 if i % 3 == 0 else 1
lc = C_LINE_FAT if i % 3 == 0 else C_LINE_THIN
pygame.draw.line(screen, lc, (GRID_X+i*CELL, GRID_Y), (GRID_X+i*CELL, GRID_Y+9*CELL), lw)
pygame.draw.line(screen, lc, (GRID_X, GRID_Y+i*CELL), (GRID_X+9*CELL, GRID_Y+i*CELL), lw)
# 计时
mins = int(elapsed) // 60
secs = int(elapsed) % 60
t_str = f"⏱ {mins:02d}:{secs:02d}"
tt = FONT_SM.render(t_str, True, C_TIME)
screen.blit(tt, tt.get_rect(centerx=W//2, y=GRID_Y + 9*CELL + 12))
# 提示文字
ht = FONT_SM.render("点击格子 → 数字键输入 | 方向键移动 | Del 清除", True, C_TIME)
screen.blit(ht, ht.get_rect(centerx=W//2, y=GRID_Y + 9*CELL + 36))
# 胜利
if won:
ov = pygame.Surface((W, H), pygame.SRCALPHA)
ov.fill((0,0,0,100))
screen.blit(ov, (0,0))
box = pygame.Rect(W//2-160, H//2-70, 320, 160)
pygame.draw.rect(screen, (240,255,240), box, border_radius=16)
pygame.draw.rect(screen, C_WIN, box, 3, border_radius=16)
wt = FONT_TL.render("🎉 完成!", True, C_WIN)
screen.blit(wt, wt.get_rect(centerx=W//2, centery=H//2-20))
mt = FONT_SM.render(f"用时 {mins:02d}:{secs:02d}", True, C_TITLE)
screen.blit(mt, mt.get_rect(centerx=W//2, centery=H//2+20))
pygame.display.flip()
clock.tick(FPS)
if __name__ == "__main__":
main()
附:文章说明
本文仅为个人理解,若有不当之处,欢迎指正~