用 Hex Grid Primitives 组合 War Chest 规则
把距离、直线、路径与阻挡拆成可组合的几何 primitive,再用它们表达弓箭手、弩手等 War 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]因此,判断 from 和 to 是否在同一条 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])
;; => nilnil 表示不共线;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-between 和 empty-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 restrictionstraight? 和 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. 规则更接近说明书。 distance、straight、line-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 的不同叠加。先把这组语言建立好,新增规则就不再是继续堆条件,而是在已有概念上写出新的组合。