<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[使用 Colyseus 把 React.js 游戏变成多人在线游戏]]></title><description><![CDATA[<p><img src="/assets/uploads/files/1670586903006-57c95dc4-2a21-4cdd-a7c8-473e480ff750-image.png" alt="0_1670586900630_57c95dc4-2a21-4cdd-a7c8-473e480ff750-image.png" class="img-responsive img-markdown" /></p>
<p>这回我们来试着把一个单机游戏 (<a href="https://github.com/pmndrs" rel="nofollow">@pmndrs</a> 开发的) 改写成多人在线游戏.</p>
<p>原来的游戏是用 <a href="https://github.com/facebook/react" rel="nofollow">React</a> 和 <a href="https://github.com/pmndrs/react-three-fiber" rel="nofollow">react-three-fiber</a> 开发的.</p>
<p><img src="/assets/uploads/files/1670670518505-cfa89e2a-eee2-43e3-87b1-909c20d4ed45-image.png" alt="0_1670670509330_cfa89e2a-eee2-43e3-87b1-909c20d4ed45-image.png" class="img-responsive img-markdown" /></p>
<p><strong>本教程将涵盖:</strong></p>
<ol>
<li>创建 Colyseus 服务器</li>
<li>将 Player 位置发往服务器 ( Player 位置属客户端权威)</li>
<li>在场景中显示远程 Player</li>
</ol>
<p><strong>参考</strong></p>
<ul>
<li>Github 上的<a href="https://github.com/colyseus/react-racing-game" rel="nofollow">源代码</a></li>
<li><a href="https://www.colyseus.io/arena" rel="nofollow">Colyseus Arena</a> 上的 <a href="https://heek9z.api-colyseus.com/" rel="nofollow">Live demo</a></li>
</ul>
<hr />
<p><strong>第一步: 了解原版游戏 (单机版)</strong></p>
<p>首先, 我们来分析一下<a href="https://github.com/pmndrs/racing-game" rel="nofollow">游戏项目</a>, 搞清楚它是如何处理 Player 移动的.</p>
<p>从 <code>App.tsx</code> 文件中我们看到 <code>Vehicle</code> 组件是用于 Player 逻辑和表现的:</p>
<pre><code>&lt;Vehicle angularVelocity={[...angularVelocity]} position={[...position]} rotation={[...rotation]}&gt;
</code></pre>
<p><code>Vehicle</code> 组件负责处理移动的键盘事件并渲染 3D 模型, 包括车体和它的轮子 - <code>Chassis</code> 和 <code>Wheel</code> 两个组件.</p>
<p>我们复用这个 <code>Vehicle</code> 代表 <strong>当前 Player <strong>. 在每一帧中让服务器知晓其位置 (x, y, z) 和 旋转角度, 所以</strong>其他</strong> Player 都能看到它. 然后新建一个组件来代表 &quot;其他&quot;  Player .</p>
<p><strong>第二步: 创建服务器</strong></p>
<p>为了接收各个 Player 的位置, 我们首先要创建一个 Colyseus 服务器. 在控制台, 运行如下命令:</p>
<pre><code>npm init colyseus-app ./my-colyseus-server 2cd my-colyseus-server
</code></pre>
<p>根据提示选择 TypeScript 作为其编程语言, 本教程用的就是 TypeScript.</p>
<blockquote>
<p>首先要在开发环境安装配置好 <a href="https://nodejs.org/" rel="nofollow">Node.js LTS</a></p>
</blockquote>
<p>启动服务器, 运行命令 <code>npm start</code>.</p>
<p><strong>定义同步结构 (即 Schema)</strong></p>
<p>Colyseus 使用叫做 Schema 的数据结构在连接的 Player 之间进行实时数据同步. (参考<a href="https://docs.colyseus.io/colyseus/state/schema/#how-to-define-synchronizable-structures" rel="nofollow">文档</a>)</p>
<p>我们要使用一个 Map 结构存放 Player, 包括其位置和旋转角度.</p>
<p>为了方便, 我们先定义一个 Schema 模型代表位置 (x, y, z) 和 旋转角度 (x, y, z, w).</p>
<pre><code>// {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 
}
</code></pre>
<p>然后我们来定义一个 <code>Player</code> 结构使用 <code>AxisData</code> 保存其位置和旋转:</p>
<pre><code>// {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() 
}
</code></pre>
<p>本教程后面会用到这些结构, 主要是 <code>{SERVER_ROOT}/src/rooms/MyRoom.ts</code> 里的游戏房间逻辑.</p>
<p><strong>第三步: 游戏整合 Colyseus 客户端</strong></p>
<p>为了连接服务器, 需要在客户端项目里安装 Colyseus SDK.</p>
<p>新开一个控制台, 运行如下命令</p>
<pre><code>npm install --save colyseus.js
</code></pre>
<blockquote>
<p>如果需要安装 JavaScript/TypeScript SDK 的方法, 参见<a href="https://docs.colyseus.io/colyseus/getting-started/javascript-client/" rel="nofollow">文档</a>.</p>
</blockquote>
<p>现在来做一个连接 Colyseus 服务器的功能. 新建文件 <code>src/network/api.ts</code>:</p>
<pre><code>// {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&lt;MyRoomState&gt; 

