- 教程
- 文档
- 示例工程
【免费下载链接】website-archive
Archive of the Coding Train website (first version)
本篇以 Coding Train 多期编程挑战的第 4 期(094.4-2048.md)为核心,结合仓库内 CC_094_2048 的最终源码,完整拆解这一期中"优化视觉效果、修复缺陷、重构代码"三项工作的落地方式:包括按数值区分配色的
colorsSizes配置表、新增方块的高亮标记机制、无效移动检测、胜负判定,以及把单文件拆分为config.js / grid.js / game.js / sketch.js的分层重构。读完本文,你将掌握 2048 这类 4×4 滑动拼图游戏的完整实现脉络,理解"先滑动—合并—再滑动"的行操作核心算法,并能把同样的重构与可测试性思路迁移到自己的 p5.js 项目中。
系列背景与第 4 期的定位
2048 是 Coding Train 的第 94 号多期编程挑战,目标是用 JavaScript(canvas + p5.js)从零实现风靡一时的滑动拼图游戏。四期内容依次递进:
| 期数 | 文档 | 主题 |
|---|---|---|
| 第 1 期 | 094.1-2048.md | 游戏核心机制(网格、滑动、合并) |
| 第 2 期 | 094.2-2048.md | 继续完善游戏机制 |
| 第 3 期 | 094.3-2048.md | 加入计分、调整字号、增加"获胜"判定 |
| 第 4 期 | 094.4-2048.md | 改进视觉(并不算成功)、修复 Bug、重构代码 |
第 4 期文档对本期工作的概括非常坦诚:"I attempt but mostly fail to improve the visuals of the game, fix a bug and refactor the code a bit"(尝试但基本没能成功改进视觉、修复一个 Bug、稍微重构了一下代码)。这期内容虽然篇幅不长,但正好是观察"游戏逻辑与表现层分离"的绝佳样本——仓库中留下的最终代码,就是这次重构的成果,我们可以直接从源码出发逐层验证。
视觉优化:colorsSizes配置表与新增方块高亮
第 4 期"改进视觉"的成果,集中体现在两个地方:独立的视觉配置表,以及新生成方块的一次性高亮描边。
按数值区分配色的配置表
仓库把视觉常量从主逻辑中抽离到了独立的 config.js 文件,用一张对象表把每个数值映射到"字号 + 填充色":
// CodingChallenges/CC_094_2048/P5/config.js let colorsSizes = { '2': { size: 64, color: '#F35956' }, '4': { size: 64, color: '#F35956' }, '8': { size: 64, color: '#49BB6C' }, '16': { size: 64, color: '#2494C1' }, '32': { size: 64, color: '#9659A7' }, '64': { size: 64, color: '#F1C500' }, '128': { size: 36, color: '#FF5956' }, '256': { size: 36, color: '#F1C5FF' }, '512': { size: 36, color: '#00000' }, '1024': { size: 64, color: '#24FFC1' }, '2048': { size: 64, color: '#A659A9' } };在 sketch.js 的drawGrid()中,绘制每个格子时直接查表取色和字号:
if (val != 0) { fill(colorsSizes[s].color); } else { noFill(); } rect(i * w, j * w, w, w, 30); // 最后一个参数 30 是圆角半径 if (val !== 0) { textAlign(CENTER, CENTER); noStroke(); fill(0); textSize(colorsSizes[s].size); text(val, i * w + w / 2, j * w + w / 2); }把颜色、字号这类"表现参数"集中到一张配置表里,是重构带来的直接好处:调整配色不必再钻进绘图函数内部改代码。
值得注意的是,这份配置表正是文档所说"mostly fail"的物证——从最终代码看仍留有明显瑕疵:
'512'的颜色是'#00000',这是 5 位十六进制色值,并非合法的 CSS 颜色(合法值应为 3 位或 6 位),该方块实际会以异常/黑色显示;'2'与'4'共用'#F35956',而'128'用的是相近但不一致的'#FF5956';- 字号分配也欠合理:三位数
128/256/512用了 36px,而四位数1024/2048反而用回 64px,长数字有溢出格子的风险。
这些细节恰好印证了作者对本期视觉改造的自我评价——方向是对的(配置化),但效果并不圆满。
新增方块的一次性高亮描边
第 4 期另一个视觉尝试是"标记新生成的方块"。实现方式是在 grid.js 的addNumber()中,除了向grid写入新数值,还同步在一张平行的标记网格grid_new中打点:
function addNumber() { let options = []; for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { if (grid[i][j] === 0) { options.push({ x: i, y: j }); } } } if (options.length > 0) { let spot = random(options); let r = random(1); grid[spot.x][spot.y] = r > 0.1 ? 2 : 4; // 90% 概率出 2,10% 概率出 4 grid_new[spot.x][spot.y] = 1; // 标记为新方块 } }对应地在drawGrid()中,读到grid_new[i][j] === 1的格子会被绘制成紫色粗描边(stroke(200, 0, 200)、strokeWeight(16)),画完立刻把标记清回 0,保证高亮只出现一次:
if (grid_new[i][j] === 1) { stroke(200, 0, 200); strokeWeight(16); grid_new[i][j] = 0; } else { strokeWeight(4); stroke(0); }由于主循环使用了noLoop()(见下文 setup),画面只在每次按键后由updateCanvas()主动重绘,因此"新方块高亮"恰好能持续到下一次移动之前,让玩家一眼看到刚生成的数字落在哪里。从数据结构上看,grid(数值)与grid_new(标记)两张 4×4 网格平行维护,是"数据层 + 表现层"分离的一种轻量实现。
Bug 修复:无效移动检测、胜负判定与新方块生成
第 4 期"修复一个 Bug",从最终源码可以推断出几处为杜绝典型 2048 缺陷而设的机制,它们共同保证了游戏状态的正确性。
无效移动不生成新方块
2048 中最常见的缺陷是:按下没有产生任何变化的按键(例如最右列已满时按"右"),系统却仍然随机生成一个新方块。仓库在 sketch.js 的keyPressed()里通过"操作前复制、操作后比较"的方式规避了它:
let past = copyGrid(grid); for (let i = 0; i < 4; i++) { grid[i] = operate(grid[i]); } let changed = compare(past, grid); // ... 还原 flip / transpose ... if (changed) { addNumber(); // 只有棋盘真的变了才生成新方块 }配套的两个工具函数都在 grid.js 中:copyGrid()(L31-L39)深拷贝当前棋盘用于事后比对,compare()(L20-L29)逐格比较两个棋盘、只要任一格子不同就返回true。这一"先快照、后校验"的写法把移动与生成两个环节解耦,是 2048 实现中非常关键的一处防御。
胜负判定
胜利与失败判定放在 game.js 中:
isGameWon()(L41-L50):遍历整个 4×4 棋盘,一旦发现某个格子等于2048即判定获胜;isGameOver()(L52-L67):只有当棋盘既没有空格、又没有相邻等值对时才返回true——即既无路可走也无对可合:
function isGameOver() { for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { if (grid[i][j] == 0) return false; // 还有空格 if (i !== 3 && grid[i][j] === grid[i + 1][j]) return false; // 竖向可合并 if (j !== 3 && grid[i][j] === grid[i][j + 1]) return false; // 横向可合并 } } return true; }注意两个函数的返回值语义是相反的:isGameWon找到 2048 即返回true,而isGameOver找到任何"还能玩"的证据即返回false。在keyPressed()末尾,两者结果都只是通过console.log打印'GAME WON'/'GAME OVER',游戏本身并未真正停表或弹出结束界面——这同样是"半成品"式收尾的体现,也是后续可以自行扩展的方向(例如显示胜利/失败画面、禁止继续输入)。
90/10 的新方块数值概率
addNumber()中grid[spot.x][spot.y] = r > 0.1 ? 2 : 4;实现了经典 2048 的概率规则:90% 概率生成2、10% 概率生成4。随机空位从全部空格的候选列表中选出,保证任何情况下新方块都落在合法位置。这一点与 Processing 版本(r > 0.5 ? 4 : 2)的概率分配略有差异,后者是 50/50——两版对照可以清楚看到概率参数对游戏节奏的影响。
代码重构:单文件拆分为四文件分层
第 4 期最重要的产出是重构后的文件结构。仓库中 CC_094_2048/P5 目录下的四个源文件职责清晰:
| 文件 | 职责 | 关键内容 |
|---|---|---|
| config.js | 表现层配置 | colorsSizes数值→字号/配色映射 |
| grid.js | 网格数据结构与操作 | blankGrid、compare、copyGrid、flipGrid、transposeGrid、addNumber |
| game.js | 纯游戏逻辑 | operate、slide、combine、isGameWon、isGameOver |
| sketch.js | p5.js 入口与表现 | setup、keyPressed、updateCanvas、drawGrid |
加载顺序在 index.html 中体现为严格的依赖关系:
<script src="https://cdn.jsdelivr.net/npm/p5@1.4.1/lib/p5.min.js"></script> <script src="config.js"></script> <script src="grid.js"></script> <script src="game.js"></script> <script src="sketch.js"></script>先加载 p5 运行时,再依次加载配置、网格工具、游戏逻辑,最后是作为入口的 sketch。页面主体只有一个用于显示分数的元素:<p id="score">,分数更新在updateCanvas()中通过select('#score').html(score)完成(sketch.js#L82-L86)。
这种分层最大的价值在于:网格变换(grid.js)与移动判定(game.js)都不直接依赖 p5 的绘图 API,operate/slide/combine/isGameOver等核心逻辑理论上可以被抽出来做纯函数单元测试——这正好呼应了第 4 期文档关联的 "CircleCI: Unit Testing and Continuous Integration" 视频系列(见 094.4-2048.md 的videos字段)。
移动算法核心:slide → combine → slide 与方向键映射
行操作的三段式流程
2048 的本质是对每一行(或列)做"滑动-合并-滑动"。仓库在 game.js#L11-L16 用operate()把三个步骤封装成一个不可分割的原子操作:
function operate(row) { row = slide(row); // 1. 把所有非零数字挤向行尾(对齐) row = combine(row); // 2. 从右向左合并相邻等值 row = slide(row); // 3. 合并产生的空格再次对齐 return row; }slide()(L19-L25)的实现非常简洁——用filter(val => val)剔除零值,再把缺失的零补到数组头部:
function slide(row) { let arr = row.filter(val => val); // 去掉所有 0 let missing = 4 - arr.length; // 需要补回的空格数 let zeros = Array(missing).fill(0); arr = zeros.concat(arr); // 零在前,非零数字挤到行尾 return arr; }combine()(L28-L39)从右向左(i = 3到1)扫描,一旦发现相邻两格相等就合并到右侧格子并累加分数:
function combine(row) { for (let i = 3; i >= 1; i--) { let a = row[i]; let b = row[i - 1]; if (a == b) { row[i] = a + b; // 合并结果落在右侧 score += row[i]; // 分数即时累加 row[i - 1] = 0; // 左侧清空 } } return row; }从右向左的遍历顺序保证了"同一轮内已合并的格子不会再次被合并"(例如[2, 2, 2, 2]一轮后变成[0, 0, 4, 4],而不是[0, 0, 0, 8]),这是 2048 合并语义的关键细节。注意score在这里是全局变量(在 sketch.js#L13 声明),combine直接读写它——若想进一步把 game.js 做成纯逻辑模块,下一步可以考虑把分数作为返回值传出。
四个方向统一为一维行操作
p5 版本的技巧在于:四个方向的移动全部归约为对"行"的operate调用。在 sketch.js#L27-L51 的keyPressed()中,先把网格做翻转(flipGrid)和转置(transposeGrid),使目标方向上的数据都落到同一行上:
UP_ARROW:flipGrid(grid)(每行反转,让下边数据对齐到右边处理逻辑)RIGHT_ARROW:transposeGrid(grid)(转置后"列"变"行")LEFT_ARROW:transposeGrid+flipGridDOWN_ARROW:不处理(即保持基准方向)
处理完所有行后,再按原路反向还原变换,使棋盘方向回到玩家视角。transposeGrid与flipGrid都在 grid.js:
function flipGrid(grid) { for (let i = 0; i < 4; i++) { grid[i].reverse(); // 就地反转每一行 } return grid; } function transposeGrid(grid) { let newGrid = blankGrid(); for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { newGrid[i][j] = grid[j][i]; // 行列互换 } } return newGrid; }"四个方向共用一份行逻辑"是 2048 实现中最优雅的抽象之一:只需写一次滑动合并代码,再配合线性代数里的翻转/转置变换即可覆盖全部方向。作为对照,Processing 版本采用了同样的思路,只是在"还原转置"时把rotateGrid连续调用三次(相当于转置的逆运算),两版对照阅读有助于理解方向变换的对称性。
主循环与按键处理
sketch.js#L15-L24 的setup()建立了整个游戏的运行框架:
function setup() { createCanvas(400, 400); // 400×400 画布,每格 100px noLoop(); // 关闭自动循环,改为事件驱动重绘 grid = blankGrid(); grid_new = blankGrid(); addNumber(); // 开局生成两个初始方块 addNumber(); updateCanvas(); }noLoop()是本作的重要设计:p5 默认每帧调用draw(),而 2048 是回合制游戏,只有按键时才需要重绘,因此关闭循环、由keyPressed()主动调用updateCanvas()重绘,既省性能又让"新方块高亮"这类一次性表现得以稳定呈现。
可测试性:重构与单元测试、CI 的衔接
第 4 期文档的videos字段专门关联了 "CircleCI: Unit Testing and Continuous Integration" 系列(文档见 _Streams/117.2-unit-testing-ci-circleci.md),这暗示了本期的重构与"让代码更容易被测试"的意图直接相关。仓库根目录下的 unit_testing 目录正是这套理念的具体落地:
- unit_testing/readme.md 说明:该目录用 Jest 对所有
scriptname.test.js文件做单元测试,并为基于 CircleCI 的持续集成做准备; - unit_testing/yaml.test.js 会对
_CodingChallenges等目录下每一个文档的 YAML front matter做格式与类型校验,例如"标题与内部编号一致""video_id全局唯一""repository与web_editor映射唯一且对应仓库目录真实存在"; - unit_testing/formats.js 用
prop-types定义了video文档的字段契约——title、video_number、date、video_id为必填,repository、web_editor、parts等为可选字段。
以本篇核心文档 094.4-2048.md 为例,它的 front matter 完全符合这套契约:video_number: 94.4与文件名编号094.4一致(去掉前导零后匹配),repository: CC_094_2048对应仓库中真实存在的 CodingChallenges/CC_094_2048 目录,web_editor: aeIk9JZ-U关联 p5.js 在线编辑器版本。运行测试的方式见 unit_testing/package.json:在unit_testing目录执行npm test(即jest)即可校验全站文档与仓库资源的一致性。
从源码结构看,把 game.js / grid.js 中的纯逻辑与 p5 绘图分离,正是为了让这类算法函数能够脱离浏览器环境、在 Node + Jest 下被直接断言——这是"重构提升可测试性"最直接的证据链。
运行与调试方式
要在本地运行这套 p5.js 版 2048,无需安装任何构建工具:
- 打开 CodingChallenges/CC_094_2048/P5/index.html,它通过 jsdelivr CDN 引入 p5.js 1.4.1(需要网络环境);
- 页面按顺序加载
config.js → grid.js → game.js → sketch.js,setup()会自动生成 400×400 画布并初始化两个方块; - 用键盘方向键操作:
←左移、→右移、↑上移、↓下移;每次有效移动后自动生成一个新方块(90% 为 2、10% 为 4),紫色描边即为新方块高亮; - 分数显示在页面顶部的
<p id="score">中,每次合并即时累加; - 打开浏览器控制台可看到
GAME WON/GAME OVER的判定输出。
小结
第 4 期"尝试改进视觉、修复一个 Bug、稍微重构代码"这三件事,在仓库最终代码中留下了清晰可查的痕迹:
- 视觉:颜色/字号被抽入 config.js 的
colorsSizes配置表,新方块通过grid_new标记网格获得紫色高亮描边;同时配置表里残留的非法色值'#00000'与字号分配问题,如实印证了"mostly fail"的自我评价; - Bug 修复:
copyGrid + compare的快照比对机制杜绝了无效移动误生新方块,isGameWon/isGameOver提供了正确的胜负判定; - 重构:代码按"配置 / 网格 / 游戏逻辑 / p5 入口"拆分为四个文件,"slide → combine → slide"的行操作配合翻转、转置统一处理四个方向,纯逻辑与绘图分离也为单元测试与 CI 铺平了道路。
对想深入学习 p5.js 游戏开发的读者,建议顺着 094.1-2048.md 到 094.4-2048.md 的四期文档顺序阅读,同时对照仓库中的 P5 版本 与 Processing 版本,两版在同一算法框架下的实现差异本身就是极好的学习材料。
- 教程
- 文档
- 示例工程
【免费下载链接】website-archive
Archive of the Coding Train website (first version)
相关推荐
Coding in the Cabana 第 1 期:使用 p5.js 与 Processing 实现 Maurer Rose 可视化
Coding in the Cabana 第 1 期:使用 p5.js 与 Processing 实现 Maurer Rose 可视化 导读 Maurer Ro
教程文档示例工程p5.js模块化编程:代码组织与重用
p5.js模块化编程:代码组织与重用 你是否曾在p5.js项目中遇到过代码混乱、功能复用困难的问题?本文将从基础到进阶,系统讲解如何通过模块化编程解决这些痛点,
前端图形学3D渲染行为验证码 Android 接入实战:基于 Anji Plus 滑动拼图与点选文字的 Kotlin/Java 双 Demo 全解析
行为验证码 Android 接入实战:基于 Anji Plus 滑动拼图与点选文字的 Kotlin/Java 双 Demo 全解析 本文以 view/andro
后端应用安全图像处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考