ココスクリエイター フレーム同期ゲーム例

最近暇なので同期戦略のフレーム同期について勉強し直していますが、まずフレーム同期とステータス同期の違いについてお話します。

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<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: プレーヤー コントロール クラスは、サーバーにアップロードするプレーヤーの操作を記録する責任があります。

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: プレーヤーはソケット メッセージを受信した後、論理処理を実行します。

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<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);
    }
  }
}

おすすめ

転載: blog.csdn.net/lck8989/article/details/131247543