使用cocos实现Cramped-Room-Of-Death

最近发现了一个cocos宝藏教学 [CocosCreator游戏开发教学]Steam游戏《Cramped Room Of Death》复刻教程(已完结),学到了很多东西。本文主要记录一下相关的收获

<!--more-->

非常感谢原作者提供的游戏素材、游戏思路和教学视频!!

整个项目放在github上面了

1. 各种单例

cocos的基本脚本开发单元是Component,这些组件之间的通信需要借助一些全局的对象

1.1. Singleton 单例

typescript实现一个单例类还是比较简单的

export default class Singleton {
  private static _instance: any = null

  static getInstance<T>(): T {
    if (this._instance === null) {
      this._instance = new this()
    }

    return this._instance
  }
}

1.2. DataManager全局数据管理

数据管理器可以存放游戏需要的各种数据,比如地图信息、玩家初始状态、敌人初始位置等参数。

export default class DataManager extends Singleton {
  static get Instance() {
    return super.getInstance<DataManager>()
  }
  // 保存各种数据
  mapInfo: Array<Array<ITile>> = []
}

// 使用的话也很简单
console.log(DataManager.Instance.mapInfo)

除了保存基本的数据外,还有一些场景,也可以灵活使用DataManager。

比如要判断玩家是否处于可以攻击敌人的状态时有两种方案

  • 通过函数参数的方式,将敌人组件传传给玩家
  • 将敌人组件挂载到全局对象上,然后在玩家组件中就可以直接通过全局对象访问敌人组件

从设计上来看,第一种方式更解耦,但从灵活性和实现上来看,第二种方案更方便,因此可以用将敌人组件和玩家组件都挂载到DataManager上面。

export default class DataManager extends Singleton {
  // ...
  player: PlayerManager = null
  enemies: EnemyManager[] = []
}

这样就可以直接遍历敌人列表,判断是否有敌人可以被攻击了。

@ccclass('PlayerManager')
export class PlayerManager extends EntityManager {
  willAttack(dir: CONTROLLER_NUM): EntityManager | null {
    const enemies = DataManager.Instance.enemies
    for (const enemy of enemies) {
      const { x: enemyX, y: enemyY, isDie } = enemy
      if (isDie) continue
      if (checkAttackRange(player, enemy)) {
        this.state = ENTITY_STATE_ENUM.ATTACK
        return enemy
      }
    }
    return null
  }
}

1.3. EventManager事件总线

事件通信是系统各模块解耦的好帮手。

interface EventRecord {
  func: Function,
  ctx: unknown
}

export default class EventManager extends Singleton {
  static get Instance() {
    return super.getInstance<EventManager>()
  }

  eventDic: Map<string, EventRecord[]> = new Map()

  on(name: string, func: Function, ctx?: unknown) {
    const list = this.eventDic.get(name) || []
    list.push({ func, ctx })
    this.eventDic.set(name, list)
  }

  off(name, func) {
    const idx = this.eventDic.get(name)?.findIndex(row => row.func === func)
    idx > -1 && this.eventDic.get(name).splice(idx, 1)
  }

  emit(name, ...params: unknown[]) {
    this.eventDic.get(name)?.forEach((i) => {
      i.ctx ? i.func.apply(i.ctx, params) : i.func(...params)
    })
  }

  clear() {
    this.eventDic.clear()
  }
}

在组件创建时监听其关心的事件,在其销毁时注销监听的事件,比如敌人需要监听被攻击的事件,当被攻击时,播放死亡动画

export class EnemyManager extends EntityManager {
    init(params: IEntity) {
      super.init(params)
      EventManager.Instance.on(EVENT_ENUM.ATTACK_ENEMY, this.onDie, this)
    }

    onDestroy() {
      super.onDestroy()
      EventManager.Instance.off(EVENT_ENUM.ATTACK_ENEMY, this.onDie)
    }

    onDie(target: EnemyManager) {
      if (target === this && !this.isDie) {
        this.state = ENTITY_STATE_ENUM.DEATH
      }
    }
}

这样玩家组件和敌人组件都无需关注彼此了。

1.4. 其他

比如全局弹窗DialogManager、资源管理器ResourcesManager等,都可以封装成单例,然后在各个模块灵活使用。

2. 动画状态机

动画是游戏的重要组成部分。当节点具备不同状态时,需要播放对应的动画,如何在正确的时机播放这些动画是非常重要的。

