Fun is worth it.
Posts made by COCO
Node.js
//传入请求HttpRequest
function getClientIp(req) {
return req.headers['x-forwarded-for'] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.socket.remoteAddress;
}
Express
//express框架则简单许多
req.ip
Colyseus
- 使用 Express
//arena.config.ts 的 initializeExpress 函数中
app.use('/*', (req, res) => {
console.log("getting ip address...")
var ip = req.ip;
console.log(ip);
});
- 使用 onAuth 函数
//Room 类的 onAuth 函数
onAuth(client: Client, options: any, request?: http.IncomingMessage): any {
console.log("getting ip address...")
var ip = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
console.log(ip);
return true;
}
Filter 是什么意思?
Filter 这个词在检索和匹配类的程序中经常见到.
比如下面这个网站.
Type to filter... 这里我们填写关键词,
匹配程序精准地帮我们找到了需要的内容.
Colyseus 里面有两处与 Filter 有关的地方. 一个是 Matchmaking 用的 filterBy(); 一个是 Schema 用的 @filter 装饰器.
RegisteredHandler.filterBy()
这次我们用 Colyseus Server + Cocos Creator Client 来做展示.
首先建立服务器环境.
不知道这个菜单哪里来的的话请参考这里.
新建 Cocos 项目, 安装 Colyseus 客户端 SDK.
如果发现导入时报错,
要在 tsconfig.json 文件中开启 allowSyntheticDefaultImports
参数.
好了, 准备工作结束, 让我们来连接房间看看.
joinOrCreate
函数的第一个参数是房间名, 第二个参数是自定义的房间参数.
这里我们自定义一个叫做 mode
的参数, 值为 duo
, 表示是双人游戏房间.
this.room = await this.client.joinOrCreate("my_room", { mode: "duo" });
在服务器端, 我们定义了房间名 my_room, 然后后面跟着一个 filterBy, 里面有两个过滤参数, "mode" 和 "level".
gameServer.define('my_room', MyRoom).filterBy(["mode", "level"]);
它的意思是, 在进行房间匹配的时候, 把所有房间根据 "mode" 和 "level" 两个参数过滤, 只有这两个参数 完全匹配
的玩家才可以匹配进入相应房间.
还记得客户端连接时的需求吗? 玩家要进入 "mode" 为 "duo" 的房间, "level" 没有指定.
匹配器会根据 server 上已有的没有锁定的房间, 过滤出 "mode" 为 "duo" 的, "level" 为 null 的房间, 如果存在, 优先进入; 如果不存在, 新建一个这样的房间再进入.
如果客户端没有要求参数, 如何匹配房间呢? 匹配器会寻找 "mode" 为 "null" 而且 "level" 为 "null" 的房间优先匹配进入; 对于 "mode" 为 "duo" 的房间, 它是 不考虑在内
的.
除了 filterBy, 常用的还有一个 sortBy, 两个联合使用又是什么意思呢?
gameServer.define('my_room', MyRoom).filterBy(["mode"]).sortBy({level:-1});
sortBy 的意思是排序. 根据参数值升序 (+) 或者降序 (-) 排序, 然后进行优先匹配.
上面那条语句的意思是, 寻找 mode 一致的, 按 level 从大到小排列, 选最大的, 进行匹配.
可以看出, filterBy 的作用就是告诉匹配器, 如何寻找, 找什么样的房间, 来给客户端进行分配.
Schema 的 @filter 装饰器
众所周知(?), Schema 是会自动同步到房间的每一个客户端中的, 是 "服务器权威" 模式的基础.
但是有时候, 我们需要某个客户端只能知道 Schema 的一部分而非全部.
典型的需求就是牌类游戏, 每个客户端只能了解自己的手牌而不能知道其他人的牌.
让我们开发一个简单的猜骰子点数大小的游戏. 游戏分三个步骤:
- 玩家进入服务器房间, 服务器开始摇骰子出一个点数, 此时玩家虽然知道已摇出点数, 但是不能知道点数是多少;
- 玩家猜测点数大小并发送到服务器;
- 服务器开点数, 并把结果发回给客户端.
点数 1~3 是 "小", 4~6 是 "大".
游戏的 Schema 设定是关键所在:
import {Schema, Context, type, filter} from "@colyseus/schema";
import {Client} from "colyseus";
export class Dice extends Schema {
@type("string") status: string = "rolling";
@type("string") result: string = "";
@filter(function (this: Dice, client: Client, value: Dice['number'], root: Schema) {
return this.status == "opened";
})
@type("uint8") number: number;
}
首先导入 filter 装饰器, 才能使用. 这个游戏我们要过滤的是骰子点数, 也就是最后的 number. 过滤方法是看当前状态是否已经到了开点数的 status.
status 分为 "要骰子", "猜点数", "开点数" 三个阶段.
"猜点数" 阶段虽然客户端知道有 "number" 这个数据, 但是值是 undefined.
当服务器在任何地方执行了 this.state.status = "opened";
这条语句, 过滤自动失效, 此后客户端就能知道具体点数了.
代码和手册参考:
https://github.com/CocosGames/ColyseusFilters
https://docs.colyseus.io/colyseus/state/schema/#filtering-data-per-client
Hi. There isn't OT functions out of box, but you can do it yourself with colyseus.
Hi, lots of things can cause issues like this, please post more details.
HI! There's an example here: https://github.com/CocosGames/ColyseusAudience.
这回我们来试着把一个单机游戏 (@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 实现.
结语
希望您能从这篇教程当中学到些什么. 下个教程再见!