0_1670586900630_57c95dc4-2a21-4cdd-a7c8-473e480ff750-image.png

这回我们来试着把一个单机游戏 (@pmndrs 开发的) 改写成多人在线游戏.

原来的游戏是用 Reactreact-three-fiber 开发的.

0_1670670509330_cfa89e2a-eee2-43e3-87b1-909c20d4ed45-image.png

本教程将涵盖:

  1. 创建 Colyseus 服务器
  2. 将 Player 位置发往服务器 ( Player 位置属客户端权威)
  3. 在场景中显示远程 Player

参考


第一步: 了解原版游戏 (单机版)

首先, 我们来分析一下游戏项目, 搞清楚它是如何处理 Player 移动的.

App.tsx 文件中我们看到 Vehicle 组件是用于 Player 逻辑和表现的:

<Vehicle angularVelocity={[...angularVelocity]} position={[...position]} rotation={[...rotation]}>

Vehicle 组件负责处理移动的键盘事件并渲染 3D 模型, 包括车体和它的轮子 - ChassisWheel 两个组件.

我们复用这个 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 的 VehicleChassis 组件, 分别重命名为 OpponentVehicleOpponentChassis, 然后删除了诸如键盘事件, 摄像机位置等没用的代码.

最后改好的代码参见 Github 上的 OpponentVehicleOpponentChasis.

第七步: 创建对手 Player

让我们来新建一个对手列表组件, 以确保无论何时 Player 进入/离开 房间, 我们只渲染列表里的对手.

Colyseus 服务器的数据改变时就会触发客户端的 Schema 回调函数. 我们要在 Player 加入/离开 房间时的 onAddonRemove 回调里强制刷新渲染对手.

onAddonRemove 回调不宜每帧都添加监听. 所以我们使用 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 实现.

结语

希望您能从这篇教程当中学到些什么. 下个教程再见!


Follow Colyseus on Twitter, Join the discussion on Discord