export const joinGame = async (): Promise&lt;Room&gt; =&gt; { 
 gameRoom = await client.joinOrCreate&lt;MyRoomState&gt;(GAME_ROOM) 
 return gameRoom 
} 

export const initializeNetwork = function () { 
 return new Promise&lt;void&gt;((resolve, reject) =&gt; { 
 joinGame() 
 .then((room) =&gt; { 
   gameRoom = room 
   gameRoom.state.players.onAdd = (player, sessionId) =&gt; { 
   if (sessionId === gameRoom.sessionId) { 
     gameRoom.state.players.onAdd = undefined 
     resolve() 
   } 
  } 
 }) 
 .catch((err) =&gt; reject(err)) 
 }) 
}
</code></pre>
<p>Colyseus 提供的 <code>sessionId</code> 代表当前游戏 session. 我们用这个值判断哪个 <code>player</code> 是**当前 Player **.</p>
<p>让我们从 <code>main.tsx</code> 里的应用入口调用 <code>initializeNetwork</code>. 如果 <code>initializeNetwork</code> 连接失败, 我们要在客户端上显示出 &quot;network failure&quot; 的信息.</p>
<pre><code>// {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( 
 &lt;div style={defaultStyle}&gt; 
 &lt;h2&gt;Establishing connection with server...&lt;/h2&gt; 
 &lt;/div&gt;, 
) 

initializeNetwork() 
 .then(() =&gt; { 
 root.render(&lt;App /&gt;) 
 }) 
 .catch((e) =&gt; { 
 console.error(e) 
 root.render( 
 &lt;div style={errorStyle}&gt; 
 &lt;h2&gt;Network failure!&lt;/h2&gt; 
 &lt;h3&gt;Is your server running?&lt;/h3&gt; 
 &lt;/div&gt;, 
 ) 
 })
</code></pre>
<p>好, 现在我们能连接上服务器了.</p>
<p>目前仍然没有客户端-服务器之间的信息交换, 我们继续吧.</p>
<p><strong>第四步: 从服务器上创建 Player</strong></p>
<p>现在来在随机位置上创建各个 Player. 该功能由服务器负责.</p>
<p>服务器上创建的位置信息将会同步到每个连接着的客户端里.</p>
<pre><code>// {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) 
} 
// ...
</code></pre>
<p>由于这些数据被保存在了游戏房间的 state 里, 之后加入房间的 Player 也都能接收到这些数据.</p>
<hr />
<p>现在客户端方面, 我们来把接收到的信息用在主 Player 上.</p>
<pre><code>// {CLIENT_ROOT}/src/App.tsx 
// ... 

