1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-11 15:43:32 +08:00
parent f19e482c8f
commit 0981d6ee1b
78 changed files with 1102 additions and 8510 deletions

View File

@@ -43,6 +43,7 @@ AUTH_REFRESH_COOKIE_SECURE="false"
# 手机号验证码登录配置(阿里云 PNVS
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。
# 在 `.env.local` 或进程环境中填入 AccessKey 后会自动启用;如需强制关闭,请显式设置 `SMS_AUTH_ENABLED="false"`。
SMS_AUTH_ENABLED="false"
SMS_AUTH_PROVIDER="aliyun"
ALIYUN_SMS_ACCESS_KEY_ID=""

View File

@@ -6,3 +6,5 @@ DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"
EMBEDDING_MODEL="doubao-embedding-text-240715"
VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI"
VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ=="
WECHAT_AUTH_ENABLED="true"
WECHAT_AUTH_PROVIDER="mock"

View File

@@ -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`

View File

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

View File

@@ -50,6 +50,10 @@ export type AuthMeResponse = {
availableLoginMethods: AuthLoginMethod[];
};
export type AuthLoginOptionsResponse = {
availableLoginMethods: AuthLoginMethod[];
};
export type AuthWechatStartResponse = {
authorizationUrl: string;
};

View File

@@ -710,7 +710,7 @@ function buildNpcVisualPrompt(
.filter(Boolean)
.join('\n');
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。');
}
function buildImageSequencePrompt(

View File

@@ -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}`);

View File

