@@ -43,6 +43,7 @@ AUTH_REFRESH_COOKIE_SECURE="false"
|
|||||||
|
|
||||||
# 手机号验证码登录配置(阿里云 PNVS)。
|
# 手机号验证码登录配置(阿里云 PNVS)。
|
||||||
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。
|
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。
|
||||||
|
# 在 `.env.local` 或进程环境中填入 AccessKey 后会自动启用;如需强制关闭,请显式设置 `SMS_AUTH_ENABLED="false"`。
|
||||||
SMS_AUTH_ENABLED="false"
|
SMS_AUTH_ENABLED="false"
|
||||||
SMS_AUTH_PROVIDER="aliyun"
|
SMS_AUTH_PROVIDER="aliyun"
|
||||||
ALIYUN_SMS_ACCESS_KEY_ID=""
|
ALIYUN_SMS_ACCESS_KEY_ID=""
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"
|
|||||||
EMBEDDING_MODEL="doubao-embedding-text-240715"
|
EMBEDDING_MODEL="doubao-embedding-text-240715"
|
||||||
VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI"
|
VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI"
|
||||||
VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ=="
|
VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ=="
|
||||||
|
WECHAT_AUTH_ENABLED="true"
|
||||||
|
WECHAT_AUTH_PROVIDER="mock"
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 当前工程优化优先级汇总(2026-04-10)
|
||||||
|
|
||||||
|
## 结论先说
|
||||||
|
|
||||||
|
和 `2026-04-01` 那轮工程审查相比,当前仓库的主问题已经发生了明显迁移:
|
||||||
|
|
||||||
|
- 运行时主链拆分已经有进展,`useStoryGeneration.ts` 不再是最高复杂度热点。
|
||||||
|
- `typecheck`、前后端测试、内容校验、编码校验都已经回到可通过状态。
|
||||||
|
- 当前真正卡住工程节奏的,已经变成:
|
||||||
|
- 绿色门禁不可信
|
||||||
|
- 构建 warning 仍然会直接打断发布门禁
|
||||||
|
- 自定义世界 / 编辑器 / 资产链路出现了新的巨型模块热点
|
||||||
|
- 生成产物与旧工具链残留开始反向污染 lint、watch 和本地开发信号
|
||||||
|
|
||||||
|
一句话判断:
|
||||||
|
|
||||||
|
**现在最该优先做的,不是继续扩功能,而是先把门禁重新拉回可信状态,再拆 editor / custom world / assets 这批新的复杂度中心。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-10 当前校验快照
|
||||||
|
|
||||||
|
本次汇总不是只复述旧文档,额外执行了当前仓库校验命令。
|
||||||
|
|
||||||
|
| 项目 | 结果 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `npm run check:encoding` | 通过 | 编码基线正常 |
|
||||||
|
| `npm run typecheck` | 通过 | 当前严格类型门禁可通过 |
|
||||||
|
| `npm run test` | 通过 | `92` 个测试文件、`228` 个测试通过 |
|
||||||
|
| `npm run server-node:test:baseline` | 通过 | 观测基线正常 |
|
||||||
|
| `npm run server-node:test` | 通过 | `72` 个后端测试通过 |
|
||||||
|
| `npm run check:content` | 通过 | 内容与覆盖校验正常 |
|
||||||
|
| `npm run lint:eslint` | 失败 | `330` 个 error、`4` 个 warning |
|
||||||
|
| `npm run build` | 失败 | 构建完成,但因 warning 被 `build-gate` 拦截 |
|
||||||
|
|
||||||
|
当前状态说明:
|
||||||
|
|
||||||
|
- 仓库不是“完全不可用”,而是已经进入“测试绿,但门禁信号不一致”的阶段。
|
||||||
|
- 这类状态比纯红线更危险,因为团队会误以为主链已经稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0:先恢复可信的绿色门禁
|
||||||
|
|
||||||
|
### P0-1:修复 lint 失真,重新建立可信基线
|
||||||
|
|
||||||
|
这是当前第一优先级。
|
||||||
|
|
||||||
|
#### 证据
|
||||||
|
|
||||||
|
- `npm run lint:eslint` 当前失败,报出 `330` 个 error、`4` 个 warning。
|
||||||
|
- 问题既有真实源码问题,也有明显的门禁污染:
|
||||||
|
- `src/`、`server-node/`、`scripts/` 中存在 import 排序、未使用导入、少量 hook 规则问题。
|
||||||
|
- `temp-build-goal-check/` 这类生成产物目录也被 ESLint 扫描进来,放大了噪音。
|
||||||
|
- `.eslintrc.cjs` 当前忽略了 `dist`、`media` 等目录,但没有忽略 `temp-build-goal-check`。
|
||||||
|
- `vite.config.ts` 的 `server.watch.ignored` 已经忽略了 `**/temp*build*/**`,说明当前 watch 口径和 lint 口径并不一致。
|
||||||
|
|
||||||
|
#### 影响
|
||||||
|
|
||||||
|
- 团队无法快速判断“现在是源码真问题,还是产物目录噪音”。
|
||||||
|
- lint 失真会直接削弱 review、回归和集成效率。
|
||||||
|
- 在这种状态下继续加功能,只会让真实错误被更多噪音淹没。
|
||||||
|
|
||||||
|
#### 当前建议
|
||||||
|
|
||||||
|
1. 先清理或迁出 `temp-build-goal-check/` 这类生成产物目录,至少不要再让它进入 lint 扫描范围。
|
||||||
|
2. 统一 `watch / lint / build` 对临时目录和生成目录的忽略口径。
|
||||||
|
3. 再集中清当前源码层 lint 问题,优先处理:
|
||||||
|
- import 排序
|
||||||
|
- 未使用导入
|
||||||
|
- 少量真实规则错误,例如 hook 误用和 `ban-types`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P0-2:修复构建 warning,恢复可发布构建
|
||||||
|
|
||||||
|
这是和 P0-1 同级的阻塞项。
|
||||||
|
|
||||||
|
#### 证据
|
||||||
|
|
||||||
|
- `npm run build` 当前会被 `scripts/build-gate.mjs` 拦截。
|
||||||
|
- 当前构建输出里最关键的 warning 有两类:
|
||||||
|
- `src/services/ai.ts` 虽然尝试走动态加载,但又被 `src/components/CustomWorldEntityEditorModal.tsx` 静态引入,导致拆包失效。
|
||||||
|
- `AuthenticatedApp-*.js` 达到 `1078.61 kB`,超过当前 `750 kB` 的 chunk warning 门槛。
|
||||||
|
- 同轮构建里,`index-*.css` 也已经达到 `157.56 kB`,说明不仅 JS 主块重,样式也在继续膨胀。
|
||||||
|
|
||||||
|
#### 影响
|
||||||
|
|
||||||
|
- 当前不是“构建有一点 warning 可以先带着走”,而是发布门禁已经被 warning 直接打断。
|
||||||
|
- editor / custom world / asset 工具能力正在把非主链代码重新带回主包路径。
|
||||||
|
- 后续如果继续叠加这条链路,首屏、缓存和回归都会继续变差。
|
||||||
|
|
||||||
|
#### 当前建议
|
||||||
|
|
||||||
|
1. 先切断 `CustomWorldEntityEditorModal.tsx -> ../services/ai` 的静态依赖,让 `ai.ts` 真正留在懒加载路径。
|
||||||
|
2. 把自定义世界编辑器、资产工作台、非首屏工具能力继续从 `AuthenticatedApp` 主块中拆出。
|
||||||
|
3. 保持 `build warning = 失败` 的策略,不建议通过放宽阈值掩盖问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1:拆掉新的复杂度中心
|
||||||
|
|
||||||
|
### P1-1:优先拆 editor / custom world / assets 新热点
|
||||||
|
|
||||||
|
旧的运行时主链热点已经有所缓解,但复杂度并没有消失,而是转移到了新的模块上。
|
||||||
|
|
||||||
|
#### 当前大文件热点
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
- `src/components/CustomWorldEntityEditorModal.tsx`:`2778` 行
|
||||||
|
- `src/services/ai.ts`:`2454` 行
|
||||||
|
- `src/services/customWorld.ts`:`2217` 行
|
||||||
|
- `src/data/npcInteractions.ts`:`2103` 行
|
||||||
|
- `src/data/characterPresets.ts`:`1953` 行
|
||||||
|
- `src/services/prompt.ts`:`1725` 行
|
||||||
|
|
||||||
|
后端:
|
||||||
|
|
||||||
|
- `server-node/src/modules/assets/characterAssetRoutes.ts`:`2295` 行
|
||||||
|
- `server-node/src/app.test.ts`:`1527` 行
|
||||||
|
- `server-node/src/auth/authService.ts`:`1243` 行
|
||||||
|
- `server-node/src/modules/quest/runtimeQuestModule.ts`:`1137` 行
|
||||||
|
|
||||||
|
工具链:
|
||||||
|
|
||||||
|
- `scripts/dev-server/localApiPlugins.ts`:`1504` 行
|
||||||
|
|
||||||
|
#### 影响
|
||||||
|
|
||||||
|
- 复杂度并没有真正被消灭,而是从运行时 story hook 转移到了自定义世界、资产编辑、提示词和数据装配链。
|
||||||
|
- 这些文件大多同时承载了:
|
||||||
|
- 领域规则
|
||||||
|
- API 调用
|
||||||
|
- 文本拼装
|
||||||
|
- UI 状态
|
||||||
|
- 工具流程
|
||||||
|
- 后续任何一个小改动,都容易牵动整条大链,回归成本会再次上升。
|
||||||
|
|
||||||
|
#### 当前建议
|
||||||
|
|
||||||
|
1. 前端优先拆 `CustomWorldEntityEditorModal.tsx`,按“世界锚点 / 角色 / 地点 / 资产 / 高级设置”分段。
|
||||||
|
2. 后端优先拆 `characterAssetRoutes.ts`,把 route、job orchestration、文件发布、模板读取拆开。
|
||||||
|
3. 把 `src/services/ai.ts` 和 `src/services/customWorld.ts` 继续按运行时 / 编辑器 / 资产工具三条职责分层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-2:继续收口 editor / assets 工具链边界
|
||||||
|
|
||||||
|
这项的重要性正在上升。
|
||||||
|
|
||||||
|
#### 证据
|
||||||
|
|
||||||
|
- `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md` 已说明 editor/assets API 已经迁到 `server-node`,方向是对的。
|
||||||
|
- 但当前仓库里仍保留一个 `1504` 行的 `scripts/dev-server/localApiPlugins.ts`。
|
||||||
|
- 目录 `temp-build-goal-check/` 当前包含 `15099` 个文件,已经开始干扰 lint 和本地开发信号。
|
||||||
|
- 相关日志里还出现了大量指向 `temp-build-goal-check` 的页面 reload 与 `ENOENT` 噪音。
|
||||||
|
|
||||||
|
#### 影响
|
||||||
|
|
||||||
|
- 旧工具链虽然“不再是主入口”,但它们还在继续占据认知空间和仓库噪音预算。
|
||||||
|
- 新旧 editor/assets 路径长期并存,会导致维护者很难快速判断哪条链才是正式路径。
|
||||||
|
|
||||||
|
#### 当前建议
|
||||||
|
|
||||||
|
1. 明确把旧 Vite 插件链标记为迁移参考,避免继续被误用。
|
||||||
|
2. 将临时构建目录、检查目录、导出目录统一移出主工程扫描面。
|
||||||
|
3. 对 editor/assets 正式入口补一份“唯一推荐入口”文档或 README 更新,减少后续回流。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2:继续做架构收口,但不必抢在 P0 前面
|
||||||
|
|
||||||
|
### P2-1:继续压缩前端遗留 AI / 自定义世界实现
|
||||||
|
|
||||||
|
这一项仍然值得做,但当前不再是最前面的阻塞。
|
||||||
|
|
||||||
|
#### 原因
|
||||||
|
|
||||||
|
- `docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md` 显示正式运行时主链已经大幅回收到后端。
|
||||||
|
- 当前更明显的遗留,已经集中到编辑器、自定义世界工作台和资产工具,而不是正式运行时 story 主链。
|
||||||
|
|
||||||
|
#### 当前建议
|
||||||
|
|
||||||
|
1. 继续让正式运行时保持“后端为真相源”。
|
||||||
|
2. 对仍留在前端的大 AI / prompt / custom world 实现,优先做职责收缩,而不是继续在原文件上堆逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-2:继续优化自定义世界工作台,但以“减负”和“分层”为主
|
||||||
|
|
||||||
|
这一项更适合作为 P0、P1 稳住后的下一轮重点。
|
||||||
|
|
||||||
|
#### 依据
|
||||||
|
|
||||||
|
- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md` 已经明确指出:
|
||||||
|
- 自定义世界入口、澄清、锁定、局部重生成、结果工作台仍是半收口状态。
|
||||||
|
- 当前最大的前端热点文件也集中在这条链路上,说明它已经不仅是产品问题,也是工程复杂度问题。
|
||||||
|
|
||||||
|
#### 当前建议
|
||||||
|
|
||||||
|
1. 优先减少“大一统编辑弹窗”的职责,把高杠杆编辑和高级编辑分层。
|
||||||
|
2. 让自定义世界生成、锁定、局部重生成规则继续向后端收口。
|
||||||
|
3. 移动端优先,避免长表单和重弹窗继续吞掉维护成本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐执行顺序
|
||||||
|
|
||||||
|
### 第一阶段:先把门禁拉回可信
|
||||||
|
|
||||||
|
1. 修 lint 口径失真
|
||||||
|
2. 清生成产物扫描污染
|
||||||
|
3. 修 build warning
|
||||||
|
|
||||||
|
### 第二阶段:再拆新的复杂度中心
|
||||||
|
|
||||||
|
1. 拆 `CustomWorldEntityEditorModal.tsx`
|
||||||
|
2. 拆 `characterAssetRoutes.ts`
|
||||||
|
3. 收缩 `src/services/ai.ts` / `src/services/customWorld.ts`
|
||||||
|
|
||||||
|
### 第三阶段:最后收 editor / custom world 架构尾巴
|
||||||
|
|
||||||
|
1. 清理旧 Vite 工具链残留
|
||||||
|
2. 继续把自定义世界和资产工具收回正式后端边界
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前不建议优先做的事
|
||||||
|
|
||||||
|
- 不建议在当前 lint 与 build 仍然是红线时继续横向扩 editor / custom world 功能。
|
||||||
|
- 不建议通过放宽 chunk warning 阈值来“修复”构建。
|
||||||
|
- 不建议继续在 `CustomWorldEntityEditorModal.tsx`、`src/services/ai.ts`、`characterAssetRoutes.ts` 这类巨型文件中直接堆新逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本文依据
|
||||||
|
|
||||||
|
文档依据:
|
||||||
|
|
||||||
|
- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md`
|
||||||
|
- `docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md`
|
||||||
|
- `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md`
|
||||||
|
- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md`
|
||||||
|
- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md`
|
||||||
|
|
||||||
|
当前仓库校验依据:
|
||||||
|
|
||||||
|
- `npm run check:encoding`
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run test`
|
||||||
|
- `npm run server-node:test:baseline`
|
||||||
|
- `npm run server-node:test`
|
||||||
|
- `npm run check:content`
|
||||||
|
- `npm run lint:eslint`
|
||||||
|
- `npm run build`
|
||||||
@@ -36,8 +36,7 @@
|
|||||||
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
||||||
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
||||||
"check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts",
|
"check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts",
|
||||||
"check:content": "npm run check:data && npm run check:overrides && npm run check:smoke",
|
"check:content": "npm run check:data && npm run check:overrides && npm run check:smoke"
|
||||||
"report:story-audit": "node scripts/run-tsx.cjs scripts/export-story-audit-report.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export type AuthMeResponse = {
|
|||||||
availableLoginMethods: AuthLoginMethod[];
|
availableLoginMethods: AuthLoginMethod[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthLoginOptionsResponse = {
|
||||||
|
availableLoginMethods: AuthLoginMethod[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthWechatStartResponse = {
|
export type AuthWechatStartResponse = {
|
||||||
authorizationUrl: string;
|
authorizationUrl: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -710,7 +710,7 @@ function buildNpcVisualPrompt(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
|
return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImageSequencePrompt(
|
function buildImageSequencePrompt(
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, resolve } from 'node:path';
|
|
||||||
|
|
||||||
import { buildCurrentGameStoryAuditMarkdown } from '../src/services/storyEngine/storyAuditReport.ts';
|
|
||||||
|
|
||||||
const defaultOutputPath = resolve(
|
|
||||||
process.cwd(),
|
|
||||||
'docs/audits/text/CURRENT_GAME_STORY_SOURCE_REVIEW_2026-04-07.md',
|
|
||||||
);
|
|
||||||
const outputPath = process.argv[2]
|
|
||||||
? resolve(process.cwd(), process.argv[2])
|
|
||||||
: defaultOutputPath;
|
|
||||||
|
|
||||||
mkdirSync(dirname(outputPath), { recursive: true });
|
|
||||||
writeFileSync(outputPath, buildCurrentGameStoryAuditMarkdown(), 'utf8');
|
|
||||||
|
|
||||||
console.log(`[story-audit] wrote ${outputPath}`);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildCompanionState, PRESET_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts';
|
import { buildCompanionState, ROLE_TEMPLATE_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts';
|
||||||
import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts';
|
import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts';
|
||||||
import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts';
|
import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts';
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +37,7 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBaseState(worldType: WorldType, sceneId?: string): GameState {
|
function createBaseState(worldType: WorldType, sceneId?: string): GameState {
|
||||||
const playerCharacter = PRESET_CHARACTERS[0];
|
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
const currentScenePreset = sceneId
|
const currentScenePreset = sceneId
|
||||||
? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null
|
? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null
|
||||||
: getScenePresetsByWorld(worldType)[0] ?? null;
|
: getScenePresetsByWorld(worldType)[0] ?? null;
|
||||||
@@ -114,7 +114,7 @@ function smokeNpcStories() {
|
|||||||
context: sceneWithNpc.npcs[0].role,
|
context: sceneWithNpc.npcs[0].role,
|
||||||
xMeters: 3.2,
|
xMeters: 3.2,
|
||||||
};
|
};
|
||||||
const playerCharacter = PRESET_CHARACTERS[0];
|
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
const npcState = buildInitialNpcState(encounter, worldType);
|
const npcState = buildInitialNpcState(encounter, worldType);
|
||||||
const story = buildNpcEncounterStoryMoment({
|
const story = buildNpcEncounterStoryMoment({
|
||||||
encounter,
|
encounter,
|
||||||
@@ -216,7 +216,7 @@ function smokeObserveAndCallOut() {
|
|||||||
|
|
||||||
function smokeInventoryUseLoop() {
|
function smokeInventoryUseLoop() {
|
||||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||||
const playerCharacter = PRESET_CHARACTERS[0];
|
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
const inventory = buildInitialPlayerInventory(playerCharacter, worldType);
|
const inventory = buildInitialPlayerInventory(playerCharacter, worldType);
|
||||||
const usableItem = inventory.find(item => isInventoryItemUsable(item));
|
const usableItem = inventory.find(item => isInventoryItemUsable(item));
|
||||||
assert(usableItem, `[inventory] missing usable starter item for ${worldType}`);
|
assert(usableItem, `[inventory] missing usable starter item for ${worldType}`);
|
||||||
@@ -231,7 +231,7 @@ function smokeInventoryUseLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function smokeEquipmentLoop() {
|
function smokeEquipmentLoop() {
|
||||||
const playerCharacter = PRESET_CHARACTERS[0];
|
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
||||||
const starterBonuses = getEquipmentBonuses(starterLoadout);
|
const starterBonuses = getEquipmentBonuses(starterLoadout);
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ function smokeTradeEconomyLoop() {
|
|||||||
};
|
};
|
||||||
const npcState = buildInitialNpcState(encounter, worldType);
|
const npcState = buildInitialNpcState(encounter, worldType);
|
||||||
const npcItem = npcState.inventory[0];
|
const npcItem = npcState.inventory[0];
|
||||||
const playerItem = buildInitialPlayerInventory(PRESET_CHARACTERS[0], worldType)[0];
|
const playerItem = buildInitialPlayerInventory(ROLE_TEMPLATE_CHARACTERS[0], worldType)[0];
|
||||||
assert(npcItem, `[trade] missing npc item for ${worldType}`);
|
assert(npcItem, `[trade] missing npc item for ${worldType}`);
|
||||||
assert(playerItem, `[trade] missing player item for ${worldType}`);
|
assert(playerItem, `[trade] missing player item for ${worldType}`);
|
||||||
|
|
||||||
@@ -326,9 +326,9 @@ function smokeEncounterTransitionLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function smokeRosterLoop() {
|
function smokeRosterLoop() {
|
||||||
const playerCharacter = PRESET_CHARACTERS[0];
|
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
const reserveCharacter = PRESET_CHARACTERS[1];
|
const reserveCharacter = ROLE_TEMPLATE_CHARACTERS[1];
|
||||||
const recruitCharacter = PRESET_CHARACTERS[2];
|
const recruitCharacter = ROLE_TEMPLATE_CHARACTERS[2];
|
||||||
const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68);
|
const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68);
|
||||||
const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62);
|
const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62);
|
||||||
const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72);
|
const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, PRESET_CHARACTERS } from '../src/data/characterPresets.ts';
|
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||||
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||||
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
||||||
@@ -45,7 +45,7 @@ function validateScenes(errors: string[]) {
|
|||||||
}
|
}
|
||||||
npcIds.add(npc.id);
|
npcIds.add(npc.id);
|
||||||
|
|
||||||
if (npc.characterId && !PRESET_CHARACTERS.some(character => character.id === npc.characterId)) {
|
if (npc.characterId && !ROLE_TEMPLATE_CHARACTERS.some(character => character.id === npc.characterId)) {
|
||||||
addError(errors, `[scene] ${scene.id} npc "${npc.id}" references unknown character "${npc.characterId}"`);
|
addError(errors, `[scene] ${scene.id} npc "${npc.id}" references unknown character "${npc.characterId}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -57,7 +57,7 @@ function validateCharacters(errors: string[]) {
|
|||||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||||
const sceneIdSet = new Set(getScenePresetsByWorld(worldType).map(scene => scene.id));
|
const sceneIdSet = new Set(getScenePresetsByWorld(worldType).map(scene => scene.id));
|
||||||
|
|
||||||
PRESET_CHARACTERS.forEach(character => {
|
ROLE_TEMPLATE_CHARACTERS.forEach(character => {
|
||||||
const homeSceneId = getCharacterHomeSceneId(worldType, character.id);
|
const homeSceneId = getCharacterHomeSceneId(worldType, character.id);
|
||||||
if (homeSceneId && !sceneIdSet.has(homeSceneId)) {
|
if (homeSceneId && !sceneIdSet.has(homeSceneId)) {
|
||||||
addError(errors, `[character] ${character.id} homeSceneId "${homeSceneId}" not found in ${worldType}`);
|
addError(errors, `[character] ${character.id} homeSceneId "${homeSceneId}" not found in ${worldType}`);
|
||||||
@@ -110,7 +110,7 @@ function main() {
|
|||||||
const monsterCount = MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA].length + MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA].length;
|
const monsterCount = MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA].length + MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA].length;
|
||||||
const functionCount = buildStateFunctionDefinitions().length;
|
const functionCount = buildStateFunctionDefinitions().length;
|
||||||
|
|
||||||
console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${PRESET_CHARACTERS.length} functions=${functionCount}`);
|
console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${ROLE_TEMPLATE_CHARACTERS.length} functions=${functionCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { readdirSync } from 'node:fs';
|
import { readdirSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { PRESET_CHARACTERS } from '../src/data/characterPresets.ts';
|
import { ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||||
import { buildItemCatalogId } from '../src/data/itemCatalog.ts';
|
import { buildItemCatalogId } from '../src/data/itemCatalog.ts';
|
||||||
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||||
@@ -34,7 +34,7 @@ function validateCharacterOverrides(errors: string[]) {
|
|||||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/characterOverrides.json');
|
const overrides = readJsonFile<Record<string, unknown>>('src/data/characterOverrides.json');
|
||||||
if (!expectPlainObject(errors, 'characterOverrides', overrides)) return;
|
if (!expectPlainObject(errors, 'characterOverrides', overrides)) return;
|
||||||
|
|
||||||
const characterIds = new Set(PRESET_CHARACTERS.map(character => character.id));
|
const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id));
|
||||||
const sceneIds = new Set(
|
const sceneIds = new Set(
|
||||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||||
);
|
);
|
||||||
@@ -142,7 +142,7 @@ function validateSceneNpcOverrides(errors: string[]) {
|
|||||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const characterIds = new Set(PRESET_CHARACTERS.map(character => character.id));
|
const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id));
|
||||||
|
|
||||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||||
if (!npcIds.has(npcId)) {
|
if (!npcIds.has(npcId)) {
|
||||||
|
|||||||
@@ -14,12 +14,25 @@ import { requestIdMiddleware } from './middleware/requestId.ts';
|
|||||||
import { createAppContext } from './server.ts';
|
import { createAppContext } from './server.ts';
|
||||||
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
||||||
|
|
||||||
function createTestConfig(testName: string): AppConfig {
|
type TestConfigOverrides = Partial<
|
||||||
|
Omit<AppConfig, 'llm' | 'dashScope' | 'smsAuth' | 'wechatAuth' | 'authSession'>
|
||||||
|
> & {
|
||||||
|
llm?: Partial<AppConfig['llm']>;
|
||||||
|
dashScope?: Partial<AppConfig['dashScope']>;
|
||||||
|
smsAuth?: Partial<AppConfig['smsAuth']>;
|
||||||
|
wechatAuth?: Partial<AppConfig['wechatAuth']>;
|
||||||
|
authSession?: Partial<AppConfig['authSession']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTestConfig(
|
||||||
|
testName: string,
|
||||||
|
overrides: TestConfigOverrides = {},
|
||||||
|
): AppConfig {
|
||||||
const tempRoot = fs.mkdtempSync(
|
const tempRoot = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
|
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const baseConfig: AppConfig = {
|
||||||
nodeEnv: 'test',
|
nodeEnv: 'test',
|
||||||
projectRoot: tempRoot,
|
projectRoot: tempRoot,
|
||||||
publicDir: path.join(tempRoot, 'public'),
|
publicDir: path.join(tempRoot, 'public'),
|
||||||
@@ -99,13 +112,39 @@ function createTestConfig(testName: string): AppConfig {
|
|||||||
refreshCookiePath: '/api/auth',
|
refreshCookiePath: '/api/auth',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
...overrides,
|
||||||
|
llm: {
|
||||||
|
...baseConfig.llm,
|
||||||
|
...overrides.llm,
|
||||||
|
},
|
||||||
|
dashScope: {
|
||||||
|
...baseConfig.dashScope,
|
||||||
|
...overrides.dashScope,
|
||||||
|
},
|
||||||
|
smsAuth: {
|
||||||
|
...baseConfig.smsAuth,
|
||||||
|
...overrides.smsAuth,
|
||||||
|
},
|
||||||
|
wechatAuth: {
|
||||||
|
...baseConfig.wechatAuth,
|
||||||
|
...overrides.wechatAuth,
|
||||||
|
},
|
||||||
|
authSession: {
|
||||||
|
...baseConfig.authSession,
|
||||||
|
...overrides.authSession,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withTestServer<T>(
|
async function withTestServer<T>(
|
||||||
testName: string,
|
testName: string,
|
||||||
run: (options: { baseUrl: string }) => Promise<T>,
|
run: (options: { baseUrl: string }) => Promise<T>,
|
||||||
|
overrides: TestConfigOverrides = {},
|
||||||
) {
|
) {
|
||||||
const context = await createAppContext(createTestConfig(testName));
|
const context = await createAppContext(createTestConfig(testName, overrides));
|
||||||
const app = createApp(context);
|
const app = createApp(context);
|
||||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||||
@@ -348,6 +387,130 @@ test('auth entry auto-registers, me works, logout invalidates old token', async
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('login options expose enabled methods without authentication', async () => {
|
||||||
|
await withTestServer('auth-login-options', async ({ baseUrl }) => {
|
||||||
|
const response = await httpRequest(`${baseUrl}/api/auth/login-options`);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
availableLoginMethods: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(payload.availableLoginMethods, ['phone', 'wechat']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat start uses qrconnect for desktop browsers', async () => {
|
||||||
|
await withTestServer(
|
||||||
|
'wechat-start-desktop',
|
||||||
|
async ({ baseUrl }) => {
|
||||||
|
const response = await httpRequest(
|
||||||
|
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/135.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
authorizationUrl: string;
|
||||||
|
};
|
||||||
|
const authorizationUrl = new URL(payload.authorizationUrl);
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||||||
|
'https://open.weixin.qq.com/connect/qrconnect',
|
||||||
|
);
|
||||||
|
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_login');
|
||||||
|
assert.equal(authorizationUrl.hash, '#wechat_redirect');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wechatAuth: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'wechat',
|
||||||
|
appId: 'wx-test-app-id',
|
||||||
|
appSecret: 'wx-test-app-secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat start uses oauth authorize inside wechat browser', async () => {
|
||||||
|
await withTestServer(
|
||||||
|
'wechat-start-in-app',
|
||||||
|
async ({ baseUrl }) => {
|
||||||
|
const response = await httpRequest(
|
||||||
|
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.54',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
authorizationUrl: string;
|
||||||
|
};
|
||||||
|
const authorizationUrl = new URL(payload.authorizationUrl);
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||||||
|
'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||||||
|
);
|
||||||
|
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_userinfo');
|
||||||
|
assert.equal(authorizationUrl.hash, '#wechat_redirect');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wechatAuth: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'wechat',
|
||||||
|
appId: 'wx-test-app-id',
|
||||||
|
appSecret: 'wx-test-app-secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat start rejects unsupported mobile browsers for real provider', async () => {
|
||||||
|
await withTestServer(
|
||||||
|
'wechat-start-mobile-browser',
|
||||||
|
async ({ baseUrl }) => {
|
||||||
|
const response = await httpRequest(
|
||||||
|
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
assert.equal(payload.error.code, 'BAD_REQUEST');
|
||||||
|
assert.equal(
|
||||||
|
payload.error.message,
|
||||||
|
'当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wechatAuth: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'wechat',
|
||||||
|
appId: 'wx-test-app-id',
|
||||||
|
appSecret: 'wx-test-app-secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('phone login sends code, creates a user and returns masked profile info', async () => {
|
test('phone login sends code, creates a user and returns masked profile info', async () => {
|
||||||
await withTestServer('phone-login', async ({ baseUrl }) => {
|
await withTestServer('phone-login', async ({ baseUrl }) => {
|
||||||
const sendResult = await sendPhoneCode(baseUrl, '13800138000');
|
const sendResult = await sendPhoneCode(baseUrl, '13800138000');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AuthAuditLogsResponse,
|
AuthAuditLogsResponse,
|
||||||
AuthBindingStatus,
|
AuthBindingStatus,
|
||||||
AuthEntryResponse,
|
AuthEntryResponse,
|
||||||
|
AuthLoginOptionsResponse,
|
||||||
AuthLiftRiskBlockResponse,
|
AuthLiftRiskBlockResponse,
|
||||||
AuthLoginMethod,
|
AuthLoginMethod,
|
||||||
AuthLogoutAllResponse,
|
AuthLogoutAllResponse,
|
||||||
@@ -151,6 +152,14 @@ export async function buildAuthMeResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAuthLoginOptionsResponse(
|
||||||
|
context: AppContext,
|
||||||
|
): AuthLoginOptionsResponse {
|
||||||
|
return {
|
||||||
|
availableLoginMethods: resolveAvailableLoginMethods(context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function signUserAuthPayload(
|
async function signUserAuthPayload(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
user: UserRecord,
|
user: UserRecord,
|
||||||
@@ -1077,12 +1086,14 @@ export async function startWechatLogin(
|
|||||||
context: AppContext,
|
context: AppContext,
|
||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
redirectPath: string,
|
redirectPath: string,
|
||||||
|
requestContext: RefreshSessionRequestContext | null = null,
|
||||||
): Promise<AuthWechatStartResponse> {
|
): Promise<AuthWechatStartResponse> {
|
||||||
const stateRecord = context.wechatAuthStates.create(redirectPath);
|
const stateRecord = context.wechatAuthStates.create(redirectPath);
|
||||||
return {
|
return {
|
||||||
authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({
|
authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
state: stateRecord.state,
|
state: stateRecord.state,
|
||||||
|
userAgent: requestContext?.userAgent ?? null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
61
server-node/src/config.test.ts
Normal file
61
server-node/src/config.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { loadConfig } from './config.ts';
|
||||||
|
|
||||||
|
function createTempProjectRoot(prefix: string) {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('development config auto-enables aliyun sms auth when local credentials are provided', () => {
|
||||||
|
const projectRoot = createTempProjectRoot('genarrative-config-dev-');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, '.env.example'),
|
||||||
|
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, '.env.local'),
|
||||||
|
'ALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadConfig({
|
||||||
|
projectRoot,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(config.smsAuth.enabled, true);
|
||||||
|
assert.equal(config.smsAuth.provider, 'aliyun');
|
||||||
|
assert.equal(config.smsAuth.accessKeyId, 'test-ak');
|
||||||
|
assert.equal(config.smsAuth.accessKeySecret, 'test-sk');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('development config respects explicit local sms auth overrides', () => {
|
||||||
|
const projectRoot = createTempProjectRoot('genarrative-config-local-');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, '.env.example'),
|
||||||
|
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, '.env.local'),
|
||||||
|
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\nALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadConfig({
|
||||||
|
projectRoot,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(config.smsAuth.enabled, false);
|
||||||
|
assert.equal(config.smsAuth.provider, 'aliyun');
|
||||||
|
});
|
||||||
@@ -131,14 +131,39 @@ function resolveDefaultProjectRoot() {
|
|||||||
: cwd;
|
: cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
|
function readMergedEnv(
|
||||||
|
exampleEnv: Record<string, string>,
|
||||||
|
localEnv: Record<string, string>,
|
||||||
|
processEnv: NodeJS.ProcessEnv,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
...readEnvFile(path.join(projectRoot, '.env.example')),
|
...exampleEnv,
|
||||||
...readEnvFile(path.join(projectRoot, '.env.local')),
|
...localEnv,
|
||||||
...processEnv,
|
...processEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwnEnvKey(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(env, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanOverride(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
overrideSources: Array<Record<string, string | undefined>>,
|
||||||
|
key: string,
|
||||||
|
fallback: boolean,
|
||||||
|
) {
|
||||||
|
const hasOverride = overrideSources.some((source) => hasOwnEnvKey(source, key));
|
||||||
|
if (!hasOverride) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readBoolean(env, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
function readString(
|
function readString(
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -199,33 +224,51 @@ function readBoolean(
|
|||||||
|
|
||||||
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||||
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
|
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
|
||||||
const env = readMergedEnv(projectRoot, options.env ?? process.env);
|
const exampleEnv = readEnvFile(path.join(projectRoot, '.env.example'));
|
||||||
|
const localEnv = readEnvFile(path.join(projectRoot, '.env.local'));
|
||||||
|
const processEnv = options.env ?? process.env;
|
||||||
|
const env = readMergedEnv(exampleEnv, localEnv, processEnv);
|
||||||
const logsDir = path.join(projectRoot, 'server-node', 'logs');
|
const logsDir = path.join(projectRoot, 'server-node', 'logs');
|
||||||
const dataDir = path.join(projectRoot, 'server-node', 'data');
|
const dataDir = path.join(projectRoot, 'server-node', 'data');
|
||||||
const defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production';
|
const nodeEnv = readString(env, 'NODE_ENV', 'development');
|
||||||
|
const defaultEditorApiEnabled = nodeEnv !== 'production';
|
||||||
const editorApiEnabled = readBoolean(
|
const editorApiEnabled = readBoolean(
|
||||||
env,
|
env,
|
||||||
'EDITOR_API_ENABLED',
|
'EDITOR_API_ENABLED',
|
||||||
defaultEditorApiEnabled,
|
defaultEditorApiEnabled,
|
||||||
);
|
);
|
||||||
const smsProvider = readString(
|
const smsProviderFromEnv = readString(
|
||||||
env,
|
env,
|
||||||
'SMS_AUTH_PROVIDER',
|
'SMS_AUTH_PROVIDER',
|
||||||
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun',
|
nodeEnv === 'test' ? 'mock' : 'aliyun',
|
||||||
) as AppConfig['smsAuth']['provider'];
|
) as AppConfig['smsAuth']['provider'];
|
||||||
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
|
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
|
||||||
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
|
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
|
||||||
|
const smsProvider = smsProviderFromEnv;
|
||||||
const defaultSmsEnabled =
|
const defaultSmsEnabled =
|
||||||
smsProvider === 'mock' || Boolean(smsAccessKeyId && smsAccessKeySecret);
|
smsProvider === 'mock' ||
|
||||||
|
Boolean(smsAccessKeyId && smsAccessKeySecret);
|
||||||
|
const smsEnabled = readBooleanOverride(
|
||||||
|
env,
|
||||||
|
[localEnv, processEnv],
|
||||||
|
'SMS_AUTH_ENABLED',
|
||||||
|
defaultSmsEnabled,
|
||||||
|
);
|
||||||
const wechatProvider = readString(
|
const wechatProvider = readString(
|
||||||
env,
|
env,
|
||||||
'WECHAT_AUTH_PROVIDER',
|
'WECHAT_AUTH_PROVIDER',
|
||||||
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat',
|
nodeEnv === 'test' ? 'mock' : 'wechat',
|
||||||
) as AppConfig['wechatAuth']['provider'];
|
) as AppConfig['wechatAuth']['provider'];
|
||||||
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
|
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
|
||||||
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
|
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
|
||||||
const defaultWechatEnabled =
|
const defaultWechatEnabled =
|
||||||
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
|
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
|
||||||
|
const wechatEnabled = readBooleanOverride(
|
||||||
|
env,
|
||||||
|
[localEnv, processEnv],
|
||||||
|
'WECHAT_AUTH_ENABLED',
|
||||||
|
defaultWechatEnabled,
|
||||||
|
);
|
||||||
const refreshSameSite = readString(
|
const refreshSameSite = readString(
|
||||||
env,
|
env,
|
||||||
'AUTH_REFRESH_COOKIE_SAME_SITE',
|
'AUTH_REFRESH_COOKIE_SAME_SITE',
|
||||||
@@ -233,7 +276,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodeEnv: readString(env, 'NODE_ENV', 'development'),
|
nodeEnv,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
publicDir: path.join(projectRoot, 'public'),
|
publicDir: path.join(projectRoot, 'public'),
|
||||||
logsDir,
|
logsDir,
|
||||||
@@ -295,7 +338,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
smsAuth: {
|
smsAuth: {
|
||||||
enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled),
|
enabled: smsEnabled,
|
||||||
provider: smsProvider,
|
provider: smsProvider,
|
||||||
endpoint: readString(
|
endpoint: readString(
|
||||||
env,
|
env,
|
||||||
@@ -410,7 +453,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
wechatAuth: {
|
wechatAuth: {
|
||||||
enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled),
|
enabled: wechatEnabled,
|
||||||
provider: wechatProvider,
|
provider: wechatProvider,
|
||||||
appId: wechatAppId,
|
appId: wechatAppId,
|
||||||
appSecret: wechatAppSecret,
|
appSecret: wechatAppSecret,
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ function readStringArray(value: unknown) {
|
|||||||
function describeWorld(worldType: string) {
|
function describeWorld(worldType: string) {
|
||||||
switch (worldType) {
|
switch (worldType) {
|
||||||
case 'WUXIA':
|
case 'WUXIA':
|
||||||
return '武侠';
|
return '边城模板';
|
||||||
case 'XIANXIA':
|
case 'XIANXIA':
|
||||||
return '仙侠';
|
return '灵潮模板';
|
||||||
case 'CUSTOM':
|
case 'CUSTOM':
|
||||||
return '自定义世界';
|
return '自定义世界';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
|
|||||||
generatedFrom: {
|
generatedFrom: {
|
||||||
worldType,
|
worldType,
|
||||||
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
|
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
|
||||||
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界',
|
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
|
||||||
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震',
|
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
|
||||||
conflictCore: '旧秩序与新威胁正在同时逼近',
|
conflictCore: '旧秩序与新威胁正在同时逼近',
|
||||||
},
|
},
|
||||||
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
|
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
|
||||||
@@ -371,9 +371,10 @@ function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
|
|||||||
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
|
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
|
||||||
subtitle: '前路未明',
|
subtitle: '前路未明',
|
||||||
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
|
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
|
||||||
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震',
|
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
|
||||||
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
|
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
|
||||||
templateWorldType: worldType,
|
templateWorldType: worldType,
|
||||||
|
compatibilityTemplateWorldType: worldType,
|
||||||
majorFactions: inferMajorFactions(seed),
|
majorFactions: inferMajorFactions(seed),
|
||||||
coreConflicts: inferCoreConflicts(setting),
|
coreConflicts: inferCoreConflicts(setting),
|
||||||
attributeSchema: buildAttributeSchema(worldType),
|
attributeSchema: buildAttributeSchema(worldType),
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function readNumber(value: unknown, fallback = 0) {
|
|||||||
function describeWorld(worldType: string) {
|
function describeWorld(worldType: string) {
|
||||||
switch (worldType) {
|
switch (worldType) {
|
||||||
case 'WUXIA':
|
case 'WUXIA':
|
||||||
return '武侠';
|
return '边城模板';
|
||||||
case 'XIANXIA':
|
case 'XIANXIA':
|
||||||
return '仙侠';
|
return '灵潮模板';
|
||||||
case 'CUSTOM':
|
case 'CUSTOM':
|
||||||
return '自定义世界';
|
return '自定义世界';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -709,7 +709,7 @@ function buildNpcVisualPrompt(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
|
return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImageSequencePrompt(
|
function buildImageSequencePrompt(
|
||||||
|
|||||||
@@ -757,9 +757,9 @@ function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
|||||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||||
switch (worldType) {
|
switch (worldType) {
|
||||||
case 'WUXIA':
|
case 'WUXIA':
|
||||||
return '武侠';
|
return '边城模板';
|
||||||
case 'XIANXIA':
|
case 'XIANXIA':
|
||||||
return '仙侠';
|
return '灵潮模板';
|
||||||
case 'CUSTOM':
|
case 'CUSTOM':
|
||||||
return '自定义世界';
|
return '自定义世界';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
|
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
|
||||||
import {
|
import {
|
||||||
bindWechatPhone,
|
bindWechatPhone,
|
||||||
|
buildAuthLoginOptionsResponse,
|
||||||
buildAuthMeResponse,
|
buildAuthMeResponse,
|
||||||
changeUserPhone,
|
changeUserPhone,
|
||||||
createRefreshSession,
|
createRefreshSession,
|
||||||
@@ -115,6 +116,14 @@ export function createAuthRoutes(context: AppContext) {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/login-options',
|
||||||
|
routeMeta({ operation: 'auth.login_options' }),
|
||||||
|
asyncHandler(async (_request, response) => {
|
||||||
|
sendApiResponse(response, buildAuthLoginOptionsResponse(context));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/entry',
|
'/entry',
|
||||||
routeMeta({ operation: 'auth.entry' }),
|
routeMeta({ operation: 'auth.entry' }),
|
||||||
@@ -232,6 +241,7 @@ export function createAuthRoutes(context: AppContext) {
|
|||||||
request.query.redirectPath,
|
request.query.redirectPath,
|
||||||
context.config.wechatAuth.defaultRedirectPath,
|
context.config.wechatAuth.defaultRedirectPath,
|
||||||
);
|
);
|
||||||
|
const requestContext = buildAuthRequestContext(request);
|
||||||
const callbackUrl = new URL(
|
const callbackUrl = new URL(
|
||||||
context.config.wechatAuth.callbackPath,
|
context.config.wechatAuth.callbackPath,
|
||||||
resolveRequestOrigin(request),
|
resolveRequestOrigin(request),
|
||||||
@@ -239,7 +249,12 @@ export function createAuthRoutes(context: AppContext) {
|
|||||||
|
|
||||||
sendApiResponse(
|
sendApiResponse(
|
||||||
response,
|
response,
|
||||||
await startWechatLogin(context, callbackUrl, redirectPath),
|
await startWechatLogin(
|
||||||
|
context,
|
||||||
|
callbackUrl,
|
||||||
|
redirectPath,
|
||||||
|
requestContext,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
|
|||||||
return !config.accessKeyId || !config.accessKeySecret;
|
return !config.accessKeyId || !config.accessKeySecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) {
|
||||||
|
if (!config.signName.trim()) {
|
||||||
|
throw new Error('ALIYUN_SMS_SIGN_NAME 未配置');
|
||||||
|
}
|
||||||
|
if (!config.templateCode.trim()) {
|
||||||
|
throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置');
|
||||||
|
}
|
||||||
|
if (!config.templateParamKey.trim()) {
|
||||||
|
throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildProviderErrorMessage(prefix: string, message: string) {
|
function buildProviderErrorMessage(prefix: string, message: string) {
|
||||||
const normalizedMessage = message.trim();
|
const normalizedMessage = message.trim();
|
||||||
return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix;
|
return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix;
|
||||||
@@ -48,6 +60,7 @@ class AliyunSmsVerificationService implements SmsVerificationService {
|
|||||||
if (isAliyunConfigMissing(config)) {
|
if (isAliyunConfigMissing(config)) {
|
||||||
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
|
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
|
||||||
}
|
}
|
||||||
|
assertAliyunRequiredConfig(config);
|
||||||
|
|
||||||
const clientConfig = new OpenApiClient.Config({
|
const clientConfig = new OpenApiClient.Config({
|
||||||
accessKeyId: config.accessKeyId,
|
accessKeyId: config.accessKeyId,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type WechatAuthService = {
|
|||||||
buildAuthorizationUrl(params: {
|
buildAuthorizationUrl(params: {
|
||||||
callbackUrl: string;
|
callbackUrl: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
userAgent?: string | null;
|
||||||
}): string;
|
}): string;
|
||||||
resolveCallbackProfile(params: {
|
resolveCallbackProfile(params: {
|
||||||
code?: string | null;
|
code?: string | null;
|
||||||
@@ -22,12 +23,40 @@ export type WechatAuthService = {
|
|||||||
}): Promise<WechatIdentityProfile>;
|
}): Promise<WechatIdentityProfile>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WechatAuthorizationScene = 'desktop' | 'wechat_in_app';
|
||||||
|
|
||||||
|
const WECHAT_IN_APP_AUTHORIZE_ENDPOINT =
|
||||||
|
'https://open.weixin.qq.com/connect/oauth2/authorize';
|
||||||
|
|
||||||
|
function isWechatBrowser(userAgent?: string | null) {
|
||||||
|
return /MicroMessenger/iu.test(userAgent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileBrowser(userAgent?: string | null) {
|
||||||
|
return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWechatAuthorizationScene(
|
||||||
|
userAgent?: string | null,
|
||||||
|
): WechatAuthorizationScene {
|
||||||
|
if (isWechatBrowser(userAgent)) {
|
||||||
|
return 'wechat_in_app';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobileBrowser(userAgent)) {
|
||||||
|
throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
class MockWechatAuthService implements WechatAuthService {
|
class MockWechatAuthService implements WechatAuthService {
|
||||||
constructor(private readonly config: AppConfig['wechatAuth']) {}
|
constructor(private readonly config: AppConfig['wechatAuth']) {}
|
||||||
|
|
||||||
buildAuthorizationUrl(params: {
|
buildAuthorizationUrl(params: {
|
||||||
callbackUrl: string;
|
callbackUrl: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
userAgent?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const callbackUrl = new URL(params.callbackUrl);
|
const callbackUrl = new URL(params.callbackUrl);
|
||||||
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
|
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
|
||||||
@@ -64,12 +93,21 @@ class RealWechatAuthService implements WechatAuthService {
|
|||||||
buildAuthorizationUrl(params: {
|
buildAuthorizationUrl(params: {
|
||||||
callbackUrl: string;
|
callbackUrl: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
userAgent?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const url = new URL(this.config.authorizeEndpoint);
|
const scene = resolveWechatAuthorizationScene(params.userAgent);
|
||||||
|
const url = new URL(
|
||||||
|
scene === 'wechat_in_app'
|
||||||
|
? WECHAT_IN_APP_AUTHORIZE_ENDPOINT
|
||||||
|
: this.config.authorizeEndpoint,
|
||||||
|
);
|
||||||
url.searchParams.set('appid', this.config.appId);
|
url.searchParams.set('appid', this.config.appId);
|
||||||
url.searchParams.set('redirect_uri', params.callbackUrl);
|
url.searchParams.set('redirect_uri', params.callbackUrl);
|
||||||
url.searchParams.set('response_type', 'code');
|
url.searchParams.set('response_type', 'code');
|
||||||
url.searchParams.set('scope', 'snsapi_login');
|
url.searchParams.set(
|
||||||
|
'scope',
|
||||||
|
scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login',
|
||||||
|
);
|
||||||
url.searchParams.set('state', params.state);
|
url.searchParams.set('state', params.state);
|
||||||
return `${url.toString()}#wechat_redirect`;
|
return `${url.toString()}#wechat_redirect`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||||
import {
|
import {
|
||||||
buildCustomWorldPlayableCharacters,
|
buildCustomWorldPlayableCharacters,
|
||||||
PRESET_CHARACTERS,
|
ROLE_TEMPLATE_CHARACTERS,
|
||||||
} from '../data/characterPresets';
|
} from '../data/characterPresets';
|
||||||
import {
|
import {
|
||||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
|
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
|
||||||
@@ -522,18 +522,18 @@ function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) {
|
|||||||
if (candidates.length === 1) {
|
if (candidates.length === 1) {
|
||||||
const firstCandidate = candidates[0];
|
const firstCandidate = candidates[0];
|
||||||
if (!firstCandidate) {
|
if (!firstCandidate) {
|
||||||
return PRESET_CHARACTERS.slice(0, 2);
|
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
|
||||||
}
|
}
|
||||||
const fallback =
|
const fallback =
|
||||||
PRESET_CHARACTERS.find(
|
ROLE_TEMPLATE_CHARACTERS.find(
|
||||||
(character) => character.id !== firstCandidate.id,
|
(character) => character.id !== firstCandidate.id,
|
||||||
) ??
|
) ??
|
||||||
PRESET_CHARACTERS[0] ??
|
ROLE_TEMPLATE_CHARACTERS[0] ??
|
||||||
firstCandidate;
|
firstCandidate;
|
||||||
return [firstCandidate, fallback];
|
return [firstCandidate, fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
return PRESET_CHARACTERS.slice(0, 2);
|
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
const [leftCharacter, rightCharacter] = sparringCharacters;
|
const [leftCharacter, rightCharacter] = sparringCharacters;
|
||||||
@@ -1622,10 +1622,10 @@ function PlayableNpcEditor({
|
|||||||
const [draft, setDraft] = useDraft(npc);
|
const [draft, setDraft] = useDraft(npc);
|
||||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||||
const selectedTemplate =
|
const selectedTemplate =
|
||||||
PRESET_CHARACTERS.find(
|
ROLE_TEMPLATE_CHARACTERS.find(
|
||||||
(character) => character.id === draft.templateCharacterId,
|
(character) => character.id === draft.templateCharacterId,
|
||||||
) ??
|
) ??
|
||||||
PRESET_CHARACTERS[0] ??
|
ROLE_TEMPLATE_CHARACTERS[0] ??
|
||||||
null;
|
null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1682,14 +1682,14 @@ function PlayableNpcEditor({
|
|||||||
|
|
||||||
<Field label="外观模板">
|
<Field label="外观模板">
|
||||||
<SelectField
|
<SelectField
|
||||||
value={draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id ?? ''}
|
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
templateCharacterId: value,
|
templateCharacterId: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
options={PRESET_CHARACTERS.map((character) => ({
|
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
|
||||||
value: character.id,
|
value: character.id,
|
||||||
label: `${character.name} / ${character.title}`,
|
label: `${character.name} / ${character.title}`,
|
||||||
}))}
|
}))}
|
||||||
@@ -1838,7 +1838,7 @@ function PlayableNpcEditor({
|
|||||||
onSave({
|
onSave({
|
||||||
...draft,
|
...draft,
|
||||||
templateCharacterId:
|
templateCharacterId:
|
||||||
draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id,
|
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
@@ -2548,9 +2548,9 @@ function createPlayableNpc(
|
|||||||
): CustomWorldPlayableNpc {
|
): CustomWorldPlayableNpc {
|
||||||
const seed = Date.now() + profile.playableNpcs.length;
|
const seed = Date.now() + profile.playableNpcs.length;
|
||||||
const template =
|
const template =
|
||||||
PRESET_CHARACTERS[
|
ROLE_TEMPLATE_CHARACTERS[
|
||||||
profile.playableNpcs.length % Math.max(1, PRESET_CHARACTERS.length)
|
profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
|
||||||
] ?? PRESET_CHARACTERS[0];
|
] ?? ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: createEntryId(
|
id: createEntryId(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
|
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { PRESET_CHARACTERS } from '../data/characterPresets';
|
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||||
import {
|
import {
|
||||||
AnimationState,
|
AnimationState,
|
||||||
type CustomWorldNpc,
|
type CustomWorldNpc,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
buildAnimationClipFromVideoSource,
|
buildAnimationClipFromVideoSource,
|
||||||
type DraftAnimationClip,
|
type DraftAnimationClip,
|
||||||
readFileAsDataUrl,
|
readFileAsDataUrl,
|
||||||
} from './preset-editor/characterAssetStudioModel';
|
} from './asset-studio/characterAssetWorkflowModel';
|
||||||
import {
|
import {
|
||||||
type CharacterAnimationDraftPayload,
|
type CharacterAnimationDraftPayload,
|
||||||
type CharacterAnimationGenerationPayload,
|
type CharacterAnimationGenerationPayload,
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
generateCharacterVisualCandidates,
|
generateCharacterVisualCandidates,
|
||||||
publishCharacterAnimationAssets,
|
publishCharacterAnimationAssets,
|
||||||
publishCharacterVisualAsset,
|
publishCharacterVisualAsset,
|
||||||
} from './preset-editor/characterAssetStudioPersistence';
|
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||||
|
|
||||||
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
|
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
|
|
||||||
const selectedTemplate =
|
const selectedTemplate =
|
||||||
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
|
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
|
||||||
? PRESET_CHARACTERS.find(
|
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||||
(character) => character.id === role.templateCharacterId,
|
(character) => character.id === role.templateCharacterId,
|
||||||
) ?? null
|
) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -661,7 +661,7 @@ export function CustomWorldRoleAssetStudioModal({
|
|||||||
value={visualPromptText}
|
value={visualPromptText}
|
||||||
onChange={setVisualPromptText}
|
onChange={setVisualPromptText}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
|
placeholder="例如:衣摆更利落、主武器辨识度更高、整体更像边境世界的年轻冒险者。"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="参考图">
|
<Field label="参考图">
|
||||||
|
|||||||
@@ -1,908 +0,0 @@
|
|||||||
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { PRESET_CHARACTERS } from '../data/characterPresets';
|
|
||||||
import { getInventoryItemValue } from '../data/economy';
|
|
||||||
import { validateItemOverrides } from '../data/editorValidation';
|
|
||||||
import { getEquipmentSlotFromItem, getEquipmentSlotLabel } from '../data/equipmentEffects';
|
|
||||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
|
||||||
import {
|
|
||||||
applyItemCatalogOverride,
|
|
||||||
buildItemCatalogFromAssetPaths,
|
|
||||||
createInventoryItemFromCatalogEntry,
|
|
||||||
ITEM_CATALOG_API_PATH,
|
|
||||||
ITEM_CATEGORY_OPTIONS,
|
|
||||||
} from '../data/itemCatalog';
|
|
||||||
import {
|
|
||||||
EDITOR_JSON_RESOURCE_IDS,
|
|
||||||
fetchEditorJsonResource,
|
|
||||||
saveEditorJsonResource,
|
|
||||||
} from '../editor/shared/editorApiClient';
|
|
||||||
import { fetchJson } from '../editor/shared/jsonClient';
|
|
||||||
import { SectionCard as Section } from '../editor/shared/SectionCard';
|
|
||||||
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
|
|
||||||
import { PixelIcon } from './PixelIcon';
|
|
||||||
|
|
||||||
const ITEM_PREVIEW_CHARACTER = PRESET_CHARACTERS[0] ?? null;
|
|
||||||
const LIST_PREVIEW_LIMIT = 240;
|
|
||||||
|
|
||||||
type ItemCatalogAssetResponse = {
|
|
||||||
assetPaths: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const RARITY_OPTIONS: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
|
||||||
const RARITY_LABELS: Record<ItemRarity, string> = {
|
|
||||||
common: '普通',
|
|
||||||
uncommon: '不普通',
|
|
||||||
rare: '稀有',
|
|
||||||
epic: '史诗',
|
|
||||||
legendary: '传奇',
|
|
||||||
};
|
|
||||||
|
|
||||||
function arraysEqual(left: string[], right: string[]) {
|
|
||||||
if (left.length !== right.length) return false;
|
|
||||||
return left.every((value, index) => value === right[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTagsInput(value: string) {
|
|
||||||
return [...new Set(
|
|
||||||
value
|
|
||||||
.split(/[\n,]/u)
|
|
||||||
.map(tag => tag.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagsInputValue(tags: string[]) {
|
|
||||||
return tags.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBuildBuffLines(
|
|
||||||
value: string,
|
|
||||||
sourceType: TimedBuildBuff['sourceType'],
|
|
||||||
sourceId: string,
|
|
||||||
) {
|
|
||||||
return value
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line, index) => {
|
|
||||||
const [namePart, tagsPart, durationPart] = line.split('|').map(part => part.trim());
|
|
||||||
const tags = parseTagsInput(tagsPart ?? '');
|
|
||||||
const durationTurns = Math.max(1, Number(durationPart ?? '1') || 1);
|
|
||||||
return {
|
|
||||||
id: `${sourceId}-buff-${index + 1}`,
|
|
||||||
sourceType,
|
|
||||||
sourceId,
|
|
||||||
name: namePart || `${sourceId}-buff-${index + 1}`,
|
|
||||||
tags,
|
|
||||||
durationTurns,
|
|
||||||
} satisfies TimedBuildBuff;
|
|
||||||
})
|
|
||||||
.filter(buff => buff.tags.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBuffLinesValue(buffs: TimedBuildBuff[] | null | undefined) {
|
|
||||||
return (buffs ?? [])
|
|
||||||
.map(buff => `${buff.name}|${buff.tags.join(',')}|${buff.durationTurns}`)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function Label({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="mb-1 text-xs font-medium text-zinc-300">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
disabled = false,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
value={value}
|
|
||||||
onChange={event => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextArea({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
rows = 4,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
rows?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
rows={rows}
|
|
||||||
value={value}
|
|
||||||
onChange={event => onChange(event.target.value)}
|
|
||||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Select({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
options: Array<{ label: string; value: string }>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={event => onChange(event.target.value)}
|
|
||||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40"
|
|
||||||
>
|
|
||||||
{options.map(option => (
|
|
||||||
<option key={`${option.value}-${option.label}`} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ItemCatalogEditor() {
|
|
||||||
const [assetPaths, setAssetPaths] = useState<string[]>([]);
|
|
||||||
const [overrideMap, setOverrideMap] = useState<Record<string, ItemCatalogOverride>>({});
|
|
||||||
const [selectedItemId, setSelectedItemId] = useState('');
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('ALL');
|
|
||||||
const [rarityFilter, setRarityFilter] = useState<'ALL' | ItemRarity>('ALL');
|
|
||||||
const [previewWorld, setPreviewWorld] = useState<WorldType>(WorldType.WUXIA);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
|
||||||
const deferredSearchText = useDeferredValue(searchText);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoadError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [catalogResponse, overridesResponse] = await Promise.all([
|
|
||||||
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
|
|
||||||
fetchEditorJsonResource<Record<string, ItemCatalogOverride>>(
|
|
||||||
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (disposed) return;
|
|
||||||
|
|
||||||
const nextAssetPaths = catalogResponse.assetPaths ?? [];
|
|
||||||
setAssetPaths(nextAssetPaths);
|
|
||||||
setOverrideMap(overridesResponse ?? {});
|
|
||||||
setSelectedItemId(current => current || (buildItemCatalogFromAssetPaths(nextAssetPaths)[0]?.id ?? ''));
|
|
||||||
} catch (error) {
|
|
||||||
if (disposed) return;
|
|
||||||
setLoadError(error instanceof Error ? error.message : '物品目录加载失败');
|
|
||||||
} finally {
|
|
||||||
if (!disposed) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void load();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const baseItems = useMemo(
|
|
||||||
() => buildItemCatalogFromAssetPaths(assetPaths),
|
|
||||||
[assetPaths],
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseItemMap = useMemo(
|
|
||||||
() => new Map(baseItems.map(item => [item.id, item])),
|
|
||||||
[baseItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const effectiveItems = useMemo(
|
|
||||||
() => baseItems.map(item => applyItemCatalogOverride(item, overrideMap[item.id])),
|
|
||||||
[baseItems, overrideMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
const query = deferredSearchText.trim().toLowerCase();
|
|
||||||
|
|
||||||
return effectiveItems.filter(item => {
|
|
||||||
if (categoryFilter !== 'ALL' && item.category !== categoryFilter) return false;
|
|
||||||
if (rarityFilter !== 'ALL' && item.rarity !== rarityFilter) return false;
|
|
||||||
if (!query) return true;
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
item.name,
|
|
||||||
item.category,
|
|
||||||
item.rarity,
|
|
||||||
item.description,
|
|
||||||
item.sourcePath,
|
|
||||||
...item.tags,
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
|
|
||||||
return haystack.includes(query);
|
|
||||||
});
|
|
||||||
}, [categoryFilter, deferredSearchText, effectiveItems, rarityFilter]);
|
|
||||||
|
|
||||||
const visibleItems = useMemo(
|
|
||||||
() => filteredItems.slice(0, LIST_PREVIEW_LIMIT),
|
|
||||||
[filteredItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!effectiveItems.length) {
|
|
||||||
setSelectedItemId('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedItemId || !baseItemMap.has(selectedItemId)) {
|
|
||||||
setSelectedItemId(effectiveItems[0]?.id ?? '');
|
|
||||||
}
|
|
||||||
}, [baseItemMap, effectiveItems, selectedItemId]);
|
|
||||||
|
|
||||||
const selectedBaseItem = selectedItemId ? baseItemMap.get(selectedItemId) ?? null : null;
|
|
||||||
const selectedItem = selectedBaseItem
|
|
||||||
? applyItemCatalogOverride(selectedBaseItem, overrideMap[selectedBaseItem.id])
|
|
||||||
: null;
|
|
||||||
const selectedOverride = selectedItemId ? overrideMap[selectedItemId] ?? null : null;
|
|
||||||
|
|
||||||
const previewInventoryItem = useMemo(
|
|
||||||
() => selectedItem ? createInventoryItemFromCatalogEntry(selectedItem, 1, previewWorld) : null,
|
|
||||||
[previewWorld, selectedItem],
|
|
||||||
);
|
|
||||||
const worldProfile = selectedItem?.worldProfiles?.[previewWorld] ?? null;
|
|
||||||
|
|
||||||
const previewUseEffect = useMemo(
|
|
||||||
() => (previewInventoryItem && ITEM_PREVIEW_CHARACTER)
|
|
||||||
? resolveInventoryItemUseEffect(previewInventoryItem, ITEM_PREVIEW_CHARACTER)
|
|
||||||
: null,
|
|
||||||
[previewInventoryItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const previewEquipmentSlot = useMemo(
|
|
||||||
() => previewInventoryItem
|
|
||||||
? getEquipmentSlotFromItem(previewInventoryItem)
|
|
||||||
: null,
|
|
||||||
[previewInventoryItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSelectedOverride = <K extends keyof ItemCatalogOverride>(
|
|
||||||
key: K,
|
|
||||||
value: ItemCatalogOverride[K],
|
|
||||||
) => {
|
|
||||||
if (!selectedBaseItem) return;
|
|
||||||
|
|
||||||
setOverrideMap(current => {
|
|
||||||
const nextOverride = {
|
|
||||||
...(current[selectedBaseItem.id] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizedOverride: ItemCatalogOverride = {...nextOverride};
|
|
||||||
|
|
||||||
if ((normalizedOverride.name ?? selectedBaseItem.name) === selectedBaseItem.name) {
|
|
||||||
delete normalizedOverride.name;
|
|
||||||
}
|
|
||||||
if ((normalizedOverride.category ?? selectedBaseItem.category) === selectedBaseItem.category) {
|
|
||||||
delete normalizedOverride.category;
|
|
||||||
}
|
|
||||||
if ((normalizedOverride.rarity ?? selectedBaseItem.rarity) === selectedBaseItem.rarity) {
|
|
||||||
delete normalizedOverride.rarity;
|
|
||||||
}
|
|
||||||
if ((normalizedOverride.description ?? selectedBaseItem.description) === selectedBaseItem.description) {
|
|
||||||
delete normalizedOverride.description;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalizedOverride.tags &&
|
|
||||||
arraysEqual(normalizedOverride.tags, selectedBaseItem.tags)
|
|
||||||
) {
|
|
||||||
delete normalizedOverride.tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasOverride = Object.keys(normalizedOverride).length > 0;
|
|
||||||
if (!hasOverride) {
|
|
||||||
const { [selectedBaseItem.id]: _removed, ...rest } = current;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[selectedBaseItem.id]: normalizedOverride,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSelectedStatProfileField = (
|
|
||||||
key: 'maxHpBonus' | 'maxManaBonus' | 'outgoingDamageBonus' | 'incomingDamageMultiplier',
|
|
||||||
value: number,
|
|
||||||
) => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
const nextProfile = {
|
|
||||||
...(selectedItem.statProfile ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
};
|
|
||||||
updateSelectedOverride('statProfile', nextProfile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSelectedUseProfileField = (
|
|
||||||
key: 'hpRestore' | 'manaRestore' | 'cooldownReduction',
|
|
||||||
value: number,
|
|
||||||
) => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
const nextProfile = {
|
|
||||||
...(selectedItem.useProfile ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
buildBuffs: selectedItem.useProfile?.buildBuffs ?? [],
|
|
||||||
};
|
|
||||||
updateSelectedOverride('useProfile', nextProfile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSelectedUseProfileBuffs = (value: string) => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
const nextProfile = {
|
|
||||||
...(selectedItem.useProfile ?? {}),
|
|
||||||
hpRestore: selectedItem.useProfile?.hpRestore ?? 0,
|
|
||||||
manaRestore: selectedItem.useProfile?.manaRestore ?? 0,
|
|
||||||
cooldownReduction: selectedItem.useProfile?.cooldownReduction ?? 0,
|
|
||||||
buildBuffs: parseBuildBuffLines(value, 'item', selectedItem.id),
|
|
||||||
};
|
|
||||||
updateSelectedOverride('useProfile', nextProfile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSelectedBuildProfileField = (
|
|
||||||
key: 'role' | 'setId' | 'setName' | 'pieceName' | 'forgeRank',
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
const nextProfile = {
|
|
||||||
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
|
|
||||||
[key]: value,
|
|
||||||
tags: selectedItem.buildProfile?.tags ?? [],
|
|
||||||
synergy: selectedItem.buildProfile?.synergy ?? [],
|
|
||||||
craftTags: selectedItem.buildProfile?.craftTags ?? [],
|
|
||||||
};
|
|
||||||
updateSelectedOverride('buildProfile', nextProfile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSelectedBuildProfileTags = (key: 'tags' | 'synergy' | 'craftTags', value: string) => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
const nextProfile = {
|
|
||||||
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
|
|
||||||
role: selectedItem.buildProfile?.role ?? '',
|
|
||||||
tags: key === 'tags' ? parseTagsInput(value) : (selectedItem.buildProfile?.tags ?? []),
|
|
||||||
synergy: key === 'synergy' ? parseTagsInput(value) : (selectedItem.buildProfile?.synergy ?? []),
|
|
||||||
craftTags: key === 'craftTags' ? parseTagsInput(value) : (selectedItem.buildProfile?.craftTags ?? []),
|
|
||||||
forgeRank: selectedItem.buildProfile?.forgeRank ?? 0,
|
|
||||||
};
|
|
||||||
updateSelectedOverride('buildProfile', nextProfile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSelectedOverride = () => {
|
|
||||||
if (!selectedItemId) return;
|
|
||||||
|
|
||||||
setOverrideMap(current => {
|
|
||||||
const { [selectedItemId]: _removed, ...rest } = current;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const validationErrors = validateItemOverrides(
|
|
||||||
overrideMap,
|
|
||||||
baseItems.map(item => item.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validationErrors.length > 0) {
|
|
||||||
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
|
|
||||||
setTimeout(() => setSaveMessage(null), 5000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setSaveMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveEditorJsonResource(
|
|
||||||
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
|
|
||||||
overrideMap as Record<string, unknown>,
|
|
||||||
'保存失败',
|
|
||||||
);
|
|
||||||
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
|
|
||||||
setTimeout(() => setSaveMessage(null), 5000);
|
|
||||||
} catch (error) {
|
|
||||||
setSaveMessage(error instanceof Error ? error.message : '保存失败');
|
|
||||||
setTimeout(() => setSaveMessage(null), 5000);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryOptions = [
|
|
||||||
{ label: '全部分类', value: 'ALL' },
|
|
||||||
...ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category })),
|
|
||||||
];
|
|
||||||
|
|
||||||
const rarityOptions = [
|
|
||||||
{ label: '全部稀有度', value: 'ALL' },
|
|
||||||
...RARITY_OPTIONS.map(rarity => ({ label: rarity, value: rarity })),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
|
|
||||||
正在加载物品目录...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadError) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-6 text-sm text-rose-100">
|
|
||||||
{loadError}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[360px_1fr_420px]">
|
|
||||||
<Section title="物品列表" description="基于 public/Icons 下的全部 png 素材自动构建物品目录,可按名称、路径、分类和稀有度筛选。">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>搜索</Label>
|
|
||||||
<TextInput
|
|
||||||
value={searchText}
|
|
||||||
onChange={setSearchText}
|
|
||||||
placeholder="按名称、路径、标签搜索"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<Label>分类筛选</Label>
|
|
||||||
<Select value={categoryFilter} onChange={setCategoryFilter} options={categoryOptions} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>稀有度筛选</Label>
|
|
||||||
<Select value={rarityFilter} onChange={value => setRarityFilter(value as 'ALL' | ItemRarity)} options={rarityOptions} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
|
|
||||||
总物品 {effectiveItems.length},当前匹配 {filteredItems.length},列表预渲染前 {Math.min(visibleItems.length, LIST_PREVIEW_LIMIT)} 条。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 max-h-[70vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
|
|
||||||
{visibleItems.map(item => {
|
|
||||||
const selected = item.id === selectedItemId;
|
|
||||||
const overridden = Boolean(overrideMap[item.id]);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedItemId(item.id)}
|
|
||||||
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition ${
|
|
||||||
selected
|
|
||||||
? 'border-emerald-400/40 bg-emerald-500/10'
|
|
||||||
: 'border-white/8 bg-black/20 hover:border-white/15'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
|
|
||||||
<PixelIcon src={item.iconSrc} className="h-9 w-9" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-semibold text-white">{item.name}</div>
|
|
||||||
<div className="mt-1 truncate text-[10px] text-zinc-500">{item.sourcePath}</div>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
|
|
||||||
{item.category}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
|
|
||||||
{item.rarity}
|
|
||||||
</span>
|
|
||||||
{overridden && (
|
|
||||||
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
|
||||||
已覆盖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="物品预览" description="这里会实时预览当前素材构建出的物品效果,包括图标、系统推断结果以及一张背包卡片。">
|
|
||||||
{selectedItem ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
|
||||||
<div className="flex min-h-[240px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),transparent_45%),linear-gradient(180deg,#171a22,#0d1016)] p-6">
|
|
||||||
<PixelIcon src={selectedItem.iconSrc} className="h-40 w-40" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">{selectedItem.category}</div>
|
|
||||||
<div className="mt-1 text-xl font-semibold text-white">{selectedItem.name}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">{selectedItem.sourcePath}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-[12rem]">
|
|
||||||
<Label>世界语境</Label>
|
|
||||||
<Select
|
|
||||||
value={previewWorld}
|
|
||||||
onChange={value => setPreviewWorld(value as WorldType)}
|
|
||||||
options={[
|
|
||||||
{ label: '武侠', value: WorldType.WUXIA },
|
|
||||||
{ label: '仙侠', value: WorldType.XIANXIA },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
|
|
||||||
稀有度: {RARITY_LABELS[selectedItem.rarity]}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
|
|
||||||
价值: {getInventoryItemValue(previewInventoryItem!)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
|
|
||||||
可使用: {isInventoryItemUsable(previewInventoryItem!) ? '是' : '否'}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
|
|
||||||
可装备: {previewEquipmentSlot ? getEquipmentSlotLabel(previewEquipmentSlot) : '否'}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
|
|
||||||
世界: {selectedItem.worldAffinity === 'neutral' ? '中立' : selectedItem.worldAffinity === 'wuxia' ? '武侠' : selectedItem.worldAffinity === 'xianxia' ? '仙侠' : '中立'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm leading-relaxed text-zinc-300">{selectedItem.description}</p>
|
|
||||||
{worldProfile && (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">
|
|
||||||
{previewWorld === WorldType.WUXIA ? '武侠命名' : '仙侠命名'}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm font-semibold text-white">{worldProfile.name}</div>
|
|
||||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{worldProfile.description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedItem.tags.length > 0 ? selectedItem.tags.map(tag => (
|
|
||||||
<span
|
|
||||||
key={`${selectedItem.id}-${tag}`}
|
|
||||||
className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
)) : (
|
|
||||||
<span className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300">
|
|
||||||
无标签
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">属性设计</div>
|
|
||||||
<div className="space-y-1 text-sm text-zinc-300">
|
|
||||||
<div>生命值: {selectedItem.statProfile?.maxHpBonus ?? 0}</div>
|
|
||||||
<div>内力: {selectedItem.statProfile?.maxManaBonus ?? 0}</div>
|
|
||||||
<div>伤害: {selectedItem.statProfile?.outgoingDamageBonus ?? 0}</div>
|
|
||||||
<div>防御: {selectedItem.statProfile?.incomingDamageMultiplier ?? 1}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">使用效果</div>
|
|
||||||
{selectedItem.useProfile ? (
|
|
||||||
<div className="space-y-1 text-sm text-zinc-300">
|
|
||||||
<div>生命值恢复: {selectedItem.useProfile.hpRestore ?? 0}</div>
|
|
||||||
<div>内力恢复: {selectedItem.useProfile.manaRestore ?? 0}</div>
|
|
||||||
<div>冷却减少: {selectedItem.useProfile.cooldownReduction ?? 0}</div>
|
|
||||||
<div>构筑增益: {(selectedItem.useProfile.buildBuffs ?? []).map(buff => buff.name).join(' / ') || '无'}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-zinc-500">无即时使用效果</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">构筑 / 套装</div>
|
|
||||||
{selectedItem.buildProfile ? (
|
|
||||||
<div className="space-y-1 text-sm text-zinc-300">
|
|
||||||
<div>角色: {selectedItem.buildProfile.role}</div>
|
|
||||||
<div>套装: {selectedItem.buildProfile.setName ?? '无'}</div>
|
|
||||||
<div>部件: {selectedItem.buildProfile.pieceName ?? '独立'}</div>
|
|
||||||
<div>{(selectedItem.buildProfile.synergy ?? []).join(' / ') || '无'}</div>
|
|
||||||
<div>工艺标签: {(selectedItem.buildProfile.craftTags ?? []).join(' / ') || '无'}</div>
|
|
||||||
<div>锻造等级: {selectedItem.buildProfile.forgeRank ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-zinc-500">无构筑信息</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
|
||||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">背包卡片预览</div>
|
|
||||||
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-black/25 p-4">
|
|
||||||
<div className="relative flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
|
||||||
<PixelIcon src={selectedItem.iconSrc} className="h-14 w-14" />
|
|
||||||
<div className="absolute bottom-2 right-2 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<div className="text-base font-semibold text-white">{selectedItem.name}</div>
|
|
||||||
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
|
|
||||||
{previewUseEffect && (
|
|
||||||
<div className="text-sm text-zinc-300">
|
|
||||||
效果预估:生命 +{previewUseEffect.hpRestore} / 灵力 +{previewUseEffect.manaRestore} / 冷却 -{previewUseEffect.cooldownReduction}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!previewUseEffect && (
|
|
||||||
<div className="text-sm text-zinc-400">
|
|
||||||
当前推断为非即时使用型物品。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
|
||||||
没有可预览的物品。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="物品字段" description="编辑当前物品的覆盖字段。未修改的字段不会写入 override,重置后会恢复自动生成值。">
|
|
||||||
{selectedBaseItem && selectedItem ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>物品 ID</Label>
|
|
||||||
<TextInput value={selectedItem.id} onChange={() => undefined} disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>素材路径</Label>
|
|
||||||
<TextInput value={selectedItem.sourcePath} onChange={() => undefined} disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>名称</Label>
|
|
||||||
<TextInput
|
|
||||||
value={selectedItem.name}
|
|
||||||
onChange={value => updateSelectedOverride('name', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<Label>分类</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedItem.category}
|
|
||||||
onChange={value => updateSelectedOverride('category', value)}
|
|
||||||
options={ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>稀有度</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedItem.rarity}
|
|
||||||
onChange={value => updateSelectedOverride('rarity', value as ItemRarity)}
|
|
||||||
options={RARITY_OPTIONS.map(rarity => ({ label: RARITY_LABELS[rarity], value: rarity }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>标签</Label>
|
|
||||||
<TextArea
|
|
||||||
value={tagsInputValue(selectedItem.tags)}
|
|
||||||
onChange={value => updateSelectedOverride('tags', parseTagsInput(value))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">支持逗号或换行分隔。</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>描述</Label>
|
|
||||||
<TextArea
|
|
||||||
value={selectedItem.description}
|
|
||||||
onChange={value => updateSelectedOverride('description', value)}
|
|
||||||
rows={5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<Label>生命上限加成</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
|
|
||||||
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>灵力上限加成</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
|
|
||||||
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>输出倍率加成</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.statProfile?.outgoingDamageBonus ?? 0)}
|
|
||||||
onChange={value => updateSelectedStatProfileField('outgoingDamageBonus', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>承伤倍率</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.statProfile?.incomingDamageMultiplier ?? 1)}
|
|
||||||
onChange={value => updateSelectedStatProfileField('incomingDamageMultiplier', Number(value) || 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<Label>使用时恢复生命</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
|
|
||||||
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>使用时恢复灵力</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
|
|
||||||
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>使用减少冷却</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.useProfile?.cooldownReduction ?? 0)}
|
|
||||||
onChange={value => updateSelectedUseProfileField('cooldownReduction', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>使用时附加构筑增益(每行:名称|标签1,标签2|回合)</Label>
|
|
||||||
<TextArea
|
|
||||||
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
|
|
||||||
onChange={updateSelectedUseProfileBuffs}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<Label>构筑角色</Label>
|
|
||||||
<TextInput
|
|
||||||
value={selectedItem.buildProfile?.role ?? ''}
|
|
||||||
onChange={value => updateSelectedBuildProfileField('role', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>套装 ID</Label>
|
|
||||||
<TextInput
|
|
||||||
value={selectedItem.buildProfile?.setId ?? ''}
|
|
||||||
onChange={value => updateSelectedBuildProfileField('setId', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>套装名称</Label>
|
|
||||||
<TextInput
|
|
||||||
value={selectedItem.buildProfile?.setName ?? ''}
|
|
||||||
onChange={value => updateSelectedBuildProfileField('setName', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>部件名称</Label>
|
|
||||||
<TextInput
|
|
||||||
value={selectedItem.buildProfile?.pieceName ?? ''}
|
|
||||||
onChange={value => updateSelectedBuildProfileField('pieceName', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>锻造等级</Label>
|
|
||||||
<TextInput
|
|
||||||
value={String(selectedItem.buildProfile?.forgeRank ?? 0)}
|
|
||||||
onChange={value => updateSelectedBuildProfileField('forgeRank', Number(value) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>构筑标签</Label>
|
|
||||||
<TextArea
|
|
||||||
value={tagsInputValue(selectedItem.buildProfile?.tags ?? [])}
|
|
||||||
onChange={value => updateSelectedBuildProfileTags('tags', value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>协同标签</Label>
|
|
||||||
<TextArea
|
|
||||||
value={tagsInputValue(selectedItem.buildProfile?.synergy ?? [])}
|
|
||||||
onChange={value => updateSelectedBuildProfileTags('synergy', value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>工艺标签</Label>
|
|
||||||
<TextArea
|
|
||||||
value={tagsInputValue(selectedItem.buildProfile?.craftTags ?? [])}
|
|
||||||
onChange={value => updateSelectedBuildProfileTags('craftTags', value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
|
|
||||||
当前状态:{selectedOverride ? '该物品有覆盖字段,保存后会写入 itemOverrides.json。' : '当前全部字段都在使用自动生成值。'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{isSaving ? '保存中...' : '保存物品覆盖'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetSelectedOverride}
|
|
||||||
disabled={!selectedOverride}
|
|
||||||
className={`rounded-lg border px-4 py-2 text-sm transition ${
|
|
||||||
selectedOverride
|
|
||||||
? 'border-white/15 bg-black/20 text-white hover:border-white/30'
|
|
||||||
: 'border-white/8 bg-black/20 text-zinc-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
重置当前物品覆盖
|
|
||||||
</button>
|
|
||||||
{saveMessage && <div className="text-xs text-zinc-400">{saveMessage}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
|
||||||
请选择一个物品开始编辑。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
|||||||
import type { ComponentType, LazyExoticComponent } from 'react';
|
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { LazyEditorFallback } from './preset-editor/LazyEditorFallback';
|
|
||||||
import {
|
|
||||||
EDITOR_TAB_OPTIONS,
|
|
||||||
type PresetEditorTab,
|
|
||||||
} from './preset-editor/shared';
|
|
||||||
|
|
||||||
const CharacterPresetTab = lazy(
|
|
||||||
() => import('./preset-editor/CharacterPresetTab'),
|
|
||||||
);
|
|
||||||
const CharacterAssetTab = lazy(
|
|
||||||
() => import('./preset-editor/CharacterAssetTab'),
|
|
||||||
);
|
|
||||||
const SceneNpcPresetTab = lazy(
|
|
||||||
() => import('./preset-editor/SceneNpcPresetTab'),
|
|
||||||
);
|
|
||||||
const ScenePresetTab = lazy(() => import('./preset-editor/ScenePresetTab'));
|
|
||||||
const MonsterPresetTab = lazy(() => import('./preset-editor/MonsterPresetTab'));
|
|
||||||
const ItemCatalogEditor = lazy(async () => {
|
|
||||||
const module = await import('./ItemCatalogEditor');
|
|
||||||
return { default: module.ItemCatalogEditor };
|
|
||||||
});
|
|
||||||
const StateFunctionEditor = lazy(async () => {
|
|
||||||
const module = await import('./StateFunctionEditor');
|
|
||||||
return { default: module.StateFunctionEditor };
|
|
||||||
});
|
|
||||||
|
|
||||||
const TAB_COMPONENTS: Record<
|
|
||||||
PresetEditorTab,
|
|
||||||
LazyExoticComponent<ComponentType>
|
|
||||||
> = {
|
|
||||||
assets: CharacterAssetTab,
|
|
||||||
characters: CharacterPresetTab,
|
|
||||||
npcs: SceneNpcPresetTab,
|
|
||||||
scenes: ScenePresetTab,
|
|
||||||
monsters: MonsterPresetTab,
|
|
||||||
items: ItemCatalogEditor,
|
|
||||||
functions: StateFunctionEditor,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { PresetEditorTab } from './preset-editor/shared';
|
|
||||||
|
|
||||||
export function PresetEditor({
|
|
||||||
initialTab = 'characters',
|
|
||||||
}: {
|
|
||||||
initialTab?: PresetEditorTab;
|
|
||||||
}) {
|
|
||||||
const [activeTab, setActiveTab] = useState<PresetEditorTab>(initialTab);
|
|
||||||
const tabLabels = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
EDITOR_TAB_OPTIONS.map((option) => [option.id, option.label]),
|
|
||||||
) as Record<PresetEditorTab, string>,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const ActiveTabPanel = TAB_COMPONENTS[activeTab];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveTab(initialTab);
|
|
||||||
}, [initialTab]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0b0d11] text-zinc-100">
|
|
||||||
<div className="mx-auto max-w-[1600px] px-6 py-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="text-xs uppercase tracking-[0.3em] text-emerald-400/70">
|
|
||||||
预设工坊
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-white">
|
|
||||||
统一预设预览与编辑器
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 max-w-4xl text-sm leading-relaxed text-zinc-400">
|
|
||||||
从一个编辑器界面管理角色、场景角色、场景、怪物、物品和行为预设。每个标签页现在加载自己的容器,使入口组件保持小巧和专注。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 flex flex-wrap gap-3">
|
|
||||||
{EDITOR_TAB_OPTIONS.map((option) => {
|
|
||||||
const Icon = option.icon;
|
|
||||||
const isActive = option.id === activeTab;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(option.id)}
|
|
||||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition ${
|
|
||||||
isActive
|
|
||||||
? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-100'
|
|
||||||
: 'border-white/10 bg-black/20 text-zinc-400 hover:border-white/20 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense
|
|
||||||
fallback={<LazyEditorFallback label={tabLabels[activeTab]} />}
|
|
||||||
>
|
|
||||||
<ActiveTabPanel />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
|
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
|
||||||
import { PRESET_CHARACTERS } from '../data/characterPresets';
|
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||||
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
||||||
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
|
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
|
||||||
import { getScenePreset } from '../data/scenePresets';
|
import { getScenePreset } from '../data/scenePresets';
|
||||||
@@ -75,7 +75,7 @@ export function SkillEffectPreview({
|
|||||||
}: SkillEffectPreviewProps) {
|
}: SkillEffectPreviewProps) {
|
||||||
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
|
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
|
||||||
const fallbackTargetCharacter = useMemo(
|
const fallbackTargetCharacter = useMemo(
|
||||||
() => targetCharacter ?? PRESET_CHARACTERS.find(candidate => candidate.id !== character.id) ?? PRESET_CHARACTERS[0] ?? character,
|
() => targetCharacter ?? ROLE_TEMPLATE_CHARACTERS.find(candidate => candidate.id !== character.id) ?? ROLE_TEMPLATE_CHARACTERS[0] ?? character,
|
||||||
[character, targetCharacter],
|
[character, targetCharacter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type AuthAuditLogEntry,
|
type AuthAuditLogEntry,
|
||||||
type AuthCaptchaChallenge,
|
type AuthCaptchaChallenge,
|
||||||
|
type AuthLoginMethod,
|
||||||
type AuthRiskBlockSummary,
|
type AuthRiskBlockSummary,
|
||||||
type AuthSessionSummary,
|
type AuthSessionSummary,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
consumeAuthCallbackResult,
|
consumeAuthCallbackResult,
|
||||||
ensureAutoAuthUser,
|
ensureAutoAuthUser,
|
||||||
getAuthAuditLogs,
|
getAuthAuditLogs,
|
||||||
|
getAuthLoginOptions,
|
||||||
getAuthRiskBlocks,
|
getAuthRiskBlocks,
|
||||||
getAuthSessions,
|
getAuthSessions,
|
||||||
getCaptchaChallengeFromError,
|
getCaptchaChallengeFromError,
|
||||||
@@ -50,6 +52,9 @@ const allowDevGuestAutoAuth =
|
|||||||
export function AuthGate({ children }: AuthGateProps) {
|
export function AuthGate({ children }: AuthGateProps) {
|
||||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [availableLoginMethods, setAvailableLoginMethods] = useState<
|
||||||
|
AuthLoginMethod[]
|
||||||
|
>([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sendingCode, setSendingCode] = useState(false);
|
const [sendingCode, setSendingCode] = useState(false);
|
||||||
const [loggingIn, setLoggingIn] = useState(false);
|
const [loggingIn, setLoggingIn] = useState(false);
|
||||||
@@ -104,6 +109,16 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
|
const loadLoginOptions = async () => {
|
||||||
|
const options = await getAuthLoginOptions();
|
||||||
|
if (!isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableLoginMethods(options.availableLoginMethods);
|
||||||
|
return options;
|
||||||
|
};
|
||||||
|
|
||||||
const callbackResult = consumeAuthCallbackResult();
|
const callbackResult = consumeAuthCallbackResult();
|
||||||
if (callbackResult?.error && isActive) {
|
if (callbackResult?.error && isActive) {
|
||||||
setError(callbackResult.error);
|
setError(callbackResult.error);
|
||||||
@@ -121,6 +136,20 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
try {
|
||||||
|
await loadLoginOptions();
|
||||||
|
} catch (optionsError) {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableLoginMethods([]);
|
||||||
|
setError(
|
||||||
|
optionsError instanceof Error
|
||||||
|
? optionsError.message
|
||||||
|
: '读取登录方式失败,请稍后再试。',
|
||||||
|
);
|
||||||
|
}
|
||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,11 +162,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
|
|
||||||
if (!nextSession.user) {
|
if (!nextSession.user) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(nextSession.user);
|
setUser(nextSession.user);
|
||||||
|
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||||
setStatus(
|
setStatus(
|
||||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||||
? 'pending_bind_phone'
|
? 'pending_bind_phone'
|
||||||
@@ -155,6 +186,20 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
try {
|
||||||
|
await loadLoginOptions();
|
||||||
|
} catch (optionsError) {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableLoginMethods([]);
|
||||||
|
setError(
|
||||||
|
optionsError instanceof Error
|
||||||
|
? optionsError.message
|
||||||
|
: '读取登录方式失败,请稍后再试。',
|
||||||
|
);
|
||||||
|
}
|
||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -278,6 +323,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
return (
|
return (
|
||||||
<LoginScreen
|
<LoginScreen
|
||||||
|
availableLoginMethods={availableLoginMethods}
|
||||||
sendingCode={sendingCode}
|
sendingCode={sendingCode}
|
||||||
loggingIn={loggingIn}
|
loggingIn={loggingIn}
|
||||||
wechatLoading={wechatLoading}
|
wechatLoading={wechatLoading}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
import type {
|
||||||
|
AuthCaptchaChallenge,
|
||||||
|
AuthLoginMethod,
|
||||||
|
} from '../../services/authService';
|
||||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||||
|
|
||||||
type LoginScreenProps = {
|
type LoginScreenProps = {
|
||||||
|
availableLoginMethods: AuthLoginMethod[];
|
||||||
sendingCode: boolean;
|
sendingCode: boolean;
|
||||||
loggingIn: boolean;
|
loggingIn: boolean;
|
||||||
wechatLoading: boolean;
|
wechatLoading: boolean;
|
||||||
@@ -24,6 +28,7 @@ type LoginScreenProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LoginScreen({
|
export function LoginScreen({
|
||||||
|
availableLoginMethods,
|
||||||
sendingCode,
|
sendingCode,
|
||||||
loggingIn,
|
loggingIn,
|
||||||
wechatLoading,
|
wechatLoading,
|
||||||
@@ -38,6 +43,8 @@ export function LoginScreen({
|
|||||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||||
const [hint, setHint] = useState('');
|
const [hint, setHint] = useState('');
|
||||||
|
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||||
|
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cooldownSeconds <= 0) {
|
if (cooldownSeconds <= 0) {
|
||||||
@@ -69,7 +76,7 @@ export function LoginScreen({
|
|||||||
账号登录
|
账号登录
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||||
先登录账号,再同步你的冒险进度。本轮先开放手机号验证码登录,微信登录将在下一阶段接入。
|
先登录账号,再同步你的冒险进度。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
||||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||||
@@ -85,77 +92,90 @@ export function LoginScreen({
|
|||||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!phoneLoginEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void onSubmit(phone, code);
|
void onSubmit(phone, code);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="grid gap-2 text-sm text-zinc-300">
|
{phoneLoginEnabled ? (
|
||||||
<span>手机号</span>
|
<>
|
||||||
<input
|
<label className="grid gap-2 text-sm text-zinc-300">
|
||||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
<span>手机号</span>
|
||||||
autoComplete="tel"
|
<input
|
||||||
inputMode="numeric"
|
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||||
value={phone}
|
autoComplete="tel"
|
||||||
onChange={(event) => setPhone(event.target.value)}
|
inputMode="numeric"
|
||||||
placeholder="13800000000"
|
value={phone}
|
||||||
/>
|
onChange={(event) => setPhone(event.target.value)}
|
||||||
</label>
|
placeholder="13800000000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm text-zinc-300">
|
<label className="grid gap-2 text-sm text-zinc-300">
|
||||||
<span>验证码</span>
|
<span>验证码</span>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(event) => setCode(event.target.value)}
|
onChange={(event) => setCode(event.target.value)}
|
||||||
placeholder="输入验证码"
|
placeholder="输入验证码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||||
|
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await onSendCode(phone, {
|
||||||
|
challengeId: captchaChallenge?.challengeId,
|
||||||
|
answer: captchaAnswer,
|
||||||
|
});
|
||||||
|
setCooldownSeconds(result.cooldownSeconds);
|
||||||
|
setHint(
|
||||||
|
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||||
|
);
|
||||||
|
setCaptchaAnswer('');
|
||||||
|
} catch {
|
||||||
|
setHint('');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sendingCode
|
||||||
|
? '发送中...'
|
||||||
|
: cooldownSeconds > 0
|
||||||
|
? `${cooldownSeconds}s`
|
||||||
|
: '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{hint ? (
|
||||||
|
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CaptchaChallengeField
|
||||||
|
challenge={captchaChallenge}
|
||||||
|
answer={captchaAnswer}
|
||||||
|
onAnswerChange={setCaptchaAnswer}
|
||||||
/>
|
/>
|
||||||
<button
|
</>
|
||||||
type="button"
|
|
||||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
|
||||||
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
|
||||||
onClick={() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await onSendCode(phone, {
|
|
||||||
challengeId: captchaChallenge?.challengeId,
|
|
||||||
answer: captchaAnswer,
|
|
||||||
});
|
|
||||||
setCooldownSeconds(result.cooldownSeconds);
|
|
||||||
setHint(
|
|
||||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
|
||||||
);
|
|
||||||
setCaptchaAnswer('');
|
|
||||||
} catch {
|
|
||||||
setHint('');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sendingCode
|
|
||||||
? '发送中...'
|
|
||||||
: cooldownSeconds > 0
|
|
||||||
? `${cooldownSeconds}s`
|
|
||||||
: '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{hint ? (
|
|
||||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
|
||||||
{hint}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<CaptchaChallengeField
|
{phoneLoginEnabled || wechatLoginEnabled ? (
|
||||||
challenge={captchaChallenge}
|
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
|
||||||
answer={captchaAnswer}
|
{phoneLoginEnabled && wechatLoginEnabled
|
||||||
onAnswerChange={setCaptchaAnswer}
|
? '手机号可直接登录,也可以先用微信。'
|
||||||
/>
|
: phoneLoginEnabled
|
||||||
|
? '当前开放手机号登录。'
|
||||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
|
: '当前开放微信登录。'}
|
||||||
手机验证码适合直接登录;如果你更习惯微信,也可以先走微信登录再绑定手机号。
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||||
@@ -163,24 +183,34 @@ export function LoginScreen({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
{phoneLoginEnabled ? (
|
||||||
type="submit"
|
<button
|
||||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
type="submit"
|
||||||
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||||
>
|
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
{loggingIn ? '正在进入...' : '登录并进入游戏'}
|
>
|
||||||
</button>
|
{loggingIn ? '正在进入...' : '登录并进入游戏'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
{wechatLoginEnabled ? (
|
||||||
type="button"
|
<button
|
||||||
disabled={wechatLoading || sendingCode || loggingIn}
|
type="button"
|
||||||
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
|
disabled={wechatLoading || sendingCode || loggingIn}
|
||||||
onClick={() => {
|
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
void onStartWechatLogin();
|
onClick={() => {
|
||||||
}}
|
void onStartWechatLogin();
|
||||||
>
|
}}
|
||||||
{wechatLoading ? '正在跳转微信...' : '微信登录'}
|
>
|
||||||
</button>
|
{wechatLoading ? '正在跳转微信...' : '微信登录'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||||
|
当前登录入口暂不可用,请稍后再试。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
|
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
|
||||||
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
||||||
import {AnimationState, WorldType} from '../../types';
|
import {AnimationState, WorldType} from '../../types';
|
||||||
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
|
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
|
||||||
@@ -47,7 +47,9 @@ export function GameCanvasRuntime({
|
|||||||
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
|
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
|
||||||
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
|
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
|
||||||
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
|
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
|
||||||
const resolvedWorldType = worldType ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
|
const resolvedWorldType = worldType
|
||||||
|
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
|
||||||
|
: null;
|
||||||
const backgroundSrc = currentScenePreset?.imageSrc
|
const backgroundSrc = currentScenePreset?.imageSrc
|
||||||
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
||||||
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
|
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildCustomWorldPlayableCharacters,
|
buildCustomWorldPlayableCharacters,
|
||||||
PRESET_CHARACTERS,
|
ROLE_TEMPLATE_CHARACTERS,
|
||||||
} from '../../data/characterPresets';
|
} from '../../data/characterPresets';
|
||||||
import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types';
|
import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types';
|
||||||
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
|
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
|
||||||
@@ -160,7 +160,7 @@ export function CharacterSelectionFlow({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: CharacterSelectionFlowProps) {
|
}: CharacterSelectionFlowProps) {
|
||||||
const selectionCharacters = useMemo(
|
const selectionCharacters = useMemo(
|
||||||
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : PRESET_CHARACTERS),
|
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
|
||||||
[customWorldProfile],
|
[customWorldProfile],
|
||||||
);
|
);
|
||||||
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');
|
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import {
|
import {
|
||||||
|
CUSTOM_WORLD_THEME_ICONS,
|
||||||
CHROME_ICONS,
|
CHROME_ICONS,
|
||||||
getNineSliceStyle,
|
getNineSliceStyle,
|
||||||
UI_CHROME,
|
UI_CHROME,
|
||||||
WORLD_SELECT_ICONS,
|
|
||||||
} from '../../uiAssets';
|
} from '../../uiAssets';
|
||||||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||||||
import { CustomWorldResultView } from '../CustomWorldResultView';
|
import { CustomWorldResultView } from '../CustomWorldResultView';
|
||||||
@@ -187,9 +187,9 @@ export function PreGameSelectionFlow({
|
|||||||
featurePortrait: leadCharacter?.portrait ?? '',
|
featurePortrait: leadCharacter?.portrait ?? '',
|
||||||
featureIcon:
|
featureIcon:
|
||||||
themeMode === 'martial'
|
themeMode === 'martial'
|
||||||
? WORLD_SELECT_ICONS.wuxia
|
? CUSTOM_WORLD_THEME_ICONS.martial
|
||||||
: themeMode === 'arcane'
|
: themeMode === 'arcane'
|
||||||
? WORLD_SELECT_ICONS.xianxia
|
? CUSTOM_WORLD_THEME_ICONS.arcane
|
||||||
: CHROME_ICONS.refreshOptions,
|
: CHROME_ICONS.refreshOptions,
|
||||||
accentLabel: '自定义世界',
|
accentLabel: '自定义世界',
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
export { CharacterAssetPanel as default } from './CharacterAssetPanel';
|
|
||||||
@@ -1,808 +0,0 @@
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import characterOverridesJson from '../../data/characterOverrides.json';
|
|
||||||
import {
|
|
||||||
type CharacterPresetOverride,
|
|
||||||
getCharacterEquipment,
|
|
||||||
getCharacterNpcSceneIds,
|
|
||||||
getInventoryItems,
|
|
||||||
PRESET_CHARACTERS,
|
|
||||||
} from '../../data/characterPresets';
|
|
||||||
import { validateCharacterOverrides } from '../../data/editorValidation';
|
|
||||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
|
||||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
|
||||||
import { cloneValue } from '../../editor/shared/cloneValue';
|
|
||||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
|
||||||
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
|
|
||||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
|
||||||
import {
|
|
||||||
NumberField,
|
|
||||||
SelectField,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
} from '../../editor/shared/FormFields';
|
|
||||||
import { SectionCard } from '../../editor/shared/SectionCard';
|
|
||||||
import { useJsonSave } from '../../editor/shared/useJsonSave';
|
|
||||||
import {
|
|
||||||
AnimationState,
|
|
||||||
type Character,
|
|
||||||
type CharacterSkillDefinition,
|
|
||||||
WorldType,
|
|
||||||
} from '../../types';
|
|
||||||
import { CharacterAnimator } from '../CharacterAnimator';
|
|
||||||
import { SkillEffectPreview } from '../SkillEffectPreview';
|
|
||||||
import {
|
|
||||||
ANIMATION_OPTIONS,
|
|
||||||
applyCharacterOverride,
|
|
||||||
buildBuffsInputValue,
|
|
||||||
CHARACTER_SKILL_STYLE_OPTIONS,
|
|
||||||
getAnimationStateLabel,
|
|
||||||
getCharacterSkillStyleLabel,
|
|
||||||
isRangedSkill,
|
|
||||||
listInputValue,
|
|
||||||
normalizeOptionalSceneId,
|
|
||||||
parseBuildBuffsInput,
|
|
||||||
parseListInput,
|
|
||||||
WORLD_LABELS,
|
|
||||||
WORLD_OPTIONS,
|
|
||||||
} from './shared';
|
|
||||||
export function CharacterPresetPanel() {
|
|
||||||
const sceneOptionsByWorld = useMemo(
|
|
||||||
() => ({
|
|
||||||
[WorldType.WUXIA]: getScenePresetsByWorld(WorldType.WUXIA),
|
|
||||||
[WorldType.XIANXIA]: getScenePresetsByWorld(WorldType.XIANXIA),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [overrideMap, setOverrideMap] = useState<
|
|
||||||
Record<string, CharacterPresetOverride>
|
|
||||||
>(characterOverridesJson as Record<string, CharacterPresetOverride>);
|
|
||||||
const [selectedCharacterId, setSelectedCharacterId] = useState(
|
|
||||||
PRESET_CHARACTERS[0]?.id ?? '',
|
|
||||||
);
|
|
||||||
const [previewAnimation, setPreviewAnimation] = useState<AnimationState>(
|
|
||||||
AnimationState.IDLE,
|
|
||||||
);
|
|
||||||
const [inventoryWorld, setInventoryWorld] = useState<WorldType>(
|
|
||||||
WorldType.WUXIA,
|
|
||||||
);
|
|
||||||
const [skillPreviewWorld, setSkillPreviewWorld] = useState<WorldType>(
|
|
||||||
WorldType.WUXIA,
|
|
||||||
);
|
|
||||||
const [selectedSkillPreviewId, setSelectedSkillPreviewId] = useState('');
|
|
||||||
const [selectedSkillPreviewMonsterId, setSelectedSkillPreviewMonsterId] =
|
|
||||||
useState(MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA][0]?.id ?? '');
|
|
||||||
const selectedCharacter =
|
|
||||||
PRESET_CHARACTERS.find(
|
|
||||||
(character) => character.id === selectedCharacterId,
|
|
||||||
) ?? null;
|
|
||||||
const effectiveCharacter = selectedCharacter
|
|
||||||
? applyCharacterOverride(
|
|
||||||
selectedCharacter,
|
|
||||||
overrideMap[selectedCharacter.id],
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const { isSaving, saveMessage, save } = useJsonSave({
|
|
||||||
resourceId: EDITOR_JSON_RESOURCE_IDS.characterOverrides,
|
|
||||||
payload: overrideMap as Record<string, unknown>,
|
|
||||||
validate: () =>
|
|
||||||
validateCharacterOverrides(
|
|
||||||
overrideMap,
|
|
||||||
PRESET_CHARACTERS,
|
|
||||||
sceneOptionsByWorld,
|
|
||||||
),
|
|
||||||
successMessage: '角色预设覆盖已保存到 src/data/characterOverrides.json。',
|
|
||||||
errorMessage: '保存角色预设覆盖失败。',
|
|
||||||
});
|
|
||||||
const animationEntries = Object.entries(
|
|
||||||
effectiveCharacter?.animationMap ?? {},
|
|
||||||
) as Array<
|
|
||||||
[AnimationState, NonNullable<Character['animationMap']>[AnimationState]]
|
|
||||||
>;
|
|
||||||
const previewAnimationOptions = animationEntries.map(([animation]) => ({
|
|
||||||
label: getAnimationStateLabel(animation),
|
|
||||||
value: animation,
|
|
||||||
}));
|
|
||||||
const rangedSkills = useMemo(
|
|
||||||
() => effectiveCharacter?.skills.filter(isRangedSkill) ?? [],
|
|
||||||
[effectiveCharacter],
|
|
||||||
);
|
|
||||||
const skillPreviewMonsterOptions = MONSTER_PRESETS_BY_WORLD[
|
|
||||||
skillPreviewWorld
|
|
||||||
].map((monster) => ({
|
|
||||||
label: monster.name,
|
|
||||||
value: monster.id,
|
|
||||||
}));
|
|
||||||
const selectedSkillPreview =
|
|
||||||
rangedSkills.find((skill) => skill.id === selectedSkillPreviewId) ??
|
|
||||||
rangedSkills[0] ??
|
|
||||||
null;
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
previewAnimationOptions.some(
|
|
||||||
(option) => option.value === previewAnimation,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewAnimation(
|
|
||||||
(previewAnimationOptions[0]?.value as AnimationState | undefined) ??
|
|
||||||
AnimationState.IDLE,
|
|
||||||
);
|
|
||||||
}, [previewAnimation, previewAnimationOptions]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (rangedSkills.some((skill) => skill.id === selectedSkillPreviewId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedSkillPreviewId(rangedSkills[0]?.id ?? '');
|
|
||||||
}, [rangedSkills, selectedSkillPreviewId]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
skillPreviewMonsterOptions.some(
|
|
||||||
(option) => option.value === selectedSkillPreviewMonsterId,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedSkillPreviewMonsterId(
|
|
||||||
skillPreviewMonsterOptions[0]?.value ?? '',
|
|
||||||
);
|
|
||||||
}, [selectedSkillPreviewMonsterId, skillPreviewMonsterOptions]);
|
|
||||||
if (!selectedCharacter || !effectiveCharacter) {
|
|
||||||
return <EditorEmptyState message="没有可用的角色预设。" />;
|
|
||||||
}
|
|
||||||
const setCharacterField = <K extends keyof CharacterPresetOverride>(
|
|
||||||
key: K,
|
|
||||||
value: CharacterPresetOverride[K],
|
|
||||||
) => {
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedCharacter.id]: {
|
|
||||||
...(prev[selectedCharacter.id] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setAttribute = (key: keyof Character['attributes'], value: number) => {
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedCharacter.id]: {
|
|
||||||
...(prev[selectedCharacter.id] ?? {}),
|
|
||||||
attributes: {
|
|
||||||
...effectiveCharacter.attributes,
|
|
||||||
...(prev[selectedCharacter.id]?.attributes ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setAnimationConfig = (
|
|
||||||
animation: AnimationState,
|
|
||||||
key: 'folder' | 'prefix' | 'frames' | 'startFrame',
|
|
||||||
value: string | number,
|
|
||||||
) => {
|
|
||||||
const baseConfig = effectiveCharacter.animationMap?.[animation] ?? {
|
|
||||||
folder: '',
|
|
||||||
prefix: '',
|
|
||||||
frames: 1,
|
|
||||||
};
|
|
||||||
const currentOverrideConfig =
|
|
||||||
overrideMap[selectedCharacter.id]?.animationMap?.[animation];
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedCharacter.id]: {
|
|
||||||
...(prev[selectedCharacter.id] ?? {}),
|
|
||||||
animationMap: {
|
|
||||||
...(prev[selectedCharacter.id]?.animationMap ?? {}),
|
|
||||||
[animation]: {
|
|
||||||
...baseConfig,
|
|
||||||
...currentOverrideConfig,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setSkills = (skills: CharacterSkillDefinition[]) => {
|
|
||||||
setCharacterField('skills', skills);
|
|
||||||
};
|
|
||||||
const updateSkill = <K extends keyof CharacterSkillDefinition>(
|
|
||||||
index: number,
|
|
||||||
key: K,
|
|
||||||
value: CharacterSkillDefinition[K],
|
|
||||||
) => {
|
|
||||||
const nextSkills = cloneValue(effectiveCharacter.skills);
|
|
||||||
const currentSkill = nextSkills[index];
|
|
||||||
if (!currentSkill) return;
|
|
||||||
nextSkills[index] = { ...currentSkill, [key]: value };
|
|
||||||
setSkills(nextSkills);
|
|
||||||
};
|
|
||||||
const addSkill = () => {
|
|
||||||
setSkills([
|
|
||||||
...cloneValue(effectiveCharacter.skills),
|
|
||||||
{
|
|
||||||
id: `${selectedCharacter.id}-skill-${effectiveCharacter.skills.length + 1}`,
|
|
||||||
name: '新技能',
|
|
||||||
animation: AnimationState.SKILL1,
|
|
||||||
damage: 10,
|
|
||||||
manaCost: 5,
|
|
||||||
cooldownTurns: 1,
|
|
||||||
range: 1.5,
|
|
||||||
style: 'steady',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
const removeSkill = (index: number) => {
|
|
||||||
setSkills(
|
|
||||||
cloneValue(effectiveCharacter.skills).filter(
|
|
||||||
(_, skillIndex) => skillIndex !== index,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const setSceneBinding = (
|
|
||||||
worldType: WorldType,
|
|
||||||
key: 'homeSceneId' | 'npcSceneIds',
|
|
||||||
value: string | string[],
|
|
||||||
) => {
|
|
||||||
const normalizedValue =
|
|
||||||
key === 'homeSceneId' && typeof value === 'string'
|
|
||||||
? normalizeOptionalSceneId(value)
|
|
||||||
: value;
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedCharacter.id]: {
|
|
||||||
...(prev[selectedCharacter.id] ?? {}),
|
|
||||||
sceneBindings: {
|
|
||||||
...(prev[selectedCharacter.id]?.sceneBindings ?? {}),
|
|
||||||
[worldType]: {
|
|
||||||
...(prev[selectedCharacter.id]?.sceneBindings?.[worldType] ?? {}),
|
|
||||||
[key]: normalizedValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 xl:grid-cols-3">
|
|
||||||
{' '}
|
|
||||||
<EditorSelectionCard
|
|
||||||
title="角色"
|
|
||||||
description="浏览角色列表并编辑预设数据。"
|
|
||||||
selectLabel="角色"
|
|
||||||
selectValue={selectedCharacter.id}
|
|
||||||
onSelectChange={setSelectedCharacterId}
|
|
||||||
selectOptions={PRESET_CHARACTERS.map((character) => {
|
|
||||||
const optionCharacter = applyCharacterOverride(
|
|
||||||
character,
|
|
||||||
overrideMap[character.id],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
label: `${optionCharacter.name} - ${optionCharacter.title}`,
|
|
||||||
value: character.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
saveLabel="保存角色覆盖"
|
|
||||||
onSave={save}
|
|
||||||
isSaving={isSaving}
|
|
||||||
saveMessage={saveMessage}
|
|
||||||
>
|
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
|
||||||
{' '}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{' '}
|
|
||||||
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
|
||||||
{' '}
|
|
||||||
<img
|
|
||||||
src={effectiveCharacter.portrait}
|
|
||||||
alt={effectiveCharacter.name}
|
|
||||||
className="h-full w-full scale-125 object-contain"
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{' '}
|
|
||||||
<div className="text-sm font-semibold text-white">
|
|
||||||
{effectiveCharacter.name}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-1 text-[11px] uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
{effectiveCharacter.title}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
|
|
||||||
{effectiveCharacter.description}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</EditorSelectionCard>{' '}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="角色详情"
|
|
||||||
description="编辑核心角色资料和预览配置。"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="mb-4 grid gap-3 md:grid-cols-2">
|
|
||||||
{' '}
|
|
||||||
<SelectField
|
|
||||||
label="动画"
|
|
||||||
value={previewAnimation}
|
|
||||||
onChange={(value) => setPreviewAnimation(value as AnimationState)}
|
|
||||||
options={previewAnimationOptions}
|
|
||||||
/>{' '}
|
|
||||||
<SelectField
|
|
||||||
label="世界"
|
|
||||||
value={inventoryWorld}
|
|
||||||
onChange={(value) => setInventoryWorld(value as WorldType)}
|
|
||||||
options={WORLD_OPTIONS.map((worldType) => ({
|
|
||||||
label: WORLD_LABELS[worldType],
|
|
||||||
value: worldType,
|
|
||||||
}))}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mb-5 flex min-h-[320px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.14),transparent_45%),linear-gradient(180deg,#161922,#0c0f15)] p-6">
|
|
||||||
{' '}
|
|
||||||
<div className="relative flex h-[260px] w-[220px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
|
|
||||||
{' '}
|
|
||||||
<div className="absolute inset-x-0 bottom-0 h-20 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.16),transparent_65%)]" />{' '}
|
|
||||||
<CharacterAnimator
|
|
||||||
state={previewAnimation}
|
|
||||||
character={effectiveCharacter}
|
|
||||||
className="h-[210px] w-[210px] scale-[1.15] origin-bottom"
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
初始装备
|
|
||||||
</div>{' '}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{' '}
|
|
||||||
{getCharacterEquipment(effectiveCharacter).map((item) => (
|
|
||||||
<div
|
|
||||||
key={`${item.slot}-${item.item}`}
|
|
||||||
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-sm text-zinc-200"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="text-[11px] text-zinc-500">
|
|
||||||
{item.slot}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-1">{item.item}</div>{' '}
|
|
||||||
<div className="mt-1 text-[11px] text-amber-200/80">
|
|
||||||
{item.rarity}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
))}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
初始背包
|
|
||||||
</div>{' '}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{' '}
|
|
||||||
{getInventoryItems(effectiveCharacter, inventoryWorld).map(
|
|
||||||
(item) => (
|
|
||||||
<div
|
|
||||||
key={`${item.category}-${item.name}`}
|
|
||||||
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-sm text-zinc-200"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="text-[11px] text-zinc-500">
|
|
||||||
{item.category}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-1">{item.name}</div>{' '}
|
|
||||||
<div className="mt-1 text-[11px] text-zinc-400">
|
|
||||||
x{item.quantity}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="技能预览"
|
|
||||||
description="预览当前角色的远程技能效果。"
|
|
||||||
>
|
|
||||||
{rangedSkills.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
<SelectField
|
|
||||||
label="技能"
|
|
||||||
value={selectedSkillPreview?.id ?? ''}
|
|
||||||
onChange={setSelectedSkillPreviewId}
|
|
||||||
options={rangedSkills.map((skill) => ({
|
|
||||||
label: skill.name,
|
|
||||||
value: skill.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<SelectField
|
|
||||||
label="世界"
|
|
||||||
value={skillPreviewWorld}
|
|
||||||
onChange={(value) => setSkillPreviewWorld(value as WorldType)}
|
|
||||||
options={WORLD_OPTIONS.map((worldType) => ({
|
|
||||||
label: WORLD_LABELS[worldType],
|
|
||||||
value: worldType,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<SelectField
|
|
||||||
label="预览敌人"
|
|
||||||
value={selectedSkillPreviewMonsterId}
|
|
||||||
onChange={setSelectedSkillPreviewMonsterId}
|
|
||||||
options={skillPreviewMonsterOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SkillEffectPreview
|
|
||||||
mode="player"
|
|
||||||
worldType={skillPreviewWorld}
|
|
||||||
character={effectiveCharacter}
|
|
||||||
skill={selectedSkillPreview}
|
|
||||||
targetMonsterId={selectedSkillPreviewMonsterId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
|
|
||||||
当前角色没有可预览的远程技能。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="技能配置"
|
|
||||||
description="编辑当前角色的技能列表。"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{' '}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{' '}
|
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
技能列表
|
|
||||||
</div>{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addSkill}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-emerald-400/20 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-100 transition hover:bg-emerald-500/20"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<Plus className="h-3.5 w-3.5" /> <span>添加技能</span>{' '}
|
|
||||||
</button>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
{effectiveCharacter.skills.map((skill, index) => (
|
|
||||||
<div
|
|
||||||
key={`${skill.id}-${index}`}
|
|
||||||
className="rounded-xl border border-white/10 bg-black/20 p-4"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
|
||||||
{' '}
|
|
||||||
<div className="text-sm font-semibold text-white">
|
|
||||||
{skill.name}
|
|
||||||
</div>{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeSkill(index)}
|
|
||||||
className="rounded-lg border border-rose-400/20 bg-rose-500/10 p-2 text-rose-100 transition hover:bg-rose-500/20"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<Trash2 className="h-4 w-4" />{' '}
|
|
||||||
</button>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{' '}
|
|
||||||
<TextField
|
|
||||||
label="技能 ID"
|
|
||||||
value={skill.id}
|
|
||||||
onChange={(value) => updateSkill(index, 'id', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="名称"
|
|
||||||
value={skill.name}
|
|
||||||
onChange={(value) => updateSkill(index, 'name', value)}
|
|
||||||
/>{' '}
|
|
||||||
<SelectField
|
|
||||||
label="动画"
|
|
||||||
value={skill.animation}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateSkill(index, 'animation', value as AnimationState)
|
|
||||||
}
|
|
||||||
options={ANIMATION_OPTIONS.map((animation) => ({
|
|
||||||
label: getAnimationStateLabel(animation),
|
|
||||||
value: animation,
|
|
||||||
}))}
|
|
||||||
/>{' '}
|
|
||||||
<SelectField
|
|
||||||
label="风格"
|
|
||||||
value={skill.style}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateSkill(
|
|
||||||
index,
|
|
||||||
'style',
|
|
||||||
value as CharacterSkillDefinition['style'],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
options={CHARACTER_SKILL_STYLE_OPTIONS.map((style) => ({
|
|
||||||
label: getCharacterSkillStyleLabel(style),
|
|
||||||
value: style,
|
|
||||||
}))}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="伤害"
|
|
||||||
value={skill.damage}
|
|
||||||
onChange={(value) => updateSkill(index, 'damage', value)}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="法力消耗"
|
|
||||||
value={skill.manaCost}
|
|
||||||
onChange={(value) => updateSkill(index, 'manaCost', value)}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="冷却回合"
|
|
||||||
value={skill.cooldownTurns}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateSkill(index, 'cooldownTurns', value)
|
|
||||||
}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="射程"
|
|
||||||
value={skill.range}
|
|
||||||
onChange={(value) => updateSkill(index, 'range', value)}
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="构筑增益"
|
|
||||||
value={buildBuffsInputValue(skill.buildBuffs)}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateSkill(
|
|
||||||
index,
|
|
||||||
'buildBuffs',
|
|
||||||
parseBuildBuffsInput(
|
|
||||||
value,
|
|
||||||
'skill',
|
|
||||||
skill.id,
|
|
||||||
) as CharacterSkillDefinition['buildBuffs'],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
/>{' '}
|
|
||||||
</div>
|
|
||||||
))}{' '}
|
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
动作资源
|
|
||||||
</div>{' '}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{' '}
|
|
||||||
{animationEntries.map(([animation, config]) => {
|
|
||||||
const resolvedConfig = {
|
|
||||||
folder: '',
|
|
||||||
prefix: '',
|
|
||||||
frames: 1,
|
|
||||||
startFrame: 1,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={animation}
|
|
||||||
className="rounded-xl border border-white/10 bg-black/20 p-4"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">
|
|
||||||
{getAnimationStateLabel(animation)}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{' '}
|
|
||||||
<TextField
|
|
||||||
label="素材目录"
|
|
||||||
value={resolvedConfig.folder}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAnimationConfig(animation, 'folder', value)
|
|
||||||
}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="文件前缀"
|
|
||||||
value={resolvedConfig.prefix}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAnimationConfig(animation, 'prefix', value)
|
|
||||||
}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="帧数"
|
|
||||||
value={resolvedConfig.frames}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAnimationConfig(animation, 'frames', value)
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="起始帧"
|
|
||||||
value={resolvedConfig.startFrame ?? 1}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAnimationConfig(animation, 'startFrame', value)
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{' '}
|
|
||||||
<SectionCard title="基础信息" description="编辑角色基础资料。">
|
|
||||||
{' '}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{' '}
|
|
||||||
<TextField
|
|
||||||
label="角色 ID"
|
|
||||||
value={effectiveCharacter.id}
|
|
||||||
onChange={() => undefined}
|
|
||||||
disabled
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="名称"
|
|
||||||
value={effectiveCharacter.name}
|
|
||||||
onChange={(value) => setCharacterField('name', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="称号"
|
|
||||||
value={effectiveCharacter.title}
|
|
||||||
onChange={(value) => setCharacterField('title', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="头像"
|
|
||||||
value={effectiveCharacter.avatar}
|
|
||||||
onChange={(value) => setCharacterField('avatar', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="立绘"
|
|
||||||
value={effectiveCharacter.portrait}
|
|
||||||
onChange={(value) => setCharacterField('portrait', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="资源目录"
|
|
||||||
value={effectiveCharacter.assetFolder}
|
|
||||||
onChange={(value) => setCharacterField('assetFolder', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="资源变体"
|
|
||||||
value={effectiveCharacter.assetVariant}
|
|
||||||
onChange={(value) => setCharacterField('assetVariant', value)}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="地面偏移 Y"
|
|
||||||
value={effectiveCharacter.groundOffsetY ?? 0}
|
|
||||||
onChange={(value) => setCharacterField('groundOffsetY', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="描述"
|
|
||||||
value={effectiveCharacter.description}
|
|
||||||
onChange={(value) => setCharacterField('description', value)}
|
|
||||||
rows={4}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="性格"
|
|
||||||
value={effectiveCharacter.personality}
|
|
||||||
onChange={(value) => setCharacterField('personality', value)}
|
|
||||||
rows={3}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="战斗标签"
|
|
||||||
value={listInputValue(effectiveCharacter.combatTags ?? [])}
|
|
||||||
onChange={(value) =>
|
|
||||||
setCharacterField('combatTags', parseListInput(value))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="属性"
|
|
||||||
description="调整角色的核心属性。"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{' '}
|
|
||||||
<NumberField
|
|
||||||
label="力量"
|
|
||||||
value={effectiveCharacter.attributes.strength}
|
|
||||||
onChange={(value) => setAttribute('strength', value)}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="敏捷"
|
|
||||||
value={effectiveCharacter.attributes.agility}
|
|
||||||
onChange={(value) => setAttribute('agility', value)}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="悟性"
|
|
||||||
value={effectiveCharacter.attributes.intelligence}
|
|
||||||
onChange={(value) => setAttribute('intelligence', value)}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="灵性"
|
|
||||||
value={effectiveCharacter.attributes.spirit}
|
|
||||||
onChange={(value) => setAttribute('spirit', value)}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard title="场景绑定" description="编辑角色在不同世界中的场景绑定。">
|
|
||||||
{' '}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{' '}
|
|
||||||
{WORLD_OPTIONS.map((worldType) => (
|
|
||||||
<div
|
|
||||||
key={worldType}
|
|
||||||
className="rounded-xl border border-white/10 bg-black/20 p-4"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">
|
|
||||||
{WORLD_LABELS[worldType]}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{' '}
|
|
||||||
<SelectField
|
|
||||||
label="主场景"
|
|
||||||
value={
|
|
||||||
overrideMap[selectedCharacter.id]?.sceneBindings?.[
|
|
||||||
worldType
|
|
||||||
]?.homeSceneId ?? ''
|
|
||||||
}
|
|
||||||
onChange={(value) =>
|
|
||||||
setSceneBinding(worldType, 'homeSceneId', value)
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ label: '未设置', value: '' },
|
|
||||||
...sceneOptionsByWorld[worldType].map((scene) => ({
|
|
||||||
label: scene.name,
|
|
||||||
value: scene.id,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="角色场景"
|
|
||||||
value={listInputValue(
|
|
||||||
overrideMap[selectedCharacter.id]?.sceneBindings?.[
|
|
||||||
worldType
|
|
||||||
]?.npcSceneIds ??
|
|
||||||
getCharacterNpcSceneIds(
|
|
||||||
worldType,
|
|
||||||
selectedCharacter.id,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
onChange={(value) =>
|
|
||||||
setSceneBinding(
|
|
||||||
worldType,
|
|
||||||
'npcSceneIds',
|
|
||||||
parseListInput(value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
rows={4}
|
|
||||||
placeholder={'scene-id-1\nscene-id-2'}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
))}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { CharacterPresetPanel as default } from './CharacterPresetPanel';
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export function LazyEditorFallback({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-400">
|
|
||||||
正在加载{label}...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { validateMonsterOverrides } from '../../data/editorValidation';
|
|
||||||
import {
|
|
||||||
MONSTER_PRESETS_BY_WORLD,
|
|
||||||
type MonsterPreset,
|
|
||||||
type MonsterPresetOverride,
|
|
||||||
} from '../../data/hostileNpcPresets';
|
|
||||||
import monsterOverridesJson from '../../data/monsterOverrides.json';
|
|
||||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
|
||||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
|
||||||
import {
|
|
||||||
NumberField,
|
|
||||||
SelectField,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
} from '../../editor/shared/FormFields';
|
|
||||||
import { SectionCard } from '../../editor/shared/SectionCard';
|
|
||||||
import { useJsonSave } from '../../editor/shared/useJsonSave';
|
|
||||||
import { WorldType } from '../../types';
|
|
||||||
import { HostileNpcAnimator } from '../HostileNpcAnimator';
|
|
||||||
import {
|
|
||||||
applyMonsterOverride,
|
|
||||||
getMonsterAnimationLabel,
|
|
||||||
listInputValue,
|
|
||||||
MONSTER_ANIMATION_OPTIONS,
|
|
||||||
parseListInput,
|
|
||||||
WORLD_LABELS,
|
|
||||||
} from './shared';
|
|
||||||
export function MonsterPresetPanel() {
|
|
||||||
const allMonsters = useMemo(
|
|
||||||
() => [
|
|
||||||
...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA],
|
|
||||||
...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA],
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [overrideMap, setOverrideMap] = useState<
|
|
||||||
Record<string, MonsterPresetOverride>
|
|
||||||
>(monsterOverridesJson as Record<string, MonsterPresetOverride>);
|
|
||||||
const [selectedMonsterId, setSelectedMonsterId] = useState(
|
|
||||||
allMonsters[0]?.id ?? '',
|
|
||||||
);
|
|
||||||
const [previewAnimation, setPreviewAnimation] =
|
|
||||||
useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle');
|
|
||||||
const { isSaving, saveMessage, save } = useJsonSave({
|
|
||||||
resourceId: EDITOR_JSON_RESOURCE_IDS.monsterOverrides,
|
|
||||||
payload: overrideMap as Record<string, unknown>,
|
|
||||||
validate: () => validateMonsterOverrides(overrideMap, allMonsters),
|
|
||||||
successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。',
|
|
||||||
errorMessage: '保存敌人预设覆盖失败。',
|
|
||||||
});
|
|
||||||
const selectedMonster =
|
|
||||||
allMonsters.find((monster) => monster.id === selectedMonsterId) ??
|
|
||||||
allMonsters[0];
|
|
||||||
if (!selectedMonster) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
|
|
||||||
当前没有可用的敌人预设。
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const effectiveMonster = applyMonsterOverride(
|
|
||||||
selectedMonster,
|
|
||||||
overrideMap[selectedMonster.id],
|
|
||||||
);
|
|
||||||
const setMonsterField = <K extends keyof MonsterPresetOverride>(
|
|
||||||
key: K,
|
|
||||||
value: MonsterPresetOverride[K],
|
|
||||||
) => {
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedMonster.id]: {
|
|
||||||
...(prev[selectedMonster.id] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setMonsterBaseStat = (
|
|
||||||
key: keyof MonsterPreset['baseStats'],
|
|
||||||
value: number,
|
|
||||||
) => {
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedMonster.id]: {
|
|
||||||
...(prev[selectedMonster.id] ?? {}),
|
|
||||||
baseStats: {
|
|
||||||
...effectiveMonster.baseStats,
|
|
||||||
...(prev[selectedMonster.id]?.baseStats ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setMonsterAnimation = (
|
|
||||||
animation: (typeof MONSTER_ANIMATION_OPTIONS)[number],
|
|
||||||
key: 'start' | 'frames' | 'fps',
|
|
||||||
value: number,
|
|
||||||
) => {
|
|
||||||
const baseConfig = effectiveMonster.animations[animation] ?? {
|
|
||||||
start: 0,
|
|
||||||
frames: 1,
|
|
||||||
fps: 12,
|
|
||||||
};
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedMonster.id]: {
|
|
||||||
...(prev[selectedMonster.id] ?? {}),
|
|
||||||
animations: {
|
|
||||||
...(prev[selectedMonster.id]?.animations ?? {}),
|
|
||||||
[animation]: {
|
|
||||||
...baseConfig,
|
|
||||||
...(prev[selectedMonster.id]?.animations?.[animation] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
|
|
||||||
{' '}
|
|
||||||
<EditorSelectionCard
|
|
||||||
title="敌人预设"
|
|
||||||
description="浏览并选择一个敌人预设。"
|
|
||||||
selectLabel="敌人"
|
|
||||||
selectValue={selectedMonster.id}
|
|
||||||
onSelectChange={setSelectedMonsterId}
|
|
||||||
selectOptions={allMonsters.map((monster) => {
|
|
||||||
const optionMonster = applyMonsterOverride(
|
|
||||||
monster,
|
|
||||||
overrideMap[monster.id],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
label: `${WORLD_LABELS[monster.worldType]} · ${optionMonster.name}`,
|
|
||||||
value: monster.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
saveLabel="保存敌人覆盖"
|
|
||||||
onSave={save}
|
|
||||||
isSaving={isSaving}
|
|
||||||
saveMessage={saveMessage}
|
|
||||||
>
|
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
|
||||||
{' '}
|
|
||||||
<div className="text-sm font-semibold text-white">
|
|
||||||
{effectiveMonster.name}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-1 text-xs text-zinc-400">
|
|
||||||
{WORLD_LABELS[effectiveMonster.worldType]}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
|
|
||||||
{effectiveMonster.description}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</EditorSelectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="敌人预览"
|
|
||||||
description="预览当前敌人的外观与基础属性。"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="mb-4">
|
|
||||||
{' '}
|
|
||||||
<SelectField
|
|
||||||
label="预览动画"
|
|
||||||
value={previewAnimation}
|
|
||||||
onChange={(value) =>
|
|
||||||
setPreviewAnimation(
|
|
||||||
value as (typeof MONSTER_ANIMATION_OPTIONS)[number],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
options={MONSTER_ANIMATION_OPTIONS.filter(
|
|
||||||
(animation) =>
|
|
||||||
effectiveMonster.animations[animation] || animation === 'idle',
|
|
||||||
).map((animation) => ({
|
|
||||||
label: getMonsterAnimationLabel(animation),
|
|
||||||
value: animation,
|
|
||||||
}))}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="flex min-h-[360px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(250,204,21,0.12),transparent_40%),linear-gradient(180deg,#1a1711,#0f0d09)] p-6">
|
|
||||||
{' '}
|
|
||||||
<div className="flex h-[240px] w-[240px] items-end justify-center rounded-2xl border border-white/5 bg-black/20">
|
|
||||||
{' '}
|
|
||||||
<HostileNpcAnimator
|
|
||||||
hostileNpc={effectiveMonster}
|
|
||||||
animation={previewAnimation}
|
|
||||||
className="scale-[2.5] origin-bottom"
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
||||||
{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
攻击距离:{effectiveMonster.baseStats.attackRange}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
速度:{effectiveMonster.baseStats.speed}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
生命值:{effectiveMonster.baseStats.hp}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
生命上限:{effectiveMonster.baseStats.maxHp}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{' '}
|
|
||||||
<SectionCard title="基础信息" description="编辑当前敌人的基础资料。">
|
|
||||||
{' '}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{' '}
|
|
||||||
<TextField
|
|
||||||
label="敌人 ID"
|
|
||||||
value={effectiveMonster.id}
|
|
||||||
onChange={() => undefined}
|
|
||||||
disabled
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="名称"
|
|
||||||
value={effectiveMonster.name}
|
|
||||||
onChange={(value) => setMonsterField('name', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="素材路径"
|
|
||||||
value={effectiveMonster.src}
|
|
||||||
onChange={(value) => setMonsterField('src', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="描述"
|
|
||||||
value={effectiveMonster.description}
|
|
||||||
onChange={(value) => setMonsterField('description', value)}
|
|
||||||
rows={4}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="出场动作"
|
|
||||||
value={effectiveMonster.introAction}
|
|
||||||
onChange={(value) => setMonsterField('introAction', value)}
|
|
||||||
rows={3}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="栖息标签"
|
|
||||||
value={listInputValue(effectiveMonster.habitatTags)}
|
|
||||||
onChange={(value) =>
|
|
||||||
setMonsterField('habitatTags', parseListInput(value))
|
|
||||||
}
|
|
||||||
rows={4}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="战斗标签"
|
|
||||||
value={listInputValue(effectiveMonster.combatTags ?? [])}
|
|
||||||
onChange={(value) =>
|
|
||||||
setMonsterField('combatTags', parseListInput(value))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="帧宽"
|
|
||||||
value={effectiveMonster.frameWidth}
|
|
||||||
onChange={(value) => setMonsterField('frameWidth', value)}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="帧高"
|
|
||||||
value={effectiveMonster.frameHeight}
|
|
||||||
onChange={(value) => setMonsterField('frameHeight', value)}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="图集宽度"
|
|
||||||
value={effectiveMonster.sheetWidth}
|
|
||||||
onChange={(value) => setMonsterField('sheetWidth', value)}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard title="基础数值" description="调整当前敌人的基础属性。">
|
|
||||||
{' '}
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{' '}
|
|
||||||
<NumberField
|
|
||||||
label="攻击距离"
|
|
||||||
value={effectiveMonster.baseStats.attackRange}
|
|
||||||
onChange={(value) => setMonsterBaseStat('attackRange', value)}
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="速度"
|
|
||||||
value={effectiveMonster.baseStats.speed}
|
|
||||||
onChange={(value) => setMonsterBaseStat('speed', value)}
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="生命值"
|
|
||||||
value={effectiveMonster.baseStats.hp}
|
|
||||||
onChange={(value) => setMonsterBaseStat('hp', value)}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="生命上限"
|
|
||||||
value={effectiveMonster.baseStats.maxHp}
|
|
||||||
onChange={(value) => setMonsterBaseStat('maxHp', value)}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard title="动画配置" description="调整当前敌人的动画参数。">
|
|
||||||
{' '}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{' '}
|
|
||||||
{MONSTER_ANIMATION_OPTIONS.filter(
|
|
||||||
(animation) => effectiveMonster.animations[animation],
|
|
||||||
).map((animation) => {
|
|
||||||
const config = effectiveMonster.animations[animation]!;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={animation}
|
|
||||||
className="rounded-xl border border-white/10 bg-black/20 p-4"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">
|
|
||||||
{getMonsterAnimationLabel(animation)}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{' '}
|
|
||||||
<NumberField
|
|
||||||
label="起始帧"
|
|
||||||
value={config.start}
|
|
||||||
onChange={(value) =>
|
|
||||||
setMonsterAnimation(animation, 'start', value)
|
|
||||||
}
|
|
||||||
min={0}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="帧数"
|
|
||||||
value={config.frames}
|
|
||||||
onChange={(value) =>
|
|
||||||
setMonsterAnimation(animation, 'frames', value)
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="帧率"
|
|
||||||
value={config.fps ?? 12}
|
|
||||||
onChange={(value) =>
|
|
||||||
setMonsterAnimation(animation, 'fps', value)
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
/>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { MonsterPresetPanel as default } from './MonsterPresetPanel';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { CharacterPresetPanel } from './CharacterPresetPanel';
|
|
||||||
export { MonsterPresetPanel } from './MonsterPresetPanel';
|
|
||||||
export { SceneNpcPresetPanel } from './SceneNpcPresetPanel';
|
|
||||||
export { ScenePresetPanel } from './ScenePresetPanel';
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getCharacterById,
|
|
||||||
PRESET_CHARACTERS,
|
|
||||||
} from '../../data/characterPresets';
|
|
||||||
import { validateSceneNpcOverrides } from '../../data/editorValidation';
|
|
||||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
|
||||||
import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json';
|
|
||||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
|
||||||
import {
|
|
||||||
getScenePresetsByWorld,
|
|
||||||
type SceneNpcPresetOverride,
|
|
||||||
} from '../../data/scenePresets';
|
|
||||||
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
|
|
||||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
|
||||||
import {
|
|
||||||
NumberField,
|
|
||||||
SelectField,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
} from '../../editor/shared/FormFields';
|
|
||||||
import { SectionCard } from '../../editor/shared/SectionCard';
|
|
||||||
import { useJsonSave } from '../../editor/shared/useJsonSave';
|
|
||||||
import { type Encounter, type SceneNpc, WorldType } from '../../types';
|
|
||||||
import { HostileNpcAnimator } from '../HostileNpcAnimator';
|
|
||||||
import { MedievalNpcAnimator } from '../MedievalNpcAnimator';
|
|
||||||
import { NpcVisualEditor } from '../NpcVisualEditor';
|
|
||||||
import { SkillEffectPreview } from '../SkillEffectPreview';
|
|
||||||
import {
|
|
||||||
applySceneNpcOverride,
|
|
||||||
isRangedSkill,
|
|
||||||
WORLD_LABELS,
|
|
||||||
WORLD_OPTIONS,
|
|
||||||
} from './shared';
|
|
||||||
export function SceneNpcPresetPanel() {
|
|
||||||
const npcCatalog = useMemo(() => {
|
|
||||||
const map = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
npc: SceneNpc;
|
|
||||||
worldTypes: WorldType[];
|
|
||||||
sceneIds: string[];
|
|
||||||
sceneNames: string[];
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
for (const worldType of WORLD_OPTIONS) {
|
|
||||||
for (const scene of getScenePresetsByWorld(worldType)) {
|
|
||||||
for (const npc of scene.npcs) {
|
|
||||||
const existing = map.get(npc.id);
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.sceneIds.includes(scene.id)) {
|
|
||||||
existing.sceneIds.push(scene.id);
|
|
||||||
existing.sceneNames.push(scene.name);
|
|
||||||
}
|
|
||||||
if (!existing.worldTypes.includes(worldType)) {
|
|
||||||
existing.worldTypes.push(worldType);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
map.set(npc.id, {
|
|
||||||
npc,
|
|
||||||
worldTypes: [worldType],
|
|
||||||
sceneIds: [scene.id],
|
|
||||||
sceneNames: [scene.name],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...map.values()].sort((a, b) =>
|
|
||||||
a.npc.name.localeCompare(b.npc.name, 'zh-Hans-CN'),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
const [overrideMap, setOverrideMap] = useState<
|
|
||||||
Record<string, SceneNpcPresetOverride>
|
|
||||||
>(sceneNpcOverridesJson as Record<string, SceneNpcPresetOverride>);
|
|
||||||
const [selectedNpcId, setSelectedNpcId] = useState(
|
|
||||||
npcCatalog[0]?.npc.id ?? '',
|
|
||||||
);
|
|
||||||
const [npcSkillPreviewWorld, setNpcSkillPreviewWorld] = useState<WorldType>(
|
|
||||||
npcCatalog[0]?.worldTypes[0] ?? WorldType.WUXIA,
|
|
||||||
);
|
|
||||||
const [selectedNpcSkillPreviewId, setSelectedNpcSkillPreviewId] =
|
|
||||||
useState('');
|
|
||||||
const selectedNpcEntry =
|
|
||||||
npcCatalog.find((item) => item.npc.id === selectedNpcId) ?? null;
|
|
||||||
const effectiveNpc = selectedNpcEntry
|
|
||||||
? applySceneNpcOverride(
|
|
||||||
selectedNpcEntry.npc,
|
|
||||||
overrideMap[selectedNpcEntry.npc.id],
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const linkedNpcCharacter = effectiveNpc?.characterId
|
|
||||||
? getCharacterById(effectiveNpc.characterId)
|
|
||||||
: null;
|
|
||||||
const rangedNpcSkills = useMemo(
|
|
||||||
() => linkedNpcCharacter?.skills.filter(isRangedSkill) ?? [],
|
|
||||||
[linkedNpcCharacter],
|
|
||||||
);
|
|
||||||
const selectedNpcSkillPreview =
|
|
||||||
rangedNpcSkills.find((skill) => skill.id === selectedNpcSkillPreviewId) ??
|
|
||||||
rangedNpcSkills[0] ??
|
|
||||||
null;
|
|
||||||
const selectedNpcWorldTypes = useMemo(
|
|
||||||
() => selectedNpcEntry?.worldTypes ?? [],
|
|
||||||
[selectedNpcEntry],
|
|
||||||
);
|
|
||||||
const hostileNpcWorldType = selectedNpcWorldTypes[0] ?? WorldType.WUXIA;
|
|
||||||
const hostileNpcPreset = effectiveNpc?.monsterPresetId
|
|
||||||
? (MONSTER_PRESETS_BY_WORLD[hostileNpcWorldType].find(
|
|
||||||
(monster) => monster.id === effectiveNpc.monsterPresetId,
|
|
||||||
) ?? null)
|
|
||||||
: null;
|
|
||||||
const isHostileNpcEntry = Boolean(
|
|
||||||
effectiveNpc?.monsterPresetId ||
|
|
||||||
effectiveNpc?.hostile ||
|
|
||||||
(effectiveNpc?.initialAffinity ?? 0) < 0,
|
|
||||||
);
|
|
||||||
const { isSaving, saveMessage, save } = useJsonSave({
|
|
||||||
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneNpcOverrides,
|
|
||||||
payload: overrideMap as Record<string, unknown>,
|
|
||||||
validate: () =>
|
|
||||||
validateSceneNpcOverrides(
|
|
||||||
overrideMap,
|
|
||||||
npcCatalog.map((item) => item.npc.id),
|
|
||||||
PRESET_CHARACTERS,
|
|
||||||
),
|
|
||||||
successMessage: '角色覆盖已保存。',
|
|
||||||
errorMessage: '保存角色覆盖失败。',
|
|
||||||
});
|
|
||||||
const previewEncounter: Encounter | null = effectiveNpc
|
|
||||||
? {
|
|
||||||
id: effectiveNpc.id,
|
|
||||||
kind: 'npc',
|
|
||||||
characterId: effectiveNpc.characterId,
|
|
||||||
monsterPresetId: effectiveNpc.monsterPresetId,
|
|
||||||
npcName: effectiveNpc.name,
|
|
||||||
npcDescription: effectiveNpc.description,
|
|
||||||
npcAvatar: effectiveNpc.avatar,
|
|
||||||
context: effectiveNpc.role,
|
|
||||||
initialAffinity: effectiveNpc.initialAffinity,
|
|
||||||
hostile: isHostileNpcEntry,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedNpcWorldTypes.includes(npcSkillPreviewWorld)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setNpcSkillPreviewWorld(selectedNpcWorldTypes[0] ?? WorldType.WUXIA);
|
|
||||||
}, [npcSkillPreviewWorld, selectedNpcWorldTypes]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
rangedNpcSkills.some((skill) => skill.id === selectedNpcSkillPreviewId)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedNpcSkillPreviewId(rangedNpcSkills[0]?.id ?? '');
|
|
||||||
}, [rangedNpcSkills, selectedNpcSkillPreviewId]);
|
|
||||||
if (!selectedNpcEntry || !effectiveNpc || !previewEncounter) {
|
|
||||||
return <EditorEmptyState message="当前没有可用的角色预设。" />;
|
|
||||||
}
|
|
||||||
const setNpcField = <K extends keyof SceneNpcPresetOverride>(
|
|
||||||
key: K,
|
|
||||||
value: SceneNpcPresetOverride[K],
|
|
||||||
) => {
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedNpcEntry.npc.id]: {
|
|
||||||
...(prev[selectedNpcEntry.npc.id] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
|
|
||||||
{' '}
|
|
||||||
<EditorSelectionCard
|
|
||||||
title="角色库"
|
|
||||||
description="浏览并选择一个角色预设。"
|
|
||||||
selectLabel="角色 ID"
|
|
||||||
selectValue={selectedNpcEntry.npc.id}
|
|
||||||
onSelectChange={setSelectedNpcId}
|
|
||||||
selectOptions={npcCatalog.map((item) => {
|
|
||||||
const optionNpc = applySceneNpcOverride(
|
|
||||||
item.npc,
|
|
||||||
overrideMap[item.npc.id],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
label: `${optionNpc.name} (${item.sceneNames.join(' / ')})`,
|
|
||||||
value: item.npc.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
saveLabel="保存角色覆盖"
|
|
||||||
onSave={save}
|
|
||||||
isSaving={isSaving}
|
|
||||||
saveMessage={saveMessage}
|
|
||||||
>
|
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
|
||||||
{' '}
|
|
||||||
<div className="text-sm font-semibold text-white">
|
|
||||||
{effectiveNpc.name}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-1 text-xs text-zinc-400">{effectiveNpc.role}</div>{' '}
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{' '}
|
|
||||||
{selectedNpcEntry.worldTypes.map((worldType) => (
|
|
||||||
<span
|
|
||||||
key={worldType}
|
|
||||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[11px] text-zinc-300"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
{WORLD_LABELS[worldType]}{' '}
|
|
||||||
</span>
|
|
||||||
))}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
|
|
||||||
{effectiveNpc.description}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</EditorSelectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="技能预览"
|
|
||||||
description="预览关联角色的远程技能。"
|
|
||||||
>
|
|
||||||
{linkedNpcCharacter && rangedNpcSkills.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<SelectField
|
|
||||||
label="技能"
|
|
||||||
value={selectedNpcSkillPreview?.id ?? ''}
|
|
||||||
onChange={setSelectedNpcSkillPreviewId}
|
|
||||||
options={rangedNpcSkills.map((skill) => ({
|
|
||||||
label: skill.name,
|
|
||||||
value: skill.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<SelectField
|
|
||||||
label="世界"
|
|
||||||
value={npcSkillPreviewWorld}
|
|
||||||
onChange={(value) =>
|
|
||||||
setNpcSkillPreviewWorld(value as WorldType)
|
|
||||||
}
|
|
||||||
options={selectedNpcEntry.worldTypes.map((worldType) => ({
|
|
||||||
label: WORLD_LABELS[worldType],
|
|
||||||
value: worldType,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SkillEffectPreview
|
|
||||||
mode="npc"
|
|
||||||
worldType={npcSkillPreviewWorld}
|
|
||||||
character={linkedNpcCharacter}
|
|
||||||
skill={selectedNpcSkillPreview}
|
|
||||||
npcEncounter={previewEncounter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
|
|
||||||
当前角色没有可预览的远程技能。
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="形象预览"
|
|
||||||
description={
|
|
||||||
isHostileNpcEntry
|
|
||||||
? '敌对角色使用敌人预设,无法预览内嵌角色形象。'
|
|
||||||
: '叙事角色可以在这里预览绑定形象与技能效果。'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="flex min-h-[420px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(244,63,94,0.16),transparent_45%),linear-gradient(180deg,#17131a,#0d0a0f)] p-6">
|
|
||||||
{' '}
|
|
||||||
<div className="relative flex h-[340px] w-[260px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
|
|
||||||
{' '}
|
|
||||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:20px_20px]" />{' '}
|
|
||||||
<div className="mb-8 drop-shadow-[0_18px_24px_rgba(0,0,0,0.45)]">
|
|
||||||
{' '}
|
|
||||||
{hostileNpcPreset ? (
|
|
||||||
<HostileNpcAnimator
|
|
||||||
hostileNpc={hostileNpcPreset}
|
|
||||||
className="scale-[2.4] origin-bottom"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MedievalNpcAnimator encounter={previewEncounter} />
|
|
||||||
)}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-black/20 p-4">
|
|
||||||
{' '}
|
|
||||||
<div className="mb-3 text-xs uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
出现于场景
|
|
||||||
</div>{' '}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{' '}
|
|
||||||
{selectedNpcEntry.sceneNames.map((sceneName) => (
|
|
||||||
<span
|
|
||||||
key={sceneName}
|
|
||||||
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-zinc-200"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
{sceneName}{' '}
|
|
||||||
</span>
|
|
||||||
))}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="角色详情"
|
|
||||||
description="编辑当前选中的角色预设。"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{' '}
|
|
||||||
<TextField
|
|
||||||
label="角色 ID"
|
|
||||||
value={effectiveNpc.id}
|
|
||||||
onChange={() => undefined}
|
|
||||||
disabled
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="名称"
|
|
||||||
value={effectiveNpc.name}
|
|
||||||
onChange={(value) => setNpcField('name', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="身份"
|
|
||||||
value={effectiveNpc.role}
|
|
||||||
onChange={(value) => setNpcField('role', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="头像"
|
|
||||||
value={effectiveNpc.avatar}
|
|
||||||
onChange={(value) => setNpcField('avatar', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="关联角色 ID"
|
|
||||||
value={effectiveNpc.characterId ?? ''}
|
|
||||||
onChange={() => undefined}
|
|
||||||
disabled
|
|
||||||
/>{' '}
|
|
||||||
<TextField
|
|
||||||
label="敌人预设 ID"
|
|
||||||
value={effectiveNpc.monsterPresetId ?? ''}
|
|
||||||
onChange={(value) =>
|
|
||||||
setNpcField('monsterPresetId', value || undefined)
|
|
||||||
}
|
|
||||||
/>{' '}
|
|
||||||
<NumberField
|
|
||||||
label="初始好感"
|
|
||||||
value={effectiveNpc.initialAffinity ?? 0}
|
|
||||||
onChange={(value) => setNpcField('initialAffinity', value)}
|
|
||||||
/>{' '}
|
|
||||||
<TextAreaField
|
|
||||||
label="描述"
|
|
||||||
value={effectiveNpc.description}
|
|
||||||
onChange={(value) => setNpcField('description', value)}
|
|
||||||
rows={5}
|
|
||||||
/>{' '}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3 text-xs leading-relaxed text-zinc-400">
|
|
||||||
{' '}
|
|
||||||
当前预览上下文:
|
|
||||||
{previewEncounter.npcName} / {previewEncounter.context}{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
<div className="xl:col-span-3">
|
|
||||||
{' '}
|
|
||||||
<SectionCard
|
|
||||||
title="形象编辑器"
|
|
||||||
description={
|
|
||||||
isHostileNpcEntry
|
|
||||||
? '敌对角色不能使用形象编辑器,请切换到叙事角色或清空敌人预设 ID。'
|
|
||||||
: '叙事角色的形象覆盖可以在这里预览与调整。'
|
|
||||||
}
|
|
||||||
className="p-6"
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
{isHostileNpcEntry ? (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
|
|
||||||
当前角色被视为敌对角色,形象表现由敌人预设驱动,无法在这里编辑叙事形象。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<NpcVisualEditor
|
|
||||||
embedded
|
|
||||||
selectedNpcId={selectedNpcEntry.npc.id}
|
|
||||||
hideNpcSelector
|
|
||||||
/>
|
|
||||||
)}{' '}
|
|
||||||
</SectionCard>{' '}
|
|
||||||
</div>{' '}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { SceneNpcPresetPanel as default } from './SceneNpcPresetPanel';
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { PRESET_CHARACTERS } from '../../data/characterPresets';
|
|
||||||
import { validateSceneOverrides } from '../../data/editorValidation';
|
|
||||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
|
||||||
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
|
|
||||||
import sceneOverridesJson from '../../data/sceneOverrides.json';
|
|
||||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
|
||||||
import {
|
|
||||||
getSceneHostileNpcPresetIds,
|
|
||||||
getSceneHostileNpcs,
|
|
||||||
getScenePresetsByWorld,
|
|
||||||
type ScenePresetOverride,
|
|
||||||
} from '../../data/scenePresets';
|
|
||||||
import {
|
|
||||||
SaveBar,
|
|
||||||
SelectField,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
} from '../../editor/shared/FormFields';
|
|
||||||
import { SectionCard } from '../../editor/shared/SectionCard';
|
|
||||||
import { useJsonSave } from '../../editor/shared/useJsonSave';
|
|
||||||
import { AnimationState, type Encounter, WorldType } from '../../types';
|
|
||||||
import { GameCanvas } from '../GameCanvas';
|
|
||||||
import {
|
|
||||||
applySceneOverride,
|
|
||||||
listInputValue,
|
|
||||||
parseListInput,
|
|
||||||
WORLD_LABELS,
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
type PreviewMode = 'monster' | 'npc' | 'treasure' | 'empty';
|
|
||||||
|
|
||||||
export function ScenePresetPanel() {
|
|
||||||
const allScenes = useMemo(
|
|
||||||
() => [
|
|
||||||
...getScenePresetsByWorld(WorldType.WUXIA),
|
|
||||||
...getScenePresetsByWorld(WorldType.XIANXIA),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [overrideMap, setOverrideMap] = useState<
|
|
||||||
Record<string, ScenePresetOverride>
|
|
||||||
>(sceneOverridesJson as Record<string, ScenePresetOverride>);
|
|
||||||
const [selectedSceneId, setSelectedSceneId] = useState(
|
|
||||||
allScenes[0]?.id ?? '',
|
|
||||||
);
|
|
||||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('monster');
|
|
||||||
const { isSaving, saveMessage, save } = useJsonSave({
|
|
||||||
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneOverrides,
|
|
||||||
payload: overrideMap as Record<string, unknown>,
|
|
||||||
validate: () =>
|
|
||||||
validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD),
|
|
||||||
successMessage: '场景覆盖已保存。',
|
|
||||||
errorMessage: '保存场景覆盖失败。',
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedScene =
|
|
||||||
allScenes.find((scene) => scene.id === selectedSceneId) ?? allScenes[0];
|
|
||||||
|
|
||||||
if (!selectedScene) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
|
|
||||||
当前没有可用的场景预设。
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveScene = applySceneOverride(
|
|
||||||
selectedScene,
|
|
||||||
overrideMap[selectedScene.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hostileSceneNpcs = getSceneHostileNpcs(effectiveScene);
|
|
||||||
const hostileScenePresetIds = getSceneHostileNpcPresetIds(effectiveScene);
|
|
||||||
const previewCharacter = PRESET_CHARACTERS[0] ?? null;
|
|
||||||
const previewMonsters =
|
|
||||||
previewMode === 'monster' && hostileSceneNpcs.length > 0
|
|
||||||
? createSceneHostileNpcsFromIds(
|
|
||||||
effectiveScene.worldType,
|
|
||||||
hostileScenePresetIds.slice(0, 1),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const previewNpc =
|
|
||||||
previewMode === 'npc'
|
|
||||||
? (effectiveScene.npcs.find((npc) => !npc.monsterPresetId) ??
|
|
||||||
effectiveScene.npcs[0])
|
|
||||||
: null;
|
|
||||||
const previewEncounter: Encounter | null =
|
|
||||||
previewMode === 'npc' && previewNpc
|
|
||||||
? {
|
|
||||||
id: previewNpc.id,
|
|
||||||
kind: 'npc',
|
|
||||||
characterId: previewNpc.characterId,
|
|
||||||
npcName: previewNpc.name,
|
|
||||||
npcDescription: previewNpc.description,
|
|
||||||
npcAvatar: previewNpc.avatar,
|
|
||||||
context: previewNpc.role,
|
|
||||||
}
|
|
||||||
: previewMode === 'treasure' && effectiveScene.treasureHints[0]
|
|
||||||
? {
|
|
||||||
id: `${effectiveScene.id}-treasure`,
|
|
||||||
kind: 'treasure',
|
|
||||||
npcName: '前方宝藏',
|
|
||||||
npcDescription: effectiveScene.treasureHints[0],
|
|
||||||
npcAvatar: '宝',
|
|
||||||
context: '宝藏',
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const setSceneField = <K extends keyof ScenePresetOverride>(
|
|
||||||
key: K,
|
|
||||||
value: ScenePresetOverride[K],
|
|
||||||
) => {
|
|
||||||
setOverrideMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedScene.id]: {
|
|
||||||
...(prev[selectedScene.id] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sceneOptions = allScenes
|
|
||||||
.filter((scene) => scene.worldType === effectiveScene.worldType)
|
|
||||||
.map((scene) => ({ label: scene.name, value: scene.id }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
|
|
||||||
<SectionCard
|
|
||||||
title="场景库"
|
|
||||||
description="浏览并选择一个场景预设。"
|
|
||||||
>
|
|
||||||
<SelectField
|
|
||||||
label="场景"
|
|
||||||
value={selectedScene.id}
|
|
||||||
onChange={setSelectedSceneId}
|
|
||||||
options={allScenes.map((scene) => {
|
|
||||||
const optionScene = applySceneOverride(
|
|
||||||
scene,
|
|
||||||
overrideMap[scene.id],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
label: `${WORLD_LABELS[scene.worldType]} - ${optionScene.name}`,
|
|
||||||
value: scene.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div className="text-sm font-semibold text-white">
|
|
||||||
{effectiveScene.name}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-400">
|
|
||||||
{WORLD_LABELS[effectiveScene.worldType]}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
|
|
||||||
{effectiveScene.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SaveBar
|
|
||||||
saveLabel="保存场景覆盖"
|
|
||||||
onSave={save}
|
|
||||||
isSaving={isSaving}
|
|
||||||
saveMessage={saveMessage}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard
|
|
||||||
title="场景预览"
|
|
||||||
description="预览当前场景中的敌人、角色和宝藏表现。"
|
|
||||||
>
|
|
||||||
<div className="mb-4">
|
|
||||||
<SelectField
|
|
||||||
label="预览模式"
|
|
||||||
value={previewMode}
|
|
||||||
onChange={(value) => setPreviewMode(value as PreviewMode)}
|
|
||||||
options={[
|
|
||||||
{ label: '敌人预览', value: 'monster' },
|
|
||||||
{ label: '角色预览', value: 'npc' },
|
|
||||||
{ label: '宝藏预览', value: 'treasure' },
|
|
||||||
{ label: '空场景', value: 'empty' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-[420px] overflow-hidden rounded-2xl border border-white/10 bg-black">
|
|
||||||
<GameCanvas
|
|
||||||
scrollWorld={false}
|
|
||||||
animationState={AnimationState.IDLE}
|
|
||||||
playerCharacter={previewCharacter}
|
|
||||||
encounter={previewEncounter}
|
|
||||||
currentScenePreset={effectiveScene}
|
|
||||||
worldType={effectiveScene.worldType}
|
|
||||||
sceneHostileNpcs={previewMonsters}
|
|
||||||
playerX={0}
|
|
||||||
playerOffsetY={0}
|
|
||||||
playerFacing="right"
|
|
||||||
inBattle={previewMode === 'monster'}
|
|
||||||
playerHp={180}
|
|
||||||
playerMaxHp={180}
|
|
||||||
onSceneNameClick={null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
敌对角色
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
{hostileSceneNpcs.map((npc) => npc.name).join('、') || '无'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
场景角色
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
{effectiveScene.npcs.map((npc) => npc.name).join(' / ') || '无'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
|
||||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
|
|
||||||
宝藏线索
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
{effectiveScene.treasureHints[0] || '无'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard
|
|
||||||
title="场景详情"
|
|
||||||
description="编辑当前选中的场景预设。"
|
|
||||||
>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<TextField
|
|
||||||
label="场景 ID"
|
|
||||||
value={effectiveScene.id}
|
|
||||||
onChange={() => undefined}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="世界"
|
|
||||||
value={WORLD_LABELS[effectiveScene.worldType]}
|
|
||||||
onChange={() => undefined}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="名称"
|
|
||||||
value={effectiveScene.name}
|
|
||||||
onChange={(value) => setSceneField('name', value)}
|
|
||||||
/>
|
|
||||||
<TextAreaField
|
|
||||||
label="描述"
|
|
||||||
value={effectiveScene.description}
|
|
||||||
onChange={(value) => setSceneField('description', value)}
|
|
||||||
rows={5}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="图片资源"
|
|
||||||
value={effectiveScene.imageSrc}
|
|
||||||
onChange={(value) => setSceneField('imageSrc', value)}
|
|
||||||
/>
|
|
||||||
<SelectField
|
|
||||||
label="前进场景"
|
|
||||||
value={effectiveScene.forwardSceneId ?? ''}
|
|
||||||
onChange={(value) =>
|
|
||||||
setSceneField('forwardSceneId', value || undefined)
|
|
||||||
}
|
|
||||||
options={[{ label: '未设置', value: '' }, ...sceneOptions]}
|
|
||||||
/>
|
|
||||||
<TextAreaField
|
|
||||||
label="连接场景 ID"
|
|
||||||
value={listInputValue(effectiveScene.connectedSceneIds)}
|
|
||||||
onChange={(value) =>
|
|
||||||
setSceneField('connectedSceneIds', parseListInput(value))
|
|
||||||
}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<TextAreaField
|
|
||||||
label="敌对预设 ID(由场景 NPC 自动推导)"
|
|
||||||
value={listInputValue(hostileScenePresetIds)}
|
|
||||||
onChange={() => undefined}
|
|
||||||
rows={4}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<div className="-mt-1 rounded-xl border border-amber-400/15 bg-amber-500/8 px-3 py-2 text-xs leading-6 text-amber-100/80">
|
|
||||||
敌对目标应直接维护在场景 NPC 列表中,这里只展示当前敌对 NPC 自动映射出的 hostile visual/combat preset。
|
|
||||||
</div>
|
|
||||||
<TextAreaField
|
|
||||||
label="宝藏线索"
|
|
||||||
value={listInputValue(effectiveScene.treasureHints)}
|
|
||||||
onChange={(value) =>
|
|
||||||
setSceneField('treasureHints', parseListInput(value))
|
|
||||||
}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="mb-2 text-xs font-medium text-zinc-300">
|
|
||||||
场景内角色
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{effectiveScene.npcs.map((npc) => (
|
|
||||||
<span
|
|
||||||
key={npc.id}
|
|
||||||
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-zinc-200"
|
|
||||||
>
|
|
||||||
{npc.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ScenePresetPanel as default } from './ScenePresetPanel';
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import {
|
|
||||||
Braces,
|
|
||||||
Map as MapIcon,
|
|
||||||
Package,
|
|
||||||
Sparkles,
|
|
||||||
Sword,
|
|
||||||
User,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { ComponentType } from 'react';
|
|
||||||
|
|
||||||
import type { CharacterPresetOverride } from '../../data/characterPresets';
|
|
||||||
import type {
|
|
||||||
MonsterPreset,
|
|
||||||
MonsterPresetOverride,
|
|
||||||
} from '../../data/hostileNpcPresets';
|
|
||||||
import type {
|
|
||||||
SceneNpcPresetOverride,
|
|
||||||
ScenePreset,
|
|
||||||
ScenePresetOverride,
|
|
||||||
} from '../../data/scenePresets';
|
|
||||||
import {
|
|
||||||
AnimationState,
|
|
||||||
type Character,
|
|
||||||
type CharacterSkillDefinition,
|
|
||||||
type SceneNpc,
|
|
||||||
WorldType,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
export type PresetEditorTab =
|
|
||||||
| 'assets'
|
|
||||||
| 'characters'
|
|
||||||
| 'npcs'
|
|
||||||
| 'scenes'
|
|
||||||
| 'monsters'
|
|
||||||
| 'items'
|
|
||||||
| 'functions';
|
|
||||||
|
|
||||||
export const PRESET_EDITOR_TABS: Array<{
|
|
||||||
id: PresetEditorTab;
|
|
||||||
label: string;
|
|
||||||
icon: ComponentType<{ className?: string }>;
|
|
||||||
}> = [
|
|
||||||
{ id: 'assets', label: '资产', icon: Sparkles },
|
|
||||||
{ id: 'characters', label: '角色', icon: User },
|
|
||||||
{ id: 'npcs', label: '角色', icon: Users },
|
|
||||||
{ id: 'scenes', label: '场景', icon: MapIcon },
|
|
||||||
{ id: 'monsters', label: '敌人', icon: Sword },
|
|
||||||
{ id: 'items', label: '物品', icon: Package },
|
|
||||||
{ id: 'functions', label: '函数', icon: Braces },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const EDITOR_TAB_OPTIONS = PRESET_EDITOR_TABS;
|
|
||||||
|
|
||||||
export const WORLD_OPTIONS = [WorldType.WUXIA, WorldType.XIANXIA] as const;
|
|
||||||
|
|
||||||
export const WORLD_LABELS: Record<WorldType, string> = {
|
|
||||||
[WorldType.WUXIA]: '武侠',
|
|
||||||
[WorldType.XIANXIA]: '仙侠',
|
|
||||||
[WorldType.CUSTOM]: '自定义世界',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ANIMATION_OPTIONS = Object.values(AnimationState);
|
|
||||||
export const ANIMATION_LABELS: Record<AnimationState, string> = {
|
|
||||||
[AnimationState.IDLE]: '待机',
|
|
||||||
[AnimationState.ACQUIRE]: '拾取',
|
|
||||||
[AnimationState.ATTACK]: '攻击',
|
|
||||||
[AnimationState.RUN]: '奔跑',
|
|
||||||
[AnimationState.JUMP]: '跳跃',
|
|
||||||
[AnimationState.DOUBLE_JUMP]: '二段跳',
|
|
||||||
[AnimationState.JUMP_ATTACK]: '跳斩',
|
|
||||||
[AnimationState.DASH]: '冲刺',
|
|
||||||
[AnimationState.HURT]: '受击',
|
|
||||||
[AnimationState.DIE]: '倒下',
|
|
||||||
[AnimationState.CLIMB]: '攀爬',
|
|
||||||
[AnimationState.SKILL1]: '技能 1',
|
|
||||||
[AnimationState.SKILL1_JUMP]: '技能 1 跃击',
|
|
||||||
[AnimationState.SKILL1_BULLET]: '技能 1 弹道',
|
|
||||||
[AnimationState.SKILL1_BULLET_FX]: '技能 1 特效',
|
|
||||||
[AnimationState.SKILL2]: '技能 2',
|
|
||||||
[AnimationState.SKILL2_JUMP]: '技能 2 跃击',
|
|
||||||
[AnimationState.SKILL3]: '技能 3',
|
|
||||||
[AnimationState.SKILL3_JUMP]: '技能 3 跃击',
|
|
||||||
[AnimationState.SKILL3_BULLET]: '技能 3 弹道',
|
|
||||||
[AnimationState.SKILL3_BULLET_FX]: '技能 3 特效',
|
|
||||||
[AnimationState.SKILL4]: '技能 4',
|
|
||||||
[AnimationState.WALL_SLIDE]: '贴墙滑行',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MONSTER_ANIMATION_OPTIONS = [
|
|
||||||
'idle',
|
|
||||||
'move',
|
|
||||||
'attack',
|
|
||||||
'die',
|
|
||||||
] as const;
|
|
||||||
export const MONSTER_ANIMATION_LABELS: Record<
|
|
||||||
(typeof MONSTER_ANIMATION_OPTIONS)[number],
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
idle: '待机',
|
|
||||||
move: '移动',
|
|
||||||
attack: '攻击',
|
|
||||||
die: '倒下',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CHARACTER_SKILL_STYLE_OPTIONS = [
|
|
||||||
'steady',
|
|
||||||
'burst',
|
|
||||||
'mobility',
|
|
||||||
'finisher',
|
|
||||||
'projectile',
|
|
||||||
] as const;
|
|
||||||
export const CHARACTER_SKILL_STYLE_LABELS: Record<
|
|
||||||
(typeof CHARACTER_SKILL_STYLE_OPTIONS)[number],
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
steady: '稳扎稳打',
|
|
||||||
burst: '爆发',
|
|
||||||
mobility: '机动',
|
|
||||||
finisher: '终结',
|
|
||||||
projectile: '投射',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getAnimationStateLabel(animation: AnimationState) {
|
|
||||||
return ANIMATION_LABELS[animation] ?? animation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMonsterAnimationLabel(
|
|
||||||
animation: (typeof MONSTER_ANIMATION_OPTIONS)[number],
|
|
||||||
) {
|
|
||||||
return MONSTER_ANIMATION_LABELS[animation] ?? animation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCharacterSkillStyleLabel(
|
|
||||||
style: (typeof CHARACTER_SKILL_STYLE_OPTIONS)[number],
|
|
||||||
) {
|
|
||||||
return CHARACTER_SKILL_STYLE_LABELS[style] ?? style;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRangedSkill(skill: CharacterSkillDefinition) {
|
|
||||||
return skill.delivery === 'ranged' || skill.style === 'projectile';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseListInput(value: string) {
|
|
||||||
return value
|
|
||||||
.split('\n')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listInputValue(items: string[]) {
|
|
||||||
return items.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseBuildBuffsInput(
|
|
||||||
value: string,
|
|
||||||
sourceType: 'skill' | 'item' | 'forge',
|
|
||||||
sourceId: string,
|
|
||||||
) {
|
|
||||||
return value
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line, index) => {
|
|
||||||
const [namePart, tagsPart, durationPart] = line
|
|
||||||
.split('|')
|
|
||||||
.map((part) => part.trim());
|
|
||||||
const tags = tagsPart
|
|
||||||
? tagsPart
|
|
||||||
.split(',')
|
|
||||||
.map((tag) => tag.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${sourceId}-buff-${index + 1}`,
|
|
||||||
sourceType,
|
|
||||||
sourceId,
|
|
||||||
name: namePart || `${sourceId}-buff-${index + 1}`,
|
|
||||||
tags,
|
|
||||||
durationTurns: Math.max(1, Number(durationPart ?? '1') || 1),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((buff) => buff.tags.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildBuffsInputValue(
|
|
||||||
buffs: CharacterSkillDefinition['buildBuffs'] | undefined,
|
|
||||||
) {
|
|
||||||
return (buffs ?? [])
|
|
||||||
.map(
|
|
||||||
(buff) =>
|
|
||||||
`${buff.name}|${(buff.tags ?? []).join(',')}|${buff.durationTurns}`,
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeOptionalSceneId(value: string) {
|
|
||||||
const normalized = value.trim();
|
|
||||||
return normalized.length > 0 ? normalized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyCharacterOverride(
|
|
||||||
baseCharacter: Character,
|
|
||||||
override?: CharacterPresetOverride | null,
|
|
||||||
): Character {
|
|
||||||
if (!override) {
|
|
||||||
return baseCharacter;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseCharacter,
|
|
||||||
...override,
|
|
||||||
attributes: { ...baseCharacter.attributes, ...(override.attributes ?? {}) },
|
|
||||||
animationMap: override.animationMap
|
|
||||||
? { ...(baseCharacter.animationMap ?? {}), ...override.animationMap }
|
|
||||||
: baseCharacter.animationMap,
|
|
||||||
skills: override.skills ?? baseCharacter.skills,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyMonsterOverride(
|
|
||||||
baseMonster: MonsterPreset,
|
|
||||||
override?: MonsterPresetOverride | null,
|
|
||||||
): MonsterPreset {
|
|
||||||
if (!override) {
|
|
||||||
return baseMonster;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseMonster,
|
|
||||||
...override,
|
|
||||||
animations: { ...baseMonster.animations, ...(override.animations ?? {}) },
|
|
||||||
baseStats: { ...baseMonster.baseStats, ...(override.baseStats ?? {}) },
|
|
||||||
habitatTags: override.habitatTags ?? baseMonster.habitatTags,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySceneOverride(
|
|
||||||
baseScene: ScenePreset,
|
|
||||||
override?: ScenePresetOverride | null,
|
|
||||||
): ScenePreset {
|
|
||||||
if (!override) {
|
|
||||||
return baseScene;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...baseScene, ...override };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySceneNpcOverride(
|
|
||||||
baseNpc: SceneNpc,
|
|
||||||
override?: SceneNpcPresetOverride | null,
|
|
||||||
): SceneNpc {
|
|
||||||
if (!override) {
|
|
||||||
return baseNpc;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...baseNpc, ...override };
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from './buildDamage';
|
} from './buildDamage';
|
||||||
import { getCharacterCombatTags } from './buildTags';
|
import { getCharacterCombatTags } from './buildTags';
|
||||||
import { getCharacterById } from './characterPresets';
|
import { getCharacterById } from './characterPresets';
|
||||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||||
|
|
||||||
function requireCharacter(characterId: string) {
|
function requireCharacter(characterId: string) {
|
||||||
const character = getCharacterById(characterId);
|
const character = getCharacterById(characterId);
|
||||||
@@ -37,8 +37,8 @@ function cloneCharacter(
|
|||||||
},
|
},
|
||||||
} satisfies Character;
|
} satisfies Character;
|
||||||
|
|
||||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||||
const wuxiaProfile = buildCharacterAttributeProfile(
|
const wuxiaProfile = buildCharacterAttributeProfile(
|
||||||
nextCharacter,
|
nextCharacter,
|
||||||
wuxiaSchema,
|
wuxiaSchema,
|
||||||
@@ -150,7 +150,7 @@ describe('buildDamage', () => {
|
|||||||
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
|
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
|
||||||
const character = requireCharacter('sword-princess');
|
const character = requireCharacter('sword-princess');
|
||||||
const breakdown = getCompanionBuildDamageBreakdown(character);
|
const breakdown = getCompanionBuildDamageBreakdown(character);
|
||||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||||
|
|
||||||
expect(breakdown.rows.length).toBeGreaterThan(0);
|
expect(breakdown.rows.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
@@ -381,7 +381,7 @@ describe('buildDamage', () => {
|
|||||||
|
|
||||||
it('does not allow resource attributes to enter tag bonus rows', () => {
|
it('does not allow resource attributes to enter tag bonus rows', () => {
|
||||||
const character = requireCharacter('sword-princess');
|
const character = requireCharacter('sword-princess');
|
||||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||||
const mpBreakdown = getPlayerBuildDamageBreakdown(
|
const mpBreakdown = getPlayerBuildDamageBreakdown(
|
||||||
buildGameState({
|
buildGameState({
|
||||||
weapon: buildEquipmentItem({
|
weapon: buildEquipmentItem({
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
buildCustomWorldStarterInventoryItems,
|
buildCustomWorldStarterInventoryItems,
|
||||||
} from './customWorldCharacterLoadout';
|
} from './customWorldCharacterLoadout';
|
||||||
import { getRuntimeCustomWorldProfile, isCustomWorldType } from './customWorldRuntime';
|
import { getRuntimeCustomWorldProfile, isCustomWorldType } from './customWorldRuntime';
|
||||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||||
|
|
||||||
function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition {
|
function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition {
|
||||||
return skill;
|
return skill;
|
||||||
@@ -289,8 +289,8 @@ function hydrateCharacterRoleData(
|
|||||||
customRole?: CustomWorldRuntimeRole | null;
|
customRole?: CustomWorldRuntimeRole | null;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||||
const wuxiaProfile = buildCharacterAttributeProfile(character, wuxiaSchema);
|
const wuxiaProfile = buildCharacterAttributeProfile(character, wuxiaSchema);
|
||||||
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
|
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
|
||||||
const customProfile = options.customWorldProfile
|
const customProfile = options.customWorldProfile
|
||||||
@@ -520,7 +520,7 @@ export function getInventoryItems(character: Character, worldType: WorldType | n
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
|
const BASE_ROLE_TEMPLATE_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
|
||||||
{
|
{
|
||||||
id: 'sword-princess',
|
id: 'sword-princess',
|
||||||
name: '剑之公主',
|
name: '剑之公主',
|
||||||
@@ -563,7 +563,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.WUXIA]: opening({
|
[WorldType.WUXIA]: opening({
|
||||||
reason: '追查失落王庭誓剑流入江湖的踪迹',
|
reason: '追查失落王庭誓剑流入江湖的踪迹',
|
||||||
goal: '在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人',
|
goal: '在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人',
|
||||||
monologue: '你来到这个武侠世界,是为追查失落王庭誓剑流入江湖的踪迹。此行最重要的目标,是在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人。',
|
monologue: '你来到这片旧桥与边城交错的地界,是为追查失落王庭誓剑流入各方势力的踪迹。此行最重要的目标,是在野心家之前找回誓剑,并逼出宫变幕后之人。',
|
||||||
surfaceHook: '我追着一件不该流落在外的王庭旧物而来。',
|
surfaceHook: '我追着一件不该流落在外的王庭旧物而来。',
|
||||||
immediateConcern: '前面盯着这条线的人不止一拨,走错一步就会被人截住。',
|
immediateConcern: '前面盯着这条线的人不止一拨,走错一步就会被人截住。',
|
||||||
guardedMotive: '我来这里不是巡游散心,有件旧账必须先查清。',
|
guardedMotive: '我来这里不是巡游散心,有件旧账必须先查清。',
|
||||||
@@ -571,7 +571,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.XIANXIA]: opening({
|
[WorldType.XIANXIA]: opening({
|
||||||
reason: '王庭圣印坠入云海裂隙,你循着残光闯入了仙域',
|
reason: '王庭圣印坠入云海裂隙,你循着残光闯入了仙域',
|
||||||
goal: '寻回圣印,截断借它开启天门禁制的野心',
|
goal: '寻回圣印,截断借它开启天门禁制的野心',
|
||||||
monologue: '你来到这个仙侠世界,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
|
monologue: '你来到这片灵潮翻涌的高空异境,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
|
||||||
surfaceHook: '我循着一道王庭残光追到了这里。',
|
surfaceHook: '我循着一道王庭残光追到了这里。',
|
||||||
immediateConcern: '云海里的局势已经被人搅乱,圣印不会等你慢慢摸索。',
|
immediateConcern: '云海里的局势已经被人搅乱,圣印不会等你慢慢摸索。',
|
||||||
guardedMotive: '我来这里是为收回一件必须回到我手里的东西。',
|
guardedMotive: '我来这里是为收回一件必须回到我手里的东西。',
|
||||||
@@ -720,7 +720,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.WUXIA]: opening({
|
[WorldType.WUXIA]: opening({
|
||||||
reason: '追着一份指向边军叛徒的密图进入江湖',
|
reason: '追着一份指向边军叛徒的密图进入江湖',
|
||||||
goal: '找出贩卖军情的人,并截回被转移的军械账册',
|
goal: '找出贩卖军情的人,并截回被转移的军械账册',
|
||||||
monologue: '你来到这个武侠世界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
|
monologue: '你来到这片边城动荡未平的地界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
|
||||||
surfaceHook: '我追着一份旧军密图走到了这片江湖。',
|
surfaceHook: '我追着一份旧军密图走到了这片江湖。',
|
||||||
immediateConcern: '这条线上的人都擅长放假风声,前路不只一层埋伏。',
|
immediateConcern: '这条线上的人都擅长放假风声,前路不只一层埋伏。',
|
||||||
guardedMotive: '我在追一条和边军旧案有关的线,但还不到全说的时候。',
|
guardedMotive: '我在追一条和边军旧案有关的线,但还不到全说的时候。',
|
||||||
@@ -728,7 +728,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.XIANXIA]: opening({
|
[WorldType.XIANXIA]: opening({
|
||||||
reason: '星舟坠毁后,你顺着碎裂航迹漂进了仙域云海',
|
reason: '星舟坠毁后,你顺着碎裂航迹漂进了仙域云海',
|
||||||
goal: '找回星图核心,查清是谁击落了你的船队',
|
goal: '找回星图核心,查清是谁击落了你的船队',
|
||||||
monologue: '你来到这个仙侠世界,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
|
monologue: '你来到这片灵潮与云海交错的异境,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
|
||||||
surfaceHook: '我是顺着一段断掉的航迹飘进来的。',
|
surfaceHook: '我是顺着一段断掉的航迹飘进来的。',
|
||||||
immediateConcern: '云海里残留的痕迹还没散干净,说明对方离得不远。',
|
immediateConcern: '云海里残留的痕迹还没散干净,说明对方离得不远。',
|
||||||
guardedMotive: '我在追查一场坠毁后的尾线,暂时只确认到这里。',
|
guardedMotive: '我在追查一场坠毁后的尾线,暂时只确认到这里。',
|
||||||
@@ -939,7 +939,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.WUXIA]: opening({
|
[WorldType.WUXIA]: opening({
|
||||||
reason: '追着偷走密信的人潜入了这片雨夜江湖',
|
reason: '追着偷走密信的人潜入了这片雨夜江湖',
|
||||||
goal: '夺回密信,查清究竟是谁把你推上了被追杀的路',
|
goal: '夺回密信,查清究竟是谁把你推上了被追杀的路',
|
||||||
monologue: '你来到这个武侠世界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
|
monologue: '你来到这片雨夜与旧案交错的地界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
|
||||||
surfaceHook: '我追着一个偷走东西的人摸进了这里。',
|
surfaceHook: '我追着一个偷走东西的人摸进了这里。',
|
||||||
immediateConcern: '前面这条路像是专门给人设的套,闯快了只会替别人探雷。',
|
immediateConcern: '前面这条路像是专门给人设的套,闯快了只会替别人探雷。',
|
||||||
guardedMotive: '我来这儿是为了追一封信,也顺便追查是谁想让我闭嘴。',
|
guardedMotive: '我来这儿是为了追一封信,也顺便追查是谁想让我闭嘴。',
|
||||||
@@ -947,7 +947,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.XIANXIA]: opening({
|
[WorldType.XIANXIA]: opening({
|
||||||
reason: '密信指向一座只会在月湖现身的仙门残阵',
|
reason: '密信指向一座只会在月湖现身的仙门残阵',
|
||||||
goal: '找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁',
|
goal: '找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁',
|
||||||
monologue: '你来到这个仙侠世界,是因为那封密信把你引向了一座只会在月湖现身的仙门残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
|
monologue: '你来到这片月湖与残阵交错的异境,是因为那封密信把你引向了一座只会在月湖现身的残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
|
||||||
surfaceHook: '有封信把我一路引到了月湖这一带。',
|
surfaceHook: '有封信把我一路引到了月湖这一带。',
|
||||||
immediateConcern: '残阵现身的时间很短,再慢一点就只剩空壳。',
|
immediateConcern: '残阵现身的时间很短,再慢一点就只剩空壳。',
|
||||||
guardedMotive: '我来这里不只是找阵眼,还想弄明白一件跟我自己有关的怪事。',
|
guardedMotive: '我来这里不只是找阵眼,还想弄明白一件跟我自己有关的怪事。',
|
||||||
@@ -1034,7 +1034,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.WUXIA]: opening({
|
[WorldType.WUXIA]: opening({
|
||||||
reason: '循着毁掉拳馆的凶手线索来到了这片江湖',
|
reason: '循着毁掉拳馆的凶手线索来到了这片江湖',
|
||||||
goal: '找到凶手首领,让拳馆遗物和弟子名册不再被人践踏',
|
goal: '找到凶手首领,让拳馆遗物和弟子名册不再被人践踏',
|
||||||
monologue: '你来到这个武侠世界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
|
monologue: '你来到这片拳馆旧怨未平的地界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
|
||||||
surfaceHook: '我追着一帮砸了拳馆的人一路追到了这里。',
|
surfaceHook: '我追着一帮砸了拳馆的人一路追到了这里。',
|
||||||
immediateConcern: '前面那股气味不对,像是有人刚动过手脚。',
|
immediateConcern: '前面那股气味不对,像是有人刚动过手脚。',
|
||||||
guardedMotive: '我来这里是为了算账,也为了把该带回去的东西带回去。',
|
guardedMotive: '我来这里是为了算账,也为了把该带回去的东西带回去。',
|
||||||
@@ -1042,7 +1042,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.XIANXIA]: opening({
|
[WorldType.XIANXIA]: opening({
|
||||||
reason: '师门遗物在灵火裂隙里传来回应,你一路追进了熔境',
|
reason: '师门遗物在灵火裂隙里传来回应,你一路追进了熔境',
|
||||||
goal: '夺回遗物中的真传拳谱,阻止它被人炼成杀器',
|
goal: '夺回遗物中的真传拳谱,阻止它被人炼成杀器',
|
||||||
monologue: '你来到这个仙侠世界,是因为师门遗物在灵火裂隙里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
|
monologue: '你来到这片灵火裂隙仍在回响的异境,是因为师门遗物在那里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
|
||||||
surfaceHook: '我顺着师门遗物的回应一路追到了熔境。',
|
surfaceHook: '我顺着师门遗物的回应一路追到了熔境。',
|
||||||
immediateConcern: '灵火裂隙正往外吐东西,再拖下去只会更难收拾。',
|
immediateConcern: '灵火裂隙正往外吐东西,再拖下去只会更难收拾。',
|
||||||
guardedMotive: '我来这里是为了把师门留下的东西抢回来,别的以后再细说。',
|
guardedMotive: '我来这里是为了把师门留下的东西抢回来,别的以后再细说。',
|
||||||
@@ -1222,7 +1222,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.WUXIA]: opening({
|
[WorldType.WUXIA]: opening({
|
||||||
reason: '奉旧部最后一道军令,独自赶来守住山门防线',
|
reason: '奉旧部最后一道军令,独自赶来守住山门防线',
|
||||||
goal: '找回失散军旗,重新拼起已经溃散的同袍',
|
goal: '找回失散军旗,重新拼起已经溃散的同袍',
|
||||||
monologue: '你来到这个武侠世界,是奉着旧部最后一道军令赶来守住山门防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
|
monologue: '你来到这片山门与防线都在失守边缘的地界,是奉着旧部最后一道军令赶来守住防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
|
||||||
surfaceHook: '我奉着一条没法搁下的旧军令守在这里。',
|
surfaceHook: '我奉着一条没法搁下的旧军令守在这里。',
|
||||||
immediateConcern: '山门前的防线已经松了,再往前走的人很可能被卷进去。',
|
immediateConcern: '山门前的防线已经松了,再往前走的人很可能被卷进去。',
|
||||||
guardedMotive: '我来这里不是巡查,是在补一段还没补上的旧阵线。',
|
guardedMotive: '我来这里不是巡查,是在补一段还没补上的旧阵线。',
|
||||||
@@ -1230,7 +1230,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
|||||||
[WorldType.XIANXIA]: opening({
|
[WorldType.XIANXIA]: opening({
|
||||||
reason: '雷坛异动引发旧式甲胄共鸣,你被迫一路追进了仙域',
|
reason: '雷坛异动引发旧式甲胄共鸣,你被迫一路追进了仙域',
|
||||||
goal: '封住失控雷坛,避免整支旧军的甲魂被拿去驱使',
|
goal: '封住失控雷坛,避免整支旧军的甲魂被拿去驱使',
|
||||||
monologue: '你来到这个仙侠世界,是因为雷坛异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
|
monologue: '你来到这片雷坛异动不断放大的异境,是因为那场异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
|
||||||
surfaceHook: '我顺着旧甲的共鸣一路追到了雷坛附近。',
|
surfaceHook: '我顺着旧甲的共鸣一路追到了雷坛附近。',
|
||||||
immediateConcern: '这里的雷势还在往上走,再晚一点就不是一两个人能压住的事。',
|
immediateConcern: '这里的雷势还在往上走,再晚一点就不是一两个人能压住的事。',
|
||||||
guardedMotive: '我来这里是为了封住一处失控源头,也为了不让旧军的东西落到错的人手里。',
|
guardedMotive: '我来这里是为了封住一处失控源头,也为了不让旧军的东西落到错的人手里。',
|
||||||
@@ -1502,7 +1502,8 @@ function mergeCharacterPreset(baseCharacter: Character): Character {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PRESET_CHARACTERS: Character[] = BASE_PRESET_CHARACTERS.map(mergeCharacterPreset);
|
export const ROLE_TEMPLATE_CHARACTERS: Character[] =
|
||||||
|
BASE_ROLE_TEMPLATE_CHARACTERS.map(mergeCharacterPreset);
|
||||||
const runtimeCharacterOverrides = new Map<string, Character>();
|
const runtimeCharacterOverrides = new Map<string, Character>();
|
||||||
let runtimeCustomWorldCharacters: Character[] = [];
|
let runtimeCustomWorldCharacters: Character[] = [];
|
||||||
|
|
||||||
@@ -1668,15 +1669,15 @@ function pickCustomWorldRoleTemplateCharacter(
|
|||||||
fallbackIndex: number,
|
fallbackIndex: number,
|
||||||
profile?: CustomWorldProfile | null,
|
profile?: CustomWorldProfile | null,
|
||||||
) {
|
) {
|
||||||
const fallbackTemplateCharacter = PRESET_CHARACTERS[
|
const fallbackTemplateCharacter = ROLE_TEMPLATE_CHARACTERS[
|
||||||
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
|
fallbackIndex % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
|
||||||
] ?? PRESET_CHARACTERS[0];
|
] ?? ROLE_TEMPLATE_CHARACTERS[0];
|
||||||
if (!fallbackTemplateCharacter) {
|
if (!fallbackTemplateCharacter) {
|
||||||
throw new Error('Missing preset characters for custom world generation');
|
throw new Error('Missing preset characters for custom world generation');
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitTemplateCharacter = role.templateCharacterId
|
const explicitTemplateCharacter = role.templateCharacterId
|
||||||
? PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
|
? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
|
||||||
: null;
|
: null;
|
||||||
if (explicitTemplateCharacter) {
|
if (explicitTemplateCharacter) {
|
||||||
return explicitTemplateCharacter;
|
return explicitTemplateCharacter;
|
||||||
@@ -1696,7 +1697,7 @@ function pickCustomWorldRoleTemplateCharacter(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const referenceTemplateCharacter = referenceTemplateCharacterId
|
const referenceTemplateCharacter = referenceTemplateCharacterId
|
||||||
? PRESET_CHARACTERS.find(
|
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||||
(character) => character.id === referenceTemplateCharacterId,
|
(character) => character.id === referenceTemplateCharacterId,
|
||||||
) ?? null
|
) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -1704,7 +1705,7 @@ function pickCustomWorldRoleTemplateCharacter(
|
|||||||
return referenceTemplateCharacter;
|
return referenceTemplateCharacter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
|
const heuristicTemplateCharacter = ROLE_TEMPLATE_CHARACTERS.find(
|
||||||
character =>
|
character =>
|
||||||
character.id === resolveFallbackRecruitTemplateCharacterId([
|
character.id === resolveFallbackRecruitTemplateCharacterId([
|
||||||
role.role,
|
role.role,
|
||||||
@@ -1722,11 +1723,11 @@ function pickCustomWorldRoleTemplateCharacter(
|
|||||||
|
|
||||||
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
|
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return PRESET_CHARACTERS;
|
return ROLE_TEMPLATE_CHARACTERS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.playableNpcs.length === 0) {
|
if (profile.playableNpcs.length === 0) {
|
||||||
return PRESET_CHARACTERS;
|
return ROLE_TEMPLATE_CHARACTERS;
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile.playableNpcs.map((role, index) => {
|
return profile.playableNpcs.map((role, index) => {
|
||||||
@@ -1780,7 +1781,7 @@ export function setRuntimeCharacterOverrides(characters: Character[] | null) {
|
|||||||
|
|
||||||
export function getCharacterById(characterId: string) {
|
export function getCharacterById(characterId: string) {
|
||||||
return runtimeCharacterOverrides.get(characterId)
|
return runtimeCharacterOverrides.get(characterId)
|
||||||
?? PRESET_CHARACTERS.find(character => character.id === characterId)
|
?? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === characterId)
|
||||||
?? null;
|
?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -642,9 +642,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
const settingText = toText(value.settingText, toText(value.summary, name));
|
const settingText = toText(value.settingText, toText(value.summary, name));
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
|
||||||
const templateWorldType = value.templateWorldType === WorldType.XIANXIA
|
const compatibilityTemplateWorldType =
|
||||||
? WorldType.XIANXIA
|
value.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||||
: WorldType.WUXIA;
|
? WorldType.XIANXIA
|
||||||
|
: value.compatibilityTemplateWorldType === WorldType.WUXIA
|
||||||
|
? WorldType.WUXIA
|
||||||
|
: value.templateWorldType === WorldType.XIANXIA
|
||||||
|
? WorldType.XIANXIA
|
||||||
|
: WorldType.WUXIA;
|
||||||
|
const templateWorldType = compatibilityTemplateWorldType;
|
||||||
const subtitle = toText(value.subtitle);
|
const subtitle = toText(value.subtitle);
|
||||||
const summary = toText(value.summary);
|
const summary = toText(value.summary);
|
||||||
const tone = toText(value.tone);
|
const tone = toText(value.tone);
|
||||||
@@ -687,6 +693,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
tone,
|
tone,
|
||||||
playerGoal,
|
playerGoal,
|
||||||
templateWorldType,
|
templateWorldType,
|
||||||
|
compatibilityTemplateWorldType,
|
||||||
majorFactions: [],
|
majorFactions: [],
|
||||||
coreConflicts: [summary || playerGoal || settingText || name],
|
coreConflicts: [summary || playerGoal || settingText || name],
|
||||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type CustomWorldProfile,
|
type CustomWorldProfile,
|
||||||
WorldType,
|
WorldType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
|
||||||
import {
|
import {
|
||||||
getMonsterPresetsByWorld,
|
getMonsterPresetsByWorld,
|
||||||
type HostileNpcPreset,
|
type HostileNpcPreset,
|
||||||
@@ -181,7 +182,10 @@ function scoreMonsterPresetWithArchetype(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getCustomWorldMonsterPresetPool(
|
export function getCustomWorldMonsterPresetPool(
|
||||||
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
|
profile?: Pick<
|
||||||
|
CustomWorldProfile,
|
||||||
|
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||||
|
> | null,
|
||||||
) {
|
) {
|
||||||
const presets = getAllMonsterPresets();
|
const presets = getAllMonsterPresets();
|
||||||
const creatureArchetypes =
|
const creatureArchetypes =
|
||||||
@@ -191,7 +195,9 @@ export function getCustomWorldMonsterPresetPool(
|
|||||||
return presets;
|
return presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredWorldType = profile?.templateWorldType ?? null;
|
const preferredWorldType = profile
|
||||||
|
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
||||||
|
: null;
|
||||||
const scoredPresets = presets
|
const scoredPresets = presets
|
||||||
.map((preset) => {
|
.map((preset) => {
|
||||||
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
|
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
|
||||||
@@ -223,7 +229,10 @@ export function getCustomWorldMonsterPresetPool(
|
|||||||
export function resolveCustomWorldNpcMonsterPreset(
|
export function resolveCustomWorldNpcMonsterPreset(
|
||||||
npc: CustomWorldMonsterSource,
|
npc: CustomWorldMonsterSource,
|
||||||
worldType?: WorldType | null,
|
worldType?: WorldType | null,
|
||||||
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
|
profile?: Pick<
|
||||||
|
CustomWorldProfile,
|
||||||
|
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||||
|
> | null,
|
||||||
) {
|
) {
|
||||||
const sourceText = buildMonsterSourceText(npc);
|
const sourceText = buildMonsterSourceText(npc);
|
||||||
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
|
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
|
||||||
@@ -235,7 +244,9 @@ export function resolveCustomWorldNpcMonsterPreset(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
|
const preferredWorldType = profile
|
||||||
|
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
||||||
|
: worldType ?? null;
|
||||||
const referenceArchetype = resolveCreatureArchetypeForSource(
|
const referenceArchetype = resolveCreatureArchetypeForSource(
|
||||||
profile as CustomWorldProfile | null | undefined,
|
profile as CustomWorldProfile | null | undefined,
|
||||||
npc,
|
npc,
|
||||||
@@ -271,7 +282,10 @@ export function resolveCustomWorldNpcMonsterPreset(
|
|||||||
export function resolveCustomWorldNpcMonsterPresetId(
|
export function resolveCustomWorldNpcMonsterPresetId(
|
||||||
npc: CustomWorldMonsterSource,
|
npc: CustomWorldMonsterSource,
|
||||||
worldType?: WorldType | null,
|
worldType?: WorldType | null,
|
||||||
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
|
profile?: Pick<
|
||||||
|
CustomWorldProfile,
|
||||||
|
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||||
|
> | null,
|
||||||
) {
|
) {
|
||||||
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
|
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type CustomWorldThemeMode,
|
type CustomWorldThemeMode,
|
||||||
detectCustomWorldThemeMode,
|
detectCustomWorldThemeMode,
|
||||||
resolveCustomWorldAnchorWorldType,
|
resolveCustomWorldCompatibilityTemplateWorldType,
|
||||||
} from '../services/customWorldTheme';
|
} from '../services/customWorldTheme';
|
||||||
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
||||||
|
|
||||||
@@ -15,13 +15,15 @@ export function getRuntimeCustomWorldProfile() {
|
|||||||
return runtimeCustomWorldProfile;
|
return runtimeCustomWorldProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRuleWorldType(
|
export function resolveCompatibilityTemplateWorldType(
|
||||||
worldType: WorldType | null | undefined,
|
worldType: WorldType | null | undefined,
|
||||||
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
||||||
): WorldTemplateType | null {
|
): WorldTemplateType | null {
|
||||||
if (!worldType) return null;
|
if (!worldType) return null;
|
||||||
if (worldType === WorldType.CUSTOM) {
|
if (worldType === WorldType.CUSTOM) {
|
||||||
return customWorldProfile ? resolveCustomWorldAnchorWorldType(customWorldProfile) : WorldType.WUXIA;
|
return customWorldProfile
|
||||||
|
? resolveCustomWorldCompatibilityTemplateWorldType(customWorldProfile)
|
||||||
|
: WorldType.WUXIA;
|
||||||
}
|
}
|
||||||
return worldType;
|
return worldType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type WorldTemplateType,
|
type WorldTemplateType,
|
||||||
WorldType,
|
WorldType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
|
||||||
|
|
||||||
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
|
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
|
||||||
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
||||||
@@ -80,7 +81,7 @@ const SCENE_MATCH_STOP_CHARS = new Set([
|
|||||||
'桥',
|
'桥',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
const MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||||
{
|
{
|
||||||
name: '山门石阶',
|
name: '山门石阶',
|
||||||
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
|
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
|
||||||
@@ -131,7 +132,7 @@ const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
const ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||||
{
|
{
|
||||||
name: '云海仙门',
|
name: '云海仙门',
|
||||||
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
|
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
|
||||||
@@ -182,12 +183,12 @@ const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const WORLD_SCENE_IMAGE_REFERENCES: Record<
|
const COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES: Record<
|
||||||
WorldTemplateType,
|
WorldTemplateType,
|
||||||
readonly SceneImageReference[]
|
readonly SceneImageReference[]
|
||||||
> = {
|
> = {
|
||||||
[WorldType.WUXIA]: WUXIA_SCENE_IMAGE_REFERENCES,
|
[WorldType.WUXIA]: MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES,
|
||||||
[WorldType.XIANXIA]: XIANXIA_SCENE_IMAGE_REFERENCES,
|
[WorldType.XIANXIA]: ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CustomWorldSceneImageMatchOptions = {
|
type CustomWorldSceneImageMatchOptions = {
|
||||||
@@ -259,7 +260,7 @@ function uniqueStrings(values: Array<string | null | undefined>) {
|
|||||||
|
|
||||||
function buildSceneReferencePool(worldType: WorldTemplateType) {
|
function buildSceneReferencePool(worldType: WorldTemplateType) {
|
||||||
const pool = collectWorldSceneImagePool(worldType);
|
const pool = collectWorldSceneImagePool(worldType);
|
||||||
const references = WORLD_SCENE_IMAGE_REFERENCES[worldType] ?? [];
|
const references = COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
|
||||||
|
|
||||||
return references.map((reference, index) => ({
|
return references.map((reference, index) => ({
|
||||||
...reference,
|
...reference,
|
||||||
@@ -488,6 +489,7 @@ export function resolveCustomWorldLandmarkImage(
|
|||||||
| 'playerGoal'
|
| 'playerGoal'
|
||||||
| 'settingText'
|
| 'settingText'
|
||||||
| 'templateWorldType'
|
| 'templateWorldType'
|
||||||
|
| 'compatibilityTemplateWorldType'
|
||||||
| 'ownedSettingLayers'
|
| 'ownedSettingLayers'
|
||||||
>,
|
>,
|
||||||
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
|
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
|
||||||
@@ -502,7 +504,7 @@ export function resolveCustomWorldLandmarkImage(
|
|||||||
return getDefaultCustomWorldSceneImage(
|
return getDefaultCustomWorldSceneImage(
|
||||||
profile.id || profile.name,
|
profile.id || profile.name,
|
||||||
index,
|
index,
|
||||||
profile.templateWorldType,
|
resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||||
{
|
{
|
||||||
profile,
|
profile,
|
||||||
landmark,
|
landmark,
|
||||||
@@ -521,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
|
|||||||
| 'playerGoal'
|
| 'playerGoal'
|
||||||
| 'settingText'
|
| 'settingText'
|
||||||
| 'templateWorldType'
|
| 'templateWorldType'
|
||||||
|
| 'compatibilityTemplateWorldType'
|
||||||
| 'landmarks'
|
| 'landmarks'
|
||||||
| 'camp'
|
| 'camp'
|
||||||
| 'ownedSettingLayers'
|
| 'ownedSettingLayers'
|
||||||
@@ -559,6 +562,7 @@ export function resolveCustomWorldCampSceneImage(
|
|||||||
| 'playerGoal'
|
| 'playerGoal'
|
||||||
| 'settingText'
|
| 'settingText'
|
||||||
| 'templateWorldType'
|
| 'templateWorldType'
|
||||||
|
| 'compatibilityTemplateWorldType'
|
||||||
| 'landmarks'
|
| 'landmarks'
|
||||||
| 'camp'
|
| 'camp'
|
||||||
| 'ownedSettingLayers'
|
| 'ownedSettingLayers'
|
||||||
@@ -575,7 +579,7 @@ export function resolveCustomWorldCampSceneImage(
|
|||||||
return getDefaultCustomWorldSceneImage(
|
return getDefaultCustomWorldSceneImage(
|
||||||
profile.id || profile.name,
|
profile.id || profile.name,
|
||||||
-1,
|
-1,
|
||||||
profile.templateWorldType,
|
resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||||
{
|
{
|
||||||
profile,
|
profile,
|
||||||
landmark: {
|
landmark: {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, Runti
|
|||||||
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
|
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
|
||||||
import { buildDefaultAxisVector } from './attributeResolver';
|
import { buildDefaultAxisVector } from './attributeResolver';
|
||||||
import {normalizeBuildTags} from './buildTags';
|
import {normalizeBuildTags} from './buildTags';
|
||||||
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
|
import {
|
||||||
|
buildRuntimeCustomWorldInventoryItems,
|
||||||
|
resolveCompatibilityTemplateWorldType,
|
||||||
|
} from './customWorldRuntime';
|
||||||
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
|
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
|
||||||
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
|
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
|
||||||
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
|
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
|
||||||
@@ -13,7 +16,7 @@ import {
|
|||||||
buildRuntimeItemAiIntent,
|
buildRuntimeItemAiIntent,
|
||||||
flattenDirectedRuntimeRewardItems,
|
flattenDirectedRuntimeRewardItems,
|
||||||
} from './runtimeItemNarrative';
|
} from './runtimeItemNarrative';
|
||||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||||
|
|
||||||
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
|
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
|
||||||
worldType: WorldType;
|
worldType: WorldType;
|
||||||
@@ -930,7 +933,7 @@ function buildHostileNpcBehaviorVectors(preset: {
|
|||||||
function hydrateHostileNpcPresetRoleData(
|
function hydrateHostileNpcPresetRoleData(
|
||||||
preset: Omit<HostileNpcPreset, 'attributeProfile' | 'behaviorVectors'>,
|
preset: Omit<HostileNpcPreset, 'attributeProfile' | 'behaviorVectors'>,
|
||||||
): HostileNpcPreset {
|
): HostileNpcPreset {
|
||||||
const schema = getPresetWorldAttributeSchema(
|
const schema = getTemplateWorldAttributeSchema(
|
||||||
preset.worldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA,
|
preset.worldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -955,7 +958,8 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
|
|||||||
if (worldType === WorldType.CUSTOM) {
|
if (worldType === WorldType.CUSTOM) {
|
||||||
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
|
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
|
||||||
}
|
}
|
||||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
const resolvedWorldType =
|
||||||
|
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||||
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
|
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +969,8 @@ export function getHostileNpcPresetsByWorld(worldType: WorldType) {
|
|||||||
if (worldType === WorldType.CUSTOM) {
|
if (worldType === WorldType.CUSTOM) {
|
||||||
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
|
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
|
||||||
}
|
}
|
||||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
const resolvedWorldType =
|
||||||
|
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||||
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
|
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
WorldType,
|
WorldType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { resolveRoleCombatStats } from './attributeCombat';
|
import { resolveRoleCombatStats } from './attributeCombat';
|
||||||
import { resolveRuleWorldType } from './customWorldRuntime';
|
import { resolveCompatibilityTemplateWorldType } from './customWorldRuntime';
|
||||||
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
|
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
|
||||||
|
|
||||||
export const METERS_TO_PIXELS = 48;
|
export const METERS_TO_PIXELS = 48;
|
||||||
@@ -78,7 +78,8 @@ function getHostileNpcFormationSlots(
|
|||||||
worldType: WorldType,
|
worldType: WorldType,
|
||||||
monsterCount: number,
|
monsterCount: number,
|
||||||
): HostileNpcFormationSlot[] {
|
): HostileNpcFormationSlot[] {
|
||||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
const resolvedWorldType =
|
||||||
|
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||||
const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType];
|
const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType];
|
||||||
const centerSlot = { xMeters: frontX, yOffset: 0 };
|
const centerSlot = { xMeters: frontX, yOffset: 0 };
|
||||||
const lowerBackSlot = {
|
const lowerBackSlot = {
|
||||||
|
|||||||
@@ -471,14 +471,14 @@ function buildLegacyDesign(
|
|||||||
category,
|
category,
|
||||||
rarity,
|
rarity,
|
||||||
tags: dedupe(tags),
|
tags: dedupe(tags),
|
||||||
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两个世界中以不同风格登场,适合作为${category}基础模板继续扩展。`,
|
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两套兼容模板中以不同风格登场,适合作为${category}基础模板继续扩展。`,
|
||||||
worldAffinity: "neutral",
|
worldAffinity: "neutral",
|
||||||
equipmentSlotId: slot,
|
equipmentSlotId: slot,
|
||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
wuxiaName,
|
wuxiaName,
|
||||||
xianxiaName,
|
xianxiaName,
|
||||||
`${wuxiaName},适用于武侠世界的基础${category}条目。`,
|
`${wuxiaName},适用于边城模板的基础${category}条目。`,
|
||||||
`${xianxiaName},适用于仙侠世界的基础${category}条目。`,
|
`${xianxiaName},适用于灵潮模板的基础${category}条目。`,
|
||||||
),
|
),
|
||||||
statProfile,
|
statProfile,
|
||||||
useProfile,
|
useProfile,
|
||||||
@@ -544,8 +544,8 @@ function buildArmoryDesign(
|
|||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
wuxiaName,
|
wuxiaName,
|
||||||
xianxiaName,
|
xianxiaName,
|
||||||
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}的武侠 build。`,
|
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}的边城模板 build。`,
|
||||||
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑仙侠战法。`,
|
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑灵潮模板战法。`,
|
||||||
),
|
),
|
||||||
statProfile,
|
statProfile,
|
||||||
useProfile: null,
|
useProfile: null,
|
||||||
@@ -633,8 +633,8 @@ function buildPotionDesign(
|
|||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
"药瓶",
|
"药瓶",
|
||||||
"灵瓶",
|
"灵瓶",
|
||||||
"武侠世界常见的炼药容器。",
|
"边城模板里常见的炼药容器。",
|
||||||
"仙侠世界常用的盛装灵液器皿。",
|
"灵潮模板里常用的盛装灵液器皿。",
|
||||||
),
|
),
|
||||||
statProfile: null,
|
statProfile: null,
|
||||||
useProfile: null,
|
useProfile: null,
|
||||||
@@ -708,8 +708,8 @@ function buildPotionDesign(
|
|||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
wuxiaName,
|
wuxiaName,
|
||||||
xianxiaName,
|
xianxiaName,
|
||||||
`${wuxiaName}常见于江湖行囊,用于快速续战或调息。`,
|
`${wuxiaName}常见于边城模板的远行行囊,用于快速续战或调息。`,
|
||||||
`${xianxiaName}多用于洞府与试炼前后,负责补元、聚灵与压缩冷却。`,
|
`${xianxiaName}多用于灵潮模板的据点与试炼前后,负责补元、聚灵与压缩冷却。`,
|
||||||
),
|
),
|
||||||
statProfile: null,
|
statProfile: null,
|
||||||
useProfile,
|
useProfile,
|
||||||
@@ -757,8 +757,8 @@ function buildGemDesign(
|
|||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
wuxiaName,
|
wuxiaName,
|
||||||
xianxiaName,
|
xianxiaName,
|
||||||
`${wuxiaName}偏向江湖匠造、镶嵌与兵刃锻造。`,
|
`${wuxiaName}偏向边城模板里的匠造、镶嵌与兵刃锻造。`,
|
||||||
`${xianxiaName}更适合灵器镶嵌与灵力 build 的核心堆叠。`,
|
`${xianxiaName}更适合灵潮模板里的灵器镶嵌与灵力 build 核心堆叠。`,
|
||||||
),
|
),
|
||||||
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
||||||
useProfile: null,
|
useProfile: null,
|
||||||
@@ -830,7 +830,7 @@ function buildSkillRelicDesign(
|
|||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
wuxiaName,
|
wuxiaName,
|
||||||
xianxiaName,
|
xianxiaName,
|
||||||
`${wuxiaName}适合在武侠世界里解释为武学秘卷、战术符印或绝招凭证。`,
|
`${wuxiaName}适合在边城模板里解释为武学秘卷、战术符印或绝招凭证。`,
|
||||||
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
|
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
|
||||||
),
|
),
|
||||||
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
||||||
@@ -916,8 +916,8 @@ function buildUtilityDesign(
|
|||||||
worldProfiles: buildWorldProfiles(
|
worldProfiles: buildWorldProfiles(
|
||||||
wuxiaName || readable,
|
wuxiaName || readable,
|
||||||
xianxiaName || readable,
|
xianxiaName || readable,
|
||||||
`${wuxiaName || readable}更适合武侠世界的江湖使用语境。`,
|
`${wuxiaName || readable}更适合边城模板的在地使用语境。`,
|
||||||
`${xianxiaName || readable}更适合仙侠世界的灵物/法器语境。`,
|
`${xianxiaName || readable}更适合灵潮模板的灵物/法器语境。`,
|
||||||
),
|
),
|
||||||
statProfile,
|
statProfile,
|
||||||
useProfile,
|
useProfile,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const STRUCTURAL_TAG_LABELS: Record<string, string> = {
|
|||||||
healing: '疗伤',
|
healing: '疗伤',
|
||||||
mana: '法力',
|
mana: '法力',
|
||||||
rare: '稀有',
|
rare: '稀有',
|
||||||
wuxia: '武侠',
|
wuxia: '边城模板',
|
||||||
xianxia: '仙侠',
|
xianxia: '灵潮模板',
|
||||||
neutral: '中性',
|
neutral: '中性',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function resolvePublicAssetPath(assetPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('scene background assets', () => {
|
describe('scene background assets', () => {
|
||||||
it('ships background files for every wuxia and xianxia scene preset', () => {
|
it('ships background files for every compatibility template scene preset', () => {
|
||||||
const scenes = [
|
const scenes = [
|
||||||
...getScenePresetsByWorld(WorldType.WUXIA),
|
...getScenePresetsByWorld(WorldType.WUXIA),
|
||||||
...getScenePresetsByWorld(WorldType.XIANXIA),
|
...getScenePresetsByWorld(WorldType.XIANXIA),
|
||||||
@@ -27,7 +27,7 @@ describe('scene background assets', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns existing default custom world backgrounds for both anchor worlds', () => {
|
it('returns existing default custom world backgrounds for both compatibility templates', () => {
|
||||||
const wuxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.WUXIA);
|
const wuxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.WUXIA);
|
||||||
const xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
|
const xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ describe('scene background assets', () => {
|
|||||||
'/generated-custom-world-scenes/test-world/generated-ruins.png';
|
'/generated-custom-world-scenes/test-world/generated-ruins.png';
|
||||||
const profile: CustomWorldProfile = {
|
const profile: CustomWorldProfile = {
|
||||||
id: 'custom-world-test',
|
id: 'custom-world-test',
|
||||||
settingText: '荒城断碑与边关旧营并存的武侠世界',
|
settingText: '荒城断碑与边关旧营并存的边城地界',
|
||||||
name: '断碑边城',
|
name: '断碑边城',
|
||||||
subtitle: '烽烟未熄',
|
subtitle: '烽烟未熄',
|
||||||
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',
|
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import {
|
|||||||
buildCustomWorldPlayableCharacters,
|
buildCustomWorldPlayableCharacters,
|
||||||
getCharacterHomeSceneId,
|
getCharacterHomeSceneId,
|
||||||
getCharacterNpcSceneIds,
|
getCharacterNpcSceneIds,
|
||||||
PRESET_CHARACTERS,
|
ROLE_TEMPLATE_CHARACTERS,
|
||||||
} from './characterPresets';
|
} from './characterPresets';
|
||||||
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
|
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
|
||||||
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
|
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
|
||||||
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
|
import {
|
||||||
|
getRuntimeCustomWorldProfile,
|
||||||
|
resolveCompatibilityTemplateWorldType,
|
||||||
|
} from './customWorldRuntime';
|
||||||
import {
|
import {
|
||||||
resolveCustomWorldCampSceneImage,
|
resolveCustomWorldCampSceneImage,
|
||||||
resolveCustomWorldLandmarkImageMap,
|
resolveCustomWorldLandmarkImageMap,
|
||||||
@@ -111,7 +114,8 @@ function buildImagePath(packName: string, imageNumber: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
|
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
|
||||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
const resolvedWorldType =
|
||||||
|
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||||
const refs: string[] = [];
|
const refs: string[] = [];
|
||||||
let globalIndex = 0;
|
let globalIndex = 0;
|
||||||
|
|
||||||
@@ -571,7 +575,7 @@ function resolveSceneNpcGender(
|
|||||||
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
|
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
|
||||||
const npcs: SceneNpc[] = [];
|
const npcs: SceneNpc[] = [];
|
||||||
|
|
||||||
for (const character of PRESET_CHARACTERS) {
|
for (const character of ROLE_TEMPLATE_CHARACTERS) {
|
||||||
const characterId = character.id;
|
const characterId = character.id;
|
||||||
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
|
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
|
||||||
if (sceneIds.includes(sceneId)) {
|
if (sceneIds.includes(sceneId)) {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSetti
|
|||||||
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
|
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
|
||||||
import {WorldType} from '../types';
|
import {WorldType} from '../types';
|
||||||
|
|
||||||
export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType.CUSTOM>, WorldAttributeSchema> = {
|
export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
|
||||||
|
Exclude<WorldType, WorldType.CUSTOM>,
|
||||||
|
WorldAttributeSchema
|
||||||
|
> = {
|
||||||
[WorldType.WUXIA]: {
|
[WorldType.WUXIA]: {
|
||||||
id: 'schema:wuxia:v1',
|
id: 'schema:wuxia:v1',
|
||||||
worldId: WorldType.WUXIA,
|
worldId: WorldType.WUXIA,
|
||||||
@@ -155,8 +158,10 @@ export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPresetWorldAttributeSchema(worldType: Exclude<WorldType, WorldType.CUSTOM>) {
|
export function getTemplateWorldAttributeSchema(
|
||||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[worldType];
|
worldType: Exclude<WorldType, WorldType.CUSTOM>,
|
||||||
|
) {
|
||||||
|
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[worldType];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorldAttributeSchema(
|
export function getWorldAttributeSchema(
|
||||||
@@ -171,8 +176,8 @@ export function getWorldAttributeSchema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (worldType === WorldType.XIANXIA) {
|
if (worldType === WorldType.XIANXIA) {
|
||||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
|
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
|
||||||
}
|
}
|
||||||
|
|
||||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
|
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,15 @@ describe('matchAppRoute', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes item editor paths to the preset editor items tab', () => {
|
it('routes deprecated editor paths back to the main game', () => {
|
||||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||||
kind: 'preset-editor',
|
kind: 'game',
|
||||||
initialTab: 'items',
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('routes behavior editor paths to the functions tab', () => {
|
|
||||||
expect(matchAppRoute('/behavior-editor')).toEqual({
|
expect(matchAppRoute('/behavior-editor')).toEqual({
|
||||||
kind: 'preset-editor',
|
kind: 'game',
|
||||||
initialTab: 'functions',
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts nested preset editor paths with trailing slashes', () => {
|
|
||||||
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
|
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
|
||||||
kind: 'preset-editor',
|
kind: 'game',
|
||||||
initialTab: 'npcs',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,4 +26,10 @@ describe('matchAppRoute', () => {
|
|||||||
kind: 'game',
|
kind: 'game',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the sprite tool route', () => {
|
||||||
|
expect(matchAppRoute('/sprite-tool')).toEqual({
|
||||||
|
kind: 'qwen-sprite-tool',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
|
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
|
||||||
|
|
||||||
import type { PresetEditorTab } from '../components/PresetEditor';
|
|
||||||
|
|
||||||
type AppRouteComponent = LazyExoticComponent<
|
type AppRouteComponent = LazyExoticComponent<
|
||||||
ComponentType<Record<string, unknown>>
|
ComponentType<Record<string, unknown>>
|
||||||
>;
|
>;
|
||||||
@@ -12,10 +10,6 @@ export type AppRouteMatch =
|
|||||||
| {
|
| {
|
||||||
kind: 'game';
|
kind: 'game';
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
kind: 'preset-editor';
|
|
||||||
initialTab: PresetEditorTab;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
kind: 'qwen-sprite-tool';
|
kind: 'qwen-sprite-tool';
|
||||||
};
|
};
|
||||||
@@ -29,43 +23,10 @@ export type ResolvedAppRoute = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||||
const PresetEditorApp = lazy(async () => {
|
|
||||||
const module = await import('../components/PresetEditor');
|
|
||||||
|
|
||||||
return {
|
|
||||||
default: module.PresetEditor,
|
|
||||||
};
|
|
||||||
}) as AppRouteComponent;
|
|
||||||
const QwenSpriteToolApp = lazy(
|
const QwenSpriteToolApp = lazy(
|
||||||
() => import('../tools/QwenSpriteSheetTool'),
|
() => import('../tools/QwenSpriteSheetTool'),
|
||||||
) as AppRouteComponent;
|
) as AppRouteComponent;
|
||||||
|
|
||||||
const PRESET_EDITOR_ROUTES: Array<{
|
|
||||||
prefixes: string[];
|
|
||||||
initialTab: PresetEditorTab;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
prefixes: ['/character-asset-studio', '/asset-studio'],
|
|
||||||
initialTab: 'assets',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prefixes: ['/function-editor', '/behavior-editor'],
|
|
||||||
initialTab: 'functions',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prefixes: ['/item-editor'],
|
|
||||||
initialTab: 'items',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prefixes: ['/npc-editor'],
|
|
||||||
initialTab: 'npcs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prefixes: ['/preset-editor'],
|
|
||||||
initialTab: 'characters',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const QWEN_SPRITE_TOOL_PREFIXES = [
|
const QWEN_SPRITE_TOOL_PREFIXES = [
|
||||||
'/qwen-sprite-tool',
|
'/qwen-sprite-tool',
|
||||||
'/sprite-tool',
|
'/sprite-tool',
|
||||||
@@ -102,19 +63,6 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const presetRoute = PRESET_EDITOR_ROUTES.find((route) =>
|
|
||||||
route.prefixes.some((prefix) =>
|
|
||||||
matchesRoutePrefix(normalizedPathname, prefix),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (presetRoute) {
|
|
||||||
return {
|
|
||||||
kind: 'preset-editor',
|
|
||||||
initialTab: presetRoute.initialTab,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
};
|
};
|
||||||
@@ -132,18 +80,6 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedRoute.kind === 'preset-editor') {
|
|
||||||
return {
|
|
||||||
kind: matchedRoute.kind,
|
|
||||||
loadingEyebrow: '正在载入编辑器',
|
|
||||||
loadingText: '正在载入编辑器...',
|
|
||||||
Component: PresetEditorApp,
|
|
||||||
componentProps: {
|
|
||||||
initialTab: matchedRoute.initialTab,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
loadingEyebrow: '正在载入游戏',
|
loadingEyebrow: '正在载入游戏',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {validateWorldAttributeSchema} from '../data/attributeValidation';
|
import {validateWorldAttributeSchema} from '../data/attributeValidation';
|
||||||
import {getPresetWorldAttributeSchema} from '../data/worldAttributeSchemas';
|
import {getTemplateWorldAttributeSchema} from '../data/worldAttributeSchemas';
|
||||||
import type {
|
import type {
|
||||||
AttributeSchemaGenerationInput,
|
AttributeSchemaGenerationInput,
|
||||||
WorldAttributeSchema,
|
WorldAttributeSchema,
|
||||||
@@ -96,17 +96,17 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
schemaName: '叙境六维',
|
schemaName: '叙境六维',
|
||||||
slots: getPresetWorldAttributeSchema(WorldType.WUXIA).slots,
|
slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInput) {
|
export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInput) {
|
||||||
if (input.worldType === WorldType.WUXIA) {
|
if (input.worldType === WorldType.WUXIA) {
|
||||||
return getPresetWorldAttributeSchema(WorldType.WUXIA);
|
return getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.worldType === WorldType.XIANXIA) {
|
if (input.worldType === WorldType.XIANXIA) {
|
||||||
return getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
return getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||||
}
|
}
|
||||||
|
|
||||||
const generated = buildCustomThemeSlots(input);
|
const generated = buildCustomThemeSlots(input);
|
||||||
@@ -116,7 +116,7 @@ export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInp
|
|||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
const fallbackWorldType = /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA;
|
const fallbackWorldType = /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||||
return {
|
return {
|
||||||
...getPresetWorldAttributeSchema(fallbackWorldType),
|
...getTemplateWorldAttributeSchema(fallbackWorldType),
|
||||||
id: `schema:custom-fallback:${input.worldName}`,
|
id: `schema:custom-fallback:${input.worldName}`,
|
||||||
worldId: `custom:${input.worldName}`,
|
worldId: `custom:${input.worldName}`,
|
||||||
generatedFrom: {
|
generatedFrom: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
createAutoAuthCredentials,
|
createAutoAuthCredentials,
|
||||||
ensureAutoAuthUser,
|
ensureAutoAuthUser,
|
||||||
getAuthAuditLogs,
|
getAuthAuditLogs,
|
||||||
|
getAuthLoginOptions,
|
||||||
getAuthRiskBlocks,
|
getAuthRiskBlocks,
|
||||||
getAuthSessions,
|
getAuthSessions,
|
||||||
getCaptchaChallengeFromError,
|
getCaptchaChallengeFromError,
|
||||||
@@ -339,6 +340,23 @@ describe('authService auto auth', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||||
|
requestJsonMock.mockResolvedValue({
|
||||||
|
availableLoginMethods: ['phone', 'wechat'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getAuthLoginOptions();
|
||||||
|
|
||||||
|
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||||
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/login-options',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
'读取登录方式失败',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('consumes auth callback hash and stores token', () => {
|
it('consumes auth callback hash and stores token', () => {
|
||||||
const replaceStateMock = vi.fn();
|
const replaceStateMock = vi.fn();
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import type {
|
|||||||
AuthAuditLogsResponse,
|
AuthAuditLogsResponse,
|
||||||
AuthCaptchaChallenge,
|
AuthCaptchaChallenge,
|
||||||
AuthEntryResponse,
|
AuthEntryResponse,
|
||||||
AuthLiftRiskBlockResponse,
|
|
||||||
AuthLoginMethod,
|
AuthLoginMethod,
|
||||||
|
AuthLoginOptionsResponse,
|
||||||
|
AuthLiftRiskBlockResponse,
|
||||||
AuthLogoutAllResponse,
|
AuthLogoutAllResponse,
|
||||||
AuthMeResponse,
|
AuthMeResponse,
|
||||||
AuthPhoneChangeResponse,
|
AuthPhoneChangeResponse,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from './apiClient';
|
} from './apiClient';
|
||||||
|
|
||||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||||
|
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||||
|
|
||||||
export type AutoAuthCredentials = {
|
export type AutoAuthCredentials = {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -207,6 +209,16 @@ export async function startWechatLogin() {
|
|||||||
window.location.assign(response.authorizationUrl);
|
window.location.assign(response.authorizationUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAuthLoginOptions() {
|
||||||
|
return requestJson<AuthLoginOptionsResponse>(
|
||||||
|
'/api/auth/login-options',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
'读取登录方式失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function authEntry(username: string, password: string) {
|
export async function authEntry(username: string, password: string) {
|
||||||
const credentials = normalizeCredentials({ username, password });
|
const credentials = normalizeCredentials({ username, password });
|
||||||
const response = await requestJson<AuthEntryResponse>(
|
const response = await requestJson<AuthEntryResponse>(
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名
|
|||||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||||
|
|
||||||
function describeWorld(world: WorldType) {
|
function describeWorld(world: WorldType) {
|
||||||
if (world === WorldType.WUXIA) return '武侠';
|
if (world === WorldType.WUXIA) return '边城模板';
|
||||||
if (world === WorldType.XIANXIA) return '仙侠';
|
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||||
return '自定义世界';
|
return '自定义世界';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export interface CustomWorldGenerationFramework {
|
|||||||
tone: string;
|
tone: string;
|
||||||
playerGoal: string;
|
playerGoal: string;
|
||||||
templateWorldType: WorldType;
|
templateWorldType: WorldType;
|
||||||
|
compatibilityTemplateWorldType: WorldType;
|
||||||
majorFactions: string[];
|
majorFactions: string[];
|
||||||
coreConflicts: string[];
|
coreConflicts: string[];
|
||||||
camp: CustomWorldGenerationCampOutline;
|
camp: CustomWorldGenerationCampOutline;
|
||||||
@@ -619,6 +620,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
|||||||
tone,
|
tone,
|
||||||
playerGoal,
|
playerGoal,
|
||||||
templateWorldType,
|
templateWorldType,
|
||||||
|
compatibilityTemplateWorldType: templateWorldType,
|
||||||
majorFactions: [],
|
majorFactions: [],
|
||||||
coreConflicts: [summary],
|
coreConflicts: [summary],
|
||||||
attributeSchema: generateWorldAttributeSchema({
|
attributeSchema: generateWorldAttributeSchema({
|
||||||
@@ -674,6 +676,8 @@ export function normalizeCustomWorldGenerationFramework(
|
|||||||
tone: fallback.tone,
|
tone: fallback.tone,
|
||||||
playerGoal: fallback.playerGoal,
|
playerGoal: fallback.playerGoal,
|
||||||
templateWorldType: fallback.templateWorldType,
|
templateWorldType: fallback.templateWorldType,
|
||||||
|
compatibilityTemplateWorldType:
|
||||||
|
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
|
||||||
majorFactions: [],
|
majorFactions: [],
|
||||||
coreConflicts: [fallback.summary],
|
coreConflicts: [fallback.summary],
|
||||||
camp: {
|
camp: {
|
||||||
@@ -710,6 +714,7 @@ export function normalizeCustomWorldGenerationFramework(
|
|||||||
tone: toText(item.tone) || fallback.tone,
|
tone: toText(item.tone) || fallback.tone,
|
||||||
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
|
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
|
||||||
templateWorldType,
|
templateWorldType,
|
||||||
|
compatibilityTemplateWorldType: templateWorldType,
|
||||||
majorFactions: normalizeTags(item.majorFactions, []),
|
majorFactions: normalizeTags(item.majorFactions, []),
|
||||||
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
|
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
|
||||||
camp: normalizeCampOutline(item.camp, {
|
camp: normalizeCampOutline(item.camp, {
|
||||||
@@ -744,6 +749,7 @@ export function buildCustomWorldRawProfileFromFramework(
|
|||||||
tone: framework.tone,
|
tone: framework.tone,
|
||||||
playerGoal: framework.playerGoal,
|
playerGoal: framework.playerGoal,
|
||||||
templateWorldType: framework.templateWorldType,
|
templateWorldType: framework.templateWorldType,
|
||||||
|
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
|
||||||
majorFactions: framework.majorFactions,
|
majorFactions: framework.majorFactions,
|
||||||
coreConflicts: framework.coreConflicts,
|
coreConflicts: framework.coreConflicts,
|
||||||
camp: {
|
camp: {
|
||||||
@@ -1136,6 +1142,7 @@ export function normalizeCustomWorldProfile(
|
|||||||
tone,
|
tone,
|
||||||
playerGoal,
|
playerGoal,
|
||||||
templateWorldType,
|
templateWorldType,
|
||||||
|
compatibilityTemplateWorldType: templateWorldType,
|
||||||
majorFactions: normalizeTags(item.majorFactions, []),
|
majorFactions: normalizeTags(item.majorFactions, []),
|
||||||
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
||||||
attributeSchema: coerceWorldAttributeSchema(
|
attributeSchema: coerceWorldAttributeSchema(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
|||||||
import { CustomWorldProfile, WorldType } from '../types';
|
import { CustomWorldProfile, WorldType } from '../types';
|
||||||
import { normalizeCustomWorldProfile } from './customWorld';
|
import { normalizeCustomWorldProfile } from './customWorld';
|
||||||
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
|
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
|
||||||
|
import { resolveCustomWorldCompatibilityTemplateWorldType } from './customWorldTheme';
|
||||||
import {
|
import {
|
||||||
buildFallbackActorNarrativeProfile,
|
buildFallbackActorNarrativeProfile,
|
||||||
normalizeActorNarrativeProfile,
|
normalizeActorNarrativeProfile,
|
||||||
@@ -161,7 +162,9 @@ export function buildExpandedCustomWorldProfile(
|
|||||||
description: clampText(landmark.description, 96),
|
description: clampText(landmark.description, 96),
|
||||||
dangerLevel:
|
dangerLevel:
|
||||||
landmark.dangerLevel ||
|
landmark.dangerLevel ||
|
||||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
(resolveCustomWorldCompatibilityTemplateWorldType(profile) === WorldType.XIANXIA
|
||||||
|
? 'high'
|
||||||
|
: 'medium'),
|
||||||
}));
|
}));
|
||||||
const landmarkIdByReference = new Map<string, string>();
|
const landmarkIdByReference = new Map<string, string>();
|
||||||
landmarkDrafts.forEach((landmark) => {
|
landmarkDrafts.forEach((landmark) => {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
type SceneArchetypeBucket,
|
type SceneArchetypeBucket,
|
||||||
WorldType,
|
WorldType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { type CustomWorldThemeMode, detectCustomWorldThemeMode } from './customWorldTheme';
|
import {
|
||||||
|
type CustomWorldThemeMode,
|
||||||
|
detectCustomWorldThemeMode,
|
||||||
|
resolveCustomWorldCompatibilityTemplateWorldType,
|
||||||
|
} from './customWorldTheme';
|
||||||
import {
|
import {
|
||||||
buildThemePackFromWorldProfile,
|
buildThemePackFromWorldProfile,
|
||||||
normalizeThemePack,
|
normalizeThemePack,
|
||||||
@@ -407,7 +411,7 @@ function buildThemePackSeed(profile: CustomWorldProfile) {
|
|||||||
summary: profile.summary,
|
summary: profile.summary,
|
||||||
tone: profile.tone,
|
tone: profile.tone,
|
||||||
playerGoal: profile.playerGoal,
|
playerGoal: profile.playerGoal,
|
||||||
templateWorldType: profile.templateWorldType,
|
templateWorldType: resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||||
majorFactions: profile.majorFactions,
|
majorFactions: profile.majorFactions,
|
||||||
coreConflicts: profile.coreConflicts,
|
coreConflicts: profile.coreConflicts,
|
||||||
ownedSettingLayers: null,
|
ownedSettingLayers: null,
|
||||||
@@ -502,8 +506,12 @@ function compileReferenceProfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compileCompatibilityProfile(profile: CustomWorldProfile) {
|
function compileCompatibilityProfile(profile: CustomWorldProfile) {
|
||||||
|
const compatibilityTemplateWorldType =
|
||||||
|
resolveCustomWorldCompatibilityTemplateWorldType(profile);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
legacyTemplateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
compatibilityTemplateWorldType,
|
||||||
|
legacyTemplateWorldType: compatibilityTemplateWorldType,
|
||||||
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||||||
} satisfies CustomWorldCompatibilityProfile;
|
} satisfies CustomWorldCompatibilityProfile;
|
||||||
}
|
}
|
||||||
@@ -629,6 +637,8 @@ export function compileOwnedSettingLayersFromLegacyTemplate(
|
|||||||
tone: profile.tone,
|
tone: profile.tone,
|
||||||
playerGoal: profile.playerGoal,
|
playerGoal: profile.playerGoal,
|
||||||
templateWorldType: profile.templateWorldType,
|
templateWorldType: profile.templateWorldType,
|
||||||
|
compatibilityTemplateWorldType:
|
||||||
|
profile.compatibilityTemplateWorldType ?? profile.templateWorldType,
|
||||||
ownedSettingLayers: null,
|
ownedSettingLayers: null,
|
||||||
});
|
});
|
||||||
const semanticAnchor = compileSemanticAnchor(profile, mode);
|
const semanticAnchor = compileSemanticAnchor(profile, mode);
|
||||||
@@ -920,6 +930,12 @@ export function normalizeCustomWorldOwnedSettingLayers(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
compatibilityProfile: {
|
compatibilityProfile: {
|
||||||
|
compatibilityTemplateWorldType:
|
||||||
|
compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||||
|
? WorldType.XIANXIA
|
||||||
|
: compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.WUXIA
|
||||||
|
? WorldType.WUXIA
|
||||||
|
: fallback.compatibilityProfile?.compatibilityTemplateWorldType ?? null,
|
||||||
legacyTemplateWorldType:
|
legacyTemplateWorldType:
|
||||||
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
|
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
|
||||||
? WorldType.XIANXIA
|
? WorldType.XIANXIA
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function detectCustomWorldThemeMode(
|
|||||||
| 'tone'
|
| 'tone'
|
||||||
| 'playerGoal'
|
| 'playerGoal'
|
||||||
| 'templateWorldType'
|
| 'templateWorldType'
|
||||||
|
| 'compatibilityTemplateWorldType'
|
||||||
| 'ownedSettingLayers'
|
| 'ownedSettingLayers'
|
||||||
>,
|
>,
|
||||||
): CustomWorldThemeMode {
|
): CustomWorldThemeMode {
|
||||||
@@ -45,17 +46,36 @@ export function detectCustomWorldThemeMode(
|
|||||||
return 'mythic';
|
return 'mythic';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCustomWorldAnchorWorldType(
|
export function resolveCustomWorldCompatibilityTemplateWorldType(
|
||||||
profile: Pick<
|
profile: Pick<
|
||||||
CustomWorldProfile,
|
CustomWorldProfile,
|
||||||
| 'settingText'
|
|
||||||
| 'summary'
|
|
||||||
| 'tone'
|
|
||||||
| 'playerGoal'
|
|
||||||
| 'templateWorldType'
|
| 'templateWorldType'
|
||||||
|
| 'compatibilityTemplateWorldType'
|
||||||
| 'ownedSettingLayers'
|
| 'ownedSettingLayers'
|
||||||
>,
|
> &
|
||||||
|
Partial<
|
||||||
|
Pick<
|
||||||
|
CustomWorldProfile,
|
||||||
|
'settingText' | 'summary' | 'tone' | 'playerGoal'
|
||||||
|
>
|
||||||
|
>,
|
||||||
): WorldTemplateType {
|
): WorldTemplateType {
|
||||||
|
if (
|
||||||
|
profile.compatibilityTemplateWorldType === WorldType.WUXIA ||
|
||||||
|
profile.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||||
|
) {
|
||||||
|
return profile.compatibilityTemplateWorldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compatibilityTemplateWorldType =
|
||||||
|
profile.ownedSettingLayers?.compatibilityProfile?.compatibilityTemplateWorldType;
|
||||||
|
if (
|
||||||
|
compatibilityTemplateWorldType === WorldType.WUXIA ||
|
||||||
|
compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||||
|
) {
|
||||||
|
return compatibilityTemplateWorldType;
|
||||||
|
}
|
||||||
|
|
||||||
const legacyTemplateWorldType =
|
const legacyTemplateWorldType =
|
||||||
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
|
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
|
||||||
|
|
||||||
@@ -66,6 +86,24 @@ export function resolveCustomWorldAnchorWorldType(
|
|||||||
return legacyTemplateWorldType;
|
return legacyTemplateWorldType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeMode = detectCustomWorldThemeMode(profile);
|
if (
|
||||||
|
profile.templateWorldType === WorldType.WUXIA ||
|
||||||
|
profile.templateWorldType === WorldType.XIANXIA
|
||||||
|
) {
|
||||||
|
return profile.templateWorldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeMode = detectCustomWorldThemeMode({
|
||||||
|
settingText: profile.settingText ?? '',
|
||||||
|
summary: profile.summary ?? '',
|
||||||
|
tone: profile.tone ?? '',
|
||||||
|
playerGoal: profile.playerGoal ?? '',
|
||||||
|
templateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
||||||
|
compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType,
|
||||||
|
ownedSettingLayers: profile.ownedSettingLayers,
|
||||||
|
});
|
||||||
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
|
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const resolveCustomWorldAnchorWorldType =
|
||||||
|
resolveCustomWorldCompatibilityTemplateWorldType;
|
||||||
|
|||||||
@@ -469,8 +469,8 @@ function describeAnimationLabel(animation: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function describeWorld(world: WorldType) {
|
export function describeWorld(world: WorldType) {
|
||||||
if (world === WorldType.WUXIA) return '武侠';
|
if (world === WorldType.WUXIA) return '边城模板';
|
||||||
if (world === WorldType.XIANXIA) return '仙侠';
|
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||||
return '自定义世界';
|
return '自定义世界';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
|
|||||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||||
switch (worldType) {
|
switch (worldType) {
|
||||||
case 'WUXIA':
|
case 'WUXIA':
|
||||||
return '武侠';
|
return '边城模板';
|
||||||
case 'XIANXIA':
|
case 'XIANXIA':
|
||||||
return '仙侠';
|
return '灵潮模板';
|
||||||
case 'CUSTOM':
|
case 'CUSTOM':
|
||||||
return '自定义世界';
|
return '自定义世界';
|
||||||
default:
|
default:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@ import {
|
|||||||
buildAnimationClipFromVideoSource,
|
buildAnimationClipFromVideoSource,
|
||||||
GENERATED_FRAME_HEIGHT,
|
GENERATED_FRAME_HEIGHT,
|
||||||
GENERATED_FRAME_WIDTH,
|
GENERATED_FRAME_WIDTH,
|
||||||
} from '../components/preset-editor/characterAssetStudioModel';
|
} from '../components/asset-studio/characterAssetWorkflowModel';
|
||||||
import { generateCharacterAnimationDraft } from '../components/preset-editor/characterAssetStudioPersistence';
|
import { generateCharacterAnimationDraft } from '../components/asset-studio/characterAssetWorkflowPersistence';
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
SelectField,
|
SelectField,
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export interface CustomWorldExpressionProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomWorldCompatibilityProfile {
|
export interface CustomWorldCompatibilityProfile {
|
||||||
|
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||||
legacyTemplateWorldType?: WorldTemplateType | null;
|
legacyTemplateWorldType?: WorldTemplateType | null;
|
||||||
migrationVersion: string;
|
migrationVersion: string;
|
||||||
}
|
}
|
||||||
@@ -319,6 +320,7 @@ export interface CustomWorldProfile {
|
|||||||
tone: string;
|
tone: string;
|
||||||
playerGoal: string;
|
playerGoal: string;
|
||||||
templateWorldType: WorldTemplateType;
|
templateWorldType: WorldTemplateType;
|
||||||
|
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||||
majorFactions: string[];
|
majorFactions: string[];
|
||||||
coreConflicts: string[];
|
coreConflicts: string[];
|
||||||
attributeSchema: WorldAttributeSchema;
|
attributeSchema: WorldAttributeSchema;
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ export function getNineSliceStyle(
|
|||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WORLD_SELECT_ICONS = {
|
export const CUSTOM_WORLD_THEME_ICONS = {
|
||||||
wuxia: '/Icons/38_sword.png',
|
martial: '/Icons/38_sword.png',
|
||||||
xianxia: '/Icons/72_magic.png',
|
arcane: '/Icons/72_magic.png',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BRAND_ASSETS = {
|
export const BRAND_ASSETS = {
|
||||||
@@ -58,18 +58,6 @@ export const BRAND_ASSETS = {
|
|||||||
export const UI_CHROME = {
|
export const UI_CHROME = {
|
||||||
appBackground: '/UI/Background_fill.png',
|
appBackground: '/UI/Background_fill.png',
|
||||||
// 图源 125×28:上下 slice 之和必须 < 28,否则中间行高度为 0,border-image fill 失效(见 UI_CODING_STANDARD.md)
|
// 图源 125×28:上下 slice 之和必须 < 28,否则中间行高度为 0,border-image fill 失效(见 UI_CODING_STANDARD.md)
|
||||||
worldButtonWuxia: {
|
|
||||||
src: '/UI/1_orange_button.png',
|
|
||||||
slice: { top: 9, right: 14, bottom: 9, left: 14 },
|
|
||||||
padding: { x: 18, y: 12 },
|
|
||||||
repeat: 'stretch',
|
|
||||||
},
|
|
||||||
worldButtonXianxia: {
|
|
||||||
src: '/UI/1_violet_button.png',
|
|
||||||
slice: { top: 9, right: 14, bottom: 9, left: 14 },
|
|
||||||
padding: { x: 18, y: 12 },
|
|
||||||
repeat: 'stretch',
|
|
||||||
},
|
|
||||||
characterCardFrame: {
|
characterCardFrame: {
|
||||||
src: '/UI/pick_hero_frame.png',
|
src: '/UI/pick_hero_frame.png',
|
||||||
slice: { top: 18, right: 18, bottom: 18, left: 18 },
|
slice: { top: 18, right: 18, bottom: 18, left: 18 },
|
||||||
|
|||||||
Reference in New Issue
Block a user