最近暇なので同期戦略のフレーム同期について勉強し直していますが、まずフレーム同期とステータス同期の違いについてお話します。
1: フレーム同期:
フレーム同期は、異なるプレイヤー間のゲーム状態の一貫性を確保するために、マルチプレイヤー ゲームで一般的に使用されるネットワーク同期テクノロジです。フレーム同期では、すべてのプレイヤーが同じフレーム シーケンスに従ってゲーム ロジックを実行し、レンダリングすることで一貫したゲーム状態を実現します。
ゲームフレーム同期の基本プロセスは次のとおりです。
-
サーバーがフレーム データを送信する: ゲーム内のサーバーはホストとして、ゲームのフレーム データを生成し、それをすべてのクライアントに送信する責任があります。フレーム データには、プレイヤーの位置、オブジェクトのステータスなど、現在のゲーム状態に関する必要な情報がすべて含まれています。
-
クライアントはフレーム データを受信します。クライアントはサーバーから送信されたフレーム データを受信し、受信したデータに基づいてゲーム ロジックをシミュレートおよびレンダリングします。
-
クライアントフレーム同期: クライアントは、受信したフレームデータに基づいて、サーバーから送信されたフレームシーケンスに従って、ゲームロジックとレンダリングを順番に実行します。これにより、すべてのクライアントが同じ順序と状態でゲームをプレイし、一貫性が維持されるようになります。
-
プレーヤー入力の同期: 各クライアントはプレーヤー入力情報をサーバーに送信し、サーバーはすべてのプレーヤー入力を収集して処理します。
-
サーバーロジック処理:サーバーは、受信したプレイヤー入力情報に基づいてゲームロジック処理を実行します。これにより、すべてのクライアントのゲーム状態が同じ入力に基づいていることが保証されます。
-
フレーム データの更新: サーバーはゲーム ロジックを実行した後、新しいフレーム データを生成してすべてのクライアントに送信し、上記のプロセスを繰り返します。
フレーム同期により、すべてのプレイヤーが異なるクライアント上で同じゲーム状態を確認できるため、ゲームの公平性と一貫性が確保されます。
アドバンテージ:
-
公平性: フレーム同期により、異なるクライアント上のすべてのプレイヤーから見えるゲーム状態が一貫していることが保証され、不公平な状況が回避されます。
-
予測可能性: すべてのクライアントが同じフレーム シーケンスに従ってゲーム ロジックを実行するため、プレーヤーは他のプレーヤーのアクションと結果を予測でき、戦略と競争が高まります。
-
ネットワーク通信の簡素化: フレーム同期では、ゲーム内のすべての操作や状態変化を送信するのではなく、フレーム データとプレーヤー入力のみを送信する必要があるため、ネットワーク通信のデータ量とオーバーヘッドが削減されます。再生に優しい
-
オフラインと再接続を許可: フレーム同期により、プレーヤーのステータスと入力がフレーム データに基づいて同期されるため、プレーヤーはオフラインになった後、またはゲーム中に切断された後に再参加できます。
欠点:
-
ネットワーク遅延と不安定性: ネットワーク遅延により、特にプレーヤー間のネットワーク接続の品質が不安定な場合、フレーム同期の遅延やフリーズが発生する可能性があります。遅延が長くなると、ゲームの応答性とリアルタイム性に影響します。
-
帯域幅とデータ量の要件: フレーム同期では、サーバーとクライアントの間でフレーム データとプレーヤー入力を頻繁に送信する必要があるため、高いネットワーク帯域幅とデータ量が必要です。大規模なマルチプレイヤー ゲームやネットワーク状態が悪い場合は、ネットワークの負担とコストが増加する可能性があります。
-
複雑さと同期の問題: フレーム同期の実装には、ネットワーク遅延の処理、入力予測の処理、補償と補間などを含む、複雑な同期メカニズムとアルゴリズムが含まれます。ゲームの一貫性とスムーズさを確保するには、これらのメカニズムを慎重に設計および調整する必要があります。
2: ステータスの同期
ステータス同期の一般的なプロセスは次のとおりです。
-
プレーヤー入力収集: 各クライアントは、キー操作、マウスの動きなどの入力情報をローカル プレーヤーから収集します。この入力情報は通常、イベントの形式で記録され、適切なタイミングでサーバーに送信されます。
-
サーバーの入力処理: サーバーはプレーヤーの入力情報を受信した後、対応するゲーム ロジックと計算を実行します。サーバーはプレーヤーのアクションをシミュレートし、ゲームのステータスを更新できます。
-
ゲーム ステータスの更新: サーバーは、ゲーム ロジックの実行結果に基づいて、ゲーム内のオブジェクトのステータス、位置、属性、およびその他の情報を更新します。
-
ステータス ブロードキャスト: サーバーは、更新されたゲーム ステータスをすべてのクライアントにブロードキャストします。ブロードキャストでは通常、TCP や UDP などのネットワーク通信プロトコルを使用して、ステータス更新の信頼性の高い配信を保証します。
-
クライアントの受信とアプリケーションの状態: 各クライアントは、サーバーによってブロードキャストされたゲーム状態の更新を受信し、それらをローカル ゲームのシミュレーションとレンダリングに適用します。クライアントは受信したステータス更新に基づいてゲームシーン、キャラクターの位置、アニメーションなどを更新します。
上記のプロセスを通じて、プレイヤーの入力はサーバー側で処理および計算され、その結果がすべてのクライアントにブロードキャストされることで、マルチプレイヤー ゲームのステータスの同期が実現されます。
ステータス同期のプロセス中、ステータス更新のリアルタイム性と効率性を確保するには、ネットワーク遅延、帯域幅制限、データ圧縮などの要因を考慮する必要があります。差分補間、予測、補償などの一部の最適化手法を使用すると、待ち時間を短縮し、状態の変化を滑らかにし、より良いゲーム体験を提供できます。
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);
}
}
}