6.4    状态管理

上例中马里奥只有走路一种状态,仅作为演示,下面尝试将走路动画集成到游戏中。

考虑到马里奥拥有多种状态,每种状态对应不同的图片,且朝向左右的图片也会有所不同,因此,我们需要单独创建一个类来存储不同状态对应的图片信息。

现在的时间差累计值,实际仅用于马里奥的走路状态,站立和跳跃状态并不需要该值,因为它们仅包含单张图片。但考虑到未来肯定会加入更多的状态,比如游泳、爬藤蔓等等,它们都包含多帧动画,并需要根据时间进行帧切换。因此,为每种多帧动画状态保存时间差累计值、帧切换间隔、帧下标等变量是必要的。

此外,当前状态切换过程较为复杂,按键处理需考虑多种状态,导致代码混乱不清晰。

综合以上几点,考虑编写一个马里奥状态的父类,封装动画帧处理的逻辑,并实现多个子类,分别对应不同状态,每个子类负责处理按键和状态切换等复杂逻辑,而Mario类的主要功能就是负责状态管理。

6.4.1  Mario

修改后Mario类的代码如下所示:

06\04\Mario.py

import pygame

from pygame import Rect

from utils.utils import *

from game_globals.Globals import Globals

from mario.state.StandState import *

 

 

# 马里奥

class Mario(object):

 

    # 构造方法,记录初始坐标

    def __init__(self, x, y, screen):

        self.screen = screen

        self.rect = Rect(x, y, MAP_BLOCK_SIZE, MAP_BLOCK_SIZE)

        self.walkStepDefault = 2  # 走路步伐,默认值

        self.jumpStepDefault = 10  # 跳跃步伐,默认值

        self.dropStepDefault = 4  # 掉落步伐,默认值

        self.walkStep = 2  # 走路步伐,实际值

        self.jumpStep = 10  # 跳跃步伐,实际值

        self.dropStep = 4  # 掉落步伐,实际值

 

        self.walkSpeed = 128  # 走路速度,每秒128像素

        self.direction = DIRECTION_RIGHT  # 运动方向,右是1,左

        self.image = Globals.marioImageCache.marioStand  # 站立图片

        self.currentState = StandState(self)  # 站立状态

 

    # 更新

    def update(self, keys, deltaTime):

 

        # 步伐,先恢复默认值

        self.walkStep = self.walkStepDefault

        self.jumpStep = self.jumpStepDefault

        self.dropStep = self.dropStepDefault

 

        # 由当前状态类,处理按键,并返回新状态类(如果状态变化的话)

        newState = self.currentState.update(keys, deltaTime)

        print("state=" + newState.getStateName())

 

        # 状态没变,则施加重力

        # 状态变化,先不管重力,下一帧再施加重力。

        # 否则站立变跳跃时,跳不起来(因为新状态是跳跃,这里直接检测到碰撞,又变成了站立)。

        if newState == self.currentState:

            # ----重力影响----

            # 下一步预判断

            nextStepRect = self.rect.move(0, self.dropStep)

            pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)

            if pipeIndex != -1:

                self.dropStep = mapToScreen(Globals.game.objectArray[pipeIndex]).y - self.rect.bottom

 

                # 下边界碰撞,通知状态类

                self.currentState = self.currentState.updateGravity(True)

            self.rect = self.rect.move(0, self.dropStep)

        else:

            self.currentState = newState

 

        # 显示马里奥

        self.screen.blit(self.image, self.rect)

 

        # 滚动地图

        if self.currentState.getScrollMap():

            Globals.game.scrollMap(self.walkStep)

代码中使用currentState变量保存当前状态对象,由当前状态的update方法进行主要逻辑处理。如果需要切换状态,则update()方法会返回新状态,保存到currentState变量即可。

6.4.2  MarioState父类

多个状态类的文件结构如6‑11所示。

611状态类的文件结构

其中,MarioState类是父类,其他三个子类分别对应站立、走路和跳跃三种状态。

MarioState类的代码如下所示:

06\04\mario\state\MarioState.py

import abc

 

 

# 马里奥状态,父类

