Skip to content

245. 综合实战:开放世界(四十六)

Published:

这节我们把 websocket 的后端部分写一下。

首先支持下跨域访问:

image.png

我们按照这个事件来设计服务端:

服务端事件

方向事件说明
→ 客户端worldRoster{ players: { userId, username }[] },当前已在房里的其他人
→ 客户端playerJoined有人进房
→ 客户端playerLeft有人离开
→ 客户端playerState他人状态;由下面 state 转发,并带上 userIdusername
客户端 →state任意可序列化对象(如 position),会合并进 playerState 发给其他人

worldRoster 是服务端给客户端推送当前的所有玩家

playerJoined 是推送有人进入房间的消息

playerState 是某个用户的 state 更新

state 是客户端给服务端发的更新某个人 state 的逻辑

按照这个来写一下 websocket 的逻辑:

创建 src/world.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { WorldGateway } from './world.gateway';

@Module({
  imports: [
    JwtModule.register({
      secret: process.env.JWT_SECRET ?? 'open-world-dev-secret',
      signOptions: { expiresIn: '7d' },
    }),
  ],
  providers: [WorldGateway],
})
export class WorldModule {}

实现这个 WorldGateway

import { JwtService } from '@nestjs/jwt';
import {
  OnGatewayConnection,
  OnGatewayDisconnect,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

const WORLD_ROOM = 'world';

@WebSocketGateway({
  namespace: '/world',
  cors: { origin: '*' },
})
export class WorldGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  constructor(private readonly jwtService: JwtService) {}

  async handleConnection(client: Socket) {
    const token = this.extractToken(client);
    if (!token) {
      client.disconnect(true);
      return;
    }
    try {
      const payload = await this.jwtService.verifyAsync<{
        sub: number;
        username: string;
      }>(token);
      client.data.userId = payload.sub;
      client.data.username = payload.username;
      await client.join(WORLD_ROOM);

      const sockets = await this.server.in(WORLD_ROOM).fetchSockets();
      const others = sockets
        .filter((s) => s.id !== client.id)
        .map((s) => ({
          userId: s.data.userId as number,
          username: s.data.username as string,
        }));
      client.emit('worldRoster', { players: others });

      client.to(WORLD_ROOM).emit('playerJoined', {
        userId: payload.sub,
        username: payload.username,
      });
    } catch {
      client.disconnect(true);
    }
  }

  handleDisconnect(client: Socket) {
    const userId = client.data?.userId as number | undefined;
    if (userId != null) {
      this.server.to(WORLD_ROOM).emit('playerLeft', { userId });
    }
  }

  @SubscribeMessage('state')
  handleState(client: Socket, payload: unknown) {
    if (client.data.userId == null) return;
    const extra =
      payload && typeof payload === 'object' && !Array.isArray(payload)
        ? (payload as Record<string, unknown>)
        : {};
    client.to(WORLD_ROOM).emit('playerState', {
      userId: client.data.userId as number,
      username: client.data.username as string,
      ...extra,
    });
  }

  private extractToken(client: Socket): string | undefined {
    const auth = client.handshake.auth as { token?: string } | undefined;
    if (auth?.token && typeof auth.token === 'string') return auth.token;
    const q = client.handshake.query?.token;
    if (typeof q === 'string') return q;
    if (Array.isArray(q) && typeof q[0] === 'string') return q[0];
    return undefined;
  }
}

首先,客户端向服务端发的消息是这样监听:

image.png

收到 state 消息后,拿到 payload 里的信息,通过 playerState 事件广播到房间内的所有 client

建立连接的时候还要做一下鉴权:

取出 jwt 的 token,校验下,拿到其中的 username 的信息,广播给所有的客户端 playerJoined 事件

image.png

理解逻辑就行,细节不用关注,AI 时代主要还是理解思路。

在 AppModule 引入这个模块:

image.png

这样,ws 的服务端部分就写好了。

安装下用到的依赖:

pnpm install @nestjs/websockets socket.io

总结

这节实现了 ws 的服务端,设计了 4 个事件:

worldRoster 所有的 player

playerJoined 新的 player 加入

playerState 某个 player 的状态

state player 的 state 更新

下节基于这 4 个实现来实现前端和后端的 ws 通信。

评论