联机游戏原理入门即入土 -- 入门篇

语言: CN / TW / HK

       

本文来自 字节教育-成人与创新前端团队 ,已授权 ELab 发布。

单机游戏是了解别人的人生, 而联机游戏是体验另一种人生 ---- by 核桃仁

一、背景

联机游戏是指多个客户端共同参与的游戏, 这里主要有以下三种方式

  1. 玩家主机的 P2P 联机模式, 比如流星蝴蝶剑、以及破解游戏(盗版)

  1. 玩家进入公共服务器进行游戏,玩家资料由服务器储存的网络游戏, 比如星际争霸、魔兽等

  1. 可以在单人模式中开启局域网来与他人进行多人游戏,但仅限于连接同一局域网的玩家使用

二、服务器架构历史

大多数联机游戏采用的是 CS 架构, 使用独立设备作为主机与玩家进行交互通信

image.png

client/server 架构

第一代架构(一个服):

这种模式, 将所有玩家的请求发送到同一个线程中进行处理, 主线程每隔一段时间对所有对象进行更新. 适合一些回合制以及运算量小的游戏

第二代架构(分服):

后来随着玩家越来越多, 第一代架构已经不堪重负, 于是就产生了第二种架构 --- 分服, 这样对玩家进行分流, 让玩家在不同的服务器上玩, 不同服之间就像不同的平行世界

第三代架构(世界服):

虽然第二代架构已经可以满足玩家增长的需求 (人满了就再开个服), 但是又出现了玩家开始想跨服玩或者时间长了, 单服务器上没有多少活跃玩家, 所以又出现了世界服模型

基础三层架构

这种设计将网关、和数据存储进行分离, 数据使用同一个数据服务器, 不同游戏服务器的数据交换由网关进行交换

进阶三层架构

在基础三层架构的基础上再进行拆分, 将不同的功能进行抽离独立, 提高性能

无缝地图架构

在进阶三层架构中, 地图的切换总是需要loading (DNF), 为了解决这个问题, 在无缝地图架构中, 由一组节点 (Node) 服务器来管理地图区域, 这个组就是 NodeMaster, 它来进行整体管理, 如果还有更大的就再又更大的 WorldMaster 来进行管理

玩家在地图上进行移动其实就是在 Node 服务器间进行移动, 比如从 A ----> B, 需要由 NodeMaster 把数据从 NodeA 复制到 NodeB 后, 再移除 NodeA 的数据

三、通信

联机最大特点便是多玩家之间的交互, 保证每个玩家的数据和显示一致是必不可少的步骤, 在介绍同步方案之前, 我们先来了解一下如何实现两端的通信

长连接通信 (Socket.io)

极度简陋的聊天室 Demo (React + node) [1]

实现步骤:

  1. 前后端建立连接

  1. 前端发送消息至服务端

  1. 服务端收到消息后对当前所有用户进行广播

  1. 前端收到广播, 更新状态

// client
import React, { memo, useEffect, useState, useRef } from "react";
import { io } from "socket.io-client";
import { nanoid } from "nanoid";

import "./index.css";

const host = "192.168.0.108",
port = 3101;

const ChatRoom = () => {
const [socket, setSocket] = useState(io());
const [message, setMessage] = useState("");
const [content, setContent] = useState<
{
id: string;
message: string;
type?: string;
}[]
>([]);
const [userList, setUserList] = useState<string[]>([]);

const userInfo = useRef({ id: "", enterRoomTS: 0 });
const roomState = useRef({
content: [] as {
id: string;
message: string;
type?: string;
}[],
});

useEffect(() => {
// 初始化 Socket
initSocket();

// 初始化用户信息
userInfo.current = {
id: nanoid(),
enterRoomTS: Date.now(),
};
}, []);

useEffect(() => {
roomState.current.content = content;
}, [content]);

const initSocket = () => {
const socket = io(`ws://${host}:${port}`);
setSocket(socket);

// 建立连接
socket.on("connect", () => {
console.log("连接成功");
//用户加入
socket.emit("add user", userInfo.current);
});

//用户加入聊天室
socket.on("user joined", ({ id, userList }) => {
const newContent = [...roomState.current.content];
newContent.push({ id, message: `${id}加入`, type: "tip" });

setContent(newContent);
setUserList(userList);
});

//新消息
socket.on("new message", ({ id, message }) => {
const newContent = [...roomState.current.content];
newContent.push({ id, message });

setContent(newContent);
});

//用户离开聊天室
socket.on("user leave", function ({ id, userList }) {
const newContent = [...roomState.current.content];
newContent.push({ id, message: `${id}离开`, type: "tip" });

setContent(newContent);
setUserList(userList);
});
};

const handleEnterSend: React.KeyboardEventHandler<HTMLTextAreaElement> = (
e
) => {
if (e.key === "Enter") {
//客户端发送新消息
socket.emit("new message", {
id: userInfo.current.id,
message,
});
setMessage("");
e.preventDefault();
}
};

const handleButtonSend = () => {
//客户端发送新消息
socket.emit("new message", {
id: userInfo.current.id,
message,
});
setMessage("");
};

const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
const val = e.target.value ?? "";
setMessage(val);
};