const room = gameRoom 
const currentPlayer = room.state.players.get(room.sessionId)! 

// ... 

&lt;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]}&gt; 
 {light &amp;&amp; &lt;primitive object={light.target} /&gt;} 
 &lt;Cameras /&gt; 
&lt;/Vehicle&gt; 

// ...
</code></pre>
<p><strong>第五步: 持续向服务器发送 Player 位置</strong></p>
<p>在客户端的 <code>Chassis</code> 模型里, 我们要在每一帧向服务器发送一个 Player 的位置和旋转角度信息.</p>
<pre><code>// {CLIENT_ROOT}/src/models/vehicle/Chassis.tsx 

import { gameRoom } from '../../network/api' 
// ... 

useFrame((_, delta) =&gt; { 
 // ... 

 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 } 
 }) 
 } 
})
</code></pre>
<blockquote>
<p>这种形式被称作 <strong>客户端权威</strong> -- <strong>客户端</strong> 决定了服务器上的 state</p>
</blockquote>
<hr />
<p>现在在服务器端, 我们要基于发信者的 <code>sessionId</code>, 更新 Player 的位置.</p>
<p>为了处理来自客户端的信息, 我们要定义一个处理 <code>&quot;movementData&quot;</code> 消息的监听处理程序.</p>
<p>服务器不知道谁是 <em>&quot;当前&quot;</em> Player, 我们要用发信者的 <code>sessionId</code> 判断是谁发来消息, 该维护谁的数据:</p>
<pre><code>// {SERVER_ROOT}/src/rooms/GameRoom.ts 
 onCreate(options: any) { 
 this.setState(new GameRoomState()) 

 this.onMessage('movementData', (client, data) =&gt; { 
 const player = this.state.players.get(client.sessionId) 
 if (!player) { 
 console.warn(&quot;trying to move a player that doesn't exist&quot;, 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 
 }) 
 }
</code></pre>
<p><strong>第六步: 对手 Player 的可视化</strong></p>
<p>对于对手 Player, 我们简单地拷贝了主 Player 的 <code>Vehicle</code> 和 <code>Chassis</code> 组件, 分别重命名为 <code>OpponentVehicle</code> 和 <code>OpponentChassis</code>, 然后删除了诸如键盘事件, 摄像机位置等没用的代码.</p>
<p>最后改好的代码参见 Github 上的  <a href="https://github.com/colyseus/react-racing-game/blob/main/src/models/vehicle/OpponentVehicle.tsx" rel="nofollow">OpponentVehicle</a> 和 <a href="https://github.com/colyseus/react-racing-game/blob/main/src/models/vehicle/OpponentChassis.tsx" rel="nofollow">OpponentChasis</a>.</p>
<p><strong>第七步: 创建对手 Player</strong></p>
<p>让我们来新建一个对手列表组件, 以确保无论何时 Player 进入/离开 房间, 我们只渲染列表里的对手.</p>
<p>Colyseus 服务器的数据改变时就会触发客户端的 <a href="https://docs.colyseus.io/colyseus/state/schema/#callbacks" rel="nofollow">Schema 回调函数</a>. 我们要在 Player 加入/离开 房间时的 <code>onAdd</code> 和 <code>onRemove</code> 回调里强制刷新渲染对手.</p>
<p><code>onAdd</code> 和 <code>onRemove</code> 回调不宜每帧都添加监听. 所以我们使用 <code>useLayoutEffect(() =&gt; {}, [])</code> 调用确保只添加监听一次.</p>
<pre><code>// {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) =&gt; { 
 // ignore current/local player 
  if (sessionId === room.sessionId) { 
  return 
  } 
  opponents.push(opponent) 
  }) 

 return opponents 
 } 

 const [otherPlayers, setOtherPlayers] = useState(getOpponents()) 

 useLayoutEffect(() =&gt; { 
 let timeout: number 

 room.state.players.onAdd = (_, key) =&gt; { 
 // use timeout to prevent re-rendering multiple times 
  window.clearTimeout(timeout) 
  timeout = window.setTimeout(() =&gt; {
 // skip if current/local player 
  if (key === room.sessionId) { 
  return 
  } 

 setOtherPlayers(getOpponents()) 
 }, 50) 
 } 
 
 room.state.players.onRemove = (player) =&gt; setOtherPlayers(otherPlayers.filter((p) =&gt; p !== player)) 
 }, []) 

 return ( 
 &lt;group&gt; 
 {otherPlayers.map((player) =&gt; { 
 return ( 
 &lt;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]} 
 &gt;&lt;/OpponentVehicle&gt; 
 ) 
 })} 
 &lt;/group&gt; 
 ) 
}
</code></pre>
<p>现在对手列表组件准备好了, 我们把它放在 主 <code>App.tsx</code> 组件里的 主 <code>Vehicle</code> 旁边.</p>
<pre><code>// {CLIENT_ROOT}/src/App.tsx 
// ... 
 &lt;ToggledDebug scale={1.0001} color=&quot;white&quot;&gt; 
 { 
 &lt;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]} 
 &gt; 
 {light &amp;&amp; &lt;primitive object={light.target} /&gt;} 
 &lt;Cameras /&gt; 
 &lt;/Vehicle&gt; 
 } 
 &lt;OpponentListComponent /&gt; 
 &lt;Train /&gt; 
