Skip to content

246. 综合实战:开放世界(四十七)

Published:

上节实现了 ws 的服务端

这节我们在前端里连上,实现多人在线的功能。

进入前端项目,安装下依赖:

pnpm install socket.io-client

在 vite.config.ts 加一下接口转发逻辑:

image.png

'/socket.io': {
    target: 'http://localhost:3000',
    changeOrigin: true,
    ws: true
}

创建 src/worldSync.js 做一下 ws 的连接和各种事件的处理:

image.png

import { io } from 'socket.io-client';

/** 与 open-world-server 一致:命名空间 /world,path /socket.io */
function getSocketOrigin() {
  const base = import.meta.env.VITE_WS_BASE;
  if (base) return String(base).replace(/\/$/, '');
  if (import.meta.env.DEV) {
    return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173';
  }
  return typeof window !== 'undefined' ? window.location.origin : '';
}

let socket = null;
let emitAcc = 0;
const EMIT_INTERVAL = 1 / 12;

/** playerState 较频繁,仅节流打印摘要 */
let lastPlayerStateLog = 0;
const PLAYER_STATE_LOG_MS = 1000;

/**
 * 连接多人房间(需已登录 JWT)。重复调用会先断开旧连接。
 * @param {string} accessToken
 */
export function connectWorld(accessToken) {
  if (!accessToken) return;
  disconnectWorld();

  const origin = getSocketOrigin();
  socket = io(`${origin}/world`, {
    path: '/socket.io',
    auth: { token: accessToken },
    transports: ['websocket', 'polling'],
  });

  socket.on('connect', () => {
    console.log('[world] 已连接', { id: socket.id });
  });

  socket.on('disconnect', (reason) => {
    console.log('[world] 已断开', reason);
  });

  socket.on('connect_error', (err) => {
    console.warn('[world] 连接失败', err.message);
  });

  socket.on('worldRoster', (data) => {
    console.log('[world] worldRoster', data);
  });

  socket.on('playerJoined', (data) => {
    console.log('[world] playerJoined', data);
  });

  socket.on('playerLeft', (data) => {
    console.log('[world] playerLeft', data);
  });

  socket.on('playerState', (payload) => {
    const now = Date.now();
    if (now - lastPlayerStateLog >= PLAYER_STATE_LOG_MS) {
      lastPlayerStateLog = now;
      console.log('[world] playerState (节流 1s 内汇总)', payload);
    }
  });
}

export function disconnectWorld() {
  emitAcc = 0;
  lastPlayerStateLog = 0;
  if (socket) {
    socket.removeAllListeners();
    socket.disconnect();
    socket = null;
  }
}

/**
 * 在 mesh 的物理循环里调用;仅在步行且角色已加载时上传状态。
 * @param {number} dt
 * @param {() => { position: { x: number; y: number; z: number }; rotationY: number }} getPayload
 * @param {boolean} enabled
 */
export function worldSyncTick(dt, getPayload, enabled) {
  if (!socket?.connected || !enabled) return;
  emitAcc += dt;
  if (emitAcc < EMIT_INTERVAL) return;
  emitAcc = 0;
  const p = getPayload();
  socket.emit('state', {
    position: p.position,
    rotationY: p.rotationY,
  });
}

这里我们只是打印了下

然后在 mesh.js 里每次动画都更新下:

image.png

import { worldSyncTick } from './worldSync.js';
const canWorldSync =
    characterModel &&
    !isCarView &&
    !isPlaneView &&
    !isComputerView;
    worldSyncTick(
    dt,
    () => ({
      position: {
        x: playerBody.position.x,
        y: playerBody.position.y,
        z: playerBody.position.z,
      },
      rotationY: characterModel.rotation.y,
    }),
    canWorldSync
);

通过 state 事件来把当前的位置、角度等传到服务端,分发给 room 内其他客户端

在 main.js 里连接下:

image.png

image.png

if (data.accessToken) {
  connectWorld(data.accessToken);
}

image.png

const token = localStorage.getItem('accessToken');
if (token) {
  connectWorld(token);
}

这样,整体从建立连接,到 state 的状态更新,以及接收其他 client 的事件就都完成了。

测试下:

首先我注册了 guang、dogndong 两个账号。

分别打开一个浏览器、一个无痕浏览器来测试:

image.png

image.png

可以看到,当一个玩家在移动的时候,另一个玩家也能收到其他玩家的状态,包括位置、角度等。

那把这些渲染出来不就是多人在线了么?

总结

这节我们学了 ws 客户端的逻辑,连上 ws 服务端之后,和后端通过各种事件传递消息。

比如其他 player 的 state 的变更。

接下来把这些绘制出来,就是多人在线功能。

评论