Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -23,9 +23,19 @@ DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"
# Optional model name for custom-world scene image generation.
DASHSCOPE_IMAGE_MODEL="wan2.2-t2i-flash"
# Optional model names for character asset studio.
DASHSCOPE_CHARACTER_VISUAL_MODEL="wan2.7-image-pro"
DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL="wan2.7-image-pro"
DASHSCOPE_CHARACTER_VIDEO_MODEL="wan2.7-i2v"
DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL="wan2.7-r2v"
DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL="wan2.2-animate-move"
# Optional: server-side polling timeout for custom-world scene image generation, in milliseconds.
DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000"
# Optional: longer timeout for character video generation, in milliseconds.
DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS="420000"
# Optional: generic frontend timeout for regular LLM requests, in milliseconds.
VITE_LLM_REQUEST_TIMEOUT_MS="15000"

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ coverage/
.DS_Store
*.log
/public/generated-custom-world-scenes
temp*build*/

View File

@@ -0,0 +1,236 @@
# 当前游戏剧情原文整理与质量评测
日期:`2026-04-07`
## 总结先说
- 当前游戏的剧情骨架已经能让玩家在武侠、仙侠两个世界里感到“我正在追一件事”,整体判断为:**部分达到预期**。
- 强项在于场景残痕、地图推进、NPC 保留、线程结构已经开始互相咬合。
- 短板也很明确:强回收、强情感爆点、真正能改写后续理解的长线后果还没有完全跑起来。
## 方法
- 先把当前仓库里的可扮演角色、场景、场景 NPC、宝藏残痕原文整理出来。
- 再用现有 story engine 模块补出 ThemePack、WorldStoryGraph、ActorNarrativeProfile、KnowledgeGraph、ThreadContract、QA Report 和 Release Gate。
- 最后按“玩家真实会感受到什么剧情”重组样章,并对照 PRD 的经典 RPG 体验目标做评测。
## 武侠世界
### 说明
- “原文”部分整理的是当前仓库角色、场景、NPC 和残痕里已经存在的中文文本。
- “引擎整理”部分是根据这些原文,经过 story engine 的主题包、线程图谱、角色叙事档案和 QA 规则重新编译出的结构化结果。
### 项目内原始剧情文本整理
### 可扮演角色原文
- 剑之公主 / 王庭剑姬
角色原文:以迅疾剑技和正面压制见长,适合喜欢凌厉推进的玩家。
背景原文:王庭旁支出身,自幼被当作执剑者培养。一次宫变让她失去旧有庇护,也背上了亲手追回王室誓剑与真相的责任。
表层来意:以迅疾剑技和正面压制见长,适合喜欢凌厉推进的玩家。
- 神箭游侠 / 流风弓卫
角色原文:擅长远距离压制与精准射击,节奏灵活,机动性很强。
背景原文:曾是边境游骑与斥候,被一场伏击逼得离开旧军阵。如今他只信自己亲眼见过的风向与箭路,却仍背着守住边境故土的旧誓。
表层来意:擅长远距离压制与精准射击,节奏灵活,机动性很强。
- 双刃旅者 / 疾影斥候
角色原文:速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。
背景原文:她在暗巷与帮派追杀中长大,学会靠速度、直觉和先手活下去。表面上轻快利落,心里却一直在追查那封改变命运的密信去向。
表层来意:速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。
### 场景角色原文
- 神箭游侠 / 流风弓卫
角色原文:擅长远距离压制与精准射击,节奏灵活,机动性很强。
保留线索:曾是边境游骑与斥候,被一场伏击逼得离开旧军…
- 青鳞毒蛇 / 敌对角色
角色原文:身形细长,吐信极快,最喜欢守在草木掩映和石缝交错之地。
保留线索:青鳞毒蛇长期出没于竹林古道。身形细长,吐信…
- 枯藤伏虫 / 敌对角色
角色原文:像一截会蠕动的枯藤,贴地潜行,适合在林地和湿地里伏击。
保留线索:枯藤伏虫长期出没于竹林古道。像一截会蠕动的…
- 樵夫老周 / 樵夫
角色原文:常在竹海边缘砍柴,对附近路数和兽踪了如指掌。
保留线索:樵夫老周长期出没于竹林古道。常在竹海边缘砍…
- 玄甲战锋 / 重装先锋
角色原文:攻守兼备,推进稳健,适合喜欢扎实前排风格的玩家。
保留线索:他长期担任重装前锋,习惯站在最危险的位置替…
- 石背蜗怪 / 敌对角色
角色原文:驮着厚重石壳缓慢爬行,常盘踞在石阶、桥边与潮湿山路上。
保留线索:石背蜗怪长期出没于山门石阶。驮着厚重石壳缓…
### 场景原文整理
- 竹林古道
场景原文:风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。
第一残痕:竹根旁半埋的刀鞘
场景角色:神箭游侠(流风弓卫)、青鳞毒蛇(敌对角色)、枯藤伏虫(敌对角色)
- 山门石阶
场景原文:青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。
第一残痕:裂缝里的铜钥
场景角色:玄甲战锋(重装先锋)、石背蜗怪(敌对角色)、岩甲蛛兽(敌对角色)
- 雨夜长街
场景原文:长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。
第一残痕:灯檐下浸湿的布包
场景角色:双刃旅者(疾影斥候)、夜牙潜兽(敌对角色)、孢爆菇灵(敌对角色)
- 荒村断垣
场景原文:残墙和空屋挤成一团,风里总像夹着旧哭声与游荡脚步。
第一残痕:断墙后压着的木匣
场景角色:断骨祟灵(敌对角色)、孢爆菇灵(敌对角色)、守村妇人(遗民)
- 古桥渡口
场景原文:桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。
第一残痕:桥柱缝里的油纸包
场景角色:双刃旅者(疾影斥候)、石背蜗怪(敌对角色)、夜牙潜兽(敌对角色)
### 引擎整理出的明线
- 旧宫旧案仍在牵动江湖局势:旧宫旧案仍在牵动江湖局势,焦点常落在竹林古道。
- 护送线:边关与地宫残痕正在把旧事重新拖回台前,焦点常落在山门石阶。
- 回收线:当前武侠世界不是单点冒险,而是一张由边关军需、渡口风声、地宫旧痕和宫苑旧案交叉拉紧的追查网络。,焦点常落在雨夜长街。
- 分歧对峙线:沿着场景残痕和人物试探,一步步追清边关与宫苑旧案背后的真相,焦点常落在荒村断垣。
### 引擎整理出的暗线
- 神箭游侠的隐线:神箭游侠并不只是流风弓卫,他与旧宫旧案仍在牵动江湖局势之间还有一段未被说破的牵连。
- 青鳞毒蛇的隐线:青鳞毒蛇并不只是敌对角色,他与护送线之间还有一段未被说破的牵连。
- 枯藤伏虫的隐线:枯藤伏虫并不只是敌对角色,他与回收线之间还有一段未被说破的牵连。
- 樵夫老周的隐线:樵夫老周并不只是樵夫,他与分歧对峙线之间还有一段未被说破的牵连。
- 玄甲战锋的隐线:玄甲战锋并不只是重装先锋,他与旧宫旧案仍在牵动江湖局势之间还有一段未被说破的牵连。
### 场景旧痕
- 竹林古道留下的旧痕:表层残痕是“风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。”;压着的真相是“神箭游侠并不只是流风弓卫,他与旧宫旧案仍在牵动江湖局势之间还有一段未被说破的牵连。”
- 山门石阶留下的旧痕:表层残痕是“青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。”;压着的真相是“青鳞毒蛇并不只是敌对角色,他与护送线之间还有一段未被说破的牵连。”
- 雨夜长街留下的旧痕:表层残痕是“长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。”;压着的真相是“枯藤伏虫并不只是敌对角色,他与回收线之间还有一段未被说破的牵连。”
- 荒村断垣留下的旧痕:表层残痕是“残墙和空屋挤成一团,风里总像夹着旧哭声与游荡脚步。”;压着的真相是“樵夫老周并不只是樵夫,他与分歧对峙线之间还有一段未被说破的牵连。”
- 古桥渡口留下的旧痕:表层残痕是“桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。”;压着的真相是“玄甲战锋并不只是重装先锋,他与旧宫旧案仍在牵动江湖局势之间还有一段未被说破的牵连。”
### 玩家在游戏中真实感受到的剧情样章
你来到这个武侠世界,是为追查失落王庭誓剑流入江湖的踪迹。此行最重要的目标,是在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人。 第一眼看到的不是纯说明,而是 边关营地 里的环境压迫:营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。
走进边关营地时,玩家实际感受到的核心不是“到了新地图”,而是“营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。”。这句场景原文会立刻把体验拉回到“旧宫旧案仍在牵动江湖局势”这条明线。神箭游侠表面只是流风弓卫,但他的公开面是“擅长远距离压制与精准射击,节奏灵活,机动性很强。”,真正压在肩上的却是“找出贩卖军情的人,并截回被转移的军械账册”。而像“废营帐里的箭囊”这样的场景残痕,会把玩家往“这里一定还藏着别的事”那种感觉里继续推。如果继续追下去,边关营地背后会逐渐显出“神箭游侠并不只是流风弓卫,他与旧宫旧案仍在牵动江湖局势之间还有一段未被说破的牵连。”这层旧伤。
走进雨夜长街时,玩家实际感受到的核心不是“到了新地图”,而是“长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。”。这句场景原文会立刻把体验拉回到“护送线”这条明线。双刃旅者表面只是疾影斥候,但他的公开面是“速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。”,真正压在肩上的却是“夺回密信,查清究竟是谁把你推上了被追杀的路”。而像“灯檐下浸湿的布包”这样的场景残痕,会把玩家往“这里一定还藏着别的事”那种感觉里继续推。如果继续追下去,雨夜长街背后会逐渐显出“青鳞毒蛇并不只是敌对角色,他与护送线之间还有一段未被说破的牵连。”这层旧伤。
走进古桥渡口时,玩家实际感受到的核心不是“到了新地图”,而是“桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。”。这句场景原文会立刻把体验拉回到“回收线”这条明线。双刃旅者表面只是疾影斥候,但他的公开面是“速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。”,真正压在肩上的却是“夺回密信,查清究竟是谁把你推上了被追杀的路”。而像“桥柱缝里的油纸包”这样的场景残痕,会把玩家往“这里一定还藏着别的事”那种感觉里继续推。如果继续追下去,古桥渡口背后会逐渐显出“枯藤伏虫并不只是敌对角色,他与回收线之间还有一段未被说破的牵连。”这层旧伤。
因此,武侠世界目前最容易让玩家产生真实剧情感的地方,不是某一句高光台词,而是“场景描述 -> 人物保留 -> 残痕线索 -> 线程压力”这条连续链路。它已经能让玩家觉得自己在追一件还没完全揭开的事,但离“经典 RPG 式的强收束和强情感爆点”还差最后一层回响回收。
### 质量评测
整体判断:**部分达成预期**
### 维度评测
- 角色记忆点:达成。当前可扮演角色的人设、背景、开局动机和首遇目标已经能形成第一轮代入,玩家能记住“谁在上路、为什么上路”。
- 低关系也有戏:达成。低好感或首遇 NPC 不再只是“更冷淡”,而是能从当前压力、错位说辞和反应钩子里带出暗线存在感。
- 世界互文与旧史厚度达成。场景、NPC、旧痕和线程已经能互相指向同一批旧事不再只是各自独立的设定块。
- 空间与残痕叙事:达成。地点不是纯背景图,场景描述、宝藏线索和 narrative residue 已经能共同承担“空间会说话”的职责。
- 选择后果与主线抓手:部分达成。当前任务抓手和线程合约已经存在,但真正影响关系、理解和后续回响的后果层还没有被完全跑满。
- 长线回响与收束:未达成。从 QA 结果看,当前版本最明显的短板仍是“已经埋下的线,后面有没有被稳定回收”。这一步决定它能不能真正跨到经典 RPG 质感。
### 客观检查
- Narrative QA4 条明线 / 1 条问题。
- Release Gatewarn。当前版本可继续观察但仍有若干 narrative 风险。
- Simulation共跑了 3 条 simulationending family 1 类,单次最高 QA 问题 1 条。
### 当前主要问题
- 有线程合约尚未在 chronicle 中留下足够的回收痕迹。
## 仙侠世界
### 说明
- “原文”部分整理的是当前仓库角色、场景、NPC 和残痕里已经存在的中文文本。
- “引擎整理”部分是根据这些原文,经过 story engine 的主题包、线程图谱、角色叙事档案和 QA 规则重新编译出的结构化结果。
### 项目内原始剧情文本整理
### 可扮演角色原文
- 剑之公主 / 王庭剑姬
角色原文:以迅疾剑技和正面压制见长,适合喜欢凌厉推进的玩家。
背景原文:王庭旁支出身,自幼被当作执剑者培养。一次宫变让她失去旧有庇护,也背上了亲手追回王室誓剑与真相的责任。
表层来意:以迅疾剑技和正面压制见长,适合喜欢凌厉推进的玩家。
- 神箭游侠 / 流风弓卫
角色原文:擅长远距离压制与精准射击,节奏灵活,机动性很强。
背景原文:曾是边境游骑与斥候,被一场伏击逼得离开旧军阵。如今他只信自己亲眼见过的风向与箭路,却仍背着守住边境故土的旧誓。
表层来意:擅长远距离压制与精准射击,节奏灵活,机动性很强。
- 双刃旅者 / 疾影斥候
角色原文:速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。
背景原文:她在暗巷与帮派追杀中长大,学会靠速度、直觉和先手活下去。表面上轻快利落,心里却一直在追查那封改变命运的密信去向。
表层来意:速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。
### 场景角色原文
- 神箭游侠 / 流风弓卫
角色原文:擅长远距离压制与精准射击,节奏灵活,机动性很强。
保留线索:曾是边境游骑与斥候,被一场伏击逼得离开旧军…
- 玄甲战锋 / 重装先锋
角色原文:攻守兼备,推进稳健,适合喜欢扎实前排风格的玩家。
保留线索:他长期担任重装前锋,习惯站在最危险的位置替…
- 秘匣书妖 / 敌对角色
角色原文:像会自行翻页的秘典与宝匣,常在仙门、遗迹与禁制附近浮游。
保留线索:秘匣书妖长期出没于云海仙门。像会自行翻页的…
- 噬雾飞蛾 / 敌对角色
角色原文:借雾气遮身,飞行轨迹诡谲,喜欢围着灵光和人影打转。
保留线索:噬雾飞蛾长期出没于云海仙门。借雾气遮身,飞…
- 守门灵官 / 门官
角色原文:站在门阙侧旁观来者,像在等一份迟迟未到的回报。
保留线索:守门灵官长期出没于云海仙门。站在门阙侧旁观…
- 幽烬灵蝠 / 敌对角色
角色原文:翅翼缭绕灰烬般的灵火,常成群出没于洞天、崖壁与灵脉附近。
保留线索:幽烬灵蝠长期出没于悬空仙岛。翅翼缭绕灰烬般…
### 场景原文整理
- 云海仙门
场景原文:云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。
第一残痕:云阶尽头的灵符匣
场景角色:神箭游侠(流风弓卫)、玄甲战锋(重装先锋)、秘匣书妖(敌对角色)
- 悬空仙岛
场景原文:浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。
第一残痕:浮岛边缘的灵羽匣
场景角色:幽烬灵蝠(敌对角色)、噬雾飞蛾(敌对角色)、云栖散修(散修)
- 天宫长廊
场景原文:廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。
第一残痕:廊柱暗槽里的玉简
场景角色:剑之公主(王庭剑姬)、玄甲战锋(重装先锋)、秘匣书妖(敌对角色)
- 灵药花圃
场景原文:灵草灵花层层叠开,香气诱人,却也最容易养出食灵的怪物。
第一残痕:药圃深处的灵壶
场景角色:噬灵妖花(敌对角色)、血瞳妖眼(敌对角色)、药圃执事(药师)
- 寒玉洞天
场景原文:洞壁结着寒玉光泽,地面湿滑,水灵和阴性异物都爱停在这里。
第一残痕:寒玉裂隙里的灵髓
场景角色:青腐泥灵(敌对角色)、幽烬灵蝠(敌对角色)、澄潮灵母(敌对角色)
### 引擎整理出的明线
- 灵脉与封印正在失衡:灵脉与封印正在失衡,焦点常落在云海仙门。
- 追索线:宗门旧案与秘境争夺彼此缠住了当下局势,焦点常落在悬空仙岛。
- 封印失衡线:当前仙侠世界由宗门秩序、秘境余波、灵脉封印和古仙残迹共同推着故事前进,玩家每深入一层,都会撞上新的旧事回响。,焦点常落在天宫长廊。
- 宗门旧案线:顺着灵痕、残识和人物保留,一层层摸清宗门旧案与秘境失衡的根源,焦点常落在灵药花圃。
### 引擎整理出的暗线
- 神箭游侠的隐线:神箭游侠并不只是流风弓卫,他与灵脉与封印正在失衡之间还有一段未被说破的牵连。
- 玄甲战锋的隐线:玄甲战锋并不只是重装先锋,他与追索线之间还有一段未被说破的牵连。
- 秘匣书妖的隐线:秘匣书妖并不只是敌对角色,他与封印失衡线之间还有一段未被说破的牵连。
- 噬雾飞蛾的隐线:噬雾飞蛾并不只是敌对角色,他与宗门旧案线之间还有一段未被说破的牵连。
- 守门灵官的隐线:守门灵官并不只是门官,他与灵脉与封印正在失衡之间还有一段未被说破的牵连。
### 场景旧痕
- 云海仙门留下的旧痕:表层残痕是“云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。”;压着的真相是“神箭游侠并不只是流风弓卫,他与灵脉与封印正在失衡之间还有一段未被说破的牵连。”
- 悬空仙岛留下的旧痕:表层残痕是“浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。”;压着的真相是“玄甲战锋并不只是重装先锋,他与追索线之间还有一段未被说破的牵连。”
- 天宫长廊留下的旧痕:表层残痕是“廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。”;压着的真相是“秘匣书妖并不只是敌对角色,他与封印失衡线之间还有一段未被说破的牵连。”
- 灵药花圃留下的旧痕:表层残痕是“灵草灵花层层叠开,香气诱人,却也最容易养出食灵的怪物。”;压着的真相是“噬雾飞蛾并不只是敌对角色,他与宗门旧案线之间还有一段未被说破的牵连。”
- 寒玉洞天留下的旧痕:表层残痕是“洞壁结着寒玉光泽,地面湿滑,水灵和阴性异物都爱停在这里。”;压着的真相是“守门灵官并不只是门官,他与灵脉与封印正在失衡之间还有一段未被说破的牵连。”
### 玩家在游戏中真实感受到的剧情样章
你来到这个仙侠世界,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。 第一眼看到的不是纯说明,而是 星舟甲板 里的环境压迫:甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。
走进星舟甲板时,玩家实际感受到的核心不是“到了新地图”,而是“甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。”。这句场景原文会立刻把体验拉回到“灵脉与封印正在失衡”这条明线。神箭游侠表面只是流风弓卫,但他的公开面是“擅长远距离压制与精准射击,节奏灵活,机动性很强。”,真正压在肩上的却是“找回星图核心,查清是谁击落了你的船队”。而像“舵台后的星图匣”这样的场景残痕,会把玩家往“这里一定还藏着别的事”那种感觉里继续推。如果继续追下去,星舟甲板背后会逐渐显出“神箭游侠并不只是流风弓卫,他与灵脉与封印正在失衡之间还有一段未被说破的牵连。”这层旧伤。
走进悬空仙岛时,玩家实际感受到的核心不是“到了新地图”,而是“浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。”。这句场景原文会立刻把体验拉回到“追索线”这条明线。云栖散修表面只是散修,但他的公开面是“常坐在浮岛边缘打坐,对天风和禁制的变化很敏感。”,真正压在肩上的却是“在悬空仙岛守住自己不愿失去的那一层秩序。”。而像“浮岛边缘的灵羽匣”这样的场景残痕,会把玩家往“这里一定还藏着别的事”那种感觉里继续推。如果继续追下去,悬空仙岛背后会逐渐显出“玄甲战锋并不只是重装先锋,他与追索线之间还有一段未被说破的牵连。”这层旧伤。
走进月湖仙洲时,玩家实际感受到的核心不是“到了新地图”,而是“湖光像铺开的镜面,水灵、章灵与花影都可能从月色里浮出来。”。这句场景原文会立刻把体验拉回到“封印失衡线”这条明线。双刃旅者表面只是疾影斥候,但他的公开面是“速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。”,真正压在肩上的却是“找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁”。而像“湖岸边漂来的玉匣”这样的场景残痕,会把玩家往“这里一定还藏着别的事”那种感觉里继续推。如果继续追下去,月湖仙洲背后会逐渐显出“秘匣书妖并不只是敌对角色,他与封印失衡线之间还有一段未被说破的牵连。”这层旧伤。
因此,仙侠世界目前最容易让玩家产生真实剧情感的地方,不是某一句高光台词,而是“场景描述 -> 人物保留 -> 残痕线索 -> 线程压力”这条连续链路。它已经能让玩家觉得自己在追一件还没完全揭开的事,但离“经典 RPG 式的强收束和强情感爆点”还差最后一层回响回收。
### 质量评测
整体判断:**部分达成预期**
### 维度评测
- 角色记忆点:达成。当前可扮演角色的人设、背景、开局动机和首遇目标已经能形成第一轮代入,玩家能记住“谁在上路、为什么上路”。
- 低关系也有戏:达成。低好感或首遇 NPC 不再只是“更冷淡”,而是能从当前压力、错位说辞和反应钩子里带出暗线存在感。
- 世界互文与旧史厚度达成。场景、NPC、旧痕和线程已经能互相指向同一批旧事不再只是各自独立的设定块。
- 空间与残痕叙事:达成。地点不是纯背景图,场景描述、宝藏线索和 narrative residue 已经能共同承担“空间会说话”的职责。
- 选择后果与主线抓手:部分达成。当前任务抓手和线程合约已经存在,但真正影响关系、理解和后续回响的后果层还没有被完全跑满。
- 长线回响与收束:未达成。从 QA 结果看,当前版本最明显的短板仍是“已经埋下的线,后面有没有被稳定回收”。这一步决定它能不能真正跨到经典 RPG 质感。
### 客观检查
- Narrative QA4 条明线 / 1 条问题。
- Release Gatewarn。当前版本可继续观察但仍有若干 narrative 风险。
- Simulation共跑了 3 条 simulationending family 1 类,单次最高 QA 问题 1 条。
### 当前主要问题
- 有线程合约尚未在 chronicle 中留下足够的回收痕迹。
## 最终结论
- 如果目标只是“让玩家进入游戏后立刻感觉世界里有事正在发生”,当前文本资产已经够用,且部分环节已经明显跑起来了。
- 如果目标是“对标经典单机 RPG 的强角色回响、强关系后果、强主线收束”,当前版本还只能算走到了一半,最该补的是 payoff 和长线回响。
- 也就是说:**当前剧情原文的底座已经部分达到预期,但还没到可以完全放心交给玩家沉浸式吃剧情的程度。**

View File

