林子豪的 PKM
← 返回 Blog

用 Hex Grid Primitives 组合 War Chest 规则

把距离、直线、路径与阻挡拆成可组合的几何 primitive,再用它们表达弓箭手、弩手等 War Chest 单位规则。

25 min read系列 · war-chest
game-developmenthex-gridclojurewar-chest

<!-- vault refs: hex-grid-coordinates vector space game tree clojure%2Fset java field -->

在上一篇 hex-grid-coordinates 中,我把 War Chest 的 hex grid 分成了三种表示:

Cube coordinate [q r s]  →  理解几何
Axial coordinate [q r]   →  保存游戏状态
Screen point {x y}       →  绘制棋盘

有了坐标,还没有真正得到游戏规则。接下来需要回答的是:

- 两个格子相距多远?

- 两个格子是否位于 hex grid 的同一条直线上?

- 如果在同一直线上,它们之间有哪些格子?

- 中间是否有棋子阻挡?

- 为什么弓箭手和弩手可以共享这些计算,却得到不同的战术射击范围?

一种直接的写法,是为每个单位写一段完整判断:

(case unit
  :archer ...
  :crossbow ...
  :lancer ...)

但这样很快会把几何、棋盘状态和单位规则混在一起。更适合继续扩展 War Chest 的方式,是先实现一组小的 primitive,然后把单位规则写成 primitive 的组合。

这里的 primitive 不是 UI component,也不是某个单位的能力。它是一条更基础、可以被多条规则重复使用的事实,例如:

distance(a, b) = 2
straight?(a, b)
cells-between(a, b) = [...]
line-clear?(state, a, b)
enemy?(state, side, b)

单位规则只负责组合这些事实。

第一层 primitive:坐标与向量

War Chest 在规则层使用 axial coordinate:

[q r]

两个位置相减,会得到从 from 指向 to 的向量:

(defn pos-delta
  [[from-q from-r] [to-q to-r]]
  [(- to-q from-q)
   (- to-r from-r)])

例如:

from = [0 0]
to   = [2 0]
delta = [2 0]

这个 delta 不再是“棋盘上的一个位置”,而是一次位移。这个区别很重要:位置可以用来查询棋子,位移可以用来判断距离和方向。

当前 flat-top 投影的六个最小方向向量是:

{:n  [0 -1]
 :ne [1 -1]
 :se [1 0]
 :s  [0 1]
 :sw [-1 1]
 :nw [-1 0]}

它们是其他几何 primitive 的共同基础。

距离:最短需要走多少步

Axial coordinate [q r] 可以恢复出 cube coordinate 的第三个坐标:

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

于是两个格子的距离可以写成:

(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 dq) (abs dr) (abs ds)) 2)))

它回答的是一个与单位无关的问题:

a 走到 b,最少要经过多少次相邻移动?

因此以下规则都可以复用同一个 distance primitive:

普通移动             distance = 1
普通攻击             distance = 1
队友身边             distance = 1
弓箭手战术射击       distance = 2
弩手战术射击         distance = 2,再叠加直线与无阻挡

弓箭手并不关心目标是否和自己在一条直线上。只要最短距离是 2,就属于它的战术射击范围:

(defn archer-range?
  [from to]
  (= 2 (axial-distance from to)))

所以 [2 0][1 -2] 都可以是从 [0 0] 出发、距离为 2 的目标。前者在一条 hex 直线上,后者不是;对弓箭手来说没有区别。

直线:delta 是否是某个方向向量的整数倍

在方格中,我们习惯用“相同 x”“相同 y”或斜率判断直线。但 hex grid 有三个轴、六个有方向的射线。

从一个格子出发,沿某个方向不断加上同一个 direction vector,会得到一条 hex 直线:

[0 0] + 1 × [1 0] = [1 0]
[0 0] + 2 × [1 0] = [2 0]
[0 0] + 3 × [1 0] = [3 0]

因此,判断 fromto 是否在同一条 hex 直线上,可以转换成:

to - from 是否等于六个方向向量之一乘以某个正整数?

