Hexo 博客实战:光影拼图游戏开发指南(优化篇)

前言

光影拼图游戏开发指南(功能篇)介绍功能实现后,本篇聚焦优化过程中解决的 7 个 P0 级集成问题 与运行 Bug。问题的根源在于:Phase 1/Phase 2 的优化模块代码已完成,但未正确集成到游戏主流程中。

本文重点讲解:

  1. 模块集成问题:优化代码为何闲置,如何激活
  2. 集成修复:6 大模块重构
  3. Bug 修复:融入具体集成章节中

问题发现:从文档到实战

在对光影拼图项目进行代码审查时,发现了一个尴尬的局面:

模块 代码状态 集成状态
puzzle-render-optimized.js ✅ 已完成 ❌ 未加载
game-state-manager.js ✅ 已完成 ❌ 未使用
game-data-persistence.js ✅ 已完成 ❌ 未调用
touch-manager.js ✅ 已完成 ⚠️ 冲突

优化代码写好了,但游戏仍在使用旧逻辑。新功能无法生效,用户体验没有提升。


一、渲染引擎替换

问题描述

puzzle-render-optimized.js(脏矩形渲染 + 粒子对象池)已编写完成,但 index.md 仍引用旧版 puzzle-render.js。优化代码完全闲置,性能零提升。

同时发现两个运行 Bug:

  • Bug 1:Canvas 初始尺寸 600px,但 CSS 限制 350px,内部分辨率不匹配
  • Bug 2:锁定/选中碎片的 shadowBlur 发光泄漏到背景

解决方案

1.1 优化版加载

1
2
3
4
5
// index.md 脚本引用 (修改前)
<script src="/ai-games/puzzle/modules/puzzle-render.js" data-pjax></script>

// index.md 脚本引用 (修改后)
<script src="/ai-games/puzzle/modules/puzzle-render-optimized.js" data-pjax></script>

为保持兼容性,优化版模块同时导出两个名称:

1
2
3
4
5
// puzzle-render-optimized.js 导出
if (typeof window !== 'undefined') {
window.PuzzleRenderOptimized = PuzzleRenderOptimized;
window.PuzzleRender = PuzzleRenderOptimized; // 兼容旧引用
}

效果:脏矩形渲染生效,渲染性能提升 60-80%。

1.2 Canvas 尺寸修复

根因resize() 使用 container.clientWidth 而非 canvas 的 CSS 显示尺寸。

修复:使用 canvas.clientWidth 获取 CSS 约束后的实际显示尺寸:

1
2
3
4
5
6
// 修改前
const container = this.canvas.parentElement;
const newWidth = Math.min(600, container.clientWidth - 40);

// 修改后
const newWidth = Math.min(600, this.canvas.clientWidth || (this.canvas.parentElement.clientWidth - 40));

关键点:使用 clientWidth 反映 CSS 约束后的实际显示尺寸(含 max-width: 350px 限制)。

1.3 视觉发光泄漏修复

根因isSelectedisLocked 使用 shadowBlur,绘制两次时发光泄漏到背景。

修复:移除 shadowBlur,改用边框线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// drawPiece 中 isSelected 修复
// 修改前
if (piece.isSelected) {
this.ctx.shadowBlur = 25;
this.ctx.shadowColor = '#FFD700';
}

// 修改后
if (piece.isSelected) {
this.ctx.lineWidth = 4;
this.ctx.strokeStyle = '#FFD700';
this.ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
}

// drawPiecesWithImage 中 isLocked 修复
// 修改前
if (piece.isLocked) {
this.ctx.shadowBlur = 15;
this.ctx.shadowColor = '#4CAF50';
}

// 修改后
if (piece.isLocked) {
this.ctx.lineWidth = 3;
this.ctx.strokeStyle = '#4CAF50';
}

二、事件处理统一

问题描述

puzzle-game.js 注册了 TouchManager 事件,同时保留了完整的原生鼠标/触摸事件处理器。两个系统同时运行,导致 drag/click/dblclick 重复触发

事件 TouchManager 原生处理器 冲突
mouseup 双重响应
dblclick 双重响应

同时发现:

  • Bug 3:移动端触摸选择碎片无响应

解决方案

2.1 删除原生处理器

删除 puzzle-game.js 中约 150 行原生事件处理器,统一通过 TouchManager 处理:

1
2
3
4
5
6
7
8
// 删除前
canvas.addEventListener('mousedown', handleCanvasMouseDown);
canvas.addEventListener('mousemove', handleCanvasMouseMove);
canvas.addEventListener('mouseup', handleCanvasMouseUp);
canvas.addEventListener('dblclick', handleCanvasClick);

// 保留 TouchManager
window.TouchManager.setupGestures(canvas);

2.2 补充缺失方法

touch-manager.js 缺少 handleMouseDownhandleMouseMove 方法:

1
2
3
4
5
6
7
8
9
10
// touch-manager.js 补充
handleMouseDown(e) {
const coords = this.getCanvasCoords(e);
this.handleTap({ x: coords.x, y: coords.y, type: 'mousedown' });
}

handleMouseMove(e) {
const coords = this.getCanvasCoords(e);
this.handleDragMove({ x: coords.x, y: coords.y });
}

2.3 移动端触摸适配

移动端触摸无响应的根因是缺失上述方法。补充后,触摸选择、拖拽、双击旋转、长按提示均可正常工作。


三、状态管理桥接

问题描述

game-state-manager.js 已加载但从未使用,游戏仍使用独立的局部 gameState 对象。两套状态系统并存,GameStateManager 的观察者模式完全闲置。

同时发现:

  • Bug 4:PJAX 切换页面后,游戏状态残留

解决方案

3.1 Proxy 桥接实现

使用 Proxy 对象桥接本地状态与全局管理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 桥接方案:保留本地属性,委托存储属性
const gameState = new Proxy({
// 保留为本地属性(动画帧频繁读写)
currentPiece: null,
selectedPiece: null,
dragOffset: null,
dragStartX: 0,
dragStartY: 0
}, {
get(target, prop) {
if (prop === 'isPlaying' || prop === 'isComplete' || prop === 'level') {
return window.GameStateManager.get('game.' + prop);
}
return target[prop];
},
set(target, prop, value) {
if (prop === 'isPlaying' || prop === 'isComplete' || prop === 'level') {
window.GameStateManager.set('game.' + prop, value);
}
target[prop] = value;
return true;
}
});

3.2 PJAX 状态清理

根因:模块 IIFE 防止重开,cleanup 未完全重置。

修复:添加 reset() 方法 + 防御性重置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// puzzle-core.js 添加
reset() {
this.pieces = [];
this.targetGrid = [];
this.completedPieces = 0;
this.currentLevel = 1;
}

// initPuzzleGame() 入口防御
function initPuzzleGame() {
if (window.PuzzleCore) window.PuzzleCore.reset();
if (window.PuzzleRender) window.PuzzleRender.stopAnimation();
// ...
}

四、数据持久化整合

问题描述

4 个独立 localStorage key,数据碎片化严重。game-data-persistence.js 的完整数据模型完全未被使用。

key 写入处 状态
puzzle-best-level puzzle-game.js 需迁移
puzzle-player-performance puzzle-config.js 需迁移
puzzle-player-behavior puzzle-ai.js 需迁移
puzzle-game-data GameDataPersistence 从未写入

同时发现:

  • Bug 5:拖拽吸附成功后,选择状态的高亮消���

解决方案

4.1 统一 localStorage

在游戏完成和失败时调用会话保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// handleGameComplete 中
window.GameDataPersistence.saveGameSession({
level: gameState.level,
completed: true,
timeUsed: config.time - gameState.timeRemaining,
hintsUsed: config.hints - currentHints
});

// showFailedMessage 中
window.GameDataPersistence.saveGameSession({
level: gameState.level,
completed: false,
timeUsed: config.time
});

效果:4 个 key → 统一 1 个 key。

4.2 选择状态修复

根因handleDragEnd 未清理选择状态。

修复

1
2
3
4
5
// handleDragEnd 中吸附成功后
if (result.snapped) {
window.PuzzleCore.clearSelection();
gameState.selectedPiece = null;
}

五、反馈闭环串联

问题描述

自适应难度系统的记录方法已定义但从未被调用,玩家表现数据永远为初始值。

1
2
游戏事件 → recordLevelComplete()  →  ✗ 从未调用
→ recordHintResult() → ✗ 从未调用

同时发现:

  • Bug 6:AI 提示返回 score: bestScore 显示 undefined

解决方案

5.1 记录方法调用

在游戏流程关键节点串联调用:

1
2
3
4
5
6
7
8
9
// handleGameComplete 中
window.PuzzleConfig.recordLevelComplete(gameState.level, timeUsed, true);
window.PuzzleAI.recordCompletionTime(timeUsed);

