【日常篇】008_pygame还原推箱子

  推箱子可以说是一个非常经典的益智游戏了,玩法也非常简单:玩家只需要控制角色上下左右移动,去将地图上的箱子推到指定的位置,就算作游戏通关

  因为玩法的简单性,所以在编写程序时,游戏逻辑上也几乎没有太大的难度。相反,主要的难度反而都集中在了图形界面化上……

基本思路

玩家移动与箱子的推动

  推箱子的时候,玩家唯一采取的行动便是“上下左右移动”,在移动的过程中,无非只有下述的几种情况:

  (1)玩家移动到的目标位置是空气

  (2)玩家移动到的目标位置是墙

  (3)玩家移动到的目标位置是箱子

  对于情况(1),玩家自然可以直接移动过去。对于情况(2),玩家则不能进行移动。而对于情况(3),则只需要同样地对那个箱子进行判断,如果那个箱子背后是空气,则玩家和箱子将会一起移动,否则玩家和箱子都不能移动

撤销与重做

  在推箱子的过程中,难免会因为手残等各种因素,导致一不小心走进死胡同。对于难度较高的level,直接重开一把显然会让人感到极其痛苦,这个时候撤销和重复的功能就非常地重要了

  因为撤销和重复的本质便是恢复到最近的那个状态中,这个过程显然是具备“后进先出”的特征的,所以很直接地就能联想到“栈”——利用栈来存储撤销和重复的动作

  在玩家每走完一步时,都将上一步的状态压入undo(撤销)栈中,这样在undo的时候,只要从undo栈中弹出栈顶元素,就可以恢复到上一步的状态。在undo的时候,会将玩家undo前的状态压入到redo(重复)栈中,这样在undo后,如果又反悔了,就可以通过弹出redo栈的栈顶元素的方式(当前状态再压回到undo栈中),恢复到undo前的状态,即完成了redo操作:

在这里插入图片描述

  如果在栈中,直接把所有物体的状态都给存储下来了的话,必然会涵盖大量的没有被操作过的物体,这样一来就会浪费许多空间。在这里联想到数据库日志系统中的undo日志和redo日志:它们只记录了被改动的元素及其改动前/后的值,从而最大程度地节省了空间。因此在这里也采用了相似的手法:

undo/redo栈的格式:
        [[[-1(表示玩家), [玩家上/下一步的行坐标, 玩家上/下一步的列坐标]],
         [(被移动的箱子的index), [箱子上/下一步的行坐标, 箱子上/下一步的列坐标]]], ...]

  栈中每个元素都是一个列表,列表中又嵌套了一系列的二级列表,每个二级列表都存储了各个被移动的物体所对应的(标识)编号以及在上一步/下一步时的位置,这样一来,在进行undo/redo操作时,只要直接把这些坐标拿出来,“对号入座”就可以了

大致实现过程

  首先是完成了游戏场景类(LevelStage),这个类是单个指定关卡的“内核”,包含了地图,玩家位置,箱子位置等信息,以及玩家移动判定,游戏胜利判定,撤销与重做等方法。在仅使用这个类的情况下,也可以通过传递玩家移动信号的方式进行一盘完整的游戏

  随后便利用pygame实现了游戏图形界面类(Display),这个类的作用便是将游戏场景可视化,形成与玩家交互的媒介。因为在实现LevelStage的时候已经定义了许多“信号接收”的“接口”,所以在Display中只要设定好在何时向LevelStage发送何种信号,就可以很轻松地完成衔接

  最后再是利用PyQt5制作了一个极其简易的选关界面(MainWindow),载入在Qt Creator中设计的ui文件,并将play某关游戏的动作与按钮相关联,同时通过读取“当前打到哪一关“的存档,来限制玩家所能够选择的最大关卡编号数

  至此,一个包含最基本功能的推箱子游戏就完成了

效果展示

在这里插入图片描述

代码下载

  https://github.com/VtaSkywalker/push_box

代码展示

  因为涉及到的文件较多,在这里就只贴出python代码了,以方便查看(众所周知浏览github的速度极其不稳定……)

stage.py

import json
import numpy as np