// ...
</code></pre>
<p>好了! 现在我们就能够看到 Player 加入/离开 房间了. 多人在线即将完成!</p>
<p><strong>第八步: 移动对手 Player</strong></p>
<p>客户端已经接收了每个对手的最新的位置和旋转信息, 但是我们还没有使用他们.</p>
<p>我们要在先前创建的 <code>OpponentChassis</code> 组件里更新对手的可视状态, 并基于来自服务器的数据持续更新对手的位置和旋转.</p>
<p>我们会用到 <a href="https://docs.pmnd.rs/react-three-fiber/api/hooks#useframe" rel="nofollow">useFrame</a> 回调:</p>
<pre><code>// {CLIENT_ROOT}/src/models/vehicle/OpponentChassis.tsx 
// ... 
import type { Player } from '../../network/api' 
 
export const OpponentChassis = forwardRef&lt;Group, PropsWithChildren&lt;BoxProps &amp; { player: Player }&gt;&gt;( 

 // ... 

 const player = props.player 

 useFrame((/*_, delta*/) =&gt; { 
 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) 
 }) 

// ...
</code></pre>
<p>好! 现在每个对手都基于 <code>Player</code> schema 的更新移动了.</p>
<p><strong>第九步: &quot;迷你地图&quot; 上的对手</strong></p>
<p>最后一件事是我们要在 &quot;迷你地图&quot; 上显示对手标记. 详细情况就不在这里阐述了. 方法和我们对对手列表的处理非常类似. 详情请参考  <a href="https://github.com/colyseus/react-racing-game/blob/main/src/ui/Minimap.tsx" rel="nofollow">Minimap.tsx 实现</a>.</p>
<p><strong>结语</strong></p>
<p>希望您能从这篇教程当中学到些什么. 下个教程再见!</p>
<hr />
<p><strong>Follow Colyseus on <a href="https://twitter.com/colyseus" rel="nofollow">Twitter</a>, Join the discussion on <a href="https://discord.gg/RY8rRS7" rel="nofollow">Discord</a></strong></p>
]]></description><link>http://discuss.colyseus.io/topic/857/使用-colyseus-把-react-js-游戏变成多人在线游戏</link><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Mar 2026 20:32:24 GMT</lastBuildDate><atom:link href="http://discuss.colyseus.io/topic/857.rss" rel="self" type="application/rss+xml"/><pubDate>Fri, 09 Dec 2022 10:06:00 GMT</pubDate><ttl>60</ttl><item><title><![CDATA[Reply to 使用 Colyseus 把 React.js 游戏变成多人在线游戏 on Sun, 11 Dec 2022 09:58:11 GMT]]></title><description><![CDATA[<p><img src="/assets/uploads/files/1670586903006-57c95dc4-2a21-4cdd-a7c8-473e480ff750-image.png" alt="0_1670586900630_57c95dc4-2a21-4cdd-a7c8-473e480ff750-image.png" class="img-responsive img-markdown" /></p>
<p>这回我们来试着把一个单机游戏 (<a href="https://github.com/pmndrs" rel="nofollow">@pmndrs</a> 开发的) 改写成多人在线游戏.</p>
<p>原来的游戏是用 <a href="https://github.com/facebook/react" rel="nofollow">React</a> 和 <a href="https://github.com/pmndrs/react-three-fiber" rel="nofollow">react-three-fiber</a> 开发的.</p>
<p><img src="/assets/uploads/files/1670670518505-cfa89e2a-eee2-43e3-87b1-909c20d4ed45-image.png" alt="0_1670670509330_cfa89e2a-eee2-43e3-87b1-909c20d4ed45-image.png" class="img-responsive img-markdown" /></p>
<p><strong>本教程将涵盖:</strong></p>
<ol>
<li>创建 Colyseus 服务器</li>
<li>将 Player 位置发往服务器 ( Player 位置属客户端权威)</li>
<li>在场景中显示远程 Player</li>
</ol>
<p><strong>参考</strong></p>
<ul>
<li>Github 上的<a href="https://github.com/colyseus/react-racing-game" rel="nofollow">源代码</a></li>
<li><a href="https://www.colyseus.io/arena" rel="nofollow">Colyseus Arena</a> 上的 <a href="https://heek9z.api-colyseus.com/" rel="nofollow">Live demo</a></li>
</ul>
<hr />
<p><strong>第一步: 了解原版游戏 (单机版)</strong></p>
<p>首先, 我们来分析一下<a href="https://github.com/pmndrs/racing-game" rel="nofollow">游戏项目</a>, 搞清楚它是如何处理 Player 移动的.</p>
<p>从 <code>App.tsx</code> 文件中我们看到 <code>Vehicle</code> 组件是用于 Player 逻辑和表现的:</p>
<pre><code>&lt;Vehicle angularVelocity={[...angularVelocity]} position={[...position]} rotation={[...rotation]}&gt;
</code></pre>
<p><code>Vehicle</code> 组件负责处理移动的键盘事件并渲染 3D 模型, 包括车体和它的轮子 - <code>Chassis</code> 和 <code>Wheel</code> 两个组件.</p>
<p>我们复用这个 <code>Vehicle</code> 代表 <strong>当前 Player <strong>. 在每一帧中让服务器知晓其位置 (x, y, z) 和 旋转角度, 所以</strong>其他</strong> Player 都能看到它. 然后新建一个组件来代表 &quot;其他&quot;  Player .</p>
<p><strong>第二步: 创建服务器</strong></p>
<p>为了接收各个 Player 的位置, 我们首先要创建一个 Colyseus 服务器. 在控制台, 运行如下命令:</p>
<pre><code>npm init colyseus-app ./my-colyseus-server 2cd my-colyseus-server
</code></pre>
<p>根据提示选择 TypeScript 作为其编程语言, 本教程用的就是 TypeScript.</p>
<blockquote>
<p>首先要在开发环境安装配置好 <a href="https://nodejs.org/" rel="nofollow">Node.js LTS</a></p>
</blockquote>
<p>启动服务器, 运行命令 <code>npm start</code>.</p>
<p><strong>定义同步结构 (即 Schema)</strong></p>
<p>Colyseus 使用叫做 Schema 的数据结构在连接的 Player 之间进行实时数据同步. (参考<a href="https://docs.colyseus.io/colyseus/state/schema/#how-to-define-synchronizable-structures" rel="nofollow">文档</a>)</p>
<p>我们要使用一个 Map 结构存放 Player, 包括其位置和旋转角度.</p>
<p>为了方便, 我们先定义一个 Schema 模型代表位置 (x, y, z) 和 旋转角度 (x, y, z, w).</p>
<pre><code>// {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 
}
</code></pre>
<p>然后我们来定义一个 <code>Player</code> 结构使用 <code>AxisData</code> 保存其位置和旋转:</p>
<pre><code>// {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() 
}
</code></pre>
<p>本教程后面会用到这些结构, 主要是 <code>{SERVER_ROOT}/src/rooms/MyRoom.ts</code> 里的游戏房间逻辑.</p>
<p><strong>第三步: 游戏整合 Colyseus 客户端</strong></p>
<p>为了连接服务器, 需要在客户端项目里安装 Colyseus SDK.</p>
<p>新开一个控制台, 运行如下命令</p>
<pre><code>npm install --save colyseus.js
</code></pre>
<blockquote>
<p>如果需要安装 JavaScript/TypeScript SDK 的方法, 参见<a href="https://docs.colyseus.io/colyseus/getting-started/javascript-client/" rel="nofollow">文档</a>.</p>
</blockquote>
<p>现在来做一个连接 Colyseus 服务器的功能. 新建文件 <code>src/network/api.ts</code>:</p>
<pre><code>// {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&lt;MyRoomState&gt; 