class MarioState(abc.ABC):

    # 初始化

    def __init__(self, mario, frameDuration):

        self.mario = mario  # 马里奥

        self.frames = []  # 动画图片

        self.frameDuration = frameDuration  # 每帧显示时间,单位为毫秒

        self.frameIndex = 0  # 当前帧的下标

        self.elapsedTime = 0  # 时间差累计

        self.isScrollMap = False  # 是否滚动地图

 

        # 设置动画帧

        self._setFrames(mario.direction)

 

    # 设置动画帧(运行时动态切换方向)

    def _setFrames(self, direction):

        print("----setFrames:"+str(direction))

        self.frames = self._getFrames(direction)

        self.frameIndex = 0  # 当前帧的下标

        self.elapsedTime = 0  # 时间差累计

 

        # 切换后,显示第一帧

        self.mario.image = self.frames[0]

 

    # 获取动画帧(子状态负责实现)

    @abc.abstractmethod

    def _getFrames(self, direction):

        pass

 

    # 更新动画,处理按键

    def update(self, keys, deltaTime):

 

        # 只有一帧,无需累积时间差

        if len(self.frames) == 1:

            self.mario.image = self.frames[0]

        else:

            # 时间差累计

            self.elapsedTime += deltaTime

 

            # 切换动画图片

            if self.elapsedTime > self.frameDuration:

                self.elapsedTime -= self.frameDuration  # 减去差值

                self.frameIndex += 1

                if self.frameIndex >= len(self.frames):

                    self.frameIndex = 0

                self.mario.image = self.frames[self.frameIndex]

 

        # 处理按键

        return self._updateKeys(self.mario, keys, deltaTime)

 

    # 处理按键(子状态负责实现)

    @abc.abstractmethod

    def _updateKeys(self, mario, keys, deltaTime):

        pass

 

    # 获取状态名称(子状态负责实现)

    @abc.abstractmethod

    def getStateName(self):

        pass

 

    # 重力处理,下边界碰撞(子状态负责实现)

    @abc.abstractmethod

    def updateGravity(self, isHitBottom):

        pass

 

    # 是否滚动地图

    def getScrollMap(self):

        return self.isScrollMap

父类封装了动画帧相关的变量和处理逻辑,并提供了一些方法,具体如下:

l  _setFrames()方法:设置动画帧内容,用来切换动画帧,主要就是马里奥变更方向时调用。

l  _getFrames()抽象方法:由每个子类具体实现,获取动画帧内容。

l  update()方法:封装了动画帧切换的逻辑,最后会调用_updateKeys()方法处理按键。

l  _updateKeys()抽象方法:由每个子类具体实现,处理按键。

l  getStateName()抽象方法:由每个子类具体实现,返回状态名称。

l  updateGravity()抽象方法:由每个子类具体实现,马里奥由于重力下边界碰撞时会调用该方法。

l  getScrollMap()方法:获取是否滚动地图。

6.4.3  站立状态StandState

StandState类的代码如下所示:

06\04\mairo\state\StandState.py

import pygame

from mario.state.JumpState import JumpState

from mario.state.MarioState import MarioState

from utils.utils import *

from mario.state.WalkState import WalkState

 

 

# 站立状态

class StandState(MarioState):

    # 初始化

    def __init__(self, mario):

        super().__init__(mario, 80)

 

    # 设置动画帧

    def _getFrames(self, direction):

        cache = Globals.marioImageCache

        if direction == DIRECTION_RIGHT:

            return [cache.marioStand]

        else:

            return [cache.marioLeftStand]

 

    # 获取状态名称

    def getStateName(self):

        return "stand"

 

    # 处理按键(子类必须实现这个方法)

    def _updateKeys(self, mario, keys, deltaTime):

        # 按键状态

        if keys[pygame.K_LEFT]:

            print("按了左方向键")

            mario.direction = DIRECTION_LEFT

            return WalkState(mario)

        elif keys[pygame.K_RIGHT]:

            print("按了右方向键")

            mario.direction = DIRECTION_RIGHT

            return WalkState(mario)

 

        if keys[pygame.K_g]:

            # 原地跳跃,原来脸朝向哪边就是哪边

            return JumpState(mario)

 

        # 没按方向键和跳跃键,则保持站立状态

        return self

 

    # 重力处理,下边界碰撞

    def updateGravity(self, isHitBottom):

        return self

 

代码比较简单,主要功能如下:

l  实现_getFrames()方法,根据方向设置不同的图片。

l  实现_updateKeys()方法,根据按键返回不同的状态子类。

l  实现updateGravity()方法,直接返回自身。因为站立状态时,下边界肯定是碰撞状态,继续保持站立状态即可,无需变更。

6.4.4  走路状态WalkState

