联机游戏是指多个客户端共同参与的游戏, 这里主要有以下三种方式
1 . 玩家主机的 P2P 联机模式, 比如流星蝴蝶剑、以及破解游戏(盗版)
2 . 玩家进入公共服务器进行游戏,玩家资料由服务器储存的网络游戏, 比如星际争霸、魔兽等
3 . 可以在单人模式中开启局域网来与他人进行多人游戏,但仅限于连接同一局域网的玩家使用
大多数联机游戏采用的是 CS 架构, 使用独立设备作为主机与玩家进行交互通信
image.png
client/server 架构
这种模式, 将所有玩家的请求发送到同一个线程中进行处理, 主线程每隔一段时间对所有对象进行更新. 适合一些回合制以及运算量小的游戏
后来随着玩家越来越多, 第一代架构已经不堪重负, 于是就产生了第二种架构 --- 分服, 这样对玩家进行分流, 让玩家在不同的服务器上玩, 不同服之间就像不同的平行世界
虽然第二代架构已经可以满足玩家增长的需求 (人满了就再开个服), 但是又出现了玩家开始想跨服玩或者时间长了, 单服务器上没有多少活跃玩家, 所以又出现了世界服模型
这种设计将网关、和数据存储进行分离, 数据使用同一个数据服务器, 不同游戏服务器的数据交换由网关进行交换
在基础三层架构的基础上再进行拆分, 将不同的功能进行抽离独立, 提高性能
在进阶三层架构中, 地图的切换总是需要loading (DNF), 为了解决这个问题, 在无缝地图架构中, 由一组节点 (Node) 服务器来管理地图区域, 这个组就是 NodeMaster, 它来进行整体管理, 如果还有更大的就再又更大的 WorldMaster 来进行管理
玩家在地图上进行移动其实就是在 Node 服务器间进行移动, 比如从 A ----> B, 需要由 NodeMaster 把数据从 NodeA 复制到 NodeB 后, 再移除 NodeA 的数据
联机最大特点便是多玩家之间的交互, 保证每个玩家的数据和显示一致是必不可少的步骤, 在介绍同步方案之前, 我们先来了解一下如何实现两端的通信
极度简陋的聊天室 Demo (React + node)[1]
实现步骤:
1 . 前后端建立连接
2 . 前端发送消息至服务端
3 . 服务端收到消息后对当前所有用户进行广播
4 . 前端收到广播, 更新状态
// 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
我们把服务端返回老状态作为基础状态, 然后再筛选出这个老状态之后的操作进行预测, 这样就可以避免客户端回退的现象发生
我们通过之前的 预测、和解 两个步骤, 已经可以实现 客户端 无延迟且不卡顿的效果, 但是联机游戏是多玩家交互, 自己虽然不卡了, 但是在别的玩家那里却没有办法做预测和和解, 所以在其他玩家的视角中, 我们仍然是一卡一卡的
我们这时候使用一些过渡动画, 让移动变得丝滑起来, 虽然本质上接受到的实际状态还是一卡一卡的, 但是至少看起来不卡
// 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 的效果有所不同.
为什么说标题是入门即入土? 网络联机游戏的原理还有很多很多, 通信和同步测量只是基础中的基础, 在学习的过程中才发现, 联机游戏的领域还很大, 这对我来说是一个很大的挑战.
[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
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8