export const joinGame = async (): Promise&lt;Room&gt; =&gt; { 
 gameRoom = await client.joinOrCreate&lt;MyRoomState&gt;(GAME_ROOM) 
 return gameRoom 
} 

export const initializeNetwork = function () { 
 return new Promise&lt;void&gt;((resolve, reject) =&gt; { 
 joinGame() 
 .then((room) =&gt; { 
   gameRoom = room 
   gameRoom.state.players.onAdd = (player, sessionId) =&gt; { 
   if (sessionId === gameRoom.sessionId) { 
     gameRoom.state.players.onAdd = undefined 
     resolve() 
   } 
  } 
 }) 
 .catch((err) =&gt; reject(err)) 
 }) 
}
</code></pre>
<p>Colyseus 提供的 <code>sessionId</code> 代表当前游戏 session. 我们用这个值判断哪个 <code>player</code> 是**当前 Player **.</p>
<p>让我们从 <code>main.tsx</code> 里的应用入口调用 <code>initializeNetwork</code>. 如果 <code>initializeNetwork</code> 连接失败, 我们要在客户端上显示出 &quot;network failure&quot; 的信息.</p>
<pre><code>// {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( 
 &lt;div style={defaultStyle}&gt; 
 &lt;h2&gt;Establishing connection with server...&lt;/h2&gt; 
 &lt;/div&gt;, 
) 