2.1. 单个动画

首先是单个动画,比如帧动画由一组图片组成。比起在动画编辑器创建动画,使用程序创建动画更容易维护与迁移

const spriteFrames = await ResourceManager.Instance.loadDir(this.path)

const track = new animation.ObjectTrack() // 创建一个向量轨道
track.path = new animation.TrackPath().toComponent(Sprite).toProperty('spriteFrame')

const frames: Array<[number, SpriteFrame]> = spriteFrames.map((item, index) => [
    index * ANIMATION_SPEED,
    item,
])
track.channel.curve.assignSorted(frames)


this.animationClip = new AnimationClip()
this.animationClip.addTrack(track)

this.animationClip.name = this.path
this.animationClip.duration = frames.length * ANIMATION_SPEED
this.animationClip.wrapMode = this.wrapMode

得到animationClip之后,就可以使用动画组件进行播放了

this.animationComponent.defaultClip = this.animationClip
this.animationComponent.play()

当需要在某个节点上播放多个类似的动画时,初始化动画、切换动画状态就会变得非常复杂。比如某个人物,有4个方向,每个方向上有静止的idle动画,切换方向时,有方向切换的turn动画

可以借助状态机来实现。

状态机的核心思想是:先定义拥有的状态,再定义每个状态切换时对应的逻辑

2.2. 定义状态

什么是状态,状态就是将这一堆动画给抽象出来,在动画状态机中,每种动画可以理解为一种状态

export default class AnimateState {

  animationClip: AnimationClip

  constructor(private fsm: PlayerStateMachine, private path: string, private wrapMode: AnimationClip.WrapMode = AnimationClip.WrapMode.Normal) {
    this.init()
  }

  init() {
    // 上面的初始化 this.animationClip逻辑
  }

  run() {
    this.fsm.animationComponent.defaultClip = this.animationClip
    this.fsm.animationComponent.play()
  }
}

定义run方法就是在切换到对应状态后,需要执行的逻辑,这里当然就是播放动画了。

2.3. 状态维护

状态机需要维护多个状态,并在满足某种条件时,选择一个状态作为当前状态

export abstract class AnimationStateMachine extends Component {
  params: Map<string, IParamsValue> = new Map()
  stateMachines: Map<string, AnimateState> = new Map()

  private _currentState: AnimateState
  get currentState() {
    return this._currentState
  }

  set currentState(val) {
    this._currentState = val
    // 切换状态时的逻辑,调用对应state的run方法
    this._currentState.run()
  }

  // 初始化定义当前状态机的所有状态
  abstract init();

  // 状态切换逻辑
  abstract run();
}

比如一个玩家动画状态机

@ccclass('PlayerStateMachine')
export class PlayerStateMachine extends AnimationStateMachine {
  animationComponent: Animation

  init() {
    this.animationComponent = this.addComponent(Animation)
    this.initStateMachines()
  }

  initStateMachines() {
    // this作为fsm传入State中
    this.stateMachines.set(PARAMS_NAME_ENUM.IDLE, new AnimateState(this, 'texture/player/idle/top', AnimationClip.WrapMode.Loop))
    this.stateMachines.set(PARAMS_NAME_ENUM.TURN_LEFT, new AnimateState(this, 'texture/player/turnleft/top'))
  }

  // 待完善
  run() {
    switch (this.currentState) {
      case this.stateMachines.get(PARAMS_NAME_ENUM.TURN_LEFT):
        // todo
      case this.stateMachines.get(PARAMS_NAME_ENUM.IDLE):
        // todo
      default:
    }
  }
}

2.4. 状态切换

上面的run方法主要用于更新this.currentState,思考一下,状态应该会在什么场景下切换?比如按下某个方向键?

为了保存状态机的封闭特性,不让外部感知currentState的变化,状态机可以提供一些接口,在接口中调用run方法。

比如提供一个初始值count和一个add接口,当count=1时切换成状态1,count=2时切换成状态2,诸如此类。

为了保持高度抽象,我们为状态机提供一个params字段,用于维持各种数据的引用,并在run中通过判断数据值,来切换状态即可

export type ParamsValueType = number | boolean

export interface IParamsValue {
  type: FSM_PARAMS_TYPE_ENUM,
  value: ParamsValueType
}

export abstract class AnimationStateMachine extends Component {
    // 其他同上
    params: Map<string, IParamsValue> = new Map()

