Stack 叠箱子游戏重构总结

Stack 叠箱子游戏重构总结

_

项目概述

本项目是一个基于 Three.js 和 React 的双人叠箱子游戏,支持通过 Socket.io 进行实时对战。在开发过程中,我们对代码进行了大规模重构,将游戏逻辑从主页面组件中抽离出来,形成了独立可复用的游戏组件。

重构背景

原始代码问题

  1. 代码冗余:主游戏页面中包含了大量的 Three.js 游戏逻辑代码

  2. ref 引用过多:存在大量的 useRef 引用,导致代码维护困难

  3. Socket 连接管理混乱:每次状态更新都会创建新的 Socket 连接

  4. 组件职责不清:UI 逻辑和游戏逻辑混杂在一起

  5. 难以复用:游戏逻辑无法在其他地方复用

重构目标

  1. 分离关注点:将游戏逻辑和 UI 逻辑分离

  2. 提高复用性:创建独立的游戏组件

  3. 简化状态管理:优化 Socket 连接和游戏状态管理

  4. 提升可维护性:减少代码重复,提高代码清晰度

重构过程

阶段一:游戏逻辑抽离

1. 创建独立的游戏组件

文件:src/components/StackGame.js

// 抽离出的核心类
class Base {
  // Three.js 基础功能
}

class Stack extends Base {
  // 游戏逻辑
}

// React 组件包装
const StackGame = forwardRef((props, ref) => {
  // 组件逻辑
});

主要改进:

  • 将 Three.js 相关的所有逻辑封装到独立的类中

  • 使用 React forwardRef 暴露游戏控制方法

  • 通过回调函数处理游戏状态变化

2. 重构主游戏页面

文件:src/routes/Game/index.js

重构前:

// 大量的游戏逻辑代码
class Base { /* 几百行代码 */ }
class Stack extends Base { /* 几百行代码 */ }
// 复杂的 ref 管理
const startRef1 = useRef(null);
const scoreRef1 = useRef(null);
// ... 更多 ref

重构后:

// 简洁的组件调用
<StackGame
  ref={stackGame1Ref}
  onScoreUpdate={handleScoreUpdate(1)}
  onGameOver={handleGameOver(1)}
  onGameStart={() => handleGameStart(1)}
/>

阶段二:Socket 连接优化

问题诊断

原始代码中每次 setRoomCode 都会创建新的 Socket 连接:

const socket = io("http://localhost:9527"); // 每次渲染都执行

解决方案

使用单例模式管理 Socket 连接:

let socket = null;

const initSocket = () => {
  if (!socket) {
    socket = io("http://localhost:9527");
    // 事件监听器设置
  }
  return socket;
};

遇到的问题与解决方案

问题一:游戏逻辑错误

问题描述

重构后的游戏组件无法正确实现堆叠效果,方块会直接替换而不是切割堆叠。

问题分析

对比原始代码发现重叠检测算法有误:

错误的算法:

const distance = Math.abs(currentPosition - prevPosition);
const overlap = edge - distance;

正确的算法:

const direction = Math.sign(currentPosition - prevPosition);
const overlap = edge + direction * (prevPosition - currentPosition);

解决过程

  1. 对比原始逻辑:仔细对比 index copy 2.js 中的原始算法

  2. 修复计算公式:使用正确的重叠距离计算公式

  3. 修复位置计算:正确计算重叠方块和切掉方块的位置

  4. 修复创建顺序:按照原始逻辑的顺序创建和移除方块

问题二:游戏状态异常

问题描述

第二次点击就会直接重启游戏,控制台显示:

Click detected, current level: 0 gameState: paused gameover: false
Starting game...

问题分析

通过添加调试信息发现问题根源:

  1. useEffect 重复执行

useEffect(() => {
  // 游戏实例创建
}, [onScoreUpdate, onGameOver, onGameStart, debug]); // 依赖数组有问题
  1. 回调函数重新创建:每次父组件渲染时,回调函数都会重新创建,导致 useEffect 重新执行

  2. 游戏实例被重置:每次 useEffect 执行都会创建新的游戏实例,重置所有状态

解决方案

分离创建和更新逻辑:

// 只在组件挂载时创建一次
useEffect(() => {
  // 创建游戏实例
}, []); // 空依赖数组

// 单独更新回调函数
useEffect(() => {
  if (stackInstanceRef.current) {
    stackInstanceRef.current.onScoreUpdate = onScoreUpdate;
    stackInstanceRef.current.onGameOver = onGameOver;
    stackInstanceRef.current.onGameStart = onGameStart;
  }
}, [onScoreUpdate, onGameOver, onGameStart]);

问题三:事件监听器管理

问题描述

点击事件可能被重复绑定,导致游戏行为异常。

解决方案

优化点击逻辑判断:

onClick() {
  this.renderer.domElement.addEventListener("click", () => {
    // 添加详细的状态检查
    if (this.gameover) return;
    
    if (this.level === 0 && !this.gamestart) {
      this.start();
    } else if (this.gameState === "running") {
      this.detectOverlap();
    }
  });
}

调试策略

1. 分步调试

  • 在关键方法中添加 console.log

  • 跟踪游戏状态变化

  • 对比原始代码的执行流程

2. 状态监控

console.log('detectOverlap debug:', {
  currentPosition,
  prevPosition,
  direction,
  edge,
  overlap,
  moveAxis,
  moveEdge,
  level: this.level
});

3. 生命周期追踪

useEffect(() => {
  console.log('useEffect running, creating new Stack instance');
  // ...
  return () => {
    console.log('useEffect cleanup, destroying Stack instance');
  };
}, []);

重构成果

代码结构优化

重构前:

  • 主文件:800+ 行代码

  • 混杂的逻辑:游戏 + UI + Socket

  • 难以维护和扩展

重构后:

  • 游戏组件:600+ 行纯游戏逻辑

  • 主页面:200+ 行 UI 和状态管理

  • 清晰的职责分离

复用性提升

// 可以轻松创建多个游戏实例
<StackGame ref={player1Ref} debug={false} />
<StackGame ref={player2Ref} debug={true} />

维护性改善

  1. 单一职责:每个组件只负责自己的逻辑

  2. 接口清晰:通过 props 和 ref 进行通信

  3. 状态隔离:游戏状态和 UI 状态分离

经验总结

重构最佳实践

  1. 逐步重构:不要一次性重构所有代码

  2. 保留原始代码:作为参考和对比

  3. 充分测试:每个重构步骤都要验证功能正确性

  4. 详细调试:添加足够的调试信息帮助定位问题

React 开发注意事项

  1. useEffect 依赖管理

  • 谨慎使用依赖数组

  • 区分创建和更新逻辑

  • 避免不必要的重新执行

  1. 组件生命周期

  • 合理管理资源的创建和销毁

  • 避免内存泄漏

  1. 状态管理

  • 明确状态的归属

  • 避免状态冲突

Three.js 集成经验

  1. 资源管理:正确释放 Three.js 资源

  2. 事件处理:合理管理事件监听器

  3. 性能优化:避免不必要的渲染循环

项目结构

src/
├── components/
│   └── StackGame.js          # 独立的游戏组件
├── routes/
│   └── Game/
│       ├── index.js          # 主游戏页面(重构后)
│       ├── index copy 2.js   # 原始代码备份
│       └── style.css         # 样式文件
└── ...

技术栈

  • React:组件化开发

  • Three.js:3D 图形渲染

  • GSAP:动画效果

  • Socket.io:实时通信

  • Ant Design:UI 组件库

总结

通过这次重构,我们不仅解决了原有代码的问题,还积累了宝贵的开发经验。重构过程中遇到的问题让我们更深入地理解了 React 组件生命周期、Three.js 资源管理和 JavaScript 事件处理机制。

最重要的是,我们学会了:

  1. 系统性思考:从整体架构角度思考问题

  2. 问题定位:通过调试信息快速定位问题根源

  3. 渐进式改进:逐步优化而不是推倒重来

  4. 文档化:记录问题和解决方案,便于后续维护

这次重构为项目的后续开发奠定了良好的基础,也为团队积累了宝贵的技术经验。

阶段六:双人游戏状态管理优化

问题发现

