Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -21,7 +21,7 @@ DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"
# Optional model name for custom-world scene image generation.
DASHSCOPE_IMAGE_MODEL="wan2.2-t2i-flash"
DASHSCOPE_IMAGE_MODEL="wan2.7-image"
# Optional model names for character asset studio.
DASHSCOPE_CHARACTER_VISUAL_MODEL="wan2.7-image-pro"

View File

@@ -0,0 +1,660 @@
# 自定义世界自有设定层优化方案
更新时间:`2026-04-08`
## 0. 目标
这份文档要解决的问题是:
**当前自定义世界虽然已经是唯一正式可玩的世界入口,但它底层仍依赖武侠 / 仙侠模板设定。**
本次优化目标不是直接删除这些依赖,而是把它们逐步改造成:
**属于自定义世界自身的设定层,并且这套设定层必须能通用于任何题材。**
同时必须满足一条底线:
**不能破坏当前自定义世界生成流程的任何可用功能。**
一句话定义:
**让自定义世界从“借模板世界运行”,升级成“拥有自有设定层、可跨题材运行”的架构。**
---
## 1. 设计原则
这次优化必须同时遵守 4 条原则:
1. 设定归自定义世界自身所有
- 运行时、生成期、表现层真正依赖的世界设定,应该落回 `CustomWorldProfile` 或由它直接编译出的配置。
2. 设定必须跨题材
- 新设定不能只是把“武侠 / 仙侠”换一个更抽象的名字。
- 它必须能支撑奇幻、科幻、悬疑、校园、末世、神话、现代、海洋、裂界等任何题材。
3. 优化优先做兼容迁移
- 不能先删旧字段,再补新结构。
- 必须先补新设定层,再逐步迁读,最后再让旧模板字段退化成兼容层。
4. 不能增加创作者负担
- 这次不是让创作者多填一堆底层 schema。
- 这些设定仍然应由 AI / 系统编译出来,只是所有权从模板世界转移到自定义世界自己。
---
## 2. 当前自定义世界实际依赖了什么
根据 [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](../reference/CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md),当前依赖可以归纳成 6 组:
1. 模板锚点字段
- `templateWorldType`
- `WorldTemplateType`
2. 规则桥接
- `resolveRuleWorldType(...)`
3. 主题与词汇底板
- `detectCustomWorldThemeMode(...)`
- `buildThemePackFromWorldProfile(...)`
4. 属性、资源词与经济 fallback
- 预设属性 schema
- 资源命名
- 初始货币规则
5. 内容骨架
- 角色模板骨架
- 怪物模板池
- 场景视觉参考池
6. prompt 兼容字段
- `framework` 生成仍要求输出 `templateWorldType`
这些东西现在还不能直接删,因为:
**它们不是单纯的预设世界残留,而是当前自定义世界生成与运行时的真实支撑层。**
---
## 3. 本次优化的核心思路
这次不建议新增很多彼此平行的新系统,而是把现有模板依赖统一收束成:
**自定义世界自己的 5 层设定层。**
这 5 层分别是:
1. 语义锚层
2. 规则层
3. 表现层
4. 原型参考层
5. 兼容迁移层
这样可以让现在分散在:
- 模板世界枚举
- 预设 schema
- 视觉参考池
- 怪物池
- ThemePack 底板
这些地方的依赖,被重新编译回 `CustomWorldProfile` 自己的配置。
---
## 4. 目标结构
## 4.1 语义锚层:替代 `templateWorldType`
当前 `templateWorldType` 实际在回答的不是“你是不是武侠”,而是:
1. 这个世界更接近哪类冲突结构
2. 这个世界更接近哪类制度和禁忌
3. 这个世界更接近哪类叙事载体与力量来源
所以应把它升级成一套真正属于自定义世界自己的语义锚:
```ts
interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictForms: string[];
institutionTypes: string[];
tabooTypes: string[];
carrierTypes: string[];
forceSystemTypes: string[];
atmosphereTags: string[];
}
```
这层应该回答:
1. 这个世界的主要矛盾像什么
2. 这个世界的秩序结构像什么
3. 这个世界的危险和禁忌像什么
4. 这个世界的线索、遗物、文书、证物、技术、仪式像什么
关键点:
- 这里不再出现“武侠 / 仙侠”的模板世界名
- 只保留通用语义
例如:
- `institutionTypes`
- 宗门
- 公司
- 学园
- 教团
- 调查局
- 舰队
- 部族
- `forceSystemTypes`
- 灵脉
- 科技
- 仪式
- 契约
- 血统
- 神谕
- 污染
这就天然跨题材了。
---
## 4.2 规则层:把 fallback 变成自定义世界自己的规则设定
当前很多规则仍然会回落到武侠 / 仙侠:
- 属性 schema
- 资源命名
- 经济命名
- 初始货币
优化后应由自定义世界自己持有:
```ts
interface CustomWorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
};
}
```
这意味着以后运行时读取逻辑应优先变成:
1. 先读 `profile.ruleProfile`
2. 再读 `profile.attributeSchema`
3. 最后才允许读兼容 fallback
而不是:
1. 先判断 `WUXIA / XIANXIA`
2. 再猜自定义世界应该像哪边
这层的原则是:
**规则是这个自定义世界自己的,不再借模板世界托管。**
---
## 4.3 表现层:把 ThemePack 变成自定义世界自身的派生物
当前 `ThemePack` 仍然是“预设底板 + 自定义词汇补丁”。
优化后应改成:
```ts
interface CustomWorldExpressionProfile {
themePack: ThemePack;
presentationTone: string[];
namingDirectives: string[];
clueDirectives: string[];
revealDirectives: string[];
}
```
也就是说:
- `ThemePack` 仍然可以保留
- 但它不再被理解为“武侠底板 / 仙侠底板的延伸”
- 它应当是:
- `semanticAnchor`
- `creatorIntent`
- `majorFactions`
- `coreConflicts`
- `world text`
共同编译出来的结果
这一步非常关键,因为它会决定:
1. 物件命名
2. 势力命名
3. 线索形式
4. 提示词词汇风格
5. reveal 风格
只有把这层做成自定义世界自己的派生物,后面跨题材才会真的稳。
---
## 4.4 原型参考层:把模板骨架改成通用原型库
当前自定义世界借用模板的最深部分是:
1. 角色模板骨架
2. 场景视觉参考池
3. 怪物模板池
这三类不能粗暴删除,但可以改造成:
**通用原型参考层。**
建议统一成:
```ts
interface CustomWorldReferenceProfile {
roleArchetypes: RoleArchetypeProfile[];
sceneBuckets: SceneArchetypeBucket[];
creatureArchetypes: CreatureArchetypeProfile[];
}
```
### 4.4.1 角色原型
角色原型应描述的是:
- 正面推进型
- 远程压制型
- 控场解构型
- 续航承压型
- 潜行爆发型
而不是:
- 剑之公主
- 神箭游侠
- 双刃旅者
也就是说:
**保留战斗骨架和技能骨架,不保留模板角色人格设定。**
### 4.4.2 场景原型
场景原型应描述的是:
- 高压入口区
- 雨湿街巷区
- 临水渡口区
- 仪式神殿区
- 高空通路区
- 工业热区
- 地底遗迹区
- 群落聚居区
而不是:
- 竹林古道
- 云海仙门
也就是说:
**保留“空间语义 -> 视觉参考”的逻辑,不保留模板世界场景名作为中心。**
### 4.4.3 生物原型
生物原型应描述的是:
- 潜伏袭击者
- 重甲承压者
- 群居骚扰者
- 远程威胁者
- 异化污染体
- 灵体回响体
- 机关守卫体
而不是:
- 某个武侠怪
- 某个仙侠怪
这样之后:
- 自定义世界依赖的是“通用原型”
- 原型再映射到底层素材与 preset
---
## 4.5 兼容迁移层:旧字段继续保留一段时间
为了不破坏当前流程,短期内不能直接删除:
- `templateWorldType`
- `WorldTemplateType`
- 以及相关 normalize / save / read 逻辑
这层应被降级成:
```ts
interface CustomWorldCompatibilityProfile {
legacyTemplateWorldType?: 'WUXIA' | 'XIANXIA' | null;
migrationVersion: string;
}
```
作用:
1. 旧存档兼容
2. 旧 prompt 兼容
3. 旧测试兼容
4. 旧工具链兼容
但它不再应是新生成世界的第一真相来源。
---
## 5. 现有每类依赖如何改造成自定义世界自己的设定
下面把现有依赖逐项映射成未来目标。
## 5.1 `templateWorldType`
当前作用:
- 世界锚点
- 规则 fallback
- 视觉和怪物参考入口
目标改造:
- 拆成:
- `semanticAnchor`
- `ruleProfile`
- `referenceProfile`
- `compatibilityProfile`
迁移方式:
1. 旧字段保留
2. 新字段生成后优先读新字段
3. 旧字段只做迁移 fallback
## 5.2 `resolveRuleWorldType(...)`
当前作用:
-`CUSTOM` 解析回 `WUXIA / XIANXIA`
目标改造:
- 改成:
```ts
resolveCustomWorldRuleProfile(profile)
```
运行时不再需要知道“它更像武侠还是仙侠”,而只需要知道:
- 这个世界的规则 profile 是什么
## 5.3 `getPresetWorldAttributeSchema(...)`
当前作用:
- 自定义世界 schema 的参考底板
目标改造:
- 把预设 schema 底板重构成:
- 通用 schema seeds
例如按功能分:
1. 承压轴
2. 机动轴
3. 洞察轴
4. 决断轴
5. 共鸣轴
6. 续航轴
然后由自定义世界的 `semanticAnchor + creatorIntent` 生成最终命名与说明。
## 5.4 `PRESET_CHARACTERS`
当前作用:
- 自定义世界角色的战斗模板和技能骨架
目标改造:
- 抽出:
- `RoleArchetypeProfile`
- `SkillArchetypeProfile`
也就是说:
- 还可以继续复用当前角色的战斗结构
- 但不应再让自定义世界依赖那些模板角色的人设文本
## 5.5 场景图片参考池
当前作用:
- 自定义世界默认场景图匹配
目标改造:
- 改成:
- `SceneArchetypeBucket`
每个 bucket 只表达通用空间语义,不表达模板世界名。
## 5.6 怪物池
当前作用:
- 自定义世界敌对单位匹配 preset
目标改造:
- 改成:
- `CreatureArchetypeProfile`
由 archetype 再映射到底层怪物素材与 preset。
## 5.7 `buildThemePackFromWorldProfile(...)`
当前作用:
- 以模板题材包为底生成自定义世界 ThemePack
目标改造:
- 变成:
- `buildThemePackFromCustomWorldSemanticAnchor(...)`
即 ThemePack 以自定义世界自己的语义锚和词汇锚为底。
---
## 6. 不破坏当前流程的迁移顺序
这是最关键的落地顺序。
## 阶段 A先给 `CustomWorldProfile` 补新设定层
先补:
1. `semanticAnchor`
2. `ruleProfile`
3. `expressionProfile`
4. `referenceProfile`
5. `compatibilityProfile`
这一步只做新增,不删旧字段。
## 阶段 B旧字段自动编译新字段
当前已有 profile、旧存档、旧生成结果先统一经过
```ts
compileOwnedSettingLayersFromLegacyTemplate(profile)
```
让:
- 旧世界也拥有新设定层
- 新运行时可优先消费新字段
## 阶段 C生成链开始直接产出新设定层
修改:
- `framework prompt`
- `normalizeCustomWorldGenerationFramework(...)`
- `customWorld.ts`
使新生成世界优先产出:
- `semanticAnchor`
- `ruleProfile hints`
- `referenceProfile hints`
而不是只产出 `templateWorldType`
## 阶段 D运行时逐步改读新设定层
优先改:
1. 资源词与货币
2. attribute schema
3. ThemePack
4. 视觉参考
5. 怪物映射
6. 角色原型引用
要求:
- 每次只切一层
- 每层都保留 fallback
## 阶段 E把旧模板字段降级为兼容层
当上面的读取已经都切走后:
- `templateWorldType` 就不再是主链依赖
- 只作为:
- migration
- 老存档兼容
- 老工具兼容
---
## 7. 推荐新增的最小字段
为了避免系统膨胀,建议只先补最小集合。
## 7.1 `CustomWorldOwnedSettingLayers`
```ts
interface CustomWorldOwnedSettingLayers {
semanticAnchor: CustomWorldSemanticAnchor;
ruleProfile: CustomWorldRuleProfile;
expressionProfile: CustomWorldExpressionProfile;
referenceProfile: CustomWorldReferenceProfile;
compatibilityProfile?: CustomWorldCompatibilityProfile | null;
}
```
然后挂到:
```ts
interface CustomWorldProfile {
ownedSettingLayers?: CustomWorldOwnedSettingLayers | null;
}
```
这样好处是:
1. 不用在 `CustomWorldProfile` 顶层堆太多字段
2. 迁移更集中
3. 后续删除兼容层更容易
---
## 8. 对当前功能链的保护要求
这次优化过程中,下面这些能力不能坏:
1. 自定义世界创建
2. 自定义世界保存 / 读取
3. 自定义世界角色生成
4. 自定义世界场景生成
5. 自定义世界开局
6. 自定义世界运行时的:
- 属性
- 资源词
- 经济
- 场景图
- 敌对实体
- ThemePack
- prompt 组织
也就是说:
**任何一次迭代都必须是“新层可用 + 旧层仍可兜底”。**
---
## 9. 验收标准
当下面这些标准成立时,说明这套优化开始有效:
1. 新生成的自定义世界不再必须依赖 `templateWorldType` 才能表达自身设定。
2. 运行时优先读取 `ownedSettingLayers`,而不是先问武侠 / 仙侠。
3. 自定义世界的属性、资源词、经济、视觉参考、怪物映射、ThemePack 都能从自身设定层派生出来。
4. 新设定层描述的是通用语义,不是模板世界换皮。
5. 当前自定义世界生成流程、旧存档、旧结果页工作台仍然可用。
---
## 10. 最后结论
如果目标是:
**让这些依赖都变成属于自定义世界的设定,而且这些设定要通用于任何题材。**
那么最正确的方向不是“继续弱化武侠 / 仙侠字样”,而是:
**把模板支撑层整体迁移成自定义世界自己的设定层。**
更具体地说,就是把当前依赖重组为:
1. 自定义世界自己的语义锚
2. 自定义世界自己的规则 profile
3. 自定义世界自己的表达 profile
4. 自定义世界自己的原型参考 profile
5. 只负责兼容的旧模板字段
这样之后,自定义世界才会真正从:
**模板依赖型生成架构**
迁移成:
**跨题材、自有设定层、且兼容当前流程的生成架构。**

View File

