华容道:先写判定,再写动画
滑块游戏的难点不在棋盘画得多好看,而在「这一块能不能挪过去」。把 4×5 的棋盘压成占位表,判定只剩三行;最少步数用 BFS,别用 A*。
华容道只有 20 个格子、10 块棋子,但真正难写的地方不是布局,是判定:一块棋子在某个方向能不能动一格。写错了手感立刻不对——点一下没反应,或者两块棋子叠在一起。
棋盘是 20 个格子,不是 20 个对象
第一版我用「每格记住属于哪块棋子」的二维数组,移动时先清空旧位置再写新位置。问题是:一块 2×2 的棋子占四格,每次移动要写四处、清四处,还得自己保证不会写到别人的格子上。
换一种表示就简单了:棋子只记左上角坐标和宽高,判定时现算占位。

图:判定只有两步,状态去重则把棋子按 id 排序拼成字符串。
function occupies(block: Block, x: number, y: number): boolean {
return x >= block.x && x < block.x + block.w
&& y >= block.y && y < block.y + block.h;
}
判定一块棋子能不能走,只需要两件事:目标矩形在不在棋盘内,以及目标矩形里有没有别的棋子。
export function canMove(blocks: Block[], id: string, dx: number, dy: number): boolean {
const block = blocks.find((item) => item.id === id);
if (!block) return false;
const nx = block.x + dx;
const ny = block.y + dy;
if (nx < 0 || ny < 0 || nx + block.w > W || ny + block.h > H) return false;
return !blocks.some((other) =>
other.id !== id && overlaps(other, nx, ny, block.w, block.h),
);
}
「重叠」比「逐格检查」好写,也不依赖棋子形状——将来加一块 3×1 的长条,这段代码不用改。
状态去重:把棋盘压成一个字符串
如果只是给人玩,判定写完就够了。但我还想知道「最少几步能出关」,那就得搜状态空间。
华容道的合法状态大约十万级,用 BFS 完全跑得动。关键是去重:同样的棋局不能因为棋子数组顺序不同就算两个状态。
function keyOf(blocks: Block[]): string {
return blocks
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.map((block) => block.id + ':' + block.x + ',' + block.y)
.join('|');
}
按 id 排序后拼字符串,比序列化整个对象便宜,也比哈希好调试——出问题时能直接读出局面。
为什么不用 A*
A* 看起来更聪明,但华容道的启发式很难写得可靠:目标是把曹操挪到出口,中间要让开一条通道,这个「让路成本」估不准。估高了会漏解,估低了等于 BFS。
BFS 在这个规模下几十毫秒出结果,而且步数一定最优。为了一个玩家感觉不到的优化,去引入一个可能算错的启发式,不划算。
动画是最后一步
判定与搜索都是纯函数,不碰 DOM。动画只做一件事:把 moveBlock 的结果渲染成位移。所以动画还没写,逻辑就能在自检里跑——现在自检里有四条断言专门测华容道:开局不通关、曹操向下被关羽挡住、卒能下移、移动后坐标变了。
先写数据,再写手感。反过来会很痛苦:动画写完才发现判定是错的,改起来要同时动两处。

评论
…