Compare commits

...
3 Commits
17 changed files with 1185 additions and 59 deletions
+2 -2
View File
@@ -16,7 +16,7 @@
"id": "c1-open2",
"type": "dialogue",
"speaker": "旁白",
"text": "显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。(点击确认即视为知晓:剧情选择将用于为你推荐伴侣。按本心选,没有对错——此提示全剧只出现这一次。)",
"text": "显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。",
"sprite": "scene",
"next": "c1-d1"
},
@@ -278,7 +278,7 @@
"id": "c1-05",
"type": "choice",
"pointId": "C1-05",
"prompt": "碑额拓片特写:上方是屋顶(宀),屋顶之下一个安坐的人形,膝前有衣袂垂落之态——宀下一个女子安坐的形象。这是什么字?它刻在碑额上,意味着这是什么碑?(可主动求提示,每用一档降一档解谜评分,不设失败:一档拆字、二档给构件含义、三档给答案范围。)",
"prompt": "碑额拓片特写:上方是屋顶(宀),屋顶之下一个安坐的人形,膝前有衣袂垂落之态——宀下一个女子安坐的形象。这是什么字?它刻在碑额上,意味着这是什么碑?",
"dimensions": ["culture"],
"options": [
{ "id": "stele_guardian", "label": "「安」。室中有女,安坐不惊——这是镇城安民碑,碑额朝向即古城安门所在。", "next": "c1-05-ok" },
+15 -7
View File
@@ -8,7 +8,15 @@
"id": "c2-d1",
"type": "dialogue",
"speaker": "旁白",
"text": "【落点变体 · 若 c1_06_landing = a_safe(稳妥落点)】清晨,城外官道。薄雾,黄土路被夜雨泡软。你研究过一千年的苍梧,可当它真的立在雾里的时候,你第一个念头竟然是:原来城墙上的夯土层是这个颜色。驿站里,客商们在传一件事:城里从上月起,夜里不许点灯了。官府没说为什么。有个老客商压低声音:「听说是宫里传出来的。天上有东西不对。」——【落点变体 · 若 c1_06_landing = b_precise(精确落点)】黄昏,苍梧城内西市,满城无灯。落点舱的余震还在耳膜里,你已经站在了一千年前的苍梧城里。家家户户门窗紧闭,街上的人走路都贴着墙根。一个卖炊饼的老汉收摊时看了你一眼,那一眼不是好奇,是数你。「苍梧最后一个月,夜不举火。」人在怕。只是不知道在怕什么。",
"text": "【若 c1_06_landing = a_safe】清晨,城外官道。薄雾,黄土路被夜雨泡软,车辙里积着水,远处苍梧城墙的轮廓在雾里浮出来,比任何复原图都高。你研究过一千年的苍梧,可当它真的立在雾里的时候,你第一个念头竟然是:原来城墙上的夯土层是这个颜色。驿站里,客商们在传一件事:城里从上月起,夜里不许点灯了。官府没说为什么。有个老客商压低声音:「听说是宫里传出来的。天上有东西不对。」",
"sprite": "scene",
"next": "c2-d1v"
},
{
"id": "c2-d1v",
"type": "dialogue",
"speaker": "旁白",
"text": "【若 c1_06_landing = b_precise】黄昏,苍梧城内西市。西市正收摊,灯笼却没亮——满城无灯。落点舱的余震还在耳膜里,你已经站在了一千年前的苍梧城里。没有人看见你出现,但你觉得整座城都看见了:家家户户门窗紧闭,街上的人走路都贴着墙根。一个卖炊饼的老汉收摊时看了你一眼,那一眼不是好奇,是数你。天上最后一线光里,你忽然明白资料里那句话是什么意思:「苍梧最后一个月,夜不举火。」人在怕。只是不知道在怕什么。",
"sprite": "scene",
"next": "c2-d1a"
},
@@ -16,7 +24,7 @@
"id": "c2-d1a",
"type": "dialogue",
"speaker": "旁白",
"text": "(两变体汇合处)小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。",
"text": "小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。",
"sprite": "scene",
"next": "c2-d2"
},
@@ -245,7 +253,7 @@
"options": [
{ "id": "a_main", "label": "「走主街。」(支持沈铎:四十分钟买确定性,值)", "next": "c2-03-ra" },
{ "id": "b_alley", "label": "「走巷子。」(支持阿棠:饮水和时间是真约束,悬梁看着还稳)", "next": "c2-03-rb" },
{ "id": "c_scout", "label": "「巷子我先进。我体重最轻,走前面探,有问题退回来,主力走主街汇合。」(折中:用自己去验证风险——剧本写定:选此项必然塌梁,置 flag_fresh_collapse)", "next": "c2-03-rc" }
{ "id": "c_scout", "label": "「巷子我先进。我体重最轻,走前面探,有问题退回来,主力走主街汇合。」(折中:用自己去验证风险)", "next": "c2-03-rc" }
]
},
{
@@ -268,7 +276,7 @@
"id": "c2-03-rc",
"type": "dialogue",
"speaker": "旁白",
"text": "你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。(flag_fresh_collapse = true,宫墟深处会回收这条观察。)",
"text": "你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。记住这个细节,往下走,你会再想起它。",
"sprite": "scene",
"next": "c2-d5"
},
@@ -300,7 +308,7 @@
"id": "c2-04",
"type": "choice",
"pointId": "C2-04",
"prompt": "星图盘外圈完整刻着东方苍龙七宿(角、亢、氐、房、心、尾、箕)的连线,被磨去的中央区域正是苍龙的「心脏」。盘面残留一行小字铭文:「火中,而后城安。」补全被磨去的部分——缺的是哪一宿?「大火」又是哪一颗?(可求提示:一档指数数缺哪宿、二档解铭文钥匙、三档给答案。)",
"prompt": "星图盘外圈完整刻着东方苍龙七宿(角、亢、氐、房、心、尾、箕)的连线,被磨去的中央区域正是苍龙的「心脏」。盘面残留一行小字铭文:「火中,而后城安。」补全被磨去的部分——缺的是哪一宿?「大火」又是哪一颗?",
"dimensions": ["culture"],
"options": [
{ "id": "heart_xin", "label": "心宿三星,排成一线微弯;中间最红最亮的那颗是大火——心宿二。把它点在苍龙的心口。", "next": "c2-04-ok" },
@@ -393,7 +401,7 @@
"olfactory_gustatory": "那段日子有什么气味或味道,留在记忆里了吗?"
},
"variants": [
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "【同担】温简听完后沉默很久,然后讲了第二个、剧本外的细节——他哥病房的窗帘颜色。当夜他低声告诉你:档案馆给每个记录员配的板子,上报内容比你知道的多一行。基调:两个把伤口摊开的人,在火边达成不声张的同盟。", "tint": "#d4844b" },
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "【同担】温简听完后沉默很久,然后讲了第二个、从没对任何人讲过的细节——他哥病房的窗帘颜色。当夜他低声告诉你:档案馆给每个记录员配的板子,上报内容比你知道的多一行。基调:两个把伤口摊开的人,在火边达成不声张的同盟。", "tint": "#d4844b" },
{ "signature": "浅暴露", "variantId": "wenjian_close_late", "label": "【半程】温简认真听完,道谢,没再追问。次日他与你同行,路上主动讲了些苍梧的掌故。档案馆疑云他没提。基调:得体,温热,留有余地。", "tint": "#8a7a5a" },
{ "signature": "沉默", "variantId": "wenjian_distant", "label": "【守夜】你们并肩坐到火熄。温简往你手里塞了个烤热的薯,什么都没说。次日他照旧对你点头,不多一分不少一分——但阿棠偷偷告诉你:「温简昨晚跟沈铎说,你这个人『稳』。」基调:沉默被当作一种讲述接住了;温简的亲密度走慢线。", "tint": "#4b4b5a" }
],
@@ -447,7 +455,7 @@
"id": "c2-06-rb",
"type": "dialogue",
"speaker": "老鬼",
"text": "次日私下对质。老鬼收起笑,盯了你三秒,忽然又笑了:「新人,你比看上去老练。」他把玉佩交给你,让你转交登记,临走撂下一句:「这队里不止我一个人手上不干净,你慢慢看。」(沈铎收到玉佩时问来路,你替他圆了——或者没有:剧本的二级选项,记 c2_06_cover,引擎暂不建模。)",
"text": "次日私下对质。老鬼收起笑,盯了你三秒,忽然又笑了:「新人,你比看上去老练。」他把玉佩交给你,让你转交登记,临走撂下一句:「这队里不止我一个人手上不干净,你慢慢看。」沈铎收到玉佩时问了来路——替他圆,还是不圆,那一刻是你自己拿的主意。",
"sprite": "laogui",
"next": "c2-d8"
},
+7 -7
View File
@@ -16,7 +16,7 @@
"id": "c3-d1a",
"type": "dialogue",
"speaker": "旁白",
"text": "【若 flag_fresh_collapse = true(C2-03 你亲自探路)】台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。",
"text": "【若 flag_fresh_collapse = true】台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。",
"sprite": "scene",
"next": "c3-d1b"
},
@@ -48,7 +48,7 @@
"id": "c3-01",
"type": "choice",
"pointId": "C3-01",
"prompt": "台基上器物分四区:盘与盉(盥洗之器)、爵与尊(酒器)、鼎七件簋六件(盛牺牲黍稷)、最深处一座空着的玉璧座(礼天以璧)。① 还原仪式流程的先后顺序;② 空璧座意味着什么?(可求提示:一档按用途分类、二档给列鼎制度与「先洁后献」、三档给答案。)",
"prompt": "台基上器物分四区:盘与盉(盥洗之器)、爵与尊(酒器)、鼎七件簋六件(盛牺牲黍稷)、最深处一座空着的玉璧座(礼天以璧)。还原仪式流程的先后顺序,并回答:空璧座意味着什么?",
"dimensions": ["culture"],
"options": [
{ "id": "rite_correct", "label": "沃盥 → 献酒 → 荐食 → 奉璧。七鼎六簋,主祭是诸侯级的国君——这是国祭;璧座是空的:献给天的那件礼,始终没拿出来。或者,拿不出来。", "next": "c3-01-ok" },
@@ -161,7 +161,7 @@
"id": "c3-cutscene",
"type": "cutscene",
"video": "cutscene-illusion-clear",
"caption": "黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」【若你选择退出石室,石门同样滑开,馆长台词换为:「不出来的人,档案馆不催。你来了,看了,走了——这也是告别的一种。」】",
"caption": "黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」【若 c3_02_mode = exit_room:你最终没有走进去。石门同样滑开。馆长:「不出来的人,档案馆不催。你来了,看了,走了——这也是告别的一种。」】",
"next": "c3-d3"
},
{
@@ -244,7 +244,7 @@
"id": "c3-d4b",
"type": "dialogue",
"speaker": "阿棠",
"text": "哎你少拉别人下水!【若 c2_06_jade = a_public(你当初当场揭发玉佩)】上次的玉佩也是你吧?手痒?我看你是手黑!【若 c2_06_jade = b_private / c_watch】(她忽然停住,看向你——她不知想起了什么,但没有说破。你只有自己知道,你和她此刻想的,是不是同一件事。)",
"text": "哎你少拉别人下水!【若 c2_06_jade = a_public:上次的玉佩也是你吧?手痒?我看你是手黑!】【若 c2_06_jade != a_public:(她忽然停住,看向你——她不知想起了什么,但没有说破。你只有自己知道,你和她此刻想的,是不是同一件事。)】",
"sprite": "atang",
"next": "c3-d4c"
},
@@ -388,7 +388,7 @@
"id": "c3-d6c",
"type": "dialogue",
"speaker": "旁白",
"text": "【若 c1_03_motive = d_trust(你的动机是「有人希望我去」)】通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」",
"text": "【若 c1_03_motive = d_trust】通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」",
"sprite": "scene",
"next": "c3-d6d"
},
@@ -404,14 +404,14 @@
"id": "c3-06",
"type": "choice",
"pointId": "C3-06",
"prompt": "档案馆的隐瞒,你打算怎么办?(剧本的二级选项「叫醒谁」并入本点:选哪个队友,即向谁倾诉。)",
"prompt": "档案馆的隐瞒,你打算怎么办?(叫醒队友的话——叫醒谁,就向谁倾诉。)",
"dimensions": ["attachment", "mate_preference"],
"options": [
{ "id": "a_confront", "label": "(按下通讯键,直接问馆长)「刚才那段通话。问心石是你们放的?璧是什么?——现在,跟我说实话。」", "next": "c3-06-ra" },
{ "id": "b_conceal", "label": "(退回营地,躺平,盯着黑暗)按下不表。听到的每一个字都记住。在弄清楚「他们为什么要瞒」之前,装作没听过。", "next": "c3-06-rb" },
{ "id": "c_confide_shenduo", "label": "(叫醒沈铎,把听到的一切说出来)", "next": "c3-06-rc1" },
{ "id": "c_confide_atang", "label": "(叫醒阿棠,把听到的一切说出来)", "next": "c3-06-rc2" },
{ "id": "c_confide_wenjian", "label": "(叫醒温简,把听到的一切说出来)【若 flag_wenjian_secret(篝火边他告诉你板子的秘密):温简说过,记录板上报的内容比你多一行。他可能知道些什么。】", "next": "c3-06-rc3" }
{ "id": "c_confide_wenjian", "label": "(叫醒温简,把听到的一切说出来)【若 flag_wenjian_secret = true:温简说过,记录板上报的内容比你多一行。他可能知道些什么。】", "next": "c3-06-rc3" }
]
},
{
+264
View File
@@ -0,0 +1,264 @@
# 《苍梧疑冢》Act 1 插图批量生成任务
你好。附件/下文是一份互动叙事游戏的插图需求清单,共 57 张。
请按以下规则逐张生成插图:
## 全局风格
统一美术风格(每张图都严格套用):水彩手绘漫画质感,暗色调悬疑氛围,电影级光影与构图,克制的暖冷对比;考古现场的旧照片颗粒感与奇幻微光结合。横幅 16:9。画面中不出现任何文字、字母、数字、水印。人物一律以中远景、背影或剪影出现,不要正脸特写(角色立绘后续单独处理,靠远景保持角色一致性)。
## 工作规则
1. 每个编号条目生成**一张**插图,按编号顺序来(编号即优先级,P0 最先)。
2. 每条目的"画面"字段是场景描述,请在不违背风格的前提下自由构图;
描述若引用剧情文本,取其中的**视觉内容**(环境、光线、器物、氛围),忽略叙事性语句。
3. 每条目生成后,先单独展示这张图并标注其编号(如"C1-V01"),便于我逐张下载核对。
4. 每批生成 4 张后停下来,等我回复"继续"再生成下一批——避免触发生成限额导致风格漂移。
5. 如果某条的描述让你不确定画什么,不要自己发明剧情元素,按最接近描述字面意思的静物/空镜处理。
---
## 插图清单
### P0 · 讲述点变体与过场(情感高潮,最优先)
**C1-V01**
画面:「爱被封存」墓室——门后是墓道尽头一方石室,四壁绘着双人同耕、同炊、同观星的壁画,色彩温暖如新。石室中央两座并排的陶棺,棺盖上落满花瓣状的朱砂。空气是静的,静得像有人刚刚还在这里说话。基调:珍重,不是悲伤——有人把最好的东西封存了起来。
**C1-V02**
画面:「战乱乱冢」——门后是荒野,低低的土丘一座挨一座,没有碑,只有削尖的木桩。风把灰白的纸钱碎屑贴在脚踝上,远处有乌鸦起落,天色是黄昏将尽的那种灰蓝。基调:来不及告别——所有话都没说出口,所有名字都没来得及刻上。
**C1-V03**
画面:「远行者的空驿」——门后是一座山间驿站,晨光,桌椅收拾得干干净净,灶膛里的灰还是温的,人已经走了。墙上挂着一件没带走的旧斗篷,桌上压着半张写了又划掉的字条。基调:告别已经完成——走得干净,留得体面,剩下的路各走各的。
**C1-V04**
画面:「被凿毁的合葬龛」——门后是崖壁上的佛龛式墓龛,本该并坐的两尊石像,其中一尊被凿去了面目,凿痕新鲜得刺眼。香烛还燃着,蜡泪流到龛外。基调:被亏欠——有人毁了约,而另一个人连讨说法的对象都没有。
**C1-V05**
画面:【沉默分支】馆长:「门不开。但门记住了你站在它面前。这也是档案。」木门以通用的方式无声开启,门后是朦胧的星光与风声,不对应任何一段具体记忆。
**C1-CS1**
画面:舱门合拢。倒计时的最后十秒,馆长的声音最后一次响起,很近,像贴着耳朵:「C-2049,最后嘱咐一句。那边的三十天里,你会看到很多想不明白的事。别急着明白。先记住。」然后,光灭下去,风声起来。
**C2-V01**
画面:【上档 · 夜探陵区】月色下的陵区,石像生的影子很长。老人亲自带路,指出暗门位置,并赠一句关于苍梧消亡的民间谶语:「不是人走了,是请走的。」他不点火把——「陵里的路,闭眼走比睁眼走直」。基调:被信任的厚重——老人把六十年的秘密交出来,因为他在你的悔里认出了他自己。
**C2-V02**
画面:【中档 · 界碑指路】老人口述暗门方位与开启时辰,不带路,加赠半句谶语。他送到陵区界碑就回窝棚了,你们自己走进月下的封土群,找到他说的那块「会唱歌的石头」。基调:得体的距离——他认可你的坦然,但坦然换不来托付。
**C2-V03**
画面:【下档 · 只给方位】老人听完,编草绳的手停了很久。指了路,说了句「去吧」,就再没开口。没有时辰,没有谶语,陵区的路你们自己摸,多花了半夜。基调:未被接住——不是惩罚,是他不敢把陵区的门交给一个心里还在打仗的人。
**C2-V04**
画面:【沉默 · 编草绳支线】你陪他编了一下午草绳。天黑时老人忽然说:「不说的人,心里的事最重。」他指了暗门方位,没给时辰。基调:笨拙的善意——沉默被尊重,且沉默本身被老人读懂了。
**C2-V05**
画面:【同担】温简听完后沉默很久,然后讲了第二个、剧本外的细节——他哥病房的窗帘颜色。当夜他低声告诉你:档案馆给每个记录员配的板子,上报内容比你知道的多一行。基调:两个把伤口摊开的人,在火边达成不声张的同盟。
**C2-V06**
画面:【半程】温简认真听完,道谢,没再追问。次日他与你同行,路上主动讲了些苍梧的掌故。档案馆疑云他没提。基调:得体,温热,留有余地。
**C2-V07**
画面:【守夜】你们并肩坐到火熄。温简往你手里塞了个烤热的薯,什么都没说。次日他照旧对你点头,不多一分不少一分——但阿棠偷偷告诉你:「温简昨晚跟沈铎说,你这个人『稳』。」基调:沉默被当作一种讲述接住了;温简的亲密度走慢线。
**C3-V01**
画面:【情绪配色 · warm_loss】幻境光色暖,记忆场景完整如昨,告别在「封存」中完成——基调:安放。
**C3-V02**
画面:【情绪配色 · grief】幻境多雨,低饱和,告别对象始终背对,说完才转身——基调:迟到的送别。
**C3-V03**
画面:【情绪配色 · release】幻境是清晨的车站(渡口),告别平静,对方先挥手——基调:两讫。
**C3-V04**
画面:【情绪配色 · grievance】幻境里有一件被毁坏(被夺走)的物件,告别的方式是你决定「放下它还是带走它」——基调:讨还与放手之间,两种处置都开门。
**C3-V05**
画面:【通用幻境 · 回退/沉默档】雾中的长堤,远处有一个看不清面目的人影站着,不走,也不回头。你靠近一步,雾散一分。基调:等待被承认——幻境不催,不吓,只是站着等你。
**C3-CS1**
画面:黑石的画面缓缓沉下去,像深水重新凝住。石面最后亮了一下——映出的是你自己现在的脸。然后石室另一侧,一扇先前不存在的石门,无声滑开。馆长:「门开了。它不要你的记忆,它要的是你承认那是你的。这段不登记入档。问心之室里的事,归你。」
### P1 · 章节主视觉与开场场景
**C1-KV**
画面:章节主视觉:第 1 章 · 登记。考古悬疑题材的主视觉构图,留出标题空间。
**C1-S01**
画面:雨下了三天。你把《苍梧遗址勘探报告(第七次)》的最后一页校完,已经是凌晨一点。这座城你在图上走了七年——城墙的弧度、水门的朝向、西北角那片始终不敢下铲的宫墟,你闭着眼都能画出它的等高线。但它仍然是哑的。没有墓葬被盗扰,没有地层被翻乱,整座城像一颗含了一千年的核桃,完整,沉默,拒绝开口。
**C1-S02**
画面:显示器右下角,弹出一封没有发件人的邮件。只有一行字:「你想不想听听它自己怎么说?——时空档案馆」。你盯着那行字看了很久。窗外雨声很大,像很多人在很远的地方说话。
**C2-KV**
画面:章节主视觉:第 2 章 · 入墟。考古悬疑题材的主视觉构图,留出标题空间。
**C3-KV**
画面:章节主视觉:第 3 章 · 问心。考古悬疑题材的主视觉构图,留出标题空间。
### P2 · 一般场景
**C1-S03**
画面:木门无声地开了。门后不是房间,是星光和风声。穿门而入是一间圆形简报室,四壁挂满苍梧遗址的复原图与勘测照片——比你桌上那些更全、更新,有些角度现实中根本不存在。
**C1-S04**
画面:沈铎核对清单的手停了半拍。阿棠翻了个白眼但没说话。温简看向别处。没人接话。所有人的余光都在你身上。
**C2-S01**
画面:【落点变体 · 若 c1_06_landing = a_safe(稳妥落点)】清晨,城外官道。薄雾,黄土路被夜雨泡软。你研究过一千年的苍梧,可当它真的立在雾里的时候,你第一个念头竟然是:原来城墙上的夯土层是这个颜色。驿站里,客商们在传一件事:城里从上月起,夜里不许点灯了。官府没说为什么。有个老客商压低声音:「听说是宫里传出来的。天上有东西不对。」——【落点变体 · 若 c1_06_landing = b_precise(精确落点)】黄昏……
**C2-S02**
画面:(两变体汇合处)小队按预定暗号在城西水门内会合,建立营地。沈铎分派:明日采集开始。
**C2-S03**
画面:三个人都看着你。沈铎没表态——按规程,资源分配僵局由采集员裁断。火塘上的药罐咕嘟作响。
**C2-S04**
画面:温简当日退烧,次日能下床。老鬼笑嘻嘻记下「新人讲人情」。次日沈铎外勤淋了瘴雨,低烧半日——他瞒着没说,阿棠后来发现,跟老鬼大吵一架。
**C2-S05**
画面:温简烧到第三日才退,虚弱期内无法随行(篝火戏他躺在火边参与)。沈铎与老鬼无恙。老鬼对你明显热络。
**C2-S06**
画面:两人同时看向你。老鬼蹲下系鞋带,明显不想沾手。温简(无论健康状态)轻声说:「你定,我跟着。」
**C2-S07**
画面:主街行进无事,但在主街中段发现一队苍梧官差刚贴出的告示——「夜禁」告示原件,信息量极高的独有采集素材。阿棠服了气,但嘟囔了一路。
**C2-S08**
画面:你先进巷子,中段一根悬梁真的塌了——提前预警,你扑出来,灰头土脸,人没事。全队退回走主街,损失二十分钟。阿棠主动说「是我撺掇的」,沈铎说「探法本身没错,下次用绳」。你获得一条独有观察:塌梁的断口是新茬——这片废墟不久前塌过一次。(flag_fresh_collapse = true,宫墟深处会回收这条观察。)
**C2-S09**
画面:你补全最后一笔的瞬间,天色恰好全沉。头顶,真实的心宿三星升起来了——中间那颗红得发烫,一千年前的火,烧在今晚的天上。
**C2-S10**
画面:夜,营地火塘。白日采集的器物残片在墙根码成一排,月光落在上头。沈铎查哨去了,老鬼睡了——或者装睡。温简往火里添了一根柴,火星升起来,散进夜里。他看着火,忽然开口,声音比平时还低。
**C2-S11**
画面:后半夜,营地角落,物资箱的布帘掀开一角。月光下,老鬼背对营地坐着,掌心摊着一枚玉佩,指腹一遍遍地摩挲。玉佩的形制你在白天的采集登记册上刚见过——是今日出土、已登记在册的东西。他没发现你。
**C2-S12**
画面:玉佩在老鬼掌心转了个面。螭纹,和田料,沁色自然——白天温简登记它的时候,编号是 CW-0317。登记册在沈铎的背囊里。玉佩在老鬼的掌心里。这两样东西之间的距离,就是你现在站着的三步。
**C2-S13**
画面:你退回暗处。十分钟后,老鬼把玉佩放回了箱子,对着箱子轻声说了句:「急什么。」——你至今不知道他那十分钟里改变了什么主意。你守着一个没人知道的秘密,也拿到一条没人有的信息:老鬼会自己收手,但不是因为规矩,是因为时机。
**C2-S14**
画面:入苍梧的第五个夜晚。你躺在营地,听着这座一千年前的城。它太安静了。没有狗吠,没有更鼓,没有婴儿的夜啼。一座十几万人的城,安静得像屏着一口气。
**C2-S15**
画面:星图上那颗大火星,正在一夜一夜地,走向南方的正中。城在等。你现在知道了。只是还不知道,它在等的那一天,是你们的第几天。
**C3-S01**
画面:暗门后的台阶比想象中长。走到一半你就明白了:这不是墓。墓是让人安息的地方,这里是让人等待的地方。祭祀坑一层压着一层,器物成组地朝着南方,像一片朝着太阳倒伏的麦田。考古报告里那个你标注过无数次的词,此刻从纸上站了起来——禳。这不是下葬。这是祈福。整座宫墟,是苍梧人修给上天的、一封加急的信。
**C3-S02**
画面:台阶转角处,你又见到了那种新茬——塌过的夯土,被人重新夯实过。不久之前,有人在这里修过路。往下走。城在等的那一天到来之前,已经有人急着下去了。
**C3-S03**
画面:台基之下,暗门尽头,一间不在任何勘探图上的石室。四壁无画无字,只有中央一面立着的黑石——石面不像打磨的,像凝住的深水。沈铎的探灯照上去,光被吸进去,没有反光。老鬼在入口就停下了脚步:「这个……哥哥在外面替你们望风。」
**C3-S04**
画面:阿棠那句「一块黑石头,能怎么问」没有说完。黑石表面,像深水被搅动,浮起了一个画面。那是你的记忆——你在离别之门讲过的那一段:那天的光线,那个人的背影,那件你说过或没说出口的事,此刻在黑石里,一帧一帧,亮给你看。
**C3-S05**
画面:话音未落,前方传来阿棠的喊声和一声闷响。甬道中段,一段夯土塌了:阿棠被滑落的一方石构件压住了小腿,人清醒,挣不脱。同一时刻,甬道另一侧——你们今早刚刚清理出的工作面上,那组七鼎六簋正随着台基的颤动缓缓滑向裂开的地缝。鼎簋一旦落缝,是粉身碎骨;那是苍梧国祭唯一一套完整留存、还带着使用痕的实证。
**C3-S06**
画面:头顶,第二声闷响在逼近。沈铎扑向阿棠,一个人的力气撼不动石构件,他吼:「来人!」温简已经扑向鼎簋那边,用身体抵住滑动的器座,回头看你的那一眼没有求助——是托付。两边都需要你。你只能先到一边。
**C3-S07**
画面:阿棠脱困,小腿挫伤。鼎簋中最外侧的一件鼎坠入地缝,闷响之后是死寂。温简在裂缝边跪了很久,没说一个字。事后他对你说的第一句话是:「人没事就好。」——你听得出,这句话他是说给自己听的。
**C3-S08**
画面:鼎簋全组保住。阿棠脱困晚了四分钟,小腿骨裂,撤离时沈铎背着她。她疼得满头汗还冲你笑:「哎,选得对,那套鼎比我金贵——别告诉沈铎我这么说。」但她夜里疼醒三次。
**C3-S09**
画面:沈铎的笔停在文书上方。他抬起头,看向你:「你是目击链上的人。报上去之前,我需要听听你的。」
**C3-S10**
画面:沈铎点头,文书发出。老鬼盯着你看了一眼,那一眼里没有恨,只有一种重新估价。他当夜被限制行动。次日他对你说:「做得对。是哥哥我小看你了。」——你不知道这句是不是反话。沈铎在文书副本里为你附了一句「该员判断稳定」。
**C3-S11**
画面:沈铎看你两秒,点头:「好。」文书发出。老鬼被限制行动。无人对你有微词,也无人对你有谢意。阿棠夜里问温简:「哎,你说他到底怎么想的?」温简答:「不知道。可能他自己也不知道。」——沉默被全队记下了,读法各一。
**C3-S12**
画面:阿棠咧嘴笑了,拍拍身边的位置让你坐。沈铎欲言又止,最终在记录板上把「主绳保管人」一栏改成了你的编号。
**C3-S13**
画面:通话末尾还有一句,声音更低,几乎不像在通话,像在自言自语:「……是的,托付那类,最不好办。」
**C3-S14**
画面:通话结束。电流声熄灭。墙那侧重新只剩风声。你站在断墙的阴影里,手里攥着的东西(哨子、绳包扣,或空的手)不知什么时候攥出了汗。问心石是档案馆放的。三十天的尽头有什么,档案馆知道,且不打算让你们来得及回头。还有那件璧——空了的璧座,等了一千年的国祭——档案馆说,一个字都不许提。
**C3-S15**
画面:你躺回睡袋,一夜无眠。次日晨,沈铎宣布撤离计划,馆长照常播报,声音温和如常。你发现自己开始注意所有人的手——谁在背后有动作,谁的笑容什么时候到不了眼睛。「不信任」改变了你看世界的方式。
**C3-S16**
画面:撤离的晨光里,苍梧城在身后一点点退远。大火星这几夜已经挨近了南方的正中——火中,而后城安。你数过了:按星位推算,那一天,是九天后。而你们的回程,定在八天后。档案馆让你们早走一天。(第 1 章完——Act 2「裂缝」,敬请期待。)
---
## 编号映射表(出图后按"建议文件名"重命名归位)
| 编号 | 来源 | 建议文件名 |
|---|---|---|
| C1-V01 | C1-02(记忆钥匙)变体 sealed_love_tomb · 情绪签名「温暖×永失」 | assets/art/chapter1/C1-02-sealed_love_tomb.png |
| C1-V02 | C1-02(记忆钥匙)变体 war_graves · 情绪签名「悲恸」 | assets/art/chapter1/C1-02-war_graves.png |
| C1-V03 | C1-02(记忆钥匙)变体 empty_post_station · 情绪签名「释然」 | assets/art/chapter1/C1-02-empty_post_station.png |
| C1-V04 | C1-02(记忆钥匙)变体 desecrated_niche · 情绪签名「怨怼」 | assets/art/chapter1/C1-02-desecrated_niche.png |
| C1-V05 | C1-02(记忆钥匙)变体 generic_fog · 情绪签名「沉默」 | assets/art/chapter1/C1-02-generic_fog.png |
| C1-CS1 | c1-cutscene 过场(cutscene-crossing) | assets/art/chapter1/c1-cutscene-cutscene.png |
| C2-V01 | C2-02(故事换情报)变体 intel_full · 情绪签名「自责」 | assets/art/chapter2/C2-02-intel_full.png |
| C2-V02 | C2-02(故事换情报)变体 intel_mid · 情绪签名「遗憾但坦然」 | assets/art/chapter2/C2-02-intel_mid.png |
| C2-V03 | C2-02(故事换情报)变体 intel_low · 情绪签名「愤怒指向他人」 | assets/art/chapter2/C2-02-intel_low.png |
| C2-V04 | C2-02(故事换情报)变体 intel_silent · 情绪签名「沉默」 | assets/art/chapter2/C2-02-intel_silent.png |
| C2-V05 | C2-05(篝火互惠)变体 wenjian_close_early · 情绪签名「深暴露」 | assets/art/chapter2/C2-05-wenjian_close_early.png |
| C2-V06 | C2-05(篝火互惠)变体 wenjian_close_late · 情绪签名「浅暴露」 | assets/art/chapter2/C2-05-wenjian_close_late.png |
| C2-V07 | C2-05(篝火互惠)变体 wenjian_distant · 情绪签名「沉默」 | assets/art/chapter2/C2-05-wenjian_distant.png |
| C3-V01 | C3-02(问心幻境)变体 sealed_love_tomb · 情绪签名「温暖×永失」 | assets/art/chapter3/C3-02-sealed_love_tomb.png |
| C3-V02 | C3-02(问心幻境)变体 war_graves · 情绪签名「悲恸」 | assets/art/chapter3/C3-02-war_graves.png |
| C3-V03 | C3-02(问心幻境)变体 empty_post_station · 情绪签名「释然」 | assets/art/chapter3/C3-02-empty_post_station.png |
| C3-V04 | C3-02(问心幻境)变体 desecrated_niche · 情绪签名「怨怼」 | assets/art/chapter3/C3-02-desecrated_niche.png |
| C3-V05 | C3-02(问心幻境)变体 generic_fog · 情绪签名「沉默」 | assets/art/chapter3/C3-02-generic_fog.png |
| C3-CS1 | c3-cutscene 过场(cutscene-illusion-clear) | assets/art/chapter3/c3-cutscene-cutscene.png |
| C1-KV | chapter1 章节主视觉 | assets/art/chapter1/key-visual.png |
| C1-S01 | c1-open1 场景旁白 | assets/art/chapter1/c1-open1.png |
| C1-S02 | c1-open2 场景旁白 | assets/art/chapter1/c1-open2.png |
| C2-KV | chapter2 章节主视觉 | assets/art/chapter2/key-visual.png |
| C3-KV | chapter3 章节主视觉 | assets/art/chapter3/key-visual.png |
| C1-S03 | c1-d4a 场景旁白 | assets/art/chapter1/c1-d4a.png |
| C1-S04 | c1-d5e 场景旁白 | assets/art/chapter1/c1-d5e.png |
| C2-S01 | c2-d1 场景旁白 | assets/art/chapter2/c2-d1.png |
| C2-S02 | c2-d1a 场景旁白 | assets/art/chapter2/c2-d1a.png |
| C2-S03 | c2-d2e 场景旁白 | assets/art/chapter2/c2-d2e.png |
| C2-S04 | c2-01-ra 场景旁白 | assets/art/chapter2/c2-01-ra.png |
| C2-S05 | c2-01-rb 场景旁白 | assets/art/chapter2/c2-01-rb.png |
| C2-S06 | c2-d4f 场景旁白 | assets/art/chapter2/c2-d4f.png |
| C2-S07 | c2-03-ra 场景旁白 | assets/art/chapter2/c2-03-ra.png |
| C2-S08 | c2-03-rc 场景旁白 | assets/art/chapter2/c2-03-rc.png |
| C2-S09 | c2-04-ok 场景旁白 | assets/art/chapter2/c2-04-ok.png |
| C2-S10 | c2-d6 场景旁白 | assets/art/chapter2/c2-d6.png |
| C2-S11 | c2-d7a 场景旁白 | assets/art/chapter2/c2-d7a.png |
| C2-S12 | c2-d7b 场景旁白 | assets/art/chapter2/c2-d7b.png |
| C2-S13 | c2-06-rc 场景旁白 | assets/art/chapter2/c2-06-rc.png |
| C2-S14 | c2-d8 场景旁白 | assets/art/chapter2/c2-d8.png |
| C2-S15 | c2-d8a 场景旁白 | assets/art/chapter2/c2-d8a.png |
| C3-S01 | c3-d1 场景旁白 | assets/art/chapter3/c3-d1.png |
| C3-S02 | c3-d1a 场景旁白 | assets/art/chapter3/c3-d1a.png |
| C3-S03 | c3-d2 场景旁白 | assets/art/chapter3/c3-d2.png |
| C3-S04 | c3-d2d 场景旁白 | assets/art/chapter3/c3-d2d.png |
| C3-S05 | c3-d3a 场景旁白 | assets/art/chapter3/c3-d3a.png |
| C3-S06 | c3-d3b 场景旁白 | assets/art/chapter3/c3-d3b.png |
| C3-S07 | c3-03-ra 场景旁白 | assets/art/chapter3/c3-03-ra.png |
| C3-S08 | c3-03-rb 场景旁白 | assets/art/chapter3/c3-03-rb.png |
| C3-S09 | c3-d4c 场景旁白 | assets/art/chapter3/c3-d4c.png |
| C3-S10 | c3-04-ra 场景旁白 | assets/art/chapter3/c3-04-ra.png |
| C3-S11 | c3-04-rc 场景旁白 | assets/art/chapter3/c3-04-rc.png |
| C3-S12 | c3-05-ra 场景旁白 | assets/art/chapter3/c3-05-ra.png |
| C3-S13 | c3-d6c 场景旁白 | assets/art/chapter3/c3-d6c.png |
| C3-S14 | c3-d6d 场景旁白 | assets/art/chapter3/c3-d6d.png |
| C3-S15 | c3-06-rb 场景旁白 | assets/art/chapter3/c3-06-rb.png |
| C3-S16 | c3-d7 场景旁白 | assets/art/chapter3/c3-d7.png |
+2 -1
View File
@@ -10,7 +10,8 @@
"preview": "vite preview",
"tauri": "tauri",
"verify": "tsx scripts/verify.ts",
"verify:live": "tsx scripts/verify-live.ts"
"verify:live": "tsx scripts/verify-live.ts",
"art:prompts": "tsx scripts/extract-art-prompts.ts"
},
"dependencies": {
"@tauri-apps/api": "^2.12.1",
+162
View File
@@ -0,0 +1,162 @@
/**
* 插图提示词萃取:从 content/chapters/*.json 提取需要配插图的场景,
* 生成一份可一次性上传给 ChatGPT 网页版的提示词文件(docs/art/illustration-prompts.md)。
*
* 运行:pnpm art:prompts
*
* 萃取范围:
* - P0 讲述点场景变体(narration.variants,情感高潮戏,最优先)
* - P0 过场(cutscene)
* - P1 章节主视觉(每章一张)
* - P2 场景对话节点(dialogue 且 sprite="scene")
* 编号与章节/节点 ID 的映射表附在文末,下载后按编号重命名归位。
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
interface SceneVariant { signature: string; variantId: string; label: string; tint?: string }
interface StoryNode {
id: string; type: string;
speaker?: string; text?: string; sprite?: string;
pointId?: string; mechanism?: string;
variants?: SceneVariant[];
video?: string; caption?: string;
}
interface Chapter { id: string; act: number; title: string; nodes: StoryNode[] }
interface ArtItem {
code: string; // 出图编号,如 C1-V01
priority: 'P0' | 'P1' | 'P2';
source: string; // 来源(章节/节点/变体)
filename: string; // 建议保存路径
scene: string; // 画面说明(中文,喂给出图模型)
}
const CHAPTERS = ['chapter1', 'chapter2', 'chapter3'];
/** 清掉文案里的玩家提示括号与条件标记,只留画面内容 */
function cleanSceneText(text: string): string {
return text
.replace(/【若[^】]*】/g, '')
.replace(/([^)]*(?:点击|确认|知晓|提示|按住|选择|视为)[^)]*)/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function truncate(text: string, max = 220): string {
return text.length > max ? text.slice(0, max) + '……' : text;
}
const items: ArtItem[] = [];
for (const chId of CHAPTERS) {
const chapter = JSON.parse(readFileSync(join(ROOT, 'content/chapters', `${chId}.json`), 'utf-8')) as Chapter;
const chNum = chId.replace('chapter', 'C');
items.push({
code: `${chNum}-KV`,
priority: 'P1',
source: `${chId} 章节主视觉`,
filename: `assets/art/${chId}/key-visual.png`,
scene: `章节主视觉:${chapter.title}。考古悬疑题材的主视觉构图,留出标题空间。`,
});
let sceneIdx = 0, variantIdx = 0, cutIdx = 0;
const seen = new Set<string>();
for (const node of chapter.nodes) {
if (node.type === 'narration' && node.variants) {
for (const v of node.variants) {
variantIdx += 1;
const code = `${chNum}-V${String(variantIdx).padStart(2, '0')}`;
items.push({
code,
priority: 'P0',
source: `${node.pointId}(${node.mechanism})变体 ${v.variantId} · 情绪签名「${v.signature}」`,
filename: `assets/art/${chId}/${node.pointId}-${v.variantId}.png`,
scene: cleanSceneText(v.label),
});
}
} else if (node.type === 'cutscene') {
cutIdx += 1;
items.push({
code: `${chNum}-CS${cutIdx}`,
priority: 'P0',
source: `${node.id} 过场(${node.video})`,
filename: `assets/art/${chId}/${node.id}-cutscene.png`,
scene: cleanSceneText(node.caption ?? ''),
});
} else if (node.type === 'dialogue' && node.sprite === 'scene' && node.text) {
const scene = cleanSceneText(node.text);
if (scene.length < 20 || seen.has(scene)) continue;
seen.add(scene);
sceneIdx += 1;
const isOpen = node.id.includes('open');
items.push({
code: `${chNum}-S${String(sceneIdx).padStart(2, '0')}`,
priority: isOpen ? 'P1' : 'P2',
source: `${node.id} 场景旁白`,
filename: `assets/art/${chId}/${node.id}.png`,
scene: truncate(scene),
});
}
}
}
const STYLE = [
'统一美术风格(每张图都严格套用):水彩手绘漫画质感,暗色调悬疑氛围,',
'电影级光影与构图,克制的暖冷对比;考古现场的旧照片颗粒感与奇幻微光结合。',
'横幅 16:9。画面中不出现任何文字、字母、数字、水印。',
'人物一律以中远景、背影或剪影出现,不要正脸特写(角色立绘后续单独处理,靠远景保持角色一致性)。',
].join('');
const HEADER = `# 《苍梧疑冢》Act 1 插图批量生成任务
你好。附件/下文是一份互动叙事游戏的插图需求清单,共 ${items.length} 张。
请按以下规则逐张生成插图:
## 全局风格
${STYLE}
## 工作规则
1. 每个编号条目生成**一张**插图,按编号顺序来(编号即优先级,P0 最先)。
2. 每条目的"画面"字段是场景描述,请在不违背风格的前提下自由构图;
描述若引用剧情文本,取其中的**视觉内容**(环境、光线、器物、氛围),忽略叙事性语句。
3. 每条目生成后,先单独展示这张图并标注其编号(如"C1-V01"),便于我逐张下载核对。
4. 每批生成 4 张后停下来,等我回复"继续"再生成下一批——避免触发生成限额导致风格漂移。
5. 如果某条的描述让你不确定画什么,不要自己发明剧情元素,按最接近描述字面意思的静物/空镜处理。
---
## 插图清单
`;
const PRIORITY_ORDER: Record<ArtItem['priority'], number> = { P0: 0, P1: 1, P2: 2 };
const sorted = [...items].sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
const sections: string[] = [];
let currentPriority = '';
for (const item of sorted) {
if (item.priority !== currentPriority) {
currentPriority = item.priority;
const label = { P0: 'P0 · 讲述点变体与过场(情感高潮,最优先)', P1: 'P1 · 章节主视觉与开场场景', P2: 'P2 · 一般场景' }[item.priority];
sections.push(`\n### ${label}\n`);
}
sections.push(`**${item.code}**\n画面:${item.scene}\n`);
}
const MAPPING = `\n---\n\n## 编号映射表(出图后按"建议文件名"重命名归位)\n\n| 编号 | 来源 | 建议文件名 |\n|---|---|---|\n${sorted.map((i) => `| ${i.code} | ${i.source} | ${i.filename} |`).join('\n')}\n`;
mkdirSync(join(ROOT, 'docs/art'), { recursive: true });
const outPath = join(ROOT, 'docs/art/illustration-prompts.md');
writeFileSync(outPath, HEADER + sections.join('\n') + MAPPING, 'utf-8');
const counts = { P0: 0, P1: 0, P2: 0 };
for (const i of sorted) counts[i.priority] += 1;
console.log(`✓ 共 ${items.length} 张插图需求(P0:${counts.P0} P1:${counts.P1} P2:${counts.P2})`);
console.log(`✓ 输出:${outPath}`);
+180 -3
View File
@@ -6,14 +6,18 @@
* 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本;
* 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete;
* SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。
* 6. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过;
* 7. 条件文本:flag 推导(附录 A)、【若】标记零泄漏、差分显隐、条件不满足节点跳过无死路。
* 运行:pnpm verify
*/
import { readFileSync } from 'node:fs';
import { StoryEngine } from '../src/engine/engine';
import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine';
import { LocalStorageEventStore } from '../src/engine/eventStore';
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import { choiceFlags, narrationFlags } from '../src/engine/flags';
import { MemoryProgressStore } from '../src/engine/progress';
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
import type { Chapter, ChoiceNode, IdentityProfile, NarrationNode } from '../src/engine/types';
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev';
import type { JevAnswer, JevQuestion } from '../src/services/jev';
@@ -294,8 +298,181 @@ for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) {
);
}
// --- 9. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过 ---
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
check(!(await eng.hasConsent()), '同意门:无 consent 事件时需要展示同意门');
await eng.grantConsent();
const ev = s.events.find((e) => e.kind === 'consent');
check(
!!ev && ev.consentVersion === CONSENT_VERSION && ev.ts > 0,
`同意动作落 consent 事件(consentVersion=${ev?.consentVersion},含时间戳)`,
);
await eng.grantConsent();
check(s.events.filter((e) => e.kind === 'consent').length === 1, '重复同意幂等(只存证一次)');
const eng2 = new StoryEngine(chapters, s, new MockJevClient());
check(await eng2.hasConsent(), 'consent 事件已存在则同意门跳过(全剧只出现一次)');
}
// --- 10. 条件文本:flag 推导、标记不泄漏、差分显隐、空节点跳过 ---
// 10a. 变量映射表(附录 A)
check(choiceFlags('C2-03', 'c_scout').flag_fresh_collapse === 'true', 'flag 推导:C2-03 c_scout → flag_fresh_collapse=true');
check(choiceFlags('C2-01', 'b_exposure').wenjian_health === 'weak', 'flag 推导:C2-01 b_exposure → wenjian_health=weak');
check(choiceFlags('C2-01', 'a_weakest').wenjian_health === 'recovering', 'flag 推导:C2-01 a_weakest → wenjian_health=recovering');
check(narrationFlags('C3-02', undefined, null).c3_02_mode === 'exit_room', 'flag 推导:C3-02 沉默退出 → c3_02_mode=exit_room');
{
const f = choiceFlags('C3-06', 'c_confide_wenjian');
check(
f.c3_06_response === 'c_confide' && f.c3_06_confidant === 'wenjian' && f.flag_arch_doubt === 'true',
'flag 推导:C3-06 队友子分支 → c_confide / confidant=wenjian / flag_arch_doubt',
);
}
check(narrationFlags('C2-05', 'wenjian_close_early', '(讲述)').flag_wenjian_secret === 'true', 'flag 推导:C2-05 深暴露 → flag_wenjian_secret');
/** 走查三章并收集途经节点与渲染文本;picker 可指定选择点的选项 */
async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
const seen: string[] = [];
const rendered: string[] = [];
let guard = 0;
while (!eng.state.finished && guard++ < 250) {
const node = eng.currentNode();
seen.push(node.id);
if (node.type === 'dialogue') rendered.push(eng.renderText(node.text));
else if (node.type === 'cutscene') rendered.push(eng.renderText(node.caption));
else if (node.type === 'choice') rendered.push(eng.renderText(node.prompt), ...node.options.map((o) => eng.renderText(o.label)));
else if (node.type === 'narration' || node.type === 'identity') rendered.push(eng.renderText(node.prompt));
if (node.type === 'choice') {
await eng.choose(picker[node.pointId] ?? node.options[0].id);
} else if (node.type === 'identity') {
await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
} else if (node.type === 'narration') {
if (node.pointId === 'C3-02' && picker.C3_02_SILENT) await eng.skipNarration();
else {
await eng.submitNarration('(验证用占位讲述)');
if (eng.state.pendingFollowUp) await eng.answerFollowUp('(验证用追问回答)');
}
} else {
eng.advance();
}
}
return { seen, rendered, finished: eng.state.finished };
}
// 10b. 默认路径(全取首选项):标记零泄漏 + 条件节点按 flag 跳过
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
const { seen, rendered, finished } = await walkAll(eng);
check(finished, '条件系统下默认路径三章跑通');
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(默认路径)');
check(seen.includes('c2-d1') && !seen.includes('c2-d1v'), '落点 a_safe:变体 A 节点可见、变体 B 节点被跳过');
check(!seen.includes('c3-d1a'), '未选 c_scout:flag_fresh_collapse 差分节点被跳过(无死路,继续走到 c3-d1b)');
check(seen.includes('c3-d1b'), '跳过差分节点后落到主线下一节点');
check(!seen.includes('c3-d6c'), '动机非 d_trust:§3-7 追加句节点被跳过');
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
check(
d5a?.type === 'dialogue' && !eng.renderText(d5a.text).includes('通讯频道'),
'wenjian_health=recovering:温简在场,「通讯频道」内联段不渲染',
);
}
// 10c. 差分路径:c_scout 塌梁 + b_exposure 温简虚弱 + C3-02 沉默退出
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
const { seen, rendered, finished } = await walkAll(eng, { 'C2-03': 'c_scout', 'C2-01': 'b_exposure', C3_02_SILENT: '1' });
check(finished, '条件系统下差分路径三章跑通');
check(seen.includes('c3-d1a'), '选了 c_scout:flag_fresh_collapse 差分节点可见');
check(eng.state.flags.flag_fresh_collapse === 'true' && eng.state.flags.wenjian_health === 'weak', '引擎 flags 随选择增量更新');
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
check(
d5a?.type === 'dialogue' && eng.renderText(d5a.text).includes('通讯频道'),
'wenjian_health=weak:温简台词的「通讯频道」内联段渲染',
);
const cut = chapters[2].nodes.find((n) => n.id === 'c3-cutscene');
check(
cut?.type === 'cutscene' && eng.renderText(cut.caption).includes('不出来的人'),
'C3-02 沉默退出:cutscene 追加馆长退出台词(exit_room 内联段)',
);
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)');
}
// --- 11. 进度持久化与恢复(真机刷新/热更新断点续玩) ---
{
const s = new MemoryStore();
const progress = new MemoryProgressStore();
const eng = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
await walkTo(eng, 'c1-d5'); // 途经 C1-01 身份点与 C1-03 选择点(产生 flag)
const snap = progress.load();
check(
snap?.chapterId === 'chapter1' && snap.nodeId === 'c1-d5',
`推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`,
);
const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
const restored = eng2.restore(progress.load());
check(
restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId,
'重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)',
);
check(eng2.state.flags.c1_03_motive === 'a_truth', '恢复后 flags 随快照还原(条件文本不断档)');
// 内容更新导致持久化节点消失 → 降级该章开头
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
check(
eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) &&
eng3.currentNode().id === chapters[0].startNodeId,
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
);
check(!eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} }), '持久化章节不存在 → 不恢复');
// replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点)
eng2.replay('chapter2');
const after = progress.load();
check(
after?.chapterId === 'chapter2' &&
after.nodeId === eng2.state.nodeId &&
eng2.state.sessionId !== snap?.sessionId,
'replay 产生新 sessionId 并重置持久化位置到目标章(跳过条件隐藏节点后的首个可见节点)',
);
}
// --- 12. Jev 调用失败优雅降级(CORS / 网络故障不卡死) ---
class FailingJev extends MockJevClient {
override async ask(): Promise<Record<string, JevAnswer>> {
throw new Error('模拟 Jev 故障:CORS / network');
}
}
{
// 12a. 讲述流程:缺失检测失败 → 不追问;情绪签名失败 → 沉默/通用档变体;节点照常推进
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FailingJev());
await walkTo(eng, 'c1-02');
const r = await eng.submitNarration('去年夏天我离开了家乡,站台上都是雾');
check(r !== null && eng.state.pendingFollowUp === null, 'Jev 故障:缺失检测失败降级为不追问,流程继续');
check(eng.state.nodeId === 'c1-d4', 'Jev 故障:变体解析失败后节点照常推进');
const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode;
const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!;
const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02');
check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体');
// 12b. 身份抽取失败 → 全空字段(UI 直接进可手填确认页),不抛出
const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev());
await walkTo(eng2, 'c1-01');
let profile: IdentityProfile | null = null;
let threw = false;
try {
profile = await eng2.extractIdentity('我叫阿黎,29岁,在杭州');
} catch {
threw = true;
}
check(
!threw && profile !== null && Object.values(profile).every((v) => v === ''),
'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)',
);
await eng2.confirmIdentity(profile!);
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
}
if (failures > 0) {
console.error(`\n${failures} 项校验失败`);
process.exit(1);
}
console.log('\n全部校验通过');
console.log('\n全部校验通过');
+41
View File
@@ -331,3 +331,44 @@ button.secondary {
color: #e8dcc8;
font-size: 15px;
}
/* ---- 同意门(ADR-0004) ---- */
.consent-box {
background: rgba(20, 16, 30, 0.85);
border: 1px solid #3a3248;
border-radius: 12px;
padding: 24px;
max-width: 560px;
margin: 48px auto;
}
.consent-box h2 {
color: #f0e6d2;
font-size: 20px;
margin: 8px 0 16px;
}
.consent-list {
color: #d8ccb8;
font-size: 15px;
line-height: 1.8;
padding-left: 20px;
margin-bottom: 20px;
}
.consent-list li {
margin-bottom: 8px;
}
.consent-note {
color: #d8ccb8;
font-size: 15px;
line-height: 1.7;
margin-bottom: 16px;
}
.consent-box .primary,
.consent-box .secondary {
margin-right: 10px;
margin-bottom: 10px;
}
+31 -3
View File
@@ -1,11 +1,13 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import './App.css';
import { StoryEngine } from './engine/engine';
import { LocalStorageEventStore } from './engine/eventStore';
import { LocalStorageProgressStore } from './engine/progress';
import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore';
import type { Chapter } from './engine/types';
import { getJevClient } from './services/jev';
import { StoryPlayer } from './ui/StoryPlayer';
import { ConsentGate } from './ui/ConsentGate';
import { ReportPage } from './ui/report/ReportPage';
import chapter1 from '../content/chapters/chapter1.json';
import chapter2 from '../content/chapters/chapter2.json';
@@ -15,14 +17,40 @@ const chapters = [chapter1, chapter2, chapter3] as Chapter[];
export default function App() {
const [screen, setScreen] = useState<'story' | 'report'>('story');
// consent: unknown=查询中 / needed=待同意 / granted=已同意(事件存证命中,同意门只出现一次)
const [consent, setConsent] = useState<'unknown' | 'needed' | 'granted'>('unknown');
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
// 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口)
const { engine, store, jev } = useMemo(() => {
const { engine, store, jev, progress } = useMemo(() => {
const store = isTauriRuntime() ? new SqliteEventStore() : new LocalStorageEventStore();
const jev = getJevClient();
return { engine: new StoryEngine(chapters, store, jev), store, jev };
const progress = new LocalStorageProgressStore();
return { engine: new StoryEngine(chapters, store, jev, undefined, progress), store, jev, progress };
}, []);
useEffect(() => {
void engine.hasConsent().then((ok) => {
// 同意门之后才恢复剧情进度(刷新/热更新前的断点)
if (ok) engine.restore(progress.load());
setConsent(ok ? 'granted' : 'needed');
});
}, [engine, progress]);
if (consent !== 'granted') {
return (
<div className="app">
{consent === 'needed' && (
<ConsentGate
onAgree={async () => {
await engine.grantConsent();
setConsent('granted');
}}
/>
)}
</div>
);
}
return (
<div className="app">
{screen === 'story' ? (
+138 -19
View File
@@ -2,7 +2,13 @@ import type { Chapter, FollowUpDimension, IdentityProfile, NarrationNode, SceneV
import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types';
import type { EventStore, StoryEvent } from './eventStore';
import { makeEvent } from './eventStore';
import type { JevClient, JevQuestion } from '../services/jev';
import type { JevAnswer, JevClient, JevQuestion } from '../services/jev';
import type { Flags } from './flags';
import { choiceFlags, narrationFlags, resolveConditionalText } from './flags';
import type { ProgressSnapshot, ProgressStore } from './progress';
/** 告知文案版本:文案变动时升版,consent 事件存证所同意的版本(ADR-0004) */
export const CONSENT_VERSION = 'act1-consent-v1';
export interface EngineState {
sessionId: string;
@@ -12,6 +18,8 @@ export interface EngineState {
activeVariant: SceneVariant | null;
/** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */
pendingFollowUp: PendingFollowUp | null;
/** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */
flags: Flags;
/** 全部三章完成后为 true → 进入画像报告 */
finished: boolean;
}
@@ -61,13 +69,15 @@ export class StoryEngine {
private chapters: Chapter[];
private store: EventStore;
private jev: JevClient;
private progress?: ProgressStore;
private nodeIndex: Map<string, StoryNode> = new Map();
constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string) {
constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string, progress?: ProgressStore) {
if (chapters.length === 0) throw new Error('StoryEngine: no chapters');
this.chapters = chapters;
this.store = store;
this.jev = jev;
this.progress = progress;
const start = chapters.find((c) => c.id === startChapterId) ?? chapters[0];
for (const c of chapters) {
for (const n of c.nodes) this.nodeIndex.set(`${c.id}/${n.id}`, n);
@@ -78,8 +88,10 @@ export class StoryEngine {
nodeId: start.startNodeId,
activeVariant: null,
pendingFollowUp: null,
flags: {},
finished: false,
};
this.skipHiddenNodes();
}
currentChapter(): Chapter {
@@ -110,6 +122,61 @@ export class StoryEngine {
} else {
throw new Error(`advance() 不适用于 ${node.type} 节点`);
}
this.skipHiddenNodes();
this.persist();
}
/** 进度持久化:每次状态迁移后落盘(真机刷新/热更新后从断点恢复) */
private persist(): void {
this.progress?.save({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
nodeId: this.state.nodeId,
flags: { ...this.state.flags },
});
}
/**
* 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时,
* 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。
*/
restore(snapshot: ProgressSnapshot | null): boolean {
if (!snapshot) return false;
const chapter = this.chapters.find((c) => c.id === snapshot.chapterId);
if (!chapter) return false;
const nodeExists = this.nodeIndex.has(`${snapshot.chapterId}/${snapshot.nodeId}`);
this.state.sessionId = snapshot.sessionId;
this.state.chapterId = snapshot.chapterId;
this.state.nodeId = nodeExists ? snapshot.nodeId : chapter.startNodeId;
this.state.flags = { ...snapshot.flags };
this.state.activeVariant = null;
this.state.pendingFollowUp = null;
this.state.finished = false;
this.skipHiddenNodes();
this.persist();
return true;
}
/**
* 条件节点跳过:节点级【若 …】条件不满足的 dialogue / cutscene 对玩家不可见,
* 直接走到 next(条件分支不产生死路)。每次状态迁移后调用。
*/
private skipHiddenNodes(): void {
for (let guard = 0; guard < 100; guard++) {
const node = this.currentNode();
if (node.type !== 'dialogue' && node.type !== 'cutscene') return;
const text = node.type === 'dialogue' ? node.text : node.caption;
if (resolveConditionalText(text, this.state.flags) !== null) return;
this.state.nodeId = node.next;
}
}
/**
* 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段),
* 返回玩家可见文本。条件标记本身永不渲染给玩家。
*/
renderText(text: string): string {
return resolveConditionalText(text, this.state.flags) ?? '';
}
/** B 类选择:落事件并按选项跳转 */
@@ -129,7 +196,10 @@ export class StoryEngine {
optionLabel: option.label,
}),
);
Object.assign(this.state.flags, choiceFlags(node.pointId, option.id));
this.state.nodeId = option.next;
this.skipHiddenNodes();
this.persist();
}
/**
@@ -143,7 +213,13 @@ export class StoryEngine {
if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
if (this.state.pendingFollowUp) throw new Error('有未处理的追问,请先 answerFollowUp / skipFollowUp');
if (transcript === null) return this.finalizeNarration(node, null);
const missing = await this.detectMissing(node, transcript);
// Jev 故障(CORS / 网络)时降级:跳过追问环节,剧情继续,不卡死
let missing: FollowUpDimension | null = null;
try {
missing = await this.detectMissing(node, transcript);
} catch (e) {
console.warn('[engine] 缺失检测失败,跳过追问继续剧情', e);
}
if (missing) {
this.state.pendingFollowUp = {
pointId: node.pointId,
@@ -235,8 +311,11 @@ export class StoryEngine {
transcript,
});
await this.store.append(event);
Object.assign(this.state.flags, narrationFlags(node.pointId, variant.variantId, transcript));
this.state.activeVariant = variant;
this.state.nodeId = node.next;
this.skipHiddenNodes();
this.persist();
return { variant, event };
}
@@ -260,23 +339,28 @@ export class StoryEngine {
const silent = node.variants.find((v) => v.signature.includes('沉默'));
return silent ?? node.variants[node.variants.length - 1];
}
// 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)
const answer = await this.jev.ask(
{ transcript, pointId: node.pointId },
{
emotion_signature: {
type: 'choice',
instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])),
// 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)。
// Jev 故障时降级到"沉默/通用"档变体继续剧情,console.warn 记录,不向用户报错。
try {
const answer = await this.jev.ask(
{ transcript, pointId: node.pointId },
{
emotion_signature: {
type: 'choice',
instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])),
},
},
},
);
const picked = answer.emotion_signature;
if (picked?.type === 'choice') {
const match = node.variants.find((v) => v.signature === picked.choice);
if (match) return match;
);
const picked = answer.emotion_signature;
if (picked?.type === 'choice') {
const match = node.variants.find((v) => v.signature === picked.choice);
if (match) return match;
}
} catch (e) {
console.warn('[engine] 情绪签名解析失败,降级到沉默/通用档变体', e);
}
return node.variants[0];
return node.variants.find((v) => v.signature.includes('沉默')) ?? node.variants[0];
}
/**
@@ -337,7 +421,14 @@ export class StoryEngine {
},
},
};
const answers = await this.jev.ask({ transcript }, questions);
// Jev 故障(CORS / 网络)时降级:返回全空字段,直接进入可手填的确认页
let answers: Record<string, JevAnswer>;
try {
answers = await this.jev.ask({ transcript }, questions);
} catch (e) {
console.warn('[engine] 身份字段抽取失败,降级为手填确认页', e);
return { city: '', age: '', gender: '', occupation: '', education: '' };
}
const choiceVal = (id: string): string => {
const a = answers[id];
return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : '';
@@ -370,6 +461,31 @@ export class StoryEngine {
}),
);
this.state.nodeId = node.next;
this.skipHiddenNodes();
this.persist();
}
/**
* 同意存证(ADR-0004):告知与同意的 UX 由 ConsentGate 承担,
* 同意动作在这里 append 一条 consent 事件(含版本与时间戳)。
* 事件存在于任意轨迹即视为已同意——同意门全剧只出现一次。
*/
async hasConsent(): Promise<boolean> {
return (await this.store.list()).some((e) => e.kind === 'consent');
}
async grantConsent(): Promise<void> {
if (await this.hasConsent()) return;
await this.store.append(
makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'consent',
pointId: 'CONSENT',
dimensions: [],
consentVersion: CONSENT_VERSION,
}),
);
}
/**
@@ -384,6 +500,9 @@ export class StoryEngine {
this.state.nodeId = chapter.startNodeId;
this.state.activeVariant = null;
this.state.pendingFollowUp = null;
this.state.flags = {};
this.state.finished = false;
this.skipHiddenNodes();
this.persist();
}
}
+4 -2
View File
@@ -17,10 +17,12 @@ export interface StoryEvent {
sessionId: string;
ts: number;
chapterId: string;
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped';
/** 选择点 ID,如 C1-03 */
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped' | 'consent';
/** 选择点 ID,如 C1-03;consent 事件固定为 'CONSENT' */
pointId: string;
dimensions: DimensionTag[];
/** consent 事件:同意的告知文案版本(ADR-0004 存证) */
consentVersion?: string;
/** choice 事件:所选选项 */
optionId?: string;
optionLabel?: string;
+174
View File
@@ -0,0 +1,174 @@
import type { StoryEvent } from './eventStore';
/**
* 分支变量(flag)系统 —— 依据 docs/script/act1.md 附录 A。
*
* 引擎不单独存变量:全部变量从事件流推导(append-only 的唯一事实源)。
* 本模块提供:
* 1. choiceFlags / narrationFlags:单个事件 → 变量赋值增量;
* 2. deriveFlags:整条事件流 → 变量全集(verify 与画像用);
* 3. 条件文本解析:章节 JSON 里的【若 变量 = 值】标记按当前 flag 求值,
* 不满足的文本对玩家不可见(marker 本身永不渲染)。
*
* 条件语法(JSON 文案内):
* - 节点级(整条文本的条件):`【若 变量 = 值】正文……` —— 不满足则整点跳过(走 next);
* - 内联段:`【若 变量 = 值1 / 值2:内容】` —— 满足则替换为内容,不满足则整段移除;
* - 取反:`!=`(如 `【若 c2_06_jade != a_public:……】`)。
*/
export type Flags = Record<string, string>;
/** 解谜点正确答案(optionId),用于 *_solved 变量 */
const PUZZLE_ANSWERS: Record<string, string> = {
'C1-05': 'stele_guardian',
'C2-04': 'heart_xin',
'C3-01': 'rite_correct',
};
/** 选择事件 → 变量赋值(变量名与取值对齐附录 A;optionId 已与枚举值对齐) */
export function choiceFlags(pointId: string, optionId: string): Flags {
const f: Flags = {};
switch (pointId) {
case 'C1-03':
f.c1_03_motive = optionId; // a_truth / b_thrill / c_escape / d_trust
break;
case 'C1-04':
f.c1_04_response = optionId;
if (optionId === 'd_play_along') f.flag_laogui_thread = 'true';
break;
case 'C1-05':
case 'C2-04':
case 'C3-01': {
const prefix = pointId.toLowerCase().replace('-', '_');
f[`${prefix}_solved`] = String(optionId === PUZZLE_ANSWERS[pointId]);
break;
}
case 'C1-06':
f.c1_06_landing = optionId; // a_safe / b_precise
break;
case 'C2-01':
f.c2_01_medicine = optionId;
f.wenjian_health = optionId === 'a_weakest' || optionId === 'd_process' ? 'recovering' : 'weak';
break;
case 'C2-03':
f.c2_03_route = optionId; // a_main / b_alley / c_scout
if (optionId === 'c_scout') f.flag_fresh_collapse = 'true';
break;
case 'C2-06':
f.c2_06_jade = optionId; // a_public / b_private / c_watch
if (optionId === 'b_private') f.flag_laogui_hint = 'true';
if (optionId === 'c_watch') f.flag_watched_return = 'true';
break;
case 'C3-03':
f.c3_03_choice = optionId;
f.atang_injury = optionId === 'a_person' ? 'bruise' : optionId === 'b_relic' ? 'fracture' : 'sprain';
f.wenjian_injury = optionId === 'c_command' ? 'shoulder' : 'none';
f.relic_loss = optionId === 'a_person' ? 'one_ding' : 'none';
break;
case 'C3-04':
f.c3_04_stance = optionId;
f.flag_laogui_reported = String(optionId !== 'b_plead');
if (optionId === 'b_plead') f.flag_shenduo_cover = 'true';
break;
case 'C3-05':
f.c3_05_rope = optionId;
f.flag_rope_holder = optionId === 'a_accept' ? 'you' : optionId === 'b_protocol' ? 'shenduo' : 'atang';
break;
case 'C3-06':
f.flag_arch_doubt = 'true';
if (optionId.startsWith('c_confide_')) {
f.c3_06_response = 'c_confide';
f.c3_06_confidant = optionId.slice('c_confide_'.length); // shenduo / atang / wenjian
} else {
f.c3_06_response = optionId; // a_confront / b_conceal
f.c3_06_confidant = 'null';
}
break;
}
return f;
}
/** C2-02 / C2-05 场景变体 → 情绪签名枚举(附录 A) */
const VARIANT_SIGNATURE: Record<string, string> = {
intel_full: 'self_blame',
intel_mid: 'at_peace',
intel_low: 'outward_anger',
intel_silent: 'silent',
wenjian_close_early: 'deep',
wenjian_close_late: 'shallow',
wenjian_distant: 'silent',
};
/** 讲述事件 → 变量赋值 */
export function narrationFlags(pointId: string, variantId: string | undefined, transcript: string | null): Flags {
const f: Flags = {};
switch (pointId) {
case 'C1-02':
if (transcript === null) f.c1_02_signature = 'silent';
break;
case 'C2-02': {
const sig = transcript === null ? 'silent' : variantId ? VARIANT_SIGNATURE[variantId] : undefined;
if (sig) f.c2_02_signature = sig;
if (variantId === 'intel_full') f.flag_prophecy_heard = 'true';
break;
}
case 'C2-05': {
const depth = transcript === null ? 'silent' : variantId ? VARIANT_SIGNATURE[variantId] : undefined;
if (depth) f.c2_05_depth = depth;
if (variantId === 'wenjian_close_early') f.flag_wenjian_secret = 'true';
break;
}
case 'C3-02':
f.c3_02_mode = transcript === null ? 'exit_room' : 'farewell_done';
break;
}
return f;
}
/** 整条事件流 → 变量全集(后发生的事件覆盖先发生的,重玩新轨迹自然刷新) */
export function deriveFlags(events: StoryEvent[]): Flags {
const flags: Flags = {};
for (const e of events) {
if (e.kind === 'choice' && e.optionId) Object.assign(flags, choiceFlags(e.pointId, e.optionId));
else if (e.kind === 'narration') Object.assign(flags, narrationFlags(e.pointId, e.variantId, e.transcript ?? null));
}
return flags;
}
// ---------- 条件文本解析 ----------
const CONDITION_RE = /【若([^】]+)】/g;
/** 求值单个条件表达式,如 `c2_06_jade = a_public`、`wenjian_health != weak` */
export function evalCondition(expr: string, flags: Flags): boolean {
const m = expr.match(/^\s*([A-Za-z0-9_]+)\s*(!=|=)\s*([^:]+?)\s*$/);
if (!m) return false; // 解析失败按不满足处理:宁可少显示,不泄漏标记
const [, name, op, rawValues] = m;
const values = rawValues.split('/').map((v) => v.trim());
const hit = values.includes(flags[name] ?? '');
return op === '=' ? hit : !hit;
}
/**
* 节点级条件:文本以 `【若 …】`(无内联内容)开头时,拆出条件与正文。
* 内联段(含 `:`)不算节点级条件。
*/
export function splitNodeCondition(text: string): { condition: string | null; body: string } {
const m = text.match(/^【若([^】]+)】/);
if (!m) return { condition: null, body: text };
if (m[1].includes(':')) return { condition: null, body: text };
return { condition: m[1], body: text.slice(m[0].length) };
}
/**
* 渲染解析:节点级条件不满足 → null(调用方跳过该节点);
* 否则剥离节点级标记、求值全部内联段,返回玩家可见文本。
*/
export function resolveConditionalText(text: string, flags: Flags): string | null {
const { condition, body } = splitNodeCondition(text);
if (condition !== null && !evalCondition(condition, flags)) return null;
return body.replace(CONDITION_RE, (_seg, inner: string) => {
const idx = inner.indexOf(':');
if (idx < 0) return ''; // 裸条件标记不渲染
return evalCondition(inner.slice(0, idx), flags) ? inner.slice(idx + 1) : '';
});
}
+53
View File
@@ -0,0 +1,53 @@
import type { Flags } from './flags';
/**
* 剧情进度持久化(真机刷新/热更新恢复用)。
* 引擎位置本来只在内存里,权限弹窗等引发的页面刷新会让玩家回到开头——
* 每次状态迁移后把当前位置落盘,启动时恢复。
*/
export interface ProgressSnapshot {
sessionId: string;
chapterId: string;
nodeId: string;
/** 分支变量随位置一起持久化,恢复后条件文本渲染不断档 */
flags: Flags;
}
export interface ProgressStore {
save(p: ProgressSnapshot): void;
load(): ProgressSnapshot | null;
}
const PROGRESS_KEY = 'jev-dating-match.progress.v1';
/** 浏览器 / Tauri WKWebView 通用:localStorage。 */
export class LocalStorageProgressStore implements ProgressStore {
save(p: ProgressSnapshot): void {
try {
localStorage.setItem(PROGRESS_KEY, JSON.stringify(p));
} catch {
// 隐私模式等写不进去的场景:静默降级为不持久化,不影响游玩
}
}
load(): ProgressSnapshot | null {
try {
const raw = localStorage.getItem(PROGRESS_KEY);
return raw ? (JSON.parse(raw) as ProgressSnapshot) : null;
} catch {
return null;
}
}
}
/** verify / 测试用内存实现 */
export class MemoryProgressStore implements ProgressStore {
private snap: ProgressSnapshot | null = null;
save(p: ProgressSnapshot): void {
this.snap = p;
}
load(): ProgressSnapshot | null {
return this.snap;
}
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { CONSENT_VERSION } from '../engine/engine';
/**
* 正式同意门(ADR-0004 合规义务):首次启动、进入剧情前展示一次。
* 告知 = 数据用途 + 语音即焚 + 不展示给其他用户 + 历史不可涂改;
* 「同意并开始」append consent 事件存证(含 consentVersion),之后全剧不再出现。
*/
const NOTICE_ITEMS = [
'你在剧情中的选择与语音讲述,将用于为你推荐伴侣。按本心选,没有对错。',
'语音只用于转写文字与提取语气特征(语速、停顿等),提取后即焚——不保存音频,不含声纹,不可还原你的声音。',
'你的记忆与经历仅作模型输入,永远不会原样展示给其他用户;记忆展厅向匹配对象开放,需你逐次明确授权。',
'历史选择不可涂改;想重来可以重玩章节——重玩产生新轨迹,旧轨迹原样保留。',
];
export function ConsentGate({ onAgree }: { onAgree: () => Promise<void> }) {
const [busy, setBusy] = useState(false);
const [declined, setDeclined] = useState(false);
if (declined) {
return (
<div className="consent-box">
<h2>门为你留着</h2>
<p className="consent-note">档案馆不催。你准备好了,随时回来。</p>
<button className="secondary" onClick={() => setDeclined(false)}>
重新考虑
</button>
</div>
);
}
return (
<div className="consent-box">
<div className="mechanism-tag">时空档案馆 · 入馆告知(仅一次)</div>
<h2>在你点燃第一段燃料之前</h2>
<ul className="consent-list">
{NOTICE_ITEMS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<button
className="primary"
disabled={busy}
onClick={async () => {
setBusy(true);
await onAgree();
}}
>
同意并开始
</button>
<button className="secondary" disabled={busy} onClick={() => setDeclined(true)}>
暂不
</button>
<p className="hint">点击「同意并开始」即表示你已知晓并同意上述内容(告知版本 {CONSENT_VERSION},同意记录仅作合规存证)。</p>
</div>
);
}
+10 -8
View File
@@ -60,13 +60,15 @@ export function StoryPlayer({ engine, onFinish }: Props) {
</div>
<div>
<div className="speaker-name">{node.speaker}</div>
<p className="dialogue-text">{node.text}</p>
<p className="dialogue-text">{engine.renderText(node.text)}</p>
<span className="hint">点击继续 ▸</span>
</div>
</div>
)}
{node.type === 'choice' && <ChoiceView node={node} onPick={async (id) => { await engine.choose(id); rerender(); }} />}
{node.type === 'choice' && (
<ChoiceView node={node} renderText={(t) => engine.renderText(t)} onPick={async (id) => { await engine.choose(id); rerender(); }} />
)}
{node.type === 'narration' && (
<NarrationView
@@ -84,7 +86,7 @@ export function StoryPlayer({ engine, onFinish }: Props) {
{node.type === 'cutscene' && (
<div className="cutscene" onClick={() => { engine.advance(); rerender(); }}>
<div className="cutscene-placeholder">▶ {node.video}</div>
<p>{node.caption}</p>
<p>{engine.renderText(node.caption)}</p>
<span className="hint">点击继续 ▸</span>
</div>
)}
@@ -108,11 +110,11 @@ export function StoryPlayer({ engine, onFinish }: Props) {
);
}
function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) => void }) {
function ChoiceView({ node, renderText, onPick }: { node: ChoiceNode; renderText: (t: string) => string; onPick: (id: string) => void }) {
const [busy, setBusy] = useState(false);
return (
<div className="choice-box">
<p className="choice-prompt">{node.prompt}</p>
<p className="choice-prompt">{renderText(node.prompt)}</p>
<div className="choice-options">
{node.options.map((o) => (
<button
@@ -125,7 +127,7 @@ function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) =
setBusy(false);
}}
>
{o.label}
{renderText(o.label)}
</button>
))}
</div>
@@ -184,7 +186,7 @@ function NarrationView({
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
{ key: 'city', label: '城市', placeholder: '如:杭州' },
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他' },
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他 / 不便透露' },
{ key: 'occupation', label: '职业', placeholder: '如:互联网' },
{ key: 'education', label: '学历', placeholder: '如:硕士' },
];
@@ -213,7 +215,7 @@ function IdentityView({ node, engine, onDone }: { node: IdentityNode; engine: St
return (
<div className="identity-box">
<div className="mechanism-tag">身份登记 · 确认</div>
<p className="narration-prompt">档案馆听写的结果如下,不对的地方直接改——这几项只用于帮你找到合适的人。</p>
<p className="narration-prompt">档案馆听写的结果如下,不对的地方直接改——这几项仅用于匹配的硬性筛选(城市、年龄段等),不会展示给其他用户。</p>
{IDENTITY_FIELDS.map((f) => (
<label key={f.key} className="identity-field">
<span>{f.label}</span>
+31 -5
View File
@@ -47,13 +47,17 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
const [live, setLive] = useState('');
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const recRef = useRef<SpeechRecognitionLike | null>(null);
const finalRef = useRef('');
// 出错(权限被拒/识别失败)后 onend 仍会触发,用它阻止错误后的自动提交
const erroredRef = useRef(false);
async function submit(t: string) {
const trimmed = t.trim();
if (!trimmed) return;
setBusy(true);
setError(null);
try {
await onSubmit(trimmed);
} finally {
@@ -74,15 +78,18 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button className="primary" disabled={disabled || busy || !text.trim()} onClick={() => submit(text)}>
<button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={() => submit(text)}>
提交
</button>
{error && <p className="hint">{error}</p>}
</div>
);
}
// ---- 语音路径:按住说话 ----
function start() {
setError(null);
erroredRef.current = false;
const rec = new SR!();
rec.lang = 'zh-CN';
rec.continuous = true;
@@ -98,34 +105,53 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
}
setLive(finalRef.current + interim);
};
rec.onerror = () => setHolding(false);
rec.onerror = () => {
// 权限被拒或识别失败:可见的温和提示,不自动提交已识别片段
erroredRef.current = true;
setHolding(false);
setError('没听清,或麦克风没授权成功——再按一次试试。');
};
rec.onend = () => {
setHolding(false);
const t = finalRef.current;
if (t.trim()) void submit(t);
if (!erroredRef.current && t.trim()) void submit(t);
};
recRef.current = rec;
setHolding(true);
rec.start();
try {
rec.start();
} catch {
// 权限弹窗打断按住手势等场景下 start 可能裸抛
setHolding(false);
recRef.current = null;
setError('麦克风没能启动——再按一次试试。');
}
}
function stop() {
recRef.current?.stop();
try {
recRef.current?.stop();
} catch {
// InvalidStateError:权限弹窗/来电打断手势时 stop 会裸抛,吞掉即可
}
recRef.current = null;
}
return (
<div className="voice-input">
<button
type="button"
className={`hold-btn ${holding ? 'holding' : ''}`}
disabled={disabled || busy}
onPointerDown={start}
onPointerUp={stop}
onPointerLeave={() => holding && stop()}
onPointerCancel={() => holding && stop()}
>
{busy ? '档案馆听写中…' : holding ? '松开结束 · 讲述中…' : '按住说话'}
</button>
{live && <p className="voice-live">{live}</p>}
{error && <p className="hint">{error}</p>}
</div>
);
}
+13 -2
View File
@@ -42,6 +42,7 @@ function buildState(events: StoryEvent[]): string {
export function ReportPage({ store, jev, onReplay }: Props) {
const [scores, setScores] = useState<DimensionScore[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
useEffect(() => {
let cancelled = false;
@@ -79,14 +80,24 @@ export function ReportPage({ store, jev, onReplay }: Props) {
return () => {
cancelled = true;
};
}, [store, jev]);
}, [store, jev, retry]);
if (error) {
return (
<div className="report">
<h1>画像报告生成失败</h1>
<p>{error}</p>
<button className="primary" onClick={onReplay}>
<button
className="primary"
onClick={() => {
setError(null);
setScores(null);
setRetry((r) => r + 1);
}}
>
画像生成失败,点此重试
</button>
<button className="secondary" onClick={onReplay}>
回到剧情
</button>
</div>