const handleQuit = () => {
//断开连接
socket.disconnect();
};

return (
<div>
//...
</div>
);
};

export default memo(ChatRoom);
// server
import { Server } from "socket.io";

const host = "192.168.0.108",
port = 3101;

const io = new Server(port, { cors: true });
const sessionList = [];

io.on("connection", (socket) => {
console.log("socket connected successful");

//用户进入聊天室
socket.on("add user", ({ id }) => {
socket.id = id;
if (!sessionList.includes(id)) {
sessionList.push(id);
}

console.log(`${id} 已加入房间, 房间人数: ${sessionList.length}`);
console.log(JSON.stringify(sessionList));

io.emit("user joined", { id, userList: sessionList });
});

//发送的新消息
socket.on("new message", ({ id, message }) => {
io.emit("new message", { id, message });
});

socket.on("disconnect", () => {
sessionList.splice(sessionList.indexOf(socket.id), 1);
socket.broadcast.emit("user leave", {
id: socket.id,
userList: sessionList,
});
});
});

四、同步策略

现在大多游戏常用的两种同步技术方向分别是: 帧同步状态同步

帧同步的方式服务端很简单, 只承担了操作转发的操作, 你给我了什么, 我就通知其他人你怎么了, 具体的执行是各个客户端拿到操作后自己执行

image.png

状态同步

状态同步是客户端将操作告诉服务端, 然后服务端拿着操作进行计算, 最后把结果返给各个客户端, 然后客户端根据新数据进行渲染即可

image.png

延时同步处理

我们先看看不处理延时的情况:

image.png

网络延时是无法避免的, 但我们可以通过一些方法让玩家感受不到延时, 主要有以下三个步骤

预测

先说明预测不是预判, 也需要玩家进行操作, 只是 客户端 不再等待 服务端 的返回, 先自行计算操作展示给玩家, 等 服务端 状态返回后再次渲染:

image.png

虽然在客户端通过预测的方式提前模拟了玩家的操作, 但是服务端返回的状态始终是之前的状态, 所以我们会发现有状态回退的现象发生

和解

预测能让客户端流畅的运行, 如果我们在此基础上再做一层处理是否能够避免状态回退的方式呢? 如果我们在收到服务端的延迟状态的时候, 在这个延迟基础上再进行预测就可以避免回退啦! 看看下面的流程:

image.png

我们把服务端返回老状态作为基础状态, 然后再筛选出这个老状态之后的操作进行预测, 这样就可以避免客户端回退的现象发生

插值

我们通过之前的 预测、和解 两个步骤, 已经可以实现 客户端 无延迟且不卡顿的效果, 但是联机游戏是多玩家交互, 自己虽然不卡了, 但是在别的玩家那里却没有办法做预测和和解, 所以在其他玩家的视角中, 我们仍然是一卡一卡的

我们这时候使用一些过渡动画, 让移动变得丝滑起来, 虽然本质上接受到的实际状态还是一卡一卡的, 但是至少看起来不卡

五、 同步策略主要实现 [2]

// index.tsx
type Action = {
actionId: string;
actionType: -1 | 1;
ts: number;
};

