[daily] 008_pygame restore push box

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

Keywords: Python pygame pyqt

Added by idotcom on Tue, 08 Feb 2022 14:18:52 +0200