push box can be said to be a very classic puzzle game, and the playing method is also very simple: players only need to control the character to move up, down, left and right to push the box on the map to the specified position, even if it is used as a game to pass customs
because of the simplicity of the playing method, there is hardly much difficulty in the logic of the game when writing the program. On the contrary, the main difficulty is focused on the graphical interface
Basic ideas
Player movement and box push
when pushing a box, the only action taken by the player is to "move up, down, left and right". In the process of moving, there are only the following situations:
(1) the target position the player moves to is air
(2) the target position the player moves to is the wall
(3) the target position the player moves to is the box
for case (1), the player can naturally move over directly. In case (2), the player cannot move. For case (3), you only need to judge the box in the same way. If there is air behind the box, the player and the box will move together, otherwise neither the player nor the box can move
Undo and redo
in the process of pushing boxes, it is inevitable that various factors such as hand injuries will lead to careless walking into a dead end. For difficult level s, it is obviously very painful to reopen them directly. At this time, the function of undo and repetition is very important
because the essence of undo and repeat is to restore to the latest state, this process obviously has the characteristics of "last in, first out", so it can be directly associated with "stack" - using stack to store undo and repeat actions
when the player finishes each step, the state of the previous step is pressed into the undo stack. In this way, when undoing, as long as the top element of the stack pops up from the undo stack, it can be restored to the state of the previous step. During undo, the player's state before undo will be pushed into the redo (repeat) stack. In this way, if he goes back after undo, he can restore to the state before undo by popping the top element of the redo stack (pressing the current state back into the undo stack), that is, the redo operation is completed:
if the states of all objects are directly stored in the stack, it will inevitably cover a large number of objects that have not been operated, which will waste a lot of space. Think of the undo log and redo log in the database log system here: they only record the changed elements and their values before / after the change, thus saving space to the greatest extent. Therefore, a similar approach is adopted here:
undo/redo Stack format: [[[-1((represents a player), [Player up/Next row coordinates, Player up/Next column coordinates]], [(Of the box being moved index), [On the box/Next row coordinates, On the box/Next column coordinates]]], ...]
each element in the stack is a list, and a series of secondary lists are nested in the list. Each secondary list stores the corresponding (identification) number of each moved object and the position in the previous / next step. In this way, when performing undo/redo operations, just take out these coordinates directly and "seat by number"
Approximate implementation process
the first is to complete the game scene class (LevelStage), which is the "kernel" of a single specified level, including map, player position, box position and other information, as well as player movement judgment, game victory judgment, cancellation and redo and other methods. In the case of only using this class, you can also play a complete game by transmitting the player's mobile signal
then, the game graphical interface class (Display) is realized by using pygame. The function of this class is to visualize the game scene and form a medium for interaction with players. Because many "interfaces" for "signal reception" have been defined when implementing LevelStage, the connection can be easily completed by setting when and what signals to send to LevelStage in the Display
finally, we use PyQt5 to make an extremely simple level selection interface (MainWindow), load the ui file designed in Qt Creator, associate the action of a level game with the button, and limit the maximum number of level numbers that players can select by reading the archive of "which level they are currently playing"
so far, a push box game with the most basic functions has been completed
Effect display
Code download
https://github.com/VtaSkywalker/push_box
Code display
because there are many files involved, only python code is posted here for easy viewing (as we all know, the speed of browsing github is extremely unstable...)
stage.py
import json import numpy as np class LevelStage: """ level Scene class Attributes ---------- level_map : char[][] Map player_pos : int[2] Player position (row, column) box_pos_list : int[][2] Position of each box (row, column) level_file_path : str level Path to file undo_stack : list[][] Stack for undo operation (see the bottom of the note for format) Info) redo_stack : list[][] Stack for repeated operations (see the bottom of the note for format) Info) level_id : int Current level number Info ---- undo/redo Stack format: [[[-1((represents a player), [Player up/Next row coordinates, Player up/Next column coordinates]], [(Of the box being moved index), [On the box/Next row coordinates, On the box/Next column coordinates]]], ...] """ 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): """ from level map Read map from file Parameters ---------- level_map_file_path : str level map File path """ level_map_file_path = np.loadtxt(level_map_file_path, dtype=str) return level_map_file_path def load_level(self, level_id): """ from level Read the corresponding from the file level,Initialize scene level Structure of documents: ``` [ { "level_id" : xxx, "level_info" : { "level_map" : mapFilePath, "player_pos" : [i, j], "box_pos_list" : [[i1, j1], [i2, j2], ..., [in, jn]] } }, ... ] ``` Structure of map file: ``` XXXXXX XCCHXX XXXXX0 ``` X Is an obstacle, 0 is air, C For the carpet, H Is the target location of the box Parameters ---------- level_id : int level number """ 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): """ Get the size of the map (unit: grid) Returns ------- width : int wide height : int high """ width = len(self.level_map[0]) height = len(self.level_map) return [width, height] def restart_level(self): """ Reopen this close """ self.load_level(self.level_id) def move(self, direction): """ The player moves in a specified direction Parameters ---------- direction : int Number of moving direction, where: 1 - upper 2 - Left 3 - lower 4 - right """ # Number rotation direction component 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 # The new location the player should have moved to new_pos = [self.player_pos[0]+i, self.player_pos[1]+j] # If it is a box, judge whether the box can be pushed if(new_pos in self.box_pos_list): box_new_pos = [new_pos[0]+i, new_pos[1]+j] # If the front of the box is still a box, it cannot be pushed if(box_new_pos in self.box_pos_list): return else: # If there is an air / transmission point in front of the box, it can be pushed if(self.level_map[box_new_pos[0]][box_new_pos[1]] in ['C', 'H']): # Add the location of the current player and box to the undo stack to facilitate the undo operation 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]]]) # Update player location self.player_pos = new_pos # Update box position self.box_pos_list[box_idx] = box_new_pos # Otherwise, it cannot be pushed else: return # If it is a wall, it cannot be moved elif(self.level_map[new_pos[0]][new_pos[1]] == 'X'): return # If it is an air / transmission point, it can be moved elif(self.level_map[new_pos[0]][new_pos[1]] in ['C', 'H']): # Add the current player's location to the undo stack to facilitate the undo operation self.undo_stack.append([[-1, self.player_pos]]) # Update player location self.player_pos = new_pos # If the move succeeds, the redo stack will be cleared, because redo cannot be performed in this case self.redo_stack = [] return def undo(self): """ Undo operation """ 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): """ Redo operation """ 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): """ Judge whether to pass customs 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): """ Processing: direction signals sent by players Returns ------- Ture / False : Is the game winning """ self.move(direction=direction) if(self.is_game_win()): return True return False def show_in_cmd(self): """ Print out the current scene state on the command line for debugging """ 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: """ Window displaying game interface """ def __init__(self): self.stage = LevelStage() self.init_img_src() def init_img_src(self): """ Initialize image material """ 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): """ Read the level and initialize the graphical interface after reading Parameters ---------- level_id : int Checkpoints id """ # Read level 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) # Initialize graphical interface 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): """ Main loop of graphical interface """ 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): # Direction key 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() # revoke if(pygame.key.get_pressed()[pygame.K_z]): self.stage.undo() # redo if(pygame.key.get_pressed()[pygame.K_x]): self.stage.redo() # Reopen if(pygame.key.get_pressed()[pygame.K_r]): self.stage.restart_level() # Draw game interface self.game_stage_draw() pygame.display.update() # Update Timestamp 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): """ Draw game interface """ # Initialization - black screen self.screen.fill((0,0,0)) # Draw carpet / wall / target point 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) # Draw box 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) # Draw player 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) # Customs clearance text display 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): """ Pass and unlock a new pass """ 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() # Button associated action 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): """ Read the current level progress """ sav_file_path = "./level.sav" with open(sav_file_path, "r") as f: max_unlock_level = int(f.readline().strip("\n")) # Update spinbox value and maximum value 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())