const GameDemo = () => {
const [socket, setSocket] = useState(io());
const [playerList, setPlayerList] = useState<Player[]>([]);
const [serverPlayerList, setServerPlayerList] = useState<Player[]>([]);
const [query, setQuery] = useUrlState({ port: 3101, host: "localhost" });

const curPlayer = useRef(new Player({ id: nanoid(), speed: 5 }));
const btnTimer = useRef<number>(0);
const actionList = useRef<Action[]>([]);
const prePlayerList = useRef<Player[]>([]);

useEffect(() => {
initSocket();
}, []);

const initSocket = () => {
const { host, port } = query;
console.error(host, port);

const socket = io(`ws://${host}:${port}`);
socket.id = curPlayer.current.id;

setSocket(socket);

socket.on("connect", () => {
// 创建玩家
socket.emit("create-player", { id: curPlayer.current.id });
});

socket.on("create-player-done", ({ playerList }) => {
setPlayerList(playerList);
const curPlayerIndex = (playerList as Player[]).findIndex(
(player) => player.id === curPlayer.current.id
);
curPlayer.current.socketId = playerList[curPlayerIndex].socketId;
});

socket.on("player-disconnect", ({ id, playerList }) => {
setPlayerList(playerList);
});

socket.on("interval-update", ({ state }) => {
curPlayer.current.state = state;
});


socket.on(
"update-state",
({
playerList,
actionId: _actionId,
}: {
playerList: Player[];
actionId: string;
ts: number;
}) => {
setPlayerList(playerList);

const player = playerList.find((p) => curPlayer.current.id === p.id);
if (player) {
// 和解
if (player.reconciliation && _actionId) {
const actionIndex = actionList.current.findIndex(
(action) => action.actionId === _actionId
);

// 偏移量计算
let pivot = 0;
// 过滤掉状态之前的操作, 留下预测操作
for (let i = actionIndex; i < actionList.current.length; i++) {
pivot += actionList.current[i].actionType;
}

const newPlayerState = cloneDeep(player);
// 计算和解后的位置
newPlayerState.state.x += pivot * player.speed;
curPlayer.current = newPlayerState;
} else {
curPlayer.current = player;
}
}

playerList.forEach((player) => {
// 其他玩家
if (player.interpolation && player.id !== curPlayer.current.id) {
// 插值
const prePlayerIndex = prePlayerList.current.findIndex(
(p) => player.id === p.id
);
// 第一次记录
if (prePlayerIndex === -1) {
prePlayerList.current.push(player);
} else {
// 如果已经有过去的状态
const thumbEl = document.getElementById(`thumb-${player.id}`);

if (thumbEl) {
const prePos = {
x: prePlayerList.current[prePlayerIndex].state.x,
};

new TWEEN.Tween(prePos)
.to({ x: player.state.x }, 100)
.onUpdate(() => {
thumbEl.style.setProperty(
"transform",
`translateX(${prePos.x}px)`
);
console.error("onUpdate", 2, prePos.x);
})
.start();
}
prePlayerList.current[prePlayerIndex] = player;
}
}
});
}
);

// 服务端无延迟返回状态
socket.on("update-real-state", ({ playerList }) => {
setServerPlayerList(playerList);
});
};

// 玩家操作 (输入)
// 向左移动
const handleLeft = () => {
const { id, predict, speed, reconciliation } = curPlayer.current;
// 和解
if (reconciliation) {
const actionId = uuidv4();
actionList.current.push({ actionId, actionType: -1, ts: Date.now() });
socket.emit("handle-left", { id, actionId });
} else {
socket.emit("handle-left", { id });
}

// 预测
if (predict) {
curPlayer.current.state.x -= speed;
}

btnTimer.current = window.requestAnimationFrame(handleLeft);
TWEEN.update();
};

// 向右移动
const handleRight = (time?: number) => {
const { id, predict, speed, reconciliation } = curPlayer.current;
// 和解
if (reconciliation) {
const actionId = uuidv4();
actionList.current.push({ actionId, actionType: 1, ts: Date.now() });
socket.emit("handle-right", { id, actionId });
} else {
socket.emit("handle-right", { id });
}
// 预测
if (predict) {
curPlayer.current.state.x += speed;
}

// socket.emit("handle-right", { id });

btnTimer.current = window.requestAnimationFrame(handleRight);
TWEEN.update();
};

return (
<div>
<div>
当前用户
<div>{curPlayer.current.id}</div>
在线用户
{playerList.map((player) => {
return (
<div
key={player.id}
style={{ display: "flex", justifyContent: "space-around" }}
>
<div>{player.id}</div>
<div>{moment(player.enterRoomTS).format("HH:mm:ss")}</div>
</div>
);
})}
</div>

{playerList.map((player, index) => {
const mySelf = player.id === curPlayer.current.id;
const disabled = !mySelf;

return (
<div className="player-wrapper" key={player.id}>
<div style={{ display: "flex", justifyContent: "space-evenly" }}>
<div style={{ color: mySelf ? "red" : "black" }}>{player.id}</div>
<div>
预测
<input
disabled={disabled}
type="checkbox"
checked={player.predict}
onChange={() => {
socket.emit("predict-change", {
id: curPlayer.current.id,
predict: !player.predict,
});
}}
></input>
</div>
<div>
和解
<input
disabled={disabled}
type="checkbox"
checked={player.reconciliation}
onChange={() => {
socket.emit("reconciliation-change", {
id: curPlayer.current.id,
reconciliation: !player.reconciliation,
});
}}
></input>
</div>
<div>
插值
<input
// disabled={!disabled}
disabled={true}
type="checkbox"
checked={player.interpolation}
onChange={() => {
socket.emit("interpolation-change", {
id: player.id,
interpolation: !player.interpolation,
});
}}
></input>
</div>
</div>

<div>Client</div>
{mySelf ? (
<div className="track">
<div
id={`thumb-${player.id}`}
className="left"
style={{
backgroundColor: teamColor[player.state.team],
transform: `translateX(${
// 是否预测
curPlayer.current.predict
? curPlayer.current.state.x
: player.state.x
}px)`,
}}
>
自己
</div>
</div>
) : (
<div className="track">
<div
id={`thumb-${player.id}`}
className="left"
style={
// 是否插值
player.interpolation
? {
backgroundColor: teamColor[player.state.team],
}
: {
backgroundColor: teamColor[player.state.team],
transform: `translateX(${player.state.x}px)`,
}
}
>
别人
</div>
</div>
)}

<div>Server</div>
{serverPlayerList.length && (
<div className="server-track">
<div
className="left"
style={{
backgroundColor: teamColor[player.state.team],
transform: `translateX(${
serverPlayerList[index]?.state?.x ?? 0
}px)`,
}}
></div>
</div>
)}

<div>
delay:
<input
type="number"
min={1}
max={3000}
onChange={(e) => {
const val = parseInt(e.target.value);
socket.emit("delay-change", {
delay: val,
id: curPlayer.current.id,
});
}}
value={player.delay}
disabled={disabled}
></input>
speed:
<input
onChange={(e) => {
const val =
e.target.value === "" ? 0 : parseInt(e.target.value);
socket.emit("speed-change", {
speed: val,
id: curPlayer.current.id,
});
}}
value={player.speed}
disabled={disabled}
></input>
</div>
<button
onMouseDown={() => {
window.requestAnimationFrame(handleLeft);
}}
onMouseUp={() => {
cancelAnimationFrame(btnTimer.current);
}}
disabled={disabled}
>

</button>
<button
onMouseDown={() => {
window.requestAnimationFrame(handleRight);
}}
onMouseUp={() => {
cancelAnimationFrame(btnTimer.current);
}}
disabled={disabled}
>

</button>
</div>
);
})}
</div>
);
};

