Files
jev-match-game/scripts/build-adaptive-branches.ts

186 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 将已定稿支线册的正文/选项/后续转换为静态 JSON。运行:pnpm exec tsx scripts/build-adaptive-branches.ts */
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import type { BranchPack, ChoiceNode, DialogueNode, StoryNode } from '../src/engine/types';
const source = readFileSync(new URL('../docs/script/adaptive-branches.md', import.meta.url), 'utf8');
const sections = [...source.matchAll(/^## B-(ATT|CON|EMO|VAL|CUL) 变体 ([AB]).*\n([\s\S]*?)(?=^## B-|^# 支线|^# 附录)/gm)];
const dimensions = { ATT: 'attachment', CON: 'conflict_style', EMO: 'emotional_stability', VAL: 'values', CUL: 'culture' } as const;
const sprites: Record<string, string> = { 沈铎: 'shenduo', 阿棠: 'atang', 温简: 'wenjian', 老鬼: 'laogui' };
const clean = (s: string) => s.replace(/^> ?/gm, '').replace(/\*\*/g, '').replace(/\n[ \t]*/g, '').trim();
const conditional = (normal: string, weak: string) => `【若 wenjian_health != weak:${normal}】【若 wenjian_health = weak:${weak}】`;
const waterLedger = [
['a_give', '施水一囊,未收物。'],
['b_cup', '施水一杯,未收物。'],
['c_trade', '售水两囊,得镯一支(CW-0322,有偿征集)。'],
['d_redirect', '水支出零,受赠镯一支(CW-0322,赠与代收)。'],
] as const;
const ledgerDetails = waterLedger.map(([value, text]) => `【若 cb_val_a_water = ${value}:${text}】`).join('');
function adapt(text: string, code: string, skin: string): string {
let t = text.replace(/【工程注:[\s\S]*?】/g, '').replace(/(`[^)]*)/g, '')
.replace(/(主线等值情报[^)]*)/g, '').replace(/(Act 2 伏笔[^)]*)/g, '')
.replace(/(氛围换色[^)]*)/g, '').replace(/(剧本明示[^)]*)/g, '')
.replace('(C2-05 的他,懂的人自然懂。)', '')
.replace('(轻伤演出:肩头淤青)', ',肩头淤青')
.replace('(背景演出)', '')
.replace('(或摊上最年长的)', '')
.replace('这地方刚塌过(或:这地方还没探完)', '这地方还没探完')
.replace('石柱(或:门框)', '牢固的支点');
if (skin === 'A') {
const replacements: [string, string][] = [
['温简一遍遍校准呼叫频率', conditional('温简一遍遍校准呼叫频率', '通讯频道里,温简在营地一遍遍校准呼叫频率')],
['(轻声)', conditional('(轻声)', '(营地通讯频道,声音虚弱)')],
['我和温简上塬,带足信标,沿路插', conditional('我和温简上塬,带足信标,沿路插', '我带老鬼上塬,温简在频道里报坐标,带足信标,沿路插')],
['队长和老鬼留守坐标点做呼叫中继,', conditional('队长和老鬼留守坐标点做呼叫中继,', '队长留守坐标点做呼叫中继,')],
['又看看你和温简', conditional('又看看你和温简', '又看看你和老鬼')],
['温简与你并肩,用只有两个人听见的音量说', conditional('温简与你并肩,用只有两个人听见的音量说', '温简切到与你单独通话的频道,虚弱地说')],
['说完他加快半步去追阿棠,留你一个人在后面走。', conditional('说完他加快半步去追阿棠,留你一个人在后面走。', '说完频道安静下去,留你一个人在后面走。')],
['温简在旁边低头走路,睫毛动了一下,什么都没说。', conditional('温简在旁边低头走路,睫毛动了一下,什么都没说。', '频道里温简的呼吸停了一拍,什么都没说。')],
['温简在记录板的边角,无意识地画着什么。', conditional('温简在记录板的边角,无意识地画着什么。', '通讯频道里,传来温简翻页的声音。')],
['(什么都没说,看你。所有人都在看你。)', conditional('(什么都没说,看你。所有人都在看你。)', '(频道里只有他虚弱的呼吸声。所有人都在等你。)')],
['(贴着你的耳朵喊)', conditional('(贴着你的耳朵喊)', '(在通讯频道里喊,电流声盖掉半句)')],
['五个人叠着喘', conditional('五个人叠着喘', '四个人叠着喘')],
['五个人的脸', conditional('五个人的脸', '四个人的脸')],
['温简最后开口', conditional('温简最后开口', '温简最后在频道里虚弱地开口')],
['(把铭文描进记录板,越描越慢)', conditional('(把铭文描进记录板,越描越慢)', '(营地频道,他对着传回的铭文照片描进记录板,声音虚弱)')],
];
for (const [from, to] of replacements) t = t.replaceAll(from, to);
}
// 支线册以代码字体标注变量名;游戏条件解析只接受裸变量名。
return t.replace(/【若\s*`?([a-z0-9_]+)`?\s*(!=|=)\s*`?([a-z0-9_]+)`?\s*:/g, '【若 $1 $2 $3:');
}
function polish(text: string): string {
return text.replace('(若 C2-05玩家与他有同行约定,追加一句他的低语:「正好,我步子慢,站得稳。」)', '【若 c2_05_depth = deep:他低语:「正好,我步子慢,站得稳。」】')
.replace('(C2-03 已埋的约束)', '')
.replace('(C2-04)', '')
.replace('星图盘上复原的苍龙心宿', '苍龙心宿')
.replace('(呼应 C2-01 的规程设定)', '')
.replace('说了全支线最重要的一句台词', '低声说')
.replace('冲你们喊了全支线唯一一句话', '冲你们喊')
.replace('——但这半方纹样,是后来§3-2 解谜时她第一个认出来的。', '')
.replace('——这句"账先欠着",是 Act 2 重回此地的钩子。', '')
.replace('(Act 2 沈铎线的一粒沙)', '')
.replace('(各线统一,他溜之前)', '')
.replace('你关掉的这条线,Act 2 永远不会再开——', '你关掉了这条线——')
.replace('(§3-1「有人修过路」的直接延续)', '')
.replace('(§3-1)', '')
.replace('守陵人的窄阶(若 B-VAL-B 已触发)或你们的探查路线', '你们的探查路线')
.replace('后来 §3-2 解谜', '待会儿上台基辨纹时')
.replace(/八月大火昏见——此铭是[\s\S]*?解题后台词指向未来。/, '')
.replace('与解谜线同一时刻、同一片黄昏。进 §2-5。', '正是黄昏。')
.replace('(沈铎的规程与主角方的红线一致:记录者,不取不扰)', '')
.replace('想想你在星图盘上,刚把什么补回苍龙的心口。', '大火是星名,苍龙的心宿。')
.replace('这和星图盘是一套思路。「火中,而后城安」,等的是星;这段铭文,记的也是星。', '这和老人说的「火中,而后城安」是一套思路。等的是星,记的也是星。')
.replace('【场景】', '').replace('【场景(坑道内)】', '')
.replace('——这句问话没有选项,悬在你头上,带回队里', '——这句问话悬在你头上,带回队里');
}
const exits: Record<string, string> = {
'ATT-A': '阿棠带路,全队沿观星台的侧阶向上走。黄昏落在阶石上。',
'ATT-B': '阿棠在记录板上写下「第 27 号坑·一家人俑」。采集收尾,沈铎招手,全队向台基合拢。',
'CON-A': '纠纷了结,日头西斜。从草市抄近路抵达宫墟外围,正是黄昏。',
'CON-B': '鱼牙人退走了。你们从庭院向台基合拢。',
'EMO-A': '风停,浮沙被刮走整整一层。主街中段露出一截石阶和圆形基座的边缘。阿棠用袖子擦着刻纹:「哎……这纹路,观星台。它这是——给咱们开门呢?」黄昏前,你们登台。',
'EMO-B': '采集收尾,全队向台基合拢。陶范的纹样与「铸完那一批,再没有下一批」的话,跟着你们一起上去。',
'VAL-A': '账毕,日头西斜。黄昏时,你们登上观星台。',
'VAL-B': '【若 cb_val_b_medicine != b_refer:少年拄着木枝,一瘸一拐把你们引到台基背阴面,亲手拨开荒草:「这边。我阿爷说,给神走的路修得高,给人走的路修得低。走低的,不摔跤。」】【若 cb_val_b_medicine = b_refer:老人和少年已往城里去。阿棠照着少年的喊话,在排水沟边找到荒草,拨开它。她学着少年的口气说:「给神走的路修得高,给人走的路修得低。走低的,不摔跤。」】',
};
mkdirSync(new URL('../content/branches/', import.meta.url), { recursive: true });
for (const match of sections) {
const code = match[1] as keyof typeof dimensions;
const skin = match[2];
const body = match[3].replace(/^> ?/gm, '');
const id = `b-${code.toLowerCase()}-${skin.toLowerCase()}`;
const nodes: StoryNode[] = [];
let counter = 0;
function dialogue(text: string, next = ''): DialogueNode[] {
const out: DialogueNode[] = [];
for (let paragraph of text.split(/\n\s*\n/)) {
if (!paragraph.trim() || /^\s*(?:#{1,3} |【正文】|---|【工程注)/.test(paragraph)) continue;
const raw = clean(paragraph);
if (!paragraph) continue;
const speech = raw.match(/^(沈铎|阿棠|温简|老鬼|马四|鱼牙人|妇人|守墓老人|守陵少年)(([^)]*))?:([\s\S]*)$/);
const speaker = speech ? speech[1] : '旁白';
let spoken = polish(adapt(speech ? (speech[2] ?? '') + speech[3] : raw, code, skin));
if (spoken.startsWith('【') && !spoken.startsWith('【若') && spoken.endsWith('】')) spoken = spoken.slice(1, -1);
out.push({ id: `${id}-d${++counter}`, type: 'dialogue', speaker, text: spoken, sprite: sprites[speaker] ?? 'scene', next: '' });
}
out.forEach((n, i) => { n.next = out[i + 1]?.id ?? next; });
nodes.push(...out);
return out;
}
if (code !== 'CUL') {
const choices = [...body.matchAll(/【选项((CB-[A-Z]+-[AB]-\d+))[^]*?】([\s\S]*?)(?=\n###)/g)];
let previousResults: DialogueNode[] = [];
let previousEnd = 0;
for (const choice of choices) {
const point = choice[1];
const intro = body.slice(previousEnd, choice.index);
const opening = dialogue(intro, point.toLowerCase());
previousResults.forEach((n) => { n.next = opening[0]?.id ?? point.toLowerCase(); });
// 每个选项以空行分隔;只取玩家台词,括号中的测量解释不入游戏。
const optionBlocks = choice[2].split(/\n\s*\n/).filter((s) => /^\*\*[A-D]\./.test(s.trim()));
const resultStart = body.indexOf(`### 各选项后续(${point})`, choice.index!);
const resultEnd = body.indexOf('\n### ', resultStart + 4);
const resultText = body.slice(resultStart, resultEnd < 0 ? body.length : resultEnd);
const results = [...resultText.matchAll(/^- \*\*([A-D])\*\*:([\s\S]*?)(?=^- \*\*[A-D]\*\*:|$(?![\s\S]))/gm)];
const choiceNode: ChoiceNode = { id: point.toLowerCase(), type: 'choice', pointId: point, prompt: '你怎么做?', dimensions: [dimensions[code]], options: [] };
previousResults = [];
for (const block of optionBlocks) {
const letter = block.trim()[2];
const label = adapt(clean(block.replace(/^\*\*[A-D]\./, '').split(/\n(/)[0]), code, skin);
const result = results.find((r) => r[1] === letter);
if (!result) throw new Error(`缺少后续 ${point}-${letter}`);
const after = dialogue(result[2]);
if (point === 'CB-VAL-A-02' && letter === 'B') after[0].text = after[0].text.replace('每项实数,', `每项实数:${ledgerDetails}`);
if (point === 'CB-VAL-A-02' && letter === 'C') after[0].text = after[0].text.replace('按实数记了,', `按实数记了:${ledgerDetails}`);
choiceNode.options.push({ id: `${point}-${letter}`, label, next: after[0].id });
previousResults.push(after.at(-1)!);
}
if (code === 'EMO' && point.endsWith('01')) {
const timeout = dialogue(skin === 'A' ? '你继续听沈铎指挥点人。队员在警戒线内一一应答;风声盖住阿棠的声音,她举手示意自己还在。' : '沈铎按既定预案接管排险,其他队员退出阵列。阿棠在受训人员协助下安全离开。');
choiceNode.options.push({ id: `${point}-TIMEOUT`, label: skin === 'A' ? '听沈铎指挥点人' : '由沈铎接管排险', next: timeout[0].id });
previousResults.push(timeout.at(-1)!);
choiceNode.timer = { seconds: skin === 'A' ? 10 : 8, optionId: `${point}-TIMEOUT` };
}
nodes.push(choiceNode);
previousEnd = resultEnd;
if (choiceNode.options.length < 3) throw new Error(`选项不完整 ${point}`);
}
const lastSection = body.slice(previousEnd).split('### 选项设计说明')[0];
const closing = code === 'VAL' && skin === 'B' ? [] : dialogue(lastSection);
const exit = dialogue(exits[`${code}-${skin}`], '$resume');
if (closing.length) closing.at(-1)!.next = exit[0].id;
previousResults.forEach((n) => { n.next = closing[0]?.id ?? exit[0].id; });
} else {
const point = `CB-CUL-${skin}-01`;
const beforePuzzle = body.slice(0, body.indexOf('### 谜面'));
const opening = dialogue(beforePuzzle.split('【前置选择')[0], skin === 'A' ? 'cb-cul-a-00' : point.toLowerCase());
void opening;
if (skin === 'A') {
const skipped = dialogue(body.slice(body.indexOf('### skip 线正文'), body.indexOf('【场景(坑道内)】')).replace(/###.*\n/, ''), '$resume');
const interior = dialogue(body.slice(body.indexOf('【场景(坑道内)】'), body.indexOf('### 谜面')), point.toLowerCase());
nodes.push({ id: 'cb-cul-a-00', type: 'choice', pointId: 'CB-CUL-A-00', prompt: '下去看看,还是赶路?', dimensions: ['culture'], untimed: true,
options: [{ id: 'CB-CUL-A-00-A', label: '下去看看', next: interior[0].id }, { id: 'CB-CUL-A-00-B', label: '赶路要紧', next: skipped[0].id }] });
}
const puzzleText = body.slice(body.indexOf('### 谜面'), body.indexOf('**谜底**'));
const questionBlocks = [...puzzleText.matchAll(/- \*\*(第[一二]问)\*\*:([^\n]+)\n([\s\S]*?)(?=\n- \*\*第|$(?![\s\S]))/gm)];
const hintsText = body.slice(body.indexOf('### 三档难度提示'), body.indexOf('### 解题后正文'));
const hints = [...hintsText.matchAll(/^\d\. ([\s\S]*?)(?=^\d\. |$(?![\s\S]))/gm)].map((h) => polish(clean(h[1])));
const after = dialogue(body.slice(body.indexOf('### 解题后正文'), body.indexOf('### 选项设计说明')));
const explanation = dialogue(body.slice(body.indexOf('**谜底**'), body.indexOf('### 三档难度提示')).replace('**谜底**:', '温简:'), after[0].id);
const exit = dialogue(skin === 'A' ? '半小时到,沈铎封道拍照。爬出断口时,日头贴着观星台的圆盘往下坠。阿棠眯着眼:「该去台上,看那颗星了。」' : '俑道尽头有侧阶直通台基顶。沈铎说:「记录。上去。」全队拾级登台。', '$resume');
after.at(-1)!.next = exit[0].id;
nodes.push({ id: point.toLowerCase(), type: 'choice', pointId: point, dimensions: ['culture'],
prompt: skin === 'A' ? '主壁铭文:「隹八月壬辰,大火見,王命作冊:城南垣,徙。」旁刻一人在高台举火,台下众人负土而行。残刻干支表起甲子、止癸酉,十枚围成一旬。' : '前排九俑胸口刻字:文、武、成、康、昭、穆、共、懿、孝。单数位微偏左,双数位微偏右,交错成两列。',
puzzle: { questions: questionBlocks.map((q, i) => ({ prompt: clean(q[2]), options: [...q[3].matchAll(/^ - [A-D]\. (.+)$/gm)].map((o) => clean(o[1])), answer: skin === 'A' && i === 1 ? 2 : 1 })), hints, exitLabel: skin === 'B' ? '上台基' : '请温简释读' },
options: [{ id: `${point}-ANSWER`, label: '确认释读', next: explanation[0].id }, { id: `${point}-EXIT`, label: skin === 'B' ? '上台基' : '请温简释读', next: explanation[0].id }] });
}
const pack: BranchPack = { id, dimension: dimensions[code], anchor: { chapter: skin === 'A' ? 'chapter2' : 'chapter3', afterNode: skin === 'A' ? 'c2-branch-anchor' : 'c3-d1b' }, estimatedMinutes: 8, startNodeId: nodes[0].id, nodes };
writeFileSync(new URL(`../content/branches/${id}.json`, import.meta.url), JSON.stringify(pack, null, 2) + '\n');
console.log(`${id}: ${nodes.length} nodes`);
}
if (sections.length !== 10) throw new Error(`预期十条支线,实际 ${sections.length}`);