initializeNetwork() 
 .then(() =&gt; { 
 root.render(&lt;App /&gt;) 
 }) 
 .catch((e) =&gt; { 
 console.error(e) 
 root.render( 
 &lt;div style={errorStyle}&gt; 
 &lt;h2&gt;Network failure!&lt;/h2&gt; 
 &lt;h3&gt;Is your server running?&lt;/h3&gt; 
 &lt;/div&gt;, 
 ) 
 })
</code></pre>
<p>好, 现在我们能连接上服务器了.</p>
<p>目前仍然没有客户端-服务器之间的信息交换, 我们继续吧.</p>
<p><strong>第四步: 从服务器上创建 Player</strong></p>
<p>现在来在随机位置上创建各个 Player. 该功能由服务器负责.</p>
<p>服务器上创建的位置信息将会同步到每个连接着的客户端里.</p>
<pre><code>// {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) 
} 
// ...
</code></pre>
<p>由于这些数据被保存在了游戏房间的 state 里, 之后加入房间的 Player 也都能接收到这些数据.</p>
<hr />
<p>现在客户端方面, 我们来把接收到的信息用在主 Player 上.</p>
<pre><code>// {CLIENT_ROOT}/src/App.tsx 
// ... 

const room = gameRoom 
const currentPlayer = room.state.players.get(room.sessionId)! 

