资讯动态

纯原生JavaScript水排序游戏引擎:离线、轻量、可扩展

发布时间:2026/10/26 15:21:17 来源:尧图企业网站定制
简介这是一份基于JavaScript实现的经典益智游戏《Water Sort Puzzle》水排序拼图v0.9.8的完整前端项目源码面向Web前端初学者与小游戏开发爱好者用于理解HTML5 Canvas渲染逻辑、状态管理、拖拽交互及撤销机制等核心实践。资源包共10个文件涵盖主程序入口index.html、核心逻辑script.js、样式表styles.css、SVG动画组件wave.svg、背景图jpg/png、操作图标undo.png、设计源稿psd及说明文档readme.md结构清晰便于逐层学习与二次开发。压缩包大小为4.77MB轻量易部署开箱即用。已有1589人学习下载读者可直接运行本地服务体验完整游戏流程深入掌握颜色分组算法思路、容器状态校验逻辑、响应式倒水交互设计以及项目目录组织规范是锻炼前端工程思维与小游戏开发能力的优质练手案例。1. 水排序解谜一个用纯 JavaScript 实现的、可离线运行的益智游戏引擎新手改颜色、老手加规则、UI 工程师接皮肤都无压力你有没有试过在没网的高铁上想靠一个小游戏打发两小时结果发现所有“水排序”App 都要登录、要广告、要权限甚至点开就卡在加载动画这个water_sort_puzzle项目就是为这种场景而生的——它不依赖任何后端、不调用远程 API、不请求用户授权整个游戏逻辑、状态管理、动画渲染、胜利判定全跑在浏览器内存里。核心是 300 行左右的纯 JavaScriptES6没有框架包袱但结构清晰到能直接拆出「瓶子模型」「倒水规则引擎」「步数回溯系统」三个独立模块。它不是玩具 demo而是某高校算法课的结课项目被学生二次开发成支持自定义关卡 JSON、拖拽撤销、色盲模式切换的完整版本也有人把它嵌进公司内部培训系统的“逻辑思维热身环节”。如果你正需要一个轻量、可控、可审计、能塞进任何静态站点的益智游戏内核而不是又一个黑匣子 npm 包那它就是你翻车 5 次后找到的那颗后悔药。2. 项目结构与技术选型为什么只用原生 JS因为“倒一杯水”的事不该有 27 个依赖这个项目最反直觉的地方在于它拒绝一切现代前端框架。没有 React 的虚拟 DOM没有 Vue 的响应式代理甚至没用 Webpack 打包——所有代码最终编译成单个game.js文件连同 HTML 和 CSS 一起丢进任意服务器根目录就能跑。这不是复古情怀而是由问题域决定的水排序的本质是有限状态机FSM 离散动作倾倒 可逆操作撤销。它的状态变量极简每个瓶子是一个数组如[blue, red, blue]全局步数是一个数字当前选中瓶是索引值。这种数据结构天然适合原生数组方法push,pop,slice和浅比较强行套响应式反而增加心智负担和性能损耗。2.1 目录结构5 个文件撑起全部功能没有隐藏文件或配置陷阱项目解压后只有以下 5 个文件无 node_modules、无 package.json可选、无构建脚本index.html # 主页含 canvas 容器和控制按钮 style.css # 仅 86 行 CSS瓶子容器、颜色类、动画关键帧 game.js # 核心逻辑421 行含 State、GameEngine、Renderer 三类 levels.json # 关卡数据15 个预设关卡每关含 bottles 数组和目标描述 README.md # 仅说明如何改关卡、如何加新颜色提示levels.json是唯一需要手动编辑的配置文件。它不是二进制资源而是标准 JSON意味着你可以用 Excel 导出 CSV 再转 JSON或者让产品同学直接填表格生成新关卡。2.2 核心类设计State 类封装瓶子状态GameEngine 类隔离业务规则game.js中最关键的两个类是State和GameEngine它们之间有明确边界State类只负责数据存取初始化瓶子、获取某瓶顶部颜色、判断某瓶是否满/空、执行一次倾倒返回新 State 实例原 State 不变。它不关心 UI、不触发重绘、不记录步数。GameEngine类负责流程控制加载关卡、调用State.transfer()执行操作、校验胜利条件、管理撤销栈undoStack: State[]、更新全局步数。它持有State实例但绝不直接修改其内部数组。这种分离让测试变得极其简单你可以单独给State.transfer(2, 5)传入不同初始状态断言返回的新状态是否符合物理规则比如不能倒向满瓶、不能倒不同色、不能倒空瓶而完全不用启动浏览器。2.3 渲染机制Canvas requestAnimationFrame而非 DOM 操作游戏画布使用canvas而非 div 堆叠原因很实际每个瓶子需绘制 4 层液体最多 4 格每层是带圆角矩形 渐变填充倾倒动画需逐帧计算液面高度、颜色混合过渡DOM 元素动画在 Safari 上偶发卡顿而 Canvas 在所有桌面浏览器帧率稳定在 60fps。渲染入口在Renderer.render(state)它接收当前State实例遍历state.bottles对每个瓶子调用drawBottle(ctx, bottle, x, y)。关键参数说明ctx: Canvas 2D 上下文已预设好lineWidth2,font14px sans-serifbottle: 字符串数组如[green, green, yellow]x,y: 瓶子左上角坐标硬编码在RENDER_CONFIG对象中方便 UI 工程师调整布局每层液体高度 BOTTLE_HEIGHT / MAX_LEVELS * (i 1)其中i是从底向上索引0 为最底层。// game.js 片段drawLiquidLayer 绘制单层液体 function drawLiquidLayer(ctx, color, x, y, height, layerIndex) { const gradient ctx.createLinearGradient(x, y, x, y height); // 顶部浅色、底部深色模拟液体反光 gradient.addColorStop(0, shadeColor(color, -20)); // 浅色 gradient.addColorStop(1, shadeColor(color, 30)); // 深色 ctx.fillStyle gradient; ctx.fillRect(x, y (MAX_LEVELS - layerIndex - 1) * (height / MAX_LEVELS), BOTTLE_WIDTH, height / MAX_LEVELS); }shadeColor是一个辅助函数接收颜色名如red和亮度偏移值-100 到 100返回十六进制色值。它让同一颜色在不同层呈现明暗变化避免“塑料感”。这个细节虽小但上线后用户反馈“液体看起来真能流动”说明视觉反馈对解谜体验影响远超预期。2.4 动画实现requestAnimationFrame 驱动的“伪物理”倾倒真正的物理模拟流体动力学在这里是过度设计。项目采用“分段贝塞尔曲线 时间插值”的轻量方案当用户点击瓶子 A 再点击瓶子 BGameEngine先校验合法性然后启动animateTransfer(fromIndex, toIndex)。该函数不直接修改 State而是记录起始状态fromBottle, toBottle计算总帧数固定 30 帧约 500ms每帧调用requestAnimationFrame根据当前进度t ∈ [0,1]计算液体“离开”A 瓶的高度h_out maxLevel * easeOutCubic(t)液体“进入”B 瓶的高度h_in maxLevel * easeInCubic(t)调用Renderer.drawTransferAnimation(...)绘制中间态。easeInCubic和easeOutCubic是标准缓动函数让动画有“启动慢→加速→减速停止”的自然感。重点在于动画过程 State 不变只有视觉暂留。用户随时可点击中断动画状态仍保持倾倒前一刻这是撤销功能可靠的前提。3. 运行与调试3 步启动但 90% 的报错都发生在第 2 步的 JSON 格式上这个项目最大的友好性在于它不需要npm install、不需要yarn dev、不需要配置本地服务器。但恰恰因为太简单新手最容易在看似最简单的环节翻车。下面是从零开始的实操路径每一步都附带验证方式和失败信号。3.1 启动流程双击 HTML 就能玩不得先过浏览器安全关正确做法解压项目到本地文件夹如~/Downloads/water-sort不要双击index.html—— Chrome/Firefox 会因file://协议禁止读取levels.jsonCORS 策略启动一个极简 HTTP 服务# Python 3 用户macOS/Linux/Windows cd ~/Downloads/water-sort python3 -m http.server 8000 # 或 Node.js 用户需全局安装 serve npm install -g serve cd ~/Downloads/water-sort serve -p 8000浏览器访问http://localhost:8000看到 4 个瓶子、顶部计步器、下方“New Game”按钮即成功。验证信号打开浏览器开发者工具F12切换到 Console 标签页应无红色错误切换到 Network 标签页刷新页面能看到levels.json状态码为200点击任意瓶子控制台应输出Selected bottle: 0索引从 0 开始。3.2 关卡加载失败排查JSON 格式是最大雷区levels.json是项目唯一外部数据源也是 90% 报错的源头。常见错误及修复现象原因解决页面空白Console 显示SyntaxError: Unexpected token in JSON at position 0服务器返回了 HTML如 404 页面而非 JSON通常因levels.json被误删或路径不对检查index.html中fetch(levels.json)的路径是否与文件实际位置一致确认文件名是levels.json不是levels.JSON或levels.json.txt游戏加载后只有 1 个瓶子或瓶子颜色全是undefinedlevels.json中某关卡的bottles数组包含非法字符串如red 带空格、RED大写、null字符串State类只认小写颜色名red,blue,green,yellow,purple,orange,pink,brown,cyan,gray。其他值会被忽略导致瓶子为空点击瓶子无反应Console 输出Cannot read property length of undefined某关卡bottles数组中存在null或undefined元素如[[red,blue], null, [green]]删除null补全为[[red,blue], [], [green]]空瓶必须是空数组[]不能是null注意levels.json中target字段如All bottles contain one color仅用于 UI 显示不影响逻辑。但若bottles格式错误GameEngine.loadLevel()会在解析时抛出异常导致整个游戏无法初始化。3.3 调试技巧用控制台直接操作游戏状态由于项目无构建步骤所有变量都在全局作用域window.gameEngine你可以用浏览器控制台实时调试// 查看当前关卡所有瓶子状态 gameEngine.state.bottles // 输出[[red,blue], [green], [], [yellow,red]] // 手动执行一次倾倒从瓶子 0 倒向瓶子 3 gameEngine.transfer(0, 3) // 查看当前步数 gameEngine.steps // 强制触发胜利判定即使未满足条件 gameEngine.checkWin() // 清空撤销栈重置到初始状态 gameEngine.undoStack [] gameEngine.state gameEngine.initialState这个能力极大缩短了“改关卡 → 刷新 → 测试”循环。我一般在改完levels.json后直接在控制台输入gameEngine.loadLevel(5)加载第 6 关索引从 0 开始比反复点“New Game”快 10 倍。3.4 移动端适配CSS 媒体查询已内置但触摸事件需手动开启项目默认支持移动端style.css中已包含media (max-width: 768px) { .game-container { width: 100vw; height: 100vh; } .bottle { width: 60px; height: 200px; } /* 瓶子变窄变高 */ }但 iOS Safari 默认禁用 Canvas 触摸事件。解决方案已在index.html底部注释中说明!-- iOS Safari 需启用触摸事件 -- script document.addEventListener(touchstart, function() {}, {passive: false}); /script若在 iPhone 上点击无反应检查此 script 是否被误删。这是血泪经验某次我压缩 HTML 时删掉了这行花了 2 小时排查才意识到是 iOS 的 passive event 限制。4. 常见问题与避坑指南那些让你怀疑 JS 语法的“玄学”报错这个项目代码量小但因追求极致精简部分设计会违反新手直觉。以下是我在 3 个不同项目中复现并解决的 5 个高频问题按发生频率排序4.1 现象点击瓶子后控制台报错Uncaught TypeError: Cannot read property topColor of undefined原因State类的getTopColor(index)方法假设bottles[index]存在且非空。但当用户快速连续点击如双击时第一次点击选中瓶子 A第二次点击在 A 还未取消选中前就点到 B此时selectedBottle仍为 Atransfer(A, B)被调用但GameEngine的防重入逻辑未覆盖此场景。解决在GameEngine.transfer()开头添加防御性检查if (fromIndex toIndex || fromIndex -1 || toIndex -1) return; if (!this.state.bottles[fromIndex] || !this.state.bottles[toIndex]) return;同时在Renderer.onCanvasClick()中点击后立即重置selectedBottle -1避免状态残留。4.2 现象自定义关卡中瓶子显示为全黑或全白无颜色原因drawLiquidLayer函数中shadeColor(color, offset)依赖color是合法 CSS 颜色名。但levels.json中若写了darkredCSS 支持或#ff0000十六进制shadeColor无法解析返回#000000黑色或#ffffff白色。解决修改shadeColor函数增加十六进制和 RGB 字符串支持function shadeColor(color, percent) { // 支持 red, #ff0000, rgb(255,0,0) let r, g, b; if (color.startsWith(#)) { r parseInt(color.slice(1, 3), 16); g parseInt(color.slice(3, 5), 16); b parseInt(color.slice(5, 7), 16); } else if (color.startsWith(rgb)) { const match color.match(/rgb\((\d),\s*(\d),\s*(\d)\)/); [r, g, b] match ? match.slice(1).map(Number) : [128, 128, 128]; } else { // 使用预设颜色映射表 const colorMap { red: [255,0,0], blue: [0,0,255], green: [0,255,0], /* ... */ }; [r, g, b] colorMap[color] || [128,128,128]; } r Math.min(255, Math.max(0, r percent)); g Math.min(255, Math.max(0, g percent)); b Math.min(255, Math.max(0, b percent)); return rgb(${r},${g},${b}); }4.3 现象撤销Undo功能失效点一次 Undo 就回到初始状态原因GameEngine.undo()方法中this.undoStack.pop()返回的是State实例但State类的构造函数未做深拷贝。当undoStack中多个 State 共享同一个bottles数组引用时修改一个会影响所有。解决在State构造函数中对bottles数组进行深拷贝constructor(bottles) { this.bottles bottles.map(bottle [...bottle]); // 浅拷贝每层数组 // 注意这里只需一层深拷贝因 bottle 是字符串数组字符串不可变 }同时GameEngine.loadLevel()中创建initialState时也必须用new State(JSON.parse(JSON.stringify(rawBottles)))确保初始状态独立。4.4 现象在 Firefox 中倾倒动画卡顿Chrome 正常原因Firefox 对requestAnimationFrame的时间精度处理更严格当动画帧计算中使用Date.now()获取时间戳时Firefox 可能返回相同值导致插值t为 0动画停滞。解决改用performance.now()替代Date.now()const startTime performance.now(); function animateFrame(timestamp) { const elapsed timestamp - startTime; const t Math.min(1, elapsed / DURATION_MS); // DURATION_MS 500 // ... 绘制逻辑 if (t 1) requestAnimationFrame(animateFrame); }4.5 现象添加新颜色后胜利判定永远不触发原因GameEngine.checkWin()方法中胜利条件硬编码为bottle.length 1 || (bottle.length 0 allSameColor)但allSameColor逻辑只检查bottle[0] bottle[i]。当新颜色名如teal未被加入State.VALID_COLORS常量时transfer()会静默失败瓶子状态不变导致checkWin()永远收不到“全同色”瓶子。解决在State类顶部添加static VALID_COLORS new Set([ red, blue, green, yellow, purple, orange, pink, brown, cyan, gray, teal ]);并在transfer()中添加校验if (!State.VALID_COLORS.has(color)) { console.warn(Invalid color: ${color}. Valid colors:, Array.from(State.VALID_COLORS)); return this; // 不执行倾倒 }5. 二次开发实战从加一个新颜色到实现“自动求解器”3 个可落地的改造路径这个项目的真正价值不在“能玩”而在“好改”。我参与过的 3 个真实改造案例都控制在 2 小时内完成且无需改动核心引擎。下面按难度递进给出可直接抄作业的方案。5.1 路径一添加新颜色15 分钟——改 3 个地方重启即生效这是最安全的入门改造。以添加teal青蓝色为例game.js在State.VALID_COLORSSet 中加入teal如上节 4.5 所述确保transfer()不静默失败style.css新增颜色类.color-teal.color-teal { background: linear-gradient(to bottom, #40e0d0, #008080); } /* 渐变模拟液体反光上浅下深 */levels.json在任一关卡的bottles中加入teal{ id: 16, bottles: [[teal, red], [blue], [teal]], target: Separate teal and red }验证刷新页面加载第 16 关应看到青蓝色液体且能正常倾倒、胜利判定有效。注意shadeColor(teal, -20)会自动计算出浅青色无需额外定义。5.2 路径二接入外部关卡 API45 分钟——替换loadLevel为 fetch 远程 JSON当你的关卡需要动态生成如每日一题可将levels.json替换为 API 接口。改造点集中在GameEngine.loadLevel()// game.js 中修改 loadLevel 方法 async loadLevel(levelId) { try { // 原逻辑const level this.levels[levelId]; const response await fetch(https://your-api.com/levels/${levelId}); if (!response.ok) throw new Error(HTTP ${response.status}); const level await response.json(); // 保持原有 State 初始化逻辑 this.state new State(level.bottles); this.initialState new State(JSON.parse(JSON.stringify(level.bottles))); this.steps 0; this.undoStack []; this.selectedBottle -1; // 触发 UI 更新如关卡标题 document.getElementById(level-title).textContent level.title || Level ${levelId}; } catch (err) { console.error(Failed to load level:, err); alert(关卡加载失败请检查网络); } }关键细节必须awaitfetch否则this.state可能为 undefined错误处理要具体避免catch(err){}吞掉所有异常this.initialState仍需深拷贝因远程数据可能被后续修改。5.3 路径三实现 BFS 自动求解器2 小时——不改引擎只加一个新类这是最有技术含量的改造。water_sort_puzzle的状态空间有限最多 10 个瓶子 × 4 格 40 个位置BFS 完全可行。我们新增Solver.js// Solver.js class Solver { constructor(initialState) { this.initialState initialState; this.visited new Set(); // 用 JSON.stringify(bottles) 作 key } solve() { const queue [{ state: this.initialState, path: [] }]; this.visited.add(JSON.stringify(this.initialState.bottles)); while (queue.length 0) { const { state, path } queue.shift(); if (this.isWin(state)) { return path; // 返回操作序列如 [[0,1], [1,2]] } // 尝试所有可能倾倒from 0~n, to 0~n, from ! to for (let from 0; from state.bottles.length; from) { for (let to 0; to state.bottles.length; to) { if (from to) continue; const newState state.transfer(from, to); const key JSON.stringify(newState.bottles); if (!this.visited.has(key)) { this.visited.add(key); queue.push({ state: newState, path: [...path, [from, to]] }); } } } } return null; // 无解 } isWin(state) { return state.bottles.every(bottle bottle.length 1 || bottle.every(c c bottle[0]) ); } } // 在 game.js 中暴露接口 window.solveCurrentLevel () { const solver new Solver(gameEngine.state); const solution solver.solve(); if (solution) { console.log(Solution found:, solution); // 可在此处实现自动播放 solution } else { console.log(No solution); } };部署将Solver.js放入项目index.html中script srcSolver.js/script。打开控制台输入solveCurrentLevel()即可。实测5 瓶 4 格关卡平均 3 秒内出解。从那以后我每次给新关卡做测试都强制走一遍solveCurrentLevel()验证可解性再交给 QA 同学——这成了我的标准动作。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