/** 将已定稿支线册的正文/选项/后续转换为静态 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 = { 沈铎: '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 = { '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}`);