Phaser 格子解谜规则引擎:撤销、重放和关卡验证要从第一天设计

讲解用 Phaser 实现推箱子、机关、连线等格子解谜时,如何设计规则引擎、命令历史、撤销重放和关卡可解性校验。

解谜游戏最怕“看起来能动”

格子解谜用 Phaser 做原型非常快:一个二维数组表示地图,方向键移动角色,撞到箱子就推动,踩到机关就开门。几天后,需求开始变复杂:玩家要撤销,机关要延迟触发,冰面会滑行,箱子有重量,传送门能改变方向,关卡编辑器要验证是否可解,提示系统要回放最短路径。这个时候如果所有规则都写在 Player.update() 里,项目会迅速失控。解谜游戏的核心不是移动动画,而是一个可预测的规则引擎。

规则引擎的目标是:给定当前状态和一个输入命令,计算下一个状态,并产出表现事件。它不关心 Phaser Sprite,也不播放音效。Phaser 负责把状态变化画出来。这个边界能带来三个好处:撤销很容易,因为状态和命令可记录;重放很稳定,因为同样命令序列得到同样结果;关卡验证可以在没有 Canvas 的环境里跑。对于解谜游戏,这些能力不是锦上添花,而是内容生产的基础设施。

状态要能完整描述棋盘

一个格子解谜关卡通常包含静态层和动态层。静态层有地面、墙、目标点、固定机关;动态层有玩家、箱子、门的开关状态、临时机关状态、回合计数。不要把动态信息藏在 Sprite 属性里。比如门是否打开应该在模型里,Sprite 只是根据门状态显示不同贴图。否则撤销时你要同时恢复数组和 Sprite,很容易漏。

状态结构可以保持朴素:地图宽高、静态 tile 数组、实体列表、变量表。实体有 id、type、position、flags。规则执行时先复制或结构化更新状态,再返回新状态。关卡小的话深拷贝足够;关卡大时可以做局部 copy-on-write。不要为了过早优化牺牲可读性,因为解谜 bug 的成本通常高于一次小数组复制。

flowchart TD
  A["输入命令:上/下/左/右/等待"] --> B["RuleEngine 读取当前 BoardState"]
  B --> C["RulePipeline:碰撞、推动、机关、胜负"]
  C --> D["NextState:新的棋盘状态"]
  C --> E["VisualEvents:移动、撞墙、开门、粒子"]
  D --> F["HistoryStack:用于撤销和重放"]
  E --> G["PhaserScene:按事件播放动画"]
  F --> H["Solver / Hint:复用命令历史"]

命令是撤销的钥匙

玩家每一次操作都应该变成命令,比如 { type: "move", dir: "left" }。规则引擎消费命令并返回结果。撤销可以有两种实现:保存每一步完整状态,或者保存反向补丁。小型解谜建议先保存完整状态快照,因为简单可靠。每步状态通常不大,1000 步也只是可接受的内存。等关卡和实体数量真的变大,再考虑补丁。

命令历史还可以用于重放和提示。玩家通关后,系统可以回放他的路径;提示系统可以在当前状态上运行搜索,找到下一步建议;QA 可以把一串命令发给开发复现 bug。如果输入直接驱动 Sprite,没有命令层,这些能力都要重写。

规则管线要有固定顺序

复杂格子规则最容易出问题的是触发顺序。玩家推箱子到按钮上,是先开门再检查箱子是否被门挡住,还是先移动完成再开门?冰面滑行过程中经过按钮会不会触发?两个箱子同时压住同一个机关怎么算?这些不是代码细节,而是游戏规则。必须写成固定管线,并在文档和测试中保持一致。

一个常见管线是:验证输入、尝试移动玩家、解析推动链、应用实体位移、触发进入格子的机关、更新持续效果、检查胜负。某些游戏需要“同时结算”,比如激光和移动平台,则可以把规则拆成阶段。每个阶段只修改自己负责的状态,避免一个规则在半路直接调用另一个规则。管线清晰,后续加新机关才不会影响旧关卡。

Phaser 动画不能反过来决定状态

在表现层,玩家按下方向键后,可以先让规则引擎立即算出结果,再让 Phaser 播放移动动画。动画期间锁定输入或缓存下一步输入。不要等 Sprite tween 完成后才修改模型,因为动画可能被跳过、暂停或销毁。规则已经发生,动画只是让玩家看懂。若移动失败,规则结果可以返回 bump 事件,Scene 播放轻微撞墙动画,但状态不变。

这种方式对撤销也很友好。撤销时从历史栈恢复上一个状态,然后生成一组反向视觉事件,或者直接重绘棋盘。移动端小游戏通常不需要每次撤销都播放完整反向动画,快速可靠比花哨更重要。

一个最小规则执行器

下面的代码展示了移动与推动的骨架。真实游戏会有更多规则,但接口应该保持“输入状态和命令,输出结果”。

type Dir = "up" | "down" | "left" | "right";
type EntityType = "player" | "box" | "door";