    getParams(name: string) {
        if (this.params.has(name)) {
            return this.params.get(name).value
        }
    }

    setParams(name: string, value: ParamsValueType) {
        if (this.params.has(name)) {
            this.params.get(name).value = value
            // 这里就实现了值改变时切换状态
            this.run()
            this.resetTrigger()
        }
    }

    resetTrigger() {
        for (const [_, item] of this.params) {
            if (item.type === FSM_PARAMS_TYPE_ENUM.TRIGGER) {
                item.value = false
            }
        }
    }
}

定义的值类型ParamsValueType有两种

  • trigger,布尔值,可以快速根据值是否为真进行状态切换,只在过渡时使用,state切换完毕后所有trigger会恢复原样,所以需要实现一个resetTrigger
  • number,数字,更通用的值判断切换

在初始化状态机时,除了初始化状态,还需要初始化参数

export const getInitParamsTrigger = () => {
  return {
    type: FSM_PARAMS_TYPE_ENUM.TRIGGER,
    value: false,
  }
}

export class PlayerStateMachine extends AnimationStateMachine {
  animationComponent: Animation

  init() {
    this.animationComponent = this.addComponent(Animation)
    this.initParams()
    this.initStateMachines()
  }

  initParams() {
    // 初始化各种值,当值变化时,会更新状态
    this.params.set(PARAMS_NAME_ENUM.IDLE, getInitParamsTrigger())
    this.params.set(PARAMS_NAME_ENUM.TURN_LEFT, getInitParamsTrigger())
  }
}

现在就可以来完善状态机的run方法了

export class PlayerStateMachine extends AnimationStateMachine {
    run() {
        switch (this.currentState) {
            case this.stateMachines.get(PARAMS_NAME_ENUM.TURN_LEFT):
            case this.stateMachines.get(PARAMS_NAME_ENUM.IDLE):
                // 根据值来切换状态
                if (this.params.get(PARAMS_NAME_ENUM.TURN_LEFT).value) {
                    this.currentState = this.stateMachines.get(PARAMS_NAME_ENUM.TURN_LEFT)
                } else if (this.params.get(PARAMS_NAME_ENUM.IDLE).value) {
                    this.currentState = this.stateMachines.get(PARAMS_NAME_ENUM.IDLE)
                }
                break
            default:
                this.currentState = this.stateMachines.get(PARAMS_NAME_ENUM.IDLE)
                break
        }
    }
}

现在,就可以通过fsm.setParams来更新值,内部调用fsm.run方法来实现currentState的切换了

@ccclass('PlayerManager')
export class PlayerManager extends Component {
    fsm:PlayerStateMachine

    init() {
        // 初始化状态机
        this.fsm = this.addComponent(PlayerStateMachine)
        this.fsm.init()

        // 状态机的初始状态
        this.fsm.setParams(PARAMS_NAME_ENUM.IDLE, true)

        // 监听按键
        EventManager.Instance.on(EVENT_ENUM.PLAYER_CTRL, this.move, this)
    }

    move(dir: CONTROLLER_NUM) {
        switch (dir) {
            case CONTROLLER_NUM.TURN_LEFT:
                // 切换状态机的状态
                this.fsm.setParams(PARAMS_NAME_ENUM.TURN_LEFT, true)
                break
        }
    }
}

加入params的步骤,看起来比较饶,但这样外部就无需关注状态机内部的currentState了,维护起来更加方便。

2.5. 子状态机

目前为止状态机都运行良好,在initStateMachines中定义了多个AnimateState,就可以快速封装各种状态的切换了。

但在某些场景下,AnimateState的数量会非常多。为了减少在状态机中run方法的体积,可以使用子状态机。

子状态机可以理解为一种聚合的AnimateState,其内部会自动处理某一类相关连的状态的切换,此外由于它还要实现状态切换相关的功能,因此可以看做是局部的状态机,这也是它为什么叫做状态机的原因。

export abstract class AnimationSubStateMachine {
  stateMachines: Map<string, AnimateState> = new Map()

  private _currentState: AnimateState

  constructor(public fsm: AnimationStateMachine) {

  }

  get currentState() {
    return this._currentState
  }

  set currentState(val: AnimateState) {
    this._currentState = val
    // 切换状态时的逻辑
    this._currentState.run()
  }

  // 用于切换currentState
  abstract run();
}

来实现一些具体的子状态机