// showFailedMessage 中
window.PuzzleConfig.recordLevelComplete(gameState.level, timeUsed, false);

// handleDoubleTap 中
window.PuzzleAI.recordRotationResult(true);

效果:自适应难度系统获得真实数据,AI 权重开始动态调整。

5.2 提示返回值修复

根因bestScore 超出作用域(定义在循环内)。

修复:移除该字段

1
2
3
4
5
6
7
8
9
10
// 修改前
return {
piece: bestPiece,
score: bestScore // ← bestScore 未定义
};

// 修改后
return {
piece: bestPiece
};

六、死代码清理

问题PuzzleCore.useHint()PuzzleCore.nextLevel() 已被 PuzzleAI 取代,但从未调用。

解决方案

1
2
3
4
5
6
7
8
9
// puzzle-core.js 删除
- useHint() {
- if (this.hintsRemaining <= 0) return null;
- // ... 20 行代码
- },
-
- nextLevel() {
- return this.init(this.currentLevel + 1);
- },

效果:减少 30 行死代码,明确提示逻辑由 PuzzleAI 统一管理。


七、竞态条件修复

问题:双重超时(外部 15 秒 + ImageLoadManager 内部 15 秒),Promise 超时返回 null 后原加载仍可能完成并覆写数据。

1
2
3
4
5
6
7
// 问题代码
const loadPromise = window.PuzzleCore.init(level, width, height, seed);
initData = await Promise.race([
loadPromise, // 内部已有超时
new Promise((resolve) => setTimeout(...)) // 外部再加一层
]);
// loadPromise 完成后可能覆写数据

解决方案:添加 abort 标志丢弃结果:

1
2
3
4
5
6
7
8
// startGame 中
let loadAborted = false;
const loadPromise = window.PuzzleCore.init(level, width, height, imageSeed);

initData = await Promise.race([
loadPromise.then(data => { if (loadAborted) return null; return data; }),
new Promise((resolve) => setTimeout(() => { loadAborted = true; resolve(null); }, 15000))
]);

八、移动端适配修复

问题清单

优化篇发布后,在真机测试中发现 5 个移动端专属 Bug:

# Bug 位置 严重程度
7 Canvas 硬编码 500x400,小屏比例失调碎片跑出屏幕 index.md / puzzle-render-optimized.js P0
8 碎片拖拽放开后弹回左上角 touch-manager.js P0
9 虚线框与实际吸附位置不匹配 puzzle-render-optimized.js P0
10 拖动的碎片被其他碎片遮挡 puzzle-core.js P1
11 图片海外 CDN 国内加载失败 image-load-manager.js P2

8.1 画布尺寸自适应

根因:Canvas 标签写死 width="500" height="400",CSS 只缩放了显示尺寸,内部像素坐标系未同步。

修复

1
2
3
4
5
<!-- 修改前 -->
<canvas id="game-canvas" width="500" height="400"></canvas>

<!-- 修改后 -->
<canvas id="game-canvas"></canvas>

CSS 侧启用 aspect-ratio,JS 侧 resize() 依据容器宽度和视口高度动态设置像素尺寸:

1
2
3
4
5
6
7
8
// puzzle-render-optimized.js resize()
const containerW = this.canvas.parentElement.clientWidth - 40;
const maxW = Math.min(containerW, 500);
const newWidth = Math.min(maxW, window.innerWidth - 30);
const newHeight = Math.round(newWidth * 0.8);

this.canvas.width = finalWidth;
this.canvas.height = finalHeight;

效果:画布像素尺寸 = CSS 显示尺寸,触摸坐标不再缩放失真。

8.2 碎片初始布局适配

根因generatePuzzle() 将碎片放在左右各 25% 侧边区,窄屏(<450px)时侧边极窄,高关卡碎片尺寸大于侧边可用宽度,直接溢出屏幕。

修复:窄屏时改为下条带布局——碎片全部放在网格下方的一个水平条带内:

1
2
3
4
5
6
7
8
9
10
11
// puzzle-core.js generatePuzzle()
const isNarrow = canvasWidth < 450;

if (isNarrow) {
const areaTop = gridOffsetY + gridHeight + 10;
shuffledIndices.forEach((originalIndex, newIndex) => {
const startX = Math.random() * (canvasWidth - pieceSize - 10) + 5;
const startY = areaTop + Math.random() * Math.max(0, areaH - pieceSize - 10);
// ...
});
}

同时 movePiece() 增加边界约束,防止拖拽过程中碎片超出画布范围:

1
2
3
4
5
6
7
movePiece(pieceId, newX, newY) {
const piece = this.pieces.find(p => p.id === pieceId);
if (!piece || piece.isLocked) return false;
piece.currentX = Math.max(0, Math.min(this.canvasWidth - piece.width, newX));
piece.currentY = Math.max(0, Math.min(this.canvasHeight - piece.height, newY));
return true;
}

8.3 触控拖拽修复

根因touch-manager.jshandleTouchEnd() 同时存在两个 Bug:

Bug 代码 后果
TouchEvent 自身没有 clientX/Y,应通过 touch.clientX/Y 访问 e.clientXtouch.clientX 坐标传入 undefined
手指释放时应触发 'dragend' 执行吸附检测,却触发了 'dragmove' 'dragmove''dragend' checkSnap() 从未被调用,碎片永远无法吸附

修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 修改前
- if (storedTouch.isDragging) {
- this.triggerEvent('dragmove', {
- x: e.clientX, // undefined
- y: e.clientY, // undefined
- touchId: 0
- });
- }

// 修改后
+ if (storedTouch.isDragging) {
+ this.triggerEvent('dragend', {
+ x: touch.clientX,
+ y: touch.clientY,
+ touchId: touchId
+ });
+ } else if (duration < 300 && distance < 10) {
+ this.handleTap(touch.clientX, touch.clientY);
+ }
+ this.touches.delete(touchId);

效果:碎片释放后正确执行吸附检测,近距自动归位。

8.4 碎片渲染层级修复

根因:所有碎片按数组顺序绘制,被拖动的碎片没有提升到顶层,手机小屏上容易被遮盖。

修复:在 getPiecesForRender() 中将选中的碎片移到数组末尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
getPiecesForRender() {
const rendered = this.pieces.map(p => ({
...p,
displayX: p.isLocked ? p.targetX : p.currentX,
displayY: p.isLocked ? p.targetY : p.currentY
}));
if (this.selectedPieceId != null) {
const selIdx = rendered.findIndex(p => p.id === this.selectedPieceId && !p.isLocked);
if (selIdx >= 0) {
const [sel] = rendered.splice(selIdx, 1);
rendered.push(sel);
}
}
return rendered;
}

Canvas 中 drawPiecesWithImage() 使用 forEach 遍历,末尾的碎片最后绘制,自然在最上层。

8.5 虚线框对齐修复

根因drawTargetGrid() 独立计算居中偏移量,而 generatePuzzle() 中的网格 offsetY 在窄屏时多了 cellSize * 0.3 的偏移,两者不一致导致虚线框偏高。

修复:虚线框直接读取 PuzzleCore.gridOffsetX / gridOffsetY

1
2
3
4
- const offsetX = canvasCenterX - gridPixelSize / 2;
- const offsetY = canvasCenterY - gridPixelSize / 2;
+ const offsetX = window.PuzzleCore ? window.PuzzleCore.gridOffsetX : (canvasCenterX - gridPixelSize / 2);
+ const offsetY = window.PuzzleCore ? window.PuzzleCore.gridOffsetY : (canvasCenterY - gridPixelSize / 2);

8.6 图片加载优化

根因picsum.photos 海外 CDN 在国内移动端加载慢甚至失败。同时 crossOrigin = 'anonymous' 在某些 CDN 场景下导致 CORS 错误。

修复:移除 crossOrigin 配置,增加国内可用的备选图片源:

1
2
3
4
5
6
// image-load-manager.js
sources: [
'https://picsum.photos', // 主源
'https://api.paugram.com/wallpaper/', // 国内 fallback
'https://source.unsplash.com/1600x900'
]

九、验证结果

1
2
3
hexo clean && hexo g
# ✓ 356 files generated in 1.3s
# ✓ No errors

本文解决的集成与适配问题:

问题类型 数量
模块未加载 1
功能未调用 3
代码冗余 1
竞态条件 1
运行 Bug 6
移动端适配 5

优化后的光影拼图游戏:

  • 渲染性能提升 60-80%
  • 移动端操作响应准确
  • 单一状态源可追踪
  • 完整数据模型生效
  • 画布自适应所有屏幕尺寸
  • 碎片拖拽流畅不溢出

后续方向

  • 持续优化移动端触摸灵敏度
  • 增加本地图片上传功能
  • 测试更多老旧设备的兼容性

相关链接:

博客实战-小游戏 系列
第 6/6 篇