// ... 

&lt;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]}&gt; 
 {light &amp;&amp; &lt;primitive object={light.target} /&gt;} 
 &lt;Cameras /&gt; 
&lt;/Vehicle&gt; 

// ...
</code></pre>
<p><strong>第五步: 持续向服务器发送 Player 位置</strong></p>
<p>在客户端的 <code>Chassis</code> 模型里, 我们要在每一帧向服务器发送一个 Player 的位置和旋转角度信息.</p>
<pre><code>// {CLIENT_ROOT}/src/models/vehicle/Chassis.tsx 

import { gameRoom } from '../../network/api' 
// ... 

useFrame((_, delta) =&gt; { 
 // ... 

 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 } 
 }) 
 } 
})
</code></pre>
<blockquote>
<p>这种形式被称作 <strong>客户端权威</strong> -- <strong>客户端</strong> 决定了服务器上的 state</p>
</blockquote>
<hr />
<p>现在在服务器端, 我们要基于发信者的 <code>sessionId</code>, 更新 Player 的位置.</p>
<p>为了处理来自客户端的信息, 我们要定义一个处理 <code>&quot;movementData&quot;</code> 消息的监听处理程序.</p>
<p>服务器不知道谁是 <em>&quot;当前&quot;</em> Player, 我们要用发信者的 <code>sessionId</code> 判断是谁发来消息, 该维护谁的数据:</p>
<pre><code>// {SERVER_ROOT}/src/rooms/GameRoom.ts 
 onCreate(options: any) { 
 this.setState(new GameRoomState()) 

 this.onMessage('movementData', (client, data) =&gt; { 
 const player = this.state.players.get(client.sessionId) 
 if (!player) { 
 console.warn(&quot;trying to move a player that doesn't exist&quot;, 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 
 }) 
 }
</code></pre>
<p><strong>第六步: 对手 Player 的可视化</strong></p>
<p>对于对手 Player, 我们简单地拷贝了主 Player 的 <code>Vehicle</code> 和 <code>Chassis</code> 组件, 分别重命名为 <code>OpponentVehicle</code> 和 <code>OpponentChassis</code>, 然后删除了诸如键盘事件, 摄像机位置等没用的代码.</p>
<p>最后改好的代码参见 Github 上的  <a href="https://github.com/colyseus/react-racing-game/blob/main/src/models/vehicle/OpponentVehicle.tsx" rel="nofollow">OpponentVehicle</a> 和 <a href="https://github.com/colyseus/react-racing-game/blob/main/src/models/vehicle/OpponentChassis.tsx" rel="nofollow">OpponentChasis</a>.</p>
<p><strong>第七步: 创建对手 Player</strong></p>
<p>让我们来新建一个对手列表组件, 以确保无论何时 Player 进入/离开 房间, 我们只渲染列表里的对手.</p>
<p>Colyseus 服务器的数据改变时就会触发客户端的 <a href="https://docs.colyseus.io/colyseus/state/schema/#callbacks" rel="nofollow">Schema 回调函数</a>. 我们要在 Player 加入/离开 房间时的 <code>onAdd</code> 和 <code>onRemove</code> 回调里强制刷新渲染对手.</p>
<p><code>onAdd</code> 和 <code>onRemove</code> 回调不宜每帧都添加监听. 所以我们使用 <code>useLayoutEffect(() =&gt; {}, [])</code> 调用确保只添加监听一次.</p>
<pre><code>// {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) =&gt; { 
 // ignore current/local player 
  if (sessionId === room.sessionId) { 
  return 
  } 
  opponents.push(opponent) 
  }) 

 return opponents 
 } 

 const [otherPlayers, setOtherPlayers] = useState(getOpponents()) 

 useLayoutEffect(() =&gt; { 
 let timeout: number 

 room.state.players.onAdd = (_, key) =&gt; { 
 // use timeout to prevent re-rendering multiple times 
  window.clearTimeout(timeout) 
  timeout = window.setTimeout(() =&gt; {
 // skip if current/local player 
  if (key === room.sessionId) { 
  return 
  } 

 setOtherPlayers(getOpponents()) 
 }, 50) 
 } 
 
 room.state.players.onRemove = (player) =&gt; setOtherPlayers(otherPlayers.filter((p) =&gt; p !== player)) 
 }, []) 

 return ( 
 &lt;group&gt; 
 {otherPlayers.map((player) =&gt; { 
 return ( 
 &lt;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]} 
 &gt;&lt;/OpponentVehicle&gt; 
 ) 
 })} 
 &lt;/group&gt; 
 ) 
}
</code></pre>
<p>现在对手列表组件准备好了, 我们把它放在 主 <code>App.tsx</code> 组件里的 主 <code>Vehicle</code> 旁边.</p>
<pre><code>// {CLIENT_ROOT}/src/App.tsx 
// ... 
 &lt;ToggledDebug scale={1.0001} color=&quot;white&quot;&gt; 
 { 
 &lt;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]} 
 &gt; 
 {light &amp;&amp; &lt;primitive object={light.target} /&gt;} 
 &lt;Cameras /&gt; 
 &lt;/Vehicle&gt; 
 } 
 &lt;OpponentListComponent /&gt; 
 &lt;Train /&gt; 