当前 War Chest 只实现了“直线距离恰好为 2”的版本:

(def straight-range-two-deltas
  (set (map #(add-pos % %) direction-vectors)))

(defn straight-range-two?
  [from to]
  (contains? straight-range-two-deltas
             (pos-delta from to)))

它足以回答当前弩手的射程问题,但不能告诉我们方向、长度和中间格。为了支持阻挡,更有用的 primitive 是返回结构化结果:

(direction-and-steps [0 0] [2 0])
;; => {:direction [1 0]
;;     :steps 2}

(direction-and-steps [0 0] [1 -2])
;; => nil

nil 表示不共线;map 同时提供后续枚举路径所需的方向和步数。相比先写 straight?,再重新计算一次方向,这个返回值保留了更多可以复用的信息。

路径:枚举起点与终点之间的格子

知道方向和步数以后,就能沿方向生成一条 ray:

(defn scale-pos
  [[q r] n]
  [(* q n) (* r n)])

(defn cells-between
  [from direction steps]
  (mapv #(add-pos from (scale-pos direction %))
        (range 1 steps)))

例如:

(cells-between [0 0] [1 0] 3)
;; => [[1 0] [2 0]]

这里刻意不包含起点和终点:

- 起点通常是发起行动的棋子,本来就不是空格;

- 终点可能是攻击目标,也不应该要求为空;

- 真正需要检查阻挡的是中间格。

这是一个纯几何 primitive。它只计算坐标,不读取游戏状态,也不需要知道棋子是什么单位。

阻挡:把路径和棋盘占用组合起来

“是否有阻挡”已经不只是几何问题,因为它需要读取棋盘状态:

(defn empty-cell?
  [state pos]
  (and (inside-board? state pos)
       (nil? (piece-at state pos))))

有了 cells-betweenempty-cell?,视线是否畅通可以直接组合出来:

(defn line-clear?
  [state from to]
  (when-let [{:keys [direction steps]}
             (direction-and-steps from to)]
    (every? #(empty-cell? state %)
            (cells-between from direction steps))))

它表达了三个条件:

1. 起点和终点必须共线;

2. 找出它们之间的所有格子;

3. 每个中间格都必须为空。

如果 [0 0] 攻击 [2 0],中间格是 [1 0]。只要 [1 0] 上有任何一方的棋子,line-clear? 就返回 false。

“阻挡”最好不要直接写进弩手的单位分支。Lancer 的冲锋、未来更远距离的射击或其他需要检查路径的能力,都可能复用相同的路径 primitive。

单位规则是 primitive 的组合

拆到这里以后,弓箭手和弩手的差异会变得非常清楚。

先固定 action 概念

在讨论单位能力之前,需要先把普通 action 和单位战术分开:

这里的“默认规则”允许单位明确 override。例如侦察兵改变的是部署位置,但仍然是在执行部署;弓箭手则完全不能执行普通攻击,只能使用自己的射击战术。

六个区域与 coin 循环

War Chest 不只有战场。完整状态需要同时表达六个区域:

初始设置中,每位玩家把一枚皇家硬币(Royal Coin)和每种已启用部队的两枚 coin 放入布袋;每种部队另外两枚 coin 留在预备队。然后各自抽三枚作为第一轮次的手牌。皇家硬币没有对应的场上部队,因此不能部署、移动或攻击,但可以反面弃置来执行通用行动。

                         招募一枚部队 coin
                    ┌────────────────────────┐
                    │                        ▼
┌────────┐  抽取  ┌──────┐  使用手牌  ┌────────┐
│  布袋  │───────▶│ 手牌 │───────────▶│ 弃置区 │
└───▲────┘        └──┬───┘            └───┬────┘
    │                │                    │
    │                │ 部署/强化          │ 布袋不足时洗回
    │                ▼                    │
    │             ┌────────┐              │
    │             │  战场  │              │
    │             └───┬────┘              │
    │                 │ 受击移除          │
    │                 ▼                    │
    │             ┌────────┐              │
    │             │ 阵亡区 │              │
    │             └────────┘              │
    └──────────────────────────────────────┘

┌────────┐
│ 预备队 │───招募──▶ 弃置区
└────────┘

移动、普通攻击和战术等正面行动必须消耗一个与执行部队同兵种的手牌 coin,并把费用放入弃置区。反面弃置则可以使用任意手牌,包括皇家硬币和暂时不想使用的部队 coin,来选择三个通用行动之一:

反面弃置任意手牌
├─ 招募:预备队 coin → 弃置区
├─ 抢先手:记录下一轮次的先手玩家
└─ 过牌:不产生额外效果

招募不会把 coin 直接放进手牌,也不会直接部署。支付的手牌 coin 与被招募的预备队 coin 都进入弃置区;等它们未来被洗入布袋并抽到手里后,才可以继续使用。

回合、轮次与先手

这里需要严格区分两个时间尺度:

一次打出手牌 = 一个回合(turn)
双方各用完三枚手牌 = 一个轮次(round)

当前先手玩家开始轮次,双方交替进行回合。两边都用完三枚手牌后,才一起抽取下一轮次的三枚新手牌。抽取时如果布袋不足三枚,先拿出布袋中剩余的 coin,再把弃置区洗入布袋,继续抽到三枚;阵亡区与预备队不会参与这次洗牌。

“抢先手”只修改下一轮次的先手,不会打乱当前轮次剩余回合的交替顺序。同一轮次只能成功抢一次:一旦某位玩家抢到下一轮先手,本轮另一位玩家不能再次抢回。轮次结束后,新先手成为下一轮第一个行动的玩家,抢先手锁同时重置。

基础版的 16 种部队

在继续拆规则之前,先把需要覆盖的完整部队集合固定下来。现在 16 种部队都已经有交互教学;每实现一个单位,都先判断它能否复用现有 primitive,还是需要增加新的通用概念。

后续不会先为每种部队创建一个彼此隔离的大函数,而是依次回答三个问题:

1. 这个单位的规则由哪些已有 primitive 组成?

2. 哪一部分能力还无法用现有 primitive 表达?

3. 缺少的概念是否足够通用,值得成为新的 primitive?

雇佣兵:招募与场上行动组成一个复合 action

招募雇佣兵仍然先遵守普通招募:反面弃置任意一枚手牌,再把预备队中的一枚雇佣兵 coin 放入弃置区。如果己方雇佣兵已经在场,这次招募还可以让它额外执行一次普通移动、普通攻击或控制。

招募雇佣兵
= 支付任意手牌 coin 到弃置区
AND 雇佣兵 supply coin 到弃置区
OPTIONALLY
  场上雇佣兵普通移动 OR 普通攻击 OR 控制

额外行动不是发动雇佣兵战术,也不再消耗雇佣兵手牌 coin。它直接复用真实的普通移动、普通攻击和控制 primitive,因此攻击骑士等目标限制仍然有效。即使场上还没有雇佣兵,或者场上雇佣兵暂时没有合法行动,玩家仍可只完成普通招募。

下面的雇佣兵站在一个中立控制点上,旁边也有敌人。点击雇佣兵后,可以选择招募附带的移动、攻击或控制;再点击一个目标会结算完整招募。

{:title "雇佣兵:招募时让场上雇佣兵行动"
 :mode :mercenary-recruit
 :focus [2 0]
 :pieces [{:at [2 0] :side :red :unit :mercenary}
          {:at [1 0] :side :blue :unit :crossbow}]}

皇家卫队:皇家硬币是有边界的替代 coin

皇家硬币可以视为皇家卫队 coin,支付皇家卫队的部署、移动、普通攻击和控制。代码中把“coin 能否激活这个棋子”与“coin 能否叠到这个棋子上”拆成两个 primitive:前者允许皇家硬币匹配皇家卫队,后者仍要求 coin 的真实兵种与棋子完全相同。

皇家硬币匹配皇家卫队行动 = true
皇家硬币匹配皇家卫队强化 = false

这个边界避免皇家硬币被永久叠进皇家卫队的 strength。下面点击皇家卫队,绿色与红色来自使用皇家硬币生成的真实普通行动;图例同时保留“不可强化”这一限制。

{:title "皇家卫队:皇家硬币可行动,但不可强化"
 :mode :royal-guard
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :royal-guard}
          {:at [1 0] :side :blue :unit :crossbow}]}

枪骑兵:同方向的 2 格移动与第 3 格攻击

枪骑兵保留距离 1 的普通移动和普通攻击。它的战术则是一个不可拆开的完整计划:先沿某个 hex 方向移动正好两格,再攻击起点同方向第三格的敌人。

枪骑兵战术
= move-to = from + 2 × direction
AND target = from + 3 × direction
AND 第 1 格为空
AND 第 2 格为空
AND 第 3 格是合法敌方目标

因此,没有第三格敌人时不能只移动两格;第 1 格或落点有任意棋子时也不能发动。攻击发生时枪骑兵已经位于第 2 格,所以攻击第 3 格长矛兵会按相邻攻击触发反伤。

下面点击枪骑兵。绿色是普通移动,金色是完整战术存在时的两格落点。点击金色落点后,枪骑兵会先移动,红色则显示同方向第三格必须攻击的敌人。

{:title "枪骑兵:直线移动两格并攻击第三格"
 :mode :lancer-tactic
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :lancer}
          {:at [3 0] :side :blue :unit :crossbow}]}

