Python推箱子游戏开发
推箱子(Sokoban)是一款经典的益智游戏,玩家需要将所有箱子推到指定位置。本文将使用Python和Pygame实现一个完整的推箱子游戏,包含地图设计、移动逻辑、碰撞检测、关卡系统、撤销功能和胜利判定。
一、推箱子游戏规则
推箱子的规则很简单:玩家在网格地图上移动,可以推动相邻的箱子(但不能拉),目标是将所有箱子推到目标点上。需要注意:箱子只能推不能拉,箱子不能穿过墙壁或其他箱子,玩家也不能穿过墙壁。
二、地图设计(二维数组表示)
我们使用字符来表示地图元素,便于设计和阅读。每个关卡就是一个字符串列表。
# 地图元素符号
WALL = '#' # 墙壁
FLOOR = ' ' # 空地
TARGET = '.' # 目标点
BOX = '$' # 箱子(不在目标点上)
BOX_ON_TARGET = '*' # 箱子(在目标点上)
PLAYER = '@' # 玩家(不在目标点上)
PLAYER_ON_TARGET = '+' # 玩家(在目标点上)
# 关卡数据格式设计
# 每个关卡是一个字符串列表,每行代表地图的一行
LEVELS = [
# 关卡1:入门
[
" ##### ",
"### # ",
"#.@$ # ",
"### $.# ",
"#.##$ # ",
"# # . ###",
"#$ *$$.# ",
"# . # ",
"######## "
],
# 关卡2:简单
[
" #### ",
"### ###",
"# $ #",
"# # ## #",
"# $.. #",
"# @ $ #",
"### ###",
" #### "
],
# 关卡3:中等
[
"########",
"# . #",
"# $$$ #",
"# @ #",
"# ... #",
"########"
],
]
class Level:
"""关卡数据类"""
def __init__(self, level_data):
self.raw_data = level_data
self.height = len(level_data)
self.width = max(len(row) for row in level_data)
self.walls = set()
self.targets = set()
self.boxes = set()
self.player_pos = None
self.parse()
def parse(self):
"""解析关卡数据"""
for row, line in enumerate(self.raw_data):
for col, char in enumerate(line):
pos = (row, col)
if char == WALL:
self.walls.add(pos)
elif char == TARGET:
self.targets.add(pos)
elif char == BOX:
self.boxes.add(pos)
elif char == BOX_ON_TARGET:
self.boxes.add(pos)
self.targets.add(pos)
elif char == PLAYER:
self.player_pos = pos
elif char == PLAYER_ON_TARGET:
self.player_pos = pos
self.targets.add(pos)
def get_initial_state(self):
"""获取关卡初始状态(用于重置)"""
return {
'player': self.player_pos,
'boxes': set(self.boxes),
'walls': set(self.walls),
'targets': set(self.targets),
}
三、游戏核心逻辑:移动与推动
玩家移动时需要判断:前方是空地可以直接走;前方是箱子则需要检查箱子后面是否可推;前方是墙则不能移动。
class SokobanGame:
"""推箱子游戏核心逻辑"""
# 方向定义:上、下、左、右
DIRECTIONS = {
'up': (-1, 0),
'down': (1, 0),
'left': (0, -1),
'right': (0, 1),
}
def __init__(self, level_data):
self.level = Level(level_data)
self.reset()
self.move_count = 0
self.push_count = 0
self.history = [] # 用于撤销
self.won = False
def reset(self):
"""重置到关卡初始状态"""
state = self.level.get_initial_state()
self.player_pos = state['player']
self.boxes = set(state['boxes'])
self.walls = state['walls']
self.targets = state['targets']
self.move_count = 0
self.push_count = 0
self.history = []
self.won = False
def is_wall(self, pos):
"""检查位置是否是墙"""
return pos in self.walls
def is_box(self, pos):
"""检查位置是否有箱子"""
return pos in self.boxes
def is_target(self, pos):
"""检查位置是否是目标点"""
return pos in self.targets
def move(self, direction):
"""尝试移动玩家
返回: True 如果移动成功, False 如果无法移动
"""
if self.won:
return False
dr, dc = self.DIRECTIONS[direction]
new_pos = (self.player_pos[0] + dr, self.player_pos[1] + dc)
# 检查:撞墙
if self.is_wall(new_pos):
return False
# 检查:推动箱子
pushed = False
if self.is_box(new_pos):
box_new_pos = (new_pos[0] + dr, new_pos[1] + dc)
# 箱子后面必须是空地且不是墙、不是其他箱子
if self.is_wall(box_new_pos) or self.is_box(box_new_pos):
return False
# 可以推动
pushed = True
# 记录历史(用于撤销)
self.history.append({
'player': self.player_pos,
'boxes': set(self.boxes),
'pushed': pushed,
})
# 执行移动
if pushed:
self.boxes.remove(new_pos)
self.boxes.add(box_new_pos)
self.push_count += 1
self.player_pos = new_pos
self.move_count += 1
# 检查胜利
self.check_win()
return True
def check_win(self):
"""检查是否胜利(所有箱子都在目标点上)"""
self.won = self.boxes == self.targets
return self.won
def undo(self):
"""撤销上一步"""
if not self.history:
return False
last = self.history.pop()
self.player_pos = last['player']
self.boxes = last['boxes']
self.move_count -= 1
if last['pushed']:
self.push_count -= 1
self.won = False
return True
def get_state(self):
"""获取当前游戏状态"""
return {
'player': self.player_pos,
'boxes': set(self.boxes),
'walls': set(self.walls),
'targets': set(self.targets),
'move_count': self.move_count,
'push_count': self.push_count,
'won': self.won,
}
四、碰撞检测详解
推箱子中的"碰撞"主要体现在移动判定中。以下是更详细的碰撞检测逻辑:
class SokobanGame:
# ... 前面的代码 ...
def can_move(self, direction):
"""检查是否可以向某个方向移动(不实际移动)"""
dr, dc = self.DIRECTIONS[direction]
new_pos = (self.player_pos[0] + dr, self.player_pos[1] + dc)
# 边界检查(虽然没有显式边界,但墙充当边界)
if self.is_wall(new_pos):
return False, "撞墙了"
if self.is_box(new_pos):
box_new_pos = (new_pos[0] + dr, new_pos[1] + dc)
if self.is_wall(box_new_pos):
return False, "箱子后面是墙"
if self.is_box(box_new_pos):
return False, "箱子后面是另一个箱子"
return True, "可以推箱子"
return True, "可以移动"
def get_box_state(self, pos):
"""获取箱子状态:在目标点上 / 不在目标点上"""
if pos in self.boxes:
if pos in self.targets:
return 'on_target' # 箱子在目标点
return 'normal' # 箱子不在目标点
return None # 没有箱子
def get_progress(self):
"""获取关卡完成进度"""
total = len(self.targets)
completed = len(self.boxes & self.targets)
return completed, total
def is_deadlock(self, box_pos):
"""简单死锁检测:箱子被推到角落且不在目标点"""
if box_pos in self.targets:
return False
row, col = box_pos
# 检查四个角落情况
# 角落 = 两个相邻方向都是墙
corner_checks = [
((-1, 0), (0, -1)), # 左上
((-1, 0), (0, 1)), # 右上
((1, 0), (0, -1)), # 左下
((1, 0), (0, 1)), # 右下
]
for (dr1, dc1), (dr2, dc2) in corner_checks:
pos1 = (row + dr1, col + dc1)
pos2 = (row + dr2, col + dc2)
if self.is_wall(pos1) and self.is_wall(pos2):
return True # 死锁
return False
五、关卡系统设计
关卡系统负责管理多个关卡,支持关卡切换、进度记录等功能。
class LevelManager:
"""关卡管理器"""
def __init__(self, levels):
self.levels = levels
self.current_level = 0
self.completed_levels = set() # 已通关的关卡
self.best_scores = {} # 最佳成绩 {level_index: (moves, pushes)}
def get_current_level_data(self):
"""获取当前关卡数据"""
return self.levels[self.current_level]
def next_level(self):
"""下一关"""
if self.current_level < len(self.levels) - 1:
self.current_level += 1
return True
return False
def prev_level(self):
"""上一关"""
if self.current_level > 0:
self.current_level -= 1
return True
return False
def goto_level(self, index):
"""跳转到指定关卡"""
if 0 <= index < len(self.levels):
self.current_level = index
return True
return False
def complete_level(self, moves, pushes):
"""完成关卡"""
self.completed_levels.add(self.current_level)
# 记录最佳成绩
if self.current_level not in self.best_scores:
self.best_scores[self.current_level] = (moves, pushes)
else:
old_moves, old_pushes = self.best_scores[self.current_level]
if moves < old_moves or (moves == old_moves and pushes < old_pushes):
self.best_scores[self.current_level] = (moves, pushes)
def get_progress_info(self):
"""获取进度信息"""
return {
'current': self.current_level + 1,
'total': len(self.levels),
'completed': len(self.completed_levels),
'best_score': self.best_scores.get(self.current_level),
}
def is_last_level(self):
"""是否是最后一关"""
return self.current_level == len(self.levels) - 1
def save_progress(self, filename="sokoban_save.json"):
"""保存进度到文件"""
import json
data = {
'current_level': self.current_level,
'completed_levels': list(self.completed_levels),
'best_scores': {str(k): list(v) for k, v in self.best_scores.items()},
}
with open(filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_progress(self, filename="sokoban_save.json"):
"""从文件加载进度"""
import json
import os
if not os.path.exists(filename):
return False
with open(filename, 'r', encoding='utf-8') as f:
data = json.load(f)
self.current_level = data['current_level']
self.completed_levels = set(data['completed_levels'])
self.best_scores = {int(k): tuple(v) for k, v in data['best_scores'].items()}
return True
六、撤销功能实现
撤销功能通过记录每一步的状态历史来实现。为了支持"撤销"和"重做",可以维护两个栈。
class UndoRedoManager:
"""撤销/重做管理器"""
def __init__(self, max_history=1000):
self.undo_stack = []
self.redo_stack = []
self.max_history = max_history
def record(self, state):
"""记录一个状态"""
self.undo_stack.append(state)
if len(self.undo_stack) > self.max_history:
self.undo_stack.pop(0) # 超出限制,删除最早的
self.redo_stack.clear() # 新操作后清空重做栈
def undo(self):
"""撤销"""
if not self.undo_stack:
return None
state = self.undo_stack.pop()
self.redo_stack.append(state)
return state
def redo(self):
"""重做"""
if not self.redo_stack:
return None
state = self.redo_stack.pop()
self.undo_stack.append(state)
return state
def can_undo(self):
return len(self.undo_stack) > 0
def can_redo(self):
return len(self.redo_stack) > 0
def clear(self):
"""清空历史"""
self.undo_stack.clear()
self.redo_stack.clear()
# 集成到游戏类中
class SokobanGame:
def __init__(self, level_data):
# ... 其他初始化 ...
self.undo_manager = UndoRedoManager()
def move(self, direction):
"""移动(集成撤销功能)"""
if self.won:
return False
# 记录当前状态(移动前)
current_state = {
'player': self.player_pos,
'boxes': set(self.boxes),
'move_count': self.move_count,
'push_count': self.push_count,
}
# 尝试移动
success = self._do_move(direction)
if success:
self.undo_manager.record(current_state)
return success
def _do_move(self, direction):
"""实际执行移动"""
dr, dc = self.DIRECTIONS[direction]
new_pos = (self.player_pos[0] + dr, self.player_pos[1] + dc)
if self.is_wall(new_pos):
return False
pushed = False
if self.is_box(new_pos):
box_new_pos = (new_pos[0] + dr, new_pos[1] + dc)
if self.is_wall(box_new_pos) or self.is_box(box_new_pos):
return False
pushed = True
if pushed:
self.boxes.remove(new_pos)
self.boxes.add(box_new_pos)
self.push_count += 1
self.player_pos = new_pos
self.move_count += 1
self.check_win()
return True
def undo(self):
"""撤销"""
state = self.undo_manager.undo()
if state:
self.player_pos = state['player']
self.boxes = state['boxes']
self.move_count = state['move_count']
self.push_count = state['push_count']
self.won = False
return True
return False
七、Pygame渲染实现
使用Pygame绘制游戏画面,包括墙壁、地板、目标点、箱子和玩家。
import pygame
class SokobanRenderer:
"""推箱子游戏渲染器"""
# 显示配置
CELL_SIZE = 48
MARGIN = 20
STATUS_HEIGHT = 60
# 颜色
COLOR_BG = (40, 40, 50)
COLOR_WALL = (100, 80, 70)
COLOR_FLOOR = (200, 190, 170)
COLOR_TARGET = (255, 100, 100)
COLOR_BOX = (180, 120, 60)
COLOR_BOX_DONE = (100, 200, 100)
COLOR_PLAYER = (80, 150, 250)
COLOR_TEXT = (255, 255, 255)
def __init__(self, game):
self.game = game
pygame.init()
level = game.level
width = level.width * self.CELL_SIZE + self.MARGIN * 2
height = level.height * self.CELL_SIZE + self.MARGIN * 2 + self.STATUS_HEIGHT
self.screen = pygame.display.set_mode((width, height))
pygame.display.set_caption("推箱子 - Sokoban")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("simhei", 20)
self.small_font = pygame.font.SysFont("simhei", 14)
def draw(self):
"""绘制整个游戏画面"""
self.screen.fill(self.COLOR_BG)
self.draw_map()
self.draw_entities()
self.draw_status()
def draw_map(self):
"""绘制地图(墙壁和地板)"""
level = self.game.level
for row in range(level.height):
for col in range(level.width):
x = self.MARGIN + col * self.CELL_SIZE
y = self.MARGIN + row * self.CELL_SIZE
pos = (row, col)
if pos in self.game.walls:
# 绘制墙壁
rect = pygame.Rect(x, y, self.CELL_SIZE, self.CELL_SIZE)
pygame.draw.rect(self.screen, self.COLOR_WALL, rect)
pygame.draw.rect(self.screen, (60, 40, 30), rect, 2)
else:
# 绘制地板
rect = pygame.Rect(x, y, self.CELL_SIZE, self.CELL_SIZE)
pygame.draw.rect(self.screen, self.COLOR_FLOOR, rect)
pygame.draw.rect(self.screen, (160, 150, 130), rect, 1)
# 绘制目标点
if pos in self.game.targets:
cx = x + self.CELL_SIZE // 2
cy = y + self.CELL_SIZE // 2
pygame.draw.circle(self.screen, self.COLOR_TARGET,
(cx, cy), self.CELL_SIZE // 4, 3)
def draw_entities(self):
"""绘制箱子和玩家"""
# 绘制箱子
for box_pos in self.game.boxes:
row, col = box_pos
x = self.MARGIN + col * self.CELL_SIZE
y = self.MARGIN + row * self.CELL_SIZE
color = self.COLOR_BOX_DONE if box_pos in self.game.targets else self.COLOR_BOX
rect = pygame.Rect(x + 4, y + 4, self.CELL_SIZE - 8, self.CELL_SIZE - 8)
pygame.draw.rect(self.screen, color, rect)
pygame.draw.rect(self.screen, (60, 40, 20), rect, 2)
# 箱子纹理(十字线)
cx, cy = x + self.CELL_SIZE // 2, y + self.CELL_SIZE // 2
pygame.draw.line(self.screen, (60, 40, 20),
(cx - 12, cy), (cx + 12, cy), 2)
pygame.draw.line(self.screen, (60, 40, 20),
(cx, cy - 12), (cx, cy + 12), 2)
# 绘制玩家
row, col = self.game.player_pos
x = self.MARGIN + col * self.CELL_SIZE
y = self.MARGIN + row * self.CELL_SIZE
cx = x + self.CELL_SIZE // 2
cy = y + self.CELL_SIZE // 2
radius = self.CELL_SIZE // 2 - 6
pygame.draw.circle(self.screen, self.COLOR_PLAYER, (cx, cy), radius)
pygame.draw.circle(self.screen, (255, 255, 255), (cx, cy), radius, 2)
# 眼睛
pygame.draw.circle(self.screen, (255, 255, 255),
(cx - 6, cy - 4), 3)
pygame.draw.circle(self.screen, (255, 255, 255),
(cx + 6, cy - 4), 3)
pygame.draw.circle(self.screen, (0, 0, 0),
(cx - 6, cy - 4), 1)
pygame.draw.circle(self.screen, (0, 0, 0),
(cx + 6, cy - 4), 1)
def draw_status(self):
"""绘制状态栏"""
level = self.game.level
status_y = level.height * self.CELL_SIZE + self.MARGIN * 2
completed, total = self.game.get_progress()
text = (f"移动: {self.game.move_count} "
f"推动: {self.game.push_count} "
f"进度: {completed}/{total} "
f"[Z]撤销 [R]重置 [ESC]退出")
text_surface = self.font.render(text, True, self.COLOR_TEXT)
self.screen.blit(text_surface, (self.MARGIN, status_y + 10))
if self.game.won:
win_text = "恭喜通关!按 N 进入下一关"
win_surface = self.font.render(win_text, True, (100, 255, 100))
self.screen.blit(win_surface, (self.MARGIN, status_y + 35))
八、完整游戏主循环
将所有部分组合成完整的游戏:
import pygame
import sys
class SokobanApp:
"""推箱子游戏主应用"""
def __init__(self):
self.level_manager = LevelManager(LEVELS)
self.load_level()
self.renderer = SokobanRenderer(self.game)
def load_level(self):
"""加载当前关卡"""
level_data = self.level_manager.get_current_level_data()
self.game = SokobanGame(level_data)
def run(self):
"""主游戏循环"""
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
running = self.handle_key(event.key)
self.renderer.draw()
pygame.display.flip()
self.renderer.clock.tick(30)
pygame.quit()
sys.exit()
def handle_key(self, key):
"""处理键盘输入"""
if key == pygame.K_ESCAPE:
return False
if key == pygame.K_UP or key == pygame.K_w:
self.game.move('up')
elif key == pygame.K_DOWN or key == pygame.K_s:
self.game.move('down')
elif key == pygame.K_LEFT or key == pygame.K_a:
self.game.move('left')
elif key == pygame.K_RIGHT or key == pygame.K_d:
self.game.move('right')
elif key == pygame.K_z:
self.game.undo()
elif key == pygame.K_r:
self.game.reset()
elif key == pygame.K_n and self.game.won:
# 下一关
self.level_manager.complete_level(
self.game.move_count, self.game.push_count
)
if self.level_manager.next_level():
self.load_level()
elif key == pygame.K_p:
# 上一关
if self.level_manager.prev_level():
self.load_level()
return True
if __name__ == "__main__":
app = SokobanApp()
app.run()
九、关卡数据格式设计(进阶)
为了支持更复杂的关卡和外部关卡文件,我们可以设计一个结构化的关卡格式:
import json
# 结构化关卡格式
LEVEL_FORMAT_EXAMPLE = {
"name": "第一关 - 入门",
"description": "把箱子推到红色目标点上",
"difficulty": "easy",
"author": "平平",
"map": [
" ##### ",
"### # ",
"#.@$ # ",
"### $.# ",
"#.##$ # ",
"# # . ###",
"#$ *$$.# ",
"# . # ",
"######## "
],
"hint": "注意箱子只能推不能拉"
}
class LevelLoader:
"""关卡加载器"""
@staticmethod
def load_from_json(filename):
"""从JSON文件加载关卡集"""
with open(filename, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
@staticmethod
def load_from_text(filename):
"""从文本文件加载关卡(XSB格式)"""
levels = []
current_level = []
current_name = ""
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
# 关卡标题行(以;开头)
if line.startswith(';'):
if current_level:
levels.append({
'name': current_name,
'map': current_level
})
current_level = []
current_name = line[1:].strip()
elif line and any(c in line for c in '#$.@*+ '):
current_level.append(line)
elif current_level:
levels.append({
'name': current_name,
'map': current_level
})
current_level = []
if current_level:
levels.append({
'name': current_name,
'map': current_level
})
return levels
@staticmethod
def save_to_json(levels, filename):
"""保存关卡到JSON文件"""
with open(filename, 'w', encoding='utf-8') as f:
json.dump(levels, f, ensure_ascii=False, indent=2)
# XSB 格式示例文件内容
XSB_EXAMPLE = """; Level 1 - Tutorial
#####
# #
#$ #
### $##
# $ $ #
### # ## # ######
# # ## ##### ..#
# $ $ ..#
##### ### #@## ..#
# #########
#######
"""
# 使用示例
# levels = LevelLoader.load_from_text("levels.xsb")
# LevelLoader.save_to_json(levels, "levels.json")
十、扩展与改进建议
- 自动求解器:使用BFS或A*算法实现关卡自动求解
- 死锁检测:更完善的死锁检测,提示玩家无法完成
- 动画效果:添加箱子推动动画和玩家移动平滑过渡
- 音效:添加移动、推动箱子、通关音效
- 关卡编辑器:可视化编辑器,方便创建自定义关卡
- 皮肤系统:支持不同的图形主题
- 排行榜:记录每关最少步数排行榜
- 回放功能:记录通关过程,支持回放观看
- 移动端适配:支持触摸滑动操作
- 撤销/重做:完整支持多步撤销和重做
通过本教程,你学会了使用Python和Pygame开发一个完整的推箱子游戏,涵盖了地图设计、移动逻辑、碰撞检测、关卡系统、撤销功能和Pygame渲染。推箱子是一个经典的益智游戏,通过实现它你掌握了网格游戏开发的核心技术。你可以在此基础上添加更多关卡和功能,打造属于自己的推箱子游戏。