从零到一:打造实时对战 3D 叠箱子游戏

从零到一:打造实时对战 3D 叠箱子游戏

_

一个基于 Socket.io + Three.js 的在线对战游戏开发实战

🎮 项目简介

这是一个支持双人实时对战的 3D 叠箱子游戏,玩家需要精准地将移动的方块叠加到底座上,考验反应速度和空间感知能力。项目采用前后端分离架构,支持微服务部署,实现了从本地开发到生产环境的完整工程化流程。

在线体验: https://fiber-study.crychic.com.cn/online-game-stack

技术栈:

  • 后端: Node.js + Express + Socket.io

  • 前端: React + TypeScript + Three.js

  • 部署: Docker + Nginx

  • 构建: Vite + pnpm


🏗️ 技术架构

整体架构图

┌─────────────────┐
│  Nginx (443)    │ ← HTTPS + 反向代理
└────────┬────────┘
         │ /stack
         ↓
┌─────────────────┐
│  主应用 (5173)  │ ← 微前端主应用
└────────┬────────┘
         │ 加载微应用
         ↓
┌─────────────────┐       WebSocket
│  游戏微应用     │ ←──────────────→ ┌──────────────────┐
│  (Three.js)     │                  │  游戏服务器      │
│  Socket.io      │                  │  (Node.js:9527)  │
└─────────────────┘                  └──────────────────┘

核心功能模块

游戏服务器 (game-server):

// 房间管理
- 创建/加入房间(4位随机房间号)
- 玩家匹配(最多2人)
- 房间状态同步

// 游戏逻辑
- 实时分数同步
- 游戏状态广播
- 结算判定

// Socket.io 事件
- createRoom / joinRoom
- gameReady / gameStart
- updateGame / gameOver

前端游戏引擎 (StackGame):

// Three.js 场景管理
- OrthographicCamera 正交相机
- MeshToonMaterial 卡通材质
- 动态光照系统

// 游戏机制
- 方块移动与碰撞检测
- 重叠区域计算(overlap detection)
- GSAP 动画(掉落效果、相机跟随)

// 状态管理
- 游戏状态机:paused → running → ended
- 关卡系统(level)
- 分数实时更新

💡 核心技术亮点

1. 微服务路由与 WebSocket 代理

挑战: 游戏部署在 /stack 路径下,Socket.io 默认连接 /socket.io,需要动态适配路径。

解决方案:

// 前端:动态 URL 解析
const getSocketUrl = () => {
  const url = new URL(window.APP_CONFIG.socketUrl);
  const pathname = url.pathname;  // '/stack'
  
  return {
    serverUrl: `${url.protocol}//${url.host}`,
    socketPath: pathname ? `${pathname}/socket.io` : '/socket.io'
  };
};

const { serverUrl, socketPath } = getSocketUrl();
const socket = io(serverUrl, { path: socketPath });
# Nginx:WebSocket 升级支持
location /stack/socket.io/ {
    proxy_pass http://localhost:8095/socket.io/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 7d;  # 长连接超时
}

技术要点:

  • URL 路径自动解析,支持任意路径前缀部署

  • Nginx 识别 WebSocket 握手请求并升级协议

  • 配置外部化,无需修改代码即可切换环境


2. Docker 多阶段构建优化

挑战: 开发环境使用 pnpm 7.33.6,初始 Dockerfile 使用 pnpm 9.x 导致 lockfile 版本冲突。

优化方案:

# Stage 1: 依赖安装(~200MB)
FROM node:lts-alpine AS deps
RUN npm install -g pnpm@7.33.6  # 锁定版本
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

# Stage 2: 生产运行(~80MB)
FROM node:lts-alpine AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 9527
CMD ["node", "src/index.js"]

优化效果:

  • 镜像体积: 900MB → 80MB(减少 91%)

  • 构建时间: 5分钟 → 2分钟

  • 依赖一致性: 100%(通过 frozen-lockfile 保证)

技术细节:

  • Alpine Linux 轻量级基础镜像

  • 多阶段构建分离依赖和运行环境

  • 仅复制生产依赖,排除 devDependencies


3. Three.js 游戏状态管理

挑战: 处理游戏初始化、方块创建、碰撞检测等复杂状态。

核心逻辑:

class Stack extends Base {
  base!: THREE.Mesh;   // 底座(固定)
  box!: THREE.Mesh;    // 当前移动方块
  level: number = 0;   // 关卡等级
  gameState: 'paused' | 'running' = 'paused';
  
  // 防止重复调用
  start() {
    if (this.gamestart) return;
    this.gamestart = true;
    this.startNextLevel();
  }
  
  // 关卡管理
  startNextLevel() {
    this.level += 1;
    this.moveAxis = isOdd(this.level) ? 'x' : 'z';  // 交替移动轴
    this.updateColor();  // 渐变色彩
    
    // 清理残留方块(保留底座)
    this.scene.traverse((obj) => {
      if (obj.isMesh && obj !== this.base) {
        if (Math.abs(obj.position.y - targetY) < 1e-4) {
          this.scene.remove(obj);
        }
      }
    });
    
    this.box = this.createBox(boxParams);
    this.gameState = 'running';
  }
  