class LevelStage:
    """
        level场景类

        Attributes
        ----------
        level_map : char[][]
            地图
        player_pos : int[2]
            玩家位置(行、列)
        box_pos_list : int[][2]
            各个箱子的位置(行、列)
        level_file_path : str
            level文件的路径
        undo_stack : list[][]
            用于撤销操作的栈(格式见注释最下方Info)
        redo_stack : list[][]
            用于重复操作的栈(格式见注释最下方Info)
        level_id : int
            当前所处的关卡编号
        
        Info
        ----
        undo/redo栈的格式:
        [[[-1(表示玩家), [玩家上/下一步的行坐标, 玩家上/下一步的列坐标]],
         [(被移动的箱子的index), [箱子上/下一步的行坐标, 箱子上/下一步的列坐标]]], ...]
    """
    def __init__(self):
        self.level_map = np.array(["XXX", "X0X", "XXX"])
        self.player_pos = [1, 1]
        self.box_pos_list = []
        self.level_file_path = "levelConfig.json"
        self.undo_stack = []
        self.redo_stack = []
        self.level_id = -1

    def load_level_map(self, level_map_file_path):
        """
            从level map文件中读取地图

            Parameters
            ----------
            level_map_file_path : str
                level map文件路径
        """
        level_map_file_path = np.loadtxt(level_map_file_path, dtype=str)
        return level_map_file_path

    def load_level(self, level_id):
        """
            从level文件中读取相应的level,初始化场景

            level文件的结构:
            ```
            [
                {
                    "level_id" : xxx,
                    "level_info" :
                    {
                        "level_map" : mapFilePath,
                        "player_pos" : [i, j],
                        "box_pos_list" : [[i1, j1], [i2, j2], ..., [in, jn]]
                    }
                }, ...
            ]
            ```

            地图文件的结构:
            ```
            XXXXXX
            XCCHXX
            XXXXX0
            ```
            X为障碍物,0为空气,C为地毯,H为箱子的目标位置

            Parameters
            ----------
            level_id : int
                level编号
        """
        with open(self.level_file_path, "r") as f:
            data = json.load(f)[level_id]
        self.level_map = self.load_level_map(data["level_info"]["level_map"])
        self.player_pos = data["level_info"]["player_pos"]
        self.box_pos_list = data["level_info"]["box_pos_list"]
        self.level_id = level_id
        self.undo_stack = []
        self.redo_stack = []

    def get_map_size(self):
        """
            获取地图的尺寸(单位:grid)

            Returns
            -------
            width : int
                宽
            height : int
                高
        """
        width = len(self.level_map[0])
        height = len(self.level_map)
        return [width, height]

    def restart_level(self):
        """
            重开本关
        """
        self.load_level(self.level_id)

    def move(self, direction):
        """
            玩家向某个指定的方向移动

            Parameters
            ----------
            direction : int
                移动方向的编号,其中:
                1 —— 上
                2 —— 左
                3 —— 下
                4 —— 右
        """
        # 编号转方向分量
        if(direction == 1):
            i = -1
            j = 0
        elif(direction == 2):
            i = 0
            j = -1
        elif(direction == 3):
            i = 1
            j = 0
        elif(direction == 4):
            i = 0
            j = 1
        # 玩家本该移动到的新位置
        new_pos = [self.player_pos[0]+i, self.player_pos[1]+j]
        # 如果是箱子,则判断箱子能否被推动
        if(new_pos in self.box_pos_list):
            box_new_pos = [new_pos[0]+i, new_pos[1]+j]
            # 如果箱子前面还是箱子,则不能推动
            if(box_new_pos in self.box_pos_list):
                return
            else:
                # 如果箱子前面是空气/传动点,则可以推动
                if(self.level_map[box_new_pos[0]][box_new_pos[1]] in ['C', 'H']):
                    # 将当前玩家和箱子所在位置添加到undo栈中,方便撤销操作
                    box_idx = self.box_pos_list.index(new_pos)
                    self.undo_stack.append([[-1, self.player_pos], [box_idx, self.box_pos_list[box_idx]]])
                    # 更新玩家位置
                    self.player_pos = new_pos
                    # 更新箱子位置
                    self.box_pos_list[box_idx] = box_new_pos
                # 否则不能推动
                else:
                    return
        # 如果是墙壁,则不能移动
        elif(self.level_map[new_pos[0]][new_pos[1]] == 'X'):
            return
        # 如果是空气/传动点,则可以移动
        elif(self.level_map[new_pos[0]][new_pos[1]] in ['C', 'H']):
            # 将当前玩家所在位置添加到undo栈中,方便撤销操作
            self.undo_stack.append([[-1, self.player_pos]])
            # 更新玩家位置
            self.player_pos = new_pos
        # 如果移动成功,则将redo栈清空,因为这种情况下就不能redo了
        self.redo_stack = []
        return

    def undo(self):
        """
            撤销操作
        """
        if(len(self.undo_stack) != 0):
            undo_info = self.undo_stack[-1]
            new_redo_info = []
            for each_undo_obj in undo_info:
                if(each_undo_obj[0] == -1):
                    new_redo_info.append([-1, self.player_pos])
                    self.player_pos = each_undo_obj[1]
                else:
                    box_idx = each_undo_obj[0]
                    new_redo_info.append([box_idx, self.box_pos_list[box_idx]])
                    self.box_pos_list[box_idx] = each_undo_obj[1]
            self.redo_stack.append(new_redo_info)
            self.undo_stack = self.undo_stack[:-1]

    def redo(self):
        """
            重做操作
        """
        if(len(self.redo_stack) != 0):
            redo_info = self.redo_stack[-1]
            new_undo_info = []
            for each_redo_obj in redo_info:
                if(each_redo_obj[0] == -1):
                    new_undo_info.append([-1, self.player_pos])
                    self.player_pos = each_redo_obj[1]
                else:
                    box_idx = each_redo_obj[0]
                    new_undo_info.append([box_idx, self.box_pos_list[box_idx]])
                    self.box_pos_list[box_idx] = each_redo_obj[1]
            self.undo_stack.append(new_undo_info)
            self.redo_stack = self.redo_stack[:-1]

    def is_game_win(self):
        """
            判断是否通关

            Returns
            -------
            True / False
        """
        flag = True
        for each_box_pos in self.box_pos_list:
            if(self.level_map[each_box_pos[0]][each_box_pos[1]] != 'H'):
                flag = False
                break
        return flag

    def player_direction_signal_handler(self, direction):
        """
            处理:玩家发出的方向信号

            Returns
            -------
            Ture / False : 是否游戏胜利
        """
        self.move(direction=direction)
        if(self.is_game_win()):
            return True
        return False

    def show_in_cmd(self):
        """
            在命令行中打印出当前的场景状态,用于调试
        """
        for i, eachLine in enumerate(self.level_map):
            for j, eachColumn in enumerate(eachLine):
                if([i, j] == self.player_pos):
                    each_char = 'P'
                elif([i, j] in self.box_pos_list):
                    each_char = '+'
                else:
                    if(eachColumn == 'X'):
                        each_char = '#'
                    elif(eachColumn in ['0', 'C']):
                        each_char = ' '
                    elif(eachColumn == 'H'):
                        each_char = '.'
                print(each_char, end="\t")
            print("")