强化与逐层伤害:部队不是一个布尔值

场上的一支部队实际是一叠同兵种 coin。强化(Bolster)是从手中取出一个与场上己方部队同兵种的 coin,叠到那支部队上。它不要求部队位于己方基地;基地限制属于部署,不属于强化。

合法强化
= 手中 coin 与目标部队同兵种
AND 目标是己方部队

部队 strength = 叠放的 coin 数量

伤害也不是直接把整个部队设为“死亡”,而是每次移除最上面的一层。强化部队受击后只减少一层;只有最后一层也被移除时,棋盘格才变为空。

下面的剑士并不在红方基地上。点击它,会通过真实的 :bolster action 把 strength 从 1 变成 2。

{:title "强化:在任何位置叠加同兵种 coin"
 :mode :bolster
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :swordsman}]}

下面是一个直接使用同一套 War Chest 棋盘绘制 primitive 的交互例子。点击中央的弓箭手,棋盘会用绿色标出普通移动位置,用红色标出战术射击的目标:

{:title "弓箭手:普通移动一格,战术射击距离 2"
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :archer}
          {:at [2 0] :side :blue :unit :crossbow}
          {:at [1 -2] :side :blue :unit :pikeman}]}

弓箭手:普通攻击与战术射击是两件事

(defn archer-tactic-target?
  [from to]
  (= 2 (axial-distance from to)))