export default memo(GameDemo);

六、结束语

首先感谢在学习过程中给我提供帮助的 大佬King [3] . 我先模仿着 他的动图 [4] 和讲解的思路自己实现了一版 动图里面的效果 [5] , 我发现我的效果总是比较卡顿, 于是我拿到了动图demo的代码进行学习, 原来只是一个纯前端的演示效果, 所以与我使用 socket 的效果有所不同.

为什么说标题是入门即入土? 网络联机游戏的原理还有很多很多, 通信和同步测量只是基础中的基础, 在学习的过程中才发现, 联机游戏的领域还很大, 这对我来说是一个很大的挑战.

七、参考

  • 如何设计大型游戏服务器架构?-今日头条 [6]

  • 2 天做了个多人实时对战,200ms 延迟竟然也能丝滑流畅? - 掘金 [7]

  • 如何做一款网络联机的游戏? - 知乎 [8]

参考资料

[1]

极度简陋的聊天室 Demo (React + node): https://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/chat-room

[2]

同步策略主要实现: https://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/game-demo

[3]

大佬King: https://juejin.cn/user/3272618092799501

[4]

他的动图: https://juejin.cn/post/7041560950897377293

[5]

动图里面的效果: https://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/game-demo

[6]

如何设计大型游戏服务器架构?-今日头条: https://www.toutiao.com/article/6768682173030466051/

[7]

2 天做了个多人实时对战,200ms 延迟竟然也能丝滑流畅? - 掘金: https://juejin.cn/post/7041560950897377293

[8]

如何做一款网络联机的游戏? - 知乎: https://www.zhihu.com/question/275075420

- END -

:heart: 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章

字节 / :   13HAUHW

:   https://jobs.toutiao.com/s/LvNLPHX