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

語言: 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): http://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/chat-room

[2]

同步策略主要實現: http://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/game-demo

[3]

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

[4]

他的動圖: http://juejin.cn/post/7041560950897377293

[5]

動圖裏面的效果: http://github.com/SmaIIstars/react-demo/tree/master/src/pages/socket/game-demo

[6]

如何設計大型遊戲服務器架構?-今日頭條: http://www.toutiao.com/article/6768682173030466051/

[7]

2 天做了個多人實時對戰,200ms 延遲竟然也能絲滑流暢? - 掘金: http://juejin.cn/post/7041560950897377293

[8]

如何做一款網絡聯機的遊戲? - 知乎: http://www.zhihu.com/question/275075420

- END -

:heart: 謝謝支持

以上便是本次分享的全部內容,希望對你有所幫助^_^

喜歡的話別忘了 分享、點贊、收藏 三連哦~。

歡迎關注公眾號 ELab團隊 收貨大廠一手好文章

字節 / :   13HAUHW

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