# Sli97-Cocos网络对战游戏开发 **Repository Path**: choubaodxs/Sli97-cocos-online-battle-game ## Basic Information - **Project Name**: Sli97-Cocos网络对战游戏开发 - **Description**: 《Cocos Node.js 网络对战游戏开发》 视频地址:https://www.bilibili.com/video/BV1jW4y1M7uK/ - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-07-11 - **Last Updated**: 2025-07-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 《Cocos Node.js 网络对战游戏开发》 关键词 Cocos Creator、Node.js、实时对战、IO、帧同步 视频地址:https://www.bilibili.com/video/BV1jW4y1M7uK/ start-demo:https://gitee.com/sli97/cocos-nodejs-io-game-start-demo 成品仓库:https://gitee.com/sli97/cocos-nodejs-io-game [TOC] ### 1.简介 拉取项目初始代码:`https://gitee.com/sli97/cocos-nodejs-io-game-start-demo` apps 目录包含客户端 client 和服务端 server,使用 cocos 打开 client 需要全局安装一下 yarn:`npm i yarn -g`,进入到项目根目录执行`yarn`命令安装依赖 一些目录介绍: - apps/server/src/Base:存放以前教程的单例代码 - apps/server/src/Utils:同步服务端的Common目录给客户端,保持一些代码同步,执行`yarn dev`在启动服务端的同时就会同步代码 - apps/client/assets/Scripts/Global:以前教程写的 DataManager、EventManager、ResourceManager ### 2.虚拟摇杆 1. 创建一个场景`apps/client/assets/Scenes/Battle.scene`,在 Canvas 下创建两个空节点 Stage 和 UI 2. 把资源里的`resources/texture/bg/tile.png`拖到 Stage 节点下并将节点名改为 Map 3. 为了让 Map 组件填满背景,给 Map 和 Stage 都添加一个 widget 组件并将四个方向的距离都设置为 0 4. 将 Map 的 Sprite.Type 属性设置为 TILED,让图片重复铺满背景而不是默认的拉伸 5. 在 UI 节点下创建一个空节点 JoyStick,并创建文件`Scripts/UI/JoyStickManager.ts`挂载到 JoyStick 节点。代码如下,可以运行起来点击一下试试效果,输出的坐标是以左下角为原点(0,0)的 ```ts @ccclass('JoyStickManager') export class JoyStickManager extends Component { onLoad() { input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); } onDestroy() { input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); } onTouchStart(event: EventTouch) { console.log(event.getUILocation()) } } ``` 6. 现在 JoyStick 组件在屏幕中间,要把它的锚点设置到左下角:给 JoyStick 和 UI 节点都添加 widget 组件并将四周距离都设置为 0 来撑满屏幕,然后将 JoyStick 的 cc.UITransform.(Achor Point)设置为(0,0) 7. 在 JoyStick 下新建一个空节点 Body,将图片`resources/texture/joystick/circle.png`拖到该节点下,并调整 circle 和 Boy 的大小 cc.UITransform.(Content Size)均为(200,200)。给 Body 添加 widget 组件并设置为靠左和靠下,距离均为 50 8. 将 circle 重命名为 Base,并将 cc.Sprite.Color 里的透明度 A 值改为 100。复制一份 Base 重命名为 Stick,并将缩放属性 Node.Scale 设置为(0.4,0.4),颜色透明度改回 255 9. 修改 JoyStickManager.ts,实现触摸移动时修改 JoyStick 和 Stick 坐标并记录移动值 ```ts @ccclass('JoyStickManager') export class JoyStickManager extends Component { input: Vec2 = Vec2.ZERO private body: Node private stick: Node private defaultPos: Vec2 private radius: number onLoad() { this.body = this.node.getChildByName('Body') this.stick = this.body.getChildByName('Stick') this.defaultPos = new Vec2(this.body.position.x, this.body.position.y) this.radius = this.body.getComponent(UITransform).contentSize.x / 2 input.on(Input.EventType.TOUCH_START, this.onTouchStart, this) input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this) input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this) } onDestroy() { input.off(Input.EventType.TOUCH_START, this.onTouchStart, this) input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this) input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this) } onTouchStart(e: EventTouch) { const touchPos = e.getUILocation() this.body.setPosition(touchPos.x, touchPos.y) console.log(e.getUILocation()) } onTouchMove(e: EventTouch) { const touchPos = e.getUILocation() // Stick 节点在 Body 节点下,所以坐标需要转换成相对 Body 的坐标,并且限制在半径范围内 const stickPos = new Vec2(touchPos.x - this.body.position.x, touchPos.y - this.body.position.y) if (stickPos.length() > this.radius) { stickPos.multiplyScalar(this.radius / stickPos.length()) } this.stick.setPosition(stickPos.x, stickPos.y) this.input = stickPos.clone().normalize() console.log(`JoyStick Input: ${this.input.x}, ${this.input.y}`) } onTouchEnd() { this.body.setPosition(this.defaultPos.x, this.defaultPos.y) this.stick.setPosition(0, 0) this.input = Vec2.ZERO } } ``` ### 3.角色移动数据 逻辑流程:封装操作Input——>ApplyInput函数——>DataManager State 字段——>BattleManager——>ActorManager 驱动游戏实体(角色、子弹)更新逻辑(数据)和渲染(UI) 1. 编写`Scripts/Global/DataManager.ts` ```ts const ACTOR_SPEED = 100 export default class DataManager extends Singleton { static get Instance() { return super.GetInstance(); } jm: JoyStickManager state: IState = { actors: [ { id: 1, position: { x: 0, y: 0, }, direction: { x: 1, y: 0, } } ] } applyInput(input: IActorMove) { const { id, dt, direction: { x, y }, } = input const actor = this.state.actors.find(actor => actor.id === id) if (!actor) { return } actor.direction.x = x actor.direction.y = y actor.position.x += x * dt * ACTOR_SPEED actor.position.y += y * dt * ACTOR_SPEED } } ``` 2. 编写`Scripts/Scene/BattleManager.ts` ```ts @ccclass('BattleManager') export class BattleManager extends Component { private stage: Node private ui: Node onLoad() { this.stage = this.node.getChildByName("Stage") this.ui = this.node.getChildByName("UI") DataManager.Instance.jm = this.ui.getComponentInChildren("JoyStickManager") as any } } ``` 3. 编写`Scripts/Entity/Actor/ActorManager.ts` ```ts @ccclass('ActorManager') export class ActorManager extends Component { onLoad() { } update(dt) { if(DataManager.Instance.jm.input.length()) { const {x, y} = DataManager.Instance.jm.input DataManager.Instance.applyInput({ id: 1, type: InputTypeEnum.ActorMove, direction: {x, y}, dt, }) console.log(DataManager.Instance.state.actors[0].position) } } } ``` 4. BattleManager.ts 挂载到 Canvas 上 5. Stage 节点下创建一个 Sprite,命名为 Actor,cc.Sprite.(Sprite Frame)设置为`resources/texture/actor/actor1/idle/idle (1).png`,cc.UITransform.(Content Size)设置为(100,100),挂载 ActorManager.ts。 ### 4.角色移动渲染 1. 在 resources 目录下创建 prefab 文件夹,把场景里的 Map 和 Actor 都拖到这个文件夹里做成预制体,并且取消这两个节点和预制体的关联。删除 Actor 预制体上的 ActorManager.ts 脚本,后面会通过代码添加。 2. DataManager.ts 添加两个 Map 用于保存数据: ```ts actorMap: Map = new Map() // 场景里的角色 prefabMap: Map = new Map() // 加载的预制体 ``` 3. ActorManager.ts 里添加两个方法: ```ts init(data: IActor) {} render(data: IActor) { this.node.setPosition(data.position.x, data.position.y) } ``` 4. BattleManage.ts 添加加载预制体逻辑以及渲染角色并移动逻辑: ```ts @ccclass('BattleManager') export class BattleManager extends Component { private stage: Node private ui: Node private shouldUpdate = false // 所有资源加载完才允许 update onLoad() { this.stage = this.node.getChildByName("Stage") this.ui = this.node.getChildByName("UI") this.stage.destroyAllChildren() // 清空舞台上的所有节点 DataManager.Instance.jm = this.ui.getComponentInChildren("JoyStickManager") as any } async start() { await this.loadRes() this.initMap() this.shouldUpdate = true } async loadRes() { const list = [] for (const type in PrefabPathEnum) { const p = ResourceManager.Instance.loadRes(PrefabPathEnum[type], Prefab).then(prefab => { DataManager.Instance.prefabMap.set(type, prefab) }) list.push(p) } await Promise.all(list) } initMap() { const prefab = DataManager.Instance.prefabMap.get(EntityTypeEnum.Map) const map = instantiate(prefab) map.setParent(this.stage) } update() { if (!this.shouldUpdate) { return } this.render() } render() { this.renderActor() } async renderActor() { for (const data of DataManager.Instance.state.actors) { const { id, type } = data let am = DataManager.Instance.actorMap.get(id) if (!am) { const prefab = DataManager.Instance.prefabMap.get(type) const actor = instantiate(prefab) actor.setParent(this.stage) am = actor.addComponent(ActorManager) DataManager.Instance.actorMap.set(id, am) am.init(data) } else { am.render(data) } } } } ``` ### 5.角色状态机 1. 先微调一下代码,把 ActorManager.ts 的 update 改名为 tick,交由 BattleManager.ts 统一控制 update 渲染。BattleManager.ts 新增代码: ```ts update(dt) { // ... this.tick(dt) } tick(dt) { this.tickActor(dt) } tickActor(dt) { for (const data of DataManager.Instance.state.actors) { const { id } = data const am = DataManager.Instance.actorMap.get(id) am.tick(dt) } } ``` 2. 修改 ActorManager.ts,实现角色根据左右移动方向进行翻转 ```ts render(data: IActor) { const {direction, position} = data this.node.setPosition(position.x, position.y) if (direction.x !== 0) { // 角色根据左右移动方向进行翻转 this.node.setScale(direction.x > 0 ? 1 : -1, 1) } } ``` 3. 人物的动画切换通过和以前教程一样的有限状态机来实现。先在 DataManager 中新增一个 Map 用于存储动画贴图,然后在 BattleManager 里和预制体一样设置这个 Map: ```ts // DataManager.ts textureMap: Map = new Map() // 加载的贴图 // BattleManager.ts async loadRes() { const list = [] for (const type in PrefabPathEnum) { const p = ResourceManager.Instance.loadRes(PrefabPathEnum[type], Prefab).then(prefab => { DataManager.Instance.prefabMap.set(type, prefab) }) list.push(p) } for (const type in TexturePathEnum) { const p = ResourceManager.Instance.loadDir(TexturePathEnum[type], SpriteFrame).then(spriteFrames => { DataManager.Instance.textureMap.set(type, spriteFrames) }) list.push(p) } await Promise.all(list) } ``` 4. 新建状态机文件 ActorStateMachine.ts,内容就是和以前的死亡地牢教程里的类似。视频里是直接从`Scripts/Base/StateMachineTemplate.ts`文件复制来的。 5. 修改 ActorManager 来进行角色的状态切换,这样就会根据状态播放动画了。 ```ts tick(dt) { if (DataManager.Instance.jm.input.length()) { const { x, y } = DataManager.Instance.jm.input DataManager.Instance.applyInput({ id: 1, type: InputTypeEnum.ActorMove, direction: { x, y }, dt, }) this.state = EntityStateEnum.Run } else { this.state = EntityStateEnum.Idle } } init(data: IActor) { this.fsm = this.addComponent(ActorStateMachine) this.fsm.init(data.type) this.state = EntityStateEnum.Idle } ``` ### 6.武器状态机 1. 将武器贴图`resources/texture/weapon/weapon1/idle/idle (1).png`拖动到 Actor 节点下,Content Size 属性设置为(150,150)。现在这张贴图的坐标中心和角色重合,实际上应当偏左或右一点点拿在人物的手上。所以在 Actor 节点下创建一个 Weapon1 节点,把武器贴图作为这个节点的子节点,将 idle 的 Postion 设置为(40, 0),这样武器就拿在人物的手上了,旋转的时候就以 Weapon1 节点为准旋转。 2. 将武器节点 idle(1) 重命名为 Body,在该节点下新建一个节点 Anchor,在 Anchor 节点下新建一个节点 Point。以 Anchor 和 Point 为准作为子弹发射的方向。Anchor 的 Position 设置为(0,5),Point 的 Position 设置为(50,0)。 3. 将 Weapon1 拖到 prefab 文件夹里并取消和预制体的关联。 4. 修改 `Scripts/Enum/index.ts` 以初始化武器资源: ```ts export enum PrefabPathEnum { // ... Weapon1 = "prefab/Weapon1", } export enum TexturePathEnum { // ... Weapon1Idle = "texture/weapon/weapon1/idle", Weapon1Attack = "texture/weapon/weapon1/attack", } ``` 5. 角色可能有多种武器,所以给 DataManager 的角色添加一个武器种类属性: ```ts weaponType: EntityTypeEnum.Weapon1, ``` 6. 新建`Scripts/Entity/Weapon/WeaponManager.ts`和`Scripts/Entity/Weapon/WeaponStateMachine.ts`,其中`WeaponStateMachine.ts`可以复制之前角色的,只需要把 Run 改成 Attack 即可。 ```ts // WeaponManager.ts @ccclass('WeaponManager') export class WeaponManager extends EntityManager { private body: Node private anchor: Node private point: Node init(data: IActor) { this.body = this.node.getChildByName('Body') this.anchor = this.body.getChildByName('Anchor') this.point = this.anchor.getChildByName('Point') this.fsm = this.body.addComponent(WeaponStateMachine) this.fsm.init(data.weaponType) this.state = EntityStateEnum.Idle } } ``` 7. 修改 ActorManager 来渲染武器: ```ts init(data: IActor) { // ... // 初始化武器 const prefab = DataManager.Instance.prefabMap.get(EntityTypeEnum.Weapon1) const weapon = instantiate(prefab) weapon.setParent(this.node) this.wm = weapon.addComponent(WeaponManager) this.wm.init(data) } render(data: IActor) { // ... // 根据移动角度设置武器的朝向 const side = Math.sqrt(direction.x ** 2 + direction.y ** 2) const rad = Math.asin(direction.y / side) const angle = rad2Angle(rad) this.wm.node.setRotationFromEuler(0, 0, angle) } ``` ### 7.子弹数据 1. 先做一个发射子弹的按钮,在 UI 节点下创建一个名为 Shoot 的 Sprite。贴图就用 circle,大小改为 200x200,透明度为 100。添加一个 widget 组件,设置为靠右和靠下,距离均为 50。 2. 新建一个 ts 文件`Scripts/UI/ShootManager.ts`,只有一个`handleShoot()`方法。将该脚本挂载到 Shoot 节点上,并在 Shoot 节点上添加一个 Button 组件并绑定事件到`handleShoot()`方法上。设置 Button 组件的 Transition 属性为 SCALE 以便在点击时有缩放效果,缩放大小 Zoom Scale 设置为 0.9。 ```ts // ShootManager.ts handleShoot() { EventManager.Instance.emit(EventEnum.WeaponShoot) } ``` 3. 修改 WeaponManager.ts,处理子弹射击事件: ```ts init(data: IActor) { // ... EventManager.Instance.on(EventEnum.WeaponShoot, this.handleWeaponShoot, this) } onDestory() { EventManager.Instance.off(EventEnum.WeaponShoot, this.handleWeaponShoot, this) } handleWeaponShoot() { const pointWorldPos = this.point.getWorldPosition() // 将世界坐标转换为舞台的坐标 const pointStagePos = DataManager.Instance.stage.getComponent(UITransform).convertToNodeSpaceAR(pointWorldPos) const anchorWorldPos = this.anchor.getWorldPosition() // 子弹的射击方向 const direction = new Vec2(pointWorldPos.x - anchorWorldPos.x, pointWorldPos.y - anchorWorldPos.y).normalize() DataManager.Instance.applyInput({ type: InputTypeEnum.WeaponShoot, owner: this.owner, position: { x: pointStagePos.x, y: pointStagePos.y, }, direction: { x: direction.x, y: direction.y, }, }) } ``` 4. 修改 DataManager.ts,维护一个子弹数组: ```ts state: IState = { // ... bullets: [], nextBulletId: 1, } applyInput(input: IClientInput) { switch (input.type) { case InputTypeEnum.ActorMove: // ... break case InputTypeEnum.WeaponShoot: const { owner, position, direction } = input const bullet: IBullet = { id: this.state.nextBulletId++, owner, position, direction, type: this.actorMap.get(owner).bulletType, } this.state.bullets.push(bullet) break } } ``` ### 8.子弹渲染 1. 素材里 bullet1 是圆形的,bullet2 是长条的。为了更好地展现子弹方向,这里用 bullet2。拖动 bullet2 到节点 Stage 下,重命名为 Bullet2,然后拖动到 prefab 文件夹并取消关联预制体。 2. 修改 BattleManager.ts: ```ts render() { this.renderActor() this.renderBullet() } renderBullet() { for (const data of DataManager.Instance.state.bullets) { const { id, type } = data let bm = DataManager.Instance.bulletMap.get(id) if (!bm) { const prefab = DataManager.Instance.prefabMap.get(type) const bullet = instantiate(prefab) bullet.setParent(this.stage) bm = bullet.addComponent(BulletManager) DataManager.Instance.bulletMap.set(id, bm) bm.init(data) } else { bm.render(data) } } } ``` 3. 新建文件 BulletManager.ts 和 BulletStateMachine.ts,状态机里只有一种状态 Idle,编写 BulletManager.ts: ```ts @ccclass('BulletManager') export class BulletManager extends EntityManager { type: EntityTypeEnum init(data: IBullet) { this.type = data.type this.fsm = this.addComponent(BulletStateMachine) this.fsm.init(data.type) this.state = EntityStateEnum.Idle } render(data: IBullet) { const { direction, position } = data this.node.setPosition(position.x, position.y) const side = Math.sqrt(direction.x ** 2 + direction.y ** 2) let angle if (direction.x > 0) { angle = rad2Angle(Math.asin(direction.y / side)) } else { // 朝左侧发射子弹的时候,子弹朝向要处理一下 angle = rad2Angle(Math.asin(-direction.y / side)) + 180 } this.node.setRotationFromEuler(0, 0, angle) } } ``` ### 9.碰撞检测 1. 子弹要随着时间的流逝飞行,修改 BattleManager.ts 里 update 调用的 tick 方法: ```ts tick(dt) { this.tickActor(dt) DataManager.Instance.applyInput({ type: InputTypeEnum.TimePast, dt, }) } ``` 2. DataManager.ts 里处理时间流逝输入: ```ts case InputTypeEnum.TimePast: { const { dt } = input const bullets = this.state.bullets // 删除超出地图的子弹 for (let i = bullets.length - 1; i >= 0; i--) { const bullet = bullets[i] if (Math.abs(bullet.position.x) > MAP_WIDTH / 2 || Math.abs(bullet.position.y) > MAP_HEIGHT / 2) { bullets.splice(i, 1) this.bulletMap.delete(bullet.id) } } // 子弹飞行 for (const bullet of bullets) { bullet.position.x += bullet.direction.x * dt * BULLET_SPEED bullet.position.y += bullet.direction.y * dt * BULLET_SPEED } break } ``` ### 10.子弹爆炸 1. 将 `resources/texture/explosion/explosion(1)`拖动到 Stage 节点下,并重命名为 Explosion,然后拖动到 prefab 文件夹。 2. 老套路,配置好初始化 prefab 所需的 Enum,新建 ExplosionManager.ts 和 ExplosionStateMachine.ts,注意状态机里初始化 State 的时候动画播放次数为 1 次。 3. 在 DataManager 子弹碰到地图边缘时触发子弹爆炸事件: ```ts EventManager.Instance.emit(EventEnum.ExplosionBorn, bullet.id, { x: bullet.position.x, y: bullet.position.y }) ``` 4. 在 BulletManager 里处理子弹爆炸事件: ```ts init(data: IBullet) { // ... EventManager.Instance.on(EventEnum.ExplosionBorn, this.handleExplosionBorn, this) } handleExplosionBorn(id: number, { x, y }: IVec2) { if (id !== this.id) { return } const prefab = DataManager.Instance.prefabMap.get(EntityTypeEnum.Explosion) const explosion = instantiate(prefab) explosion.setParent(DataManager.Instance.stage) const em = explosion.addComponent(ExplosionManager) em.init(EntityTypeEnum.Explosion, { x, y }) EventManager.Instance.off(EventEnum.ExplosionBorn, this.handleExplosionBorn, this) DataManager.Instance.bulletMap.delete(id) this.node.destroy() } ``` 5. 接下来制作枪口火焰动画,先在 DataManager 里触发事件: ```ts case InputTypeEnum.WeaponShoot: // ... EventManager.Instance.emit(EventEnum.BulletBorn, owner) ``` 6. WeaponStateMachine.ts 之前已经注册过 Attack 状态了,只是需要修改一下动画播放状态为 Normal,并且传递强制播放动画参数为 true。修改 WeaponManager.ts 处理事件: ```ts // WeaponStateMachine.ts initStateMachines() { this.stateMachines.set(ParamsNameEnum.Idle, new State(this, `${this.type}${EntityStateEnum.Idle}`, AnimationClip.WrapMode.Loop, true)); this.stateMachines.set(ParamsNameEnum.Attack, new State(this, `${this.type}${EntityStateEnum.Attack}`, AnimationClip.WrapMode.Normal, true)); } initAnimationEvent() { // 动画播放完成后修改武器状态回 Idle this.animationComponent.on(Animation.EventType.FINISHED, () => { if(this.animationComponent.defaultClip.name.includes(EntityStateEnum.Attack)) { this.node.parent.getComponent(WeaponManager).state = EntityStateEnum.Idle } }) } // WeaponManager.ts init(data: IActor) { // ... EventManager.Instance.on(EventEnum.BulletBorn, this.handleBulletBorn, this) } handleBulletBorn(owner: number) { if (this.owner !== owner) { return } this.state = EntityStateEnum.Attack } ``` ### 11.攻击扣血 1. 在场景里添加一个敌人并且可以攻击扣血,修改 DataManager 的初始化数据,新增一个 actor,并且先写死一个属性叫 myPlayerId 表示当前玩家的角色 id,以便在各种脚本里判断是不是当前用户的操作: ```ts // DataManager.ts myPlayerId: number = 1 actors: [ { id: 1, type: EntityTypeEnum.Actor1, weaponType: EntityTypeEnum.Weapon1, bulletType: EntityTypeEnum.Bullet2, position: { x: -150, y: -150, }, direction: { x: 1, y: 0, }, }, { id: 2, type: EntityTypeEnum.Actor1, weaponType: EntityTypeEnum.Weapon1, bulletType: EntityTypeEnum.Bullet2, position: { x: 150, y: 150, }, direction: { x: -1, y: 0, }, } ], // ActorManager.ts tick(dt) { if(this.id !== DataManager.Instance.myPlayerId) { return } // ... } // WeaponManager.ts handleWeaponShoot() { if(this.owner !== DataManager.Instance.myPlayerId) { return } // ... } ``` 2. 在 DataManager 的 TimePast 处理逻辑里判断子弹是否和角色碰撞: ```ts // DataManager.ts case InputTypeEnum.TimePast: { const { dt } = input const { bullets, actors } = this.state // 删除超出地图的子弹 for (let i = bullets.length - 1; i >= 0; i--) { const bullet = bullets[i] // 判断是否碰撞到角色 for (let j = actors.length - 1; j >= 0; j--) { const actor = actors[j]; if (((actor.position.x - bullet.position.x) ** 2 + (actor.position.y - bullet.position.y) ** 2) < (ACTOR_RADIUS + BULLET_RADIUS) ** 2) { EventManager.Instance.emit( EventEnum.ExplosionBorn, bullet.id, { x: (actor.position.x + bullet.position.x) / 2, y: (actor.position.y + bullet.position.y) / 2 } ) bullets.splice(i, 1) break } } // ... } } ``` 3. 给人物加上血条,双击 cocos 里 Prefab 下的 Actor,在 Actor 上新建一个进度条组件,重命名为 HP,将其 Content Size 改为(100,15),子节点 Bar 的 Context Size 也改为(100,15),给子节点添加一个 widget 组件来和父组件对齐(水平向左对齐,距离为 0 px)。将 HP 的 Position 改为(0,70),这样就在角色头顶了。将 HP 的颜色改为纯黑色,Bar 的颜色改为纯红色。HP 的 Total Length 改为 100。 4. DataManager 里给角色添加一个 hp 属性并且在子弹触碰到角色的时候扣除角色血量,ActorManager 里绑定进度条并在渲染的时候显示血量,注意角色翻转的时候血条也要再翻转一次以保持不变: ```ts // DataManager.ts actors: [ { id: 1, hp: 30, // ... } // ... ] // 判断是否碰撞到角色 for (let j = actors.length - 1; j >= 0; j--) { const actor = actors[j]; if (((actor.position.x - bullet.position.x) ** 2 + (actor.position.y - bullet.position.y) ** 2) < (ACTOR_RADIUS + BULLET_RADIUS) ** 2) { actor.hp -= BULLET_DAMAGE // 角色扣血 // ... } // ... } // ActorManager.ts private hp: ProgressBar this.hp = this.node.getComponentInChildren(ProgressBar) render(data: IActor) { // ... if (direction.x !== 0) { // 角色根据左右移动方向进行翻转 this.node.setScale(direction.x > 0 ? 1 : -1, 1) // 角色翻转之后,血条也要跟着翻转以保持不变 this.hp.node.setScale(direction.x > 0 ? 1 : -1, 1) } // ... this.hp.progress = data.hp / this.hp.totalLength } ``` ### 12.对象池 ``` 对象池 ObjectPoolManager Stage ObjectPool 方法:get获取,ret归还 BulletPool Bullet Bullet Bullet ExplosionPool Explosion Explosion Explosion ``` 1. 编写`Scripts/Global/ObjectPoolManager.ts` ```ts export default class ObjectPoolManager extends Singleton { static get Instance() { return super.GetInstance() } private objectPool: Node = null // 该节点用于承载所有的对象 private map: Map = new Map() private getContainerName(objectName: EntityTypeEnum) { return objectName + "Pool" } reset() { this.objectPool = null this.map.clear() } get(objectName: EntityTypeEnum) { if (this.objectPool === null) { this.objectPool = new Node("ObjectPool") this.objectPool.setParent(DataManager.Instance.stage) } if (!this.map.has(objectName)) { this.map.set(objectName, []) const container = new Node(this.getContainerName(objectName)) container.setParent(this.objectPool) } let node: Node const nodes = this.map.get(objectName) if (!nodes.length) { const prefab = DataManager.Instance.prefabMap.get(objectName) node = instantiate(prefab) node.name = objectName node.setParent(this.objectPool.getChildByName(this.getContainerName(objectName))) } else { node = nodes.pop() } node.active = true return node } ret(object: Node) { object.active = false const objectName = object.name as EntityTypeEnum this.map.get(objectName).push(object) } } ``` 2. 修改 BulletManager.ts、BattleManager.ts、ExplosionStateMachine.ts 来使用上面的对象池,简单来说就是把之前初始化 prefab 的逻辑改成从对象池获取: ```ts // BulletManager.ts handleExplosionBorn(id: number, { x, y }: IVec2) { if (id !== this.id) { return } // const prefab = DataManager.Instance.prefabMap.get(EntityTypeEnum.Explosion) // const explosion = instantiate(prefab) // explosion.setParent(DataManager.Instance.stage) const explosion = ObjectPoolManager.Instance.get(EntityTypeEnum.Explosion) // 从对象池取出的可能已经有 ExplosionManager 组件了 const em = explosion.getComponent(ExplosionManager) || explosion.addComponent(ExplosionManager) em.init(EntityTypeEnum.Explosion, { x, y }) EventManager.Instance.off(EventEnum.ExplosionBorn, this.handleExplosionBorn, this) DataManager.Instance.bulletMap.delete(id) // this.node.destroy() ObjectPoolManager.Instance.ret(this.node) } // BattleManager.ts renderBullet() { for (const data of DataManager.Instance.state.bullets) { const { id, type } = data let bm = DataManager.Instance.bulletMap.get(id) if (!bm) { // const prefab = DataManager.Instance.prefabMap.get(type) // const bullet = instantiate(prefab) // bullet.setParent(this.stage) const bullet = ObjectPoolManager.Instance.get(type) // 从对象池取出的可能已经有 BulletManager 组件了 bm = bullet.getComponent(BulletManager) || bullet.addComponent(BulletManager) DataManager.Instance.bulletMap.set(id, bm) bm.init(data) } else { bm.render(data) } } } // ExplosionStateMachine.ts initAnimationEvent() { this.animationComponent.on(Animation.EventType.FINISHED, () => { // this.node.destroy() ObjectPoolManager.Instance.ret(this.node) }) } ``` ### 13.Node.js与Socket Node.js 是一个 JavaScript 的运行环境,特点:单线程、事件驱动、非阻塞IO 结论:适合 IO 密集型,不适合 CPU 密集型 如果我们的游戏是一个 MMORPG 游戏,玩家的操作会同步给服务器,然后服务端运行大量的游戏逻辑计算,再把计算后的状态结果同步给玩家,由于没有多线程不能充分利用多核 CPU 的计算能力,Node.js 面对这种 CPU 密集型业务会非常吃力。而我们的游戏是一个帧同步游戏,服务端只需要把用户操作转发给别的用户,这涉及了大量的 IO 操作,所以 Nodejs 就能充分发挥他非阻塞 IO 的优势了。 1. 网络 - 网络模型分为七层,本教程最核心的似乎传输层和应用层 - 传输层决定网络连接:TCP、UDP - 应用层决定数据结构:HTTP、WebSocket 2. 实战 - Node.js 原生 Socket 编程,建立 TCP 连接并发送数据 ```js // server.js const net = require('net') const server = net.createServer() server.listen(9876, 'localhost') server.on('connection', (socket) => { socket.on('data', (buffer) => { console.log(buffer.toString()) }) socket.write("Hello, I'm Server!") }) // client.js const net = require('net') const client = net.createConnection({ port: 9876, host: 'localhost' }) client.write('Hello, server!') client.on('data', (buffer) => { console.log(buffer.toString()) }) ``` 3. 是否自定义应用层协议 - 浏览器没有提供 net 模块来实现最底层的 socket 编程,只能用 http 或者 websocket ### 14.WebSocket通信 1. 编写`apps/server/src/index.ts`,这里 up 主定义了交互信息的格式为 {name, data},实现类似 socket.io 的效果: ```ts import { symlinkCommon } from "./Utils" import { WebSocketServer } from "ws" symlinkCommon() const wss = new WebSocketServer({ port: 9876 }) wss.on("connection", (socket) => { socket.on("message", (buffer) => { console.log(buffer.toString()) }) // 2 秒后再发送一条测试消息给客户端 setTimeout(() => { const obj = { name: "haha", data: "haha123", } socket.send(JSON.stringify(obj)) }, 2000) }) wss.on("listening", () => { console.log("服务启动!") }) ``` 2. 编写`apps/client/assets/Scripts/Global/NetworkManager.ts`用于管理客户端的网络操作: ```ts import Singleton from "../Base/Singleton" interface IItem { cb: Function ctx: unknown } export default class NetworkManager extends Singleton { static get Instance() { return super.GetInstance() } port = 9876 ws: WebSocket private map: Map> = new Map() // 类似事件订阅的机制,感觉有点像 socket.io 的实现 connect() { return new Promise((resolve, reject) => { this.ws = new WebSocket(`ws://localhost:${this.port}`) this.ws.onopen = () => { console.log("ws 连接成功") resolve(true) } this.ws.onclose = () => { console.log("ws 连接关闭") } this.ws.onerror = (error) => { console.error("ws 连接失败", error) reject(false) } this.ws.onmessage = (event) => { try { console.log("ws 收到消息:", event.data) const json = JSON.parse(event.data) const { name, data } = json if (this.map.has(name)) { this.map.get(name).forEach(({ cb, ctx }) => { cb.call(ctx, data) }) } } catch (e) { console.error(e) } } }) } sendMsg(data) { this.ws.send(data) } listenMsg(name: string, cb: Function, ctx: unknown) { if (this.map.has(name)) { this.map.get(name).push({ cb, ctx }) } else { this.map.set(name, [{ cb, ctx }]) } } } ``` 3. BattleManager.ts: ```ts async start() { await this.connectServer() NetworkManager.Instance.sendMsg("你好,我是 cocos creator 客户端") NetworkManager.Instance.listenMsg("haha", (data) => { console.log("listenMsg:", data) }, this) // ... } async connectServer() { if (!(await NetworkManager.Instance.connect().catch(() => false))) { await new Promise(rs => setTimeout(rs, 1000)) // 连接失败,等待1秒后重试 await this.connectServer() // 递归重试连接 } } ``` ### 15.WebSocket帧同步 1. 修改 ActorManager.ts,将之前直接操作数据中心改为触发事件,通过 ws 将操作数据发给服务端: ```ts // ActorManager.ts EventManager.Instance.emit(EventEnum.ClientSync, { id: 1, type: InputTypeEnum.ActorMove, direction: { x, y }, dt, }) // 操作移交给发送网络数据了 // DataManager.Instance.applyInput({ // id: 1, // type: InputTypeEnum.ActorMove, // direction: { x, y }, // dt, // }) ``` 2. BattleManager.ts 里处理数据发送和接受事件: ```ts // BattleManager.ts async start() { this.clearGame() await Promise.all([this.connectServer(), this.loadRes()]) this.initGame() } initGame() { // ... EventManager.Instance.on(EventEnum.ClientSync, this.handleClientSync, this) NetworkManager.Instance.listenMsg(ApiMsgEnum.MsgServerSync, this.handleServerSync, this) } clearGame() { EventManager.Instance.off(EventEnum.ClientSync, this.handleClientSync, this) NetworkManager.Instance.unlistenMsg(ApiMsgEnum.MsgServerSync, this.handleServerSync, this) // ... } handleClientSync(input: IClientInput) { const msg = { input, frameId: DataManager.Instance.frameId++, } NetworkManager.Instance.sendMsg(ApiMsgEnum.MsgClientSync, msg) } handleServerSync({ inputs }: any) { for (const input of inputs) { DataManager.Instance.applyInput(input) } } // NetworkManager.ts sendMsg(name: string, data) { const msg = { name, data } this.ws.send(JSON.stringify(msg)) } ``` 3. 服务端代码接受客户端数据并存储到数组里定时批量发送给客户端: ```ts // apps/server/src/index.ts let inputs = [] socket.on("message", (buffer) => { const str = buffer.toString() try { const msg = JSON.parse(str) const { name, data } = msg const { frameId, input } = data inputs.push(input) // 并不直接发送每一份数据,先存起来 } catch (error) { console.log(error) } }) // 每隔100毫秒批量发送一次数据 setInterval(() => { const temp = inputs inputs = [] const msg = { name: ApiMsgEnum.MsgServerSync, data: { inputs: temp, } } socket.send(JSON.stringify(msg)) }, 100) ``` ### 16.Server和Connection - Server:Connect1、Connect2、Connect3、Connect4... - PlayManager:每个 Connect 就是一个玩家 - RoomManager:每隔房间就是几个 Connect 的集合 1. 封装 WebSocket 服务,先写一个 Connections 类 ```ts // apps/server/src/Core/Connections.ts import { EventEmitter } from "stream" import { MyServer } from "./MyServer" import { WebSocket } from "ws" interface IItem { cb: Function ctx: unknown } export class Connection extends EventEmitter { private msgMap: Map> = new Map() // 类似事件订阅的机制,感觉有点像 socket.io 的实现 constructor(private server: MyServer, private ws: WebSocket) { super() this.ws.on("close", () => { // this.server.connections.delete(this) this.emit("close") }) this.ws.on("message", (buffer: Buffer) => { const str = buffer.toString() try { const msg = JSON.parse(str) const { name, data } = msg const { frameId, input } = data } catch (error) { console.log(error) } }) } sendMsg(name: string, data) { const msg = { name, data } this.ws.send(JSON.stringify(msg)) } listenMsg(name: string, cb: Function, ctx: unknown) { if (this.msgMap.has(name)) { this.msgMap.get(name).push({ cb, ctx }) } else { this.msgMap.set(name, [{ cb, ctx }]) } } unlistenMsg(name: string, cb: Function, ctx: unknown) { if (this.msgMap.has(name)) { const index = this.msgMap.get(name).findIndex((i) => cb === i.cb && i.ctx === ctx); index > -1 && this.msgMap.get(name).splice(index, 1); } } } ``` 2. 写一个 Server: ```ts // apps/server/src/Core/MyServer.ts import { WebSocketServer, WebSocket } from "ws" import { Connection } from "./Connections" export class MyServer { port: number wss: WebSocketServer connections: Set = new Set() constructor({ port }: { port: number }) { this.port = port } start() { return new Promise((resolve, reject) => { this.wss = new WebSocketServer({ port: 9876 }) this.wss.on("listening", () => { resolve(true) }) this.wss.on("close", () => { reject(false) }) this.wss.on("error", (e) => { reject(e) }) this.wss.on("connection", (ws: WebSocket) => { const connection = new Connection(this, ws) this.connections.add(connection) console.log("来人了:", this.connections.size) connection.on("close", () => { this.connections.delete(connection) console.log("走人了:", this.connections.size) }) }) }) } } ``` 3. 修改服务端的 index.ts 代码,使用 MyServer: ```ts // apps/server/src/index.ts import { symlinkCommon } from "./Utils" import { MyServer } from "./Core" symlinkCommon() const server = new MyServer({ port: 9876 }) server.start().then(() => { console.log("服务启动成功!") }).catch((e) => { console.error("服务启动失败:", e) }) ``` ### 17.HTTP服务 玩家需要先发送登录请求和服务器建立连接。把一个 http 库封装进 MyServer 太臃肿了,而且发 http 请求还要重新建立 TCP 连接。所以本节要在 MyServer 里模拟一个 HTTP 服务。 1. MyServer 里维护一个 Map,表示接口名和函数的映射,在服务器启动之前设置一下: ```ts // MyServer.ts apiMap: Map = new Map() setApi(name: ApiMsgEnum, cb: Function) { this.apiMap.set(name, cb) } // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiPlayerJoin, (connection: Connection, data: any) => { return data + "我是服务端,我知道了" }) ``` 2. Connection.ts 里分别处理接口服务和消息服务: ```ts this.ws.on("message", (buffer: Buffer) => { const str = buffer.toString() try { const msg = JSON.parse(str) const { name, data } = msg // api 接口服务 if (this.server.apiMap.has(name)) { try { const cb = this.server.apiMap.get(name) const res = cb.call(null, this, data) this.sendMsg(name, { success: true, res, }) } catch (e) { this.sendMsg(name, { success: false, error: e.message, }) } } else { // msg 消息服务 try { if (this.msgMap.has(name)) { this.msgMap.get(name).forEach(({ cb, ctx }) => { cb.call(ctx, data) }) } } catch (e) { console.log("处理消息失败:", e) } } } catch (error) { console.log(error) } }) ``` 3. 客户端调用接口: ```ts // NetworkManager.ts callApi(name: string, data): Promise { return new Promise((resolve) => { try { const timer = setTimeout(() => { resolve({ success: false, error: new Error("请求超时") }) this.unlistenMsg(name, cb, this) }, 5000) const cb = (res) => { resolve(res) clearTimeout(timer) this.unlistenMsg(name, cb, this) } this.listenMsg(name, cb, this) this.sendMsg(name, data) } catch (error) { resolve({ success: false, error }) } }) } // BattleManager.ts async start() { this.clearGame() await Promise.all([this.connectServer(), this.loadRes()]) // 调用登录接口 const {success, error, res} = await NetworkManager.Instance.callApi(ApiMsgEnum.ApiPlayerJoin, "我是 cocos ") if (!success) { console.error("登录失败", error) return } console.log("登录成功", res) this.initGame() } ``` ### 18.登录功能 1. 先在服务端创建 Player.ts 和 PlayerManager.ts 用于管理玩家: ```ts // apps/server/src/Biz/Player.ts export class Player { id: number nickname: string connection: Connection rid: number // room id constructor({id,nickname,connection}:Pick) { this.id = id this.nickname = nickname this.connection = connection } } // apps/server/src/Biz/PlayerManager.ts export class PlayerManager extends Singleton { static get Instance() { return super.GetInstance() } nextPlayerId players: Set = new Set() idMapPlayer: Map = new Map() createPlayer({ nickname, connection }: any) { const player = new Player({ id: this.nextPlayerId++, nickname, connection }) this.players.add(player) this.idMapPlayer.set(player.id, player) return player } removePlayer(pid: number) { const player = this.idMapPlayer.get(pid) if (player) { this.players.delete(player) this.idMapPlayer.delete(pid) } } // 有些字段不需要暴露给客户端,比如 connection getPlayerView({ id, nickname, rid }: Player) { return { id, nickname, rid } } } // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiPlayerJoin, (connection: Connection, data: any) => { const player = PlayerManager.Instance.createPlayer({ nickname: data.nickname, connection }) return { player: PlayerManager.Instance.getPlayerView(player), } }) ``` 2. 在 cocos 里创建一个登录场景 Login,把预制体里的 Map 拖动到 Canvas 节点下。在 Canvas 节点下创建一个 EditBox 和 Button。编写 LoginManager.ts 挂载到 Canvas 上,Button 绑定点击函数 handClick ```ts @ccclass("LoginManager") export class LoginManager extends Component { input: EditBox onLoad() { this.input = this.getComponentInChildren(EditBox) director.preloadScene(SceneEnum.Battle) } async start() { await NetworkManager.Instance.connect() } async handClick() { if (!NetworkManager.Instance.isConnected) { console.error("网络未连接") await NetworkManager.Instance.connect() return } const nickname = this.input.string.trim() if (!nickname) { console.error("昵称不能为空") return } // 调用登录接口 const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiPlayerJoin, { nickname } ) if (!success) { console.error("登录失败", error) return } console.log("登录成功", res) DataManager.Instance.myPlayerId = res.player.id director.loadScene(SceneEnum.Battle) } } ``` 3. 此时 Login 场景就能用了。处理一下客户端断开连接后服务端删除 Player 的逻辑。为了避免在 MyServer 处理连接的代码里写上业务逻辑,所以选择让 MyServer 继承 EventEmitter。然后这里注意在服务端的 index.ts 里通过 declare 直接在 Connection 上扩展了一个 playerId 字段。 ```ts // MyServer.ts export class MyServer extends EventEmitter this.wss.on("connection", (ws: WebSocket) => { const connection = new Connection(this, ws) this.connections.add(connection) this.emit("connection", connection) // console.log("来人了:", this.connections.size) connection.on("close", () => { this.connections.delete(connection) this.emit("disconnection", connection) // console.log("走人了:", this.connections.size) }) } // apps/server/src/index.ts declare module "./Core" { // 扩展 Connection,添加 playerId 字段,不直接在 Connection 上添加,避免业务代码污染原有代码 interface Connection { playerId: number // 连接对应的玩家ID } } const server = new MyServer({ port: 9876 }) server.on("connection", (connection: Connection) => { console.log("来人了: ", server.connections.size) }) server.on("disconnection", (connection: Connection) => { console.log("走人了: ", server.connections.size) if(connection.playerId) { // 断开连接时删除 Player PlayerManager.Instance.removePlayer(connection.playerId) } console.log("PlayerManager.Instance.players.size: ", PlayerManager.Instance.players.size) }) server.setApi(ApiMsgEnum.ApiPlayerJoin, (connection: Connection, data: any) => { const player = PlayerManager.Instance.createPlayer({ nickname: data.nickname, connection }) connection.playerId = player.id return { player: PlayerManager.Instance.getPlayerView(player), } }) ``` ### 19.协议约定 1. 新建文件各种类型描述文件: ```ts // apps/server/src/Common/Model.ts export interface IModel { api: { [ApiMsgEnum.ApiPlayerJoin]: { req: IApiPlayerJoinReq, res: IApiPlayerJoinRes, } } msg: { [ApiMsgEnum.MsgClientSync]: IMsgClientSync [ApiMsgEnum.MsgServerSync]: IMsgServerSync } } // apps/server/src/Common/Api.ts interface IPlayer { id: number nickname: string rid: number } export interface IApiPlayerJoinReq { nickname: string } export interface IApiPlayerJoinRes { player: IPlayer } // apps/server/src/Common/Msg.ts export interface IMsgClientSync { input: IClientInput frameId: number } export interface IMsgServerSync { inputs: IClientInput[] lastFrameId: number } ``` 2. 使用: ```ts // apps/client/assets/Scripts/Global/NetworkManager.ts // callApi(name: string, data): Promise { callApi(name: T, data: IModel['api'][T]['req']): Promise> {} // sendMsg(name: string, data) { sendMsg(name: T, data: IModel['msg'][T]) {} // listenMsg(name: string, cb: Function, ctx: unknown) { listenMsg(name: T, cb: (args: IModel['msg'][T]) => void, ctx: unknown) {} // unlistenMsg(name: string, cb: Function, ctx: unknown) { unlistenMsg(name: T, cb: (args: IModel['msg'][T]) => void, ctx: unknown) {} // apps/client/assets/Scripts/Scene/BattleManager.ts handleClientSync(input: IClientInput) {} handleServerSync({ inputs }: IMsgServerSync) {} // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiPlayerJoin, (connection: Connection, data: IApiPlayerJoinReq) => {}) // apps/server/src/Biz/PlayerManager.ts // createPlayer({ nickname, connection }: any) { createPlayer({ nickname, connection }: IApiPlayerJoinReq & { connection: Connection }) { // apps/server/src/Core/MyServer.ts // setApi(name: ApiMsgEnum, cb: Function) { setApi(name: T, cb: (connection:Connection, args:IModel['api'][T]['req']) => void) { this.apiMap.set(name, cb) } ``` ### 20.游戏大厅 1. 在 cocos 里新建一个场景 Hall,同样把预制体 Map 拖到 Canvas 节点下作为背景。在 Canvas 下新建一个 ScrollView 组件,Position设置为(-300,60)。ScrollView 组件下有 view.content.item,复制一份 item,给 content 组件添加一个 Layout 组件用于布局,Layout 的 Type 设置为 VERTICAL,Padding Top、Padding Bottom、Spacing Y 都设置为 10。效果满意之后删掉复制出来的 item,将原始的 item 重命名为 Player,拖动到 prefab 文件夹里。创建文件`Scripts/UI/PlayerManager.ts`挂载到 Player 预制体上。 ```ts // apps/client/assets/Scripts/UI/PlayerManager.ts @ccclass('PlayerManager') export class PlayerManager extends Component { init({ id, rid, nickname }: IPlayer) { const label = this.getComponent(Label) label.string = nickname this.node.active = true } } ``` 2. 新建`apps/client/assets/Scripts/Scene/HallManager.ts`,添加到 Canvas 节点,这个文件就是获取玩家数据并渲染 ScrollView,这里界面逻辑比较简单,所以这里选择通过 cocos 拖拽的方式绑定 ScrollView 下的 content 给 playerContainer,上面创建的 Player 预制体给 playerPrefab。 ```ts // apps/client/assets/Scripts/Scene/HallManager.ts @ccclass("HallManager") export class HallManager extends Component { @property(Node) playerContainer: Node @property(Prefab) playerPrefab: Prefab start() { NetworkManager.Instance.listenMsg(ApiMsgEnum.MsgPlayerList, this.renderPlayer, this) this.playerContainer.destroyAllChildren() this.getPlayers() } onDestroy(){ NetworkManager.Instance.unlistenMsg(ApiMsgEnum.MsgPlayerList, this.renderPlayer, this) } async getPlayers() { const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiPlayerList, {} ) if (!success) { console.error("获取玩家列表失败: ", error) return } console.log("res: ", res) this.renderPlayer(res) } renderPlayer({ list }: IApiPlayerListRes) { for (const e of this.playerContainer.children) { e.active = false } while (this.playerContainer.children.length < list.length) { const node = instantiate(this.playerPrefab) node.active = false node.setParent(this.playerContainer) } console.log("list: ", list) for (let i = 0; i < list.length; i++) { const data = list[i] const node = this.playerContainer.children[i] node.getComponent(PlayerManager).init(data) } } } ``` 3. 服务端要提供获取玩家列表的接口和消息: ```ts // apps/server/src/Biz/PlayerManager.ts // 同步玩家列表给所有玩家 syncPlayers() { const playerList = this.getPlayersView() for (const player of this.players) { player.connection.sendMsg(ApiMsgEnum.MsgPlayerList, { list: playerList }) } } getPlayersView(players: Set = this.players) { return Array.from(players).map(player => this.getPlayerView(player)) // return [...players].map(player => this.getPlayerView(player)) } // apps/server/src/index.ts // 有玩家断线,同步一份玩家列表给所有人 PlayerManager.Instance.syncPlayers() // 有新玩家登录,同步一份玩家列表给所有人 PlayerManager.Instance.syncPlayers() // 获取玩家列表接口 server.setApi(ApiMsgEnum.ApiPlayerList, (connection: Connection, data: IApiPlayerListReq): IApiPlayerListRes => { return { list: PlayerManager.Instance.getPlayersView(), } }) ``` ### 21.房间系统创建 1. 编写 Room.ts 和 RoomManager.ts: ```ts // apps/server/src/Biz/Room.ts export class Room { id: number players: Set = new Set() constructor(rid: number) { this.id = rid } join(uid: number) { // 玩家加入房间 const player = PlayerManager.Instance.idMapPlayer.get(uid) if(player) { player.rid = this.id this.players.add(player) } } } // apps/server/src/Biz/RoomManager.ts export class RoomManager extends Singleton { static get Instance() { return super.GetInstance() } nextRoomId = 1 rooms: Set = new Set() idMapRoom: Map = new Map() createRoom() { const room = new Room(this.nextRoomId++) this.rooms.add(room) this.idMapRoom.set(room.id, room) return room } joinRoom(rid:number, uid:number) { const room = this.idMapRoom.get(rid) if(room) { room.join(uid) return room } } getRoomsView(rooms: Set = this.rooms) { return [...rooms].map(room => this.getRoomView(room)) } getRoomView({ id, players }: Room) { return { id, players: PlayerManager.Instance.getPlayersView(players) } } } ``` 2. 服务端暴露创建房间接口: ```ts // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiRoomCreate, (connection: Connection, data: IApiRoomCreateReq): IApiRoomCreateRes => { if (connection.playerId) { const newRoom = RoomManager.Instance.createRoom() const room = RoomManager.Instance.joinRoom(newRoom.id, connection.playerId) if (room) { return { room: RoomManager.Instance.getRoomView(room) } } else { throw new Error("房间不存在") } } else { throw new Error("未登录") } }) ``` 3. 在 cocos 里添加一个按钮来创建房间,在 Hall 场景的 Canvas 下新建一个按钮,给其添加一个 widget 组件设置为靠右和靠下,距离都为 0。将其子节点的 Label 字符串设置为"创建房间"。新建一个场景 Room,把预制体 Map 拖给 Canvas。创建房间按钮绑定 HallManager.ts 上的方法 handleCreateRoom,方法代码如下。 ```ts // apps/client/assets/Scripts/Scene/HallManager.ts async handleCreateRoom() { const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiRoomCreate, {} ) if (!success) { console.error("创建房间失败: ", error) return } console.log("创建房间成功: ", res.room) DataManager.Instance.roomInfo = res.room director.loadScene(SceneEnum.Room) } ``` ### 22.房间系统列表 1. 本节操作和之前渲染用户列表基本一样。在 cocos 里的 Hall 场景下复制一份之前的用户列表 ScrollView,Position 设置为(100,60),解除下面的 view.content.Player 和预制体的关联,重命名为 Room,删除上面原本的 PlayManager.ts 脚本组件。创建一个新的 RoomManager.ts 并添加到这个 Room 节点上,将 Room 拖动到 prefab 文件夹里。 ```ts // apps/client/assets/Scripts/UI/RoomManager.ts @ccclass('RoomManager') export class RoomManager extends Component { init({ id, players }: IRoom) { const label = this.getComponent(Label) label.string = `房间id:${id}` this.node.active = true } } ``` 2. 给 HallManager.ts 新增两个 property 并在 cocos 里挂载,并编写获取房间列表和渲染的方法: ```ts // apps/client/assets/Scripts/Scene/HallManager.ts @property(Node) roomContainer: Node @property(Prefab) roomPrefab: Prefab onLoad() { NetworkManager.Instance.listenMsg(ApiMsgEnum.MsgPlayerList, this.renderPlayer, this) NetworkManager.Instance.listenMsg(ApiMsgEnum.MsgRoomList, this.renderRoom, this) } async getRooms() { const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiRoomList, {} ) if (!success) { console.error("获取玩家列表失败: ", error) return } console.log("res: ", res) this.renderRoom(res) } renderRoom({ list }: IApiRoomListRes) { for (const e of this.roomContainer.children) { e.active = false } while (this.roomContainer.children.length < list.length) { const node = instantiate(this.roomPrefab) node.active = false node.setParent(this.roomContainer) } for (let i = 0; i < list.length; i++) { const data = list[i] const node = this.roomContainer.children[i] node.getComponent(RoomManager).init(data) } } ``` 3. 服务端提供房间列表接口并在创建完房间之后就同步一下房间列表消息 ```ts // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiRoomList, (connection: Connection, data: IApiRoomListReq): IApiRoomListRes => { return { list: RoomManager.Instance.getRoomsView(), } }) RoomManager.Instance.syncRooms() // apps/server/src/Biz/RoomManager.ts syncRooms() { for (const player of PlayerManager.Instance.players) { player.connection.sendMsg(ApiMsgEnum.MsgRoomList, { list: this.getRoomsView() }) } } ``` ### 23.房间系统加入 1. Room 场景应该也要显示玩家列表,复制 Hall 场景的 ScrollView(注意要删除 content 下的 Player 节点),粘贴到 Room 场景的 Canvas 下,编写一个 RoomManager.ts 挂载到 Canvas 上。 ```ts // apps/client/assets/Scripts/Scene/RoomManager.ts @ccclass("RoomManager") export class RoomManager extends Component { @property(Node) playerContainer: Node @property(Prefab) playerPrefab: Prefab onLoad() { NetworkManager.Instance.listenMsg(ApiMsgEnum.MsgRoom, this.renderPlayer, this) } start() { // this.playerContainer.destroyAllChildren() // 由于destory操作会延迟到当前帧渲染前执行,可能会render之后再销毁,所以这里就不destory了,在cocos里先删除 content 下的 Player 节点 // 首次渲染玩家列表数据来自数据中心 this.renderPlayer({ room: DataManager.Instance.roomInfo }) } onDestroy() { NetworkManager.Instance.unlistenMsg(ApiMsgEnum.MsgRoom, this.renderPlayer, this) } renderPlayer({ room: { players: list } }: IMsgRoom) { for (const e of this.playerContainer.children) { e.active = false } while (this.playerContainer.children.length < list.length) { const node = instantiate(this.playerPrefab) node.active = false node.setParent(this.playerContainer) } for (let i = 0; i < list.length; i++) { const data = list[i] const node = this.playerContainer.children[i] node.getComponent(PlayerManager).init(data) } } } ``` 2. 给场景 Hall 的房间列表增加点击加入的功能,给预制体 Room 添加一个 Button 组件。给`UI/RoomManager.ts`新增一个函数触发点击事件并绑定给 Button,在 HallManager.ts 里监听并处理这个事件。 ```ts // apps/client/assets/Scripts/UI/RoomManager.ts handleClick() { EventManager.Instance.emit(EventEnum.RoomJoin, this.id) } // apps/client/assets/Scripts/Scene/HallManager.ts EventManager.Instance.on(EventEnum.RoomJoin, this.handleJoinRoom, this) async handleJoinRoom(rid: number) { const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiRoomJoin, { rid } ) if (!success) { console.error("加入房间失败: ", error) return } console.log("加入房间成功: ", res.room) DataManager.Instance.roomInfo = res.room director.loadScene(SceneEnum.Room) } ``` 3. 后端编写 ApiMsgEnum.ApiRoomJoin: ```ts // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiRoomJoin, (connection: Connection, data: IApiRoomJoinReq): IApiRoomJoinRes => { if (connection.playerId) { const rid = data.rid const room = RoomManager.Instance.joinRoom(rid, connection.playerId) if (room) { // PlayerManager.Instance.syncPlayers() // 加入房间感觉不需要同步玩家列表 // RoomManager.Instance.syncRooms() // 加入房间感觉不需要同步房间列表 RoomManager.Instance.syncRoom(rid) // 告诉这个房间里的其他人有新玩家进入 return { room: RoomManager.Instance.getRoomView(room) } } else { throw new Error("房间不存在") } } else { throw new Error("未登录") } }) // apps/server/src/Biz/RoomManager.ts syncRoom(rid: number) { const room = this.idMapRoom.get(rid) if (room) { room.sync() } } // apps/server/src/Biz/Room.ts sync() { // 同步当前房间信息给所有房间内的玩家 const room = RoomManager.Instance.getRoomView(this) for (const player of this.players) { player.connection.sendMsg(ApiMsgEnum.MsgRoom, { room }) } } ``` ### 24.房间系统离开 1. 在 Room 场景添加一个按钮,用于离开房间,添加 widget 组件让它居于右下角,Label 字符串设置为"离开房间",在`Scene/RoomManager.ts`编写点击方法并绑定。 ```ts // apps/client/assets/Scripts/Scene/RoomManager.ts async handleLeaveRoom() { const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiRoomLeave, {} ) if (!success) { console.error("离开房间失败: ", error) return } console.log("离开房间成功") DataManager.Instance.roomInfo = null director.loadScene(SceneEnum.Hall) } ``` 2. 服务端写接口: ```ts // apps/server/src/index.ts server.setApi(ApiMsgEnum.ApiRoomLeave, (connection: Connection, data: IApiRoomLeaveReq): IApiRoomLeaveRes => { if (!connection.playerId) { throw new Error("未登录") } const player = PlayerManager.Instance.idMapPlayer.get(connection.playerId) if (!player) { throw new Error("玩家不存在") } const rid = player.rid if (!rid) { throw new Error("玩家不在任何房间内") } RoomManager.Instance.leaveRoom(rid, connection.playerId) RoomManager.Instance.syncRoom(rid) // 玩家离开房间后房间可能销毁,此时有必要同步一下房间列表给所有人 RoomManager.Instance.syncRooms() return {} }) // apps/server/src/Biz/RoomManager.ts leaveRoom(rid: number, uid: number) { const room = this.idMapRoom.get(rid) if (room) { room.leave(uid) } } closeRoom(rid: number) { const room = this.idMapRoom.get(rid) if (room) { room.close() this.rooms.delete(room) this.idMapRoom.delete(rid) } } // apps/server/src/Biz/Room.ts leave(uid: number) { // 玩家离开房间 const player = PlayerManager.Instance.idMapPlayer.get(uid) if (player) { player.rid = null this.players.delete(player) if(!this.players.size) { // 如果房间内没有玩家了,则删除房间 RoomManager.Instance.closeRoom(this.id) } } } close() { this.players.clear() } ``` ### 25.战斗场景 1. 上一节要补充一点,当玩家断开 websocket 连接的时候,也要执行离开房间逻辑。 ```ts // apps/server/src/Biz/PlayerManager.ts removePlayer(pid: number) { const player = this.idMapPlayer.get(pid) if (player) { const rid = player.rid if (rid) { RoomManager.Instance.leaveRoom(rid, player.id) RoomManager.Instance.syncRoom(rid) RoomManager.Instance.syncRooms() } this.players.delete(player) this.idMapPlayer.delete(pid) } } ``` 2. 当某一个玩家点击开始游戏,服务端就要告诉给这个房间内的所有人。先在 Room 场景添加一个 开始游戏 Button,添加 widget 组件靠右下角,距离底部距离 50 以保持在离开房间按钮上方。编写并绑定方法: ```ts // apps/client/assets/Scripts/Scene/RoomManager.ts async handleButtonStart() { const { success, error, res } = await NetworkManager.Instance.callApi( ApiMsgEnum.ApiGameStart, {} ) if (!success) { console.error("开始游戏失败: ", error) return } } ``` 3. 注释掉 DataManager.ts 里 state 数据下有关 actors 的部分。开始编写后端接口: ```ts // apps/server/src/index.ts // 游戏开始接口 server.setApi(ApiMsgEnum.ApiGameStart, (connection: Connection, data: IApiGameStartReq): IApiGameStartRes => { if (!connection.playerId) { throw new Error("未登录") } const player = PlayerManager.Instance.idMapPlayer.get(connection.playerId) if (!player) { throw new Error("玩家不存在") } const rid = player.rid if (!rid) { throw new Error("玩家不在任何房间内") } RoomManager.Instance.startRoom(rid) return {} }) // apps/server/src/Biz/RoomManager.ts startRoom(rid: number) { const room = this.idMapRoom.get(rid) if (room) { room.start() } } // apps/server/src/Biz/Room.ts start() { // 游戏开始时,初始化游戏状态 const state: IState = { actors: [...this.players].map((player, index) => ({ id: player.id, nickname: player.nickname, hp: 100, type: EntityTypeEnum.Actor1, weaponType: EntityTypeEnum.Weapon1, bulletType: EntityTypeEnum.Bullet2, position: { x: -150 + index * 300, y: -150 + index * 300, }, direction: { x: 1, y: 0, }, })), bullets: [], nextBulletId: 1, } for (const player of this.players) { player.connection.sendMsg(ApiMsgEnum.MsgGameStart, { state }) } } ``` 4. RoomManager.ts 处理游戏开始消息: ```ts // apps/client/assets/Scripts/Scene/RoomManager.ts NetworkManager.Instance.listenMsg(ApiMsgEnum.MsgGameStart, this.handleGameStart, this) handleGameStart({ state }: IMsgGameStart) { DataManager.Instance.state = state director.loadScene(SceneEnum.Battle) } ``` ### 26.帧同步角色移动 1. 修改服务端的 Room,在开始游戏时同时注册监听房间内所有玩家的输入事件,并定时发送给房间内的所有用户。 ```ts // apps/server/src/Biz/Room.ts pendingInput: IClientInput[] = [] // 存储玩家的输入事件 start() { // ... for (const player of this.players) { player.connection.sendMsg(ApiMsgEnum.MsgGameStart, { state }) // 同时开始监听玩家的输入事件 player.connection.listenMsg(ApiMsgEnum.MsgClientSync, this.getClientMsg, this) } const timer1 = setInterval(()=>{ this.sendServerMsg(), 100 }) getClientMsg(connection, { input, frameId }: IMsgClientSync) { this.pendingInput.push(input) } sendServerMsg() { const inputs = this.pendingInput this.pendingInput = [] for (const player of this.players) { player.connection.sendMsg(ApiMsgEnum.MsgServerSync, { lastFrameId: 0, inputs }) } } } ``` 2. 将 ActorManager.ts 之前写死的用户 id 改成数据中心获取: ```ts // apps/client/assets/Scripts/Entity/Actor/ActorManager.ts // id: 1 id: DataManager.Instance.myPlayerId, ``` ### 27.帧同步子弹发射 1. 修改 WeaponManager.ts,把之前数据中心的 applyInput 改成向服务端发送数据 ```ts // apps/client/assets/Scripts/Entity/Weapon/WeaponManager.ts // DataManager.Instance.applyInput({ EventManager.Instance.emit(EventEnum.ClientSync, { // ... }) ``` 2. 注释掉 BattleManager 里时间流逝的代码,交给服务端来 ```ts // apps/client/assets/Scripts/Scene/BattleManager.ts tick(dt) { this.tickActor(dt) // 时间流逝应当交给服务端,不然每个客户端都流逝就不准了 // DataManager.Instance.applyInput({ // type: InputTypeEnum.TimePast, // dt, // }) } ``` 3. 修改服务端的 Room.ts,发送时间流逝数据: ```ts // apps/server/src/Biz/Room.ts lastTime: number const timer2 = setInterval(() => { this.timePast(), 16 }) timePast() { const now = process.uptime() const dt = now - (this.lastTime ?? now) this.pendingInput.push({ type: InputTypeEnum.TimePast, dt, }) this.lastTime = now } ``` ### 28.动画缓动Tween 当前游戏每 100 毫秒才推送一次数据,所以会有卡顿的感觉,此时只需要每 100 毫秒把每个目标 pos 作为 targetPos,利用 cocos 的 tween 动画缓动实现过渡即可。 1. 修改 ActorManager.ts 的 render 方法: ```ts // apps/client/assets/Scripts/Entity/Actor/ActorManager.ts private targetPos: Vec3 private tw: Tween render(data: IActor) { this.renderPos(data) this.renderDire(data) this.renderHP(data) } renderPos(data: IActor) { const { direction, position } = data const newPos = new Vec3(position.x, position.y) if (!this.targetPos) { // 第一次渲染位置 this.node.active = true this.node.setPosition(newPos) this.targetPos = new Vec3(newPos) } else if (!this.targetPos.equals(newPos)) { // 位置发生变化才真正执行,因为数据每100毫秒才发一次,但是render是每帧 this.tw?.stop() // 停止之前的动画 this.node.setPosition(this.targetPos) this.targetPos.set(newPos) this.state = EntityStateEnum.Run this.tw = tween(this.node).to(0.1, { position: this.targetPos, }).call(() => { this.state = EntityStateEnum.Idle }).start() } } renderDire(data: IActor) { const { direction, position } = data if (direction.x !== 0) { // 角色根据左右移动方向进行翻转 this.node.setScale(direction.x > 0 ? 1 : -1, 1) // 角色翻转之后,血条也要跟着翻转以保持不变 this.hp.node.setScale(direction.x > 0 ? 1 : -1, 1) } // 根据移动角度设置武器的朝向 const side = Math.sqrt(direction.x ** 2 + direction.y ** 2) const rad = Math.asin(direction.y / side) const angle = rad2Angle(rad) this.wm.node.setRotationFromEuler(0, 0, angle) } renderHP(data: IActor) { this.hp.progress = data.hp / this.hp.totalLength } ``` 2. 子弹的 BullerManager.ts 也类似: ```ts render(data: IBullet) { this.node.active = true // 确保子弹节点是激活的,up的代码里没写,游戏子弹部分会消失 this.renderPos(data) this.renderDire(data) } renderPos(data: IBullet) { const { direction, position } = data const newPos = new Vec3(position.x, position.y) if (!this.targetPos) { // 第一次渲染位置 this.node.active = true this.node.setPosition(newPos) this.targetPos = new Vec3(newPos) } else if (!this.targetPos.equals(newPos)) { // 位置发生变化才真正执行,因为数据每100毫秒才发一次,但是render是每帧 this.tw?.stop() // 停止之前的动画 this.node.setPosition(this.targetPos) this.targetPos.set(newPos) this.tw = tween(this.node).to(0.1, { position: this.targetPos, }).start() } } renderDire(data: IBullet) { const { direction, position } = data const side = Math.sqrt(direction.x ** 2 + direction.y ** 2) let angle if (direction.x > 0) { angle = rad2Angle(Math.asin(direction.y / side)) } else { // 朝左侧发射子弹的时候,子弹朝向要处理一下 angle = rad2Angle(Math.asin(-direction.y / side)) + 180 } this.node.setRotationFromEuler(0, 0, angle) } ``` ### 29.帧同步预测回滚 1. 修改 NetworkManager 模拟延时 2 秒操作 ```ts // apps/client/assets/Scripts/Global/NetworkManager.ts async sendMsg() { // ... await new Promise((rs)=>setTimeout(rs, 2000)) // 模拟延时 2 秒 this.ws.send(JSON.stringify(msg)) } ``` 2. 解决方案:Input 发送给 Server,同时把 Input 应用到本地。比如要发送操作 12345 给客户端,由于延时的存在可能 123 先到服务器再传回给客户端了,后面 45 才到。在客户端接受到 123 时,此时客户端可以先回滚到上一次服务端状态,应用 Server Input 123,记录一下服务端状态,应用 Client Input 45。 3. 修改代码: ```ts // apps/client/assets/Scripts/Scene/BattleManager.ts handleClientSync(input: IClientInput) { const msg: IMsgClientSync = { input, frameId: DataManager.Instance.frameId++, } NetworkManager.Instance.sendMsg(ApiMsgEnum.MsgClientSync, msg) // input 应用到本地作为预测输入 if(input.type === InputTypeEnum.ActorMove) { DataManager.Instance.applyInput(input) this.pendingMsg.push(msg) } } handleServerSync({ inputs, lastFrameId }: IMsgServerSync) { // 回滚到上一次服务器状态 DataManager.Instance.state = DataManager.Instance.lastState // 应用服务器输入 for (const input of inputs) { DataManager.Instance.applyInput(input) } // 记录服务器状态 DataManager.Instance.lastState = deepClone(DataManager.Instance.state) // 应用本地输入 this.pendingMsg = this.pendingMsg.filter((msg)=>msg.frameId > lastFrameId) for (const msg of this.pendingMsg) { DataManager.Instance.applyInput(msg.input) } } // apps/client/assets/Scripts/Global/DataManager.ts lastState: IState // 用于记录上一次服务器状态 // apps/client/assets/Scripts/Scene/RoomManager.ts handleGameStart({ state }: IMsgGameStart) { DataManager.Instance.state = state DataManager.Instance.lastState = deepClone(state) // 游戏开始时初始化上一次服务器状态 director.loadScene(SceneEnum.Battle) } // apps/server/src/Biz/Room.ts lastPlayerFrameIdMap: Map = new Map() // 存储每个玩家的上一次输入事件的帧 ID getClientMsg(connection: Connection, { input, frameId }: IMsgClientSync) { this.pendingInput.push(input) this.lastPlayerFrameIdMap.set(connection.playerId, frameId) } sendServerMsg() { const inputs = this.pendingInput this.pendingInput = [] for (const player of this.players) { player.connection.sendMsg(ApiMsgEnum.MsgServerSync, { lastFrameId: this.lastPlayerFrameIdMap.get(player.id) ?? 0, inputs }) } } // apps/client/assets/Scripts/Utils/index.ts export const deepClone = (obj: any) => { if (typeof obj !== "object" || obj === null) { return obj } const res = Array.isArray(obj) ? [] : {} for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { res[key] = deepClone(obj[key]) } } return res } ``` ### 30.帧同步浮点数 1. 为了解决浮点数问题,就是实现 toFixed 函数,放大制定 10 的倍数后截取。新建文件`apps/server/src/Common/Utils.ts`: ```ts // apps/server/src/Common/Utils.ts export const toFixed = (num: number, digit: number = 4): number => { const scale = 10 ** digit return Math.floor(num * scale) / scale } ``` 2. 修改代码文件里的小数传递方式: ```ts // apps/client/assets/Scripts/Entity/Actor/ActorManager.ts EventManager.Instance.emit(EventEnum.ClientSync, { id: DataManager.Instance.myPlayerId, type: InputTypeEnum.ActorMove, direction: { x: toFixed(x), y: toFixed(y), }, dt: toFixed(dt), }) // apps/client/assets/Scripts/Entity/Weapon/WeaponManager.ts EventManager.Instance.emit(EventEnum.ClientSync, { type: InputTypeEnum.WeaponShoot, owner: this.owner, position: { x: toFixed(pointStagePos.x), y: toFixed(pointStagePos.y), }, direction: { x: toFixed(direction.x), y: toFixed(direction.y), }, }) // apps/server/src/Biz/Room.ts this.pendingInput.push({ type: InputTypeEnum.TimePast, dt: toFixed(dt), }) // apps/client/assets/Scripts/Global/DataManager.ts actor.position.x += toFixed(x * dt * ACTOR_SPEED) actor.position.y += toFixed(y * dt * ACTOR_SPEED) EventManager.Instance.emit( EventEnum.ExplosionBorn, bullet.id, { x: toFixed((actor.position.x + bullet.position.x) / 2), y: toFixed((actor.position.y + bullet.position.y) / 2), } ) // 子弹飞行 for (const bullet of bullets) { bullet.position.x += toFixed(bullet.direction.x * dt * BULLET_SPEED) bullet.position.y += toFixed(bullet.direction.y * dt * BULLET_SPEED) } ``` ### 31.字符编码 json字符串格式数据传输体积太大了,所以要用二进制传输数据。 前置知识: - Unicode 是什么:Unicode 是字符的集合,集合中每个字符都用四个字节来唯一标识 - UTF-8 是什么:UTF-8 是一种可变长的编码方式,由于 Unicode 每个字符都需要四个字节来表示,像英文字母这些本来在 ASCII 编码中只需要一个字节就能表示,显然就会出现浪费的情况 前端相关的二进制类数组: - ArrayBuffer: 初始化的时候需要长度参数,并且没法直接修改里面的数组(需要DataView) - DataView: 基于ArrayBuffer生成,提供了操作二进制数据非常方便的API(setUint8,setFloat32) - TypeArray(Uint8Array等): 最像js的数组,初始化的时候不要数组长度参数 - 例如要把“我是abc”这个字符串转成二进制数组ArrayBuffer(WebSocket的send方法的入参) 1. 首先使用字符编码(会返回TypeArray),得到这个字符串编码成二进制后的数组长度 2. 通过该长度,初始化ArrayBuffer,再通过DataView写入数组 3. WebSocket使用ArrayBuffer 1. 直接从 up 的代码仓库复制二进制编解码方法代码到`apps/server/src/Common/Utils.ts`,就两个方法:strencode、strdecode 2. 修改数据发送和接受代码: ```ts // apps/client/assets/Scripts/Global/NetworkManager.ts this.ws = new WebSocket(`ws://localhost:${this.port}`) this.ws.binaryType = "arraybuffer" // 设置二进制类型为 ArrayBuffer // 发送数据 const str = JSON.stringify(msg) const ta = strencode(str) const ab = new ArrayBuffer(ta.length) const da = new DataView(ab) for (let index = 0; index < ta.length; index++) { da.setUint8(index, ta[index]) } this.ws.send(da.buffer) // this.ws.send(JSON.stringify(msg)) // 接收数据 // console.log("ws 收到消息:", event.data) // const json = JSON.parse(event.data) const ta = new Uint8Array(event.data) const str = strdecode(ta) const json = JSON.parse(str) // apps/server/src/Core/Connections.ts // 接收数据 // const str = buffer.toString() const ta = new Uint8Array(buffer) const str = strdecode(ta) // 发送数据 const str = JSON.stringify(msg) const ta = strencode(str) const buffer = Buffer.from(ta) this.ws.send(buffer) // this.ws.send(JSON.stringify(msg)) ``` 3. 此时仅仅只是将数据转成了二进制,数据包大小和之前字符串还是没有变化,下一节进行优化。 ### 32.二进制编码 二进制编码核心思想:提取关键信息,并最大程度压缩 1. 常用二进制编码工具:如 Protobuf 2. 使用过程: 1. 第三方语言定义协议:idl文件(接口描述语言,通常以 thrift 后缀结尾) 2. 通过对应语言的编译器编译成目标语言(ts、java、go等),然后在项目里使用编码解码函数(Encode、Decode)进行处理 3. 原理: ``` // base thrift struct BaseInfo { 1: optional i32 id; 2: optional string url; 3: optional string name; } ``` 如果要发送 msg1,包含 BaseInfo 接口,只需要把里面有用的数据填充进二进制数据: ``` [1, 1234, "baidu.com", "haha"] 这种编码格式也要TLV格式:T(tag)表示唯一标识、L(Length)代表数据长度、V(value)代表数据的值 ``` 我们的项目是 ts 全栈项目,而且项目规模比较小,没必要使用 protobuf 这种重量编码工具。我们可以从原理出发,直接把 value 用对象里摘出来传输,不需要传对象的 key 了。 编写代码: ```ts // 先将 InputTypeEnum 和 ApiMsgEnum 都从字符串枚举改成数字,只需要删除 = 后面字符串的部分即可 // apps/server/src/Common/Enum.ts // export enum InputTypeEnum { // ActorMove = "ActorMove", // WeaponShoot = "WeaponShoot", // TimePast = "TimePast", // } export enum InputTypeEnum { ActorMove, WeaponShoot, TimePast, } ``` ```ts // 修改 apps/client/assets/Scripts/Global/NetworkManager.ts 和 apps/server/src/Core/Connections.ts 的 map 定义 // private map: Map> = new Map() private map: Map> = new Map() // private msgMap: Map> = new Map() private msgMap: Map> = new Map() ``` 编码数据原理例子如下: ```ts export const binaryEncodeExample = (name: ApiMsgEnum, data: any) => { if (name === ApiMsgEnum.MsgClientSync && data.input.type === InputTypeEnum.ActorMove) { const { frameId, input } = data // name // frameId: number // input: IClientInput // id: number // type: InputTypeEnum.ActorMove // direction: IVec2 // dt: number // new ArrayBuffer(1 + 4 + 1 + 1 + 4 + 4 + 4) let index = 0 const ab = new ArrayBuffer(1 + 4 + 14) const da = new DataView(ab) da.setUint8(index++, name) da.setUint32(index, frameId) index += 4 da.setUint8(index++, input.type) da.setUint8(index++, input.id) da.setFloat32(index, input.direction.x) index += 4 da.setFloat32(index, input.direction.y) index += 4 da.setFloat32(index, input.dt) index += 4 return ab } else { } } ``` 这里直接从 up 的代码仓库复制文件:`apps/server/src/Common/Binary.ts`。然后就能在代码里使用了: ```ts // apps/client/assets/Scripts/Global/NetworkManager.ts const json = binaryDecode(event.data) const { name, data } = json const da = binaryEncode(name, data) this.ws.send(da.buffer) // apps/server/src/Core/Connections.ts const json = binaryDecode(buffer2ArrayBuffer(buffer)) const { name, data } = json const da = binaryEncode(name, data) const buffer = Buffer.from(da.buffer) this.ws.send(buffer) ``` 此时运行游戏,发现数据包大小减少了非常多,最高的直降90%(91B->11B)。 ### 33.伪随机 seed:随机种子,一般小于 233280 即可,由服务端发送给所有客户端保证种子一致。 随机数计算公式:seed = (seed * 9301 + 49297) % 233280 - 玩家攻击随机暴击效果,seed / 233280 >= 暴击率(假设0.5) ```ts // apps/client/assets/Scripts/Utils/index.ts export const randomBySeed = (seed: number) => { return (seed * 9301 + 49297) % 233280 } // apps/client/assets/Scripts/Global/DataManager.ts // 随机暴击效果 const random = randomBySeed(this.state.seed) this.state.seed = random const damage = random / 233280 >= 0.5 ? BULLET_DAMAGE * 2 : BULLET_DAMAGE // 50% 几率暴击 actor.hp -= damage // 角色扣血 ``` 完结,当然教程还有很多暂未实现的功能,不过基于目前的服务端代码,要增加这些功能非常简单: 1. 房主功能、房间准备、踢人、聊天等 2. 游戏结束判定 3. 断线重连