@@ -0,0 +1,656 @@
# 自定义世界去模板依赖与跨题材通用化优化设计
更新时间:`2026-04-08`
## 0. 目标
这份文档解决的是一个已经明确暴露出来的问题:
**当前玩家主流程虽然已经移除了武侠 / 仙侠两个预设世界,但自定义世界底层仍然依赖武侠 / 仙侠模板设定。**
本次优化的目标不是简单“删掉模板字段”,而是要在不破坏当前自定义世界生成流程的前提下,把这些依赖逐步改造成:
**属于自定义世界自身、且能通用于任何题材的设定层。**
一句话定义:
**让自定义世界从“挂靠武侠 / 仙侠模板运行”,升级成“基于通用世界设定层独立运行”。**
---
## 1. 优化原则
这次优化必须同时满足 3 条硬原则:
1. 设定归自定义世界自身所有
- 任何运行时、生成期、表现层真正依赖的设定,都应尽量落回 `CustomWorldProfile` 或由它直接编译出的通用配置,不再默认挂在 `WUXIA / XIANXIA` 两个模板世界上。
2. 设定必须跨题材通用
- 不能把“自定义世界去模板化”理解成“再做一套更抽象的武侠 / 仙侠替代字段”。
- 新结构必须能容纳奇幻、科幻、悬疑、末世、现代、神话、校园等任何题材。
3. 不能破坏当前自定义世界生成流程
- 现有 `framework -> themePack -> storyGraph -> role / landmark -> runtime` 主链必须继续能跑。
- 优化应以兼容迁移为主,而不是大爆破式重写。
---
## 2. 当前问题归纳
根据 [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](../reference/CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md),当前自定义世界仍依赖模板层的地方主要有:
1. 模板锚点类型
- `templateWorldType`
- `WorldTemplateType`
- `resolveRuleWorldType(...)`
2. 主题与规则回退
- `detectCustomWorldThemeMode(...)`
- `resolveCustomWorldAnchorWorldType(...)`
- `buildThemePackFromWorldProfile(...)` 底板
3. 属性与表现
- 预设世界属性 schema
- 资源词、数值命名、货币命名
4. 角色骨架
- `PRESET_CHARACTERS`
- 模板技能定义
- 模板 opening 接口
5. 场景与视觉参考
- 武侠 / 仙侠场景图片参考池
- 模板 camp 场景映射
6. 怪物模板池
- 武侠 / 仙侠怪物 preset 池
这些依赖本质上说明:
**当前自定义世界不是完全自足,而是“先生成自定义内容,再把它映射回两套模板世界骨架”。**
---
## 3. 这次优化不应该怎么做
先明确几个错误方向:
## 3.1 不能直接删掉 `templateWorldType`
如果直接删除:
- `templateWorldType`
- `WorldTemplateType`
- `WUXIA / XIANXIA` 相关回退
而不先补新的通用设定层,那么当前自定义世界会立刻失去:
1. 规则桥接
2. 主题底板
3. 场景图参考
4. 怪物池匹配
5. 属性 schema fallback
这会直接打断现有生成和运行链路。
## 3.2 不能把新设定继续写成“武侠 / 仙侠的抽象别名”
例如下面这种思路是不够的:
-`templateWorldType` 改名成 `worldFamily`
- 但值仍然只有两类近似武侠 / 仙侠的内部枚举
这不是真正跨题材,只是换了名字。
## 3.3 不能让创作者承担更多底层配置工作
这次优化不是让创作者额外填写:
- 怪物模板表
- 场景参考池
- 属性 schema 槽位
- 规则 profile
正确方向应该是:
**这些通用设定仍由系统生成 / 编译,但所有权回到自定义世界自身。**
---
## 4. 目标结构:把模板依赖改造成自定义世界自己的 4 层通用设定
为了避免系统越改越散,这次建议不要新增很多彼此平行的新系统,而是把现有模板依赖统一收束成 4 层通用设定。
## 4.1 第一层:世界语义锚层
这是替代 `templateWorldType` 的核心层。
它不再回答“你更像武侠还是仙侠”,而回答:
1. 这个世界的主要冲突形式是什么
2. 这个世界的制度、禁忌、叙事载体、力量来源是什么
3. 这个世界更接近哪类表现模式
建议由自定义世界自己持有一个通用结构,例如:
```ts
interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictModel: string[];
institutionHints: string[];
tabooHints: string[];
carrierHints: string[];
forceSystemHints: string[];
}
```
说明:
- 它是自定义世界自己的语义锚。
- 它可以表示:
- 宗门与灵脉
- 财团与实验体
- 学园与旧规
- 边境与裂界
- 海潮与失落群岛
- 神话与誓约
- 不再强制回落到武侠 / 仙侠二选一。
## 4.2 第二层:规则与表现配置层
这是替代:
- `resolveRuleWorldType(...)`
- 预设属性 schema fallback
- 资源命名 fallback
- 经济 fallback
建议改成由自定义世界持有一份通用 `WorldRuleProfile`
```ts
interface WorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
rarityValueScale: Record<string, number>;
};
}
```
关键点:
1. 以后运行时不要再优先问“这是武侠还是仙侠”。
2. 应该直接问:
- `profile.ruleProfile.attributeSchema`
- `profile.ruleProfile.resourceLabels`
- `profile.ruleProfile.economyProfile`
这样:
- 奇幻世界可以叫“体魄 / 法力”
- 末世世界可以叫“生命 / 电量”
- 校园悬疑世界甚至可以弱化“mana”概念改成“专注 / 压力”
## 4.3 第三层:内容骨架与参考层
这是替代:
- 预设角色模板骨架
- 武侠 / 仙侠场景图参考池
- 武侠 / 仙侠怪物 preset 池
这里不建议让自定义世界自己保存大批素材,而是建议让自定义世界持有:
**对内容骨架的“编译后引用配置”。**
建议统一成一个 `ContentReferenceProfile`
```ts
interface ContentReferenceProfile {
roleArchetypes: RoleArchetypeProfile[];
sceneReferenceBuckets: SceneReferenceBucket[];
creatureArchetypes: CreatureArchetypeProfile[];
}
```
其中每个子项都应是通用语义,而不是模板世界名:
1. `RoleArchetypeProfile`
- 例如:
- 正面压制型
- 远程游击型
- 灵术控制型
- 重装承压型
- 潜行刺击型
2. `SceneReferenceBucket`
- 例如:
- 高压门禁区
- 雨夜街巷区
- 高空通道区
- 神殿 / 仪式区
- 工业热区
- 潮湿临水区
3. `CreatureArchetypeProfile`
- 例如:
- 潜伏掠食者
- 重甲承压者
- 群居灵体
- 远程骚扰者
- 寄生污染体
关键点:
- 这些 archetype 可以继续映射到底层素材和 preset。
- 但对自定义世界来说,它依赖的是“通用原型”,不是“武侠怪物池 / 仙侠怪物池”。
## 4.4 第四层:叙事与词汇编译层
这是替代:
- 以模板世界为底的 `ThemePack` fallback
- prompt 中的模板世界兼容字段
建议做法:
1. `ThemePack` 继续保留,但它的来源变成:
- `semanticAnchor + creatorIntent + world text + content reference profile`
2. 生成框架 prompt 不再要求输出:
- `templateWorldType: WUXIA|XIANXIA`
3. 改为要求输出:
- 世界语义锚
- 规则表现关键词
- 冲突和制度线索
例如:
```ts
interface CustomWorldGenerationFramework {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
semanticAnchor: CustomWorldSemanticAnchor;
majorFactions: string[];
coreConflicts: string[];
camp: CampOutline;
}
```
这样:
- 生成流程仍然是 `framework -> themePack -> storyGraph -> role / landmark`
- 只是 framework 的核心锚不再依赖预设世界枚举
---
## 5. 现有依赖如何一一改造
## 5.1 `templateWorldType` -> `semanticAnchor + ruleProfile`
当前用途:
- 表示自定义世界挂靠武侠还是仙侠
目标改造:
- 运行时不再直接读取 `templateWorldType`
- 改为读取:
- `semanticAnchor`
- `ruleProfile`
迁移策略:
1. 先保留 `templateWorldType` 作为兼容字段
2. 新增 `semanticAnchor / ruleProfile`
3. 由旧字段自动编译出新字段
4. 所有读取逻辑逐步切到新字段
5. 最后把 `templateWorldType` 降级为 migration-only 字段
## 5.2 `resolveRuleWorldType(...)` -> `resolveWorldRuleProfile(...)`
当前用途:
-`CUSTOM` 解析回 `WUXIA / XIANXIA`
目标改造:
- 不再返回模板 world type
- 直接返回自定义世界自己的 `ruleProfile`
即:
```ts
resolveWorldRuleProfile(worldType, customWorldProfile)
```
返回:
- `attributeSchema`
- `resourceLabels`
- `economyProfile`
- 其它规则信息
## 5.3 预设属性 schema -> 通用 attribute schema seed
当前用途:
- 用武侠 / 仙侠 schema 作为自定义世界 schema 参考底板
目标改造:
- 不再直接引用“武侠六脉 / 仙侠六轴”作为唯一底板
- 改为维护一组通用 `attribute schema seeds`
例如可按世界体验而不是题材命名:
1. 正面对抗型
2. 机动博弈型
3. 灵知洞察型
4. 共鸣契约型
5. 生存续航型
6. 高危推进型
然后由自定义世界的 `semanticAnchor` 决定如何组合或命名这些槽位。
## 5.4 预设角色模板 -> 通用角色原型骨架
当前用途:
-`PRESET_CHARACTERS` 选模板角色,再覆写自定义世界内容
目标改造:
- 把当前模板角色骨架抽成“通用战斗原型角色”
例如保留的是:
- 技能骨架
- 动作风格
- build 倾向
- 动画资源挂载方式
而不是保留“剑之公主 / 神箭游侠”这样的预设世界人格设定。
关键点:
- 动画素材和技能骨架可以保留
- 但运行时不应再依赖具体模板角色的人设文本
## 5.5 武侠 / 仙侠场景图参考池 -> 通用场景参考桶
当前用途:
- 用武侠 / 仙侠场景关键词为自定义世界匹配默认背景图
目标改造:
- 改成通用 `SceneReferenceBucket`
- 每个 bucket 对应一类空间语义
例如:
1. 落脚处 / 归舍
2. 高压入口
3. 雨湿街巷
4. 高空通路
5. 仪式空间
6. 工业热区
7. 临水空间
8. 地底遗迹
这样就能让:
- 科幻世界
- 校园世界
- 神话世界
- 末世世界
都共享同一套“空间语义 -> 视觉参考”的逻辑。
## 5.6 武侠 / 仙侠怪物池 -> 通用生物原型库
当前用途:
- 从武侠 / 仙侠怪物池里为自定义世界匹配怪物
目标改造:
- 改成通用 `CreatureArchetypeProfile`
- 再由 archetype 去映射底层 preset / 数值 / 动画素材
这样做的好处:
1. 自定义世界依赖的是“潜伏者 / 重压者 / 群居体 / 异化体”
2. 而不是“这更像武侠怪还是仙侠怪”
## 5.7 `ThemePack` 底板 -> 通用语义底板
当前用途:
- 自定义世界的 ThemePack 还是从预设题材包底板开始
目标改造:
- 让 ThemePack 直接从:
- `semanticAnchor`
- `creatorIntent`
- `majorFactions`
- `coreConflicts`
- `contentReferenceProfile`
编译出来
也就是说:
**ThemePack 应变成自定义世界自己的派生物,而不是模板世界的扩写版。**
---
## 6. 不破坏现有生成流程的迁移方案
这是这份文档最重要的部分。
正确做法不是一口气替换,而是兼容迁移。
## 阶段 A新增通用设定字段但不删旧字段
先做:
1.`CustomWorldProfile` 上新增:
- `semanticAnchor`
- `ruleProfile`
- `contentReferenceProfile`
2. 保留:
- `templateWorldType`
3. 由当前旧字段自动编译出新字段
目标:
- 先让新结构出现
- 但现有流程完全不受影响
## 阶段 B生成流程改为优先产出新字段
先改:
- `framework prompt`
- `customWorld.ts`
让 AI 先输出:
- 通用语义锚
- 规则提示
- 通用 archetype 线索
同时在 normalize 层继续兼容旧的 `templateWorldType`
目标:
- 新生成的自定义世界开始“原生带通用设定”
- 旧存档仍可继续读取
## 阶段 C运行时读取切到新设定
依次改:
1. 规则层
-`resolveRuleWorldType` 切到 `resolveWorldRuleProfile`
2. 属性层
- 优先读 `profile.ruleProfile.attributeSchema`
3. 资源词与经济层
- 优先读 `profile.ruleProfile.resourceLabels / economyProfile`
4. 场景图与怪物映射
- 优先读 `contentReferenceProfile`
目标:
- 让模板世界字段不再是运行时第一来源
## 阶段 D模板世界字段退化为兼容层
这一步完成后:
1. `templateWorldType` 只用于:
- 旧存档迁移
- 老测试兼容
- 数据修复 fallback
2. 不再用于:
- 正式生成主链
- 正式运行时主链
## 阶段 E再做深清理
只有当上面几步都完成后,才适合继续清理:
1. 非必要的模板回退逻辑
2. 非必要的主流程模板枚举消费点
3. 非必要的审计 / 工具硬编码
---
## 7. 对当前主链的兼容要求
这次优化过程中,下面这些链路必须始终可用:
1. `PreGameSelectionFlow -> generateCustomWorldProfile(...)`
2. `framework -> themePack -> storyGraph -> role / landmark`
3. 保存 / 读取自定义世界 profile
4. 自定义世界开局
5. 自定义世界角色与场景生成
6. 自定义世界运行时怪物 / 物品 / 场景图 /词汇表现
也就是说:
**任何迁移都必须先补新字段,再迁读,再退旧字段,不能先删旧字段。**
---
## 8. 建议新增或改造的最小数据结构
为了避免系统膨胀,这次不建议引入很多彼此割裂的新系统,建议只在 `CustomWorldProfile` 周边增量补三块。
## 8.1 `semanticAnchor`
```ts
interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictModel: string[];
institutionHints: string[];
tabooHints: string[];
carrierHints: string[];
forceSystemHints: string[];
}
```
## 8.2 `ruleProfile`
```ts
interface WorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
};
}
```
## 8.3 `contentReferenceProfile`
```ts
interface ContentReferenceProfile {
roleArchetypes: string[];
sceneReferenceBuckets: string[];
creatureArchetypes: string[];
}
```
这三块足够作为第一轮去模板化的最小自定义世界设定层。
---
## 9. 验收标准
做到以下几点,才能说明自定义世界真正开始脱离模板依赖:
1. 新生成的自定义世界框架不再需要直接输出 `WUXIA|XIANXIA` 才能工作。
2. 运行时核心逻辑优先读取 `semanticAnchor / ruleProfile / contentReferenceProfile`
3. 自定义世界的属性 schema、资源词、经济词不再默认直接回退到武侠 / 仙侠文案。
4. 自定义世界的角色骨架、场景视觉、怪物映射能通过通用 archetype 表达。
5. 现有生成流程、存档读取、运行时体验不受破坏。
6. 旧存档仍能通过兼容层运行。
---
## 10. 最后结论
当前自定义世界真正缺的,不是“再删一点武侠 / 仙侠字样”,而是:
**把模板世界支撑层改写成自定义世界自己的通用设定层。**
更准确地说,这次优化要完成的是:
1. 把模板锚点改成自定义世界自己的语义锚
2. 把模板回退改成自定义世界自己的规则配置
3. 把模板角色 / 场景 / 怪物参考改成通用原型引用
4. 把 ThemePack 和生成 prompt 从“依附模板世界”改成“直接从自定义世界自身编译”
同时整个过程必须遵守一条底线:
**任何优化都不能破坏当前自定义世界生成与运行主链。**
所以这不是“删模板”的问题,而是一次:
**在兼容现有流程前提下,把自定义世界从模板依赖型架构,迁移成真正跨题材、自足型架构。**

View File

@@ -5,6 +5,8 @@
## 文档列表
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里创作者输入与 AI 分工边界设计。
- [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [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):角色首遇感、关系分层解锁、私聊系统设计。
@@ -15,6 +17,8 @@
- 做物品、Build、锻造相关需求时先看前两份。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。
- 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。
- 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。
- 做角色关系、同伴互动、对话表现时,先看后两份。
- 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。
- 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。

View File

@@ -0,0 +1,398 @@
# 自定义世界依赖的模板设定清单
更新时间:`2026-04-08`
## 0. 这份清单回答什么
这份文档只回答一个问题:
**当前仓库里,自定义世界仍然依赖哪些“模板世界设定”。**
这里的“模板世界设定”不是指玩家还能进入的 `武侠 / 仙侠` 预设世界流程,而是指:
1. 自定义世界在生成时仍借用哪些模板锚点。
2. 自定义世界在运行时仍复用哪些模板 schema、词库、怪物池、角色模板、图片参考池。
3. 哪些引用是“必须保留”,否则会直接伤到自定义世界。
4. 哪些引用只是编辑器 / 审计 / 测试残留,不属于自定义世界主链硬依赖。
一句话结论先说:
**当前主流程虽然已经不再让玩家进入武侠 / 仙侠预设世界,但自定义世界底层仍明确依赖“模板世界锚点层”。**
---
## 1. 依赖总览
自定义世界当前仍依赖的模板设定,主要分成 7 类:
1. 模板世界锚点类型
2. 主题判定与规则回退
3. 属性 schema 与术语体系
4. 角色模板与技能骨架
5. 场景图参考池与营地图像逻辑
6. 怪物 / 敌对实体模板池
7. 叙事 ThemePack 与生成 prompt 兼容字段
除此之外,还有一类是:
8. 编辑器 / 审计 / 测试中的残留模板引用
这类不一定属于自定义世界正式运行时硬依赖,但当前仓库里仍然存在。
---
## 2. 核心硬依赖
## 2.1 模板世界锚点类型
这是最底层、也是目前最不能直接删的部分。
相关文件:
- `src/types/core.ts`
- `src/types/customWorld.ts`
- `src/services/customWorld.ts`
- `src/data/customWorldLibrary.ts`
当前依赖点:
1. `WorldType.WUXIA / WorldType.XIANXIA / WorldType.CUSTOM`
- 自定义世界虽然运行时是 `CUSTOM`,但其模板锚点仍通过 `WUXIA / XIANXIA` 表达。
2. `WorldTemplateType = Exclude<WorldType, WorldType.CUSTOM>`
- 这就是“自定义世界挂靠哪个模板锚”的类型定义。
3. `CustomWorldProfile.templateWorldType`
- 当前自定义世界 profile 里明确保存这个字段。
- 它是兼容字段,但目前仍被多个系统直接消费。
4. `buildCustomWorldFrameworkPrompt(...)`
- 生成框架 prompt 仍要求模型输出:
- `"templateWorldType": "WUXIA|XIANXIA"`
5. `customWorldLibrary.normalizeProfile(...)`
- 本地读取自定义世界时,也会把 `templateWorldType` 归一化到:
- `WUXIA`
- `XIANXIA`
结论:
**如果直接删掉 `WUXIA / XIANXIA / WorldTemplateType / templateWorldType` 这层,自定义世界的生成、存档归一化和多处运行时回退都会直接断。**
---
## 2.2 主题判定与规则回退
相关文件:
- `src/services/customWorldTheme.ts`
- `src/data/customWorldRuntime.ts`
- `src/services/storyEngine/themePack.ts`
当前依赖点:
1. `detectCustomWorldThemeMode(profile)`
- 先把自定义世界识别成:
- `martial`
- `arcane`
- `machina`
- `tide`
- `rift`
- `mythic`
2. `resolveCustomWorldAnchorWorldType(profile)`
- 再把主题模式压回模板锚点:
- `arcane -> XIANXIA`
- 其它默认回到 `WUXIA`
3. `resolveRuleWorldType(worldType, customWorldProfile)`
- 这是当前运行时非常关键的桥接函数。
-`worldType === CUSTOM` 时,它会把规则世界解析成模板锚点。
- 如果没有 profile还会默认回退到 `WUXIA`
4. `buildThemePackFromWorldProfile(profile)`
- 自定义世界最终的 `ThemePack` 并不是纯空中生成。
- 它是从预设的主题包底板开始,再用自定义世界自己的词汇补进去。
结论:
**自定义世界现在不是完全脱离模板世界独立运行,而是“先判定自己的主题模式,再回落到模板锚点做规则支撑”。**
---
## 2.3 属性 schema 与术语体系
相关文件:
- `src/data/worldAttributeSchemas.ts`
- `src/services/attributeSchemaGenerator.ts`
- `src/services/customWorldPresentation.ts`
- `src/data/economy.ts`
当前依赖点:
1. `PRESET_WORLD_ATTRIBUTE_SCHEMAS`
- 当前仓库里有两套预设属性 schema
- 武侠:`江湖六脉`
- 仙侠:`灵界六轴`
2. `getPresetWorldAttributeSchema(...)`
- 多个地方仍然用它作为生成自定义世界 schema 的参考底板。
3. `generateWorldAttributeSchema(...)`
- 自定义世界自己的 attribute schema 虽然是生成的,
- 但内部会参考预设世界 schema 的槽位结构和 fallback 逻辑。
4. `getAttributeLabelsForWorld(...) / getResourceLabelsForWorld(...)`
- 如果当前没有显式自定义 world presentation会按 `WUXIA / XIANXIA` 走回退术语。
5. `getCurrencyName(...) / getInitialPlayerCurrency(...)`
- 经济层仍按模板世界决定初始货币命名和初始数量。
结论:
**自定义世界现在虽然有自己的 presentation 和 attribute schema但模板世界仍然是它们的 fallback 和参考骨架。**
---
## 2.4 角色模板与技能骨架
相关文件:
- `src/data/characterPresets.ts`
当前依赖点:
1. `PRESET_CHARACTERS`
- 自定义世界的可玩角色 / 场景角色运行时形态,是从现有预设角色模板变体化出来的。
2. `pickCustomWorldRoleTemplateCharacter(...)`
- 会从 `PRESET_CHARACTERS` 里挑一个模板角色作为骨架。
3. `buildCustomWorldRoleCharacter(...)`
- 会把自定义世界角色内容覆盖到模板角色上:
- 名字
- 背景
- 描述
- 技能文案
- 外观
- 等等
4. `buildCustomWorldSkillVariant(...)`
- 自定义世界角色技能的数值和命名,也是从模板技能定义变体生成出来的。
5. `adventureOpenings`
- 当前实现里,自定义世界角色 opening 同时写入:
- `WUXIA`
- `XIANXIA`
- `CUSTOM`
- 说明角色开局结构仍沿用模板世界的旧接口习惯。
6. `getCharacterHomeSceneId / getCharacterNpcSceneIds`
-`CUSTOM` 路径已经优先走自定义世界自己的 landmark 映射;
- 但非 CUSTOM 路径仍大量依赖模板世界的基础场景绑定表。
结论:
**自定义世界角色并不是从零独立建模,而是“自定义内容 + 预设角色模板骨架”的组合。**
---
## 2.5 场景图参考池与营地图像逻辑
相关文件:
- `src/data/customWorldVisuals.ts`
- `src/services/customWorldCamp.ts`
当前依赖点:
1. `WUXIA_SCENE_IMAGE_REFERENCES`
2. `XIANXIA_SCENE_IMAGE_REFERENCES`
3. `WORLD_SCENE_IMAGE_REFERENCES`
当前自定义世界场景图不是纯随机抽图,而是:
1. 先确定模板锚点世界
2. 再从对应模板世界的场景参考词池里匹配:
- 场景名称
- 关键词
- 图片参考
具体依赖:
1. `collectWorldSceneImagePool(worldType)`
- 按模板世界从背景包里抽参考池。
2. `buildSceneReferencePool(worldType)`
- 用武侠 / 仙侠各自的场景参考名和关键词构造图像匹配池。
3. `getDefaultCustomWorldSceneImage(...)`
- 自定义世界 landmark / camp / 场景默认图,会基于模板世界参考池挑选。
4. `resolveCustomWorldCampSceneImage(profile)`
- 开局归处场景图也依赖 `templateWorldType` 和主题判定。
结论:
**当前自定义世界的场景视觉虽然是独立输出,但“默认图像匹配逻辑”仍然是挂在武侠 / 仙侠两套参考池上的。**
---
## 2.6 怪物 / 敌对实体模板池
相关文件:
- `src/data/customWorldNpcMonsters.ts`
- `src/data/hostileNpcPresets.ts`
- `src/data/hostileNpcs.ts`
- `src/data/scenePresets.ts`
当前依赖点:
1. `resolveCustomWorldNpcMonsterPreset(...)`
- 自定义世界中带敌意的 NPC / 怪物,会从模板怪物池里找最接近的 preset。
2. `getMonsterPresetPool(worldType?)`
- 如果传了 worldType就取对应模板世界的怪物池。
- 如果没传,就把武侠和仙侠怪物池拼起来一起选。
3. `resolveRuleWorldType(...)`
- `hostileNpcPresets.ts``hostileNpcs.ts` 在处理 `CUSTOM` 时,会先解析到模板锚点,再决定取哪个怪物 preset / schema。
4. `getMonsterPresetsByWorld(...) / getHostileNpcPresetById(...)`
- 自定义世界的运行时怪物表现,目前仍然依赖这套模板怪物 preset 查询接口。
结论:
**自定义世界当前没有完全独立的怪物体系,仍然是基于武侠 / 仙侠预设怪物池做匹配和包装。**
---
## 2.7 叙事 ThemePack 与 prompt 兼容字段
相关文件:
- `src/services/customWorld.ts`
- `src/services/storyEngine/themePack.ts`
- `src/services/ai.ts`
当前依赖点:
1. 自定义世界框架 prompt 仍强制要求 `templateWorldType`
2. `normalizeCustomWorldGenerationFramework(...)`
- 会把模型输出的模板世界字段规范化
3. `buildThemePackFromWorldProfile(...)`
- 会以预设主题包为底,再混入自定义世界词汇
4. `ai.ts` 里某些 fallback 逻辑仍根据 `templateWorldType` 决定主题包回退
结论:
**自定义世界的 AI 生成链条目前明确假设“世界框架里存在模板锚点字段”。**
---
## 3. 运行时硬依赖 vs 非运行时残留
## 3.1 自定义世界正式运行时硬依赖
这些目前不能轻易删:
1. `WorldType.WUXIA / WorldType.XIANXIA / WorldTemplateType`
2. `CustomWorldProfile.templateWorldType`
3. `detectCustomWorldThemeMode / resolveCustomWorldAnchorWorldType / resolveRuleWorldType`
4. `PRESET_WORLD_ATTRIBUTE_SCHEMAS / getPresetWorldAttributeSchema`
5. `PRESET_CHARACTERS` 作为自定义角色模板骨架
6. 武侠 / 仙侠场景图参考池
7. 武侠 / 仙侠怪物 preset 池
8. `buildThemePackFromWorldProfile` 的模板底板
9. `customWorld.ts` 里生成框架 prompt 的 `templateWorldType` 字段约束
## 3.2 不是主流程硬依赖,但仓库里仍存在的残留引用
这些更多是编辑器 / 工具 / 审计 / 测试引用:
1. 预设编辑器
- `preset-editor/*`
2. 一些开发工具页
- 例如 `ItemCatalogEditor.tsx`
- `StateFunctionEditor.tsx`
3. 审计 / 报告工具
- `storyAuditReport.ts`
4. 各类基于武侠 / 仙侠的测试
5. 一些 UI 图标与世界按钮贴图
- 例如 `uiAssets.ts` 里的图标键位仍保留
这些不一定影响当前玩家主流程,但如果目标是“代码库级彻底清理”,它们也需要后续处理。
---
## 4. 当前最不能动的边界
如果前提是:
**不要动素材和自定义世界的任何设定。**
那么当前最不能直接删除的是:
1. `templateWorldType`
2. `WorldTemplateType`
3. `resolveRuleWorldType(...)`
4. `detectCustomWorldThemeMode(...)`
5. `resolveCustomWorldAnchorWorldType(...)`
6. `PRESET_WORLD_ATTRIBUTE_SCHEMAS`
7. `PRESET_CHARACTERS`
8. `customWorldVisuals.ts` 里的模板场景参考池
9. `customWorldNpcMonsters.ts` 对模板怪物池的映射
10. `themePack.ts` 的模板底板
原因很简单:
**这些不是“预设世界可玩入口”,而是自定义世界当前仍在使用的模板支撑层。**
---
## 5. 可以安全理解为“已从玩家主流程移除”的部分
目前已经可以视作从正式玩家流程移除的,是:
1. 世界选择页里的武侠 / 仙侠入口
2. 主流程的世界选择 API
3. 继续游戏入口中的武侠 / 仙侠旧存档
但这不等于:
- 代码库内部已经完全不再依赖模板世界
这两件事要分开看。
---
## 6. 最后结论
当前仓库里,自定义世界对模板设定的依赖,本质上是:
**“不再复用预设世界的玩家入口,但仍然复用预设世界的模板支撑层。”**
最准确的理解是:
1. 玩家已经不能直接进入武侠 / 仙侠预设世界
2. 但自定义世界仍借用武侠 / 仙侠作为:
- 模板锚点
- 规则回退
- 属性 schema 参考
- 角色模板骨架
- 场景图参考池
- 怪物模板池
- ThemePack 底板
所以如果后续要继续“深度清理”,正确顺序不是直接删光 `WUXIA / XIANXIA`,而应该是:
1. 先识别哪些是主流程入口,哪些是模板支撑层
2. 再决定是否要把自定义世界从“模板依赖型”重构成“完全自足型”
在那一步没做完之前,模板支撑层仍然是自定义世界当前可用性的真实依赖。

View File

@@ -3,8 +3,11 @@
## 当前入口
- [FUNCTION_SCRIPT_CATALOG_2026-04-04.md](./FUNCTION_SCRIPT_CATALOG_2026-04-04.md)Function 独立脚本目录与分类速查。
- [TASK_GENERATION_TRACE_2026-04-08.md](./TASK_GENERATION_TRACE_2026-04-08.md):任务描述、达成条件与奖励生成链路梳理。
- [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md):自定义世界当前仍依赖哪些模板世界设定的清单。
## 使用建议
- 需要快速定位 Function 脚本,而不是阅读长篇方案时,优先看这里。
- 需要判断“武侠 / 仙侠模板层”哪些还能删、哪些不能删时,优先看自定义世界模板依赖清单。
- 如果要评估 Function 分层是否合理,再配合 `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md` 一起看。

View File

@@ -0,0 +1,248 @@
# 任务生成链路与简化建议
更新时间:`2026-04-08`
## 0. 简化版结论
推荐把任务系统收敛成一条主链:
1. `npcInteractions.ts`
- 只负责判断“现在是否适合接任务”,不再提前本地造整份任务预览。
2. `npcEncounterActions.ts`
- 玩家真正点击“接下委托”时,才调用 AI 任务导演。
3. `questDirector.ts + questPrompt.ts`
- 用 AI 原生剧情引擎根据当前局势生成任务意图。
4. `questFlow.ts`
- 只负责把 AI 意图编译成可追踪任务、生成本地奖励、推进步骤。
5. `goalDirector.ts`
- 把任务编译成“当前目标 / 下一步”。
6. `AdventurePanelOverlays.tsx`
- 只负责展示,不再自己承载任务生成逻辑。
一句话:
**任务内容主要由 AI 原生剧情引擎生成,本地代码只保留状态推进、奖励结算和失败兜底。**
## 1. 现在建议保留的主流程
## 1.1 接任务
推荐主流程:
`npcInteractions.ts`
-> 展示“可接任务”入口
-> `npcEncounterActions.ts`
-> `generateQuestForNpcEncounter(...)`
-> `questDirector.ts`
-> `questPrompt.ts`
-> `questFlow.ts`
-> 写入 `QuestLogEntry`
当前已经做的简化:
- NPC 面板不再为了预览,先本地生成一整份 fallback 任务。
- 现在只做任务机会判断,并提示“接取后将由 AI 剧情引擎根据当前局势生成具体目标、步骤与奖励”。
这样可以直接消掉一层不必要的双轨:
- 旧流程:
- 预览先本地生成
- 正式接取再 AI 生成
- 新流程:
- 预览只判断有没有任务机会
- 正式接取时再真正生成任务
## 1.2 任务描述
简化后应理解成:
- 任务描述的主来源是 AI 生成的 `QuestIntent.description`
- 本地只负责把它写入 `QuestLogEntry.description`
- UI 只负责展示 `quest.description`
主脚本:
- `src/services/questPrompt.ts`
- `src/services/questDirector.ts`
- `src/data/questFlow.ts`
## 1.3 达成条件
简化后应理解成:
- AI 负责给出“这件事大概要怎么做”的意图
- 本地把意图编译成 `steps / objective`
- UI 根据当前 `activeStep` 生成最短的“下一步”
也就是:
**AI 决定任务方向本地决定可追踪步骤UI 只显示当前这一步。**
主脚本:
- `src/data/questFlow.ts`
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
## 1.4 奖励
奖励不建议交给 AI 直接写死。
更稳的边界是:
- AI 只给 `rewardTheme`
- 本地生成具体金币、物品、好感奖励
- 交付时由本地状态系统结算
主脚本:
- `src/data/questFlow.ts`
- `src/data/runtimeItemDirector.ts`
- `src/hooks/story/sessionActions.ts`
- `src/hooks/story/npcEncounterActions.ts`
## 2. 你这次最关心的三个问题
## 2.1 任务描述怎么生成
主生成链:
- `src/services/questPrompt.ts`
- 约束 AI 输出 `title / description / summary / rewardTheme`
- `src/services/questDirector.ts`
- 请求 AI得到 `QuestIntent`
- `src/data/questFlow.ts`
- `compileQuestIntentToQuest(...)``description` 写入 `QuestLogEntry`
前台展示:
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- 优先显示 `quest.description`
## 2.2 达成条件怎么生成
主生成链:
- `src/data/questFlow.ts`
- `buildPrimaryQuestStep(...)`
- `buildTalkBackStep(...)`
- 把 AI 任务意图编译成 `steps`
前台展示:
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- `buildQuestConditionText(...)`
- 根据当前 `activeStep / objective` 重算成一句玩家看得懂的话
## 2.3 任务奖励怎么生成
主生成链:
- `src/data/questFlow.ts`
- `buildQuestReward(...)`
- `src/data/runtimeItemContext.ts`
- 给奖励物品生成器准备上下文
- `src/data/runtimeItemDirector.ts`
- 根据 seed 和频道生成奖励物品
- `src/data/runtimeItemNarrative.ts`
- 把奖励物品摊平回 `reward.items`
结算链:
- `src/hooks/story/sessionActions.ts`
- `src/hooks/story/npcEncounterActions.ts`
## 3. 哪些预设逻辑应该收缩
## 3.1 应该保留
- `buildFallbackQuestIntent(...)`
- 保留
- 但只作为 AI 失败兜底
- `buildQuestReward(...)`
- 保留
- 因为奖励和数值应该继续走本地规则
- `buildPrimaryQuestStep(...) / buildTalkBackStep(...)`
- 保留
- 因为任务必须能被本地追踪和结算
## 3.2 应该降级
- `buildQuestForEncounter(...)`
- 不再作为 NPC 面板预览主路径
- 只保留给 fallback 和测试使用
- `buildFallbackQuestIntent(...)` 里的多套模板
- 可以继续精简
- 最终只保留最小 2 到 3 种兜底 archetype 即可
## 3.3 应该谨慎接线
- `buildChapterQuestForScene(...)`
- 当前更像“备用章节任务生成器”
- 但我还没有在运行时主链里找到直接调用点
- `SCENE_CHAPTER_OVERRIDES`
- 不应该继续扩大
- 只保留少量关键样板场景
如果要坚持“AI 原生任务引擎”为主,这部分不应继续膨胀成大规模预设系统。
## 4. 脚本解释速查
| 脚本 | 作用 | 简化方案中的定位 |
| --- | --- | --- |
| `src/data/npcInteractions.ts` | 生成 NPC 面板选项、礼物/交易/帮助等交互入口 | 只负责任务机会判断和入口展示,不再预生成整任务 |
| `src/hooks/story/npcEncounterActions.ts` | 处理点击“接任务 / 交任务 / 切磋 / 离开”等真实执行逻辑 | 任务接取与交付的运行时主入口 |
| `src/services/questDirector.ts` | 调用 AI拿到任务意图 `QuestIntent` | AI 原生任务生成主入口 |
| `src/services/questPrompt.ts` | 组织任务 prompt约束 AI 输出格式 | AI 任务生成的提示词层 |
| `src/data/questFlow.ts` | 把任务意图编译成 quest、推进 steps、生成 reward、做 fallback | 任务数据层主脚本,保留但收缩预设分支 |
| `src/services/storyEngine/goalDirector.ts` | 把 quest / chapter / journeyBeat 编译成当前目标和下一步 | 负责“目标感”,不是负责生成任务本身 |
| `src/components/adventure-panel/AdventurePanelOverlays.tsx` | 展示任务描述、达成条件、奖励和日志 | 纯展示层 |
| `src/data/runtimeItemContext.ts` | 给奖励物品生成器准备上下文 | 奖励生成辅助层 |
| `src/data/runtimeItemDirector.ts` | 生成奖励物品 | 奖励物品生成主脚本 |
| `src/data/runtimeItemNarrative.ts` | 整理奖励物品和叙事 hint | 奖励辅助层 |
| `src/hooks/story/sessionActions.ts` | 统一处理领奖、章节同步等动作 | 非 NPC 面板路径下的奖励结算入口 |
## 5. 当前代码现状
截至这次整理,任务主链可以这样理解:
### 已经对齐到“AI 优先”的部分
- 真正点击“接下委托”时,优先调用 `generateQuestForNpcEncounter(...)`
- AI 返回任务意图后,再由 `questFlow.ts` 编译成本地任务
### 仍然是本地规则负责的部分
- `steps / objective` 推进
- 奖励数值和奖励物品
- 领奖与状态结算
### 仍然偏预设、但建议继续收缩的部分
- `buildFallbackQuestIntent(...)`
- `buildChapterQuestForScene(...)`
- `SCENE_CHAPTER_OVERRIDES`
## 6. 推荐后的最简架构
如果后面继续收缩,我建议把目标定成下面这版:
1. NPC 面板只判断“能不能接任务”
2. 点击接任务后,统一走 `questDirector.ts`
3. AI 只产出:
- `title`
- `description`
- `summary`
- `recommendedObjectiveKinds`
- `rewardTheme`
4. `questFlow.ts` 只负责:
- 编译 steps
- 生成 reward
- 推进 status
5. `goalDirector.ts` 只负责把 quest 变成“当前目标 / 下一步”
6. 章节任务生成器只做少量 fallback不再做大规模场景预设扩张
这样之后,代码层的职责会更清楚:
- AI 负责“这件事讲什么”
- 本地规则负责“这件事怎么追踪、怎么结算”
- UI 负责“把当前最重要的一步展示给玩家”

View File

@@ -0,0 +1,16 @@
{
"taskId": "15838831-6431-4867-a814-b6e7f1613169",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.2-kf2v-flash",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:33:00.095Z",
"updatedAt": "2026-04-08T07:33:31.916Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633611867/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633611867"
}
}

