cocos creator 帧同步游戏示例

最近闲来无事重新研究一下同步策略里面的帧同步,首先说下帧同步与状态同步的区别,

1:帧同步:

帧同步是一种多人游戏中常用的网络同步技术,用于确保不同玩家之间的游戏状态保持一致。在帧同步中,所有玩家通过按照相同的帧序列执行游戏逻辑和渲染,以达到一致的游戏状态。

以下是游戏帧同步的基本流程:

  1. 服务器发送帧数据:游戏中的服务器作为主机负责生成游戏的帧数据,并将其发送给所有客户端。帧数据包含了当前游戏状态的所有必要信息,例如玩家位置、物体状态等。

  2. 客户端接收帧数据:客户端接收服务器发送的帧数据,并根据接收到的数据进行游戏逻辑的模拟和渲染。

  3. 客户端帧同步:客户端根据接收到的帧数据,按照服务器发送的帧序列顺序执行游戏逻辑和渲染。这确保了所有客户端按照相同的顺序和状态进行游戏,保持一致性。

  4. 玩家输入同步:每个客户端将玩家的输入信息发送给服务器,服务器收集并处理所有玩家的输入。

  5. 服务器逻辑处理:服务器根据接收到的玩家输入信息,执行游戏逻辑的处理。这确保了所有客户端的游戏状态基于相同的输入。

  6. 更新帧数据:服务器在执行完游戏逻辑后,生成新的帧数据,并将其发送给所有客户端,重复上述过程。

通过帧同步,所有玩家在不同的客户端上可以看到相同的游戏状态,保证了游戏的公平性和一致性。

 

优点:

  1. 公平性:帧同步确保所有玩家在不同客户端上看到的游戏状态是一致的,避免了不公平的情况出现。

  2. 可预测性:由于所有客户端按照相同的帧序列执行游戏逻辑,因此玩家可以预测其他玩家的行动和结果,增加了策略性和竞争性。

  3. 简化网络通信:帧同步只需要传输帧数据和玩家输入,而不需要传输游戏中的每个操作和状态变化,减少了网络通信的数据量和开销。对于回放比较友好

  4. 允许离线和断线重连:帧同步允许玩家在游戏进行中离线或断线后重新加入,因为玩家的状态和输入都是根据帧数据进行同步的。

缺点:

  1. 网络延迟和不稳定性:网络延迟会导致帧同步的延迟和卡顿,尤其在玩家之间的网络连接质量不稳定时。较高的延迟会影响游戏的响应性和实时性。

  2. 带宽和数据量要求:帧同步需要在服务器和客户端之间频繁传输帧数据和玩家输入,因此对网络带宽和数据量要求较高。对于大规模多人游戏或网络条件较差的情况,可能会增加网络负担和成本。

  3. 复杂性和同步问题:帧同步的实现涉及到复杂的同步机制和算法,包括处理网络延迟、处理输入预测、补偿和插值等。这些机制需要仔细设计和调整,以确保游戏的一致性和流畅

2:状态同步

下面是状态同步的一般流程:

  1. 玩家输入收集:每个客户端收集本地玩家的输入信息,例如按键操作、鼠标移动等。这些输入信息通常以事件的形式记录,并在适当的时机发送给服务器。

  2. 服务器处理输入:服务器接收到玩家的输入信息后,执行相应的游戏逻辑和计算。服务器可以模拟玩家的操作并更新游戏状态。

  3. 游戏状态更新:服务器根据执行游戏逻辑的结果,更新游戏中的物体状态、位置、属性等信息。

  4. 状态广播:服务器将更新后的游戏状态广播给所有客户端。广播通常采用网络通信协议,如TCP或UDP,以确保状态更新的可靠传输。

  5. 客户端接收和应用状态:各个客户端接收到服务器广播的游戏状态更新,将其应用于本地的游戏模拟和渲染。客户端根据接收到的状态更新,更新游戏场景、角色位置、动画等。

通过以上流程,玩家的输入在服务器端进行处理和计算,并将结果广播给所有客户端,从而实现多人游戏状态的同步。

