推箱子可以说是一个非常经典的益智游戏了,玩法也非常简单:玩家只需要控制角色上下左右移动,去将地图上的箱子推到指定的位置,就算作游戏通关
因为玩法的简单性,所以在编写程序时,游戏逻辑上也几乎没有太大的难度。相反,主要的难度反而都集中在了图形界面化上……
基本思路
玩家移动与箱子的推动
推箱子的时候,玩家唯一采取的行动便是“上下左右移动”,在移动的过程中,无非只有下述的几种情况:
(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())