Compare commits
3 Commits
bd9fdcbe31
...
323aa94c87
| Author | SHA1 | Date | |
|---|---|---|---|
| 323aa94c87 | |||
| a02f7b6414 | |||
|
|
a83841ff2d |
17
.env.example
@@ -13,6 +13,21 @@ VITE_LLM_PROXY_BASE_URL="/api/llm"
|
||||
# Optional frontend override for the local custom-world scene image proxy path.
|
||||
VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image"
|
||||
|
||||
# Local Node backend address and target used by Vite's dev proxy for runtime API routes.
|
||||
NODE_SERVER_ADDR=":8081"
|
||||
NODE_SERVER_TARGET="http://127.0.0.1:8081"
|
||||
|
||||
# Local Caddy upstream target used for dist-based testing.
|
||||
CADDY_API_UPSTREAM="http://127.0.0.1:8081"
|
||||
|
||||
# Node backend SQLite database path.
|
||||
SQLITE_PATH=""
|
||||
|
||||
# Node backend JWT settings.
|
||||
JWT_SECRET="CHANGE_ME_FOR_PRODUCTION"
|
||||
# 当前默认签发永久 JWT,此字段暂未使用,后续如果恢复有限期 token 再启用。
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# Model name for chat completions.
|
||||
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||
|
||||
@@ -21,7 +36,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"
|
||||
|
||||
5
.gitignore
vendored
@@ -6,3 +6,8 @@ coverage/
|
||||
*.log
|
||||
/public/generated-custom-world-scenes
|
||||
temp*build*/
|
||||
/server-node/dist/
|
||||
/server-node/logs/*
|
||||
!/server-node/logs/.gitkeep
|
||||
/server-node/data/*
|
||||
!/server-node/data/.gitkeep
|
||||
|
||||
79
AGENTS.md
@@ -2,7 +2,6 @@
|
||||
|
||||
## 项目约束
|
||||
|
||||
- 积极学习 `docs/` 与根目录经验文档后再开始实现。
|
||||
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
|
||||
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
|
||||
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
|
||||
@@ -10,4 +9,80 @@
|
||||
- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。
|
||||
- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。
|
||||
- UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。
|
||||
- 不要在gitignore中添加.env.local文件。
|
||||
- 不要在gitignore中添加.env.local文件。
|
||||
- 严格遵循简洁的代码风格
|
||||
|
||||
## 文档图谱
|
||||
|
||||
```text
|
||||
docs/
|
||||
├─ README.md
|
||||
├─ audits/
|
||||
│ ├─ README.md
|
||||
│ ├─ FUNCTION_DESIGN_AUDIT_2026-04-03.md
|
||||
│ ├─ ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md
|
||||
│ ├─ engineering/
|
||||
│ │ ├─ README.md
|
||||
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md
|
||||
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md
|
||||
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md
|
||||
│ │ └─ MONSTER_NPC_UNIFICATION_AUDIT_2026-04-06.md
|
||||
│ └─ text/
|
||||
│ ├─ README.md
|
||||
│ ├─ CHINESE_MOJIBAKE_INVENTORY.md
|
||||
│ ├─ EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md
|
||||
│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md
|
||||
│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md
|
||||
│ ├─ GAME_UI_PRESET_EDITOR_NPC_PROMPT_TEXT_AUDIT_2026-04-02_DEEP_SCAN.md
|
||||
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md
|
||||
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md
|
||||
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md
|
||||
│ └─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md
|
||||
├─ design/
|
||||
│ ├─ README.md
|
||||
│ ├─ AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md
|
||||
│ ├─ COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md
|
||||
│ ├─ CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md
|
||||
│ ├─ EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md
|
||||
│ └─ npc-conversation-situation-draft.md
|
||||
├─ experience/
|
||||
│ ├─ README.md
|
||||
│ ├─ ADVENTURE_RUNTIME_DEV_EXPERIENCE.md
|
||||
│ ├─ AGENT_UI_CHANGELOG.md
|
||||
│ ├─ CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md
|
||||
│ ├─ CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md
|
||||
│ ├─ MOBILE_UI_DEV_EXPERIENCE.md
|
||||
│ ├─ PROJECT_DEVELOPMENT_EXPERIENCE.md
|
||||
│ └─ PROJECT_WORK_EXPERIENCE_PLAYBOOK.md
|
||||
├─ planning/
|
||||
│ ├─ README.md
|
||||
│ └─ CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md
|
||||
├─ prd/
|
||||
│ ├─ AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md
|
||||
│ ├─ AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md
|
||||
│ ├─ AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md
|
||||
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE3_IMPLEMENTATION_PLAN_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE4_IMPLEMENTATION_PLAN_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE5_IMPLEMENTATION_PLAN_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE6_IMPLEMENTATION_PLAN_2026-04-06.md
|
||||
│ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md
|
||||
│ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md
|
||||
│ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md
|
||||
├─ reference/
|
||||
│ ├─ README.md
|
||||
│ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md
|
||||
└─ technical/
|
||||
├─ README.md
|
||||
├─ AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md
|
||||
├─ GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md
|
||||
├─ GO_SERVER_TASKLIST_2026-04-08.md
|
||||
├─ NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md
|
||||
├─ PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md
|
||||
└─ SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md
|
||||
```
|
||||
|
||||
@@ -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. 只负责兼容的旧模板字段
|
||||
|
||||
这样之后,自定义世界才会真正从:
|
||||
|
||||
**模板依赖型生成架构**
|
||||
|
||||
迁移成:
|
||||
|
||||
**跨题材、自有设定层、且兼容当前流程的生成架构。**
|
||||
@@ -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 从“依附模板世界”改成“直接从自定义世界自身编译”
|
||||
|
||||
同时整个过程必须遵守一条底线:
|
||||
|
||||
**任何优化都不能破坏当前自定义世界生成与运行主链。**
|
||||
|
||||
所以这不是“删模板”的问题,而是一次:
|
||||
|
||||
**在兼容现有流程前提下,把自定义世界从模板依赖型架构,迁移成真正跨题材、自足型架构。**
|
||||
@@ -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 对照阅读。
|
||||
|
||||
@@ -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. 再决定是否要把自定义世界从“模板依赖型”重构成“完全自足型”
|
||||
|
||||
在那一步没做完之前,模板支撑层仍然是自定义世界当前可用性的真实依赖。
|
||||
@@ -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` 一起看。
|
||||
|
||||
248
docs/reference/TASK_GENERATION_TRACE_2026-04-08.md
Normal 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 负责“把当前最重要的一步展示给玩家”
|
||||
194
docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Node 后端知识图谱
|
||||
|
||||
日期:`2026-04-08`
|
||||
|
||||
## 1. 当前定位
|
||||
|
||||
当前运行时后端以 `server-node/` 为唯一有效服务端实现。
|
||||
|
||||
当前职责:
|
||||
|
||||
- 承接运行时鉴权
|
||||
- 承接运行时持久化
|
||||
- 承接运行时 AI 接口
|
||||
- 为 Vite 前端提供开发期代理目标
|
||||
|
||||
当前不再使用:
|
||||
|
||||
- Vite 本地 API 插件 `scripts/dev-server/`
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
- HTTP 框架:`Express`
|
||||
- 语言与构建:`TypeScript` + `tsx` + `esbuild`
|
||||
- 数据库:`better-sqlite3`
|
||||
- JWT:`jose`
|
||||
- 密码哈希:`@node-rs/argon2`
|
||||
- 日志:`pino` + `pino-http` + `pino-roll`
|
||||
|
||||
## 3. 运行入口
|
||||
|
||||
推荐命令:
|
||||
|
||||
```bash
|
||||
npm run dev:node
|
||||
```
|
||||
|
||||
相关脚本:
|
||||
|
||||
- 根目录联调:`npm run dev:node`
|
||||
- 单独启动后端开发模式:`npm run server-node:dev`
|
||||
- 构建后端:`npm run server-node:build`
|
||||
- 运行后端测试:`npm run server-node:test`
|
||||
|
||||
默认监听:
|
||||
|
||||
- 前端:`3000`
|
||||
- Node 后端:`8081`
|
||||
|
||||
## 4. 目录与主入口
|
||||
|
||||
服务端主入口:
|
||||
|
||||
- `server-node/src/server.ts`
|
||||
- `server-node/src/app.ts`
|
||||
|
||||
路由入口:
|
||||
|
||||
- `server-node/src/routes/authRoutes.ts`
|
||||
- `server-node/src/routes/runtimeRoutes.ts`
|
||||
|
||||
基础设施:
|
||||
|
||||
- `server-node/src/config.ts`
|
||||
- `server-node/src/logging.ts`
|
||||
- `server-node/src/db.ts`
|
||||
- `server-node/src/context.ts`
|
||||
|
||||
数据访问:
|
||||
|
||||
- `server-node/src/repositories/userRepository.ts`
|
||||
- `server-node/src/repositories/runtimeRepository.ts`
|
||||
|
||||
鉴权相关:
|
||||
|
||||
- `server-node/src/auth/authService.ts`
|
||||
- `server-node/src/auth/token.ts`
|
||||
- `server-node/src/auth/password.ts`
|
||||
- `server-node/src/middleware/auth.ts`
|
||||
|
||||
## 5. 鉴权模型
|
||||
|
||||
当前采用:
|
||||
|
||||
- 前端本地保存 `JWT + 自动生成的用户名密码`
|
||||
- 请求头使用 `Authorization: Bearer <token>`
|
||||
- 后端 middleware 统一解析出 `UserID`
|
||||
- handler 不直接解析 token
|
||||
|
||||
当前账号策略:
|
||||
|
||||
- 默认自动匿名账号启动
|
||||
- 本地无 JWT 时,前端会自动生成随机用户名密码并调用 `POST /api/auth/entry`
|
||||
- 本地 JWT 失效但仍保留随机凭据时,前端自动重新调用 `auth/entry` 恢复同一账号
|
||||
|
||||
JWT 现状:
|
||||
|
||||
- 当前为永久签发
|
||||
- claim 仍保留:`sub`、`iat`、`iss`、`ver`
|
||||
- `logout` 通过递增 `token_version` 立即失效旧 token
|
||||
|
||||
## 6. 数据存储
|
||||
|
||||
当前数据库:
|
||||
|
||||
- 默认 SQLite 文件:`server-node/data/genarrative.sqlite`
|
||||
- 可通过 `SQLITE_PATH` 覆盖
|
||||
|
||||
当前核心表:
|
||||
|
||||
- `users`
|
||||
- `save_snapshots`
|
||||
- `runtime_settings`
|
||||
- `custom_world_profiles`
|
||||
|
||||
当前隔离原则:
|
||||
|
||||
- 所有运行时数据按用户隔离
|
||||
|
||||
## 7. 已承接接口
|
||||
|
||||
鉴权:
|
||||
|
||||
- `POST /api/auth/entry`
|
||||
- `GET /api/auth/me`
|
||||
- `POST /api/auth/logout`
|
||||
|
||||
运行时持久化:
|
||||
|
||||
- `GET /api/runtime/save/snapshot`
|
||||
- `PUT /api/runtime/save/snapshot`
|
||||
- `DELETE /api/runtime/save/snapshot`
|
||||
- `GET /api/runtime/settings`
|
||||
- `PUT /api/runtime/settings`
|
||||
- `GET /api/runtime/custom-world-library`
|
||||
- `PUT /api/runtime/custom-world-library/:profileId`
|
||||
- `DELETE /api/runtime/custom-world-library/:profileId`
|
||||
|
||||
运行时 AI:
|
||||
|
||||
- `POST /api/llm/chat/completions`
|
||||
- `POST /api/custom-world/scene-image`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
- `POST /api/runtime/custom-world/sessions`
|
||||
- `GET /api/runtime/custom-world/sessions/:sessionId`
|
||||
- `POST /api/runtime/custom-world/sessions/:sessionId/answers`
|
||||
- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream`
|
||||
- `POST /api/runtime/chat/character/suggestions`
|
||||
- `POST /api/runtime/chat/character/summary`
|
||||
- `POST /api/runtime/chat/character/reply/stream`
|
||||
- `POST /api/runtime/chat/npc/dialogue/stream`
|
||||
- `POST /api/runtime/chat/npc/recruit/stream`
|
||||
- `POST /api/runtime/items/runtime-intent`
|
||||
- `POST /api/runtime/quests/generate`
|
||||
|
||||
## 8. Story 与 Custom World 现状
|
||||
|
||||
Story:
|
||||
|
||||
- Node 后端直接复用前端成熟 prompt 与归一化逻辑
|
||||
- 服务端走 `src/services/ai.ts` 中的严格版 story 生成链
|
||||
|
||||
Custom World:
|
||||
|
||||
- Node 后端直接复用前端现有多阶段生成编排
|
||||
- 当前保留 `session + answers + SSE progress/result/error` 协议
|
||||
- 前端已支持接收真实阶段进度对象
|
||||
|
||||
## 9. 前端接入点
|
||||
|
||||
鉴权与请求:
|
||||
|
||||
- `src/services/apiClient.ts`
|
||||
- `src/services/authService.ts`
|
||||
- `src/components/auth/AuthGate.tsx`
|
||||
|
||||
运行时服务层:
|
||||
|
||||
- `src/services/storageService.ts`
|
||||
- `src/services/aiService.ts`
|
||||
|
||||
## 10. 当前 Vite 角色
|
||||
|
||||
Vite 当前只负责代理,不再提供本地 API 插件。
|
||||
|
||||
当前代理目标:
|
||||
|
||||
- `/api/auth`
|
||||
- `/api/runtime`
|
||||
- `/api/llm`
|
||||
- `/api/custom-world/scene-image`
|
||||
- `/api/ws`
|
||||
|
||||
全部转发到 Node 后端。
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
||||
- [GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md](./GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md):Go 服务端接入、运行时持久化迁移与当前进展记录。
|
||||
- [GO_SERVER_TASKLIST_2026-04-08.md](./GO_SERVER_TASKLIST_2026-04-08.md):Go 服务端已完成与未完成事项的执行清单。
|
||||
- [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md):AI 生成角色形象与角色动画的技术路线。
|
||||
- [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。
|
||||
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md):PixelMotion 产品形态与能力拆解。
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0",
|
||||
"dev:node": "node scripts/dev-node.mjs",
|
||||
"serve:caddy": "node scripts/run-caddy-dev.mjs",
|
||||
"server-node:dev": "npm --prefix server-node run dev",
|
||||
"server-node:build": "npm --prefix server-node run build",
|
||||
"server-node:test": "npm --prefix server-node run test",
|
||||
"build": "node scripts/build-gate.mjs",
|
||||
"build:raw": "node scripts/vite-cli.mjs build",
|
||||
"preview": "node scripts/vite-cli.mjs preview",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.\"}}"
|
||||
}
|
||||
@@ -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\"}}"
|
||||
}
|
||||
@@ -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.\"}}"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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"
|
||||
}
|
||||
198
scripts/dev-node.mjs
Normal file
@@ -0,0 +1,198 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
||||
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
function parseEnvContents(contents) {
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.reduce((envMap, rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 0) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envMap[key] = value;
|
||||
return envMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnvContents(readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function resolveServerTarget(serverAddr) {
|
||||
const trimmed = serverAddr.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return 'http://127.0.0.1:8081';
|
||||
}
|
||||
|
||||
if (/^https?:\/\//u.test(trimmed)) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname === '0.0.0.0') {
|
||||
url.hostname = '127.0.0.1';
|
||||
}
|
||||
return url.toString().replace(/\/$/u, '');
|
||||
} catch {
|
||||
return trimmed.replace(/\/$/u, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(':')) {
|
||||
return `http://127.0.0.1${trimmed}`;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('0.0.0.0:')) {
|
||||
return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`;
|
||||
}
|
||||
|
||||
return `http://${trimmed}`;
|
||||
}
|
||||
|
||||
const mergedEnv = {
|
||||
...readEnvFile(envExamplePath),
|
||||
...readEnvFile(envLocalPath),
|
||||
...process.env,
|
||||
};
|
||||
|
||||
mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot;
|
||||
mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081';
|
||||
mergedEnv.NODE_SERVER_TARGET =
|
||||
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
|
||||
mergedEnv.SQLITE_PATH =
|
||||
mergedEnv.SQLITE_PATH || path.join(repoRoot, 'server-node', 'data', 'genarrative.sqlite');
|
||||
|
||||
console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`);
|
||||
console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
|
||||
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
|
||||
console.log(`[dev:node] SQLITE_PATH=${mergedEnv.SQLITE_PATH}`);
|
||||
|
||||
const children = new Set();
|
||||
let shuttingDown = false;
|
||||
let pendingExitCode = 0;
|
||||
|
||||
function stopChild(child) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 2000).unref();
|
||||
}
|
||||
|
||||
function stopAllChildren() {
|
||||
for (const child of children) {
|
||||
stopChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeExit(code = 0) {
|
||||
pendingExitCode = code;
|
||||
if (children.size === 0) {
|
||||
process.exit(pendingExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
function requestShutdown(code = 0) {
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
pendingExitCode = code;
|
||||
stopAllChildren();
|
||||
}
|
||||
|
||||
finalizeExit(pendingExitCode);
|
||||
}
|
||||
|
||||
function registerChild(name, child, siblingProvider) {
|
||||
children.add(child);
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[dev:node] ${name} failed to start`, error);
|
||||
requestShutdown(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
children.delete(child);
|
||||
|
||||
if (!shuttingDown) {
|
||||
const resolvedExitCode = code ?? 1;
|
||||
const signalSuffix = signal ? ` (${signal})` : '';
|
||||
console.error(
|
||||
`[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`,
|
||||
);
|
||||
|
||||
const sibling = siblingProvider();
|
||||
if (sibling) {
|
||||
stopChild(sibling);
|
||||
}
|
||||
|
||||
requestShutdown(resolvedExitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
finalizeExit(pendingExitCode);
|
||||
});
|
||||
}
|
||||
|
||||
const serverProcess = spawn(npmCommand, ['run', 'dev'], {
|
||||
cwd: serverRoot,
|
||||
env: mergedEnv,
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const viteProcess = spawn(
|
||||
process.execPath,
|
||||
[viteCliPath, '--port=3000', '--host=0.0.0.0'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
registerChild('node server', serverProcess, () => viteProcess);
|
||||
registerChild('vite dev server', viteProcess, () => serverProcess);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[dev:node] received SIGINT, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[dev:node] received SIGTERM, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
149
scripts/run-caddy-dev.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
||||
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
||||
const caddyConfigPath = fileURLToPath(new URL('../tools/Caddyfile.dev', import.meta.url));
|
||||
const distRoot = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||
const bundledCaddyExe = fileURLToPath(new URL('../tools/caddy.exe', import.meta.url));
|
||||
|
||||
function parseEnvContents(contents) {
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.reduce((envMap, rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 0) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envMap[key] = value;
|
||||
return envMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnvContents(readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function normalizePathForCaddy(filePath) {
|
||||
return path.resolve(filePath).replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function resolveApiUpstream(env) {
|
||||
return (
|
||||
env.CADDY_API_UPSTREAM
|
||||
|| env.NODE_SERVER_TARGET
|
||||
|| 'http://127.0.0.1:8081'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCaddyBinary() {
|
||||
if (process.platform === 'win32' && existsSync(bundledCaddyExe)) {
|
||||
return bundledCaddyExe;
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'caddy.exe' : 'caddy';
|
||||
}
|
||||
|
||||
const mergedEnv = {
|
||||
...readEnvFile(envExamplePath),
|
||||
...readEnvFile(envLocalPath),
|
||||
...process.env,
|
||||
};
|
||||
|
||||
if (!existsSync(path.join(distRoot, 'index.html'))) {
|
||||
console.error('[serve:caddy] dist/index.html 不存在,请先运行 npm run build:raw');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot);
|
||||
mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv);
|
||||
|
||||
const caddyBinary = resolveCaddyBinary();
|
||||
|
||||
console.log('[serve:caddy] listen=:8080');
|
||||
console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`);
|
||||
console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`);
|
||||
console.log(`[serve:caddy] config=${caddyConfigPath}`);
|
||||
|
||||
const caddyProcess = spawn(
|
||||
caddyBinary,
|
||||
['run', '--config', caddyConfigPath, '--adapter', 'caddyfile'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' && !existsSync(bundledCaddyExe),
|
||||
},
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
function requestShutdown(code = 0) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
|
||||
if (caddyProcess.exitCode === null) {
|
||||
caddyProcess.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (caddyProcess.exitCode === null) {
|
||||
caddyProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 2000).unref();
|
||||
}
|
||||
|
||||
if (caddyProcess.exitCode !== null) {
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
caddyProcess.on('error', (error) => {
|
||||
console.error('[serve:caddy] 启动 Caddy 失败', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
caddyProcess.on('exit', (code, signal) => {
|
||||
if (!shuttingDown) {
|
||||
const resolvedExitCode = code ?? 1;
|
||||
const signalSuffix = signal ? ` (${signal})` : '';
|
||||
console.error(
|
||||
`[serve:caddy] Caddy exited with code ${resolvedExitCode}${signalSuffix}`,
|
||||
);
|
||||
process.exit(resolvedExitCode);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[serve:caddy] received SIGINT, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[serve:caddy] received SIGTERM, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
13
server-node/build.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import esbuild from 'esbuild';
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/server.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
outfile: 'dist/server.js',
|
||||
sourcemap: true,
|
||||
packages: 'external',
|
||||
tsconfig: 'tsconfig.json',
|
||||
});
|
||||
1
server-node/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
server-node/logs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2957
server-node/package-lock.json
generated
Normal file
32
server-node/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "genarrative-server-node",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "node build.mjs",
|
||||
"start": "node dist/server.js",
|
||||
"test": "node --test --import tsx src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jose": "^6.1.0",
|
||||
"pino": "^9.9.5",
|
||||
"pino-http": "^10.5.0",
|
||||
"pino-roll": "^3.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
264
server-node/src/app.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { createAppContext } from './server.js';
|
||||
|
||||
function createTestConfig(testName: string): AppConfig {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot: tempRoot,
|
||||
publicDir: path.join(tempRoot, 'public'),
|
||||
logsDir: path.join(tempRoot, 'logs'),
|
||||
dataDir: path.join(tempRoot, 'data'),
|
||||
sqlitePath: path.join(tempRoot, 'data', 'test.sqlite'),
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
jwtSecret: 'test-secret',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'genarrative-server-node-test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withTestServer<T>(
|
||||
testName: string,
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
) {
|
||||
const context = createAppContext(createTestConfig(testName));
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address() as AddressInfo;
|
||||
return await run({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
context.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function authEntry(baseUrl: string, username: string, password: string) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/entry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json() as {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(payload.token);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function withBearer(token: string, init: RequestInit = {}) {
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} satisfies RequestInit;
|
||||
}
|
||||
|
||||
test('auth entry auto-registers, me works, logout invalidates old token', async () => {
|
||||
await withTestServer('auth', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'hero_test', 'secret123');
|
||||
|
||||
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
const mePayload = await meResponse.json() as {
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(meResponse.status, 200);
|
||||
assert.equal(mePayload.user.username, 'hero_test');
|
||||
|
||||
const logoutResponse = await fetch(
|
||||
`${baseUrl}/api/auth/logout`,
|
||||
withBearer(entry.token, { method: 'POST' }),
|
||||
);
|
||||
assert.equal(logoutResponse.status, 200);
|
||||
|
||||
const expiredResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(expiredResponse.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test('issued jwt remains valid without exp until logout invalidates token version', async () => {
|
||||
await withTestServer('permanent-jwt', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123');
|
||||
const tokenParts = entry.token.split('.');
|
||||
assert.equal(tokenParts.length, 3);
|
||||
|
||||
const payloadJson = JSON.parse(
|
||||
Buffer.from(tokenParts[1] || '', 'base64url').toString('utf8'),
|
||||
) as {
|
||||
exp?: number;
|
||||
sub?: string;
|
||||
ver?: number;
|
||||
};
|
||||
|
||||
assert.equal(typeof payloadJson.sub, 'string');
|
||||
assert.equal(typeof payloadJson.ver, 'number');
|
||||
assert.equal('exp' in payloadJson, false);
|
||||
|
||||
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(meResponse.status, 200);
|
||||
|
||||
const logoutResponse = await fetch(
|
||||
`${baseUrl}/api/auth/logout`,
|
||||
withBearer(entry.token, { method: 'POST' }),
|
||||
);
|
||||
assert.equal(logoutResponse.status, 200);
|
||||
|
||||
const invalidatedResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(invalidatedResponse.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime persistence is isolated by user', async () => {
|
||||
await withTestServer('persistence', async ({ baseUrl }) => {
|
||||
const userA = await authEntry(baseUrl, 'player_one', 'secret123');
|
||||
const userB = await authEntry(baseUrl, 'player_two', 'secret123');
|
||||
|
||||
const saveResponse = await fetch(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(userA.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
gameState: { worldType: 'WUXIA', value: 1 },
|
||||
bottomTab: 'adventure',
|
||||
currentStory: { text: 'story A' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(saveResponse.status, 200);
|
||||
|
||||
const settingsResponse = await fetch(
|
||||
`${baseUrl}/api/runtime/settings`,
|
||||
withBearer(userA.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
musicVolume: 0.25,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(settingsResponse.status, 200);
|
||||
|
||||
const libraryResponse = await fetch(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||||
withBearer(userA.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
id: 'world-a',
|
||||
name: '世界 A',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(libraryResponse.status, 200);
|
||||
|
||||
const userASave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userA.token}`,
|
||||
},
|
||||
});
|
||||
const userASavePayload = await userASave.json() as {
|
||||
gameState: {
|
||||
value: number;
|
||||
};
|
||||
};
|
||||
assert.equal(userASavePayload.gameState.value, 1);
|
||||
|
||||
const userBSave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userB.token}`,
|
||||
},
|
||||
});
|
||||
const userBSavePayload = await userBSave.json();
|
||||
assert.equal(userBSavePayload, null);
|
||||
|
||||
const userBSettings = await fetch(`${baseUrl}/api/runtime/settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userB.token}`,
|
||||
},
|
||||
});
|
||||
const userBSettingsPayload = await userBSettings.json() as {
|
||||
musicVolume: number;
|
||||
};
|
||||
assert.equal(userBSettingsPayload.musicVolume, 0.42);
|
||||
|
||||
const userBLibrary = await fetch(
|
||||
`${baseUrl}/api/runtime/custom-world-library`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${userB.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const userBLibraryPayload = await userBLibrary.json() as {
|
||||
profiles: unknown[];
|
||||
};
|
||||
assert.deepEqual(userBLibraryPayload.profiles, []);
|
||||
});
|
||||
});
|
||||
69
server-node/src/app.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import express from 'express';
|
||||
import pinoHttp from 'pino-http';
|
||||
|
||||
import type { AppContext } from './context.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requestIdMiddleware } from './middleware/requestId.js';
|
||||
import { createAuthRoutes } from './routes/authRoutes.js';
|
||||
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
|
||||
|
||||
export function createApp(context: AppContext) {
|
||||
const app = express();
|
||||
const createHttpLogger = pinoHttp as unknown as (options: Record<string, unknown>) => express.RequestHandler;
|
||||
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(requestIdMiddleware);
|
||||
app.use(
|
||||
createHttpLogger({
|
||||
logger: context.logger,
|
||||
genReqId: (request) => request.requestId,
|
||||
customProps: (request: express.Request) => ({
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
}),
|
||||
customSuccessObject: (
|
||||
request: express.Request,
|
||||
response: express.Response,
|
||||
baseObject: Record<string, unknown> & { responseTime?: number },
|
||||
) => ({
|
||||
...baseObject,
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
status: response.statusCode,
|
||||
latency_ms: baseObject.responseTime,
|
||||
}),
|
||||
customErrorObject: (
|
||||
request: express.Request,
|
||||
response: express.Response,
|
||||
error: unknown,
|
||||
baseObject: Record<string, unknown> & { responseTime?: number },
|
||||
) => ({
|
||||
...baseObject,
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
status: response.statusCode,
|
||||
latency_ms: baseObject.responseTime,
|
||||
err: error,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/healthz', (_request, response) => {
|
||||
response.json({
|
||||
ok: true,
|
||||
service: 'genarrative-node-server',
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/auth', createAuthRoutes(context));
|
||||
app.use('/api', createRuntimeRoutes(context));
|
||||
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
70
server-node/src/auth/authService.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest, unauthorized } from '../errors.js';
|
||||
import { hashPassword, verifyPassword } from './password.js';
|
||||
import { signAccessToken } from './token.js';
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u;
|
||||
|
||||
function normalizeUsername(username: string) {
|
||||
return username.trim();
|
||||
}
|
||||
|
||||
function validateCredentials(username: string, password: string) {
|
||||
if (!USERNAME_PATTERN.test(username)) {
|
||||
throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线');
|
||||
}
|
||||
if (password.length < 6 || password.length > 128) {
|
||||
throw badRequest('密码长度需要在 6 到 128 位之间');
|
||||
}
|
||||
}
|
||||
|
||||
export async function entryWithPassword(
|
||||
context: AppContext,
|
||||
usernameInput: string,
|
||||
password: string,
|
||||
) {
|
||||
const username = normalizeUsername(usernameInput);
|
||||
validateCredentials(username, password);
|
||||
|
||||
let user = context.userRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
user = context.userRepository.create(username, passwordHash);
|
||||
} else {
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
throw unauthorized('用户名或密码错误');
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error('failed to resolve user after auth entry');
|
||||
}
|
||||
|
||||
const token = await signAccessToken(
|
||||
{
|
||||
userId: user.id,
|
||||
tokenVersion: user.tokenVersion,
|
||||
},
|
||||
context.config,
|
||||
);
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutUser(context: AppContext, userId: string) {
|
||||
const user = context.userRepository.incrementTokenVersion(userId);
|
||||
if (!user) {
|
||||
throw unauthorized('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
};
|
||||
}
|
||||
16
server-node/src/auth/password.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Algorithm, hash, verify } from '@node-rs/argon2';
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
return hash(password, {
|
||||
algorithm: Algorithm.Argon2id,
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(passwordHash: string, password: string) {
|
||||
return verify(passwordHash, password, {
|
||||
algorithm: Algorithm.Argon2id,
|
||||
});
|
||||
}
|
||||
46
server-node/src/auth/token.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { jwtVerify, SignJWT } from 'jose';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { unauthorized } from '../errors.js';
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
userId: string;
|
||||
tokenVersion: number;
|
||||
};
|
||||
|
||||
function getSecret(config: AppConfig) {
|
||||
return new TextEncoder().encode(config.jwtSecret);
|
||||
}
|
||||
|
||||
export async function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
config: AppConfig,
|
||||
) {
|
||||
return new SignJWT({ ver: claims.tokenVersion })
|
||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||
.setSubject(claims.userId)
|
||||
.setIssuer(config.jwtIssuer)
|
||||
.setIssuedAt()
|
||||
.sign(getSecret(config));
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string, config: AppConfig) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret(config), {
|
||||
issuer: config.jwtIssuer,
|
||||
});
|
||||
const userId = typeof payload.sub === 'string' ? payload.sub : '';
|
||||
const tokenVersion = typeof payload.ver === 'number' ? payload.ver : NaN;
|
||||
|
||||
if (!userId || !Number.isFinite(tokenVersion)) {
|
||||
throw unauthorized('JWT 内容无效');
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
tokenVersion,
|
||||
} satisfies AccessTokenClaims;
|
||||
} catch (error) {
|
||||
throw unauthorized('JWT 校验失败');
|
||||
}
|
||||
}
|
||||
162
server-node/src/config.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type AppConfig = {
|
||||
nodeEnv: string;
|
||||
projectRoot: string;
|
||||
publicDir: string;
|
||||
logsDir: string;
|
||||
dataDir: string;
|
||||
sqlitePath: string;
|
||||
serverAddr: string;
|
||||
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
|
||||
jwtSecret: string;
|
||||
jwtExpiresIn: string;
|
||||
jwtIssuer: string;
|
||||
llm: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
dashScope: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
imageModel: string;
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
type LoadConfigOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
projectRoot?: string;
|
||||
};
|
||||
|
||||
function parseEnvContents(contents: string) {
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.reduce<Record<string, string>>((envMap, rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 0) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envMap[key] = value;
|
||||
return envMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readEnvFile(filePath: string) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnvContents(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function resolveDefaultProjectRoot() {
|
||||
const cwd = process.cwd();
|
||||
return path.basename(cwd) === 'server-node'
|
||||
? path.resolve(cwd, '..')
|
||||
: cwd;
|
||||
}
|
||||
|
||||
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
|
||||
return {
|
||||
...readEnvFile(path.join(projectRoot, '.env.example')),
|
||||
...readEnvFile(path.join(projectRoot, '.env.local')),
|
||||
...processEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function readString(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
) {
|
||||
const value = env[key]?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function readPositiveInt(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
const parsed = Number(env[key]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
|
||||
const env = readMergedEnv(projectRoot, options.env ?? process.env);
|
||||
const logsDir = path.join(projectRoot, 'server-node', 'logs');
|
||||
const dataDir = path.join(projectRoot, 'server-node', 'data');
|
||||
|
||||
return {
|
||||
nodeEnv: readString(env, 'NODE_ENV', 'development'),
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir,
|
||||
dataDir,
|
||||
sqlitePath: readString(
|
||||
env,
|
||||
'SQLITE_PATH',
|
||||
path.join(dataDir, 'genarrative.sqlite'),
|
||||
),
|
||||
serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'),
|
||||
logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'],
|
||||
jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'),
|
||||
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '7d'),
|
||||
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
|
||||
llm: {
|
||||
baseUrl: readString(
|
||||
env,
|
||||
'LLM_BASE_URL',
|
||||
'https://ark.cn-beijing.volces.com/api/v3',
|
||||
),
|
||||
apiKey:
|
||||
env.LLM_API_KEY?.trim() ||
|
||||
env.ARK_API_KEY?.trim() ||
|
||||
env.VITE_LLM_API_KEY?.trim() ||
|
||||
'',
|
||||
model: readString(
|
||||
env,
|
||||
'LLM_MODEL',
|
||||
readString(
|
||||
env,
|
||||
'VITE_LLM_MODEL',
|
||||
'doubao-1-5-pro-32k-character-250715',
|
||||
),
|
||||
),
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: readString(
|
||||
env,
|
||||
'DASHSCOPE_BASE_URL',
|
||||
'https://dashscope.aliyuncs.com/api/v1',
|
||||
),
|
||||
apiKey: env.DASHSCOPE_API_KEY?.trim() || '',
|
||||
imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'),
|
||||
requestTimeoutMs: readPositiveInt(
|
||||
env,
|
||||
'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS',
|
||||
150000,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
18
server-node/src/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from './config.js';
|
||||
import type { AppDatabase } from './db.js';
|
||||
import { RuntimeRepository } from './repositories/runtimeRepository.js';
|
||||
import { UserRepository } from './repositories/userRepository.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
|
||||
export type AppContext = {
|
||||
config: AppConfig;
|
||||
logger: Logger;
|
||||
db: AppDatabase;
|
||||
userRepository: UserRepository;
|
||||
runtimeRepository: RuntimeRepository;
|
||||
llmClient: UpstreamLlmClient;
|
||||
customWorldSessions: CustomWorldSessionStore;
|
||||
};
|
||||
57
server-node/src/db.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
const schemaSql = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
token_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS save_snapshots (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
bottom_tab TEXT NOT NULL,
|
||||
game_state_json TEXT NOT NULL,
|
||||
current_story_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runtime_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
music_volume REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_world_profiles (
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export type AppDatabase = Database.Database;
|
||||
|
||||
export function createDatabase(config: AppConfig) {
|
||||
const sqliteDir = path.dirname(config.sqlitePath);
|
||||
fs.mkdirSync(sqliteDir, { recursive: true });
|
||||
|
||||
const db = new Database(config.sqlitePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(schemaSql);
|
||||
return db;
|
||||
}
|
||||
35
server-node/src/errors.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
expose: boolean;
|
||||
|
||||
constructor(statusCode: number, message: string, expose = true) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
this.statusCode = statusCode;
|
||||
this.expose = expose;
|
||||
}
|
||||
}
|
||||
|
||||
export function badRequest(message: string) {
|
||||
return new HttpError(400, message);
|
||||
}
|
||||
|
||||
export function unauthorized(message = '未授权访问') {
|
||||
return new HttpError(401, message);
|
||||
}
|
||||
|
||||
export function forbidden(message = '禁止访问') {
|
||||
return new HttpError(403, message);
|
||||
}
|
||||
|
||||
export function notFound(message = '资源不存在') {
|
||||
return new HttpError(404, message);
|
||||
}
|
||||
|
||||
export function conflict(message: string) {
|
||||
return new HttpError(409, message);
|
||||
}
|
||||
|
||||
export function upstreamError(message: string) {
|
||||
return new HttpError(502, message);
|
||||
}
|
||||
48
server-node/src/http.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: NextFunction,
|
||||
) => Promise<unknown> | unknown,
|
||||
): RequestHandler {
|
||||
return (request, response, next) => {
|
||||
Promise.resolve(handler(request, response, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function extractApiErrorMessage(
|
||||
rawText: string,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
if (!rawText.trim()) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as {
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) {
|
||||
return parsed.error.message.trim();
|
||||
}
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
return parsed.message.trim();
|
||||
}
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return `${fallbackMessage}(${parsed.code.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed json responses.
|
||||
}
|
||||
|
||||
return rawText.trim() || fallbackMessage;
|
||||
}
|
||||
|
||||
export function jsonClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
65
server-node/src/logging.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import pino, { type Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
const LOG_RETENTION_DAYS = 7;
|
||||
|
||||
function cleanupExpiredLogs(logsDir: string) {
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiryTime = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const entry of fs.readdirSync(logsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.startsWith('server.log')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(logsDir, entry.name);
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (stats.mtimeMs < expiryTime) {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(config: AppConfig): Logger {
|
||||
fs.mkdirSync(config.logsDir, { recursive: true });
|
||||
cleanupExpiredLogs(config.logsDir);
|
||||
|
||||
const transport = pino.transport({
|
||||
targets: [
|
||||
{
|
||||
target: 'pino-roll',
|
||||
level: config.logLevel,
|
||||
options: {
|
||||
file: path.join(config.logsDir, 'server.log'),
|
||||
mkdir: true,
|
||||
size: '10m',
|
||||
frequency: 'daily',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
},
|
||||
},
|
||||
{
|
||||
target: 'pino/file',
|
||||
level: config.logLevel,
|
||||
options: {
|
||||
destination: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return pino(
|
||||
{
|
||||
level: config.logLevel,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
base: undefined,
|
||||
},
|
||||
transport,
|
||||
);
|
||||
}
|
||||
40
server-node/src/middleware/auth.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { verifyAccessToken } from '../auth/token.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { unauthorized } from '../errors.js';
|
||||
import { type UserRepository } from '../repositories/userRepository.js';
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
const authorization = request.header('authorization')?.trim() || '';
|
||||
if (!authorization.startsWith('Bearer ')) {
|
||||
return '';
|
||||
}
|
||||
return authorization.slice('Bearer '.length).trim();
|
||||
}
|
||||
|
||||
export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) {
|
||||
return async (request: Request, _response: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = readBearerToken(request);
|
||||
if (!token) {
|
||||
throw unauthorized('缺少 Authorization Bearer Token');
|
||||
}
|
||||
|
||||
const claims = await verifyAccessToken(token, config);
|
||||
const user = userRepository.findById(claims.userId);
|
||||
if (!user) {
|
||||
throw unauthorized('用户不存在');
|
||||
}
|
||||
if (user.tokenVersion !== claims.tokenVersion) {
|
||||
throw unauthorized('登录状态已失效,请重新登录');
|
||||
}
|
||||
|
||||
request.auth = claims;
|
||||
request.userId = claims.userId;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
27
server-node/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ErrorRequestHandler } from 'express';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error, request, response, _next) => {
|
||||
const statusCode =
|
||||
error instanceof HttpError ? error.statusCode : 500;
|
||||
const message =
|
||||
error instanceof HttpError
|
||||
? error.message
|
||||
: '服务器内部错误';
|
||||
|
||||
request.log?.error(
|
||||
{
|
||||
err: error,
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
},
|
||||
'request failed',
|
||||
);
|
||||
|
||||
response.status(statusCode).json({
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
});
|
||||
};
|
||||
8
server-node/src/middleware/requestId.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export const requestIdMiddleware: RequestHandler = (request, _response, next) => {
|
||||
request.requestId = request.header('x-request-id')?.trim() || crypto.randomUUID();
|
||||
next();
|
||||
};
|
||||
182
server-node/src/repositories/runtimeRepository.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
const SAVE_SNAPSHOT_VERSION = 2;
|
||||
const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
const MAX_CUSTOM_WORLD_PROFILES = 12;
|
||||
|
||||
export type SavedSnapshot = {
|
||||
version: number;
|
||||
savedAt: string;
|
||||
gameState: unknown;
|
||||
bottomTab: string;
|
||||
currentStory: unknown;
|
||||
};
|
||||
|
||||
export type RuntimeSettings = {
|
||||
musicVolume: number;
|
||||
};
|
||||
|
||||
function parseJson<T>(value: string): T {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
export class RuntimeRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
getSnapshot(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
|
||||
FROM save_snapshots
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.get(userId) as
|
||||
| {
|
||||
version: number;
|
||||
saved_at: string;
|
||||
game_state_json: string;
|
||||
bottom_tab: string;
|
||||
current_story_json: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: row.version,
|
||||
savedAt: row.saved_at,
|
||||
gameState: parseJson(row.game_state_json),
|
||||
bottomTab: row.bottom_tab,
|
||||
currentStory: parseJson(row.current_story_json),
|
||||
} satisfies SavedSnapshot;
|
||||
}
|
||||
|
||||
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
|
||||
const snapshot = {
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
savedAt: payload.savedAt,
|
||||
gameState: payload.gameState,
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
saved_at = excluded.saved_at,
|
||||
bottom_tab = excluded.bottom_tab,
|
||||
game_state_json = excluded.game_state_json,
|
||||
current_story_json = excluded.current_story_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
toJson(snapshot.gameState),
|
||||
toJson(snapshot.currentStory),
|
||||
now,
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
deleteSnapshot(userId: string) {
|
||||
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
|
||||
}
|
||||
|
||||
getSettings(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT music_volume
|
||||
FROM runtime_settings
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.get(userId) as { music_volume: number } | undefined;
|
||||
|
||||
return {
|
||||
musicVolume:
|
||||
typeof row?.music_volume === 'number'
|
||||
? row.music_volume
|
||||
: DEFAULT_MUSIC_VOLUME,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
putSettings(userId: string, settings: RuntimeSettings) {
|
||||
const nextSettings = {
|
||||
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
|
||||
} satisfies RuntimeSettings;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
music_volume = excluded.music_volume,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(userId, nextSettings.musicVolume, new Date().toISOString());
|
||||
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
listCustomWorldProfiles(userId: string) {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT payload_json
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
|
||||
|
||||
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
|
||||
}
|
||||
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
) {
|
||||
const payload = {
|
||||
...profile,
|
||||
id: profileId,
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
}
|
||||
|
||||
deleteCustomWorldProfile(userId: string, profileId: string) {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM custom_world_profiles
|
||||
WHERE user_id = ? AND profile_id = ?`,
|
||||
)
|
||||
.run(userId, profileId);
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
}
|
||||
}
|
||||
88
server-node/src/repositories/userRepository.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type UserRecord = {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
tokenVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
token_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toUserRecord(row: UserRow | undefined): UserRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
tokenVersion: row.token_version,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
findByUsername(username: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, username, password_hash, token_version, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = ?`,
|
||||
)
|
||||
.get(username) as UserRow | undefined;
|
||||
return toUserRecord(row);
|
||||
}
|
||||
|
||||
findById(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, username, password_hash, token_version, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.get(userId) as UserRow | undefined;
|
||||
return toUserRecord(row);
|
||||
}
|
||||
|
||||
create(username: string, passwordHash: string) {
|
||||
const now = new Date().toISOString();
|
||||
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(id, username, passwordHash, now, now);
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
incrementTokenVersion(userId: string) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE users
|
||||
SET token_version = token_version + 1, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(new Date().toISOString(), userId);
|
||||
|
||||
return this.findById(userId);
|
||||
}
|
||||
}
|
||||
53
server-node/src/routes/authRoutes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { entryWithPassword, logoutUser } from '../auth/authService.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { asyncHandler } from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
|
||||
const authEntrySchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export function createAuthRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.post(
|
||||
'/entry',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = authEntrySchema.parse(request.body);
|
||||
response.json(
|
||||
await entryWithPassword(context, payload.username, payload.password),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/me',
|
||||
requireAuth,
|
||||
asyncHandler(async (request, response) => {
|
||||
const user = context.userRepository.findById(request.userId!);
|
||||
response.json({
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/logout',
|
||||
requireAuth,
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json(await logoutUser(context, request.userId!));
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
386
server-node/src/routes/runtimeRoutes.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameState } from '../../../src/types/game.js';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../../../src/types/runtimeItem.js';
|
||||
import type { Encounter } from '../../../src/types/scene.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest, notFound } from '../errors.js';
|
||||
import { asyncHandler, jsonClone } from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
import { plainTextRequestSchema } from '../services/chatService.js';
|
||||
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
|
||||
import { generateSceneImage, sceneImageSchema } from '../services/sceneImageService.js';
|
||||
import {
|
||||
generateHighQualityInitialStory,
|
||||
generateHighQualityNextStory,
|
||||
parseStoryRequest,
|
||||
} from '../services/storyService.js';
|
||||
|
||||
const saveSnapshotSchema = z.object({
|
||||
gameState: z.unknown(),
|
||||
bottomTab: z.string().trim().min(1),
|
||||
currentStory: z.unknown().nullable().optional().default(null),
|
||||
savedAt: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const settingsSchema = z.object({
|
||||
musicVolume: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const customWorldProfileSchema = z.object({
|
||||
profile: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
const customWorldSessionSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: z.record(z.string(), z.unknown()).nullable().optional().default(null),
|
||||
generationMode: z.enum(['fast', 'full']).default('fast'),
|
||||
});
|
||||
|
||||
const customWorldAnswerSchema = z.object({
|
||||
questionId: z.string().trim().min(1),
|
||||
answer: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const runtimeItemIntentSchema = z.object({
|
||||
context: z.custom<RuntimeItemGenerationContext>(),
|
||||
plans: z.array(z.custom<RuntimeItemPlan>()),
|
||||
});
|
||||
|
||||
const questGenerationSchema = z.object({
|
||||
state: z.custom<GameState>(),
|
||||
encounter: z.custom<Encounter>(),
|
||||
});
|
||||
|
||||
const llmProxySchema = z.record(z.string(), z.unknown());
|
||||
|
||||
function readParam(param: string | string[] | undefined) {
|
||||
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
||||
}
|
||||
|
||||
export function createRuntimeRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post(
|
||||
'/llm/chat/completions',
|
||||
asyncHandler(async (request, response) => {
|
||||
const body = llmProxySchema.parse(request.body);
|
||||
await context.llmClient.forwardCompletion(body, response);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-image',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = sceneImageSchema.parse(request.body);
|
||||
response.json(await generateSceneImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/save/snapshot',
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/save/snapshot',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = saveSnapshotSchema.parse(request.body);
|
||||
response.json(
|
||||
context.runtimeRepository.putSnapshot(request.userId!, {
|
||||
savedAt: payload.savedAt || new Date().toISOString(),
|
||||
gameState: payload.gameState,
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory ?? null,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/runtime/save/snapshot',
|
||||
asyncHandler(async (request, response) => {
|
||||
context.runtimeRepository.deleteSnapshot(request.userId!);
|
||||
response.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/settings',
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json(context.runtimeRepository.getSettings(request.userId!));
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/settings',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = settingsSchema.parse(request.body);
|
||||
response.json(context.runtimeRepository.putSettings(request.userId!, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-library',
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json({
|
||||
profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
const payload = customWorldProfileSchema.parse(request.body);
|
||||
response.json({
|
||||
profiles: context.runtimeRepository.upsertCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
jsonClone(payload.profile),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
response.json({
|
||||
profiles: context.runtimeRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/story/initial',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = parseStoryRequest(request.body);
|
||||
response.json(await generateHighQualityInitialStory(payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/story/continue',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = parseStoryRequest(request.body);
|
||||
response.json(await generateHighQualityNextStory(payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/suggestions',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
response.json({
|
||||
text: await context.llmClient.requestMessageContent(payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/summary',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
response.json({
|
||||
text: await context.llmClient.requestMessageContent(payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/reply/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
await context.llmClient.forwardSseText({
|
||||
...payload,
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/dialogue/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
await context.llmClient.forwardSseText({
|
||||
...payload,
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/recruit/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
await context.llmClient.forwardSseText({
|
||||
...payload,
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldSessionSchema.parse(request.body);
|
||||
response.json(
|
||||
context.customWorldSessions.create(
|
||||
request.userId!,
|
||||
payload.settingText,
|
||||
payload.creatorIntent,
|
||||
payload.generationMode,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
response.json(session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions/:sessionId/answers',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldAnswerSchema.parse(request.body);
|
||||
const session = context.customWorldSessions.answer(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
payload.questionId,
|
||||
payload.answer,
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
response.json(session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
|
||||
response.status(200);
|
||||
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
response.setHeader('Cache-Control', 'no-cache');
|
||||
response.setHeader('Connection', 'keep-alive');
|
||||
response.setHeader('X-Accel-Buffering', 'no');
|
||||
const controller = new AbortController();
|
||||
|
||||
request.on('close', () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const writeEvent = (event: string, payload: Record<string, unknown>) => {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
||||
context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generating',
|
||||
);
|
||||
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
|
||||
|
||||
try {
|
||||
const profile = await generateCustomWorldProfile(context, session, {
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => {
|
||||
writeEvent('progress', progress as unknown as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
context.customWorldSessions.setResult(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
profile,
|
||||
);
|
||||
writeEvent('progress', { phase: 'completed', progress: 100 });
|
||||
writeEvent('result', { profile });
|
||||
writeEvent('done', { ok: true });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'custom world generation failed';
|
||||
context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generation_error',
|
||||
message,
|
||||
);
|
||||
writeEvent('error', { message });
|
||||
} finally {
|
||||
response.end();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/items/runtime-intent',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeItemIntentSchema.parse(request.body);
|
||||
response.json({
|
||||
intents: await generateRuntimeItemIntents(context.llmClient, payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/quests/generate',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = questGenerationSchema.parse(request.body);
|
||||
response.json(
|
||||
await generateQuestForNpcEncounter(context.llmClient, payload),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get('/ws/health', (_request, response) => {
|
||||
response.json({
|
||||
ok: true,
|
||||
message: 'websocket routes reserved for future real-time support',
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
93
server-node/src/server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
import { type AppConfig,loadConfig } from './config.js';
|
||||
import type { AppContext } from './context.js';
|
||||
import { createDatabase } from './db.js';
|
||||
import { createLogger } from './logging.js';
|
||||
import { RuntimeRepository } from './repositories/runtimeRepository.js';
|
||||
import { UserRepository } from './repositories/userRepository.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
|
||||
function resolveListenTarget(serverAddr: string) {
|
||||
const trimmed = serverAddr.trim();
|
||||
if (!trimmed) {
|
||||
return { host: '0.0.0.0', port: 8081 };
|
||||
}
|
||||
if (trimmed.startsWith(':')) {
|
||||
return {
|
||||
host: '0.0.0.0',
|
||||
port: Number(trimmed.slice(1)),
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
const url = new URL(trimmed);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 80),
|
||||
};
|
||||
}
|
||||
if (trimmed.includes(':')) {
|
||||
const [host, portText] = trimmed.split(':');
|
||||
return {
|
||||
host: host || '0.0.0.0',
|
||||
port: Number(portText),
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: '0.0.0.0',
|
||||
port: Number(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAppContext(config: AppConfig = loadConfig()) {
|
||||
const logger = createLogger(config);
|
||||
const db = createDatabase(config);
|
||||
const context: AppContext = {
|
||||
config,
|
||||
logger,
|
||||
db,
|
||||
userRepository: new UserRepository(db),
|
||||
runtimeRepository: new RuntimeRepository(db),
|
||||
llmClient: new UpstreamLlmClient(config, logger),
|
||||
customWorldSessions: new CustomWorldSessionStore(),
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const context = createAppContext();
|
||||
const app = createApp(context);
|
||||
const { host, port } = resolveListenTarget(context.config.serverAddr);
|
||||
const server = app.listen(port, host, () => {
|
||||
context.logger.info(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
sqlite_path: context.config.sqlitePath,
|
||||
},
|
||||
'server-node started',
|
||||
);
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
context.logger.info('server-node shutting down');
|
||||
server.close(() => {
|
||||
context.db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
const isEntryPoint =
|
||||
typeof process.argv[1] === 'string' &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isEntryPoint) {
|
||||
void main();
|
||||
}
|
||||
6
server-node/src/services/chatService.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const plainTextRequestSchema = z.object({
|
||||
systemPrompt: z.string().trim().min(1),
|
||||
userPrompt: z.string().trim().min(1),
|
||||
});
|
||||
29
server-node/src/services/customWorldGenerationService.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
|
||||
type GenerateCustomWorldProfileInput,
|
||||
} from '../../../src/services/ai.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import type { CustomWorldSession } from './customWorldSessionStore.js';
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
_context: AppContext,
|
||||
session: CustomWorldSession,
|
||||
options: {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const input = {
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
} satisfies GenerateCustomWorldProfileInput;
|
||||
|
||||
const profile = await generateCustomWorldProfileFromAi(input, {
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
|
||||
}
|
||||
174
server-node/src/services/customWorldSessionStore.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export type CustomWorldSessionStatus =
|
||||
| 'clarifying'
|
||||
| 'ready_to_generate'
|
||||
| 'generating'
|
||||
| 'completed'
|
||||
| 'generation_error';
|
||||
|
||||
export type CustomWorldQuestion = {
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
};
|
||||
|
||||
export type CustomWorldSession = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
status: CustomWorldSessionStatus;
|
||||
settingText: string;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: Record<string, unknown>;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function cloneSession(session: CustomWorldSession) {
|
||||
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
|
||||
}
|
||||
|
||||
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
|
||||
return questions.some((question) => !question.answer?.trim());
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | null,
|
||||
) {
|
||||
const questions: CustomWorldQuestion[] = [];
|
||||
const worldHook =
|
||||
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
|
||||
const playerPremise =
|
||||
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
|
||||
const openingSituation =
|
||||
typeof creatorIntent?.openingSituation === 'string'
|
||||
? creatorIntent.openingSituation.trim()
|
||||
: '';
|
||||
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
|
||||
? creatorIntent.coreConflicts
|
||||
: [];
|
||||
|
||||
if (!worldHook && settingText.trim().length < 24) {
|
||||
questions.push({
|
||||
id: 'world_hook',
|
||||
label: '世界核心',
|
||||
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
|
||||
});
|
||||
}
|
||||
if (!playerPremise) {
|
||||
questions.push({
|
||||
id: 'player_premise',
|
||||
label: '玩家身份',
|
||||
question: '玩家在这个世界里是什么身份、立场或来历?',
|
||||
});
|
||||
}
|
||||
if (!openingSituation) {
|
||||
questions.push({
|
||||
id: 'opening_situation',
|
||||
label: '开局处境',
|
||||
question: '故事开局时,玩家正处于什么局面?',
|
||||
});
|
||||
}
|
||||
if (coreConflicts.length === 0) {
|
||||
questions.push({
|
||||
id: 'core_conflict',
|
||||
label: '核心冲突',
|
||||
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
|
||||
});
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
export class CustomWorldSessionStore {
|
||||
private readonly sessions = new Map<string, Map<string, CustomWorldSession>>();
|
||||
|
||||
create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | null,
|
||||
generationMode: 'fast' | 'full',
|
||||
) {
|
||||
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const session: CustomWorldSession = {
|
||||
sessionId,
|
||||
userId,
|
||||
status: 'ready_to_generate',
|
||||
settingText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
questions: buildClarificationQuestions(settingText, creatorIntent),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (hasPendingQuestion(session.questions)) {
|
||||
session.status = 'clarifying';
|
||||
}
|
||||
|
||||
const userSessions = this.sessions.get(userId) ?? new Map<string, CustomWorldSession>();
|
||||
userSessions.set(sessionId, session);
|
||||
this.sessions.set(userId, userSessions);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
get(userId: string, sessionId: string) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
return session ? cloneSession(session) : null;
|
||||
}
|
||||
|
||||
answer(userId: string, sessionId: string, questionId: string, answer: string) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = session.questions.find((item) => item.id === questionId);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
question.answer = answer;
|
||||
session.status = hasPendingQuestion(session.questions)
|
||||
? 'clarifying'
|
||||
: 'ready_to_generate';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
updateStatus(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: CustomWorldSessionStatus,
|
||||
lastError = '',
|
||||
) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = status;
|
||||
session.lastError = lastError || undefined;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = 'completed';
|
||||
session.lastError = undefined;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
}
|
||||