在状态同步的过程中,需要考虑网络延迟、带宽限制和数据压缩等因素,以确保状态更新的实时性和效率。一些优化技术,如差值插值、预测和补偿等,可以用于减少延迟和平滑状态的变化,提供更好的游戏体验。

3:下面的示例是使用了帧同步的策略来做的

本示例服务端使用了colyseus 第三方网络同步方案,客户端使用cocos creator2.4.8

注意要确保服务端的colyseus的版本和客户端的colyseus版本适配,否则容易造成版本的不兼容导致的报错,具体报错可以看看我之前发的文章:colyseus的常见报错原因。

怎么引入colyseus我就不具体讲了,自行在官网搜索就可以了。

a: 客户端处理colyseus服务端的消息类:

import Player from "../../server/src/rooms/entity/Player";
import { MyRoomState } from "../../server/src/rooms/schema/MyRoomState";
import BallPlayer from "./BallPlayer";
import BallGameData, { FrameData, FrameDataItem, FrameListData, ballGameData } from "./GameData";
import { gameManager } from "./GameManager";
import BaseComp from "./common/ui/baseComp";
import { deepClone } from "./common/utils/util";

const { ccclass, property } = cc._decorator;

/**
 * 
 * 
 * 帧同步和状态同步结合的方式进行同步
 * 
 * 帧同步:连接建立完毕后 frameIndex = 0 每隔一段时间,服务器广播当前帧的状态,客户端收到后,更新当前帧的状态
 * 
 */
@ccclass("NetworkManager")
export default class NetworkManager extends BaseComp {

    @property hostname = "localhost";
    @property port = 2567;
    @property useSSL = false;

    public client: Colyseus.Client = null;
    public room: Colyseus.Room = null;

    /** 客户端缓存的所有帧数据 */
    public frameList: FrameListData[] = [];

    private serverFrameRate: number = 20;

    /** 默认16ms的帧速率 */
    public frameSpeed = 16;

    /** 服务端帧插值 */
    private serverFrameAcc: number = 3;

    /** 当前游戏进行到了第几帧 */
    private _frameIndex: number = 0;

    private lastTickTimeOut: any = null;

    public get frameIndex() {
        return this._frameIndex;
    }

    private set frameIndex(f: number) {
        this._frameIndex = f;
    }

    /** 房间是否初始化完毕 */
    public roomIsInit: boolean = false;

    private interval: any = null;

    __preload(): void {
        this.openFilter = true;
        super.__preload();
        gameManager.networkManager = this;
    }

    onLoad() {
        const url = `${this.useSSL ? "wss" : "ws"}://${this.hostname}${([443, 80].includes(this.port) || this.useSSL) ? "" : `:${this.port}`}`;
        console.log("url si ", url);
        // @ts-ignore
        this.client = new Colyseus.Client(url);
        console.log("client is ", this.client);
        // @ts-ignore
        console.log("colyseus version is ", Colyseus.VERSION);
        this.connect();
    }

    async connect() {
        try {
            console.log("joinOrCreate is ", this.client.joinOrCreate);
            this.room = await this.client.joinOrCreate("my_room");
            console.log("room is ", this.room);
            console.log(this.room.state.playerMap);
            /** 停止游戏 */
            cc.game.pause();

            this.room.send("link", { frame: this.frameIndex });
            this.roomIsInit = true;
            // 向服务端询问游戏所有帧数据
            this.room.send("all_frames");
            this.interval = setInterval(this.sendCmd.bind(this), 1000 / 60);

            this.room.state.playerMap.onAdd = (player, sessionId) => {
                console.log("player added ", player, " sessionId is ", sessionId);
                return function () {
                    return true;
                }
            };

            this.room.onStateChange((state) => {
                console.log('state change is ', state);
                this.updatePlayerInfo(state.playerMap);
            });

            // this.room.state.playerMap.onChange = (player, sessionId) => {

            // }

            this.room.onLeave((code) => {
                console.log('leave is ', code);
            });

            this.room.onMessage("*", this._messageHandler.bind(this));

        } catch (e) {
            console.log("e is ", e);
        }
    }

