react-three实现3D游戏(5)——多人同屏概述 上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在
概述
上一次我们为游戏添加了小地图,但整个场景上依然只有一个机器人在孤单的游荡。是时候,添加些其他存在了。
为了让场景热闹起来,我们决定添加些其他玩家!这次我们将仅仅使用最简单的状态同步来实现多人同屏在线。
分析
我们的场景中有2种玩家,一种是我们可以控制的,一种是被同步数据更新并驱动的玩家。这2种玩家的组件是有差异的。我们把自己控制的角色称为玩家,其他玩家控制的统一称为其他玩家。
玩家组件,只有一个角色模型且响应本地用户的输入,并且把输入同步到所有其他玩家的本地。
其他玩家组件,有复数的角色模型,而且不响应本地用户的任何输入,它响应各自玩家的输入。
我们需要一个中转站将本地玩家的信息同步到其他玩家的本地,并且获取其他玩家本地的状态信息。
服务
我们的重心不是服务端,所以我们选用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}
...
/>
将当前的玩家位置同步到服务端。
获取其他玩家信息
在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。
结语
效果
源码地址
结语
同步的效果有很多可以优化的地方,例如使用预测回滚等技术,保证本地和服务端的一致性。且本人不会感知到延迟。
将玩家的状态同步过来,将玩家的四元数组同步过来,利用服务端缓存玩家的信息等等等等。
仅仅是同步位置过去,玩家的移动和动画还是稍显僵硬。这块可能需要进一步的优化!
转载自:https://juejin.cn/post/7380694342745210918