前一章我们详细讲解了一种数据驱动的单机游戏框架。
主要思路为,将游戏内所有实体的状态储存在DataManager的state字段下,从输入系统拿到Input,调用DataManager中的applyInput方法进行处理。
其中有一种特殊的Input,作为时间流逝的量度,周期性地使用applyInput方法应用它,达成某些事件累积状态发生改变的效果。
这一章将要讲解状态机和动态创建。
状态机是一种很常见的概念。在游戏中,一个实体如果只具有有限状态的动画,我们就可以使用状态机进行实体的控制。
举个栗子,一个玩家可能有站着和跑着两种状态,这两种状态下播放的动画是不同的。如果我们想当玩家移动时播放跑着的动画,当玩家站着时播放静止的动画,可以将玩家的两种状态封装到一个状态机中。
如果输入系统没有Input,我们就认为玩家静止,然后设置state为idle。如果输入系统有Input,我们就认为玩家正在跑动,就设置state为run。这样就可以很方便的控制动画的播放。
在Cocos的3.4版本官方开始引入动画状态机的概念,称为Marionette 动画系统。在此之前,需要手动进行状态机的编码。一个只有idle和run两种状态的状态机编码示例如下
import { _decorator, Animation, AnimationClip } from "cc";
import State from "../../Base/State";
import StateMachine, { getInitParamsTrigger } from "../../Base/StateMachine";
import { EntityTypeEnum } from "../../Common";
import { EntityStateEnum, ParamsNameEnum } from "../../Enum";
const { ccclass } = _decorator;
@ccclass("ActorStateMachine")
export class ActorStateMachine extends StateMachine {
init(type: EntityTypeEnum) {
this.type = type;
this.animationComponent = this.node.addComponent(Animation);
this.initParams();
this.initStateMachines();
this.initAnimationEvent();
}
initParams() {
this.params.set(ParamsNameEnum.Idle, getInitParamsTrigger());
this.params.set(ParamsNameEnum.Run, getInitParamsTrigger());
}
initStateMachines() {
this.stateMachines.set(ParamsNameEnum.Idle, new State(this, `${this.type}${EntityStateEnum.Idle}`, AnimationClip.WrapMode.Loop));
this.stateMachines.set(ParamsNameEnum.Run, new State(this, `${this.type}${EntityStateEnum.Run}`, AnimationClip.WrapMode.Loop));
}
initAnimationEvent() {}
run() {
switch (this.currentState) {
case this.stateMachines.get(ParamsNameEnum.Idle):
case this.stateMachines.get(ParamsNameEnum.Run):
if (this.params.get(ParamsNameEnum.Run).value) {
this.currentState = this.stateMachines.get(ParamsNameEnum.Run);
} else if (this.params.get(ParamsNameEnum.Idle).value) {
this.currentState = this.stateMachines.get(ParamsNameEnum.Idle);
} else {
this.currentState = this.currentState;
}
break;
default:
this.currentState = this.stateMachines.get(ParamsNameEnum.Idle);
break;
}
}
}
这种写法显然比较麻烦,建议使用3.4以上版本的Cocos进行可视化状态机制作。
也举个例子。玩家射击时发射的子弹,本来是不存在的。我们需要它出现在玩家发射的一瞬间的枪口上。这就需要用到动态创建的概念。
一个比较好的思路是,在DataManager中建立资源的Map用于维护各种需要加载或者复用的资源。在游戏初始化的时候,把所有资源都加载到DataManager的Map中。比如,我可能需要加载子弹的prefab,然后在战斗过程中动态的加载它。为此我需要先建立一个资源名称和资源路径的映射Map
resourceMap:<ResourceTypeEnum,string> = new Map()
然后建立一个prefabMap来将资源名称映射到对应的的prefab上
prefabMap:<ResourceTypeEnum,cc.Prefab> = new Map()
在游戏初始化的时候对这个map进行赋值,假定我们的Bullet预制体位于resources的bullet/bullet1
onLoad(){
DataManager.Instance.resourceMap.set(ResourceTypeEnum.Bullet,'bullet/bullet1')
}
然后加载资源
cc.resources.load(DataManager.Instance.resourceMap.get(ResouceTypeEnum.Bullet),cc.Prefab,(pre)=>{
DataManager.Instance.prefabMap.set(ResouceTypeEnum.Bullet,pre)
})
加载完毕后,我们就可以通过DataManager里面的prefabMap映射方便地获取到子弹的prefab,然后进行动态创建。
一个可能的代码示例如下
onLoad(){
EventManager.Instance.on(EventTypeEnum.PlayerShoot,this.createBullet,this)
...
}
createBullet(position:cc.Vec2,direction:cc.Vec2){
const bullet = cc.instantiate(DataManager.Instance.prefabMap.get(ResourceTypeEnum.Bullet))
DataManager.Instance.stage.addChild(bullet)
bullet.setPosition(position.x,position.y)
const angle =
direction.x > 0
? rad2Angle(Math.asin(direction.y / side))
: rad2Angle(Math.asin(-direction.y / side)) + 180;
this.node.setRotationFromEuler(0, 0, angle);
}
在这段代码中,我们先监听了PlayerShoot这个事件,当玩家发射子弹触发这个事件的时候,我们就调用createBullet这个函数,传入position和direction两个参数。通过资源名称,利用DataManager中的map映射到对应的prefab,获取prefab之后,我们调用instantiate方法进行实例化,将新生成子弹的父节点设为舞台,然后设置子弹的位置和方向。
这仅仅是一个很简单的动态创建栗子。值得注意的是,动态创建实体很消耗电脑的性能。更加优越的解决方案是建立对象池。这个我们下节再讲。