使用cocos实现Cramped-Room-Of-Death
最近发现了一个cocos宝藏教学 [CocosCreator游戏开发教学]Steam游戏《Cramped Room Of Death》复刻教程(已完结),学到了很多东西。本文主要记录一下相关的收获
非常感谢原作者提供的游戏素材、游戏思路和教学视频!!
整个项目放在github上面了
各种单例
cocos的基本脚本开发单元是Component
,这些组件之间的通信需要借助一些全局的对象
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
}
}
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
}
}
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
}
}
}
这样玩家组件和敌人组件都无需关注彼此了。
其他
比如全局弹窗DialogManager、资源管理器ResourcesManager等,都可以封装成单例,然后在各个模块灵活使用。
动画状态机
动画是游戏的重要组成部分。当节点具备不同状态时,需要播放对应的动画,如何在正确的时机播放这些动画是非常重要的。
单个动画
首先是单个动画,比如帧动画由一组图片组成。比起在动画编辑器创建动画,使用程序创建动画更容易维护与迁移
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
动画
可以借助状态机来实现。
状态机的核心思想是:先定义拥有的状态,再定义每个状态切换时对应的逻辑
定义状态
什么是状态,状态就是将这一堆动画给抽象出来,在动画状态机中,每种动画可以理解为一种状态
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
方法就是在切换到对应状态后,需要执行的逻辑,这里当然就是播放动画了。
状态维护
状态机需要维护多个状态,并在满足某种条件时,选择一个状态作为当前状态
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:
}
}
}
状态切换
上面的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
了,维护起来更加方便。
子状态机
目前为止状态机都运行良好,在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))
}
}
这样,在切换PlayerStateMachine
的currentState
时,就可以借助AnimationSubStateMachine
来处理某一类状态的切换了,PlayerStateMachine
的代码可以保持的很简洁,只负责整体的状态切换,以及对外暴露更改值的接口即可。
在切换子状态机时,也是通过修改状态机的this.currentState
来实现的,跟一个普通的State一致。
游戏实体
Entity
代表游戏中的各个事务,实体既没有行为也没有数据;相反,它标识哪些数据属于一起。系统提供行为,而组件存储数据。
在Cramped-Room-Of-Death的游戏场景中,包含多个实体,包括
- 玩家
PlayerManger
,可以上下左右移动,可以向左或向右转向 - 白骷髅
IronSkeletonManger
,普通敌人障碍,无法移动,无法攻击,可以被玩家攻击 - 持刀骷髅
WoodenSkeletonManager
,无法移动,当玩家位于其周围四格时,会攻击玩家,可以被玩家攻击 - 地裂陷阱
BurstManger
,玩家从上面经过后会塌陷,无法再移动到上面去 - 地刺陷阱
SpikesManger
,玩家从上面经过时,如果地刺貌似,会导致玩家死亡
实体都有自己的动画状态机,并在对应状态切换时,播放对应的动画,包括idle普通动画、death死亡动画、attack攻击动画
实体会通过EventManager,在初始化时注册自己关心的事件,在某些时间也会广播自己触发的事件
DataManager会在关卡初始化时保持某些实体组件,方便后续逻辑处理,比如实现undo操作等
我之前一直没有实体这个概念,一直按照Cocos的节点和组件来编写代码,现在发现自己是在是太愚钝了。有了实体的概念之后,整个游戏的框架就非常明确了。
小结
跟着这个项目学完之后,感觉受益颇深。之前总是苦于不知道如何将动画与游戏逻辑结合起来,看了状态机的真正用途之后,有一种豁然开朗的感觉。趁热打铁,我会去做一些其他的游戏demo,感觉上道了!!!
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。