它需要:

distance = 2

它不需要:

straight?
line-clear?

弩手:组合距离、直线和无阻挡

下面的弩手面对三个敌人:相邻的敌人可以用普通攻击;正上方距离 2、路径畅通的敌人可以用射击战术;右下方距离 2 的敌人虽然共线,却被中间的敌人挡住。点击弩手后,灰色虚线会标出这个被阻挡的战术目标。

{:title "弩手:直线射击会被中间的棋子阻挡"
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :crossbow}
          {:at [1 0] :side :blue :unit :archer}
          {:at [2 0] :side :blue :unit :pikeman}
          {:at [0 -2] :side :blue :unit :pikeman}]}
(defn crossbow-tactic-target?
  [state from to]
  (and (= 2 (axial-distance from to))
       (line-clear? state from to)))

它表达的是:

普通攻击 = 相邻目标
战术射击 = 距离 2 AND 位于直线 AND 中间没有阻挡

这里的 line-clear? 已经包含共线判断,所以单位层不需要重复调用 straight?。这也是 primitive 设计的一个选择:调用者应能从函数名和返回值看出它已经保证了哪些事实。

队友距离 1:侦察兵、掌旗官与元帅共享的 primitive

这三个单位表面上能力不同,但都需要先回答同一个问题:某个位置距离哪些友方部队恰好为 1?