WalkState类代码如下所示:

06\04\mario\state\WalkState.py

import pygame

from mario.state.MarioState import MarioState

from mario.state.JumpState import JumpState

 

from utils.utils import *

 

 

# 走路状态

class WalkState(MarioState):

    # 初始化

    def __init__(self, mario):

        super().__init__(mario, 80)

 

    # 设置动画帧

    def _getFrames(self, direction):

        cache = Globals.marioImageCache

        if direction == DIRECTION_RIGHT:

            return [cache.marioWalk1, cache.marioWalk2, cache.marioWalk3]

        else:

            return [cache.marioLeftWalk1, cache.marioLeftWalk2, cache.marioLeftWalk3]

 

    # 获取状态名称

    def getStateName(self):

        return "walk"

 

    # 处理按键(子类必须实现这个方法)

    def _updateKeys(self, mario, keys, deltaTime):

 

        # 是否滚动地图

        self.isScrollMap = False

 

        # 移动距离

        mario.walkStep = mario.walkSpeed * deltaTime / 1000.0

 

        # 按键状态

        if keys[pygame.K_LEFT]:

            print("按了左方向键")

 

            # 之前马里奥向右走,现在变成向左走,切换动画帧

            if mario.direction == DIRECTION_RIGHT:

                mario.direction = DIRECTION_LEFT

                self._setFrames(DIRECTION_LEFT)

 

            # 窗口边界判断

            if mario.rect.x - mario.walkStep <= 0:

                mario.walkStep = mario.rect.x

 

            # 下一步预判断

            nextStepRect = mario.rect.move(-1 * mario.walkStep, 0)

            pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)  # 是否碰撞

            if pipeIndex != -1:

                mario.walkStep = mario.rect.x - mapToScreen(Globals.game.objectArray[pipeIndex]).right

            mario.rect = mario.rect.move(-1 * mario.walkStep, 0)

        elif keys[pygame.K_RIGHT]:

            print("按了右方向键")

 

            # 之前马里奥向左走,现在变成向右走,切换动画帧

            if mario.direction == DIRECTION_LEFT:

                mario.direction = DIRECTION_RIGHT

                self._setFrames(DIRECTION_RIGHT)

 

            # 超过屏幕中线才滚动地图,地图末尾右半屏也不滚动

            if mario.rect.x >= SCREEN_WIDTH / 2 and screenToMap(

                    mario.rect).x < Globals.game.mapViewFromMax + SCREEN_WIDTH / 2:

 

                # 如果地图滚动后,有碰撞发生,则不能滚动

                # 地图向左滚动,相当于马里奥向右移动。

                # 这里不能修改mapViewFrom的值

                nextStepRect = mario.rect.move(mario.walkStep, 0)

                pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)

                if pipeIndex == -1:

                    self.isScrollMap = True

            else:

                # 窗口边界判断

                if mario.rect.right + mario.walkStep >= SCREEN_WIDTH:

                    mario.walkStep = SCREEN_WIDTH - mario.rect.right

 

                # 下一步预判断

                nextStepRect = mario.rect.move(mario.walkStep, 0)

                pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)

                if pipeIndex != -1:

                    mario.walkStep = mapToScreen(Globals.game.objectArray[pipeIndex]).x - mario.rect.right

                mario.rect = mario.rect.move(mario.walkStep, 0)

 

        # 1)如果跳跃,则返回JumpState

        # 2)只走路,不跳跃,返回WalkState

        # 3)没走路,没跳跃,返回StandState

        if keys[pygame.K_g]:

            print("按了跳跃键")

            return JumpState(mario)

        elif keys[pygame.K_LEFT] or keys[pygame.K_RIGHT]:

            return self

        else:

            # 没有走路,没有跳跃,则变成站立

            from mario.state.StandState import StandState

            return StandState(mario)

 

    # 重力处理,下边界碰撞

    def updateGravity(self, isHitBottom):

        return self

         代码实现类似,只是将走路状态的处理进行了封装。当马里奥改变方向时,只需重新设置动画帧即可。如果没有走路也没有跳跃,则返回站立状态子类。

         由于走路状态和站立状态二者之间存在交叉引用,因此将"import StandState"语句放在使用之前,而不是放在Python文件的顶部,避免交叉引用导致的错误。

6.4.5  跳跃状态JumpState

JumpState类的代码如下所示:

06\04\mario\state\JumpState.py

import pygame

from mario.state.MarioState import MarioState