View File

@@ -0,0 +1,16 @@
{
"taskId": "433b4f4f-d6f0-4b3b-8246-5ae4b626cb4c",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "run",
"strategy": "image-to-video",
"model": "wan2.2-kf2v-flash",
"prompt": "单人 NPC 全身动作视频,动作主题是 run。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 奔跑循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧右腿前摆左腿后蹬身体略前倾5-8 帧双腿交叉经过身体下方手臂反向摆动9-12 帧左腿前摆右腿后蹬继续前倾13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态。结尾要求:第 16 帧能无缝接回第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:34:34.233Z",
"updatedAt": "2026-04-08T07:35:08.207Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/run/animation-video-1775633708180/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/run/animation-video-1775633708180"
}
}

View File

@@ -0,0 +1,16 @@
{
"taskId": "5867f357-ac12-4e7c-848b-71c4274ba717",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:边跳边走 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T05:02:04.795Z",
"updatedAt": "2026-04-08T05:05:09.119Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775624709088/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775624709088"
}
}

View File

@@ -0,0 +1,13 @@
{
"taskId": "92f0d503-7b86-4f43-9637-81e5119aa646",
"kind": "animation",
"status": "failed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为人形国王角色,穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 只有在文字设定明确写出非人结构时,才保留对应非人身体,例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。 角色设定:水母公主 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T05:49:32.962Z",
"updatedAt": "2026-04-08T05:49:32.962Z",
"errorMessage": "{\"request_id\":\"260207f2-45ab-9fd2-82a1-21cce8ea0465\",\"output\":{\"task_id\":\"92f0d503-7b86-4f43-9637-81e5119aa646\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-08 13:43:29.724\",\"scheduled_time\":\"2026-04-08 13:43:31.170\",\"end_time\":\"2026-04-08 13:49:32.185\",\"code\":\"DataInspectionFailed\",\"message\":\"Output data may contain inappropriate content.\"}}"
}

View File

@@ -0,0 +1,13 @@
{
"taskId": "bd19ca9c-e8c8-4c02-b016-d184c8457463",
"kind": "animation",
"status": "failed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。背景为纯绿色绿幕无其他人物和场景元素方便后期抽帧与抠像。动作补充细节头部轻微上下起伏整体保持可爱Q版待机感。目标是后续抽帧为横版动作游戏精灵表因此不要镜头切换不要景别变化不要角色漂移。",
"createdAt": "2026-04-08T04:49:32.756Z",
"updatedAt": "2026-04-08T04:49:32.756Z",
"errorMessage": "{\"request_id\":\"c6092653-cfc9-919c-bbc5-a8dc3fedbda8\",\"output\":{\"task_id\":\"bd19ca9c-e8c8-4c02-b016-d184c8457463\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-08 12:48:32.251\",\"scheduled_time\":\"2026-04-08 12:48:34.671\",\"end_time\":\"2026-04-08 12:49:22.912\",\"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": "e0ca440a-8a50-45a7-88a2-9e550a582272",
"kind": "animation",
"status": "failed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。背景为纯绿色绿幕无其他人物和场景元素方便后期抽帧与抠像。动作补充细节头部轻微上下起伏整体保持可爱Q版待机感。目标是后续抽帧为横版动作游戏精灵表因此不要镜头切换不要景别变化不要角色漂移。",
"createdAt": "2026-04-08T04:53:22.156Z",
"updatedAt": "2026-04-08T04:53:22.156Z",
"errorMessage": "{\"request_id\":\"c76e2066-9ee8-9551-a687-f805c33deeca\",\"output\":{\"task_id\":\"e0ca440a-8a50-45a7-88a2-9e550a582272\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-08 12:50:35.698\",\"scheduled_time\":\"2026-04-08 12:50:39.177\",\"end_time\":\"2026-04-08 12:53:07.303\",\"code\":\"DataInspectionFailed\",\"message\":\"Output data may contain inappropriate content.\"}}"
}

View File

@@ -0,0 +1,16 @@
{
"taskId": "ebaee9f5-9245-489b-a03f-2a84ea8ea936",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.2-kf2v-flash",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色。",
"createdAt": "2026-04-08T07:28:15.160Z",
"updatedAt": "2026-04-08T07:28:48.456Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633328425/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633328425"
}
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "5867f357-ac12-4e7c-848b-71c4274ba717",
"model": "wan2.7-i2v",
"strategy": "image-to-video",
"animation": "idle",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:边跳边走 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T05:05:09.116Z",
"videoUrl": "https://dashscope-a717.oss-accelerate.aliyuncs.com/1d/c0/20260408/b1fbde2b/27777303-metadata_c156c3d3f8c73ab0.mp4?Expires=1775711089&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=q8juQS13NdFUUQ%2FeGC1%2B2xYHLlI%3D"
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "ebaee9f5-9245-489b-a03f-2a84ea8ea936",
"model": "wan2.2-kf2v-flash",
"strategy": "image-to-video",
"animation": "idle",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色。",
"createdAt": "2026-04-08T07:28:48.453Z",
"videoUrl": "https://dashscope-7c2c.oss-cn-shanghai.aliyuncs.com/1d/69/20260408/1ce55b62/ebaee9f5-9245-489b-a03f-2a84ea8ea936.mp4?Expires=1775719713&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=pd3vhAP%2FOb5IXC2iVMGkERXQyHc%3D"
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "15838831-6431-4867-a814-b6e7f1613169",
"model": "wan2.2-kf2v-flash",
"strategy": "image-to-video",
"animation": "idle",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:33:31.912Z",
"videoUrl": "https://dashscope-7c2c.oss-cn-shanghai.aliyuncs.com/1d/4f/20260408/1ce55b62/15838831-6431-4867-a814-b6e7f1613169.mp4?Expires=1775719998&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=WyNwCc%2BZ50%2BODrEm8cewZVywInk%3D"
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "433b4f4f-d6f0-4b3b-8246-5ae4b626cb4c",
"model": "wan2.2-kf2v-flash",
"strategy": "image-to-video",
"animation": "run",
"prompt": "单人 NPC 全身动作视频,动作主题是 run。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 奔跑循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧右腿前摆左腿后蹬身体略前倾5-8 帧双腿交叉经过身体下方手臂反向摆动9-12 帧左腿前摆右腿后蹬继续前倾13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态。结尾要求:第 16 帧能无缝接回第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:35:08.203Z",
"videoUrl": "https://dashscope-7c2c.oss-cn-shanghai.aliyuncs.com/1d/d9/20260408/1ce55b62/433b4f4f-d6f0-4b3b-8246-5ae4b626cb4c.mp4?Expires=1775720090&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=Lj5K18exe6OkFaKf6xCTAhZELdU%3D"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775621887918",
"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-1775621887918-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621887918/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/1c/20260408/76483b06/b258b423-90f9-4d09-9ca5-71c0ff84ef36.png?Expires=1776227687&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=6RbPDqWGIVf5BrEy%2FpScw0S891U%3D"
},
{
"id": "qwen-master-1775621887918-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621887918/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/79/20260408/76483b06/35b45710-ee4a-41b1-bdbe-339df531f7bd.png?Expires=1776227687&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SGyX9pdu%2BUqdsV9p41cmVbcDFpk%3D"
}
],
"createdAt": "2026-04-08T04:18:10.374Z"
}

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-1775621901628",
"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-1775621901628-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621901628/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/43/20260408/76483b06/3e1302c8-84f7-4794-baa6-b745d01c69f1.png?Expires=1776227701&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=7G%2FzQKhvDjFTDjJRVxNHnbGwh6I%3D"
},
{
"id": "qwen-master-1775621901628-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621901628/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/b8/20260408/76483b06/6ea29373-6458-404e-b7be-c2fea0e72a36.png?Expires=1776227701&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=CjwS1ksjowlMWOHXY1k6I3SQb9Q%3D"
}
],
"createdAt": "2026-04-08T04:18:24.134Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775621956284",
"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-1775621956284-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621956284/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/78/20260408/76483b06/7bc06ee3-5087-4c76-9a63-c3a504f913e2.png?Expires=1776227755&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=w9NQpmwkjYPvUCXqka7Npy4DPxg%3D"
},
{
"id": "qwen-master-1775621956284-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621956284/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/f3/20260408/76483b06/5cc25a8e-62d8-4d1e-a5cb-c7158f4a1a08.png?Expires=1776227755&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=gfbCHs09gqPQMPxsH5l87VGkD%2BI%3D"
}
],
"createdAt": "2026-04-08T04:19:18.856Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775624392456",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\n海妖",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775624392456-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624392456/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/6e/20260408/8b3aee91/ee234373-ea8c-442e-ba82-82d39512b2bd.png?Expires=1776230191&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=213U2JTKtHnMxYsVD%2BvEAdRaQls%3D"
},
{
"id": "qwen-master-1775624392456-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624392456/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d4/20260408/8b3aee91/ace63da1-4906-4545-8ee1-a5662a1524a8.png?Expires=1776230192&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=ZQrv0t1btJ9NuacOlu9jvGKntrQ%3D"
}
],
"createdAt": "2026-04-08T04:59:54.906Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775624876722",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n水母国王",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775624876722-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624876722/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/42/20260408/8b3aee91/0770d986-ed4f-41ed-a8b0-8a265cb847f7.png?Expires=1776230676&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=bBtx%2B%2FOisKA0scmab3%2F4icAOQtM%3D"
},
{
"id": "qwen-master-1775624876722-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624876722/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/f0/20260408/8b3aee91/eecc33d6-ec36-44cc-8d51-7dbf13ccc383.png?Expires=1776230676&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=vd7lRhTwthHRC4u4ah0ogzMDkeA%3D"
}
],
"createdAt": "2026-04-08T05:07:59.699Z"
}

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-master-1775625635778",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,不复制参考图的具体种族、身体结构、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n文字设定优先决定角色的物种、身体结构、肢体数量、头部形态、尾巴、触手、翅膀、下肢形式和材质特征。如果文字设定是非人、怪物、海洋生物、植物体、机械体、凝胶体或亡灵体就必须保留对应非人形态不要自动人类化。\n角色主体底部结构必须服从文字设定。如果角色是鱼尾、触手、漂浮体、凝胶体、蛇尾、机械履带或其他非人下肢结构就保持对应结构不要强行补成人类双腿、脚或站立姿势。\n水母国王",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要默认生成普通人类,不要把非人设定自动改成人类,不要强行补成人类脸、人类双腿、人类四肢、人类耳朵或普通人类皮肤结构,除非文字设定明确要求",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775625635778-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775625635778/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/fc/20260408/8b3aee91/5301a16e-1464-4b8f-a9a8-fa9dc8c9bc62.png?Expires=1776231435&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=jPUpwJUN%2Bz8eoK5dH2wPmAqS6Ew%3D"
},
{
"id": "qwen-master-1775625635778-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775625635778/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/84/20260408/8b3aee91/15ade2f0-e86a-4d18-bc1c-d665a2ba48ee.png?Expires=1776231435&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=quEzTquQCJ2r%2BEfq3AJVlEGV%2BHw%3D"
}
],
"createdAt": "2026-04-08T05:20:38.510Z"
}

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-1775626712086",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,不复制参考图的具体种族、身体结构、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n水母公主",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775626712086-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775626712086/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/9a/20260408/8b3aee91/a3ad1312-0bfb-4ed5-b852-a9222d6bdeae.png?Expires=1776232511&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SsFMW587lgbD9J5hkw%2BTTUw4VPw%3D"
},
{
"id": "qwen-master-1775626712086-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775626712086/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/00/20260408/8b3aee91/0785aeca-5eba-4bab-8eba-05a5db22b8e4.png?Expires=1776232511&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=%2F42%2BwjoM0mtahK%2FwocvxadeuU6g%3D"
}
],
"createdAt": "2026-04-08T05:38:34.575Z"
}

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-1775627051206",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n水母公主",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775627051206-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627051206/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/28/20260408/8b3aee91/60fe96fa-3fe4-4ad5-89a5-62c154003c18.png?Expires=1776232850&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=XunqrTOrrh%2BBG4ToyejUXNHDxjw%3D"
},
{
"id": "qwen-master-1775627051206-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627051206/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/4f/20260408/8b3aee91/9a1521ff-ec60-4d2a-8f0d-ef383cd5d154.png?Expires=1776232850&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=825mPHtUdTw0DHeG2wIeOhBpmlY%3D"
}
],
"createdAt": "2026-04-08T05:44:13.679Z"
}

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-1775627124102",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n高贵的水母公主",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775627124102-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627124102/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/b3/20260408/8b3aee91/c6ec3cf0-9b35-4eba-bf5f-e21072b6a924.png?Expires=1776232923&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=J9sr%2FCWxWs2BFY70eHdWCIwRugo%3D"
},
{
"id": "qwen-master-1775627124102-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627124102/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/fd/20260408/8b3aee91/c2573141-524b-44ed-9200-152a18d0fbb4.png?Expires=1776232923&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=5fSRKI48uKApHz11F655w6khEFI%3D"
}
],
"createdAt": "2026-04-08T05:45:26.542Z"
}

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-1775629317889",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n黑熊战神",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775629317889-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775629317889/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/67/20260408/8b3aee91/3567ac38-fc5f-4448-9923-cea09a0d318f.png?Expires=1776235117&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=f6h12%2BQ%2FAct29kHJfUFl4SpK4Wc%3D"
},
{
"id": "qwen-master-1775629317889-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775629317889/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/57/20260408/8b3aee91/0ee1d5d2-377c-4de0-98a6-7dd5495fd2f0.png?Expires=1776235117&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=wA0K8Kpl%2FeAvOUue2UAiNIkVJ0o%3D"
}
],
"createdAt": "2026-04-08T06:22:00.397Z"
}

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-1775630268164",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色再穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n黑熊首领",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775630268164-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775630268164/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/54/20260408/8b3aee91/4357f0aa-7a1b-4331-a2a0-9ef78f92e3f3.png?Expires=1776236067&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=bwac03%2BDrGMucwsrsf8%2F4nlloRE%3D"
},
{
"id": "qwen-master-1775630268164-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775630268164/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d9/20260408/8b3aee91/b0d395b6-43b6-449c-ac81-4e00584be65e.png?Expires=1776236067&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=xNAxVvSjij2SU4wdFFPyzkwC3gU%3D"
}
],
"createdAt": "2026-04-08T06:37:50.861Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775631112051",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色再穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n黑熊首领",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775631112051-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775631112051/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/0e/20260408/8b3aee91/74803635-ce09-481e-9a51-1287a5ef729f.png?Expires=1776236911&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=d6nWY0doc0bZCDLMq%2Fq3rmpserE%3D"
},
{
"id": "qwen-master-1775631112051-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775631112051/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/01/20260408/8b3aee91/5225636f-3b25-440a-82ce-477371f2d251.png?Expires=1776236911&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SUY9fJdU4cDGZYcZAGNyL0EhhP0%3D"
}
],
"createdAt": "2026-04-08T06:51:56.620Z"
}

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-1775633556964",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色再穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n黑熊首领",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775633556964-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775633556964/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d9/20260408/8b3aee91/ea17e524-8af3-41a0-9246-1183e306b608.png?Expires=1776239356&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=GOIqUHovAYNxNzEBaIifBiWsUHU%3D"
},
{
"id": "qwen-master-1775633556964-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775633556964/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/2f/20260408/8b3aee91/6e921ff7-8c7a-4a24-bab7-b3bd47c60912.png?Expires=1776239356&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=7E7ef9RlGkM%2BFi8BtBdUk96ew4g%3D"
}
],
"createdAt": "2026-04-08T07:32:40.381Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775622034018",
"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-1775622034018-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775622034018/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/0a/20260408/8b3aee91/a60efc3a-dc00-42c3-90a0-987ffeb94973.png?Expires=1776227833&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=vRjvyHXKKq6IKjvRM9%2BoKnT8FNg%3D"
},
{
"id": "qwen-sheet-1775622034018-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775622034018/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/ad/20260408/8b3aee91/28305000-f9b6-484b-a70b-5bb80ca55d08.png?Expires=1776227833&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=dILDDQDifvGxx3MFYopC%2BXtKoOs%3D"
}
],
"createdAt": "2026-04-08T04:20:37.029Z"
}

