最近闲来无事重新研究一下同步策略里面的帧同步,首先说下帧同步与状态同步的区别,
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 = 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);
}
}
}