    /** 客户端以特定时间发送指令集 */
    sendCmd() {
        if (!this.roomIsInit) return;
        const player = gameManager.playerManager.playerMap.get(ballGameData.selfSesssioId);
        if (!player) return;
        // 获取当前帧的指令集
        const cmd = gameManager.joystickManager.getCmd();
        if (cmd) {
            this.room.send("cmd", cmd);
        }
    }

    /** 更新玩家信息 */
    updatePlayerInfo(players: any) {
        players.forEach((v, k) => {
            if (v) {
                if (v.id == ballGameData.selfSesssioId) {
                    // 更新自己的位置
                    return;
                }
                let player = gameManager.playerManager.playerMap.get(v.id);
                if (!player) {
                    gameManager.playerManager.pushPlayer(v.id);
                    player = gameManager.playerManager.playerMap.get(v.id);
                }
                const playerComp = player.getComponent(BallPlayer);
                playerComp.updatePlayerInfo(v);
            }
        })
    }

    /**
     * 服务端消息
     * @param  {any} type
     * @param  {any} message
     */
    _messageHandler(type: any, message: any) {
        switch (type) {
            case "joinSuccess":
                ballGameData.selfSesssioId = message;
                if (gameManager.playerManager.playerMap.get(ballGameData.selfSesssioId)) return;
                gameManager.playerManager.pushPlayer(ballGameData.selfSesssioId);
                break;
            case "f":
                // 当前帧的数据 服务器每隔50ms发送一次 客户端需要进行补帧操作
                this.receiveFrameData(message);
                break;
            case "all_frames":
                /** 游戏服务器上的所有帧数据 */
                this.receiveAllFrameData(message);
                // 执行自己的游戏循环
                this.nextTick();
                break;
            case "bye":
                break;
        }
    }

    /**
     * 接收服务器的帧数据
     */
    receiveFrameData(data: Array) {
        for (let i = 0, len = data.length; i < len; i++) {
            const frameIndex = data[0];
            this.frameList[frameIndex] = data[1] as FrameListData;
            if (!data[1]) {
                this.frameList[frameIndex] = [];
            }
            // 客户端对帧数据进行补帧操作 预测
            this.prediction(frameIndex, this.frameList[frameIndex]);
        }
    }

    /**
     * 接收服务端所有的帧数据
     * @param  {Array>} data 帧数据数组
     */
    receiveAllFrameData(data: Array>) {
        for (let i = 0, len = data.length; i < len; i++) {
            const frameIndex = data[i][0];
            this.frameList[frameIndex] = data[i][1] as FrameListData;
            if (!data[i][1]) {
                this.frameList[frameIndex] = [];
            }
            // 客户端对帧数据进行补帧操作 预测
            this.prediction(frameIndex, this.frameList[frameIndex]);
        }
        console.log("服务端所有帧数据:", this.frameList);
    }

    /**
     * 预测 补帧
     * @param  {number} frameIndex 当前帧的索引
     * @param  {FrameListData} serverFrameData 当前帧的数据
     */
    prediction(frameIndex: number, frameData: FrameListData) {
        for (let i = 1; i <= this.serverFrameAcc - 1; i++) {
            if (!this.frameList[frameIndex + i]) {
                this.frameList[frameIndex + i] = frameData;
            }
        }
    }

    /**
     * 执行当前帧
     */
    runTick() {
        let frame = null;
        if (this.frameList.length > 1) {
            frame = this.frameList[this.frameIndex];
        }

        // 如果frame 为null的话 说明当前帧没有数据 服务端也没有,说明客户端的帧快于服务端
        if (frame) {
            // console.log(`帧索引:${this.frameIndex}, 帧数据:${frame}`);
            if (frame.length > 0) {
                frame.forEach((item: FrameData) => {
                    const player = gameManager.playerManager.playerMap.get(item.id);
                    const actionData = item.data;
                    if (player) {
                        const playerComp = player.getComponent(BallPlayer);
                        playerComp.updatePlayerFromServer(actionData);
                    }
                });
            }
            // 前进帧
            this.frameIndex++;
            cc.game.step();
        } else {
            // console.warn("没有帧数据:", frame);
        }

    }