@@ -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 { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts';
import {
@@ -37,7 +37,7 @@ function assert(condition: unknown, message: string): asserts condition {
}
function createBaseState(worldType: WorldType, sceneId?: string): GameState {
const playerCharacter = PRESET_CHARACTERS[0];
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
const currentScenePreset = sceneId
? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null
: getScenePresetsByWorld(worldType)[0] ?? null;
@@ -114,7 +114,7 @@ function smokeNpcStories() {
context: sceneWithNpc.npcs[0].role,
xMeters: 3.2,
};
const playerCharacter = PRESET_CHARACTERS[0];
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
const npcState = buildInitialNpcState(encounter, worldType);
const story = buildNpcEncounterStoryMoment({
encounter,
@@ -216,7 +216,7 @@ function smokeObserveAndCallOut() {
function smokeInventoryUseLoop() {
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 usableItem = inventory.find(item => isInventoryItemUsable(item));
assert(usableItem, `[inventory] missing usable starter item for ${worldType}`);
@@ -231,7 +231,7 @@ function smokeInventoryUseLoop() {
}
function smokeEquipmentLoop() {
const playerCharacter = PRESET_CHARACTERS[0];
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
const starterBonuses = getEquipmentBonuses(starterLoadout);
@@ -261,7 +261,7 @@ function smokeTradeEconomyLoop() {
};
const npcState = buildInitialNpcState(encounter, worldType);
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(playerItem, `[trade] missing player item for ${worldType}`);
@@ -326,9 +326,9 @@ function smokeEncounterTransitionLoop() {
}
function smokeRosterLoop() {
const playerCharacter = PRESET_CHARACTERS[0];
const reserveCharacter = PRESET_CHARACTERS[1];
const recruitCharacter = PRESET_CHARACTERS[2];
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
const reserveCharacter = ROLE_TEMPLATE_CHARACTERS[1];
const recruitCharacter = ROLE_TEMPLATE_CHARACTERS[2];
const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68);
const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62);
const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72);

View File

@@ -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 { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
@@ -45,7 +45,7 @@ function validateScenes(errors: string[]) {
}
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}"`);
}
});
@@ -57,7 +57,7 @@ function validateCharacters(errors: string[]) {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
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);
if (homeSceneId && !sceneIdSet.has(homeSceneId)) {
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 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();

View File

@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { readdirSync } from 'node:fs';
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 { buildItemCatalogId } from '../src/data/itemCatalog.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');
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(
[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)),
),
);
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]) => {
if (!npcIds.has(npcId)) {

View File

@@ -14,12 +14,25 @@ import { requestIdMiddleware } from './middleware/requestId.ts';
import { createAppContext } from './server.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(
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
);
return {
const baseConfig: AppConfig = {
nodeEnv: 'test',
projectRoot: tempRoot,
publicDir: path.join(tempRoot, 'public'),
@@ -99,13 +112,39 @@ function createTestConfig(testName: string): AppConfig {
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>(
testName: string,
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 server = await new Promise<import('node:http').Server>((resolve) => {
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 () => {
await withTestServer('phone-login', async ({ baseUrl }) => {
const sendResult = await sendPhoneCode(baseUrl, '13800138000');

View File

@@ -6,6 +6,7 @@ import type {
AuthAuditLogsResponse,
AuthBindingStatus,
AuthEntryResponse,
AuthLoginOptionsResponse,
AuthLiftRiskBlockResponse,
AuthLoginMethod,
AuthLogoutAllResponse,
@@ -151,6 +152,14 @@ export async function buildAuthMeResponse(
};
}
export function buildAuthLoginOptionsResponse(
context: AppContext,
): AuthLoginOptionsResponse {
return {
availableLoginMethods: resolveAvailableLoginMethods(context),
};
}
async function signUserAuthPayload(
context: AppContext,
user: UserRecord,
@@ -1077,12 +1086,14 @@ export async function startWechatLogin(
context: AppContext,
callbackUrl: string,
redirectPath: string,
requestContext: RefreshSessionRequestContext | null = null,
): Promise<AuthWechatStartResponse> {
const stateRecord = context.wechatAuthStates.create(redirectPath);
return {
authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({
callbackUrl,
state: stateRecord.state,
userAgent: requestContext?.userAgent ?? null,
}),
};
}

View 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');
});

View File

@@ -131,14 +131,39 @@ function resolveDefaultProjectRoot() {
: cwd;
}
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
function readMergedEnv(
exampleEnv: Record<string, string>,
localEnv: Record<string, string>,
processEnv: NodeJS.ProcessEnv,
) {
return {
...readEnvFile(path.join(projectRoot, '.env.example')),
...readEnvFile(path.join(projectRoot, '.env.local')),
...exampleEnv,
...localEnv,
...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(
env: Record<string, string | undefined>,
key: string,
@@ -199,33 +224,51 @@ function readBoolean(
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
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 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(
env,
'EDITOR_API_ENABLED',
defaultEditorApiEnabled,
);
const smsProvider = readString(
const smsProviderFromEnv = readString(
env,
'SMS_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun',
nodeEnv === 'test' ? 'mock' : 'aliyun',
) as AppConfig['smsAuth']['provider'];
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
const smsProvider = smsProviderFromEnv;
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(
env,
'WECHAT_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat',
nodeEnv === 'test' ? 'mock' : 'wechat',
) as AppConfig['wechatAuth']['provider'];
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
const defaultWechatEnabled =
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
const wechatEnabled = readBooleanOverride(
env,
[localEnv, processEnv],
'WECHAT_AUTH_ENABLED',
defaultWechatEnabled,
);
const refreshSameSite = readString(
env,
'AUTH_REFRESH_COOKIE_SAME_SITE',
@@ -233,7 +276,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
);
return {
nodeEnv: readString(env, 'NODE_ENV', 'development'),
nodeEnv,
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir,
@@ -295,7 +338,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
),
},
smsAuth: {
enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled),
enabled: smsEnabled,
provider: smsProvider,
endpoint: readString(
env,
@@ -410,7 +453,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
),
},
wechatAuth: {
enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled),
enabled: wechatEnabled,
provider: wechatProvider,
appId: wechatAppId,
appSecret: wechatAppSecret,

View File

@@ -73,9 +73,9 @@ function readStringArray(value: unknown) {
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

View File

@@ -65,8 +65,8 @@ function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
generatedFrom: {
worldType,
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
conflictCore: '旧秩序与新威胁正在同时逼近',
},
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
@@ -371,9 +371,10 @@ function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
subtitle: '前路未明',
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震',
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
templateWorldType: worldType,
compatibilityTemplateWorldType: worldType,
majorFactions: inferMajorFactions(seed),
coreConflicts: inferCoreConflicts(setting),
attributeSchema: buildAttributeSchema(worldType),

View File

@@ -11,9 +11,9 @@ function readNumber(value: unknown, fallback = 0) {
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

View File

@@ -709,7 +709,7 @@ function buildNpcVisualPrompt(
.filter(Boolean)
.join('\n');
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。');
}
function buildImageSequencePrompt(

View File

@@ -757,9 +757,9 @@ function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

View File

@@ -11,6 +11,7 @@ import type {
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
import {
bindWechatPhone,
buildAuthLoginOptionsResponse,
buildAuthMeResponse,
changeUserPhone,
createRefreshSession,
@@ -115,6 +116,14 @@ export function createAuthRoutes(context: AppContext) {
const router = Router();
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(
'/entry',
routeMeta({ operation: 'auth.entry' }),
@@ -232,6 +241,7 @@ export function createAuthRoutes(context: AppContext) {
request.query.redirectPath,
context.config.wechatAuth.defaultRedirectPath,
);
const requestContext = buildAuthRequestContext(request);
const callbackUrl = new URL(
context.config.wechatAuth.callbackPath,
resolveRequestOrigin(request),
@@ -239,7 +249,12 @@ export function createAuthRoutes(context: AppContext) {
sendApiResponse(
response,
await startWechatLogin(context, callbackUrl, redirectPath),
await startWechatLogin(
context,
callbackUrl,
redirectPath,
requestContext,
),
);
}),
);

View File

@@ -33,6 +33,18 @@ function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
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) {
const normalizedMessage = message.trim();
return normalizedMessage ? `${prefix}${normalizedMessage}` : prefix;
@@ -48,6 +60,7 @@ class AliyunSmsVerificationService implements SmsVerificationService {
if (isAliyunConfigMissing(config)) {
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
}
assertAliyunRequiredConfig(config);
const clientConfig = new OpenApiClient.Config({
accessKeyId: config.accessKeyId,

View File

@@ -15,6 +15,7 @@ export type WechatAuthService = {
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
userAgent?: string | null;
}): string;
resolveCallbackProfile(params: {
code?: string | null;
@@ -22,12 +23,40 @@ export type WechatAuthService = {
}): 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 {
constructor(private readonly config: AppConfig['wechatAuth']) {}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
userAgent?: string | null;
}) {
const callbackUrl = new URL(params.callbackUrl);
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
@@ -64,12 +93,21 @@ class RealWechatAuthService implements WechatAuthService {
buildAuthorizationUrl(params: {
callbackUrl: 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('redirect_uri', params.callbackUrl);
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);
return `${url.toString()}#wechat_redirect`;
}

View File

@@ -5,7 +5,7 @@ import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
ROLE_TEMPLATE_CHARACTERS,
} from '../data/characterPresets';
import {
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
@@ -522,18 +522,18 @@ function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) {
if (candidates.length === 1) {
const firstCandidate = candidates[0];
if (!firstCandidate) {
return PRESET_CHARACTERS.slice(0, 2);
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}
const fallback =
PRESET_CHARACTERS.find(
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id !== firstCandidate.id,
) ??
PRESET_CHARACTERS[0] ??
ROLE_TEMPLATE_CHARACTERS[0] ??
firstCandidate;
return [firstCandidate, fallback];
}
return PRESET_CHARACTERS.slice(0, 2);
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}, [profile]);
const [leftCharacter, rightCharacter] = sparringCharacters;
@@ -1622,10 +1622,10 @@ function PlayableNpcEditor({
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const selectedTemplate =
PRESET_CHARACTERS.find(
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
) ??
PRESET_CHARACTERS[0] ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null;
return (
@@ -1682,14 +1682,14 @@ function PlayableNpcEditor({
<Field label="外观模板">
<SelectField
value={draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id ?? ''}
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
onChange={(value) =>
setDraft((current) => ({
...current,
templateCharacterId: value,
}))
}
options={PRESET_CHARACTERS.map((character) => ({
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
value: character.id,
label: `${character.name} / ${character.title}`,
}))}
@@ -1838,7 +1838,7 @@ function PlayableNpcEditor({
onSave({
...draft,
templateCharacterId:
draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id,
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
});
onClose();
}}
@@ -2548,9 +2548,9 @@ function createPlayableNpc(
): CustomWorldPlayableNpc {
const seed = Date.now() + profile.playableNpcs.length;
const template =
PRESET_CHARACTERS[
profile.playableNpcs.length % Math.max(1, PRESET_CHARACTERS.length)
] ?? PRESET_CHARACTERS[0];
ROLE_TEMPLATE_CHARACTERS[
profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
return {
id: createEntryId(

View File

@@ -6,7 +6,7 @@ import {
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
@@ -17,7 +17,7 @@ import {
buildAnimationClipFromVideoSource,
type DraftAnimationClip,
readFileAsDataUrl,
} from './preset-editor/characterAssetStudioModel';
} from './asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationDraftPayload,
type CharacterAnimationGenerationPayload,
@@ -27,7 +27,7 @@ import {
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from './preset-editor/characterAssetStudioPersistence';
} from './asset-studio/characterAssetWorkflowPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
@@ -353,7 +353,7 @@ export function CustomWorldRoleAssetStudioModal({
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
? PRESET_CHARACTERS.find(
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
@@ -661,7 +661,7 @@ export function CustomWorldRoleAssetStudioModal({
value={visualPromptText}
onChange={setVisualPromptText}
rows={6}
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
placeholder="例如:衣摆更利落、主武器辨识度更高、整体更像边境世界的年轻冒险者。"
/>
</Field>
<Field label="参考图">

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -2,7 +2,7 @@ import { RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
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 { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
import { getScenePreset } from '../data/scenePresets';
@@ -75,7 +75,7 @@ export function SkillEffectPreview({
}: SkillEffectPreviewProps) {
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
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],
);

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import {
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
@@ -15,6 +16,7 @@ import {
consumeAuthCallbackResult,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
@@ -50,6 +52,9 @@ const allowDevGuestAutoAuth =
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [availableLoginMethods, setAvailableLoginMethods] = useState<
AuthLoginMethod[]
>([]);
const [error, setError] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [loggingIn, setLoggingIn] = useState(false);
@@ -104,6 +109,16 @@ export function AuthGate({ children }: AuthGateProps) {
};
const hydrate = async () => {
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
return null;
}
setAvailableLoginMethods(options.availableLoginMethods);
return options;
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
@@ -121,6 +136,20 @@ export function AuthGate({ children }: AuthGateProps) {
}
setUser(null);
try {
await loadLoginOptions();
} catch (optionsError) {
if (!isActive) {
return;
}
setAvailableLoginMethods([]);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
}
setStatus('unauthenticated');
return;
}
@@ -133,11 +162,13 @@ export function AuthGate({ children }: AuthGateProps) {
if (!nextSession.user) {
setUser(null);
setAvailableLoginMethods(nextSession.availableLoginMethods);
setStatus('unauthenticated');
return;
}
setUser(nextSession.user);
setAvailableLoginMethods(nextSession.availableLoginMethods);
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'
@@ -155,6 +186,20 @@ export function AuthGate({ children }: AuthGateProps) {
}
setUser(null);
try {
await loadLoginOptions();
} catch (optionsError) {
if (!isActive) {
return;
}
setAvailableLoginMethods([]);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
}
setStatus('unauthenticated');
}
};
@@ -278,6 +323,7 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'unauthenticated') {
return (
<LoginScreen
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}

View File

@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import type { AuthCaptchaChallenge } from '../../services/authService';
import type {
AuthCaptchaChallenge,
AuthLoginMethod,
} from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type LoginScreenProps = {
availableLoginMethods: AuthLoginMethod[];
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
@@ -24,6 +28,7 @@ type LoginScreenProps = {
};
export function LoginScreen({
availableLoginMethods,
sendingCode,
loggingIn,
wechatLoading,
@@ -38,6 +43,8 @@ export function LoginScreen({
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
useEffect(() => {
if (cooldownSeconds <= 0) {
@@ -69,7 +76,7 @@ export function LoginScreen({
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<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">
@@ -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"
onSubmit={(event) => {
event.preventDefault();
if (!phoneLoginEnabled) {
return;
}
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
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"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
{phoneLoginEnabled ? (
<>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
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"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<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"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<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"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
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}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
</div>
{phoneLoginEnabled || wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
{phoneLoginEnabled && wechatLoginEnabled
? '手机号可直接登录,也可以先用微信。'
: phoneLoginEnabled
? '当前开放手机号登录。'
: '当前开放微信登录。'}
</div>
) : null}
{error ? (
<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>
) : null}
<button
type="submit"
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>
{phoneLoginEnabled ? (
<button
type="submit"
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>
) : null}
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
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"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
{wechatLoginEnabled ? (
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
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"
onClick={() => {
void onStartWechatLogin();
}}
>
{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>
</div>
</div>

View File

@@ -1,6 +1,6 @@
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 {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
@@ -47,7 +47,9 @@ export function GameCanvasRuntime({
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
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
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];

View File

@@ -2,7 +2,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
ROLE_TEMPLATE_CHARACTERS,
} from '../../data/characterPresets';
import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types';
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
@@ -160,7 +160,7 @@ export function CharacterSelectionFlow({
onConfirm,
}: CharacterSelectionFlowProps) {
const selectionCharacters = useMemo(
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : PRESET_CHARACTERS),
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile],
);
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');

View File

@@ -34,10 +34,10 @@ import {
type GameState,
} from '../../types';
import {
CUSTOM_WORLD_THEME_ICONS,
CHROME_ICONS,
getNineSliceStyle,
UI_CHROME,
WORLD_SELECT_ICONS,
} from '../../uiAssets';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import { CustomWorldResultView } from '../CustomWorldResultView';
@@ -187,9 +187,9 @@ export function PreGameSelectionFlow({
featurePortrait: leadCharacter?.portrait ?? '',
featureIcon:
themeMode === 'martial'
? WORLD_SELECT_ICONS.wuxia
? CUSTOM_WORLD_THEME_ICONS.martial
: themeMode === 'arcane'
? WORLD_SELECT_ICONS.xianxia
? CUSTOM_WORLD_THEME_ICONS.arcane
: CHROME_ICONS.refreshOptions,
accentLabel: '自定义世界',
};

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
export { CharacterAssetPanel as default } from './CharacterAssetPanel';

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { CharacterPresetPanel as default } from './CharacterPresetPanel';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { MonsterPresetPanel as default } from './MonsterPresetPanel';

View File

@@ -1,4 +0,0 @@
export { CharacterPresetPanel } from './CharacterPresetPanel';
export { MonsterPresetPanel } from './MonsterPresetPanel';
export { SceneNpcPresetPanel } from './SceneNpcPresetPanel';
export { ScenePresetPanel } from './ScenePresetPanel';

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { SceneNpcPresetPanel as default } from './SceneNpcPresetPanel';

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { ScenePresetPanel as default } from './ScenePresetPanel';

View File

@@ -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 };
}

View File

@@ -16,7 +16,7 @@ import {
} from './buildDamage';
import { getCharacterCombatTags } from './buildTags';
import { getCharacterById } from './characterPresets';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
function requireCharacter(characterId: string) {
const character = getCharacterById(characterId);
@@ -37,8 +37,8 @@ function cloneCharacter(
},
} satisfies Character;
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaProfile = buildCharacterAttributeProfile(
nextCharacter,
wuxiaSchema,
@@ -150,7 +150,7 @@ describe('buildDamage', () => {
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
const character = requireCharacter('sword-princess');
const breakdown = getCompanionBuildDamageBreakdown(character);
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
expect(breakdown.rows.length).toBeGreaterThan(0);
@@ -381,7 +381,7 @@ describe('buildDamage', () => {
it('does not allow resource attributes to enter tag bonus rows', () => {
const character = requireCharacter('sword-princess');
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const mpBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({

View File

@@ -41,7 +41,7 @@ import {
buildCustomWorldStarterInventoryItems,
} from './customWorldCharacterLoadout';
import { getRuntimeCustomWorldProfile, isCustomWorldType } from './customWorldRuntime';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition {
return skill;
@@ -289,8 +289,8 @@ function hydrateCharacterRoleData(
customRole?: CustomWorldRuntimeRole | null;
} = {},
) {
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaProfile = buildCharacterAttributeProfile(character, wuxiaSchema);
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
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',
name: '剑之公主',
@@ -563,7 +563,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '追查失落王庭誓剑流入江湖的踪迹',
goal: '在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人',
monologue: '你来到这个武侠世界,是为追查失落王庭誓剑流入江湖的踪迹。此行最重要的目标,是在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人。',
monologue: '你来到这片旧桥与边城交错的地界,是为追查失落王庭誓剑流入各方势力的踪迹。此行最重要的目标,是在野心家之前找回誓剑,并逼出宫变幕后之人。',
surfaceHook: '我追着一件不该流落在外的王庭旧物而来。',
immediateConcern: '前面盯着这条线的人不止一拨,走错一步就会被人截住。',
guardedMotive: '我来这里不是巡游散心,有件旧账必须先查清。',
@@ -571,7 +571,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '王庭圣印坠入云海裂隙,你循着残光闯入了仙域',
goal: '寻回圣印,截断借它开启天门禁制的野心',
monologue: '你来到这个仙侠世界,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
monologue: '你来到这片灵潮翻涌的高空异境,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
surfaceHook: '我循着一道王庭残光追到了这里。',
immediateConcern: '云海里的局势已经被人搅乱,圣印不会等你慢慢摸索。',
guardedMotive: '我来这里是为收回一件必须回到我手里的东西。',
@@ -720,7 +720,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '追着一份指向边军叛徒的密图进入江湖',
goal: '找出贩卖军情的人,并截回被转移的军械账册',
monologue: '你来到这个武侠世界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
monologue: '你来到这片边城动荡未平的地界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
surfaceHook: '我追着一份旧军密图走到了这片江湖。',
immediateConcern: '这条线上的人都擅长放假风声,前路不只一层埋伏。',
guardedMotive: '我在追一条和边军旧案有关的线,但还不到全说的时候。',
@@ -728,7 +728,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '星舟坠毁后,你顺着碎裂航迹漂进了仙域云海',
goal: '找回星图核心,查清是谁击落了你的船队',
monologue: '你来到这个仙侠世界,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
monologue: '你来到这片灵潮与云海交错的异境,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
surfaceHook: '我是顺着一段断掉的航迹飘进来的。',
immediateConcern: '云海里残留的痕迹还没散干净,说明对方离得不远。',
guardedMotive: '我在追查一场坠毁后的尾线,暂时只确认到这里。',
@@ -939,7 +939,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '追着偷走密信的人潜入了这片雨夜江湖',
goal: '夺回密信,查清究竟是谁把你推上了被追杀的路',
monologue: '你来到这个武侠世界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
monologue: '你来到这片雨夜与旧案交错的地界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
surfaceHook: '我追着一个偷走东西的人摸进了这里。',
immediateConcern: '前面这条路像是专门给人设的套,闯快了只会替别人探雷。',
guardedMotive: '我来这儿是为了追一封信,也顺便追查是谁想让我闭嘴。',
@@ -947,7 +947,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '密信指向一座只会在月湖现身的仙门残阵',
goal: '找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁',
monologue: '你来到这个仙侠世界,是因为那封密信把你引向了一座只会在月湖现身的仙门残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
monologue: '你来到这片月湖与残阵交错的异境,是因为那封密信把你引向了一座只会在月湖现身的残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
surfaceHook: '有封信把我一路引到了月湖这一带。',
immediateConcern: '残阵现身的时间很短,再慢一点就只剩空壳。',
guardedMotive: '我来这里不只是找阵眼,还想弄明白一件跟我自己有关的怪事。',
@@ -1034,7 +1034,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '循着毁掉拳馆的凶手线索来到了这片江湖',
goal: '找到凶手首领,让拳馆遗物和弟子名册不再被人践踏',
monologue: '你来到这个武侠世界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
monologue: '你来到这片拳馆旧怨未平的地界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
surfaceHook: '我追着一帮砸了拳馆的人一路追到了这里。',
immediateConcern: '前面那股气味不对,像是有人刚动过手脚。',
guardedMotive: '我来这里是为了算账,也为了把该带回去的东西带回去。',
@@ -1042,7 +1042,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '师门遗物在灵火裂隙里传来回应,你一路追进了熔境',
goal: '夺回遗物中的真传拳谱,阻止它被人炼成杀器',
monologue: '你来到这个仙侠世界,是因为师门遗物在灵火裂隙里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
monologue: '你来到这片灵火裂隙仍在回响的异境,是因为师门遗物在里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
surfaceHook: '我顺着师门遗物的回应一路追到了熔境。',
immediateConcern: '灵火裂隙正往外吐东西,再拖下去只会更难收拾。',
guardedMotive: '我来这里是为了把师门留下的东西抢回来,别的以后再细说。',
@@ -1222,7 +1222,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '奉旧部最后一道军令,独自赶来守住山门防线',
goal: '找回失散军旗,重新拼起已经溃散的同袍',
monologue: '你来到这个武侠世界,是奉着旧部最后一道军令赶来守住山门防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
monologue: '你来到这片山门与防线都在失守边缘的地界,是奉着旧部最后一道军令赶来守住防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
surfaceHook: '我奉着一条没法搁下的旧军令守在这里。',
immediateConcern: '山门前的防线已经松了,再往前走的人很可能被卷进去。',
guardedMotive: '我来这里不是巡查,是在补一段还没补上的旧阵线。',
@@ -1230,7 +1230,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '雷坛异动引发旧式甲胄共鸣,你被迫一路追进了仙域',
goal: '封住失控雷坛,避免整支旧军的甲魂被拿去驱使',
monologue: '你来到这个仙侠世界,是因为雷坛异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
monologue: '你来到这片雷坛异动不断放大的异境,是因为那场异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
surfaceHook: '我顺着旧甲的共鸣一路追到了雷坛附近。',
immediateConcern: '这里的雷势还在往上走,再晚一点就不是一两个人能压住的事。',
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>();
let runtimeCustomWorldCharacters: Character[] = [];
@@ -1668,15 +1669,15 @@ function pickCustomWorldRoleTemplateCharacter(
fallbackIndex: number,
profile?: CustomWorldProfile | null,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
] ?? PRESET_CHARACTERS[0];
const fallbackTemplateCharacter = ROLE_TEMPLATE_CHARACTERS[
fallbackIndex % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
if (!fallbackTemplateCharacter) {
throw new Error('Missing preset characters for custom world generation');
}
const explicitTemplateCharacter = role.templateCharacterId
? PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
: null;
if (explicitTemplateCharacter) {
return explicitTemplateCharacter;
@@ -1696,7 +1697,7 @@ function pickCustomWorldRoleTemplateCharacter(
},
);
const referenceTemplateCharacter = referenceTemplateCharacterId
? PRESET_CHARACTERS.find(
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === referenceTemplateCharacterId,
) ?? null
: null;
@@ -1704,7 +1705,7 @@ function pickCustomWorldRoleTemplateCharacter(
return referenceTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
const heuristicTemplateCharacter = ROLE_TEMPLATE_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
role.role,
@@ -1722,11 +1723,11 @@ function pickCustomWorldRoleTemplateCharacter(
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
if (!profile) {
return PRESET_CHARACTERS;
return ROLE_TEMPLATE_CHARACTERS;
}
if (profile.playableNpcs.length === 0) {
return PRESET_CHARACTERS;
return ROLE_TEMPLATE_CHARACTERS;
}
return profile.playableNpcs.map((role, index) => {
@@ -1780,7 +1781,7 @@ export function setRuntimeCharacterOverrides(characters: Character[] | null) {
export function getCharacterById(characterId: string) {
return runtimeCharacterOverrides.get(characterId)
?? PRESET_CHARACTERS.find(character => character.id === characterId)
?? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === characterId)
?? null;
}

View File

@@ -642,9 +642,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const settingText = toText(value.settingText, toText(value.summary, name));
if (!name) return null;
const templateWorldType = value.templateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: WorldType.WUXIA;
const compatibilityTemplateWorldType =
value.compatibilityTemplateWorldType === WorldType.XIANXIA
? 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 summary = toText(value.summary);
const tone = toText(value.tone);
@@ -687,6 +693,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
tone,
playerGoal,
templateWorldType,
compatibilityTemplateWorldType,
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),

View File

@@ -8,6 +8,7 @@ import {
type CustomWorldProfile,
WorldType,
} from '../types';
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
@@ -181,7 +182,10 @@ function scoreMonsterPresetWithArchetype(
}
export function getCustomWorldMonsterPresetPool(
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
const presets = getAllMonsterPresets();
const creatureArchetypes =
@@ -191,7 +195,9 @@ export function getCustomWorldMonsterPresetPool(
return presets;
}
const preferredWorldType = profile?.templateWorldType ?? null;
const preferredWorldType = profile
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
: null;
const scoredPresets = presets
.map((preset) => {
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
@@ -223,7 +229,10 @@ export function getCustomWorldMonsterPresetPool(
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
@@ -235,7 +244,9 @@ export function resolveCustomWorldNpcMonsterPreset(
return null;
}
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
const preferredWorldType = profile
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
: worldType ?? null;
const referenceArchetype = resolveCreatureArchetypeForSource(
profile as CustomWorldProfile | null | undefined,
npc,
@@ -271,7 +282,10 @@ export function resolveCustomWorldNpcMonsterPreset(
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
}

View File

@@ -1,7 +1,7 @@
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
resolveCustomWorldAnchorWorldType,
resolveCustomWorldCompatibilityTemplateWorldType,
} from '../services/customWorldTheme';
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
@@ -15,13 +15,15 @@ export function getRuntimeCustomWorldProfile() {
return runtimeCustomWorldProfile;
}
export function resolveRuleWorldType(
export function resolveCompatibilityTemplateWorldType(
worldType: WorldType | null | undefined,
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
): WorldTemplateType | null {
if (!worldType) return null;
if (worldType === WorldType.CUSTOM) {
return customWorldProfile ? resolveCustomWorldAnchorWorldType(customWorldProfile) : WorldType.WUXIA;
return customWorldProfile
? resolveCustomWorldCompatibilityTemplateWorldType(customWorldProfile)
: WorldType.WUXIA;
}
return worldType;
}

View File

@@ -10,6 +10,7 @@ import {
type WorldTemplateType,
WorldType,
} from '../types';
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
'/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: '山门石阶',
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
@@ -131,7 +132,7 @@ const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
},
] as const;
const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
const ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '云海仙门',
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
@@ -182,12 +183,12 @@ const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
},
] as const;
const WORLD_SCENE_IMAGE_REFERENCES: Record<
const COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES: Record<
WorldTemplateType,
readonly SceneImageReference[]
> = {
[WorldType.WUXIA]: WUXIA_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: XIANXIA_SCENE_IMAGE_REFERENCES,
[WorldType.WUXIA]: MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES,
};
type CustomWorldSceneImageMatchOptions = {
@@ -259,7 +260,7 @@ function uniqueStrings(values: Array<string | null | undefined>) {
function buildSceneReferencePool(worldType: WorldTemplateType) {
const pool = collectWorldSceneImagePool(worldType);
const references = WORLD_SCENE_IMAGE_REFERENCES[worldType] ?? [];
const references = COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
return references.map((reference, index) => ({
...reference,
@@ -488,6 +489,7 @@ export function resolveCustomWorldLandmarkImage(
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'ownedSettingLayers'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
@@ -502,7 +504,7 @@ export function resolveCustomWorldLandmarkImage(
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
index,
profile.templateWorldType,
resolveCustomWorldCompatibilityTemplateWorldType(profile),
{
profile,
landmark,
@@ -521,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
@@ -559,6 +562,7 @@ export function resolveCustomWorldCampSceneImage(
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
@@ -575,7 +579,7 @@ export function resolveCustomWorldCampSceneImage(
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
-1,
profile.templateWorldType,
resolveCustomWorldCompatibilityTemplateWorldType(profile),
{
profile,
landmark: {

View File

@@ -4,7 +4,10 @@ import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, Runti
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
import { buildDefaultAxisVector } from './attributeResolver';
import {normalizeBuildTags} from './buildTags';
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
import {
buildRuntimeCustomWorldInventoryItems,
resolveCompatibilityTemplateWorldType,
} from './customWorldRuntime';
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
@@ -13,7 +16,7 @@ import {
buildRuntimeItemAiIntent,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
worldType: WorldType;
@@ -930,7 +933,7 @@ function buildHostileNpcBehaviorVectors(preset: {
function hydrateHostileNpcPresetRoleData(
preset: Omit<HostileNpcPreset, 'attributeProfile' | 'behaviorVectors'>,
): HostileNpcPreset {
const schema = getPresetWorldAttributeSchema(
const schema = getTemplateWorldAttributeSchema(
preset.worldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA,
);
@@ -955,7 +958,8 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
if (worldType === WorldType.CUSTOM) {
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;
}
@@ -965,7 +969,8 @@ export function getHostileNpcPresetsByWorld(worldType: WorldType) {
if (worldType === 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];
}

View File

@@ -11,7 +11,7 @@ import {
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import { resolveRuleWorldType } from './customWorldRuntime';
import { resolveCompatibilityTemplateWorldType } from './customWorldRuntime';
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
export const METERS_TO_PIXELS = 48;
@@ -78,7 +78,8 @@ function getHostileNpcFormationSlots(
worldType: WorldType,
monsterCount: number,
): HostileNpcFormationSlot[] {
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType];
const centerSlot = { xMeters: frontX, yOffset: 0 };
const lowerBackSlot = {

View File

@@ -471,14 +471,14 @@ function buildLegacyDesign(
category,
rarity,
tags: dedupe(tags),
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两个世界中以不同风格登场,适合作为${category}基础模板继续扩展。`,
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两套兼容模板中以不同风格登场,适合作为${category}基础模板继续扩展。`,
worldAffinity: "neutral",
equipmentSlotId: slot,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName},适用于武侠世界的基础${category}条目。`,
`${xianxiaName},适用于仙侠世界的基础${category}条目。`,
`${wuxiaName},适用于边城模板的基础${category}条目。`,
`${xianxiaName},适用于灵潮模板的基础${category}条目。`,
),
statProfile,
useProfile,
@@ -544,8 +544,8 @@ function buildArmoryDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}武侠 build。`,
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑仙侠战法。`,
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}边城模板 build。`,
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑灵潮模板战法。`,
),
statProfile,
useProfile: null,
@@ -633,8 +633,8 @@ function buildPotionDesign(
worldProfiles: buildWorldProfiles(
"药瓶",
"灵瓶",
"武侠世界常见的炼药容器。",
"仙侠世界常用的盛装灵液器皿。",
"边城模板里常见的炼药容器。",
"灵潮模板里常用的盛装灵液器皿。",
),
statProfile: null,
useProfile: null,
@@ -708,8 +708,8 @@ function buildPotionDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}常见于江湖行囊,用于快速续战或调息。`,
`${xianxiaName}多用于洞府与试炼前后,负责补元、聚灵与压缩冷却。`,
`${wuxiaName}常见于边城模板的远行行囊,用于快速续战或调息。`,
`${xianxiaName}多用于灵潮模板的据点与试炼前后,负责补元、聚灵与压缩冷却。`,
),
statProfile: null,
useProfile,
@@ -757,8 +757,8 @@ function buildGemDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}偏向江湖匠造、镶嵌与兵刃锻造。`,
`${xianxiaName}更适合灵器镶嵌与灵力 build 核心堆叠。`,
`${wuxiaName}偏向边城模板里的匠造、镶嵌与兵刃锻造。`,
`${xianxiaName}更适合灵潮模板里的灵器镶嵌与灵力 build 核心堆叠。`,
),
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
useProfile: null,
@@ -830,7 +830,7 @@ function buildSkillRelicDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}适合在武侠世界里解释为武学秘卷、战术符印或绝招凭证。`,
`${wuxiaName}适合在边城模板里解释为武学秘卷、战术符印或绝招凭证。`,
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
),
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
@@ -916,8 +916,8 @@ function buildUtilityDesign(
worldProfiles: buildWorldProfiles(
wuxiaName || readable,
xianxiaName || readable,
`${wuxiaName || readable}更适合武侠世界的江湖使用语境。`,
`${xianxiaName || readable}更适合仙侠世界的灵物/法器语境。`,
`${wuxiaName || readable}更适合边城模板的在地使用语境。`,
`${xianxiaName || readable}更适合灵潮模板的灵物/法器语境。`,
),
statProfile,
useProfile,

View File

@@ -11,8 +11,8 @@ const STRUCTURAL_TAG_LABELS: Record<string, string> = {
healing: '疗伤',
mana: '法力',
rare: '稀有',
wuxia: '武侠',
xianxia: '仙侠',
wuxia: '边城模板',
xianxia: '灵潮模板',
neutral: '中性',
};

View File

@@ -14,7 +14,7 @@ function resolvePublicAssetPath(assetPath: string) {
}
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 = [
...getScenePresetsByWorld(WorldType.WUXIA),
...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 xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
@@ -40,7 +40,7 @@ describe('scene background assets', () => {
'/generated-custom-world-scenes/test-world/generated-ruins.png';
const profile: CustomWorldProfile = {
id: 'custom-world-test',
settingText: '荒城断碑与边关旧营并存的武侠世界',
settingText: '荒城断碑与边关旧营并存的边城地界',
name: '断碑边城',
subtitle: '烽烟未熄',
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',

View File

@@ -22,11 +22,14 @@ import {
buildCustomWorldPlayableCharacters,
getCharacterHomeSceneId,
getCharacterNpcSceneIds,
PRESET_CHARACTERS,
ROLE_TEMPLATE_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import {
getRuntimeCustomWorldProfile,
resolveCompatibilityTemplateWorldType,
} from './customWorldRuntime';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
@@ -111,7 +114,8 @@ function buildImagePath(packName: string, imageNumber: number) {
}
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
const refs: string[] = [];
let globalIndex = 0;
@@ -571,7 +575,7 @@ function resolveSceneNpcGender(
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
const npcs: SceneNpc[] = [];
for (const character of PRESET_CHARACTERS) {
for (const character of ROLE_TEMPLATE_CHARACTERS) {
const characterId = character.id;
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
if (sceneIds.includes(sceneId)) {

View File

@@ -2,7 +2,10 @@ import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSetti
import type {CustomWorldProfile, WorldAttributeSchema} 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]: {
id: 'schema:wuxia:v1',
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>) {
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[worldType];
export function getTemplateWorldAttributeSchema(
worldType: Exclude<WorldType, WorldType.CUSTOM>,
) {
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[worldType];
}
export function getWorldAttributeSchema(
@@ -171,8 +176,8 @@ export function getWorldAttributeSchema(
}
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];
}

View File

@@ -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({
kind: 'preset-editor',
initialTab: 'items',
kind: 'game',
});
});
it('routes behavior editor paths to the functions tab', () => {
expect(matchAppRoute('/behavior-editor')).toEqual({
kind: 'preset-editor',
initialTab: 'functions',
kind: 'game',
});
});
it('accepts nested preset editor paths with trailing slashes', () => {
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
kind: 'preset-editor',
initialTab: 'npcs',
kind: 'game',
});
});
@@ -35,4 +26,10 @@ describe('matchAppRoute', () => {
kind: 'game',
});
});
it('keeps the sprite tool route', () => {
expect(matchAppRoute('/sprite-tool')).toEqual({
kind: 'qwen-sprite-tool',
});
});
});

View File

@@ -2,8 +2,6 @@
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
import type { PresetEditorTab } from '../components/PresetEditor';
type AppRouteComponent = LazyExoticComponent<
ComponentType<Record<string, unknown>>
>;
@@ -12,10 +10,6 @@ export type AppRouteMatch =
| {
kind: 'game';
}
| {
kind: 'preset-editor';
initialTab: PresetEditorTab;
}
| {
kind: 'qwen-sprite-tool';
};
@@ -29,43 +23,10 @@ export type ResolvedAppRoute = {
};
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(
() => import('../tools/QwenSpriteSheetTool'),
) 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 = [
'/qwen-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 {
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 {
kind: 'game',
loadingEyebrow: '正在载入游戏',

View File

@@ -1,5 +1,5 @@
import {validateWorldAttributeSchema} from '../data/attributeValidation';
import {getPresetWorldAttributeSchema} from '../data/worldAttributeSchemas';
import {getTemplateWorldAttributeSchema} from '../data/worldAttributeSchemas';
import type {
AttributeSchemaGenerationInput,
WorldAttributeSchema,
@@ -96,17 +96,17 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
return {
schemaName: '叙境六维',
slots: getPresetWorldAttributeSchema(WorldType.WUXIA).slots,
slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots,
};
}
export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInput) {
if (input.worldType === WorldType.WUXIA) {
return getPresetWorldAttributeSchema(WorldType.WUXIA);
return getTemplateWorldAttributeSchema(WorldType.WUXIA);
}
if (input.worldType === WorldType.XIANXIA) {
return getPresetWorldAttributeSchema(WorldType.XIANXIA);
return getTemplateWorldAttributeSchema(WorldType.XIANXIA);
}
const generated = buildCustomThemeSlots(input);
@@ -116,7 +116,7 @@ export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInp
if (issues.length > 0) {
const fallbackWorldType = /||||/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA;
return {
...getPresetWorldAttributeSchema(fallbackWorldType),
...getTemplateWorldAttributeSchema(fallbackWorldType),
id: `schema:custom-fallback:${input.worldName}`,
worldId: `custom:${input.worldName}`,
generatedFrom: {

View File

@@ -20,6 +20,7 @@ import {
createAutoAuthCredentials,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
getAuthSessions,
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', () => {
const replaceStateMock = vi.fn();
vi.stubGlobal('window', {

View File

@@ -3,8 +3,9 @@ import type {
AuthAuditLogsResponse,
AuthCaptchaChallenge,
AuthEntryResponse,
AuthLiftRiskBlockResponse,
AuthLoginMethod,
AuthLoginOptionsResponse,
AuthLiftRiskBlockResponse,
AuthLogoutAllResponse,
AuthMeResponse,
AuthPhoneChangeResponse,
@@ -30,6 +31,7 @@ import {
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
export type AutoAuthCredentials = {
username: string;
@@ -207,6 +209,16 @@ export async function startWechatLogin() {
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) {
const credentials = normalizeCredentials({ username, password });
const response = await requestJson<AuthEntryResponse>(

View File

@@ -59,8 +59,8 @@ export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
function describeWorld(world: WorldType) {
if (world === WorldType.WUXIA) return '武侠';
if (world === WorldType.XIANXIA) return '仙侠';
if (world === WorldType.WUXIA) return '边城模板';
if (world === WorldType.XIANXIA) return '灵潮模板';
return '自定义世界';
}

View File

@@ -126,6 +126,7 @@ export interface CustomWorldGenerationFramework {
tone: string;
playerGoal: string;
templateWorldType: WorldType;
compatibilityTemplateWorldType: WorldType;
majorFactions: string[];
coreConflicts: string[];
camp: CustomWorldGenerationCampOutline;
@@ -619,6 +620,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
tone,
playerGoal,
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: [],
coreConflicts: [summary],
attributeSchema: generateWorldAttributeSchema({
@@ -674,6 +676,8 @@ export function normalizeCustomWorldGenerationFramework(
tone: fallback.tone,
playerGoal: fallback.playerGoal,
templateWorldType: fallback.templateWorldType,
compatibilityTemplateWorldType:
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
@@ -710,6 +714,7 @@ export function normalizeCustomWorldGenerationFramework(
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: normalizeCampOutline(item.camp, {
@@ -744,6 +749,7 @@ export function buildCustomWorldRawProfileFromFramework(
tone: framework.tone,
playerGoal: framework.playerGoal,
templateWorldType: framework.templateWorldType,
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
@@ -1136,6 +1142,7 @@ export function normalizeCustomWorldProfile(
tone,
playerGoal,
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
attributeSchema: coerceWorldAttributeSchema(

View File

@@ -8,6 +8,7 @@ import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { CustomWorldProfile, WorldType } from '../types';
import { normalizeCustomWorldProfile } from './customWorld';
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
import { resolveCustomWorldCompatibilityTemplateWorldType } from './customWorldTheme';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -161,7 +162,9 @@ export function buildExpandedCustomWorldProfile(
description: clampText(landmark.description, 96),
dangerLevel:
landmark.dangerLevel ||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
(resolveCustomWorldCompatibilityTemplateWorldType(profile) === WorldType.XIANXIA
? 'high'
: 'medium'),
}));
const landmarkIdByReference = new Map<string, string>();
landmarkDrafts.forEach((landmark) => {

View File

@@ -12,7 +12,11 @@ import {
type SceneArchetypeBucket,
WorldType,
} from '../types';
import { type CustomWorldThemeMode, detectCustomWorldThemeMode } from './customWorldTheme';
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
resolveCustomWorldCompatibilityTemplateWorldType,
} from './customWorldTheme';
import {
buildThemePackFromWorldProfile,
normalizeThemePack,
@@ -407,7 +411,7 @@ function buildThemePackSeed(profile: CustomWorldProfile) {
summary: profile.summary,
tone: profile.tone,
playerGoal: profile.playerGoal,
templateWorldType: profile.templateWorldType,
templateWorldType: resolveCustomWorldCompatibilityTemplateWorldType(profile),
majorFactions: profile.majorFactions,
coreConflicts: profile.coreConflicts,
ownedSettingLayers: null,
@@ -502,8 +506,12 @@ function compileReferenceProfile(
}
function compileCompatibilityProfile(profile: CustomWorldProfile) {
const compatibilityTemplateWorldType =
resolveCustomWorldCompatibilityTemplateWorldType(profile);
return {
legacyTemplateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
compatibilityTemplateWorldType,
legacyTemplateWorldType: compatibilityTemplateWorldType,
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
} satisfies CustomWorldCompatibilityProfile;
}
@@ -629,6 +637,8 @@ export function compileOwnedSettingLayersFromLegacyTemplate(
tone: profile.tone,
playerGoal: profile.playerGoal,
templateWorldType: profile.templateWorldType,
compatibilityTemplateWorldType:
profile.compatibilityTemplateWorldType ?? profile.templateWorldType,
ownedSettingLayers: null,
});
const semanticAnchor = compileSemanticAnchor(profile, mode);
@@ -920,6 +930,12 @@ export function normalizeCustomWorldOwnedSettingLayers(
),
},
compatibilityProfile: {
compatibilityTemplateWorldType:
compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.WUXIA
? WorldType.WUXIA
: fallback.compatibilityProfile?.compatibilityTemplateWorldType ?? null,
legacyTemplateWorldType:
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA

View File

@@ -16,6 +16,7 @@ export function detectCustomWorldThemeMode(
| 'tone'
| 'playerGoal'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'ownedSettingLayers'
>,
): CustomWorldThemeMode {
@@ -45,17 +46,36 @@ export function detectCustomWorldThemeMode(
return 'mythic';
}
export function resolveCustomWorldAnchorWorldType(
export function resolveCustomWorldCompatibilityTemplateWorldType(
profile: Pick<
CustomWorldProfile,
| 'settingText'
| 'summary'
| 'tone'
| 'playerGoal'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'ownedSettingLayers'
>,
> &
Partial<
Pick<
CustomWorldProfile,
'settingText' | 'summary' | 'tone' | 'playerGoal'
>
>,
): 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 =
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
@@ -66,6 +86,24 @@ export function resolveCustomWorldAnchorWorldType(
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;
}
export const resolveCustomWorldAnchorWorldType =
resolveCustomWorldCompatibilityTemplateWorldType;

View File

@@ -469,8 +469,8 @@ function describeAnimationLabel(animation: string | null | undefined) {
}
export function describeWorld(world: WorldType) {
if (world === WorldType.WUXIA) return '武侠';
if (world === WorldType.XIANXIA) return '仙侠';
if (world === WorldType.WUXIA) return '边城模板';
if (world === WorldType.XIANXIA) return '灵潮模板';
return '自定义世界';
}

View File

@@ -5,9 +5,9 @@ import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '武侠';
return '边城模板';
case 'XIANXIA':
return '仙侠';
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,8 @@ import {
buildAnimationClipFromVideoSource,
GENERATED_FRAME_HEIGHT,
GENERATED_FRAME_WIDTH,
} from '../components/preset-editor/characterAssetStudioModel';
import { generateCharacterAnimationDraft } from '../components/preset-editor/characterAssetStudioPersistence';
} from '../components/asset-studio/characterAssetWorkflowModel';
import { generateCharacterAnimationDraft } from '../components/asset-studio/characterAssetWorkflowPersistence';
import {
NumberField,
SelectField,

View File

@@ -170,6 +170,7 @@ export interface CustomWorldExpressionProfile {
}
export interface CustomWorldCompatibilityProfile {
compatibilityTemplateWorldType?: WorldTemplateType | null;
legacyTemplateWorldType?: WorldTemplateType | null;
migrationVersion: string;
}
@@ -319,6 +320,7 @@ export interface CustomWorldProfile {
tone: string;
playerGoal: string;
templateWorldType: WorldTemplateType;
compatibilityTemplateWorldType?: WorldTemplateType | null;
majorFactions: string[];
coreConflicts: string[];
attributeSchema: WorldAttributeSchema;

View File

@@ -46,9 +46,9 @@ export function getNineSliceStyle(
return style;
}
export const WORLD_SELECT_ICONS = {
wuxia: '/Icons/38_sword.png',
xianxia: '/Icons/72_magic.png',
export const CUSTOM_WORLD_THEME_ICONS = {
martial: '/Icons/38_sword.png',
arcane: '/Icons/72_magic.png',
} as const;
export const BRAND_ASSETS = {
@@ -58,18 +58,6 @@ export const BRAND_ASSETS = {
export const UI_CHROME = {
appBackground: '/UI/Background_fill.png',
// 图源 125×28上下 slice 之和必须 < 28否则中间行高度为 0border-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: {
src: '/UI/pick_hero_frame.png',
slice: { top: 18, right: 18, bottom: 18, left: 18 },