这回我们来试着把一个单机游戏 (@pmndrs 开发的) 改写成多人在线游戏.
原来的游戏是用 React 和 react-three-fiber 开发的.
本教程将涵盖:
- 创建 Colyseus 服务器
- 将 Player 位置发往服务器 ( Player 位置属客户端权威)
- 在场景中显示远程 Player
参考
- Github 上的源代码
- Colyseus Arena 上的 Live demo
第一步: 了解原版游戏 (单机版)
首先, 我们来分析一下游戏项目, 搞清楚它是如何处理 Player 移动的.
从 App.tsx
文件中我们看到 Vehicle
组件是用于 Player 逻辑和表现的:
<Vehicle angularVelocity={[...angularVelocity]} position={[...position]} rotation={[...rotation]}>
Vehicle
组件负责处理移动的键盘事件并渲染 3D 模型, 包括车体和它的轮子 - Chassis
和 Wheel
两个组件.
我们复用这个 Vehicle
代表 当前 Player . 在每一帧中让服务器知晓其位置 (x, y, z) 和 旋转角度, 所以其他 Player 都能看到它. 然后新建一个组件来代表 "其他" Player .
第二步: 创建服务器
为了接收各个 Player 的位置, 我们首先要创建一个 Colyseus 服务器. 在控制台, 运行如下命令:
npm init colyseus-app ./my-colyseus-server 2cd my-colyseus-server
根据提示选择 TypeScript 作为其编程语言, 本教程用的就是 TypeScript.
首先要在开发环境安装配置好 Node.js LTS
启动服务器, 运行命令 npm start
.
定义同步结构 (即 Schema)
Colyseus 使用叫做 Schema 的数据结构在连接的 Player 之间进行实时数据同步. (参考文档)
我们要使用一个 Map 结构存放 Player, 包括其位置和旋转角度.
为了方便, 我们先定义一个 Schema 模型代表位置 (x, y, z) 和 旋转角度 (x, y, z, w).
// {SERVER_ROOT}/src/rooms/schema/MyRoomState.ts
import { Schema, type } from '@colyseus/schema'
export class AxisData extends Schema {
@type('number')
x: number = 0
@type('number')
y: number = 0
@type('number')
z: number = 0
@type('number')
w: number = 0
}
然后我们来定义一个 Player
结构使用 AxisData
保存其位置和旋转:
// {SERVER_ROOT}/src/rooms/schema/MyRoomState.ts
// ...
export class Player extends Schema {
@type('string')
sessionId = ''
@type(AxisData)
position: AxisData = new AxisData()
@type(AxisData)
rotation: AxisData = new AxisData()
}
本教程后面会用到这些结构, 主要是 {SERVER_ROOT}/src/rooms/MyRoom.ts
里的游戏房间逻辑.
第三步: 游戏整合 Colyseus 客户端
为了连接服务器, 需要在客户端项目里安装 Colyseus SDK.
新开一个控制台, 运行如下命令
npm install --save colyseus.js
如果需要安装 JavaScript/TypeScript SDK 的方法, 参见文档.
现在来做一个连接 Colyseus 服务器的功能. 新建文件 src/network/api.ts
:
// {CLIENT_ROOT}/src/network/api.ts
import { Client, Room } from 'colyseus.js'
// import state type directly from the server code
import type { MyRoomState } from '{SERVER_ROOT}/src/rooms/schema/GameRoomState'
const COLYSEUS_HOST = 'ws://localhost:2567'
const GAME_ROOM = 'my_room'
export const client: Client = new Client(COLYSEUS_HOST)
export let gameRoom: Room<MyRoomState>
export const joinGame = async (): Promise<Room> => {
gameRoom = await client.joinOrCreate<MyRoomState>(GAME_ROOM)
return gameRoom
}
export const initializeNetwork = function () {
return new Promise<void>((resolve, reject) => {
joinGame()
.then((room) => {
gameRoom = room
gameRoom.state.players.onAdd = (player, sessionId) => {
if (sessionId === gameRoom.sessionId) {
gameRoom.state.players.onAdd = undefined
resolve()
}
}
})
.catch((err) => reject(err))
})
}
Colyseus 提供的 sessionId
代表当前游戏 session. 我们用这个值判断哪个 player
是**当前 Player **.
让我们从 main.tsx
里的应用入口调用 initializeNetwork
. 如果 initializeNetwork
连接失败, 我们要在客户端上显示出 "network failure" 的信息.
// {CLIENT_ROOT}/src/main.tsx
import { createRoot } from 'react-dom/client'
import { useGLTF, useTexture } from '@react-three/drei'
import 'inter-ui'
import './styles.css'
import App from './App'
import { initializeNetwork } from './network/api'
// ...
const defaultStyle = { color: 'green', paddingLeft: '2%' }
const errorStyle = { color: 'red', paddingLeft: '2%' }
const root = createRoot(document.getElementById('root')!)
root.render(
<div style={defaultStyle}>
<h2>Establishing connection with server...</h2>
</div>,
)
initializeNetwork()
.then(() => {
root.render(<App />)
})
.catch((e) => {
console.error(e)
root.render(
<div style={errorStyle}>
<h2>Network failure!</h2>
<h3>Is your server running?</h3>
</div>,
)
})
好, 现在我们能连接上服务器了.
目前仍然没有客户端-服务器之间的信息交换, 我们继续吧.
第四步: 从服务器上创建 Player
现在来在随机位置上创建各个 Player. 该功能由服务器负责.
服务器上创建的位置信息将会同步到每个连接着的客户端里.
// {SERVER_ROOT}/src/rooms/GameRoom.ts
// ...
onJoin(client: Client, options: any) {
// Initialize dummy player positions
const newPlayer = new Player()
newPlayer.sessionId = client.sessionId
newPlayer.position.x = -generateRandomInteger(109, 115)
newPlayer.position.y = 0.75 11 newPlayer.position.z = generateRandomInteger(215, 220)
newPlayer.rotation.w = 0
newPlayer.rotation.x = 0
newPlayer.rotation.y = Math.PI / 2 + 0.35
newPlayer.rotation.z = 0
this.state.players.set(client.sessionId, newPlayer)
}
// ...
由于这些数据被保存在了游戏房间的 state 里, 之后加入房间的 Player 也都能接收到这些数据.
现在客户端方面, 我们来把接收到的信息用在主 Player 上.
// {CLIENT_ROOT}/src/App.tsx
// ...
const room = gameRoom
const currentPlayer = room.state.players.get(room.sessionId)!
// ...
<Vehicle
key={currentPlayer.sessionId}
angularVelocity={[0, 0, 0]}
position={[currentPlayer.position.x, currentPlayer.position.y, currentPlayer.position.z]}
rotation={[0, Math.PI / 2 + 0.33, 0]}>
{light && <primitive object={light.target} />}
<Cameras />
</Vehicle>
// ...
第五步: 持续向服务器发送 Player 位置
在客户端的 Chassis
模型里, 我们要在每一帧向服务器发送一个 Player 的位置和旋转角度信息.
// {CLIENT_ROOT}/src/models/vehicle/Chassis.tsx
import { gameRoom } from '../../network/api'
// ...
useFrame((_, delta) => {
// ...
if (chassis_1.current.parent) {
const _position = new Vector3()
chassis_1.current.getWorldPosition(_position)
const _rotation = new Quaternion()
chassis_1.current.getWorldQuaternion(_rotation)
gameRoom.send('movementData', {
position: { x: _position.x, y: _position.y, z: _position.z },
rotation: { w: _rotation.w, x: _rotation.x, y: _rotation.y, z: _rotation.z }
})
}
})
这种形式被称作 客户端权威 -- 客户端 决定了服务器上的 state
现在在服务器端, 我们要基于发信者的 sessionId
, 更新 Player 的位置.
为了处理来自客户端的信息, 我们要定义一个处理 "movementData"
消息的监听处理程序.
服务器不知道谁是 "当前" Player, 我们要用发信者的 sessionId
判断是谁发来消息, 该维护谁的数据:
// {SERVER_ROOT}/src/rooms/GameRoom.ts
onCreate(options: any) {
this.setState(new GameRoomState())
this.onMessage('movementData', (client, data) => {
const player = this.state.players.get(client.sessionId)
if (!player) {
console.warn("trying to move a player that doesn't exist", client.sessionId)
return
}
player.position.x = data.position.x
player.position.y = data.position.y
player.position.z = data.position.z
player.rotation.w = data.rotation.w
player.rotation.x = data.rotation.x
player.rotation.y = data.rotation.y
player.rotation.z = data.rotation.z
})
}
第六步: 对手 Player 的可视化
对于对手 Player, 我们简单地拷贝了主 Player 的 Vehicle
和 Chassis
组件, 分别重命名为 OpponentVehicle
和 OpponentChassis
, 然后删除了诸如键盘事件, 摄像机位置等没用的代码.
最后改好的代码参见 Github 上的 OpponentVehicle 和 OpponentChasis.
第七步: 创建对手 Player
让我们来新建一个对手列表组件, 以确保无论何时 Player 进入/离开 房间, 我们只渲染列表里的对手.
Colyseus 服务器的数据改变时就会触发客户端的 Schema 回调函数. 我们要在 Player 加入/离开 房间时的 onAdd
和 onRemove
回调里强制刷新渲染对手.
onAdd
和 onRemove
回调不宜每帧都添加监听. 所以我们使用 useLayoutEffect(() => {}, [])
调用确保只添加监听一次.
// {CLIENT_ROOT}/src/network/OpponentListComponent.tsx
import React, { useLayoutEffect, useState } from 'react'
import type { Player } from './api'
import { gameRoom } from './api'
import { OpponentVehicle } from '../models/vehicle/OpponentVehicle'
export function OpponentListComponent() {
const room = gameRoom
function getOpponents() {
const opponents: Player[] = []
room.state.players.forEach((opponent, sessionId) => {
// ignore current/local player
if (sessionId === room.sessionId) {
return
}
opponents.push(opponent)
})
return opponents
}
const [otherPlayers, setOtherPlayers] = useState(getOpponents())
useLayoutEffect(() => {
let timeout: number
room.state.players.onAdd = (_, key) => {
// use timeout to prevent re-rendering multiple times
window.clearTimeout(timeout)
timeout = window.setTimeout(() => {
// skip if current/local player
if (key === room.sessionId) {
return
}
setOtherPlayers(getOpponents())
}, 50)
}
room.state.players.onRemove = (player) => setOtherPlayers(otherPlayers.filter((p) => p !== player))
}, [])
return (
<group>
{otherPlayers.map((player) => {
return (
<OpponentVehicle
key={player.sessionId}
player={player}
playerId={player.sessionId}
angularVelocity={[0, 0, 0]}
position={[player.position.x, player.position.y, player.position.z]}
rotation={[player.rotation.x, player.rotation.y, player.rotation.z]}
></OpponentVehicle>
)
})}
</group>
)
}
现在对手列表组件准备好了, 我们把它放在 主 App.tsx
组件里的 主 Vehicle
旁边.
// {CLIENT_ROOT}/src/App.tsx
// ...
<ToggledDebug scale={1.0001} color="white">
{
<Vehicle
key={currentPlayer.sessionId}
angularVelocity={[0, 0, 0]}
position={[currentPlayer.position.x, currentPlayer.position.y, currentPlayer.position.z]}
rotation={[0, Math.PI / 2 + 0.33, 0]}
>
{light && <primitive object={light.target} />}
<Cameras />
</Vehicle>
}
<OpponentListComponent />
<Train />
// ...
好了! 现在我们就能够看到 Player 加入/离开 房间了. 多人在线即将完成!
第八步: 移动对手 Player
客户端已经接收了每个对手的最新的位置和旋转信息, 但是我们还没有使用他们.
我们要在先前创建的 OpponentChassis
组件里更新对手的可视状态, 并基于来自服务器的数据持续更新对手的位置和旋转.
我们会用到 useFrame 回调:
// {CLIENT_ROOT}/src/models/vehicle/OpponentChassis.tsx
// ...
import type { Player } from '../../network/api'
export const OpponentChassis = forwardRef<Group, PropsWithChildren<BoxProps & { player: Player }>>(
// ...
const player = props.player
useFrame((/*_, delta*/) => {
chassis_1.current.material.color.set('maroon')
// Set synchronized player movement for the frame
api.quaternion.set(player.rotation.x, player.rotation.y, player.rotation.z, player.rotation.w)
api.position.set(player.position.x, player.position.y, player.position.z)
})
// ...
好! 现在每个对手都基于 Player
schema 的更新移动了.
第九步: "迷你地图" 上的对手
最后一件事是我们要在 "迷你地图" 上显示对手标记. 详细情况就不在这里阐述了. 方法和我们对对手列表的处理非常类似. 详情请参考 Minimap.tsx 实现.
结语
希望您能从这篇教程当中学到些什么. 下个教程再见!