interface Pos { x: number; y: number }
interface Entity { id: string; type: EntityType; pos: Pos; blocking: boolean }
interface BoardState { width: number; height: number; walls: Set<string>; entities: Entity[] }
interface RuleResult { state: BoardState; events: Array<{ type: string; id?: string; to?: Pos }> }

const delta: Record<Dir, Pos> = {
  up: { x: 0, y: -1 },
  down: { x: 0, y: 1 },
  left: { x: -1, y: 0 },
  right: { x: 1, y: 0 },
};

export function applyMove(state: BoardState, dir: Dir): RuleResult {
  const player = state.entities.find((item) => item.type === "player");
  if (!player) throw new Error("Missing player");
  const step = delta[dir];
  const next = { x: player.pos.x + step.x, y: player.pos.y + step.y };
  const key = `${next.x},${next.y}`;
  if (state.walls.has(key)) return { state, events: [{ type: "bump", id: player.id }] };

  const target = state.entities.find((item) => item.pos.x === next.x && item.pos.y === next.y);
  if (target?.type === "box") {
    const boxNext = { x: target.pos.x + step.x, y: target.pos.y + step.y };
    const boxKey = `${boxNext.x},${boxNext.y}`;
    const blocked = state.walls.has(boxKey) || state.entities.some((item) => item.blocking && item.pos.x === boxNext.x && item.pos.y === boxNext.y);
    if (blocked) return { state, events: [{ type: "bump", id: player.id }] };
    const entities = state.entities.map((item) =>
      item.id === player.id ? { ...item, pos: next } : item.id === target.id ? { ...item, pos: boxNext } : item,
    );
    return { state: { ...state, entities }, events: [{ type: "move", id: target.id, to: boxNext }, { type: "move", id: player.id, to: next }] };
  }

  const entities = state.entities.map((item) => item.id === player.id ? { ...item, pos: next } : item);
  return { state: { ...state, entities }, events: [{ type: "move", id: player.id, to: next }] };
}

这段代码最重要的不是覆盖所有规则,而是没有直接碰 Phaser。你可以给它写测试:墙不能穿过,箱子不能推入墙,玩家移动后位置正确。后续加传送门、冰面和机关时,也是在这个层面扩展,而不是在 Sprite tween 回调里补判断。

关卡验证要进入内容流程

解谜关卡不是画出来就能发布。至少要验证基础合法性:玩家数量为 1,目标点数量合理,所有动态实体在地图内,没有实体出生在墙里,所有引用的机关 id 存在。更进一步,可以做可解性搜索。推箱子类游戏状态空间可能很大,但对小关卡,用 BFS 或 A* 找到一个解足够实用。找不到解不一定说明关卡不可解,可能搜索深度不够,但它能筛掉大量明显错误。

验证工具还可以输出最短步数、箱子推动次数、关键路径长度和死锁位置。策划用这些指标评估难度,比单纯试玩更稳定。比如一个关卡最短 12 步但有 20 个干扰格,可能是观察题;另一个最短 80 步且没有分段目标,移动端玩家可能太累。

提示系统不要直接给答案

很多解谜游戏会提供提示。提示不一定要展示完整解法,可以只给下一步方向、指出目标箱子或高亮一个机关。因为我们有命令历史和规则引擎,提示可以从当前状态运行搜索,得到一条路径,然后取第一步。要注意,如果玩家已经偏离最优路径,提示应基于当前状态,而不是关卡初始状态。否则提示会让玩家走向死路。

提示也要消耗资源或计入评分时,必须在模型层记录,不要只在 UI 上扣图标。玩家撤销不应该撤销提示消耗,除非规则明确允许。评分系统同样要基于命令历史、撤销次数、提示次数和用时,而不是只看通关瞬间。

上线前检查清单

确认棋盘状态完整描述所有动态信息;确认输入被记录为命令;确认撤销恢复的是状态而不是只移动 Sprite;确认规则执行顺序写成管线;确认 Phaser 动画只消费 visual events;确认关卡导入时做基础合法性检查;确认固定命令序列可以重放同一结果;确认提示系统基于当前状态;确认开发模式能显示实体 id、坐标、机关状态和历史栈长度;确认内容更新后旧关卡解法不会被悄悄破坏。

解谜游戏的快乐来自“我理解了规则,所以我赢了”。如果规则系统本身不稳定,玩家的思考就没有意义。Phaser 很适合做清晰的格子表现,但规则引擎必须站在 Phaser 之外。先让状态和命令可信,再去做漂亮的机关动画。

编辑器预览要复用同一套规则

如果项目有内置关卡编辑器,预览按钮一定要调用正式 RuleEngine,而不是编辑器自己写一套简化移动逻辑。很多内容事故来自“编辑器里能过,游戏里不能过”。编辑器可以提供额外辅助,比如显示死锁格、目标覆盖情况、最短解长度,但这些都应该基于正式规则计算。发布关卡时,把规则版本写入关卡元数据。以后规则调整导致旧关卡变化时,至少能知道哪些内容需要重新验证。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页