Phaser 六边形策略地图:坐标、寻路、范围预览和点击命中要先定规则

从轴向坐标、格子选中、移动范围、攻击范围、寻路缓存和 Phaser 表现层拆分,讲清六边形策略地图的可落地实现。

为什么要把它当成系统来做

一款小队战术游戏的第一张教学关里,玩家控制三名角色穿过港口仓库。角色移动不是普通矩形瓦片,而是六边形格子:近战要绕到侧翼,狙击手要找高地,工程师要在两回合内拆掉警报器。

如果只把六边形画成一张贴图,点击、寻路、范围判断和遮挡都会变成临时判断。更稳的方式是先建立可测试的网格模型,再让 Phaser 负责把模型结果画出来。 本文不把它写成一个一次性 Demo,而是按可上线、可维护的小系统拆开。重点不是堆 API,而是回答几个真实问题:数据从哪里来,谁有权修改状态,失败时玩家看到什么,调试时程序能看到什么,内容增加后系统还能不能承受。

核心架构

flowchart TD
  Ne78ea9e5ae["玩家点击屏幕"] --> N5363726565["ScreenToHex"]
  N5363726565["ScreenToHex"] --> N4865784d61["HexMapModel"]
  N4865784d61["HexMapModel"] --> N52616e6765["RangeSolver"]
  N52616e6765["RangeSolver"] --> N4153746172["AStarPathfinder"]
  N4153746172["AStarPathfinder"] --> N5072657669["PreviewRenderer"]
  N5072657669["PreviewRenderer"] --> N5068617365["Phaser Scene"]
  N5068617365["Phaser Scene"] --> Ne8b083e8af["调试覆盖层"]

这张图的关键,是把 HexMapModel、AxialCoord、RangeSolver、AStarPathfinder、SelectionLayer、MovePreview 放在单向流里。玩家输入或系统 tick 进入核心模型,模型产出结果,Phaser 再把结果转成动画、粒子、声音和界面。不要让显示对象反向决定规则。只要核心模型能在没有 Canvas 的环境中运行,就能写测试、做编辑器预览,也能在以后接入服务端校验或云存档。

坐标系统要统一

六边形地图最常见的事故,是渲染层用 offset 坐标,规则层用 cube 坐标,关卡编辑器又导出 row 和 col。上线前看不出问题,等到角色跨越地图边界、技能需要画环形范围、AI 需要估算距离时,三个坐标体系开始互相转换,bug 会变得很难复现。建议内部统一使用 axial 坐标 q、r,距离计算临时转 cube,导入导出时才处理 offset。Phaser 的 Sprite 坐标只作为显示结果,不参与规则判断。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

点击命中不能靠贴图透明度

六边形格子看起来有斜边,很多人会用图片透明区域或近似矩形命中。这样在手机上会出现点击边界错位,尤其镜头缩放后更明显。更可靠的做法是先把屏幕坐标转换为世界坐标,再按 hex 尺寸换算到 fractional axial 坐标,最后 round 到最近格。这个函数应该能单独测试,输入若干边界点,输出稳定的格子编号。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

移动范围和路径不是一回事

移动范围只回答角色在行动点数内能到哪些格子,路径则回答去某个格子应该怎么走。范围求解可以用带成本的广度优先,考虑泥地、门、敌方控制区和临时障碍;路径可以在玩家真正选择目标格时再跑 A*。不要为了画范围对每个格子跑一次完整寻路,那会让大地图的回合开始阶段明显卡顿。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

范围预览要能解释

玩家把鼠标停在技能上时,希望看到红色攻击范围、黄色危险区、蓝色移动区。三者应该来自同一个规则服务,而不是三个 UI 脚本各算各的。预览层只接收格子集合与颜色优先级,若移动区和危险区重叠,由规则层给出合成结果。这样策划调技能半径时,调试面板和正式 UI 会同时更新。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

遮挡与高度先做轻量版

战棋项目经常想在第一版就做完整视线遮挡,但成本很高。一个实用起点是把每个格子记录 height、blockMove、blockSight 三个字段。远程攻击先用采样线段经过的格子判断 blockSight,高低差只影响命中修正。等核心循环稳定后,再把掩体方向、半身遮挡和技能穿透加入同一条查询管线。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

镜头缩放会放大手感问题

六边形地图一般支持拖拽和平移。拖拽结束时不要立刻触发点击,必须用按下到抬起的移动距离、时间和指针数量过滤。移动端还要区分双指缩放和单指选择。建议输入层先产出 Tap、Drag、Pinch 这样的意图事件,地图层再消费,不要让每个格子 Sprite 自己监听 pointerdown。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

调试工具比炫酷贴图更早需要

开发模式下至少显示 q、r、移动成本、是否阻挡、当前范围来源和路径父节点。六边形项目的 bug 往往不是画错,而是某个格子成本不对或导入数据少了一列。把这些信息放在一个可开关的 Graphics 图层里,内容团队在编辑器和运行时看到的是同一组数据,沟通成本会低很多。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

TypeScript 实现骨架