display.py

from abc import update_abstractmethods
from stage import LevelStage
import pygame

MAX_LEVEL_ID = 10

class Display:
    """
        显示游戏界面的窗口
    """
    def __init__(self):
        self.stage = LevelStage()
        self.init_img_src()

    def init_img_src(self):
        """
            初始化图像素材
        """
        self.player_gif_img_list = [pygame.image.load("./img/player_gif/player_0.png"), pygame.image.load("./img/player_gif/player_1.png")]
        self.aim_pos_img = pygame.image.load("./img/aim_pos.png")
        self.box_img = pygame.image.load("./img/box.png")
        self.box_complete_img = pygame.image.load("./img/box_complete.png")
        self.carpet_img = pygame.image.load("./img/carpet.png")
        self.wall_img = pygame.image.load("./img/wall.png")

    def load_level(self, level_id):
        """
            读取关卡,读取完成后初始化图形界面

            Parameters
            ----------
            level_id : int
                关卡id
        """
        # 读取关卡
        self.stage.load_level(level_id)
        self.grid_size = 50
        self.screen_size = (self.stage.get_map_size()[0] * self.grid_size, self.stage.get_map_size()[1] * self.grid_size)
        # 初始化图形界面
        pygame.init()
        self.screen = pygame.display.set_mode(self.screen_size)
        pygame.display.set_caption('push box - level %d' % level_id)
        self.main_loop()

    def main_loop(self):
        """
            图形界面的主循环
        """
        self.time_stamp = 0
        self.fps = 60
        self.is_game_win = False
        while True:
            events = pygame.event.get()
            for event in events:
                if(event.type == pygame.QUIT):
                    pygame.display.quit()
                    return
                if(event.type == pygame.KEYDOWN):
                    if(not self.is_game_win):
                        # 方向键
                        if(pygame.key.get_pressed()[pygame.K_UP] or pygame.key.get_pressed()[pygame.K_LEFT] or pygame.key.get_pressed()[pygame.K_DOWN] or pygame.key.get_pressed()[pygame.K_RIGHT]):
                            if(pygame.key.get_pressed()[pygame.K_UP]):
                                direction = 1
                            elif(pygame.key.get_pressed()[pygame.K_LEFT]):
                                direction = 2
                            elif(pygame.key.get_pressed()[pygame.K_DOWN]):
                                direction = 3
                            elif(pygame.key.get_pressed()[pygame.K_RIGHT]):
                                direction = 4
                            if(self.stage.player_direction_signal_handler(direction=direction)):
                                self.game_win()
                        # 撤销
                        if(pygame.key.get_pressed()[pygame.K_z]):
                            self.stage.undo()
                        # 重做
                        if(pygame.key.get_pressed()[pygame.K_x]):
                            self.stage.redo()
                        # 重开
                        if(pygame.key.get_pressed()[pygame.K_r]):
                            self.stage.restart_level()
            # 绘制游戏界面
            self.game_stage_draw()
            pygame.display.update()
            # 更新时间戳
            self.update_time_stamp()

    def update_time_stamp(self):
        pygame.time.delay(int(1e3 / self.fps))
        self.time_stamp += 1
        self.time_stamp = self.time_stamp % self.fps

    def game_stage_draw(self):
        """
            绘制游戏界面
        """
        # 初始化-黑屏
        self.screen.fill((0,0,0))
        # 绘制地毯/墙壁/目标点
        carpet_rect = self.carpet_img.get_rect()
        wall_rect = self.wall_img.get_rect()
        aim_pos_rect = self.aim_pos_img.get_rect()
        level_map = self.stage.level_map
        [level_map_width, level_map_height] = self.stage.get_map_size()
        for i in range(level_map_height):
            for j in range(level_map_width):
                if(level_map[i][j] == 'C'):
                    img_list = [self.carpet_img]
                    rect_list = [carpet_rect]
                elif(level_map[i][j] == "X"):
                    img_list = [self.wall_img]
                    rect_list = [wall_rect]
                elif(level_map[i][j] == "H"):
                    img_list = [self.carpet_img, self.aim_pos_img]
                    rect_list = [carpet_rect, aim_pos_rect]
                else:
                    continue
                centerx = self.grid_size * (0.5 + j)
                centery = self.grid_size * (0.5 + i)
                for img, rect in zip(img_list, rect_list):
                    rect.centerx = centerx
                    rect.centery = centery
                    self.screen.blit(img, rect)
        # 绘制箱子
        box_rect = self.box_img.get_rect()
        box_complete_rect = self.box_complete_img.get_rect()
        for each_box_pos in self.stage.box_pos_list:
            i = each_box_pos[0]
            j = each_box_pos[1]
            centerx = self.grid_size * (0.5 + each_box_pos[1])
            centery = self.grid_size * (0.5 + each_box_pos[0])
            if(level_map[i][j] == 'H'):
                img = self.box_complete_img
                rect = box_complete_rect
            else:
                img = self.box_img
                rect = box_rect
            rect.centerx = centerx
            rect.centery = centery
            self.screen.blit(img, rect)
        # 绘制玩家
        frame = (self.time_stamp % 30) // int(self.fps / 4)
        player_gif_img = self.player_gif_img_list[frame]
        player_gif_rect = player_gif_img.get_rect()
        player_pos = self.stage.player_pos
        centerx = self.grid_size * (0.5 + player_pos[1])
        centery = self.grid_size * (0.5 + player_pos[0])
        player_gif_rect.centerx = centerx
        player_gif_rect.centery = centery
        self.screen.blit(player_gif_img, player_gif_rect)
        # 通关文字显示
        if(self.is_game_win):
            font = pygame.font.SysFont("arial", 35)
            img = font.render('Game Win', True, (0, 255, 0))
            rect = img.get_rect()
            rect.centerx = self.screen_size[0] / 2
            rect.centery = self.grid_size * 0.5
            self.screen.blit(img, rect)

    def game_win(self):
        self.is_game_win = True
        self.unlock_new_level()

    def unlock_new_level(self):
        """
            通关,解锁新的一关
        """
        sav_file_path = "./level.sav"
        with open(sav_file_path, "r") as f:
            max_unlock_level = int(f.readline().strip("\n"))
        if(self.stage.level_id == max_unlock_level and max_unlock_level < MAX_LEVEL_ID):
            with open(sav_file_path, "w") as f:
                f.write("%d" % (max_unlock_level+1))

mainform.py

from PyQt5 import QtWidgets, uic
from display import Display

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = uic.loadUi("./mainwindow.ui")
        self.ui.show()
        self.load_sav()
        # 按钮关联动作
        self.ui.pushButton.clicked.connect(self.start_level)

    def start_level(self):
        d = Display()
        d.load_level(self.ui.spinBox.value())
        self.load_sav()

    def load_sav(self):
        """
            读取当前关卡进度
        """
        sav_file_path = "./level.sav"
        with open(sav_file_path, "r") as f:
            max_unlock_level = int(f.readline().strip("\n"))
        # 更新spinbox的值与最大值
        self.ui.spinBox.setMaximum(max_unlock_level)
        self.ui.spinBox.setValue(max_unlock_level)

start.py

from PyQt5 import QtCore, QtWidgets
from mainform import MainWindow
import sys

if __name__ == "__main__":
    QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
    app = QtWidgets.QApplication([])
    window = MainWindow()
    sys.exit(app.exec())

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值