// ...
</code></pre>
<p>好了! 现在我们就能够看到 Player 加入/离开 房间了. 多人在线即将完成!</p>
<p><strong>第八步: 移动对手 Player</strong></p>
<p>客户端已经接收了每个对手的最新的位置和旋转信息, 但是我们还没有使用他们.</p>
<p>我们要在先前创建的 <code>OpponentChassis</code> 组件里更新对手的可视状态, 并基于来自服务器的数据持续更新对手的位置和旋转.</p>
<p>我们会用到 <a href="https://docs.pmnd.rs/react-three-fiber/api/hooks#useframe" rel="nofollow">useFrame</a> 回调:</p>
<pre><code>// {CLIENT_ROOT}/src/models/vehicle/OpponentChassis.tsx 
// ... 
import type { Player } from '../../network/api' 
 
export const OpponentChassis = forwardRef&lt;Group, PropsWithChildren&lt;BoxProps &amp; { player: Player }&gt;&gt;( 

 // ... 

 const player = props.player 

 useFrame((/*_, delta*/) =&gt; { 
 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) 
 }) 

// ...
</code></pre>
<p>好! 现在每个对手都基于 <code>Player</code> schema 的更新移动了.</p>
<p><strong>第九步: &quot;迷你地图&quot; 上的对手</strong></p>
<p>最后一件事是我们要在 &quot;迷你地图&quot; 上显示对手标记. 详细情况就不在这里阐述了. 方法和我们对对手列表的处理非常类似. 详情请参考  <a href="https://github.com/colyseus/react-racing-game/blob/main/src/ui/Minimap.tsx" rel="nofollow">Minimap.tsx 实现</a>.</p>
<p><strong>结语</strong></p>
<p>希望您能从这篇教程当中学到些什么. 下个教程再见!</p>
<hr />
<p><strong>Follow Colyseus on <a href="https://twitter.com/colyseus" rel="nofollow">Twitter</a>, Join the discussion on <a href="https://discord.gg/RY8rRS7" rel="nofollow">Discord</a></strong></p>
]]></description><link>http://discuss.colyseus.io/post/2266</link><guid isPermaLink="true">http://discuss.colyseus.io/post/2266</guid><dc:creator><![CDATA[COCO]]></dc:creator><pubDate>Sun, 11 Dec 2022 09:58:11 GMT</pubDate></item></channel></rss>