likes
comments
collection
share

react-three实现3D游戏(5)——多人同屏概述 上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在

作者站长头像
站长
· 阅读数 45

概述

上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在孤单的游荡。是时候,添加些其他存在了。

为了让场景热闹起来,我们决定添加些其他玩家!这次我们将仅仅使用最简单的状态同步来实现多人同屏在线。

分析

我们的场景中有2种玩家,一种是我们可以控制的,一种是被同步数据更新并驱动的玩家。这2种玩家的组件是有差异的。我们把自己控制的角色称为玩家,其他玩家控制的统一称为其他玩家。

玩家组件,只有一个角色模型且响应本地用户的输入,并且把输入同步到所有其他玩家的本地。

其他玩家组件,有复数的角色模型,而且不响应本地用户的任何输入,它响应各自玩家的输入。

我们需要一个中转站将本地玩家的信息同步到其他玩家的本地,并且获取其他玩家本地的状态信息。

react-three实现3D游戏(5)——多人同屏概述 上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在

服务

我们的重心不是服务端,所以我们选用nodejs来实现最简同步服务,它只有一个js文件。 需要安装的npm包只有express和 socket.io

const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const cors = require("cors");

const app = express();
const server = http.createServer(app);
var io = new Server(server, { cors: true });

const PORT = 6868;
const user = new Set();

const SocketEvents = {
  OFF: 0,
  MSG: 1,
};

io.on("connection", (socket) => {
  socket.on("disconnect", function () {
    user.delete(socket.id);
    socket.broadcast.emit(SocketEvents.OFF, socket.id);
    console.log(`玩家断开: ${socket.id} | 当前在线: ${user.size}`);
  });

  // 将收到的信息广播给所有用户
  socket.on(SocketEvents.MSG, function (data) {
    if (!user.has(socket.id)) connection(socket.id);
    data[2] = socket.id;
    socket.broadcast.emit(SocketEvents.MSG, data);
  });
});
function connection(socketID) {
  user.add(socketID);
  console.log(`玩家连接: ${socketID} | 当前在线: ${user.size}`);
}

app.use(cors());
// 设置响应头 & 跨域允许
app.all("*", function (req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header(
    "Access-Control-Allow-Headers",
    "Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild"
  );
  res.header("Access-Control-Allow-Headers", ["mytoken", "Content-Type"]);
  res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
  res.header("X-Powered-By", " 3.2.1");
  res.header("Content-Type", "charset=utf-8");
  next();
});

server.listen(PORT, () => {
  console.log("listening on: localhost:6868");
});

我们只是简单的做个中转,把所有的socket消息转发给其他所有人。例如有人发了一条消息,服务端会广播给所有人,有人断开连接也会广播给所有人。

服务端可以进一步优化,例如按频道分发消息,这样玩家就可以创建和加入不同的频道等等。

但我认为这些优化可以以后再做,作为服务的起始阶段,这些已经足够了。

前端部分

连接socket

安装socket.io-client包,初始化socket。

进入utils.tsx文件中初始并连接socket:

import createSocket from "socket.io-client";
import { SOCKET_ADDRESS } from "@/config";

...

export const socket = (() => createSocket(SOCKET_ADDRESS))();

在config文件中中配置socket地址

export const SOCKET_ADDRESS = `http://127.0.0.1:6868`;

然后将后端服务启动。前端刷新下,后端就会打印玩家的链接id和在线人数了。 在config文件中定义socket事件,和服务端保持一致一共有2个事件分别是玩家断开连接和玩家同步位置信息。

export enum SocketEvents {
  OFF,
  MSG,
}

同步玩家数据

这一步我们将当前玩家的位置同步到socket服务端。 进入到models文件夹下的player.tsx文件 添加以下代码:

  const rigidRef = useRef<RapierRigidBody>(null) //  玩家所在刚体
  ...
useFrame(() => {
    if (!rigidRef.current) return
    const pos = rigidRef.current.translation()
    socket.emit(SocketEvents.MSG, [
      [
        toFixed(pos.x),
        toFixed(pos.y),
        toFixed(pos.z)
      ]
    ])
  })
...
return  <Ecctrl
        ref={rigidRef}
        ...
        />

将当前的玩家位置同步到服务端。

react-three实现3D游戏(5)——多人同屏概述 上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在

获取其他玩家信息

在models下新建others文件夹,分别添加index.tsx和actor.tsx文件。其中actor.tsx是单个玩家的逻辑,index.tsx中会渲染出所有的其他玩家。

单个其他玩家组件

import { useMemo, useEffect, useState, useRef, memo } from "react";
import { useFrame } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { CapsuleCollider, RapierRigidBody, RigidBody, quat } from "@react-three/rapier";
import { SkeletonUtils } from "three-stdlib";
import { PATH, PLAYER_STATUS } from "@/config";
import { useModels } from "@/stores/models";
import * as THREE from "three";

