Navigation

    Colyseus
    • Register
    • Login
    • Search
    • Recent
    • Tags
    • Users
    1. Home
    2. COCO
    COCO

    COCO

    @COCO

    Chat Follow Unfollow

    Fun is worth it.

    10
    Reputation
    212
    Posts
    1313
    Profile views
    3
    Followers
    1
    Following
    Joined Last Online
    Website colyseus.io Location China

    • Profile
    • More
      • Continue chat with COCO
      • Flag Profile
      • Following
      • Followers
      • Topics
      • Posts
      • Best
      • Groups
    COCO Follow

    Posts made by COCO

    在服务器端运行物理引擎

    https://github.com/CocosGames/ServerPhysics

    posted in 中文 •
    获取客户端 ip 地址

    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;
        }
    
    posted in 中文 •
    新年快乐

    0_1672573001082_newyear.png

    posted in 中文 •
    Colyseus 中的 "Filter"

    Filter 是什么意思?
    Filter 这个词在检索和匹配类的程序中经常见到.
    比如下面这个网站.

    0_1672746177268_00.jpg

    Type to filter... 这里我们填写关键词,

    0_1672746210946_01.jpg

    匹配程序精准地帮我们找到了需要的内容.
    Colyseus 里面有两处与 Filter 有关的地方. 一个是 Matchmaking 用的 filterBy(); 一个是 Schema 用的 @filter 装饰器.


    RegisteredHandler.filterBy()

    这次我们用 Colyseus Server + Cocos Creator Client 来做展示.

    首先建立服务器环境.

    0_1672914626753_19e93398-283a-41cb-8a16-49dd31537d5b-image.png

    不知道这个菜单哪里来的的话请参考这里.

    新建 Cocos 项目, 安装 Colyseus 客户端 SDK.

    0_1672746222679_1.jpg

    如果发现导入时报错,

    0_1672746232472_2.jpg
    要在 tsconfig.json 文件中开启 allowSyntheticDefaultImports 参数.

    0_1672746249762_3.jpg
    好了, 准备工作结束, 让我们来连接房间看看.

    0_1672747117496_5.jpg

    joinOrCreate 函数的第一个参数是房间名, 第二个参数是自定义的房间参数.
    这里我们自定义一个叫做 mode 的参数, 值为 duo, 表示是双人游戏房间.

    this.room = await this.client.joinOrCreate("my_room", { mode: "duo" });
    

    0_1672746263798_4.jpg

    在服务器端, 我们定义了房间名 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 的一部分而非全部.
    典型的需求就是牌类游戏, 每个客户端只能了解自己的手牌而不能知道其他人的牌.

    让我们开发一个简单的猜骰子点数大小的游戏. 游戏分三个步骤:

    • 玩家进入服务器房间, 服务器开始摇骰子出一个点数, 此时玩家虽然知道已摇出点数, 但是不能知道点数是多少;
    • 玩家猜测点数大小并发送到服务器;
    • 服务器开点数, 并把结果发回给客户端.

    0_1673095144215_game.jpg

    点数 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 分为 "要骰子", "猜点数", "开点数" 三个阶段.

    0_1673095592433_filtered.jpg

    "猜点数" 阶段虽然客户端知道有 "number" 这个数据, 但是值是 undefined.

    当服务器在任何地方执行了 this.state.status = "opened"; 这条语句, 过滤自动失效, 此后客户端就能知道具体点数了.

    0_1673095858075_result.jpg

    代码和手册参考:
    https://github.com/CocosGames/ColyseusFilters
    https://docs.colyseus.io/colyseus/state/schema/#filtering-data-per-client

    posted in 中文 •
    RE: Operational Transformation

    Hi. There isn't OT functions out of box, but you can do it yourself with colyseus.

    posted in Questions & Help •
    RE: Matchmake error

    Hi, lots of things can cause issues like this, please post more details.

    posted in Questions & Help •
    RE: Spectate a running game

    HI! There's an example here: https://github.com/CocosGames/ColyseusAudience.

    posted in Questions & Help •
    圣诞节快乐!

    0_1671804955246_bfab9348-32a4-43bd-a7d1-d63fe1595e36-image.png

    posted in 中文 •
    使用 Colyseus 把 React.js 游戏变成多人在线游戏

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

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

    原来的游戏是用 React 和 react-three-fiber 开发的.

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

    本教程将涵盖:

    1. 创建 Colyseus 服务器
    2. 将 Player 位置发往服务器 ( Player 位置属客户端权威)
    3. 在场景中显示远程 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 实现.

    结语

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


    Follow Colyseus on Twitter, Join the discussion on Discord

    posted in 中文 •