林子豪的 PKM
← 返回 Blog

Hex Grid 的坐标是怎么工作的?

从 War Chest 的棋盘出发,用 cube coordinates 的三维切面理解 axial coordinates、六个邻居以及 hex distance。

6 min read系列 · game-ai
game-developmenthex-gridmathematicsvisualization

<!-- vault refs: degree of freedom vector space game tree web 可视化技术栈梳理 -->

我在 Knowledge Base 里实现 War Chest 时,棋盘上的一个位置被写成:

[q r]

例如 [0 0][1 -1][-2 3]

这看起来和普通的 [x y] 差不多。但一旦继续追问,就会遇到一串不那么直观的问题:

- qr 分别指向哪里?

- 一个格子的六个邻居如何计算?

- 为什么介绍 axial coordinates 时,经常又冒出第三个坐标 s

- 为什么三个坐标满足 q + r + s = 0

- 明明棋盘是二维的,为什么要把它放进三维空间理解?

这篇文章从这些问题出发,建立一套能够直接用于 War Chest 规则的 hex grid 坐标模型。

Hex grid 的困难不是画六边形

方格棋盘天然适合 row/column 或 x/y coordinate。一个位置 [x y] 的四个邻居可以写成:

[x+1 y]
[x-1 y]
[x y+1]
[x y-1]

Hex grid 有六个邻居。如果把它强行塞进二维数组,相邻位置会依赖当前行或列的奇偶性。以 offset coordinates 为例,同样是“右上”,在奇数行和偶数行可能要使用不同公式。

这并不是不能做,但它把棋盘的几何关系变成了奇偶分支。移动、攻击范围、距离和寻路都会反复遇到这种特殊情况。

War Chest 因此没有把棋盘保存为 row-major matrix,而是保存为一组合法的 axial coordinates:

{:board/cells #{[0 -3] [1 -3] [2 -3] ...}
 :board/controls #{[3 -2] [1 -1] ...}
 :board/home {:red #{[1 2] [-2 3]}
              :blue #{[2 -3] [-1 -2]}}}

棋盘可以是不规则形状。判断一个位置是否属于棋盘,只需检查它是否在 :board/cells 集合中。

先不要把 q 和 r 当成 x 和 y

War Chest 使用 flat-top hex。当前 SVG 投影是:

x=1.5q
y=\sqrt{3}\left(r+\frac{q}{2}\right)

因此固定 q 时,x 不变:相同 q 的格子在画面中组成一条垂直列。但这不意味着 +q 是垂直方向。

[0 0] 变成 [1 0] 时,xy 都会增加,所以它在屏幕上向右下移动。真正垂直向下的是 [0 0] → [0 1],也就是 +r

当前投影下,六个方向名、相邻变化和屏幕方向是:

这里有一个重要分层:

[q r] 描述格子之间的逻辑关系;[x y] 描述它最后画在屏幕上的位置。

改变六边形大小、棋盘旋转角度或 SVG viewBox,不应该改变移动和攻击规则。

为什么二维棋盘会出现三个坐标?

Hex grid 有三组彼此对称的网格方向。只用两个轴存储最简洁,但三个轴能让六个方向拥有完全对称的表达。

我们先允许 qrs 独立取整数。这样得到的不是 hex grid,而是一个三维整数点阵。然后只保留满足下面约束的点:

q+r+s=0

这些点位于三维空间中的一张二维平面上。

q + r + s = 0 不是六边形从外部强加给我们的自然定律,而是一种对三个方向一视同仁的坐标约定。系数都取 1,让三条轴保持对称;常数取 0,是因为我们把原点定义成 [0 0 0]q + r + s = c 也会得到一张平行的二维平面,但只有 c = 0 穿过我们选择的原点。

下面的交互图分四步展示这个过程。左侧 Three.js 场景可以拖动旋转;右侧同时显示平面投影和当前坐标。

{:title "把三维整数空间切成 Hex Grid"
 :steps [{:mode :space
          :title "三个独立坐标"
          :body "q、r、s 都能独立变化时,整数点填满三维空间。此时还没有 hex grid。"}
         {:mode :plane
          :title "只保留 q + r + s = 0"
          :body "约束去掉一个自由度:三个数字仍然存在,但只有两个能够独立选择。剩下的整数点组成二维 hex grid。"}
         {:mode :illegal
          :title "只让 q + 1"
          :body "从 [0 0 0] 到 [1 0 0] 后,坐标和变成 1。红点离开约束平面,因此它不是一个合法 hex coordinate。"}
         {:mode :neighbor
          :title "让 s - 1 进行补偿"
          :body "[1 0 -1] 的坐标和重新变成 0。它位于平面上,并且是距离原点最近的六个合法整数点之一。"}]}

所谓“三维空间中的二维平面”,并不是说 War Chest 是一个三维游戏。它表达的是自由度:

- 三个独立数字有三个自由度,会填满三维空间;

- 加上 q + r + s = 0 后,任意两个坐标都会决定第三个;

- 因此只剩两个自由度,所有合法点都落在一张二维平面上。

这和在三维坐标中用 z = 0 表示普通二维平面类似。区别只是这里的平面是倾斜的:q + r + s = 0

为什么移动到邻居时是“一加一减”?

从一个合法坐标移动到另一个合法坐标,移动前后都必须满足:

q+r+s=0

把移动量写成 ΔqΔrΔs,就能得到:

\Delta q+\Delta r+\Delta s=0

如果只让 q + 1

[0 0 0] → [1 0 0]

变化量是 [1 0 0],它们的和是 1。新点离开了平面。

为了继续留在平面上,另外两个坐标必须提供 -1 的补偿。最小的整数补偿方式,是其中一个坐标 -1,另一个保持不变:

[0 0 0] → [1 -1 0]
[0 0 0] → [1 0 -1]

把三个坐标轮流选择,就得到六种最小非零变化:

[+1 -1  0]
[+1  0 -1]
[ 0 +1 -1]
[-1 +1  0]
[-1  0 +1]
[ 0 -1 +1]

这六个点正好是一个格子的六个邻居。

这里的“必须一加一减”需要说得严谨一些。只要变化量总和为 0,移动就仍然位于平面上,例如:

[0 0 0] → [2 -1 -1]

也是合法 cube coordinate。但它不是相邻格。只有当目标是最近的另一个合法整数点时,变化才必然是一个 +1、一个 -1、一个 0

所以六个邻居来自三个条件的交集:

1. 目标仍在 q + r + s = 0 平面上;

2. 三个坐标仍然是整数;

3. 目标是最近的非原点格点。

Axial coordinates 只是省略了可以推导的 s

由于:

s=-q-r

存储时没有必要保存第三个数字。War Chest 只保存 [q r],需要计算距离时再恢复 s

(defn axial-s [[q r]]
  (- (+ q r)))

例如:

Axial: [-2 3]
Cube:  [-2 3 -1]

因为:

-2+3-1=0

因此 axial coordinates 和 cube coordinates 不是两套互不相关的系统。更准确的理解是:

Axial coordinates 是 cube coordinates 去掉冗余的第三个坐标后的存储形式。

规则层使用简洁的 [q r],推导距离和直线关系时仍然可以借用三个轴的对称性。

Hex distance 从哪里来?

一次相邻移动会让两个 cube coordinate 各变化 1,第三个不变。例如:

[0 0 0] → [1 0 -1]

三个绝对变化量之和是 2,但只走了一步。因此两个位置之间的距离是:

d(a,b)=\frac{|\Delta q|+|\Delta r|+|\Delta s|}{2}

War Chest 当前实现直接使用这个公式:

(defn axial-distance
  [[q1 r1 :as a] [q2 r2 :as b]]
  (let [dq (- q1 q2)
        dr (- r1 r2)
        ds (- (axial-s a) (axial-s b))]
    (/ (+ (abs-num dq)
          (abs-num dr)
          (abs-num ds))
       2)))

这使游戏规则可以直接使用几何语言:

- 普通单位移动到 distance 1;

- Archer 攻击 exact distance 2;

- Light Cavalry 移动到 distance 1 或 2;

- Marshal 选择 distance 2 的友方单位。

不需要判断当前位于奇数行还是偶数行。

从坐标模型回到 War Chest

现在可以把整个数据流分成三层:

Cube coordinate [q r s]
    用于理解三个对称方向与距离
              ↓ 省略 s = -q-r
Axial coordinate [q r]
    用于规则、棋盘集合、棋子位置和存档
              ↓ axial->point
Screen point {x y}
    用于 SVG polygon、文字和点击区域

三层分别回答不同问题:

- Cube:为什么 hex grid 有六个对称邻居?

- Axial:游戏状态如何简洁、稳定地保存位置?

- Screen:这些位置最后画在哪里?

这也是为什么在 War Chest 中,UI 只在渲染边界把 [q r] 转成 SVG point。棋盘规则不应该知道像素,也不应该因为旋转了棋盘就改变移动结果。

下一步可以沿着同一个模型继续推导两件事:如何判断多个 hex 是否位于同一直线上,以及如何枚举距离中心不超过 n 的范围。这两件事会直接进入 Crossbow、Lancer、Archer 等单位的规则。