const BASE_URL = 'texture/player/idle'

export default class IdleStateMachine extends AnimationSubStateMachine {
    constructor(fsm: PlayerStateMachine) {
        super(fsm)
        this.stateMachines.set(DIRECTION_ENUM.TOP, new AnimateState(fsm, `${BASE_URL}/top`, AnimationClip.WrapMode.Loop))
        this.stateMachines.set(DIRECTION_ENUM.LEFT, new AnimateState(fsm, `${BASE_URL}/left`, AnimationClip.WrapMode.Loop))
        this.stateMachines.set(DIRECTION_ENUM.BOTTOM, new AnimateState(fsm, `${BASE_URL}/bottom`, AnimationClip.WrapMode.Loop))
        this.stateMachines.set(DIRECTION_ENUM.RIGHT, new AnimateState(fsm, `${BASE_URL}/right`, AnimationClip.WrapMode.Loop))
    }

    run() {
        const value = this.fsm.getParams(PARAMS_NAME_ENUM.DIRECTION)
        this.currentState = this.stateMachines.get(DIRECTION_ORDER_ENUM[value as number])
    }
}

在主状态机中,就无需在定义多个AnimateState,只需要定义IdleStateMachine这种的子状态机即可,比如还可以定义TurnLeftStateMachine,他与IdleStateMachine的实现基本类似,这里不再赘述。

// 扩展stateMachines支持的类型
type State = AnimateState | AnimationSubStateMachine

export abstract class AnimationStateMachine extends Component {
  params: Map<string, IParamsValue> = new Map()
  stateMachines: Map<string, State> = new Map()

  private _currentState: State
}

export class PlayerStateMachine extends AnimationStateMachine {
    initStateMachines() {
        // 初始化各种状态,每种状态只需定义一次
        // this.stateMachines.set(PARAMS_NAME_ENUM.IDLE, new AnimateState(this, 'texture/player/idle/top', AnimationClip.WrapMode.Loop))
        // this.stateMachines.set(PARAMS_NAME_ENUM.TURN_LEFT, new AnimateState(this, 'texture/player/turnleft/top'))

        this.stateMachines.set(PARAMS_NAME_ENUM.IDLE, new IdleStateMachine(this))
        this.stateMachines.set(PARAMS_NAME_ENUM.TURN_LEFT, new TurnLeftStateMachine(this))
    }
}

这样,在切换PlayerStateMachinecurrentState时,就可以借助AnimationSubStateMachine来处理某一类状态的切换了,PlayerStateMachine的代码可以保持的很简洁,只负责整体的状态切换,以及对外暴露更改值的接口即可。

在切换子状态机时,也是通过修改状态机的this.currentState来实现的,跟一个普通的State一致。

3. 游戏实体

Entity代表游戏中的各个事务,实体既没有行为也没有数据;相反,它标识哪些数据属于一起。系统提供行为,而组件存储数据。

在Cramped-Room-Of-Death的游戏场景中,包含多个实体,包括

  • 玩家PlayerManger ,可以上下左右移动,可以向左或向右转向
  • 白骷髅IronSkeletonManger,普通敌人障碍,无法移动,无法攻击,可以被玩家攻击
  • 持刀骷髅WoodenSkeletonManager,无法移动,当玩家位于其周围四格时,会攻击玩家,可以被玩家攻击
  • 地裂陷阱BurstManger,玩家从上面经过后会塌陷,无法再移动到上面去
  • 地刺陷阱SpikesManger,玩家从上面经过时,如果地刺貌似,会导致玩家死亡

实体都有自己的动画状态机,并在对应状态切换时,播放对应的动画,包括idle普通动画、death死亡动画、attack攻击动画

实体会通过EventManager,在初始化时注册自己关心的事件,在某些时间也会广播自己触发的事件

DataManager会在关卡初始化时保持某些实体组件,方便后续逻辑处理,比如实现undo操作等

我之前一直没有实体这个概念,一直按照Cocos的节点和组件来编写代码,现在发现自己是在是太愚钝了。有了实体的概念之后,整个游戏的框架就非常明确了。

4. 小结

跟着这个项目学完之后,感觉受益颇深。之前总是苦于不知道如何将动画与游戏逻辑结合起来,看了状态机的真正用途之后,有一种豁然开朗的感觉。趁热打铁,我会去做一些其他的游戏demo,感觉上道了!!!