9.1 条件表达式:旗标、数值、持有
Godot 任务条件图编写 与 Godot 任务图资源编辑器 讲的是在 Godot 里怎么把条件连起来、怎么在编辑器里点出来——那是引擎专属的实现与可视化 UI。本节问的是另一个层面的问题:把条件从引擎里拿出来、只当成一串可求值的表达式,它是什么形状?
答案是一棵表达式树:叶子只读三个东西(旗标、好感度、持有),内点只有三个运算符(and / or / not)。形状一旦定死,「这个条件能不能成立」「它是不是永远不成立」就都成了可判定的性质,与用什么引擎、有没有编辑器无关。
三种叶子:条件只能读三个东西
一个条件要判断的事,拆到底只有三类。它们对应状态里的三个命名空间,读法各不相同:
| 叶子写法 | 读的是什么 | 值的类型 | 例子 | 含义 |
|---|---|---|---|---|
["flag", 名, 值] | 一个 flag | 整数,实际只有 0 / 1 | ["flag","knows_yan_missing",1] | 这条旗标置位了 |
["aff", 名, 运算符, 整数] | 一个好感度变量 | 整数 0–100 | ["aff","yan",">=",60] | 砚的好感度到 60 了 |
["item", 名, 值] | 一件物品 | 整数,实际只有 0 / 1 | ["item","lantern",1] | 提灯在手上 |
三行合起来是一条读取范围的声明:条件永远读不到第四个东西。它读不到「玩家上一步点了什么」,读不到「现在是第几幕」,也读不到「这条线走过几次」。需要这类信息时,规格的做法是先在某个选项的效果里把它写成 flag,再由条件去读那个 flag——读取范围窄,可判定性才守得住。
aff 是唯一带运算符的叶子,四个运算符是 >= / < / == / !=。为什么给 < 而不给 <=?因为整数域上 v < n 与 v >= n 正好互补,两个就够表达四种关系;多给一个 <= 只会让同一个意思有两种写法,写出来的规格也就有了两种形状。
三种组合:内点只有三个运算符
| 组合写法 | 元数 | 求值语义 | 例子 |
|---|---|---|---|
["and", 条件, 条件] | 二元 | 两侧都真才真 | ["and", ["flag","found_yan_journal",1], ["aff","yan",">=",40]] |
["or", 条件, 条件] | 二元 | 任一侧真即真 | ["or", ["flag","hui_trusts",1], ["item","rope_knot",1]] |
["not", 条件] | 一元 | 取反 | ["not", ["flag","dawn_choice_made",1]] |
三种叶子 + 三种组合,就是这份文法的全部。没有优先级问题(每个组合都把括号写成数组本身),没有隐式类型转换(值都是整数),也没有副作用(求值只读状态)。null 是第四个「形式」,它表示无条件成立——不是「条件失败」,是「这里根本没有条件」。这个区分很重要:null 与恒真的条件在求值上等价,但在阅读规格时,前者说「这里不需要条件」,后者说「这里写了一个碰巧总是真的条件」。
完整文法表
规格里的 conditions 段把上面这些写成了机器可读的声明:
grammar: nested-array # 条件用嵌套数组表示,数组首位是算子名
ops: >= / < / == / != # 好感度叶子支持的四个比较
truthy: 1 # flag 与 item 的「真」写作 1
forms:
flag ["flag", "<flag_id>", 1]
aff ["aff", "<character_id>", "<op>", "<int>"]
item ["item", "<item_id>", 1]
and ["and", "<cond>", "<cond>"]
or ["or", "<cond>", "<cond>"]
not ["not", "<cond>"]
这张表是声明,不是使用情况。声明了六种形式不等于六种都用上了——下面 P 层的第一件事就是把两者分开数。
P 层:六种形式,每种都配一个负例
一个只会返回 true 的求值器等于没有求值器。所以下面的测试对每种形式都给正例与负例,并且额外检查两条代数性质:>= 与 < 在整数上互斥、== 与 != 在整数上互补。这两条性质用 0–100 的全区间逐个整数验,比抽查几个点更能说明问题。
// file: core/test/na_09_1_cond_expr.test.ts
// tier: P
// verify: node --test 'core/test/na_09_1_cond_expr.test.ts'
// 卷一 9.1:条件表达式的六种形式 —— 三种叶子(flag / aff / item)+ 三种组合(and / or / not)。
// 每种形式都配正例与负例:只能返回 true 的求值器等于没有求值器,负例才是它有用的证据。
// 另统计这六种形式在本规格里各出现多少次(文法声明 ≠ 规格实际用到)。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { condOk, initState } from "../src/inkwell/engine.ts";
import type { Cond, Spec, State } from "../src/inkwell/engine.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
/** 受控状态:只覆盖本节关心的字段,其余取初始值。 */
function mk(over: Partial<State>): State {
return { ...initState(spec), ...over };
}
// ---------------------------------------------------------------- 叶子一:flag
test("flag 叶子:置位为真,未置位为假", () => {
const c: Cond = ["flag", "met_hui", 1];
assert.equal(condOk(c, mk({ flags: { met_hui: 1 } })), true);
assert.equal(condOk(c, mk({ flags: { met_hui: 0 } })), false);
});
test("flag 叶子:未声明的 flag 落到 0,不抛异常", () => {
const c: Cond = ["flag", "met_hui", 1];
assert.equal(condOk(c, mk({ flags: {} })), false);
assert.equal(condOk(["not", c], mk({ flags: {} })), true);
});
// ---------------------------------------------------------------- 叶子二:aff
test("aff 叶子:>= 的边界是「等于也真」", () => {
const c: Cond = ["aff", "yan", ">=", 30];
assert.equal(condOk(c, mk({ aff: { yan: 29 } })), false);
assert.equal(condOk(c, mk({ aff: { yan: 30 } })), true);
assert.equal(condOk(c, mk({ aff: { yan: 31 } })), true);
});
test("aff 叶子:< 是 >= 的补集(同一整数上互斥)", () => {
const ge: Cond = ["aff", "yan", ">=", 30];
const lt: Cond = ["aff", "yan", "<", 30];
for (let v = 0; v <= 100; v += 1) {
const s = mk({ aff: { yan: v } });
assert.equal(condOk(ge, s) !== condOk(lt, s), true, `v=${v} 上 >= 与 < 应互斥`);
}
});
test("aff 叶子:== 与 != 在整数上互为补集", () => {
const eq: Cond = ["aff", "hui", "==", 55];
const ne: Cond = ["aff", "hui", "!=", 55];
assert.equal(condOk(eq, mk({ aff: { hui: 55 } })), true);
assert.equal(condOk(ne, mk({ aff: { hui: 55 } })), false);
assert.equal(condOk(eq, mk({ aff: { hui: 54 } })), false);
assert.equal(condOk(ne, mk({ aff: { hui: 54 } })), true);
});
// ---------------------------------------------------------------- 叶子三:item
test("item 叶子:持有为真,不持有为假", () => {
const c: Cond = ["item", "lantern", 1];
assert.equal(condOk(c, mk({ items: { lantern: 1 } })), true);
assert.equal(condOk(c, mk({ items: { lantern: 0 } })), false);
assert.equal(condOk(c, mk({ items: {} })), false);
});
// ---------------------------------------------------------------- 组合:and / or / not
test("and 是合取:两真才真,任一假即假", () => {
const c: Cond = ["and", ["flag", "found_yan_journal", 1], ["aff", "yan", ">=", 40]];
assert.equal(condOk(c, mk({ flags: { found_yan_journal: 1 }, aff: { yan: 40 } })), true);
assert.equal(condOk(c, mk({ flags: { found_yan_journal: 1 }, aff: { yan: 39 } })), false);
assert.equal(condOk(c, mk({ flags: { found_yan_journal: 0 }, aff: { yan: 99 } })), false);
});
test("or 是析取:任一真即真,两假才假", () => {
const c: Cond = ["or", ["flag", "hui_trusts", 1], ["item", "rope_knot", 1]];
assert.equal(condOk(c, mk({ flags: { hui_trusts: 1 }, items: { rope_knot: 0 } })), true);
assert.equal(condOk(c, mk({ flags: { hui_trusts: 0 }, items: { rope_knot: 1 } })), true);
assert.equal(condOk(c, mk({ flags: { hui_trusts: 0 }, items: { rope_knot: 0 } })), false);
});
test("not 是取反:单目,嵌套一层后回到原值", () => {
const inner: Cond = ["flag", "dawn_choice_made", 1];
assert.equal(condOk(["not", inner], mk({ flags: { dawn_choice_made: 0 } })), true);
assert.equal(condOk(["not", inner], mk({ flags: { dawn_choice_made: 1 } })), false);
assert.equal(condOk(["not", ["not", inner]], mk({ flags: { dawn_choice_made: 1 } })), true);
});
test("null 条件恒真:无条件是「无条件成立」而不是「条件失败」", () => {
assert.equal(condOk(null, mk({})), true);
assert.equal(condOk(undefined, mk({})), true);
});
// ---------------------------------------------------------------- 文法覆盖统计
function kinds(c: Cond | null | undefined, acc: string[]): void {
if (!c) return;
acc.push(c[0] as string);
if (c[0] === "and" || c[0] === "or") {
kinds(c[1] as Cond, acc);
kinds(c[2] as Cond, acc);
} else if (c[0] === "not") {
kinds(c[1] as Cond, acc);
}
}
type WideSpec = Spec & {
conditions: { forms: Record<string, unknown> };
scene_graph: { edges: { when: Cond | null }[] };
world: { locations: { unlock_when: Cond | null }[] };
};
/** 把规格里全部条件位点(对话行 / 选项 / 边 / 地点 / 结局)摊平成一串算子名。 */
function allSites(): string[] {
const w = spec as WideSpec;
const acc: string[] = [];
for (const n of w.dialogue) {
kinds(n.line_when, acc);
for (const c of n.choices ?? []) kinds(c.when, acc);
}
for (const e of w.scene_graph.edges) kinds(e.when, acc);
for (const l of w.world.locations) kinds(l.unlock_when, acc);
for (const e of w.endings) kinds(e.when, acc);
return acc;
}
test("文法声明 6 种形式,规格实际用到 5 种", () => {
const acc = allSites();
const counts: Record<string, number> = {};
for (const k of acc) counts[k] = (counts[k] ?? 0) + 1;
const declared = Object.keys((spec as WideSpec).conditions.forms);
assert.deepEqual(declared.sort(), ["aff", "and", "flag", "item", "not", "or"]);
const used = Object.keys(counts).sort();
assert.deepEqual(used, ["aff", "and", "flag", "item", "not"]);
assert.equal(counts["or"], undefined);
const leaves = counts["flag"] + counts["aff"] + counts["item"];
const combiners = counts["and"] + counts["not"];
assert.equal(acc.length, leaves + combiners);
console.log(`全规格条件位点 ${acc.length} 个:叶子 ${leaves}(flag ${counts["flag"]} / aff ${counts["aff"]} / item ${counts["item"]})`
+ `,组合 ${combiners}(and ${counts["and"]} / or 0 / not ${counts["not"]})`);
});
本机实跑 node --test 'core/test/na_09_1_cond_expr.test.ts':
全规格条件位点 58 个:叶子 54(flag 22 / aff 15 / item 17),组合 4(and 2 / or 0 / not 2)
✔ flag 叶子:置位为真,未置位为假 (1.157958ms)
✔ flag 叶子:未声明的 flag 落到 0,不抛异常 (0.124792ms)
✔ aff 叶子:>= 的边界是「等于也真」 (0.088625ms)
✔ aff 叶子:< 是 >= 的补集(同一整数上互斥) (0.567416ms)
✔ aff 叶子:== 与 != 在整数上互为补集 (0.121583ms)
✔ item 叶子:持有为真,不持有为假 (0.205708ms)
✔ and 是合取:两真才真,任一假即假 (0.088125ms)
✔ or 是析取:任一真即真,两假才假 (0.222292ms)
✔ not 是取反:单目,嵌套一层后回到原值 (0.187291ms)
✔ null 条件恒真:无条件是「无条件成立」而不是「条件失败」 (0.133958ms)
✔ 文法声明 6 种形式,规格实际用到 5 种 (1.669875ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1179.051333
11 条全过,fail 0。
判读:声明 6 种,用到 5 种
输出第一行值得单独读一遍。把规格里全部条件位点(对话行、选项、场景边、地点解锁、结局)的条件树摊平,得到 58 个节点:叶子 54 个(flag 22、aff 15、item 17),组合 4 个(and 2、not 2)。
对照文法表会发现两件事:
| 观察 | 数字 | 读法 |
|---|---|---|
or 声明了但一次没用 | 声明 6 种,实际 5 种 | 不是缺陷,是当前案例的写法偏好——凡是「或」的意思,这里都拆成了两条独立选项 |
| 组合只占 4 / 58 | 叶子 54,组合 4 | 条件整体偏「浅」:绝大多数条件是单个叶子,深树很少 |
第一条要说清:「声明了没用」和「声明了用不了」是两件事。or 的求值路径由测试覆盖(or 那条用例就是直接构造一个 or 条件来跑的),所以它不是死代码;它只是在本案例的规格文本里还没被用上。如果哪天规格里出现一个真正需要析取的条件,求值器已经准备好了。
第二条是这份规格的形状:58 个节点里只有 4 个是组合,说明这里的条件几乎都是一次判断。这既让条件容易读,也让后面的闭包检验便宜——树越浅,逐位点判定可满足性的成本越低。
自检表
| 检查项 | 合格标准 |
|---|---|
| 读取范围封闭 | 叶子只有 flag / aff / item 三种,读不到路径、幕次、访问次数 |
| 运算符正交 | >= 与 < 互补、== 与 != 互补,同一意思只有一种写法 |
| 正负例成对 | 六种形式每种都有能返回 false 的用例 |
| 声明与使用分开 | 文法表是声明;实际用到几种要单独数出来 |
| 空条件有定义 | null 表示「无条件成立」,与「条件失败」明确区分 |
条件规定了「什么情况下可以走这条路」。但条件读的那些名字——旗标、好感度、物品——是谁写的、能写成什么,还没有规矩。下一节把三类状态与四个效果算子的写入域定下来。
阅读导航:上一节:8.3 假选择与真后果的判定 · 下一节:9.2 状态变量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。