View File

@@ -9,6 +9,12 @@ import path from 'node:path';
import { loadEnv, type Plugin } from 'vite';
import {
buildMasterPrompt,
buildVideoActionPrompt,
getActionTemplateById,
} from '../../src/tools/qwenSpriteSheetToolModel';
const CHARACTER_VISUAL_GENERATE_PATH = '/api/character-visual/generate';
const CHARACTER_VISUAL_JOBS_PATH = '/api/character-visual/jobs/';
const CHARACTER_ANIMATION_GENERATE_PATH = '/api/animation/generate';
@@ -17,7 +23,7 @@ const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/animation/import-video';
const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/animation/templates';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro';
const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.7-i2v';
const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash';
const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v';
const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move';
const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
@@ -302,6 +308,18 @@ async function resolveMediaSourcePayload(
};
}
async function resolveMediaSourceAsDataUrl(
rootDir: string,
source: string,
) {
if (/^data:/u.test(source)) {
return source;
}
const payload = await resolveMediaSourcePayload(rootDir, source);
return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`;
}
function requestResponse(
urlString: string,
options: {
@@ -684,14 +702,15 @@ function extractImageUrls(payload: Record<string, unknown>) {
return [...new Set(urls)];
}
function buildNpcVisualPrompt(promptText: string) {
const trimmed = promptText.trim();
return [
'单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。',
'画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。',
'不要多人,不要复杂场景,不要夸张透视,不要截断脚底。',
trimmed || '江湖风格角色,服装完整,姿态自然。',
].join(' ');
function buildNpcVisualPrompt(
promptText: string,
characterBriefText = '',
) {
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
.filter(Boolean)
.join('\n');
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
}
function buildImageSequencePrompt(
@@ -713,19 +732,36 @@ function buildImageSequencePrompt(
.join(' ');
}
function buildNpcAnimationPrompt(
animation: string,
promptText: string,
useChromaKey: boolean,
) {
function buildNpcAnimationPrompt(options: {
animation: string;
promptText: string;
useChromaKey: boolean;
characterBriefText?: string;
actionTemplateId?: string;
}) {
if (options.actionTemplateId) {
return buildVideoActionPrompt({
actionTemplate: getActionTemplateById(
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
),
actionDetailText: options.promptText,
useChromaKey: options.useChromaKey,
characterBrief:
options.characterBriefText?.trim() || `${options.animation} 动作角色`,
});
}
return [
`单人 NPC 全身动作视频,动作主题是 ${animation}`,
`单人 NPC 全身动作视频,动作主题是 ${options.animation}`,
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
'动作连贯,避免服装、发型、面部、武器随机漂移。',
useChromaKey
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
: '背景简洁纯净,无复杂场景。',
promptText.trim(),
options.characterBriefText?.trim()
? `角色设定:${options.characterBriefText.trim()}`
: '',
options.promptText.trim(),
]
.filter(Boolean)
.join(' ');
@@ -787,13 +823,17 @@ async function handleGenerateCharacterVisuals(
typeof body.characterId === 'string'
? body.characterId.trim()
: 'character';
const sourceMode =
typeof body.sourceMode === 'string' ? body.sourceMode.trim() : '';
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls)
? body.referenceImageDataUrls.slice(0, 4)
: [];
const sourceMode =
typeof body.sourceMode === 'string' ? body.sourceMode.trim() : '';
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const characterBriefText =
typeof body.characterBriefText === 'string'
? body.characterBriefText.trim()
: '';
const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls)
? body.referenceImageDataUrls.slice(0, 4)
: [];
const candidateCountRaw =
typeof body.candidateCount === 'number' ? body.candidateCount : 3;
const candidateCount = Math.max(
@@ -818,20 +858,20 @@ async function handleGenerateCharacterVisuals(
return;
}
if (!promptText && sourceMode === 'text-to-image') {
sendJson(res, 400, {
error: { message: '文生主形象需要填写角色设定。' },
});
if (!promptText && !characterBriefText && sourceMode === 'text-to-image') {
sendJson(res, 400, {
error: { message: '文生主形象需要填写角色设定。' },
});
return;
}
let activeTaskId = '';
let activePrompt = '';
try {
const finalPrompt = buildNpcVisualPrompt(promptText);
activePrompt = finalPrompt;
const content = [
{ text: finalPrompt },
let activeTaskId = '';
let activePrompt = '';
try {
const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText);
activePrompt = finalPrompt;
const content = [
{ text: finalPrompt },
...referenceImageDataUrls.map((image) => ({ image })),
];
const createTaskResponse = await proxyJsonRequest(
@@ -1059,6 +1099,14 @@ async function handleGenerateCharacterAnimation(
typeof body.animation === 'string' ? body.animation.trim() : 'idle';
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const characterBriefText =
typeof body.characterBriefText === 'string'
? body.characterBriefText.trim()
: '';
const actionTemplateId =
typeof body.actionTemplateId === 'string'
? body.actionTemplateId.trim()
: '';
const visualSource =
typeof body.visualSource === 'string' ? body.visualSource.trim() : '';
const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls)
@@ -1076,7 +1124,7 @@ async function handleGenerateCharacterAnimation(
typeof body.frameCount === 'number' && Number.isFinite(body.frameCount)
? Math.max(2, Math.min(16, Math.round(body.frameCount)))
: 8;
const durationSeconds =
const requestedDurationSeconds =
typeof body.durationSeconds === 'number' &&
Number.isFinite(body.durationSeconds)
? Math.max(1, Math.min(8, Math.round(body.durationSeconds)))
@@ -1098,6 +1146,10 @@ async function handleGenerateCharacterAnimation(
? body.videoModel.trim()
: runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL ||
DEFAULT_CHARACTER_VIDEO_MODEL;
const durationSeconds =
videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds;
const normalizedResolution =
videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution;
const referenceVideoModel =
typeof body.referenceVideoModel === 'string' &&
body.referenceVideoModel.trim()
@@ -1295,62 +1347,70 @@ async function handleGenerateCharacterAnimation(
return;
}
const modelForVisualUpload =
strategy === 'reference-to-video'
? referenceVideoModel
: strategy === 'motion-transfer'
? motionTransferModel
: videoModel;
const visualUrl = await uploadFileToDashScope(
baseUrl,
apiKey,
modelForVisualUpload,
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
if (strategy === 'image-to-video') {
const finalPrompt = buildNpcAnimationPrompt(
const finalPrompt = buildNpcAnimationPrompt({
animation,
promptText,
useChromaKey,
);
characterBriefText,
actionTemplateId,
});
activePrompt = finalPrompt;
activeModel = videoModel;
const media = [
{ type: 'image', url: visualUrl, role: 'first_frame' },
...(lastFrameImageDataUrl
? [
{
type: 'image',
url: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-last-frame`,
await resolveMediaSourcePayload(
rootDir,
lastFrameImageDataUrl,
),
),
role: 'last_frame',
},
]
: []),
];
const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash';
const visualInputRef = isKf2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, visualSource)
: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
const lastFrameRef = lastFrameImageDataUrl
? isKf2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl)
: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-last-frame`,
await resolveMediaSourcePayload(
rootDir,
lastFrameImageDataUrl,
),
)
: '';
const inputPayload =
isKf2vFlash
? {
prompt: finalPrompt,
first_frame_url: visualInputRef,
...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}),
}
: {
prompt: finalPrompt,
media: [
{ type: 'first_frame', url: visualInputRef },
...(lastFrameRef
? [{ type: 'last_frame', url: lastFrameRef }]
: []),
],
};
const videoSynthesisEndpoint = isKf2vFlash
? `${baseUrl}/services/aigc/image2video/video-synthesis`
: `${baseUrl}/services/aigc/video-generation/video-synthesis`;
const createTaskResponse = await proxyJsonRequest(
`${baseUrl}/services/aigc/video-generation/video-synthesis`,
videoSynthesisEndpoint,
apiKey,
{
model: videoModel,
input: {
prompt: finalPrompt,
media,
},
input: inputPayload,
parameters: {
duration: durationSeconds,
resolution,
resolution: normalizedResolution,
...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}),
},
},
{
@@ -1482,6 +1542,20 @@ async function handleGenerateCharacterAnimation(
return;
}
const modelForVisualUpload =
strategy === 'reference-to-video'
? referenceVideoModel
: strategy === 'motion-transfer'
? motionTransferModel
: videoModel;
const visualUrl = await uploadFileToDashScope(
baseUrl,
apiKey,
modelForVisualUpload,
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
if (strategy === 'motion-transfer') {
if (referenceVideoDataUrls.length === 0) {
sendJson(res, 400, {
@@ -1490,11 +1564,12 @@ async function handleGenerateCharacterAnimation(
return;
}
const finalPrompt = buildNpcAnimationPrompt(
const finalPrompt = buildNpcAnimationPrompt({
animation,
promptText,
useChromaKey,
);
characterBriefText,
});
activePrompt = finalPrompt;
activeModel = motionTransferModel;
const referenceVideoUrl = await uploadFileToDashScope(
@@ -1679,11 +1754,12 @@ async function handleGenerateCharacterAnimation(
return;
}
const finalPrompt = buildNpcAnimationPrompt(
const finalPrompt = buildNpcAnimationPrompt({
animation,
promptText,
useChromaKey,
);
characterBriefText,
});
activePrompt = finalPrompt;
activeModel = referenceVideoModel;
const createTaskResponse = await proxyJsonRequest(

View File

@@ -27,7 +27,7 @@ const CHARACTER_VISUAL_PUBLISH_PATH = '/api/character-visual/publish';
const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/animation/publish';
const CUSTOM_WORLD_SCENE_IMAGE_PATH = '/api/custom-world/scene-image';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.2-t2i-flash';
const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.7-image';
const DASHSCOPE_TASK_POLL_INTERVAL_MS = 2000;
const DASHSCOPE_TASK_TIMEOUT_MS = 150000;
@@ -541,6 +541,43 @@ async function resolveAssetSourcePayload(
};
}
async function resolveAssetSourceAsDataUrl(
rootDir: string,
source: string,
fallbackMessage: string,
) {
if (/^data:image\/[^;]+;base64,/u.test(source)) {
return source;
}
const payload = await resolveAssetSourcePayload(
rootDir,
source,
fallbackMessage,
);
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')}`;
}
function resolveDashScopeSceneImageModel(model: string) {
if (/^wan2\.7-image(?:-pro)?$/u.test(model)) {
return model;
}
return DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
}
function resolveImageExtension(
contentTypeHeader: string | string[] | undefined,
sourceUrl: string,
@@ -657,6 +694,44 @@ function getDashScopeImageUrl(taskResponse: Record<string, unknown>) {
}
}
const choices = output && Array.isArray(output.choices) ? output.choices : [];
for (const choice of choices) {
if (!isRecordValue(choice)) {
continue;
}
const message = isRecordValue(choice.message) ? choice.message : null;
const content =
message && Array.isArray(message.content) ? message.content : [];
for (const entry of content) {
if (!isRecordValue(entry)) {
continue;
}
const imageUrl =
typeof entry.image === 'string' && entry.image.trim()
? entry.image.trim()
: typeof entry.url === 'string' && entry.url.trim()
? entry.url.trim()
: '';
if (imageUrl) {
return {
url: imageUrl,
actualPrompt:
typeof entry.actual_prompt === 'string' &&
entry.actual_prompt.trim()
? entry.actual_prompt.trim()
: typeof entry.revised_prompt === 'string' &&
entry.revised_prompt.trim()
? entry.revised_prompt.trim()
: undefined,
};
}
}
}
throw new Error('场景图片生成成功,但没有返回可下载的图片地址。');
}
@@ -886,10 +961,11 @@ function createCustomWorldSceneImagePlugin(
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1280*720';
const model =
const requestedModel =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: defaultModel;
const model = resolveDashScopeSceneImageModel(requestedModel);
const worldName =
typeof body.worldName === 'string' ? body.worldName.trim() : '';
const profileId =
@@ -898,6 +974,10 @@ function createCustomWorldSceneImagePlugin(
typeof body.landmarkName === 'string' ? body.landmarkName.trim() : '';
const landmarkId =
typeof body.landmarkId === 'string' ? body.landmarkId.trim() : '';
const referenceImageSrc =
typeof body.referenceImageSrc === 'string'
? body.referenceImageSrc.trim()
: '';
if (!prompt) {
sendJson(res, 400, { error: { message: 'prompt is required.' } });
@@ -912,20 +992,37 @@ function createCustomWorldSceneImagePlugin(
}
try {
const messageContent: Array<{ image: string } | { text: string }> = [];
if (referenceImageSrc) {
messageContent.push({
image: await resolveAssetSourceAsDataUrl(
rootDir,
referenceImageSrc,
'参考图必须来自 public 目录或使用 Data URL。',
),
});
}
messageContent.push({ text: prompt });
const createTaskResponse = await proxyJsonRequest(
`${baseUrl}/services/aigc/text2image/image-synthesis`,
`${baseUrl}/services/aigc/image-generation/generation`,
apiKey,
{
model,
input: {
prompt,
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
messages: [
{
role: 'user',
content: messageContent,
},
],
},
parameters: {
n: 1,
size,
prompt_extend: true,
watermark: false,
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
},
},
{
@@ -1023,6 +1120,7 @@ function createCustomWorldSceneImagePlugin(
size,
prompt,
negativePrompt,
referenceImageSrc: referenceImageSrc || undefined,
actualPrompt: imageResult.actualPrompt,
remoteUrl: imageResult.url,
imageSrc,
@@ -1189,6 +1287,7 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
typeof body.height === 'number' && Number.isFinite(body.height)
? body.height
: 1536;
const updateCharacterOverride = body.updateCharacterOverride !== false;
if (!characterId) {
sendJson(res, 400, { error: { message: 'characterId is required.' } });
@@ -1259,26 +1358,30 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
'utf8',
);
const overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
nextOverride.generatedVisualAssetId = assetId;
nextOverride.portrait = masterImagePath;
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
let overrideMap: Record<string, unknown> = {};
if (updateCharacterOverride) {
overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
nextOverride.generatedVisualAssetId = assetId;
nextOverride.portrait = masterImagePath;
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
}
sendJson(res, 200, {
ok: true,
assetId,
portraitPath: masterImagePath,
overrideMap,
saveMessage:
'主形象已发布到 public/generated-characters并更新角色覆盖。',
saveMessage: updateCharacterOverride
? '主形象已发布到 public/generated-characters并更新角色覆盖。'
: '主形象已保存到 public/generated-characters可直接写回当前自定义世界角色。',
});
} catch (error) {
sendJson(res, 500, {
@@ -1333,6 +1436,7 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
!Array.isArray(body.animations)
? (body.animations as Record<string, unknown>)
: null;
const updateCharacterOverride = body.updateCharacterOverride !== false;
if (!characterId) {
sendJson(res, 400, { error: { message: 'characterId is required.' } });
@@ -1476,35 +1580,40 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
'utf8',
);
const overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
const existingAnimationMap =
nextOverride.animationMap &&
typeof nextOverride.animationMap === 'object' &&
!Array.isArray(nextOverride.animationMap)
? (nextOverride.animationMap as Record<string, unknown>)
: {};
nextOverride.generatedAnimationSetId = animationSetId;
nextOverride.generatedVisualAssetId = visualAssetId;
nextOverride.animationMap = {
...existingAnimationMap,
...nextAnimationMap,
};
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
let overrideMap: Record<string, unknown> = {};
if (updateCharacterOverride) {
overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
const existingAnimationMap =
nextOverride.animationMap &&
typeof nextOverride.animationMap === 'object' &&
!Array.isArray(nextOverride.animationMap)
? (nextOverride.animationMap as Record<string, unknown>)
: {};
nextOverride.generatedAnimationSetId = animationSetId;
nextOverride.generatedVisualAssetId = visualAssetId;
nextOverride.animationMap = {
...existingAnimationMap,
...nextAnimationMap,
};
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
}
sendJson(res, 200, {
ok: true,
animationSetId,
overrideMap,
saveMessage:
'基础动作资源已发布到 public/generated-animations并更新角色覆盖。',
animationMap: nextAnimationMap,
saveMessage: updateCharacterOverride
? '基础动作资源已发布到 public/generated-animations并更新角色覆盖。'
: '基础动作资源已保存到 public/generated-animations可直接写回当前自定义世界角色。',
});
} catch (error) {
sendJson(res, 500, {

View File

@@ -20,7 +20,7 @@ export default function App() {
isMapOpen,
setIsMapOpen,
resetGame,
handleWorldSelect: selectWorld,
handleCustomWorldSelect: selectCustomWorld,
handleBackToWorldSelect: backToWorldSelect,
handleCharacterSelect: selectCharacter,
} = useGameFlow();
@@ -73,12 +73,11 @@ export default function App() {
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
const handleWorldSelect = (
worldType: Parameters<typeof selectWorld>[0],
customWorldProfile?: Parameters<typeof selectWorld>[1],
const handleCustomWorldSelect = (
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
) => {
storyFlow.resetStoryState();
selectWorld(worldType, customWorldProfile);
selectCustomWorld(customWorldProfile);
};
const handleCharacterSelect = (
@@ -152,7 +151,7 @@ export default function App() {
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
};

View File

@@ -1,8 +1,7 @@
import { type ReactNode, useDeferredValue, useMemo, useState } from 'react';
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import {
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
@@ -25,11 +24,8 @@ interface CustomWorldEntityCatalogProps {
onActiveTabChange: (tab: ResultTab) => void;
onEditTarget: (target: CustomWorldEditorTarget) => void;
onProfileChange: (profile: CustomWorldProfile) => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
onDeleteStoryNpcs?: (ids: string[]) => void;
onDeleteLandmarks?: (ids: string[]) => void;
createActionLabel?: string;
onCreateAction?: () => void;
}
@@ -146,6 +142,57 @@ function EmptyState({ title }: { title: string }) {
);
}
function CatalogCard({
title,
description,
media,
isSelectionMode,
isSelected,
onClick,
}: {
title: string;
description: string;
media: ReactNode;
isSelectionMode: boolean;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
}`}
>
<div className="space-y-3">
<div className="overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25">
{media}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
{isSelectionMode ? (
<div
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
isSelected
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{isSelected ? '已选' : '选择'}
</div>
) : null}
</div>
<div className="text-sm leading-6 text-zinc-300">
{description || '暂无描述'}
</div>
</div>
</button>
);
}
function matchText(text: string, query: string) {
return text.toLowerCase().includes(query.toLowerCase());
}
@@ -161,6 +208,8 @@ type CatalogRole =
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number];
type BulkDeleteTab = 'story' | 'landmarks';
function buildRoleSearchText(role: CatalogRole) {
return [
role.name,
@@ -215,15 +264,14 @@ export function CustomWorldEntityCatalog({
onActiveTabChange,
onEditTarget,
onProfileChange,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
onDeleteStoryNpcs,
onDeleteLandmarks,
createActionLabel,
onCreateAction,
}: CustomWorldEntityCatalogProps) {
const [searchDraft, setSearchDraft] = useState('');
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
const deferredSearch = useDeferredValue(searchDraft.trim());
const storyNpcById = useMemo(
@@ -289,16 +337,6 @@ export function CustomWorldEntityCatalog({
),
[profile.creatorIntent],
);
const lockedLandmarkNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
);
const counts = {
world: 1,
@@ -308,6 +346,17 @@ export function CustomWorldEntityCatalog({
landmarks: profile.landmarks.length,
} satisfies Record<ResultTab, number>;
const bulkDeleteTab: BulkDeleteTab | null =
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
useEffect(() => {
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
setBulkDeleteMode(null);
setSelectedBulkIds([]);
}
}, [activeTab, bulkDeleteMode]);
const removePlayable = (id: string, name: string) => {
if (profile.playableNpcs.length <= 1) {
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
@@ -320,37 +369,43 @@ export function CustomWorldEntityCatalog({
});
};
const removeStoryNpc = (id: string, name: string) => {
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
const nextStoryNpcs = profile.storyNpcs.filter(npc => npc.id !== id);
onProfileChange({
...profile,
storyNpcs: nextStoryNpcs,
landmarks: normalizeCustomWorldLandmarks({
landmarks: profile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => npcId !== id),
})),
storyNpcs: nextStoryNpcs,
}),
});
const startBulkDelete = (tab: BulkDeleteTab) => {
setBulkDeleteMode(tab);
setSelectedBulkIds([]);
};
const removeLandmark = (id: string, name: string) => {
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
const nextLandmarks = profile.landmarks.filter(landmark => landmark.id !== id);
onProfileChange({
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: nextLandmarks.map((landmark) => ({
...landmark,
connections: landmark.connections.filter(
(connection) => connection.targetLandmarkId !== id,
),
})),
storyNpcs: profile.storyNpcs,
}),
});
const cancelBulkDelete = () => {
setBulkDeleteMode(null);
setSelectedBulkIds([]);
};
const toggleBulkSelected = (id: string) => {
setSelectedBulkIds((current) =>
current.includes(id)
? current.filter((entry) => entry !== id)
: [...current, id],
);
};
const confirmBulkDelete = () => {
if (!bulkDeleteTab || selectedBulkIds.length === 0) {
return;
}
const label = bulkDeleteTab === 'story' ? '场景角色' : '场景';
const confirmed = window.confirm(
`确认批量删除 ${selectedBulkIds.length}${label}吗?`,
);
if (!confirmed) {
return;
}
if (bulkDeleteTab === 'story') {
onDeleteStoryNpcs?.(selectedBulkIds);
} else {
onDeleteLandmarks?.(selectedBulkIds);
}
cancelBulkDelete();
};
return (
@@ -378,13 +433,37 @@ export function CustomWorldEntityCatalog({
</div>
{activeTab !== 'world' && activeTab !== 'anchors' ? (
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
</div>
{createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
{isBulkDeleteMode ? (
<>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300">
{selectedBulkIds.length}
</div>
<SmallButton onClick={cancelBulkDelete}></SmallButton>
<SmallButton
onClick={confirmBulkDelete}
tone="rose"
>
</SmallButton>
</>
) : (
<>
{createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
) : null}
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
</SmallButton>
) : null}
</>
)}
</div>
</div>
) : null}
</div>
@@ -560,14 +639,6 @@ export function CustomWorldEntityCatalog({
subtitle={role.title}
actions={(
<div className="flex items-center gap-2">
{onRegeneratePlayableNpc && !lockedCharacterNames.has(role.name.trim()) ? (
<SmallButton
onClick={() => onRegeneratePlayableNpc(role.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose"></SmallButton>
</div>
@@ -646,111 +717,32 @@ export function CustomWorldEntityCatalog({
{activeTab === 'story' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateStoryExpansion ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateStoryExpansion} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map(npc => (
<div key={npc.id}>
<Section
<CatalogCard
title={npc.name}
subtitle={npc.role}
actions={(
<div className="flex items-center gap-2">
{onRegenerateStoryNpc && !lockedCharacterNames.has(npc.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateStoryNpc(npc.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
description={npc.description}
isSelectionMode={isBulkDeleteMode}
isSelected={selectedBulkIds.includes(npc.id)}
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(npc.id)
: onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })
}
media={(
<CustomWorldNpcPortrait
npc={npc}
profile={profile}
visual={npc.visual}
className="aspect-square"
scale={2.18}
preferImageSrc
/>
<div className="min-w-0 space-y-3">
{lockedCharacterNames.has(npc.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
{npc.backstoryReveal.publicSummary || '未填写'}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.title}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.initialAffinity}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.personality || '未填写'}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.combatStyle || '未填写'}</div>
</div>
{npc.backstory ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.backstory}</div>
) : null}
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.motivation}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{npc.backstoryReveal.chapters.map(chapter => (
<div key={`${npc.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{chapter.affinityRequired} · {chapter.title}{chapter.teaser}
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{npc.skills.map(skill => (
<div key={`${npc.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{skill.name} · {skill.style}{skill.summary}
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{npc.initialItems.map(item => (
<div key={`${npc.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{item.name} x{item.quantity} · {item.category} · {item.rarity}{item.description}
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{npc.relationshipHooks.map(hook => (
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{hook}
</span>
))}
{npc.tags.map(tag => (
<span key={`${npc.id}-tag-${tag}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
{tag}
</span>
))}
</div>
</div>
</div>
</Section>
)}
/>
</div>
))
)}
@@ -759,85 +751,30 @@ export function CustomWorldEntityCatalog({
{activeTab === 'landmarks' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateLandmarkNetwork ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateLandmarkNetwork} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredLandmarks.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredLandmarks.map(landmark => (
<div key={landmark.id}>
<Section
<CatalogCard
title={landmark.name}
actions={(
<div className="flex items-center gap-2">
{onRegenerateLandmark && !lockedLandmarkNames.has(landmark.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateLandmark(landmark.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="space-y-3">
{lockedLandmarkNames.has(landmark.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
description={landmark.description}
isSelectionMode={isBulkDeleteMode}
isSelected={selectedBulkIds.includes(landmark.id)}
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(landmark.id)
: onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })
}
media={(
<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 || '未填写'}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"> NPC</div>
<div className="mt-2 flex flex-wrap gap-2">
{landmark.sceneNpcIds.length > 0 ? (
landmark.sceneNpcIds.map((npcId) => (
<span key={`${landmark.id}-npc-${npcId}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
{storyNpcById.get(npcId)?.name ?? '未匹配角色'}
</span>
))
) : (
<span className="text-xs text-zinc-500"></span>
)}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{landmark.connections.length > 0 ? (
landmark.connections.map((connection) => (
<div key={`${landmark.id}-connection-${connection.targetLandmarkId}-${connection.relativePosition}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} · {landmarkById.get(connection.targetLandmarkId)?.name ?? '未匹配场景'}
{connection.summary ? `${connection.summary}` : ''}
</div>
))
) : (
<div className="text-xs text-zinc-500"></div>
)}
</div>
</div>
</div>
</Section>
)}
/>
</div>
))
)}

View File

@@ -1,8 +1,8 @@
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import type { ChangeEvent } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
} from '../data/affinityLevels';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
@@ -21,10 +21,6 @@ import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/ai';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
@@ -42,6 +38,7 @@ import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
import { PixelIcon } from './PixelIcon';
export type CustomWorldEditorTarget =
@@ -170,6 +167,15 @@ function useDraft<T>(value: T) {
return [draft, setDraft] as const;
}
function readImageFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败。'));
reader.readAsDataURL(file);
});
}
function ModalShell({
title,
subtitle,
@@ -179,6 +185,7 @@ function ModalShell({
overlayClassName = 'z-[98]',
bodyClassName = '',
disableClose = false,
usePixelFont = false,
}: {
title: string;
subtitle?: string;
@@ -188,6 +195,7 @@ function ModalShell({
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
@@ -195,7 +203,7 @@ function ModalShell({
onClick={disableClose ? undefined : onClose}
>
<div
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${panelClassName} sm:rounded-[1.75rem]`}
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
@@ -229,6 +237,83 @@ function ModalShell({
);
}
function _PortalModalShell(props: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<ModalShell {...props} />, document.body);
}
function CompactDialogShell({
title,
onClose,
children,
overlayClassName = 'z-[140]',
disableClose = false,
usePixelFont = false,
}: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
onClick={disableClose ? undefined : onClose}
>
<div
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
<div className="min-w-0 text-sm font-semibold text-white">
{title}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
function PortalCompactDialogShell(props: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<CompactDialogShell {...props} />, document.body);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
const hasVisibleChildren = Children.toArray(children).some(
(child) => !(typeof child === 'string' && child.trim().length === 0),
@@ -401,12 +486,14 @@ function ActionButton({
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky';
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
}) {
const toneClassName =
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: tone === 'rose'
? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
return (
@@ -556,36 +643,7 @@ function ScenePresetPickerModal({
);
}
function AiComingSoonModal({
title,
subtitle,
onClose,
}: {
title: string;
subtitle: string;
onClose: () => void;
}) {
return (
<ModalShell title={title} subtitle={subtitle} onClose={onClose}>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.18),transparent_55%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] px-6 py-10 text-center">
<div className="whitespace-pre-line text-2xl font-black tracking-[0.2em] text-white">
{'\n'}
</div>
</div>
<div className="flex justify-end">
<ActionButton label="知道了" onClick={onClose} tone="sky" />
</div>
</div>
</ModalShell>
);
}
const SCENE_IMAGE_SIZE_OPTIONS = [
{ value: '1280*720', label: '横版 16:9推荐' },
{ value: '1280*1280', label: '方图 1:1' },
{ value: '960*1280', label: '竖版 3:4' },
] as const;
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
@@ -598,27 +656,17 @@ function SceneImageGenerationModal({
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
const defaultPrompt = useMemo(
() => buildCustomWorldSceneImagePrompt(profile, landmark),
[profile, landmark],
);
const [prompt, setPrompt] = useDraft(defaultPrompt);
const [negativePrompt, setNegativePrompt] = useDraft(
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
);
const [size, setSize] = useDraft<string>(
SCENE_IMAGE_SIZE_OPTIONS[0]?.value ?? '1280*720',
const [userPrompt, setUserPrompt] = useDraft(
landmark.name.trim() || landmark.description.trim(),
);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const previewImageSrc = useMemo(() => {
if (latestResult?.imageSrc) {
return latestResult.imageSrc;
}
const originalImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
@@ -632,11 +680,46 @@ function SceneImageGenerationModal({
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, latestResult, profile]);
}, [landmark, profile]);
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageFileAsDataUrl(file);
setReferenceImageSrc(dataUrl);
setError(null);
} catch (uploadError) {
setError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const handleRequestClose = () => {
if (isGenerating) {
return;
}
if (latestResult) {
setIsExitConfirmOpen(true);
return;
}
onClose();
};
const handleGenerate = async () => {
if (!prompt.trim()) {
setError('请先填写场景提示词。');
if (!userPrompt.trim()) {
setError('请先描述想要生成的画面内容。');
return;
}
@@ -647,12 +730,11 @@ function SceneImageGenerationModal({
const result = await generateCustomWorldSceneImage({
profile,
landmark,
prompt,
negativePrompt,
size,
userPrompt,
size: FIXED_SCENE_IMAGE_SIZE,
...(referenceImageSrc ? { referenceImageSrc } : {}),
});
setLatestResult(result);
onApply(result);
} catch (generationError) {
setError(
generationError instanceof Error
@@ -664,136 +746,195 @@ function SceneImageGenerationModal({
}
};
const handleSave = () => {
if (!latestResult || isGenerating) {
return;
}
onApply(latestResult);
onClose();
};
return (
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
subtitle="会调用阿里云文生图模型生成新的场景背景,并立即回写到当前编辑草稿。"
onClose={onClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
<>
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
usePixelFont
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="space-y-4">
<Field label="画面内容描述">
<TextArea
value={userPrompt}
onChange={(value) => setUserPrompt(value)}
rows={8}
placeholder="例如:雨夜的悬桥横跨黑色峡谷,桥下翻涌蓝绿色雾潮,远处有半坍塌塔楼与零星灯火。"
/>
</Field>
<Field label="自定义参考图(可选)">
<div className="space-y-3">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
<img
src={referenceImageSrc}
alt="自定义参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
</div>
<ActionButton
label="移除"
onClick={() => setReferenceImageSrc('')}
disabled={isGenerating}
/>
</div>
) : null}
</div>
</Field>
</div>
<Field label="场景提示词">
<TextArea
value={prompt}
onChange={(value) => setPrompt(value)}
rows={10}
placeholder="描述这个场景的地貌、建筑、天气、光线与氛围。"
/>
</Field>
<Field label="反向提示词">
<TextArea
value={negativePrompt}
onChange={(value) => setNegativePrompt(value)}
rows={4}
placeholder="例如文字、水印、logo、UI界面、人物近景。"
/>
</Field>
<Field label="图片比例">
<SelectField
value={size}
onChange={setSize}
options={SCENE_IMAGE_SIZE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
/>
</Field>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={landmark.name ? landmark.name.slice(0, 4) : '场景'}
tone="landscape"
/>
</div>
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
稿{latestResult.model}
{latestResult.size}
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={
landmark.name ? landmark.name.slice(0, 4) : '场景'
}
tone="landscape"
/>
</div>
) : (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
)}
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
退
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="关闭"
onClick={onClose}
disabled={isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="保存"
onClick={handleSave}
disabled={!latestResult || isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
</div>
</div>
</div>
</div>
</ModalShell>
</ModalShell>
{isExitConfirmOpen ? (
<PortalCompactDialogShell
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
usePixelFont
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
退退
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsExitConfirmOpen(false)}
/>
<ActionButton
label="仍然退出"
onClick={() => {
setIsExitConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function SaveBar({
onClose,
onSave,
extraAction,
}: {
onClose: () => void;
onSave: () => void;
extraAction?: ReactNode;
}) {
return (
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
<div
className={`flex flex-col gap-3 ${
extraAction
? 'sm:flex-row sm:items-center sm:justify-between'
: 'sm:flex-row sm:justify-end'
}`}
>
</button>
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
{extraAction ? (
<div className="flex flex-col gap-3 sm:flex-row">{extraAction}</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
</div>
);
@@ -1025,7 +1166,9 @@ function SkillListEditor({
<ActionButton
label="删除技能"
onClick={() =>
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
onChange(
value.filter((_skill, skillIndex) => skillIndex !== index),
)
}
/>
</div>
@@ -1120,7 +1263,9 @@ function InitialItemsEditor({
<ActionButton
label="删除物品"
onClick={() =>
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
onChange(
value.filter((_item, itemIndex) => itemIndex !== index),
)
}
/>
</div>
@@ -1209,15 +1354,15 @@ function StoryNpcVisualEditorModal({
npc,
visual,
onChange,
onOpenAiStudio,
onClose,
}: {
npc: CustomWorldNpc;
visual: NonNullable<CustomWorldNpc['visual']>;
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
onOpenAiStudio?: () => void;
onClose: () => void;
}) {
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
return (
<ModalShell
title={`修改形象:${npc.name}`}
@@ -1235,15 +1380,11 @@ function StoryNpcVisualEditorModal({
}}
value={visual}
onChange={onChange}
onAiGenerate={() => setIsAiGenerateOpen(true)}
onAiGenerate={() => {
onClose();
onOpenAiStudio?.();
}}
/>
{isAiGenerateOpen ? (
<AiComingSoonModal
title="智能生成场景角色形象"
subtitle="场景角色形象智能生成功能仍在开发中。"
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
</ModalShell>
);
}
@@ -1391,7 +1532,7 @@ function WorldEditor({
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={(
footer={
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
@@ -1408,7 +1549,7 @@ function WorldEditor({
</div>
</div>
)}
}
/>
<Field label="玩家原始设定">
<TextArea
@@ -1475,6 +1616,7 @@ function PlayableNpcEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const selectedTemplate =
PRESET_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
@@ -1493,7 +1635,7 @@ function PlayableNpcEditor({
<div className="grid gap-4 rounded-2xl border border-white/8 bg-black/20 p-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
src={selectedTemplate.portrait}
src={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
className="h-28 w-full object-cover object-top"
/>
@@ -1511,6 +1653,25 @@ function PlayableNpcEditor({
<div className="mt-3 text-sm leading-6 text-zinc-300">
{selectedTemplate.description}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-3">
<ActionButton
label="AI生成形象与动作"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
</div>
) : null}
@@ -1678,6 +1839,19 @@ function PlayableNpcEditor({
onClose();
}}
/>
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="playable"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1696,6 +1870,7 @@ function StoryNpcEditor({
}) {
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
return (
<ModalShell
@@ -1712,6 +1887,7 @@ function StoryNpcEditor({
visual={draft.visual}
className="aspect-square w-full max-w-[9.5rem]"
scale={2.05}
preferImageSrc
/>
</div>
<div className="min-w-0 space-y-3">
@@ -1729,6 +1905,23 @@ function StoryNpcEditor({
onClick={() => setIsVisualEditorOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成形象与动作"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
<div className="flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
</div>
</div>
@@ -1892,9 +2085,23 @@ function StoryNpcEditor({
onChange={(visual) =>
setDraft((current) => ({ ...current, visual }))
}
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
onClose={() => setIsVisualEditorOpen(false)}
/>
) : null}
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="story"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1957,7 +2164,9 @@ function LandmarkEditor({
const updateConnection = (
index: number,
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
updater: (
connection: CustomWorldSceneConnection,
) => CustomWorldSceneConnection,
) => {
setDraft((current) => ({
...current,
@@ -1996,7 +2205,9 @@ function LandmarkEditor({
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
: profile.landmarks.map((entry) =>
entry.id === draft.id ? draft : entry,
);
onSaveProfile({
...profile,
@@ -2077,7 +2288,8 @@ function LandmarkEditor({
NPC
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
3 NPC NPC
3 NPC
NPC
</div>
</div>
<ActionButton
@@ -2179,11 +2391,7 @@ function LandmarkEditor({
线
</div>
</div>
<ActionButton
label="新增连接"
onClick={addConnection}
tone="sky"
/>
<ActionButton label="新增连接" onClick={addConnection} tone="sky" />
</div>
<div className="mt-3 space-y-3">
{draft.connections.length > 0 ? (
@@ -2214,7 +2422,8 @@ function LandmarkEditor({
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
relativePosition:
value as CustomWorldSceneConnection['relativePosition'],
}))
}
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
@@ -2246,7 +2455,8 @@ function LandmarkEditor({
setDraft((current) => ({
...current,
connections: current.connections.filter(
(_item, connectionIndex) => connectionIndex !== index,
(_item, connectionIndex) =>
connectionIndex !== index,
),
}))
}
@@ -2309,7 +2519,9 @@ function LandmarkEditor({
setDraftStoryNpcs((current) =>
npcEditorState.mode === 'create'
? [...current, nextNpc]
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
: current.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
);
setDraft((current) => ({
...current,
@@ -2428,7 +2640,9 @@ function createPlayableNpc(
};
}
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
function createStoryNpc(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
@@ -2638,15 +2852,15 @@ export function CustomWorldEntityEditorModal({
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);

View File

@@ -23,12 +23,19 @@ import {
type MedievalRace,
sanitizeCustomWorldNpcVisual,
} from '../data/medievalNpcVisuals';
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
import {
type CustomWorldNpc,
type CustomWorldNpcVisual,
type CustomWorldProfile,
} from '../types';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
type EditableNpcSource = Pick<
CustomWorldNpc,
'id' | 'name' | 'role' | 'description' | 'imageSrc'
>
& Partial<
Pick<
CustomWorldNpc,
@@ -291,25 +298,37 @@ function ActionButton({
export function CustomWorldNpcPortrait({
npc,
profile,
visual,
className = '',
scale = 2.05,
preferImageSrc = false,
}: {
npc: EditableNpcSource;
profile?: CustomWorldProfile | null;
visual?: CustomWorldNpcVisual;
className?: string;
scale?: number;
preferImageSrc?: boolean;
}) {
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
const monsterPreset = visual
? null
: resolveCustomWorldNpcMonsterPreset(npc);
: resolveCustomWorldNpcMonsterPreset(npc, undefined, profile ?? null);
const preferredImageSrc =
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
{monsterPreset ? (
{preferredImageSrc ? (
<img
src={preferredImageSrc}
alt={npc.name}
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
) : monsterPreset ? (
<div
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
style={{
@@ -333,11 +352,13 @@ export function CustomWorldNpcPortrait({
export function CustomWorldNpcVisualEditor({
npc,
profile,
value,
onChange,
onAiGenerate,
}: {
npc: EditableNpcSource;
profile?: CustomWorldProfile | null;
value?: CustomWorldNpcVisual;
onChange: (value: CustomWorldNpcVisual) => void;
onAiGenerate: () => void;
@@ -411,6 +432,7 @@ export function CustomWorldNpcVisualEditor({
<div className="mx-auto w-full max-w-[9.5rem] space-y-3">
<CustomWorldNpcPortrait
npc={npc}
profile={profile}
visual={effectiveVisual}
className="aspect-square"
scale={2.05}

View File

@@ -1,5 +1,6 @@
import { type ReactNode,useMemo, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
@@ -16,11 +17,6 @@ interface CustomWorldResultViewProps {
onEditSetting: () => void;
onRegenerate: () => void;
onContinueExpand?: () => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
@@ -66,6 +62,46 @@ function getCreateLabelByTab(activeTab: ResultTab) {
return '';
}
function removeStoryNpcsFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
return {
...profile,
storyNpcs: nextStoryNpcs,
landmarks: normalizeCustomWorldLandmarks({
landmarks: profile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
})),
storyNpcs: nextStoryNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) {
const idSet = new Set(ids);
const nextLandmarks = profile.landmarks.filter(
(landmark) => !idSet.has(landmark.id),
);
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: nextLandmarks.map((landmark) => ({
...landmark,
connections: landmark.connections.filter(
(connection) => !idSet.has(connection.targetLandmarkId),
),
})),
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
export function CustomWorldResultView({
profile,
previewCharacters,
@@ -77,11 +113,6 @@ export function CustomWorldResultView({
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
onSave,
onProfileChange,
}: CustomWorldResultViewProps) {
@@ -101,6 +132,16 @@ export function CustomWorldResultView({
triggerRegenerate();
};
const handleDeleteStoryNpcs = (ids: string[]) => {
if (ids.length === 0) return;
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
};
const handleDeleteLandmarks = (ids: string[]) => {
if (ids.length === 0) return;
onProfileChange(removeLandmarksFromProfile(profile, ids));
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex justify-start">
@@ -122,11 +163,8 @@ export function CustomWorldResultView({
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onRegeneratePlayableNpc={onRegeneratePlayableNpc}
onRegenerateStoryNpc={onRegenerateStoryNpc}
onRegenerateLandmark={onRegenerateLandmark}
onRegenerateStoryExpansion={onRegenerateStoryExpansion}
onRegenerateLandmarkNetwork={onRegenerateLandmarkNetwork}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
/>

View File

@@ -0,0 +1,987 @@
import {
CheckCircle2,
Film,
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
type CustomWorldPlayableNpc,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import {
buildAnimationClipFromVideoSource,
type DraftAnimationClip,
readFileAsDataUrl,
} from './preset-editor/characterAssetStudioModel';
import {
type CharacterAnimationDraftPayload,
type CharacterAnimationGenerationPayload,
type CharacterVisualDraft,
type CharacterVisualSourceMode,
generateCharacterAnimationDraft,
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from './preset-editor/characterAssetStudioPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
type CustomWorldAiActionConfig = {
animation: AnimationState;
label: string;
templateId: string;
fps: number;
frameCount: number;
durationSeconds: number;
loop: boolean;
};
const VISUAL_SOURCE_OPTIONS: Array<{
label: string;
value: Exclude<CharacterVisualSourceMode, 'upload'>;
}> = [
{ label: '纯文生主图', value: 'text-to-image' },
{ label: '参考图生主图', value: 'image-to-image' },
];
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{
animation: AnimationState.RUN,
label: '奔跑',
templateId: 'run',
fps: 12,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{
animation: AnimationState.ATTACK,
label: '攻击',
templateId: 'attack_slash',
fps: 12,
frameCount: 8,
durationSeconds: 3,
loop: false,
},
{
animation: AnimationState.HURT,
label: '受击',
templateId: 'hurt',
fps: 10,
frameCount: 6,
durationSeconds: 3,
loop: false,
},
{
animation: AnimationState.DIE,
label: '死亡',
templateId: 'die',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: false,
},
];
function ModalShell({
title,
subtitle,
onClose,
disableClose = false,
children,
}: {
title: string;
subtitle?: string;
onClose: () => void;
disableClose?: boolean;
children: React.ReactNode;
}) {
return (
<div
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4"
onClick={disableClose ? undefined : onClose}
>
<div
className="flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-4 py-4 sm:px-5">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{title}
</div>
{subtitle ? (
<div className="mt-1 text-xs leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-300 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
{children}
</div>
</div>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-4 rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">
{title}
</div>
{children}
</section>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
{children}
</label>
);
}
function SelectInput({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function TextArea({
value,
onChange,
rows = 4,
placeholder,
readOnly = false,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
readOnly?: boolean;
}) {
return (
<textarea
rows={rows}
value={value}
placeholder={placeholder}
readOnly={readOnly}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function StatusBadge({
tone,
children,
}: {
tone: 'green' | 'amber' | 'zinc';
children: ReactNode;
}) {
const toneClassName = {
green: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100',
amber: 'border-amber-400/30 bg-amber-500/10 text-amber-100',
zinc: 'border-white/10 bg-black/20 text-zinc-300',
}[tone];
return (
<span
className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] ${toneClassName}`}
>
{children}
</span>
);
}
function ActionButton({
icon,
label,
onClick,
disabled = false,
tone = 'default',
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
tone?: 'default' | 'sky' | 'green';
}) {
const toneClassName =
tone === 'green'
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
: tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{icon}
<span>{label}</span>
</button>
);
}
function buildRoleCharacterBrief(
role: EditableCustomWorldRole,
templateLabel?: string,
) {
return [
`角色名称:${role.name}`,
`角色头衔:${role.title}`,
`世界身份:${role.role}`,
role.description ? `角色描述:${role.description}` : '',
role.backstory ? `角色背景:${role.backstory}` : '',
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
.join('\n');
}
function mergeRole<T extends EditableCustomWorldRole>(
role: T,
patch: Partial<T>,
) {
return {
...role,
...patch,
};
}
export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onClose,
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply: (nextRole: EditableCustomWorldRole) => void;
onClose: () => void;
}) {
const [sourceMode, setSourceMode] =
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
role.imageSrc ? 'image-to-image' : 'text-to-image',
);
const [visualPromptText, setVisualPromptText] = useState('');
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
string[]
>([]);
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
const [visualStatus, setVisualStatus] = useState<string | null>(null);
const [isGeneratingVisuals, setIsGeneratingVisuals] = useState(false);
const [isApplyingVisual, setIsApplyingVisual] = useState(false);
const [selectedAnimation, setSelectedAnimation] = useState<AnimationState>(
CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE,
);
const [animationPromptText, setAnimationPromptText] = useState('');
const [draftAnimations, setDraftAnimations] = useState<
Partial<Record<AnimationState, DraftAnimationClip>>
>({});
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
? PRESET_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
role,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
[role, selectedTemplate],
);
const effectiveReferenceImages =
referenceImageDataUrls.length > 0
? referenceImageDataUrls
: role.imageSrc
? [role.imageSrc]
: [];
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
role.imageSrc ??
selectedTemplate?.portrait ??
'';
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const appliedActionCount = CORE_ACTIONS.filter(
(item) => role.animationMap?.[item.animation]?.basePath,
).length;
const handleReferenceImageUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const uploadedDataUrls = await Promise.all(
Array.from(fileList)
.slice(0, 4)
.map((file) => readFileAsDataUrl(file)),
);
setReferenceImageDataUrls(uploadedDataUrls);
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。`);
event.target.value = '';
};
const handleGenerateVisuals = async () => {
setIsGeneratingVisuals(true);
setVisualStatus(null);
try {
if (
sourceMode === 'image-to-image' &&
effectiveReferenceImages.length === 0
) {
throw new Error('参考图生主图至少需要一张参考图。');
}
const result = await generateCharacterVisualCandidates({
characterId: role.id,
sourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: effectiveReferenceImages,
candidateCount: 3,
imageModel: 'wan2.7-image-pro',
size: '1024*1536',
});
setVisualDrafts(result.drafts);
setSelectedVisualDraftId(result.drafts[0]?.id ?? '');
setVisualStatus(`已生成 ${result.drafts.length} 个主图候选。`);
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '生成角色主图失败。',
);
} finally {
setIsGeneratingVisuals(false);
}
};
const handleApplyVisual = async () => {
if (!selectedVisualDraft) {
setVisualStatus('请先选择一个主图候选。');
return;
}
setIsApplyingVisual(true);
setVisualStatus(null);
try {
const result = await publishCharacterVisualAsset({
characterId: role.id,
sourceMode,
promptText: visualPromptText,
selectedPreviewSource: selectedVisualDraft.imageSrc,
previewSources: visualDrafts.map((draft) => draft.imageSrc),
width: selectedVisualDraft.width,
height: selectedVisualDraft.height,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}),
);
setDraftAnimations({});
setAnimationStatus(null);
setVisualStatus('主图已应用到当前角色,可继续生成核心动作。');
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '应用角色主图失败。',
);
} finally {
setIsApplyingVisual(false);
}
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
throw new Error('请先应用主图,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: role.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: role.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
loop: config.loop,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (result.strategy !== 'image-to-video') {
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
}
return buildAnimationClipFromVideoSource(result.previewVideoPath, {
animation: config.animation,
fps: config.fps,
loop: config.loop,
frameCount: config.frameCount,
applyChromaKey: true,
});
};
const handleGenerateSingleAnimation = async () => {
if (!selectedActionConfig) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
const clip = await generateActionClip(selectedActionConfig);
setDraftAnimations((current) => ({
...current,
[selectedActionConfig.animation]: clip,
}));
setAnimationStatus(`${selectedActionConfig.label} 动作草稿已生成。`);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '生成角色动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handleGenerateAllAnimations = async () => {
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
const nextDrafts: Partial<Record<AnimationState, DraftAnimationClip>> = {
...draftAnimations,
};
for (const config of CORE_ACTIONS) {
setAnimationStatus(`正在生成 ${config.label} 动作...`);
nextDrafts[config.animation] = await generateActionClip(config);
}
setDraftAnimations(nextDrafts);
setAnimationStatus('核心动作草稿已生成,可直接应用到当前角色。');
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '批量生成核心动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handleApplyAnimations = async () => {
if (!role.generatedVisualAssetId) {
setAnimationStatus('请先应用主图,再应用动作。');
return;
}
const animationEntries = Object.entries(draftAnimations).filter(
(entry): entry is [AnimationState, DraftAnimationClip] => Boolean(entry[1]),
);
if (animationEntries.length === 0) {
setAnimationStatus('请先生成至少一个核心动作草稿。');
return;
}
setIsApplyingAnimations(true);
setAnimationStatus(null);
try {
const payload = Object.fromEntries(
animationEntries.map(([animation, clip]) => [
animation,
{
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
} satisfies CharacterAnimationDraftPayload,
]),
);
const result = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
animations: payload,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...(role.animationMap ?? {}),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
}),
);
setAnimationStatus('核心动作已应用到当前角色。');
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '应用角色动作失败。',
);
} finally {
setIsApplyingAnimations(false);
}
};
return (
<ModalShell
title="AI 角色资产"
subtitle="先应用主图,再走图生视频抽帧生成核心动作。"
onClose={onClose}
disableClose={
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
isApplyingAnimations
}
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
<div className="space-y-5">
<Section title="阶段 A · 主图">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-4">
<Field label="主图方式">
<SelectInput
value={sourceMode}
onChange={(value) =>
setSourceMode(
value as Exclude<CharacterVisualSourceMode, 'upload'>,
)
}
options={VISUAL_SOURCE_OPTIONS}
/>
</Field>
<Field label="形象补充要求">
<TextArea
value={visualPromptText}
onChange={setVisualPromptText}
rows={6}
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
/>
</Field>
<Field label="参考图">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={handleReferenceImageUpload}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
<div className="mt-3 flex flex-wrap gap-2">
{effectiveReferenceImages.map((imageSrc, index) => (
<div
key={`${imageSrc}-${index}`}
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
>
<img
src={imageSrc}
alt={`reference-${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
{effectiveReferenceImages.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-4 py-3 text-xs text-zinc-500">
使
</div>
) : null}
</div>
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<ImagePlus className="h-4 w-4" />}
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals}
tone="sky"
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingVisual ? '应用中...' : '应用主图'}
onClick={() => void handleApplyVisual()}
disabled={isApplyingVisual || !selectedVisualDraft}
tone="green"
/>
</div>
{visualStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{visualStatus}
</div>
) : null}
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))]">
<div className="flex min-h-[20rem] items-center justify-center p-4">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
<img
src={selectedTemplate.portrait}
alt={selectedTemplate.name}
className="max-h-[20rem] w-full object-contain"
/>
) : (
<div className="px-6 text-center text-sm leading-7 text-zinc-500">
</div>
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{visualDrafts.map((draft) => {
const isSelected = draft.id === selectedVisualDraftId;
return (
<button
key={draft.id}
type="button"
onClick={() => setSelectedVisualDraftId(draft.id)}
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
isSelected
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="aspect-[3/4] overflow-hidden bg-black/30 p-2">
<img
src={draft.imageSrc}
alt={draft.label}
className="h-full w-full object-contain"
/>
</div>
<div className="flex items-center justify-between gap-3 px-3 py-3">
<div className="text-sm text-white">{draft.label}</div>
{isSelected ? (
<StatusBadge tone="green"></StatusBadge>
) : null}
</div>
</button>
);
})}
{visualDrafts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-black/20 px-4 py-8 text-sm text-zinc-500 sm:col-span-2">
</div>
) : null}
</div>
</div>
</div>
</Section>
<Section title="阶段 B · 核心动作">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-4">
<Field label="动作槽位">
<SelectInput
value={selectedAnimation}
onChange={(value) =>
setSelectedAnimation(value as AnimationState)
}
options={CORE_ACTIONS.map((item) => ({
value: item.animation,
label: item.label,
}))}
/>
</Field>
<Field label="动作补充要求">
<TextArea
value={animationPromptText}
onChange={setAnimationPromptText}
rows={5}
placeholder="例如:剑客攻击更干脆,收招更稳;奔跑时衣摆不要飘得过大。"
/>
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={
isGeneratingAnimations
? '生成中...'
: `生成${selectedActionConfig?.label ?? '当前'}动作`
}
onClick={() => void handleGenerateSingleAnimation()}
disabled={isGeneratingAnimations || !role.imageSrc}
tone="sky"
/>
<ActionButton
icon={<Film className="h-4 w-4" />}
label={
isGeneratingAnimations ? '生成中...' : '生成核心动作'
}
onClick={() => void handleGenerateAllAnimations()}
disabled={isGeneratingAnimations || !role.imageSrc}
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingAnimations ? '应用中...' : '应用动作'}
onClick={() => void handleApplyAnimations()}
disabled={isApplyingAnimations}
tone="green"
/>
</div>
{animationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
</div>
) : null}
</div>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
{role.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CORE_ACTIONS.map((item) => {
const hasDraft = Boolean(draftAnimations[item.animation]);
const isApplied = Boolean(
role.animationMap?.[item.animation]?.basePath,
);
return (
<div
key={item.animation}
className={`rounded-2xl border px-4 py-4 ${
item.animation === selectedAnimation
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/10 bg-black/20'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{item.label}
</div>
{hasDraft ? (
<StatusBadge tone="green">稿</StatusBadge>
) : isApplied ? (
<StatusBadge tone="amber"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="mt-2 text-xs leading-6 text-zinc-500">
</div>
</div>
);
})}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
<div className="mb-3 text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<div className="flex items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{roleKind === 'playable' && selectedTemplate ? (
<div className="h-[220px] w-[220px]">
<CharacterAnimator
state={selectedAnimation}
character={{
...selectedTemplate,
id: role.id,
name: role.name,
title: role.title,
portrait: role.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId:
role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(selectedTemplate.animationMap ?? {}),
...role.animationMap,
}
: selectedTemplate.animationMap,
}}
className="h-full w-full"
/>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
className="max-h-[16rem] w-full object-contain"
/>
) : (
<div className="px-4 text-sm text-zinc-500">
</div>
)}
</div>
</div>
</div>
</div>
</Section>
</div>
<div className="space-y-5">
<Section title="当前角色档案">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-lg font-semibold text-white">{role.name}</div>
<div className="mt-1 text-sm text-zinc-400">
{role.title} / {role.role}
</div>
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
{role.description ? <div>{role.description}</div> : null}
{role.combatStyle ? <div>{role.combatStyle}</div> : null}
{role.tags.length > 0 ? <div>{role.tags.join('、')}</div> : null}
</div>
</div>
<Field label="自动提示词依据">
<TextArea
value={characterBriefText}
onChange={() => {}}
rows={14}
placeholder=""
readOnly
/>
</Field>
</Section>
<Section title="当前进度">
<div className="grid gap-3">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{role.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{appliedActionCount > 0 ? (
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
</div>
</div>
</Section>
</div>
</div>
</ModalShell>
);
}

View File

@@ -14,11 +14,11 @@ import {
} from '../hooks/useStoryGeneration';
import {
type Character,
type CustomWorldProfile,
type CompanionRenderState,
type GameState,
type StoryMoment,
type StoryOption,
type WorldType,
} from '../types';
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
@@ -58,7 +58,7 @@ interface GameShellEntryProps {
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}
@@ -211,7 +211,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
@@ -430,7 +430,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
)}

View File

@@ -150,7 +150,10 @@ function TradeQuantityStepper({
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
const currencyName = getCurrencyName(gameState.worldType);
const currencyName = getCurrencyName(
gameState.worldType,
gameState.customWorldProfile,
);
const tradeModal = npcUi.tradeModal;
const tradeNpcState = tradeModal
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]

View File

@@ -1135,84 +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={`${activeGoalQuest ? 'mt-3' : ''} space-y-2`}>
<div className="space-y-2">
{sortedQuests.map(quest => (
<button
<div
key={quest.id}
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition hover:border-white/15"
className="rounded-2xl border border-white/8 bg-black/20 transition hover:border-white/15 focus-within:border-white/15"
>
<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
? '当前主任务'
: '任务'}
<button
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full px-3 pt-3 text-left focus:outline-none"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500"></div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
: quest.status === 'turned_in'
? 'border border-white/10 bg-white/8 text-zinc-200'
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
}`}>
{getQuestStatusLabel(quest.status)}
</span>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
: quest.status === 'turned_in'
? 'border border-white/10 bg-white/8 text-zinc-200'
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
}`}>
{getQuestStatusLabel(quest.status)}
</span>
<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>
</button>
<div className="px-3 pb-3">
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
/>
</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>
))}
</div>
) : (

View File

@@ -10,10 +10,10 @@ import type {
} from '../../hooks/useStoryGeneration';
import type {
CompanionRenderState,
CustomWorldProfile,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
@@ -51,7 +51,7 @@ export function GameShellMainContent({
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
displayedOptions,
@@ -88,7 +88,7 @@ export function GameShellMainContent({
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
displayedOptions: StoryOption[];
@@ -132,7 +132,7 @@ export function GameShellMainContent({
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
)}

View File

@@ -40,7 +40,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
@@ -228,7 +228,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleCustomWorldSelect={handleCustomWorldSelect}
handleBackToWorldSelect={handleBackToWorldSelect}
handleCharacterSelect={handleCharacterSelect}
displayedOptions={displayedOptions}

View File

@@ -9,7 +9,6 @@ import {
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { getScenePreset } from '../../data/scenePresets';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
@@ -25,7 +24,6 @@ import {
type CustomWorldGenerationMode,
type CustomWorldProfile,
type GameState,
WorldType,
} from '../../types';
import {
CHROME_ICONS,
@@ -45,8 +43,6 @@ export type SelectionStage =
| 'custom-world-generating'
| 'custom-world-result';
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
@@ -54,10 +50,7 @@ type PreGameSelectionFlowProps = {
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (
type: WorldType,
customWorldProfile?: GameState['customWorldProfile'],
) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
const DEVELOPER_TEAM_MESSAGE =
@@ -68,32 +61,6 @@ const START_SCREEN_CONTACTS = [
{ label: '微信', value: 'bzh253518756' },
] as const;
const WORLD_OPTIONS = [
{
id: WorldType.WUXIA,
name: '武侠',
subtitle: '刀剑江湖',
icon: WORLD_SELECT_ICONS.wuxia,
texture: UI_CHROME.worldButtonWuxia,
},
{
id: WorldType.XIANXIA,
name: '仙侠',
subtitle: '云灵仙境',
icon: WORLD_SELECT_ICONS.xianxia,
texture: UI_CHROME.worldButtonXianxia,
},
] as const;
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
return {
[WorldType.WUXIA]: roll(146),
[WorldType.XIANXIA]: roll(173),
};
}
function buildLockedSeedNameSets(profile: CustomWorldProfile) {
const lockedCharacterNames = new Set(
profile.creatorIntent?.keyCharacters
@@ -168,7 +135,7 @@ export function PreGameSelectionFlow({
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
handleCustomWorldSelect,
}: PreGameSelectionFlowProps) {
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<GameState['customWorldProfile']>(null);
@@ -176,9 +143,6 @@ export function PreGameSelectionFlow({
CustomWorldProfile[]
>(() => readSavedCustomWorldProfiles());
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState<CustomWorldCreatorIntent>(() =>
@@ -200,23 +164,6 @@ export function PreGameSelectionFlow({
[generatedCustomWorldProfile],
);
const worldCards = useMemo(
() =>
WORLD_OPTIONS.map((world, index) => ({
...world,
sceneImage:
getScenePreset(world.id, index + 1)?.imageSrc ??
getScenePreset(world.id, 0)?.imageSrc ??
'',
featureIcon:
world.id === WorldType.WUXIA
? '/Icons/03_Torch.png'
: '/Icons/19_Mana_potion.png',
onlineCount: worldOnlineCounts[world.id] ?? 0,
})),
[worldOnlineCounts],
);
const savedCustomWorldCards = useMemo(
() =>
savedCustomWorldProfiles.map((profile) => {
@@ -255,12 +202,6 @@ export function PreGameSelectionFlow({
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
}
}, [gameState.worldType, selectionStage]);
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
@@ -343,7 +284,7 @@ export function PreGameSelectionFlow({
return;
}
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
handleCustomWorldSelect(generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
@@ -454,147 +395,6 @@ export function PreGameSelectionFlow({
);
};
const regeneratePlayableNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.playableNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.playableNpcs[targetIndex] ??
regeneratedProfile.playableNpcs.find(
(entry) =>
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
return {
...currentProfile,
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
),
};
},
{
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.storyNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.storyNpcs[targetIndex] ??
regeneratedProfile.storyNpcs.find(
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
);
return {
...currentProfile,
storyNpcs: nextStoryNpcs,
landmarks: currentProfile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
npcId === id ? nextNpc.id : npcId,
),
})),
};
},
{
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateLandmark = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.landmarks.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextLandmark =
regeneratedProfile.landmarks[targetIndex] ??
regeneratedProfile.landmarks.find(
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
);
if (!nextLandmark) {
return currentProfile;
}
return {
...currentProfile,
landmarks: currentProfile.landmarks.map((entry, index) =>
index === targetIndex ? nextLandmark : entry,
),
};
},
{
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryExpansion = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
storyNpcs: regeneratedProfile.storyNpcs,
}),
{
confirmMessage:
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
generationMode: 'full',
},
);
};
const regenerateLandmarkNetwork = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
landmarks: currentProfile.landmarks.map((landmark, index) => ({
...landmark,
sceneNpcIds:
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
landmark.sceneNpcIds,
connections:
regeneratedProfile.landmarks[index]?.connections ??
landmark.connections,
})),
}),
{
confirmMessage:
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
generationMode: 'full',
},
);
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
@@ -800,7 +600,7 @@ export function PreGameSelectionFlow({
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
</div>
<button
type="button"
@@ -814,67 +614,14 @@ export function PreGameSelectionFlow({
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
{worldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() => handleWorldSelect(world.id)}
className="pixel-nine-slice pixel-pressable order-2 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.subtitle}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25">
<PixelIcon
src={world.featureIcon}
className="h-5 w-5 opacity-95"
/>
</div>
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
{world.name}
</div>
<PixelIcon
src={world.icon}
className="h-10 w-10 opacity-95"
/>
</div>
<div className="mt-auto">
<div className="text-3xl font-black text-white">
{world.subtitle}
</div>
<div className="mt-2 flex items-center gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
线 {world.onlineCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
</span>
</div>
</div>
</div>
</button>
))}
{savedCustomWorldCards.map((world) => (
<div key={world.id} className="order-1 relative">
<button
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
handleCustomWorldSelect(world.profile)
}
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
@@ -1013,21 +760,6 @@ export function PreGameSelectionFlow({
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onRegeneratePlayableNpc={(id) => {
void regeneratePlayableNpc(id);
}}
onRegenerateStoryNpc={(id) => {
void regenerateStoryNpc(id);
}}
onRegenerateLandmark={(id) => {
void regenerateLandmark(id);
}}
onRegenerateStoryExpansion={() => {
void regenerateStoryExpansion();
}}
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>

View File

@@ -9,11 +9,11 @@ import type {
} from '../../hooks/useStoryGeneration';
import type {
Character,
CustomWorldProfile,
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
export interface GameShellSessionProps {
@@ -46,7 +46,7 @@ export interface GameShellEntryProps {
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}

View File

@@ -43,6 +43,7 @@ export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
promptText: string;
characterBriefText?: string;
referenceImageDataUrls: string[];
candidateCount: number;
imageModel: string;
@@ -57,6 +58,7 @@ export type CharacterVisualPublishPayload = {
previewSources: string[];
width: number;
height: number;
updateCharacterOverride?: boolean;
};
export type CharacterAnimationGenerationPayload = {
@@ -64,6 +66,8 @@ export type CharacterAnimationGenerationPayload = {
strategy: CharacterAnimationStrategy;
animation: string;
promptText: string;
characterBriefText?: string;
actionTemplateId?: string;
visualSource: string;
referenceImageDataUrls: string[];
referenceVideoDataUrls: string[];
@@ -243,6 +247,7 @@ export async function publishCharacterAnimationAssets(payload: {
characterId: string;
visualAssetId: string;
animations: Record<string, CharacterAnimationDraftPayload>;
updateCharacterOverride?: boolean;
}) {
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
method: 'POST',
@@ -259,6 +264,7 @@ export async function publishCharacterAnimationAssets(payload: {
ok: true;
animationSetId: string;
overrideMap: Record<string, unknown>;
animationMap: Record<string, unknown>;
saveMessage: string;
};
}

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { AnimationState } from '../types';
import {
buildCustomWorldRuntimeCharacters,
getCharacterById,
@@ -136,6 +137,28 @@ describe('characterPresets custom world runtime characters', () => {
relationshipHooks: ['断桥旧案', '夜港潮路'],
tags: ['码头', '潮路', '短刀'],
imageSrc: '/custom/npcs/shenwu.png',
generatedVisualAssetId: 'visual-custom-shenwu',
generatedAnimationSetId: 'animation-set-custom-shenwu',
animationMap: {
[AnimationState.IDLE]: {
folder: 'idle',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
},
[AnimationState.ATTACK]: {
folder: 'attack',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/attack',
},
},
visual: {
race: 'human',
bodyColor: 'blue',
@@ -217,6 +240,15 @@ describe('characterPresets custom world runtime characters', () => {
expect(storyCharacter?.backstory).toContain('断桥坠潮夜');
expect(storyCharacter?.skills[0]?.name).toBe('技能11-1');
expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png');
expect(storyCharacter?.generatedVisualAssetId).toBe(
'visual-custom-shenwu',
);
expect(storyCharacter?.generatedAnimationSetId).toBe(
'animation-set-custom-shenwu',
);
expect(storyCharacter?.animationMap?.[AnimationState.IDLE]?.basePath).toBe(
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
);
expect(storyCharacter?.visual).toEqual(storyRole?.visual);
expect(storyCharacter?.groundOffsetY).toBe(22);

View File

@@ -1,4 +1,5 @@
import { buildThemedSkillName } from '../services/customWorldPresentation';
import { resolveRoleTemplateCharacterIdFromReferenceProfile } from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
AnimationState,
@@ -1625,7 +1626,15 @@ function buildCustomWorldRoleCharacter(
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: ('imageSrc' in role && role.imageSrc?.trim()) || baseCharacter.portrait,
portrait: role.imageSrc?.trim() || baseCharacter.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: baseCharacter.animationMap,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
@@ -1657,6 +1666,7 @@ function buildCustomWorldRoleCharacter(
function pickCustomWorldRoleTemplateCharacter(
role: CustomWorldRuntimeRole,
fallbackIndex: number,
profile?: CustomWorldProfile | null,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
@@ -1672,6 +1682,28 @@ function pickCustomWorldRoleTemplateCharacter(
return explicitTemplateCharacter;
}
const referenceTemplateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
profile ?? null,
{
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.description,
personality: role.personality,
combatStyle: role.combatStyle,
tags: role.tags,
},
);
const referenceTemplateCharacter = referenceTemplateCharacterId
? PRESET_CHARACTERS.find(
(character) => character.id === referenceTemplateCharacterId,
) ?? null
: null;
if (referenceTemplateCharacter) {
return referenceTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
@@ -1698,7 +1730,11 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
}
return profile.playableNpcs.map((role, index) => {
const templateCharacter = pickCustomWorldRoleTemplateCharacter(role, index);
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
index,
profile,
);
return buildCustomWorldRoleCharacter(
templateCharacter,
@@ -1721,6 +1757,7 @@ export function buildCustomWorldRuntimeCharacters(profile: CustomWorldProfile |
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
profile.playableNpcs.length + index,
profile,
);
return buildCustomWorldRoleCharacter(

View File

@@ -7,7 +7,10 @@ import {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
import {
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
@@ -47,6 +50,7 @@ const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
const ANIMATION_STATES = new Set<AnimationState>(Object.values(AnimationState));
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
@@ -361,6 +365,54 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u
};
}
function normalizeCharacterAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!isRecord(value)) return null;
const folder = toText(value.folder);
const prefix = toText(value.prefix);
const frames = Math.max(1, toOptionalInteger(value.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toOptionalInteger(value.startFrame);
const extension = toText(value.extension);
const file = toText(value.file);
const basePath = toText(value.basePath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeCharacterAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>
: undefined;
}
function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
if (!isRecord(value)) return null;
@@ -408,9 +460,9 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -419,14 +471,18 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
if (!isRecord(value)) return null;
@@ -450,9 +506,9 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -461,15 +517,18 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
if (!isRecord(value)) return null;
@@ -619,7 +678,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
return {
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
name,
@@ -670,6 +729,14 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
value.ownedSettingLayers,
normalizedProfile,
),
};
}

View File

@@ -1,4 +1,13 @@
import { type CustomWorldNpc, type CustomWorldPlayableNpc, WorldType } from '../types';
import {
collectCreatureArchetypeSignals,
resolveCreatureArchetypeForSource,
} from '../services/customWorldReferenceSignals';
import {
type CustomWorldNpc,
type CustomWorldPlayableNpc,
type CustomWorldProfile,
WorldType,
} from '../types';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
@@ -64,6 +73,10 @@ function getMonsterPresetPool(worldType?: WorldType | null) {
});
}
function getAllMonsterPresets() {
return getMonsterPresetPool(null);
}
function uniqueText(values: Array<string | null | undefined>) {
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
@@ -118,9 +131,99 @@ function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
return score;
}
function scoreMonsterPresetWithArchetype(
preset: HostileNpcPreset,
sourceText: string,
options: {
archetypeSignals?: ReturnType<typeof collectCreatureArchetypeSignals> | null;
preferredWorldType?: WorldType | null;
} = {},
) {
let score = scoreMonsterPreset(preset, sourceText);
const { archetypeSignals, preferredWorldType } = options;
if (archetypeSignals) {
archetypeSignals.keywords.forEach((keyword) => {
if (!keyword) {
return;
}
if (
preset.name.includes(keyword)
|| preset.habitatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
|| preset.combatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
) {
score += keyword.length >= 3 ? 6 : 4;
}
});
archetypeSignals.combatTags.forEach((tag) => {
if (preset.combatTags.includes(tag)) {
score += 8;
}
});
archetypeSignals.habitatTags.forEach((tag) => {
if (preset.habitatTags.includes(tag)) {
score += 6;
}
});
}
if (
preferredWorldType
&& preferredWorldType !== WorldType.CUSTOM
&& preset.worldType === preferredWorldType
) {
score += 3;
}
return score;
}
export function getCustomWorldMonsterPresetPool(
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
const presets = getAllMonsterPresets();
const creatureArchetypes =
profile?.ownedSettingLayers?.referenceProfile.creatureArchetypes ?? [];
if (creatureArchetypes.length === 0) {
return presets;
}
const preferredWorldType = profile?.templateWorldType ?? null;
const scoredPresets = presets
.map((preset) => {
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
const nextScore = scoreMonsterPresetWithArchetype(
preset,
preset.name,
{
archetypeSignals: collectCreatureArchetypeSignals(archetype),
preferredWorldType,
},
);
return Math.max(bestScore, nextScore);
}, 0);
return {
preset,
score: archetypeScore,
};
})
.sort((left, right) => right.score - left.score);
const filtered = scoredPresets
.filter((entry) => entry.score > 0)
.map((entry) => entry.preset);
return filtered.length > 0 ? filtered : presets;
}
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
@@ -132,7 +235,18 @@ export function resolveCustomWorldNpcMonsterPreset(
return null;
}
const candidates = getMonsterPresetPool(worldType);
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
const referenceArchetype = resolveCreatureArchetypeForSource(
profile as CustomWorldProfile | null | undefined,
npc,
);
const archetypeSignals = referenceArchetype
? collectCreatureArchetypeSignals(referenceArchetype)
: null;
const candidates =
profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
? getCustomWorldMonsterPresetPool(profile)
: getMonsterPresetPool(worldType);
if (candidates.length === 0) {
return null;
}
@@ -140,7 +254,10 @@ export function resolveCustomWorldNpcMonsterPreset(
const scoredCandidates = candidates
.map((candidate) => ({
candidate,
score: scoreMonsterPreset(candidate, sourceText),
score: scoreMonsterPresetWithArchetype(candidate, sourceText, {
archetypeSignals,
preferredWorldType,
}),
}))
.sort((left, right) => right.score - left.score);
@@ -154,6 +271,7 @@ export function resolveCustomWorldNpcMonsterPreset(
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType)?.id ?? null;
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
}

View File

@@ -1,4 +1,8 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
collectSceneBucketSignalKeywords,
resolveSceneBucketForLandmark,
} from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
type CustomWorldLandmark,
@@ -197,6 +201,7 @@ type CustomWorldSceneImageMatchOptions = {
| 'settingText'
| 'templateWorldType'
| 'camp'
| 'ownedSettingLayers'
> | null;
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel'> | null;
usedImageSrcs?: Iterable<string>;
@@ -262,6 +267,36 @@ function buildSceneReferencePool(worldType: WorldTemplateType) {
}));
}
function buildOwnedSceneReferencePool(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'ownedSettingLayers'
>,
) {
const sceneBuckets =
profile.ownedSettingLayers?.referenceProfile.sceneBuckets ?? [];
if (sceneBuckets.length === 0) {
return [];
}
const pool = getAllCustomWorldSceneImages();
if (pool.length === 0) {
return [];
}
return sceneBuckets.map((bucket, index) => {
const offset =
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`)
% pool.length;
return {
name: bucket.label,
keywords: collectSceneBucketSignalKeywords(bucket),
imageSrc: pool[(offset + index) % pool.length] ?? '',
};
});
}
function buildSourceText(
seedKey: string,
index: number,
@@ -369,7 +404,13 @@ export function getDefaultCustomWorldSceneImage(
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions = {},
) {
const pool = collectWorldSceneImagePool(worldType);
const ownedReferencePool = options.profile
? buildOwnedSceneReferencePool(options.profile)
: [];
const pool =
ownedReferencePool.length > 0
? getAllCustomWorldSceneImages()
: collectWorldSceneImagePool(worldType);
if (pool.length === 0) {
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
}
@@ -379,12 +420,34 @@ export function getDefaultCustomWorldSceneImage(
.map((value) => normalizeOptionalImageSrc(value))
.filter((value): value is string => Boolean(value)),
);
const sourceText = buildSourceText(seedKey, index, worldType, options);
const referencePool = buildSceneReferencePool(worldType);
const preferredSceneBucket =
options.profile && options.landmark
? resolveSceneBucketForLandmark(
options.profile as CustomWorldProfile,
options.landmark,
)
: null;
const sourceText = [
buildSourceText(seedKey, index, worldType, options),
preferredSceneBucket?.label ?? '',
...(preferredSceneBucket
? collectSceneBucketSignalKeywords(preferredSceneBucket)
: []),
].join(' ');
const referencePool =
ownedReferencePool.length > 0
? ownedReferencePool
: buildSceneReferencePool(worldType);
const scoredReferences = referencePool
.map((reference, referenceIndex) => ({
imageSrc: reference.imageSrc,
score: scoreSceneReference(reference, sourceText),
score:
scoreSceneReference(reference, sourceText)
+ (
preferredSceneBucket && reference.name === preferredSceneBucket.label
? 28
: 0
),
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
}))
.sort((left, right) => {
@@ -418,7 +481,14 @@ export function getDefaultCustomWorldSceneImage(
export function resolveCustomWorldLandmarkImage(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'ownedSettingLayers'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
index: number,
@@ -453,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
| 'templateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const usedImageSrcs = new Set(
@@ -490,6 +561,7 @@ export function resolveCustomWorldCampSceneImage(
| 'templateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const campScene = resolveCustomWorldCampScene(profile);

View File

@@ -1,4 +1,6 @@
import { InventoryItem, WorldType } from '../types';
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import { CustomWorldProfile, InventoryItem, WorldType } from '../types';
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
common: 12,
@@ -8,13 +10,40 @@ const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
legendary: 168,
};
export function getCurrencyName(worldType: WorldType | null) {
function resolveEconomyProfile(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const profile =
customWorldProfile ??
(worldType === WorldType.CUSTOM ? getRuntimeCustomWorldProfile() : null);
return resolveCustomWorldRuleProfile(profile);
}
export function getCurrencyName(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.resourceLabels.currency;
}
if (worldType === WorldType.XIANXIA) return '灵石';
if (worldType === WorldType.WUXIA) return '铜钱';
return '钱币';
}
export function getInitialPlayerCurrency(worldType: WorldType | null) {
export function getInitialPlayerCurrency(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.economyProfile.initialCurrency;
}
return worldType === WorldType.XIANXIA ? 140 : 160;
}
@@ -55,6 +84,10 @@ export function getNpcBuybackPrice(item: InventoryItem, affinity: number) {
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
}
export function formatCurrency(value: number, worldType: WorldType | null) {
return `${value} ${getCurrencyName(worldType)}`;
export function formatCurrency(
value: number,
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
return `${value} ${getCurrencyName(worldType, customWorldProfile)}`;
}

View File

@@ -186,6 +186,30 @@ describe('npcInteractions', () => {
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
});
it('uses ai-first copy for quest offers instead of prebuilding a fallback quest preview', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-ruins',
name: '遗迹外缘',
npcs: [],
treasureHints: ['半截封泥'],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const questOption = story.options.find((option) => option.functionId === 'npc_quest_accept');
expect(questOption).toBeTruthy();
expect(questOption?.detailText).toContain('AI 剧情引擎');
expect(questOption?.detailText).not.toContain('完成后可获得');
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();

View File

@@ -68,9 +68,8 @@ import {
type GiftAffinityInsight,
} from './npcAttributeInsights';
import {
buildQuestAcceptDetail,
buildQuestForEncounter,
buildQuestTurnInDetail,
evaluateQuestOpportunity,
getQuestForIssuer,
} from './questFlow';
import {
@@ -1379,6 +1378,30 @@ function buildNpcOption(
} as StoryOption;
}
function buildQuestAcceptOpportunityDetail(params: {
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'npcs' | 'treasureHints'> | null;
worldType: WorldType | null;
currentQuests: QuestLogEntry[];
}) {
const opportunity = evaluateQuestOpportunity({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
roleText: params.roleText,
scene: params.scene,
worldType: params.worldType,
currentQuests: params.currentQuests,
});
if (!opportunity.shouldOffer) {
return null;
}
return `${opportunity.reason} 接取后将由 AI 剧情引擎根据当前局势生成具体目标、步骤与奖励。`;
}
function getPlayerBenefitScore(item: InventoryItem, character: Character) {
let score = getInventoryItemValue(item);
const customWorldProfile = getRuntimeCustomWorldProfile();
@@ -1967,13 +1990,14 @@ export function buildNpcEncounterStoryMoment({
})
: null;
const activeQuest = getQuestForIssuer(activeQuests, npcId);
const generatedQuest = buildQuestForEncounter({
const questAcceptDetail = !activeQuest ? buildQuestAcceptOpportunityDetail({
issuerNpcId: npcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene,
worldType,
});
currentQuests: activeQuests,
}) : null;
const options: StoryOption[] = [];
const isHostileEncounter =
npcState.affinity < 0 ||
@@ -2098,15 +2122,14 @@ export function buildNpcEncounterStoryMoment({
activeQuest.id,
),
);
} else if (!activeQuest && generatedQuest) {
} else if (!activeQuest && questAcceptDetail) {
options.push(
buildNpcOption(
NPC_QUEST_ACCEPT_FUNCTION.id,
`接下${encounter.npcName}的委托`,
buildQuestAcceptDetail(generatedQuest),
questAcceptDetail,
npcId,
'quest_accept',
generatedQuest.id,
),
);
}

View File

@@ -5,6 +5,7 @@ import {WorldType} from '../types';
import {
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
buildChapterQuestForScene,
buildQuestForEncounter,
isQuestReadyToClaim,
normalizeQuestLogEntries,
@@ -29,6 +30,62 @@ const TEST_SCENE = {
treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
const CHAPTER_SCENE = {
id: 'palace_court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'npc-maid',
name: '旧宫侍女',
description: '她总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
const OVERRIDDEN_SCENE = {
id: 'wuxia-palace-court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'wuxia-npc-maid',
name: '旧宫侍女',
description: '嘴上说得少,却总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
const step = quest.steps?.find(item => item.id === stepId);
expect(step).toBeTruthy();
@@ -111,5 +168,55 @@ describe('questFlow', () => {
expect(normalized?.status).toBe('completed');
expect(normalized?.progress).toBe(1);
});
});
it('builds a scene chapter quest that reuses staged quest steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
expect(quest?.chapterId).toBe('chapter:scene:palace_court');
expect(quest?.sceneId).toBe('palace_court');
expect(quest?.steps?.map((step) => step.kind)).toEqual([
'talk_to_npc',
'defeat_hostile_npc',
'talk_to_npc',
]);
});
it('lets scene chapter quests advance through npc talk and scene pressure steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
const afterOpeningTalk = applyQuestProgressFromNpcTalk([quest!], 'npc-maid')[0];
expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
const afterPressure = applyQuestProgressFromHostileNpcDefeat(
[afterOpeningTalk!],
CHAPTER_SCENE.id,
['monster-11'],
)[0];
expect(afterPressure?.objective.kind).toBe('talk_to_npc');
const afterTurningTalk = applyQuestProgressFromNpcTalk([afterPressure!], 'npc-maid')[0];
expect(afterTurningTalk?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
});
it('uses scene chapter overrides to prefer investigation beats on key scenes', () => {
const quest = buildChapterQuestForScene({
scene: OVERRIDDEN_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
expect(quest?.title).toBe('查清内庭旧痕');
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure');
expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格');
expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女');
});
});

View File

@@ -28,7 +28,7 @@ import {
} from './runtimeItemContext';
import {buildDirectedRuntimeReward} from './runtimeItemDirector';
import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative';
import {getSceneHostileNpcs} from './scenePresets';
import {getSceneFriendlyNpcs, getSceneHostileNpcs} from './scenePresets';
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired'];
@@ -52,6 +52,112 @@ type SceneQuestThreat =
suggestedThreatType: 'relationship';
};
type SceneChapterOverride = {
title?: string;
description?: string;
summary?: string;
preferredObjectiveKind?: QuestObjectiveKind;
openingTalk?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
pressureStep?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
turningTalk?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
};
const SCENE_CHAPTER_OVERRIDES: Record<string, SceneChapterOverride> = {
'wuxia-palace-court': {
title: '查清内庭旧痕',
description: '旧宫侍女显然知道宫苑内庭近来的异动不只是一条禁行回廊那么简单,你需要顺着残痕把这一章真正翻开。',
summary: '在宫苑内庭查清旧案残痕,并逼出侍女压着没说的那一层旧事',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问禁行回廊',
revealText: '先问清旧宫侍女为什么总拦着那条回廊,这一章的开口多半就藏在她的口风里。',
completeText: '旧宫侍女已经把最表层的理由说出来了,但真正的旧事还压在更深处。',
},
pressureStep: {
title: '调查回廊暗格',
revealText: '先把回廊暗格里的香囊翻出来,确认内庭异动究竟是在遮人,还是在遮旧案。',
completeText: '回廊暗格已经给出了回应,内庭这章也开始逼近改判前的节点。',
},
turningTalk: {
title: '拿旧金牌去对问侍女',
revealText: '把你查到的旧金牌和暗格痕迹带回去,和旧宫侍女把这层旧事对清楚。',
completeText: '旧宫侍女已经接住你的追问,这章也被你真正推到了收束前夜。',
},
},
'wuxia-rain-street': {
title: '追索雨街账册',
description: '夜灯摊主见过太多不该见的人,雨夜长街的异常更像一条被水汽和灯影压着的旧账,你得先把线索翻出来。',
summary: '在雨夜长街查出湿布包和账册残页背后到底是谁在追索谁',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '向摊主问清夜街异样',
revealText: '先和夜灯摊主把这条街最近不对劲的地方问清,别让这章一开始就被人带偏。',
},
pressureStep: {
title: '翻出灯下残页',
revealText: '顺着浸湿的布包和账册残页查下去,这条长街真正压着的旧账就会冒头。',
},
turningTalk: {
title: '拿账册回去对灯摊主',
revealText: '把你翻到的账册残页拿回去,逼夜灯摊主把没说透的那半句话补完。',
},
},
'wuxia-forge-works': {
title: '追索失落兵谱',
description: '老铸匠一眼就认出你身上的杀气来源,铸坊工场里压着的旧兵谱和铁匣显然不只是废料,你得先把这章的火候逼出来。',
summary: '在铸坊工场追出失落兵谱的去向,并问清是谁把旧匣压在风箱后面',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问兵器缺口来路',
revealText: '先让老铸匠把兵器缺口看清楚,他多半已经从上面认出了这章的来路。',
},
pressureStep: {
title: '翻出风箱后兵谱',
revealText: '先把风箱后压着的旧兵谱和铁匣找出来,铸坊这章的真正火候就在那附近。',
},
turningTalk: {
title: '拿兵谱回去问铸匠',
revealText: '把你翻到的兵谱拿回去,对着老铸匠把来历和去向一并问透。',
},
},
'xianxia-cloud-gate': {
title: '查明仙门符匣异动',
description: '守门灵官一直像在等一份迟迟未到的回报,云海仙门的符匣和玉牌显然牵着更深的禁制线,你得先把这章的入口找准。',
summary: '在云海仙门查清符匣和门阙阴影背后的异常,确认谁在借仙门遮掩旧事',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '向灵官问清门阙异象',
revealText: '先让守门灵官把云海仙门最近的异象说清楚,这章的入口多半就藏在他守着不放的话头里。',
},
pressureStep: {
title: '调查云阶符匣',
revealText: '顺着云阶尽头的灵符匣查下去,把仙门这一章真正的异常先钉住。',
},
turningTalk: {
title: '带着符匣回去问灵官',
revealText: '把你查到的符匣线索带回去,逼守门灵官把没说完的禁制旧事补全。',
},
},
'xianxia-star-vessel': {
title: '追索星图旧航线',
description: '星舟舵手守着旧航线图不肯松手,甲板上压着的星图匣和灵罗盘像在等人把残缺航线拼起来,这一章更适合从调查切进去。',
summary: '在星舟甲板拼出失落航线的缺口,并问清是谁把旧坐标压在高空风压里',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问失落航线',
revealText: '先和星舟舵手把旧航线的缺口问清楚,别让甲板上的风声把真正的方向吹散。',
},
pressureStep: {
title: '调查舵台后星图匣',
revealText: '把舵台后的星图匣和灵罗盘先翻出来,这章的方向才会真正落到你手里。',
},
turningTalk: {
title: '带着航线回去问舵手',
revealText: '把你拼出来的航线缺口带回去,逼星舟舵手把这段旧路说到底。',
},
},
};
function resolveQuestRewardRuntimeConfig(params: {
roleText: string;
rewardTheme: QuestIntent['rewardTheme'];
@@ -244,6 +350,10 @@ function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey:
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
}
export function buildSceneChapterId(sceneId: string) {
return `chapter:scene:${sceneId}`;
}
function isRewardReadyStatus(status: QuestStatus) {
return REWARD_READY_STATUSES.includes(status);
}
@@ -505,6 +615,214 @@ function buildTalkBackStep(issuerNpcId: string, issuerNpcName: string): QuestSte
};
}
function buildSceneOpeningTalkStep(params: {
issuerNpcId: string;
issuerNpcName: string;
sceneName: string;
override?: SceneChapterOverride | null;
}) {
const {issuerNpcId, issuerNpcName, sceneName, override} = params;
const title = override?.openingTalk?.title ?? `${issuerNpcName} 打听异动`;
return {
id: 'step_scene_opening',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title,
revealText: override?.openingTalk?.revealText
?? `${issuerNpcName} 明显知道 ${sceneName} 最近不对劲,先和她把眼前局势问清楚。`,
completeText: override?.openingTalk?.completeText
?? `${issuerNpcName} 的回应已经把 ${sceneName} 这一章真正带进了正题。`,
} satisfies QuestStep;
}
function buildSceneTurningTalkStep(params: {
issuerNpcId: string;
issuerNpcName: string;
sceneName: string;
override?: SceneChapterOverride | null;
}) {
const {issuerNpcId, issuerNpcName, sceneName, override} = params;
const title = override?.turningTalk?.title ?? `回去与 ${issuerNpcName} 对证`;
return {
id: 'step_scene_turning',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title,
revealText: override?.turningTalk?.revealText
?? `把你在 ${sceneName} 查到的情况带回去,和 ${issuerNpcName} 把这一层旧事对清楚。`,
completeText: override?.turningTalk?.completeText
?? `${issuerNpcName} 已经接住你的回报,这一章也逼近最后的收束。`,
} satisfies QuestStep;
}
function resolveSceneChapterIssuer(scene: QuestSceneSnapshot | null) {
const friendlyNpc = getSceneFriendlyNpcs(scene)[0] ?? null;
if (!friendlyNpc) {
return {
issuerNpcId: scene?.id ? `scene-chapter:${scene.id}` : 'scene-chapter:unknown',
issuerNpcName: scene?.name ?? '当前区域',
roleText: scene?.description ?? scene?.name ?? '场景章节',
hasGuideNpc: false,
};
}
return {
issuerNpcId: friendlyNpc.id,
issuerNpcName: friendlyNpc.name,
roleText: friendlyNpc.role || friendlyNpc.description || scene?.description || friendlyNpc.name,
hasGuideNpc: true,
};
}
function buildSceneChapterPrimaryStep(params: {
scene: QuestSceneSnapshot;
worldType: WorldType | null;
issuerNpcId: string;
issuerNpcName: string;
hasGuideNpc: boolean;
override?: SceneChapterOverride | null;
}) {
const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params;
const threat = getScenePrimaryThreat(scene, worldType);
const preferredObjectiveKind = override?.preferredObjectiveKind ?? null;
if (preferredObjectiveKind === 'inspect_treasure' && (scene.treasureHints?.length ?? 0) > 0) {
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
return {
id: 'step_scene_pressure',
kind: 'inspect_treasure',
targetSceneId: scene.id,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`
),
completeText: override?.pressureStep?.completeText
?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
} satisfies QuestStep;
}
if ((preferredObjectiveKind === 'defeat_hostile_npc' || !preferredObjectiveKind) && threat?.kind === 'defeat_hostile_npc') {
const hostileNpcName = threat.targetHostileNpcName;
return {
id: 'step_scene_pressure',
kind: 'defeat_hostile_npc',
targetHostileNpcId: threat.targetHostileNpcId,
targetSceneId: threat.targetSceneId,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `压制 ${hostileNpcName}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 要你先压制 ${hostileNpcName},再回来确认 ${scene.name} 里的异动究竟是谁在推动。`
: `先压下 ${hostileNpcName} 带来的压力,才能把 ${scene.name} 这一章继续往下推。`
),
completeText: override?.pressureStep?.completeText
?? `${hostileNpcName} 已被压制,${scene.name} 这一章的核心压力开始松动。`,
} satisfies QuestStep;
}
if ((scene.treasureHints?.length ?? 0) > 0) {
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
return {
id: 'step_scene_pressure',
kind: 'inspect_treasure',
targetSceneId: scene.id,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`
),
completeText: override?.pressureStep?.completeText
?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
} satisfies QuestStep;
}
return {
id: 'step_scene_pressure',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `继续逼问 ${issuerNpcName}`,
revealText: override?.pressureStep?.revealText ?? `${issuerNpcName} 还压着一层没说透的话,把这章的中段压力继续顶上去。`,
completeText: override?.pressureStep?.completeText ?? `${issuerNpcName} 的口风终于松了一层,这章也开始逼近转折。`,
} satisfies QuestStep;
}
function buildSceneChapterSteps(params: {
scene: QuestSceneSnapshot;
worldType: WorldType | null;
issuerNpcId: string;
issuerNpcName: string;
hasGuideNpc: boolean;
override?: SceneChapterOverride | null;
}) {
const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params;
const steps: QuestStep[] = [];
if (hasGuideNpc) {
steps.push(buildSceneOpeningTalkStep({
issuerNpcId,
issuerNpcName,
sceneName: scene.name,
override,
}));
}
steps.push(buildSceneChapterPrimaryStep({
scene,
worldType,
issuerNpcId,
issuerNpcName,
hasGuideNpc,
override,
}));
if (hasGuideNpc) {
steps.push(buildSceneTurningTalkStep({
issuerNpcId,
issuerNpcName,
sceneName: scene.name,
override,
}));
}
return steps;
}
function resolveSceneChapterNarrativeType(scene: QuestSceneSnapshot, worldType: WorldType | null) {
const threat = getScenePrimaryThreat(scene, worldType);
if (threat?.kind === 'defeat_hostile_npc') {
return 'bounty' as const;
}
if ((scene.treasureHints?.length ?? 0) > 0) {
return 'investigation' as const;
}
return 'relationship' as const;
}
function resolveSceneChapterRewardTheme(scene: QuestSceneSnapshot, worldType: WorldType | null) {
const threat = getScenePrimaryThreat(scene, worldType);
if ((scene.treasureHints?.length ?? 0) > 0) {
return 'intel' as const;
}
if (threat?.kind === 'defeat_hostile_npc') {
return 'resource' as const;
}
return 'relationship' as const;
}
function deriveObjectiveFromStep(step: QuestStep | null, issuerNpcId: string): QuestObjective {
if (!step) {
return {
@@ -609,6 +927,7 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
const normalizedQuest: QuestLogEntry = {
...quest,
chapterId: quest.chapterId ?? null,
objective,
progress,
status,
@@ -713,6 +1032,18 @@ export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string)
return quests.find(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in') ?? null;
}
export function getChapterQuestForScene(quests: QuestLogEntry[], sceneId: string | null | undefined) {
if (!sceneId) {
return null;
}
const chapterId = buildSceneChapterId(sceneId);
return quests.find((quest) =>
quest.chapterId === chapterId
&& !isTerminalStatus(quest.status),
) ?? null;
}
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity {
const {issuerNpcId, scene, currentQuests = []} = params;
if (!scene) {
@@ -927,6 +1258,109 @@ export function buildQuestForEncounter(params: QuestPreviewRequest): QuestLogEnt
);
}
export function buildChapterQuestForScene(params: {
scene: QuestSceneSnapshot | null;
worldType: WorldType | null;
context?: QuestGenerationContext;
}) {
const {scene, worldType, context} = params;
if (!scene) {
return null;
}
const {
issuerNpcId,
issuerNpcName,
roleText,
hasGuideNpc,
} = resolveSceneChapterIssuer(scene);
const override = SCENE_CHAPTER_OVERRIDES[scene.id] ?? null;
const steps = buildSceneChapterSteps({
scene,
worldType,
issuerNpcId,
issuerNpcName,
hasGuideNpc,
override,
});
if (steps.length <= 0) {
return null;
}
const narrativeType = resolveSceneChapterNarrativeType(scene, worldType);
const rewardTheme = resolveSceneChapterRewardTheme(scene, worldType);
const reward = buildQuestReward({
issuerNpcId,
issuerNpcName,
worldType,
roleText,
rewardTheme,
narrativeType,
scene,
context,
});
const rewardText = buildRewardText(reward, worldType);
const threadContract = resolveQuestThreadContract({
context,
issuerNpcId,
scene,
});
const chapterId = buildSceneChapterId(scene.id);
const threat = getScenePrimaryThreat(scene, worldType);
const title = normalizeQuestTitle(
override?.title ?? `${compactQuestLabel(scene.name, 6)}异动`,
`查明${compactQuestLabel(scene.name, 6)}`,
);
return normalizeQuestLogEntry({
id: `quest:chapter:${scene.id}`,
issuerNpcId,
issuerNpcName,
sceneId: scene.id,
chapterId,
actId: context?.actState?.id ?? null,
threadId: threadContract?.threadId ?? null,
contractId: threadContract?.id ?? null,
title,
description: override?.description ?? (
hasGuideNpc
? `${issuerNpcName} 认为 ${scene.name} 这一带的异动并不简单,希望你把眼前的线索与压力真正查清。`
: `${scene.name} 当前的局势还没有收束,你需要把这一章的线索和压力真正接住。`
),
summary: override?.summary ?? `${scene.name} 接住这一章的线索并完成收束`,
objective: deriveObjectiveFromStep(steps[0] ?? null, issuerNpcId),
progress: 0,
status: 'active',
completionNotified: false,
reward,
rewardText,
narrativeBinding: {
origin: 'fallback_builder',
narrativeType,
dramaticNeed: hasGuideNpc
? `${issuerNpcName} 明显知道 ${scene.name} 的局势正在失衡,但还没把真正的问题说透。`
: `${scene.name} 的异常正在把这段局势往前推,你需要先把现场的主压力接住。`,
issuerGoal: hasGuideNpc
? `查清 ${scene.name} 的异动到底是谁、哪件旧事或哪层残痕在推动。`
: `${scene.name} 当前未收束的压力和线索梳理清楚。`,
playerHook: `你已经进入 ${scene.name},这一章现在就落在你面前。`,
worldReason: threat?.kind === 'defeat_hostile_npc'
? `${scene.name} 的敌对压力已经摆到了台前,不先处理就很难继续推进。`
: `${scene.name} 的线索和残痕已经堆到足以独立成章的程度。`,
followupHooks: [
`${scene.name} 的这一章收束后,下一段 lead 会开始变得更明确。`,
],
},
steps,
activeStepId: steps[0]?.id ?? null,
visibleStage: 0,
hiddenFlags: [],
discoveredFactIds: [],
relatedCarrierIds: [],
consequenceIds: [],
});
}
export function buildQuestAcceptDetail(quest: QuestLogEntry) {
const normalizedQuest = withNormalizedQuest(quest);
const activeStep = getQuestActiveStep(normalizedQuest);

View File

@@ -25,12 +25,13 @@ import {
PRESET_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getMonsterPresetById, getMonsterPresetsByWorld } from './hostileNpcPresets';
import { getMonsterPresetById } from './hostileNpcPresets';
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
import sceneOverridesJson from './sceneOverrides.json';
@@ -307,7 +308,7 @@ function buildCustomSceneNpc(
);
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc)
? resolveCustomWorldNpcMonsterPreset(npc, WorldType.CUSTOM, profile)
: null;
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
const attributeProfile = monsterPreset?.attributeProfile
@@ -378,7 +379,7 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const campSceneProfile = resolveCustomWorldCampScene(profile);
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const baseMonsterPool: string[] = getMonsterPresetsByWorld(WorldType.CUSTOM)
const baseMonsterPool: string[] = getCustomWorldMonsterPresetPool(profile)
.map((monster) => monster.id)
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];

View File

@@ -1,3 +1,4 @@
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
import {WorldType} from '../types';
@@ -162,8 +163,11 @@ export function getWorldAttributeSchema(
worldType: WorldType | null | undefined,
customWorldProfile?: CustomWorldProfile | null,
) {
if (worldType === WorldType.CUSTOM && customWorldProfile?.attributeSchema) {
return customWorldProfile.attributeSchema;
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
}
if (worldType === WorldType.XIANXIA) {

View File

@@ -25,6 +25,7 @@ import {
import {
acceptQuest,
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
applyQuestProgressFromSpar,
buildQuestAcceptResultText,
buildQuestForEncounter,
@@ -456,8 +457,13 @@ export function createStoryNpcEncounterActions({
]
: provisionalHistory
: appendHistory(gameState, actionText, finalDialogueText);
const progressedQuests = applyQuestProgressFromNpcTalk(
nextState.quests,
encounter.id ?? encounter.npcName,
);
const finalState = {
...nextState,
quests: progressedQuests,
storyHistory: finalHistory,
};
const finalOpeningCampContext = buildOpeningCampChatContext(

View File

@@ -3,6 +3,11 @@ import type {
SetStateAction,
} from 'react';
import {
acceptQuest,
buildChapterQuestForScene,
getChapterQuestForScene,
} from '../../data/questFlow';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
@@ -176,6 +181,67 @@ function findNewInventoryItems(previousState: GameState, nextState: GameState) {
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
}
function ensureSceneChapterQuestState(params: {
previousState: GameState;
nextState: GameState;
}) {
const storyEngineMemory =
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const scene = params.nextState.currentScenePreset;
if (
params.nextState.currentScene !== 'Story'
|| !params.nextState.worldType
|| !scene?.id
) {
return {
...params.nextState,
storyEngineMemory,
};
}
const openedSceneChapterIds = dedupeStrings([
...(storyEngineMemory.openedSceneChapterIds ?? []),
], 64);
if (openedSceneChapterIds.includes(scene.id)) {
return {
...params.nextState,
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
},
};
}
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
};
const existingChapterQuest = getChapterQuestForScene(params.nextState.quests, scene.id);
if (existingChapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
const chapterQuest = buildChapterQuestForScene({
scene,
worldType: params.nextState.worldType,
});
if (!chapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
return {
...params.nextState,
storyEngineMemory: nextMemory,
quests: acceptQuest(params.nextState.quests, chapterQuest),
};
}
function applyStoryEngineEchoes(params: {
previousState: GameState;
nextState: GameState;
@@ -200,13 +266,17 @@ function applyStoryEngineEchoes(params: {
signals,
contracts,
});
const stateWithSceneChapter = ensureSceneChapterQuestState({
previousState: params.previousState,
nextState: stateWithSignals,
});
const reactions = buildCompanionReactionBatch({
state: stateWithSignals,
state: stateWithSceneChapter,
signals,
actionText: params.actionText,
});
const stateWithReactions = applyCompanionReactionToStance({
state: stateWithSignals,
state: stateWithSceneChapter,
reactions,
});
const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();

View File

@@ -56,6 +56,7 @@ function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
issuerNpcId: 'npc-trader',
issuerNpcName: 'Trader Lin',
sceneId: 'scene-1',
chapterId: 'chapter:scene:scene-1',
title: 'Deliver the cache',
description: 'Deliver the cache safely.',
summary: 'Help Trader Lin recover the cache.',
@@ -174,4 +175,75 @@ describe('sessionActions', () => {
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim).toHaveProperty('handoff');
});
it('refreshes chapter state after a chapter quest is turned in', () => {
const baseState = {
...createBaseState(),
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '断桥边风声未散。',
imageSrc: '/scene-1.png',
treasureHints: [],
npcs: [],
},
chapterState: {
id: 'chapter:scene:scene-1',
title: '断桥旧哨·高潮',
theme: '回报遗迹调查',
primaryThreadIds: [],
stage: 'climax' as const,
chapterSummary: '当前章节已逼近最后收束。',
sceneId: 'scene-1',
chapterQuestId: 'quest-1',
},
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: ['scene-1'],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: {
id: 'chapter:scene:scene-1',
title: '断桥旧哨·高潮',
theme: '回报遗迹调查',
primaryThreadIds: [],
stage: 'climax' as const,
chapterSummary: '当前章节已逼近最后收束。',
sceneId: 'scene-1',
chapterQuestId: 'quest-1',
},
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
},
} satisfies GameState;
const rewardClaim = applyQuestRewardClaim(baseState, 'quest-1');
expect(rewardClaim).not.toBeNull();
if (!rewardClaim) {
throw new Error('Expected reward claim result');
}
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
});
});

View File

@@ -9,8 +9,13 @@ import {
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
GameState,
StoryMoment,
@@ -63,10 +68,29 @@ export function applyQuestRewardClaim(
}
: state.npcStates,
}, quest.reward.items);
const chapterState = advanceChapterState({
previousChapter:
nextState.chapterState
?? nextState.storyEngineMemory?.currentChapter
?? null,
nextChapter: resolveCurrentChapterState({
state: nextState,
}),
});
const storyEngineMemory =
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const synchronizedNextState: GameState = {
...nextState,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
};
return {
nextState,
handoff: buildGoalHandoffFromState(nextState),
nextState: synchronizedNextState,
handoff: buildGoalHandoffFromState(synchronizedNextState),
};
}

View File

@@ -7,6 +7,9 @@ import {
NPC_TRADE_FUNCTION,
shouldNpcRecruitOpenModal,
} from '../../data/functionCatalog';
import {
applyQuestProgressFromSceneReached,
} from '../../data/questFlow';
import {
buildInitialNpcState,
getPreferredGiftItemId,
@@ -139,6 +142,7 @@ export function buildMapTravelResolution(
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
scenesTraveled: 1,
}),
quests: applyQuestProgressFromSceneReached(state.quests, targetScene.id),
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,

View File

@@ -120,11 +120,11 @@ export function useGameFlow() {
setGameState(createInitialGameState());
};
const handleWorldSelect = (type: WorldType, customWorldProfile: CustomWorldProfile | null = null) => {
const resolvedWorldType = customWorldProfile ? WorldType.CUSTOM : type;
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
const resolvedWorldType = WorldType.CUSTOM;
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
customWorldProfile ? buildCustomWorldRuntimeCharacters(customWorldProfile) : null,
buildCustomWorldRuntimeCharacters(customWorldProfile),
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);
@@ -217,7 +217,10 @@ export function useGameFlow() {
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(gameState.worldType),
playerCurrency: getInitialPlayerCurrency(
gameState.worldType,
gameState.customWorldProfile,
),
playerInventory: buildInitialPlayerInventory(character, gameState.worldType),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates: initialEncounter && initialNpcState
@@ -248,7 +251,7 @@ export function useGameFlow() {
isMapOpen,
setIsMapOpen,
resetGame,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
};

View File

@@ -18,7 +18,7 @@ import {
buildSaveMigrationManifest,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment } from '../types';
import { GameState, StoryMoment, WorldType } from '../types';
import { BottomTab } from './useGameFlow';
const AUTO_SAVE_DELAY_MS = 400;
@@ -31,6 +31,14 @@ function normalizeSavedStory(story: StoryMoment | null) {
} satisfies StoryMoment;
}
function isPlayableSavedGameState(gameState: GameState | null | undefined) {
return Boolean(
gameState
&& gameState.worldType === WorldType.CUSTOM
&& gameState.customWorldProfile,
);
}
function normalizeCharacterChats(gameState: GameState) {
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
characterId,
@@ -100,7 +108,10 @@ function normalizeSavedGameState(gameState: GameState) {
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency
: getInitialPlayerCurrency(gameState.worldType),
: getInitialPlayerCurrency(
gameState.worldType,
normalizedEncounterState.customWorldProfile,
),
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
roster: normalizedRoster,
npcStates: Object.fromEntries(
@@ -171,7 +182,8 @@ export function useGamePersistence({
const [hasSavedGame, setHasSavedGame] = useState(false);
useEffect(() => {
setHasSavedGame(Boolean(readSavedSnapshot()));
const snapshot = readSavedSnapshot();
setHasSavedGame(isPlayableSavedGameState(snapshot?.gameState ?? null));
}, []);
useEffect(() => {
@@ -187,7 +199,7 @@ export function useGamePersistence({
});
if (didSave) {
setHasSavedGame(true);
setHasSavedGame(isPlayableSavedGameState(gameState));
}
}, AUTO_SAVE_DELAY_MS);
@@ -214,7 +226,7 @@ export function useGamePersistence({
});
if (didSave) {
setHasSavedGame(true);
setHasSavedGame(isPlayableSavedGameState(nextGameState));
}
return didSave;
@@ -232,6 +244,11 @@ export function useGamePersistence({
return false;
}
if (!isPlayableSavedGameState(snapshot.gameState)) {
clearSavedGame();
return false;
}
resetStoryState();
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');

View File

@@ -639,6 +639,7 @@ function buildStoryContextFromState(
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState,
journeyBeat,
setpieceDirective,
@@ -1865,6 +1866,7 @@ export function useStoryGeneration({
buildGoalStackState({
quests: gameState.quests,
worldType: gameState.worldType,
currentSceneId: gameState.currentScenePreset?.id ?? null,
chapterState:
gameState.chapterState
?? gameState.storyEngineMemory?.currentChapter
@@ -1878,6 +1880,7 @@ export function useStoryGeneration({
}),
[
gameState.chapterState,
gameState.currentScenePreset?.id,
gameState.currentScenePreset?.name,
gameState.quests,
gameState.storyEngineMemory?.currentCampEvent,

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