useGLTF.preload(PATH);
const lastPos = new THREE.Vector3();
const FACTOR = 0.1
const Actor = memo(({ id }: { id: string }) => {
  /*----------------------------变量命名------------------------------*/
  // 加载模型
  const { scene, animations } = useGLTF(PATH);
  // 克隆网格
  const clone = useMemo(() => SkeletonUtils.clone(scene), [scene]);
  // 获取动画
  const { ref, actions, names } = useAnimations(animations, clone);

  const [status, setStatus] = useState(PLAYER_STATUS.idle); // 玩家状态
  const playerRef = useRef<RapierRigidBody>(null) //  玩家所在刚体

  const players = useModels(state => state.players)

  /*----------------------------生命周期-------------------------------*/
  // 运动状态
  useEffect(() => {
    switch (status) {
      case PLAYER_STATUS.run:
        actions[names[1]]?.fadeOut(0.2);
        actions[names[2]]?.reset().fadeIn(0.2).play();
        break;
      case PLAYER_STATUS.walk:
        actions[names[1]]?.fadeOut(0.2);
        actions[names[0]]?.reset().fadeIn(0.2).play();
        break;
      default:
        actions[names[2]]?.fadeOut(0.2);
        actions[names[1]]?.reset().fadeIn(0.2).play();
        break;
    }
  }, [status]);

  useFrame(() => {
    if (!playerRef?.current) return;
    const player = players.get(id);
    if (!player) return;
    const { pos } = player;
    if (pos.x == lastPos.x && pos.z == lastPos.z) return setStatus(PLAYER_STATUS.idle)

    const direction = new THREE.Vector3().subVectors(pos, lastPos);  // 计算方向向量
    // 平滑更新位置
    const newPosition = new THREE.Vector3().lerpVectors(playerRef.current.translation(), pos, FACTOR);
    playerRef.current.setTranslation(newPosition, true);

    // 记录位置
    lastPos.copy(pos)

    // 如果方向向量的 x 或 z 分量不为 0,表示玩家在移动
    if ((direction.x) != 0 || (direction.z) != 0) {
      setStatus(PLAYER_STATUS.run);  // 更新状态为 'run'
      if (direction.lengthSq() > 0.02) rotation(direction)
    } else {
      setStatus(PLAYER_STATUS.idle);  // 更新状态为 'idle'
    }
  });

  /*----------------------------辅助函数-------------------------------*/
  // 人物旋转
  function rotation(velocity: THREE.Vector3) {
    if (!playerRef?.current) return;
    const angle = Math.atan2(velocity.x, velocity.z);  // 计算角色需要旋转的角度
    // 应用旋转角度
    const newRotation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
    // 平滑应用旋转角度
    const currentRotation = quat().copy(playerRef.current.rotation()); // 当前旋转四元数
    currentRotation.slerp(newRotation, FACTOR);
    playerRef.current.setRotation(currentRotation, false);
  }

  /*----------------------------模型渲染-------------------------------*/
  return (
    <group dispose={null}>
      <RigidBody
        ref={playerRef}
        colliders={false}
        enabledRotations={[false, false, false]}
      >
        <primitive ref={ref} object={clone} position={[0, -1, 0]} />
        <CapsuleCollider args={[0.6, 0.3]} position={[0, -0.1, 0]} />
      </RigidBody>
    </group>
  );
})
export default Actor

该组件根据socket同步到的其他玩家的位置信息,渲染其他玩家模型。

使用memo防止该组件被反复重复渲染。

使用SkeletonUtils.clone对模型进行克隆,防止引用模型导致多个玩家共用同一个模型。

在useFrame中对玩家位置同步更新,因为我们只能获得位置信息。所以通过位置的变化判断玩家当前的移动方向和运动状态,并进行旋转和播放相关动画。

这里我们使用线性插值(lerp)和球面线性插值(slerp)变化来分别进行更平滑的玩家移动和旋转。

其他玩家集合

我们初始化socket监听,监听socket推送的消息。并相应改变本地的players。这里我选择使用map对象来存放player,因为它存取性能较高。

当有新玩家加入时,判断该玩家id是否已经存在,存在则更新,否则以id为键新增玩家。 当有玩家离开时,直接删除id对应的玩家即可。

再将players循环渲染出所有的其他玩家。

import { SocketEvents } from "@/config";
import { useModels } from "@/stores/models";
import { socket } from "@/utils";
import { useEffect } from "react";
import * as THREE from "three";
import Actor from "./actor";

export default function Others() {
  const players = useModels(state => state.players)
  useEffect(() => {
    initSocketEvent()
  }, [])
  function initSocketEvent() {
    // @ts-ignore
    socket.on(SocketEvents.MSG, (data: Array) => {
      const id = data[2]
      if (players.has(id)) {
        const player = players.get(id)
        if (!player) return
        player.pos = new THREE.Vector3(...data[0])
      } else {
        console.log('新成员:', id)
        players.set(id, { pos: new THREE.Vector3(...data[0]) })
      }
    })
    // @ts-ignore
    socket.on(SocketEvents.OFF, (id) => {
      console.log("离线成员", id);
      players.delete(id);
    });
  }

  return [...players.keys()].map((vo, index) => <Actor key={index} id={vo} />);
}

这里我为了压缩socket的消息体积,事件使用的是数字。同步传参时的位置数据也是简单的数组包数字。同时使用toFixed函数处理浮点数默认保留3位。当前的id用的是服务端的socketid,它本身也比较长,可以替换掉。你可以在玩家上传时使用和生成一些较短的唯一id。

react-three实现3D游戏(5)——多人同屏概述 上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在

结语

效果

react-three实现3D游戏(5)——多人同屏概述 上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在

源码地址

结语

同步的效果有很多可以优化的地方,例如使用预测回滚等技术,保证本地和服务端的一致性。且本人不会感知到延迟。

将玩家的状态同步过来,将玩家的四元数组同步过来,利用服务端缓存玩家的信息等等等等。

仅仅是同步位置过去,玩家的移动和动画还是稍显僵硬。这块可能需要进一步的优化!

转载自:https://juejin.cn/post/7380694342745210918
评论
请登录