@@ -8,6 +8,7 @@
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。
- [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。
- [SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md](./SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md):把每个场景收束成章节单元,并在首进场景时开启章节任务的设计稿。
- [npc-conversation-situation-draft.md](./npc-conversation-situation-draft.md)NPC 对话阶段和情景注入草案。
## 推荐阅读
@@ -15,4 +16,5 @@
- 做物品、Build、锻造相关需求时先看前两份。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。
- 做角色关系、同伴互动、对话表现时,先看后两份。
- 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。
- 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。

View File

@@ -0,0 +1,715 @@
# 场景章节闭环与首进章节任务设计
更新时间:`2026-04-08`
## 0. 目标
这份设计稿要把当前仓库里已经存在的:
- `章节状态`
- `任务 contract / step`
- `场景残痕`
- `NPC 首遇与关系`
- `Goal Stack`
进一步收束成一条更明确的结构:
**每个场景都是一个剧情章节单元;每个章节在当前剧情引擎里都要形成“起承转合”的完整闭环;并且在玩家首次进入该场景时,自动开启一个章节任务。**
这里的重点不是再造一套全新系统,而是把现有能力重新组织成:
1. 场景不再只是地图节点,而是章节容器。
2. 章节不再只是背景摘要,而是有明确动作面的闭环。
3. 任务不再只是零散委托,而是章节在前台的执行外壳。
4. NPC 不再只是“场景里有什么人”,而是按章节节拍承担起、承、转、合的叙事职责。
---
## 1. 基于当前仓库的判断
结合当前文档与代码,现状已经具备一半骨架,但关键一半还没接上。
### 1.1 已经具备的基础
1. `src/services/storyEngine/chapterDirector.ts`
- 已经有 `ChapterState`,但当前主要根据 `signalCount / chronicleCount / activeThreadCount` 推章节阶段,更像“抽象章节热度”,还不是“具体场景章节实例”。
2. `src/data/questFlow.ts`
- 已经有 `QuestIntent -> QuestContract -> QuestStep -> QuestProgressSignal` 的完整任务闭环。
- `QuestLogEntry` 也已经有 `actId / threadId / contractId / steps / activeStepId / visibleStage` 这些可扩展入口。
3. `src/services/storyEngine/goalDirector.ts`
- 已经能把 `chapter + quest + journeyBeat + setpiece` 编译成玩家前台目标感。
- 说明章节任务一旦成型,前台目标层基本不用重做,只要接正确数据。
4. `src/data/scenePresets.ts`
- 已经有场景描述、敌对实体、额外 NPC、宝藏线索、narrative residues。
- 这些字段已经够支持“场景章节蓝图”的第一版自动编译。
### 1.2 当前缺口
当前最核心的缺口有 4 个:
1. 没有“场景首次进入”对应的持久状态。
- 现在能做场景切换,也能累计 `scenesTraveled`,但没有 `openedSceneChapterIds` 或等价结构。
2. 没有“场景章节实例”。
- `ChapterState` 已有,但没有一个明确指向 `sceneId`、可追踪起承转合进度的运行时对象。
3. 没有“章节任务自动开章”的规则。
- 现在任务更多还是由 NPC 委托机会触发,不是“首进场景即开章”。
4. 没有按章节节拍组织 NPC。
- 现在场景里有 NPC、有敌人、有残痕但还没有明确规定谁负责起、谁负责承、谁负责转、谁负责合。
一句话总结:
**当前仓库已经有章节系统、任务系统和场景叙事素材,但还没有“场景章节实例 + 首进自动开章任务 + 起承转合 NPC 编排”这条真正把它们串起来的中介层。**
---
## 2. 核心决策
## 2.1 场景 = 章节单元
从这次开始,默认把每个可到达场景都视为一个章节单元。
这意味着:
1. 玩家进入一个新场景,不只是“换地图”,而是“开启一个新章节”。
2. 该场景内必须具备一个可完成的最小剧情闭环。
3. 即使大世界主线跨多个场景延续,每个场景也要有自己的局部收束。
## 2.2 章节仍然是叙事语义,任务是前台动作面
这点要继续保持和当前项目既有方向一致:
- `章节` 负责表达“这一段故事在追什么”。
- `章节任务` 负责表达“玩家现在要做什么”。
也就是说:
**章节不是单独再做一个和任务平级的前台入口,而是通过“章节任务”落到玩家动作层。**
## 2.3 保留现有五阶段结构,起承转合作为体验要求理解
当前 `ChapterState.stage` 已经在用:
- `opening`
- `expansion`
- `turning_point`
- `climax`
- `aftermath`
这次不再新增新的叙事阶段枚举,也不再额外引入一套“四拍语义”运行时字段。
“起承转合”保留为章节闭环的体验要求,但在系统里直接压到现有五阶段上理解:
| 当前字段 | 体验语义 |
| --- | --- |
| `opening` | 起:开章立题 |
| `expansion` | 承:压力展开 |
| `turning_point` | 转:理解改判 |
| `climax` | 合:正面收束 |
| `aftermath` | 合后的余波与交接 |
这意味着:
1. 玩家体感上仍然能得到“起承转合”的完整闭环。
2. 系统运行时只认现有 `stage`,不新增第二套章节阶段系统。
3. `goalDirector / journeyBeatPlanner / setpieceDirector / UI` 都可以继续沿用现有结构。
---
## 3. 场景章节的标准闭环
每个场景章节都必须至少回答这 4 个问题:
1. 玩家刚进来时,这里有什么事正在发生?
2. 玩家在这一章里到底要处理什么压力?
3. 这一章中途会出现什么反转或改判?
4. 玩家离开前,这一章给出了什么局部收束?
对应到现有系统里,就是当前五阶段的完整跑通。
## 3.1 `opening`:开章立题
触发时机:
- 玩家首次进入某场景
必须完成的事:
1. 建立场景章节标题与主题。
2. 让一个 NPC、残痕或现场异常把问题抛出来。
3. 自动开启本章章节任务。
4. 给玩家一个明确的第一步。
适合使用的现有信号:
- `scene_reached`
- 首次 NPC 对话
- 首次观察残痕 / 宝藏线索
适合落地的任务 step
- `reach_scene`
- `talk_to_npc`
- `inspect_treasure`
## 3.2 `expansion`:压力展开
触发时机:
- 玩家已经接住本章 lead开始深入该场景
必须完成的事:
1. 让玩家确认“这一章不是空壳,确实有事要处理”。
2. 把当前场景的主压力推到前台。
3. 让场景内的敌对 NPC / 障碍 NPC / 关键残痕承担中段推进。
适合落地的任务 step
- `inspect_treasure`
- `defeat_hostile_npc`
- `spar_with_npc`
- `talk_to_npc`
## 3.3 `turning_point`:改判与揭示
触发时机:
- 玩家完成了承段主动作,系统需要让当前理解发生偏转
必须完成的事:
1. 出现一条新事实、矛盾证词或旧痕反证。
2. 让至少一个 NPC 的定位发生变化。
3. 把任务从“处理中”切到“确认真相 / 回去对话 / 做最后一跳”。
适合落地的任务 step
- `talk_to_npc`
- `inspect_treasure`
- `reach_scene`
- `item_delivered`
## 3.4 `climax`:本章收束
触发时机:
- 玩家已经拿到足够信息,或者已经处理完这一章的核心冲突
必须完成的事:
1. 当前场景的局部问题得到收束。
2. 章节任务进入可交付或已交付状态。
3. 章节回写 `chronicle`
4. 给出下一场景 handoff。
适合落地的任务 step
- `talk_to_npc`
- `item_delivered`
注意:
**`climax` 不等于世界真相彻底结束,而是这一个场景章节的核心矛盾必须在这里得到局部收束。**
也就是说,大主线可以继续,但当前场景不能只留下“半段没收”的悬空状态。
## 3.5 `aftermath`:余波与交接
触发时机:
- 章节任务已经 `ready_to_turn_in``turned_in`
- 本章主要冲突已经完成,系统需要把结果沉淀并交给下一段
必须完成的事:
1. 把本章结果写进 `chronicle` 或最近摘要。
2. 把当前场景的局部余波写回场景状态、NPC 态度或任务说明。
3. 给出下一场景或下一条线程的 handoff。
这里的重点不是再加一轮复杂任务,而是把已有结果接住。
一句话总结:
**起承转合仍然保留为设计目标,但系统实现上直接用 `opening -> expansion -> turning_point -> climax -> aftermath` 跑完整闭环。**
---
## 4. NPC 编排规则
这次的 NPC 设计重点不是“一个场景塞多少人”,而是:
**谁在这一章里负责什么。**
## 4.1 不新增独立的 `npcCasting` 系统,先按现有阶段组织职责
这轮不建议为了章节化单独新增一套 NPC casting 数据结构。
更稳的做法是直接基于现有场景数据组织阶段职责:
1. `opening`
- 优先由场景里的首遇 NPC、第一条残痕或第一层异动承担立题职责。
2. `expansion`
- 优先由敌对单位、阻拦型 NPC、关键残痕承担压力职责。
3. `turning_point`
- 优先由第二条线索、矛盾证词、返回对话的 NPC 承担改判职责。
4. `climax / aftermath`
- 优先由最初开章 NPC 或新的接棒 NPC 承担收束和 handoff。
## 4.2 允许一人多阶段承担职责,但阶段职责不能空
考虑到当前有些场景 NPC 数量不多,因此允许:
- 同一个 NPC 同时承担 `opening + aftermath`
- 同一个 NPC 同时承担 `expansion + turning_point`
如果友方 NPC 不够,可以由这些现有对象补位:
1. 场景残痕
2. 宝藏线索
3. 文书 / 道具 / 遗物
4. 敌对单位
也就是说,本章的“转”不一定靠新 NPC也可以靠现有证据或现场变化来完成。
## 4.3 NPC 的章节职责应该是动态解释,不是静态标签
不要把 NPC 只写成:
- 商人
- 侍女
- 守门人
- 怪物
更应该补的是:
- 他在这一章里为什么是开章人
- 他卡住玩家的是什么压力
- 他掌握的转折信息是什么
- 他能否承接本章结算
这部分优先通过现有 `npc context / dialogue / rewardText / quest step 文案` 去表达,不急着为每个 NPC 新增专门类型。
---
## 5. 数据结构建议
## 5.1 扩展 `ChapterState`
建议扩展为:
```ts
export interface ChapterState {
id: string;
title: string;
theme: string;
primaryThreadIds: string[];
stage: 'opening' | 'expansion' | 'turning_point' | 'climax' | 'aftermath';
chapterSummary: string;
sceneId?: string | null;
chapterQuestId?: string | null;
}
```
这样当前系统读取 `stage` 的地方仍然可用,同时章节状态也能明确绑定:
- 这是哪个场景章节
- 当前绑定的是哪一个章节任务
建议:
- 对“场景章节”使用稳定 id例如 `chapter:scene:${sceneId}`
- 不再额外发明新的章节实例类型
## 5.2 扩展 `StoryEngineMemoryState`
建议新增:
```ts
export interface StoryEngineMemoryState {
openedSceneChapterIds?: string[];
}
```
这是这次最关键的数据层补丁,因为当前仓库现在没有“首进场景已开章”的持久状态。
这里刻意不新增 `sceneChapterStates` 这类新的章节运行时容器,优先复用现有:
- `currentChapter`
- `quests`
- `storyHistory`
- `chronicle`
## 5.3 尽量复用 `QuestLogEntry`
建议补充:
```ts
export interface QuestLogEntry {
chapterId?: string | null;
}
```
其他字段尽量直接复用现有结构:
- `sceneId`
- `steps`
- `activeStepId`
- `visibleStage`
- `status`
目的不是让 UI 暴露更多字段,而是让系统能知道:
- 这是某个场景章节自动生成的主任务
- 该任务与当前 `ChapterState` 是否一一对应
一句话原则:
**这轮只补“老系统里真正缺的最小字段”,不再额外发明一整套章节蓝图 / 章节运行时数据模型。**
---
## 6. 章节任务设计
## 6.1 首次进入场景时自动开任务
根据这次需求,章节任务默认不再依赖玩家额外点一次“接受委托”。
建议规则:
1. 玩家首次进入某场景时,直接创建该场景的章节任务。
2. 章节任务默认进入 `active`
3. 同一场景后续再次进入时,不重复开同一任务。
也就是说:
**首进场景 = 开章节 = 章节任务自动入列。**
## 6.2 章节任务不强制另起一套五步或四步系统,优先复用现有 step + status
这次不建议为了章节化再发明一套新的任务阶段结构。
更稳的做法是:
1. 章节层继续使用现有五阶段 `stage`
2. 任务层继续使用现有 `steps + activeStepId + status`
3. 通过任务进度去驱动章节阶段,而不是反过来再创建一套章节 step
建议映射关系:
| 章节阶段 | 现有任务侧表现 |
| --- | --- |
| `opening` | 章节任务刚创建,首个 `step` 为接 lead |
| `expansion` | 中段调查 / 战斗 / 接触 step 在推进 |
| `turning_point` | `activeStep` 切换到改判或回报前置 step |
| `climax` | 最后一个核心 step 正在执行,或任务刚进入 `ready_to_turn_in` |
| `aftermath` | 任务 `turned_in`,或已经完成本章结算并进入 handoff |
推荐的 step 仍然复用当前支持的类型:
| 当前阶段 | 常见 step kind |
| --- | --- |
| `opening` | `reach_scene` / `talk_to_npc` / `inspect_treasure` |
| `expansion` | `inspect_treasure` / `defeat_hostile_npc` / `spar_with_npc` |
| `turning_point` | `talk_to_npc` / `inspect_treasure` / `reach_scene` |
| `climax` | `talk_to_npc` / `item_delivered` / 关键收束 step |
| `aftermath` | 优先使用 `ready_to_turn_in / turned_in` 和 handoff不再强塞新 step |
## 6.3 章节任务应该服务于 Goal Stack
当前仓库已经有 `GoalStack`,因此章节任务一旦建立,应默认成为:
1. `active_contract`
2. `immediate_step`
`ChapterState` 继续承担:
1. 章节主题
2. 章节承诺
3. 章节背景总结
也就是说前台玩家看到的是:
- 当前章节任务标题
- 下一步去哪 / 找谁 / 做什么
后台章节层则继续给叙事和 prompt 提供语义。
---
## 7. 运行时流程接入建议
## 7.1 调整 `chapterDirector.ts`
当前 `chapterDirector` 更像“热度评分器”。
这次建议直接在它内部补两类能力:
1. 场景绑定
- 当前场景存在且符合开章条件时,直接生成 `sceneId` 绑定的 `ChapterState`
2. 阶段推导
- 优先从当前场景绑定的章节任务进度推导 `opening -> expansion -> turning_point -> climax -> aftermath`
- 只有在场景章节信息不足时,才回退到现有 `signalCount / chronicleCount / activeThreadCount` 逻辑
建议新增的只是 `chapterDirector.ts` 内部 helper而不是新的模块例如
```ts
resolveSceneBoundChapterState(params)
deriveChapterStageFromQuest(params)
```
## 7.2 调整 `questFlow.ts`
建议新增:
```ts
buildChapterQuestForScene(params)
findActiveChapterQuestForScene(params)
```
这里依然放在现有 `questFlow.ts` 内部处理,不单独拆新系统。
章节任务 builder 的原则是:
1. 输入继续使用当前能拿到的场景信息
- `scenePreset`
- `currentSceneId`
- `activeThreadIds`
- `scene npcs / hostile npc / treasureHints`
2. 输出继续是现有 `QuestLogEntry`
- 只是多补 `chapterId`
- 并尽量让 `sceneId = 当前场景`
## 7.3 调整 `useStoryGeneration.ts` / `sessionActions.ts`
推荐接入点:
1. 场景切换完成后
2. `scene_reached` 信号写入时
3. 地图跳转 `travelToSceneFromMap(...)` 成功后
处理顺序建议为:
1. 先判断是否首进场景
2. 若首进:
-`sceneId` 写入 `openedSceneChapterIds`
- 检查当前场景是否已有未结清的章节任务
- 若没有,则在 `questFlow.ts` 内生成章节任务
- 写入 `chapterState(stage = opening)`
- 触发章节 pulse
3. 再刷新 `goalStack / chapterState / journeyBeat`
## 7.4 调整 `goalDirector.ts`
当前 `goalDirector.ts` 已经能编译:
- `chapter`
- `quest`
- `journeyBeat`
- `setpiece`
因此这里只需要一个优先级调整:
1. 当前场景若存在匹配 `chapterId` 的章节任务
2. 且它还未 `turned_in`
3. 则优先把它当作当前场景的 `active_contract` / `immediate_step`
这样 Goal Stack 继续复用,不需要再加新层。
## 7.5 调整 `storyChronicle.ts`
建议章节至少写 3 次 chronicle
1. 开章
2. 转折发生
3. 本章收束
这样章节不会只存在于当前一屏,而能进入长期回顾。
---
## 8. 首进场景的标准触发流程
建议标准流程如下:
```text
玩家进入场景
-> 触发 scene_reached
-> 检查 openedSceneChapterIds 是否已包含当前 sceneId
-> 若否:
-> 将当前 sceneId 写入 openedSceneChapterIds
-> 生成 chapterId = chapter:scene:${sceneId}
-> 生成章节任务并直接设为 active
-> 写入当前 ChapterState(stage = opening, sceneId, chapterQuestId)
-> 触发 Goal Pulse: 新章节开启
-> 写入 chronicle 开章记录
-> 若是:
-> 只同步当前章节状态,不重复开任务
-> 随着任务 step 与 signal 推进:
-> ChapterState.stage 依次推进到 expansion / turning_point / climax
-> 当任务 ready_to_turn_in 或 turned_in
-> ChapterState.stage = aftermath
-> 写入余波 chronicle 与下一跳 handoff
```
这一步是整个方案能不能真正成立的关键,因为用户这次要的就是:
**“首次进入某个场景”这一刻,就要像进入新章节一样被系统接住。**
---
## 9. 当前仓库可直接套用的样章示例
下面用当前仓库已经存在的 `宫苑内庭` 说明这套设计怎么落。
当前场景素材来自 `src/data/scenePresets.ts`
- 场景:`宫苑内庭`
- 场景描述:`回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。`
- 场景友方 NPC`旧宫侍女`
- 场景线索:`回廊暗格里的香囊``花圃石座下的旧金牌`
- 相邻场景:`铸坊工场``雨夜长街``地宫通道`
可直接编成如下章节:
## 9.1 章节标题
`宫苑内庭·旧痕回廊`
## 9.2 `opening`
- 玩家首次进入 `宫苑内庭`
- `旧宫侍女` 作为开章角色
- 她不给完整解释,只提醒“最近不该过去的回廊”
- 系统自动开启章节任务:`查明内庭异动`
## 9.3 `expansion`
- 玩家需要先调查 `回廊暗格里的香囊` 或处理场景里的敌对压力
- 任务 step 进入“确认这条旧痕到底指向谁”
## 9.4 `turning_point`
- 玩家在第二条线索 `花圃石座下的旧金牌` 中得到反证
- 旧宫侍女此前的说法出现缺口
- 当前理解从“单纯禁区提醒”转成“她知道旧案,但在刻意压着不说”
## 9.5 `climax`
- 玩家返回与 `旧宫侍女` 对话
- 她承认这里只是旧案的一层外壳,并把下一跳 handoff 到:
- `雨夜长街`
-`铸坊工场`
- 当前章节任务进入可交付或已交付
## 9.6 `aftermath`
- `chronicle` 写入本章收束
- 当前章节状态进入余波
- Goal Stack 把下一步交接到新场景或下一段线索
这个例子里的玩家体感仍然是完整的“起承转合”,但系统实现上始终只在跑当前已有的五阶段。
这个例子说明:
**即使大主线还没结束,`宫苑内庭` 这个单独场景也已经能形成一章完整体验。**
---
## 10. MVP 落地顺序
## 阶段 A先补数据层和首进判定
先做:
1. `openedSceneChapterIds`
2. 场景首次进入 hook
3. `chapter:scene:${sceneId}` 的章节 id 规则
验收标准:
- 同一场景只在第一次进入时开章节任务
## 阶段 B把章节任务接到现有 questFlow
先做:
1. `buildChapterQuestForScene(...)`
2. `chapterId`
3. 场景 lead 与当前 quest step 的默认映射
验收标准:
- 章节任务能在现有 `steps + status` 下正常推进
## 阶段 C让 chapterDirector 真正按场景章节输出
先做:
1. `ChapterState.sceneId`
2. `ChapterState.chapterQuestId`
3. `chapterDirector` 优先从当前章节任务推导 `stage`
验收标准:
- 当前章节标题与当前场景一致
- 章节五阶段能和任务推进基本同步
## 阶段 D补 NPC 章节职责与 handoff
先做:
1. 为每个场景补默认开章 NPC / 转折线索 / 收束对话
2. 为每个场景补 handoff 规则
3. 回写 `chronicle`
验收标准:
- 每个场景都能给出明确的阶段承担者与下一跳
---
## 11. 验收标准
做到以下几点,才算真正满足这次需求:
1. 玩家首次进入任一可达场景时,系统会自动开启该场景的章节任务。
2. 每个场景章节都能在当前系统里跑出 `opening -> expansion -> turning_point -> climax -> aftermath` 的完整闭环,玩家体感上形成完整的起承转合。
3. 每个场景至少能找到开章、承压、转折、收束这些阶段承担者,允许一人多阶段承担,但阶段职责不能缺。
4. 章节任务不是孤立任务,而是当前章节在前台的动作面。
5. 同一场景重复进入时,不会重复开章,但会继承已存在的章节状态或余波状态。
6. 本章收束后,系统能明确交接下一场景或下一段主线程 lead。
7. 这轮实现主要落在现有 `chapterDirector / questFlow / useStoryGeneration / goalDirector / storyChronicle` 上,不再另起一套章节运行时系统。
---
## 12. 最后结论
如果我们接受“每个场景都是一个章节单元”这条方向,那么当前仓库最该补的不是一套新系统,而是对现有系统的三处收紧:
1. 补上 `openedSceneChapterIds`
2.`ChapterState` 显式绑定 `sceneId + chapterQuestId`
3. 让现有章节任务与现有五阶段直接挂钩
这样之后,现有系统会形成更简洁的收束关系:
- `scenePresets` 提供场景素材
- `questFlow` 直接把场景 lead 落成章节任务
- `chapterDirector` 用现有五阶段输出章节状态
- `useStoryGeneration / sessionActions` 处理首进开章
- `goalDirector` 把章节任务继续编译成玩家当前目标
最终玩家感受到的就不再是“我只是进了一个新场景”,而会更接近:
**我进入了这一章,接住了这一章的任务,见到了这一章该见的人,也在这一章里把一段局势真正走完了。**

View File

@@ -0,0 +1,310 @@
# 当前游戏全流程体验报告2026-04-07
## 1. 报告说明
本次报告基于 `2026-04-07` 仓库现状完成,目标不是评审 PRD而是从玩家进入游戏的第一秒开始顺着当前可达链路实际跑一遍记录“能不能玩、玩到哪、哪里出戏、哪里已经有感觉”。
本次模拟采用两段式验证:
- 开发服验证:直接访问本地 `http://127.0.0.1:3000`
- 临时生产包试玩:执行 `node scripts/vite-cli.mjs build --outDir temp_playtest_build` 后,通过静态服务器预览
- 试玩视口:移动端优先,约 `430 x 932`
需要先说明一个前提:
- 当前开发服首页会被 Vite 错误遮罩拦住,玩家无法直接进入游戏
- 为了继续完成全流程体验,我改走了临时生产包试玩
- 临时生产包没有本地 `/api` 代理,因此剧情区会持续出现 `501 Unsupported method ('POST')` 的报错文案AI 文本体验会被明显污染
因此,这份报告同时包含两类结论:
- 一类是“当前版本玩家真实会撞到的入口问题”
- 一类是“绕过入口问题后,主流程骨架本身的可玩性表现”
---
## 2. 本次实际跑通的流程
本次实际走通的路径如下:
1. 启动页
2. 世界选择
3. 角色选择
4. 进入营地开场
5. 首次剧情抉择
6. 任务更新
7. 场景移动到 `宫苑内庭`
8. NPC 首次互动
9. 交易面板
10. 地图弹窗
11. 队伍面板
12. 背包 / 工坊面板
13. 设置面板
14. 保存并退出
15. 继续游戏恢复存档
结论先说:
- 主流程骨架已经成型,已经不是“只有页面没有游戏”
- 进入营地、触发任务、切场景、遇 NPC、开交易、开地图、看队伍、看背包、保存继续这一整圈是能跑下来的
- 但入口稳定性、错误文案兜底、语言一致性、部分中文乱码,已经直接影响玩家的第一轮真实体验
---
## 3. 分阶段体验记录
## 3.1 启动页
玩家第一眼看到的是一个相对简洁的开始页,只有标题、开始按钮、开发团队入口和联系方式,节奏是对的,确实更像游戏入口,不像表单式 Demo。
正向感受:
- 开始动作很聚焦,玩家不会迷路
- 首屏信息量不大,移动端阅读负担低
- “开始游戏 / 新游戏 / 继续游戏”的结构清晰
问题:
- 当前开发服并不能进入这个页面,实际先看到的是 Vite 错误遮罩
- “开发团队 / 联系方式”仍然偏开发样态,正式玩家会有一点出戏
## 3.2 世界选择
世界选择页目前有 `武侠``仙侠``自定义世界` 三个主要入口。对玩家来说,这一页的信息组织已经够直观:世界名、气质、副标题、在线人数氛围标签都能快速帮助判断。
正向感受:
- 选世界成本低,点击欲望明确
- 自定义世界入口放得足够醒目,不会被埋
- 武侠 / 仙侠区分清楚,符合开局决策直觉
问题:
- “在线人数”更像氛围数字,不像真实系统状态,容易被当作假在线
- 当前页面更偏“卡片入口”,还没有形成非常强的世界身份记忆点
## 3.3 角色选择
角色选择页已经具备“选人进入冒险”的基本仪式感。当前可见角色包括 `剑之公主``神箭游侠``双刃旅者``破军拳师``玄甲战锋`。属性、背景故事、性格标签和详情入口都齐了。
正向感受:
- 选角信息够完整,能形成第一轮角色代入
- “背景故事 + 标签 + 属性”三层信息组织合理
- 移动端视口下仍然能完成浏览和确认
问题:
- 角色页里的“自定义 / 详情”会让玩家在开局阶段产生一点分心
- 当前角色差异更多停留在说明层,开局前还没完全转化成“我为什么要选这个人”的强动机
## 3.4 营地开场
选择角色进入营地后,游戏会先给一段开场对话,再给玩家两个非常关键的初始决策:
- 先问问对方为什么会出现在这里
- 直接前往下一场景
这一段是目前全流程里最像“正式游戏开局”的部分之一。
正向感受:
- 营地比直接扔进战斗更稳,给了玩家进入状态的缓冲
- 开场对话能自然把关系、任务感和前路危险感一起立起来
- “先聊聊再走 / 直接上路”是个很好的第一轮分流
问题:
- 在静态试玩环境下,剧情区会同时出现完整 `501` HTML 报错,极度出戏
- 开场后文本会夹杂英文 fallback语言氛围被打断
## 3.5 首次选择后的反馈
当我选择“先问问你为什么会出现在这里”后,系统会立即给出关系变化与互动解锁反馈,例如可继续触发:
- 交易
- 切磋
- 前往下一场景
这一点说明“问一句话不是纯文案,而是会打开玩法分支”,这非常重要。
正向感受:
- 玩家会感觉自己的选择真的改变了接下来能做什么
- NPC 不是摆设,至少已经能作为玩法节点工作
- “营地开场 -> 关系松动 -> 解锁交互”这条节奏是成立的
问题:
- 互动文案和角色名偶尔会出现中英混用
- 某些反馈更像系统摘要,而不是完全沉浸式叙事
## 3.6 场景切换与任务推进
离开营地前往 `宫苑内庭` 后,系统会弹出任务更新提示,随后给出新的场景内可选路线,例如:
-`旧宫侍女` 交谈
- 继续向前探路
- 前往 `铸坊工场`
这是当前版本最能证明“游戏不是单房间对话器”的一段。
正向感受:
- 切场景后有明确任务更新,玩家知道自己没有在空转
- 新场景不是纯背景图替换,而是伴随新的实体和路径选择
- “NPC / 探索 / 场景跳转”三种入口并列,主循环味道出来了
问题:
- 任务弹窗表达比较清楚,但质感仍偏功能通知
- 任务标题、阶段名、剧情节拍这些信息有时偏系统化,缺少一点戏剧包装
## 3.7 NPC 互动与交易
`宫苑内庭``旧宫侍女` 接触后,可以进一步进入交易。交易弹窗中已经具备:
- NPC 名称
- 玩家当前货币
- 对方库存
- 购买数量调整
- 总价计算
- 取消 / 确认购买
这是当前版本完成度相对高的一段玩法。
正向感受:
- 交易不是假按钮,而是完整闭环
- 商品分类、稀有度、价格都能读懂
- 作为玩家,会明显感觉“这个 NPC 是有功能的”
问题:
- 从“与 NPC 交谈”直接跳到“交易 / 战斗 / 切磋”,中间缺少一层更自然的对话承接
- 当前交易更偏功能正确,角色气质和商品叙事关联还不够强
## 3.8 战斗 / 切磋
我实际触发了切磋和场景战斗。进入后,系统会给出带有数值提示的选项,例如:
- 耗蓝
- 伤害
- 正面压制 / 稳扎稳打 / 假动作切入
这说明战斗不是纯文案,而是有明确本地规则参与的。
正向感受:
- 玩家能看见技能选择的直接代价和收益
- 战斗选项语义比较明确,不是模糊散文式描述
- 从营地切磋到场景战斗的承接是通的
问题:
- 在本次静态试玩里,战斗文本会出现英文 fallback
- 战斗推进感还不够强,玩家能感知到“进入战斗了”,但还不够容易感知“这一手到底让局势前进了多少”
## 3.9 地图、队伍、背包
我实际打开了地图、队伍和背包。
地图方面:
- 点击场景名可直接打开地图弹窗
- 当前场景和相邻场景关系可见
- `宫苑内庭` 可看到 `雨夜长街``铸坊工场``地宫通道`
队伍方面:
- 队伍列表能打开
- 可读到主角状态、标签数、适配倍率
- 但面板里已经出现明显乱码,如 `闃熼暱`
背包方面:
- 能看到初始资源、材料和工坊配方
- 锻造 / 合成入口都已经在主流程里
- 玩家会明确感觉到“我不是只有剧情,我还有 build 和资源循环”
整体判断:
- 三个面板都不是空壳
- 地图和背包的功能价值已经足够成立
- 队伍页的信息密度没问题,但乱码已经直接破坏观感
## 3.10 保存并退出 / 继续游戏
设置面板中已经有:
- 音乐音量
- 运行统计
- 保存并退出
我实际触发了“保存并退出”,随后回到开始页,再点击“继续游戏”,能够恢复到先前场景和流程状态。
这是本次试玩里最让我放心的一条链路。
结论:
- 存档与继续不是摆设,是真的通了
- 这让整套流程第一次具备了“可以连续玩,而不是每次重开”的基础感
---
## 4. 当前版本最明显的优点
1. 主循环骨架已经成立。开局、选世界、选角色、营地、任务、切场景、NPC、交易、战斗、背包、地图、存档这些点已经能串起来。
2. 移动端优先思路是对的。至少在窄屏下,核心路径没有因为布局崩掉而不可玩。
3. 功能入口大多不是假入口。交易、地图、背包、保存继续都是真能执行的。
4. “AI 叙事 + 本地规则”的边界能感知到。尤其战斗选项里的耗蓝 / 伤害提示,已经把规则感立起来了。
---
## 5. 当前版本最影响玩家体验的问题
## P0
1. 开发入口直接被错误遮罩拦住。当前 `http://127.0.0.1:3000` 不是“有点瑕疵”,而是玩家根本进不去。
2. 标准构建命令当前不可用。`npm run build` 会因为 `dist` 清理阶段的 `EPERM` 失败,说明发布路径并不稳定。
3. 没有 `/api` 代理时,剧情区会直接显示完整 `501` HTML 错误正文,沉浸感几乎被瞬间打穿。
## P1
1. 多处英文 fallback 直接进入正式体验例如营地、NPC 接触、切磋文本。
2. 队伍面板已经出现玩家可见乱码,如 `闃熼暱``褰撳墠濮旀墭` 一类内容。
3. 冒险主标签栏被隐藏后,玩家主要依赖小按钮进入队伍/背包,主导航层级不够直观。
4. NPC 首次互动到“交易 / 战斗 / 切磋”的切换偏硬,少了一层更自然的剧情过渡。
## P2
1. 世界页和角色页已经能用,但记忆点还不够强,个体世界身份和角色差异还可以再拉开。
2. 任务提示偏功能型,情绪包装和戏剧感还可以继续加强。
---
## 6. 玩家视角总结
如果只从“玩法骨架”看,这个项目已经不是 PPT也不是只有几个页面的壳。它已经有一条能走完整圈的游戏主流程而且最关键的交易、地图、任务、战斗、存档都不是假的。
但如果从“当前玩家第一次打开就会得到什么体验”来看,问题也很直接:
- 入口不稳
- 构建不稳
- 离线 / 无代理时错误文案直接冲到脸上
- 中英混用和部分乱码会快速打断沉浸
一句话总结:
**当前版本已经具备“能玩一圈”的核心骨架,但距离“放心交给玩家体验”还差一次很扎实的入口修复、错误兜底和文本统一收尾。**
---
## 7. 建议的下一步
建议优先顺序如下:
1. 先修入口可玩性:解决开发服错误遮罩、`build` 清理失败、静态环境错误文案泄露。
2. 再修体验一致性:清掉玩家可见英文 fallback 和明显乱码。
3. 然后打磨主循环表达让营地开场、任务更新、NPC 接触这三段更有戏。
4. 最后再扩内容:因为现在真正限制体验的不是“内容太少”,而是“入口和表达不够稳”。

View File

@@ -0,0 +1,876 @@
# AI 原生任务驱动目标感增强 PRD
更新时间:`2026-04-07`
## 0. 目标
这份 PRD 面向当前仓库,解决的是一个已经被用户明确反馈出来的问题:
**当前游戏虽然已经具备任务、章节、旅程节拍、剧情线程等系统,但玩家在实际游玩里,仍然经常感受不到“我现在到底在朝什么目标推进”。**
这里要增强的,不是单纯“多做一些任务”,而是把现有系统重新组织成一条玩家可感知的目标驱动链路,让玩家在大多数时刻都能快速回答 3 个问题:
1. 我现在在做什么?
2. 为什么这件事值得我去做?
3. 我下一步应该去哪里、找谁、做哪件事?
一句话目标:
**把当前分散在任务、章节、旅程、线程里的推进信息,编译成玩家随时能感知到的“主目标 -> 当前委托 -> 下一步行动”体验。**
---
## 1. 当前问题定位
## 1.1 当前项目其实已经有不少“目标相关系统”
从现有仓库看,项目并不是没有目标系统,而是已经有了多套“部分成立”的目标表达:
- `src/data/questFlow.ts`
- 已经有 `QuestIntent -> QuestContract -> QuestStep -> QuestProgressSignal` 的任务闭环。
- `src/hooks/useStoryGeneration.ts`
- 已经在运行时维护 `chapterState``journeyBeat``storyEngineMemory.activeThreadIds``setpieceDirective` 等叙事推进信息。
- `src/components/AdventurePanel.tsx`
- 已经有任务入口、章节入口、任务完成提示。
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- 已经能展示章节面板、任务面板、任务详情与奖励弹窗。
- `src/types/storyEngine.ts`
- 已经有 `ChapterState``JourneyBeat``ThreadContract``SetpieceDirective` 这些中长线推进结构。
这说明当前问题不是“系统完全缺失”,而是:
**这些系统还没有被编译成一套稳定、持续、前台可见的目标体验。**
## 1.2 为什么玩家仍然会觉得“没目标”
结合现有实现,当前目标感不足主要来自 6 个原因。
### 1.2.1 目标信息分散在多个系统里,但没有统一前台主语
目前玩家可能同时受到这些信息影响:
- 章节标题
- 当前旅程节拍
- 活跃任务
- 任务当前 step
- 活跃剧情线程
- 当前场景异动
但这些信息没有被统一编译成一句更强的玩家语义:
- “你这章在追什么”
- “你此刻最该先推进哪一件事”
- “如果你现在只做一步,最有价值的是哪一步”
结果就是后台系统知道很多,前台玩家却只感到“事情很多,但方向不够清楚”。
### 1.2.2 任务存在,但任务不等于持续目标感
当前任务系统已经能接受、推进、完成、交付,但它的可见性仍偏“日志型”:
- 任务主要在任务面板里看
- 任务完成时有提示,但完成前的存在感不够强
- 玩家在主冒险视图里,并不会持续被提醒“这就是你当前的核心目标”
这会导致:
- 任务更像“可选记录”
- 而不是“持续牵引当前行动的主线张力”
### 1.2.3 章节 / 旅程节拍更像背景摘要,不像可执行目标
现有 `chapterState``journeyBeat` 已经很接近“中长期目标骨架”,但当前更偏:
- 章节氛围说明
- 旅程阶段命名
- 剧情回顾面板内容
而不是:
- 此刻最该推进哪条线程
- 当前节拍对应的推荐行动
- 这一步不推进会错过什么
于是它们更像“叙事状态”,不够像“行动目标”。
### 1.2.4 选项列表没有稳定表达“哪些动作在推进当前目标”
当前 `StoryOption` 主要按 function 合法性和 priority 输出,玩家能看到:
- 可以做什么
但不总能一眼看出:
- 哪个选项是在推进当前主目标
- 哪个选项只是支线绕行
- 哪个选项是补给、社交、整理状态
当所有选项都以类似强度出现时,玩家会更容易进入“我每一步都像在随便试试”的体验。
### 1.2.5 目标完成后的“下一目标交接”不够强
当前一个任务完成后,系统能做的是:
- 显示完成提示
- 去任务日志领奖
但目标体验更关键的一步其实是:
**完成之后,系统要马上把下一段方向递到玩家手上。**
如果这一步缺失,玩家就会在“完成一个点”之后重新掉回目标真空。
### 1.2.6 探索和叙事已经有内容,但缺少“承诺感”
当前系统已经能提供:
- NPC 氛围
- 场景异常
- 宝藏调查
- 战斗奖励
- 关系推进
但玩家未必知道这些碎片最后会指向什么。
也就是说,项目已经有不少“局部有趣”,但还缺一层更明确的:
- 这一章我正在靠近什么
- 这一段我为什么继续走下去
- 现在这次调查、切磋、汇报,和后面的更大目标是什么关系
## 1.3 当前问题的根因总结
可以把根因压缩成一句话:
**项目已经有“任务系统”和“叙事阶段系统”,但还没有“玩家视角的统一目标导演层”。**
---
## 2. 设计原则
## 2.1 目标感增强不等于满屏任务文案
这个项目已经明确要求 UI 保持清爽、移动端优先,因此这次方案不能走:
- 左上角一大堆任务文字
- 常驻厚重任务列表
- 密密麻麻的规则说明
正确方向是:
- 前台只突出 1 个当前主目标
- 再给 1 个清晰的下一步
- 其余信息折叠到任务 / 章节 / 地图面板中
## 2.2 AI 负责叙事强化,本地规则负责目标裁决
目标系统继续遵循当前项目已经验证有效的边界:
AI 负责:
- 当前目标为什么重要
- 这一步在故事里的张力
- 目标推进时的氛围和话术
本地规则负责:
- 当前目标从哪里来
- 哪个目标优先级最高
- 哪个选项算推进目标
- 目标何时完成、阻塞、切换
## 2.3 玩家应始终看到“短中长”三层目标
真正稳定的目标感,不是只有任务,也不是只有主线,而是至少同时成立 3 层:
1. 长目标
- 这一章 / 这一幕到底在逼近什么
2. 中目标
- 当前正在处理哪条委托、哪条线程、哪段关系
3. 短目标
- 下一步具体要去哪里 / 找谁 / 做什么
## 2.4 至少保证一个明确前进方向,但保留探索自由
目标驱动不是把玩家锁死在唯一按钮上。
正确体验应该是:
- 大多数时刻都有一个清晰可前进的方向
- 但仍然允许补给、聊天、绕行、观察、整理 build
- 玩家知道自己是在“主动偏离”,而不是“系统根本没方向”
## 2.5 目标必须来自当前局面,而不是硬塞公告栏
这次不做传统 MMO 式的固定任务板,而要让目标继续从这些上下文里长出来:
- 当前章节主题
- 当前旅程节拍
- 活跃线程
- 当前场景压力
- 当前 NPC 关系
- 当前资源缺口
换句话说:
**目标感要更强,但目标来源仍然要像从当前叙事局面里自然长出来。**
---
## 3. 核心方案Goal Stack目标栈
建议在现有 `quest + chapter + journeyBeat + threadContract` 之上,新增一层统一的玩家目标结构:
**Goal Stack**
它不替代现有系统,而是把现有系统编译成玩家当前最容易理解的目标层级。
## 3.1 三层结构
### 3.1.1 North Star Goal章节承诺
这是玩家当前阶段最大的“为什么继续往前”的理由。
它来自:
- `chapterState`
- `actState`
- `setpieceDirective`
- 当前主线程组合
玩家可感知的表达应该像:
- 追查失踪背后的真正势力
- 逼近这一区域的核心威胁
- 弄清某位关键 NPC 为何始终在回避真相
它回答的问题是:
**这一章总体在往哪里去。**
### 3.1.2 Active Contract Goal当前主目标
这是当前最应该推进的一件事。
它通常来自:
- 当前活跃任务
- 当前线程合约
- 当前旅程节拍对应的调查 / 汇报 / 前往 / 对峙目标
它回答的问题是:
**此刻最值得优先推进的事情是什么。**
### 3.1.3 Immediate Step Goal下一步行动
这是玩家在当前回合、当前场景最容易执行的实际动作。
它通常来自:
- 当前任务 active step
- 当前推荐场景
- 当前推荐 NPC
- 当前可触发 signal
它回答的问题是:
**如果我现在就迈一步,最合理的是先做什么。**
## 3.2 支持目标
除了主目标外,再允许最多 `2` 个支持目标,作为轻量附属存在:
- 关系目标
- 补给目标
- 构筑目标
- 探索支线
它们应该存在,但不挤占主目标在前台的视觉优先级。
## 3.3 3 秒规则
Goal Stack 设计的核心验收标准是:
**玩家在任意正常游玩时刻,用 3 秒就能看懂当前主目标和下一步。**
---
## 4. 目标导演层Goal Director
建议新增 `Goal Director`,负责把现有多个系统编译成一份统一的前台目标。
## 4.1 输入来源
目标导演层的输入应至少包含:
- `chapterState`
- `journeyBeat`
- `storyEngineMemory.activeThreadIds`
- `setpieceDirective`
- `quests`
- `currentScene`
- `currentEncounter`
- `npcStates`
- 玩家资源状态
- 最近若干 `StorySignal` / `QuestProgressSignal`
## 4.2 输出目标
输出应是一个稳定的 `GoalStackState`
```ts
type GoalSourceKind =
| 'quest'
| 'chapter'
| 'journey_beat'
| 'thread_contract'
| 'setpiece'
| 'relationship'
| 'survival';
type GoalTrack = 'main' | 'side' | 'relationship' | 'survival' | 'exploration';
type GoalStatus =
| 'teased'
| 'active'
| 'blocked'
| 'ready_to_resolve'
| 'resolved'
| 'archived';
interface GoalStackEntry {
id: string;
sourceKind: GoalSourceKind;
sourceId: string;
layer: 'north_star' | 'active_contract' | 'immediate_step' | 'support';
track: GoalTrack;
title: string;
promiseText: string;
whyNow: string;
nextStepText: string;
sceneHint?: string | null;
npcHint?: string | null;
progressLabel?: string | null;
status: GoalStatus;
urgency: 'low' | 'medium' | 'high';
relatedThreadIds: string[];
}
interface GoalStackState {
northStarGoal: GoalStackEntry | null;
activeGoal: GoalStackEntry | null;
immediateStepGoal: GoalStackEntry | null;
supportGoals: GoalStackEntry[];
}
```
## 4.3 目标优先级裁决
建议 Goal Director 按下面顺序裁决前台主目标:
1. 若存在 `ready_to_turn_in` 的关键主目标任务,优先前台化“去交付”
2. 若存在活跃任务 step优先将该 step 作为 `immediate_step`
3. 若当前无明确任务,但 `journeyBeat` 有推荐场景或推进方向,则编译成临时主目标
4. 若当前有强 setpiece / showdown / boss_prelude则将其提升为主目标承诺
5. 若玩家资源严重不足,可生成支持型生存目标,但默认不覆盖主线目标
## 4.4 目标切换规则
目标切换不能随便抖动,建议满足以下规则:
1. 主目标默认保持稳定,直到完成、阻塞或被更高优先级事件接管
2. 支持目标可以进出,但不频繁替换主目标
3. 章节目标只在章内关键阶段变化时更新
4. 当前 step 完成后,必须立刻切到下一 step 或交付目标
5. 如果玩家连续若干轮没有明确目标,系统必须主动重新生成一份当前 lead
---
## 5. 核心体验闭环Promise -> Commit -> Advance -> Confirm -> Handoff
这次目标感增强的关键,不是“加一个 HUD”而是补齐完整闭环。
## 5.1 Promise先告诉玩家为什么值得追
每一阶段都要先有一句承诺,回答:
- 这件事背后有什么更大的意义
- 当前章节到底在逼近什么
这层主要来自:
- `chapterState`
- `journeyBeat`
- `setpieceDirective`
## 5.2 Commit把大目标落成当前可接的委托或 lead
大目标不能悬空,必须落到玩家当前能承接的一件事:
- 接受委托
- 去某处调查
- 找某 NPC 对话
- 回去交付
- 前往某场景验证异常
## 5.3 Advance推进时持续看到自己在接近目标
推进不能只在任务日志里发生。
推进感需要在主流程里被持续表达:
- 选项提示“推进当前目标”
- 场景文本回响“你正在靠近这条线”
- 进度提示明确“已完成哪一步”
## 5.4 Confirm完成后给明确确认
推进成功后,系统要立即确认:
- 你已经推进了
- 你推进的是哪条目标
- 这一步意味着什么
而不只是静默更新后台进度。
## 5.5 Handoff立刻交接下一目标
真正决定目标感是否持续的,是交接。
完成一件事后,系统要尽快把下一句说出来:
- 现在回去交付
- 线索已经到手,下一步去找谁
- 这一步已完成,更大的目标因此前进到了哪里
如果没有 handoff再好的任务系统也会出现“刚做完就空了”的断层。
---
## 6. UI 表达方案
UI 目标是:
**不增厚页面、不堆规则说明,但让目标在主冒险视图里持续可见。**
## 6.1 冒险页新增常驻 Goal Ribbon
建议在主冒险页增加一个轻量常驻的 `Goal Ribbon`,只展示当前最需要知道的目标信息。
推荐展示字段:
- 目标标题
- 目标 track 标签
- 一句 `nextStepText`
- 一行极短 `sceneHint / npcHint`
- 简短进度表达
表现要求:
- 默认只显示 1 个主目标
- 风格轻,不做厚重说明板
- 手机端优先单列、可折叠
- 不抢占剧情区和选项区的核心空间
## 6.2 主冒险视图要直接显示“下一步”
对玩家而言,最重要的一句不是任务标题,而是:
- 去哪里
- 找谁
- 做什么
因此 Goal Ribbon 里最该强化的是 `nextStepText`,而不是一堆背景说明。
例如:
- 去遗迹外缘确认异动,再回来和林朔对话
- 返回营地,把调查结果告诉同伴
- 前往北桥,追上刚刚提到的敌对角色
## 6.3 选项按钮增加目标关联标记
建议给 `StoryOption` 增加目标关联信息:
```ts
interface StoryOptionGoalAffordance {
goalId: string;
relation: 'advance' | 'support' | 'detour';
label: string;
}
```
然后在前台做极轻量表达:
- 推进当前目标
- 支持当前准备
- 暂时绕开目标
要求:
- 只对关键选项打标
- 不让所有按钮都挂一堆说明
- 至少保证存在主目标时,通常有一个 `advance` 选项
## 6.4 任务面板从“日志”转成“目标板”
当前任务面板基础可复用,但信息组织建议升级为:
1. 当前主目标
2. 正在推进
3. 可交付
4. 支持目标
5. 已归档
这样玩家打开任务页时,看到的不是一排同权列表,而是更明确的目标优先级结构。
## 6.5 章节面板只做“承诺 + 当前节拍 + 当前主目标”
章节面板不需要继续扩成说明书。
建议只保留:
- 当前章节标题
- 当前章节主题
- 当前旅程节拍
- 本章正在追的主问题
- 当前建议推进方向
## 6.6 任务完成提示要直接导向下一个动作
当前“任务完成,可前往日志领奖”已经比没有强,但还不够。
建议改成:
- 任务完成
- 现在去哪里交付 / 现在建议做什么
- 一键打开当前目标详情
这样完成提示本身也成为 handoff 的一部分。
---
## 7. 与叙事生成和选项生成的联动
目标感不是纯 UI 问题,还必须进入叙事与选项生成。
## 7.1 Prompt 上下文注入当前目标摘要
建议在 `buildStoryContextFromState(...)` 产出的上下文中,增加统一目标摘要:
```ts
interface GoalPromptContext {
northStarSummary?: string | null;
activeGoalTitle?: string | null;
activeGoalWhyNow?: string | null;
immediateStepText?: string | null;
supportGoalTitles?: string[];
}
```
AI 使用这层上下文的目的不是发明新目标,而是:
- 在剧情文本里强化当前推进感
- 在选项措辞里更明确表达“哪步是往前”
- 在阶段切换时自然回写 handoff 语气
## 7.2 本地规则保证前进选项存在
当存在主目标且当前场景允许推进时,本地规则应尽量保证:
- 至少一个选项能推进当前目标
- `换一换` 不应把所有前进方向都刷掉
换句话说:
**选项池可以多样,但不能把方向感洗掉。**
## 7.3 目标推进要进入剧情文本回响
当 step 被推进时,剧情文本应更常出现这些反馈:
- 你已经拿到了关键线索
- 这一步让某人对你的判断改变了
- 当前区域的异常已经被你确认
- 现在该回去把结果说清楚
这类文本能明显强化“我不是在原地刷新内容,而是在前进”。
---
## 8. 目标来源设计
为了避免只有“NPC 发任务”才有目标,建议目标来源拆成 6 类。
## 8.1 Quest Goal委托型目标
来自现有 `QuestContract / QuestStep`
适合表达:
- 讨伐
- 调查
- 切磋
- 回报
- 交付
## 8.2 Thread Goal叙事线程型目标
来自:
- `ThreadContract`
- `activeThreadIds`
适合表达:
- 当前章节的主要调查方向
- 某条持续存在但暂未显式任务化的追查线
## 8.3 Journey Goal旅程阶段型目标
来自:
- `JourneyBeat`
适合表达:
- 现在是调查阶段
- 现在该回营整备
- 现在正在逼近冲突前奏
## 8.4 Setpiece Goal高潮前奏型目标
来自:
- `SetpieceDirective`
适合表达:
- 决战前奏
- 对峙前整理
- 余波中的关键善后
## 8.5 Relationship Goal关系推进型目标
来自:
- NPC 好感阶段
- 同伴弧线
- 当前营地事件 / 私聊机会
适合表达:
- 找某人谈清楚
- 处理一次关系冲突
- 在营地承接一段角色剧情
## 8.6 Survival Goal生存补给型目标
来自:
- 资源紧张
- build 缺口
- 路线压力
适合表达:
- 先补给
- 先回营整理
- 先准备能支撑下一段推进的资源
但它默认只做支持目标,不轻易覆盖主线目标。
---
## 9. 数据结构与模块建议
## 9.1 建议新增类型
建议新增:
- `src/services/storyEngine/goalTypes.ts`
- 或直接扩展 `src/types/storyEngine.ts`
核心结构建议包括:
- `GoalStackEntry`
- `GoalStackState`
- `StoryOptionGoalAffordance`
- `GoalPulseEvent`
- `GoalHandoff`
其中 `GoalPulseEvent` 用于前台反馈:
```ts
interface GoalPulseEvent {
id: string;
goalId: string;
pulseType: 'progress' | 'ready_to_turn_in' | 'resolved' | 'handoff';
title: string;
detail: string;
}
```
## 9.2 建议新增模块
建议新增:
- `src/services/storyEngine/goalDirector.ts`
- `src/services/storyEngine/goalCompiler.ts`
- `src/services/storyEngine/goalSignals.ts`
职责如下:
- `goalDirector`
- 汇总章节 / 旅程 / 任务 / 线程 / 资源状态,裁决当前目标栈
- `goalCompiler`
- 把不同来源编译成统一 GoalStackEntry
- `goalSignals`
- 处理 progress、resolve、handoff 反馈事件
## 9.3 建议改动的现有区域
建议优先接入这些文件:
- `src/hooks/useStoryGeneration.ts`
- 在返回值中新增 `goalUi`
- `src/hooks/story/uiTypes.ts`
- 增加目标 UI 类型
- `src/components/AdventurePanel.tsx`
- 增加 Goal Ribbon 与选项目标标记
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- 将任务 / 章节视图改成围绕主目标组织
- `src/types/story.ts`
- 扩展 `StoryOption` 目标标记字段
- `src/data/questFlow.ts`
- 暴露任务当前 step 与 handoff 所需摘要
---
## 10. MVP 落地顺序
## 阶段 A先做前台统一目标层不重写底层任务系统
先做:
- Goal Director
- Goal Stack 编译
- Adventure 主视图 Goal Ribbon
此阶段目标:
- 玩家打开主冒险页就能看到当前主目标和下一步
- 不要求任务生成逻辑大改
## 阶段 B补齐目标交接
重点做:
- 任务接受后的主目标接管
- step 完成后的下一步切换
- ready_to_turn_in 的前台提升
- 领奖后的下一目标 handoff
此阶段目标:
- 目标不再只在“开始接任务”时存在
- 完成后也能顺势接到下一步
## 阶段 C让选项和剧情文本都带目标感
重点做:
- `StoryOption.goalAffordance`
- 目标推进相关 prompt 上下文
- 目标推进反馈 pulse
此阶段目标:
- 玩家不仅在任务面板里看见目标
- 也能在每一回合的文本和选项里感到自己在朝目标前进
## 阶段 D补非任务型目标来源
重点做:
- `journeyBeat -> goal`
- `thread -> goal`
- `relationship -> support goal`
- `survival -> support goal`
此阶段目标:
- 即使没有显式任务,系统也仍然能给出清晰 lead
---
## 11. 验收标准
做到以下几点,才能说明“任务驱动目标感”真的提升了,而不是只多了一层 UI。
## 11.1 体验验收
1. 新开局 `3` 次有效交互内,玩家必须看到一个明确主目标。
2. 在存在主目标的正常游玩阶段,主冒险页必须持续可见“当前目标 + 下一步”。
3. 任一目标完成后,下一目标或交付动作必须在 `1` 次交互内被明确交接。
4. 玩家不打开任务面板,也能在多数时刻知道自己下一步该做什么。
## 11.2 交互验收
1. 有活跃目标时,选项池中应尽量存在至少 `1` 个推进当前目标的选项。
2. `换一换` 不应把唯一前进方向刷没。
3. 任务完成提示应直接导向当前后续动作,而不只是泛提示。
## 11.3 UI 验收
1. 手机竖屏下 Goal Ribbon 不应把主剧情区和底部操作区挤出首屏。
2. 前台目标信息应控制在轻量级,不堆规则文案。
3. 任务 / 章节 / 目标表达需保持当前项目的清爽游戏 UI 风格。
## 11.4 叙事验收
1. 当前章节承诺、当前主目标、下一步行动三层语义必须能同时成立。
2. 任务推进、调查推进、关系推进都应在剧情文本里有回响。
3. 玩家应明显感到“我正在逼近某件事”,而不只是“我又看了一段新文本”。
---
## 12. 为什么这套方案适合当前仓库
这套方案不是推翻重做,而是顺着仓库已经形成的系统继续往前走:
1. 当前仓库已经有任务 contract 和 step progression
- 所以短目标层并不是从零开始。
2. 当前仓库已经有章节、旅程节拍、线程与 setpiece
- 所以中长目标层已经有素材,只差统一导演。
3. 当前 Adventure UI 已有任务入口、章节入口和弹层体系
- 所以前台表达可以在现有壳层上增量升级。
4. 当前项目强调移动端优先与清爽 UI
- 所以本方案明确走“一个主目标 + 一个下一步”的轻量表达,而不是堆面板。
换句话说:
**当前项目最需要的,不是再造一套新任务系统,而是把已有的任务、章节、线程、旅程节拍编译成一条持续存在的玩家目标体验。**
---
## 13. 最后结论
用户反馈“缺乏任务驱动的目标感”,真正指向的问题不是“没有任务”,而是:
- 任务没有持续站在前台
- 章节和旅程没有转成行动承诺
- 完成后的下一步交接不够强
- 选项和剧情文本没有持续强化“你正在前进”
因此,这次 PRD 的核心不是“继续扩任务数量”,而是补上一个统一的 `Goal Director + Goal Stack`
1. 用章节承诺给玩家长期方向
2. 用当前主目标给玩家中程牵引
3. 用下一步行动给玩家即时清晰的操作方向
4. 用推进反馈与 handoff 把整条目标链接起来
这样之后,玩家感受到的就不再是“系统里有任务”,而会更接近:
**我知道自己为什么在这里、正在推进什么、下一步该去哪,这个世界也在不断回应我的前进。**

View File

@@ -0,0 +1,473 @@
# AI 原生任务系统主前台化调整方案
更新时间:`2026-04-07`
## 0. 背景
在当前迭代里,右上区域同时出现了:
- `目标`
- `章节`
- `任务`
从系统设计角度看,这 3 个概念分别对应:
- `目标`:当前应该优先推进的事情
- `章节`:当前叙事阶段与长期承诺
- `任务`:玩家可执行、可追踪、可交付的实际推进载体
但从玩家视角看,这 3 个入口被并列摆在同一层级时,会产生两个直接问题:
1. 概念重复
- `目标``任务` 都在告诉玩家“下一步做什么”。
2. 主次倒置
- 原本最该成为主前台的“任务系统”,反而被拆散到 `目标 / 章节 / 任务` 三个入口中,导致任务系统看起来像被边缘化。
一句话判断:
**当前不是“信息不够”,而是“前台概念过多,任务作为主推进载体没有站到 C 位”。**
---
## 1. 核心结论
建议把当前前台结构调整为:
**任务系统作为唯一主推进入口,目标与章节退到任务系统内部,成为任务的上层语义与背景语义。**
也就是说:
- `任务` 是玩家前台的主概念
- `目标` 是任务面板里的当前聚焦态
- `章节` 是任务面板里的背景上下文
而不是 3 个并列一级入口。
---
## 2. 当前问题拆解
## 2.1 三个并列入口会制造额外理解成本
玩家进入第一个场景时,本来最需要快速理解的是:
- 我当前接了什么事
- 下一步去哪
- 什么时候算推进
但现在 UI 让玩家先面对的是:
- 要不要看目标
- 要不要看章节
- 要不要看任务
这会把本来应该非常直接的“任务驱动”体验,变成一次概念选择题。
## 2.2 `目标` 和 `任务` 在玩家心智中高度重叠
当前 `目标` 弹窗展示的是:
- 当前主推进
- 下一步
`任务` 面板展示的是:
- 当前主目标任务
- 任务摘要
- 任务详情
这两者在玩家眼里并不是两套系统,而更像:
- 一个是“任务的简版”
- 一个是“任务的详版”
如果它们并列出现,只会让玩家觉得重复。
## 2.3 `章节` 不应与 `任务` 争夺同一前台优先级
章节的作用更接近:
- 给长期方向
- 给叙事承诺
- 给“这一章在讲什么”的理解坐标
它并不直接回答:
- 此刻去哪
- 找谁
- 做什么
因此章节不适合做和任务并列的常驻一级入口,更适合做任务面板里的背景信息,或二级展开信息。
## 2.4 移动端空间被重复入口浪费
项目本身是移动端优先。
当前右上连续摆 3 个入口,会带来:
- 视觉拥挤
- 首屏操作点过多
- 玩家频繁在 3 个高度相似的入口之间来回切
这类浪费在手机端尤其明显。
## 2.5 当前实现会削弱“任务系统正在驱动我”的感受
`目标` 被做成独立前台入口时,用户会更容易把“目标系统”理解成一个独立模块,而不是任务系统的前台头部。
结果就是:
- 任务系统负责记录
- 目标系统负责提醒
- 章节系统负责叙事
三个系统各做一部分,但没有一个系统真正完整承担“驱动玩家前进”的主责任。
---
## 3. 调整原则
## 3.1 前台只保留一个主推进概念:任务
玩家最容易理解、最容易执行、也最容易形成长期习惯的前台概念,应该只有一个:
**任务**
因为任务天然同时满足:
- 可接取
- 可追踪
- 可推进
- 可交付
- 可奖励
这正是“目标感”最需要的系统壳。
## 3.2 目标不是独立模块,而是任务的当前聚焦态
目标仍然有价值,但它在前台的正确位置应是:
- 任务面板顶部的“当前主任务”
- 任务更新时的提示弹窗
- 任务推进时的脉冲反馈
也就是说:
**目标是任务系统的呈现方式,不是另一个并列入口。**
## 3.3 章节不是行动入口,而是任务背景
章节保留,但它更适合表达:
- 本章主题
- 当前阶段
- 当前长期承诺
所以章节应该:
- 在任务面板里提供轻量背景卡
- 或在任务面板中提供“查看章节背景”二级展开
而不是右上一级按钮。
## 3.4 先让任务系统完整承担“承接、推进、反馈、交接”
如果想真正让任务驱动体验成立,就必须让任务系统自己完成完整闭环:
1. 接任务
2. 看任务
3. 推任务
4. 知道自己推进了
5. 知道什么时候可交付
6. 领奖后获得下一任务方向
只要这条链断在别的模块上,任务系统就会继续显得像“半个主系统”。
---
## 4. 新的信息架构
## 4.1 顶层前台结构
建议调整为:
- 保留:`任务`
- 保留:`设置`
- 视情况保留:`统计`
- 移除独立常驻入口:`目标`
- 移除独立常驻入口:`章节`
其中:
- `目标` 被并入任务面板头部与任务更新弹窗
- `章节` 被并入任务面板中的“章节背景卡”
## 4.2 任务面板的新结构
建议把当前任务面板重构为下面 4 层:
### 第一层:当前主任务
只展示玩家此刻最该看的内容:
- 任务标题
- 下一步
- 地点 / 人物提示
- 当前进度
这是原 `目标` 面板应该承载的内容,但要回收到 `任务` 面板头部。
### 第二层:活跃任务列表
展示:
- 当前主任务
- 其他活跃任务
- 可交付任务
排序规则应继续保留:
1. 当前主任务
2. 可交付任务
3. 其他活跃任务
4. 已归档 / 已完成
### 第三层:章节背景卡
只显示:
- 当前章节标题
- 当前段落
- 一句章节摘要
不要再显示:
- 近期回顾大段文本
- 营地风向
- 高光导演
- 其他偏后台式的叙事结构信息
这些信息可以保留在系统内部,但不应默认占据前台。
### 第四层:任务详情页
任务详情页继续保留,但内容也要收束为玩家信息:
- 任务简介
- 目标对象
- 当前步骤
- 奖励
- 交付动作
---
## 5. 弹窗策略调整
## 5.1 “目标弹窗”重命名为“任务更新弹窗”
当前独立的目标弹窗不应继续以“目标”名义存在。
建议改成:
- `任务更新`
- `已接取任务`
- `任务可交付`
- `下一步建议`
这样玩家会直接把它理解成任务系统的反馈,而不是另一套系统。
## 5.2 任务更新弹窗的触发时机
建议只在这些关键节点弹出:
1. 初次进入场景,生成当前主任务时
2. 接受新任务时
3. 当前任务关键步骤切换时
4. 当前任务变为可交付时
5. 领奖后 handoff 到下一目标时
不建议在普通小幅状态变化时频繁弹出。
## 5.3 任务更新弹窗展示内容
只保留:
- 当前任务标题
- 当前为什么值得推进
- 下一步做什么
- 如有必要,给出地点 / 人物提示
不要再展示:
- 支持目标
- 章节承诺大段说明
- 过多后台状态信息
---
## 6. 章节信息的前台降级方案
## 6.1 章节从一级入口降为任务系统内嵌信息
章节仍然重要,但前台地位应该调整为:
- 默认不单独抢入口
- 默认只在任务面板中出现
- 仅在必要时从任务面板中二级展开
## 6.2 章节面板的最小展示集
如果仍然保留章节面板,建议最小化到:
- 当前章节标题
- 当前阶段
- 当前段落
- 当前推进方向
不再默认展示:
- 近期回顾长文
- 营地事件
- setpiece 导演问题
- 其他后台语义分层
## 6.3 章节与任务的关系表达
章节信息应服务于任务理解,而不是独立存在。
推荐表达方式:
- 在任务面板中显示:
- `所属章节:封桥旧案`
- `当前段落:调查`
- `当前推进方向:继续追查桥上的异常来源`
让玩家知道:
**任务是我现在在做的,章节是这件事属于哪一章。**
---
## 7. 系统语义重排
建议把当前 3 层语义重新映射成:
### 前台玩家语义
1. 任务
- 玩家真正会去点击、追踪、推进的对象
2. 当前主任务
- 任务中的当前聚焦项
3. 章节背景
- 帮助理解任务所在的大方向
### 后台系统语义
1. `Quest`
- 主执行壳
2. `GoalStack`
- 任务前台聚焦编译层
3. `Chapter / JourneyBeat / Setpiece`
- 任务背景和长期语义来源
也就是说:
**Goal Stack 继续保留在系统内部,但在 UI 语义上不再和 Task 并列。**
---
## 8. 实现调整建议
## 8.1 第一阶段:入口收口
直接调整:
- 删除右上独立 `目标` 按钮
- 删除右上独立 `章节` 按钮
- 保留 `任务` 按钮
同时:
- 将当前目标弹窗改名为任务更新弹窗
- 从任务按钮进入任务面板
## 8.2 第二阶段:任务面板吸收目标信息
在任务面板中新增顶部区域:
- 当前主任务
- 下一步
- 简短提示
并用它替代当前独立 `目标` 入口的职责。
## 8.3 第三阶段:章节信息降级
调整章节信息展示为:
- 任务面板中的轻量背景卡
必要时:
- 在任务面板内再点“查看章节背景”进入二级详情
## 8.4 第四阶段:任务更新弹窗统一化
把当前各种与目标相关的弹窗统一命名和风格:
- 接取任务
- 任务推进
- 任务可交付
- 下一步建议
统一挂到任务系统语义下。
---
## 9. 对当前代码结构的建议映射
建议后续实现时主要改这些位置:
- `src/components/AdventurePanel.tsx`
- 去掉独立目标前台嵌入和独立章节入口
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- 重做任务面板顶部和章节卡结构
- `src/services/storyEngine/goalDirector.ts`
- 保留 GoalStack但只作为任务聚焦编译层
- `src/hooks/useStoryGeneration.ts`
- 把 pulse / handoff 语义统一归到任务更新流
- `src/hooks/useStoryOptions.ts`
- 保持选项与当前主任务的关联标记
---
## 10. 验收标准
做到以下几点,才说明“任务系统为主”的调整真正成立:
1. 右上不再同时并列出现 `目标 / 章节 / 任务` 三个入口。
2. 玩家在前台只需要理解一个主推进概念:`任务`
3. 当前主任务、下一步、可交付状态,都能在任务系统内部闭环完成。
4. 章节信息仍然存在,但不再和任务抢夺一级前台入口。
5. 首个场景进入后,玩家首先感知到的是“接到了什么任务”,而不是“系统里还有一套目标模块”。
6. 移动端右上操作区明显更简洁,主剧情区不再被多套并列语义干扰。
---
## 11. 最后结论
当前右上同时摆 `目标 / 章节 / 任务`,本质上是在让三个语义层并列竞争前台注意力,这会直接削弱任务系统的主导感。
正确的调整方向不是“继续优化三个入口”,而是重新明确主次:
- **任务**:前台唯一主推进入口
- **目标**:任务系统内部的当前聚焦态
- **章节**:任务系统内部的背景语义
这样调整之后,玩家前台感受到的就不再是:
- “我该看目标、章节还是任务?”
而会更接近:
- “我当前的任务是什么,下一步去哪,章节只是告诉我这件任务属于哪一段故事。”

View File

@@ -0,0 +1,645 @@
# 阿里云 NPC 角色形象与动作动画编辑器实验方案2026-04-07
## 1. 文档目的
本文不是再写一份泛化的“AI 角色动画大方案”,而是专门回答当前编辑器里要怎么实验这条链路:
- 接入阿里云百炼的文生图、图生图、图生视频、参考视频动作模型
-**NPC 角色形象 + 动作动画资产化** 为目标
- 最终产物仍然要落回当前项目的 `CharacterAssetPanel -> publish -> CharacterAnimator`
本文把方案拆成 4 条实验线:
1. 先文生角色形象图,再图生动作序列帧图并解析
2. 先文生角色形象图,再图生视频
3. 先文生角色形象图,再走“参考视频驱动”的动作模板链
4. 先文生角色形象图,再走“参考生视频 / 剧情演出”链
查阅与核对时间:`2026-04-07`
---
## 1.1 当前实现状态2026-04-07
当前仓库已经把下面这些能力接进 `CharacterAssetPanel`
- 阶段 A`wan2.7-image-pro / wan2.7-image` 主形象候选生成
- 阶段 B4 条动作方案都已接入真实模型
- 阶段 C方案四单独拆成“演出片段”预览区
- 方案三增加了“内置模板库”入口,可直接把项目现有角色序列帧合成为参考视频
- 最近一次主形象任务 / 动作任务状态会回显到编辑器
- 已补动作模板列表接口与视频导入接口
当前实现的本地接口为:
- `POST /api/character-visual/generate`
- `GET /api/character-visual/jobs/:id`
- `POST /api/character-visual/publish`
- `POST /api/animation/generate`
- `GET /api/animation/jobs/:id`
- `GET /api/animation/templates`
- `POST /api/animation/import-video`
- `POST /api/animation/publish`
当前视频后处理采用:
- 模型端生成真实视频
- 浏览器端抽帧、缩放、简单绿幕抠像
- 发布阶段再写入 `public/generated-animations`
也就是说,这份文档里原先一些“推荐下一步”已经落地,但还有一部分“更重的任务化路由”尚未继续拆开。
---
## 2. 当前仓库里的可复用基础
这次实验不应该另起炉灶,因为仓库里已经有 3 个很关键的基础。
### 2.1 编辑器入口已经存在
- 路由 `/character-asset-studio` 已经接到 `PresetEditor`,说明“角色资产工坊”入口是现成的。
- 当前核心页面是 `src/components/preset-editor/CharacterAssetPanel.tsx`
### 2.2 主形象 / 动作两段式 UI 已经存在
当前 `CharacterAssetPanel` 已经分成:
- 阶段 A主形象
- 阶段 B基础动作
- 阶段 C演出片段
旧版本里生成逻辑确实是本地 mock
- 主形象候选来自 `buildVisualCandidatesFromSource`
- 动作草稿来自 `buildAnimationClipFromMaster`
现在这层已经被真实模型链路替换,但仍然保留了这些本地能力作为后处理工具:
- 参考视频模板合成
- 视频抽帧
- 简单绿幕抠像
- 生成发布用帧集
### 2.3 本地 API 插件里已经有 DashScope 接入样板
`scripts/dev-server/localApiPlugins.ts` 里已经接了自定义世界场景图:
- 默认 DashScope base URL 已经存在
- 已经有异步任务创建、轮询、下载、落盘、写 manifest 的完整样板
这意味着这次实验最合理的做法是:
- 继续沿用 `/api/*` 本地代理模式
- 新增角色图 / 角色动作的 job 路由
- 复用现有的任务轮询和文件落盘思路
---
## 3. 阿里云当前可直接利用的模型能力
基于 2026-04-07 查阅的阿里云官方文档,当前和本实验最相关的是下面几类能力。
| 能力 | 推荐模型 | 适合用途 | 备注 |
| --- | --- | --- | --- |
| 文生图 / 图生图 / 图像编辑 | `wan2.7-image-pro``wan2.7-image` | 生成 NPC 主形象图、做风格统一、生成组图候选 | 官方文档明确支持多图参考与组图输出 |
| 图生视频 | `wan2.7-i2v` | 单角色主形象转动作视频 | 支持首帧、首尾帧、续写片段 |
| 参考生视频 | `wan2.7-r2v``wan2.6-r2v-flash` | 多参考图/参考视频驱动剧情演出 | 更适合演出,不是最优基础动作线 |
| 图生动作 | `wan2.2-animate-move` | 主形象 + 参考动作视频 -> 标准动作视频 | 动作控制更强,适合模板动作库 |
| 视频换人 | `wan2.2-animate-mix` | 模板视频里的角色替换成 NPC 形象 | 适合动作模板“复刻” |
需要特别说明:
- 方案一会用到 `wan2.7-image-pro` 的组图 / 顺序组图能力,但 **官方并没有把它定义为“动作逐帧模型”**
- 所以方案一是“利用图像模型能力去逼近动作帧生产”的实验线,不是官方标准动作生产线。
- 方案二、三、四更贴近阿里云官方为视频生成准备的主线能力。
---
## 4. 方案一:文生角色形象图 -> 图生动作序列帧图 -> 解析成动画
## 4.1 目标
直接得到 `png` 帧集,尽量少碰视频编解码。
## 4.2 模型链路
1.`wan2.7-image-pro` 生成 NPC 主形象图
2. 再把主形象图作为参考图输入 `wan2.7-image-pro`
3. 对每个动作槽位生成一组候选图片
4. 打开组图输出,必要时启用 `enable_sequential`
5. 本地按动作顺序解析这些图,写回帧序列
## 4.3 为什么它成立
阿里云图像生成与编辑 API 当前明确支持:
- 文生图
- 图生图
- 多图参考
- 一次输出多张图
- 顺序组图输出 `enable_sequential`
因此可以在编辑器里做这样的实验:
- 输入:主形象图 + 动作描述 + 固定 seed
- 输出:同一动作的一组关键帧候选
- 后处理:按姿态差异、角色一致性、武器完整度排序,补成帧集
## 4.4 编辑器里的具体玩法
建议在当前“阶段 B基础动作”里加一个策略选项
- `帧序列实验(图像组图)`
每次动作生成时:
1. 选择动作槽位,如 `idle / run / attack / hurt`
2. 选择目标帧数,如 `4 / 6 / 8`
3. 传入主形象图
4. 拼出动作提示词,例如“同一角色,侧身朝右,单人,全身,武器完整,连续 6 帧,跑步动作,从预备到迈步再到回收”
5. 请求组图结果
6. 本地做帧序评分
7. 生成 `frames/*.png + manifest.json`
## 4.5 优点
- 直接产出图片,天然适合当前项目的帧资产结构
- 不需要先生成视频再解帧
- 某些短动作可以直接人工挑帧,编辑器可控性高
-`idle``acquire``hurt` 这种短动作实验门槛较低
## 4.6 风险
- 最大风险是帧间一致性,特别容易出现衣摆、武器、手部、头发抖动
- 组图的“顺序性”不等于真正的视频时序连续性
- `run``jump``dash` 这类长动作很可能不稳定
- 如果没有额外姿态评分和人工筛选,最后帧序会很跳
## 4.7 结论
这是 **低基础设施成本、高人工筛选成本** 的方案。
适合:
- 编辑器里先做原型实验
- 验证 NPC 主形象一致性能不能维持到多帧
- 生成短动作关键帧
不适合直接作为第一版唯一主线。
---
## 5. 方案二:文生角色形象图 -> 图生视频 -> 解帧资产化
## 5.1 目标
先让视频模型负责动作连续性,再由本地后处理把视频转成项目动画资产。
## 5.2 模型链路
1.`wan2.7-image-pro` 生成 NPC 主形象图
2.`wan2.7-i2v` 基于主形象图生成动作视频
3. 下载视频结果
4. 本地抽帧
5. 做裁切、稳帧、像素化、去闪烁
6. 输出序列帧、Sprite Sheet、manifest
## 5.3 方案二里的两种子模式
### A. 首帧生视频
适合:
- `attack`
- `hurt`
- `die`
- `cast`
特点:
- 主形象图作为 `first_frame`
- 文本控制动作
- 最快接入,链路最短
### B. 首尾帧生视频
适合:
- `idle`
- `run`
- 循环站姿
特点:
- `first_frame` 是起始站姿
- `last_frame` 是回正后的收尾姿态
- 更利于做循环动作和回到可衔接状态
## 5.4 编辑器里的具体玩法
建议在“阶段 B基础动作”里加
- `图生视频(首帧)`
- `图生视频(首尾帧)`
参数建议:
- 时长:`2s / 3s / 4s`
- 目标 FPS先统一导入到本地后再重采样
- 循环动作:是否要求首尾近似
- 提示词模板:按动作槽位固化
## 5.5 优点
- 动作连续性通常明显强于方案一
- `wan2.7-i2v` 是官方主线能力,兼容性和迭代空间更好
- 很适合作为当前编辑器的第一条“真实动作生成”主线
- 本地后处理完成后,仍然能回到当前项目的帧资源体系
## 5.6 风险
- 需要稳定的视频后处理链
- 解帧后仍要处理轮廓闪烁、脚底漂移、武器变形
- 主形象复杂时,单图生视频可能会有角色漂移
- 相比方案一I/O 和处理耗时更高
## 5.7 结论
这是 **最适合作为编辑器第一版正式实验主线** 的方案。
原因:
- 模型能力更贴近官方主线
- 动作连续性通常更稳定
- 生成结果仍可资产化
---
## 6. 方案三:文生角色形象图 -> 参考视频驱动动作模板链
## 6.1 目标
不是只靠文本“想象动作”,而是给动作一个明确模板视频,让模型做可控迁移。
## 6.2 模型链路
推荐两条可选子线:
### A. `wan2.2-animate-move`
输入:
- NPC 主形象图
- 参考动作视频
输出:
- NPC 执行该动作的视频
### B. `wan2.2-animate-mix`
输入:
- NPC 主形象图
- 模板视频
输出:
- 保留模板视频场景/动作,但把角色替换成 NPC
## 6.3 它和方案二的本质区别
方案二是:
- 主形象图 + 文本描述 -> 视频
方案三是:
- 主形象图 + 模板动作视频 -> 视频
因此方案三最大的价值不是“更自由”,而是“更可控”。
## 6.4 编辑器里的具体玩法
在“阶段 B基础动作”里新增
- `动作模板库`
每个动作槽位先配一份官方/自制模板:
- `idle_loop`
- `run_side`
- `attack_slash`
- `hurt_back`
- `die_fall`
工作流:
1. 先锁定 NPC 主形象
2. 选择动作槽位
3. 选择一个模板视频
4. 调用 `animate-move``animate-mix`
5. 下载视频
6. 解帧、稳帧、裁切
7. 发布为该动作槽位正式资产
## 6.5 优点
- 可控性明显高于纯文本图生视频
- 非常适合做“基础动作槽位不能为空”的项目要求
- 一旦模板库建立起来,多角色批量复用效率很高
-`run``attack``hurt` 这种标准动作尤其友好
## 6.6 风险
- 要先建设动作模板库
- `wan2.2-animate-move` 官方输入更偏“单人清晰主体”,对严格侧视游戏素材要额外测试
- 模板视频如果镜头、背景、构图不统一,后处理成本会增加
- 模板库前期准备成本高于方案二
## 6.7 结论
这是 **最适合做战斗基础动作标准化生产** 的方案。
如果只看“当前项目需要补齐 `idle / run / attack / hurt / die` 这些基础槽位”,方案三的长期价值甚至高于方案二。
建议排序:
- 第一阶段先做方案二跑通链路
- 第二阶段尽快把方案三补成稳定模板库主线
---
## 7. 方案四:文生角色形象图 -> 参考生视频 / 剧情演出链
## 7.1 目标
这条线不是优先服务“战斗基础动作”,而是服务:
- 剧情演出
- 招募演出
- NPC 说话/表态
- 立绘转小段表演视频
## 7.2 模型链路
推荐:
- `wan2.7-r2v`
- 成本敏感或无声短片可考虑 `wan2.6-r2v-flash`
参考生视频支持把图片、视频作为参考条件输入,再结合文本生成视频。
## 7.3 它和方案三的区别
方案三更像:
- 我已经知道动作模板,就要把它迁过去
方案四更像:
- 我给你角色参考和演出参考,请你生成一段新的镜头表达
所以它更适合:
- NPC 出场特写
- 对话演出
- 剧情镜头
- 情绪表演
不适合优先用于:
- 项目所有基础战斗动作槽位
## 7.4 编辑器里的具体玩法
当前已单独拆成:
- `演出片段`
字段建议:
- 角色主形象
- 参考图最多若干张
- 参考视频片段
- 台词或情绪提示
- 是否保留音频
输出:
- `preview.mp4`
- 关键帧截图
- 可选封面图
## 7.5 优点
- 角色一致性上限更高
- 更适合做剧情演出而不是纯动作片段
- 后续和 `CharacterChatModal`、NPC 招募、事件特写更容易联动
## 7.6 风险
- 对当前战斗帧资产体系帮助没有前三条直接
- 更容易产出“好看的视频”,但不一定容易切成稳定序列帧
- 这条线如果过早投入,会稀释基础动作资产生产的主线
## 7.7 结论
这是 **剧情演出增强线**,不建议抢在方案二、三之前做。
---
## 8. 四种方案横向对比
| 方案 | 动作连续性 | 可控性 | 资产化难度 | 适合基础动作 | 适合剧情演出 | 推荐阶段 |
| --- | --- | --- | --- | --- | --- | --- |
| 方案一:组图帧序列 | 低到中 | 中 | 低到中 | 中 | 低 | 研究线 |
| 方案二:图生视频 | 中到高 | 中 | 中到高 | 高 | 中 | 第一阶段主线 |
| 方案三:模板视频驱动 | 高 | 高 | 中到高 | 很高 | 中 | 第一阶段后半 / 第二阶段主线 |
| 方案四:参考生视频 | 中到高 | 中到高 | 高 | 中 | 很高 | 第三阶段增强 |
一句话总结:
- 要最快落地:先做 **方案二**
- 要把基础动作做稳:尽快补 **方案三**
- 要低成本试帧:可以并行试 **方案一**
- 要做剧情镜头:后续再做 **方案四**
---
## 9. 面向当前编辑器的落地状态与下一步
## 9.1 第一轮
这一轮已经完成:
- 阶段 A`wan2.7-image-pro` 主形象生成
- 阶段 B`wan2.7-i2v` 图生视频
原因:
- 最少改 UI
- 最快复用当前 `CharacterAssetPanel`
- 最容易复用 `localApiPlugins.ts` 里现有 DashScope 异步任务模式
## 9.2 第二轮
这一轮已经完成:
- `图生视频`
- `模板视频驱动`
- `帧序列实验`
并且已经补上:
- 方案三的内置模板库入口
- 方案四的独立“演出片段”区
## 9.3 第三轮
下一步仍然值得继续做的是:
- 把当前同步 `generate` 继续拆成显式 `jobs`
- 把视频导入后处理继续拆成独立 `import-video`
- 给方案三补更多正式模板素材与模板清单管理
- 给方案四补关键帧归档、封面和片段列表
---
## 10. 推荐的编辑器任务路由
当前已落地接口:
- `POST /api/character-visual/generate`
- `GET /api/character-visual/jobs/:id`
- `POST /api/character-visual/publish`
- `POST /api/animation/generate`
- `GET /api/animation/jobs/:id`
- `GET /api/animation/templates`
- `POST /api/animation/import-video`
- `POST /api/animation/publish`
当前职责:
### `POST /api/character-visual/generate`
负责:
-`wan2.7-image-pro`
- 生成主形象候选
- 下载并落盘
- 返回草稿图路径
### `GET /api/character-visual/jobs/:id`
负责:
- 返回最近一次主形象任务状态
- 返回模型、提示词、结果草稿等任务记录
### `POST /api/animation/generate`
负责:
- 按策略调不同模型
- `i2v`
- `animate-move`
- `animate-mix`
- `r2v`
- 返回顺序组图或视频草稿
### `GET /api/animation/jobs/:id`
负责:
- 返回最近一次动作任务状态
- 返回策略、模型、输出草稿路径和错误信息
### `GET /api/animation/templates`
负责:
- 返回方案三内置模板库清单
- 供编辑器选择 `idle_loop / run_side / attack_slash / hurt_back / die_fall`
### `POST /api/animation/import-video`
负责:
- 把浏览器侧生成或上传的视频导入本地草稿目录
- 返回可复用的本地视频路径
### `POST /api/animation/publish`
负责:
- 把草稿帧写入 `public/generated-animations`
- 生成动作 manifest
- 更新 `characterOverrides.json`
### 仍建议后续继续加强的部分
- 把当前“同步 generate + 立即返回结果”继续拆成更完整的异步 job 生命周期
-`import-video` 增加更重的服务端后处理,而不只是导入草稿
- 给模板库补正式素材管理与模板清单编辑
---
## 11. 第一批建议验证的动作
不要一上来就跑全量 12 个基础动作,先验证 4 个最关键动作:
1. `idle`
2. `run`
3. `attack`
4. `hurt`
原因:
- 这 4 个已经能覆盖循环动作、位移动作、攻击动作、受击动作
- 最容易测出“主形象一致性 + 动作连续性 + 贴地稳定性”
---
## 12. 具体推荐结论
如果只给当前编辑器实验一个最务实的建议:
1. **主形象统一先接 `wan2.7-image-pro`**
2. **动作第一条真链路先接方案二:`wan2.7-i2v`**
3. **基础动作标准化的主线尽快切到方案三:`wan2.2-animate-move / animate-mix`**
4. **方案一保留为低成本帧序实验线,方案四保留为剧情演出增强线**
换句话说:
- 方案二负责“尽快跑通”
- 方案三负责“真正稳定生产”
- 方案一负责“低成本试错”
- 方案四负责“后续演出升级”
---
## 13. 资料来源
阿里云官方文档:
- 图像生成与编辑 API 参考:
[https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference](https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference)
- 图生视频 API 参考:
[https://help.aliyun.com/zh/model-studio/image-to-video-api-reference/](https://help.aliyun.com/zh/model-studio/image-to-video-api-reference/)
- 参考生视频 API 参考:
[https://help.aliyun.com/zh/model-studio/reference-to-video-api-reference/](https://help.aliyun.com/zh/model-studio/reference-to-video-api-reference/)
- 视频生成总览:
[https://help.aliyun.com/zh/model-studio/use-video-generation](https://help.aliyun.com/zh/model-studio/use-video-generation)
- 图生动作 API 参考:
[https://help.aliyun.com/zh/model-studio/wan-video-to-video-api-reference](https://help.aliyun.com/zh/model-studio/wan-video-to-video-api-reference)
仓库内相关代码与文档:
- `src/components/preset-editor/CharacterAssetPanel.tsx`
- `src/components/preset-editor/characterAssetStudioModel.ts`
- `src/components/preset-editor/characterAssetStudioPersistence.ts`
- `src/routing/appRoutes.tsx`
- `src/services/ai.ts`
- `scripts/dev-server/localApiPlugins.ts`
- `docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md`

View File

@@ -0,0 +1,850 @@
# 基于 Qwen-Image-2.0 复刻 PixelMotion 的全流程操作手册2026-04-07
## 1. 文档目的
基于 [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md) 的拆解结果,给出一套用 `Qwen-Image-2.0` 复现 PixelMotion 核心能力的实际操作手册。
本文重点不是泛泛讲“可以做”,而是把下面这些环节写成可以直接照着执行的流程:
- 如何先把资产格式定死成 `4x4 / 16 帧 / Sprite Sheet PNG`
- 如何先锁角色,再做动作
- 如何给 `Qwen-Image-2.0` 写更像 PixelMotion 的强约束提示词
- 如何修坏帧、调帧序、调 FPS并导出 PNG / GIF / Set
---
## 2. 一句话结论
如果要用 `Qwen-Image-2.0` 复刻 PixelMotion正确思路不是“让模型自由发挥做动画”而是
1. 先把输出契约定死成 `4x4 + 16 帧 + 同方向 + 同中心点`
2. 先做一张稳定的角色标准图,把角色身份锁住
3. 再把动作写成**模板化的 16 帧节奏描述**
4.`Qwen-Image-2.0` 生成整张 Sprite Sheet 或半成品
5. 用同模型继续做修帧和一致性修补
6. 把不好的一小部分问题交给编辑器兜底,而不是要求模型一次全对
这才是最接近 PixelMotion 真实产品逻辑的复现路径。
---
## 3. 复刻目标边界
这份方案默认只用 `Qwen-Image-2.0` 这一条图像模型主线,不依赖视频模型。
也就是说,我们复刻的是 PixelMotion 最核心的能力:
- 上传或提供角色参考
- 选择动作
- 生成 `16` 帧 Sprite Sheet
- 编辑坏帧
- 导出 PNG / GIF / 角色动作集
而不是先走:
- 图生视频
- 视频抽帧
- 再转 Sprite Sheet
因为 PixelMotion 的关键也不是“视频生成”,而是“高约束的精灵表资产生产流水线”。
---
## 4. 先定死的资产契约
在写任何提示词之前,先把下面几件事固定下来,不要让用户自由选。
### 4.1 单个动作的标准输出
- 单动作固定输出一张 `Sprite Sheet PNG`
- 固定 `4``4`
- 固定 `16`
- 固定角色始终朝右
- 固定全身入镜
- 固定地面线高度基本一致
- 固定角色在格子中的尺度接近一致
### 4.2 建议分辨率
- 快速试错:`1024*1024`
- 武器细节较多或动作较复杂:`1536*1536`
对应单格尺寸大致为:
- `1024*1024` -> 每格约 `256*256`
- `1536*1536` -> 每格约 `384*384`
### 4.3 背景策略
不要一开始就要求复杂场景背景。
建议统一:
- 纯白背景
- 或纯绿背景
- 或极浅灰纯色背景
原因很简单:
- 更容易做后续去背景
- 更容易观察轮廓漂移
- 更容易判断脚底是否稳定
### 4.4 必存元数据
每个动作至少保存:
- `action`
- `rows=4`
- `cols=4`
- `frameCount=16`
- `fps`
- `activeFrames`
- `frameOrder`
- `seed`
- `masterPrompt`
- `negativePrompt`
---
## 5. Qwen-Image-2.0 能力与参数建议
根据阿里云官方文档,截至 `2026-04-07``Qwen-Image-2.0` 属于**图像生成与编辑融合模型**,适合我们这条链路,因为它既能做:
- 文生图
- 图生图 / 图像编辑
- 多图融合
也支持我们最需要的几个参数:
- `seed`
- `size`
- `negative_prompt`
- `prompt_extend`
- 多图输入编辑
### 5.1 推荐参数
| 参数 | 推荐值 | 用途 |
| --- | --- | --- |
| `model` | `qwen-image-2.0` | 主线模型 |
| `size` | `1024*1024``1536*1536` | 精灵表正方形输出 |
| `watermark` | `false` | 不要水印 |
| `prompt_extend` | 试错期 `true`,定稿期 `false` | 先帮你补描述,再固定可复现性 |
| `seed` | 每个动作固定一个整数 | 保持相对稳定 |
| `negative_prompt` | 始终填写 | 压住镜头漂移、变脸、缺手缺脚等问题 |
### 5.2 推荐的参考输入包
如果走编辑模式,推荐最多准备 3 张输入图:
1. `master.png`
角色锁定图,只负责身份一致性
2. `pose_board.png`
动作参考板,负责姿势节奏
3. `draft_sheet.png`
当前草稿图,负责网格与已有结果延续
这三张图的职责不要混。
### 5.3 成本估算
按阿里云中国内地官方价格,`qwen-image-2.0` 文生图和图像编辑都是 `0.2 元/张`
粗略估算一条动作的成本:
- 初版候选 `4`
- 修帧 `2~4`
- 最终补救 `1~2`
合计通常在 `7~10` 张,约 `1.4 ~ 2 元 / 动作`
如果先做 `idle / run / attack / hurt / die` 五个动作,首轮实验成本通常在 `7 ~ 10 元` 量级,可以接受。
---
## 6. 整体流程总览
推荐严格按下面顺序做,不要上来就直接要求模型“生成完美 16 帧攻击动画”。
### 6.1 主流程
1. 先产出角色标准图 `master.png`
2. 给动作写模板,不直接写一句“跑步”就丢模型
3. 先出整张 `4x4` 精灵表草稿
4. 挑一张最接近的版本
5. 对坏帧做局部修复
6. 进入编辑器调 `activeFrames / frameOrder / fps`
7. 导出 `sheet.png / gif / set`
### 6.2 两种执行模式
#### 模式 A最快跑通
- 角色标准图
- 直接整张 `4x4` 生成
- 编辑器隐藏坏帧
适合:
- 首次试验
- 做 MVP
- 快速验证某个动作模板是否成立
#### 模式 B更接近生产
- 角色标准图
- 动作模板卡
- 整张 `4x4` 生成多候选
- 单帧修复
- 收尾帧修复
- 编辑器保存布局
适合:
- 真正想做出可复用动作库
- 要求角色一致性更高
- 武器 / 发型 /配件不允许乱飞
后文默认都按模式 B 来写。
---
## 7. 第一步:锁定角色标准图
这一阶段的目标不是“画一张最好看的海报”,而是产出一张适合后续动作生成的**标准角色图**。
### 7.1 标准图要求
- 单人
- 全身
- 朝右
- 脚底完整可见
- 武器完整
- 背景纯净
- 轮廓清楚
- 不能是正面立绘
- 不能是夸张透视
### 7.2 文生标准图提示词模板
```text
单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。
角色设定:
[角色性别/年龄感]
[发型]
[服装]
[武器]
[配色]
[身份气质]
画面要求:
纯色浅背景,画面中心构图,角色占画面高度 75% 左右,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。
风格要求:
高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。
```
### 7.3 图生标准图提示词模板
如果你已经有角色参考图,推荐改成下面这种写法:
```text
使用图1作为唯一角色身份参考保留该角色的发型、脸型、服装主结构、武器类型和整体配色将其规范成适合 2D 横版游戏 sprite 动画制作的标准角色图。
要求:
单人,全身,始终朝右,站立待机,脚底完整可见,武器完整,轮廓清晰,背景改为纯浅色,移除杂乱环境和多余装饰,角色保持在画面中央,方便后续生成动作精灵表。
不要把角色改成正面,不要把角色改成三分之二视角,不要改发型,不要改武器手,不要裁切脚底。
```
### 7.4 角色标准图负向提示词
```text
正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化过度写实模糊运动模糊强光斑文字水印UI 元素
```
### 7.5 通过标准
满足下面 5 条再进入下一步:
1. 一眼就能看出角色身份
2. 朝向明确是右侧
3. 全身完整
4. 武器没有错手或消失
5. 这张图可以作为所有动作的唯一主参考
---
## 8. 第二步:把动作需求翻译成模板卡
不要把“跑步”“攻击”“比心”这种裸词直接丢给模型。
应该先把动作翻成结构化模板。
### 8.1 动作模板卡字段
每个动作先写一张卡,字段固定如下:
- `actionName`
- `loop`
- `facing`
- `bodyTravel`
- `weaponRule`
- `frames1to4`
- `frames5to8`
- `frames9to12`
- `frames13to16`
- `endState`
### 8.2 动作模板卡通用写法
```text
动作名:[actionName]
是否循环:[是/否]
角色朝向:始终朝右
身体位移:[原地 / 小幅前移 / 中幅前移]
武器规则:[武器始终在右手 / 双手握持 / 不换手]
1-4 帧:[起势描述]
5-8 帧:[主动作前半]
9-12 帧:[主动作后半]
13-16 帧:[收势或回正]
结尾要求:[与首帧接近 / 停在收招姿态 / 停在受击后姿态]
```
### 8.3 常用动作模板示例
#### `idle`
```text
动作名idle
是否循环:是
角色朝向:始终朝右
身体位移:原地
武器规则:武器始终在右手,位置稳定
1-4 帧:稳定站姿,轻微呼吸起伏
5-8 帧:胸腔与肩膀轻微抬起,衣摆轻微变化
9-12 帧:呼气回落,重心恢复
13-16 帧:逐渐回到与首帧接近的站姿
结尾要求:第 16 帧自然衔接第 1 帧
```
#### `run`
```text
动作名run
是否循环:是
角色朝向:始终朝右
身体位移:小幅前移但角色中心基本固定
武器规则:武器始终在右手,不换手
1-4 帧:右腿前摆,左腿后蹬,身体前倾
5-8 帧:双腿交替经过身体下方,手臂反向摆动
9-12 帧:左腿前摆,右腿后蹬,身体继续前倾
13-16 帧:回到与 1-4 帧对应的另一半跑步循环
结尾要求:第 16 帧能与第 1 帧无缝循环
```
#### `attack_slash`
```text
动作名attack_slash
是否循环:否
角色朝向:始终朝右
身体位移:中幅前探
武器规则:右手持剑,始终右手,不换手
1-4 帧:轻微收身蓄力,剑向后收
5-8 帧:重心前压,挥剑开始
9-12 帧:斩击达到最大幅度,动作最有力量
13-16 帧:顺势收刀,回到可接下一动作的姿态
结尾要求:第 16 帧停在收招后稳定姿态
```
#### `hurt`
```text
动作名hurt
是否循环:否
角色朝向:始终朝右
身体位移:原地或极小后仰
武器规则:武器不要脱手,不要换手
1-4 帧:突然受击,头肩后仰
5-8 帧:身体失衡最明显
9-12 帧:手臂和武器随受击惯性摆动
13-16 帧:逐渐恢复到勉强站稳的姿态
结尾要求:第 16 帧能接回 idle 或下一个动作
```
---
## 9. 第三步:生成整张 4x4 Sprite Sheet
这是最关键的一步。
建议优先走:
- `图像编辑模式`
- 输入 `master.png`
- 如有动作参考,再输入 `pose_board.png`
- 输出一整张 `4x4`
### 9.1 通用主提示词骨架
```text
使用图1作为唯一角色身份参考[如果有图2则使用图2作为动作节奏参考]。
生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。
动作要求:
[粘贴动作模板卡的 1-16 帧描述]
输出要求:
每一格都要清晰分开,网格顺序从左到右、从上到下,动作连续,首尾关系明确,轮廓稳定,发型稳定,服装结构稳定,武器始终在正确的手中,背景为纯浅色,适合后续切成 sprite frames。
风格要求:
2D side-view game sprite sheetreadable silhouetteclean outlineconsistent character designanimation-readygame asset oriented。
```
### 9.2 全局负向提示词
```text
多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色
```
### 9.3 `idle` 整表提示词示例
```text
使用图1作为唯一角色身份参考。
生成一张 4x4 的 sprite sheet共 16 帧,内容是同一个角色的 idle 循环动画。角色始终朝右,全身完整,脚底始终可见,地面线稳定,角色尺度稳定,镜头固定。
动作节奏:
1-4 帧稳定站姿并轻微吸气,
5-8 帧胸腔和肩膀轻微抬起,衣摆和头发只有极轻微变化,
9-12 帧呼气回落,
13-16 帧逐渐回到与第 1 帧接近的站姿。
要求循环自然16 帧能无缝接回 1 帧。每格清晰分开,背景纯浅色,轮廓清楚,服装和武器绝对保持一致。
2D side-view game sprite sheetclean silhouetteconsistent characteranimation-ready。
```
### 9.4 `run` 整表提示词示例
```text
使用图1作为唯一角色身份参考使用图2作为跑步动作节奏参考。
生成一张 4x4 的 sprite sheet共 16 帧,内容是同一个角色的 run 循环动画。角色始终朝右,全身完整,地面线稳定,角色中心基本固定,镜头固定。
动作节奏:
1-4 帧右腿前摆左腿后蹬,身体略前倾,
5-8 帧双腿交叉经过身体下方,手臂反向摆动,
9-12 帧左腿前摆右腿后蹬,
13-16 帧完成另一半跑步循环并回到可接第 1 帧的状态。
要求首尾闭环,脚步节奏清楚,手臂摆动自然,武器始终在右手,不换手,不消失。
2D side-view game sprite sheetside scrolling runner animationclean outlineconsistent anatomygame asset oriented。
```
### 9.5 `attack_slash` 整表提示词示例
```text
使用图1作为唯一角色身份参考使用图2作为攻击动作节奏参考。
生成一张 4x4 的 sprite sheet共 16 帧,内容是同一个角色的侧身挥砍攻击动作。角色始终朝右,全身完整,镜头固定,地面线稳定。
动作节奏:
1-4 帧轻微收身蓄力,剑向后收,
5-8 帧身体前压,挥剑开始,
9-12 帧斩击达到最大幅度,动作力量最强,
13-16 帧顺势收刀,回到可接下一动作的稳定姿态。
要求动作有明显起势、主挥砍、收势三个阶段;剑始终在右手,不能换手,不能消失;角色脸部、服装、武器、发型在 16 帧中保持同一人。
2D side-view attack sprite sheetclear slash motionconsistent character designclean silhouetteanimation-ready。
```
### 9.6 建议的生成策略
不要只生成 1 张。
建议每个动作先做 `4` 个候选:
- 提示词相同
- `seed` 不同
例如:
- `idle`: `1101 / 1102 / 1103 / 1104`
- `run`: `2101 / 2102 / 2103 / 2104`
- `attack`: `3101 / 3102 / 3103 / 3104`
这样你能更快选出“最少需要修”的那一张,而不是死磕单张。
---
## 10. 第四步:筛选结果时看什么
不要凭“感觉还行”选图,按下面的检查顺序来。
### 10.1 第一优先级
- 是不是同一个角色
- 朝向是不是始终一致
- 武器有没有消失或换手
- 脚底有没有持续落在同一条地面线附近
### 10.2 第二优先级
- 动作节奏是不是清楚
- 首尾是否可循环
- 斩击或受击是否有明确峰值
### 10.3 第三优先级
- 衣摆 / 头发是否轻微稳定
- 背景是否干净
- 单格边界是否清楚
### 10.4 取舍原则
- 如果坏帧在 `1~3` 帧,优先修
- 如果坏帧在 `4~6` 帧,看是否能靠隐藏和调序救
- 如果超过 `6` 帧都不稳,直接换候选,不要浪费修图次数
---
## 11. 第五步:坏帧修复
PixelMotion 很关键的一点,不是要求 16 帧都完美,而是允许修。
这里也一样。
### 11.1 修帧原则
优先修:
- 武器消失
- 手脚畸形
- 脸突然变了
- 某一帧朝向错了
- 某一帧幅度过大导致不连贯
不要优先修:
- 背景一点点噪点
- 很轻微的衣摆变化
- 肉眼几乎不影响播放的小细节
### 11.2 单帧修复操作
推荐做法:
1.`sheet.png` 裁出坏帧单格
2. 准备上一帧或下一帧的好帧
3. 再带上 `master.png`
4.`Qwen-Image-2.0` 只重绘这一格
推荐输入图职责:
- 图1`master.png`
- 图2前一帧或后一帧的好帧
- 图3当前坏帧
### 11.3 单帧修复提示词模板
```text
使用图1作为角色身份与服装武器的唯一标准参考图2的动作连续性修复图3这一个单帧。
要求输出一张单独的动作帧图片不要网格不要背景细节。角色始终朝右全身完整脚底位置稳定保持与图2连续并且与图1是同一个角色。修复图3中的错误使这一帧适合插回原来的 sprite sheet 中。
重点修复:
[这里明确写问题,例如:右手缺失 / 武器消失 / 身体朝向错误 / 头部变形]
保持不变:
发型、服装结构、主配色、武器类型、朝向。
```
### 11.4 循环收尾修复提示词模板
如果 `idle``run` 的第 16 帧接不回第 1 帧,单独修第 16 帧。
```text
使用图1作为角色身份标准参考图2作为第 1 帧起始姿态修复图3作为第 16 帧结束姿态。
目标是让第 16 帧自然衔接第 1 帧,形成循环动画。角色始终朝右,全身完整,脚底稳定,服装武器不变,动作幅度自然,不要跳变。
```
### 11.5 武器一致性修复提示词模板
```text
使用图1作为角色与武器标准修复图2这一帧。
要求武器始终在右手武器长度、形状和握持方式与图1一致不要消失不要换手不要新增第二把武器。只修复武器和持握关系其余动作姿态尽量保持原样。
```
---
## 12. 第六步:编辑器兜底规则
这一层就是 PixelMotion 真正有价值的地方,必须做。
### 12.1 编辑器必须有的能力
-`4x4` 自动切帧
- 逐帧预览
- 播放预览
- `activeFrames` 开关
- `frameOrder` 拖拽排序
- `fps` 调整
- 保存动作元数据
- 导出 PNG
- 导出 GIF
### 12.2 最小可用的操作规则
#### `activeFrames`
适合处理:
- 某一帧彻底坏掉
- 某一帧重复度太高
- 某一帧突然变脸
#### `frameOrder`
适合处理:
- 动作节奏顺序错位
- 峰值帧放早了或放晚了
- 跑步循环左右腿对应顺序不顺
#### `fps`
建议默认值:
- `idle`: `8`
- `run`: `10 ~ 12`
- `attack`: `10 ~ 12`
- `hurt`: `8 ~ 10`
- `die`: `8`
### 12.3 什么时候该隐藏帧,什么时候该重修
优先隐藏:
- 重复帧
- 接近但无意义的过渡帧
优先重修:
- 武器错了
- 手脚断了
- 脸变了
- 朝向反了
---
## 13. 第七步:导出与资产落盘
推荐导出三种产物:
### 13.1 主资产
- `sheet.png`
### 13.2 预览资产
- `preview.gif`
### 13.3 元数据
```json
{
"action": "run",
"rows": 4,
"cols": 4,
"frameCount": 16,
"fps": 12,
"activeFrames": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"frameOrder": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"seed": 2102
}
```
### 13.4 推荐目录结构
```text
pixelmotion-qwen/
refs/
master.png
pose_board_run.png
drafts/
run_candidate_2101.png
run_candidate_2102.png
repairs/
run_frame_07_fix.png
exports/
run/
sheet.png
preview.gif
metadata.json
```
---
## 14. 一套可以直接抄走的负向提示词
下面这组建议作为默认全局负向提示词起步,后面按动作微调。
```text
多角色左右镜像混乱前视图背视图特写镜头切换景别变化头顶裁切脚底裁切脚离地漂浮缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多模糊运动模糊背景复杂文字水印UI 元素,边框覆盖角色
```
---
## 15. 提示词拼装公式
如果你不想每次都重写整段,可以按这个公式拼。
### 15.1 角色锁定模块
```text
使用图1作为唯一角色身份参考保持发型、脸型、服装主结构、主配色、武器类型一致。
```
### 15.2 构图锁定模块
```text
角色始终朝右,全身完整,脚底始终可见,地面线稳定,镜头固定,角色尺度稳定。
```
### 15.3 网格输出模块
```text
生成一张 4x4 的 sprite sheet共 16 帧,从左到右、从上到下排列,每格清晰分开。
```
### 15.4 动作模板模块
```text
1-4 帧起势5-8 帧主动作前半9-12 帧主动作后半13-16 帧收势或回正。
```
### 15.5 风格模块
```text
2D side-view game sprite sheetclean silhouetteanimation-readygame asset oriented。
```
把这 5 段拼起来,再插入具体动作内容,基本就是一条能用的 PixelMotion 风格提示词。
---
## 16. 常见失败与修正方式
### 16.1 角色每帧都像不同人
修正:
- 强化“图1为唯一角色身份参考”
- 先不要复杂背景
- 先不要复杂光影
- 用编辑模式,不要只用纯文生图
### 16.2 跑步像站桩抖动
修正:
- 动作模板里明确写腿部交换节奏
- 写清“身体略前倾,手臂反向摆动”
- 写清“第 16 帧能接回第 1 帧”
### 16.3 攻击看起来没力量
修正:
- 明确写“1-4 蓄力5-8 发动9-12 峰值13-16 收势”
- 明确写“第 9-12 帧是最大动作幅度”
### 16.4 武器容易消失
修正:
- 在主体描述里写一次武器
- 在动作规则里再写一次“始终在右手”
- 在负向提示词里再写“武器消失,武器换手”
### 16.5 网格里角色大小忽大忽小
修正:
- 强化“角色在每一格中的尺度基本一致”
- 背景改成纯色
- 减少镜头语言
---
## 17. 推荐的第一批动作
如果你第一次搭这条链,不要一上来就做十几个动作。
先做下面 4 个:
1. `idle`
2. `run`
3. `attack_slash`
4. `hurt`
原因:
- 足够覆盖循环动作、位移动作、峰值动作、受击动作
- 最容易暴露角色一致性问题
- 最容易验证提示词模板是否成立
---
## 18. 最后给一个最务实的落地建议
如果你要做第一版,建议这样推进:
### 第 1 轮
- 只支持上传 `master.png`
- 只支持 `idle / run / attack / hurt`
- 只支持 `4x4 / 16 帧`
- 只支持隐藏帧、调序、调 FPS
### 第 2 轮
- 加坏帧局部修复
- 加循环首尾修复
- 加透明背景导出
### 第 3 轮
- 加动作库
- 加资产库
- 加社区 Showcase
这就已经非常像 PixelMotion 的核心骨架了。
---
## 19. 参考资料
- 仓库内拆解文档:
[PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)
- 阿里云百炼 Qwen-Image 文生图说明:
[https://help.aliyun.com/zh/model-studio/text-to-image](https://help.aliyun.com/zh/model-studio/text-to-image)
- 阿里云百炼文生图 Prompt 指南:
[https://help.aliyun.com/zh/model-studio/text-to-image-prompt](https://help.aliyun.com/zh/model-studio/text-to-image-prompt)
- 阿里云百炼 Qwen-Image API
[https://help.aliyun.com/zh/model-studio/qwen-image-api](https://help.aliyun.com/zh/model-studio/qwen-image-api)
- 阿里云百炼 Qwen-Image 编辑说明:
[https://help.aliyun.com/zh/model-studio/qwen-image-edit-guide](https://help.aliyun.com/zh/model-studio/qwen-image-edit-guide)
- 阿里云百炼 Qwen-Image 编辑 API
[https://help.aliyun.com/zh/model-studio/qwen-image-edit-api](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
- 阿里云百炼模型价格:
[https://help.aliyun.com/zh/model-studio/model-pricing](https://help.aliyun.com/zh/model-studio/model-pricing)

View File

@@ -5,6 +5,7 @@
## 文档列表
- [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。
- [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。
- [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):服务端部署、代理层与 CORS 方案。

View File

@@ -25,7 +25,8 @@
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
"check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts",
"check:content": "npm run check:data && npm run check:overrides && npm run check:smoke"
"check:content": "npm run check:data && npm run check:overrides && npm run check:smoke",
"report:story-audit": "node scripts/run-tsx.cjs scripts/export-story-audit-report.ts"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",

View File

@@ -0,0 +1,13 @@
{
"taskId": "a1ed07b9-7a51-4906-8294-83feeafca0ca",
"kind": "animation",
"status": "failed",
"characterId": "sword-princess",
"animation": "acquire",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 acquire。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 拾取",
"createdAt": "2026-04-07T11:15:39.460Z",
"updatedAt": "2026-04-07T11:15:39.460Z",
"errorMessage": "{\"request_id\":\"e0768255-d638-9e27-83f4-9fcba7236249\",\"output\":{\"task_id\":\"a1ed07b9-7a51-4906-8294-83feeafca0ca\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-07 19:14:07.238\",\"scheduled_time\":\"2026-04-07 19:14:07.789\",\"end_time\":\"2026-04-07 19:15:29.681\",\"code\":\"InvalidParameter\",\"message\":\"Input should be 'first_frame', 'last_frame', 'driving_audio' or 'first_clip': input.media.0.type\"}}"
}

View File

@@ -0,0 +1,13 @@
{
"taskId": "ae06b1fd-bb02-4a0c-8127-93d7c8a6eefb",
"kind": "animation",
"status": "failed",
"characterId": "sword-princess",
"animation": "acquire",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 acquire。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 拾取",
"createdAt": "2026-04-07T10:44:09.106Z",
"updatedAt": "2026-04-07T10:44:09.106Z",
"errorMessage": "{\"request_id\":\"2fa1b84d-c673-9875-8738-85072b1a6652\",\"output\":{\"task_id\":\"ae06b1fd-bb02-4a0c-8127-93d7c8a6eefb\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-07 18:42:38.632\",\"scheduled_time\":\"2026-04-07 18:42:41.343\",\"end_time\":\"2026-04-07 18:44:02.043\",\"code\":\"InvalidParameter\",\"message\":\"Input should be 'first_frame', 'last_frame', 'driving_audio' or 'first_clip': input.media.0.type\"}}"
}

View File

@@ -0,0 +1,36 @@
{
"taskId": "6aa631bd-4f53-48f8-8978-ec06f0a2e2ab",
"kind": "visual",
"status": "completed",
"characterId": "sword-princess",
"model": "wan2.7-image-pro",
"prompt": "单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。 画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。 不要多人,不要复杂场景,不要夸张透视,不要截断脚底。 像素风青衣剑客",
"createdAt": "2026-04-07T10:39:51.443Z",
"updatedAt": "2026-04-07T10:40:04.686Z",
"result": {
"drafts": [
{
"id": "candidate-1",
"label": "候选 1",
"imageSrc": "/generated-character-drafts/sword-princess/visual/visual-draft-1775558399370/candidate-01.png",
"width": 1024,
"height": 1536
},
{
"id": "candidate-2",
"label": "候选 2",
"imageSrc": "/generated-character-drafts/sword-princess/visual/visual-draft-1775558399370/candidate-02.png",
"width": 1024,
"height": 1536
},
{
"id": "candidate-3",
"label": "候选 3",
"imageSrc": "/generated-character-drafts/sword-princess/visual/visual-draft-1775558399370/candidate-03.png",
"width": 1024,
"height": 1536
}
],
"draftRelativeDir": "generated-character-drafts/sword-princess/visual/visual-draft-1775558399370"
}
}

View File

@@ -0,0 +1,36 @@
{
"taskId": "9392eaac-7858-4d91-9443-c71433a1f4e7",
"kind": "visual",
"status": "completed",
"characterId": "sword-princess",
"model": "wan2.7-image-pro",
"prompt": "单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。 画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。 不要多人,不要复杂场景,不要夸张透视,不要截断脚底。 青衣剑客",
"createdAt": "2026-04-07T10:37:44.388Z",
"updatedAt": "2026-04-07T10:38:16.211Z",
"result": {
"drafts": [
{
"id": "candidate-1",
"label": "候选 1",
"imageSrc": "/generated-character-drafts/sword-princess/visual/visual-draft-1775558290425/candidate-01.png",
"width": 1024,
"height": 1536
},
{
"id": "candidate-2",
"label": "候选 2",
"imageSrc": "/generated-character-drafts/sword-princess/visual/visual-draft-1775558290425/candidate-02.png",
"width": 1024,
"height": 1536
},
{
"id": "candidate-3",
"label": "候选 3",
"imageSrc": "/generated-character-drafts/sword-princess/visual/visual-draft-1775558290425/candidate-03.png",
"width": 1024,
"height": 1536
}
],
"draftRelativeDir": "generated-character-drafts/sword-princess/visual/visual-draft-1775558290425"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -0,0 +1,12 @@
{
"taskId": "9392eaac-7858-4d91-9443-c71433a1f4e7",
"model": "wan2.7-image-pro",
"prompt": "单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。 画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。 不要多人,不要复杂场景,不要夸张透视,不要截断脚底。 青衣剑客",
"sourceMode": "text-to-image",
"createdAt": "2026-04-07T10:38:16.209Z",
"imageUrls": [
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/48/20260407/312f092c/9bd7927b-a6f9-4c4f-afe5-bc93ef02567c_0.png?Expires=1775644688&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=WhvgXEB3JBnrZT8djKUacIVJx34%3D",
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/a8/20260407/312f092c/bd5467b4-5e79-4a1c-9a82-f74f6f23ba0d_0.png?Expires=1775644688&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=0cIt3eVUQoTz3R77wnJa0sB0KRA%3D",
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/df/20260407/312f092c/ff46fb19-c8dd-4ed5-8206-d051c37e19eb_0.png?Expires=1775644688&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=bnRbxFF0LSL3OfxiWWKAgyINbiE%3D"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,12 @@
{
"taskId": "6aa631bd-4f53-48f8-8978-ec06f0a2e2ab",
"model": "wan2.7-image-pro",
"prompt": "单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。 画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。 不要多人,不要复杂场景,不要夸张透视,不要截断脚底。 像素风青衣剑客",
"sourceMode": "image-to-image",
"createdAt": "2026-04-07T10:40:04.683Z",
"imageUrls": [
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/e3/20260407/24cd1d9a/450ca569-4c5e-4230-853e-a8e8e6f0694c_0.png?Expires=1775644797&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=dyKuaddGdjBubMLK1TWGFOm8SLw%3D",
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/5e/20260407/24cd1d9a/993aa4eb-836f-40b3-96f8-22090ae24bd9_0.png?Expires=1775644797&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=2Y0KJh7J9mLfqGr%2Fyt9l6RBN7FA%3D",
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/92/20260407/24cd1d9a/60d82e2d-b7b1-4e67-9863-b6151ee55dfe_0.png?Expires=1775644797&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=bwSIEbIyX4tkGdqP9gWKBnLjMnc%3D"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,16 @@
{
"id": "visual-1775558475200",
"characterId": "sword-princess",
"sourceMode": "image-to-image",
"promptText": "像素风青衣剑客",
"masterImagePath": "/generated-characters/sword-princess/visual/visual-1775558475200/master.png",
"previewImagePaths": [
"/generated-characters/sword-princess/visual/visual-1775558475200/preview-1.png",
"/generated-characters/sword-princess/visual/visual-1775558475200/preview-2.png",
"/generated-characters/sword-princess/visual/visual-1775558475200/preview-3.png"
],
"width": 1024,
"height": 1536,
"facing": "right",
"locked": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -0,0 +1,20 @@
{
"draftId": "qwen-master-1775578214095",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1536",
"promptText": "??,??,2D ???????????,????,????,????????????,????,??????",
"negativePrompt": "???,????,???,???,????,????",
"promptExtend": true,
"candidateCount": 1,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775578214095-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775578214095/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/a9/20260408/76483b06/b98cb947-186c-40fc-ae4b-7435b158929d.png?Expires=1776184013&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=BrmC7DHbTI8%2BJHuTQwEXDsNNyxk%3D"
}
],
"createdAt": "2026-04-07T16:10:16.566Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,20 @@
{
"draftId": "qwen-master-1775578727307",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1536",
"promptText": "single full-body side-view game character facing right",
"negativePrompt": "multiple characters, complex background",
"promptExtend": false,
"candidateCount": 1,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775578727307-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775578727307/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/60/20260408/76483b06/e67153c1-2f1a-452d-ae6f-49d7b3a86e62.png?Expires=1776184526&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=ZXlNC4qRjDkJoUqZp2Cb%2FLUdhRw%3D"
}
],
"createdAt": "2026-04-07T16:18:49.135Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775616387478",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1536",
"promptText": "单人像素风全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求纯色浅背景画面中心构图角色占画面高度 75% 左右,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求高可读性游戏角色设定图偏像素动画前置设计稿形体清晰服装层次明确武器握持合理便于后续连续动作生成。\n\n黑发青年剑士窄袖长衣右手持长剑青灰主色江湖侠客气质。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775616387478-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775616387478/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/4d/20260408/76483b06/4c64f515-1c36-4fe4-9f4c-6ec0fa146ea9.png?Expires=1776222186&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=pTzhxv%2BVxMgcOOytrZnM2HCPbl4%3D"
},
{
"id": "qwen-master-1775616387478-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775616387478/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/13/20260408/76483b06/241b3915-5bfd-40c2-9bfa-cce8428504aa.png?Expires=1776222186&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=LrSI87B94tENU9k1hgm5Sbxmh38%3D"
}
],
"createdAt": "2026-04-08T02:46:32.196Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775617211005",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1536",
"promptText": "单人像素风全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n海底世界龙族三太子\n\n画面要求纯色浅背景画面中心构图角色占画面高度 75% 左右,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求高可读性游戏角色设定图偏像素动画前置设计稿形体清晰服装层次明确武器握持合理便于后续连续动作生成。\n\n黑发青年剑士窄袖长衣右手持长剑青灰主色江湖侠客气质。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775617211005-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775617211005/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d1/20260408/8b3aee91/d5a03825-e2a5-4b3b-ade7-8bdaafb9c8e1.png?Expires=1776223010&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=WRGHV16ClkjcDXexCuSoeTIYCvo%3D"
},
{
"id": "qwen-master-1775617211005-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775617211005/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/cf/20260408/8b3aee91/74aeb6e0-9b8a-4de2-b097-a78cb68993f3.png?Expires=1776223010&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SNcjXHgYgqt7F7BS%2FwgS7TY1g9I%3D"
}
],
"createdAt": "2026-04-08T03:00:17.210Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775617263664",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1536",
"promptText": "单人像素风全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n\n\n画面要求纯色浅背景画面中心构图角色占画面高度 75% 左右,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求高可读性游戏角色设定图偏像素动画前置设计稿形体清晰服装层次明确武器握持合理便于后续连续动作生成。\n\n海底世界龙族三太子",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775617263664-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775617263664/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/76/20260408/8b3aee91/f1f26005-524c-4b73-a0d9-7afd9c7225c5.png?Expires=1776223062&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=jF6UsD291eqkQy398eYRkwLL2bo%3D"
},
{
"id": "qwen-master-1775617263664-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775617263664/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/54/20260408/8b3aee91/a1b6f235-c5d1-4bff-9f9e-2d40917f2c65.png?Expires=1776223062&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=8yjApWya5inBOZh0XmZLJ8%2BoBss%3D"
}
],
"createdAt": "2026-04-08T03:01:09.641Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775617407271",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1536",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求纯色浅背景画面中心构图角色占画面高度 75% 左右,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求高可读性游戏角色设定图偏像素动画前置设计稿形体清晰服装层次明确武器握持合理便于后续连续动作生成。\n\n黑发青年剑士窄袖长衣右手持长剑青灰主色江湖侠客气质。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775617407271-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775617407271/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/c8/20260408/8b3aee91/f05b03e1-1304-4158-86a0-2713cade6f5e.png?Expires=1776223206&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=QDFZY1LCMQIXRXBkGsIuAmjifvU%3D"
},
{
"id": "qwen-master-1775617407271-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775617407271/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/8a/20260408/8b3aee91/1fb01a5d-b57d-4004-b673-f126b4a142b1.png?Expires=1776223206&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=QHznk6EOFfohqaxx4M8dUhoeaLM%3D"
}
],
"createdAt": "2026-04-08T03:03:32.215Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,20 @@
{
"draftId": "qwen-master-1775618002500",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "Q版大头身少女冒险者头部占比更大约 2 到 3 头身,站立待机,侧身朝右,单人,全身,纯色浅背景。",
"negativePrompt": "多角色,复杂背景,左朝向,半身像,脚被裁切,武器消失",
"promptExtend": false,
"candidateCount": 1,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775618002500-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775618002500/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/3f/20260408/76483b06/16bf0b9f-78f0-4dbd-b4fc-55182266a6b1.png?Expires=1776223802&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=67ILuxecyge6DatLte8bx4ME%2FbU%3D"
}
],
"createdAt": "2026-04-08T03:13:23.848Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775618436554",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775618436554-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775618436554/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/09/20260408/76483b06/f430b670-9f84-4c26-a130-5650b0fc5fb3.png?Expires=1776224235&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=CnOmch7EbHL1LPyhdbT4tXKEjZk%3D"
},
{
"id": "qwen-master-1775618436554-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775618436554/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/5b/20260408/76483b06/ee19cd18-e868-45e7-bf0f-c606ed9fcde0.png?Expires=1776224236&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=QKz9VoHxrVsDYyLgrmUHjoss6vw%3D"
}
],
"createdAt": "2026-04-08T03:20:39.072Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775618727735",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775618727735-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775618727735/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/16/20260408/8b3aee91/90bf4b81-aa97-4022-a8a2-02c10b37a0fc.png?Expires=1776224527&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=0T0pQMC6EXnL0R1ETdQ0mO5sMcE%3D"
},
{
"id": "qwen-master-1775618727735-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775618727735/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/21/20260408/8b3aee91/8105eb64-dc6e-43ad-b7d2-57527ba05330.png?Expires=1776224527&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=%2FiB%2Bel9y2ECZcLtwS9uINfb4g9g%3D"
}
],
"createdAt": "2026-04-08T03:25:30.330Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View File

@@ -0,0 +1,20 @@
{
"draftId": "qwen-repair-1775578528217",
"kind": "repair",
"model": "qwen-image-2.0",
"size": "512*512",
"promptText": "Use image 1 as the identity reference, image 2 as continuity reference, and repair image 3 into a single clean side-view game animation frame. Keep the same character, same facing-right direction, same clothing, same weapon, full body visible, stable feet placement.",
"negativePrompt": "multiple characters, front view, back view, extra limbs, missing weapon, changing face, changing clothes, complex background, text, watermark",
"promptExtend": false,
"candidateCount": 1,
"referenceImageCount": 3,
"drafts": [
{
"id": "qwen-repair-1775578528217-1",
"label": "修帧 1",
"imageSrc": "/generated-qwen-sprites/_drafts/repair/qwen-repair-1775578528217/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/ed/20260408/8b3aee91/2de2d511-a810-420c-9c3e-013eb54ed619.png?Expires=1776184328&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=iq2IA3X8hL26zptWeq%2Frflk9iWs%3D"
}
],
"createdAt": "2026-04-07T16:15:28.611Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,20 @@
{
"draftId": "qwen-sheet-1775578477516",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "Use image 1 as the only character identity reference. Generate a 4x4 sprite sheet with 16 frames of the same side-view game character idle animation. The character always faces right, full body visible in every cell, fixed camera, same scale, clean pale background, clear grid separation, stable hair, stable clothing, stable weapon, animation-ready game asset.",
"negativePrompt": "multiple characters, front view, back view, camera change, cropped feet, extra limbs, missing weapon, changing clothes, changing face, complex background, text, watermark",
"promptExtend": false,
"candidateCount": 1,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775578477516-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775578477516/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/4b/20260408/8b3aee91/4a26223b-b7bb-4e92-803e-36e44f9463b8.png?Expires=1776184277&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=BV3ssZnzHLKHixn%2FNalb8pcY1Yw%3D"
}
],
"createdAt": "2026-04-07T16:14:40.484Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775617456587",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。\n动作名待机循环\n是否循环是\n身体位移原地\n武器规则武器始终在主手位置稳定\n1-4 帧:稳定站姿,轻微呼吸起伏\n5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化\n9-12 帧:呼气回落,重心恢复\n13-16 帧:逐渐回到与首帧接近的站姿\n结尾要求第 16 帧自然衔接第 1 帧\n输出要求每一格都要清晰分开网格顺序从左到右、从上到下动作连续首尾关系明确轮廓稳定发型稳定服装结构稳定武器始终在正确的手中背景为纯浅色适合后续切成 sprite frames。\n黑发青年剑士窄袖长衣右手持长剑青灰主色江湖侠客气质。\n每格边界清晰背景纯浅色适合后续切帧。",
"negativePrompt": "多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色",
"promptExtend": false,
"seed": 2101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775617456587-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775617456587/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/33/20260408/8b3aee91/aa324517-144e-4000-9837-8ccb9d717553.png?Expires=1776223256&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=w8LfikjUX8icBuZRjjMSMe1AJNg%3D"
},
{
"id": "qwen-sheet-1775617456587-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775617456587/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/f1/20260408/8b3aee91/78b62262-68b4-450c-bacc-cb82bf7355d0.png?Expires=1776223256&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=Ml7RL1EbogtQHE4DcZiyUrfiUP0%3D"
}
],
"createdAt": "2026-04-08T03:04:19.755Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775617546458",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1536*1536",
"promptText": "使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。\n动作名待机循环\n是否循环是\n身体位移原地\n武器规则武器始终在主手位置稳定\n1-4 帧:稳定站姿,轻微呼吸起伏\n5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化\n9-12 帧:呼气回落,重心恢复\n13-16 帧:逐渐回到与首帧接近的站姿\n结尾要求第 16 帧自然衔接第 1 帧\n输出要求每一格都要清晰分开网格顺序从左到右、从上到下动作连续首尾关系明确轮廓稳定发型稳定服装结构稳定武器始终在正确的手中背景为纯浅色适合后续切成 sprite frames。\n黑发青年剑士窄袖长衣右手持长剑青灰主色江湖侠客气质。\n每格边界清晰背景纯浅色适合后续切帧。",
"negativePrompt": "多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色",
"promptExtend": false,
"seed": 2101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775617546458-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775617546458/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/4f/20260408/8b3aee91/764e3c7d-1433-4bb5-a8fe-039ce0ee1b67.png?Expires=1776223345&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=hZbfO9tG%2FN3mB8b%2Bv7hDSc5xdqg%3D"
},
{
"id": "qwen-sheet-1775617546458-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775617546458/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/63/20260408/8b3aee91/1e817e6b-bc43-458f-a9d7-d9c640edf754.png?Expires=1776223345&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=iOPIkS9H%2FSVBFVXYLeC5PNGFO5I%3D"
}
],
"createdAt": "2026-04-08T03:05:54.741Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775618537288",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧展示同一个角色的连续动作。角色始终朝右全身完整出现在每一个格子里脚底始终可见地面线高度基本一致角色在每一格中的尺度基本一致镜头固定不变不要切换景别不要切换视角不要左右翻转。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。\n动作名待机循环\n是否循环是\n身体位移原地\n武器规则武器始终在主手位置稳定\n1-4 帧:稳定站姿,轻微呼吸起伏\n5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化\n9-12 帧:呼气回落,重心恢复\n13-16 帧:逐渐回到与首帧接近的站姿\n结尾要求第 16 帧自然衔接第 1 帧\n输出要求每一格都要清晰分开网格顺序从左到右、从上到下动作连续首尾关系明确轮廓稳定发型稳定服装结构稳定武器始终在正确的手中背景为纯浅色适合后续切成 sprite frames。\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。\n每格边界清晰背景纯浅色适合后续切帧。",
"negativePrompt": "多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色",
"promptExtend": false,
"seed": 2101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775618537288-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775618537288/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/9d/20260408/8b3aee91/3d3adb58-da95-419f-a6f7-e294061d0a84.png?Expires=1776224336&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=43XK%2BT20B%2FA3SU9msSYe%2B5zxggE%3D"
},
{
"id": "qwen-sheet-1775618537288-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775618537288/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/dc/20260408/8b3aee91/3dddc09b-e6a4-4f90-861c-54d40607f183.png?Expires=1776224336&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=kKgtR0Vz3aBb5d3Y9PhSfDH%2FUsU%3D"
}
],
"createdAt": "2026-04-08T03:22:20.311Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775618740654",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧展示同一个角色的连续动作。角色始终朝右全身完整出现在每一个格子里脚底始终可见地面线高度基本一致角色在每一格中的尺度基本一致镜头固定不变不要切换景别不要切换视角不要左右翻转。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。\n动作名待机循环\n是否循环是\n身体位移原地\n武器规则武器始终在主手位置稳定\n1-4 帧:稳定站姿,轻微呼吸起伏\n5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化\n9-12 帧:呼气回落,重心恢复\n13-16 帧:逐渐回到与首帧接近的站姿\n结尾要求第 16 帧自然衔接第 1 帧\n输出要求每一格都要清晰分开网格顺序从左到右、从上到下动作连续首尾关系明确轮廓稳定发型稳定服装结构稳定武器始终在正确的手中背景为纯浅色适合后续切成 sprite frames。\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。\n每格边界清晰背景纯浅色适合后续切帧。",
"negativePrompt": "多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色",
"promptExtend": false,
"seed": 2101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775618740654-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775618740654/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/f1/20260408/8b3aee91/b32faeae-f9ae-4f4f-b0b0-71d5144d03ff.png?Expires=1776224540&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=eB7JDLhwb%2FjWUmFvmfgKZYYlwcE%3D"
},
{
"id": "qwen-sheet-1775618740654-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775618740654/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/85/20260408/8b3aee91/0a9474cd-d414-493d-8861-4ffbb2a216ad.png?Expires=1776224539&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=hX5Brh5oMV57Babo29V4Y%2F0x2MI%3D"
}
],
"createdAt": "2026-04-08T03:25:43.484Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775618873101",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1536*1536",
"promptText": "使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧展示同一个角色的连续动作。角色始终朝右全身完整出现在每一个格子里脚底始终可见地面线高度基本一致角色在每一格中的尺度基本一致镜头固定不变不要切换景别不要切换视角不要左右翻转。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。\n动作名待机循环\n是否循环是\n身体位移原地\n武器规则武器始终在主手位置稳定\n1-4 帧:稳定站姿,轻微呼吸起伏\n5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化\n9-12 帧:呼气回落,重心恢复\n13-16 帧:逐渐回到与首帧接近的站姿\n结尾要求第 16 帧自然衔接第 1 帧\n输出要求每一格都要清晰分开网格顺序从左到右、从上到下动作连续首尾关系明确轮廓稳定发型稳定服装结构稳定武器始终在正确的手中背景为纯浅色适合后续切成 sprite frames。\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。\n每格边界清晰背景纯浅色适合后续切帧。",
"negativePrompt": "多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色",
"promptExtend": false,
"seed": 2101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775618873101-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775618873101/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/a5/20260408/8b3aee91/bac5522e-0708-42c1-aed0-5021f9933aa4.png?Expires=1776224672&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=7fYkWZTyAjM8A5zvnOQ7wpyRpbw%3D"
},
{
"id": "qwen-sheet-1775618873101-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775618873101/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/92/20260408/8b3aee91/0cd0c0d8-a23c-4547-897d-989ced023657.png?Expires=1776224672&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=yFBadWZso08jZyxoyBY2CZDstik%3D"
}
],
"createdAt": "2026-04-08T03:27:59.220Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -0,0 +1,28 @@
{
"assetId": "qwen-sprite-1775578573764",
"assetKey": "api-test-sprite",
"actionKey": "idle",
"masterImagePath": "/generated-qwen-sprites/api-test-sprite/idle/qwen-sprite-1775578573764/master.png",
"sheetImagePath": "/generated-qwen-sprites/api-test-sprite/idle/qwen-sprite-1775578573764/sheet.png",
"framePaths": [
"/generated-qwen-sprites/api-test-sprite/idle/qwen-sprite-1775578573764/frames/frame-01.png"
],
"metadata": {
"rows": 4,
"cols": 4,
"frameCount": 1,
"fps": 8,
"activeFrames": [
0
],
"frameOrder": [
0
]
},
"prompts": {
"masterPrompt": "test",
"sheetPrompt": "test",
"repairPrompt": "test"
},
"createdAt": "2026-04-07T16:16:13.819Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ import path from 'node:path';
import { loadEnv, type Plugin } from 'vite';
import { createCharacterAssetStudioPlugins } from './characterAssetStudioPlugins';
import { createQwenSpriteSheetToolPlugins } from './qwenSpriteSheetToolPlugins';
const LLM_PROXY_PATH = '/api/llm/chat/completions';
const ITEM_CATALOG_PATH = '/api/item-catalog';
const ITEM_OVERRIDES_PATH = '/api/item-overrides';
@@ -486,19 +489,55 @@ function isStringArray(value: unknown): value is string[] {
);
}
function decodeDataUrl(dataUrl: string) {
const matched = /^data:(image\/png|image\/jpeg);base64,(.+)$/u.exec(dataUrl);
if (!matched) {
throw new Error(
'Unsupported image payload. Expected PNG or JPEG data URL.',
);
async function resolveAssetSourcePayload(
rootDir: string,
source: string,
fallbackMessage: string,
) {
const dataUrlMatch =
/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/u.exec(source);
if (dataUrlMatch) {
const mimeType = dataUrlMatch[1];
const base64Payload = dataUrlMatch[2];
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension:
mimeType === 'image/jpeg'
? 'jpg'
: mimeType === 'image/webp'
? 'webp'
: 'png',
};
}
if (!source.startsWith('/')) {
throw new Error(fallbackMessage);
}
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
const absolutePath = path.resolve(
rootDir,
'public',
...normalizedSource.split('/'),
);
const publicRoot = path.resolve(rootDir, 'public');
if (!absolutePath.startsWith(publicRoot)) {
throw new Error('Asset source points outside the public directory.');
}
const buffer = await readFile(absolutePath);
const extension = path
.extname(absolutePath)
.replace(/^\./u, '')
.toLowerCase();
if (!extension) {
throw new Error(fallbackMessage);
}
const mimeType = matched[1];
const base64Payload = matched[2];
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension: mimeType === 'image/jpeg' ? 'jpg' : 'png',
buffer,
extension,
};
}
@@ -645,6 +684,7 @@ type PublishedAnimationManifest = {
loop: boolean;
frameWidth: number;
frameHeight: number;
previewVideoPath?: string;
framePaths: string[];
};
@@ -1134,12 +1174,12 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
typeof body.promptText === 'string' && body.promptText.trim()
? body.promptText.trim()
: undefined;
const selectedPreviewDataUrl =
typeof body.selectedPreviewDataUrl === 'string'
? body.selectedPreviewDataUrl
const selectedPreviewSource =
typeof body.selectedPreviewSource === 'string'
? body.selectedPreviewSource
: '';
const previewDataUrls = isStringArray(body.previewDataUrls)
? body.previewDataUrls
const previewSources = isStringArray(body.previewSources)
? body.previewSources
: [];
const width =
typeof body.width === 'number' && Number.isFinite(body.width)
@@ -1155,9 +1195,9 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
return;
}
if (!selectedPreviewDataUrl) {
if (!selectedPreviewSource) {
sendJson(res, 400, {
error: { message: 'selectedPreviewDataUrl is required.' },
error: { message: 'selectedPreviewSource is required.' },
});
return;
}
@@ -1173,14 +1213,22 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
);
await mkdir(visualDir, { recursive: true });
const masterPayload = decodeDataUrl(selectedPreviewDataUrl);
const masterPayload = await resolveAssetSourcePayload(
rootDir,
selectedPreviewSource,
'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
);
const masterFileName = `master.${masterPayload.extension}`;
const masterFilePath = path.join(visualDir, masterFileName);
await writeFile(masterFilePath, masterPayload.buffer);
const previewImagePaths: string[] = [];
for (let index = 0; index < previewDataUrls.length; index += 1) {
const previewPayload = decodeDataUrl(previewDataUrls[index] ?? '');
for (let index = 0; index < previewSources.length; index += 1) {
const previewPayload = await resolveAssetSourcePayload(
rootDir,
previewSources[index] ?? '',
'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
);
const previewFileName = `preview-${index + 1}.${previewPayload.extension}`;
await writeFile(
path.join(visualDir, previewFileName),
@@ -1329,6 +1377,7 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
loop?: unknown;
frameWidth?: unknown;
frameHeight?: unknown;
previewVideoPath?: unknown;
};
const framesDataUrls = isStringArray(typedAnimation.framesDataUrls)
? typedAnimation.framesDataUrls
@@ -1359,7 +1408,11 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
const framePaths: string[] = [];
for (let index = 0; index < framesDataUrls.length; index += 1) {
const framePayload = decodeDataUrl(framesDataUrls[index] ?? '');
const framePayload = await resolveAssetSourcePayload(
rootDir,
framesDataUrls[index] ?? '',
'Unsupported frame payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
);
const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`;
await writeFile(
path.join(actionDir, frameFileName),
@@ -1371,6 +1424,11 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
}
const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`;
const previewVideoPath =
typeof typedAnimation.previewVideoPath === 'string' &&
typedAnimation.previewVideoPath.trim()
? typedAnimation.previewVideoPath.trim()
: undefined;
const manifest: PublishedAnimationManifest = {
id: `${animationSetId}-${actionKey}`,
animationSetId,
@@ -1382,6 +1440,7 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
loop,
frameWidth,
frameHeight,
previewVideoPath,
framePaths,
};
@@ -1476,6 +1535,8 @@ export function createLocalApiPlugins(
env: Record<string, string>,
): Plugin[] {
return [
...createCharacterAssetStudioPlugins(rootDir, mode, env),
...createQwenSpriteSheetToolPlugins(rootDir, mode, env),
createLlmProxyPlugin(rootDir, mode, env),
createCustomWorldSceneImagePlugin(rootDir, mode, env),
createItemCatalogPlugin(rootDir),

View File

@@ -0,0 +1,902 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import http, {
type IncomingMessage,
type RequestOptions,
type ServerResponse,
} from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { loadEnv, type Plugin } from 'vite';
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/qwen-sprite/master';
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/qwen-sprite/sheet';
const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/qwen-sprite/frame-repair';
const QWEN_SPRITE_SAVE_PATH = '/api/qwen-sprite/save';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0';
function readJsonBody(req: IncomingMessage) {
return new Promise<Record<string, unknown>>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
try {
const raw =
Buffer.concat(chunks)
.toString('utf8')
.replace(/^\uFEFF/u, '') || '{}';
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
function isRecordValue(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every((item) => typeof item === 'string' && item.trim().length > 0)
);
}
function resolveRuntimeEnv(
rootDir: string,
mode: string,
env: Record<string, string>,
) {
return {
...env,
...loadEnv(mode, rootDir, ''),
};
}
function normalizeDashScopeBaseUrl(value: string) {
return value.replace(/\/$/u, '');
}
function extractApiErrorMessage(responseText: string, fallbackMessage: string) {
if (!responseText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(responseText) as {
code?: string;
message?: string;
error?: { message?: string };
};
if (
typeof parsed.error?.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message;
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message;
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return `${fallbackMessage} (${parsed.code})`;
}
} catch {
// Fall through to raw text.
}
return responseText;
}
function sanitizePathSegment(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-_]+/gu, '-')
.replace(/-+/gu, '-')
.replace(/^-|-$/gu, '');
return normalized || 'asset';
}
function createTimestampId(prefix: string) {
return `${prefix}-${Date.now()}`;
}
function requestTextResponse(
urlString: string,
options: {
method?: string;
headers?: Record<string, string>;
bodyText?: string;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
bodyText: string;
}>((resolve, reject) => {
const url = new URL(urlString);
const transport = url.protocol === 'https:' ? https : http;
const payload = options.bodyText;
const requestOptions: RequestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: `${url.pathname}${url.search}`,
method: options.method ?? 'GET',
headers: {
...(options.headers ?? {}),
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const request = transport.request(requestOptions, (upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
upstreamRes.on('end', () => {
resolve({
statusCode: upstreamRes.statusCode ?? 502,
headers: upstreamRes.headers,
bodyText: Buffer.concat(chunks).toString('utf8'),
});
});
upstreamRes.on('error', reject);
});
request.on('error', reject);
if (payload) {
request.write(payload);
}
request.end();
});
}
function requestBinaryResponse(
urlString: string,
options: {
method?: string;
headers?: Record<string, string>;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: Buffer;
}>((resolve, reject) => {
const url = new URL(urlString);
const transport = url.protocol === 'https:' ? https : http;
const requestOptions: RequestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: `${url.pathname}${url.search}`,
method: options.method ?? 'GET',
headers: options.headers ?? {},
};
const request = transport.request(requestOptions, (upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
upstreamRes.on('end', () => {
resolve({
statusCode: upstreamRes.statusCode ?? 502,
headers: upstreamRes.headers,
body: Buffer.concat(chunks),
});
});
upstreamRes.on('error', reject);
});
request.on('error', reject);
request.end();
});
}
function proxyJsonRequest(
urlString: string,
apiKey: string,
body: Record<string, unknown>,
) {
return requestTextResponse(urlString, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
bodyText: JSON.stringify(body),
});
}
function collectStringsByKey(
value: unknown,
targetKey: string,
results: string[],
) {
if (Array.isArray(value)) {
value.forEach((item) => collectStringsByKey(item, targetKey, results));
return;
}
if (!isRecordValue(value)) {
return;
}
const directValue = value[targetKey];
if (typeof directValue === 'string' && directValue.trim()) {
results.push(directValue.trim());
}
Object.values(value).forEach((nestedValue) =>
collectStringsByKey(nestedValue, targetKey, results),
);
}
function extractImageUrls(payload: Record<string, unknown>) {
const results: string[] = [];
collectStringsByKey(payload.output, 'image', results);
collectStringsByKey(payload.output, 'url', results);
return [...new Set(results)];
}
function parseDataUrl(source: string) {
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
if (!matched) {
return null;
}
const mimeType = matched[1];
const base64Payload = matched[2];
const extension = (() => {
switch (mimeType) {
case 'image/jpeg':
return 'jpg';
case 'image/webp':
return 'webp';
default:
return 'png';
}
})();
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension,
};
}
async function resolveImageSourcePayload(rootDir: string, source: string) {
const parsedDataUrl = parseDataUrl(source);
if (parsedDataUrl) {
return parsedDataUrl;
}
if (!source.startsWith('/')) {
throw new Error('图像来源必须是 Data URL 或 public 目录 URL。');
}
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
const absolutePath = path.resolve(
rootDir,
'public',
...normalizedSource.split('/'),
);
const publicRoot = path.resolve(rootDir, 'public');
if (!absolutePath.startsWith(publicRoot)) {
throw new Error('图像来源路径越界。');
}
const buffer = await readFile(absolutePath);
const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png';
return {
buffer,
extension,
};
}
async function resolveImageSourceAsDataUrl(rootDir: string, source: string) {
if (/^data:image\/[^;]+;base64,/u.test(source)) {
return source;
}
const payload = await resolveImageSourcePayload(rootDir, source);
const mimeType = (() => {
switch (payload.extension) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
default:
return 'image/png';
}
})();
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
}
async function writeDraftImageFile(
rootDir: string,
relativePath: string,
buffer: Buffer,
) {
const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/'));
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, buffer);
return `/${relativePath}`;
}
async function generateQwenImages(
rootDir: string,
mode: string,
env: Record<string, string>,
input: {
kind: 'master' | 'sheet' | 'repair';
promptText: string;
negativePrompt: string;
model: string;
size: string;
promptExtend: boolean;
seed?: number;
candidateCount: number;
referenceImages: string[];
},
) {
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
const baseUrl = normalizeDashScopeBaseUrl(
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
);
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
if (!apiKey) {
throw new Error('服务端缺少 DASHSCOPE_API_KEY无法调用 Qwen-Image。');
}
const content = [
...(await Promise.all(
input.referenceImages
.slice(0, 3)
.map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })),
)),
{ text: input.promptText },
];
const requestPayload: Record<string, unknown> = {
model: input.model || DEFAULT_QWEN_IMAGE_MODEL,
input: {
messages: [
{
role: 'user',
content,
},
],
},
parameters: {
n: Math.max(1, Math.min(6, input.candidateCount)),
negative_prompt: input.negativePrompt,
prompt_extend: input.promptExtend,
watermark: false,
size: input.size,
...(typeof input.seed === 'number' && Number.isFinite(input.seed)
? { seed: input.seed }
: {}),
},
};
const response = await proxyJsonRequest(
`${baseUrl}/services/aigc/multimodal-generation/generation`,
apiKey,
requestPayload,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(
extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'),
);
}
const parsed = JSON.parse(response.bodyText) as Record<string, unknown>;
const imageUrls = extractImageUrls(parsed);
if (imageUrls.length === 0) {
throw new Error('Qwen-Image 未返回可下载的图片结果。');
}
const draftId = createTimestampId(`qwen-${input.kind}`);
const relativeDir = path.posix.join(
'generated-qwen-sprites',
'_drafts',
input.kind,
draftId,
);
const drafts = await Promise.all(
imageUrls.map(async (imageUrl, index) => {
const binaryResponse = await requestBinaryResponse(imageUrl);
if (
binaryResponse.statusCode < 200 ||
binaryResponse.statusCode >= 300
) {
throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`);
}
const imageSrc = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`),
binaryResponse.body,
);
return {
id: `${draftId}-${index + 1}`,
label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`,
imageSrc,
remoteUrl: imageUrl,
};
}),
);
await writeFile(
path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'),
JSON.stringify(
{
draftId,
kind: input.kind,
model: input.model,
size: input.size,
promptText: input.promptText,
negativePrompt: input.negativePrompt,
promptExtend: input.promptExtend,
seed: input.seed,
candidateCount: input.candidateCount,
referenceImageCount: input.referenceImages.length,
drafts,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
'utf8',
);
return {
draftId,
drafts,
model: input.model,
size: input.size,
promptText: input.promptText,
negativePrompt: input.negativePrompt,
};
}
async function handleGenerateMaster(
rootDir: string,
mode: string,
env: Record<string, string>,
req: IncomingMessage,
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1024';
const promptExtend = body.promptExtend !== false;
const candidateCount =
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
? body.candidateCount
: 1;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
try {
const result = await generateQwenImages(rootDir, mode, env, {
kind: 'master',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '生成主图失败。',
},
});
}
}
async function handleGenerateSheet(
rootDir: string,
mode: string,
env: Record<string, string>,
req: IncomingMessage,
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1024';
const promptExtend = body.promptExtend !== false;
const candidateCount =
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
? body.candidateCount
: 1;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
try {
const result = await generateQwenImages(rootDir, mode, env, {
kind: 'sheet',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '生成精灵表失败。',
},
});
}
}
async function handleRepairFrame(
rootDir: string,
mode: string,
env: Record<string, string>,
req: IncomingMessage,
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '512*512';
const promptExtend = body.promptExtend !== false;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
if (referenceImages.length === 0) {
sendJson(res, 400, {
error: { message: '至少需要一张参考图来修复帧。' },
});
return;
}
try {
const result = await generateQwenImages(rootDir, mode, env, {
kind: 'repair',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount: 1,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
repairedFrame: result.drafts[0] ?? null,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '修帧失败。',
},
});
}
}
async function handleSaveAsset(
rootDir: string,
req: IncomingMessage,
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const assetKey =
typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : '';
const actionKey =
typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : '';
const masterSource =
typeof body.masterSource === 'string' ? body.masterSource.trim() : '';
const sheetSource =
typeof body.sheetSource === 'string' ? body.sheetSource.trim() : '';
const framesDataUrls = isStringArray(body.framesDataUrls)
? body.framesDataUrls
: [];
const metadata = isRecordValue(body.metadata) ? body.metadata : {};
const prompts = isRecordValue(body.prompts) ? body.prompts : {};
if (!assetKey) {
sendJson(res, 400, { error: { message: 'assetKey is required.' } });
return;
}
if (!actionKey) {
sendJson(res, 400, { error: { message: 'actionKey is required.' } });
return;
}
if (!sheetSource) {
sendJson(res, 400, { error: { message: 'sheetSource is required.' } });
return;
}
try {
const assetId = createTimestampId('qwen-sprite');
const relativeDir = path.posix.join(
'generated-qwen-sprites',
assetKey,
actionKey,
assetId,
);
const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/'));
await mkdir(path.join(absoluteDir, 'frames'), { recursive: true });
let masterImagePath: string | null = null;
if (masterSource) {
const payload = await resolveImageSourcePayload(rootDir, masterSource);
masterImagePath = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `master.${payload.extension}`),
payload.buffer,
);
}
const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource);
const sheetImagePath = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`),
sheetPayload.buffer,
);
const framePaths: string[] = [];
for (let index = 0; index < framesDataUrls.length; index += 1) {
const framePayload = await resolveImageSourcePayload(
rootDir,
framesDataUrls[index] ?? '',
);
const framePath = await writeDraftImageFile(
rootDir,
path.posix.join(
relativeDir,
'frames',
`frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`,
),
framePayload.buffer,
);
framePaths.push(framePath);
}
await writeFile(
path.join(absoluteDir, 'metadata.json'),
JSON.stringify(
{
assetId,
assetKey,
actionKey,
masterImagePath,
sheetImagePath,
framePaths,
metadata,
prompts,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
'utf8',
);
sendJson(res, 200, {
ok: true,
assetId,
assetDir: `/${relativeDir}`,
masterImagePath,
sheetImagePath,
framePaths,
saveMessage: '已保存到 public/generated-qwen-sprites。',
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '保存精灵表资产失败。',
},
});
}
}
export function createQwenSpriteSheetToolPlugins(
rootDir: string,
mode: string,
env: Record<string, string>,
): Plugin[] {
const masterHandler = (req: IncomingMessage, res: ServerResponse) =>
void handleGenerateMaster(rootDir, mode, env, req, res);
const sheetHandler = (req: IncomingMessage, res: ServerResponse) =>
void handleGenerateSheet(rootDir, mode, env, req, res);
const repairHandler = (req: IncomingMessage, res: ServerResponse) =>
void handleRepairFrame(rootDir, mode, env, req, res);
const saveHandler = (req: IncomingMessage, res: ServerResponse) =>
void handleSaveAsset(rootDir, req, res);
return [
{
name: 'qwen-sprite-master-generate',
configureServer(server) {
server.middlewares.use(QWEN_SPRITE_MASTER_GENERATE_PATH, masterHandler);
},
configurePreviewServer(server) {
server.middlewares.use(QWEN_SPRITE_MASTER_GENERATE_PATH, masterHandler);
},
},
{
name: 'qwen-sprite-sheet-generate',
configureServer(server) {
server.middlewares.use(QWEN_SPRITE_SHEET_GENERATE_PATH, sheetHandler);
},
configurePreviewServer(server) {
server.middlewares.use(QWEN_SPRITE_SHEET_GENERATE_PATH, sheetHandler);
},
},
{
name: 'qwen-sprite-frame-repair',
configureServer(server) {
server.middlewares.use(QWEN_SPRITE_FRAME_REPAIR_PATH, repairHandler);
},
configurePreviewServer(server) {
server.middlewares.use(QWEN_SPRITE_FRAME_REPAIR_PATH, repairHandler);
},
},
{
name: 'qwen-sprite-save',
configureServer(server) {
server.middlewares.use(QWEN_SPRITE_SAVE_PATH, saveHandler);
},
configurePreviewServer(server) {
server.middlewares.use(QWEN_SPRITE_SAVE_PATH, saveHandler);
},
},
];
}