(defn friendly-positions-at-distance
  [state side from distance]
  ...)

基于这个 primitive,可以组合出三条规则:

侦察兵部署
= 目标格为空
AND 目标格与至少一个队友距离 1

掌旗官战术
= 选择与掌旗官距离 1 的队友
AND 让该队友执行一次普通移动或普通攻击

元帅战术
= 选择与元帅距离 1 的队友
AND 让该队友执行自己的战术

侦察兵 override 的只是部署位置:它不需要使用己方基地,但目标仍然必须为空,而且场上已经有侦察兵时不能再部署第二支。下面点击中央的友方弓箭手,蓝色会标出侦察兵的合法部署格;右侧被敌人占据的格子不会被标出。

{:title "侦察兵:部署在距离友军 1 的空格"
 :mode :scout-deploy
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :archer}
          {:at [1 0] :side :blue :unit :crossbow}]}

掌旗官的战术不是替友军执行“任何能力”,而是严格限定为普通移动或普通攻击。下面先点击中央的掌旗官,金色会标出距离 1 的友军;再点击右侧剑士,绿色和红色会分别显示这支剑士可执行的普通移动与普通攻击。距离 2 的侦察兵不会成为可指挥目标。这里不能使用弓箭手演示普通攻击,因为弓箭手根本没有普通攻击。

{:title "掌旗官:指挥距离 1 的友军移动或攻击"
 :mode :ensign-order
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :standard-bearer}
          {:at [1 0] :side :red :unit :swordsman}
          {:at [0 -2] :side :red :unit :scout}
          {:at [2 0] :side :blue :unit :crossbow}]}

元帅委托的是友军的战术,不是普通行动。这个委托可以继续经过掌旗官:元帅先让相邻掌旗官执行战术,掌旗官再选择自己距离 1 的友军执行普通移动或普通攻击。下面依次点击中央元帅、右侧掌旗官、右上方剑士;最后会看到剑士的普通移动和普通攻击。整条链只消耗元帅的 coin。

{:title "元帅:指挥掌旗官,再由掌旗官指挥友军"
 :mode :marshal-order
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :marshal}
          {:at [1 0] :side :red :unit :standard-bearer}
          {:at [1 -1] :side :red :unit :swordsman}
          {:at [2 -1] :side :blue :unit :crossbow}]}

掌旗官和元帅消耗的是自己的 coin。被命令的友方部队不会额外消耗手牌 coin。元帅只委托友方单位本身的战术,不会把“普通攻击”误当成战术,也不会递归命令另一个元帅。

步兵:两支部队分别行动

默认情况下,同一兵种场上最多只能部署一支部队。步兵 override 了这个数量限制:同一方最多可以同时部署两支步兵;第三次部署仍然不合法。两次部署仍分别遵守普通部署规则,必须落在己方空闲基地。

默认同兵种部署上限 = 1
步兵部署上限 = 2

步兵战术把两支部队视为同一个战术组。每支已部署的步兵都可以各自选择一次普通移动、普通攻击或控制,也可以让其中一支不行动。所有选择共同消耗一枚步兵 coin。

步兵战术
= 对每支场上步兵,至多选择一个:
   普通移动 OR 普通攻击 OR 控制 OR 不行动

执行第二支步兵的行动时,使用第一支行动完成后的棋盘状态,因此前一支的移动或攻击可能为后一支腾出位置。攻击骑士等目标时,仍然必须经过相同的目标限制。

下面点击位于右侧控制点的步兵。金色标出两支都可以行动的步兵;绿色、红色和蓝色分别汇总了这次战术中可选择的移动、攻击与控制。

{:title "步兵:两支部队可以分别移动、攻击或控制"
 :mode :infantry-orders
 :focus [2 0]
 :pieces [{:at [2 0] :side :red :unit :infantry}
          {:at [0 0] :side :red :unit :infantry}
          {:at [1 0] :side :blue :unit :crossbow}]}

元帅指挥步兵:一支接令,两支行动

