简介:这是一款基于 JavaScript 实现的水排序拼图游戏资源,版本 v0.9.8,面向前端初学者、益智游戏爱好者与需要课堂案例的开发者。游戏规则简单但逻辑完整:玩家通过点击玻璃杯将彩色液体逐层倒入空杯,最终让每只杯子只保留一种颜色;资源内含可运行的 HTML 页面与配套 CSS、JS 脚本,便于直接打开体验或二次修改学习。压缩包为 zip 格式,共 10 个文件,涵盖 2 个 JavaScript 脚本、2 张 JPG 背景图、1 个 SVG 矢量图、1 个 PNG 按钮图、1 个 HTML 入口、1 个 PSD 源文件、1 份 Markdown 说明及 1 个 CSS 样式文件,包体仅 4.77MB,结构轻量,适合快速下载。目前已有 1575 人学习浏览,可作为 JavaScript 游戏开发入门的参考项目。资源价值在于提供了一套完整的源码与素材:从页面布局、样式控制到点击交互、倒水逻辑与撤销操作均有涉及,配合 PSD 原图及说明文档,能帮助理解经典益智游戏的功能拆解与实现思路,也方便在此基础上扩展关卡或优化界面。 最近我花了不少时间把一个叫 water_sort_puzzle 的小游戏彻底折腾了一遍。这个游戏大家应该在手机上见过,就是给你一堆装着不同颜色液体的试管,每次只能把一种颜色倒进另一根试管里,目标是把每种颜色都归到同一个试管中。听起来规则特别简单,但真等你自己上手写一套代码来求解它,你会发现问题远没有想象中那么“幼稚”。
water_sort_puzzle 这类益智题,本质上是一个典型的搜索问题:所有试管的当前状态构成一个状态节点,一次合法的倒水操作就是从当前状态走向另一个状态的“一步”。如果把所有可能的状态和转移关系铺开,你会得到一张巨大的有向图,需要在里面找到一条从初始状态到目标状态的最短路径。这个项目,既是练搜索算法和状态压缩的好素材,也是写小游戏、做关卡设计时的绝佳原型。
这篇文章我会把整个项目的拆解思路、核心算法选型、代码实现过程以及我实际踩过的坑整理出来。无论你是想自己写一个水排序求解器,还是打算基于这个机制做一个小游戏,又或者只是单纯想在状态搜索上找个练手项目,这篇内容都能给你一套可以直接用的方案。
1. 项目概述与整体设计思路
1.1 核心规则拆解
先花点时间把 water_sort_puzzle 的规则说清楚,因为后面所有算法、数据结构和状态设计都建立在这几条规则之上:
- 初始状态下,有 N 根试管,每根试管里装着若干层不同颜色的液体。试管有最大容量(通常为 4 层)。
- 一次操作只能把一个试管顶部的有色液体倒入另一个试管,并且倒入目标试管后,倒入液体的颜色必须和目标试管当前顶层液体的颜色一致。
- 目标试管可以为空,空试管可以接收任何颜色的液体。
- 游戏目标是将每种颜色全部集中到同一根试管里,且没有混杂其他颜色。
这几条规则看起来不复杂,但转化为程序逻辑时需要很小心。尤其是第 2 条,游戏里并不要求一次只能倒一整瓶,通常允许倒出“顶部连续的同色段”,但不能跨越不同颜色段。比如试管顶部是红色、红色、蓝色,那么只能一次倒出两段红色,不能把红色倒完后再把底下那层蓝色也一起带出去。
1.2 项目价值和适用人群
我为什么会选 water_sort_puzzle 来做这个项目?首先是它天然自带可视化效果,做一个控制台版本就能看到试管、颜色和倒水过程的动态变化,比普通的八皇后、迷宫求解要有趣得多。其次是问题规模适中,太小的问题体现不出算法优化的价值,太大的问题又会让人失去耐心,而 water_sort_puzzle 一变参数,难度就会跟着变化,非常适合测试不同搜索策略的效率。
如果你正在学习数据结构,尤其是图论和状态空间搜索的内容,这个项目是一个极好的进阶练习题。它融合了状态定义、去重策略、搜索剪枝、步数优化等多个知识点,做好之后你绝对不是“会背模板”的水平,而是真的能理解搜索为什么需要这些设计。
2. 核心机制与算法选型
2.1 状态空间分析
我把 water_sort_puzzle 当作搜索问题时,第一件事是想清楚状态空间有多大。假设有 8 根试管,每根试管最多装 4 层液体,颜色总数也是 8 种,每种颜色恰好有 4 层,那么总的状态数量理论上是极其庞大的。
简单估算一下:每一层的颜色可以有 8 种选择,最多 32 个层位(8 试管 × 4 层),但我又不能直接算成 8 的 32 次方,因为每根试管内部顺序有约束,液体总数也是固定的。即便如此,粗略估算下来,状态空间轻易就能到百万甚至千万级别。如果我用一个“韦恩图式”的数组去存储每一个状态,不加以压缩和去重,程序很快就会内存溢出。
这就引出了整个项目的第一个关键设计点:状态的定义和压缩方案必须从一开始就做好。
2.2 搜索策略选型
对于找最短操作步数这个问题,BFS(广度优先搜索)是理论上最稳妥的方案,因为它天然具有“层层推进”的特性,第一次搜索到目标状态的路径,一定就是最短路径。但 BFS 的缺点也很明显,它需要保存每一层的所有状态,内存消耗随层数增长很快。
DFS(深度优先搜索)在内存上更友好,但找出来的路径不保证是最短步数,而且如果剪枝策略不到位,可能会陷入深度极深的无效分支中。
我在项目中最终的方案是:以 BFS 为主干,配合多层剪枝,同时加入一个简单的启发式排序,让每次扩展节点时优先走“看起来更有希望”的状态。这个思路其实接近 A*,但由于目标状态是“同色聚齐”而非某个唯一状态,我并没有完全套用标准 A* 的启发式函数,而是给 BFS 加了一个优先队列,用评估函数来实时调整扩展顺序,效果非常明显。
具体来说,每个状态我给一个分数,分数的计算方式是:当前已经聚齐的颜色种类数越多,得分越高;试管顶部颜色分布越“整洁”,得分越高。每次扩展时按得分从高到低出队,这样可以让搜索快速聚焦到更接近目标的区域,而不是漫无目的地遍历所有状态。
2.3 状态编码方案:从数组到整数
状态压缩是让整套搜索方案能跑起来的关键。我的做法是把每根试管看成一个四位的整数向量,每种颜色用一种数字编码表示,比如红色为 1、蓝色为 2、绿色为 3,空位用 0 表示。那么一根试管的状态就可以被编码成一个四元组,一个局面就是一组有序的四元组。
为了去重,我直接从根试管开始做整体编码,把所有试管的状态拼成一个字符串或者一个元组对象。Python 的元组天然支持哈希,因此直接把“瓶子元组的元组”作为字典 key 就可以完成去重,代码上非常清爽。
但是如果你追求极致的性能,建议把所有试管的状态压缩成一个整型。比如每种颜色用 3 个比特位表示(最多支持 8 种颜色),每根试管 4 层需要 12 个比特位,8 根试管一共 96 个比特位,刚好可以塞进一个 Python 整数里。这样状态的哈希计算和比较速度都会快很多。
3. 完整实现与核心环节解析
3.1 倒水操作与状态转移
倒水操作听起来简单,但写代码时很考验细节。一次合法的倒水动作需要完成这些检查:
- 源试管不能为空。
- 目标试管不能已满。
- 如果目标试管不为空,源试管顶部的颜色必须和目标试管顶部的颜色一致。
- 源试管顶部是一段连续同色液体,需要把这段“同色段”整体倒过去,并且不能超过目标试管的剩余容量。
这里有一个特别容易忽略的细节:如果源试管顶部的同色段长度超过了目标试管的剩余空间,那么只能倒下目标试管剩余空间大小的液体量,剩下的继续留在源试管中。因为游戏规则允许“少倒”,但不能“多倒”。
我给出的代码实现如下:
def can_pour(src, dst, capacity=4): if not src or len(dst) >= capacity: return False if not dst: return True if src[-1] != dst[-1]: return False return True def pour(src, dst, capacity=4): if not can_pour(src, dst, capacity): return None count = 1 for i in range(len(src) - 2, -1, -1): if src[i] == src[-1]: count += 1 else: break room = capacity - len(dst) pour_count = min(count, room) new_src = src[:-pour_count] if pour_count == count else src[:-count] + src[-count + pour_count:] new_dst = dst + [src[-1]] * pour_count return new_src, new_dst请仔细看这段代码,尤其是pour函数中处理“倒入量小于同色段长度”那一段逻辑。当同色段长度超过目标试管剩余空间时,只能倒出一部分,剩余的同色段仍然保留在原试管顶部。这个边界情况如果不处理,很多看似可解的关卡会被误判为无解。
3.2 剪枝策略与常见死循环规避
搜索算法最怕的不是状态多,而是重复访问和无效循环。我在实现 BFS 时,用了一个全局的 visited 集合,每一个生成的新状态都先检查是否已经入队过,如果已经访问过就直接丢弃。这个做法是基础,三分钟就能写完,但真正让我跑通复杂关卡的是下面几个“高级剪枝”:
- 剔除“无意义倒回”。如果上一步操作是把颜色 X 从试管 A 倒入试管 B,那么下一步不允许把 X 从 B 倒回 A。这种来回倒的操作对解题毫无帮助,却会消耗大量搜索时间。我在状态转移时记录每一步的“来源对”(source_index, target_index),下一个状态展开时将这个反向操作直接排除。
- 跳过“顶色相同且空余”的假操作。如果源试管和目标试管当前顶部颜色相同,目标试管还有大量空余,但源试管顶部同色段长度不足以填满目标试管的空余,且源试管下面还有其他不同颜色,那么这次倒水只会制造出一个底部混杂、顶部相同的局面,通常不会导向更优解。
- 优先处理“已完成试管”。如果某根试管已经全部是同色且达到最大容量,在搜索过程中直接把它视为“冻结状态”,不再从它倒出液体,也不再向它倒入任何液体。这个剪枝在关卡后期可大幅缩小搜索空间。
关于死循环,我实测最大的问题不在算法,而在状态去重不完整。如果 visited 去重不彻底,BFS 会在几个状态之间反复横跳,直到耗尽内存。排查时我建议把每一个步数的状态数量打印出来,如果看到数量异常暴涨,优先怀疑 visited 没生效。
3.3 求解器完整流程与参数调优
我最终实现的求解器主流程大致分这几步:
- 定义初始状态,将每个试管的颜色列表转换成不可变元组。
- 初始化队列,将初始状态入队,步数记为 0。
- 初始化 visited 集合,将初始状态加入集合。
- 搜索循环,从队列头部取出一个状态,判断是否满足胜利条件(每根试管为空或只含单一颜色)。
- 若不满足条件,遍历所有试管对,生成所有合法倒水动作产生的新状态。
- 对每个新状态做去重和剪枝检查,然后计算启发式分数,按分数插入优先队列。
- 记录每个状态的父状态和操作来源,方便回溯输出完整解法。
参数调优方面,有几个点值得单独说一下:
- 优先队列的排序函数要写得轻量,不要在排序里做太复杂的计算,否则会影响整体速度。
capacity参数决定试管容量,在大多数手机游戏里是 4,但测试时建议改成 3 或 5 来验证求解器的泛化能力。- 对于 N 比较大的关卡,优先队列 + 启发式排序的优势会非常明显,而纯 BFS 可能在几十万状态之后就变得不可接受了。
3.4 可视化界面:从控制台到图形界面
在完成求解器之后,我又顺手做了一个简单版的可视化界面。这里我没有用复杂的游戏引擎,而是直接用 Python 的 pygame 库把试管画在屏幕上,每次倒水动作通过动画方式呈现,步数信息和状态提示放在旁边。
核心思路很简单:把求解器输出的每一步“从第几根试管倒入第几根试管”解析成一个动画事件,每一帧根据动画插值移动试管顶部的颜色块。动画速度可以调整,方便回溯和检查求解路径。这个可视化版本虽然功能简陋,但对调试算法的帮助非常大,比打印一堆文本日志直观多了。
如果你不是在开发游戏,而只是想看解题过程,用纯控制台打印试管状态也完全够用。我会在每次操作后打印当前所有试管的分层结构,并用不同字母代表不同颜色,这样可以很清晰地看到每一步的变化。
4. 常见问题与排查技巧实录
4.1 搜索状态爆炸,程序卡死
这是最容易遇到也最让人头疼的问题。我最初写第一版的时候,没有做很好的剪枝,只靠 visited 去重,在 8 试管、8 颜色、每色 4 层的默认关卡上跑,内存占用很快就突破了 2GB,程序直接卡死。
排查时我做了三件事。第一步,打印每个层级的节点数量,发现某一层节点数量呈指数级增长后迅速确定问题出在无效状态太多。第二步,加入“禁止反向倒回”剪枝,节点数量大概降到了原来的三分之一。第三步,加入“冻结已完成试管”剪枝后,节点数量又缩小了一个数量级。经过这两层剪枝,默认关卡的求解状态数从几十万降到了几万,内存占用也在可接受范围内。
4.2 找到的路径不是最短的
这个问题我遇到过两次,原因都是同一个:优先队列的排序逻辑在 BFS 中引入了不严谨的“跳层”,导致第一次到达目标状态的路径不是最短路径。
解决办法是不要把启发式分数作为出队的唯一依据,而要同时记录当前已走的步数。我把优先队列的排序键设计成(steps + heuristic, steps)这样的二元组,既保证能优先探索高潜力的分支,又确保步数更小的状态有机会先被搜索。实际测试下来,这样得到的路径就是最短路径,同时搜索效率仍然优于普通 BFS。
4.3 生成关卡时无解
如果你也想做一个关卡生成器,这个问题几乎绕不开。最简单的生成方式是从目标状态反向随机操作若干步,打乱试管颜色分布,这样生成的关卡一定是有解的,因为你只需要按反向操作的逆序走回去就行。
反向生成的实际操作也有讲究,每步反向操作要确保不会把局面弄成“表面上杂乱但实际接近完成”的状态,尽量增加打乱深度。官方游戏的关卡通常是在反向生成之后,再人为检查一下是否有比生成步数更短的解法,如果有,就换个随机种子重新生成。
4.4 状态去重不完整导致内存爆掉
visited 集合使用不当也会造成内存问题。如果你用的是 Python 的tuple套tuple结构,几千到几万状态没问题,但到几十万状态时,哈希表的开销会变得很大。我在优化时改成整数编码,将所有试管压缩成一个整数,加载到 visited 中的时候直接存整数而不是存元组,内存占用立刻下降了一个量级。
这里我可以给一个很直接的提示:状态去重集合中元素的表示方式,是决定整个搜索程序性能上限的关键。任何你能想到的“可读性更好”的自定义对象、字符串拼接、甚至 JSON 序列化,在性能上都不如一个干净的整数。
我在实际运行中发现,把状态编码成整数之后,visited 查找速度也比元组快很多。因为整数的哈希计算是常数时间,而元组哈希需要递归计算内部元素,再用自定义类实现的话还要额外处理__hash__和__eq__的开销。
5. 写在最后的实操心得
water_sort_puzzle 这个项目玩到后期,让我最大的收获不是写出了 BFS 或者优先队列,而是理解了搜索类问题里“状态设计决定一切”这句话的分量。同一套规则,用不同的状态编码方式、不同的去重策略和剪枝手段,算法性能可能相差几十倍。但如果不亲自动手去写、去压测、去调参数,这些经验只会一直停留在概念层面。
最后分享一个小技巧。我在排查求解器是否正确时,经常用“倒序走法”来验证。具体做法是:拿到一个经过求解器计算的解法路径,从目标状态开始按相反顺序执行每一步反向操作,看是否能准确回到初始状态。如果中间某一步恢复不了,那说明生成路径的逻辑里肯定有 bug。这个方法简单粗暴,但比肉眼检查每一步状态高效得多。
如果你正在准备写自己的求解器,建议从 3 试管、2 颜色、容量 3 这种小规模开始测试,把每个功能模块单独验证一遍,再逐步扩大试管数量和颜色种类。等到你亲眼看到代码把十几步才能完成的关卡瞬间算出最优解时,那种感觉还是挺有成就感的。
本文还有配套的精品资源,点击获取