cocos creator frame synchronization game example

Recently I have been free to re-study the frame synchronization in the synchronization strategy. First, let’s talk about the difference between frame synchronization and status synchronization.

1: Frame synchronization:

Frame synchronization is a network synchronization technology commonly used in multiplayer games to ensure that the game state between different players is consistent. In frame synchronization, all players achieve a consistent game state by executing game logic and rendering according to the same frame sequence.

The following is the basic process of game frame synchronization:

  1. Server sends frame data: The server in the game, as the host, is responsible for generating the game's frame data and sending it to all clients. Frame data contains all necessary information about the current game state, such as player position, object status, etc.

  2. The client receives frame data: The client receives the frame data sent by the server, and simulates and renders the game logic based on the received data.

  3. Client frame synchronization: The client executes game logic and rendering in sequence according to the frame sequence sent by the server based on the received frame data. This ensures that all clients play the game in the same order and state, maintaining consistency.

  4. Player input synchronization: Each client sends player input information to the server, and the server collects and processes all player input.

  5. Server logic processing: The server performs game logic processing based on the received player input information. This ensures that all clients' game state is based on the same input.

  6. Update frame data: After the server executes the game logic, it generates new frame data and sends it to all clients, repeating the above process.

Through frame synchronization, all players can see the same game state on different clients, ensuring the fairness and consistency of the game.

 

advantage:

  1. Fairness: Frame synchronization ensures that the game state seen by all players on different clients is consistent, avoiding unfair situations.

  2. Predictability: Because all clients execute game logic according to the same frame sequence, players can predict the actions and results of other players, increasing strategy and competition.

  3. Simplify network communication: Frame synchronization only needs to transmit frame data and player input, instead of transmitting every operation and state change in the game, reducing the amount of data and overhead in network communication. Friendly for playback

  4. Allow offline and reconnection: Frame synchronization allows players to rejoin after going offline or disconnecting during the game, because the player's status and input are synchronized based on frame data.

shortcoming:

  1. Network latency and instability: Network latency can cause frame synchronization delays and freezes, especially when the quality of the network connection between players is unstable. Higher latency affects the responsiveness and real-time nature of the game.

  2. Bandwidth and data volume requirements: Frame synchronization requires frequent transmission of frame data and player input between the server and the client, so it requires high network bandwidth and data volume. For large-scale multiplayer games or poor network conditions, network burden and cost may increase.

  3. Complexity and synchronization issues: The implementation of frame synchronization involves complex synchronization mechanisms and algorithms, including processing network delay, processing input prediction, compensation and interpolation, etc. These mechanics need to be carefully designed and adjusted to ensure consistency and smoothness of the game

2: Status synchronization

The following is the general process of status synchronization:

  1. Player input collection: Each client collects input information from local players, such as key operations, mouse movements, etc. This input information is usually recorded in the form of events and sent to the server at the appropriate time.

  2. Server processing input: After the server receives the player's input information, it executes the corresponding game logic and calculations. The server can simulate player actions and update game status.

  3. Game status update: The server updates the status, location, attributes and other information of objects in the game based on the results of executing game logic.

  4. Status Broadcast: The server broadcasts the updated game status to all clients. Broadcasts typically employ network communication protocols such as TCP or UDP to ensure reliable delivery of status updates.

  5. Client reception and application state: Each client receives game state updates broadcast by the server and applies them to local game simulation and rendering. The client updates the game scene, character position, animation, etc. based on the status updates received.

Through the above process, the player's input is processed and calculated on the server side, and the results are broadcast to all clients, thereby achieving synchronization of multiplayer game status.

During the process of status synchronization, factors such as network delay, bandwidth limitations, and data compression need to be considered to ensure the real-time and efficiency of status updates. Some optimization techniques, such as difference interpolation, prediction and compensation, can be used to reduce latency and smooth state changes, providing a better gaming experience.

3: The following example is done using the frame synchronization strategy.

This example server uses the colyseus third-party network synchronization solution, and the client uses cocos creator2.4.8

Be careful to ensure that the colyseus version on the server is compatible with the colyseus version on the client, otherwise it is easy to cause errors due to version incompatibility. For specific error reports, you can read my previous article: Common reasons for colyseus error reports.

I won’t go into details about how to introduce colyseus, just search it on the official website.

a: The client processes the message class of the colyseus server:

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<MyRoomState> = 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<MyRoomState>("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<any>) {
        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<Array<any>>} data 帧数据数组
     */
    receiveAllFrameData(data: Array<Array<any>>) {
        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: The player control class is responsible for recording the player's operations for uploading to the server:

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: The player performs logical processing after receiving the socket message:

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) {

    }
}

 Server room logic:


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<MyRoomState> {
  maxClients = 4;
  patchRate: number = 20;

  /** 当前游戏进行的帧数 */
  public frame_index: number = 0;
  /** 帧间隔 服务端计时器 */
  public frame_interval: Delayed = null;
  /** 游戏中帧列表 */
  public frame_list: Array<Array<{ id: string, data: any }>> = [];
  /** 帧插值 服务端发送 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<any>, 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);
    }
  }
}

Guess you like

Origin blog.csdn.net/lck8989/article/details/131247543