元帅指挥其他单位时,需要一支友军位于距离 1。对于步兵,这个邻接条件只负责确认整支步兵战术可以被发动:只要两支步兵中的任意一支贴近元帅,另一支即使在棋盘远处,也属于同一个步兵战术组,可以被遥控指挥。

元帅 → 步兵战术
= 至少一支步兵与元帅距离 1
THEN 两支场上步兵都可以分别行动

下面只有右侧步兵贴近元帅,左侧步兵距离很远。点击元帅后,两支都会显示为金色;远处步兵仍然可以攻击旁边敌人,也可以控制脚下的控制点。整段行动只消耗元帅 coin,不再额外消耗步兵 coin。

{:title "元帅:通过一支相邻步兵遥控两支步兵"
 :mode :marshal-infantry
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :marshal}
          {:at [1 0] :side :red :unit :infantry}
          {:at [-2 0] :side :red :unit :infantry}
          {:at [-3 0] :side :blue :unit :crossbow}]}

骑兵:移动一格,然后攻击

骑兵仍然拥有普通移动和普通攻击;它的战术不是取代这两个普通行动,而是把两个 primitive 串成一次复合行动:

骑兵战术
= 选择距离 1 的空格
THEN 把骑兵移动到该格
THEN 攻击新位置距离 1 的敌人

下面先点击骑兵。绿色表示它可以只做普通移动,红色表示它可以留在原地进行普通攻击;金色表示那些“移动过去以后还能继续攻击”的战术落点。点击右侧金色格,棋子会移动过去,并显示战术第二阶段可以攻击的敌人。

{:title "骑兵:移动一格,然后攻击"
 :mode :cavalry-tactic
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :cavalry}
          {:at [0 -1] :side :blue :unit :pikeman}
          {:at [2 0] :side :blue :unit :crossbow}]}

这个战术需要把“移动后的棋盘状态”作为攻击判断的输入。目标是否相邻是从新位置计算,而不是从骑兵原来的位置计算。移动与攻击合起来仍然只消耗一枚骑兵 coin;玩家也可以不使用战术,选择只移动或只攻击。

轻骑兵:沿空闲路径移动距离 2

轻骑兵可以不用战术,只执行一次距离 1 的普通移动。它的战术把目标距离扩展到 2,但不是“跳跃”:从起点到终点必须能拆成连续的两个相邻步,而且中间格与终点都必须为空。

轻骑兵战术移动
= 目标格为空
AND distance(from, target) = 2
AND 存在中间格 via:
    distance(from, via) = 1
    AND distance(via, target) = 1
    AND via 为空

距离 2 的非直线目标通常有两条最短路线。一条中间格被占据时,轻骑兵仍可以沿另一条空闲路线绕行;如果所有两步最短路线都被挡住,就不能移动到该目标。对于正好位于六条直线方向上的距离 2 目标,只有一个中间格,因此中间有部队时不能直接跳过去。

下面点击轻骑兵。绿色是距离 1 的普通移动;金色是有空闲两步路径的战术移动;灰色是落点本身为空,但所有两步路径都被部队挡住的距离 2 目标。

{:title "轻骑兵:移动距离 2,但不能穿过部队"
 :mode :light-cavalry-tactic
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :light-cavalry}
          {:at [1 0] :side :red :unit :pikeman}
          {:at [0 -1] :side :red :unit :swordsman}]}

狂战士:普通攻击之后可以再次攻击

狂战士的能力不是战术,而是普通攻击的可选后续。玩家始终可以只完成第一次普通攻击;如果第一次攻击后狂战士仍在场上,并且还有相邻的合法敌方目标,就可以继续攻击一次,整段行动只消耗一枚狂战士 coin。

狂战士攻击
= 普通攻击一次
OPTIONALLY THEN
  如果狂战士仍在场
  AND 还有相邻合法目标
  THEN 再普通攻击一次

如果狂战士第一次攻击长矛兵,并被反击移除,第二次攻击不会发生。第二次攻击可以选择第一次攻击后仍然相邻的任何合法目标。

下面点击狂战士,金色表示攻击这个目标后仍可继续攻击。再点击任意金色目标,红色会显示第二次攻击目标。

