首先感谢B站的大佬博主Sli97的教学视频。不适合小白观看,但是这个大佬码力深厚,代码优美如诗,有一定基础的可以反复观摩学习。
联机游戏跟单机游戏最大的不同在于联机需要实时同步多个客户端的数据。
要研究清楚联机游戏的前后端交互逻辑,应该先了解单机游戏的运行逻辑。
这里介绍一种数据驱动的游戏框架。
state表明了当前客户端内所有可变元素的状态。如玩家有血条,位置,方向,速度等,子弹有位置,方向,速度,造成的伤害等。我们通过建立一个state,维护所有可变元素的状态。
export interface IState{
actor:IActor
bullets:IBullet[]
nextBulletId:number
}
export interface IVec2{
x:number
y:number
}
export interface IActor{
id:number
position:IVec2
direction:IVec2
hp:number
speed:number
}
export interface IBullet{
id:number
owener:number
position:IVec2
direction:IVec2
speed:number
}
以上是一个简单的示例,栗子中我们建立了IState这个接口用来管理全局游戏中的所有实体对象。
state : IState = {
actor:...
bullets:[
{...},
{...},
],
nextBulletId:1
}
这里的nextBulletId是一个很聪明的设计,每次实例化一个新bullet就使他自增加一,这样就可以管使每一个子弹都有自己的id。
state字段用来维护游戏内所有实体的状态,也就是说它储存了游戏内所有的数据。
为此,我们需要建立一个DataManager单例来维护游戏数据,处理用户输入,发出渲染指令。
拿最普通的摇杆JoyStick来说,从这个输入系统我们可以获取JoyStick当前的Direction。诸如此类,还可以获得玩家当前Position等数值。
所有的这些Input都作用于游戏数据的修改。比如玩家发射一颗子弹,我们需要提交Input到DataManager里,由DataManager来进行Input的处理,最后实例化出一颗子弹,并渲染到屏幕上。处理Input的函数可以是这样的
//---DataManager.ts---
applyInput(input:IClientInput){
switch(input.type){
case InputTypeEnum.ActorMove:{
...
break;
}
case InputTypeEnum.WeaponShoot:{
...
break;
}
...
default:break;
}
}
我们将Input封装为一个对象,其中指定属性type为InputTypeEnum枚举中的一个值,然后在switch的分支中处理每一个Input,将数据变化应用到DataManager的state字段。需要注意的是,DataManager的state里维护的数据是静态的,代表的是一个时刻所有的游戏数据。为了让游戏物体动态运作起来,我们需要抽象出一种时间Input,并且周期性的应用它。一旦应用这个Input,就对游戏内的所有实体进行动态数据修改。
比如说,我拉动摇杆使游戏对象的方向发生改变,但游戏对象的位置并不会立刻改变,因为位置的变化量是速度对时间的累积效应。当进入时间Input的处理逻辑之后,才执行以下代码
actor.position.x += actor.speed*dt*actor.direction.x
actor.posiyion.y += actor.speed*dt*actor.direction.y
这样,我们的applyInput函数的结构就清晰了。
//---DataManager.ts---
applyInput(input:IClientInput){
switch(input.type){
case InputTypeEnum.ActorMove:{
(执行立即改变游戏数据的方法,如玩家的方向变化,血条变化)
...
break;
}
case InputTypeEnum.WeaponShoot:{
...
(执行立即改变游戏数据的方法,如子弹的实例化事件注册)
break;
}
...
case InputTypeEnum.TimePast:{
(执行所有需要依赖时间累积效应的游戏数据的变化方法,如玩家位置变化,子弹位置变化)
break;
}
default:break;
}
}
在第二部分里面我们介绍如何分类Input并将其作用于游戏数据上。但有时候仅仅修改游戏数据是不行的。如果我们想要做出子弹的爆炸效果,一个很正常的想法是,在子弹销毁的时刻之前,播放子弹爆炸的动画,然后销毁子弹。这就需要我们获取子弹的引用,通过子弹id找到子弹,然后处理很多细节。但是这会让我们的DataManager显得很臃肿。DataManager作为数据的集散中心,不应该处理具体的渲染逻辑。所以我们引入了事件系统,通过建立一个EventManager来更好的管理事件。
事件系统跟数据系统一样都是单例。
事件系统允许在任何一个地方发送事件(emit),允许在任何一个地方监听并绑定事件(on),允许事件的解绑(off)。
一个事件可能会有多个绑定函数。
我们通过一个Map来维护所有的事件以及它对应的绑定函数。
private eventMap: Map> = new Map();
提前把所有可能触发的事件放在一个EventEnum枚举中,这里的IItem就是事件绑定的函数
IItem接口如下
export interface IItem{
cb: Fuction
ctx: unknown
}
ctx即绑定函数对应的上下文。
on(event: EventEnum, cb: Function, ctx: unknown) {
if (this.map.has(event)) {
this.map.get(event).push({ cb, ctx });
} else {
this.map.set(event, [{ cb, ctx }]);
}
}
off(event: EventEnum, cb: Function, ctx: unknown) {
if (this.map.has(event)) {
const index = this.map.get(event).findIndex((i) => cb === i.cb && i.ctx === ctx);
index > -1 && this.map.get(event).splice(index, 1);
}
}
emit(event: EventEnum, ...params: unknown[]) {
if (this.map.has(event)) {
this.map.get(event).forEach(({ cb, ctx }) => {
cb.apply(ctx, params);
});
}
}
clear() {
this.map.clear();
}
事件系统内的三个函数如上定义。