JavaScript实现水排序求解器:BFS与DFS算法及状态管理

📅 发布时间:2026/10/15 20:04:30
JavaScript实现水排序求解器:BFS与DFS算法及状态管理
简介这是一款基于JavaScript实现的水排序拼图益智游戏源码版本v0.9.8面向前端初学者、小游戏开发者及想练习逻辑与交互实现的编程爱好者。玩家需将混色玻璃杯中的水逐层倒入空杯直至每杯仅剩单一颜色支持点击倒水、撤销操作与重新开局适合作为练手项目或二次开发基础。资源包共10个文件压缩后约4.77MB包含2个js脚本负责核心逻辑与交互、1个html入口页面、1个css样式表以及svg、png、jpg等图片素材和1个psd设计源文件另附readme.md说明文档结构清晰便于快速上手。目前已有1582人学习下载。通过阅读源码读者可掌握游戏状态管理、倒水判定与撤销栈等实现思路并借助现成素材与样式快速搭建可运行版本适合作为课程设计或前端练习参考。1. 从一杯水的倒法说起water_sort_puzzle 到底在练什么第一次看到 water_sort_puzzle 这个项目名很多人会以为是个休闲小游戏合集点进去才发现它其实是一套用 JavaScript 写的水排序Water Sort玩法实现。规则不复杂若干试管里装着不同颜色的水层每次只能把某根试管最上层的同色水倒进另一根顶部颜色相同、且还有空位的试管里直到每根试管只剩单一颜色。听起来像消消乐但真正写起来它是一道典型的搜索与状态管理题——状态空间随试管数和颜色数指数膨胀随手写个递归很容易在浏览器里卡死。这个资源适合三类人想练 JavaScript 状态机与搜索算法的开发者、准备做小游戏但缺一个干净起点的前端、以及想拿它当算法可视化教具的人。它解决的不是“怎么玩”而是“怎么把倒水规则抽象成可求解、可回放、可扩展的数据结构”。我拆过不少同类小游戏源码多数把规则硬编码在 DOM 事件里改一个规则要动半个文件这份资源的价值在于把“状态表示”和“渲染”拆开了这是它能被复用的前提。2. 状态怎么表示试管、颜色与倒水规则的建模2.1 用数组套数组表示试管别用字符串水排序的核心状态就是一组试管每根试管是从底到顶的颜色序列。常见做法是用二维数组外层是试管内层是颜色栈。有人图省事用字符串拼接比如rbb|gg|渲染时再 split短期能跑但一旦要做撤销、去重、哈希缓存字符串的拼接和解析成本就上来了而且容易在空试管上翻车。我一般会这样定义// 颜色用短字符串或数字都行这里用字符便于调试 // tubes 是二维数组每个子数组下标 0 是试管底部末尾是顶部 const state { tubes: [ [r, b, b, g], [g, r, g, b], [b, g, r, r], [] // 空试管容量固定为 4 ], capacity: 4 };逻辑说明tubes[i]表示第 i 根试管tubes[i][tubes[i].length - 1]就是顶部颜色。参数capacity单独抽出来是因为不同关卡容量可能不同写死 4 会让扩展变难。空试管用空数组表示而不是null这样length判断天然成立。2.2 倒水合法性判断三个条件缺一不可倒水能不能做取决于三件事源试管非空、目标试管未满、源顶部颜色等于目标顶部颜色目标为空时视为任意颜色可倒入。这三条里最容易漏的是“目标为空”这个分支很多人写成sourceTop targetTop结果空试管永远倒不进去关卡直接无解。function canPour(state, from, to) { if (from to) return false; // 不能倒给自己 const src state.tubes[from]; const dst state.tubes[to]; if (src.length 0) return false; // 源为空 if (dst.length state.capacity) return false; // 目标已满 if (dst.length 0) return true; // 目标为空任意颜色可倒 return src[src.length - 1] dst[dst.length - 1]; }逻辑说明from to这个判断看着多余但在搜索里如果不拦会产生自环BFS 会无限循环。参数上capacity从 state 读保证和状态一致。返回布尔值而不是抛异常是为了让搜索层能批量试探所有走法。2.3 执行倒水一次可能倒多层真正的倒水不是倒一层而是把源试管顶部连续相同的颜色全部倒过去直到颜色变化或目标满。这一步是很多人写错的地方——只倒一层会导致步数暴增而且不符合游戏直觉。function pour(state, from, to) { if (!canPour(state, from, to)) return null; const next state.tubes.map(t t.slice()); // 深拷贝避免污染原状态 const src next[from]; const dst next[to]; const color src[src.length - 1]; // 连续同色全部倒出受目标剩余容量限制 while (src.length src[src.length - 1] color dst.length state.capacity) { dst.push(src.pop()); } return { ...state, tubes: next }; }逻辑说明map(t t.slice())是关键搜索树里每个节点必须是独立状态共享引用会让回溯时状态错乱。while循环同时受颜色和容量两个条件约束缺一个都会出错。返回新 state 而不是原地修改是为了配合不可变数据思路方便做历史记录。3. 求解器怎么写BFS、DFS 与启发式的取舍3.1 先判断有没有解再谈快不快水排序关卡不一定有解尤其是随机生成的关卡。求解器第一件事是判断可解性而不是闷头搜。常见做法是用 BFS 从初始状态出发看能否到达“每根试管要么空、要么单一颜色且装满”的目标态。BFS 保证最短步数但状态空间大时内存吃不消。function isSolved(state) { return state.tubes.every(t t.length 0 || (t.length state.capacity new Set(t).size 1) ); }逻辑说明new Set(t).size 1判断试管内是否单一颜色比逐个比较更简洁。注意这里要求装满如果关卡允许不满也算赢把t.length state.capacity去掉即可这是个常见的规则差异点。3.2 BFS 求解用字符串做状态指纹去重BFS 的瓶颈在去重。二维数组不能直接进 Set常见做法是序列化成字符串当 key。序列化时试管之间用分隔符试管内颜色用单字符避免歧义。function serialize(state) { return state.tubes.map(t t.join()).join(|); } function solveBFS(state, maxNodes 200000) { const queue [state]; const visited new Set([serialize(state)]); const parent new Map(); // 记录路径便于回放 let head 0, nodes 0; while (head queue.length) { const cur queue[head]; if (nodes maxNodes) return { solved: false, reason: node_limit }; if (isSolved(cur)) return { solved: true, path: rebuild(parent, cur) }; for (let i 0; i cur.tubes.length; i) { for (let j 0; j cur.tubes.length; j) { const next pour(cur, i, j); if (!next) continue; const key serialize(next); if (visited.has(key)) continue; visited.add(key); parent.set(key, { prev: serialize(cur), move: [i, j] }); queue.push(next); } } } return { solved: false, reason: exhausted }; }逻辑说明maxNodes是保命参数浏览器里不设上限复杂关卡能把标签页跑崩。parent用序列化 key 做索引回放路径时从目标态反推。head指针代替shift()因为数组shift是 O(n)队列一大就拖慢整体。3.3 DFS 加剪枝内存友好但可能绕远路BFS 内存吃紧时DFS 是备选。DFS 要配深度限制和访问集合否则容易在可逆操作间来回横跳。常见剪枝是“不倒回上一步的逆操作”以及“目标试管已满且颜色单一就不动它”。function solveDFS(state, depthLimit 60) { const visited new Set(); const path []; function dfs(cur, depth) { if (isSolved(cur)) return true; if (depth depthLimit) return false; const key serialize(cur); if (visited.has(key)) return false; visited.add(key); for (let i 0; i cur.tubes.length; i) { for (let j 0; j cur.tubes.length; j) { const next pour(cur, i, j); if (!next) continue; path.push([i, j]); if (dfs(next, depth 1)) return true; path.pop(); } } return false; } return dfs(state, 0) ? { solved: true, path: path.slice() } : { solved: false }; }逻辑说明depthLimit是必须的水排序状态图有环不限制深度会栈溢出。visited在 DFS 里要谨慎全局 visited 可能剪掉更优路径这里只用于防环配合深度限制使用。参数depthLimit一般设成试管数乘以容量再乘个系数太小学不到解太大失去剪枝意义。4. 渲染与交互把状态映射到 DOM 的几种做法4.1 用 CSS 变量控制水层高度别硬算像素渲染试管时常见做法是每层水一个 div高度按容量均分。硬编码像素在响应式布局里会翻车用 CSS 变量把容量传进去更稳。function render(state, container) { container.innerHTML ; state.tubes.forEach((tube, idx) { const el document.createElement(div); el.className tube; el.dataset.index idx; el.style.setProperty(--capacity, state.capacity); // 从底到顶渲染flex-direction 设为 column-reverse 更直观 tube.forEach(color { const layer document.createElement(div); layer.className layer; layer.style.background colorMap[color]; el.appendChild(layer); }); container.appendChild(el); }); }逻辑说明--capacity交给 CSS 算每层高度JS 不掺和布局计算。dataset.index让点击事件能反查是哪根试管。colorMap是颜色到 CSS 色的映射表单独维护换主题只改这一处。4.2 点击选中与倒水两段式交互更少误操作交互上有点击选中再点目标的两段式也有拖拽式。两段式实现简单、移动端友好我一般先用它。状态里加一个selected字段记录当前选中的试管下标。let selected null; container.addEventListener(click, e { const tubeEl e.target.closest(.tube); if (!tubeEl) return; const idx Number(tubeEl.dataset.index); if (selected null) { if (state.tubes[idx].length 0) return; // 空试管不能作为源 selected idx; } else { const next pour(state, selected, idx); if (next) { history.push(state); // 入历史支持撤销 state next; render(state, container); } selected null; } highlight(container, selected); });逻辑说明selected null分支里拦掉空试管作为源避免无效选中。history.push是撤销功能的基础注意存的是旧状态。highlight单独抽出来负责给选中试管加类名和渲染解耦。5. 避坑与排查那些让关卡无解或页面卡死的细节5.1 现象关卡怎么点都过不了求解器也说无解原因随机生成关卡时没有做可解性校验或者倒水规则里漏了“目标为空可倒”的分支。前者是生成逻辑问题后者是规则实现问题。解决生成关卡后先跑一遍求解器无解就重新生成。规则层用单元测试覆盖“空试管接收任意颜色”“满试管拒绝接收”“同色连续倒出”三个用例这三条过了大部分无解都是生成器的问题。5.2 现象BFS 跑到一半浏览器标签页无响应原因状态空间爆炸visited集合没设上限或者序列化函数有歧义导致去重失效队列无限增长。解决给求解器加maxNodes上限超限就返回“未在限制内找到解”让 UI 层提示用户。序列化时试管间必须用分隔符[ab,c]和[a,bc]不加分隔符会撞 key去重直接失效。5.3 现象撤销之后状态错乱颜色对不上原因pour里没有深拷贝多个状态共享了同一个内层数组撤销时改一个影响了其他。解决pour开头必须state.tubes.map(t t.slice())历史记录里存的状态也要保证独立。如果用了不可变数据结构库这条自然满足但纯手写时这是最高频的翻车点。5.4 现象移动端点击没反应或者一次触发两次原因用了click又在触摸设备上叠加了touchstart或者closest(.tube)匹配到了内层水层元素但没往上找。解决统一用click现代移动浏览器对 click 的延迟已经可接受。closest本身会向上查找问题多出在事件绑在了错误的容器上。调试时打印e.target和tubeEl确认匹配链路。5.5 现象求解器给出的步数和手动玩的对不上原因求解器按“一次倒水”算一步而玩家可能把“选中 倒水”算两步或者求解器允许了玩家 UI 不允许的操作比如倒给自己。解决统一以pour调用次数为步数标准UI 层展示时说明清楚。canPour里from to返回 false保证求解器和 UI 规则一致否则会出现“求解器能解但玩家点不出来”的玄学现象。6. 进阶技巧把求解器变成关卡生成器和难度评估器玩到后面你会发现求解器不只是用来“给答案”的它还能反过来当关卡生成器和难度评估器。我一般会这么用随机生成一批关卡用 BFS 求最短步数步数太少的直接丢弃太简单步数超过阈值的也丢弃太难中间段留作正式关卡。这样生成的关卡难度分布可控不会出现第一关就要二十步的情况。具体做法是写一个生成循环配合前面solveBFS的返回值function generateLevel(tubeCount, colorCount, capacity, minSteps, maxSteps) { for (let attempt 0; attempt 500; attempt) { const state randomState(tubeCount, colorCount, capacity); const result solveBFS(state, 100000); if (!result.solved) continue; // 无解重来 const steps result.path.length; if (steps minSteps || steps maxSteps) continue; // 难度不符 return { state, steps }; } return null; // 500 次都没找到参数可能不合理 }逻辑说明randomState负责把颜色均匀分配到试管里注意要保证每种颜色的总数是容量的整数倍否则永远解不开。minSteps和maxSteps是难度区间按关卡序号递增。attempt上限是保命参数参数设得太苛刻比如颜色数远大于试管数时可能一直找不到返回 null 让调用方处理。参数上有个经验值试管数一般比颜色数多 2 到 3 根空试管太少会导致可解关卡比例骤降太多又让难度上不去。容量固定 4 时颜色数 6 到 8、试管数 9 到 11 是比较舒服的区间。这些数字不是死的但偏离太多时生成器命中率会明显下降这时候要么放宽难度区间要么调整试管和颜色的比例。还有一个容易被忽略的点求解器返回的路径可以直接拿来做“提示”功能。玩家卡住时从当前状态跑一次 BFS取路径第一步高亮出来比直接给完整答案体验好。注意提示也要走canPour校验避免求解器和 UI 规则不一致导致提示了一个点不出来的操作。从那以后我每次写完求解器都会先拿它跑一遍自己手写的关卡确认求解器给出的步数和手动玩出来的一致再拿它去生成新关卡。这个习惯帮我拦下过好几次规则实现和 UI 实现不一致的问题——求解器说能解玩家点不出来这种 bug 最耗时间。希望帮到你。本文还有配套的精品资源点击获取