{:title "狂战士:普通攻击后可以再次攻击"
 :mode :berserker-attack
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :berserker}
          {:at [1 0] :side :blue :unit :crossbow}
          {:at [0 -1] :side :blue :unit :scout}]}

剑士:普通攻击之后可以移动

剑士同样不是发动战术。它先完成一次普通攻击;攻击后如果剑士仍在场,可以选择移动到一个相邻空格。玩家也可以放弃后续移动,只执行普通攻击。

剑士攻击
= 普通攻击一次
OPTIONALLY THEN
  如果剑士仍在场
  THEN 普通移动一次

后续移动使用攻击完成后的棋盘状态,因此被击败单位原来占据的格子也可能成为移动目标。攻击与移动合起来仍只消耗一枚剑士 coin。

下面点击剑士,再点击金色攻击目标;绿色会显示第一次攻击完成后可以选择的移动格。

{:title "剑士:普通攻击后可以移动"
 :mode :swordsman-attack
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :swordsman}
          {:at [1 0] :side :blue :unit :crossbow}]}

战争祭司:隔空控制相邻控制点

普通控制要求部队站在控制点上;战争祭司的战术 override 了“必须站在上面”这一条件,但没有放宽其他边界:

战争祭司战术目标
= 是控制点
AND 与战争祭司距离 1
AND 控制点上没有部队
AND 控制点当前不属于己方

“不属于己方”同时包括中立控制点和敌方控制点。战争祭司发动战术后留在原地,只改变目标控制点的归属,并且只消耗一枚战争祭司 coin。

下面点击战争祭司。蓝色是唯一合法目标:另一个相邻控制点已经属于己方,还有一个相邻控制点被敌方部队占据,因此都不能选择。

{:title "战争祭司:控制距离 1 的空闲非己方控制点"
 :mode :war-priest-tactic
 :focus [2 -1]
 :friendly-controls [[1 -1]]
 :pieces [{:at [2 -1] :side :red :unit :war-priest}
          {:at [3 -2] :side :blue :unit :crossbow}]}

长矛兵:相邻攻击触发同时反伤

长矛兵的能力不是主动战术,而是普通攻击结算上的规则:任何部队从距离 1 攻击长矛兵时,长矛兵和攻击者同时各失去一层。

长矛兵反伤
= defender 是 Pikeman
AND distance(attacker, defender) = 1
THEN defender 与 attacker 各失去 1 层

触发条件只看这次攻击发生时的距离。它不需要再反向检查“长矛兵能否攻击攻击者”;这不是长矛兵发起的第二次攻击,而是同一次近战攻击的反伤结算。距离 2 的弓箭手或弩手射击不会触发它。

下面双方都先强化到 strength 2。点击剑士,再点击长矛兵,可以看到双方同时从 2 降到 1,而不是整支离场。

{:title "长矛兵:受到相邻攻击时双方各失去一层"
 :mode :pikeman-counter
 :focus [0 0]
 :pieces [{:at [0 0] :side :red :unit :swordsman :strength 2}
          {:at [1 0] :side :blue :unit :pikeman :strength 2}]}

骑士:攻击者必须已经强化

骑士把“目标是否合法”增加了一道 strength gate:只有 strength 大于 1 的攻击者,才能选择骑士作为攻击目标。

可以攻击骑士
= attacker strength > 1

这条限制同时约束普通攻击和战术攻击。未强化的弓箭手、弩手即使满足射程、直线和无阻挡,也不能用射击战术攻击骑士;强化后才可以。狂战士的第二次攻击、骑兵移动后的攻击等复合行动,也必须经过同一个目标检查。

下面点击中央骑士。左边 strength 1 的剑士会显示为不可攻击,右边 strength 2 的长矛兵会显示为合法攻击者。这些标记来自两支部队各自真实的普通攻击 action,而不是教学组件重新手写一遍骑士规则。

{:title "骑士:只有强化部队可以攻击"
 :mode :knight-defense
 :focus [0 0]
 :pieces [{:at [0 0] :side :blue :unit :knight}
          {:at [-1 0] :side :red :unit :swordsman}
          {:at [1 0] :side :red :unit :pikeman :strength 2}]}