View File

@@ -0,0 +1,17 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { buildCurrentGameStoryAuditMarkdown } from '../src/services/storyEngine/storyAuditReport.ts';
const defaultOutputPath = resolve(
process.cwd(),
'docs/audits/text/CURRENT_GAME_STORY_SOURCE_REVIEW_2026-04-07.md',
);
const outputPath = process.argv[2]
? resolve(process.cwd(), process.argv[2])
: defaultOutputPath;
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, buildCurrentGameStoryAuditMarkdown(), 'utf8');
console.log(`[story-audit] wrote ${outputPath}`);

View File

@@ -36,7 +36,7 @@ export default function App() {
playResolvedChoice: combatFlow.playResolvedChoice,
});
const { companionRenderStates } = useNpcInteractionFlow(gameState);
const { companionRenderStates, buildCompanionRenderStates } = useNpcInteractionFlow(gameState);
const settings = useGameSettings();
const persistence = useGamePersistence({
@@ -144,6 +144,7 @@ export default function App() {
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
goalUi: storyFlow.goalUi,
};
const gameShellEntry = {
@@ -158,6 +159,7 @@ export default function App() {
const gameShellCompanions = {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion: handleBenchCompanion,
onActivateRosterCompanion: handleActivateRosterCompanion,
};

View File

@@ -34,6 +34,7 @@ import {
getHostileNpcPresetById,
getMonsterPresetsByWorld,
} from '../data/hostileNpcPresets';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import {
buildEncounterAttributeRumors,
resolveEncounterAttributeProfile,
@@ -777,34 +778,55 @@ export function AdventureEntityModal({
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
{selection.kind === 'player' && playerCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
playerCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(playerCharacter.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
)
) : selection.kind === 'companion' &&
companionCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
companionCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(companionCharacter.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
)
) : npcCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
npcCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(npcCharacter.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
)
) : hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
@@ -925,9 +947,9 @@ export function AdventureEntityModal({
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record) => (
{relatedConsequences.map((record, index) => (
<div
key={record.id}
key={record.id || `consequence-${record.title}-${index}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{record.title}</span>
@@ -939,9 +961,9 @@ export function AdventureEntityModal({
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry) => (
{recentChronicleEntries.map((entry, index) => (
<div
key={entry.id}
key={entry.id || `chronicle-${entry.title}-${index}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
@@ -961,9 +983,9 @@ export function AdventureEntityModal({
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue) => (
{sceneResidues.map((residue, index) => (
<div
key={residue.id}
key={residue.id || `residue-${residue.title}-${index}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{residue.title}</span>

View File

@@ -30,14 +30,15 @@ import { getScenePresetById } from '../data/scenePresets';
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
import type {
CampEvent,
ChapterState,
Character,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
JourneyBeat,
NpcBattleMode,
QuestLogEntry,
SetpieceDirective,
StoryMoment,
StoryOption,
WorldType,
@@ -67,6 +68,9 @@ interface AdventurePanelProps {
worldType: WorldType | null;
quests: QuestLogEntry[];
questUi: QuestFlowUi;
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
onDismissGoalPulse: () => void;
battleRewardUi: BattleRewardUi;
playerHp: number;
playerMaxHp: number;
@@ -95,9 +99,6 @@ interface AdventurePanelProps {
onSaveAndExit: () => void;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
recentChronicleSummary?: string | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
}
const AdventurePanelOverlays = lazy(async () => {
@@ -250,6 +251,19 @@ function formatPlayTime(playTimeMs: number) {
return `${minutes}${String(seconds).padStart(2, '0')}`;
}
function getOptionGoalAffordanceClass(option: StoryOption) {
switch (option.goalAffordance?.relation) {
case 'advance':
return 'text-amber-200/85';
case 'support':
return 'text-sky-200/80';
case 'detour':
return 'text-zinc-400';
default:
return 'text-zinc-500';
}
}
function RewardItemIconGrid({
items,
selectedItemId,
@@ -589,6 +603,9 @@ export function AdventurePanel({
worldType,
quests,
questUi,
goalStack,
goalPulse,
onDismissGoalPulse,
battleRewardUi,
playerHp,
playerMaxHp,
@@ -602,9 +619,6 @@ export function AdventurePanel({
onSaveAndExit,
chapterState = null,
journeyBeat = null,
recentChronicleSummary = null,
currentCampEvent = null,
setpieceDirective = null,
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
@@ -615,7 +629,12 @@ export function AdventurePanel({
currentStory.deferredOptions?.length,
);
const saveAndExitDisabled = isLoading || isStoryStreaming;
const [isChapterPanelOpen, setIsChapterPanelOpen] = useState(false);
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest'
? goalStack.activeGoal
: goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal
: null;
const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false);
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false);
@@ -627,6 +646,7 @@ export function AdventurePanel({
string | null
>(null);
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null);
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
string | null
>(null);
@@ -636,6 +656,8 @@ export function AdventurePanel({
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
string | null
>(null);
const lastAutoOpenedGoalRef = useRef<string | null>(null);
const lastAutoOpenedPulseRef = useRef<string | null>(null);
const battleReward = battleRewardUi.reward;
const hasCompletedQuest = useMemo(
() => quests.some((quest) => isQuestReadyToClaim(quest)),
@@ -712,6 +734,32 @@ export function AdventurePanel({
setSelectedBattleRewardItemId(null);
}, [battleReward]);
useEffect(() => {
if (!primaryQuestGoal) {
return;
}
if (lastAutoOpenedGoalRef.current === primaryQuestGoal.id) {
return;
}
lastAutoOpenedGoalRef.current = primaryQuestGoal.id;
setIsGoalPanelOpen(true);
}, [primaryQuestGoal]);
useEffect(() => {
if (!goalPulse) {
return;
}
if (lastAutoOpenedPulseRef.current === goalPulse.id) {
return;
}
lastAutoOpenedPulseRef.current = goalPulse.id;
setIsGoalPanelOpen(true);
}, [goalPulse]);
useEffect(() => {
const container = storyScrollContainerRef.current;
if (!container) return;
@@ -813,7 +861,7 @@ export function AdventurePanel({
[statistics],
);
const shouldMountAdventureOverlays =
isChapterPanelOpen ||
isGoalPanelOpen ||
isSettingsPanelOpen ||
isStatsPanelOpen ||
isQuestPanelOpen ||
@@ -834,6 +882,11 @@ export function AdventurePanel({
onChoice(option);
};
const handleDismissGoalPanel = () => {
setIsGoalPanelOpen(false);
onDismissGoalPulse();
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<button
@@ -854,35 +907,34 @@ export function AdventurePanel({
</button>
<button
type="button"
onClick={() => setIsChapterPanelOpen(true)}
onClick={() => {
setIsQuestPanelOpen(true);
onDismissGoalPulse();
}}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
>
<ScrollText className="h-4 w-4" />
<span className="leading-none"></span>
{chapterState?.title ? (
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
{chapterState.title}
</span>
) : null}
</button>
<button
type="button"
onClick={() => setIsQuestPanelOpen(true)}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14.5vh)' }}
>
{hasCompletedQuest && (
{(hasCompletedQuest || goalPulse) && (
<span
aria-hidden="true"
className="absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]"
className={`absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border shadow-[0_0_14px_rgba(245,158,11,0.5)] ${
hasCompletedQuest
? 'border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]'
: 'border-amber-200/45 bg-amber-500'
}`}
/>
)}
<PixelIcon src={CHROME_ICONS.map} className="h-4 w-4" />
<span className="leading-none"></span>
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
{quests.length}
</span>
{primaryQuestGoal?.title ? (
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
{primaryQuestGoal.title}
</span>
) : (
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
{quests.length}
</span>
)}
</button>
{aiError && (
@@ -1058,6 +1110,11 @@ export function AdventurePanel({
{getCompactOptionDetailText(option)}
</div>
)}
{option.goalAffordance?.label && (
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
{option.goalAffordance.label}
</div>
)}
{optionImpactSummary && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
@@ -1083,8 +1140,8 @@ export function AdventurePanel({
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
saveAndExitDisabled={saveAndExitDisabled}
isChapterPanelOpen={isChapterPanelOpen}
setIsChapterPanelOpen={setIsChapterPanelOpen}
isGoalPanelOpen={isGoalPanelOpen}
setIsGoalPanelOpen={setIsGoalPanelOpen}
isQuestPanelOpen={isQuestPanelOpen}
setIsQuestPanelOpen={setIsQuestPanelOpen}
isSettingsPanelOpen={isSettingsPanelOpen}
@@ -1093,15 +1150,17 @@ export function AdventurePanel({
setIsStatsPanelOpen={setIsStatsPanelOpen}
chapterState={chapterState}
journeyBeat={journeyBeat}
recentChronicleSummary={recentChronicleSummary}
currentCampEvent={currentCampEvent}
setpieceDirective={setpieceDirective}
goalStack={goalStack}
goalPulse={goalPulse}
onDismissGoalPulse={handleDismissGoalPanel}
selectedQuest={selectedQuest}
setSelectedQuestId={setSelectedQuestId}
completionNoticeQuest={completionNoticeQuest}
setCompletionNoticeQuestId={setCompletionNoticeQuestId}
rewardQuest={rewardQuest}
setRewardQuestId={setRewardQuestId}
rewardQuestHandoff={rewardQuestHandoff}
setRewardQuestHandoff={setRewardQuestHandoff}
selectedRewardItemQuestId={selectedRewardItemQuestId}
setSelectedRewardItemQuestId={setSelectedRewardItemQuestId}
selectedRewardItemId={selectedRewardItemId}

View File

@@ -49,22 +49,34 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const startFrame = config.startFrame ?? 1;
const frameCount = config.frames;
const animationSignature = [
state,
config.basePath ?? '',
config.folder,
config.prefix,
config.file ?? '',
config.extension ?? 'png',
startFrame,
frameCount,
].join('::');
useEffect(() => {
setFrameIndex(config.startFrame || 1);
setFrameIndex(startFrame);
if (config.frames <= 1) return;
if (frameCount <= 1) return;
const interval = setInterval(() => {
const endFrame = startFrame + frameCount - 1;
const interval = window.setInterval(() => {
setFrameIndex(prev => {
const start = config.startFrame || 1;
const end = start + config.frames - 1;
return prev >= end ? start : prev + 1;
return prev >= endFrame ? startFrame : prev + 1;
});
}, 100);
return () => clearInterval(interval);
}, [config]);
return () => window.clearInterval(interval);
}, [animationSignature, frameCount, startFrame]);
const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');

View File

@@ -14,6 +14,7 @@ import {
getCharacterMaxMana,
getInventoryItems,
} from '../data/characterPresets';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,
@@ -36,6 +37,7 @@ import {
CharacterAttributeGrid,
CharacterSkillsList,
} from './CharacterInfoShared';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
interface CharacterDetailModalProps {
@@ -161,7 +163,10 @@ export function CharacterDetailModal({
worldType,
customWorldProfile,
);
const resourceLabels = getResourceLabelsForWorld(worldType);
const resourceLabels = getResourceLabelsForWorld(
worldType,
customWorldProfile,
);
return (
<motion.div
@@ -204,13 +209,20 @@ export function CharacterDetailModal({
<Section title="资料">
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
{character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
)}
</div>
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">

View File

@@ -25,6 +25,7 @@ import {
getEquipmentRarityLabel,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -61,6 +62,7 @@ import {
StatusRow,
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
interface CharacterPanelProps {
@@ -613,15 +615,22 @@ export function CharacterPanel({
>
<div className="flex flex-col items-center text-center">
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
<CharacterAnimator
state={AnimationState.IDLE}
character={selectedMember.character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
selectedMember.character,
)}
/>
{selectedMember.character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(selectedMember.character.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={selectedMember.character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
selectedMember.character,
)}
/>
)}
</div>
<div className="mt-3 text-base font-bold text-white">
{selectedMember.character.name}

View File

@@ -4,6 +4,11 @@ import {
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
@@ -229,6 +234,15 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks],
);
const landmarkImageById = useMemo(
() => resolveCustomWorldLandmarkImageMap(profile),
[profile],
);
const resolvedCampScene = useMemo(() => resolveCustomWorldCampScene(profile), [profile]);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(profile),
[profile],
);
const previewCharacterById = useMemo(
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
[previewCharacters, profile.playableNpcs],
@@ -394,6 +408,23 @@ export function CustomWorldEntityCatalog({
</Section>
) : null}
<Section title="开局归处" subtitle="玩家进入自定义世界后的第一处落脚点,也会直接作为开场场景背景。">
<div className="space-y-3">
<ImageFrame
src={resolvedCampImageSrc}
alt={resolvedCampScene.name}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
/>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-200">
{resolvedCampScene.name}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-7 text-zinc-300">
{resolvedCampScene.description}
</div>
</div>
</Section>
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
@@ -766,7 +797,12 @@ export function CustomWorldEntityCatalog({
</div>
) : null}
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
<ImageFrame
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
alt={landmark.name}
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
tone="landscape"
/>
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">
{landmark.dangerLevel || '未填写'}

View File

@@ -14,7 +14,8 @@ import {
} from '../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
getDefaultCustomWorldSceneImage,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import {
type CustomWorldSceneImageResult,
@@ -24,6 +25,7 @@ import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
CustomWorldLandmark,
@@ -612,7 +614,25 @@ function SceneImageGenerationModal({
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const previewImageSrc = latestResult?.imageSrc ?? landmark.imageSrc;
const previewImageSrc = useMemo(() => {
if (latestResult?.imageSrc) {
return latestResult.imageSrc;
}
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
return resolveCustomWorldLandmarkImage(
profile,
landmark,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== landmark.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, latestResult, profile]);
const handleGenerate = async () => {
if (!prompt.trim()) {
@@ -1238,6 +1258,29 @@ function WorldEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(profile);
const [isCampPresetPickerOpen, setIsCampPresetPickerOpen] = useState(false);
const [isCampAiGenerateOpen, setIsCampAiGenerateOpen] = useState(false);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(draft),
[draft],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(draft),
[draft],
);
const campSceneDraft = useMemo<CustomWorldLandmark>(
() => ({
id: 'custom-scene-camp',
name: resolvedCampScene.name,
description: resolvedCampScene.description,
dangerLevel: resolvedCampScene.dangerLevel,
imageSrc: resolvedCampScene.imageSrc,
sceneNpcIds: [],
connections: [],
}),
[resolvedCampScene],
);
return (
<ModalShell
@@ -1289,6 +1332,84 @@ function WorldEditor({
rows={3}
/>
</Field>
<Field label="开局归处名称">
<TextInput
value={resolvedCampScene.name}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
name: value,
},
}))
}
/>
</Field>
<Field label="开局归处描述">
<TextArea
value={resolvedCampScene.description}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
description: value,
},
}))
}
rows={4}
/>
</Field>
<Field label="开局归处危险度">
<TextInput
value={resolvedCampScene.dangerLevel}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
dangerLevel: value,
},
}))
}
/>
</Field>
<ImageField
label="开局归处背景"
value={resolvedCampImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value || undefined,
},
}))
}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={(
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="预设选择"
onClick={() => setIsCampPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="智能生成"
onClick={() => setIsCampAiGenerateOpen(true)}
/>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
</div>
)}
/>
<Field label="玩家原始设定">
<TextArea
value={draft.settingText}
@@ -1298,6 +1419,38 @@ function WorldEditor({
rows={4}
/>
</Field>
{isCampPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={resolvedCampScene.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value,
},
}))
}
onClose={() => setIsCampPresetPickerOpen(false)}
/>
) : null}
{isCampAiGenerateOpen ? (
<SceneImageGenerationModal
profile={draft}
landmark={campSceneDraft}
onApply={(result) => {
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: result.imageSrc,
},
}));
}}
onClose={() => setIsCampAiGenerateOpen(false)}
/>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1769,6 +1922,21 @@ function LandmarkEditor({
npc: CustomWorldNpc;
} | null>(null);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedDraftImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === draft.id,
);
return resolveCustomWorldLandmarkImage(
profile,
draft,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== draft.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [draft, profile]);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
@@ -1847,7 +2015,7 @@ function LandmarkEditor({
<div className="space-y-4">
<ImageField
label="场景图片"
value={draft.imageSrc}
value={resolvedDraftImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
@@ -2367,11 +2535,7 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: getDefaultCustomWorldSceneImage(
profile.id || profile.name,
profile.landmarks.length,
profile.templateWorldType,
),
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [

View File

@@ -7,6 +7,7 @@ import {BottomTab} from '../hooks/useGameFlow';
import {
type BattleRewardUi,
type CharacterChatUi,
type GoalFlowUi,
type InventoryFlowUi,
type QuestFlowUi,
type StoryGenerationNpcUi,
@@ -49,6 +50,7 @@ interface GameShellStoryProps {
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
}
interface GameShellEntryProps {
@@ -63,6 +65,7 @@ interface GameShellEntryProps {
interface GameShellCompanionProps {
companionRenderStates: CompanionRenderState[];
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
@@ -201,6 +204,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
inventoryUi,
battleRewardUi,
questUi,
goalUi,
} = story;
const {
hasSavedGame,
@@ -211,7 +215,12 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {companionRenderStates, onBenchCompanion, onActivateRosterCompanion} = companions;
const {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
@@ -287,13 +296,18 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
[gameState.characterChats],
);
const visibleCompanionRenderStates = useMemo(
() => buildCompanionRenderStates(visibleGameState),
[buildCompanionRenderStates, visibleGameState],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return companionRenderStates;
return companionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [companionRenderStates, visibleGameState.currentEncounter]);
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
@@ -530,6 +544,9 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
goalStack={goalUi.goalStack}
goalPulse={goalUi.pulse}
onDismissGoalPulse={goalUi.dismissPulse}
battleRewardUi={battleRewardUi}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
@@ -542,15 +559,6 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}

View File

@@ -2,10 +2,11 @@ import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { buildInventoryItemDescription } from '../data/itemPresentation';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
getInventoryCategoryIcon,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
@@ -66,31 +67,14 @@ function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
}
function getInventoryItemIcon(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function buildInventoryItemSummary(
item: InventoryItem,
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
) {
if (item.description?.trim()) return item.description;
if (!useEffect)
return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
const parts = [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
: null,
useEffect.buildBuffs.length > 0
? `获得 ${useEffect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.length > 0
? `${item.name} 可以立即使用,${parts.join('')}`
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
return buildInventoryItemDescription(item, useEffect);
}
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {

View File

@@ -14,6 +14,10 @@ import {
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../data/itemPresentation';
import {
buildInitialNpcState,
getGiftCandidates,
@@ -21,7 +25,7 @@ import {
} from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/useStoryGeneration';
import { GameState, InventoryItem } from '../types';
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface NpcModalsProps {
@@ -39,7 +43,7 @@ function getNpcEncounterKey(encounter: NonNullable<GameState['currentEncounter']
}
function getItemVisualSrc(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function buildTradeUseEffectText(
@@ -412,7 +416,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
</div>
<p className="text-sm leading-relaxed text-zinc-300">
{tradeDetailItem.description || `${tradeDetailItem.name}可用于交易、装备,或在合适时机直接使用。`}
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
@@ -423,7 +427,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
</div>
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{tradeDetailItem.tags.join(' / ') || '无'}
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'}
</div>
</div>

View File

@@ -245,7 +245,7 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
value={draftText}
onChange={(event) => updateDraftText(event.target.value)}
rows={8}
placeholder="例:一个雨雾笼罩的海上武侠世界,旧朝遗臣、海盗盟约沉船秘术纠缠在一起……"
placeholder="例:一个被潮雾与失落列岛切碎的边境世界,旧盟约沉船秘术与灯塔守望者纠缠在一起……"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>

View File

@@ -14,20 +14,32 @@ import {AnimatePresence, motion} from 'motion/react';
import {formatCurrency} from '../../data/economy';
import {getHostileNpcPresetById} from '../../data/hostileNpcPresets';
import {type InventoryUseEffect, isInventoryItemUsable} from '../../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../../data/itemPresentation';
import {getRarityLabel} from '../../data/npcInteractions';
import {isQuestReadyToClaim} from '../../data/questFlow';
import {getScenePresetById} from '../../data/scenePresets';
import type {BattleRewardUi, QuestFlowUi} from '../../hooks/useStoryGeneration';
import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector';
import type {
CampEvent,
ChapterState,
EquipmentSlotId,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
WorldType,
} from '../../types';
import {CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {
CHROME_ICONS,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {PixelIcon} from '../PixelIcon';
@@ -66,8 +78,8 @@ interface AdventurePanelOverlaysProps {
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isChapterPanelOpen: boolean;
setIsChapterPanelOpen: (open: boolean) => void;
isGoalPanelOpen: boolean;
setIsGoalPanelOpen: (open: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (open: boolean) => void;
isSettingsPanelOpen: boolean;
@@ -76,15 +88,17 @@ interface AdventurePanelOverlaysProps {
setIsStatsPanelOpen: (open: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
recentChronicleSummary: string | null;
currentCampEvent: CampEvent | null;
setpieceDirective: SetpieceDirective | null;
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
onDismissGoalPulse: () => void;
selectedQuest: QuestLogEntry | null;
setSelectedQuestId: (questId: string | null) => void;
completionNoticeQuest: QuestLogEntry | null;
setCompletionNoticeQuestId: (questId: string | null) => void;
rewardQuest: QuestLogEntry | null;
setRewardQuestId: (questId: string | null) => void;
rewardQuestHandoff: GoalHandoff | null;
setRewardQuestHandoff: (handoff: GoalHandoff | null) => void;
selectedRewardItemQuestId: string | null;
setSelectedRewardItemQuestId: (questId: string | null) => void;
selectedRewardItemId: string | null;
@@ -98,87 +112,402 @@ interface AdventurePanelOverlaysProps {
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
}
function getChapterStageLabel(stage: ChapterState['stage'] | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
function compactSceneTaskLabel(sceneName: string | null | undefined, fallback: string) {
if (!sceneName?.trim()) {
return fallback;
}
const cleaned = sceneName.replace(/["']/gu, '').trim();
return cleaned.length > 8 ? cleaned.slice(0, 8) : cleaned;
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType'] | null | undefined) {
function formatTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskCardCopy(params: {
beatType: JourneyBeat['beatType'] | null | undefined;
sceneName: string;
fallbackCondition: string;
}) {
const { beatType, sceneName, fallbackCondition } = params;
const sceneLabel = compactSceneTaskLabel(sceneName, '前方区域');
switch (beatType) {
case 'approach':
return '接近';
return {
title: `前往${sceneLabel}`,
description: `${sceneLabel} 一带出现了值得跟进的新线索,继续靠近,看看那里到底发生了什么。`,
condition: `前往 ${sceneName},确认新的线索。`,
progress: '靠近线索',
};
case 'investigation':
return '调查';
return {
title: `调查${sceneLabel}`,
description: `${sceneLabel} 出现了新的异常和痕迹,继续调查,查清这里到底隐藏着什么。`,
condition: `${sceneName} 调查线索或异常。`,
progress: '调查进行中',
};
case 'camp':
return '休整';
return {
title: '回营整备',
description: '先整理队伍、资源和状态,再决定下一段任务的推进方式。',
condition: '返回营地,整理队伍或与同伴交谈。',
progress: '整备中',
};
case 'conflict':
return '冲突';
return {
title: `处理${sceneLabel}`,
description: `${sceneLabel} 的冲突已经浮出水面,需要继续推进并正面处理。`,
condition: `${sceneName} 处理当前冲突。`,
progress: '冲突处理中',
};
case 'boss_prelude':
return '决战前奏';
return {
title: `备战${sceneLabel}`,
description: '关键战斗已经逼近,先把线索和状态准备好。',
condition: fallbackCondition,
progress: '战前准备',
};
case 'climax':
return '高潮';
return {
title: `决断${sceneLabel}`,
description: '决定结果的对峙已经临近,继续推进到最终现场。',
condition: fallbackCondition,
progress: '决战临近',
};
case 'recovery':
return '恢复';
return {
title: '收束结果',
description: '刚结束的事件还在留下影响,先整理结果,再决定下一步去向。',
condition: fallbackCondition,
progress: '结果收束中',
};
default:
return '旅程';
return {
title: '继续推进',
description: `${sceneLabel} 一带还有没查清的事,继续推进当前线索。`,
condition: fallbackCondition,
progress: '推进中',
};
}
}
function getCampEventLabel(eventType: CampEvent['eventType'] | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私话';
case 'party_banter':
return '同行插话';
case 'conflict':
return '争执';
case 'comfort':
return '安抚';
case 'reveal':
return '透露';
case 'decision':
return '抉择';
function buildCurrentTaskCardCopy(params: {
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
journeyBeat: JourneyBeat | null;
sceneName: string;
}) {
const { goalStack, goalPulse, journeyBeat, sceneName } = params;
const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
const stepGoal = goalStack.immediateStepGoal ?? primaryGoal;
if (!primaryGoal || !stepGoal) {
return null;
}
if (primaryGoal.sourceKind === 'quest') {
const description = primaryGoal.whyNow || primaryGoal.promiseText;
return {
eyebrow: goalPulse?.title ?? '当前任务',
title: formatTaskTitle(primaryGoal.title, '当前任务'),
description,
condition: stepGoal.nextStepText,
progress: stepGoal.progressLabel ?? primaryGoal.progressLabel ?? '推进中',
pulseNote:
goalPulse?.detail && goalPulse.detail !== description && goalPulse.detail !== stepGoal.nextStepText
? goalPulse.detail
: null,
};
}
const journeyCopy = buildJourneyTaskCardCopy({
beatType: journeyBeat?.beatType,
sceneName,
fallbackCondition: stepGoal.nextStepText,
});
return {
eyebrow: goalPulse?.title ?? '当前任务',
title: journeyCopy.title,
description: journeyCopy.description,
condition: journeyCopy.condition,
progress: journeyCopy.progress,
pulseNote:
goalPulse?.detail && goalPulse.detail !== journeyCopy.description && goalPulse.detail !== journeyCopy.condition
? goalPulse.detail
: null,
};
}
function getQuestSceneName(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return '当前区域';
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestHostileNpcName(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.objective.targetHostileNpcId) {
return null;
}
return worldType
? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId)?.name
?? quest.objective.targetHostileNpcId
: quest.objective.targetHostileNpcId;
}
function buildQuestConditionText(
quest: QuestLogEntry,
worldType: WorldType | null,
) {
if (isQuestReadyToClaim(quest)) {
return `返回找 ${quest.issuerNpcName} 交付任务并领取奖励。`;
}
if (quest.status === 'turned_in') {
return '任务已经交付。';
}
const activeStep = quest.steps?.find(step => step.id === quest.activeStepId)
?? quest.steps?.find(step => step.progress < step.requiredCount)
?? null;
const objective = activeStep ?? quest.objective;
const sceneName = getQuestSceneName(quest, worldType);
switch (objective.kind) {
case 'defeat_hostile_npc':
return `击败 ${getQuestHostileNpcName(quest, worldType) ?? '指定敌人'}`;
case 'inspect_treasure':
return `${sceneName} 调查宝藏或异常线索。`;
case 'spar_with_npc':
return `${quest.issuerNpcName} 完成一场切磋。`;
case 'talk_to_npc':
return `返回找 ${quest.issuerNpcName} 对话。`;
case 'reach_scene':
return `前往 ${objective.targetSceneId ?? sceneName}`;
case 'deliver_item':
return `把指定物品交给 ${quest.issuerNpcName}`;
default:
return '营地事件';
return activeStep?.revealText ?? quest.summary;
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType'] | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
function getQuestProgressText(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
if (quest.status === 'turned_in') {
return '已交付';
}
const activeStep = quest.steps?.find(step => step.id === quest.activeStepId)
?? quest.steps?.find(step => step.progress < step.requiredCount)
?? null;
const progressSource = activeStep ?? quest;
const requiredCount =
'requiredCount' in progressSource
? progressSource.requiredCount
: quest.objective.requiredCount;
return `${progressSource.progress}/${requiredCount}`;
}
function TaskTemplateCard({
eyebrow,
title,
description,
condition,
progress,
reward,
onRewardItemSelect,
tone = 'default',
}: {
eyebrow: string;
title: string;
description: string;
condition: string;
progress?: string | null;
reward?: QuestLogEntry['reward'] | null;
onRewardItemSelect?: ((itemId: string) => void) | null;
tone?: 'default' | 'main';
}) {
if (!title.trim() && !description.trim() && !condition.trim()) {
return null;
}
return (
<div
className={`rounded-2xl border px-4 py-3.5 ${
tone === 'main'
? 'border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(245,158,11,0.13),transparent_65%),rgba(0,0,0,0.24)]'
: 'border-white/8 bg-black/20'
}`}
>
<div className="min-w-0">
<div className={`text-[10px] tracking-[0.24em] ${tone === 'main' ? 'text-amber-200/80' : 'text-zinc-500'}`}>
{eyebrow}
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(title)}
</div>
</div>
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{description}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{condition}
</div>
{progress ? (
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{progress}
</div>
) : null}
{reward ? (
<QuestRewardIconStrip
reward={reward}
onSelectItem={onRewardItemSelect ?? undefined}
/>
) : null}
</div>
);
}
function QuestRewardIconStrip({
reward,
onSelectItem,
}: {
reward: QuestLogEntry['reward'];
onSelectItem?: (itemId: string) => void;
}) {
const hasItems = reward.items.length > 0;
return (
<div className="mt-3 rounded-xl border border-amber-300/10 bg-black/18 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="text-[10px] tracking-[0.2em] text-amber-200/75">
</div>
<div className="text-[10px] text-zinc-500">
+{reward.affinityBonus} · {reward.currency}
</div>
</div>
{hasItems ? (
<div className="mt-2 flex flex-wrap gap-2">
{reward.items.map(item => (
<button
key={item.id}
type="button"
onClick={() => onSelectItem?.(item.id)}
className="group relative flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/30 transition hover:border-amber-200/30"
title={item.name}
aria-label={`查看任务奖励 ${item.name}`}
>
<PixelIcon
src={getQuestRewardItemIcon(item)}
alt={item.name}
className="h-6 w-6"
/>
<span className="absolute -bottom-1 -right-1 rounded-full border border-black/40 bg-black/75 px-1 text-[9px] text-white">
{item.quantity}
</span>
</button>
))}
</div>
) : (
<div className="mt-2 text-[11px] text-zinc-500">
</div>
)}
</div>
);
}
function GoalFocusCard({
goalStack,
goalPulse,
journeyBeat,
sceneName,
}: {
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
journeyBeat: JourneyBeat | null;
sceneName: string;
}) {
const cardCopy = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName,
});
const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!cardCopy || primaryGoal?.sourceKind !== 'quest') {
return null;
}
return (
<div className="space-y-4">
{cardCopy.pulseNote ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-[11px] leading-relaxed text-zinc-300">
{cardCopy.pulseNote}
</div>
) : null}
<TaskTemplateCard
eyebrow={cardCopy.eyebrow}
title={cardCopy.title}
description={cardCopy.description}
condition={cardCopy.condition}
progress={cardCopy.progress}
tone="main"
/>
{(
(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
|| (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
) ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
? `地点:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint}`
: null}
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
&& (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
? ' · '
: null}
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
? `相关人物:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint}`
: null}
</div>
) : null}
</div>
);
}
function getQuestRewardItemIcon(item: InventoryItem) {
if (item.iconSrc) return item.iconSrc;
if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png';
if (item.tags.includes('armor')) return '/UI/Icon_Eq_Chest.png';
if (item.tags.includes('relic')) return '/Icons/47_treasure.png';
if (item.tags.includes('healing')) return '/Icons/12_potion.png';
if (item.tags.includes('mana')) return '/UI/Hud_icon_magic.png';
if (item.tags.includes('material')) return '/Icons/45_crystal.png';
return getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
@@ -197,21 +526,7 @@ function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
}
function buildRewardItemDescription(item: InventoryItem) {
if (item.description?.trim()) return item.description;
const traits: string[] = [];
if (item.tags.includes('healing')) traits.push('在冒险中恢复生命值');
if (item.tags.includes('mana')) traits.push('恢复法力值或技能节奏');
if (item.tags.includes('weapon')) traits.push('适合进攻型构筑');
if (item.tags.includes('armor')) traits.push('适合防御型构筑');
if (item.tags.includes('relic')) traits.push('作为稀有遗物奖励');
if (item.tags.includes('material')) traits.push('可用于制作');
if (traits.length === 0) {
return `${item.name}${item.category} 奖励物品,可用于后续路线规划、交易或构筑规划。`;
}
return `${item.name}${item.category} 奖励物品,${traits.join('')}`;
return buildInventoryItemDescription(item);
}
function getQuestObjectivePresentation(quest: QuestLogEntry, worldType: WorldType | null, sceneName: string) {
@@ -437,7 +752,7 @@ function QuestObjectiveCard({
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-zinc-100">
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-medium text-white">{presentation.primaryLabel}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-zinc-100">
@@ -481,8 +796,8 @@ export function AdventurePanelOverlays({
onMusicVolumeChange,
onSaveAndExit,
saveAndExitDisabled,
isChapterPanelOpen,
setIsChapterPanelOpen,
isGoalPanelOpen,
setIsGoalPanelOpen,
isQuestPanelOpen,
setIsQuestPanelOpen,
isSettingsPanelOpen,
@@ -491,15 +806,17 @@ export function AdventurePanelOverlays({
setIsStatsPanelOpen,
chapterState,
journeyBeat,
recentChronicleSummary,
currentCampEvent,
setpieceDirective,
goalStack,
goalPulse,
onDismissGoalPulse,
selectedQuest,
setSelectedQuestId,
completionNoticeQuest,
setCompletionNoticeQuestId,
rewardQuest,
setRewardQuestId,
rewardQuestHandoff,
setRewardQuestHandoff,
selectedRewardItemQuestId,
setSelectedRewardItemQuestId,
selectedRewardItemId,
@@ -513,117 +830,90 @@ export function AdventurePanelOverlays({
getQuestStatusLabel,
}: AdventurePanelOverlaysProps) {
const battleReward = battleRewardUi.reward;
const sortedQuests = sortQuestsForGoalPanel(quests, goalStack);
const activeGoalQuest =
goalStack.activeGoal?.sourceKind === 'quest'
? quests.find(quest => quest.id === goalStack.activeGoal?.sourceId) ?? null
: null;
const shouldShowQuestUpdateModal = Boolean(activeGoalQuest && isGoalPanelOpen);
const selectQuestRewardItem = (quest: QuestLogEntry, itemId: string) => {
setSelectedBattleRewardItemId(null);
setSelectedRewardItemQuestId(quest.id);
setSelectedRewardItemId(itemId);
};
const closeGoalPanel = () => {
setIsGoalPanelOpen(false);
onDismissGoalPulse();
};
return (
<>
<AnimatePresence>
{isChapterPanelOpen && (
{shouldShowQuestUpdateModal && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsChapterPanelOpen(false)}
className="fixed inset-0 z-[77] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={closeGoalPanel}
>
<motion.div
initial={{opacity: 0, scale: 0.96, y: 8}}
animate={{opacity: 1, scale: 1, y: 0}}
exit={{opacity: 0, scale: 0.96, y: 8}}
transition={{duration: 0.18, ease: 'easeOut'}}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(86vh,40rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(84vh,34rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
{chapterState?.title ?? '当前章节'}
{goalPulse ? '任务更新' : '当前任务'}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
onClick={() => setIsChapterPanelOpen(false)}
onClick={closeGoalPanel}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{chapterState?.title ?? '旅程推进中'}
</div>
{chapterState && (
<div className="mt-2 inline-flex rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] text-zinc-300">
{getChapterStageLabel(chapterState.stage)}
</div>
)}
{chapterState?.chapterSummary && (
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{chapterState.chapterSummary}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-hide">
<GoalFocusCard
goalStack={goalStack}
goalPulse={goalPulse}
journeyBeat={journeyBeat}
sceneName={statistics.currentSceneName}
/>
</div>
{journeyBeat && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getJourneyBeatLabel(journeyBeat.beatType)} · {journeyBeat.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{journeyBeat.emotionalGoal}
</div>
</div>
)}
{currentCampEvent && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getCampEventLabel(currentCampEvent.eventType)} · {currentCampEvent.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{currentCampEvent.triggerReason}
</div>
</div>
)}
{setpieceDirective && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getSetpieceLabel(setpieceDirective.setpieceType)} · {setpieceDirective.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{setpieceDirective.dramaticQuestion}
</div>
</div>
)}
{recentChronicleSummary && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-zinc-300">
{recentChronicleSummary}
</div>
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-white/10 px-4 py-3 sm:px-5">
{(goalStack.activeGoal?.sourceKind === 'quest' || goalStack.immediateStepGoal?.sourceKind === 'quest') ? (
<button
type="button"
onClick={() => {
closeGoalPanel();
setIsQuestPanelOpen(true);
}}
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
>
</button>
) : null}
<button
type="button"
onClick={closeGoalPanel}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 8})}
>
</button>
</div>
</motion.div>
</motion.div>
@@ -845,20 +1135,53 @@ export function AdventurePanelOverlays({
</div>
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
{(() => {
if (!activeGoalQuest) {
return null;
}
const currentTaskCard = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName: statistics.currentSceneName,
});
if (!currentTaskCard) {
return null;
}
return (
<TaskTemplateCard
eyebrow={currentTaskCard.eyebrow}
title={currentTaskCard.title}
description={currentTaskCard.description}
condition={currentTaskCard.condition}
progress={currentTaskCard.progress}
reward={activeGoalQuest?.reward ?? null}
onRewardItemSelect={
activeGoalQuest
? itemId => selectQuestRewardItem(activeGoalQuest, itemId)
: null
}
tone="main"
/>
);
})()}
{quests.length > 0 ? (
<div className="space-y-2">
{quests.map(quest => (
<div className={`${activeGoalQuest ? 'mt-3' : ''} space-y-2`}>
{sortedQuests.map(quest => (
<button
key={quest.id}
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2.5 text-left transition hover:border-white/15"
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition hover:border-white/15"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{quest.title}</div>
<div className="mt-1 text-[11px] text-zinc-500">{quest.issuerNpcName}</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{quest.summary}</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
{goalStack.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id
? '当前主任务'
: '任务'}
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
@@ -870,6 +1193,25 @@ export function AdventurePanelOverlays({
{getQuestStatusLabel(quest.status)}
</span>
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(quest.title)}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{quest.description || quest.narrativeBinding?.playerHook || quest.summary}
</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{buildQuestConditionText(quest, worldType)}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{quest.issuerNpcName}
{` · 任务进度:${getQuestProgressText(quest)}`}
</div>
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
/>
</button>
))}
</div>
@@ -917,10 +1259,16 @@ export function AdventurePanelOverlays({
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide sm:p-5">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{selectedQuest.description}</div>
</div>
<TaskTemplateCard
eyebrow="任务详情"
title={selectedQuest.title}
description={selectedQuest.description || selectedQuest.narrativeBinding?.playerHook || selectedQuest.summary}
condition={buildQuestConditionText(selectedQuest, worldType)}
progress={getQuestProgressText(selectedQuest)}
reward={selectedQuest.reward}
onRewardItemSelect={itemId => selectQuestRewardItem(selectedQuest, itemId)}
tone="main"
/>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<QuestObjectiveCard
@@ -950,6 +1298,7 @@ export function AdventurePanelOverlays({
if (!claimed) return;
setSelectedBattleRewardItemId(null);
setRewardQuestId(selectedQuest.id);
setRewardQuestHandoff(claimed.handoff);
setSelectedRewardItemQuestId(selectedQuest.id);
setSelectedRewardItemId(selectedQuest.reward.items[0]?.id ?? null);
}}
@@ -992,7 +1341,9 @@ export function AdventurePanelOverlays({
<div className="text-lg font-semibold text-white"></div>
<div className="text-sm text-zinc-300">{completionNoticeQuest.title}</div>
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-sm text-emerald-50">
{goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal.nextStepText
: '可前往任务日志领取奖励。'}
</div>
<div className="flex justify-center">
<button
@@ -1023,6 +1374,7 @@ export function AdventurePanelOverlays({
className="fixed inset-0 z-[71] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
@@ -1045,6 +1397,7 @@ export function AdventurePanelOverlays({
type="button"
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
@@ -1055,6 +1408,19 @@ export function AdventurePanelOverlays({
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{rewardQuestHandoff ? (
<div className="mb-4 rounded-2xl border border-violet-300/15 bg-[radial-gradient(circle_at_top,rgba(139,92,246,0.14),transparent_65%),rgba(0,0,0,0.24)] p-3">
<div className="text-[10px] tracking-[0.24em] text-violet-200/80">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{rewardQuestHandoff.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{rewardQuestHandoff.detail}
</div>
</div>
) : null}
<QuestRewardGrid
quest={rewardQuest}
worldType={worldType}
@@ -1223,7 +1589,7 @@ export function AdventurePanelOverlays({
)}
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-xs text-zinc-300">
: {selectedRewardItem.tags.join(' / ') || '无'}
: {getInventoryTagLabels(selectedRewardItem.tags).join(' / ') || '无'}
</div>
</div>
</motion.div>

View File

@@ -12,7 +12,6 @@ import {
type ScenePresetInfo,
type WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
@@ -31,7 +30,6 @@ import {
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
ROLE_CHARACTER_FRAME_CLASS,
ROLE_CHARACTER_SPRITE_CLASS,
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
@@ -166,19 +164,11 @@ export function GameCanvasEntityLayer({
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<div
className="h-full w-full"
style={{
transform:
(sceneTransitionPhase === 'idle' ? companion.facing : 'right') === 'left'
? 'scaleX(-1)'
: undefined,
}}
>
<CharacterAnimator
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
<RoleCharacterSprite
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
character={companion.character}
className={`${ROLE_CHARACTER_SPRITE_CLASS} ${companion.hp <= 0 ? 'opacity-45 grayscale' : ''}`}
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
/>
</div>
</div>
@@ -220,13 +210,13 @@ export function GameCanvasEntityLayer({
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
className="relative block"
>
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
<div className="relative">
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{playerCharacter && (
<CharacterAnimator
<RoleCharacterSprite
state={effectivePlayerAnimationState}
character={playerCharacter}
className={ROLE_CHARACTER_SPRITE_CLASS}
facing={effectivePlayerFacing}
/>
)}
</div>
@@ -248,15 +238,18 @@ export function GameCanvasEntityLayer({
if (!npcEncounter) return null;
const config = monsters.find(item => item.id === hostileNpc.id);
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
const npcMonsterConfig = npcEncounter?.monsterPresetId
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcMonsterConfig = !npcCharacter && npcEncounter?.monsterPresetId
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
: null;
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcSceneSpriteFacing =
npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
const npcCombatHpTop = getNpcCombatHpTop(
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const hostileNpcBottomOffsetPx = npcMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
@@ -350,7 +343,7 @@ export function GameCanvasEntityLayer({
encounter.kind !== 'treasure' && encounter.characterId
? getCharacterById(encounter.characterId)
: null;
const peacefulMonsterConfig =
const peacefulMonsterConfig = !peacefulResolvedCharacter &&
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;

View File

@@ -2,7 +2,6 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {getWorldCampScenePreset} from '../../data/scenePresets';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
@@ -51,8 +50,6 @@ export function GameCanvasRuntime({
const resolvedWorldType = worldType ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
const backgroundSrc = currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const campSceneId = worldType ? getWorldCampScenePreset(worldType)?.id ?? null : null;
const showOpeningCampOverlay = Boolean(!inBattle && currentScenePreset?.id && currentScenePreset.id === campSceneId);
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';
const stageLiftPx = 68;
@@ -154,7 +151,6 @@ export function GameCanvasRuntime({
backgroundSrc={backgroundSrc}
currentScenePreset={currentScenePreset}
resolvedWorldType={resolvedWorldType}
showOpeningCampOverlay={showOpeningCampOverlay}
sceneTitleSpinToken={sceneTitleSpinToken}
onSceneNameClick={onSceneNameClick}
onBackgroundLoadError={() => setBackgroundLoadFailed(true)}

View File

@@ -3,17 +3,13 @@ import {AnimatePresence, motion} from 'motion/react';
import {type ScenePresetInfo, WorldType} from '../../types';
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {PixelIcon} from '../PixelIcon';
import {
OPENING_CAMP_OVERLAY_SRC,
SCENE_TITLE_GEAR_FILTER,
} from './GameCanvasShared';
import { SCENE_TITLE_GEAR_FILTER } from './GameCanvasShared';
interface GameCanvasSceneLayerProps {
backgroundLoadFailed: boolean;
backgroundSrc: string;
currentScenePreset: ScenePresetInfo | null;
resolvedWorldType: WorldType | null;
showOpeningCampOverlay: boolean;
sceneTitleSpinToken: number;
onSceneNameClick?: (() => void) | null;
onBackgroundLoadError: () => void;
@@ -24,7 +20,6 @@ export function GameCanvasSceneLayer({
backgroundSrc,
currentScenePreset,
resolvedWorldType,
showOpeningCampOverlay,
sceneTitleSpinToken,
onSceneNameClick = null,
onBackgroundLoadError,
@@ -55,19 +50,6 @@ export function GameCanvasSceneLayer({
<div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-overlay [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.14),transparent_20%),radial-gradient(circle_at_80%_30%,rgba(255,255,255,0.08),transparent_18%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.06),transparent_22%)]" />
{showOpeningCampOverlay && (
<img
src={OPENING_CAMP_OVERLAY_SRC}
alt=""
aria-hidden="true"
className="pointer-events-none absolute bottom-[9%] left-1/2 z-[1] w-[min(92%,980px)] -translate-x-1/2 object-contain opacity-95"
style={{
imageRendering: 'pixelated',
filter: 'drop-shadow(0 12px 30px rgba(0, 0, 0, 0.42))',
}}
/>
)}
{currentScenePreset && (
<div className="absolute left-1/2 top-3 z-20 -translate-x-1/2">
<motion.div

View File

@@ -2,6 +2,7 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {buildMedievalNpcVisualFromCustomWorldVisual} from '../../data/medievalNpcVisuals';
import {
AnimationState,
Character,
@@ -14,6 +15,7 @@ import {
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
export type GameCanvasEntitySelection =
| {kind: 'player'}
@@ -66,7 +68,6 @@ export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
export const CHAT_BUBBLE_FRAME_COUNT = 12;
@@ -219,6 +220,17 @@ export function RoleCharacterSprite({
state: AnimationState;
facing: 'left' | 'right';
}) {
if (character.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
className="origin-bottom"
scale={1.36}
facing={facing}
/>
);
}
return (
<div className="h-full w-full" style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}>
<CharacterAnimator

View File

@@ -4,6 +4,7 @@ import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
@@ -62,6 +63,7 @@ export function GameShellMainContent({
inventoryUi,
battleRewardUi,
questUi,
goalUi,
companionRenderStates,
characterChatSummaries,
openOverlayPanel,
@@ -98,6 +100,7 @@ export function GameShellMainContent({
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
@@ -171,6 +174,7 @@ export function GameShellMainContent({
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}

View File

@@ -33,6 +33,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
inventoryUi,
battleRewardUi,
questUi,
goalUi,
} = story;
const {
hasSavedGame,
@@ -43,7 +44,12 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {companionRenderStates, onBenchCompanion, onActivateRosterCompanion} = companions;
const {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
@@ -119,13 +125,18 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
[gameState.characterChats],
);
const visibleCompanionRenderStates = useMemo(
() => buildCompanionRenderStates(visibleGameState),
[buildCompanionRenderStates, visibleGameState],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return companionRenderStates;
return companionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [companionRenderStates, visibleGameState.currentEncounter]);
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
@@ -229,6 +240,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}

View File

@@ -4,6 +4,7 @@ import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
@@ -69,6 +70,7 @@ export function GameShellStoryPanels({
inventoryUi,
battleRewardUi,
questUi,
goalUi,
companionRenderStates,
characterChatSummaries,
openOverlayPanel,
@@ -94,6 +96,7 @@ export function GameShellStoryPanels({
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
@@ -199,6 +202,9 @@ export function GameShellStoryPanels({
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
goalStack={goalUi.goalStack}
goalPulse={goalUi.pulse}
onDismissGoalPulse={goalUi.dismissPulse}
battleRewardUi={battleRewardUi}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
@@ -211,15 +217,6 @@ export function GameShellStoryPanels({
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}

Some files were not shown because too many files have changed in this diff Show More