    nextTick() {
        this.lastTickTimeOut && clearTimeout(this.lastTickTimeOut);
        this.runTick();
        if (this.frameList.length - this.frameIndex > 100) {
            console.log("追帧...");
            this.frameSpeed = 0;
        } else if (this.frameList.length - this.frameIndex > 3) {
            this.frameSpeed = 0;
        } else {
            this.frameSpeed = 1000 / (this.serverFrameRate * (this.serverFrameAcc + 1));
        }
        // 16ms调用一次nextTick()
        this.lastTickTimeOut = setTimeout(this.nextTick.bind(this), this.frameSpeed);
    }

    start() {

    }

    update(dt) {

    }

    onDestroy(): void {
        clearInterval(this.interval);
    }
}

b: 玩家控制类负责将玩家的操作记录,以便上传到服务端:

import BallGameData, { ballGameData } from "./GameData";
import { gameManager } from "./GameManager";
import { eventManager } from "./common/managers/eventManager";
import BaseComp from "./common/ui/baseComp";
import { angleToRand, clamp, randToAngle } from "./common/utils/util";

const { ccclass, property } = cc._decorator;

@ccclass
export default class JoyStickManager extends BaseComp {

    private Handle: cc.Sprite = null;

    /** 当前摇杆的方向 */
    private _dir: cc.Vec2 = cc.v2(0);

    private _width: number = 0;
    private _height: number = 0;

    /** 当前摇杆的角度值 */
    private _angle: number = 0;

    /** 是否在移动 */
    private isMoving: boolean = false;

    public set angle(a: number) {
        this._angle = a;
    }

    public get angle() {
        return this._angle;
    }

    /** 获得当前摇杆的方向 */
    public get dir(): cc.Vec2 {
        return this._dir;
    }

    private set dir(d: cc.Vec2) {
        this._dir = d;
    }

    __preload(): void {
        this.openFilter = true;
        super.__preload();
        gameManager.joystickManager = this;
    }

    /*** 获取客户端玩家的所有指令集 */
    getCmd(): any {
        return { dir: this.dir, angle: this.angle, moving: this.isMoving };
    }

    onLoad() {
        this.Handle.node.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
        this.Handle.node.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
        this.Handle.node.on(cc.Node.EventType.TOUCH_END, this.onTouchEnd, this);
        this.Handle.node.on(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);

        this._width = this.node.width;
        this._height = this.node.height;

    }

    private onTouchStart(event: cc.Event.EventTouch) {
        let touchPos = this.node.convertToNodeSpaceAR(event.getLocation());
        let dir = touchPos;
        this.dir = dir.normalize();
    }

    private onTouchMove(event: cc.Event.EventTouch) {
        this.isMoving = true;
        let touchPos = this.node.convertToNodeSpaceAR(event.getLocation());
        let dir = touchPos;
        this.dir = dir.normalize();

        const cocosAngle = this.dir.angle(cc.v2(0, 1));

        // v1 dot v2 > 0 同向  v1 dot v2 < 0 反向
        // v1 cross v2 > 0 同侧 v1 cross v2 < 0 反侧
        let targetAngle = randToAngle(cocosAngle);
        let resAngle = targetAngle;
        if (cc.v2(0, 1).clone().cross(this.dir) > 0) {
            // 在y轴的左侧
            // eventManager.emit("updateDir", { angle: targetAngle, dir: this.dir });
            resAngle = targetAngle;
        } else {
            // 在y轴的右侧
            // eventManager.emit("updateDir", { angle: -targetAngle, dir: this.dir });
            resAngle = -targetAngle;
        }
        this.angle = resAngle;

        const angle = this.dir.angle(cc.v2(1, 0));
        const R = this._width / 2;
        const maxX = R * Math.cos(angleToRand(this.angle + 90));
        const maxY = R * Math.sin(angleToRand(this.angle + 90));

        if (Math.abs(touchPos.x) > maxX) {
            touchPos.x = maxX;
        }

        if (Math.abs(touchPos.y) > maxY) {
            touchPos.y = maxY;
        }

        this.Handle.node.setPosition(touchPos);
    }

