一、背景

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

  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(() => {// 初始化 SocketinitSocket();// 初始化用户信息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 (<divkey={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>预测<inputdisabled={disabled}type="checkbox"checked={player.predict}onChange={() => {socket.emit("predict-change", {id: curPlayer.current.id,predict: !player.predict,});}}></input></div><div>和解<inputdisabled={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"><divid={`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"><divid={`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"><divclassName="left"style={{backgroundColor: teamColor[player.state.team],transform: `translateX(${serverPlayerList[index]?.state?.x ?? 0}px)`,}}></div></div>)}<div>delay:<inputtype="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:<inputonChange={(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><buttononMouseDown={() => {window.requestAnimationFrame(handleLeft);}}onMouseUp={() => {cancelAnimationFrame(btnTimer.current);}}disabled={disabled}>左</button><buttononMouseDown={() => {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

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

联机游戏原理入门即入土 -- 入门篇相关推荐

  1. Gurobi教程-从入门到入土-一篇顶万篇

    再详尽的帮助文档也不如举几个示例能让人看得明白,于是想通过一个帖子涵盖Gurobi的所有操作. 安装与激活 软件下载.免学术ip申请学术许可,参见 Gurobi中国官方网站.具体过程简单且网上很多教程 ...

  2. 【从入门到入土系列】C语言制作小游戏-贪吃蛇:Copy+运行即可另附注释

    系列文章 本系列持续更新中,欢迎您的访问! 系列简介 本系列由唐文疏撰写,负责记录博主的学习生涯中的一点一滴.独乐乐不如众乐乐,故此分享给大家.欢迎大家一起讨论.学习和批评指点. 博主只是一个普普通通 ...

  3. 【C++从入门到入土】第五篇:继承(爆肝画图详解)

    系列文章目录 [C++从入门到入土]第一篇:从C到C++. [C++从入门到入土]第二篇:类和对象基础. [C++从入门到入土]第三篇:类和对象提高. [C++从入门到入土]第四篇:运算符重载. 文章 ...

  4. 【CE入门教程】使用Cheat Engine(CE)修改游戏“植物大战僵尸”之植物篇

    目录 1.单卡片无CD 1.1 思路一 1.2 思路二 2.全卡片无CD 3.豌豆射手射速修改(修改植物射速) 4.实现豌豆射手发射"玉米加农炮"(思路) 上一期教程中,我们学习了 ...

  5. 【CE入门教程】使用Cheat Engine(CE)修改游戏“植物大战僵尸”之其他篇

    目录 1.跳关(任意选择关卡) 2.修改金币值 3.实现自动收集阳光 上一期教程中,我们学习了寻找植物大战僵尸僵尸距离基址.实现"秒杀"僵尸的方法.PS:上篇链接:[CE入门教程] ...

  6. Git使用 从入门到入土 收藏吃灰系列(四) Git工作原理

    文章目录 一.前言 一.Git基本理论(核心) 1.1工作区 1.2工作流程 一.前言 参考安装Git 详细安装教程 参考视频B站 Git最新教程通俗易懂,这个有点长,感觉讲的精华不多 参考视频『Gi ...

  7. 【AC军团周报(第二周)第二篇】线段树从入门到入土【2】

    本文章连载AC军团周报 -> 线段树 : 从入门到入土[2] 前言: 第二期了,我们要把上一期留下的锅补一下. 这一期的内容主要是懒标记,处理区间修改的问题. 我们最后再分析一下线段树时间复杂度 ...

  8. 【CE入门教程】使用Cheat Engine(CE)修改游戏“植物大战僵尸”之僵尸篇

    目录 1.寻找僵尸位置基址 2.实现"秒杀"僵尸(修改僵尸血量) 上一期教程中,我们学习了修改植物大战僵尸的单卡片无CD.全卡片无CD.豌豆射手射速修改以及实现豌豆射手发射&quo ...

  9. 【AC军团周报(第一周)第一篇】线段树从入门到入土【1】

    本文章连载AC军团周报 -> 线段树 : 从入门到入土[1] 前言: 正如你所见,我这系列文章可以从入门来看,想入土的(伪)也可以进行观看(逃 本系列的文章将详讲线段树的思想,代码实现,并以一部 ...

最新文章

  1. PHP如何进阶,提升自己
  2. loss函数为何选交叉熵
  3. EM算法 大白话讲解
  4. tensorflow2.X安装及使用
  5. UA MATH567 高维统计I 概率不等式8 亚指数范数
  6. SAP-SD计划行类别解析
  7. (十四)java版spring cloud+spring boot 社交电子商务平台-使用spring cloud Bus刷新配置...
  8. 集合三人斗地主的思路
  9. 1._请写出5种以上的android中界面常用布局方式,跳槽季“充电宝”Android面试题(一)...
  10. python中控制代码块逻辑关系_一、Python基础知识
  11. STM32常见错误error: #268: declaration may not appear after executable statement
  12. linux命令行终端设置tab补全文件名或路径不区分大小写(大小写不敏感)
  13. linux在shell中获取时间 date巧用
  14. jQuery - 滚动条插件 NiceScroll 使用详解(滚动条美化)
  15. VB.net 研华IO卡1762的编程方法 控件方法 VS2010专业版
  16. 如何带领好一个销售团队
  17. Ogre引擎渲染系列之Normal Specular Mapping
  18. 《STM32学习笔记》4——核心功能电路与编程(下)
  19. java sub函数,请问Sub子过程与Function函数过程有什么区别?
  20. Netty 通过 WebSocket 编程实现服务器和客户端全双工长连接<2021SC@SDUSC>

热门文章

  1. 图解K-Means算法
  2. c语言对10万位进行显示,C语言位运算
  3. 金港赢简述大基建大金融和三赛道
  4. 2.如何创建一个隐藏的文件夹,并且打开这个隐藏的文件夹
  5. Python免费教育工具 Online Python Tutor
  6. 通过公众号等私域渠道,为视频号直播引流
  7. 商务智能与第三方物流企业管理
  8. keras.metrics解析
  9. C语言使用说明笔记1.2
  10. RecyclerView拖拽卡顿