华容道:先写判定,再写动画

滑块游戏的难点不在棋盘画得多好看,而在「这一块能不能挪过去」。把 4×5 的棋盘压成占位表,判定只剩三行;最少步数用 BFS,别用 A*。

华容道只有 20 个格子、10 块棋子,但真正难写的地方不是布局,是判定:一块棋子在某个方向能不能动一格。写错了手感立刻不对——点一下没反应,或者两块棋子叠在一起。

棋盘是 20 个格子,不是 20 个对象

第一版我用「每格记住属于哪块棋子」的二维数组,移动时先清空旧位置再写新位置。问题是:一块 2×2 的棋子占四格,每次移动要写四处、清四处,还得自己保证不会写到别人的格子上。

换一种表示就简单了:棋子只记左上角坐标和宽高,判定时现算占位。

华容道 4×5 棋盘与移动判定:目标矩形在界内、且没有别的棋子重叠,两步都成立才允许移动

图:判定只有两步,状态去重则把棋子按 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 的结果渲染成位移。所以动画还没写,逻辑就能在自检里跑——现在自检里有四条断言专门测华容道:开局不通关、曹操向下被关羽挡住、卒能下移、移动后坐标变了。

先写数据,再写手感。反过来会很痛苦:动画写完才发现判定是错的,改起来要同时动两处。

← 返回文章列表

评论

…