在测试双人游戏功能时,发现了几个关键的状态管理问题:

  1. 重试按钮状态错误:JSX 中使用了未定义的 showRetry 变量,而不是正确的 player1ShowRetryplayer2ShowRetry

  2. 重试点击事件错误:两个玩家的重试按钮都调用了通用的 handleRetry 函数,而不是各自独立的重试函数

  3. 游戏开始状态冲突:使用全局的 gameStarted 状态导致一个玩家的操作影响另一个玩家的 UI 显示

  4. 游戏结束状态干扰:每次单个玩家游戏结束就设置全局 gameState 为 "ended",影响了双人游戏体验

修复方案

1. 修复重试按钮状态和事件绑定

修改前:

// 玩家1
<div className={classNames("status retry h-center fade-in cursor-pointer", { "opacity-0": !showRetry })} onClick={handleRetry}>
  Try Again
</div>
// 玩家2
<div className={classNames("status retry h-center fade-in cursor-pointer", { "opacity-0": !showRetry })} onClick={handleRetry}>
  Try Again
</div>

修改后:

// 玩家1
<div className={classNames("status retry h-center fade-in cursor-pointer", { "opacity-0": !player1ShowRetry })} onClick={handleRetryPlayer1}>
  Try Again
</div>
// 玩家2
<div className={classNames("status retry h-center fade-in cursor-pointer", { "opacity-0": !player2ShowRetry })} onClick={handleRetryPlayer2}>
  Try Again
</div>

2. 添加独立的游戏开始状态管理

添加状态:

// 为每个玩家分别管理游戏开始状态
const [player1Started, setPlayer1Started] = useState(false);
const [player2Started, setPlayer2Started] = useState(false);

更新游戏开始回调:

const handleGameStart = (playerNumber) => {
  console.log(`Player ${playerNumber} started game`);
  if (playerNumber === 1) {
    setPlayer1Started(true);
  } else {
    setPlayer2Started(true);
  }
  setGameStarted(true); // 保留全局状态用于其他逻辑
};

更新UI显示:

// 玩家1
<div className={classNames("status start h-center fade-in", { "opacity-0": player1Started })}>
  点击开始
</div>
// 玩家2
<div className={classNames("status start h-center fade-in", { "opacity-0": player2Started })}>
  点击开始
</div>

3. 优化重试函数逻辑

修改前:

const handleRetryPlayer1 = () => {
  setPlayer1Score(0);
  setPlayer1ShowRetry(false);
  setGameStarted(false); // 会影响玩家2
  // ...
};

修改后:

const handleRetryPlayer1 = () => {
  setPlayer1Score(0);
  setPlayer1ShowRetry(false);
  setPlayer1Started(false); // 只重置玩家1的开始状态
  // ...
};

const handleRetryPlayer2 = () => {
  setPlayer2Score(0);
  setPlayer2ShowRetry(false);
  setPlayer2Started(false); // 只重置玩家2的开始状态
  // ...
};

4. 移除游戏结束时的全局状态设置

修改前:

const handleGameOver = (playerNumber) => (finalScore) => {
  // ...
  setGameState("ended"); // 影响双人游戏
};

修改后:

const handleGameOver = (playerNumber) => (finalScore) => {
  // ...
  // 不需要设置全局游戏状态,因为这是双人游戏
  // setGameState("ended");
};

修复效果

通过这些修复,实现了:

  1. 独立的重试体验:每个玩家的"Try Again"按钮只在自己游戏结束时显示

  2. 隔离的游戏状态:一个玩家的重试不会影响另一个玩家的游戏状态

  3. 正确的UI交互:每个玩家的"点击开始"提示独立显示和隐藏

  4. 清晰的状态管理:每个玩家的游戏状态完全独立,互不干扰

技术要点

  1. 状态独立性:为双人游戏中的每个玩家维护独立的状态变量

  2. 事件隔离:确保玩家的操作只影响自己的游戏实例和状态

  3. UI一致性:保持每个玩家区域的UI显示逻辑一致且独立

  4. 调试友好:通过清晰的变量命名和日志输出,便于问题定位和调试

markdown编辑器图片上传优化 2025-12-30
Socket.IO 2025-12-30

评论区