聯機遊戲原理入門即入土 -- 入門篇
大 廠 技 術 堅 持 周 更 精 選 好 文
本文來自 字節教育-成人與創新前端團隊 ,已授權 ELab 發佈。
單機遊戲是瞭解別人的人生, 而聯機遊戲是體驗另一種人生 ---- by 核桃仁
一、背景
聯機遊戲是指多個客户端共同參與的遊戲, 這裏主要有以下三種方式
-
玩家主機的 P2P 聯機模式, 比如流星蝴蝶劍、以及破解遊戲(盜版)
-
玩家進入公共服務器進行遊戲,玩家資料由服務器儲存的網絡遊戲, 比如星際爭霸、魔獸等
-
可以在單人模式中開啟局域網來與他人進行多人遊戲,但僅限於連接同一局域網的玩家使用
二、服務器架構歷史
大多數聯機遊戲採用的是 CS 架構, 使用獨立設備作為主機與玩家進行交互通信
![](http://mdimg.wxwenku.com/getimg/356ed03bdc643f9448b3f6485edc229b66bce12e11046f35631aff0527a426c2734d6e819d5226ee4777f8ff1f9cf11f.jpg)
client/server 架構
第一代架構(一個服):
這種模式, 將所有玩家的請求發送到同一個線程中進行處理, 主線程每隔一段時間對所有對象進行更新. 適合一些回合制以及運算量小的遊戲
![](http://mdimg.wxwenku.com/getimg/6b990ce30fa9193e296dd37902816f4ba754ce5a36c4c50bcc1063a9880632b21ee59ecee28367c66b619e265b3f0ca3.jpg)
第二代架構(分服):
後來隨着玩家越來越多, 第一代架構已經不堪重負, 於是就產生了第二種架構 --- 分服, 這樣對玩家進行分流, 讓玩家在不同的服務器上玩, 不同服之間就像不同的平行世界
![](http://mdimg.wxwenku.com/getimg/ccdf080c7af7e8a10e9b88444af98393e2d9a62ba6110767dff6350cdf78b1be113ee004aed3c5bda8f4d04c9f12d78e.jpg)
第三代架構(世界服):
雖然第二代架構已經可以滿足玩家增長的需求 (人滿了就再開個服), 但是又出現了玩家開始想跨服玩或者時間長了, 單服務器上沒有多少活躍玩家, 所以又出現了世界服模型
基礎三層架構
這種設計將網關、和數據存儲進行分離, 數據使用同一個數據服務器, 不同遊戲服務器的數據交換由網關進行交換
![](http://mdimg.wxwenku.com/getimg/356ed03bdc643f9448b3f6485edc229bd1390965e1ccf37c127dae459766ce5969488bf6ed6cbdea5f098285bfa89016.jpg)
進階三層架構
在基礎三層架構的基礎上再進行拆分, 將不同的功能進行抽離獨立, 提高性能
![](http://mdimg.wxwenku.com/getimg/ccdf080c7af7e8a10e9b88444af98393b740a09777f76aa924e1b28473ff718ecdd4bf46af126253d7303882d493d3be.jpg)
無縫地圖架構
在進階三層架構中, 地圖的切換總是需要loading (DNF), 為了解決這個問題, 在無縫地圖架構中, 由一組節點 (Node) 服務器來管理地圖區域, 這個組就是 NodeMaster, 它來進行整體管理, 如果還有更大的就再又更大的 WorldMaster 來進行管理
![](http://mdimg.wxwenku.com/getimg/6b990ce30fa9193e296dd37902816f4b9c19c2393f73d5fba940c10d7481ec4642ee27b8f345fec4234bdd158a134fd4.jpg)
玩家在地圖上進行移動其實就是在 Node 服務器間進行移動, 比如從 A ----> B, 需要由 NodeMaster 把數據從 NodeA 複製到 NodeB 後, 再移除 NodeA 的數據
三、通信
聯機最大特點便是多玩家之間的交互, 保證每個玩家的數據和顯示一致是必不可少的步驟, 在介紹同步方案之前, 我們先來了解一下如何實現兩端的通信
長連接通信 (Socket.io)
極度簡陋的聊天室 Demo (React + node) [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,
});
});
});
四、同步策略
現在大多遊戲常用的兩種同步技術方向分別是: 幀同步 和 狀態同步
幀同步的方式服務端很簡單, 只承擔了操作轉發的操作, 你給我了什麼, 我就通知其他人你怎麼了, 具體的執行是各個客户端拿到操作後自己執行
![](http://mdimg.wxwenku.com/getimg/6b990ce30fa9193e296dd37902816f4b833b72f4e22f247f2fe35a775ef3399a3ad4b5ec9b80feb235ba3b1f6b6d93de.jpg)
狀態同步
狀態同步是客户端將操作告訴服務端, 然後服務端拿着操作進行計算, 最後把結果返給各個客户端, 然後客户端根據新數據進行渲染即可
![](http://mdimg.wxwenku.com/getimg/356ed03bdc643f9448b3f6485edc229b7ba2edfc2aca97b5e4131316de7c70bc8c523d699195c0e969f5aca3905339e3.jpg)
延時同步處理
我們先看看不處理延時的情況:
![](http://mdimg.wxwenku.com/getimg/356ed03bdc643f9448b3f6485edc229b0808ef5fb00ed25269d583379fd244252123191643cfc973d7335e884d9b750f.jpg)
網絡延時是無法避免的, 但我們可以通過一些方法讓玩家感受不到延時, 主要有以下三個步驟
預測
先説明預測不是預判, 也需要玩家進行操作, 只是 客户端 不再等待 服務端 的返回, 先自行計算操作展示給玩家, 等 服務端 狀態返回後再次渲染:
![](http://mdimg.wxwenku.com/getimg/356ed03bdc643f9448b3f6485edc229bf907e2ccbbdc4793c335973a665cd44bb950f5d33092d2f9e7f766c26b357cbe.jpg)
雖然在客户端通過預測的方式提前模擬了玩家的操作, 但是服務端返回的狀態始終是之前的狀態, 所以我們會發現有狀態回退的現象發生
和解
預測能讓客户端流暢的運行, 如果我們在此基礎上再做一層處理是否能夠避免狀態回退的方式呢? 如果我們在收到服務端的延遲狀態的時候, 在這個延遲基礎上再進行預測就可以避免回退啦! 看看下面的流程:
![](http://mdimg.wxwenku.com/getimg/ccdf080c7af7e8a10e9b88444af98393046cf4bc271d6f4b9bef46dabe4150677117401f5a583d6d54cadef33d3b7cc0.jpg)
我們把服務端返回老狀態作為基礎狀態, 然後再篩選出這個老狀態之後的操作進行預測, 這樣就可以避免客户端回退的現象發生
插值
我們通過之前的 預測、和解 兩個步驟, 已經可以實現 客户端 無延遲且不卡頓的效果, 但是聯機遊戲是多玩家交互, 自己雖然不卡了, 但是在別的玩家那裏卻沒有辦法做預測和和解, 所以在其他玩家的視角中, 我們仍然是一卡一卡的
我們這時候使用一些過渡動畫, 讓移動變得絲滑起來, 雖然本質上接受到的實際狀態還是一卡一卡的, 但是至少看起來不卡
五、 同步策略主要實現 [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]
參考資料
極度簡陋的聊天室 Demo (React + node): http://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/chat-room
同步策略主要實現: http://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/game-demo
大佬King: http://juejin.cn/user/3272618092799501
他的動圖: http://juejin.cn/post/7041560950897377293
動圖裏面的效果: http://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/game-demo
如何設計大型遊戲服務器架構?-今日頭條: http://www.toutiao.com/article/6768682173030466051/
2 天做了個多人實時對戰,200ms 延遲竟然也能絲滑流暢? - 掘金: http://juejin.cn/post/7041560950897377293
如何做一款網絡聯機的遊戲? - 知乎: http://www.zhihu.com/question/275075420
- END -
:heart: 謝謝支持
以上便是本次分享的全部內容,希望對你有所幫助^_^
喜歡的話別忘了 分享、點贊、收藏 三連哦~。
歡迎關注公眾號 ELab團隊 收貨大廠一手好文章
字節 跳 動 校 / 社 招 內 推 碼 : 13HAUHW
投 遞 鏈 接 : http://jobs.toutiao.com/s/LvNLPHX
- 使用 WebAssembly 打造定製 JS Runtime
- 前端也要懂算法,不會算法也能微調一個 NLP 預訓練模型
- 聯機遊戲原理入門即入土 -- 入門篇
- Plasmo Framework:次世代的瀏覽器插件開發框架
- 深入理解 Mocha 測試框架:從零實現一個 Mocha
- Single Source of Truth:XCode SwiftUI 的界面編輯的設計理念
- 深入理解 D3.js 可視化庫之力導向圖原理與實現
- 淺析神經網絡 Neural Networks
- Cutter - Web視頻剪輯工具原理淺析
- 你可能需要一個四捨五入的工具函數
- 淺析eslint原理
- 最小編譯器the-super-tiny-compiler
- Git存儲原理及部分實現
- 淺談短鏈的設計
- Web組件構建庫-Lit
- 使用Svelte開發Chrome Extension
- Web3.0開發入門
- vscode插件原理淺析與實戰
- 深入淺出 Web Audio API
- 探祕HTTPS