186 lines
16 KiB
TypeScript
186 lines
16 KiB
TypeScript
/** 将已定稿支线册的正文/选项/后续转换为静态 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}`);
|