一个基于 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: 生产环境样式丢失
现象: 部署后游戏界面背景消失,样式错乱
排查过程:
检查 CSS 是否打包 → ✅ 已打包
检查样式作用域 → ❌
.game-container与游戏界面冲突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();
}
}
技术要点:
单一职责: 游戏启动统一由外部控制
防御性检查: 添加重复调用保护
清理逻辑: 每关开始前清理残留方块
📊 性能优化
资源优化
关键优化措施
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;
});