from utils.utils import *

 

 

# 跳跃状态

class JumpState(MarioState):

    # 初始化

    def __init__(self, mario):

        super().__init__(mario, 80)

 

    # 设置动画帧

    def _getFrames(self, direction):

        cache = Globals.marioImageCache

        if direction == DIRECTION_RIGHT:

            return [cache.marioJump]

        else:

            return [cache.marioLeftJump]

 

    # 获取状态名称

    def getStateName(self):

        return "jump"

 

    # 处理按键(子类必须实现这个方法)

    def _updateKeys(self, mario, keys, deltaTime):

 

        # 是否滚动地图

        self.isScrollMap = False

 

        # 移动距离

        mario.walkStep = mario.walkSpeed * deltaTime / 1000.0

 

        # 按键状态

        if keys[pygame.K_LEFT]:

            print("按了左方向键")

 

            # 之前马里奥向右走,现在变成向左走,切换动画帧

            if mario.direction == DIRECTION_RIGHT:

                mario.direction = DIRECTION_LEFT

                self._setFrames(DIRECTION_LEFT)

 

            # 窗口边界判断

            if mario.rect.x - mario.walkStep <= 0:

                mario.walkStep = mario.rect.x

 

            # 下一步预判断

            nextStepRect = mario.rect.move(-1 * mario.walkStep, 0)

            pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)  # 是否碰撞

            if pipeIndex != -1:

                mario.walkStep = mario.rect.x - mapToScreen(Globals.game.objectArray[pipeIndex]).right

            mario.rect = mario.rect.move(-1 * mario.walkStep, 0)

        elif keys[pygame.K_RIGHT]:

            print("按了右方向键")

 

            # 之前马里奥向左走,现在变成向右走,切换动画帧

            if mario.direction == DIRECTION_LEFT:

                mario.direction = DIRECTION_RIGHT

                self._setFrames(DIRECTION_RIGHT)

 

            # 超过屏幕中线才滚动地图,地图末尾右半屏也不滚动

            if mario.rect.x >= SCREEN_WIDTH / 2 and screenToMap(

                    mario.rect).x < Globals.game.mapViewFromMax + SCREEN_WIDTH / 2:

 

                # 如果地图滚动后,有碰撞发生,则不能滚动

                # 地图向左滚动,相当于马里奥向右移动。

                # 这里不能修改mapViewFrom的值

                nextStepRect = mario.rect.move(mario.walkStep, 0)

                pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)

                if pipeIndex == -1:

                    self.isScrollMap = True

            else:

                # 窗口边界判断

                if mario.rect.right + mario.walkStep >= SCREEN_WIDTH:

                    mario.walkStep = SCREEN_WIDTH - mario.rect.right

 

                # 下一步预判断

                nextStepRect = mario.rect.move(mario.walkStep, 0)

                pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)

                if pipeIndex != -1:

                    mario.walkStep = mapToScreen(Globals.game.objectArray[pipeIndex]).x - mario.rect.right

                mario.rect = mario.rect.move(mario.walkStep, 0)

 

        if keys[pygame.K_g]:

            print("按了跳跃键")

 

            # 窗口边界判断

            if mario.rect.y - mario.jumpStep <= 0:

                mario.jumpStep = mario.rect.y

 

            # 下一步预判断

            nextStepRect = mario.rect.move(0, -1 * mario.jumpStep)

            pipeIndex = screenToMap(nextStepRect).collidelist(Globals.game.objectArray)

            if pipeIndex != -1:

                mario.jumpStep = (mapToScreen(Globals.game.objectArray[pipeIndex]).bottom - mario.rect.y) * -1

            mario.rect = mario.rect.move(0, -1 * mario.jumpStep)

 

        # 没有走路,没有跳跃,则是空中掉落状态,暂不处理

        if not (keys[pygame.K_LEFT] or keys[pygame.K_RIGHT] or keys[pygame.K_g]):

            pass

 

        return self

 

    # 重力处理,下边界碰撞

    def updateGravity(self, isHitBottom):

        # 掉落碰到物体,则转为站立状态

        if isHitBottom:

            from mario.state.StandState import StandState

            return StandState(self.mario)

        return self

         代码与走路状态类似。目前,跳跃处理与原作仍有一些差异,比如空中可以进行二次跳跃、可以改变方向、松开按键后直线掉落等,有待后续进一步优化。尽管还存在一些不完美之处,但整体效果已相当不错。