Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import { formatCurrency } from '../data/economy';
|
||||
import { getEquipmentSlotFromItem } from '../data/equipmentEffects';
|
||||
import {
|
||||
getFunctionDocumentationById,
|
||||
isContinueAdventureOption,
|
||||
NPC_CHAT_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import { getHostileNpcPresetById } from '../data/hostileNpcPresets';
|
||||
@@ -969,10 +970,11 @@ export function AdventurePanel({
|
||||
playerSkillCooldowns,
|
||||
currentNpcBattleMode,
|
||||
);
|
||||
const isContinueAdventureOption =
|
||||
hasDeferredAdventureOptions && option.actionText === '继续冒险';
|
||||
const isDeferredContinueOption =
|
||||
hasDeferredAdventureOptions &&
|
||||
isContinueAdventureOption(option);
|
||||
|
||||
if (isContinueAdventureOption) {
|
||||
if (isDeferredContinueOption) {
|
||||
return (
|
||||
<motion.button
|
||||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||||
|
||||
@@ -1,88 +1,16 @@
|
||||
type AffinityLevelMeta = {
|
||||
label: string;
|
||||
minAffinity: number;
|
||||
nextAffinity: number | null;
|
||||
description: string;
|
||||
accentClassName: string;
|
||||
};
|
||||
import {
|
||||
AFFINITY_PROGRESS_MARKERS,
|
||||
AFFINITY_PROGRESS_MAX,
|
||||
AFFINITY_PROGRESS_MIN,
|
||||
getAffinityLevelMeta,
|
||||
} from '../data/affinityLevels';
|
||||
|
||||
type AffinityProgressMarker = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const AFFINITY_PROGRESS_MIN = -40;
|
||||
const AFFINITY_PROGRESS_MAX = 90;
|
||||
|
||||
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
label: '友善',
|
||||
minAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
},
|
||||
{
|
||||
label: '信任',
|
||||
minAffinity: 60,
|
||||
nextAffinity: 90,
|
||||
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
|
||||
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
},
|
||||
{
|
||||
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: '深交' },
|
||||
];
|
||||
type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number];
|
||||
|
||||
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.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
|
||||
);
|
||||
}
|
||||
|
||||
function getNextAffinityMarker(affinity: number) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
if (currentLevel.nextAffinity == null) return null;
|
||||
|
||||
96
src/components/BackstoryArchive.tsx
Normal file
96
src/components/BackstoryArchive.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
export type BackstoryUnlockedChapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type BackstoryLockedChapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
teaser: string;
|
||||
affinityRequired: number;
|
||||
};
|
||||
|
||||
interface BackstoryArchiveProps {
|
||||
publicSummary?: string | null;
|
||||
unlockedChapters: BackstoryUnlockedChapter[];
|
||||
lockedChapters: BackstoryLockedChapter[];
|
||||
}
|
||||
|
||||
export function BackstoryArchive({
|
||||
publicSummary,
|
||||
unlockedChapters,
|
||||
lockedChapters,
|
||||
}: BackstoryArchiveProps) {
|
||||
const totalChapters = unlockedChapters.length + lockedChapters.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
背景故事
|
||||
</div>
|
||||
{totalChapters > 0 ? (
|
||||
<div className="text-[10px] tracking-[0.14em] text-zinc-500">
|
||||
已解锁 {unlockedChapters.length} / {totalChapters}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{publicSummary ? (
|
||||
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
公开印象
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
|
||||
{publicSummary}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{unlockedChapters.map((chapter) => (
|
||||
<div
|
||||
key={`unlocked-backstory-${chapter.id}`}
|
||||
className="rounded-xl border border-amber-300/18 bg-amber-500/[0.06] px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-400/10 px-2 py-0.5 text-[10px] tracking-[0.14em] text-amber-100">
|
||||
已解锁
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
|
||||
{chapter.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{lockedChapters.map((chapter) => (
|
||||
<div
|
||||
key={`locked-backstory-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-zinc-200">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] tracking-[0.14em] text-zinc-400">
|
||||
需好感 {chapter.affinityRequired}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
{chapter.teaser}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!publicSummary && totalChapters === 0 ? (
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm text-zinc-500">
|
||||
暂无可整理的背景线索。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,17 +227,21 @@ export function CharacterDetailModal({
|
||||
|
||||
<Section title="属性" chrome={UI_CHROME.statsPanel}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<StatPill label={resourceLabels.maxHp} value={`${getCharacterMaxHp(character)}`} tone="hp" />
|
||||
<StatPill
|
||||
label={resourceLabels.maxHp}
|
||||
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatPill label={resourceLabels.maxMp} value={`${getCharacterMaxMana(character)}`} tone="mp" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
{attributeRows.map(({ slot, value }) => (
|
||||
<div key={slot.slotId} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-white">{value}</div>
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
|
||||
<div className="mt-1 font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
|
||||
import {
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
describeBuildContribution,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionAttributeRows,
|
||||
getBuildSourceLabel,
|
||||
getBuildContributionQualityLabel,
|
||||
getBuildContributionQualityRatio,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from '../data/buildDamage';
|
||||
import { getCharacterEquipment } from '../data/characterPresets';
|
||||
import {
|
||||
getCharacterEquipment,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
getUnlockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
buildInitialEquipmentLoadout,
|
||||
EQUIPMENT_SLOTS,
|
||||
@@ -28,11 +38,16 @@ import {
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getEquipmentSlotIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { AffinityStatusCard } from './AffinityStatusCard';
|
||||
import { BackstoryArchive } from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
@@ -78,10 +93,6 @@ type EquipmentRow = {
|
||||
|
||||
type ContributionRow = BuildDamageBreakdown['rows'][number];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
label,
|
||||
current,
|
||||
@@ -94,18 +105,24 @@ function StatusRow({
|
||||
tone: 'hp' | 'mp';
|
||||
}) {
|
||||
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
|
||||
const fillClass = tone === 'hp'
|
||||
? 'from-emerald-400 via-lime-300 to-emerald-200'
|
||||
: 'from-sky-500 via-cyan-300 to-sky-100';
|
||||
const fillClass =
|
||||
tone === 'hp'
|
||||
? 'from-emerald-400 via-lime-300 to-emerald-200'
|
||||
: 'from-sky-500 via-cyan-300 to-sky-100';
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
|
||||
<span>{label}</span>
|
||||
<span className="text-zinc-200">{current} / {max}</span>
|
||||
<span className="text-zinc-200">
|
||||
{current} / {max}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
|
||||
<div className={`h-full bg-gradient-to-r ${fillClass}`} style={{ width: `${ratio * 100}%` }} />
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${fillClass}`}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -126,18 +143,27 @@ const SKILL_STYLE_LABELS = {
|
||||
} satisfies Record<Character['skills'][number]['style'], string>;
|
||||
|
||||
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile' ? '远程' : '近战';
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile'
|
||||
? '远程'
|
||||
: '近战';
|
||||
}
|
||||
|
||||
function CharacterSkillsList({character}: {character: Character}) {
|
||||
function CharacterSkillsList({ character }: { character: Character }) {
|
||||
if (character.skills.length === 0) {
|
||||
return <div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">暂无技能信息</div>;
|
||||
return (
|
||||
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
|
||||
暂无技能信息
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{character.skills.map(skill => (
|
||||
<div key={skill.id} className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300">
|
||||
{character.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-white">{skill.name}</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
|
||||
@@ -150,27 +176,21 @@ function CharacterSkillsList({character}: {character: Character}) {
|
||||
<div>冷却:{skill.cooldownTurns}</div>
|
||||
<div>距离:{skill.range}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">{SKILL_STYLE_LABELS[skill.style]}</div>
|
||||
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
|
||||
{SKILL_STYLE_LABELS[skill.style]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getContributionHeatRatio(value: number, minValue = 0, maxValue = 1) {
|
||||
const normalizedMin = Number.isFinite(minValue) ? minValue : 0;
|
||||
const normalizedMax = Number.isFinite(maxValue) ? maxValue : 1;
|
||||
const range = normalizedMax - normalizedMin;
|
||||
|
||||
if (range <= 0.0001) {
|
||||
return normalizedMax > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
return clamp((value - normalizedMin) / range, 0, 1);
|
||||
function getContributionHeatRatio(value: number) {
|
||||
return getBuildContributionQualityRatio(value);
|
||||
}
|
||||
|
||||
function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value, minValue, maxValue);
|
||||
function getContributionVisualStyle(value: number): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value);
|
||||
const hue = 210 - ratio * 178;
|
||||
const saturation = 62 + ratio * 16;
|
||||
const lightness = 56 + ratio * 6;
|
||||
@@ -179,69 +199,50 @@ function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1):
|
||||
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
|
||||
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
|
||||
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
|
||||
color: ratio > 0.76 ? 'rgb(255 244 235)' : ratio > 0.32 ? 'rgb(236 242 248)' : 'rgb(203 213 225)',
|
||||
};
|
||||
}
|
||||
|
||||
function getContributionTrackStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value, minValue, maxValue);
|
||||
const widthRatio = 0.18 + ratio * 0.82;
|
||||
const hue = 210 - ratio * 178;
|
||||
|
||||
return {
|
||||
width: `${widthRatio * 100}%`,
|
||||
background: `linear-gradient(90deg, hsla(${hue}, ${70 + ratio * 14}%, ${56 + ratio * 10}%, 0.94) 0%, rgba(255, 229, 214, 0.98) 100%)`,
|
||||
color:
|
||||
ratio > 0.76
|
||||
? 'rgb(255 244 235)'
|
||||
: ratio > 0.32
|
||||
? 'rgb(236 242 248)'
|
||||
: 'rgb(203 213 225)',
|
||||
};
|
||||
}
|
||||
|
||||
function MultiplierContributionList({
|
||||
breakdown,
|
||||
schema,
|
||||
onSelectContribution,
|
||||
}: {
|
||||
breakdown: BuildDamageBreakdown;
|
||||
schema: WorldAttributeSchema;
|
||||
onSelectContribution: (row: ContributionRow) => void;
|
||||
}) {
|
||||
const sortedRows = [...breakdown.rows].sort((left, right) => right.bonusDelta - left.bonusDelta || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
const contributionProducts = sortedRows.map(row => row.bonusDelta);
|
||||
const weakestProduct = contributionProducts.length > 0 ? Math.min(...contributionProducts) : 0;
|
||||
const strongestProduct = contributionProducts.length > 0 ? Math.max(...contributionProducts) : 1;
|
||||
const sortedRows = [...breakdown.rows].sort(
|
||||
(left, right) =>
|
||||
right.bonusDelta - left.bonusDelta ||
|
||||
left.label.localeCompare(right.label, 'zh-CN'),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
|
||||
<div className="space-y-2 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
|
||||
<span>{'\u5c5e\u6027\u9002\u914d\u5ea6'}</span>
|
||||
<span className="text-zinc-400">{'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u6536\u76ca\u6765\u81ea\u54ea\u4e9b\u5c5e\u6027'}</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[12rem] rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-center">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">{'\u5c5e\u6027\u9002\u914d\u500d\u7387'}</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums text-emerald-100">x{breakdown.buildDamageMultiplier.toFixed(2)}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{'\u603b\u52a0\u6210'} +{breakdown.buildDamageBonus.toFixed(2)}</div>
|
||||
</div>
|
||||
<span>{'\u72b6\u6001\u6807\u7b7e'}</span>
|
||||
<span className="text-zinc-400">
|
||||
{
|
||||
'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u5177\u4f53\u5c5e\u6027\u52a0\u6210'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{sortedRows.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedRows.map(row => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sortedRows.map((row) => (
|
||||
<button
|
||||
key={`formula-tag-${row.label}`}
|
||||
type="button"
|
||||
onClick={() => onSelectContribution(row)}
|
||||
className="min-w-[6.25rem] rounded-xl border px-3 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5"
|
||||
style={getContributionVisualStyle(row.bonusDelta, weakestProduct, strongestProduct)}
|
||||
title={`\u67e5\u770b ${row.label} \u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6`}
|
||||
className="rounded-lg border px-2.5 py-1.5 text-left text-[11px] font-medium leading-none text-white transition-transform hover:-translate-y-0.5"
|
||||
style={getContributionVisualStyle(row.bonusDelta)}
|
||||
title={`\u67e5\u770b ${row.label} \u7684\u6807\u7b7e\u6548\u679c`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{row.label}</span>
|
||||
<span className="text-[11px] font-semibold tabular-nums text-current/80">+{row.bonusDelta.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-4 text-current/70">
|
||||
{getBuildSourceLabel(row.source)} · {describeBuildContribution(row, schema)}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
|
||||
<div className="h-full rounded-full" style={getContributionTrackStyle(row.bonusDelta, weakestProduct, strongestProduct)} />
|
||||
</div>
|
||||
<span>{row.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,21 +255,44 @@ function MultiplierContributionList({
|
||||
);
|
||||
}
|
||||
|
||||
function buildLeaderEquipmentRows(playerCharacter: Character, playerEquipment: EquipmentLoadout): EquipmentRow[] {
|
||||
function formatAttributeMetricValue(value: number) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
}
|
||||
|
||||
function getAttributeBonusPillClassName(bonus: number) {
|
||||
if (bonus >= 0.05) {
|
||||
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
|
||||
}
|
||||
if (bonus > 0) {
|
||||
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100';
|
||||
}
|
||||
return 'border-white/10 bg-black/20 text-zinc-500';
|
||||
}
|
||||
|
||||
function buildLeaderEquipmentRows(
|
||||
playerCharacter: Character,
|
||||
playerEquipment: EquipmentLoadout,
|
||||
): EquipmentRow[] {
|
||||
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
||||
return EQUIPMENT_SLOTS.map(slot => {
|
||||
return EQUIPMENT_SLOTS.map((slot) => {
|
||||
const equippedItem = playerEquipment[slot] ?? starterLoadout[slot];
|
||||
return {
|
||||
key: `leader-${slot}`,
|
||||
slotLabel: getEquipmentSlotLabel(slot),
|
||||
itemLabel: equippedItem?.name ?? '绌轰綅',
|
||||
rarityLabel: equippedItem ? getEquipmentRarityLabel(equippedItem.rarity) : '绌轰綅',
|
||||
rarityLabel: equippedItem
|
||||
? getEquipmentRarityLabel(equippedItem.rarity)
|
||||
: '绌轰綅',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildCompanionEquipmentRows(character: Character, keyPrefix: string): EquipmentRow[] {
|
||||
return getCharacterEquipment(character).map(item => ({
|
||||
function buildCompanionEquipmentRows(
|
||||
character: Character,
|
||||
keyPrefix: string,
|
||||
): EquipmentRow[] {
|
||||
return getCharacterEquipment(character).map((item) => ({
|
||||
key: `${keyPrefix}-${item.slot}-${item.item}`,
|
||||
slotLabel: item.slot,
|
||||
itemLabel: item.item,
|
||||
@@ -302,7 +326,9 @@ export function CharacterPanel({
|
||||
onInspectMember,
|
||||
}: CharacterPanelProps) {
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<string | null>(null);
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const partyMembers = useMemo<PartyMember[]>(
|
||||
() => [
|
||||
@@ -318,7 +344,7 @@ export function CharacterPanel({
|
||||
maxMana: playerMaxMana,
|
||||
isLeader: true,
|
||||
},
|
||||
...companionRenderStates.map(companion => ({
|
||||
...companionRenderStates.map((companion) => ({
|
||||
id: companion.npcId,
|
||||
npcId: companion.npcId,
|
||||
renderState: companion,
|
||||
@@ -331,61 +357,164 @@ export function CharacterPanel({
|
||||
isLeader: false,
|
||||
})),
|
||||
],
|
||||
[companionRenderStates, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana],
|
||||
[
|
||||
companionRenderStates,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedMember = useMemo(
|
||||
() => partyMembers.find(member => member.id === selectedMemberId) ?? null,
|
||||
() => partyMembers.find((member) => member.id === selectedMemberId) ?? null,
|
||||
[partyMembers, selectedMemberId],
|
||||
);
|
||||
|
||||
const activeQuests = useMemo(
|
||||
() => quests.filter(quest => quest.status !== 'turned_in'),
|
||||
() => quests.filter((quest) => quest.status !== 'turned_in'),
|
||||
[quests],
|
||||
);
|
||||
|
||||
const buildBreakdownByMemberId = useMemo(
|
||||
() => Object.fromEntries(
|
||||
partyMembers.map(member => [
|
||||
member.id,
|
||||
member.isLeader
|
||||
? getPlayerBuildDamageBreakdown({
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
playerEquipment,
|
||||
activeBuildBuffs,
|
||||
} as GameState, playerCharacter)
|
||||
: getCompanionBuildDamageBreakdown(member.character, worldType, customWorldProfile),
|
||||
]),
|
||||
) as Record<string, BuildDamageBreakdown>,
|
||||
[activeBuildBuffs, customWorldProfile, partyMembers, playerCharacter, playerEquipment, worldType],
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
partyMembers.map((member) => [
|
||||
member.id,
|
||||
member.isLeader
|
||||
? getPlayerBuildDamageBreakdown(
|
||||
{
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
playerEquipment,
|
||||
activeBuildBuffs,
|
||||
} as GameState,
|
||||
playerCharacter,
|
||||
)
|
||||
: getCompanionBuildDamageBreakdown(
|
||||
member.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
),
|
||||
]),
|
||||
) as Record<string, BuildDamageBreakdown>,
|
||||
[
|
||||
activeBuildBuffs,
|
||||
customWorldProfile,
|
||||
partyMembers,
|
||||
playerCharacter,
|
||||
playerEquipment,
|
||||
worldType,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedBuildBreakdown = selectedMember ? buildBreakdownByMemberId[selectedMember.id] ?? null : null;
|
||||
const selectedContributionRow = selectedBuildBreakdown?.rows.find(row => row.label === selectedContributionLabel) ?? null;
|
||||
const selectedContributionProducts = selectedBuildBreakdown?.rows.map(row => row.bonusDelta) ?? [];
|
||||
const selectedContributionMinProduct = selectedContributionProducts.length > 0 ? Math.min(...selectedContributionProducts) : 0;
|
||||
const selectedContributionMaxProduct = selectedContributionProducts.length > 0 ? Math.max(...selectedContributionProducts) : 1;
|
||||
const selectedAttributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
|
||||
const selectedMemberAffinity = selectedMember?.npcId
|
||||
? npcStates[selectedMember.npcId]?.affinity ?? 0
|
||||
const selectedBuildBreakdown = selectedMember
|
||||
? (buildBreakdownByMemberId[selectedMember.id] ?? null)
|
||||
: null;
|
||||
const selectedContributionRow =
|
||||
selectedBuildBreakdown?.rows.find(
|
||||
(row) => row.label === selectedContributionLabel,
|
||||
) ?? null;
|
||||
const selectedAttributeSchema = resolveAttributeSchema(
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const resourceLabels = getResourceLabelsForWorld(
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const selectedMemberAffinity = selectedMember?.npcId
|
||||
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
|
||||
: null;
|
||||
const selectedMemberPublicBackstory =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
|
||||
: null;
|
||||
const selectedMemberUnlockedBackstoryChapters =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getUnlockedCharacterBackstoryChapters(
|
||||
selectedMember.character,
|
||||
selectedMemberAffinity,
|
||||
worldType,
|
||||
).map((chapter) => ({
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
content: chapter.content,
|
||||
}))
|
||||
: [];
|
||||
const selectedMemberLockedBackstoryChapters =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getLockedCharacterBackstoryChapters(
|
||||
selectedMember.character,
|
||||
selectedMemberAffinity,
|
||||
worldType,
|
||||
).map((chapter) => ({
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
teaser: chapter.teaser,
|
||||
affinityRequired: chapter.affinityRequired,
|
||||
}))
|
||||
: [];
|
||||
const selectedEquipmentRows = selectedMember
|
||||
? selectedMember.isLeader
|
||||
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
|
||||
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
|
||||
: [];
|
||||
const selectedAttributeRows = selectedMember
|
||||
? formatAttributeList(
|
||||
resolveCharacterAttributeProfile(selectedMember.character, worldType, customWorldProfile),
|
||||
const selectedAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedMember
|
||||
? formatAttributeList(
|
||||
resolveCharacterAttributeProfile(
|
||||
selectedMember.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
),
|
||||
selectedAttributeSchema,
|
||||
)
|
||||
: [],
|
||||
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
|
||||
);
|
||||
const selectedAttributeBonusBySlot = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
selectedAttributeSchema.slots.map((slot) => [
|
||||
slot.slotId,
|
||||
Number(
|
||||
(
|
||||
selectedBuildBreakdown?.rows.reduce(
|
||||
(sum, row) =>
|
||||
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
|
||||
0,
|
||||
) ?? 0
|
||||
).toFixed(4),
|
||||
),
|
||||
]),
|
||||
) as Record<string, number>,
|
||||
[selectedAttributeSchema, selectedBuildBreakdown],
|
||||
);
|
||||
const selectedDisplayAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedAttributeRows.map(({ slot, value }) => {
|
||||
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
|
||||
const boostedValue = value * (1 + totalBonus);
|
||||
|
||||
return {
|
||||
slot,
|
||||
baseValue: value,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
};
|
||||
}),
|
||||
[selectedAttributeBonusBySlot, selectedAttributeRows],
|
||||
);
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(
|
||||
selectedContributionRow,
|
||||
selectedAttributeSchema,
|
||||
{ resourceLabels },
|
||||
)
|
||||
: [];
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(selectedContributionRow, selectedAttributeSchema)
|
||||
: [];
|
||||
|
||||
const resourceLabels = getResourceLabelsForWorld(worldType);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedContributionLabel) return;
|
||||
@@ -418,15 +547,30 @@ export function CharacterPanel({
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="pixel-nine-slice pixel-panel min-h-0 flex-1" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 14,
|
||||
paddingY: 12,
|
||||
})}
|
||||
>
|
||||
{activeQuests.length > 0 && (
|
||||
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">褰撳墠濮旀墭</div>
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">
|
||||
褰撳墠濮旀墭
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{activeQuests.map(quest => (
|
||||
<div key={quest.id} className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200">
|
||||
<div className="font-semibold text-white">{quest.title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{quest.summary}</div>
|
||||
{activeQuests.map((quest) => (
|
||||
<div
|
||||
key={quest.id}
|
||||
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="font-semibold text-white">
|
||||
{quest.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{quest.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -435,7 +579,7 @@ export function CharacterPanel({
|
||||
|
||||
<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 => (
|
||||
{partyMembers.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
@@ -455,23 +599,43 @@ export function CharacterPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{member.character.name}</div>
|
||||
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">{member.character.title}</div>
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{member.character.name}
|
||||
</div>
|
||||
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{member.character.title}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}
|
||||
>
|
||||
{member.roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2.5 space-y-2.5">
|
||||
<StatusRow label={resourceLabels.hp} current={member.hp} max={member.maxHp} tone="hp" />
|
||||
<StatusRow label={resourceLabels.mp} current={member.mana} max={member.maxMana} tone="mp" />
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
current={member.hp}
|
||||
max={member.maxHp}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatusRow
|
||||
label={resourceLabels.mp}
|
||||
current={member.mana}
|
||||
max={member.maxMana}
|
||||
tone="mp"
|
||||
/>
|
||||
</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'}
|
||||
{'\u9002\u914d'} x
|
||||
{buildBreakdownByMemberId[
|
||||
member.id
|
||||
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,13 +662,19 @@ export function CharacterPanel({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,40rem)] w-full max-w-xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
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] tracking-[0.22em] text-sky-300/80">{'\u5c5e\u6027\u9002\u914d\u89e3\u6790'}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedContributionRow.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">{selectedMember.character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">
|
||||
{'\u6807\u7b7e\u6548\u679c'}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{selectedContributionRow.label}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -515,63 +685,77 @@ export function CharacterPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
<div className="rounded-xl border px-4 py-4" style={getContributionVisualStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{selectedContributionRow.label}</div>
|
||||
<div className="mt-1 text-xs text-current/70">
|
||||
{getBuildSourceLabel(selectedContributionRow.source)} · {describeBuildContribution(selectedContributionRow, selectedAttributeSchema)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold">{'\u52a0\u6210'} +{selectedContributionRow.bonusDelta.toFixed(2)}</div>
|
||||
<div className="mt-1 text-[11px] text-current/70">{'\u9002\u914d\u5ea6'} {Math.round(selectedContributionRow.fitScore * 100)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-black/35">
|
||||
<div className="h-full rounded-full" style={getContributionTrackStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
|
||||
<div className="font-medium text-white">bonusDelta = {'\u5404\u5c5e\u6027\u52a0\u6210\u4e4b\u548c'}</div>
|
||||
<div className="mt-1 text-zinc-400">
|
||||
{'\u6bcf\u4e2a\u6807\u7b7e\u90fd\u4f1a\u5206\u522b\u5339\u914d\u5f53\u524d\u4e16\u754c\u7684\u5c5e\u6027\u8f74\uff0c\u518d\u548c\u89d2\u8272\u81ea\u5df1\u7684\u5c5e\u6027\u6743\u91cd\u9010\u9879\u76f8\u4e58\u3002\u6bcf\u6761\u5c5e\u6027\u5148\u751f\u6210\u5355\u72ec\u7684\u52a0\u6210\uff0c\u6700\u540e\u6c47\u603b\u6210\u8fd9\u4e2a\u6807\u7b7e\u7684\u6536\u76ca\u3002'}
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-zinc-200">
|
||||
{selectedContributionRow.label} = 0.12 x {'\u9002\u914d\u5ea6'} {selectedContributionRow.fitScore.toFixed(2)} x {'\u6765\u6e90\u7cfb\u6570'} {selectedContributionRow.sourceCoefficient.toFixed(2)} = {selectedContributionRow.bonusDelta.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedContributionAttributes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedContributionAttributes.map(attribute => (
|
||||
<div key={`${selectedContributionRow.label}-${attribute.slotId}`} className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span>{attribute.label}</span>
|
||||
<span>{Math.round(attribute.percent * 100)}%</span>
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="rounded-2xl border px-4 py-4"
|
||||
style={getContributionVisualStyle(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
|
||||
标签概览
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-[11px] text-zinc-400 sm:grid-cols-2">
|
||||
<div>{'\u6807\u7b7e\u4eb2\u548c'} {Math.round(attribute.similarity * 100)}%</div>
|
||||
<div>{'\u89d2\u8272\u6743\u91cd'} {Math.round(attribute.weight * 100)}%</div>
|
||||
<div>{'\u9002\u914d\u8d21\u732e'} {attribute.value.toFixed(4)}</div>
|
||||
<div>{'\u5c5e\u6027\u52a0\u6210'} +{attribute.modifierDelta.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
|
||||
<div className="h-full rounded-full" style={getContributionTrackStyle(attribute.percent)} />
|
||||
<div className="mt-2 text-sm font-semibold">
|
||||
{selectedContributionRow.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
|
||||
<div className="text-[11px] tracking-[0.14em] text-current/70">
|
||||
{getBuildContributionQualityLabel(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold">
|
||||
{'\u603b\u52a0\u6210'}{' '}
|
||||
{formatBuildContributionPercent(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-400">
|
||||
{'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'}
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
{'\u5c5e\u6027\u52a0\u6210'}
|
||||
</div>
|
||||
|
||||
{selectedContributionAttributes.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{selectedContributionAttributes.map((attribute) => (
|
||||
<div
|
||||
key={`${selectedContributionRow.label}-${attribute.slotId}`}
|
||||
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span>{attribute.label}</span>
|
||||
<span className="font-semibold text-white">
|
||||
{formatBuildContributionPercent(
|
||||
attribute.modifierDelta,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
|
||||
{
|
||||
'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -594,12 +778,16 @@ export function CharacterPanel({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
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] 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="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">
|
||||
@@ -618,7 +806,10 @@ export function CharacterPanel({
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
|
||||
<div className="space-y-4 lg:max-h-full lg:overflow-y-auto lg:pr-1">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
|
||||
<CharacterAnimator
|
||||
@@ -626,74 +817,158 @@ export function CharacterPanel({
|
||||
character={selectedMember.character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(selectedMember.character)}
|
||||
style={getCharacterDetailSpriteStyle(
|
||||
selectedMember.character,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-base font-bold text-white">{selectedMember.character.name}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">{selectedMember.character.title}</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{selectedMember.character.description}</p>
|
||||
<div className="mt-3 text-base font-bold text-white">
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{selectedMember.character.title}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">状态</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.statsPanel)}
|
||||
>
|
||||
<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" />
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
current={selectedMember.hp}
|
||||
max={selectedMember.maxHp}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatusRow
|
||||
label={resourceLabels.mp}
|
||||
current={selectedMember.mana}
|
||||
max={selectedMember.maxMana}
|
||||
tone="mp"
|
||||
/>
|
||||
{selectedMemberAffinity != null && (
|
||||
<AffinityStatusCard affinity={selectedMemberAffinity} />
|
||||
)}
|
||||
{selectedMemberAffinity != null && (
|
||||
<BackstoryArchive
|
||||
publicSummary={selectedMemberPublicBackstory}
|
||||
unlockedChapters={
|
||||
selectedMemberUnlockedBackstoryChapters
|
||||
}
|
||||
lockedChapters={selectedMemberLockedBackstoryChapters}
|
||||
/>
|
||||
)}
|
||||
{selectedBuildBreakdown && (
|
||||
<MultiplierContributionList
|
||||
breakdown={selectedBuildBreakdown}
|
||||
schema={selectedAttributeSchema}
|
||||
onSelectContribution={row => setSelectedContributionLabel(row.label)}
|
||||
onSelectContribution={(row) =>
|
||||
setSelectedContributionLabel(row.label)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
{selectedAttributeRows.map(({ slot, value }) => (
|
||||
<div key={slot.slotId} className="rounded-lg border border-white/5 bg-black/20 px-3 py-2">
|
||||
<div>{slot.name}: {value}</div>
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
|
||||
</div>
|
||||
))}
|
||||
{selectedDisplayAttributeRows.map(
|
||||
({ slot, baseValue, boostedValue, totalBonus }) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{formatAttributeMetricValue(boostedValue)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
原始 {formatAttributeMetricValue(baseValue)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
|
||||
>
|
||||
{formatBuildContributionPercent(totalBonus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-zinc-500">
|
||||
{slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.backstory}
|
||||
{selectedMemberAffinity == null && (
|
||||
<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="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>
|
||||
)}
|
||||
|
||||
<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="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">{'\u6280\u80fd'}</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
{'\u6280\u80fd'}
|
||||
</div>
|
||||
<CharacterSkillsList character={selectedMember.character} />
|
||||
</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="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
装备
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
{selectedEquipmentRows.map(item => (
|
||||
{selectedEquipmentRows.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PixelIcon src={getEquipmentSlotIcon(item.slotLabel)} className="h-8 w-8" />
|
||||
<PixelIcon
|
||||
src={getEquipmentSlotIcon(item.slotLabel)}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">{item.slotLabel}</div>
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{item.slotLabel}
|
||||
</div>
|
||||
<div>{item.itemLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -713,5 +988,3 @@ export function CharacterPanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { type ReactNode,useDeferredValue, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useDeferredValue, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
@@ -137,10 +141,61 @@ function matchText(text: string, query: string) {
|
||||
function getSearchPlaceholder(tab: ResultTab) {
|
||||
if (tab === 'playable') return '搜索角色名称、称号、标签';
|
||||
if (tab === 'story') return '搜索场景角色名称、身份、动机';
|
||||
if (tab === 'landmarks') return '搜索场景名称、描述';
|
||||
if (tab === 'landmarks') return '搜索场景名称、描述、NPC、连接';
|
||||
return '搜索';
|
||||
}
|
||||
|
||||
type CatalogRole =
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number];
|
||||
|
||||
function buildRoleSearchText(role: CatalogRole) {
|
||||
return [
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.backstoryReveal.publicSummary,
|
||||
role.personality,
|
||||
role.motivation,
|
||||
role.combatStyle,
|
||||
...role.backstoryReveal.chapters.flatMap((chapter) => [
|
||||
chapter.title,
|
||||
chapter.teaser,
|
||||
chapter.content,
|
||||
chapter.contextSnippet,
|
||||
]),
|
||||
...role.skills.flatMap((skill) => [skill.name, skill.summary, skill.style]),
|
||||
...role.initialItems.flatMap((item) => [
|
||||
item.name,
|
||||
item.category,
|
||||
item.description,
|
||||
...item.tags,
|
||||
]),
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildLandmarkSearchText(
|
||||
landmark: CustomWorldProfile['landmarks'][number],
|
||||
storyNpcById: Map<string, CustomWorldProfile['storyNpcs'][number]>,
|
||||
landmarkById: Map<string, CustomWorldProfile['landmarks'][number]>,
|
||||
) {
|
||||
return [
|
||||
landmark.name,
|
||||
landmark.description,
|
||||
landmark.dangerLevel,
|
||||
...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''),
|
||||
...landmark.connections.flatMap((connection) => [
|
||||
landmarkById.get(connection.targetLandmarkId)?.name ?? '',
|
||||
getCustomWorldSceneRelativePositionLabel(connection.relativePosition),
|
||||
connection.summary,
|
||||
]),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function CustomWorldEntityCatalog({
|
||||
profile,
|
||||
previewCharacters,
|
||||
@@ -154,6 +209,14 @@ export function CustomWorldEntityCatalog({
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
const storyNpcById = useMemo(
|
||||
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
|
||||
[profile.storyNpcs],
|
||||
);
|
||||
const landmarkById = useMemo(
|
||||
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
|
||||
[profile.landmarks],
|
||||
);
|
||||
const previewCharacterById = useMemo(
|
||||
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
@@ -162,21 +225,7 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
[
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.motivation,
|
||||
role.combatStyle,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
|| matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
@@ -184,21 +233,7 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
[
|
||||
npc.name,
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...npc.relationshipHooks,
|
||||
...npc.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
|| matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
@@ -206,9 +241,12 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredLandmarks = useMemo(
|
||||
() => profile.landmarks.filter(landmark =>
|
||||
!deferredSearch
|
||||
|| matchText([landmark.name, landmark.description].join(' '), deferredSearch),
|
||||
|| matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, profile.landmarks],
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
@@ -232,17 +270,34 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
const removeStoryNpc = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
|
||||
const nextStoryNpcs = profile.storyNpcs.filter(npc => npc.id !== id);
|
||||
onProfileChange({
|
||||
...profile,
|
||||
storyNpcs: profile.storyNpcs.filter(npc => npc.id !== id),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: profile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => npcId !== id),
|
||||
})),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const removeLandmark = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
|
||||
const nextLandmarks = profile.landmarks.filter(landmark => landmark.id !== id);
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: profile.landmarks.filter(landmark => landmark.id !== id),
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: nextLandmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: landmark.connections.filter(
|
||||
(connection) => connection.targetLandmarkId !== id,
|
||||
),
|
||||
})),
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -293,7 +348,7 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="档案规模" subtitle="结果页只保留角色、场景角色与场景档案,预设物品已从自定义世界中移除。">
|
||||
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.playableNpcs.length}</div>
|
||||
@@ -347,6 +402,9 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||||
公开背景:{role.backstoryReveal.publicSummary || '未填写'}
|
||||
</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>
|
||||
@@ -354,6 +412,36 @@ export function CustomWorldEntityCatalog({
|
||||
<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 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${role.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.skills.map(skill => (
|
||||
<div key={`${role.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.initialItems.map(item => (
|
||||
<div key={`${role.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="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">
|
||||
@@ -400,6 +488,9 @@ export function CustomWorldEntityCatalog({
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
|
||||
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
|
||||
公开背景:{npc.backstoryReveal.publicSummary || '未填写'}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">头衔:{npc.title}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">初始好感:{npc.initialAffinity}</div>
|
||||
@@ -410,6 +501,36 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">背景:{npc.backstory}</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">动机:{npc.motivation}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{npc.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${npc.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{npc.skills.map(skill => (
|
||||
<div key={`${npc.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{npc.initialItems.map(item => (
|
||||
<div key={`${npc.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc.relationshipHooks.map(hook => (
|
||||
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
@@ -434,7 +555,7 @@ export function CustomWorldEntityCatalog({
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
场景图会同步用于结果页和正式世界中的背景展示。
|
||||
场景图会同步用于结果页和正式世界中的背景展示;这里还能看到每个场景承载的 NPC 和连接关系。
|
||||
</div>
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
@@ -453,6 +574,38 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="space-y-3">
|
||||
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
|
||||
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">
|
||||
危险度:{landmark.dangerLevel || '未填写'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">场景内 NPC</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{landmark.sceneNpcIds.length > 0 ? (
|
||||
landmark.sceneNpcIds.map((npcId) => (
|
||||
<span key={`${landmark.id}-npc-${npcId}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{storyNpcById.get(npcId)?.name ?? '未匹配角色'}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">尚未分配场景角色</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">连接关系</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{landmark.connections.length > 0 ? (
|
||||
landmark.connections.map((connection) => (
|
||||
<div key={`${landmark.id}-connection-${connection.targetLandmarkId}-${connection.relativePosition}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} · {landmarkById.get(connection.targetLandmarkId)?.name ?? '未匹配场景'}
|
||||
{connection.summary ? `:${connection.summary}` : ''}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">尚未配置连接关系</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
} from '../data/affinityLevels';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
PRESET_CHARACTERS,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
getAllCustomWorldSceneImages,
|
||||
getDefaultCustomWorldSceneImage,
|
||||
@@ -22,6 +30,7 @@ import {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldSceneConnection,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
@@ -48,6 +57,13 @@ interface CustomWorldEntityEditorModalProps {
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
}
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
@@ -85,6 +101,16 @@ function clampInitialAffinity(value: string, fallback: number) {
|
||||
return Math.max(-40, Math.min(90, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function syncLandmarksWithStoryNpcs(
|
||||
landmarks: CustomWorldLandmark[],
|
||||
storyNpcs: CustomWorldProfile['storyNpcs'],
|
||||
) {
|
||||
return normalizeCustomWorldLandmarks({
|
||||
landmarks,
|
||||
storyNpcs,
|
||||
});
|
||||
}
|
||||
|
||||
function useDraft<T>(value: T) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
@@ -1208,24 +1234,97 @@ function LandmarkEditor({
|
||||
profile,
|
||||
landmark,
|
||||
mode,
|
||||
onSave,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
landmark: CustomWorldLandmark;
|
||||
mode: 'create' | 'edit';
|
||||
onSave: (landmark: CustomWorldLandmark) => void;
|
||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(landmark);
|
||||
const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
|
||||
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
|
||||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||||
const [npcEditorState, setNpcEditorState] = useState<{
|
||||
mode: 'create' | 'edit';
|
||||
npc: CustomWorldNpc;
|
||||
} | null>(null);
|
||||
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
|
||||
const storyNpcById = useMemo(
|
||||
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
|
||||
[draftStoryNpcs],
|
||||
);
|
||||
const availableTargetLandmarks = useMemo(
|
||||
() => profile.landmarks.filter((entry) => entry.id !== draft.id),
|
||||
[draft.id, profile.landmarks],
|
||||
);
|
||||
|
||||
const toggleSceneNpc = (npcId: string) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sceneNpcIds: current.sceneNpcIds.includes(npcId)
|
||||
? current.sceneNpcIds.filter((entry) => entry !== npcId)
|
||||
: [...current.sceneNpcIds, npcId],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateConnection = (
|
||||
index: number,
|
||||
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: current.connections.map((connection, connectionIndex) =>
|
||||
connectionIndex === index ? updater(connection) : connection,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addConnection = () => {
|
||||
const fallbackTarget = availableTargetLandmarks[0];
|
||||
if (!fallbackTarget) {
|
||||
window.alert('请先保留至少一个其他场景,才能配置连接关系。');
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: [
|
||||
...current.connections,
|
||||
{
|
||||
targetLandmarkId: fallbackTarget.id,
|
||||
relativePosition: 'forward',
|
||||
summary: `可通往${fallbackTarget.name}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const saveLandmarkProfile = () => {
|
||||
if (draft.sceneNpcIds.length < 3) {
|
||||
window.alert('每个场景至少需要分配 3 个 NPC。');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLandmarks =
|
||||
mode === 'create'
|
||||
? [...profile.landmarks, draft]
|
||||
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
|
||||
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
storyNpcs: draftStoryNpcs,
|
||||
landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={mode === 'create' ? '新增场景' : `编辑场景:${landmark.name}`}
|
||||
subtitle="这里的场景图片会同步用于结果页展示和正式进入世界后的场景背景。"
|
||||
subtitle="这里可以同时配置场景图片、场景内 NPC,以及场景之间的相对位置连接关系。"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -1286,12 +1385,213 @@ function LandmarkEditor({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||||
场景内 NPC
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-400">
|
||||
每个场景至少保留 3 个 NPC。可以在这里直接继续新增 NPC,并立即加入当前场景。
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="新增 NPC 并加入此场景"
|
||||
onClick={() =>
|
||||
setNpcEditorState({
|
||||
mode: 'create',
|
||||
npc: createStoryNpc({ storyNpcs: draftStoryNpcs }),
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{draft.sceneNpcIds.length > 0 ? (
|
||||
draft.sceneNpcIds.map((npcId) => {
|
||||
const npc = storyNpcById.get(npcId);
|
||||
return (
|
||||
<div
|
||||
key={`${draft.id}-selected-npc-${npcId}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{npc?.name ?? '未匹配场景角色'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{npc?.role || npc?.title || '未填写身份'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc ? (
|
||||
<ActionButton
|
||||
label="编辑"
|
||||
onClick={() =>
|
||||
setNpcEditorState({
|
||||
mode: 'edit',
|
||||
npc,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
<ActionButton
|
||||
label="移出场景"
|
||||
onClick={() => toggleSceneNpc(npcId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
|
||||
还没有为这个场景分配 NPC。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 max-h-64 space-y-2 overflow-y-auto pr-1">
|
||||
{draftStoryNpcs.map((npc) => {
|
||||
const selected = draft.sceneNpcIds.includes(npc.id);
|
||||
return (
|
||||
<button
|
||||
key={`${draft.id}-npc-picker-${npc.id}`}
|
||||
type="button"
|
||||
onClick={() => toggleSceneNpc(npc.id)}
|
||||
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-sky-300/28 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/20 hover:border-white/18'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{npc.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{npc.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{selected ? '已加入' : '点击加入'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||||
场景连接关系
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-400">
|
||||
编辑当前场景与其他场景之间的相对位置关系。保存时会自动同步反向连线,避免地图断开。
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="新增连接"
|
||||
onClick={addConnection}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{draft.connections.length > 0 ? (
|
||||
draft.connections.map((connection, index) => (
|
||||
<div
|
||||
key={`${draft.id}-connection-${index}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<Field label="目标场景">
|
||||
<SelectField
|
||||
value={connection.targetLandmarkId}
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
targetLandmarkId: value,
|
||||
}))
|
||||
}
|
||||
options={availableTargetLandmarks.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="相对位置">
|
||||
<SelectField
|
||||
value={connection.relativePosition}
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
|
||||
}))
|
||||
}
|
||||
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
(option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="连接说明">
|
||||
<TextArea
|
||||
value={connection.summary}
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
summary: value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
placeholder="例如:沿山脊向北翻过去,可到达断桥。"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
label="删除连接"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: current.connections.filter(
|
||||
(_item, connectionIndex) => connectionIndex !== index,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
|
||||
当前还没有手动配置连接。若直接保存,系统会自动补一条可遍历的主路连接。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{draft.connections.length > 0 ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-400">
|
||||
当前连接预览:
|
||||
{draft.connections
|
||||
.map((connection) => {
|
||||
const targetLandmark = availableTargetLandmarks.find(
|
||||
(entry) => entry.id === connection.targetLandmarkId,
|
||||
);
|
||||
return `${getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} -> ${targetLandmark?.name ?? '未匹配场景'}`;
|
||||
})
|
||||
.join(';')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSave(draft);
|
||||
onClose();
|
||||
}}
|
||||
onSave={saveLandmarkProfile}
|
||||
/>
|
||||
{isPresetPickerOpen ? (
|
||||
<ScenePresetPickerModal
|
||||
@@ -1316,6 +1616,27 @@ function LandmarkEditor({
|
||||
onClose={() => setIsAiGenerateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{npcEditorState ? (
|
||||
<StoryNpcEditor
|
||||
npc={npcEditorState.npc}
|
||||
mode={npcEditorState.mode}
|
||||
onSave={(nextNpc) => {
|
||||
setDraftStoryNpcs((current) =>
|
||||
npcEditorState.mode === 'create'
|
||||
? [...current, nextNpc]
|
||||
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
|
||||
);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
|
||||
? current.sceneNpcIds
|
||||
: [...current.sceneNpcIds, nextNpc.id],
|
||||
}));
|
||||
setNpcEditorState(null);
|
||||
}}
|
||||
onClose={() => setNpcEditorState(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
@@ -1347,11 +1668,82 @@ function createPlayableNpc(
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['首次接触', '合作空间'],
|
||||
tags: ['自定义'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
|
||||
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
|
||||
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '随身武具',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '补给包',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '私人物件',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
],
|
||||
templateCharacterId: template?.id,
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
|
||||
const seed = Date.now() + profile.storyNpcs.length;
|
||||
const npc = {
|
||||
id: createEntryId(
|
||||
@@ -1370,6 +1762,77 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['合作', '互动'],
|
||||
tags: ['自定义'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
|
||||
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
|
||||
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '随身武具',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '补给包',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '私人物件',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
],
|
||||
} satisfies CustomWorldNpc;
|
||||
|
||||
return npc;
|
||||
@@ -1377,6 +1840,7 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
|
||||
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
|
||||
const seed = Date.now() + profile.landmarks.length;
|
||||
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
|
||||
return {
|
||||
id: createEntryId(
|
||||
'landmark',
|
||||
@@ -1391,6 +1855,16 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
|
||||
profile.landmarks.length,
|
||||
profile.templateWorldType,
|
||||
),
|
||||
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
|
||||
connections: previousLandmark
|
||||
? [
|
||||
{
|
||||
targetLandmarkId: previousLandmark.id,
|
||||
relativePosition: 'back',
|
||||
summary: `暂时接回${previousLandmark.name}这条旧路`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1483,20 +1957,15 @@ export function CustomWorldEntityEditorModal({
|
||||
}
|
||||
|
||||
if (target.mode === 'create') {
|
||||
return (
|
||||
<LandmarkEditor
|
||||
profile={profile}
|
||||
landmark={createLandmark(profile)}
|
||||
mode="create"
|
||||
onSave={(nextLandmark) =>
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: [...profile.landmarks, nextLandmark],
|
||||
})
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<LandmarkEditor
|
||||
profile={profile}
|
||||
landmark={createLandmark(profile)}
|
||||
mode="create"
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const landmark = profile.landmarks.find((entry) => entry.id === target.id);
|
||||
@@ -1505,14 +1974,7 @@ export function CustomWorldEntityEditorModal({
|
||||
profile={profile}
|
||||
landmark={landmark}
|
||||
mode="edit"
|
||||
onSave={(nextLandmark) =>
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: profile.landmarks.map((entry) =>
|
||||
entry.id === nextLandmark.id ? nextLandmark : entry,
|
||||
),
|
||||
})
|
||||
}
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -374,7 +374,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs ?? visibleGameState.sceneMonsters}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
sceneMonsters={visibleGameState.sceneMonsters}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import { getConnectedScenePresets } from '../data/scenePresets';
|
||||
import { ScenePresetInfo, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
@@ -33,9 +34,11 @@ function getMapDestinationCenterPercent(index: number, count: number) {
|
||||
|
||||
function MudMapRoom({
|
||||
scene,
|
||||
label: _label,
|
||||
label,
|
||||
compact = false,
|
||||
isInteractive = false,
|
||||
isSelected = false,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
key?: string;
|
||||
@@ -43,6 +46,8 @@ function MudMapRoom({
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
isInteractive?: boolean;
|
||||
isSelected?: boolean;
|
||||
description?: string;
|
||||
onClick?: (() => void) | null;
|
||||
}) {
|
||||
if (!scene) {
|
||||
@@ -56,11 +61,21 @@ function MudMapRoom({
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`pixel-nine-slice map-room-cell h-full font-mono ${compact ? 'text-[10px]' : 'text-[11px]'} leading-relaxed ${isInteractive ? 'transition-transform duration-150 hover:-translate-y-0.5 hover:brightness-110' : ''}`}
|
||||
className={`pixel-nine-slice map-room-cell h-full font-mono ${compact ? 'text-[10px]' : 'text-[11px]'} leading-relaxed ${isInteractive ? 'transition-transform duration-150 hover:-translate-y-0.5 hover:brightness-110' : ''} ${isSelected ? 'brightness-125' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
|
||||
>
|
||||
<div className={`flex min-h-[3.25rem] items-center justify-center px-3 py-2 text-center ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
|
||||
{scene.name}
|
||||
<div className="flex min-h-[3.25rem] flex-col items-center justify-center px-3 py-2 text-center">
|
||||
<div className="rounded-full border border-emerald-300/25 bg-emerald-500/10 px-2 py-0.5 text-[9px] tracking-[0.16em] text-emerald-100/85">
|
||||
{label}
|
||||
</div>
|
||||
<div className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
|
||||
{scene.name}
|
||||
</div>
|
||||
{!compact && description ? (
|
||||
<div className="mt-2 text-[10px] leading-5 text-zinc-300/85">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -86,6 +101,48 @@ interface MapModalProps {
|
||||
canTravel?: boolean;
|
||||
}
|
||||
|
||||
type MapConnectionEntry = {
|
||||
scene: ScenePresetInfo;
|
||||
label: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
function isMapConnectionEntry(
|
||||
entry: MapConnectionEntry | null,
|
||||
): entry is MapConnectionEntry {
|
||||
return entry !== null;
|
||||
}
|
||||
|
||||
function buildFallbackConnectionEntries(
|
||||
currentScenePreset: ScenePresetInfo | null,
|
||||
connectedScenes: ScenePresetInfo[],
|
||||
) {
|
||||
const forwardSceneId = currentScenePreset?.forwardSceneId;
|
||||
const forwardScene =
|
||||
connectedScenes.find((scene) => scene.id === forwardSceneId) ?? null;
|
||||
const branchScenes = connectedScenes.filter((scene) => scene.id !== forwardSceneId);
|
||||
|
||||
const fallbackEntries: Array<MapConnectionEntry | null> = [
|
||||
forwardScene
|
||||
? ({
|
||||
scene: forwardScene,
|
||||
label: '前方',
|
||||
summary: '沿主路继续深入',
|
||||
} satisfies MapConnectionEntry)
|
||||
: null,
|
||||
...branchScenes.map(
|
||||
(scene, index) =>
|
||||
({
|
||||
scene,
|
||||
label: index === 0 ? '支路左侧' : index === 1 ? '支路右侧' : `支路${index + 1}`,
|
||||
summary: '可转向另一片区域',
|
||||
}) satisfies MapConnectionEntry,
|
||||
),
|
||||
];
|
||||
|
||||
return fallbackEntries.filter(isMapConnectionEntry);
|
||||
}
|
||||
|
||||
export function MapModal({
|
||||
isOpen,
|
||||
currentScenePreset,
|
||||
@@ -95,7 +152,7 @@ export function MapModal({
|
||||
isTraveling = false,
|
||||
canTravel = true,
|
||||
}: MapModalProps) {
|
||||
const [pendingScene, setPendingScene] = useState<ScenePresetInfo | null>(null);
|
||||
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
|
||||
|
||||
const connectedScenes = useMemo(
|
||||
() =>
|
||||
@@ -104,14 +161,33 @@ export function MapModal({
|
||||
: [],
|
||||
[currentScenePreset, worldType],
|
||||
);
|
||||
const forwardSceneId = currentScenePreset?.forwardSceneId;
|
||||
const forwardScene = connectedScenes.find(scene => scene.id === forwardSceneId) ?? null;
|
||||
const branchScenes = connectedScenes.filter(scene => scene.id !== forwardSceneId);
|
||||
const leftBranchScene = branchScenes[0] ?? null;
|
||||
const rightBranchScene = branchScenes[1] ?? null;
|
||||
const destinationScenes = [forwardScene, leftBranchScene, rightBranchScene].filter(Boolean) as ScenePresetInfo[];
|
||||
const connectionEntries = useMemo(() => {
|
||||
if (currentScenePreset?.connections?.length) {
|
||||
const entries: Array<MapConnectionEntry | null> = currentScenePreset.connections
|
||||
.map((connection) => {
|
||||
const scene = connectedScenes.find(
|
||||
(item) => item.id === connection.sceneId,
|
||||
);
|
||||
if (!scene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scene,
|
||||
label: getCustomWorldSceneRelativePositionLabel(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: connection.summary,
|
||||
} satisfies MapConnectionEntry;
|
||||
});
|
||||
|
||||
return entries.filter(isMapConnectionEntry);
|
||||
}
|
||||
|
||||
return buildFallbackConnectionEntries(currentScenePreset, connectedScenes);
|
||||
}, [connectedScenes, currentScenePreset]);
|
||||
const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(destinationScenes.length);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -121,19 +197,19 @@ export function MapModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScene) return;
|
||||
if (!connectedScenes.some(scene => scene.id === pendingScene.id)) {
|
||||
if (!connectionEntries.some((scene) => scene.scene.id === pendingScene.scene.id)) {
|
||||
setPendingScene(null);
|
||||
}
|
||||
}, [connectedScenes, pendingScene]);
|
||||
}, [connectionEntries, pendingScene]);
|
||||
|
||||
const handleSceneSelect = (scene: ScenePresetInfo | null) => {
|
||||
if (!scene || scene.id === currentScenePreset?.id) return;
|
||||
const handleSceneSelect = (scene: MapConnectionEntry | null) => {
|
||||
if (!scene || scene.scene.id === currentScenePreset?.id) return;
|
||||
setPendingScene(scene);
|
||||
};
|
||||
|
||||
const confirmTravel = () => {
|
||||
if (!pendingScene) return;
|
||||
onTravelToScene(pendingScene);
|
||||
onTravelToScene(pendingScene.scene);
|
||||
setPendingScene(null);
|
||||
};
|
||||
|
||||
@@ -187,11 +263,10 @@ export function MapModal({
|
||||
<div className="mt-2 text-zinc-300">{currentScenePreset.description}</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5 text-zinc-300">
|
||||
{forwardScene && <div>{`- 前路:${forwardScene.name}`}</div>}
|
||||
{branchScenes.map((scene, index) => (
|
||||
<div key={scene.id}>{`- 支路 ${index + 1}:${scene.name}`}</div>
|
||||
{connectionEntries.map((entry) => (
|
||||
<div key={entry.scene.id}>{`- ${entry.label}:${entry.scene.name}`}</div>
|
||||
))}
|
||||
{connectedScenes.length === 0 && <div>- 暂无</div>}
|
||||
{connectionEntries.length === 0 && <div>- 暂无</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,15 +277,15 @@ export function MapModal({
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.length > 0 && (
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
{destinationScenes.map((scene, index) => (
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-${scene.id}`}
|
||||
key={`connector-${entry.scene.id}`}
|
||||
x1="0"
|
||||
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
|
||||
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
|
||||
x2="100%"
|
||||
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
|
||||
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
|
||||
stroke="rgba(74, 222, 128, 0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
@@ -219,14 +294,16 @@ export function MapModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.map(scene => (
|
||||
{connectionEntries.map(entry => (
|
||||
<MudMapRoom
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
label={scene.id === forwardScene?.id ? '前路' : '支路'}
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
label={entry.label}
|
||||
description={entry.summary}
|
||||
compact
|
||||
isInteractive={canTravel}
|
||||
onClick={() => handleSceneSelect(scene)}
|
||||
isSelected={pendingScene?.scene.id === entry.scene.id}
|
||||
onClick={() => handleSceneSelect(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -239,15 +316,15 @@ export function MapModal({
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.length > 0 && (
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
{destinationScenes.map((scene, index) => (
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-desktop-${scene.id}`}
|
||||
key={`connector-desktop-${entry.scene.id}`}
|
||||
x1="0"
|
||||
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
|
||||
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
|
||||
x2="100%"
|
||||
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
|
||||
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
|
||||
stroke="rgba(74, 222, 128, 0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
@@ -256,13 +333,15 @@ export function MapModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.map(scene => (
|
||||
{connectionEntries.map(entry => (
|
||||
<MudMapRoom
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
label={scene.id === forwardScene?.id ? '前路' : '支路'}
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
label={entry.label}
|
||||
description={entry.summary}
|
||||
isInteractive={canTravel}
|
||||
onClick={() => handleSceneSelect(scene)}
|
||||
isSelected={pendingScene?.scene.id === entry.scene.id}
|
||||
onClick={() => handleSceneSelect(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -296,7 +375,7 @@ export function MapModal({
|
||||
<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-amber-200/80">场景切换</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.name}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,8 +389,16 @@ export function MapModal({
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="text-[10px] tracking-[0.18em] text-amber-200/75">目标场景</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{pendingScene.name}</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.description}</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
<div className="mt-2 rounded-full border border-amber-300/20 bg-black/20 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-50">
|
||||
{pendingScene.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.scene.description}</div>
|
||||
{pendingScene.summary ? (
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
连接说明:{pendingScene.summary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -321,7 +408,7 @@ export function MapModal({
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">前往</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.name}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -240,6 +240,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
|
||||
{tradeModal.introText && (
|
||||
<div className="mb-3 whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||||
{tradeModal.introText}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||
<div className="min-h-0 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -470,6 +475,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
{npcUi.giftModal.introText && (
|
||||
<div className="whitespace-pre-line rounded-xl border border-rose-300/15 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-50/90">
|
||||
{npcUi.giftModal.introText}
|
||||
</div>
|
||||
)}
|
||||
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
|
||||
<button
|
||||
key={candidate.item.id}
|
||||
@@ -541,6 +551,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
{npcUi.recruitModal.introText && (
|
||||
<div className="whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||||
{npcUi.recruitModal.introText}
|
||||
</div>
|
||||
)}
|
||||
{gameState.companions.length > 0 ? gameState.companions.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
|
||||
@@ -91,6 +91,9 @@ export function SkillEffectPreview({
|
||||
npcEncounter,
|
||||
buildInitialNpcState(npcEncounter, worldType),
|
||||
'fight',
|
||||
{
|
||||
worldType,
|
||||
},
|
||||
),
|
||||
];
|
||||
}, [mode, npcEncounter, targetMonsterId, worldType]);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type FacingDirection,
|
||||
type FunctionCategory,
|
||||
type GameState,
|
||||
type HostileNpcRenderAnimation,
|
||||
type PlayerStateMode,
|
||||
type SceneMonster,
|
||||
type SkillStyle,
|
||||
@@ -142,12 +143,13 @@ const ANIMATION_LABELS: Record<AnimationState, string> = {
|
||||
[AnimationState.WALL_SLIDE]: '贴墙滑行',
|
||||
};
|
||||
const MONSTER_ANIMATION_LABELS: Record<
|
||||
NonNullable<FunctionVisualConfig['monsterAnimation']>,
|
||||
HostileNpcRenderAnimation,
|
||||
string
|
||||
> = {
|
||||
idle: '待机',
|
||||
move: '移动',
|
||||
attack: '攻击',
|
||||
die: '倒下',
|
||||
};
|
||||
const SKILL_STYLE_LABELS: Record<SkillStyle, string> = {
|
||||
steady: '稳扎稳打',
|
||||
@@ -173,7 +175,7 @@ function getAnimationLabel(animation: AnimationState) {
|
||||
}
|
||||
|
||||
function getMonsterAnimationLabel(
|
||||
animation: NonNullable<FunctionVisualConfig['monsterAnimation']>,
|
||||
animation: HostileNpcRenderAnimation,
|
||||
) {
|
||||
return MONSTER_ANIMATION_LABELS[animation] ?? animation;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ export function GameCanvasRuntime({
|
||||
const stageLiftPx = 68;
|
||||
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
|
||||
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
|
||||
const resolvedSceneHostileNpcs = sceneHostileNpcs ?? sceneMonsters ?? [];
|
||||
const resolvedSceneHostileNpcs =
|
||||
sceneMonsters && sceneMonsters.length > 0
|
||||
? sceneMonsters
|
||||
: (sceneHostileNpcs ?? []);
|
||||
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
|
||||
? Math.min(...resolvedSceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
|
||||
: Infinity;
|
||||
|
||||
@@ -49,7 +49,8 @@ export function GameShellCanvasStage({
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs ?? visibleGameState.sceneMonsters}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
sceneMonsters={visibleGameState.sceneMonsters}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
|
||||
@@ -523,7 +523,7 @@ export function PreGameSelectionFlow({
|
||||
创建自定义世界
|
||||
</div>
|
||||
<div className="mt-2 max-w-[16rem] text-sm leading-6 text-zinc-300">
|
||||
输入世界设置,让系统生成可玩角色、场景角色、物品和地标。
|
||||
输入世界设置,让系统生成可玩角色、场景角色、场景内 NPC 分布与场景连接关系。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user