    private onTouchEnd(event: cc.Event.EventTouch) {
        console.log("end...");
        this.Handle.node.setPosition(cc.v2(0));
        // this.dir = cc.v2(0);
        this.isMoving = false;

        gameManager.playerManager.stop(ballGameData.selfSesssioId);
        // gameManager.networkManager.room.send("stop", { dir: { x: this.dir.x, y: this.dir.y } });
    }

    start() {

    }

    // update (dt) {}
}

c: 玩家就收到socket消息之后进行逻辑处理:

import Player from "../../server/src/rooms/entity/Player";
import { FrameDataItem } from "./GameData";
import BaseComp from "./common/ui/baseComp";

const { ccclass, property } = cc._decorator;

@ccclass
export default class BallPlayer extends BaseComp {

    private candy: cc.Sprite = null;
    private username: cc.Label = null;

    speed: number = 200;

    private _dir: cc.Vec2 = cc.v2(0);
    private _moving: boolean = false;

    public get dir() {
        return this._dir;
    }

    public set dir(d: cc.Vec2) {
        this._dir = d;
    }

    public set moving(m: boolean) {
        this._moving = m;
    }

    public get moving() {
        return this._moving;
    }

    __preload() {
        this.openFilter = true;
        super.__preload();
    }

    onLoad() {

    }

    start() {

    }

    /** 更新玩家信息 */
    updatePlayerInfo(player: Player) {
        this.moving = false;
        this.node.scale = player.scale;
        this.node.angle = player.angle;
        this.dir = cc.v2(player.dir.x, player.dir.y);

        if (cc.v2(player.x, player.y).clone().sub(cc.v2(this.node.x, this.node.y)).mag() > 5) {
            console.log("tween...");
            // tween动画
            cc.tween(this.node).to(0.05, { x: player.x, y: player.y }).start();
        } else {
            this.node.x = player.x;
            this.node.y = player.y;
        }

    }

    /** 执行服务端的帧数据 ***/
    updatePlayerFromServer(data: FrameDataItem) {
        const angle = data.angle;
        this.dir = cc.v2(data.dir.x, data.dir.y);
        this.moving = data.moving;
        this.node.angle = angle;

        if (this.moving) {
            this.node.x += this.dir.x * this.speed * (16 / 1000);
            this.node.y += this.dir.y * this.speed * (16 / 1000);
        }

    }

    update(dt) {

    }
}

 服务端房间逻辑:


import { Room, Client, Delayed } from "@colyseus/core";
import { MyRoomState } from "./schema/MyRoomState";
import { IncomingMessage } from "http";
import Player, { Vec2 } from "./entity/Player";
import { MessageType } from "./models/ServerData";

const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";

/** 是否是帧同步 */
const isFrameSync: boolean = true;

/**
 * 
 * 1: mongo存储用户信息
 * 2: 进游戏之前,先登录
 * 
 */

export class MyRoom extends Room {
  maxClients = 4;
  patchRate: number = 20;

  /** 当前游戏进行的帧数 */
  public frame_index: number = 0;
  /** 帧间隔 服务端计时器 */
  public frame_interval: Delayed = null;
  /** 游戏中帧列表 */
  public frame_list: Array> = [];
  /** 帧插值 服务端发送 0,3,6,9隔帧发送减少带宽压力 客户端负责补帧 */
  public frame_acc: number = 3;

  onCreate(options: any) {
    console.log("create..");
    /** 启动时钟 */
    this.clock.start();

    this.setState(new MyRoomState());
    /** 初始化游戏进行中的帧数 */
    this.resetFrameInfo();

    this.setPatchRate(20);
    if (isFrameSync) {
      this.setSimulationInterval(this.update.bind(this), 1000 / 60);
    }

    this.frame_interval = this.clock.setInterval(this._tick.bind(this), 50);
    // this.broadcastPatch();
    this.onMessage("*", this._messageHandler.bind(this));
  }

  private _tick() {
    const curFrame = this.getFrameByIndex(this.frame_index);
    this.broadcast("f", [this.frame_index, curFrame]);
    this.frame_index += this.frame_acc;
  }

