返回文章列表

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")

十、扩展与改进建议

通过本教程,你学会了使用Python和Pygame开发一个完整的推箱子游戏,涵盖了地图设计、移动逻辑、碰撞检测、关卡系统、撤销功能和Pygame渲染。推箱子是一个经典的益智游戏,通过实现它你掌握了网格游戏开发的核心技术。你可以在此基础上添加更多关卡和功能,打造属于自己的推箱子游戏。

动手挑战

学到这里,不妨动手试一试以下练习,巩固你的理解:

  1. 基础练习:回顾本文核心概念,用自己的话总结关键知识点。
  2. 进阶实践:将文中的示例代码运行一遍,尝试修改参数观察变化。
  3. 拓展思考:想一想这个技术/方法还能应用在哪些场景中?

小贴士:遇到问题时,先独立思考,再查阅资料,最后请教他人——这是成长最快的学习方式。

赞赏支持

本文更新于 2026-08-22,环境 Python 3.12