普通攻击:继续叠加阵营与目标限制

射程合法还不等于一次完整攻击合法。最终规则还要组合棋盘状态:

(defn normal-attack-legal?
  [state side attacker-pos defender-pos]
  (let [attacker (piece-at state attacker-pos)
        defender (piece-at state defender-pos)]
    (and (own-piece? side attacker)
         (enemy-piece? side defender)
         (= 1 (axial-distance attacker-pos defender-pos))
         (attack-target-allowed? attacker defender))))

普通攻击的判断可以理解成一棵由小谓词组成的树:

legal attack?
├─ attacker is mine?
├─ defender is enemy?
├─ distance = 1?
└─ target allowed?
   └─ Knight restriction

straight?line-clear? 不属于普通攻击;它们只会出现在需要这些条件的战术计划里。这样元帅委托战术时不会意外委托普通攻击,掌旗官委托普通攻击时也不会意外获得弓箭手或弩手的射程。

每个叶子回答一个小问题,越靠近根部,语义越接近 War Chest 的具体规则。

当前代码与目标结构的映射

现在的 War Chest 已经有一部分 primitive:

当前弓箭手的战术已经映射为:

:tactic {:tactic/type :archer
         :effect {:effect/type :attack ...}}

当前弩手的战术已经映射为:

:crossbow (and (= 2 distance)
               (line-clear? state from to))

line-clear? 会先确认目标共线,再枚举中间格并要求它们全部为空。因此,如果 [1 0] 有棋子,从 [0 0][2 0] 的弩手战术射击会被判定为非法;无论中间棋子属于哪一方,都会形成阻挡。

为什么要保持 primitive 很小

如果直接写一个函数:

(crossbow-can-attack? state from to)

短期内很容易完成需求,但“直线”和“阻挡”会藏在弩手内部。下一个单位需要同样能力时,要么复制代码,要么开始调用一个名字属于弩手的函数。

小 primitive 带来几个直接好处:

1. 规则更接近说明书。 distancestraightline-clear 都是规则文本中可以直接讨论的词。

2. 组合边界清楚。 几何函数只接收坐标;占用函数才接收 state;单位函数只组合它需要的条件。

3. 测试更小。 可以分别证明距离、共线、路径枚举和阻挡,不必为每个单位重复所有几何案例。

4. 更容易发现缺口。 当规则写成 primitive 列表时,弩手缺少 line-clear? 会非常明显。

5. 搜索和 AI 修改更可靠。 函数名对应稳定的领域概念,而不是散落在巨大 case 中的坐标算式。

这和 vector space 提供基础运算、再由上层构造更复杂结构的方式相似。也和 game tree 的分层有关:primitive 决定一个 action 是否可能,合法 action 再成为 game tree 的边。

Primitive 不是越多越好

拆分的目标不是给每一行代码起名字,而是找到能够被规则共享的稳定概念。

好的 primitive 通常满足:

- 名字来自游戏或几何领域;

- 输入和输出很小;

- 不依赖具体单位;

- 可以单独解释和测试;

- 能被两个以上的规则组合,或者明确构成后续规则的基础。

例如 cells-between 是一个有意义的 primitive,因为它把“路径是什么”从“路径是否为空”中分离出来。相反,如果只有一处使用,把 (= 2 distance) 再包装成 distance-exactly-two? 未必增加了表达力。

最终希望得到的代码不是“每个单位一个彼此无关的大函数”,而是一个小型规则语言:

坐标与向量
    ↓
距离、方向、直线、路径
    ↓
棋盘边界、占用、阵营
    ↓
移动范围、攻击范围、无阻挡
    ↓
Archer、Crossbow、Lancer 等单位规则
    ↓
Legal actions
    ↓
Game tree

后面的单位能力,表面上看起来各不相同,底层往往只是这些 primitive 的不同叠加。先把这组语言建立好,新增规则就不再是继续堆条件,而是在已有概念上写出新的组合。