  private getFrameByIndex(index: number) {
    if (!this.frame_list[index]) {
      this.frame_list[index] = [];
    }
    return this.frame_list[index];
  }

  resetFrameInfo() {
    this.frame_index = 0;
    this.frame_list = [];
  }

  generateRoomIdSingle(): string {
    let result = "";
    for (let i = 0; i < 4; i++) {
      result += LETTERS.charAt(Math.floor(Math.random() * LETTERS.length));
    }
    return result;
  }

  /**
   * 收到客户端消息
   * @param  {Client} client 客户端
   * @param  {any} type 消息类型
   * @param  {any} message 消息内容
   */
  private _messageHandler(client: Client, type: any, message: any) {
    const sessionId = client.sessionId;
    const playerId = client.id;

    console.log("sessionId is ", sessionId);
    switch (type) {
      case MessageType.MOVE:
        this.movePlayer(playerId, message);
        break;
      case MessageType.STOP:
        this.stopPlayer(playerId);
        break;
      case MessageType.LINK:
        this.state.currentFrame = message.frame;
        break;
      case MessageType.CMD:
        this.onCmd(playerId, message);
        break;
      case MessageType.ALLFRAMES:
        this.onGetAllFrames(client, message);
        break;
      default:
        break;
    }
  }

  /**
   * 获得服务器上的所有游戏帧,并且发给客户端 让客户端补帧
   * @param  {Client} client 客户端
   * @param  {any} message 消息内容
   */
  onGetAllFrames(client: Client, message: any) {
    let frames: any = [];
    for (let i = 0, len = this.frame_list.length; i < len; i++) {
      if (this.frame_list[i]) {
        frames.push([i, this.frame_list[i]]);
      }
    }
    if (this.frame_list.length == 0) {
      frames.push([0, []]);
    }

    this.send(client, MessageType.ALLFRAMES, frames);
  }

  /**
   * 客户端发过来的指令
   * @param  {string} id 客户端的id
   * @param  {any} data 指令内容
   */
  onCmd(id: string, data: any) {
    console.log(`客户端id: ${id}: 操作指令:`, data);
    // 存在同一帧情况下,多个客户端同时发送指令的情况
    this.pushFrameList({ id, data });
  }


  /**
   * 向帧列表中添加数据
   * @param  {{id:string,data: any}} data
   */
  private pushFrameList(data: { id: string, data: any }) {
    if (!this.frame_list[this.frame_index]) {
      this.frame_list[this.frame_index] = [];
    }
    const selfIsCurrentFrame = this.frame_list[this.frame_index].find(item => item.id == data.id);
    if (!selfIsCurrentFrame)
      this.frame_list[this.frame_index].push(data);
  }

  movePlayer(id: string, data: { dir: { x: number, y: number }, angle: number, pos: { x: number, y: number } }) {
    const player = this.state.playerMap.get(id);
    if (!player) return;

    // 客户端传进来的方向向量
    player.dir = new Vec2(data.dir.x, data.dir.y);
    player.angle = data.angle;
    player.isMoving = true;
  }

  stopPlayer(id: string) {
    const player = this.state.playerMap.get(id);
    if (!player) return;

    player.isMoving = false;
  }

  onAuth(client: Client, options: any, request?: IncomingMessage) {
    console.log("onAuth");
    return true;
  }

  onJoin(client: Client, options: any) {
    console.log(client.sessionId, "joined!");

    this.send(client, "joinSuccess", client.id);
    // 存储用户
    this.state.playerMap.set(client.id, new Player(client.id));
  }

  onLeave(client: Client, consented: boolean) {
    console.log(client.sessionId, "left!");
    this.send(client, "bye", { id: client.id });
  }

  onDispose() {
    console.log("room", this.roomId, "disposing...");
    this.frame_interval.clear();
    this.resetFrameInfo();
  }

  update(dt: number) {
    if (!this.state) return;
    if (this.state.playerMap && this.state.playerMap.size === 0) return;

    const interval = dt / 1000;
    for (let player of this.state.playerMap.values()) {
      player.update(interval);
    }
  }
}

你可能感兴趣的:(游戏,服务器,运维)