  // 碰撞检测与重叠计算
  detectOverlap() {
    const overlap = edge + direction * (prevPosition - currentPosition);
    
    if (overlap <= 0) {
      // Game Over
      this.gameState = 'paused';
      this.onGameOver(this.level - 1);
    } else {
      // 成功叠放,创建重叠区域方块
      const overlapBox = this.createBox(overlapBoxParams);
      this.dropBox(slicedBox);  // 掉落切除部分
      this.startNextLevel();
    }
  }
}

技术亮点:

  • 状态机模式: 清晰的游戏状态流转

  • 防御性编程: 防止重复调用、方块泄漏

  • 精确碰撞检测: 浮点数比较使用 epsilon(1e-4)


4. 实时同步与事件处理

架构设计:

玩家A                  服务器                  玩家B
  │                      │                      │
  ├──gameReady────────→  │                      │
  │                      ├──gameReady────────→  │
  │                      │  ◄────gameReady──────┤
  │  ◄────gameReady──────┤                      │
  │                      │                      │
  ├──startGame────────→  │                      │
  │                      ├──gameStarted──────→  │
  │  ◄──gameStarted──────┤  ◄─────────────────  │
  │                      │                      │
 [倒计时3秒]            │                   [倒计时3秒]
  │                      │                      │
 [自动开始]             │                   [自动开始]
  │                      │                      │
  ├──updateGame───────→  │                      │
  │                      ├──opponentUpdate───→  │
  │  ◄──opponentUpdate───┤  ◄─updateGame───────┤
  │                      │                      │

关键事件处理:

// 倒计时自动开始(解决同步问题)
GameSocketHandler.on('gameStarted', (data) => {
  setGameState('playing');
  setCountdown(3);
  
  const countdownInterval = setInterval(() => {
    setCountdown((prev) => {
      if (prev <= 1) {
        clearInterval(countdownInterval);
        // 自动开始,无需点击
        gameRef.current.start();
        return null;
      }
      return prev - 1;
    });
  }, 1000);
});

同步策略:

  • 服务器权威: 游戏开始、结束由服务器广播

  • 客户端预测: 本地立即更新,服务器确认后同步

  • 状态校验: 定期同步双方分数,防止作弊


🐛 问题排查与解决

问题1: 生产环境样式丢失

现象: 部署后游戏界面背景消失,样式错乱

排查过程:

  1. 检查 CSS 是否打包 → ✅ 已打包

  2. 检查样式作用域 → ❌ .game-container 与游戏界面冲突

  3. DevTools 检查元素 → 发现游戏界面仍有渐变背景覆盖

解决方案:

// 动态切换容器类名
<div className={gameState === 'playing' ? '' : 'game-container'}>
  {gameState === 'playing' && (
    <div style={{
      position: 'fixed',
      top: 0, left: 0,
      width: '100vw', height: '100vh',
      background: '#000'  // 纯黑背景,避免遮挡 Three.js 画布
    }}>
      <StackGame ref={gameRef} />
    </div>
  )}
</div>

问题2: 首关出现多余方块

现象: 第一关有两个移动方块同时出现

原因分析:

// 外部调用(倒计时后)
gameRef.current.start();  // → level = 1,创建方块A

// 内部监听(点击画布)
onClick() {
  if (this.level === 0 && !this.gamestart) {
    this.start();  // → level = 1,创建方块B
  }
}

修复方案:

start() {
  if (this.gamestart) {
    console.warn('start() 已经被调用过,跳过重复调用');
    return;  // 防止重复创建
  }
  this.gamestart = true;
  this.startNextLevel();
}

onClick() {
  // 移除内部自动 start 逻辑,统一由外部控制
  if (this.gameState === 'running') {
    this.detectOverlap();
  }
}

技术要点:

  • 单一职责: 游戏启动统一由外部控制

  • 防御性检查: 添加重复调用保护

  • 清理逻辑: 每关开始前清理残留方块


📊 性能优化

资源优化

优化项

优化前

优化后

提升

Docker 镜像

900MB

80MB

91%

构建时间

5min

2min

60%

首屏加载

2.5s

1.2s

52%

WebSocket 延迟

~100ms

~30ms

70%

关键优化措施

1. 代码分割

// 微应用懒加载
const OnlineGameStack = lazy(() => import('./packages/online-game-stack'));

2. Three.js 性能

// 使用 OrthographicCamera 减少渲染开销
// 限制场景物体数量(最多 50 个方块)
// 及时清理不可见物体的 geometry 和 material

3. Socket.io 优化

// 服务端
io.engine.pingTimeout = 30000;
io.engine.pingInterval = 25000;

// 客户端
socket.on('connect', () => {
  socket.io.opts.reconnectionDelay = 1000;
  socket.io.opts.reconnectionAttempts = 5;
});

IndexedDB详解 2025-12-30
基于腾讯云 COS 的智能资源加载方案设计与实践 2026-01-05

评论区