Refine NPC interactions and runtime item generation
This commit is contained in:
133
docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md
Normal file
133
docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 物品生成系统与 Build 标签系统 PRD 落地审计
|
||||
|
||||
审计时间:2026-04-05
|
||||
|
||||
审计范围:
|
||||
|
||||
- `docs/prd/AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md`
|
||||
- `docs/prd/RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md`
|
||||
- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md`
|
||||
- `docs/EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md`
|
||||
|
||||
## 结论速览
|
||||
|
||||
### 1. 物品生成系统
|
||||
|
||||
当前状态可以判定为:**主链已补齐落地**。
|
||||
|
||||
已经落地的是:
|
||||
|
||||
- 有独立的运行时上下文层、导演层、本地编译层、叙事回写层。
|
||||
- 宝藏、怪物掉落、通用 NPC 商店已经能走统一的 runtime item director。
|
||||
- 永久 build 标签物品、限时 build buff 物品、少量数值物品三种骨架都已经能编译出来,并接进现有背包 / 装备 / build 结算。
|
||||
|
||||
本轮已补齐的是:
|
||||
|
||||
- 新增了 runtime item AI 意图导演与 prompt,并接入 NPC 帮助奖励主链,失败时自动回退到本地导演。
|
||||
- NPC 交易库存改成按玩家当前 build 生成,并通过 `tradeStockSignature` 只在 build 变化时刷新。
|
||||
- NPC 帮助奖励、委托奖励已经统一接入 runtime item director。
|
||||
- 怪物掉落已经改成“基础掉落 + 语义掉落”双层叠加。
|
||||
|
||||
### 2. Build 标签系统
|
||||
|
||||
当前状态可以判定为:**核心已按 PRD 落地,且实现范围比 PRD 更大**。
|
||||
|
||||
已经落地的是:
|
||||
|
||||
- `BuildTagDefinition.attributeAffinity` 已扩展。
|
||||
- `buildDamage.ts` 已改成“标签分别匹配角色属性画像”的加法模型,不再做标签两两网络效应。
|
||||
- Buff / 角色固有 / 武器 / 护甲 / 饰品 / 套装标签都能进入最终倍率。
|
||||
- Character 面板已经切到“属性适配度”展示,并能拆出单标签的属性贡献明细。
|
||||
- 有针对新公式的测试覆盖。
|
||||
|
||||
还存在的尾项主要是:
|
||||
|
||||
- 实现用的是“世界属性 schema 六轴模型”,不是 PRD 文字里的固定四维属性。
|
||||
- 旧的标签相似度辅助能力没有完全清干净,重铸仍在用 `getSimilarBuildTags` 做候选标签替换。
|
||||
|
||||
## 物品生成系统审计
|
||||
|
||||
| PRD 项 | 当前实现 | 判定 | 代码证据 |
|
||||
| --- | --- | --- | --- |
|
||||
| 上下文采样层 | 已有 `buildRuntimeItemGenerationContext` / `buildQuestRuntimeItemGenerationContext`,会收集场景、遭遇、关联 NPC、最近剧情、玩家 build 标签与 build gap。 | 已落地 | `src/data/runtimeItemContext.ts:157-188`、`src/data/runtimeItemContext.ts:191-252` |
|
||||
| AI 意图层 | 已新增 `runtimeItemAiDirector` / `runtimeItemAiPrompt`,`buildRuntimeItemAiPromptInput` 已进入真实 prompt 组装;NPC 帮助奖励主链会先请求 AI 物品意图,再回落到本地意图导演。 | 已补齐 | `src/services/runtimeItemAiPrompt.ts`、`src/services/runtimeItemAiDirector.ts`、`src/data/runtimeItemDirector.ts`、`src/hooks/story/npcEncounterActions.ts` |
|
||||
| 本地编译层 | 已按 channel / slot / permanence 做 rarity 与预算编译,并产出 `statProfile`、`useProfile.buildBuffs`、`buildProfile`、`runtimeMetadata`。 | 已落地 | `src/data/runtimeItemCompiler.ts:77-101`、`src/data/runtimeItemCompiler.ts:122-206`、`src/data/runtimeItemCompiler.ts:245-276` |
|
||||
| 叙事回写层 | 会把锚点、来源理由、build 倾向回写进物品名和描述。 | 已落地 | `src/data/runtimeItemNarrative.ts:171-189` |
|
||||
| 永久标签 / 限时标签 / 少量数值三类物品 | 永久物品走 `buildProfile`,限时物品走 `useProfile.buildBuffs`,并可附带少量数值。 | 已落地 | `src/data/runtimeItemCompiler.ts:104-155`、`src/data/runtimeItemCompiler.ts:157-206` |
|
||||
| 宝藏入口 | 宝藏奖励已经走 `buildRuntimeItemGenerationContext + buildDirectedRuntimeReward`,并把结果写回 story hint。 | 已落地 | `src/data/treasureInteractions.ts:52-89`、`src/hooks/useTreasureFlow.ts:45-75` |
|
||||
| NPC 交易入口 | 通用 NPC 商店已改成按玩家当前 build 生成;初始 NPC 状态、交易模态打开时刷新、场景预览读取都会带上完整 `GameState`,并用 `tradeStockSignature` 避免无意义重刷。角色型 NPC 仍保留既有角色装备/背包模板。 | 已补齐 | `src/data/npcInteractions.ts`、`src/hooks/story/npcInteraction.ts`、`src/hooks/useStoryGeneration.ts`、`src/components/NpcModals.tsx` |
|
||||
| NPC 帮助 / 关系奖励 | `npc_reward` 已真正接入主链。帮助奖励现在会生成带 `runtimeMetadata` 的 runtime item,并保留数值恢复、冷却缩减与 story hint。 | 已补齐 | `src/data/npcInteractions.ts`、`src/hooks/story/npcEncounterActions.ts` |
|
||||
| 委托奖励入口 | Quest reward 已改为走 runtime director,支持 `buildQuestRuntimeItemGenerationContext`,领取时发放的是 runtime item。主 NPC 接任务流程也已切到 `generateQuestForNpcEncounter`。 | 已补齐 | `src/data/questFlow.ts`、`src/hooks/story/npcEncounterActions.ts`、`src/services/questDirector.ts` |
|
||||
| 怪物掉落双层设计 | 普通世界掉落已改成预设 `lootTable` 基础掉落与 `monster_drop` runtime 语义掉落并存,不再互相覆盖。 | 已补齐 | `src/data/hostileNpcPresets.ts`、`src/data/hostileNpcPresets.test.ts` |
|
||||
|
||||
### 物品系统的具体判断
|
||||
|
||||
#### 已经明显符合 PRD 的部分
|
||||
|
||||
1. 系统分层已经形成。
|
||||
`runtimeItemContext -> runtimeItemDirector -> runtimeItemCompiler -> runtimeItemNarrative` 这条链已经非常接近 PRD 里“上下文采样 / AI 意图 / 本地编译 / 叙事回写”的结构。
|
||||
|
||||
2. build 导向优先于纯数值。
|
||||
`buildRuntimeItemContext` 会先算 `playerBuildTags` 和 `playerBuildGaps`,导演层再优先把 gap tag 与现有 build tag 拼进 `targetBuildDirection`,编译层才决定数值预算。
|
||||
|
||||
3. 奖励已经进入真实玩法结算。
|
||||
runtime item 生成出的 `buildProfile` 会进入装备 build 结算,`useProfile.buildBuffs` 会在使用物品时写入 `activeBuildBuffs`。
|
||||
|
||||
#### 本轮补齐后的说明
|
||||
|
||||
1. 运行时物品意图已经进入真实主链。
|
||||
当前至少在 NPC 帮助奖励链路中,已经先走 AI 意图导演,再走本地编译与叙事回写;如果模型不可用,会回退到既有启发式导演,保证玩法不断。
|
||||
|
||||
2. 交易库存已经真正读取玩家当前构筑。
|
||||
通用交易 NPC 的库存会基于玩家当前 build、装备标签和 build gap 生成,而不是继续依赖 NPC 自身偏好标签。
|
||||
|
||||
3. 帮助奖励、委托奖励、怪物掉落都已并入统一 runtime director。
|
||||
现在三条链路都能产出带 relation anchor / source reason / runtime metadata 的 runtime item。
|
||||
|
||||
## Build 标签系统审计
|
||||
|
||||
| PRD 项 | 当前实现 | 判定 | 代码证据 |
|
||||
| --- | --- | --- | --- |
|
||||
| `BuildTagDefinition.attributeAffinity` 扩展 | 类型已扩展,标签注册表也会为每个标签注入 affinity。 | 已落地 | `src/types/build.ts:6-13`、`src/data/buildTags.ts:66-69` |
|
||||
| 静态标签亲和度表 | 已有 `buildTagAttributeAffinity.ts`,提供标签到属性轴的静态 affinity 表。 | 已落地 | `src/data/buildTagAttributeAffinity.ts:127-183` |
|
||||
| 从“标签互相影响”改为“标签分别匹配角色属性” | `buildDamage.ts` 已按单标签计算 `fitScore`、`bonusDelta` 和属性贡献,最后做加法累积,不再做 pair/cluster 乘法网络。 | 已落地 | `src/data/buildDamage.ts:236-318` |
|
||||
| 来源系数 | Buff / 角色 / 武器 / 护甲 / 饰品 / 套装都有独立 source coefficient,和 PRD 基本一致。 | 已落地 | `src/data/buildDamage.ts:95-114` |
|
||||
| 最终伤害接入 | `resolvePlayerOutgoingDamage` / `resolveCompanionOutgoingDamage` / `resolveMonsterOutgoingDamage` 都已接 `buildDamageMultiplier`。 | 已落地 | `src/data/buildDamage.ts:498-542` |
|
||||
| 展示层从“标签协同”改成“属性适配度” | Character 面板已经按标签展示 bonus、来源、主导属性,并能打开明细弹窗。 | 已落地 | `src/components/CharacterPanel.tsx:205-247`、`src/components/CharacterPanel.tsx:497-539` |
|
||||
| 验收测试 | 已覆盖单标签可拆分、删一个标签不重算其余标签、不同属性角色用同一套装倍率不同、Buff/套装来源正常进入等场景。 | 已落地 | `src/data/buildDamage.test.ts:125-313` |
|
||||
| 四维属性口径 | PRD 写的是四维固定属性;实现已经提升为 world schema 六轴模型。目标一致,但口径不再一一对应。 | 已落地,但实现已外延扩展 | `src/types/attributes.ts:3-15`、`src/data/worldAttributeSchemas.ts:4-155` |
|
||||
| 旧标签相似度清理 | 核心伤害结算已不再依赖旧矩阵,但 `getSimilarBuildTags` 仍存在,重铸继续用它挑候选标签,`generate:build-tags` 脚本也还保留。 | 收尾未完成 | `src/data/buildTags.ts:183-215`、`src/data/forgeSystem.ts:371-399`、`package.json:21-24` |
|
||||
|
||||
### Build 系统的具体判断
|
||||
|
||||
#### 已经符合 PRD 核心目标的部分
|
||||
|
||||
1. 可解释性已经建立。
|
||||
现在每个标签都有自己的 `fitScore`、`attributeContributions` 和 `attributeModifierDeltas`,玩家可以看到“这个标签为什么强、强在哪条属性轴上”。
|
||||
|
||||
2. 新增标签不会反向扰动旧标签贡献。
|
||||
`buildDamage.test.ts` 已专门验证“删掉一个标签,只会移除它自己的 row,不会重算其他 row”。
|
||||
|
||||
3. 套装标签、Buff 标签、装备标签都能统一进入同一公式。
|
||||
这点和 PRD 的来源规则一致,且测试已经覆盖。
|
||||
|
||||
#### 需要注意但不构成核心未落地的问题
|
||||
|
||||
1. 实现已经超出 PRD 的四维设计。
|
||||
现在不是固定 `strength / agility / intelligence / spirit` 四维,而是通过 `WorldAttributeSchema` 映射成武侠/仙侠/自定义世界都能共用的语义属性轴。这更适合当前仓库的世界化属性系统,但也意味着 PRD 文案需要同步。
|
||||
|
||||
2. 旧相似度能力没有完全退场。
|
||||
核心伤害结算已经不用标签两两矩阵,但锻造重铸仍然会根据标签相似度挑换洗标签,所以“旧体系相关命名/脚本”还没彻底收尾。
|
||||
|
||||
## 综合判断
|
||||
|
||||
如果按“是否已经把 PRD 的主战场落到代码里”来判断:
|
||||
|
||||
- **Build 标签系统:可以认为已经落地。**
|
||||
- **物品生成系统:可以认为主链已经落地,之前审计出的缺口已在本轮补齐。**
|
||||
|
||||
如果接下来继续做收尾,更建议做的是:
|
||||
|
||||
1. 把 runtime item AI 意图层继续扩到宝藏、怪物掉落、委托奖励以外的更多同步入口,减少启发式 fallback 的覆盖面。
|
||||
2. 给 Build 系统补一版文档同步,明确当前实现已经从 PRD 四维模型升级为世界属性 schema 模型,并清理剩余旧相似度脚本/命名。
|
||||
3. 评估 `origin: 'ai_compiled'` 的语义是否还要细分成“AI 意图 + 本地编译”与“纯本地 fallback”两档,方便后续观测与埋点。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ function AdventurePanelOverlayLoadingFallback() {
|
||||
className="pixel-nine-slice pixel-modal-shell flex min-h-32 w-full max-w-sm items-center justify-center px-5 py-6 text-center text-[11px] uppercase tracking-[0.24em] text-zinc-400 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
>
|
||||
Loading adventure panels
|
||||
正在载入冒险面板
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -755,7 +755,7 @@ export function AdventurePanel({
|
||||
key: 'quests-completed',
|
||||
label: '完成任务',
|
||||
value: `${statistics.questsCompleted}`,
|
||||
detail: `已接 ${statistics.questsAccepted} / 已交<EFBFBD>?${statistics.questsTurnedIn}`,
|
||||
detail: `已接 ${statistics.questsAccepted} / 已交 ${statistics.questsTurnedIn}`,
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
@@ -776,7 +776,7 @@ export function AdventurePanel({
|
||||
key: 'inventory',
|
||||
label: '背包物品',
|
||||
value: `${statistics.inventoryItemCount}`,
|
||||
detail: `${statistics.inventoryStackCount} 组物<EFBFBD>?/ 使用 ${statistics.itemsUsed} 次`,
|
||||
detail: `${statistics.inventoryStackCount} 组物品 / 使用 ${statistics.itemsUsed} 次`,
|
||||
icon: Backpack,
|
||||
},
|
||||
{
|
||||
@@ -790,7 +790,7 @@ export function AdventurePanel({
|
||||
key: 'scene',
|
||||
label: '当前区域',
|
||||
value: statistics.currentSceneName,
|
||||
detail: '本次冒险所在地<EFBFBD>?',
|
||||
detail: '本次冒险所在地',
|
||||
icon: MapPinned,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,72 +1,164 @@
|
||||
type AffinityLevelMeta = {
|
||||
value: number;
|
||||
label: string;
|
||||
minAffinity: number;
|
||||
nextAffinity: number | null;
|
||||
description: string;
|
||||
accentClassName: string;
|
||||
};
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL: AffinityLevelMeta = {
|
||||
value: 0,
|
||||
label: '戒备',
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
type AffinityProgressMarker = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const AFFINITY_PROGRESS_MIN = -40;
|
||||
const AFFINITY_PROGRESS_MAX = 90;
|
||||
|
||||
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
DEFAULT_AFFINITY_LEVEL,
|
||||
{
|
||||
value: 15,
|
||||
label: '敌对',
|
||||
minAffinity: Number.NEGATIVE_INFINITY,
|
||||
nextAffinity: 0,
|
||||
description:
|
||||
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
|
||||
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
|
||||
},
|
||||
{
|
||||
label: '戒备',
|
||||
minAffinity: 0,
|
||||
nextAffinity: 15,
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
},
|
||||
{
|
||||
label: '缓和',
|
||||
minAffinity: 15,
|
||||
nextAffinity: 30,
|
||||
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
},
|
||||
{
|
||||
value: 30,
|
||||
label: '友善',
|
||||
minAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
label: '信任',
|
||||
minAffinity: 60,
|
||||
nextAffinity: 90,
|
||||
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
|
||||
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
},
|
||||
{
|
||||
value: 90,
|
||||
label: '深交',
|
||||
minAffinity: 90,
|
||||
nextAffinity: null,
|
||||
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
|
||||
|
||||
const AFFINITY_PROGRESS_MARKERS: AffinityProgressMarker[] = [
|
||||
{ value: -40, label: '敌对' },
|
||||
{ value: 0, label: '戒备' },
|
||||
{ value: 15, label: '缓和' },
|
||||
{ value: 30, label: '友善' },
|
||||
{ value: 60, label: '信任' },
|
||||
{ value: 90, label: '深交' },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getAffinityLevelMeta(affinity: number) {
|
||||
return [...AFFINITY_LEVELS].reverse().find(level => affinity >= level.value) ?? DEFAULT_AFFINITY_LEVEL;
|
||||
return (
|
||||
[...AFFINITY_LEVELS]
|
||||
.reverse()
|
||||
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
|
||||
);
|
||||
}
|
||||
|
||||
function getNextAffinityLevelMeta(affinity: number) {
|
||||
return AFFINITY_LEVELS.find(level => affinity < level.value) ?? null;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({affinity}: {affinity: number}) {
|
||||
function getNextAffinityMarker(affinity: number) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityLevelMeta(affinity);
|
||||
const maxVisibleAffinity = AFFINITY_LEVELS[AFFINITY_LEVELS.length - 1]?.value ?? 1;
|
||||
const progress = Math.max(0, Math.min(1, affinity / maxVisibleAffinity));
|
||||
if (currentLevel.nextAffinity == null) return null;
|
||||
|
||||
return (
|
||||
AFFINITY_PROGRESS_MARKERS.find(
|
||||
(marker) => marker.value === currentLevel.nextAffinity,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getAffinityProgressRatio(value: number) {
|
||||
return clamp(
|
||||
(value - AFFINITY_PROGRESS_MIN) /
|
||||
(AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
function getAnchorTransform(ratio: number) {
|
||||
if (ratio <= 0.02) return 'translateX(0)';
|
||||
if (ratio >= 0.98) return 'translateX(-100%)';
|
||||
return 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
|
||||
if (marker.value < 0) {
|
||||
return affinity < 0;
|
||||
}
|
||||
|
||||
return affinity >= marker.value;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityMarker(affinity);
|
||||
const currentRatio = getAffinityProgressRatio(affinity);
|
||||
const zeroRatio = getAffinityProgressRatio(0);
|
||||
const activeMarkerValue =
|
||||
currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN
|
||||
? AFFINITY_PROGRESS_MIN
|
||||
: currentLevel.minAffinity;
|
||||
const fillLeftRatio = Math.min(currentRatio, zeroRatio);
|
||||
const fillWidthRatio = Math.abs(currentRatio - zeroRatio);
|
||||
const fillWidthPercent =
|
||||
fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%';
|
||||
const currentPointerTone =
|
||||
affinity < 0
|
||||
? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]'
|
||||
: 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]';
|
||||
const fillGradient =
|
||||
affinity < 0
|
||||
? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">好感等级</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感等级
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
|
||||
>
|
||||
{currentLevel.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">当前好感 {affinity}</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
当前好感 {affinity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{nextLevel ? (
|
||||
<>
|
||||
@@ -83,76 +175,117 @@ export function AffinityStatusCard({affinity}: {affinity: number}) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{currentLevel.description}</p>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{currentLevel.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">好感进度</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">节点数值表示进入对应等级所需的好感度。</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感进度
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
|
||||
0 是战斗分界线,低于 0
|
||||
会直接进入对战;其余节点表示进入对应阶段所需的最低好感。
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 pt-1">
|
||||
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-white/18 to-transparent" />
|
||||
<div className="relative mt-5 pb-12 pt-1 sm:pb-14">
|
||||
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
|
||||
<div
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-full bg-gradient-to-r from-sky-300 via-amber-300 to-rose-300 shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{width: `${progress * 100}%`}}
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-l-full bg-gradient-to-r from-rose-500/18 via-rose-400/10 to-transparent"
|
||||
style={{ width: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-r-full bg-gradient-to-r from-sky-400/10 via-amber-300/10 to-rose-400/16"
|
||||
style={{
|
||||
left: `${zeroRatio * 100}%`,
|
||||
width: `${(1 - zeroRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[0.55rem] h-5 w-px bg-white/20"
|
||||
style={{ left: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-full shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{
|
||||
left: `${fillLeftRatio * 100}%`,
|
||||
width: fillWidthPercent,
|
||||
background: fillGradient,
|
||||
}}
|
||||
/>
|
||||
|
||||
{AFFINITY_LEVELS.map((level, index) => {
|
||||
const ratio = maxVisibleAffinity > 0 ? level.value / maxVisibleAffinity : 0;
|
||||
const isReached = affinity >= level.value;
|
||||
const isCurrent = currentLevel.value === level.value;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === AFFINITY_LEVELS.length - 1;
|
||||
<div
|
||||
className="absolute top-[0.76rem] h-3.5 w-3.5 rounded-full border-2"
|
||||
style={{
|
||||
left: `${currentRatio * 100}%`,
|
||||
transform: getAnchorTransform(currentRatio),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-full rounded-full border ${currentPointerTone}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{AFFINITY_PROGRESS_MARKERS.map((marker) => {
|
||||
const markerRatio = getAffinityProgressRatio(marker.value);
|
||||
const isReached = isMarkerReached(marker, affinity);
|
||||
const isActive = marker.value === activeMarkerValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`affinity-level-${level.value}`}
|
||||
key={`affinity-marker-${marker.value}`}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: `${ratio * 100}%`,
|
||||
transform: isFirst ? 'translateX(0)' : isLast ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
left: `${markerRatio * 100}%`,
|
||||
transform: getAnchorTransform(markerRatio),
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-9 w-4 items-end justify-center sm:h-11 sm:w-5">
|
||||
{isCurrent ? (
|
||||
<div className="absolute bottom-0 h-8 w-3 rounded-full bg-sky-300/20 blur-[6px] sm:h-10 sm:w-4" />
|
||||
) : null}
|
||||
{isReached && !isCurrent ? (
|
||||
<div className="absolute bottom-0 h-6 w-2.5 rounded-full bg-amber-300/10 blur-[4px] sm:h-7" />
|
||||
) : null}
|
||||
<div className="flex w-12 flex-col items-center text-center sm:w-16">
|
||||
<div className="relative flex h-9 items-end justify-center sm:h-10">
|
||||
{isActive ? (
|
||||
<div
|
||||
className={`absolute bottom-0 h-6 w-3 rounded-full blur-[5px] sm:h-7 sm:w-3.5 ${
|
||||
marker.value < 0 ? 'bg-rose-300/20' : 'bg-sky-300/18'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`relative rounded-full border transition-all duration-300 ${
|
||||
isActive
|
||||
? marker.value < 0
|
||||
? 'h-7 w-2 border-rose-100/75 bg-gradient-to-b from-rose-100 via-rose-300 to-rose-500 shadow-[0_0_14px_rgba(251,113,133,0.28)] sm:h-8'
|
||||
: 'h-7 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-8'
|
||||
: isReached
|
||||
? marker.value < 0
|
||||
? 'h-5 w-1.5 border-rose-100/45 bg-gradient-to-b from-rose-200 via-rose-300 to-rose-500 shadow-[0_0_10px_rgba(251,113,133,0.22)] sm:h-6'
|
||||
: 'h-5 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-6'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-full border transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'h-8 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-10 sm:w-2.5'
|
||||
: isReached
|
||||
? 'h-6 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-7 sm:w-2'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5 sm:w-2'
|
||||
className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${
|
||||
isActive || isReached ? 'text-zinc-100' : 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
{marker.label}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${
|
||||
isActive || isReached ? 'text-zinc-300' : 'text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{marker.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-5 gap-1 pt-10 sm:gap-2 sm:pt-12">
|
||||
{AFFINITY_LEVELS.map(level => {
|
||||
const isReached = affinity >= level.value;
|
||||
|
||||
return (
|
||||
<div key={`affinity-label-${level.value}`} className="text-center">
|
||||
<div className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${isReached ? 'text-zinc-100' : 'text-zinc-500'}`}>
|
||||
{level.label}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${isReached ? 'text-zinc-300' : 'text-zinc-600'}`}>
|
||||
{level.value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +260,7 @@ export function CharacterDetailModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="Skills">
|
||||
<Section title="技能">
|
||||
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ function StatusRow({
|
||||
);
|
||||
}
|
||||
|
||||
function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未明';
|
||||
}
|
||||
|
||||
const SKILL_STYLE_LABELS = {
|
||||
burst: '爆发',
|
||||
steady: '稳态',
|
||||
@@ -427,7 +433,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 text-xs font-bold text-white">闃熶紞鎴愬憳</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
||||
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
||||
{partyMembers.map(member => (
|
||||
<button
|
||||
@@ -462,7 +468,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0} 鏍囩
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0} 标签
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
|
||||
{'\u9002\u914d'} x{buildBreakdownByMemberId[member.id]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
||||
@@ -592,12 +598,12 @@ export function CharacterPanel({
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">瑙掕壊璇︽儏</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">角色详情</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedMember.character.name}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
<span>{selectedMember.character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
{selectedMember.character.gender === 'female' ? 'Female' : selectedMember.character.gender === 'male' ? 'Male' : 'Unknown'}
|
||||
{getGenderLabel(selectedMember.character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -630,7 +636,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">Status</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">状态</div>
|
||||
<div className="space-y-3">
|
||||
<StatusRow label={resourceLabels.hp} current={selectedMember.hp} max={selectedMember.maxHp} tone="hp" />
|
||||
<StatusRow label={resourceLabels.mp} current={selectedMember.mana} max={selectedMember.maxMana} tone="mp" />
|
||||
@@ -658,14 +664,14 @@ export function CharacterPanel({
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">鑳屾櫙鏁呬簨</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">背景故事</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.backstory}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">鎬ф牸</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">性格</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.personality}
|
||||
</div>
|
||||
@@ -677,7 +683,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">瑁呭</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">装备</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
{selectedEquipmentRows.map(item => (
|
||||
<div
|
||||
|
||||
@@ -37,36 +37,36 @@ function buildCampMoments(
|
||||
reserveCompanions: CompanionCardData[],
|
||||
) {
|
||||
if (!playerCharacter) {
|
||||
return ['Camp not ready yet.'];
|
||||
return ['营地尚未准备完毕。'];
|
||||
}
|
||||
|
||||
const moments: string[] = [];
|
||||
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
|
||||
moments.push(`${playerCharacter.name} sits by the fire alone, with no fixed companions yet.`);
|
||||
moments.push(`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`);
|
||||
}
|
||||
|
||||
if (activeCompanions.length >= 2) {
|
||||
const firstCompanion = activeCompanions[0];
|
||||
const secondCompanion = activeCompanions[1];
|
||||
if (firstCompanion && secondCompanion) {
|
||||
moments.push(`${firstCompanion.character.name} and ${secondCompanion.character.name} are quietly planning the next route.`);
|
||||
moments.push(`${firstCompanion.character.name}和${secondCompanion.character.name}正低声商量下一段路怎么走。`);
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
|
||||
if (trustedCompanion) {
|
||||
moments.push(`${trustedCompanion.character.name} checks the supplies with practiced ease and already feels like a trusted partner.`);
|
||||
moments.push(`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`);
|
||||
}
|
||||
|
||||
if (reserveCompanions.length > 0) {
|
||||
const reserveCompanion = reserveCompanions[0];
|
||||
if (reserveCompanion) {
|
||||
moments.push(`${reserveCompanion.character.name} is waiting in camp and can rejoin the team at any time.`);
|
||||
moments.push(`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`);
|
||||
}
|
||||
}
|
||||
|
||||
if (moments.length === 0) {
|
||||
moments.push(`${playerCharacter.name} looks over the camp and confirms everyone is in position.`);
|
||||
moments.push(`${playerCharacter.name}环视营地,确认众人都已经各就各位。`);
|
||||
}
|
||||
|
||||
return moments.slice(0, 3);
|
||||
@@ -139,9 +139,9 @@ export function CompanionCampModal({
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-sm font-semibold text-white">Camp Formation</div>
|
||||
<div className="text-sm font-semibold text-white">营地编组</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{playerCharacter ? `${playerCharacter.name} / Active ${companions.length}/${MAX_COMPANIONS}` : 'Party Management'}
|
||||
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -157,16 +157,16 @@ export function CompanionCampModal({
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">Active Team</div>
|
||||
<div className="text-xs font-bold text-white">当前队伍</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Bench a companion directly, or choose a swap target before bringing in a reserve member.
|
||||
可直接把同行者转入后备,或先选定替换位,再让后备成员归队。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="Active" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
</div>
|
||||
{inBattle && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Formation changes are disabled during battle.
|
||||
战斗中无法调整编组。
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -191,9 +191,9 @@ export function CompanionCampModal({
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +204,7 @@ export function CompanionCampModal({
|
||||
onClick={() => setSelectedSwapNpcId(companion.npcId)}
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
Set Swap Slot
|
||||
设为替换位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,14 +212,14 @@ export function CompanionCampModal({
|
||||
onClick={() => onBenchCompanion(companion.npcId)}
|
||||
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
Move to Reserve
|
||||
转入后备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
No active companions right now.
|
||||
当前没有已出战的同行者。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -228,12 +228,12 @@ export function CompanionCampModal({
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">Reserve Team</div>
|
||||
<div className="text-xs font-bold text-white">后备队伍</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Reserve companions stay ready in camp until you call them back.
|
||||
后备同行者会在营地待命,随时可以重新召回。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="Reserve" value={`${reserveCompanionCards.length}`} />
|
||||
<StatusPill label="后备" value={`${reserveCompanionCards.length}`} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -254,9 +254,9 @@ export function CompanionCampModal({
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,13 +270,13 @@ export function CompanionCampModal({
|
||||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
}`}
|
||||
>
|
||||
{needsSwap ? 'Swap Into Team' : 'Activate'}
|
||||
{needsSwap ? '换入队伍' : '编入队伍'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
No reserve companions yet.
|
||||
当前还没有后备同行者。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -284,7 +284,7 @@ export function CompanionCampModal({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="mb-3 text-xs font-bold text-white">Camp Mood</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{campMoments.map(moment => (
|
||||
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
|
||||
@@ -162,7 +162,21 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText([role.name, role.title, role.description, role.backstory, role.personality, ...role.tags].join(' '), deferredSearch),
|
||||
|| matchText(
|
||||
[
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.motivation,
|
||||
role.combatStyle,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
@@ -170,7 +184,21 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText([npc.name, npc.role, npc.description, npc.motivation, ...npc.relationshipHooks].join(' '), deferredSearch),
|
||||
|| matchText(
|
||||
[
|
||||
npc.name,
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...npc.relationshipHooks,
|
||||
...npc.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
@@ -320,9 +348,12 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">身份:{role.role}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">初始好感:{role.initialAffinity}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">性格:{role.personality}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">战斗:{role.combatStyle}</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">动机:{role.motivation}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map(tag => (
|
||||
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
@@ -343,7 +374,7 @@ export function CustomWorldEntityCatalog({
|
||||
{activeTab === 'story' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
每个场景角色都可以单独组合中世纪奇幻角色形象,并同步到进入世界后的展示效果。
|
||||
场景角色默认可组合中世纪奇幻角色形象;当角色文本明显指向怪物型 NPC 且初始好感偏敌对时,预览也会自动尝试引用怪物素材。
|
||||
</div>
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
@@ -362,18 +393,22 @@ export function CustomWorldEntityCatalog({
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={{
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}}
|
||||
npc={npc}
|
||||
visual={npc.visual}
|
||||
className="aspect-square"
|
||||
scale={2.18}
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">头衔:{npc.title}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">初始好感:{npc.initialAffinity}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">性格:{npc.personality || '未填写'}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">战斗:{npc.combatStyle || '未填写'}</div>
|
||||
</div>
|
||||
{npc.backstory ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">背景:{npc.backstory}</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">动机:{npc.motivation}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc.relationshipHooks.map(hook => (
|
||||
@@ -381,6 +416,11 @@ export function CustomWorldEntityCatalog({
|
||||
{hook}
|
||||
</span>
|
||||
))}
|
||||
{npc.tags.map(tag => (
|
||||
<span key={`${npc.id}-tag-${tag}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,14 @@ function commaText(value: string[]) {
|
||||
return value.join(', ');
|
||||
}
|
||||
|
||||
function clampInitialAffinity(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(-40, Math.min(90, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function useDraft<T>(value: T) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
@@ -726,8 +734,8 @@ function StoryNpcVisualEditorModal({
|
||||
/>
|
||||
{isAiGenerateOpen ? (
|
||||
<AiComingSoonModal
|
||||
title="AI生成场景角色形象"
|
||||
subtitle="场景角色形象AI生成功能仍在开发中。"
|
||||
title="智能生成场景角色形象"
|
||||
subtitle="场景角色形象智能生成功能仍在开发中。"
|
||||
onClose={() => setIsAiGenerateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -900,6 +908,14 @@ function PlayableNpcEditor({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="世界身份 / 职责">
|
||||
<TextInput
|
||||
value={draft.role}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, role: value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="简介">
|
||||
<TextArea
|
||||
value={draft.description}
|
||||
@@ -927,6 +943,15 @@ function PlayableNpcEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="当前动机">
|
||||
<TextArea
|
||||
value={draft.motivation}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, motivation: value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="战斗风格">
|
||||
<TextArea
|
||||
value={draft.combatStyle}
|
||||
@@ -936,6 +961,33 @@ function PlayableNpcEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="初始好感">
|
||||
<TextInput
|
||||
type="number"
|
||||
value={draft.initialAffinity}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
initialAffinity: clampInitialAffinity(
|
||||
value,
|
||||
current.initialAffinity,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="关系切入口">
|
||||
<TextArea
|
||||
value={commaText(draft.relationshipHooks)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
relationshipHooks: parseCommaText(value),
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标签">
|
||||
<TextArea
|
||||
value={commaText(draft.tags)}
|
||||
@@ -975,21 +1027,7 @@ function StoryNpcEditor({
|
||||
onSave: (npc: CustomWorldNpc) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialDraft = useMemo(
|
||||
() => ({
|
||||
...npc,
|
||||
visual:
|
||||
npc.visual ??
|
||||
buildDefaultCustomWorldNpcVisual({
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}),
|
||||
}),
|
||||
[npc],
|
||||
);
|
||||
const [draft, setDraft] = useDraft(initialDraft);
|
||||
const [draft, setDraft] = useDraft(npc);
|
||||
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -1003,12 +1041,7 @@ function StoryNpcEditor({
|
||||
<div className="grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<div className="flex justify-center">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={{
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
description: draft.description,
|
||||
}}
|
||||
npc={draft}
|
||||
visual={draft.visual}
|
||||
className="aspect-square w-full max-w-[9.5rem]"
|
||||
scale={2.05}
|
||||
@@ -1042,6 +1075,14 @@ function StoryNpcEditor({
|
||||
/>
|
||||
</Field>
|
||||
<Field label="头衔 / 职能">
|
||||
<TextInput
|
||||
value={draft.title}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, title: value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="世界身份 / 职能">
|
||||
<TextInput
|
||||
value={draft.role}
|
||||
onChange={(value) =>
|
||||
@@ -1058,6 +1099,24 @@ function StoryNpcEditor({
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="背景">
|
||||
<TextArea
|
||||
value={draft.backstory}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, backstory: value }))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="性格">
|
||||
<TextArea
|
||||
value={draft.personality}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, personality: value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动机">
|
||||
<TextArea
|
||||
value={draft.motivation}
|
||||
@@ -1067,6 +1126,30 @@ function StoryNpcEditor({
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="战斗风格">
|
||||
<TextArea
|
||||
value={draft.combatStyle}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, combatStyle: value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="初始好感">
|
||||
<TextInput
|
||||
type="number"
|
||||
value={draft.initialAffinity}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
initialAffinity: clampInitialAffinity(
|
||||
value,
|
||||
current.initialAffinity,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="关系切入口">
|
||||
<TextArea
|
||||
value={commaText(draft.relationshipHooks)}
|
||||
@@ -1079,6 +1162,18 @@ function StoryNpcEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标签">
|
||||
<TextArea
|
||||
value={commaText(draft.tags)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
tags: parseCommaText(value),
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
@@ -1089,7 +1184,15 @@ function StoryNpcEditor({
|
||||
{isVisualEditorOpen ? (
|
||||
<StoryNpcVisualEditorModal
|
||||
npc={draft}
|
||||
visual={draft.visual!}
|
||||
visual={
|
||||
draft.visual ??
|
||||
buildDefaultCustomWorldNpcVisual({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
description: draft.description,
|
||||
})
|
||||
}
|
||||
onChange={(visual) =>
|
||||
setDraft((current) => ({ ...current, visual }))
|
||||
}
|
||||
@@ -1235,10 +1338,14 @@ function createPlayableNpc(
|
||||
),
|
||||
name: `自定义角色${profile.playableNpcs.length + 1}`,
|
||||
title: '自定义身份',
|
||||
role: '世界中的行动者',
|
||||
description: '',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['首次接触', '合作空间'],
|
||||
tags: ['自定义'],
|
||||
templateCharacterId: template?.id,
|
||||
};
|
||||
@@ -1253,21 +1360,19 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
seed,
|
||||
),
|
||||
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
|
||||
title: '自定义头衔',
|
||||
role: '自定义身份',
|
||||
description: '',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['合作', '互动'],
|
||||
tags: ['自定义'],
|
||||
} satisfies CustomWorldNpc;
|
||||
|
||||
return {
|
||||
...npc,
|
||||
visual: buildDefaultCustomWorldNpcVisual({
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}),
|
||||
};
|
||||
return npc;
|
||||
}
|
||||
|
||||
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { resolveCustomWorldNpcMonsterPreset } from '../data/customWorldNpcMonsters';
|
||||
import {
|
||||
buildBodyPath,
|
||||
buildMedievalNpcVisual,
|
||||
@@ -24,9 +25,23 @@ import {
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
|
||||
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>;
|
||||
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
|
||||
& Partial<
|
||||
Pick<
|
||||
CustomWorldNpc,
|
||||
| 'title'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'initialAffinity'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
type GearSlot = 'headgear' | 'mainHand' | 'offHand';
|
||||
|
||||
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
|
||||
@@ -286,16 +301,31 @@ export function CustomWorldNpcPortrait({
|
||||
scale?: number;
|
||||
}) {
|
||||
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
|
||||
const monsterPreset = visual
|
||||
? null
|
||||
: resolveCustomWorldNpcMonsterPreset(npc);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
|
||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
|
||||
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={previewSpec}
|
||||
scale={scale}
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
{monsterPreset ? (
|
||||
<div
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
style={{
|
||||
transform: `scale(${Math.max(1, scale * 0.72)})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<HostileNpcAnimator hostileNpc={monsterPreset} />
|
||||
</div>
|
||||
) : (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={previewSpec}
|
||||
scale={scale}
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DeveloperTeamModal({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
aria-label="Close developer team modal"
|
||||
aria-label="关闭开发团队弹窗"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
309
src/components/InventoryItemViews.tsx
Normal file
309
src/components/InventoryItemViews.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { formatCurrency, getInventoryItemValue } from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
isInventoryItemEquippable,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryCategoryIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
|
||||
case 'epic':
|
||||
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
|
||||
case 'rare':
|
||||
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
|
||||
case 'uncommon':
|
||||
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04]';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return '传说';
|
||||
case 'epic':
|
||||
return '史诗';
|
||||
case 'rare':
|
||||
return '稀有';
|
||||
case 'uncommon':
|
||||
return '优秀';
|
||||
default:
|
||||
return '普通';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryItemIcon(item: InventoryItem) {
|
||||
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
|
||||
}
|
||||
|
||||
function buildInventoryItemSummary(
|
||||
item: InventoryItem,
|
||||
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
|
||||
) {
|
||||
if (item.description?.trim()) return item.description;
|
||||
if (!useEffect)
|
||||
return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
|
||||
const parts = [
|
||||
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
|
||||
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
|
||||
useEffect.cooldownReduction > 0
|
||||
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
|
||||
: null,
|
||||
useEffect.buildBuffs.length > 0
|
||||
? `获得 ${useEffect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0
|
||||
? `${item.name} 可以立即使用,${parts.join(',')}。`
|
||||
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
}
|
||||
|
||||
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {
|
||||
const slotCount = Math.ceil(Math.max(items.length, minimumSlotCount) / 4) * 4;
|
||||
return [
|
||||
...items,
|
||||
...Array.from(
|
||||
{ length: Math.max(0, slotCount - items.length) },
|
||||
() => null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function InventoryItemGrid({
|
||||
items,
|
||||
selectedItemId = null,
|
||||
minimumSlotCount = 16,
|
||||
onSelectItem,
|
||||
}: {
|
||||
items: InventoryItem[];
|
||||
selectedItemId?: string | null;
|
||||
minimumSlotCount?: number;
|
||||
onSelectItem: (item: InventoryItem) => void;
|
||||
}) {
|
||||
const inventorySlots = buildInventorySlots(items, minimumSlotCount);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{inventorySlots.map((item, index) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-slot-${index}`}
|
||||
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = selectedItemId === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelectItem(item)}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryItemDetailModal({
|
||||
item,
|
||||
playerCharacter,
|
||||
worldType,
|
||||
ownerLabel,
|
||||
onClose,
|
||||
footer,
|
||||
}: {
|
||||
item: InventoryItem | null;
|
||||
playerCharacter: Character;
|
||||
worldType: WorldType | null;
|
||||
ownerLabel?: string;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const selectedItemUseEffect = item
|
||||
? resolveInventoryItemUseEffect(item, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{item && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
|
||||
{item.category}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
|
||||
>
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
|
||||
{getInventoryRarityLabel(item.rarity)}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
数量:{item.quantity}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
持有者:{ownerLabel ?? playerCharacter.name}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可使用:{isInventoryItemUsable(item) ? '是' : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可装备:
|
||||
{selectedItemEquipSlot
|
||||
? getEquipmentSlotLabel(selectedItemEquipSlot)
|
||||
: '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
装备类型:
|
||||
{isInventoryItemEquippable(item)
|
||||
? '可装备物品'
|
||||
: '非装备物品'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
价值:
|
||||
{formatCurrency(getInventoryItemValue(item), worldType)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
|
||||
类型:{item.category}
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
|
||||
标签:{item.tags.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{buildInventoryItemSummary(item, selectedItemUseEffect)}
|
||||
</div>
|
||||
{selectedItemUseEffect?.buildBuffs.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItemUseEffect.buildBuffs.map((buff) => (
|
||||
<span
|
||||
key={buff.id}
|
||||
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
|
||||
>
|
||||
{buff.name} / {buff.tags.join('、')} /{' '}
|
||||
{buff.durationTurns} 回合
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.tags.length > 0 ? (
|
||||
item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
无标签
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer ?? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { formatCurrency, getInventoryItemValue } from '../data/economy';
|
||||
import { getEquipmentSlotFromItem, getEquipmentSlotLabel, isInventoryItemEquippable } from '../data/equipmentEffects';
|
||||
import { type ForgeRecipeView,getReforgeCostView } from '../data/forgeSystem';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { formatCurrency } from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
isInventoryItemEquippable,
|
||||
} from '../data/equipmentEffects';
|
||||
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
|
||||
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { buildInitialPlayerInventory } from '../data/npcInteractions';
|
||||
import { Character, InventoryItem, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
InventoryItemDetailModal,
|
||||
InventoryItemGrid,
|
||||
} from './InventoryItemViews';
|
||||
|
||||
interface InventoryPanelProps {
|
||||
playerCharacter: Character;
|
||||
@@ -28,56 +34,6 @@ interface InventoryPanelProps {
|
||||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
|
||||
case 'epic':
|
||||
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
|
||||
case 'rare':
|
||||
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
|
||||
case 'uncommon':
|
||||
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04]';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return '传说';
|
||||
case 'epic':
|
||||
return '史诗';
|
||||
case 'rare':
|
||||
return '稀有';
|
||||
case 'uncommon':
|
||||
return '优秀';
|
||||
default:
|
||||
return '普通';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryItemIcon(item: InventoryItem) {
|
||||
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
|
||||
}
|
||||
|
||||
function buildItemSummary(item: InventoryItem, useEffect: ReturnType<typeof resolveInventoryItemUseEffect>) {
|
||||
if (item.description?.trim()) return item.description;
|
||||
if (!useEffect) return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
|
||||
const parts = [
|
||||
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
|
||||
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
|
||||
useEffect.cooldownReduction > 0 ? `额外推进 ${useEffect.cooldownReduction} 回合冷却` : null,
|
||||
useEffect.buildBuffs.length > 0 ? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0
|
||||
? `${item.name} 可以立即使用,${parts.join(',')}。`
|
||||
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
}
|
||||
|
||||
export function InventoryPanel({
|
||||
playerCharacter,
|
||||
worldType,
|
||||
@@ -97,119 +53,104 @@ export function InventoryPanel({
|
||||
}: InventoryPanelProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||
const [isUsingItem, setIsUsingItem] = useState(false);
|
||||
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(null);
|
||||
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
|
||||
|
||||
const inventoryItems = useMemo(
|
||||
() => (playerInventory.length > 0 ? playerInventory : buildInitialPlayerInventory(playerCharacter, worldType)),
|
||||
() =>
|
||||
playerInventory.length > 0
|
||||
? playerInventory
|
||||
: buildInitialPlayerInventory(playerCharacter, worldType),
|
||||
[playerCharacter, playerInventory, worldType],
|
||||
);
|
||||
|
||||
const inventorySlotCount = Math.max(16, Math.ceil(inventoryItems.length / 4) * 4);
|
||||
const inventorySlots = [
|
||||
...inventoryItems,
|
||||
...Array.from({ length: Math.max(0, inventorySlotCount - inventoryItems.length) }, () => null),
|
||||
];
|
||||
|
||||
const selectedItemUseEffect = selectedItem
|
||||
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = selectedItem ? getEquipmentSlotFromItem(selectedItem) : null;
|
||||
const selectedItemReforgeCost = selectedItem ? getReforgeCostView(selectedItem, worldType) : null;
|
||||
const selectedItemEquipSlot = selectedItem
|
||||
? getEquipmentSlotFromItem(selectedItem)
|
||||
: null;
|
||||
const selectedItemReforgeCost = selectedItem
|
||||
? getReforgeCostView(selectedItem, worldType)
|
||||
: null;
|
||||
|
||||
const canUseSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
selectedItemUseEffect &&
|
||||
(
|
||||
(selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
|
||||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
|
||||
selectedItemUseEffect.cooldownReduction > 0 ||
|
||||
selectedItemUseEffect.buildBuffs.length > 0
|
||||
),
|
||||
selectedItemUseEffect &&
|
||||
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
|
||||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
|
||||
selectedItemUseEffect.cooldownReduction > 0 ||
|
||||
selectedItemUseEffect.buildBuffs.length > 0),
|
||||
);
|
||||
|
||||
const canEquipSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
selectedItemEquipSlot &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
!inBattle,
|
||||
selectedItemEquipSlot &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
!inBattle,
|
||||
);
|
||||
|
||||
const canDismantleSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
!inBattle &&
|
||||
(
|
||||
isInventoryItemEquippable(selectedItem) ||
|
||||
selectedItem.buildProfile
|
||||
),
|
||||
!inBattle &&
|
||||
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
|
||||
);
|
||||
|
||||
const canReforgeSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
!inBattle &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
selectedItem.buildProfile &&
|
||||
selectedItemReforgeCost &&
|
||||
selectedItemReforgeCost.currencyCost <= playerCurrency,
|
||||
!inBattle &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
selectedItem.buildProfile &&
|
||||
selectedItemReforgeCost &&
|
||||
selectedItemReforgeCost.currencyCost <= playerCurrency,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{inventorySlots.map((item, index) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-slot-${index}`}
|
||||
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<InventoryItemGrid
|
||||
items={inventoryItems}
|
||||
selectedItemId={selectedItem?.id ?? null}
|
||||
onSelectItem={setSelectedItem}
|
||||
/>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
<span>工坊</span>
|
||||
<span className="text-emerald-200/80">{formatCurrency(playerCurrency, worldType)}</span>
|
||||
<span className="text-emerald-200/80">
|
||||
{formatCurrency(playerCurrency, worldType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{forgeRecipes.map(recipe => (
|
||||
{forgeRecipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">{recipe.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{recipe.description}</div>
|
||||
<div className="mt-2 text-xs text-emerald-200/80">产物:{recipe.resultLabel}</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">花费:{recipe.currencyText}</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{recipe.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{recipe.description}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-emerald-200/80">
|
||||
产物:{recipe.resultLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
花费:{recipe.currencyText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!recipe.canCraft || inBattle || forgeActionKey === recipe.id}
|
||||
disabled={
|
||||
!recipe.canCraft ||
|
||||
inBattle ||
|
||||
forgeActionKey === recipe.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(recipe.id);
|
||||
const crafted = await onCraftRecipe(recipe.id);
|
||||
@@ -224,11 +165,15 @@ export function InventoryPanel({
|
||||
: 'border-white/8 bg-black/20 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{forgeActionKey === recipe.id ? '制作中...' : recipe.kind === 'forge' ? '锻造' : '合成'}
|
||||
{forgeActionKey === recipe.id
|
||||
? '制作中...'
|
||||
: recipe.kind === 'forge'
|
||||
? '锻造'
|
||||
: '合成'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{recipe.requirements.map(requirement => (
|
||||
{recipe.requirements.map((requirement) => (
|
||||
<span
|
||||
key={`${recipe.id}-${requirement.id}`}
|
||||
className={`rounded-full border px-2 py-1 text-[10px] ${
|
||||
@@ -237,7 +182,8 @@ export function InventoryPanel({
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{requirement.label} {requirement.owned}/{requirement.quantity}
|
||||
{requirement.label} {requirement.owned}/
|
||||
{requirement.quantity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,200 +193,133 @@ export function InventoryPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedItem && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">{selectedItem.category}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedItem.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(selectedItem.rarity)}`}>
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(selectedItem)}
|
||||
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
|
||||
{getInventoryRarityLabel(selectedItem.rarity)}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">数量:{selectedItem.quantity}</div>
|
||||
<div className="text-sm text-zinc-300">持有者:{playerCharacter.name}</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可使用:{isInventoryItemUsable(selectedItem) ? '是' : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可装备:{selectedItemEquipSlot ? getEquipmentSlotLabel(selectedItemEquipSlot) : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
价值:{formatCurrency(getInventoryItemValue(selectedItem), worldType)}
|
||||
</div>
|
||||
{selectedItemReforgeCost && (
|
||||
<div className="text-sm text-zinc-300">
|
||||
重铸成本:{selectedItemReforgeCost.currencyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.infoPanel)}>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">类型:{selectedItem.category}</div>
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">标签:{selectedItem.tags.length}</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{buildItemSummary(selectedItem, selectedItemUseEffect)}
|
||||
</div>
|
||||
{selectedItemUseEffect?.buildBuffs.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItemUseEffect.buildBuffs.map(buff => (
|
||||
<span
|
||||
key={buff.id}
|
||||
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
|
||||
>
|
||||
{buff.name} / {buff.tags.join('、')} / {buff.durationTurns} 回合
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItem.tags.length > 0 ? (
|
||||
selectedItem.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
无标签
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedItem || !canDismantleSelectedItem || forgeActionKey === selectedItem.id}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
setForgeActionKey(selectedItem.id);
|
||||
const dismantled = await onDismantleItem(selectedItem.id);
|
||||
setForgeActionKey(null);
|
||||
if (dismantled) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canDismantleSelectedItem && forgeActionKey !== selectedItem.id ? 'text-white' : 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedItem || !canReforgeSelectedItem || forgeActionKey === `${selectedItem.id}:reforge`}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
setForgeActionKey(`${selectedItem.id}:reforge`);
|
||||
const reforged = await onReforgeItem(selectedItem.id);
|
||||
setForgeActionKey(null);
|
||||
if (reforged) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canReforgeSelectedItem && forgeActionKey !== `${selectedItem.id}:reforge` ? 'text-white' : 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
{forgeActionKey === `${selectedItem.id}:reforge` ? '重铸中...' : '重铸'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedItem || !canEquipSelectedItem || equipmentActionKey === selectedItem.id}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
setEquipmentActionKey(selectedItem.id);
|
||||
const equipped = await onEquipItem(selectedItem.id);
|
||||
setEquipmentActionKey(null);
|
||||
if (equipped) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canEquipSelectedItem && equipmentActionKey !== selectedItem.id ? 'text-white' : 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
{equipmentActionKey === selectedItem.id
|
||||
? '装备中...'
|
||||
: selectedItemEquipSlot
|
||||
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
|
||||
: '不可装备'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedItem || !canUseSelectedItem || isUsingItem}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
setIsUsingItem(true);
|
||||
const used = await onUseItem(selectedItem.id);
|
||||
setIsUsingItem(false);
|
||||
if (used) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
{isUsingItem ? '使用中...' : '使用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<InventoryItemDetailModal
|
||||
item={selectedItem}
|
||||
playerCharacter={playerCharacter}
|
||||
worldType={worldType}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
footer={
|
||||
selectedItem ? (
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!canDismantleSelectedItem ||
|
||||
forgeActionKey === selectedItem.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(selectedItem.id);
|
||||
const dismantled = await onDismantleItem(selectedItem.id);
|
||||
setForgeActionKey(null);
|
||||
if (dismantled) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
|
||||
? 'text-white'
|
||||
: 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!canReforgeSelectedItem ||
|
||||
forgeActionKey === `${selectedItem.id}:reforge`
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(`${selectedItem.id}:reforge`);
|
||||
const reforged = await onReforgeItem(selectedItem.id);
|
||||
setForgeActionKey(null);
|
||||
if (reforged) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canReforgeSelectedItem &&
|
||||
forgeActionKey !== `${selectedItem.id}:reforge`
|
||||
? 'text-white'
|
||||
: 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{forgeActionKey === `${selectedItem.id}:reforge`
|
||||
? '重铸中...'
|
||||
: '重铸'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!canEquipSelectedItem ||
|
||||
equipmentActionKey === selectedItem.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setEquipmentActionKey(selectedItem.id);
|
||||
const equipped = await onEquipItem(selectedItem.id);
|
||||
setEquipmentActionKey(null);
|
||||
if (equipped) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
|
||||
? 'text-white'
|
||||
: 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{equipmentActionKey === selectedItem.id
|
||||
? '装备中...'
|
||||
: selectedItemEquipSlot
|
||||
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
|
||||
: '不可装备'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUseSelectedItem || isUsingItem}
|
||||
onClick={async () => {
|
||||
setIsUsingItem(true);
|
||||
const used = await onUseItem(selectedItem.id);
|
||||
setIsUsingItem(false);
|
||||
if (used) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{isUsingItem ? '使用中...' : '使用'}
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ export function ItemCatalogEditor() {
|
||||
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
|
||||
{previewUseEffect && (
|
||||
<div className="text-sm text-zinc-300">
|
||||
效果预估:HP +{previewUseEffect.hpRestore} / MP +{previewUseEffect.manaRestore} / CD -{previewUseEffect.cooldownReduction}
|
||||
效果预估:生命 +{previewUseEffect.hpRestore} / 灵力 +{previewUseEffect.manaRestore} / 冷却 -{previewUseEffect.cooldownReduction}
|
||||
</div>
|
||||
)}
|
||||
{!previewUseEffect && (
|
||||
@@ -732,14 +732,14 @@ export function ItemCatalogEditor() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>HP 加成</Label>
|
||||
<Label>生命上限加成</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
|
||||
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>MP 加成</Label>
|
||||
<Label>灵力上限加成</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
|
||||
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
|
||||
@@ -763,14 +763,14 @@ export function ItemCatalogEditor() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>使用恢复 HP</Label>
|
||||
<Label>使用时恢复生命</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
|
||||
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>使用恢复 MP</Label>
|
||||
<Label>使用时恢复灵力</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
|
||||
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
|
||||
@@ -786,7 +786,7 @@ export function ItemCatalogEditor() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>使用 Build Buff(每行:名称|标签1,标签2|回合)</Label>
|
||||
<Label>使用时附加构筑增益(每行:名称|标签1,标签2|回合)</Label>
|
||||
<TextArea
|
||||
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
|
||||
onChange={updateSelectedUseProfileBuffs}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
const tradeModal = npcUi.tradeModal;
|
||||
const tradeNpcState = tradeModal
|
||||
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
|
||||
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType)
|
||||
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState)
|
||||
: null;
|
||||
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
|
||||
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
|
||||
|
||||
@@ -105,6 +105,15 @@ const ACTION_MODE_LABELS: Record<CombatActionMode, string> = {
|
||||
melee: '近战',
|
||||
ranged: '远程',
|
||||
};
|
||||
const OPTION_KIND_LABELS: Record<ResolvedChoiceState['optionKind'], string> = {
|
||||
battle: '战斗',
|
||||
escape: '逃跑',
|
||||
idle: '空闲',
|
||||
};
|
||||
const ENCOUNTER_KIND_LABELS: Record<NonNullable<Encounter['kind']>, string> = {
|
||||
npc: '场景角色',
|
||||
treasure: '宝藏',
|
||||
};
|
||||
const MONSTER_ANIMATION_OPTIONS: Array<NonNullable<FunctionVisualConfig['monsterAnimation']>> = ['idle', 'move', 'attack'];
|
||||
const SKILL_STYLE_OPTIONS: SkillStyle[] = ['steady', 'burst', 'mobility', 'finisher', 'projectile'];
|
||||
const ANIMATION_LABELS: Record<AnimationState, string> = {
|
||||
@@ -876,10 +885,10 @@ function BehaviorExecutionPreview({
|
||||
}, [definition, definitions, worldType, character, scene, selectedMonsterId, idlePreviewKind, replayTick]);
|
||||
|
||||
const liveMonsterSummary = gameState.sceneMonsters[0]
|
||||
? `${gameState.sceneMonsters[0].name} / HP ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${gameState.sceneMonsters[0].animation}`
|
||||
? `${gameState.sceneMonsters[0].name} / 生命 ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneMonsters[0].animation)}`
|
||||
: gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ?? 'encounter'}`
|
||||
: 'No visible target';
|
||||
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ? ENCOUNTER_KIND_LABELS[gameState.currentEncounter.kind] : '遭遇目标'}`
|
||||
: '当前没有可见目标';
|
||||
const activeCooldowns = Object.entries(gameState.playerSkillCooldowns).filter(([, turns]) => Number(turns) > 0);
|
||||
const predictedSkill = getBattlePreviewSkill(resolvedChoice, character);
|
||||
const executionMode = getExecutionMode(definition);
|
||||
@@ -887,7 +896,7 @@ function BehaviorExecutionPreview({
|
||||
const displayAnimation = isPlaying ? gameState.animationState : lastKeyAnimation;
|
||||
const displayActionMode = isPlaying ? gameState.playerActionMode : lastKeyActionMode;
|
||||
const battleSnapshotName = predictedSkill?.name
|
||||
?? (displayAnimation !== AnimationState.IDLE ? displayAnimation : null);
|
||||
?? (displayAnimation !== AnimationState.IDLE ? getAnimationLabel(displayAnimation) : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -942,10 +951,10 @@ function BehaviorExecutionPreview({
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">实时玩家</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-100">
|
||||
<div>HP {gameState.playerHp}/{gameState.playerMaxHp}</div>
|
||||
<div>生命 {gameState.playerHp}/{gameState.playerMaxHp}</div>
|
||||
<div>灵力 {gameState.playerMana}/{gameState.playerMaxMana}</div>
|
||||
<div>动画 {displayAnimation}</div>
|
||||
<div>动作模式 {displayActionMode}</div>
|
||||
<div>动画 {getAnimationLabel(displayAnimation)}</div>
|
||||
<div>动作模式 {ACTION_MODE_LABELS[displayActionMode] ?? displayActionMode}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
当前执行值:{getAnimationLabel(gameState.animationState)} / {ACTION_MODE_LABELS[gameState.playerActionMode] ?? gameState.playerActionMode}
|
||||
</div>
|
||||
@@ -962,7 +971,7 @@ function BehaviorExecutionPreview({
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">解析计划</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-100">
|
||||
<div>选项类型:{resolvedChoice?.optionKind ?? '不可用'}</div>
|
||||
<div>选项类型:{resolvedChoice?.optionKind ? OPTION_KIND_LABELS[resolvedChoice.optionKind] : '不可用'}</div>
|
||||
<div>目标场景:{targetScene?.name ?? '无'}</div>
|
||||
<div>冷却:{activeCooldowns.length > 0 ? activeCooldowns.map(([skillId, turns]) => `${skillId}:${turns}`).join(', ') : '无'}</div>
|
||||
</div>
|
||||
@@ -974,7 +983,7 @@ function BehaviorExecutionPreview({
|
||||
<div>{battleSnapshotName}</div>
|
||||
<div className="text-xs text-zinc-400">动画:{getAnimationLabel(predictedSkill?.animation ?? displayAnimation)}</div>
|
||||
<div className="text-xs text-zinc-400">释放:{ACTION_MODE_LABELS[predictedSkill?.delivery ?? displayActionMode] ?? (predictedSkill?.delivery ?? displayActionMode)}</div>
|
||||
<div className="text-xs text-zinc-400">伤害:{predictedSkill?.estimatedDamage ?? 'n/a'}</div>
|
||||
<div className="text-xs text-zinc-400">伤害:{predictedSkill?.estimatedDamage ?? '未计算'}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{predictedSkill ? (predictedSkill.defeatsTarget ? '预计击杀' : '目标存活') : '基于实时播放的快照'}
|
||||
</div>
|
||||
@@ -1099,7 +1108,7 @@ export function StateFunctionEditor() {
|
||||
effect: cloneValue(template.effect),
|
||||
},
|
||||
}));
|
||||
setSaveMessage('已应用模板: ' + template.text);
|
||||
setSaveMessage('已应用模板:' + template.text);
|
||||
};
|
||||
|
||||
const previewContext = createFunctionContext(selectedDefinition, worldType, selectedCharacter, selectedScene, selectedMonster.id);
|
||||
@@ -1135,7 +1144,7 @@ export function StateFunctionEditor() {
|
||||
delete next[selectedDefinition.id];
|
||||
return next;
|
||||
});
|
||||
setSaveMessage('已重置覆盖: ' + selectedDefinition.id);
|
||||
setSaveMessage('已重置覆盖:' + selectedDefinition.id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1165,7 +1174,7 @@ export function StateFunctionEditor() {
|
||||
{isCustomized && <span className="shrink-0 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.2em] text-emerald-100">自定义</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
<span>{definition.state}</span>
|
||||
<span>{CATEGORY_LABELS[definition.state]}</span>
|
||||
<span>{CATEGORY_LABELS[definition.category]}</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1202,7 +1211,7 @@ export function StateFunctionEditor() {
|
||||
<SelectField label="世界" value={worldType} onChange={value => setWorldType(value as WorldType)} options={Object.values(WorldType).map(value => ({value, label: WORLD_LABELS[value]}))} />
|
||||
<SelectField label="角色" value={selectedCharacter.id} onChange={setSelectedCharacterId} options={PRESET_CHARACTERS.map(character => ({value: character.id, label: character.name}))} />
|
||||
<SelectField label="场景" value={selectedScene.id} onChange={setSelectedSceneId} options={sceneOptions.map(scene => ({value: scene.id, label: scene.name}))} />
|
||||
<SelectField label="敌对资源" value={selectedMonster.id} onChange={setSelectedMonsterId} options={monsterOptions.map(monster => ({value: monster.id, label: monster.name}))} />
|
||||
<SelectField label="敌对目标" value={selectedMonster.id} onChange={setSelectedMonsterId} options={monsterOptions.map(monster => ({value: monster.id, label: monster.name}))} />
|
||||
<SelectField label="空闲态目标" value={idlePreviewKind} onChange={value => setIdlePreviewKind(value as IdlePreviewKind)} options={IDLE_PREVIEW_OPTIONS} disabled={selectedDefinition.state === 'battle'} />
|
||||
</div>
|
||||
{!executable && <div className="mb-4 rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">当前预览上下文下,这个状态/分类组合不可执行。</div>}
|
||||
|
||||
@@ -1009,7 +1009,7 @@ export function AdventurePanelOverlays({
|
||||
|
||||
{selectedRewardUseEffect && (
|
||||
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-xs text-emerald-50">
|
||||
效果预览: HP +{selectedRewardUseEffect.hpRestore} / MP +{selectedRewardUseEffect.manaRestore} / 冷却 -{selectedRewardUseEffect.cooldownReduction}
|
||||
效果预览:生命 +{selectedRewardUseEffect.hpRestore} / 灵力 +{selectedRewardUseEffect.manaRestore} / 冷却 -{selectedRewardUseEffect.cooldownReduction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
|
||||
ariaLabel={`Inspect ${companion.character.name}`}
|
||||
ariaLabel={`查看${companion.character.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
@@ -216,7 +216,7 @@ export function GameCanvasEntityLayer({
|
||||
)}
|
||||
<SceneEntityButton
|
||||
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
|
||||
ariaLabel={playerCharacter ? `Inspect ${playerCharacter.name}` : undefined}
|
||||
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
|
||||
className="relative block"
|
||||
>
|
||||
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
|
||||
@@ -277,7 +277,7 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
|
||||
ariaLabel={`Inspect ${hostileNpc.name}`}
|
||||
ariaLabel={`查看${hostileNpc.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
@@ -379,7 +379,7 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
|
||||
ariaLabel={encounter.kind === 'npc' ? `Inspect ${encounter.npcName}` : undefined}
|
||||
ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
|
||||
@@ -148,7 +148,7 @@ export function GameShellStoryPanels({
|
||||
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">鑳屽寘</span>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,7 @@ export function CharacterAssetPanel() {
|
||||
[...fileList].slice(0, 4).map((file) => readFileAsDataUrl(file)),
|
||||
);
|
||||
setReferenceImageDataUrls(uploadedDataUrls);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。MVP 当前优先使用第一张进行主形象候选生成。`);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。当前阶段优先使用第一张生成主形象候选。`);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
@@ -358,7 +358,7 @@ export function CharacterAssetPanel() {
|
||||
<div className="grid gap-6 xl:grid-cols-[320px_1fr]">
|
||||
<SectionCard
|
||||
title="角色资产工坊"
|
||||
description="先锁定主形象,再生成并发布基础动作。MVP 当前优先提供可落地的本地资产闭环。"
|
||||
description="先锁定主形象,再生成并发布基础动作。当前阶段优先提供可落地的本地资产闭环。"
|
||||
>
|
||||
<SelectField
|
||||
label="当前角色"
|
||||
@@ -419,7 +419,7 @@ export function CharacterAssetPanel() {
|
||||
<div className="space-y-6">
|
||||
<SectionCard
|
||||
title="阶段 A:主形象"
|
||||
description="支持输入设定词和参考图,也支持直接上传已有角色素材。MVP 当前优先根据参考图或现有立绘生成规范化候选。"
|
||||
description="支持输入设定词和参考图,也支持直接上传已有角色素材。当前阶段优先根据参考图或现有立绘生成规范化候选。"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<div className="space-y-4">
|
||||
@@ -432,7 +432,7 @@ export function CharacterAssetPanel() {
|
||||
options={[
|
||||
{ label: '设定词 + 参考图', value: 'image-to-image' },
|
||||
{ label: '直接上传素材', value: 'upload' },
|
||||
{ label: '设定词(MVP 走当前立绘规范化)', value: 'text-to-image' },
|
||||
{ label: '设定词(当前阶段走立绘规范化)', value: 'text-to-image' },
|
||||
]}
|
||||
/>
|
||||
<TextAreaField
|
||||
@@ -455,7 +455,7 @@ export function CharacterAssetPanel() {
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-emerald-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||||
推荐上传 2:3 或 3:4 的单角色全身图。MVP 当前优先使用第一张参考图生成候选。
|
||||
推荐上传 2:3 或 3:4 的单角色全身图。当前阶段优先使用第一张参考图生成候选。
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -530,7 +530,7 @@ export function CharacterAssetPanel() {
|
||||
|
||||
<SectionCard
|
||||
title="阶段 B:基础动作"
|
||||
description="基础动作槽位必须非空。MVP 当前使用本地动作模板把主形象转换成可播放的基础动作帧集。"
|
||||
description="基础动作槽位必须非空。当前阶段使用本地动作模板把主形象转换成可播放的基础动作帧集。"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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">
|
||||
姝e湪鍔犺浇{label}...
|
||||
正在加载{label}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,15 +161,18 @@ export function buildCustomWorldPlayableNpcAttributeProfile(
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
legacyAttributes: templateAttributes,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.combatStyle,
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, schema: WorldAttributeSchema) {
|
||||
@@ -177,10 +180,15 @@ export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, sc
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
@@ -11,56 +11,56 @@ import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
|
||||
type RawBuildTagDefinition = Omit<BuildTagDefinition, 'attributeAffinity'>;
|
||||
|
||||
const TAG_CATEGORIES = {
|
||||
flow: '娴佹淳' as BuildTagCategory,
|
||||
style: '椋庢牸' as BuildTagCategory,
|
||||
resource: '璧勬簮' as BuildTagCategory,
|
||||
defense: '闃插尽' as BuildTagCategory,
|
||||
element: '鍏冪礌' as BuildTagCategory,
|
||||
craft: '宸ヨ壓' as BuildTagCategory,
|
||||
flow: '流派' as BuildTagCategory,
|
||||
style: '风格' as BuildTagCategory,
|
||||
resource: '资源' as BuildTagCategory,
|
||||
defense: '防御' as BuildTagCategory,
|
||||
element: '元素' as BuildTagCategory,
|
||||
craft: '工艺' as BuildTagCategory,
|
||||
} satisfies Record<string, BuildTagCategory>;
|
||||
|
||||
const RAW_BUILD_TAG_DEFINITIONS: RawBuildTagDefinition[] = [
|
||||
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: 'Fast melee pressure.' },
|
||||
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: 'Chain-hit rhythm and multi-stage output.' },
|
||||
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: 'Gap-closing and front-loaded engage.' },
|
||||
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: 'Chasing and follow-up punishment.' },
|
||||
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: 'Short-window assassination and weak-point bursts.' },
|
||||
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: 'Mid-long range damage with spacing.' },
|
||||
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: 'Hit-and-run skirmishing.' },
|
||||
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: 'High movement and repositioning.' },
|
||||
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: 'Light-footed speed advantage.' },
|
||||
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: 'Heavy swings and one-hit pressure.' },
|
||||
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: 'Short-window damage spikes.' },
|
||||
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: 'Breaking defense and hard targets.' },
|
||||
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: 'Tempo control through relentless offense.' },
|
||||
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: 'Trading safety for damage when low.' },
|
||||
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: 'Reliable defense and front-line stability.' },
|
||||
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: 'Barrier, protection, and status resistance.' },
|
||||
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: 'Armor hardness and standing power.' },
|
||||
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: 'Counterplay after blocks or openings.' },
|
||||
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: 'Suppressing curses and hostile energies.' },
|
||||
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: 'Spell-driven output and control.' },
|
||||
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: 'Mana pool, spend, and recovery loop.' },
|
||||
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: 'Lightning damage and instant suppression.' },
|
||||
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: 'Prepared effects and battlefield shaping.' },
|
||||
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: 'Restricting movement and action choices.' },
|
||||
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: 'High-cost high-output casting windows.' },
|
||||
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: 'Recovery and fight reset.' },
|
||||
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: 'Buffing and stabilizing allies.' },
|
||||
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: 'Long-fight consistency and tolerance.' },
|
||||
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: 'Marks, triggers, and destiny loops.' },
|
||||
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: 'Timing and fortunate trigger value.' },
|
||||
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: 'Faster rotation and recharge.' },
|
||||
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: 'Coordinating team actions.' },
|
||||
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: 'Generalist and low-risk growth.' },
|
||||
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: 'Crafting, devices, and engineered support.' },
|
||||
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: 'Potion and temporary enhancement making.' },
|
||||
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: 'Frontline initiative and lane opening.' },
|
||||
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: 'Risky offense for high return.' },
|
||||
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: 'Hybrid blade-and-magic combat.' },
|
||||
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: 'Protection, recovery, and holy punishment.' },
|
||||
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: 'Extreme defense and counter-anchoring.' },
|
||||
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: 'Early-shape and beginner-friendly setup.' },
|
||||
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: '快速近身施压。' },
|
||||
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: '连续命中与多段输出节奏。' },
|
||||
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: '拉近身位并抢先发起接敌。' },
|
||||
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: '追身压制与后续补刀。' },
|
||||
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: '短窗口刺杀与弱点爆发。' },
|
||||
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: '依靠身位经营的中远程输出。' },
|
||||
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: '打了就走的周旋消耗。' },
|
||||
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: '高机动与快速换位。' },
|
||||
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: '轻身疾行带来的速度优势。' },
|
||||
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: '重击蓄势与一击压迫。' },
|
||||
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: '短时间内抬高伤害峰值。' },
|
||||
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: '专破防御与硬目标。' },
|
||||
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: '靠连续进攻掌控节奏。' },
|
||||
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: '残血时用安全换更高输出。' },
|
||||
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: '稳定承伤与前线站位。' },
|
||||
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: '护体、防护与异常抵抗。' },
|
||||
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: '甲胄硬度与站场能力。' },
|
||||
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: '抓住破绽后的反制回击。' },
|
||||
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: '压制邪祟与敌对异力。' },
|
||||
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: '以术法驱动输出与控场。' },
|
||||
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: '围绕法力池展开的消耗与回转。' },
|
||||
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: '雷属性打击与瞬时压制。' },
|
||||
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: '预布效果与战场塑形。' },
|
||||
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: '限制对手移动与出手选择。' },
|
||||
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: '高消耗高回报的施法窗口。' },
|
||||
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: '恢复状态并重整战线。' },
|
||||
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: '增益队友并稳住队形。' },
|
||||
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: '持久在线战斗的稳定性。' },
|
||||
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: '围绕标记、触发与命数循环。' },
|
||||
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: '时机与机缘触发价值。' },
|
||||
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: '加快轮转与恢复速度。' },
|
||||
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: '协调队伍行动与站位。' },
|
||||
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: '泛用稳健、风险较低的成长路线。' },
|
||||
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: '工艺制造、装置与工程支援。' },
|
||||
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: '药剂调配与临时强化。' },
|
||||
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: '抢前排节奏并打开战线。' },
|
||||
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: '高风险高收益的进攻态势。' },
|
||||
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: '兵刃与术法并行的混合战斗。' },
|
||||
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: '兼顾防护、恢复与神圣惩戒。' },
|
||||
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: '极端防守与反打锚点。' },
|
||||
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: '适合作为起手与新手成型路线。' },
|
||||
];
|
||||
|
||||
const BUILD_TAG_DEFINITIONS: BuildTagDefinition[] = RAW_BUILD_TAG_DEFINITIONS.map(definition => ({
|
||||
|
||||
@@ -197,10 +197,14 @@ function hydrateCharacterRoleData(
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
title: character.title,
|
||||
role: character.title,
|
||||
description: character.description,
|
||||
backstory: character.backstory,
|
||||
personality: character.personality,
|
||||
motivation: character.description,
|
||||
combatStyle: character.skills.map(skill => skill.name).join('、'),
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
|
||||
tags: character.combatTags ?? [],
|
||||
},
|
||||
options.customWorldProfile.attributeSchema,
|
||||
|
||||
@@ -21,6 +21,10 @@ import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
|
||||
const MAX_SAVED_CUSTOM_WORLDS = 12;
|
||||
const MIN_CUSTOM_WORLD_AFFINITY = -40;
|
||||
const MAX_CUSTOM_WORLD_AFFINITY = 90;
|
||||
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
|
||||
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
|
||||
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
@@ -52,6 +56,13 @@ function toOptionalInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
const resolved = typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.round(value)
|
||||
: fallback;
|
||||
return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved));
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
@@ -129,16 +140,24 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
const title = toText(value.title, toText(value.role, '未命名角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title: toText(value.title, '未命名角色'),
|
||||
title,
|
||||
role,
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation, toText(value.description)),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
tags: toStringArray(value.tags),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
@@ -148,14 +167,24 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
const title = toText(value.title, toText(value.role, '未命名场景角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
role: toText(value.role, '未命名场景角色'),
|
||||
title,
|
||||
role,
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation),
|
||||
relationshipHooks: toStringArray(value.relationshipHooks),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
|
||||
159
src/data/customWorldNpcMonsters.ts
Normal file
159
src/data/customWorldNpcMonsters.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type CustomWorldNpc, type CustomWorldPlayableNpc, WorldType } from '../types';
|
||||
import {
|
||||
getMonsterPresetsByWorld,
|
||||
type HostileNpcPreset,
|
||||
} from './hostileNpcPresets';
|
||||
|
||||
type CustomWorldMonsterSource = Partial<
|
||||
Pick<
|
||||
CustomWorldNpc & CustomWorldPlayableNpc,
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'initialAffinity'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
|
||||
const MONSTER_SIGNAL_PATTERN =
|
||||
/妖|魔|鬼|怪|兽|灵|尸|蛛|蛇|虫|菇|傀|骸|骨|眼|蜗|藤|象|蝠|蛙|蛾|蟾|狼|狐|蛟|龙|祟/u;
|
||||
const MONSTER_SIGNAL_STOP_CHARS = new Set([
|
||||
'妖',
|
||||
'魔',
|
||||
'鬼',
|
||||
'怪',
|
||||
'兽',
|
||||
'灵',
|
||||
'尸',
|
||||
'祟',
|
||||
'凶',
|
||||
'异',
|
||||
'夜',
|
||||
'古',
|
||||
]);
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function getMonsterPresetPool(worldType?: WorldType | null) {
|
||||
if (worldType) {
|
||||
return getMonsterPresetsByWorld(worldType);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [
|
||||
...getMonsterPresetsByWorld(WorldType.WUXIA),
|
||||
...getMonsterPresetsByWorld(WorldType.XIANXIA),
|
||||
].filter((preset) => {
|
||||
if (seen.has(preset.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(preset.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueText(values: Array<string | null | undefined>) {
|
||||
return [
|
||||
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMonsterSourceText(npc: CustomWorldMonsterSource) {
|
||||
return uniqueText([
|
||||
npc.name,
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
]).join(' ');
|
||||
}
|
||||
|
||||
function buildSignalChars(label: string) {
|
||||
return [
|
||||
...new Set(
|
||||
label
|
||||
.replace(/[^\u4e00-\u9fa5]+/g, '')
|
||||
.split('')
|
||||
.filter((char) => char && !MONSTER_SIGNAL_STOP_CHARS.has(char)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
|
||||
let score = 0;
|
||||
|
||||
if (sourceText.includes(preset.name)) {
|
||||
score += 24;
|
||||
}
|
||||
|
||||
for (const signalChar of buildSignalChars(preset.name)) {
|
||||
if (sourceText.includes(signalChar)) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of [...preset.habitatTags, ...preset.combatTags]) {
|
||||
if (tag && sourceText.includes(tag)) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldNpcMonsterPreset(
|
||||
npc: CustomWorldMonsterSource,
|
||||
worldType?: WorldType | null,
|
||||
) {
|
||||
const sourceText = buildMonsterSourceText(npc);
|
||||
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileBias = (npc.initialAffinity ?? 0) < 0;
|
||||
if (!hostileBias) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = getMonsterPresetPool(worldType);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scoredCandidates = candidates
|
||||
.map((candidate) => ({
|
||||
candidate,
|
||||
score: scoreMonsterPreset(candidate, sourceText),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
if ((scoredCandidates[0]?.score ?? 0) >= 3) {
|
||||
return scoredCandidates[0]?.candidate ?? null;
|
||||
}
|
||||
|
||||
return candidates[hashText(sourceText) % candidates.length] ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldNpcMonsterPresetId(
|
||||
npc: CustomWorldMonsterSource,
|
||||
worldType?: WorldType | null,
|
||||
) {
|
||||
return resolveCustomWorldNpcMonsterPreset(npc, worldType)?.id ?? null;
|
||||
}
|
||||
@@ -12,11 +12,12 @@ export function buildNpcGiftModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
selectedItemId: string | null = state.playerInventory[0]?.id ?? null,
|
||||
): GiftModalState {
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId: state.playerInventory[0]?.id ?? null,
|
||||
selectedItemId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
|
||||
uiNote: '会先打开 gift modal,并默认选中背包第一件可见物品。',
|
||||
uiNote: '会先打开 gift modal,并默认选中当前最适合作为礼物的物品。',
|
||||
compactDetailText: '送礼提升好感',
|
||||
},
|
||||
};
|
||||
|
||||
91
src/data/hostileNpcPresets.test.ts
Normal file
91
src/data/hostileNpcPresets.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import type {GameState} from '../types';
|
||||
import {AnimationState, WorldType} from '../types';
|
||||
import {rollHostileNpcLoot} from './hostileNpcPresets';
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'ruins',
|
||||
name: '断碑古道',
|
||||
description: '阴气与碎骨混在旧路之间。',
|
||||
imageSrc: '/ruins.png',
|
||||
monsterIds: ['monster-03'],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 60,
|
||||
playerMaxMana: 60,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('hostileNpcPresets', () => {
|
||||
it('combines preset loot with runtime semantic drops', () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
|
||||
try {
|
||||
const loot = rollHostileNpcLoot(createGameState(), [
|
||||
{
|
||||
id: 'monster-03',
|
||||
name: '断骨祟灵',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(loot.some(item => item.id === 'monster-loot:bone-dust')).toBe(true);
|
||||
expect(
|
||||
loot.some(item => item.runtimeMetadata?.generationChannel === 'monster_drop'),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(976, 61, 4, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.3, speed: 6.8, hp: 102, maxHp: 102 },
|
||||
habitatTags: ['鍦板', '鑽掓潙', '瀵哄簷', '閬楄抗'],
|
||||
habitatTags: ['地宫', '荒村', '废寺', '遗迹'],
|
||||
},
|
||||
{
|
||||
id: 'monster-04',
|
||||
@@ -121,7 +121,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(870, 58, 3, 15),
|
||||
},
|
||||
baseStats: { attackRange: 1.1, speed: 4.8, hp: 152, maxHp: 152 },
|
||||
habitatTags: ['鐭抽樁', '娓″彛', '妗?', '闆箔'],
|
||||
habitatTags: ['石阶', '渡口', '古桥', '山门'],
|
||||
},
|
||||
{
|
||||
id: 'monster-06',
|
||||
@@ -140,7 +140,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(1606, 73, 3, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.2, speed: 5.8, hp: 140, maxHp: 140 },
|
||||
habitatTags: ['鐭块亾', '鐭抽樁', '搴熷煄', '鍦板'],
|
||||
habitatTags: ['矿道', '石阶', '废城', '地宫'],
|
||||
},
|
||||
{
|
||||
id: 'monster-07',
|
||||
@@ -159,7 +159,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(1444, 76, 3, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.1, speed: 6.1, hp: 108, maxHp: 108 },
|
||||
habitatTags: ['闆炬灄', '鑽掓潙', '瀵哄簷', '灞辫矾'],
|
||||
habitatTags: ['雾林', '荒村', '废寺', '山路'],
|
||||
},
|
||||
{
|
||||
id: 'monster-08',
|
||||
@@ -178,7 +178,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(950, 50, 3, 17),
|
||||
},
|
||||
baseStats: { attackRange: 1.0, speed: 6.7, hp: 118, maxHp: 118 },
|
||||
habitatTags: ['绔规灄', '闆炬灄', '娌兼辰', '鑽掗噹'],
|
||||
habitatTags: ['竹林', '雾林', '沼泽', '荒野'],
|
||||
},
|
||||
{
|
||||
id: 'monster-11',
|
||||
@@ -197,7 +197,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(770, 70, 3, 10),
|
||||
},
|
||||
baseStats: { attackRange: 1.4, speed: 7.4, hp: 124, maxHp: 124 },
|
||||
habitatTags: ['闀胯', '钀ュ湴', '鏂灒', '瀹嫅'],
|
||||
habitatTags: ['长街', '营地', '断垣', '宫苑'],
|
||||
},
|
||||
{
|
||||
id: 'monster-13',
|
||||
@@ -214,7 +214,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
attack: rowAnimation(1056, 66, 1, 15),
|
||||
},
|
||||
baseStats: { attackRange: 1.7, speed: 8.2, hp: 96, maxHp: 96 },
|
||||
habitatTags: ['绔规灄', '闆炬灄', '灞辫矾', '鑺卞洯'],
|
||||
habitatTags: ['竹林', '雾林', '山路', '花圃'],
|
||||
},
|
||||
{
|
||||
id: 'monster-18',
|
||||
@@ -233,7 +233,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(1771, 77, 1, 17),
|
||||
},
|
||||
baseStats: { attackRange: 1.8, speed: 4.4, hp: 186, maxHp: 186 },
|
||||
habitatTags: ['鐭块亾', '閾稿潑', '搴熷煄', '杈瑰叧'],
|
||||
habitatTags: ['矿道', '铸坊', '废城', '边关'],
|
||||
},
|
||||
{
|
||||
id: 'monster-02',
|
||||
@@ -252,7 +252,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(688, 43, 4, 7),
|
||||
},
|
||||
baseStats: { attackRange: 1.4, speed: 7.3, hp: 112, maxHp: 112 },
|
||||
habitatTags: ['浠欓棬', '闀垮粖', '閬楄抗', '绁潧'],
|
||||
habitatTags: ['仙门', '长廊', '遗迹', '祭坛'],
|
||||
},
|
||||
{
|
||||
id: 'monster-05',
|
||||
@@ -271,7 +271,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(840, 60, 4, 14),
|
||||
},
|
||||
baseStats: { attackRange: 1.6, speed: 7.0, hp: 126, maxHp: 126 },
|
||||
habitatTags: ['濡栭浘', '娲炲ぉ', '璋峰湴', '绉樻'],
|
||||
habitatTags: ['妖雾', '洞天', '谷地', '秘境'],
|
||||
},
|
||||
{
|
||||
id: 'monster-10',
|
||||
@@ -290,7 +290,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(720, 48, 3, 15),
|
||||
},
|
||||
baseStats: { attackRange: 1.2, speed: 5.0, hp: 136, maxHp: 136 },
|
||||
habitatTags: ['娲炲ぉ', '璋峰湴', '鏈堟箹', '鐭垮潙'],
|
||||
habitatTags: ['洞天', '谷地', '月湖', '石坑'],
|
||||
},
|
||||
{
|
||||
id: 'monster-12',
|
||||
@@ -309,7 +309,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(506, 46, 3, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.6, speed: 7.8, hp: 120, maxHp: 120 },
|
||||
habitatTags: ['娲炲ぉ', '宕?', '浠欏矝', '鍙よ抗'],
|
||||
habitatTags: ['洞天', '崖壁', '仙岛', '古迹'],
|
||||
},
|
||||
{
|
||||
id: 'monster-14',
|
||||
@@ -328,7 +328,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(671, 61, 4, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.7, speed: 7.1, hp: 128, maxHp: 128 },
|
||||
habitatTags: ['鏈堟箹', '澶╂渤', '浠欐床', '鐏垫硥'],
|
||||
habitatTags: ['月湖', '天河', '仙洲', '灵泉'],
|
||||
},
|
||||
{
|
||||
id: 'monster-15',
|
||||
@@ -346,7 +346,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(2400, 75, 2, 17),
|
||||
},
|
||||
baseStats: { attackRange: 1.5, speed: 4.6, hp: 168, maxHp: 168 },
|
||||
habitatTags: ['鑺卞渻', '绁炴湪', '璋峰湴', '绉樺'],
|
||||
habitatTags: ['花圃', '神木', '谷地', '秘境'],
|
||||
},
|
||||
{
|
||||
id: 'monster-16',
|
||||
@@ -365,7 +365,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(728, 52, 3, 9),
|
||||
},
|
||||
baseStats: { attackRange: 1.8, speed: 6.9, hp: 130, maxHp: 130 },
|
||||
habitatTags: ['濡栭浘', '浠欓棬', '椋炵€?', '鎮┖'],
|
||||
habitatTags: ['妖雾', '仙门', '星舟', '悬空'],
|
||||
},
|
||||
{
|
||||
id: 'monster-20',
|
||||
@@ -383,7 +383,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
attack: rowAnimation(804, 67, 2, 12),
|
||||
},
|
||||
baseStats: { attackRange: 1.4, speed: 6.4, hp: 138, maxHp: 138 },
|
||||
habitatTags: ['鏈堟箹', '鐏垫硥', '澶╂渤', '瀵掔帀'],
|
||||
habitatTags: ['月湖', '灵泉', '天河', '寒玉'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -394,12 +394,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.74,
|
||||
buildHostileNpcLootItem(
|
||||
'script-fragment',
|
||||
'鏉愭枡',
|
||||
'娈嬮〉纰庣墖',
|
||||
'材料',
|
||||
'残页碎片',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'鏁h惤鐨勪功椤靛拰鏄撶鐨勭鐗囷紝浠嶆畫鐣欑潃鎶ょ澧ㄦ按鐨勭棔杩广€?',
|
||||
'散落的书页和易碎纸片上,还残留着护符墨水的痕迹。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -407,12 +407,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.24,
|
||||
buildHostileNpcLootItem(
|
||||
'archive-sigil',
|
||||
'閬楃墿',
|
||||
'妗f鍗拌',
|
||||
'稀有品',
|
||||
'档案印记',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'涓€涓偓娴殑鍗拌锛屽瓨鍌ㄧ潃鍛ㄥ洿鐨勭伒鏂囥€?',
|
||||
'一枚悬浮的印记,内部储着周围灵文的回响。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -422,12 +422,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.74,
|
||||
buildHostileNpcLootItem(
|
||||
'bone-dust',
|
||||
'鏉愭枡',
|
||||
'楠ㄥ皹',
|
||||
'材料',
|
||||
'骨尘',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'鐮寸骞界伒鐣欎笅鐨勭矇鏈姸娈嬮銆?',
|
||||
'破碎骨灵散落下来的粉末状残渣。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -435,12 +435,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.22,
|
||||
buildHostileNpcLootItem(
|
||||
'whisper-ember',
|
||||
'閬楃墿',
|
||||
'浣庤浣欑儸',
|
||||
'稀有品',
|
||||
'低语余烬',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'寰急鐨勭伀鑺憋紝浠嶅甫鐫€涓嶅畨鐨勯榄傝兘閲忓棥鍡′綔鍝嶃€?',
|
||||
'微弱的火星里还带着不安魂力的回响。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -450,12 +450,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.7,
|
||||
buildHostileNpcLootItem(
|
||||
'stone-shell-shard',
|
||||
'鏉愭枡',
|
||||
'鐭冲3纰庣墖',
|
||||
'材料',
|
||||
'石壳碎片',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'浠庣埇琛岀煶鑳屾帬椋熻€呰韩涓婂墺钀界殑鐮寸鎶ょ敳鐗囥€?',
|
||||
'从石背蜗怪身上剥落下来的坚硬护壳碎片。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -463,12 +463,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.32,
|
||||
buildHostileNpcLootItem(
|
||||
'venom-gland',
|
||||
'鏉愭枡',
|
||||
'姣掕吅',
|
||||
'材料',
|
||||
'毒腺',
|
||||
'uncommon',
|
||||
['material'],
|
||||
1,
|
||||
'浠嶅甫鐫€浣欐俯鐨勫瘑灏佹瘨鍥娿€?',
|
||||
'仍带着余温的密封毒囊。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -478,12 +478,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.68,
|
||||
buildHostileNpcLootItem(
|
||||
'watcher-tendon',
|
||||
'鏉愭枡',
|
||||
'瀹堟湜鑰呰倢鑵?',
|
||||
'材料',
|
||||
'守望筋丝',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'浠庢紓娴溂 stalker 韬笂鎵笅鐨勭粏涓濄€?',
|
||||
'从漂浮妖眼身上扯下的细丝筋络。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -491,12 +491,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.3,
|
||||
buildHostileNpcLootItem(
|
||||
'blood-lens',
|
||||
'閬楃墿',
|
||||
'琛€鏅堕€忛暅',
|
||||
'稀有品',
|
||||
'血瞳透镜',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'缁忚繃鎶涘厜鐨勭溂鏅讹紝鍙寮烘晫鎰忓嚌瑙嗘妧宸с€?',
|
||||
'经由打磨的眼晶,可放大敌意与凝视类术式。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -506,12 +506,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.38,
|
||||
buildHostileNpcLootItem(
|
||||
'carapace-plate',
|
||||
'鎶ょ敳',
|
||||
'鐢插3鏉?',
|
||||
'护甲',
|
||||
'甲壳板',
|
||||
'rare',
|
||||
['armor', 'material'],
|
||||
1,
|
||||
'鍙噸閾镐负閲嶅瀷闃叉姢鐨勮嚧瀵嗙敳澹炦ి€?',
|
||||
'可重铸成重型防具的致密甲板。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -519,12 +519,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.18,
|
||||
buildHostileNpcLootItem(
|
||||
'guard-core',
|
||||
'閬楃墿',
|
||||
'瀹堝崼鏍稿績',
|
||||
'稀有品',
|
||||
'守御核心',
|
||||
'rare',
|
||||
['relic'],
|
||||
1,
|
||||
'钑村惈閲庢€ч槻寰℃湰鑳界殑鏍稿績锛屽潥涓嶅彲鎽ං€?',
|
||||
'蕴着本能防御意志的核心,坚实得近乎难摧。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -534,12 +534,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.72,
|
||||
buildHostileNpcLootItem(
|
||||
'spore-pouch',
|
||||
'鏉愭枡',
|
||||
'瀛㈠泭',
|
||||
'材料',
|
||||
'孢囊',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'瑁呮弧涓嶇ǔ瀹氳槕鑿囧瀛愮殑鍥婅銆?',
|
||||
'装满不稳定菌孢的鼓胀囊袋。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -547,12 +547,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.28,
|
||||
buildHostileNpcLootItem(
|
||||
'burst-cap',
|
||||
'娑堣€楀搧',
|
||||
'鐖嗚弴甯?',
|
||||
'消耗品',
|
||||
'爆菇帽',
|
||||
'uncommon',
|
||||
['healing'],
|
||||
1,
|
||||
'鍙姞宸ヤ负鎴樺湴鑽墏鐨勬尌鍙戞€ц弴甯姐€?',
|
||||
'可加工成战地药剂的挥发菌帽。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -562,12 +562,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.76,
|
||||
buildHostileNpcLootItem(
|
||||
'vine-tendril',
|
||||
'鏉愭枡',
|
||||
'钘ら』',
|
||||
'材料',
|
||||
'藤须',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'浠庝紡鍑昏棨钄撲笂鑾峰彇鐨勫潥闊х氦缁淬€?',
|
||||
'从枯藤伏虫身上取得的韧性藤丝。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -575,12 +575,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.2,
|
||||
buildHostileNpcLootItem(
|
||||
'ambush-fang',
|
||||
'姝﹀櫒',
|
||||
'浼忓嚮鐗?',
|
||||
'武器',
|
||||
'伏袭牙刃',
|
||||
'rare',
|
||||
['weapon', 'material'],
|
||||
1,
|
||||
'杩戞垬鐚庝汉鐝嶈鐨勫ぉ鐒跺皷鍒€¢€?',
|
||||
'近战猎手格外珍视的天然尖牙刃。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -590,12 +590,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.72,
|
||||
buildHostileNpcLootItem(
|
||||
'spirit-slime',
|
||||
'鏉愭枡',
|
||||
'鐏佃厫榛忔恫',
|
||||
'材料',
|
||||
'灵腐黏液',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'鍦ㄩ粦鏆椾腑缂撴參鍙戝厜鐨勭矘绋犳畫鐣欑墿銆?',
|
||||
'在黑暗中微微发光的腐灵残液。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -603,12 +603,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.2,
|
||||
buildHostileNpcLootItem(
|
||||
'marsh-core',
|
||||
'閬楃墿',
|
||||
'娌兼辰鏍稿績',
|
||||
'稀有品',
|
||||
'沼泽核心',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'娌兼辰鐏垫皵涓庢畫鐣欑槾姘旂殑鍑濊仛鑺傜偣銆?',
|
||||
'沼气与阴湿灵力凝成的核心结节。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -618,12 +618,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.58,
|
||||
buildHostileNpcLootItem(
|
||||
'night-fang',
|
||||
'鏉愭枡',
|
||||
'澶滅墮',
|
||||
'材料',
|
||||
'夜牙',
|
||||
'uncommon',
|
||||
['material'],
|
||||
1,
|
||||
'閫傚悎鍒朵綔姣掕嵂鎴栭櫡闃辩殑鍒€鍒冪姸鐛犵墮銆?',
|
||||
'适合炼毒与设陷的刃状兽牙。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -631,12 +631,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.24,
|
||||
buildHostileNpcLootItem(
|
||||
'shadow-pelt',
|
||||
'鎶ょ敳',
|
||||
'鏆楀奖鐨?',
|
||||
'护甲',
|
||||
'暗影兽皮',
|
||||
'rare',
|
||||
['armor', 'material'],
|
||||
1,
|
||||
'绌挎埓鏃跺彲娑堝辑鍔ㄩ潤鐨勬繁鑹插吔鐨€?',
|
||||
'披戴后能稍稍融入夜色的深色皮膜。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -646,12 +646,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.64,
|
||||
buildHostileNpcLootItem(
|
||||
'ember-wing',
|
||||
'鏉愭枡',
|
||||
'鐑考',
|
||||
'材料',
|
||||
'烬翼',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'浠嶅湪鏁h惤鏆栫伆鐨勭儳鐒︾繀缈肩鐗囥€?',
|
||||
'仍散着余热灰烬的焦脆翼片。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -659,12 +659,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.22,
|
||||
buildHostileNpcLootItem(
|
||||
'ashfire-feather',
|
||||
'閬楃墿',
|
||||
'鐏扮儸鐏窘',
|
||||
'稀有品',
|
||||
'灰焰翎',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'钑村惈寰皬浣嗘寔涔呯殑鐑伒鐨勭窘灏栥€?',
|
||||
'羽尖里封着微弱却持久的灼灵余火。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -674,12 +674,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.66,
|
||||
buildHostileNpcLootItem(
|
||||
'serpent-venom-sac',
|
||||
'鏉愭枡',
|
||||
'铔囨瘨鍥?',
|
||||
'材料',
|
||||
'蛇毒囊',
|
||||
'uncommon',
|
||||
['material'],
|
||||
1,
|
||||
'鐐奸噾甯堜笌鍒哄閮界弽瑙嗙殑姣掔礌鍥娿€?',
|
||||
'炼药师与刺客都很看重的高浓毒囊。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -687,12 +687,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.16,
|
||||
buildHostileNpcLootItem(
|
||||
'serpent-eye',
|
||||
'閬楃墿',
|
||||
'铔囩溂',
|
||||
'稀有品',
|
||||
'蛇瞳',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'鍗充娇闈欐涔熻兘杩借釜闄勮繎鍔ㄩ潤鐨勮寮傜溂鏅躲€?',
|
||||
'即使静止不动,也像仍在追索附近的活物。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -702,12 +702,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.7,
|
||||
buildHostileNpcLootItem(
|
||||
'tide-ink',
|
||||
'鏉愭枡',
|
||||
'娼ⅷ',
|
||||
'材料',
|
||||
'潮墨',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'鐢ㄤ簬灏佸嵃銆侀櫡闃变笌姘村睘鎬х绠撶殑娴撶榛戞恫銆?',
|
||||
'适合封印、符阵与水属术式的浓黑灵墨。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -715,12 +715,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.18,
|
||||
buildHostileNpcLootItem(
|
||||
'lake-pearl',
|
||||
'閬楃墿',
|
||||
'婀栫彔',
|
||||
'稀有品',
|
||||
'湖珠',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'钑村惈鏈堝厜婀挎皵鐨勫厜婊戠弽鐝犮€?',
|
||||
'裹着月华水气的光润珍珠。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -730,12 +730,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.46,
|
||||
buildHostileNpcLootItem(
|
||||
'thorn-nectar',
|
||||
'Consumable',
|
||||
'Thorn Nectar',
|
||||
'消耗品',
|
||||
'棘露蜜浆',
|
||||
'uncommon',
|
||||
['healing', 'material'],
|
||||
1,
|
||||
'Sticky sap that can be refined into emergency recovery tonic.',
|
||||
'黏稠树露可炼成紧急疗伤的回生药浆。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -743,12 +743,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.12,
|
||||
buildHostileNpcLootItem(
|
||||
'devour-bloom',
|
||||
'Relic',
|
||||
'Devour Bloom',
|
||||
'稀有品',
|
||||
'噬灵花核',
|
||||
'epic',
|
||||
['relic'],
|
||||
1,
|
||||
'A predatory blossom that stores concentrated life force.',
|
||||
'会掠食灵机的妖花花核,积着浓缩生机。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -758,12 +758,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.72,
|
||||
buildHostileNpcLootItem(
|
||||
'mist-wing',
|
||||
'Material',
|
||||
'Mist Wing',
|
||||
'材料',
|
||||
'雾翼膜',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'Thin membrane steeped in drifting fog and static charge.',
|
||||
'浸着游雾与静电的轻薄翼膜。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -771,12 +771,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.2,
|
||||
buildHostileNpcLootItem(
|
||||
'chase-rune',
|
||||
'Relic',
|
||||
'Chase Rune',
|
||||
'稀有品',
|
||||
'追猎符印',
|
||||
'rare',
|
||||
['relic'],
|
||||
1,
|
||||
'A pursuit mark that resonates with speed and pressure.',
|
||||
'能与速度和压迫感共鸣的追索印记。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -786,12 +786,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.62,
|
||||
buildHostileNpcLootItem(
|
||||
'ancient-hide',
|
||||
'Armor',
|
||||
'Ancient Hide',
|
||||
'护甲',
|
||||
'古苔兽皮',
|
||||
'uncommon',
|
||||
['armor', 'material'],
|
||||
1,
|
||||
'Thick hide layered with old dust, moss, and impact scars.',
|
||||
'厚重皮层间压着旧尘、苔痕与碰撞裂纹。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -799,12 +799,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.14,
|
||||
buildHostileNpcLootItem(
|
||||
'ruin-heart',
|
||||
'Relic',
|
||||
'Ruin Heart',
|
||||
'稀有品',
|
||||
'遗墟之心',
|
||||
'epic',
|
||||
['relic'],
|
||||
1,
|
||||
'A heavy core pulsing with the stubborn will of abandoned ruins.',
|
||||
'沉重的核心里跳着废墟不肯坍塌的执念。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -814,12 +814,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.44,
|
||||
buildHostileNpcLootItem(
|
||||
'spring-essence',
|
||||
'Consumable',
|
||||
'Spring Essence',
|
||||
'消耗品',
|
||||
'灵泉精露',
|
||||
'uncommon',
|
||||
['mana'],
|
||||
1,
|
||||
'A cool droplet that restores focus and spiritual rhythm.',
|
||||
'清凉的泉露能稳住心神与灵息运转。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -827,12 +827,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.11,
|
||||
buildHostileNpcLootItem(
|
||||
'tide-mother-core',
|
||||
'Relic',
|
||||
'Tide Mother Core',
|
||||
'稀有品',
|
||||
'潮母灵核',
|
||||
'epic',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'A refined water-heart formed only in the deepest luminous springs.',
|
||||
'只有在最深的明泉里才会凝出的水华灵核。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -841,22 +841,22 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
const HOSTILE_NPC_OVERRIDES = hostileNpcOverridesJson as Record<string, HostileNpcPresetOverride>;
|
||||
|
||||
const BASE_HOSTILE_NPC_COMBAT_TAGS: Record<string, string[]> = {
|
||||
'monster-03': ['闀囬偑', '鎺у満', '鏈哄姩'],
|
||||
'monster-04': ['閲嶇敳', '瀹堝尽', '鍙嶅嚮'],
|
||||
'monster-06': ['閲嶇敳', '瀹堝尽', '鍘嬪埗'],
|
||||
'monster-07': ['鎺у満', '鍥炲', '鐐艰嵂'],
|
||||
'monster-08': ['蹇', '杩藉嚮', '鏈哄姩'],
|
||||
'monster-11': ['蹇', '绐佽繘', '鍘嬪埗'],
|
||||
'monster-13': ['蹇', '杩藉嚮', '椋庤'],
|
||||
'monster-18': ['閲嶅嚮', '瀹堝尽', '鍫″瀿'],
|
||||
'monster-02': ['娉曚慨', '绗﹂樀', '鎺у満'],
|
||||
'monster-05': ['鎺у満', '娉曚慨', '闀囬偑'],
|
||||
'monster-10': ['娉曞姏', '鍥炲', '鎶や綋'],
|
||||
'monster-12': ['鏈哄姩', '杩滃皠', '椋庤'],
|
||||
'monster-14': ['娉曞姏', '绗﹂樀', '鍥炲'],
|
||||
'monster-15': ['鍥炲', '鎶や綋', '閲嶇敳'],
|
||||
'monster-16': ['闆锋硶', '鏈哄姩', '杩囪浇'],
|
||||
'monster-20': ['娉曞姏', '鍥炲', '闀囬偑'],
|
||||
'monster-03': ['镇邪', '控场', '机动'],
|
||||
'monster-04': ['重甲', '守御', '反击'],
|
||||
'monster-06': ['重甲', '守御', '压制'],
|
||||
'monster-07': ['控场', '回复', '炼药'],
|
||||
'monster-08': ['快袭', '追击', '机动'],
|
||||
'monster-11': ['快袭', '突进', '压制'],
|
||||
'monster-13': ['快袭', '追击', '风行'],
|
||||
'monster-18': ['重击', '守御', '堡垒'],
|
||||
'monster-02': ['法修', '符阵', '控场'],
|
||||
'monster-05': ['控场', '法修', '镇邪'],
|
||||
'monster-10': ['法力', '回复', '护体'],
|
||||
'monster-12': ['机动', '远射', '风行'],
|
||||
'monster-14': ['法力', '符阵', '回复'],
|
||||
'monster-15': ['回复', '护体', '重甲'],
|
||||
'monster-16': ['雷法', '机动', '过载'],
|
||||
'monster-20': ['法力', '回复', '镇邪'],
|
||||
};
|
||||
|
||||
function mergeHostileNpcPreset(
|
||||
@@ -894,7 +894,7 @@ function buildHostileNpcBehaviorVectors(preset: {
|
||||
combatTags: string[];
|
||||
baseStats: Pick<SceneHostileNpc, 'attackRange' | 'speed' | 'maxHp'>;
|
||||
}) {
|
||||
const controlBias = preset.combatTags.some(tag => ['鎺у満', '绗﹂樀', '娉曞姏'].includes(tag)) ? 0.28 : 0.12;
|
||||
const controlBias = preset.combatTags.some(tag => ['控场', '符阵', '法力'].includes(tag)) ? 0.28 : 0.12;
|
||||
const mobilityBias = preset.baseStats.speed >= 7 ? 0.34 : 0.16;
|
||||
const pressureBias = preset.baseStats.attackRange >= 1.5 ? 0.32 : 0.18;
|
||||
const enduranceBias = preset.baseStats.maxHp >= 150 ? 0.3 : 0.16;
|
||||
@@ -902,7 +902,7 @@ function buildHostileNpcBehaviorVectors(preset: {
|
||||
return [
|
||||
{
|
||||
id: `${preset.id}:predatory-strike`,
|
||||
name: `${preset.name}鐨勬湰鑳藉帇杩玚`,
|
||||
name: `${preset.name}的本能压迫`,
|
||||
category: 'combat' as const,
|
||||
baseScore: 0.52,
|
||||
intentVector: buildDefaultAxisVector({
|
||||
@@ -974,12 +974,20 @@ export function rollHostileNpcLoot(
|
||||
`monster-loot:${monster.id}:${monster.name}`,
|
||||
{
|
||||
count: 2,
|
||||
categories: ['鏉愭枡', '娑堣€楀搧', '绋€鏈夊搧', '涓撳睘鐗?'],
|
||||
categories: ['材料', '消耗品', '稀有品', '专属品'],
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return defeatedHostileNpcs.flatMap(monster => {
|
||||
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
|
||||
const presetLoot = preset
|
||||
? preset.lootTable
|
||||
.filter(entry => Math.random() <= entry.dropRate)
|
||||
.map(entry => ({
|
||||
...entry.item,
|
||||
}))
|
||||
: [];
|
||||
const context = buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'monster_drop',
|
||||
@@ -1000,17 +1008,6 @@ export function rollHostileNpcLoot(
|
||||
fixedPermanence: ['resource', 'timed'],
|
||||
});
|
||||
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
|
||||
if (runtimeItems.length > 0) {
|
||||
return runtimeItems;
|
||||
}
|
||||
|
||||
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
|
||||
if (!preset) return [];
|
||||
|
||||
return preset.lootTable
|
||||
.filter(entry => Math.random() <= entry.dropRate)
|
||||
.map(entry => ({
|
||||
...entry.item,
|
||||
}));
|
||||
return [...presetLoot, ...runtimeItems];
|
||||
});
|
||||
}
|
||||
|
||||
247
src/data/npcInteractions.test.ts
Normal file
247
src/data/npcInteractions.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Character, Encounter, GameState, InventoryItem } from '../types';
|
||||
import { AnimationState, WorldType } from '../types';
|
||||
import {
|
||||
buildNpcHelpReward,
|
||||
buildGiftCandidateSummary,
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
buildNpcTradeTransactionActionText,
|
||||
syncNpcTradeInventory,
|
||||
} from './npcInteractions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity: 1,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(
|
||||
encounter: Encounter,
|
||||
overrides: Partial<GameState> = {},
|
||||
): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: {
|
||||
id: 'scene-camp',
|
||||
name: 'Camp',
|
||||
description: 'A temporary camp.',
|
||||
imageSrc: '/camp.png',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 80,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 40,
|
||||
playerMaxMana: 60,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('npcInteractions', () => {
|
||||
it('builds a readable fallback summary for empty gift candidates', () => {
|
||||
expect(buildGiftCandidateSummary([])).toBe('暂无合适礼物');
|
||||
});
|
||||
|
||||
it('includes gift candidate context in the npc gift option detail text', () => {
|
||||
const encounter = createEncounter();
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
playerCharacter: createCharacter(),
|
||||
playerInventory: [
|
||||
createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
createInventoryItem('tea-brick', 'Tea Brick'),
|
||||
],
|
||||
activeQuests: [],
|
||||
scene: {
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
const giftOption = story.options.find((option) => option.functionId === 'npc_gift');
|
||||
expect(giftOption).toBeTruthy();
|
||||
expect(giftOption?.detailText).toContain('Jade Token');
|
||||
expect(giftOption?.detailText).toContain('Tea Brick');
|
||||
});
|
||||
|
||||
it('omits the npc gift option when the player has no gift candidates', () => {
|
||||
const encounter = createEncounter();
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
playerCharacter: createCharacter(),
|
||||
playerInventory: [],
|
||||
activeQuests: [],
|
||||
scene: {
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds concrete trade action text for story continuation', () => {
|
||||
const encounter = createEncounter();
|
||||
|
||||
expect(
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: createInventoryItem('jade-token', 'Jade Token'),
|
||||
quantity: 2,
|
||||
}),
|
||||
).toBe('从Trader Lin手里买下Jade Token x2');
|
||||
|
||||
expect(
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: createInventoryItem('tea-brick', 'Tea Brick'),
|
||||
quantity: 1,
|
||||
}),
|
||||
).toBe('把Tea Brick卖给Trader Lin');
|
||||
});
|
||||
|
||||
it('syncs generic trade stock to the current build while preserving sold-in items', () => {
|
||||
const encounter: Encounter = {
|
||||
...createEncounter(),
|
||||
context: '商贩',
|
||||
};
|
||||
const state = createGameState(encounter);
|
||||
const syncedState = syncNpcTradeInventory(state, encounter, {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
inventory: [createInventoryItem('sold-tea', 'Tea Brick')],
|
||||
tradeStockSignature: 'stale-build',
|
||||
});
|
||||
|
||||
expect(syncedState.tradeStockSignature).not.toBe('stale-build');
|
||||
expect(syncedState.inventory.some(item => item.id === 'sold-tea')).toBe(true);
|
||||
expect(
|
||||
syncedState.inventory.some(
|
||||
item => item.runtimeMetadata?.generationChannel === 'npc_trade',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('builds npc help rewards from the runtime director', () => {
|
||||
const encounter: Encounter = {
|
||||
...createEncounter(),
|
||||
context: '商贩',
|
||||
};
|
||||
const reward = buildNpcHelpReward(encounter, createGameState(encounter));
|
||||
|
||||
expect(reward.items.length).toBeGreaterThan(0);
|
||||
expect(reward.items[0]?.runtimeMetadata?.generationChannel).toBe('npc_reward');
|
||||
expect(reward.storyHint).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Character,
|
||||
CharacterConversationStyle,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
ItemRarity,
|
||||
NpcAnswerMode,
|
||||
@@ -70,8 +71,16 @@ import {
|
||||
buildQuestTurnInDetail,
|
||||
getQuestForIssuer,
|
||||
} from './questFlow';
|
||||
import { buildLooseRuntimeItemGenerationContext } from './runtimeItemContext';
|
||||
import { buildRuntimeInventoryStock } from './runtimeItemDirector';
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildRuntimeItemGenerationContext,
|
||||
} from './runtimeItemContext';
|
||||
import {
|
||||
buildDirectedRuntimeReward,
|
||||
buildRuntimeInventoryStock,
|
||||
generateDirectedRuntimeReward,
|
||||
} from './runtimeItemDirector';
|
||||
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
|
||||
import {
|
||||
getStoryOptionPriority,
|
||||
sortStoryOptionsByPriority,
|
||||
@@ -81,7 +90,8 @@ export type NpcHelpReward = {
|
||||
hp?: number;
|
||||
mana?: number;
|
||||
cooldownBonus?: number;
|
||||
item?: InventoryItem;
|
||||
items: InventoryItem[];
|
||||
storyHint?: string;
|
||||
};
|
||||
|
||||
export type GiftCandidate = {
|
||||
@@ -337,9 +347,51 @@ function getRuntimeTradeKinds(encounter: Encounter) {
|
||||
return ['consumable', 'material', 'relic', 'equipment'] as const;
|
||||
}
|
||||
|
||||
function isRuntimeTradeDrivenRoleNpc(encounter: Encounter) {
|
||||
return !encounter.characterId && !encounter.monsterPresetId;
|
||||
}
|
||||
|
||||
function buildRuntimeTradeSeedKey(
|
||||
encounter: Encounter,
|
||||
sceneId?: string | null,
|
||||
) {
|
||||
return `npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}:${sceneId ?? 'scene'}`;
|
||||
}
|
||||
|
||||
function buildNpcTradeStockSignature(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
const context = buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'npc_trade',
|
||||
encounter,
|
||||
});
|
||||
|
||||
return [
|
||||
context.worldType ?? 'unknown-world',
|
||||
encounter.id ?? encounter.npcName,
|
||||
context.playerBuildTags.join('|'),
|
||||
context.playerEquipmentTags.join('|'),
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function buildRuntimeTradeStock(
|
||||
encounter: Encounter,
|
||||
context: ReturnType<typeof buildRuntimeItemGenerationContext>
|
||||
| ReturnType<typeof buildLooseRuntimeItemGenerationContext>,
|
||||
) {
|
||||
return buildRuntimeInventoryStock(context, {
|
||||
seedKey: buildRuntimeTradeSeedKey(encounter, context.sceneId),
|
||||
itemCount: 4,
|
||||
fixedKinds: [...getRuntimeTradeKinds(encounter)],
|
||||
});
|
||||
}
|
||||
|
||||
function buildRoleInventory(
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null = WorldType.WUXIA,
|
||||
state?: GameState | null,
|
||||
) {
|
||||
if (getRuntimeCustomWorldProfile()) {
|
||||
return sortInventoryItems(
|
||||
@@ -353,18 +405,20 @@ function buildRoleInventory(
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeContext = buildLooseRuntimeItemGenerationContext({
|
||||
worldType,
|
||||
encounter,
|
||||
generationChannel: 'npc_trade',
|
||||
playerCharacterId: 'npc-trade-preview',
|
||||
playerBuildTags: getNpcPreferenceTags(encounter),
|
||||
});
|
||||
const runtimeStock = buildRuntimeInventoryStock(runtimeContext, {
|
||||
seedKey: `npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}`,
|
||||
itemCount: 4,
|
||||
fixedKinds: [...getRuntimeTradeKinds(encounter)],
|
||||
});
|
||||
const runtimeContext = state
|
||||
? buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'npc_trade',
|
||||
encounter,
|
||||
})
|
||||
: buildLooseRuntimeItemGenerationContext({
|
||||
worldType,
|
||||
encounter,
|
||||
generationChannel: 'npc_trade',
|
||||
playerCharacterId: 'npc-trade-preview',
|
||||
playerBuildTags: getNpcPreferenceTags(encounter),
|
||||
});
|
||||
const runtimeStock = buildRuntimeTradeStock(encounter, runtimeContext);
|
||||
|
||||
if (runtimeStock.length > 0) {
|
||||
return sortInventoryItems(runtimeStock);
|
||||
@@ -665,6 +719,7 @@ export function normalizeNpcPersistentState(
|
||||
knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors)
|
||||
? npcState.knownAttributeRumors.filter((fact): fact is string => typeof fact === 'string')
|
||||
: [],
|
||||
tradeStockSignature: npcState.tradeStockSignature ?? null,
|
||||
firstMeaningfulContactResolved: npcState.firstMeaningfulContactResolved ?? false,
|
||||
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
|
||||
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
|
||||
@@ -672,6 +727,47 @@ export function normalizeNpcPersistentState(
|
||||
};
|
||||
}
|
||||
|
||||
export function syncNpcTradeInventory(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
npcState: NpcPersistentState,
|
||||
) {
|
||||
if (getRuntimeCustomWorldProfile() || !isRuntimeTradeDrivenRoleNpc(encounter)) {
|
||||
return npcState;
|
||||
}
|
||||
|
||||
const tradeStockSignature = buildNpcTradeStockSignature(state, encounter);
|
||||
if (npcState.tradeStockSignature === tradeStockSignature) {
|
||||
return npcState;
|
||||
}
|
||||
|
||||
const runtimeContext = buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'npc_trade',
|
||||
encounter,
|
||||
});
|
||||
const runtimeStock = buildRuntimeTradeStock(encounter, runtimeContext);
|
||||
|
||||
if (runtimeStock.length <= 0) {
|
||||
return normalizeNpcPersistentState({
|
||||
...npcState,
|
||||
tradeStockSignature,
|
||||
});
|
||||
}
|
||||
|
||||
const preservedInventory = npcState.tradeStockSignature
|
||||
? npcState.inventory.filter(
|
||||
(item) => item.runtimeMetadata?.generationChannel !== 'npc_trade',
|
||||
)
|
||||
: [];
|
||||
|
||||
return normalizeNpcPersistentState({
|
||||
...npcState,
|
||||
inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]),
|
||||
tradeStockSignature,
|
||||
});
|
||||
}
|
||||
|
||||
export function isNpcFirstMeaningfulContact(
|
||||
encounter: Encounter,
|
||||
npcState: NpcPersistentState,
|
||||
@@ -935,59 +1031,130 @@ function getNpcChatTopics(encounter: Encounter, npcState?: NpcPersistentState) {
|
||||
];
|
||||
}
|
||||
|
||||
function getHelpItem(
|
||||
prefix: string,
|
||||
category: string,
|
||||
name: string,
|
||||
rarity: ItemRarity,
|
||||
tags: string[],
|
||||
) {
|
||||
return buildInventoryItem(prefix, category, name, 1, rarity, tags);
|
||||
}
|
||||
|
||||
export function buildNpcHelpReward(encounter: Encounter): NpcHelpReward {
|
||||
function resolveNpcHelpRewardConfig(encounter: Encounter) {
|
||||
const source = getRoleSource(encounter);
|
||||
|
||||
if (/摊主|商|军需/u.test(source)) {
|
||||
return {
|
||||
mana: 14,
|
||||
item: getHelpItem('npc-help', '消耗品', '临时补给', 'uncommon', [
|
||||
'healing',
|
||||
'mana',
|
||||
]),
|
||||
baseMana: 14,
|
||||
fixedKinds: ['consumable'] as const,
|
||||
fixedPermanence: ['timed'] as const,
|
||||
itemCount: 1,
|
||||
cooldownBonus: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/渡|舟/u.test(source)) {
|
||||
return {
|
||||
mana: 18,
|
||||
baseMana: 18,
|
||||
cooldownBonus: 1,
|
||||
fixedKinds: ['consumable', 'relic'] as const,
|
||||
fixedPermanence: ['timed', 'permanent'] as const,
|
||||
itemCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (/猎|守|卫|弟子/u.test(source)) {
|
||||
return {
|
||||
hp: 28,
|
||||
baseHp: 28,
|
||||
cooldownBonus: 1,
|
||||
fixedKinds: ['consumable', 'equipment'] as const,
|
||||
fixedPermanence: ['timed', 'permanent'] as const,
|
||||
itemCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (/书|学|碑|墓/u.test(source)) {
|
||||
return {
|
||||
mana: 20,
|
||||
item: getHelpItem('npc-help', '稀有品', '旧卷残页', 'rare', [
|
||||
'relic',
|
||||
'mana',
|
||||
]),
|
||||
baseMana: 20,
|
||||
fixedKinds: ['relic'] as const,
|
||||
fixedPermanence: ['permanent'] as const,
|
||||
itemCount: 1,
|
||||
cooldownBonus: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hp: 18,
|
||||
mana: 10,
|
||||
baseHp: 18,
|
||||
baseMana: 10,
|
||||
fixedKinds: ['consumable'] as const,
|
||||
fixedPermanence: ['timed'] as const,
|
||||
itemCount: 1,
|
||||
cooldownBonus: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNpcHelpRewardFromDirectedReward(
|
||||
directedReward: ReturnType<typeof buildDirectedRuntimeReward>,
|
||||
cooldownBonus = 0,
|
||||
): NpcHelpReward {
|
||||
return {
|
||||
hp: directedReward.hp,
|
||||
mana: directedReward.mana,
|
||||
cooldownBonus,
|
||||
items: flattenDirectedRuntimeRewardItems(directedReward),
|
||||
storyHint: directedReward.storyHint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNpcHelpReward(
|
||||
encounter: Encounter,
|
||||
state?: GameState | null,
|
||||
): NpcHelpReward {
|
||||
const rewardConfig = resolveNpcHelpRewardConfig(encounter);
|
||||
const runtimeContext = state
|
||||
? buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'npc_reward',
|
||||
encounter,
|
||||
})
|
||||
: buildLooseRuntimeItemGenerationContext({
|
||||
worldType: resolveNpcInsightWorldType(null, encounter),
|
||||
encounter,
|
||||
generationChannel: 'npc_reward',
|
||||
playerCharacterId: 'npc-help-preview',
|
||||
playerBuildTags: getNpcPreferenceTags(encounter),
|
||||
});
|
||||
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
|
||||
seedKey: `npc-help:${encounter.id ?? encounter.npcName}:${runtimeContext.sceneId ?? 'scene'}`,
|
||||
itemCount: rewardConfig.itemCount,
|
||||
fixedKinds: [...rewardConfig.fixedKinds],
|
||||
fixedPermanence: [...rewardConfig.fixedPermanence],
|
||||
baseHp: rewardConfig.baseHp,
|
||||
baseMana: rewardConfig.baseMana,
|
||||
});
|
||||
|
||||
return buildNpcHelpRewardFromDirectedReward(
|
||||
directedReward,
|
||||
rewardConfig.cooldownBonus,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateNpcHelpReward(
|
||||
encounter: Encounter,
|
||||
state: GameState,
|
||||
): Promise<NpcHelpReward> {
|
||||
const rewardConfig = resolveNpcHelpRewardConfig(encounter);
|
||||
const runtimeContext = buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'npc_reward',
|
||||
encounter,
|
||||
});
|
||||
const directedReward = await generateDirectedRuntimeReward(runtimeContext, {
|
||||
seedKey: `npc-help:${encounter.id ?? encounter.npcName}:${runtimeContext.sceneId ?? 'scene'}`,
|
||||
itemCount: rewardConfig.itemCount,
|
||||
fixedKinds: [...rewardConfig.fixedKinds],
|
||||
fixedPermanence: [...rewardConfig.fixedPermanence],
|
||||
baseHp: rewardConfig.baseHp,
|
||||
baseMana: rewardConfig.baseMana,
|
||||
});
|
||||
|
||||
return buildNpcHelpRewardFromDirectedReward(
|
||||
directedReward,
|
||||
rewardConfig.cooldownBonus,
|
||||
);
|
||||
}
|
||||
|
||||
export function describeHelpReward(reward: NpcHelpReward) {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -995,7 +1162,8 @@ export function describeHelpReward(reward: NpcHelpReward) {
|
||||
if ((reward.mana ?? 0) > 0) parts.push(`回蓝 ${reward.mana}`);
|
||||
if ((reward.cooldownBonus ?? 0) > 0)
|
||||
parts.push(`冷却 -${reward.cooldownBonus}`);
|
||||
if (reward.item) parts.push(`获得 ${reward.item.name}`);
|
||||
if (reward.items.length > 0)
|
||||
parts.push(`获得 ${reward.items.map((item) => item.name).join('、')}`);
|
||||
|
||||
return parts.join('、') || '获得一些支援';
|
||||
}
|
||||
@@ -1168,6 +1336,7 @@ function getMonsterPresetForEncounter(encounter: Encounter) {
|
||||
export function buildInitialNpcState(
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null,
|
||||
state?: GameState | null,
|
||||
): NpcPersistentState {
|
||||
const initialAffinity =
|
||||
encounter.initialAffinity ??
|
||||
@@ -1177,11 +1346,11 @@ export function buildInitialNpcState(
|
||||
const character = getCharacterById(encounter.characterId);
|
||||
return character
|
||||
? buildCharacterNpcInventory(character, worldType)
|
||||
: buildRoleInventory(encounter);
|
||||
: buildRoleInventory(encounter, worldType, state);
|
||||
})()
|
||||
: encounter.monsterPresetId
|
||||
? buildMonsterPresetInventory(encounter, worldType)
|
||||
: buildRoleInventory(encounter);
|
||||
: buildRoleInventory(encounter, worldType, state);
|
||||
const attributeRumors = buildEncounterAttributeRumors(encounter, {
|
||||
worldType: resolveNpcInsightWorldType(worldType, encounter),
|
||||
customWorldProfile: getRuntimeCustomWorldProfile(),
|
||||
@@ -1194,6 +1363,10 @@ export function buildInitialNpcState(
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory,
|
||||
tradeStockSignature:
|
||||
state && isRuntimeTradeDrivenRoleNpc(encounter) && !getRuntimeCustomWorldProfile()
|
||||
? buildNpcTradeStockSignature(state, encounter)
|
||||
: null,
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: attributeRumors,
|
||||
@@ -1348,6 +1521,34 @@ export function getGiftCandidates(
|
||||
});
|
||||
}
|
||||
|
||||
export function getPreferredGiftItemId(
|
||||
playerInventory: InventoryItem[],
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: ReturnType<typeof getRuntimeCustomWorldProfile> | null;
|
||||
} = {},
|
||||
) {
|
||||
return (
|
||||
getGiftCandidates(playerInventory, encounter, options)[0]?.item.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGiftCandidateSummary(
|
||||
giftCandidates: GiftCandidate[],
|
||||
limit = 3,
|
||||
) {
|
||||
const preview = giftCandidates
|
||||
.slice(0, limit)
|
||||
.map((candidate) => `${candidate.item.name}(好感 +${candidate.affinityGain})`);
|
||||
|
||||
if (preview.length === 0) {
|
||||
return '暂无合适礼物';
|
||||
}
|
||||
|
||||
return preview.join('、');
|
||||
}
|
||||
|
||||
export function checkTradeItem(
|
||||
playerItem: InventoryItem | null,
|
||||
npcItem: InventoryItem,
|
||||
@@ -1495,6 +1696,7 @@ export function getNpcLootItems(
|
||||
}
|
||||
|
||||
export function buildNpcEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
npcState,
|
||||
playerCharacter,
|
||||
@@ -1505,6 +1707,7 @@ export function buildNpcEncounterStoryMoment({
|
||||
partySize,
|
||||
overrideText,
|
||||
}: {
|
||||
state?: GameState | null;
|
||||
encounter: Encounter;
|
||||
npcState: NpcPersistentState;
|
||||
playerCharacter: Character;
|
||||
@@ -1532,7 +1735,7 @@ export function buildNpcEncounterStoryMoment({
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile: runtimeCustomWorldProfile,
|
||||
});
|
||||
const helpReward = buildNpcHelpReward(encounter);
|
||||
const helpReward = buildNpcHelpReward(encounter, state);
|
||||
const recruitable = canRecruitAnyNpc(encounter);
|
||||
const recruitInsight = recruitable
|
||||
? buildRecruitmentInsight({
|
||||
@@ -1637,7 +1840,7 @@ export function buildNpcEncounterStoryMoment({
|
||||
buildNpcOption(
|
||||
NPC_GIFT_FUNCTION.id,
|
||||
NPC_GIFT_FUNCTION.title,
|
||||
`打开礼物面板并显示可能获得的好感度。高品质礼物更容易打动对方。`,
|
||||
`当前较适合送出的礼物有:${buildGiftCandidateSummary(giftCandidates)}。打开礼物面板后可查看详细好感收益。`,
|
||||
npcId,
|
||||
'gift',
|
||||
),
|
||||
@@ -1750,6 +1953,13 @@ export function buildNpcGiftResultText(
|
||||
return `${encounter.npcName}收下了${item.name},${describeAffinityShift(affinityGain)}。${describeNpcAffinityInWords(encounter, nextAffinity)}${summaryText}`;
|
||||
}
|
||||
|
||||
export function buildNpcGiftCommitActionText(
|
||||
encounter: Encounter,
|
||||
item: InventoryItem,
|
||||
) {
|
||||
return `把${item.name}赠给${encounter.npcName}`;
|
||||
}
|
||||
|
||||
export function buildNpcTradeResultText(
|
||||
encounter: Encounter,
|
||||
gainedItem: InventoryItem,
|
||||
@@ -1789,11 +1999,32 @@ export function buildNpcTradeTransactionResultText({
|
||||
return `${encounter.npcName}收下了${formatCurrency(totalPrice, worldType)},把${quantityText}卖给了你。`;
|
||||
}
|
||||
|
||||
export function buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode,
|
||||
item,
|
||||
quantity,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
mode: 'buy' | 'sell';
|
||||
item: InventoryItem;
|
||||
quantity: number;
|
||||
}) {
|
||||
const quantityText = quantity > 1 ? `${item.name} x${quantity}` : item.name;
|
||||
|
||||
if (mode === 'sell') {
|
||||
return `把${quantityText}卖给${encounter.npcName}`;
|
||||
}
|
||||
|
||||
return `从${encounter.npcName}手里买下${quantityText}`;
|
||||
}
|
||||
|
||||
export function buildNpcHelpResultText(
|
||||
encounter: Encounter,
|
||||
reward: NpcHelpReward,
|
||||
) {
|
||||
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}。`;
|
||||
const storyHintText = reward.storyHint ? ` ${reward.storyHint}` : '';
|
||||
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}。${storyHintText}`;
|
||||
}
|
||||
|
||||
export function buildNpcRecruitResultText(
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('questFlow', () => {
|
||||
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
|
||||
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
|
||||
expect(quest?.status).toBe('active');
|
||||
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe('quest_reward');
|
||||
});
|
||||
|
||||
it('advances from primary objective to report-back step and then reward-ready', () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
QuestProgressSignal,
|
||||
QuestSceneSnapshot,
|
||||
} from '../services/questTypes';
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import {
|
||||
type CustomWorldProfile,
|
||||
type InventoryItem,
|
||||
type QuestLogEntry,
|
||||
type QuestObjective,
|
||||
type QuestObjectiveKind,
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from '../types';
|
||||
import {formatCurrency} from './economy';
|
||||
import {getHostileNpcPresetById} from './hostileNpcPresets';
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildQuestRuntimeItemGenerationContext,
|
||||
} from './runtimeItemContext';
|
||||
import {buildDirectedRuntimeReward} from './runtimeItemDirector';
|
||||
import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative';
|
||||
import {getSceneHostileNpcs} from './scenePresets';
|
||||
|
||||
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
|
||||
@@ -44,50 +50,129 @@ type SceneQuestThreat =
|
||||
suggestedThreatType: 'relationship';
|
||||
};
|
||||
|
||||
function buildQuestItem(
|
||||
prefix: string,
|
||||
category: string,
|
||||
name: string,
|
||||
rarity: InventoryItem['rarity'],
|
||||
tags: string[],
|
||||
): InventoryItem {
|
||||
function resolveQuestRewardRuntimeConfig(params: {
|
||||
roleText: string;
|
||||
rewardTheme: QuestIntent['rewardTheme'];
|
||||
narrativeType: QuestIntent['narrativeType'];
|
||||
}) {
|
||||
const {roleText, rewardTheme, narrativeType} = params;
|
||||
|
||||
if (rewardTheme === 'resource') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['material', 'consumable'] as const,
|
||||
fixedPermanence: ['resource', 'timed'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rewardTheme === 'intel') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['relic', 'consumable'] as const,
|
||||
fixedPermanence: ['permanent', 'timed'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rewardTheme === 'rare_item' || narrativeType === 'trial') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['equipment', 'relic'] as const,
|
||||
fixedPermanence: ['permanent', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (/猎|山|追踪/u.test(roleText)) {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['consumable', 'equipment'] as const,
|
||||
fixedPermanence: ['timed', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (/商|军需/u.test(roleText)) {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['material', 'relic'] as const,
|
||||
fixedPermanence: ['resource', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rewardTheme === 'relationship' || narrativeType === 'relationship') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['relic', 'equipment'] as const,
|
||||
fixedPermanence: ['permanent', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${prefix}:${encodeURIComponent(`${category}-${name}`)}`,
|
||||
category,
|
||||
name,
|
||||
quantity: 1,
|
||||
rarity,
|
||||
tags,
|
||||
itemCount: 2,
|
||||
fixedKinds: ['equipment', 'consumable'] as const,
|
||||
fixedPermanence: ['permanent', 'timed'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestReward(params: {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
worldType: WorldType | null;
|
||||
roleText: string;
|
||||
rewardTheme: QuestIntent['rewardTheme'];
|
||||
narrativeType: QuestIntent['narrativeType'];
|
||||
scene: QuestSceneSnapshot | null;
|
||||
context?: QuestGenerationContext;
|
||||
}): QuestReward {
|
||||
const {worldType, roleText, rewardTheme, narrativeType, scene} = params;
|
||||
const sharedRelic = worldType === 'XIANXIA'
|
||||
? buildQuestItem('quest', '稀有品', '灵纹符匣', 'rare', ['relic', 'mana'])
|
||||
: buildQuestItem('quest', '稀有品', '江湖悬赏令', 'rare', ['relic']);
|
||||
const roleItem = /猎|山|追踪/u.test(roleText)
|
||||
? buildQuestItem('quest', '消耗品', '追踪药包', 'uncommon', ['healing'])
|
||||
: /商|军需/u.test(roleText)
|
||||
? buildQuestItem('quest', '材料', '精制布包', 'uncommon', ['material'])
|
||||
: rewardTheme === 'resource'
|
||||
? buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])
|
||||
: buildQuestItem('quest', '消耗品', '回气散', 'uncommon', ['mana']);
|
||||
const {
|
||||
issuerNpcId,
|
||||
issuerNpcName,
|
||||
worldType,
|
||||
roleText,
|
||||
rewardTheme,
|
||||
narrativeType,
|
||||
scene,
|
||||
context,
|
||||
} = params;
|
||||
const runtimeConfig = resolveQuestRewardRuntimeConfig({
|
||||
roleText,
|
||||
rewardTheme,
|
||||
narrativeType,
|
||||
});
|
||||
const runtimeContext = context
|
||||
? buildQuestRuntimeItemGenerationContext({
|
||||
context,
|
||||
issuerNpcId,
|
||||
issuerNpcName,
|
||||
roleText,
|
||||
scene,
|
||||
})
|
||||
: buildLooseRuntimeItemGenerationContext({
|
||||
worldType,
|
||||
scene,
|
||||
encounter: {
|
||||
id: issuerNpcId,
|
||||
kind: 'npc',
|
||||
npcName: issuerNpcName,
|
||||
npcDescription: roleText,
|
||||
npcAvatar: '',
|
||||
context: roleText,
|
||||
},
|
||||
playerCharacterId: 'quest-preview-player',
|
||||
generationChannel: 'quest_reward',
|
||||
});
|
||||
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
|
||||
seedKey: `quest:${issuerNpcId}:${scene?.id ?? 'scene'}:${rewardTheme}:${narrativeType}`,
|
||||
itemCount: runtimeConfig.itemCount,
|
||||
fixedKinds: [...runtimeConfig.fixedKinds],
|
||||
fixedPermanence: [...runtimeConfig.fixedPermanence],
|
||||
});
|
||||
|
||||
const reward: QuestReward = {
|
||||
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
|
||||
currency: rewardTheme === 'intel'
|
||||
? (worldType === 'XIANXIA' ? 40 : 58)
|
||||
: (worldType === 'XIANXIA' ? 54 : 72),
|
||||
items: rewardTheme === 'resource'
|
||||
? [roleItem, buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])]
|
||||
: [sharedRelic, roleItem],
|
||||
items: flattenDirectedRuntimeRewardItems(directedReward),
|
||||
storyHint: directedReward.storyHint,
|
||||
};
|
||||
|
||||
if (rewardTheme === 'intel') {
|
||||
@@ -103,7 +188,7 @@ function buildQuestReward(params: {
|
||||
}
|
||||
|
||||
function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
|
||||
const itemText = reward.items.map(item => item.name).join('、');
|
||||
const itemText = reward.items.map(item => item.name).join('、') || '当前局势相关的补给';
|
||||
const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}”` : '';
|
||||
return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`;
|
||||
}
|
||||
@@ -701,11 +786,14 @@ export function compileQuestIntentToQuest(
|
||||
|
||||
const steps = [primaryStep, buildTalkBackStep(params.issuerNpcId, params.issuerNpcName)];
|
||||
const reward = buildQuestReward({
|
||||
issuerNpcId: params.issuerNpcId,
|
||||
issuerNpcName: params.issuerNpcName,
|
||||
worldType: params.worldType,
|
||||
roleText: params.roleText,
|
||||
rewardTheme: intent.rewardTheme,
|
||||
narrativeType: intent.narrativeType,
|
||||
scene: params.scene,
|
||||
context: params.context,
|
||||
});
|
||||
const rewardText = buildRewardText(reward, params.worldType);
|
||||
const contract: QuestContract = {
|
||||
@@ -791,7 +879,8 @@ export function buildQuestTurnInResultText(quest: QuestLogEntry) {
|
||||
const intelText = quest.reward.intel?.rumorText
|
||||
? `,并额外告诉了你一条消息:${quest.reward.intel.rumorText}`
|
||||
: '';
|
||||
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}。`;
|
||||
const storyHintText = quest.reward.storyHint ? ` ${quest.reward.storyHint}` : '';
|
||||
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`;
|
||||
}
|
||||
|
||||
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
|
||||
import {compileRuntimeItem} from './runtimeItemCompiler';
|
||||
import {
|
||||
applyRuntimeItemNarrative,
|
||||
@@ -157,8 +158,9 @@ function compilePlannedItem(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
seedKey: string,
|
||||
intentOverride?: ReturnType<typeof buildRuntimeItemAiIntent>,
|
||||
) {
|
||||
const intent = buildRuntimeItemAiIntent(context, plan);
|
||||
const intent = intentOverride ?? buildRuntimeItemAiIntent(context, plan);
|
||||
const compiled = compileRuntimeItem({
|
||||
seedKey,
|
||||
context,
|
||||
@@ -174,15 +176,10 @@ function compilePlannedItem(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
function buildDirectedRewardFromItems(
|
||||
compiledItems: InventoryItem[],
|
||||
options: RuntimeRewardOptions,
|
||||
): DirectedRuntimeReward {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
|
||||
);
|
||||
|
||||
const reward: DirectedRuntimeReward = {
|
||||
primaryItem: compiledItems[0] ?? null,
|
||||
supportItems: compiledItems.slice(1),
|
||||
@@ -198,6 +195,45 @@ export function buildDirectedRuntimeReward(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): DirectedRuntimeReward {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
|
||||
);
|
||||
|
||||
return buildDirectedRewardFromItems(compiledItems, options);
|
||||
}
|
||||
|
||||
export async function generateDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): Promise<DirectedRuntimeReward> {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
|
||||
try {
|
||||
const aiIntents = await generateRuntimeItemAiIntents({
|
||||
context,
|
||||
plans,
|
||||
});
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(
|
||||
context,
|
||||
plan,
|
||||
`${options.seedKey}:${plan.slot}:${index}`,
|
||||
aiIntents[index],
|
||||
),
|
||||
);
|
||||
|
||||
return buildDirectedRewardFromItems(compiledItems, options);
|
||||
} catch (error) {
|
||||
console.warn('[RuntimeItemDirector] falling back to deterministic item intent', error);
|
||||
return buildDirectedRuntimeReward(context, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeInventoryStock(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
|
||||
@@ -27,7 +27,7 @@ function getNpcEncounterKey(encounter: Encounter) {
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType);
|
||||
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
}
|
||||
|
||||
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCharacterNpcSceneIds,
|
||||
PRESET_CHARACTERS,
|
||||
} from './characterPresets';
|
||||
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
|
||||
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
|
||||
import { getMonsterPresetById } from './hostileNpcPresets';
|
||||
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
|
||||
@@ -191,12 +192,32 @@ export function buildEncounterFromSceneNpc(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profile: CustomWorldProfile): SceneNpc {
|
||||
const attributeProfile = npc.attributeProfile
|
||||
function buildCustomSceneNpc(
|
||||
npc: CustomWorldProfile['storyNpcs'][number],
|
||||
profile: CustomWorldProfile,
|
||||
anchorWorldType: WorldType,
|
||||
): SceneNpc {
|
||||
const monsterPreset =
|
||||
npc.initialAffinity < 0
|
||||
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
|
||||
: null;
|
||||
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
|
||||
const attributeProfile = monsterPreset?.attributeProfile
|
||||
?? npc.attributeProfile
|
||||
?? buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema: resolveAttributeSchema(WorldType.CUSTOM, profile),
|
||||
textBlocks: [npc.role, npc.description, npc.motivation],
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...npc.relationshipHooks,
|
||||
...npc.tags,
|
||||
],
|
||||
}).profile;
|
||||
|
||||
return {
|
||||
@@ -206,8 +227,14 @@ function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profi
|
||||
avatar: npc.name.slice(0, 1) || '?',
|
||||
description: `${npc.description} 动机:${npc.motivation}`,
|
||||
gender: inferCustomNpcGender(npc.id, npc.name),
|
||||
recruitable: true,
|
||||
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
monsterPresetId: monsterPreset?.id,
|
||||
hostileNpcPresetId: monsterPreset?.id,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
hostile,
|
||||
recruitable: !hostile,
|
||||
functions: hostile
|
||||
? ['fight']
|
||||
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
attributeProfile,
|
||||
};
|
||||
}
|
||||
@@ -238,7 +265,9 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
: null;
|
||||
}).filter(Boolean) as SceneNpc[];
|
||||
|
||||
const customStoryNpcs = profile.storyNpcs.map(npc => buildCustomSceneNpc(npc, profile));
|
||||
const customStoryNpcs = profile.storyNpcs.map(npc =>
|
||||
buildCustomSceneNpc(npc, profile, anchorWorldType),
|
||||
);
|
||||
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
|
||||
const customScenes: ScenePreset[] = [
|
||||
{
|
||||
@@ -394,7 +423,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-13', 'monster-08'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: ['???????', '??????'],
|
||||
treasureHints: ['竹根旁半埋的刀鞘', '倒竹间的旧药囊'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-bamboo-woodcutter', '樵夫老周', '樵夫', '樵', '常在竹海边缘砍柴,对附近路数和兽踪了如指掌。'),
|
||||
],
|
||||
@@ -407,7 +436,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-04', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
|
||||
forwardSceneId: 'wuxia-temple-forecourt',
|
||||
treasureHints: ['瑁傜紳涓殑閾滃專', '鐭崇嫯搴曞骇鏃侀仐钀界殑浠ょ墝'],
|
||||
treasureHints: ['裂缝里的铜钥', '石狮座下遗落的令牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-gate-disciple', '守山弟子', '门派弟子', '守', '一直盯着石阶尽头的动静,像在等某位重要来客。'),
|
||||
],
|
||||
@@ -420,7 +449,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-11', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-ferry-bridge',
|
||||
treasureHints: ['???????', '????????'],
|
||||
treasureHints: ['灯檐下浸湿的布包', '排水沟边翻起的账册残页'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-night-vendor', '夜灯摊主', '摊主', '灯', '深夜仍在街口守着灯摊,见过太多不该见的人。'),
|
||||
],
|
||||
@@ -433,7 +462,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-03', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['????????', '????????'],
|
||||
treasureHints: ['断墙后压着的木匣', '枯井边散落的旧簪'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-village-remnant', '守村妇人', '遗民', '民', '不肯离开这片断垣,似乎还在等某个人归来。'),
|
||||
],
|
||||
@@ -446,7 +475,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-04', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['???????', '????????'],
|
||||
treasureHints: ['桥柱缝里的油纸包', '渡船板下藏着的旧钱袋'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-ferryman', '老渡工', '渡工', '渡', '常年摆渡,看人看路都很准,有些话只肯对识货的人说。'),
|
||||
],
|
||||
@@ -459,7 +488,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-08', 'monster-13', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
|
||||
forwardSceneId: 'wuxia-ruined-village',
|
||||
treasureHints: ['缂犲湪鏍戞牴涓婄殑閿﹀泭', '琚浘姘存场婀跨殑鍦板浘娈嬮〉'],
|
||||
treasureHints: ['缠在树根上的锦囊', '被雾水泡湿的地图残页'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-hunter', '追迹猎户', '猎户', '猎', '脚边总带着兽夹和草药,对林中异动非常敏感。'),
|
||||
],
|
||||
@@ -472,7 +501,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-18', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['废营帐里的箭囊', '火盆旁埋着的军需匣'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-quartermaster', '军需官', '营地官', '营', '管着兵器和粮草,对各路来客始终保持戒心。'),
|
||||
],
|
||||
@@ -485,7 +514,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-03', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
|
||||
forwardSceneId: 'wuxia-mine-depths',
|
||||
treasureHints: ['???????', '????????'],
|
||||
treasureHints: ['砖缝里的陪葬铜匣', '石灯底座后的残卷'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-tomb-scholar', '探碑书生', '学者', '碑', '抱着拓本在地宫里转来转去,似乎在找某段缺失铭文。'),
|
||||
],
|
||||
@@ -498,7 +527,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-04', 'monster-03'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
|
||||
forwardSceneId: 'wuxia-crypt-passage',
|
||||
treasureHints: ['????????', '???????'],
|
||||
treasureHints: ['香炉灰里的玉珠', '石灯下压着的签牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-temple-host', '守庙僧', '僧人', '僧', '白日扫院夜里守灯,似乎知道地宫里曾封过什么。'),
|
||||
],
|
||||
@@ -511,7 +540,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-06', 'monster-18'],
|
||||
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-forge-works',
|
||||
treasureHints: ['鐭胯溅澶瑰眰閲岀殑閾剁洅', '鍩嬪湪鐭挎福涓殑绮鹃搧'],
|
||||
treasureHints: ['矿车夹层里的银匣', '埋在碎矿中的精铁'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-miner', '老矿头', '矿工', '矿', '靠耳朵分辨坑道深处的回响,比谁都先知道危险会从哪边来。'),
|
||||
],
|
||||
@@ -524,7 +553,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-18', 'monster-04'],
|
||||
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-palace-court',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['淬火池旁的铁匣', '风箱后压着的旧兵谱'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-blacksmith', '老铸匠', '铸匠', '铸', '看一眼兵器缺口就知道你刚从什么地方杀出来。'),
|
||||
],
|
||||
@@ -537,7 +566,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-11', 'monster-13'],
|
||||
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-maid', '旧宫侍女', '宫人', '侍', '嘴上说得少,却总知道哪条回廊最近不该过去。'),
|
||||
],
|
||||
@@ -553,7 +582,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-celestial-corridor',
|
||||
treasureHints: ['?????????', '????????'],
|
||||
treasureHints: ['云阶尽头的灵符匣', '门阙阴影里的玉牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-gate-attendant', '守门灵官', '门官', '门', '站在门阙侧旁观来者,像在等一份迟迟未到的回报。'),
|
||||
],
|
||||
@@ -566,7 +595,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-12', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['??????????', '???????????'],
|
||||
treasureHints: ['浮岛边缘的灵羽匣', '云藤下悬着的小玉瓶'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cloud-hermit', '云栖散修', '散修', '云', '常坐在浮岛边缘打坐,对天风和禁制的变化很敏感。'),
|
||||
],
|
||||
@@ -579,7 +608,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-14'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-thunder-altar',
|
||||
treasureHints: ['??????????', '?????????'],
|
||||
treasureHints: ['廊柱暗槽里的玉简', '风铃后藏着的封签'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-palace-page', '抄经侍者', '侍者', '卷', '抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。'),
|
||||
],
|
||||
@@ -592,7 +621,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['药圃深处的灵壶', '花架下压着的采录册'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-herbal-keeper', '药圃执事', '药师', '药', '守着花圃记录灵植开谢,也清楚哪些地方最近长出了怪东西。'),
|
||||
],
|
||||
@@ -605,7 +634,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-10', 'monster-12', 'monster-20'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['瀵掔帀瑁傞殭閲岀殑鐏甸珦', '鍐伴潰涓嬮棯鐫€鍏夌殑璐濆專'],
|
||||
treasureHints: ['寒玉裂隙里的灵髓', '冰面下闪着光的贝匣'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cold-scholar', '寒洞客', '访客', '玉', '在洞天里采样寒玉碎屑,像在研究更深处的封禁。'),
|
||||
],
|
||||
@@ -618,7 +647,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-14', 'monster-10'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['熔岩边冷却的矿匣', '焦岩后藏着的火纹石'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-fire-forger', '熔炉匠修', '炼匠', '炉', '在热浪里锻器不歇,见惯灵火失控的后果。'),
|
||||
],
|
||||
@@ -631,7 +660,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-star-vessel',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['祭坛角落的雷纹匣', '断碑背面的青铜铃'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-thunder-keeper', '祭雷守使', '守使', '雷', '总站在祭坛边缘看天,像在确认下一道雷会落到哪里。'),
|
||||
],
|
||||
@@ -644,7 +673,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-12', 'monster-16', 'monster-02'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-floating-isle',
|
||||
treasureHints: ['????????', '??????????'],
|
||||
treasureHints: ['舵台后的星图匣', '甲板缝里卡着的灵罗盘'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-helmsman', '星舟舵手', '舵手', '舟', '守着老旧星舟的航线图,对高空中的异动异常敏感。'),
|
||||
],
|
||||
@@ -657,7 +686,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-20', 'monster-14', 'monster-15'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
|
||||
forwardSceneId: 'xianxia-herb-garden',
|
||||
treasureHints: ['婀栧哺杈规紓鏉ョ殑鐜夌洅', '鏈堣壊涓嬭嫢闅愯嫢鐜扮殑閾堕搩'],
|
||||
treasureHints: ['湖岸边漂来的玉匣', '月色下若隐若现的银铃'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-lake-watcher', '湖畔琴师', '琴师', '琴', '常在月湖边抚琴,像在等某段旋律把什么引出来。'),
|
||||
],
|
||||
@@ -670,7 +699,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-05', 'monster-12'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['娈嬮樀涓績鍩嬬潃鐨勭帀绠€', '鍊掑纰戞煴閲岀殑灏忓專'],
|
||||
treasureHints: ['残阵中心埋着的玉简', '倒塌碑柱里的小匣'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-ruin-scholar', '寻迹司录', '司录', '录', '拿着一卷旧图在断墙间比对,像快要找到重要坐标。'),
|
||||
],
|
||||
@@ -683,7 +712,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['?????????', '??????????'],
|
||||
treasureHints: ['盘根间的木纹匣', '树洞深处垂着的灵种'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-tree-ward', '守木灵侍', '灵侍', '木', '一直绕着古树巡看,像是担心有人惊动树心。'),
|
||||
],
|
||||
@@ -696,7 +725,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-12', 'monster-20', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-cloud-gate',
|
||||
treasureHints: ['鐎戝箷鍚庨棯鐫€鍏夌殑鐭冲專', '宕栬竟钘や笂鎸傜潃鐨勬姢韬搩'],
|
||||
treasureHints: ['瀑幕后闪着光的石匣', '崖边藤上挂着的护身铃'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cliff-scout', '崖巡女修', '巡修', '崖', '长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。'),
|
||||
],
|
||||
|
||||
@@ -11,8 +11,8 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
|
||||
const FALLBACK_STORY: StoryMoment = {
|
||||
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
|
||||
options: [
|
||||
createFallbackOption('battle_all_in_crush', '鎴樻枟锛氬叏鍔涜繘鏀伙紝鍘嬪灝瀵规墜', AnimationState.SKILL1, 0, false),
|
||||
createFallbackOption('battle_probe_pressure', '鎴樻枟锛氱ǔ鎵庣ǔ鎵擄紝杩炵暘璇曟帰', AnimationState.SKILL2, 0, false),
|
||||
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false),
|
||||
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false),
|
||||
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false),
|
||||
],
|
||||
};
|
||||
@@ -50,7 +50,7 @@ export function inferCombatStyle(option: StoryOption): CombatStyle {
|
||||
if (category === 'escape') return 'escape';
|
||||
if (option.functionId === 'battle_all_in_crush' || option.functionId === 'battle_finisher_window') return 'all_in';
|
||||
if (classifyCombatOption(option) === 'escape') return 'escape';
|
||||
if (text.includes('鍏ㄥ姏') || text.includes('鍘嬩笂') || text.includes('鐚涙敾')) return 'all_in';
|
||||
if (text.includes('全力') || text.includes('压上') || text.includes('猛攻')) return 'all_in';
|
||||
return 'steady';
|
||||
}
|
||||
|
||||
@@ -200,16 +200,16 @@ export function getOptionImpactSummary(
|
||||
|
||||
if ((effect.healAmount ?? 0) > 0) {
|
||||
const healAmount = Math.max(0, Math.min(effect.healAmount ?? 0, maxHp - hp));
|
||||
parts.push(`鍥炶 ${healAmount}`);
|
||||
parts.push(`回血 ${healAmount}`);
|
||||
}
|
||||
|
||||
if ((effect.manaRestore ?? 0) > 0) {
|
||||
const manaRestore = Math.max(0, Math.min(effect.manaRestore ?? 0, maxMana - mana));
|
||||
parts.push(`鍥炶摑 ${manaRestore}`);
|
||||
parts.push(`回蓝 ${manaRestore}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0 && (effect.cooldownTickBonus ?? 0) > 0) {
|
||||
parts.push(`鍑廋D -${effect.cooldownTickBonus} 鍥炲悎`);
|
||||
parts.push(`减冷却 ${effect.cooldownTickBonus} 回合`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
@@ -218,7 +218,7 @@ export function getOptionImpactSummary(
|
||||
if (functionMeta.category !== 'battle') return null;
|
||||
|
||||
if (currentNpcBattleMode === 'spar') {
|
||||
return '鍒囩浼ゅ 1';
|
||||
return '切磋伤害 1';
|
||||
}
|
||||
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
@@ -226,9 +226,9 @@ export function getOptionImpactSummary(
|
||||
(a, b) => b.weight - a.weight,
|
||||
);
|
||||
const topSkill = availableSkills[0]?.skill;
|
||||
if (!topSkill) return '鑰楄摑 -- / 浼ゅ --';
|
||||
if (!topSkill) return '耗蓝 -- / 伤害 --';
|
||||
|
||||
const damageMultiplier = getFunctionEffect(option.functionId).damageMultiplier ?? 1;
|
||||
const damage = Math.max(1, Math.round(topSkill.damage * damageMultiplier));
|
||||
return `鑰楄摑 ${topSkill.manaCost} / 浼ゅ ${damage}`;
|
||||
return `耗蓝 ${topSkill.manaCost} / 伤害 ${damage}`;
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export function useCharacterChatFlow({
|
||||
messages: baseMessages,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: error instanceof Error ? error.message : '未知 AI 错误',
|
||||
error: error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions: current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
|
||||
@@ -472,7 +472,7 @@ export function createStoryChoiceActions({
|
||||
setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error('Failed to continue npc battle resolution story:', storyError);
|
||||
setAiError(storyError instanceof Error ? storyError.message : '未知 AI 错误');
|
||||
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
|
||||
}
|
||||
return;
|
||||
@@ -533,7 +533,7 @@ export function createStoryChoiceActions({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildNpcLeaveResultText,
|
||||
buildNpcSparResultText,
|
||||
createNpcBattleMonster,
|
||||
generateNpcHelpReward,
|
||||
getChatAffinityOutcome,
|
||||
getNpcLootItems,
|
||||
getNpcSparMaxHp,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
@@ -459,7 +461,7 @@ export function createStoryNpcEncounterActions({
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream npc chat story:', error);
|
||||
setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 对话 AI 不可用。',
|
||||
error instanceof Error ? error.message : '角色对话智能生成不可用。',
|
||||
);
|
||||
const fallbackOptions =
|
||||
getAvailableOptionsForState(provisionalState, character) ?? [];
|
||||
@@ -545,51 +547,136 @@ export function createStoryNpcEncounterActions({
|
||||
|
||||
switch (option.interaction.action) {
|
||||
case 'help': {
|
||||
const reward = buildNpcHelpReward(encounter);
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (let index = 0; index < (reward.cooldownBonus ?? 0); index += 1) {
|
||||
cooldowns = Object.fromEntries(
|
||||
Object.entries(cooldowns).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
Math.max(0, turns - 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
let committed = false;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
helpUsed: true,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const reward = await generateNpcHelpReward(encounter, gameState);
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (
|
||||
let index = 0;
|
||||
index < (reward.cooldownBonus ?? 0);
|
||||
index += 1
|
||||
) {
|
||||
cooldowns = Object.fromEntries(
|
||||
Object.entries(cooldowns).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
Math.max(0, turns - 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerHp: Math.min(
|
||||
nextState.playerMaxHp,
|
||||
nextState.playerHp + (reward.hp ?? 0),
|
||||
),
|
||||
playerMana: Math.min(
|
||||
nextState.playerMaxMana,
|
||||
nextState.playerMana + (reward.mana ?? 0),
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
playerInventory: reward.item
|
||||
? addInventoryItems(nextState.playerInventory, [
|
||||
cloneInventoryItemForOwner(reward.item, 'player'),
|
||||
])
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
helpUsed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
);
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerHp: Math.min(
|
||||
nextState.playerMaxHp,
|
||||
nextState.playerHp + (reward.hp ?? 0),
|
||||
),
|
||||
playerMana: Math.min(
|
||||
nextState.playerMaxMana,
|
||||
nextState.playerMana + (reward.mana ?? 0),
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
playerInventory:
|
||||
reward.items.length > 0
|
||||
? addInventoryItems(
|
||||
nextState.playerInventory,
|
||||
reward.items.map((item) =>
|
||||
cloneInventoryItemForOwner(
|
||||
item,
|
||||
'player',
|
||||
item.quantity,
|
||||
),
|
||||
),
|
||||
)
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc help reward:', error);
|
||||
const reward = buildNpcHelpReward(encounter, gameState);
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (
|
||||
let index = 0;
|
||||
index < (reward.cooldownBonus ?? 0);
|
||||
index += 1
|
||||
) {
|
||||
cooldowns = Object.fromEntries(
|
||||
Object.entries(cooldowns).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
Math.max(0, turns - 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
helpUsed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerHp: Math.min(
|
||||
nextState.playerMaxHp,
|
||||
nextState.playerHp + (reward.hp ?? 0),
|
||||
),
|
||||
playerMana: Math.min(
|
||||
nextState.playerMaxMana,
|
||||
nextState.playerMana + (reward.mana ?? 0),
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
playerInventory:
|
||||
reward.items.length > 0
|
||||
? addInventoryItems(
|
||||
nextState.playerInventory,
|
||||
reward.items.map((item) =>
|
||||
cloneInventoryItemForOwner(
|
||||
item,
|
||||
'player',
|
||||
item.quantity,
|
||||
),
|
||||
),
|
||||
)
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} finally {
|
||||
if (!committed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
case 'chat': {
|
||||
@@ -645,32 +732,83 @@ export function createStoryNpcEncounterActions({
|
||||
getNpcEncounterKey(encounter),
|
||||
);
|
||||
if (existingQuest) return true;
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
let committed = false;
|
||||
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!quest) return true;
|
||||
try {
|
||||
const quest =
|
||||
(await generateQuestForNpcEncounter({
|
||||
state: gameState,
|
||||
encounter,
|
||||
})) ??
|
||||
buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!quest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
||||
encounter,
|
||||
(currentNpcState) =>
|
||||
markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
),
|
||||
{ questsAccepted: 1 },
|
||||
);
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
||||
encounter,
|
||||
(currentNpcState) =>
|
||||
markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
),
|
||||
{questsAccepted: 1},
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to accept npc quest:', error);
|
||||
const fallbackQuest = buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!fallbackQuest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) =>
|
||||
acceptQuest(quests, fallbackQuest),
|
||||
),
|
||||
encounter,
|
||||
(currentNpcState) =>
|
||||
markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
),
|
||||
{questsAccepted: 1},
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(fallbackQuest),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} finally {
|
||||
if (!committed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
case 'quest_turn_in': {
|
||||
|
||||
@@ -17,12 +17,16 @@ import {
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcRecruitResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
getPreferredGiftItemId,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcRecruitDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
@@ -326,7 +330,7 @@ export function useStoryNpcInteractionFlow({
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to continue recruit story:', error);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
|
||||
);
|
||||
@@ -412,7 +416,7 @@ export function useStoryNpcInteractionFlow({
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
@@ -426,7 +430,20 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
const currentNpcState = getResolvedNpcState(gameState, encounter);
|
||||
const npcState = syncNpcTradeInventory(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState,
|
||||
);
|
||||
|
||||
if (
|
||||
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|
||||
|| npcState !== currentNpcState
|
||||
) {
|
||||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||||
}
|
||||
|
||||
setTradeModal({
|
||||
encounter,
|
||||
actionText,
|
||||
@@ -438,10 +455,20 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
const selectedItemId = getPreferredGiftItemId(
|
||||
gameState.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedItemId) return;
|
||||
|
||||
setGiftModal({
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId: gameState.playerInventory[0]?.id ?? null,
|
||||
selectedItemId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -494,7 +521,12 @@ export function useStoryNpcInteractionFlow({
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
tradeModal.actionText,
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
@@ -534,7 +566,12 @@ export function useStoryNpcInteractionFlow({
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
tradeModal.actionText,
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
@@ -590,7 +627,7 @@ export function useStoryNpcInteractionFlow({
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
giftModal.actionText,
|
||||
buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
|
||||
'npc_gift',
|
||||
);
|
||||
|
||||
@@ -269,7 +269,7 @@ export async function playOpeningAdventureSequence({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to infer opening camp dialogue:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
}
|
||||
|
||||
const finalHistory = [
|
||||
@@ -313,7 +313,7 @@ export async function playOpeningAdventureSequence({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to play opening adventure sequence:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createStoryProgressionActions({
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -149,7 +149,7 @@ export function createStoryProgressionActions({
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -34,7 +34,10 @@ vi.mock('../../data/scenePresets', () => ({
|
||||
getWorldCampScenePreset: () => scenes[0] ?? null,
|
||||
}));
|
||||
|
||||
import { buildInitialNpcState, MAX_COMPANIONS } from '../../data/npcInteractions';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -74,7 +77,11 @@ function createCharacter(): Character {
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(id: string, name: string): InventoryItem {
|
||||
function createInventoryItem(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -84,6 +91,7 @@ function createInventoryItem(id: string, name: string): InventoryItem {
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,6 +237,46 @@ describe('storyGenerationState', () => {
|
||||
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
|
||||
});
|
||||
|
||||
it('opens the gift modal with the preferred gift candidate selected', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('gift_modal');
|
||||
if (decision.kind !== 'gift_modal') {
|
||||
throw new Error('Expected gift modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedItemId).toBe('jade-token');
|
||||
});
|
||||
|
||||
it('does not open the gift modal when there are no gift candidates', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [],
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('none');
|
||||
});
|
||||
|
||||
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
const sourceScene = scenes[0];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getPreferredGiftItemId,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
@@ -83,10 +84,29 @@ export function resolveNpcInteractionDecision(
|
||||
),
|
||||
};
|
||||
case NPC_GIFT_FUNCTION.id:
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(state, encounter, option.actionText),
|
||||
};
|
||||
{
|
||||
const selectedGiftItemId = getPreferredGiftItemId(
|
||||
state.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedGiftItemId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
selectedGiftItemId,
|
||||
),
|
||||
};
|
||||
}
|
||||
case NPC_RECRUIT_FUNCTION.id:
|
||||
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
|
||||
return {
|
||||
|
||||
@@ -162,7 +162,7 @@ export function useGameFlow() {
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(gameState.worldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, gameState.worldType)
|
||||
? buildInitialNpcState(initialEncounter, gameState.worldType, gameState)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(character);
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const TURN_VISUAL_MS = 820;
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
|
||||
const FALLBACK_COMPANION_NAME = '鍚屼即';
|
||||
const FALLBACK_COMPANION_NAME = '同伴';
|
||||
|
||||
export type {
|
||||
CharacterChatModalState,
|
||||
@@ -152,9 +152,9 @@ function _buildLocalCharacterChatSummary(
|
||||
|
||||
function _buildLocalCharacterChatSuggestions(character: Character) {
|
||||
return [
|
||||
'I want to hear you explain that a little more clearly.',
|
||||
`${character.name}, what are you really worried about?`,
|
||||
'Let the wider situation wait for a moment. I want to understand you a bit more.',
|
||||
'我想听你把这件事再说得更明白一点。',
|
||||
`${character.name},你现在真正担心的是什么?`,
|
||||
'先把外面的局势放一放,我想更了解你一些。',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -175,10 +175,10 @@ function buildPartyRelationshipNotes(state: GameState) {
|
||||
};
|
||||
|
||||
state.companions.forEach((companion) =>
|
||||
appendNote(companion.characterId, '褰撳墠鍚岃'),
|
||||
appendNote(companion.characterId, '当前同行'),
|
||||
);
|
||||
state.roster.forEach((companion) =>
|
||||
appendNote(companion.characterId, '钀ュ湴寰呭懡'),
|
||||
appendNote(companion.characterId, '营地待命'),
|
||||
);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
@@ -190,12 +190,12 @@ function buildRecentConversationEventText(state: GameState) {
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/鍑昏触|鎬墿|鎴樻枟|鎶辨嫵涓€绀紎鍒囩|鎺犲緱|鑴辫韩/u.test(recentText)
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'You just went through a clash or spar, and the tension has not fully faded yet.';
|
||||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||||
}
|
||||
if (/鎻存墜|鐩稿姪|甯綘|骞惰偐/u.test(recentText)) {
|
||||
return 'You have just cooperated in practice, so the atmosphere is a little less distant now.';
|
||||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -221,7 +221,7 @@ function inferConversationSituation(
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/鍑昏触|鎬墿|鎴樻枟|鎶辨嫵涓€绀紎鍒囩|鎺犲緱|鑴辫韩/u.test(recentText)
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'post_battle_breath' as const;
|
||||
}
|
||||
@@ -307,7 +307,7 @@ function buildStoryContextFromState(
|
||||
const encounter = state.currentEncounter;
|
||||
return extras.encounterNpcStateOverride
|
||||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||||
?? buildInitialNpcState(encounter, state.worldType);
|
||||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
})()
|
||||
: null;
|
||||
const encounterDirective =
|
||||
@@ -659,8 +659,8 @@ function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||||
}
|
||||
|
||||
function getTypewriterDelay(char: string) {
|
||||
if (/[銆傦紒锛??]/u.test(char)) return 240;
|
||||
if (/[锛屻€侊紱锛?;:]/u.test(char)) return 150;
|
||||
if (/[。!?!?]/u.test(char)) return 240;
|
||||
if (/[,、;;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
@@ -831,7 +831,7 @@ export function useStoryGeneration({
|
||||
|
||||
const getResolvedNpcState = (state: GameState, encounter: Encounter) =>
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType);
|
||||
buildInitialNpcState(encounter, state.worldType, state);
|
||||
|
||||
const buildNpcStory = useCallback(
|
||||
(
|
||||
@@ -841,6 +841,7 @@ export function useStoryGeneration({
|
||||
overrideText?: string,
|
||||
) =>
|
||||
buildNpcEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
npcState: getResolvedNpcState(state, encounter),
|
||||
playerCharacter: character,
|
||||
@@ -960,7 +961,7 @@ export function useStoryGeneration({
|
||||
|
||||
const npcState =
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType);
|
||||
buildInitialNpcState(encounter, state.worldType, state);
|
||||
if (npcState.chattedCount > 2) {
|
||||
return {};
|
||||
}
|
||||
@@ -1484,7 +1485,7 @@ export function useStoryGeneration({
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to start story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(gameState, gameState.playerCharacter),
|
||||
);
|
||||
|
||||
@@ -135,10 +135,14 @@ function createPlayableNpc(index: number) {
|
||||
return {
|
||||
name: `角色${index + 1}`,
|
||||
title: `身份${index + 1}`,
|
||||
role: `世界职责${index + 1}`,
|
||||
description: `角色描述${index + 1}`,
|
||||
backstory: `角色背景${index + 1}`,
|
||||
personality: `角色性格${index + 1}`,
|
||||
motivation: `角色动机${index + 1}`,
|
||||
combatStyle: `战斗风格${index + 1}`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
};
|
||||
}
|
||||
@@ -146,10 +150,16 @@ function createPlayableNpc(index: number) {
|
||||
function createStoryNpc(index: number) {
|
||||
return {
|
||||
name: `世界NPC${index + 1}`,
|
||||
title: `头衔${index + 1}`,
|
||||
role: `职责${index + 1}`,
|
||||
description: `世界NPC描述${index + 1}`,
|
||||
backstory: `世界NPC背景${index + 1}`,
|
||||
personality: `世界NPC性格${index + 1}`,
|
||||
motivation: `世界NPC动机${index + 1}`,
|
||||
combatStyle: `世界NPC战斗风格${index + 1}`,
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ const CUSTOM_WORLD_RARITIES: ItemRarity[] = [
|
||||
'epic',
|
||||
'legendary',
|
||||
];
|
||||
const MIN_CUSTOM_WORLD_AFFINITY = -40;
|
||||
const MAX_CUSTOM_WORLD_AFFINITY = 90;
|
||||
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
|
||||
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
|
||||
|
||||
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
|
||||
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
|
||||
@@ -39,20 +43,30 @@ export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `你正在为像素动作 R
|
||||
{
|
||||
"name": "角色名称",
|
||||
"title": "称号",
|
||||
"role": "在世界中的身份/职责",
|
||||
"description": "简短描述",
|
||||
"backstory": "背景经历",
|
||||
"personality": "性格特点",
|
||||
"motivation": "当前动机",
|
||||
"combatStyle": "战斗风格",
|
||||
"initialAffinity": 18,
|
||||
"relationshipHooks": ["关系切入口1", "关系切入口2"],
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
],
|
||||
"storyNpcs": [
|
||||
{
|
||||
"name": "场景角色名称",
|
||||
"title": "称号",
|
||||
"role": "身份",
|
||||
"description": "简短描述",
|
||||
"backstory": "背景经历",
|
||||
"personality": "性格特点",
|
||||
"motivation": "动机",
|
||||
"relationshipHooks": ["关系切入口1", "关系切入口2"]
|
||||
"combatStyle": "战斗风格",
|
||||
"initialAffinity": 6,
|
||||
"relationshipHooks": ["关系切入口1", "关系切入口2"],
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
],
|
||||
"landmarks": [
|
||||
@@ -72,8 +86,14 @@ export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `你正在为像素动作 R
|
||||
- 必须生成足够多的 storyNpcs,使唯一角色总数至少达到 30。
|
||||
- 至少生成 10 个 landmarks。
|
||||
- 不要生成 items 字段。
|
||||
- playableNpcs 与 storyNpcs 中的每个角色都必须使用完全相同的字段结构,不要省略字段,也不要只给其中一类角色加私有字段。
|
||||
- initialAffinity 必须是整数,范围控制在 -40 到 90。
|
||||
- 可扮演角色通常从基础信任起步,initialAffinity 建议不低于 18;敌对角色、怪物型角色或开局明显 hostile 的 NPC,initialAffinity 应为负数。
|
||||
- 怪物也视为 NPC,可以直接出现在 storyNpcs 中;不要额外拆出 monster 字段。
|
||||
- 如果某个 NPC 更适合走怪物素材,请在 role、description、backstory、combatStyle、tags 中明确写出怪物特征、栖息环境、攻击方式或异形外观,方便后续形象解析同时引用 Medieval 和怪物素材。
|
||||
- 名称必须具体且有辨识度,不要使用 角色1、场景1 之类的占位名。
|
||||
- 名册中要覆盖多种社会身份,不能只有战斗角色。
|
||||
- storyNpcs 里既要有可交流、可合作的角色,也要允许出现敌对、怪物型或强压迫感的角色。
|
||||
- 地标必须像真实可游玩的场景,能够承载探索、战斗、旅行和剧情推进。
|
||||
- 不要引用现实品牌、受版权保护的 IP 或知名既有人物。`;
|
||||
|
||||
@@ -98,6 +118,19 @@ function normalizeTags(value: unknown, fallbackTags: string[] = []) {
|
||||
].slice(0, 5);
|
||||
}
|
||||
|
||||
function clampCustomWorldAffinity(value: number) {
|
||||
return Math.max(
|
||||
MIN_CUSTOM_WORLD_AFFINITY,
|
||||
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? clampCustomWorldAffinity(value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeWorldType(value: unknown, sourceText: string) {
|
||||
const worldType = toText(value).toUpperCase();
|
||||
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
|
||||
@@ -207,15 +240,28 @@ function normalizePlayableNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
const title = toText(item.title) || toText(item.role) || '未定称号';
|
||||
const role = toText(item.role) || title;
|
||||
const relationshipHooks = normalizeTags(
|
||||
item.relationshipHooks,
|
||||
normalizeTags(item.tags),
|
||||
);
|
||||
return {
|
||||
id: createEntryId('playable-npc', name, index),
|
||||
name,
|
||||
title: toText(item.title),
|
||||
title,
|
||||
role,
|
||||
description: toText(item.description),
|
||||
backstory: toText(item.backstory),
|
||||
personality: toText(item.personality),
|
||||
motivation: toText(item.motivation) || toText(item.description),
|
||||
combatStyle: toText(item.combatStyle),
|
||||
tags: normalizeTags(item.tags),
|
||||
initialAffinity: normalizeInitialAffinity(
|
||||
item.initialAffinity,
|
||||
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks,
|
||||
tags: normalizeTags(item.tags, relationshipHooks),
|
||||
} satisfies CustomWorldPlayableNpc;
|
||||
})
|
||||
.filter((entry) => entry.name)
|
||||
@@ -226,13 +272,28 @@ function normalizeStoryNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
const title = toText(item.title) || toText(item.role) || '未定称号';
|
||||
const role = toText(item.role) || title;
|
||||
const relationshipHooks = normalizeTags(
|
||||
item.relationshipHooks,
|
||||
normalizeTags(item.tags),
|
||||
);
|
||||
return {
|
||||
id: createEntryId('story-npc', name, index),
|
||||
name,
|
||||
role: toText(item.role),
|
||||
title,
|
||||
role,
|
||||
description: toText(item.description),
|
||||
backstory: toText(item.backstory),
|
||||
personality: toText(item.personality),
|
||||
motivation: toText(item.motivation),
|
||||
relationshipHooks: normalizeTags(item.relationshipHooks),
|
||||
combatStyle: toText(item.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(
|
||||
item.initialAffinity,
|
||||
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks,
|
||||
tags: normalizeTags(item.tags, relationshipHooks),
|
||||
} satisfies CustomWorldNpc;
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
@@ -339,8 +400,11 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
'- 必须生成足够多的 storyNpcs,使唯一角色总数至少达到 30。',
|
||||
'- 至少生成 10 个真正可游玩的 landmarks。',
|
||||
'- 不要生成任何 items,也不要包含 items 字段。',
|
||||
'- playableNpcs 与 storyNpcs 必须使用同一套字段结构:name、title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
|
||||
'- initialAffinity 必须是 -40 到 90 的整数;可扮演角色通常不低于 18,敌对或怪物型 NPC 应使用负数。',
|
||||
'- 每个场景角色和地标都必须直接源自玩家设定。',
|
||||
'- 要覆盖多种社会身份,不能只有战斗角色。',
|
||||
'- 怪物也视为 NPC,怪物型角色仍然放进 storyNpcs,并在文字里明确写出怪物特征、栖息环境或攻击方式,方便后续形象解析引用怪物素材。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -349,14 +413,14 @@ export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.title}:${npc.description};背景:${npc.backstory};风格:${npc.combatStyle}`,
|
||||
`- ${npc.name} / ${npc.title}:${npc.description};身份:${npc.role};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};初始好感:${npc.initialAffinity}`,
|
||||
)
|
||||
.join('\n');
|
||||
const storyNpcText = profile.storyNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.role}:${npc.description};动机:${npc.motivation}`,
|
||||
`- ${npc.name} / ${npc.role}:${npc.description};称号:${npc.title};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};初始好感:${npc.initialAffinity}`,
|
||||
)
|
||||
.join('\n');
|
||||
const landmarkText = profile.landmarks
|
||||
|
||||
@@ -10,13 +10,13 @@ const ATTRIBUTE_LABELS = {
|
||||
const RESOURCE_LABELS = {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'Max HP',
|
||||
maxMp: 'Max MP',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana Cost',
|
||||
manaCost: '灵力消耗',
|
||||
} as const;
|
||||
|
||||
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
|
||||
|
||||
@@ -99,112 +99,113 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
|
||||
},
|
||||
machina: {
|
||||
mode: 'machina',
|
||||
attributeLabels: { strength: 'Power', agility: 'Dexterity', intelligence: 'Logic', spirit: 'Core' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'Energy',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max Energy',
|
||||
damageLabel: 'Firepower',
|
||||
guardLabel: 'Shield',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Recharge',
|
||||
manaCostLabel: 'Energy Cost',
|
||||
campSuffix: 'Mobile Outpost',
|
||||
itemPrefixes: ['Iron', 'Steel', 'Pulse', 'Core', 'Nova', 'Plasma'],
|
||||
itemInfixes: ['-Core','-Drive','-Link','-Grid','-Node','-Unit'],
|
||||
skillPrefixes: ['Over','Ultra','Mega','Core','Pulse','Nova'],
|
||||
attributeLabels: { strength: '动力', agility: '精度', intelligence: '逻辑', spirit: '核心' },
|
||||
hpLabel: '耐久',
|
||||
mpLabel: '能量',
|
||||
maxHpLabel: '耐久上限',
|
||||
maxMpLabel: '能量上限',
|
||||
damageLabel: '火力',
|
||||
guardLabel: '护盾',
|
||||
rangeLabel: '射程',
|
||||
cooldownLabel: '充能',
|
||||
manaCostLabel: '能量消耗',
|
||||
campSuffix: '机动前哨',
|
||||
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
|
||||
itemInfixes: ['芯', '驱', '链', '阵', '节', '机'],
|
||||
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
burst: ['爆裂', '齐射', '连发', '倾泻'],
|
||||
steady: ['稳压', '固守', '护持', '锚定'],
|
||||
mobility: ['疾冲', '推进', '跃迁', '闪移'],
|
||||
finisher: ['终断', '歼灭', '过载', '坠落'],
|
||||
projectile: ['弹', '束', '矢', '炮'],
|
||||
},
|
||||
},
|
||||
tide: {
|
||||
mode: 'tide',
|
||||
attributeLabels: { strength: 'Strength', agility: 'Agility', intelligence: 'Intelligence', spirit: 'Spirit' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'MP',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max MP',
|
||||
damageLabel: 'Damage',
|
||||
guardLabel: 'Guard',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Cooldown',
|
||||
manaCostLabel: 'Mana Cost',
|
||||
campSuffix: 'Camp',
|
||||
itemPrefixes: ['Wave', 'Tide', 'Ocean', 'Sea', 'Storm', 'Surf'],
|
||||
itemInfixes: ['-Wave','-Tide','-Ocean','-Sea','-Storm','-Surf'],
|
||||
skillPrefixes: ['Wave','Tide','Ocean','Sea','Storm','Surf'],
|
||||
attributeLabels: { strength: '潮力', agility: '浪步', intelligence: '潮识', spirit: '潮魄' },
|
||||
hpLabel: '潮命',
|
||||
mpLabel: '潮息',
|
||||
maxHpLabel: '潮命上限',
|
||||
maxMpLabel: '潮息上限',
|
||||
damageLabel: '潮势',
|
||||
guardLabel: '潮护',
|
||||
rangeLabel: '潮距',
|
||||
cooldownLabel: '回潮',
|
||||
manaCostLabel: '潮息消耗',
|
||||
campSuffix: '潮栖营地',
|
||||
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
|
||||
itemInfixes: ['潮', '浪', '汐', '海', '涛', '澜'],
|
||||
skillPrefixes: ['潮', '浪', '汐', '海', '澜', '涌'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
burst: ['裂潮', '怒涌', '连浪', '奔潮'],
|
||||
steady: ['守潮', '潮护', '定澜', '镇流'],
|
||||
mobility: ['踏浪', '游潮', '跃汐', '逐流'],
|
||||
finisher: ['断潮', '覆海', '终汐', '沉落'],
|
||||
projectile: ['潮矢', '水矛', '浪刃', '飞涌'],
|
||||
},
|
||||
},
|
||||
rift: {
|
||||
mode: 'rift',
|
||||
attributeLabels: { strength: 'çå²', agility: 'è£æ¥', intelligence: 'çè¯', spirit: 'çå' },
|
||||
hpLabel: 'çå½',
|
||||
mpLabel: 'è£è½',
|
||||
maxHpLabel: 'çå½ä¸é',
|
||||
maxMpLabel: 'è£è½ä¸é',
|
||||
damageLabel: 'çå¿',
|
||||
guardLabel: '稳ç',
|
||||
rangeLabel: 'çè·',
|
||||
cooldownLabel: 'å¤ç',
|
||||
manaCostLabel: 'Rift Cost',
|
||||
campSuffix: 'è£çé©»è¥',
|
||||
itemPrefixes: ['è£ç', 'æå±', '边潮', 'ç°å', 'çæ¡¥', 'åå¨'],
|
||||
itemInfixes: ['edge', 'void', 'span', 'seal', 'rift', 'core'],
|
||||
skillPrefixes: ['rift', 'void', 'split', 'break', 'phase', 'warp'],
|
||||
attributeLabels: { strength: '界劲', agility: '裂步', intelligence: '界识', spirit: '界压' },
|
||||
hpLabel: '界命',
|
||||
mpLabel: '裂能',
|
||||
maxHpLabel: '界命上限',
|
||||
maxMpLabel: '裂能上限',
|
||||
damageLabel: '界势',
|
||||
guardLabel: '稳界',
|
||||
rangeLabel: '界距',
|
||||
cooldownLabel: '复界',
|
||||
manaCostLabel: '裂能消耗',
|
||||
campSuffix: '裂界驻营',
|
||||
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
|
||||
itemInfixes: ['锋', '隙', '锚', '印', '界', '核'],
|
||||
skillPrefixes: ['裂', '断', '界', '相', '折', '迁'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['break', 'crash', 'shatter', 'burst'],
|
||||
steady: ['guard', 'hold', 'veil', 'ward'],
|
||||
mobility: ['step', 'shift', 'blink', 'drift'],
|
||||
finisher: ['ending', 'drop', 'break', 'flare'],
|
||||
projectile: ['spike', 'bolt', 'shard', 'wave'],
|
||||
burst: ['崩断', '碎坠', '裂爆', '界崩'],
|
||||
steady: ['守界', '固相', '帷障', '界卫'],
|
||||
mobility: ['裂步', '转相', '闪迁', '漂移'],
|
||||
finisher: ['终坠', '断灭', '裂终', '界燃'],
|
||||
projectile: ['界刺', '裂矢', '碎片', '裂波'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
|
||||
[CATEGORY_WEAPON, ['blade', 'axe', 'bow', 'staff', 'spear', 'shield']],
|
||||
[CATEGORY_ARMOR, ['armor', 'robe', 'cloak', 'guard', 'mantle', 'bracer']],
|
||||
[CATEGORY_RELIC, ['ring', 'seal', 'badge', 'gem', 'charm', 'orb']],
|
||||
[CATEGORY_CONSUMABLE, ['potion', 'dust', 'draught', 'brew', 'oil', 'scroll']],
|
||||
[CATEGORY_MATERIAL, ['ore', 'crystal', 'bone', 'herb', 'core', 'silk']],
|
||||
[CATEGORY_RARE, ['sigil', 'relic', 'page', 'chart', 'key', 'idol']],
|
||||
[CATEGORY_EXCLUSIVE, ['core', 'seal', 'master-key', 'origin-box', 'true-mark', 'world-core']],
|
||||
[CATEGORY_WEAPON, ['剑', '刃', '弓', '杖', '枪', '盾']],
|
||||
[CATEGORY_ARMOR, ['甲', '袍', '披风', '护具', '肩甲', '护腕']],
|
||||
[CATEGORY_RELIC, ['戒', '印', '徽', '玉', '符', '珠']],
|
||||
[CATEGORY_CONSUMABLE, ['药', '散', '剂', '露', '油', '卷']],
|
||||
[CATEGORY_MATERIAL, ['矿', '晶', '骨', '草', '核', '丝']],
|
||||
[CATEGORY_RARE, ['符', '遗物', '残页', '图', '钥', '像']],
|
||||
[CATEGORY_EXCLUSIVE, ['核心', '封印', '主钥', '源匣', '真印', '界核']],
|
||||
]);
|
||||
const DEFAULT_CATEGORY_NOUNS = ['relic', 'sigil', 'token', 'seal', 'core', 'mark'];
|
||||
const DEFAULT_CATEGORY_NOUNS = ['符', '印', '信物', '匣', '核', '铭片'];
|
||||
|
||||
const ROLE_SKILL_ROOTS: Record<string, string[]> = {
|
||||
'sword-princess': ['çå', 'éå¼', 'è£é', 'è£é'],
|
||||
'archer-hero': ['弦è¯', 'è¿è¢', '追é£', 'è´¯ç¢'],
|
||||
'girl-hero': ['åå', 'å½±è¢', 'ç¾æ©', 'æ å½±'],
|
||||
'punch-hero': ['æ³å¿', 'éå»', 'è£æ³', 'å´©æ¥'],
|
||||
'fighter-4': ['éé', 'ç¾éµ', 'é线', 'åå'],
|
||||
'sword-princess': ['王剑', '锋式', '裁锋', '裂锋'],
|
||||
'archer-hero': ['弦诀', '远袭', '追风', '贯矢'],
|
||||
'girl-hero': ['双刃', '影袭', '疾斩', '掠影'],
|
||||
'punch-hero': ['拳势', '震击', '裂拳', '崩步'],
|
||||
'fighter-4': ['重锋', '盾阵', '镇线', '压城'],
|
||||
};
|
||||
|
||||
const SKILL_ROOT_STOP_WORDS = new Set([
|
||||
'ä¸ç',
|
||||
'设å®',
|
||||
'åºè°',
|
||||
'ç®æ ',
|
||||
'è§è²',
|
||||
'ææ',
|
||||
'飿 ¼',
|
||||
'èæ¯',
|
||||
'æ§æ ¼',
|
||||
'æ
äº',
|
||||
'世界',
|
||||
'设定',
|
||||
'基调',
|
||||
'目标',
|
||||
'角色',
|
||||
'战斗',
|
||||
'风格',
|
||||
'背景',
|
||||
'性格',
|
||||
'故事',
|
||||
'custom-world',
|
||||
'playable-role',
|
||||
]);
|
||||
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -286,7 +287,7 @@ function buildSkillRootOptions(
|
||||
character: Character,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['çå¼', 'è¡è¯', 'è£é', 'æ½®å°'];
|
||||
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['界式', '行诀', '裂锋', '潮印'];
|
||||
const derivedRoots = dedupeStrings([
|
||||
...collectSkillRootFragments(role?.title ?? '', 4),
|
||||
...collectSkillRootFragments(role?.combatStyle ?? '', 6),
|
||||
@@ -310,9 +311,9 @@ export function getAttributeLabelsForWorld(worldType: WorldType | null | undefin
|
||||
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return { strength: 'é躯', agility: '御è¡', intelligence: 'ç¥è¯', spirit: 'çµè´' };
|
||||
return { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' };
|
||||
}
|
||||
return { strength: 'åé', agility: 'ææ·', intelligence: 'æºå', spirit: 'ç²¾ç¥' };
|
||||
return { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' };
|
||||
}
|
||||
|
||||
return getWorldPresentation(profile).attributeLabels;
|
||||
@@ -323,27 +324,27 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return {
|
||||
hp: 'å½å
',
|
||||
mp: 'çµè´',
|
||||
maxHp: 'å½å
ä¸é',
|
||||
maxMp: 'çµè´ä¸é',
|
||||
damage: 'æ¯å¿',
|
||||
guard: 'æ¤å
',
|
||||
range: 'æ¯è·',
|
||||
cooldown: '忝',
|
||||
manaCost: 'çµè´æ¶è?',
|
||||
hp: '命元',
|
||||
mp: '灵韵',
|
||||
maxHp: '命元上限',
|
||||
maxMp: '灵韵上限',
|
||||
damage: '术势',
|
||||
guard: '护元',
|
||||
range: '术距',
|
||||
cooldown: '回息',
|
||||
manaCost: '灵韵消耗',
|
||||
};
|
||||
}
|
||||
return {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'æå¤?HP',
|
||||
maxMp: 'æå¤?MP',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana',
|
||||
hp: '生命',
|
||||
mp: '灵力',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: '伤害',
|
||||
guard: '防护',
|
||||
range: '距离',
|
||||
cooldown: '冷却',
|
||||
manaCost: '消耗',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,6 +362,7 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
|
||||
@@ -383,7 +385,7 @@ export function buildThemedSkillName(
|
||||
}
|
||||
|
||||
function getCategoryNouns(category: string) {
|
||||
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS['ç¨æå'];
|
||||
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS[CATEGORY_RARE];
|
||||
}
|
||||
|
||||
function getResolvedCategoryNouns(category: string): string[] {
|
||||
@@ -413,20 +415,20 @@ export function buildThemedItemDescription(
|
||||
) {
|
||||
const seed = hashText(`${profile.id}:${category}:${rarity}:${seedKey}`);
|
||||
const hooks = [
|
||||
`Suitable for the current goal "${profile.playerGoal}".`,
|
||||
`Its tone closely matches the world tone "${profile.tone}".`,
|
||||
'Likely to appear in one of this world\'s major conflicts.',
|
||||
`It clearly ties into the expanding conflict inside this world.`,
|
||||
`适合围绕“${profile.playerGoal}”继续推进。`,
|
||||
`它的气质和“${profile.tone}”这条世界基调很贴近。`,
|
||||
'很可能会出现在这个世界的关键冲突里。',
|
||||
'能明显牵出这个世界正在扩大的主要矛盾。',
|
||||
];
|
||||
const rarityText = {
|
||||
common: '常è§',
|
||||
uncommon: 'è¿é¶',
|
||||
rare: 'Rare',
|
||||
epic: 'æ ¸å¿',
|
||||
legendary: 'å
³é®',
|
||||
common: '常见',
|
||||
uncommon: '进阶',
|
||||
rare: '稀有',
|
||||
epic: '核心',
|
||||
legendary: '关键',
|
||||
}[rarity];
|
||||
|
||||
return `${rarityText} ${category}. ${hooks[seed % hooks.length]}`;
|
||||
return `${rarityText}${category}。${hooks[seed % hooks.length]}`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics(
|
||||
@@ -449,7 +451,7 @@ export function inferCustomItemMechanics(
|
||||
legendary: 5,
|
||||
}[rarity];
|
||||
|
||||
if (category === 'æ¦å¨') {
|
||||
if (category === CATEGORY_WEAPON) {
|
||||
return {
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
@@ -459,7 +461,7 @@ export function inferCustomItemMechanics(
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¤ç²') {
|
||||
if (category === CATEGORY_ARMOR) {
|
||||
return {
|
||||
equipmentSlotId: 'armor',
|
||||
statProfile: {
|
||||
@@ -470,7 +472,7 @@ export function inferCustomItemMechanics(
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '??' || category === '???' || category === '????') {
|
||||
if (category === CATEGORY_RELIC || category === CATEGORY_RARE || category === CATEGORY_EXCLUSIVE) {
|
||||
return {
|
||||
equipmentSlotId: 'relic',
|
||||
statProfile: {
|
||||
@@ -481,7 +483,7 @@ export function inferCustomItemMechanics(
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¶èå') {
|
||||
if (category === CATEGORY_CONSUMABLE) {
|
||||
const heals = tags.includes('healing') || seed % 2 === 0;
|
||||
return {
|
||||
useProfile: heals
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_LLM_PROXY_BASE_URL || '/api/llm';
|
||||
const MODEL = import.meta.env.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
|
||||
const ENABLE_LLM_DEBUG_LOG = import.meta.env.DEV || import.meta.env.VITE_LLM_DEBUG_LOG === 'true';
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
const API_BASE_URL = ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm';
|
||||
const MODEL = ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
|
||||
const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true';
|
||||
|
||||
export interface PlainTextCompletionOptions {
|
||||
timeoutMs?: number;
|
||||
@@ -21,9 +23,9 @@ export function resolveTimeoutMs(rawValue: string | undefined, fallback: number)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(import.meta.env.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
import.meta.env.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 45000),
|
||||
);
|
||||
|
||||
|
||||
@@ -693,7 +693,9 @@ function describeProvidedOptionCore(option: StoryOption) {
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'gift') {
|
||||
return '向面前角色送礼,以改善关系或表达诚意。';
|
||||
return option.detailText
|
||||
? `向面前角色送礼,以改善关系或表达诚意。当前礼物线索:${option.detailText}`
|
||||
: '向面前角色送礼,以改善关系或表达诚意。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'recruit') {
|
||||
|
||||
94
src/services/runtimeItemAiDirector.ts
Normal file
94
src/services/runtimeItemAiDirector.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../types';
|
||||
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {
|
||||
buildRuntimeItemIntentPrompt,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
} from './runtimeItemAiPrompt';
|
||||
|
||||
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function sanitizeRuntimeItemAiIntent(
|
||||
rawIntent: unknown,
|
||||
fallback: RuntimeItemAiIntent,
|
||||
): RuntimeItemAiIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
const desiredFunctionalBias = coerceStringArray(
|
||||
intent.desiredFunctionalBias,
|
||||
fallback.desiredFunctionalBias,
|
||||
2,
|
||||
).filter(
|
||||
(
|
||||
item,
|
||||
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
|
||||
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
|
||||
);
|
||||
const tone = coerceString(intent.tone, fallback.tone);
|
||||
|
||||
return {
|
||||
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
|
||||
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
|
||||
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
|
||||
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
|
||||
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
|
||||
desiredFunctionalBias:
|
||||
desiredFunctionalBias.length > 0
|
||||
? desiredFunctionalBias
|
||||
: fallback.desiredFunctionalBias,
|
||||
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
|
||||
? (tone as RuntimeItemAiIntent['tone'])
|
||||
: fallback.tone,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateRuntimeItemAiIntents(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
const fallbackIntents = params.plans.map(plan =>
|
||||
buildRuntimeItemAiIntent(params.context, plan),
|
||||
);
|
||||
|
||||
const content = await requestChatMessageContent(
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
buildRuntimeItemIntentPrompt(params),
|
||||
{
|
||||
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
|
||||
debugLabel: 'runtime-item-intent',
|
||||
},
|
||||
);
|
||||
const parsed = parseJsonResponseText(content) as {
|
||||
intents?: unknown[];
|
||||
};
|
||||
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
|
||||
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]),
|
||||
);
|
||||
}
|
||||
85
src/services/runtimeItemAiPrompt.ts
Normal file
85
src/services/runtimeItemAiPrompt.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {buildRuntimeItemAiPromptInput} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
@@ -10,16 +10,19 @@ import {
|
||||
} from './core';
|
||||
import type {ItemStatProfile, ItemUseProfile} from './items';
|
||||
|
||||
export interface CustomWorldPlayableNpc {
|
||||
export interface CustomWorldRoleProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
templateCharacterId?: string;
|
||||
attributeProfile?: RoleAttributeProfile;
|
||||
}
|
||||
|
||||
@@ -46,16 +49,13 @@ export interface CustomWorldNpcVisual {
|
||||
offHand?: CustomWorldNpcVisualGear | null;
|
||||
}
|
||||
|
||||
export interface CustomWorldNpc {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
motivation: string;
|
||||
relationshipHooks: string[];
|
||||
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
|
||||
templateCharacterId?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldNpc extends CustomWorldRoleProfile {
|
||||
imageSrc?: string;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
attributeProfile?: RoleAttributeProfile;
|
||||
}
|
||||
|
||||
export interface CustomWorldItem {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface NpcPersistentState {
|
||||
chattedCount: number;
|
||||
giftsGiven: number;
|
||||
inventory: InventoryItem[];
|
||||
tradeStockSignature?: string | null;
|
||||
recruited: boolean;
|
||||
revealedFacts?: string[];
|
||||
knownAttributeRumors?: string[];
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface QuestReward {
|
||||
affinityBonus: number;
|
||||
currency: number;
|
||||
items: InventoryItem[];
|
||||
storyHint?: string;
|
||||
intel?: {
|
||||
codexEntry?: string;
|
||||
rumorText?: string;
|
||||
|
||||
Reference in New Issue
Block a user