type Hex = { q: number; r: number };
type Tile = { cost: number; blockMove?: boolean; blockSight?: boolean };
const dirs: Hex[] = [
  { q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 },
  { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 },
];
function key(h: Hex) { return `${h.q},${h.r}`; }
function add(a: Hex, b: Hex): Hex { return { q: a.q + b.q, r: a.r + b.r }; }
function distance(a: Hex, b: Hex) {
  const aq = a.q, ar = a.r, as = -aq - ar;
  const bq = b.q, br = b.r, bs = -bq - br;
  return Math.max(Math.abs(aq - bq), Math.abs(ar - br), Math.abs(as - bs));
}
function reachable(start: Hex, budget: number, tiles: Map<string, Tile>) {
  const frontier: Array<{ hex: Hex; spent: number }> = [{ hex: start, spent: 0 }];
  const best = new Map<string, number>([[key(start), 0]]);
  while (frontier.length) {
    const current = frontier.shift()!;
    for (const dir of dirs) {
      const next = add(current.hex, dir);
      const tile = tiles.get(key(next));
      if (!tile || tile.blockMove) continue;
      const spent = current.spent + tile.cost;
      if (spent > budget) continue;
      const old = best.get(key(next));
      if (old === undefined || spent < old) { best.set(key(next), spent); frontier.push({ hex: next, spent }); }
    }
  }
  return best;
}

这段代码只是骨架,真正项目里还要加事件派发、错误码、配置校验和日志。但骨架已经表达了方向:核心概念是普通 TypeScript 对象,Phaser 类型只出现在输入适配或表现需要的地方。若你发现某个函数越来越依赖 Scene、Camera 或 Sprite,就应该停下来判断它是不是被放错层了。

落地步骤

  1. 第一,确认 HexMapModel 的输入输出是否是纯数据。若需要 Phaser.GameObjects 才能计算结果,说明边界还没有切开。
  2. 第二,给 AxialCoord 或同等级的核心概念写三个最小样例:正常路径、边界路径、失败路径。样例要能在没有浏览器画面的情况下运行。
  3. 第三,把 UI 上每个可点击动作都映射成明确意图,不要让按钮直接修改深层状态。意图里带 requestId,便于防重复和追踪。
  4. 第四,失败反馈要比成功反馈更早接入。成功时玩家通常愿意接受,失败时才会质疑系统是否可靠。
  5. 第五,内容配置要有默认值和校验脚本。字段缺失时宁可在启动时报错,也不要在玩家操作到一半才静默失败。
  6. 第六,性能指标要提前量化:每帧最多处理多少对象、单次刷新允许多少毫秒、低端机是否需要降级显示。

常见坑

  • 最容易踩的坑,是让表现层过早成为事实来源。比如动画播完才算成功、按钮亮着就代表可用、某个 Sprite 存在就说明状态存在。这些判断在演示机上没问题,一到跳过、暂停、断线、切场景和重连就会变成隐性故障。
  • 第二个坑是只为第一关写逻辑。第一关对象少、路径短、输入慢,任何写法都像是正确的。等内容增加到几十张地图、几百个配置和各种活动修正时,临时判断会互相覆盖。写系统时要假设它会被复用、被误用、被配置错。
  • 第三个坑是没有留下证据。玩家反馈“刚才没生效”时,如果没有事件日志、状态快照或 requestId,只能靠猜。哪怕是单机项目,也可以保留最近 50 条关键事件,开发包里导出文本,定位速度会快很多。

项目里的验证方式

把这套系统放进 Phaser 项目时,我会先建一个不依赖 Scene 的核心目录,例如 src/gameplay/hex-grid-strategy-map。里面只放模型、求解器、状态机和测试夹具。Scene 只负责输入适配、对象池、摄像机、音效和 UI。这个边界看似多写几行代码,但它换来的是可测试、可回放和可迁移。等项目进入内容生产阶段,最值钱的不是某个特效多漂亮,而是当策划说某个状态不对时,程序能在五分钟内复现并解释。

数据格式要尽量像内容团队会填写的表,而不是像程序临时拼出来的对象。每个 id 都要稳定,每个状态都要能序列化,每个失败原因都要有明确枚举。Phaser 的优势是快速把反馈做出来,但反馈越快,越容易掩盖规则层的混乱。先把规则层写清楚,再接动画,后续加新模式、新活动或新平台才不会反复拆墙。

在调试阶段,建议给这个系统加一个小面板:显示当前输入、核心状态、最近事件、最后一次失败原因和关键耗时。面板不需要好看,但要准确。很多客户端问题在日志里只是一句 undefined,在调试面板里却能看到完整链路。尤其是多人、存档、复杂 UI 或长时间运行的玩法,调试可见性直接决定维护成本。

最后检查

做完第一版后,不要只看一次演示是否顺滑。至少准备三组数据:一组正常流程,一组边界流程,一组故意配置错误的流程。正常流程证明体验成立,边界流程证明状态不会漂移,错误流程证明系统会给出可理解的失败原因。六边形策略地图:坐标、寻路、范围预览和点击命中要先定规则 的质量不取决于第一眼多热闹,而取决于玩家反复操作、内容不断扩张、版本持续迭代时,它还能保持清楚、稳定和可解释。

继续阅读

探索更多技术文章

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

全部文章 返回首页