聯機遊戲原理入門即入土 -- 入門篇

語言: 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