This commit is contained in:
171
src/components/AdventureEntityModal.test.tsx
Normal file
171
src/components/AdventureEntityModal.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type EquipmentLoadout,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { AdventureEntityModal } from './AdventureEntityModal';
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div data-testid="character-portrait" />,
|
||||
}));
|
||||
|
||||
vi.mock('./MedievalNpcAnimator', () => ({
|
||||
MedievalNpcAnimator: () => <div data-testid="medieval-npc-portrait" />,
|
||||
}));
|
||||
|
||||
vi.mock('./HostileNpcAnimator', () => ({
|
||||
HostileNpcAnimator: () => <div data-testid="hostile-npc-portrait" />,
|
||||
}));
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {} as EquipmentLoadout,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'runtime-npc',
|
||||
kind: 'npc',
|
||||
npcName: '雾中来客',
|
||||
npcDescription: '带着临时生成形象的相遇者',
|
||||
npcAvatar: '/avatar.png',
|
||||
context: '桥边试探',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('NPC 详情立绘优先展示遭遇实例形象,而不是 characterId 对应预设', () => {
|
||||
const encounter = createEncounter({
|
||||
characterId: 'sword-princess',
|
||||
imageSrc: '/runtime-npc-preview.png',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'npc', encounter }}
|
||||
gameState={createGameState()}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const portrait = screen.getByAltText('雾中来客');
|
||||
|
||||
expect(portrait.getAttribute('src')).toBe('/runtime-npc-preview.png');
|
||||
expect(screen.queryByTestId('character-portrait')).toBeNull();
|
||||
});
|
||||
|
||||
test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const encounter = createEncounter();
|
||||
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'npc', encounter }}
|
||||
gameState={createGameState({
|
||||
npcStates: {
|
||||
'runtime-npc': {
|
||||
affinity: 0,
|
||||
relationState: { affinity: 0, stance: 'neutral' },
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [
|
||||
{
|
||||
id: '',
|
||||
category: '材料',
|
||||
name: '裂纹石片',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
category: '材料',
|
||||
name: '裂纹石片',
|
||||
quantity: 2,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: false,
|
||||
seenBackstoryChapterIds: [],
|
||||
},
|
||||
},
|
||||
})}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTitle(/裂纹石片 x/)).toHaveLength(2);
|
||||
expect(
|
||||
consoleErrorSpy.mock.calls.some((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
1569
src/components/AdventureEntityModal.tsx
Normal file
1569
src/components/AdventureEntityModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
221
src/components/AffinityStatusCard.tsx
Normal file
221
src/components/AffinityStatusCard.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
AFFINITY_PROGRESS_MARKERS,
|
||||
AFFINITY_PROGRESS_MAX,
|
||||
AFFINITY_PROGRESS_MIN,
|
||||
getAffinityLevelMeta,
|
||||
} from '../data/affinityLevels';
|
||||
|
||||
type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getNextAffinityMarker(affinity: number) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
if (currentLevel.nextAffinity == null) return null;
|
||||
|
||||
return (
|
||||
AFFINITY_PROGRESS_MARKERS.find(
|
||||
(marker) => marker.value === currentLevel.nextAffinity,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getAffinityProgressRatio(value: number) {
|
||||
return clamp(
|
||||
(value - AFFINITY_PROGRESS_MIN) /
|
||||
(AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
function getAnchorTransform(ratio: number) {
|
||||
if (ratio <= 0.02) return 'translateX(0)';
|
||||
if (ratio >= 0.98) return 'translateX(-100%)';
|
||||
return 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
|
||||
if (marker.value < 0) {
|
||||
return affinity < 0;
|
||||
}
|
||||
|
||||
return affinity >= marker.value;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityMarker(affinity);
|
||||
const currentRatio = getAffinityProgressRatio(affinity);
|
||||
const zeroRatio = getAffinityProgressRatio(0);
|
||||
const activeMarkerValue =
|
||||
currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN
|
||||
? AFFINITY_PROGRESS_MIN
|
||||
: currentLevel.minAffinity;
|
||||
const fillLeftRatio = Math.min(currentRatio, zeroRatio);
|
||||
const fillWidthRatio = Math.abs(currentRatio - zeroRatio);
|
||||
const fillWidthPercent =
|
||||
fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%';
|
||||
const currentPointerTone =
|
||||
affinity < 0
|
||||
? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]'
|
||||
: 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]';
|
||||
const fillGradient =
|
||||
affinity < 0
|
||||
? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感等级
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
|
||||
>
|
||||
{currentLevel.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
当前好感 {affinity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{nextLevel ? (
|
||||
<>
|
||||
<div>下一节点</div>
|
||||
<div className="mt-1 text-zinc-200">
|
||||
{nextLevel.label} · {nextLevel.value}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-zinc-200">已达最高节点</div>
|
||||
<div className="mt-1">继续提升可稳固关系优势</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{currentLevel.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感进度
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
|
||||
0 是战斗分界线,低于 0
|
||||
会直接进入对战;其余节点表示进入对应阶段所需的最低好感。
|
||||
</div>
|
||||
|
||||
<div className="relative mt-5 pb-12 pt-1 sm:pb-14">
|
||||
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
|
||||
<div
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-l-full bg-gradient-to-r from-rose-500/18 via-rose-400/10 to-transparent"
|
||||
style={{ width: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-r-full bg-gradient-to-r from-sky-400/10 via-amber-300/10 to-rose-400/16"
|
||||
style={{
|
||||
left: `${zeroRatio * 100}%`,
|
||||
width: `${(1 - zeroRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[0.55rem] h-5 w-px bg-white/20"
|
||||
style={{ left: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-full shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{
|
||||
left: `${fillLeftRatio * 100}%`,
|
||||
width: fillWidthPercent,
|
||||
background: fillGradient,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-[0.76rem] h-3.5 w-3.5 rounded-full border-2"
|
||||
style={{
|
||||
left: `${currentRatio * 100}%`,
|
||||
transform: getAnchorTransform(currentRatio),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-full rounded-full border ${currentPointerTone}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{AFFINITY_PROGRESS_MARKERS.map((marker) => {
|
||||
const markerRatio = getAffinityProgressRatio(marker.value);
|
||||
const isReached = isMarkerReached(marker, affinity);
|
||||
const isActive = marker.value === activeMarkerValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`affinity-marker-${marker.value}`}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: `${markerRatio * 100}%`,
|
||||
transform: getAnchorTransform(markerRatio),
|
||||
}}
|
||||
>
|
||||
<div className="flex w-12 flex-col items-center text-center sm:w-16">
|
||||
<div className="relative flex h-9 items-end justify-center sm:h-10">
|
||||
{isActive ? (
|
||||
<div
|
||||
className={`absolute bottom-0 h-6 w-3 rounded-full blur-[5px] sm:h-7 sm:w-3.5 ${
|
||||
marker.value < 0 ? 'bg-rose-300/20' : 'bg-sky-300/18'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`relative rounded-full border transition-all duration-300 ${
|
||||
isActive
|
||||
? marker.value < 0
|
||||
? 'h-7 w-2 border-rose-100/75 bg-gradient-to-b from-rose-100 via-rose-300 to-rose-500 shadow-[0_0_14px_rgba(251,113,133,0.28)] sm:h-8'
|
||||
: 'h-7 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-8'
|
||||
: isReached
|
||||
? marker.value < 0
|
||||
? 'h-5 w-1.5 border-rose-100/45 bg-gradient-to-b from-rose-200 via-rose-300 to-rose-500 shadow-[0_0_10px_rgba(251,113,133,0.22)] sm:h-6'
|
||||
: 'h-5 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-6'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${
|
||||
isActive || isReached ? 'text-zinc-100' : 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{marker.label}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${
|
||||
isActive || isReached ? 'text-zinc-300' : 'text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{marker.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
91
src/components/CharacterAnimator.test.tsx
Normal file
91
src/components/CharacterAnimator.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
||||
resolvedUrl: source?.trim() ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function buildCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'generated-role',
|
||||
name: '沈砺',
|
||||
title: '守灯人',
|
||||
description: '',
|
||||
backstory: '',
|
||||
avatar: '/generated/portrait.png',
|
||||
portrait: '/generated/portrait.png',
|
||||
assetFolder: 'custom-world',
|
||||
assetVariant: 'generated',
|
||||
attributes: {} as Character['attributes'],
|
||||
personality: '',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CharacterAnimator portrait fallbacks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps idle fallback static on the portrait when idle animation is missing', () => {
|
||||
render(
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={buildCharacter()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = screen.getByRole('img', {
|
||||
name: /沈砺 idle animation/i,
|
||||
}) as HTMLImageElement;
|
||||
|
||||
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
|
||||
expect(image.style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('uses a fallen portrait fallback when death animation is missing', () => {
|
||||
render(
|
||||
<CharacterAnimator
|
||||
state={AnimationState.DIE}
|
||||
character={buildCharacter()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = screen.getByRole('img', {
|
||||
name: /沈砺 die animation/i,
|
||||
}) as HTMLImageElement;
|
||||
|
||||
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
|
||||
expect(image.style.animation).toContain(
|
||||
'character-animator-portrait-death-fall',
|
||||
);
|
||||
expect(image.style.transform).toContain('rotate(-90deg)');
|
||||
expect(image.style.transform).toContain('scaleX(-1)');
|
||||
});
|
||||
|
||||
it('uses generated portrait for movement when generated animation is missing', () => {
|
||||
render(
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={buildCharacter({generatedVisualAssetId: 'assetobj-role-main'})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = screen.getByRole('img', {
|
||||
name: /沈砺 run animation/i,
|
||||
}) as HTMLImageElement;
|
||||
|
||||
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
|
||||
});
|
||||
});
|
||||
248
src/components/CharacterAnimator.tsx
Normal file
248
src/components/CharacterAnimator.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { AnimationState, Character, CharacterAnimationConfig } from '../types';
|
||||
|
||||
interface CharacterAnimatorProps {
|
||||
state: AnimationState;
|
||||
character: Character;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
imageClassName?: string;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = {
|
||||
[AnimationState.ACQUIRE]: { frames: 1, prefix: 'acquire', folder: 'acquire' },
|
||||
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
|
||||
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
|
||||
[AnimationState.DOUBLE_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'double jump',
|
||||
folder: 'double jump',
|
||||
},
|
||||
[AnimationState.JUMP_ATTACK]: {
|
||||
frames: 1,
|
||||
prefix: 'jump attack',
|
||||
folder: 'jump attack',
|
||||
},
|
||||
[AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
|
||||
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
|
||||
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
|
||||
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
|
||||
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
|
||||
[AnimationState.SKILL1_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'skill1 jump',
|
||||
folder: 'skill1 jump',
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET]: {
|
||||
frames: 1,
|
||||
prefix: 'skill1 bullet',
|
||||
folder: 'skill1 bullet',
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET_FX]: {
|
||||
frames: 1,
|
||||
prefix: 'skill1 bullet FX',
|
||||
folder: 'skill1 bullet FX',
|
||||
},
|
||||
[AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' },
|
||||
[AnimationState.SKILL2_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'skill2 jump',
|
||||
folder: 'skill2 jump',
|
||||
},
|
||||
[AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
|
||||
[AnimationState.SKILL3_JUMP]: {
|
||||
frames: 1,
|
||||
prefix: 'skill3 jump',
|
||||
folder: 'skill3 jump',
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET]: {
|
||||
frames: 1,
|
||||
prefix: 'skill3 bullet',
|
||||
folder: 'skill3 bullet',
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET_FX]: {
|
||||
frames: 1,
|
||||
prefix: 'skill3 bullet FX',
|
||||
folder: 'skill3 bullet FX',
|
||||
},
|
||||
[AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' },
|
||||
[AnimationState.WALL_SLIDE]: {
|
||||
frames: 1,
|
||||
prefix: 'Wall Slide',
|
||||
folder: 'Wall Slide',
|
||||
},
|
||||
[AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
|
||||
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' },
|
||||
};
|
||||
|
||||
const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = {
|
||||
frames: 1,
|
||||
prefix: 'portrait',
|
||||
folder: 'portrait',
|
||||
fps: 1,
|
||||
loop: false,
|
||||
};
|
||||
|
||||
const FALLEN_PORTRAIT_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
transform: 'translateY(16%) rotate(-90deg) scaleX(-1) scale(0.82)',
|
||||
transformOrigin: '50% 85%',
|
||||
animation:
|
||||
'character-animator-portrait-death-fall 680ms cubic-bezier(0.22, 0.7, 0.18, 1) forwards',
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
};
|
||||
|
||||
export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
state,
|
||||
character,
|
||||
className,
|
||||
style,
|
||||
imageClassName,
|
||||
playbackRate = 1,
|
||||
}) => {
|
||||
const explicitConfig = character.animationMap?.[state];
|
||||
const hasGeneratedPortraitOnly =
|
||||
Boolean(character.generatedVisualAssetId && character.portrait?.trim())
|
||||
&& !explicitConfig;
|
||||
const usePortraitIdleFallback =
|
||||
!explicitConfig && state === AnimationState.IDLE;
|
||||
const usePortraitDeathFallback =
|
||||
!explicitConfig && state === AnimationState.DIE;
|
||||
const [hasRenderError, setHasRenderError] = useState(false);
|
||||
const baseConfig =
|
||||
explicitConfig ??
|
||||
DEFAULT_ANIMATIONS[state] ??
|
||||
character.animationMap?.[AnimationState.IDLE] ??
|
||||
DEFAULT_ANIMATIONS[AnimationState.IDLE];
|
||||
const fallbackToPortrait =
|
||||
hasGeneratedPortraitOnly || usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
|
||||
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
|
||||
const startFrame =
|
||||
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
|
||||
? Math.max(1, Math.floor(config.startFrame))
|
||||
: 1;
|
||||
const [frameIndex, setFrameIndex] = useState(startFrame);
|
||||
const frameCount =
|
||||
typeof config.frames === 'number' && Number.isFinite(config.frames)
|
||||
? Math.max(1, Math.floor(config.frames))
|
||||
: 1;
|
||||
const fps =
|
||||
typeof config.fps === 'number' && Number.isFinite(config.fps)
|
||||
? Math.max(1, config.fps)
|
||||
: 10;
|
||||
const effectivePlaybackRate = Number.isFinite(playbackRate)
|
||||
? Math.max(0.1, playbackRate)
|
||||
: 1;
|
||||
const requestedAnimationSignature = [
|
||||
state,
|
||||
character.id,
|
||||
character.portrait,
|
||||
baseConfig.basePath ?? '',
|
||||
baseConfig.folder,
|
||||
baseConfig.prefix,
|
||||
baseConfig.file ?? '',
|
||||
baseConfig.extension ?? 'png',
|
||||
baseConfig.startFrame ?? 1,
|
||||
baseConfig.frames,
|
||||
baseConfig.fps ?? 10,
|
||||
effectivePlaybackRate,
|
||||
].join('::');
|
||||
const animationSignature = [
|
||||
state,
|
||||
config.basePath ?? '',
|
||||
config.folder,
|
||||
config.prefix,
|
||||
config.file ?? '',
|
||||
config.extension ?? 'png',
|
||||
startFrame,
|
||||
frameCount,
|
||||
fps,
|
||||
effectivePlaybackRate,
|
||||
].join('::');
|
||||
|
||||
useEffect(() => {
|
||||
setHasRenderError(false);
|
||||
}, [requestedAnimationSignature]);
|
||||
|
||||
const endFrame = startFrame + frameCount - 1;
|
||||
const intervalDelay = Math.max(
|
||||
40,
|
||||
Math.round(1000 / (fps * effectivePlaybackRate)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex((current) => (current === startFrame ? current : startFrame));
|
||||
}, [animationSignature, startFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (frameCount <= 1) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameIndex((current) => {
|
||||
if (current < startFrame || current > endFrame) {
|
||||
return startFrame;
|
||||
}
|
||||
return current >= endFrame ? startFrame : current + 1;
|
||||
});
|
||||
}, intervalDelay);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [endFrame, frameCount, intervalDelay, startFrame]);
|
||||
|
||||
const frameNumber = frameIndex.toString().padStart(2, '0');
|
||||
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
|
||||
const generatedImagePath = normalizedBasePath
|
||||
? config.file
|
||||
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
|
||||
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`
|
||||
: (() => {
|
||||
const folder = encodeURIComponent(character.assetFolder);
|
||||
const variant = encodeURIComponent(character.assetVariant);
|
||||
const animationFolder = encodeURIComponent(config.folder);
|
||||
return config.file
|
||||
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
|
||||
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
|
||||
})();
|
||||
const imagePath = fallbackToPortrait
|
||||
? character.portrait
|
||||
: generatedImagePath;
|
||||
const {
|
||||
resolvedUrl: resolvedImagePath,
|
||||
shouldResolve: shouldResolveImagePath,
|
||||
} = useResolvedAssetReadUrl(imagePath);
|
||||
// 私有 OSS 资源必须等签名地址返回后再渲染,不能先落回原始 generated-* 路径。
|
||||
const displayImagePath =
|
||||
resolvedImagePath || (!shouldResolveImagePath ? imagePath : '');
|
||||
const resolvedImageClassName =
|
||||
`h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
|
||||
const imageStyle =
|
||||
state === AnimationState.DIE && (usePortraitDeathFallback || hasRenderError)
|
||||
? FALLEN_PORTRAIT_STYLE
|
||||
: DEFAULT_IMAGE_STYLE;
|
||||
|
||||
if (!displayImagePath) {
|
||||
return <div className={`relative ${className ?? ''}`} style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className ?? ''}`} style={style}>
|
||||
<img
|
||||
src={displayImagePath}
|
||||
alt={`${character.name} ${state} animation`}
|
||||
className={resolvedImageClassName}
|
||||
style={imageStyle}
|
||||
onError={(e) => {
|
||||
if (!hasRenderError) {
|
||||
setHasRenderError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
src/components/CharacterChatModal.tsx
Normal file
203
src/components/CharacterChatModal.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface CharacterChatModalProps {
|
||||
modal: CharacterChatModalState | null;
|
||||
onClose: () => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
onUseSuggestion: (value: string) => void;
|
||||
onRefreshSuggestions: () => void;
|
||||
onSendDraft: () => void;
|
||||
}
|
||||
|
||||
export function CharacterChatModal({
|
||||
modal,
|
||||
onClose,
|
||||
onDraftChange,
|
||||
onUseSuggestion,
|
||||
onRefreshSuggestions,
|
||||
onSendDraft,
|
||||
}: CharacterChatModalProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modal || !scrollContainerRef.current) return;
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}, [modal]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{modal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[85] flex items-center justify-center bg-black/76 p-3 backdrop-blur-sm sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">角色聊天</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{modal.target.character.name}</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
{modal.target.character.title} / {modal.target.roleLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
|
||||
<div className="space-y-4 sm:max-h-full sm:overflow-y-auto sm:pr-1">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-2 text-xs font-bold text-white">角色状态</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
|
||||
生命值 {modal.target.hp} / {modal.target.maxHp}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
|
||||
内力 {modal.target.mana} / {modal.target.maxMana}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2 text-xs leading-relaxed text-zinc-400">
|
||||
{modal.target.character.personality}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-2 text-xs font-bold text-white">聊天总结</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{modal.summary || '你们还没有形成新的私下聊天总结。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="pixel-nine-slice pixel-panel min-h-[20rem] flex-1 space-y-3 overflow-y-auto pr-1 scrollbar-hide"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel)}
|
||||
>
|
||||
{modal.messages.length > 0 ? (
|
||||
modal.messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.speaker}-${index}-${message.text}`}
|
||||
className={`flex ${message.speaker === 'player' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[88%] rounded-2xl border px-3 py-2 text-sm leading-relaxed ${
|
||||
message.speaker === 'player'
|
||||
? 'rounded-br-none border-sky-400/20 bg-sky-500/10 text-sky-50'
|
||||
: 'rounded-bl-none border-amber-400/20 bg-amber-500/10 text-amber-50'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
|
||||
{message.speaker === 'player' ? '你' : modal.target.character.name}
|
||||
</div>
|
||||
{message.text || (modal.isSending && message.speaker === 'character' ? '正在回复...' : '...')}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-black/18 px-4 py-6 text-sm leading-relaxed text-zinc-500">
|
||||
这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold text-white">帮你回复</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshSuggestions}
|
||||
disabled={modal.isLoadingSuggestions || modal.isSending}
|
||||
className={`rounded-full border px-3 py-1 text-[10px] transition-colors ${
|
||||
modal.isLoadingSuggestions || modal.isSending
|
||||
? 'border-white/8 bg-black/20 text-zinc-600'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{modal.isLoadingSuggestions ? '生成中...' : '换一组'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{modal.suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion}-${index}`}
|
||||
type="button"
|
||||
onClick={() => onUseSuggestion(suggestion)}
|
||||
disabled={modal.isSending}
|
||||
className={`rounded-xl border px-3 py-2 text-left text-xs leading-relaxed transition ${
|
||||
modal.isSending
|
||||
? 'border-white/8 bg-black/20 text-zinc-600'
|
||||
: 'border-white/8 bg-black/20 text-zinc-200 hover:border-sky-300/30 hover:bg-sky-500/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modal.error && (
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-100">
|
||||
{modal.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
onSendDraft();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={modal.draft}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
placeholder={`对${modal.target.character.name}说点什么...`}
|
||||
disabled={modal.isSending}
|
||||
rows={4}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-100 outline-none transition focus:border-sky-300/35"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={modal.isSending || !modal.draft.trim()}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
modal.isSending || !modal.draft.trim() ? 'text-zinc-600' : 'text-white'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
{modal.isSending ? '对话生成中...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
319
src/components/CharacterDetailModal.tsx
Normal file
319
src/components/CharacterDetailModal.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import { getCompanionBuildDamageBreakdown } from '../data/buildDamage';
|
||||
import {
|
||||
type CharacterEquipmentItem,
|
||||
type CharacterInventoryItem,
|
||||
getCharacterEquipment,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
getInventoryItems,
|
||||
} from '../data/characterPresets';
|
||||
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
type NineSliceTexture,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getGenderLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterSkillsList,
|
||||
} from './CharacterInfoShared';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface CharacterDetailModalProps {
|
||||
character: Character | null;
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
chrome = UI_CHROME.panel,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
chrome?: NineSliceTexture;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: 'neutral' | 'hp' | 'mp';
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'hp'
|
||||
? 'border-rose-400/20 bg-rose-500/10 text-rose-100'
|
||||
: tone === 'mp'
|
||||
? 'border-sky-400/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200';
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border px-3 py-2 ${toneClassName}`}>
|
||||
<div className="text-[10px] tracking-[0.18em] text-white/60">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${item.slot}-${item.item}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.slot}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{item.item}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${item.category}-${item.name}-${item.quantity}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.category}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
数量 x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterDetailModal({
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile = null,
|
||||
subtitle = '初始伙伴',
|
||||
onClose,
|
||||
}: CharacterDetailModalProps) {
|
||||
if (!character) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opening = worldType ? character.adventureOpenings[worldType] : null;
|
||||
const equipment = getCharacterEquipment(character);
|
||||
const inventory = getInventoryItems(character, worldType);
|
||||
const attributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const resourceLabels = getResourceLabelsForWorld(
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
aria-label="关闭角色详情"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto 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">
|
||||
<Section title="资料">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
{character.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(character)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
|
||||
候选人
|
||||
</div>
|
||||
<div className="mt-3 text-base font-bold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<span>{character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
性别: {getGenderLabel(character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.description}
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="属性" chrome={UI_CHROME.statsPanel}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<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">
|
||||
<CharacterAttributeGrid
|
||||
attributeProfile={attributeProfile}
|
||||
attributeSchema={attributeSchema}
|
||||
buildBreakdown={buildBreakdown}
|
||||
resourceLabels={resourceLabels}
|
||||
gridClassName="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4"
|
||||
cardClassName="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{opening && (
|
||||
<Section title="旅程">
|
||||
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
原因
|
||||
</div>
|
||||
<div className="mt-1">{opening.reason}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
目标
|
||||
</div>
|
||||
<div className="mt-1">{opening.goal}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="技能">
|
||||
<CharacterSkillsList skills={character.skills} />
|
||||
</Section>
|
||||
|
||||
<Section title="装备">
|
||||
<EquipmentGrid items={equipment} />
|
||||
</Section>
|
||||
|
||||
<Section title="背包">
|
||||
<InventoryGrid items={inventory} />
|
||||
</Section>
|
||||
|
||||
<Section title="背景">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.backstory}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="性格">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.personality}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
136
src/components/CharacterInfoHelpers.ts
Normal file
136
src/components/CharacterInfoHelpers.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { type RoleCombatStats } from '../data/attributeCombat';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
getBuildContributionQuality,
|
||||
getBuildContributionQualityRatio,
|
||||
} from '../data/buildDamage';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import type { Character } from '../types';
|
||||
|
||||
export function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未明';
|
||||
}
|
||||
|
||||
export function getCharacterDetailSpriteStyle(character: Character) {
|
||||
const groundOffset = character.groundOffsetY ?? 22;
|
||||
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
|
||||
|
||||
return {
|
||||
transform: `translateY(${translateY}px) scale(1.34)`,
|
||||
transformOrigin: 'center bottom',
|
||||
} satisfies CSSProperties;
|
||||
}
|
||||
|
||||
const SKILL_STYLE_LABELS = {
|
||||
burst: '爆发',
|
||||
steady: '稳态',
|
||||
mobility: '机动',
|
||||
finisher: '终结',
|
||||
projectile: '投射',
|
||||
} satisfies Record<Character['skills'][number]['style'], string>;
|
||||
|
||||
export function getSkillDeliveryLabel(skill: Character['skills'][number]) {
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile'
|
||||
? '远程'
|
||||
: '近战';
|
||||
}
|
||||
|
||||
export function getSkillStyleLabel(skill: Character['skills'][number]) {
|
||||
return SKILL_STYLE_LABELS[skill.style];
|
||||
}
|
||||
|
||||
export function buildCharacterSkillRenderId(
|
||||
skill: Character['skills'][number],
|
||||
index: number,
|
||||
) {
|
||||
const normalizedId = skill.id.trim();
|
||||
if (normalizedId) {
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
const fallbackSeed = skill.name.trim() || getSkillStyleLabel(skill) || 'skill';
|
||||
return `skill-${fallbackSeed}-${index}`;
|
||||
}
|
||||
|
||||
function getContributionHeatRatio(value: number) {
|
||||
return getBuildContributionQualityRatio(value);
|
||||
}
|
||||
|
||||
export function getContributionVisualStyle(value: number): CSSProperties {
|
||||
const quality = getBuildContributionQuality(value);
|
||||
if (quality.tier === 'epic') {
|
||||
return {
|
||||
borderColor: 'hsla(286, 68%, 66%, 0.46)',
|
||||
background:
|
||||
'linear-gradient(135deg, hsla(284, 72%, 44%, 0.34) 0%, hsla(265, 64%, 28%, 0.26) 42%, rgba(12, 16, 24, 0.94) 78%)',
|
||||
boxShadow:
|
||||
'inset 0 1px 0 rgba(255,255,255,0.05), 0 0 24px hsla(284, 78%, 62%, 0.22)',
|
||||
color: 'rgb(247 236 255)',
|
||||
};
|
||||
}
|
||||
|
||||
const ratio = getContributionHeatRatio(value);
|
||||
const hue = 210 - ratio * 178;
|
||||
const saturation = 62 + ratio * 16;
|
||||
const lightness = 56 + ratio * 6;
|
||||
|
||||
return {
|
||||
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)',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAttributeMetricValue(value: number) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
}
|
||||
|
||||
function formatAttributePercentValue(value: number) {
|
||||
return `${formatAttributeMetricValue(value * 100)}%`;
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
|
||||
export function getAttributeEffectText(
|
||||
slotId: string,
|
||||
combatStats: RoleCombatStats,
|
||||
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
|
||||
) {
|
||||
switch (slotId) {
|
||||
case 'axis_a':
|
||||
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
|
||||
case 'axis_b':
|
||||
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
|
||||
case 'axis_c':
|
||||
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
|
||||
case 'axis_d':
|
||||
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
|
||||
case 'axis_e':
|
||||
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
|
||||
case 'axis_f':
|
||||
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
|
||||
default:
|
||||
return '提升战斗表现';
|
||||
}
|
||||
}
|
||||
|
||||
export type ContributionRow = BuildDamageBreakdown['rows'][number];
|
||||
89
src/components/CharacterInfoShared.test.tsx
Normal file
89
src/components/CharacterInfoShared.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import {
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
PlayerLevelProgress,
|
||||
} from './CharacterInfoShared';
|
||||
|
||||
function createSkill(
|
||||
name: string,
|
||||
style: Character['skills'][number]['style'],
|
||||
): Character['skills'][number] {
|
||||
return {
|
||||
id: '',
|
||||
name,
|
||||
animation: AnimationState.IDLE,
|
||||
damage: 12,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSelectSkill = vi.fn();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CharacterSkillsList
|
||||
skills={[
|
||||
createSkill('潮刃突进', 'burst'),
|
||||
createSkill('雾行转位', 'mobility'),
|
||||
]}
|
||||
onSelectSkill={handleSelectSkill}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
await user.click(buttons[0]!);
|
||||
await user.click(buttons[1]!);
|
||||
|
||||
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
|
||||
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('CharacterIdentityBadges renders role and level chips together', () => {
|
||||
render(
|
||||
<CharacterIdentityBadges
|
||||
roleLabel="队长"
|
||||
roleTone="amber"
|
||||
levelText="Lv.7"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('队长')).toBeTruthy();
|
||||
expect(screen.getByText('Lv.7')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlayerLevelProgress renders xp progress details', () => {
|
||||
render(
|
||||
<PlayerLevelProgress level={6} currentLevelXp={72} xpToNextLevel={120} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Lv.6')).toBeTruthy();
|
||||
expect(screen.getByText('72/120')).toBeTruthy();
|
||||
});
|
||||
375
src/components/CharacterInfoShared.tsx
Normal file
375
src/components/CharacterInfoShared.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { resolveRoleCombatStats } from '../data/attributeCombat';
|
||||
import { getAttributeSlotValue } from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionQualityLabel,
|
||||
} from '../data/buildDamage';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import type {
|
||||
Character,
|
||||
RoleAttributeProfile,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {
|
||||
buildCharacterSkillRenderId,
|
||||
type ContributionRow,
|
||||
formatAttributeMetricValue,
|
||||
getAttributeBonusPillClassName,
|
||||
getAttributeEffectText,
|
||||
getContributionVisualStyle,
|
||||
getSkillDeliveryLabel,
|
||||
getSkillStyleLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
|
||||
export function StatusRow({
|
||||
label,
|
||||
current,
|
||||
max,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
current: number;
|
||||
max: number;
|
||||
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';
|
||||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterIdentityBadges({
|
||||
roleLabel,
|
||||
levelText = null,
|
||||
roleTone = 'sky',
|
||||
className = '',
|
||||
}: {
|
||||
roleLabel: string;
|
||||
levelText?: string | null;
|
||||
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
|
||||
className?: string;
|
||||
}) {
|
||||
const roleClass =
|
||||
roleTone === 'amber'
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||||
: roleTone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
|
||||
: roleTone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
: roleTone === 'zinc'
|
||||
? 'border-white/10 bg-black/20 text-zinc-200'
|
||||
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{levelText ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200">
|
||||
{levelText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerLevelProgress({
|
||||
level,
|
||||
currentLevelXp,
|
||||
xpToNextLevel,
|
||||
className = '',
|
||||
}: {
|
||||
level: number;
|
||||
currentLevelXp: number;
|
||||
xpToNextLevel: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const safeLevel = Math.max(1, Math.round(level));
|
||||
const safeCurrentLevelXp = Math.max(0, Math.round(currentLevelXp));
|
||||
const safeXpToNextLevel = Math.max(0, Math.round(xpToNextLevel));
|
||||
const ratio =
|
||||
safeXpToNextLevel <= 0
|
||||
? 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between gap-3 text-[11px]">
|
||||
<div className="font-semibold text-amber-50">Lv.{safeLevel}</div>
|
||||
<div className="text-zinc-400">
|
||||
{safeXpToNextLevel > 0
|
||||
? `${safeCurrentLevelXp}/${safeXpToNextLevel}`
|
||||
: 'MAX'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
|
||||
style={{
|
||||
width: ratio <= 0 ? '0%' : `${Math.max(6, ratio * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterSkillsList({
|
||||
skills,
|
||||
onSelectSkill,
|
||||
emptyText = '暂无技能信息',
|
||||
}: {
|
||||
skills: Character['skills'];
|
||||
onSelectSkill?: ((skillId: string) => void) | null;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{skills.map((skill, index) => {
|
||||
const skillRenderId = buildCharacterSkillRenderId(skill, index);
|
||||
const content = (
|
||||
<>
|
||||
<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">
|
||||
{getSkillDeliveryLabel(skill)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
|
||||
<div>伤害:{skill.damage}</div>
|
||||
<div>法力:{skill.manaCost}</div>
|
||||
<div>冷却:{skill.cooldownTurns}</div>
|
||||
<div>距离:{skill.range}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
|
||||
{getSkillStyleLabel(skill)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (onSelectSkill) {
|
||||
return (
|
||||
<button
|
||||
key={skillRenderId}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skillRenderId)}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skillRenderId}
|
||||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiplierContributionList({
|
||||
breakdown,
|
||||
onSelectContribution,
|
||||
}: {
|
||||
breakdown: BuildDamageBreakdown;
|
||||
onSelectContribution: (row: ContributionRow) => void;
|
||||
}) {
|
||||
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="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span>状态标签</span>
|
||||
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
|
||||
点击标签查看具体属性加成
|
||||
</span>
|
||||
</div>
|
||||
{sortedRows.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedRows.map((row) => (
|
||||
<button
|
||||
key={`formula-tag-${row.label}`}
|
||||
type="button"
|
||||
onClick={() => onSelectContribution(row)}
|
||||
className="min-w-[5.2rem] rounded-xl border px-2.5 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5 sm:min-w-[6.25rem] sm:px-3"
|
||||
style={getContributionVisualStyle(row.bonusDelta)}
|
||||
title={`查看 ${row.label} 的标签效果`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{row.label}</span>
|
||||
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
|
||||
{getBuildContributionQualityLabel(row.bonusDelta)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-4 text-current/70">
|
||||
总加成 {formatBuildContributionPercent(row.bonusDelta)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
当前还没有形成有效标签
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterAttributeGrid({
|
||||
attributeProfile,
|
||||
attributeSchema,
|
||||
buildBreakdown = null,
|
||||
resourceLabels,
|
||||
emptyText = '暂无属性信息',
|
||||
gridClassName = 'grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2',
|
||||
cardClassName = 'rounded-xl border border-white/8 bg-black/25 px-3 py-2',
|
||||
}: {
|
||||
attributeProfile: RoleAttributeProfile | null | undefined;
|
||||
attributeSchema: WorldAttributeSchema;
|
||||
buildBreakdown?: BuildDamageBreakdown | null;
|
||||
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
|
||||
emptyText?: string;
|
||||
gridClassName?: string;
|
||||
cardClassName?: string;
|
||||
}) {
|
||||
const attributeRows = attributeSchema.slots.map((slot) => ({
|
||||
slot,
|
||||
value: getAttributeSlotValue(attributeProfile, slot.slotId),
|
||||
}));
|
||||
const attributeBonusBySlot = Object.fromEntries(
|
||||
attributeSchema.slots.map((slot) => [
|
||||
slot.slotId,
|
||||
Number(
|
||||
(
|
||||
buildBreakdown?.rows.reduce(
|
||||
(sum, row) =>
|
||||
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
|
||||
0,
|
||||
) ?? 0
|
||||
).toFixed(4),
|
||||
),
|
||||
]),
|
||||
) as Record<string, number>;
|
||||
const boostedAttributeProfile = attributeProfile
|
||||
? {
|
||||
...attributeProfile,
|
||||
values: {
|
||||
...(attributeProfile.values ?? {}),
|
||||
...Object.fromEntries(
|
||||
attributeSchema.slots.map((slot) => {
|
||||
const baseValue = attributeProfile.values?.[slot.slotId] ?? 0;
|
||||
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
|
||||
|
||||
return [
|
||||
slot.slotId,
|
||||
Number((baseValue * (1 + totalBonus)).toFixed(4)),
|
||||
];
|
||||
}),
|
||||
),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const boostedCombatStats = boostedAttributeProfile
|
||||
? resolveRoleCombatStats(boostedAttributeProfile)
|
||||
: null;
|
||||
const displayRows = attributeRows.map(({ slot, value }) => {
|
||||
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
|
||||
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
|
||||
|
||||
return {
|
||||
slot,
|
||||
baseValue: value,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
effectText: boostedCombatStats
|
||||
? getAttributeEffectText(
|
||||
slot.slotId,
|
||||
boostedCombatStats,
|
||||
resourceLabels,
|
||||
)
|
||||
: slot.combatUseText,
|
||||
};
|
||||
});
|
||||
|
||||
if (displayRows.length === 0) {
|
||||
return <div className="text-sm text-zinc-500">{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={gridClassName}>
|
||||
{displayRows.map(
|
||||
({ slot, baseValue, boostedValue, totalBonus, effectText }) => (
|
||||
<div key={slot.slotId} className={cardClassName}>
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xl font-bold text-white sm:text-2xl">
|
||||
{formatAttributeMetricValue(boostedValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 text-left sm:shrink-0 sm:items-end sm:text-right">
|
||||
<span
|
||||
className={`max-w-full rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4 ${getAttributeBonusPillClassName(totalBonus)}`}
|
||||
>
|
||||
标签加成 {formatBuildContributionPercent(totalBonus)}
|
||||
</span>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
原始 {formatAttributeMetricValue(baseValue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||||
{effectText}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
848
src/components/CharacterPanel.tsx
Normal file
848
src/components/CharacterPanel.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionAttributeRows,
|
||||
getBuildContributionQualityLabel,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from '../data/buildDamage';
|
||||
import {
|
||||
getCharacterEquipment,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
getUnlockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
buildInitialEquipmentLoadout,
|
||||
EQUIPMENT_SLOTS,
|
||||
getEquipmentRarityLabel,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
|
||||
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CompanionArcState,
|
||||
CompanionRenderState,
|
||||
CompanionResolution,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
TimedBuildBuff,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getEquipmentSlotIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { AffinityStatusCard } from './AffinityStatusCard';
|
||||
import { BackstoryArchive } from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getContributionVisualStyle,
|
||||
getGenderLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
MultiplierContributionList,
|
||||
PlayerLevelProgress,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CharacterPanelProps {
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
playerCharacter: Character;
|
||||
playerProgression?: GameState['playerProgression'] | null;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
playerEquipment: EquipmentLoadout;
|
||||
activeBuildBuffs?: TimedBuildBuff[];
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
npcStates?: GameState['npcStates'];
|
||||
quests: QuestLogEntry[];
|
||||
onOpenCamp?: () => void;
|
||||
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
|
||||
chatSummaries?: Record<string, string>;
|
||||
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
|
||||
companionArcStates?: CompanionArcState[];
|
||||
companionResolutions?: CompanionResolution[];
|
||||
}
|
||||
|
||||
type PartyMember = {
|
||||
id: string;
|
||||
npcId: string | null;
|
||||
renderState: CompanionRenderState | null;
|
||||
character: Character;
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
isLeader: boolean;
|
||||
levelText: string | null;
|
||||
};
|
||||
|
||||
type EquipmentRow = {
|
||||
key: string;
|
||||
slotLabel: string;
|
||||
itemLabel: string;
|
||||
rarityLabel: string;
|
||||
};
|
||||
|
||||
function buildLeaderEquipmentRows(
|
||||
playerCharacter: Character,
|
||||
playerEquipment: EquipmentLoadout,
|
||||
): EquipmentRow[] {
|
||||
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
||||
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)
|
||||
: '绌轰綅',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildCompanionEquipmentRows(
|
||||
character: Character,
|
||||
keyPrefix: string,
|
||||
): EquipmentRow[] {
|
||||
return getCharacterEquipment(character).map((item) => ({
|
||||
key: `${keyPrefix}-${item.slot}-${item.item}`,
|
||||
slotLabel: item.slot,
|
||||
itemLabel: item.item,
|
||||
rarityLabel: item.rarity,
|
||||
}));
|
||||
}
|
||||
|
||||
export function CharacterPanel({
|
||||
worldType,
|
||||
customWorldProfile = null,
|
||||
playerCharacter,
|
||||
playerProgression = null,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
playerEquipment,
|
||||
activeBuildBuffs = [],
|
||||
companionRenderStates,
|
||||
npcStates = {},
|
||||
quests,
|
||||
onInspectMember,
|
||||
companionArcStates = [],
|
||||
companionResolutions = [],
|
||||
}: CharacterPanelProps) {
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const normalizedPlayerProgression =
|
||||
normalizePlayerProgressionState(playerProgression);
|
||||
const leaderLevelText = `Lv.${normalizedPlayerProgression.level}`;
|
||||
const companionReferenceLevelText = `参考 Lv.${normalizedPlayerProgression.level}`;
|
||||
|
||||
const partyMembers = useMemo<PartyMember[]>(
|
||||
() => [
|
||||
{
|
||||
id: `leader-${playerCharacter.id}`,
|
||||
npcId: null,
|
||||
renderState: null,
|
||||
character: playerCharacter,
|
||||
roleLabel: '\u961f\u957f',
|
||||
hp: playerHp,
|
||||
maxHp: playerMaxHp,
|
||||
mana: playerMana,
|
||||
maxMana: playerMaxMana,
|
||||
isLeader: true,
|
||||
levelText: leaderLevelText,
|
||||
},
|
||||
...companionRenderStates.map((companion) => ({
|
||||
id: companion.npcId,
|
||||
npcId: companion.npcId,
|
||||
renderState: companion,
|
||||
character: companion.character,
|
||||
roleLabel: '\u540c\u884c',
|
||||
hp: companion.hp,
|
||||
maxHp: companion.maxHp,
|
||||
mana: companion.mana,
|
||||
maxMana: companion.maxMana,
|
||||
isLeader: false,
|
||||
levelText: companionReferenceLevelText,
|
||||
})),
|
||||
],
|
||||
[
|
||||
companionReferenceLevelText,
|
||||
companionRenderStates,
|
||||
leaderLevelText,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedMember = useMemo(
|
||||
() => partyMembers.find((member) => member.id === selectedMemberId) ?? null,
|
||||
[partyMembers, selectedMemberId],
|
||||
);
|
||||
|
||||
const activeQuests = useMemo(
|
||||
() => 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,
|
||||
],
|
||||
);
|
||||
|
||||
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 selectedMemberArcState =
|
||||
selectedMember && !selectedMember.isLeader
|
||||
? (companionArcStates.find(
|
||||
(arcState) => arcState.characterId === selectedMember.character.id,
|
||||
) ?? null)
|
||||
: null;
|
||||
const selectedMemberResolution =
|
||||
selectedMember && !selectedMember.isLeader
|
||||
? (companionResolutions.find(
|
||||
(resolution) =>
|
||||
resolution.characterId === selectedMember.character.id,
|
||||
) ?? null)
|
||||
: 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 selectedMemberAttributeProfile = useMemo(
|
||||
() =>
|
||||
selectedMember
|
||||
? resolveCharacterAttributeProfile(
|
||||
selectedMember.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
)
|
||||
: null,
|
||||
[customWorldProfile, selectedMember, worldType],
|
||||
);
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(
|
||||
selectedContributionRow,
|
||||
selectedAttributeSchema,
|
||||
{ resourceLabels },
|
||||
)
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedContributionLabel) return;
|
||||
if (!selectedContributionRow) {
|
||||
setSelectedContributionLabel(null);
|
||||
}
|
||||
}, [selectedContributionLabel, selectedContributionRow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInspectMember || !selectedMemberId) return;
|
||||
setSelectedMemberId(null);
|
||||
}, [onInspectMember, selectedMemberId]);
|
||||
|
||||
const handleMemberInspect = (member: PartyMember) => {
|
||||
if (onInspectMember) {
|
||||
if (member.isLeader) {
|
||||
onInspectMember({ kind: 'player' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.renderState) {
|
||||
onInspectMember({ kind: 'companion', companion: member.renderState });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMemberId(member.id);
|
||||
};
|
||||
|
||||
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,
|
||||
})}
|
||||
>
|
||||
{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="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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
||||
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
||||
{partyMembers.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => handleMemberInspect(member)}
|
||||
className="w-full px-0 py-1 text-left transition-opacity hover:opacity-90"
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-white/6 bg-black/18 px-3 py-3">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent sm:h-16 sm:w-16">
|
||||
<ResolvedAssetImage
|
||||
src={member.character.portrait}
|
||||
alt={member.character.name}
|
||||
className="h-full w-full scale-125 object-contain object-bottom"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<CharacterIdentityBadges
|
||||
roleLabel={member.roleLabel}
|
||||
levelText={member.levelText}
|
||||
roleTone={member.isLeader ? 'amber' : 'sky'}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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}{' '}
|
||||
标签
|
||||
</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'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedContributionRow && selectedMember && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(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()}
|
||||
>
|
||||
<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">
|
||||
{'\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"
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="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-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-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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedMember && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={() => setSelectedMemberId(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,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()}
|
||||
>
|
||||
<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="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{selectedMember.character.title}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<CharacterIdentityBadges
|
||||
roleLabel={selectedMember.roleLabel}
|
||||
levelText={selectedMember.levelText}
|
||||
roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
|
||||
/>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
{getGenderLabel(selectedMember.character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMemberId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="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="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">
|
||||
{selectedMember.character.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
selectedMember.character.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={selectedMember.character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
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>
|
||||
</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">
|
||||
{selectedMember.isLeader && (
|
||||
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
|
||||
等级
|
||||
</div>
|
||||
<PlayerLevelProgress
|
||||
level={normalizedPlayerProgression.level}
|
||||
currentLevelXp={
|
||||
normalizedPlayerProgression.currentLevelXp
|
||||
}
|
||||
xpToNextLevel={
|
||||
normalizedPlayerProgression.xpToNextLevel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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} />
|
||||
)}
|
||||
{selectedMemberArcState && (
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
个人线阶段
|
||||
</div>
|
||||
<div className="mt-1 font-semibold text-white">
|
||||
{selectedMemberArcState.currentStage}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-sky-200/85">
|
||||
{selectedMemberArcState.arcTheme}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemberResolution && (
|
||||
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
|
||||
收束状态
|
||||
</div>
|
||||
<div className="mt-1 font-semibold text-white">
|
||||
{selectedMemberResolution.resolutionType}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-emerald-100/85">
|
||||
{selectedMemberResolution.summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemberAffinity != null && (
|
||||
<BackstoryArchive
|
||||
publicSummary={selectedMemberPublicBackstory}
|
||||
unlockedChapters={
|
||||
selectedMemberUnlockedBackstoryChapters
|
||||
}
|
||||
lockedChapters={selectedMemberLockedBackstoryChapters}
|
||||
/>
|
||||
)}
|
||||
{selectedBuildBreakdown && (
|
||||
<MultiplierContributionList
|
||||
breakdown={selectedBuildBreakdown}
|
||||
onSelectContribution={(row) =>
|
||||
setSelectedContributionLabel(row.label)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<CharacterAttributeGrid
|
||||
attributeProfile={selectedMemberAttributeProfile}
|
||||
attributeSchema={selectedAttributeSchema}
|
||||
buildBreakdown={selectedBuildBreakdown}
|
||||
resourceLabels={resourceLabels}
|
||||
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
|
||||
cardClassName="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
{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
|
||||
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>
|
||||
<CharacterSkillsList
|
||||
skills={selectedMember.character.skills}
|
||||
/>
|
||||
</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) => (
|
||||
<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"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{item.slotLabel}
|
||||
</div>
|
||||
<div>{item.itemLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
||||
{item.rarityLabel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
305
src/components/CompanionCampModal.tsx
Normal file
305
src/components/CompanionCampModal.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import { MAX_COMPANIONS } from '../data/npcInteractions';
|
||||
import { Character, CompanionState } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CompanionCampModalProps {
|
||||
isOpen: boolean;
|
||||
playerCharacter: Character | null;
|
||||
companions: CompanionState[];
|
||||
roster: CompanionState[];
|
||||
inBattle: boolean;
|
||||
onClose: () => void;
|
||||
onBenchCompanion: (npcId: string) => void;
|
||||
onActivateCompanion: (npcId: string, swapNpcId?: string | null) => void;
|
||||
}
|
||||
|
||||
type CompanionCardData = {
|
||||
companion: CompanionState;
|
||||
character: Character;
|
||||
};
|
||||
|
||||
function StatusPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
{label} {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCampMoments(
|
||||
playerCharacter: Character | null,
|
||||
activeCompanions: CompanionCardData[],
|
||||
reserveCompanions: CompanionCardData[],
|
||||
) {
|
||||
if (!playerCharacter) {
|
||||
return ['营地尚未准备完毕。'];
|
||||
}
|
||||
|
||||
const moments: string[] = [];
|
||||
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
|
||||
moments.push(`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`);
|
||||
}
|
||||
|
||||
if (activeCompanions.length >= 2) {
|
||||
const firstCompanion = activeCompanions[0];
|
||||
const secondCompanion = activeCompanions[1];
|
||||
if (firstCompanion && secondCompanion) {
|
||||
moments.push(`${firstCompanion.character.name}和${secondCompanion.character.name}正低声商量下一段路怎么走。`);
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
|
||||
if (trustedCompanion) {
|
||||
moments.push(`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`);
|
||||
}
|
||||
|
||||
if (reserveCompanions.length > 0) {
|
||||
const reserveCompanion = reserveCompanions[0];
|
||||
if (reserveCompanion) {
|
||||
moments.push(`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`);
|
||||
}
|
||||
}
|
||||
|
||||
if (moments.length === 0) {
|
||||
moments.push(`${playerCharacter.name}环视营地,确认众人都已经各就各位。`);
|
||||
}
|
||||
|
||||
return moments.slice(0, 3);
|
||||
}
|
||||
|
||||
export function CompanionCampModal({
|
||||
isOpen,
|
||||
playerCharacter,
|
||||
companions,
|
||||
roster,
|
||||
inBattle,
|
||||
onClose,
|
||||
onBenchCompanion,
|
||||
onActivateCompanion,
|
||||
}: CompanionCampModalProps) {
|
||||
const [selectedSwapNpcId, setSelectedSwapNpcId] = useState<string | null>(null);
|
||||
|
||||
const activeCompanionCards = useMemo<CompanionCardData[]>(
|
||||
() => companions
|
||||
.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
return character ? { companion, character } : null;
|
||||
})
|
||||
.filter(Boolean) as CompanionCardData[],
|
||||
[companions],
|
||||
);
|
||||
|
||||
const reserveCompanionCards = useMemo<CompanionCardData[]>(
|
||||
() => roster
|
||||
.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
return character ? { companion, character } : null;
|
||||
})
|
||||
.filter(Boolean) as CompanionCardData[],
|
||||
[roster],
|
||||
);
|
||||
|
||||
const campMoments = useMemo(
|
||||
() => buildCampMoments(playerCharacter, activeCompanionCards, reserveCompanionCards),
|
||||
[activeCompanionCards, playerCharacter, reserveCompanionCards],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (companions.length >= MAX_COMPANIONS) {
|
||||
setSelectedSwapNpcId(companions[0]?.npcId ?? null);
|
||||
return;
|
||||
}
|
||||
setSelectedSwapNpcId(null);
|
||||
}, [companions, isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-sm font-semibold text-white">营地编组</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">当前队伍</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
可直接把同行者转入后备,或先选定替换位,再让后备成员归队。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
</div>
|
||||
{inBattle && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
战斗中无法调整编组。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
|
||||
const selectedForSwap = selectedSwapNpcId === companion.npcId;
|
||||
return (
|
||||
<div
|
||||
key={companion.npcId}
|
||||
className={`rounded-xl border px-3 py-3 ${selectedForSwap ? 'border-sky-400/40 bg-sky-500/10' : 'border-white/8 bg-black/20'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
className="h-full w-full scale-125 object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={inBattle}
|
||||
onClick={() => setSelectedSwapNpcId(companion.npcId)}
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
设为替换位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={inBattle}
|
||||
onClick={() => onBenchCompanion(companion.npcId)}
|
||||
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
转入后备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
当前没有已出战的同行者。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">后备队伍</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
后备同行者会在营地待命,随时可以重新召回。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="后备" value={`${reserveCompanionCards.length}`} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
|
||||
const needsSwap = companions.length >= MAX_COMPANIONS;
|
||||
return (
|
||||
<div key={companion.npcId} className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
className="h-full w-full scale-125 object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
|
||||
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
|
||||
className={`mt-3 w-full rounded-lg border px-3 py-2 text-xs ${
|
||||
inBattle || (needsSwap && !selectedSwapNpcId)
|
||||
? 'border-white/6 bg-black/20 text-zinc-500'
|
||||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
}`}
|
||||
>
|
||||
{needsSwap ? '换入队伍' : '编入队伍'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
当前还没有后备同行者。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{campMoments.map((moment, index) => (
|
||||
<div
|
||||
key={`camp-moment-${index}-${moment}`}
|
||||
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{moment}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
78
src/components/CustomWorldCoverArtwork.tsx
Normal file
78
src/components/CustomWorldCoverArtwork.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
|
||||
const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
'h-[54%] w-[24%] translate-y-[8%]',
|
||||
'h-[68%] w-[30%]',
|
||||
'h-[56%] w-[24%] translate-y-[10%]',
|
||||
] as const;
|
||||
|
||||
type CustomWorldCoverArtworkProps = {
|
||||
imageSrc?: string | null;
|
||||
title: string;
|
||||
fallbackLabel: string;
|
||||
renderMode?: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs?: string[];
|
||||
className?: string;
|
||||
overlay?: ReactNode;
|
||||
};
|
||||
|
||||
export function CustomWorldCoverArtwork({
|
||||
imageSrc,
|
||||
title,
|
||||
fallbackLabel,
|
||||
renderMode = 'image',
|
||||
characterImageSrcs = [],
|
||||
className = '',
|
||||
overlay,
|
||||
}: CustomWorldCoverArtworkProps) {
|
||||
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.04),rgba(8,10,14,0.26)_46%,rgba(8,10,14,0.82)_100%)]" />
|
||||
{!imageSrc ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-300">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? (
|
||||
<>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[42%] bg-[linear-gradient(180deg,rgba(8,10,14,0)_0%,rgba(8,10,14,0.88)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center gap-2 px-3 pb-2 sm:pb-3">
|
||||
{coverCharacterImageSrcs.map((characterImageSrc, index) => (
|
||||
<div
|
||||
key={`${title}-cover-character-${index}-${characterImageSrc}`}
|
||||
className={`overflow-hidden rounded-[1rem] border border-white/16 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04))] shadow-[0_12px_28px_rgba(0,0,0,0.4)] ${COVER_PORTRAIT_CLASS_NAMES[index] ?? COVER_PORTRAIT_CLASS_NAMES[1]}`}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={characterImageSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{overlay ? (
|
||||
<div className="pointer-events-none absolute inset-0">{overlay}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomWorldCoverArtwork;
|
||||
1273
src/components/CustomWorldEntityCatalog.tsx
Normal file
1273
src/components/CustomWorldEntityCatalog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1280
src/components/CustomWorldEntityEditorModal.test.tsx
Normal file
1280
src/components/CustomWorldEntityEditorModal.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
295
src/components/CustomWorldGenerationView.tsx
Normal file
295
src/components/CustomWorldGenerationView.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
settingText: string;
|
||||
anchorEntries?: CustomWorldStructuredAnchorEntry[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
onInterrupt?: () => void;
|
||||
backLabel?: string;
|
||||
settingActionLabel?: string | null;
|
||||
retryLabel?: string;
|
||||
interruptLabel?: string;
|
||||
settingTitle?: string;
|
||||
settingDescription?: string | null;
|
||||
progressTitle?: string;
|
||||
activeBadgeLabel?: string;
|
||||
pausedBadgeLabel?: string;
|
||||
idleBadgeLabel?: string;
|
||||
structuredEmptyText?: string;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const safeMs = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.ceil(safeMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) {
|
||||
return `${Math.max(1, seconds)} 秒`;
|
||||
}
|
||||
|
||||
if (seconds === 0) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
return `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
||||
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
||||
}
|
||||
|
||||
function buildFallbackRenderKey(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const normalizedValue = value?.trim();
|
||||
return normalizedValue ? normalizedValue : fallback;
|
||||
}
|
||||
|
||||
export function CustomWorldGenerationView({
|
||||
settingText,
|
||||
anchorEntries = [],
|
||||
progress,
|
||||
isGenerating,
|
||||
error,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
onInterrupt,
|
||||
backLabel = '返回',
|
||||
settingActionLabel = '修改设定',
|
||||
retryLabel = '重新开始生成',
|
||||
interruptLabel = '中断世界生成',
|
||||
settingTitle = '玩家设定',
|
||||
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
|
||||
progressTitle = '生成进度',
|
||||
activeBadgeLabel = '世界建设中',
|
||||
pausedBadgeLabel = '生成已暂停',
|
||||
idleBadgeLabel = '等待操作',
|
||||
structuredEmptyText = '正在整理当前设定结构,请稍后。',
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
const steps = progress?.steps ?? [];
|
||||
const hasStructuredAnchors = anchorEntries.length > 0;
|
||||
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
|
||||
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
|
||||
const normalizedSettingDescription = settingDescription?.trim() ?? '';
|
||||
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
|
||||
const hasSettingDescription = normalizedSettingDescription.length > 0;
|
||||
const estimatedWaitText =
|
||||
progress?.estimatedRemainingMs != null
|
||||
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
|
||||
: '正在校准预计等待时间';
|
||||
const elapsedText =
|
||||
progress != null
|
||||
? `已耗时 ${formatDuration(progress.elapsedMs)}`
|
||||
: '正在启动世界生成';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="platform-sticky-fade sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 px-3 pb-3 pt-1 backdrop-blur-sm sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0 sm:backdrop-blur-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
<div className="platform-pill platform-pill--cool px-3 py-1 text-[10px] tracking-[0.2em]">
|
||||
{isGenerating
|
||||
? activeBadgeLabel
|
||||
: error
|
||||
? pausedBadgeLabel
|
||||
: idleBadgeLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.65fr)] xl:items-stretch">
|
||||
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1 xl:px-5 xl:py-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:gap-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
{progressTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem] xl:text-[2.4rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 sm:text-right">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black text-[var(--platform-cool-text)] sm:text-4xl">
|
||||
{progressValue}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full xl:mt-5 xl:h-5">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3 xl:gap-3">
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{progress?.batchLabel ?? '准备中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
预计等待
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
计时
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:gap-2 xl:space-y-0 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-300">
|
||||
{step.completed}/{step.total}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
{hasSettingActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{normalizedSettingActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="platform-button platform-button--primary w-full sm:w-auto"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</>
|
||||
) : onInterrupt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
>
|
||||
{interruptLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5 xl:flex xl:min-h-0 xl:flex-col xl:px-5 xl:py-4">
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-start xl:gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
|
||||
{settingTitle}
|
||||
</div>
|
||||
{hasSettingDescription ? (
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{normalizedSettingDescription}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{hasSettingActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
{normalizedSettingActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{hasStructuredAnchors ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-1 xl:overflow-y-auto xl:pr-1">
|
||||
{anchorEntries.map((entry, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(
|
||||
entry.id,
|
||||
`anchor-entry-${index}`,
|
||||
)}
|
||||
className="platform-subpanel rounded-2xl px-4 py-4 xl:py-3"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
|
||||
{entry.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto xl:max-h-none xl:min-h-0 xl:flex-1">
|
||||
{settingText || structuredEmptyText}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
831
src/components/CustomWorldNpcVisualEditor.tsx
Normal file
831
src/components/CustomWorldNpcVisualEditor.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { resolveCustomWorldNpcMonsterPreset } from '../data/customWorldNpcMonsters';
|
||||
import {
|
||||
buildBodyPath,
|
||||
buildMedievalNpcVisual,
|
||||
buildMedievalNpcVisualOverrideFromCustomWorldVisual,
|
||||
buildRaceAssetPath,
|
||||
getMedievalAtlasAsset,
|
||||
getMedievalAtlasOptions,
|
||||
getMedievalHeadOptions,
|
||||
getMedievalPoseOptions,
|
||||
getRaceSpriteCounts,
|
||||
MEDIEVAL_BODY_COLOR_LABELS,
|
||||
MEDIEVAL_BODY_COLORS,
|
||||
MEDIEVAL_FACIAL_HAIR_COLOR_LABELS,
|
||||
MEDIEVAL_FACIAL_HAIR_STYLE_LABELS,
|
||||
MEDIEVAL_HAIR_COLOR_LABELS,
|
||||
MEDIEVAL_HAIR_STYLE_LABELS,
|
||||
MEDIEVAL_RACE_LABELS,
|
||||
type MedievalAtlasSourceType,
|
||||
type MedievalAtlasUsage,
|
||||
type MedievalRace,
|
||||
sanitizeCustomWorldNpcVisual,
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import {
|
||||
type CustomWorldNpc,
|
||||
type CustomWorldNpcVisual,
|
||||
type CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
type EditableNpcSource = Pick<
|
||||
CustomWorldNpc,
|
||||
'id' | 'name' | 'role' | 'description' | 'imageSrc'
|
||||
>
|
||||
& Partial<
|
||||
Pick<
|
||||
CustomWorldNpc,
|
||||
| 'title'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'initialAffinity'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
type GearSlot = 'headgear' | 'mainHand' | 'offHand';
|
||||
|
||||
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc' as const,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.name.slice(0, 1) || '角',
|
||||
context: npc.role,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewSpec(npc: EditableNpcSource, visual?: CustomWorldNpcVisual) {
|
||||
const encounter = buildCustomWorldNpcEncounter(npc);
|
||||
const baseSpec = buildMedievalNpcVisual(encounter);
|
||||
|
||||
if (!visual) {
|
||||
return baseSpec;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseSpec,
|
||||
...buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual),
|
||||
};
|
||||
}
|
||||
|
||||
function getGearUsage(slot: GearSlot): MedievalAtlasUsage {
|
||||
if (slot === 'headgear') return 'headgear';
|
||||
if (slot === 'mainHand') return 'mainHand';
|
||||
return 'offHand';
|
||||
}
|
||||
|
||||
function getDefaultFileForType(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) {
|
||||
const assets = getMedievalAtlasOptions(type);
|
||||
if (usage === 'offHand' && type === 'melee') {
|
||||
return assets.find(asset => asset.file === 'shield.png')?.file ?? assets[0]?.file ?? '';
|
||||
}
|
||||
return assets[0]?.file ?? '';
|
||||
}
|
||||
|
||||
function getDefaultFrameForSelection(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage) {
|
||||
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
function buildDefaultGear(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) {
|
||||
const file = getDefaultFileForType(type, usage);
|
||||
if (!file) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
file,
|
||||
frameIndex: getDefaultFrameForSelection(type, file, usage),
|
||||
};
|
||||
}
|
||||
|
||||
function getGearSummary(visual: CustomWorldNpcVisual) {
|
||||
return [
|
||||
visual.headgear ? getMedievalAtlasAsset(visual.headgear.type, visual.headgear.file)?.label ?? '头饰' : '无头饰',
|
||||
visual.mainHand ? getMedievalAtlasAsset(visual.mainHand.type, visual.mainHand.file)?.label ?? '主手' : '无主手',
|
||||
visual.offHand ? getMedievalAtlasAsset(visual.offHand.type, visual.offHand.file)?.label ?? '副手' : '无副手',
|
||||
].join(' / ');
|
||||
}
|
||||
|
||||
function PreviewFrame({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_52%),linear-gradient(180deg,rgba(19,24,39,0.94),rgba(8,10,17,0.92))] ${className}`}>
|
||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:10px_10px]" />
|
||||
<div className="relative z-[1] flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpriteFramePreview({
|
||||
src,
|
||||
frameIndex = 0,
|
||||
tileSize = 32,
|
||||
scale = 1,
|
||||
}: {
|
||||
src: string;
|
||||
frameIndex?: number;
|
||||
tileSize?: number;
|
||||
scale?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
backgroundImage: `url("${encodeURI(src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${frameIndex * tileSize}px 0px`,
|
||||
imageRendering: 'pixelated',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasFramePreview({
|
||||
type,
|
||||
file,
|
||||
frameIndex,
|
||||
}: {
|
||||
type: MedievalAtlasSourceType;
|
||||
file: string;
|
||||
frameIndex: number;
|
||||
}) {
|
||||
const asset = getMedievalAtlasAsset(type, file);
|
||||
|
||||
if (!asset) {
|
||||
return <div className="text-[10px] font-semibold text-zinc-500">无</div>;
|
||||
}
|
||||
|
||||
const col = frameIndex % asset.columns;
|
||||
const row = Math.floor(frameIndex / asset.columns);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${asset.tileWidth}px`,
|
||||
height: `${asset.tileHeight}px`,
|
||||
backgroundImage: `url("${encodeURI(asset.src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${col * asset.tileWidth}px -${row * asset.tileHeight}px`,
|
||||
backgroundSize: 'auto',
|
||||
imageRendering: 'pixelated',
|
||||
transform: asset.tileWidth > 32 || asset.tileHeight > 32 ? 'scale(0.75)' : undefined,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPreview({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="text-[10px] font-semibold tracking-[0.08em] text-zinc-500">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortraitOptionPreview({
|
||||
npc,
|
||||
visual,
|
||||
}: {
|
||||
npc: EditableNpcSource;
|
||||
visual: CustomWorldNpcVisual;
|
||||
}) {
|
||||
return (
|
||||
<PreviewFrame className="h-14 w-14">
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildPreviewSpec(npc, visual)}
|
||||
scale={1.1}
|
||||
className="origin-center"
|
||||
/>
|
||||
</PreviewFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionCard({
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
preview,
|
||||
}: {
|
||||
key?: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
preview: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-sky-300/45 bg-sky-500/12 text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:border-white/20 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{preview}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold leading-5">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionSection({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-xs leading-5 text-zinc-500">{subtitle}</div> : null}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
tone = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'sky';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldNpcPortrait({
|
||||
npc,
|
||||
profile,
|
||||
visual,
|
||||
className = '',
|
||||
contentClassName = 'min-h-[7rem] p-3',
|
||||
scale = 2.05,
|
||||
preferImageSrc = false,
|
||||
}: {
|
||||
npc: EditableNpcSource;
|
||||
profile?: CustomWorldProfile | null;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
scale?: number;
|
||||
preferImageSrc?: boolean;
|
||||
}) {
|
||||
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
|
||||
const monsterPreset = visual
|
||||
? null
|
||||
: resolveCustomWorldNpcMonsterPreset(npc, undefined, profile ?? null);
|
||||
const preferredImageSrc =
|
||||
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
|
||||
|
||||
return (
|
||||
<div className={`platform-npc-portrait relative overflow-hidden rounded-2xl ${className}`}>
|
||||
<div className="platform-npc-portrait__grid absolute inset-0" />
|
||||
<div
|
||||
className={`relative flex h-full items-center justify-center ${contentClassName}`}
|
||||
>
|
||||
{preferredImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={preferredImageSrc}
|
||||
alt={npc.name}
|
||||
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
) : monsterPreset ? (
|
||||
<div
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
style={{
|
||||
transform: `scale(${Math.max(1, scale * 0.72)})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<HostileNpcAnimator hostileNpc={monsterPreset} />
|
||||
</div>
|
||||
) : (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={previewSpec}
|
||||
scale={scale}
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldNpcVisualEditor({
|
||||
npc,
|
||||
profile,
|
||||
value,
|
||||
onChange,
|
||||
onAiGenerate,
|
||||
}: {
|
||||
npc: EditableNpcSource;
|
||||
profile?: CustomWorldProfile | null;
|
||||
value?: CustomWorldNpcVisual;
|
||||
onChange: (value: CustomWorldNpcVisual) => void;
|
||||
onAiGenerate: () => void;
|
||||
}) {
|
||||
const effectiveVisual = sanitizeCustomWorldNpcVisual(value ?? buildDefaultCustomWorldNpcVisual(npc));
|
||||
const spriteCounts = getRaceSpriteCounts(effectiveVisual.race);
|
||||
const headOptions = getMedievalHeadOptions(effectiveVisual.race);
|
||||
const headgearAssets = effectiveVisual.headgear ? getMedievalAtlasOptions(effectiveVisual.headgear.type) : [];
|
||||
const mainHandAssets = effectiveVisual.mainHand ? getMedievalAtlasOptions(effectiveVisual.mainHand.type) : [];
|
||||
const offHandAssets = effectiveVisual.offHand ? getMedievalAtlasOptions(effectiveVisual.offHand.type) : [];
|
||||
const headgearPoseOptions = effectiveVisual.headgear ? getMedievalPoseOptions(effectiveVisual.headgear.type, effectiveVisual.headgear.file, 'headgear') : [];
|
||||
const mainHandPoseOptions = effectiveVisual.mainHand ? getMedievalPoseOptions(effectiveVisual.mainHand.type, effectiveVisual.mainHand.file, 'mainHand') : [];
|
||||
const offHandPoseOptions = effectiveVisual.offHand ? getMedievalPoseOptions(effectiveVisual.offHand.type, effectiveVisual.offHand.file, 'offHand') : [];
|
||||
|
||||
const updateVisual = (nextVisual: CustomWorldNpcVisual) => {
|
||||
onChange(sanitizeCustomWorldNpcVisual(nextVisual));
|
||||
};
|
||||
|
||||
const buildPatchedVisual = (patch: Partial<CustomWorldNpcVisual>) => (
|
||||
sanitizeCustomWorldNpcVisual({
|
||||
...effectiveVisual,
|
||||
...patch,
|
||||
})
|
||||
);
|
||||
|
||||
const updateGearType = (slot: GearSlot, nextType: MedievalAtlasSourceType | 'none') => {
|
||||
if (nextType === 'none') {
|
||||
updateVisual({
|
||||
...effectiveVisual,
|
||||
[slot]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateVisual({
|
||||
...effectiveVisual,
|
||||
[slot]: buildDefaultGear(nextType, getGearUsage(slot)),
|
||||
});
|
||||
};
|
||||
|
||||
const updateGearFile = (slot: GearSlot, nextFile: string) => {
|
||||
const currentGear = effectiveVisual[slot];
|
||||
if (!currentGear) return;
|
||||
|
||||
updateVisual({
|
||||
...effectiveVisual,
|
||||
[slot]: {
|
||||
...currentGear,
|
||||
file: nextFile,
|
||||
frameIndex: getDefaultFrameForSelection(currentGear.type, nextFile, getGearUsage(slot)),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateGearFrame = (slot: GearSlot, nextFrameIndex: number) => {
|
||||
const currentGear = effectiveVisual[slot];
|
||||
if (!currentGear) return;
|
||||
|
||||
updateVisual({
|
||||
...effectiveVisual,
|
||||
[slot]: {
|
||||
...currentGear,
|
||||
frameIndex: nextFrameIndex,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<div className="self-start lg:sticky lg:top-0">
|
||||
<div className="mx-auto w-full max-w-[9.5rem] space-y-3">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
visual={effectiveVisual}
|
||||
className="aspect-square"
|
||||
scale={2.05}
|
||||
/>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-3 py-3 text-center text-xs leading-5 text-zinc-300">
|
||||
{getGearSummary(effectiveVisual)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ActionButton label="恢复默认组合" onClick={() => onChange(buildDefaultCustomWorldNpcVisual(npc))} />
|
||||
<ActionButton label="智能生成" onClick={onAiGenerate} tone="sky" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<OptionSection title="种族" subtitle="切换基础种族,并预览对应的整体轮廓。">
|
||||
{(Object.entries(MEDIEVAL_RACE_LABELS) as Array<[MedievalRace, string]>).map(([race, label]) => {
|
||||
const previewVisual = buildPatchedVisual({ race });
|
||||
return (
|
||||
<OptionCard
|
||||
key={`race-${race}`}
|
||||
label={label}
|
||||
selected={effectiveVisual.race === race}
|
||||
onClick={() => updateVisual(previewVisual)}
|
||||
preview={<PortraitOptionPreview npc={npc} visual={previewVisual} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="服装颜色" subtitle="预览身体部位素材。">
|
||||
{MEDIEVAL_BODY_COLORS.map(color => (
|
||||
<OptionCard
|
||||
key={`body-${color}`}
|
||||
label={MEDIEVAL_BODY_COLOR_LABELS[color] ?? color}
|
||||
selected={effectiveVisual.bodyColor === color}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ bodyColor: color }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<SpriteFramePreview src={buildBodyPath(color)} frameIndex={0} />
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="肤色" subtitle="预览头部部位素材。">
|
||||
{headOptions.map(option => (
|
||||
<OptionCard
|
||||
key={`head-${option.value}`}
|
||||
label={option.label}
|
||||
selected={effectiveVisual.headIndex === option.value}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ headIndex: option.value }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<SpriteFramePreview src={buildRaceAssetPath(effectiveVisual.race, 'head', option.value)} frameIndex={0} />
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="发型" subtitle="文字和发型部位预览同步显示。">
|
||||
{MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => (
|
||||
<OptionCard
|
||||
key={`hair-style-${index}`}
|
||||
label={label}
|
||||
selected={effectiveVisual.hairStyleFrame === index}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ hairStyleFrame: index }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<SpriteFramePreview
|
||||
src={buildRaceAssetPath(effectiveVisual.race, 'hair', effectiveVisual.hairColorIndex)}
|
||||
frameIndex={index}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="发色" subtitle="基于当前发型预览不同发色。">
|
||||
{Array.from({ length: spriteCounts.hair }, (_, index) => {
|
||||
const value = index + 1;
|
||||
return (
|
||||
<OptionCard
|
||||
key={`hair-color-${value}`}
|
||||
label={MEDIEVAL_HAIR_COLOR_LABELS[index] ?? `发色 ${value}`}
|
||||
selected={effectiveVisual.hairColorIndex === value}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ hairColorIndex: value }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<SpriteFramePreview
|
||||
src={buildRaceAssetPath(effectiveVisual.race, 'hair', value)}
|
||||
frameIndex={effectiveVisual.hairStyleFrame}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="胡须样式" subtitle="可直接切换为不显示,也可预览每种胡须部位。">
|
||||
<OptionCard
|
||||
label="不显示"
|
||||
selected={!effectiveVisual.facialHairEnabled}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ facialHairEnabled: false, facialHairStyleFrame: 0 }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<EmptyPreview label="无" />
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
{MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.map((label, index) => (
|
||||
<OptionCard
|
||||
key={`facial-style-${index}`}
|
||||
label={label}
|
||||
selected={effectiveVisual.facialHairEnabled && effectiveVisual.facialHairStyleFrame === index}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ facialHairEnabled: true, facialHairStyleFrame: index }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<SpriteFramePreview
|
||||
src={buildRaceAssetPath(effectiveVisual.race, 'facialHair', effectiveVisual.facialHairColorIndex)}
|
||||
frameIndex={index}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
{effectiveVisual.facialHairEnabled ? (
|
||||
<OptionSection title="胡须颜色" subtitle="预览当前胡须样式下的颜色变化。">
|
||||
{Array.from({ length: spriteCounts.facialHair }, (_, index) => {
|
||||
const value = index + 1;
|
||||
return (
|
||||
<OptionCard
|
||||
key={`facial-color-${value}`}
|
||||
label={MEDIEVAL_FACIAL_HAIR_COLOR_LABELS[index] ?? `胡须颜色 ${value}`}
|
||||
selected={effectiveVisual.facialHairColorIndex === value}
|
||||
onClick={() => updateVisual(buildPatchedVisual({ facialHairColorIndex: value }))}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<SpriteFramePreview
|
||||
src={buildRaceAssetPath(effectiveVisual.race, 'facialHair', value)}
|
||||
frameIndex={effectiveVisual.facialHairStyleFrame}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OptionSection>
|
||||
) : null}
|
||||
|
||||
<OptionSection title="头饰类型" subtitle="先选装备类型,再挑具体素材和姿态。">
|
||||
<OptionCard
|
||||
label="不装备"
|
||||
selected={!effectiveVisual.headgear}
|
||||
onClick={() => updateGearType('headgear', 'none')}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<EmptyPreview label="无" />
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
{([
|
||||
['cloth', '布帽'],
|
||||
['leather', '皮具'],
|
||||
['metal', '金属头盔'],
|
||||
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
|
||||
const gear = buildDefaultGear(type, 'headgear');
|
||||
return (
|
||||
<OptionCard
|
||||
key={`headgear-type-${type}`}
|
||||
label={label}
|
||||
selected={effectiveVisual.headgear?.type === type}
|
||||
onClick={() => updateGearType('headgear', type)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OptionSection>
|
||||
|
||||
{effectiveVisual.headgear ? (
|
||||
<>
|
||||
<OptionSection title="头饰素材" subtitle="素材卡片同时展示名称和头饰部位预览。">
|
||||
{headgearAssets.map(asset => (
|
||||
<OptionCard
|
||||
key={`headgear-file-${asset.file}`}
|
||||
label={asset.label}
|
||||
selected={effectiveVisual.headgear?.file === asset.file}
|
||||
onClick={() => updateGearFile('headgear', asset.file)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<AtlasFramePreview
|
||||
type={effectiveVisual.headgear!.type}
|
||||
file={asset.file}
|
||||
frameIndex={getDefaultFrameForSelection(effectiveVisual.headgear!.type, asset.file, 'headgear')}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="头饰姿态" subtitle="预览当前头饰素材在不同姿态下的部位变化。">
|
||||
{headgearPoseOptions.map(option => (
|
||||
<OptionCard
|
||||
key={`headgear-pose-${option.value}`}
|
||||
label={option.label}
|
||||
selected={effectiveVisual.headgear?.frameIndex === option.value}
|
||||
onClick={() => updateGearFrame('headgear', option.value)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<AtlasFramePreview
|
||||
type={effectiveVisual.headgear!.type}
|
||||
file={effectiveVisual.headgear!.file}
|
||||
frameIndex={option.value}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<OptionSection title="主手类型" subtitle="预览不同主手武器类型。">
|
||||
<OptionCard
|
||||
label="不装备"
|
||||
selected={!effectiveVisual.mainHand}
|
||||
onClick={() => updateGearType('mainHand', 'none')}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<EmptyPreview label="无" />
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
{([
|
||||
['melee', '近战武器'],
|
||||
['magic', '法器'],
|
||||
['ranged', '远程武器'],
|
||||
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
|
||||
const gear = buildDefaultGear(type, 'mainHand');
|
||||
return (
|
||||
<OptionCard
|
||||
key={`main-hand-type-${type}`}
|
||||
label={label}
|
||||
selected={effectiveVisual.mainHand?.type === type}
|
||||
onClick={() => updateGearType('mainHand', type)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OptionSection>
|
||||
|
||||
{effectiveVisual.mainHand ? (
|
||||
<>
|
||||
<OptionSection title="主手素材" subtitle="用当前武器姿态预览每个素材。">
|
||||
{mainHandAssets.map(asset => (
|
||||
<OptionCard
|
||||
key={`main-hand-file-${asset.file}`}
|
||||
label={asset.label}
|
||||
selected={effectiveVisual.mainHand?.file === asset.file}
|
||||
onClick={() => updateGearFile('mainHand', asset.file)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<AtlasFramePreview
|
||||
type={effectiveVisual.mainHand!.type}
|
||||
file={asset.file}
|
||||
frameIndex={getDefaultFrameForSelection(effectiveVisual.mainHand!.type, asset.file, 'mainHand')}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="主手姿态" subtitle="预览当前主手素材在不同姿态下的部位。">
|
||||
{mainHandPoseOptions.map(option => (
|
||||
<OptionCard
|
||||
key={`main-hand-pose-${option.value}`}
|
||||
label={option.label}
|
||||
selected={effectiveVisual.mainHand?.frameIndex === option.value}
|
||||
onClick={() => updateGearFrame('mainHand', option.value)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<AtlasFramePreview
|
||||
type={effectiveVisual.mainHand!.type}
|
||||
file={effectiveVisual.mainHand!.file}
|
||||
frameIndex={option.value}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<OptionSection title="副手类型" subtitle="可选择不装备,或为副手配置盾牌 / 近战部件。">
|
||||
<OptionCard
|
||||
label="不装备"
|
||||
selected={!effectiveVisual.offHand}
|
||||
onClick={() => updateGearType('offHand', 'none')}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<EmptyPreview label="无" />
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
{(() => {
|
||||
const gear = buildDefaultGear('melee', 'offHand');
|
||||
return (
|
||||
<OptionCard
|
||||
label="盾牌 / 近战副手"
|
||||
selected={effectiveVisual.offHand?.type === 'melee'}
|
||||
onClick={() => updateGearType('offHand', 'melee')}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</OptionSection>
|
||||
|
||||
{effectiveVisual.offHand ? (
|
||||
<>
|
||||
<OptionSection title="副手素材" subtitle="素材卡片展示副手部件预览。">
|
||||
{offHandAssets.map(asset => (
|
||||
<OptionCard
|
||||
key={`off-hand-file-${asset.file}`}
|
||||
label={asset.label}
|
||||
selected={effectiveVisual.offHand?.file === asset.file}
|
||||
onClick={() => updateGearFile('offHand', asset.file)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<AtlasFramePreview
|
||||
type={effectiveVisual.offHand!.type}
|
||||
file={asset.file}
|
||||
frameIndex={getDefaultFrameForSelection(effectiveVisual.offHand!.type, asset.file, 'offHand')}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
|
||||
<OptionSection title="副手姿态" subtitle="预览当前副手素材在不同姿态下的部位。">
|
||||
{offHandPoseOptions.map(option => (
|
||||
<OptionCard
|
||||
key={`off-hand-pose-${option.value}`}
|
||||
label={option.label}
|
||||
selected={effectiveVisual.offHand?.frameIndex === option.value}
|
||||
onClick={() => updateGearFrame('offHand', option.value)}
|
||||
preview={(
|
||||
<PreviewFrame>
|
||||
<AtlasFramePreview
|
||||
type={effectiveVisual.offHand!.type}
|
||||
file={effectiveVisual.offHand!.file}
|
||||
frameIndex={option.value}
|
||||
/>
|
||||
</PreviewFrame>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</OptionSection>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
584
src/components/CustomWorldResultView.test.tsx
Normal file
584
src/components/CustomWorldResultView.test.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
|
||||
|
||||
vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
||||
const generatePlayableNpc = vi.fn();
|
||||
const generateStoryNpc = vi.fn();
|
||||
const generateLandmark = vi.fn();
|
||||
const generateSceneImage = vi.fn();
|
||||
const generateSceneNpc = vi.fn();
|
||||
|
||||
return {
|
||||
rpgCreationAssetClient: {
|
||||
generatePlayableNpc,
|
||||
generateStoryNpc,
|
||||
generateLandmark,
|
||||
generateSceneImage,
|
||||
generateSceneNpc,
|
||||
},
|
||||
generateCustomWorldPlayableNpc: generatePlayableNpc,
|
||||
generateCustomWorldStoryNpc: generateStoryNpc,
|
||||
generateCustomWorldLandmark: generateLandmark,
|
||||
generateCustomWorldSceneImage: generateSceneImage,
|
||||
generateCustomWorldSceneNpc: generateSceneNpc,
|
||||
};
|
||||
});
|
||||
|
||||
const mockedRpgCreationAssetClient = vi.mocked(
|
||||
rpgCreationAssetClient.rpgCreationAssetClient,
|
||||
);
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
|
||||
resolvedUrl: source?.trim() ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({
|
||||
RpgCreationEntityEditorModal: () => null,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 6,
|
||||
teaser: '表层来意',
|
||||
content: '表层来意内容',
|
||||
contextSnippet: '表层来意摘要',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 12,
|
||||
teaser: '旧事裂痕',
|
||||
content: '旧事裂痕内容',
|
||||
contextSnippet: '旧事裂痕摘要',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 18,
|
||||
teaser: '隐藏执念',
|
||||
content: '隐藏执念内容',
|
||||
contextSnippet: '隐藏执念摘要',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: 24,
|
||||
teaser: '最终底牌',
|
||||
content: '最终底牌内容',
|
||||
contextSnippet: '最终底牌摘要',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '同行者',
|
||||
role: '协作战力',
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景`,
|
||||
personality: `${name}的性格`,
|
||||
motivation: `${name}的动机`,
|
||||
combatStyle: `${name}的战斗风格`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['关系钩子'],
|
||||
relations: [],
|
||||
tags: ['测试'],
|
||||
backstoryReveal: createBackstoryReveal(),
|
||||
skills: [
|
||||
{
|
||||
id: `${id}-skill-1`,
|
||||
name: '技能一',
|
||||
summary: '技能说明一',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: `${id}-skill-2`,
|
||||
name: '技能二',
|
||||
summary: '技能说明二',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: `${id}-skill-3`,
|
||||
name: '技能三',
|
||||
summary: '技能说明三',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: `${id}-item-1`,
|
||||
name: '物品一',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明一',
|
||||
tags: ['测试'],
|
||||
},
|
||||
{
|
||||
id: `${id}-item-2`,
|
||||
name: '物品二',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明二',
|
||||
tags: ['测试'],
|
||||
},
|
||||
{
|
||||
id: `${id}-item-3`,
|
||||
name: '物品三',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '物品说明三',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const baseProfile = {
|
||||
id: 'world-1',
|
||||
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
playerGoal: '找到能让群岛重新稳定的关键节点。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守潮盟', '沉钟会'],
|
||||
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
|
||||
attributeSchema: {},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [
|
||||
{
|
||||
...createPlayableRole('story-1', '顾潮音'),
|
||||
initialAffinity: 6,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
},
|
||||
anchorContent: {
|
||||
worldPromise:
|
||||
'被海雾反复改写航路的群岛世界,旧灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。',
|
||||
playerFantasy:
|
||||
'玩家是被迫返乡的守灯人继承者,追查沉钟异动与失控航路的真相,风险是失去家族留下的最后航路坐标。',
|
||||
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免热血少年漫。',
|
||||
playerEntryPoint:
|
||||
'玩家以返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
|
||||
coreConflict:
|
||||
'守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。',
|
||||
keyRelationships:
|
||||
'玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
|
||||
hiddenLines:
|
||||
'沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
|
||||
iconicElements:
|
||||
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '沉钟栈桥',
|
||||
description: '旧钟与潮声常年相撞的码头栈桥。',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '沉钟栈桥章节',
|
||||
summary: '围绕沉钟栈桥推进的三幕结构。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '潮声逼近',
|
||||
summary: '第一幕先把潮声与旧钟压上来。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png',
|
||||
backgroundAssetId: 'scene-asset-1',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '接住首幕压力',
|
||||
transitionHook: '继续逼近钟楼深处。',
|
||||
},
|
||||
{
|
||||
id: 'scene-act-2',
|
||||
sceneId: 'landmark-1',
|
||||
title: '钟楼回响',
|
||||
summary: '第二幕把旧钟与暗线证据推到台前。',
|
||||
stageCoverage: ['investigation'],
|
||||
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png',
|
||||
backgroundAssetId: 'scene-asset-2',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_clue_found',
|
||||
actGoal: '找到旧钟证据',
|
||||
transitionHook: '钟楼深处传来第二次回响。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
ownedSettingLayers: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} as unknown as CustomWorldProfile;
|
||||
|
||||
function ResultViewHarness() {
|
||||
const [profile, setProfile] = useState(baseProfile);
|
||||
|
||||
return (
|
||||
<RpgCreationResultView
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
|
||||
mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation(
|
||||
() =>
|
||||
new Promise<CustomWorldPlayableNpc>((resolve) => {
|
||||
resolveGeneration = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: '新增可扮演角色' }));
|
||||
|
||||
expect(screen.getByText('新可扮演角色')).toBeTruthy();
|
||||
expect(screen.getByText('正在整理世界上下文')).toBeTruthy();
|
||||
|
||||
const createButton = screen.getByRole('button', { name: '新增可扮演角色' });
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const finishGeneration = resolveGeneration;
|
||||
if (!finishGeneration) {
|
||||
throw new Error('expected pending playable generation resolver');
|
||||
}
|
||||
|
||||
(finishGeneration as (value: CustomWorldPlayableNpc) => void)(
|
||||
createPlayableRole('playable-2', '云止'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /云止/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('新可扮演角色')).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText('玩家幻想')).toBeTruthy();
|
||||
expect(screen.getByText('主题边界')).toBeTruthy();
|
||||
expect(screen.getByText('玩家切入口')).toBeTruthy();
|
||||
expect(screen.getByText('核心冲突')).toBeTruthy();
|
||||
expect(screen.getByText('关键关系')).toBeTruthy();
|
||||
expect(screen.getByText('暗线与揭示')).toBeTruthy();
|
||||
expect(screen.getByText('标志元素')).toBeTruthy();
|
||||
expect(screen.queryByText('解析字段')).toBeNull();
|
||||
expect(screen.queryByText('锚点原文')).toBeNull();
|
||||
expect(screen.getByText(/被海雾反复改写航路的群岛世界/u)).toBeTruthy();
|
||||
expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
const profile = {
|
||||
...baseProfile,
|
||||
playableNpcs: [
|
||||
{
|
||||
...createPlayableRole('playable-portrait', '云止'),
|
||||
imageSrc: '/generated-characters/playable-portrait/master.png',
|
||||
generatedVisualAssetId: 'visual-playable-portrait',
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile;
|
||||
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={profile}
|
||||
previewCharacters={[
|
||||
{
|
||||
id: 'playable-portrait',
|
||||
name: '云止',
|
||||
title: '同行者',
|
||||
description: '预览角色',
|
||||
backstory: '预览背景',
|
||||
personality: '预览性格',
|
||||
portrait: '/template/portrait.png',
|
||||
avatar: '/template/avatar.png',
|
||||
assetFolder: 'test',
|
||||
assetVariant: 'Hero',
|
||||
combatTags: [],
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as never,
|
||||
]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
|
||||
const portrait = screen.getByRole('img', { name: '云止' });
|
||||
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'/generated-characters/playable-portrait/master.png',
|
||||
);
|
||||
expect(screen.getByText('已生成主图')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('landmark tab previews every generated act image while keeping chapter details out of list', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景\s*2/u }));
|
||||
|
||||
expect(screen.queryByText('沉钟栈桥章节')).toBeNull();
|
||||
expect(screen.queryByText('潮声逼近')).toBeNull();
|
||||
|
||||
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
|
||||
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/scene-act-1.png',
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByRole('img', {
|
||||
name: '沉钟栈桥-潮声逼近',
|
||||
}) as HTMLImageElement).getAttribute('src'),
|
||||
).toBe('/generated-custom-world-scenes/scene-act-1.png');
|
||||
expect(
|
||||
(screen.getByRole('img', {
|
||||
name: '沉钟栈桥-钟楼回响',
|
||||
}) as HTMLImageElement).getAttribute('src'),
|
||||
).toBe('/generated-custom-world-scenes/scene-act-1.png');
|
||||
});
|
||||
|
||||
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
readOnly
|
||||
compactAgentResultMode
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /^编辑$/u })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
expect(screen.queryByRole('button', { name: /批量删除/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('agent result view shows error when entity generation returns no new profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
compactAgentResultMode
|
||||
onGenerateEntity={async () => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: '新增场景角色' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/结果页未收到新增内容/u),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => {
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
compactAgentResultMode
|
||||
publishReady={false}
|
||||
publishBlockers={[
|
||||
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||||
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
|
||||
]}
|
||||
qualityFindings={[
|
||||
{
|
||||
id: 'role-assets-pending',
|
||||
severity: 'warning',
|
||||
code: 'role_assets_pending',
|
||||
message: '仍有角色资产未完全补齐。',
|
||||
},
|
||||
]}
|
||||
previewSourceLabel="服务端预览"
|
||||
enterWorldActionLabel="发布并进入世界"
|
||||
onEnterWorld={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const actionButton = screen.getByRole('button', {
|
||||
name: '发布并进入世界',
|
||||
});
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByText(/当前结果页数据源:服务端预览/u)).toBeNull();
|
||||
expect(screen.queryByText(/当前还有 2 个发布阻断项/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('agent result view opens publish blocker dialog only when user clicks publish action', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
compactAgentResultMode
|
||||
publishReady={false}
|
||||
publishBlockers={[
|
||||
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||||
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
|
||||
]}
|
||||
previewSourceLabel="服务端预览"
|
||||
enterWorldActionLabel="发布并进入世界"
|
||||
onEnterWorld={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
|
||||
expect(screen.getByText('发布检查')).toBeTruthy();
|
||||
expect(screen.getByText('封面设置')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/仍有角色缺少正式主图或动作资产/u),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
compactAgentResultMode
|
||||
publishReady
|
||||
publishBlockers={[]}
|
||||
qualityFindings={[
|
||||
{
|
||||
id: 'scene-assets-pending',
|
||||
severity: 'warning',
|
||||
code: 'scene_assets_pending',
|
||||
message: '仍有场景分幕图未补齐。',
|
||||
},
|
||||
]}
|
||||
previewSourceLabel="服务端预览"
|
||||
enterWorldActionLabel="发布并进入世界"
|
||||
onEnterWorld={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/发布后仍有 1 条 warning 可继续优化/u)).toBeTruthy();
|
||||
const actionButton = screen.getByRole('button', {
|
||||
name: '发布并进入世界',
|
||||
});
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
44
src/components/GameCanvas.tsx
Normal file
44
src/components/GameCanvas.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {lazy, Suspense} from 'react';
|
||||
|
||||
import type {GameCanvasProps} from './game-canvas/GameCanvasShared';
|
||||
|
||||
export type {
|
||||
GameCanvasEntitySelection,
|
||||
GameCanvasProps,
|
||||
} from './game-canvas/GameCanvasShared';
|
||||
|
||||
const GameCanvasRuntime = lazy(async () => {
|
||||
const module = await import('./game-canvas/GameCanvasRuntime');
|
||||
|
||||
return {
|
||||
default: module.GameCanvasRuntime,
|
||||
};
|
||||
});
|
||||
|
||||
function GameCanvasLoadingFallback({
|
||||
sceneName,
|
||||
}: {
|
||||
sceneName: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden bg-black">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_38%),linear-gradient(180deg,rgba(12,16,24,0.96),rgba(3,5,10,1))]" />
|
||||
{sceneName && (
|
||||
<div className="absolute left-1/2 top-3 -translate-x-1/2 rounded-full border border-white/10 bg-black/45 px-4 py-1 text-[10px] uppercase tracking-[0.2em] text-zinc-300">
|
||||
{sceneName}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[11px] uppercase tracking-[0.3em] text-zinc-500">
|
||||
Loading scene
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameCanvas(props: GameCanvasProps) {
|
||||
return (
|
||||
<Suspense fallback={<GameCanvasLoadingFallback sceneName={props.currentScenePreset?.name ?? null} />}>
|
||||
<GameCanvasRuntime {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
87
src/components/HostileNpcAnimator.tsx
Normal file
87
src/components/HostileNpcAnimator.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
export interface HostileNpcAnimationConfig {
|
||||
start: number;
|
||||
frames: number;
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
export interface HostileNpcSpriteConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
src: string;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
sheetWidth: number;
|
||||
animations: {
|
||||
idle: HostileNpcAnimationConfig;
|
||||
move?: HostileNpcAnimationConfig;
|
||||
attack?: HostileNpcAnimationConfig;
|
||||
die?: HostileNpcAnimationConfig;
|
||||
};
|
||||
}
|
||||
|
||||
interface HostileNpcAnimatorProps {
|
||||
hostileNpc: HostileNpcSpriteConfig;
|
||||
animation?: keyof HostileNpcSpriteConfig['animations'];
|
||||
className?: string;
|
||||
flip?: boolean;
|
||||
}
|
||||
|
||||
export const HostileNpcAnimator: React.FC<HostileNpcAnimatorProps> = ({
|
||||
hostileNpc,
|
||||
animation = 'idle',
|
||||
className,
|
||||
flip = false,
|
||||
}) => {
|
||||
const [frameOffset, setFrameOffset] = useState(0);
|
||||
const anim =
|
||||
hostileNpc.animations[animation] ??
|
||||
(animation === 'die' ? hostileNpc.animations.attack : undefined) ??
|
||||
(animation === 'move' ? hostileNpc.animations.attack : undefined) ??
|
||||
hostileNpc.animations.idle;
|
||||
const columns = Math.max(1, Math.floor(hostileNpc.sheetWidth / hostileNpc.frameWidth));
|
||||
const shouldLoop = animation !== 'die' || !hostileNpc.animations.die;
|
||||
|
||||
useEffect(() => {
|
||||
setFrameOffset(0);
|
||||
|
||||
if (anim.frames <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setFrameOffset(prev => {
|
||||
if (!shouldLoop) {
|
||||
return Math.min(prev + 1, anim.frames - 1);
|
||||
}
|
||||
return (prev + 1) % anim.frames;
|
||||
});
|
||||
}, 1000 / (anim.fps ?? 12));
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [anim, shouldLoop]);
|
||||
|
||||
const frameIndex = anim.start + frameOffset;
|
||||
const col = frameIndex % columns;
|
||||
const row = Math.floor(frameIndex / columns);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: `${hostileNpc.frameWidth}px`,
|
||||
height: `${hostileNpc.frameHeight}px`,
|
||||
backgroundImage: `url("${encodeURI(hostileNpc.src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${col * hostileNpc.frameWidth}px -${row * hostileNpc.frameHeight}px`,
|
||||
backgroundSize: `${hostileNpc.sheetWidth}px auto`,
|
||||
imageRendering: 'pixelated',
|
||||
transform: flip ? 'scaleX(-1)' : undefined,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
aria-label={hostileNpc.name}
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
};
|
||||
252
src/components/InventoryItemViews.tsx
Normal file
252
src/components/InventoryItemViews.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { buildInventoryItemDescription } from '../data/itemPresentation';
|
||||
import type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryItemVisualSrc,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return {
|
||||
frameClass:
|
||||
'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8',
|
||||
titleClass: 'text-amber-300',
|
||||
quantityClass:
|
||||
'border-amber-300/30 bg-amber-500/14 text-amber-50 shadow-[0_0_18px_rgba(251,191,36,0.16)]',
|
||||
auraClass: 'from-amber-500/18 via-orange-500/12 to-transparent',
|
||||
glowClass: 'bg-amber-300/24',
|
||||
};
|
||||
case 'epic':
|
||||
return {
|
||||
frameClass:
|
||||
'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-rose-500/8',
|
||||
titleClass: 'text-fuchsia-300',
|
||||
quantityClass:
|
||||
'border-fuchsia-300/28 bg-fuchsia-500/12 text-fuchsia-50 shadow-[0_0_18px_rgba(232,121,249,0.14)]',
|
||||
auraClass: 'from-fuchsia-500/18 via-rose-500/10 to-transparent',
|
||||
glowClass: 'bg-fuchsia-300/22',
|
||||
};
|
||||
case 'rare':
|
||||
return {
|
||||
frameClass:
|
||||
'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8',
|
||||
titleClass: 'text-sky-300',
|
||||
quantityClass:
|
||||
'border-sky-300/26 bg-sky-500/12 text-sky-50 shadow-[0_0_18px_rgba(56,189,248,0.14)]',
|
||||
auraClass: 'from-sky-500/18 via-cyan-500/10 to-transparent',
|
||||
glowClass: 'bg-sky-300/20',
|
||||
};
|
||||
case 'uncommon':
|
||||
return {
|
||||
frameClass:
|
||||
'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8',
|
||||
titleClass: 'text-emerald-300',
|
||||
quantityClass:
|
||||
'border-emerald-300/24 bg-emerald-500/12 text-emerald-50 shadow-[0_0_18px_rgba(74,222,128,0.12)]',
|
||||
auraClass: 'from-emerald-500/18 via-lime-500/10 to-transparent',
|
||||
glowClass: 'bg-emerald-300/18',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
frameClass: 'border-white/10 bg-white/[0.04]',
|
||||
titleClass: 'text-zinc-100',
|
||||
quantityClass:
|
||||
'border-white/12 bg-white/[0.06] text-zinc-100 shadow-[0_0_18px_rgba(255,255,255,0.06)]',
|
||||
auraClass: 'from-white/10 via-white/4 to-transparent',
|
||||
glowClass: 'bg-white/10',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryItemIcon(item: InventoryItem) {
|
||||
return getInventoryItemVisualSrc(item);
|
||||
}
|
||||
|
||||
function buildInventoryItemSummary(
|
||||
item: InventoryItem,
|
||||
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
|
||||
) {
|
||||
return buildInventoryItemDescription(item, useEffect);
|
||||
}
|
||||
|
||||
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {
|
||||
const slotCount = Math.ceil(Math.max(items.length, minimumSlotCount) / 4) * 4;
|
||||
return [
|
||||
...items,
|
||||
...Array.from(
|
||||
{ length: Math.max(0, slotCount - items.length) },
|
||||
() => null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function InventoryItemGrid({
|
||||
items,
|
||||
selectedItemId = null,
|
||||
minimumSlotCount = 16,
|
||||
onSelectItem,
|
||||
}: {
|
||||
items: InventoryItem[];
|
||||
selectedItemId?: string | null;
|
||||
minimumSlotCount?: number;
|
||||
onSelectItem: (item: InventoryItem) => void;
|
||||
}) {
|
||||
const inventorySlots = buildInventorySlots(items, minimumSlotCount);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{inventorySlots.map((item, index) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-slot-${index}`}
|
||||
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = selectedItemId === item.id;
|
||||
const rarityTheme = getInventoryRarityTheme(item.rarity);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelectItem(item)}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${rarityTheme.frameClass} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type InventoryItemDetailModalProps = {
|
||||
item: InventoryItem | null;
|
||||
playerCharacter: Character;
|
||||
worldType: WorldType | null;
|
||||
ownerLabel?: string;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export function InventoryItemDetailModal({
|
||||
item,
|
||||
playerCharacter,
|
||||
onClose,
|
||||
footer,
|
||||
}: InventoryItemDetailModalProps) {
|
||||
const selectedItemUseEffect = item
|
||||
? resolveInventoryItemUseEffect(item, playerCharacter)
|
||||
: null;
|
||||
const itemSummary = item
|
||||
? buildInventoryItemSummary(item, selectedItemUseEffect)
|
||||
: '';
|
||||
const rarityTheme = item
|
||||
? getInventoryRarityTheme(item.rarity)
|
||||
: getInventoryRarityTheme('common');
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{item && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[78] flex items-end justify-center bg-black/78 p-3 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(90vh,50rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${rarityTheme.auraClass}`}
|
||||
/>
|
||||
<div
|
||||
className={`pointer-events-none absolute -right-12 top-1/2 h-28 w-28 -translate-y-1/2 rounded-full blur-3xl sm:h-36 sm:w-36 ${rarityTheme.glowClass}`}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-4 top-4 opacity-[0.16] sm:right-6 sm:top-5">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-16 w-16 drop-shadow-[0_8px_16px_rgba(0,0,0,0.3)] sm:h-20 sm:w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative max-w-[80%] sm:max-w-[85%]">
|
||||
<div
|
||||
className={`break-words text-[clamp(1.2rem,5vw,1.95rem)] font-semibold leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.35)] ${rarityTheme.titleClass}`}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-4 inline-flex items-center rounded-full border px-3 py-1.5 text-xs sm:text-sm ${rarityTheme.quantityClass}`}
|
||||
>
|
||||
数量 x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="relative flex h-full min-h-[clamp(18rem,48vh,30rem)] flex-col overflow-hidden">
|
||||
<div
|
||||
className={`pointer-events-none absolute -left-10 bottom-4 h-24 w-24 rounded-full blur-3xl sm:h-32 sm:w-32 ${rarityTheme.glowClass}`}
|
||||
/>
|
||||
<div className="relative h-full overflow-y-auto pr-1">
|
||||
<p className="whitespace-pre-wrap text-[0.95rem] leading-7 text-zinc-100 sm:text-base sm:leading-8">
|
||||
{itemSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer != null ? (
|
||||
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
225
src/components/InventoryPanel.tsx
Normal file
225
src/components/InventoryPanel.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { formatCurrency } from '../data/economy';
|
||||
import { type ForgeRecipeView } from '../data/forgeSystem';
|
||||
import { buildInitialPlayerInventory } from '../data/npcInteractions';
|
||||
import {
|
||||
Character,
|
||||
InventoryItem,
|
||||
NarrativeCodexSection,
|
||||
NarrativeQaReport,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
InventoryItemDetailModal,
|
||||
InventoryItemGrid,
|
||||
} from './InventoryItemViews';
|
||||
|
||||
interface InventoryPanelProps {
|
||||
playerCharacter: Character;
|
||||
worldType: WorldType | null;
|
||||
playerInventory: InventoryItem[];
|
||||
playerCurrency: number;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
onUseItem: (itemId: string) => Promise<boolean>;
|
||||
onEquipItem: (itemId: string) => Promise<boolean>;
|
||||
forgeRecipes: ForgeRecipeView[];
|
||||
onCraftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
onDismantleItem: (itemId: string) => Promise<boolean>;
|
||||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||||
continueGameDigest?: string | null;
|
||||
narrativeCodex?: NarrativeCodexSection[];
|
||||
narrativeQaReport?: NarrativeQaReport | null;
|
||||
}
|
||||
|
||||
export function InventoryPanel({
|
||||
playerCharacter,
|
||||
worldType,
|
||||
playerInventory,
|
||||
playerCurrency,
|
||||
inBattle,
|
||||
forgeRecipes,
|
||||
onCraftRecipe,
|
||||
continueGameDigest = null,
|
||||
narrativeCodex = [],
|
||||
narrativeQaReport = null,
|
||||
}: InventoryPanelProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
|
||||
|
||||
const inventoryItems = useMemo(
|
||||
() =>
|
||||
playerInventory.length > 0
|
||||
? playerInventory
|
||||
: buildInitialPlayerInventory(playerCharacter, worldType),
|
||||
[playerCharacter, playerInventory, worldType],
|
||||
);
|
||||
const documentItems = useMemo(
|
||||
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')),
|
||||
[inventoryItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
{continueGameDigest && (
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
|
||||
旅程回顾
|
||||
</div>
|
||||
{continueGameDigest}
|
||||
</div>
|
||||
)}
|
||||
<InventoryItemGrid
|
||||
items={inventoryItems}
|
||||
selectedItemId={selectedItem?.id ?? null}
|
||||
onSelectItem={setSelectedItem}
|
||||
/>
|
||||
|
||||
{documentItems.length > 0 && (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
文书与证据
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{documentItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-left transition hover:border-white/15"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{item.description || '记录着当前线程的阶段性线索。'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(narrativeCodex.length > 0 || narrativeQaReport) && (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
故事档案
|
||||
</div>
|
||||
{narrativeQaReport && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/18 bg-amber-500/8 px-3 py-2 text-xs text-amber-100/85">
|
||||
QA:{narrativeQaReport.summary}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{narrativeCodex.slice(0, 3).map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{section.title}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{section.entries.slice(0, 3).map((entry) => (
|
||||
<div key={entry.id} className="text-xs text-zinc-400">
|
||||
<span className="text-zinc-200">{entry.title}</span>
|
||||
{':'}
|
||||
{entry.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
<span>工坊</span>
|
||||
<span className="text-emerald-200/80">
|
||||
{formatCurrency(playerCurrency, worldType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{forgeRecipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{recipe.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{recipe.description}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-emerald-200/80">
|
||||
产物:{recipe.resultLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
花费:{recipe.currencyText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!recipe.canCraft ||
|
||||
inBattle ||
|
||||
forgeActionKey === recipe.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(recipe.id);
|
||||
const crafted = await onCraftRecipe(recipe.id);
|
||||
setForgeActionKey(null);
|
||||
if (crafted && selectedItem) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
|
||||
recipe.canCraft && !inBattle
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
|
||||
: 'border-white/8 bg-black/20 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{forgeActionKey === recipe.id
|
||||
? '制作中...'
|
||||
: recipe.kind === 'forge'
|
||||
? '锻造'
|
||||
: '合成'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{recipe.requirements.map((requirement) => (
|
||||
<span
|
||||
key={`${recipe.id}-${requirement.id}`}
|
||||
className={`rounded-full border px-2 py-1 text-[10px] ${
|
||||
requirement.owned >= requirement.quantity
|
||||
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{requirement.label} {requirement.owned}/
|
||||
{requirement.quantity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryItemDetailModal
|
||||
item={selectedItem}
|
||||
playerCharacter={playerCharacter}
|
||||
worldType={worldType}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
449
src/components/MapModal.tsx
Normal file
449
src/components/MapModal.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
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 { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { ScenePresetInfo, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
|
||||
return {
|
||||
backgroundImage: imageSrc
|
||||
? `linear-gradient(rgba(7,10,18,0.82), rgba(7,10,18,0.76)), url("${imageSrc}")`
|
||||
: 'linear-gradient(rgba(7,10,18,0.82), rgba(7,10,18,0.76))',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
};
|
||||
}
|
||||
|
||||
const MAP_NODE_MIN_HEIGHT_PX = 52;
|
||||
const MAP_NODE_GAP_PX = 12;
|
||||
|
||||
function getMapDestinationStackHeight(count: number) {
|
||||
if (count <= 0) return MAP_NODE_MIN_HEIGHT_PX;
|
||||
return count * MAP_NODE_MIN_HEIGHT_PX + (count - 1) * MAP_NODE_GAP_PX;
|
||||
}
|
||||
|
||||
function getMapDestinationCenterPercent(index: number, count: number) {
|
||||
const totalHeight = getMapDestinationStackHeight(count);
|
||||
const centerY = index * (MAP_NODE_MIN_HEIGHT_PX + MAP_NODE_GAP_PX) + MAP_NODE_MIN_HEIGHT_PX / 2;
|
||||
return (centerY / totalHeight) * 100;
|
||||
}
|
||||
|
||||
function MudMapRoom({
|
||||
scene,
|
||||
label,
|
||||
compact = false,
|
||||
isInteractive = false,
|
||||
isSelected = false,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
key?: string;
|
||||
scene: ScenePresetInfo | null | undefined;
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
isInteractive?: boolean;
|
||||
isSelected?: boolean;
|
||||
description?: string;
|
||||
onClick?: (() => void) | null;
|
||||
}) {
|
||||
if (!scene) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice map-room-cell h-full min-h-[3.25rem] opacity-40"
|
||||
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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' : ''} ${isSelected ? 'brightness-125' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
if (!isInteractive || !onClick) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface MapModalProps {
|
||||
isOpen: boolean;
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
worldType: WorldType | null;
|
||||
onClose: () => void;
|
||||
onTravelToScene: (scene: ScenePresetInfo) => void;
|
||||
isTraveling?: boolean;
|
||||
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,
|
||||
worldType,
|
||||
onClose,
|
||||
onTravelToScene,
|
||||
isTraveling = false,
|
||||
canTravel = true,
|
||||
}: MapModalProps) {
|
||||
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
|
||||
const {
|
||||
resolvedUrl: resolvedBackdropImageSrc,
|
||||
shouldResolve: shouldResolveBackdropImage,
|
||||
} = useResolvedAssetReadUrl(currentScenePreset?.imageSrc);
|
||||
|
||||
const connectedScenes = useMemo(
|
||||
() =>
|
||||
worldType && currentScenePreset
|
||||
? getConnectedScenePresets(worldType, currentScenePreset.id)
|
||||
: [],
|
||||
[currentScenePreset, worldType],
|
||||
);
|
||||
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(
|
||||
resolvedBackdropImageSrc
|
||||
|| (!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''),
|
||||
);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPendingScene(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScene) return;
|
||||
if (!connectionEntries.some((scene) => scene.scene.id === pendingScene.scene.id)) {
|
||||
setPendingScene(null);
|
||||
}
|
||||
}, [connectionEntries, pendingScene]);
|
||||
|
||||
const handleSceneSelect = (scene: MapConnectionEntry | null) => {
|
||||
if (!scene || scene.scene.id === currentScenePreset?.id) return;
|
||||
setPendingScene(scene);
|
||||
};
|
||||
|
||||
const confirmTravel = () => {
|
||||
if (!pendingScene) return;
|
||||
onTravelToScene(pendingScene.scene);
|
||||
setPendingScene(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && currentScenePreset && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="map-modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell relative flex max-h-[min(92vh,62rem)] w-full max-w-[min(96vw,64rem)] flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="map-modal-backdrop pointer-events-none absolute inset-0"
|
||||
style={sceneBackdropStyle}
|
||||
/>
|
||||
<div className="map-modal-shade pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18),rgba(2,6,23,0.68))]" />
|
||||
<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="inline-flex items-center gap-2 text-[10px] tracking-[0.22em] text-emerald-300/75">
|
||||
<PixelIcon src={CHROME_ICONS.map} className="h-3.5 w-3.5" />
|
||||
<span>地图</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
|
||||
<div
|
||||
className="map-info-panel pixel-nine-slice pixel-panel min-w-0 font-mono text-[11px] leading-relaxed text-emerald-100/85 md:max-h-full md:self-start md:overflow-y-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="text-emerald-200/75">当前位置</div>
|
||||
<div className="mt-1 text-sm text-white">{currentScenePreset.name}</div>
|
||||
<div className="mt-2 text-zinc-300">{currentScenePreset.description}</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5 text-zinc-300">
|
||||
{connectionEntries.map((entry) => (
|
||||
<div key={entry.scene.id}>{`- ${entry.label}:${entry.scene.name}`}</div>
|
||||
))}
|
||||
{connectionEntries.length === 0 && <div>- 暂无</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 p-1 font-mono md:overflow-y-auto">
|
||||
<div className="md:hidden">
|
||||
<div className="grid grid-cols-[minmax(0,0.9fr)_2rem_minmax(0,1.1fr)] items-start gap-3">
|
||||
<div className="w-full max-w-[7.5rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-${entry.scene.id}`}
|
||||
x1="0"
|
||||
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
|
||||
x2="100%"
|
||||
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
|
||||
stroke="rgba(74, 222, 128, 0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{connectionEntries.map(entry => (
|
||||
<MudMapRoom
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
label={entry.label}
|
||||
description={entry.summary}
|
||||
compact
|
||||
isInteractive={canTravel}
|
||||
isSelected={pendingScene?.scene.id === entry.scene.id}
|
||||
onClick={() => handleSceneSelect(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-[minmax(0,12rem)_4rem_minmax(0,1fr)] items-start gap-4">
|
||||
<div className="w-full max-w-[9rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-desktop-${entry.scene.id}`}
|
||||
x1="0"
|
||||
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
|
||||
x2="100%"
|
||||
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
|
||||
stroke="rgba(74, 222, 128, 0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{connectionEntries.map(entry => (
|
||||
<MudMapRoom
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
label={entry.label}
|
||||
description={entry.summary}
|
||||
isInteractive={canTravel}
|
||||
isSelected={pendingScene?.scene.id === entry.scene.id}
|
||||
onClick={() => handleSceneSelect(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{pendingScene && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="map-modal-overlay fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-[2px]"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
setPendingScene(null);
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] tracking-[0.22em] text-amber-200/80">场景切换</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingScene(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-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.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">
|
||||
<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">{currentScenePreset.name}</div>
|
||||
</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.scene.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingScene(null)}
|
||||
className="rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isTraveling || !canTravel}
|
||||
onClick={confirmTravel}
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${isTraveling || !canTravel ? 'border-white/10 bg-black/20 text-zinc-500' : 'border-amber-400/30 bg-amber-500/20 text-amber-50'}`}
|
||||
>
|
||||
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
214
src/components/MedievalNpcAnimator.tsx
Normal file
214
src/components/MedievalNpcAnimator.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AtlasTileSpec, buildMedievalNpcVisual, MedievalNpcVisualSpec } from '../data/medievalNpcVisuals';
|
||||
import { Encounter } from '../types';
|
||||
import {
|
||||
DEFAULT_NPC_LAYOUT_CONFIG,
|
||||
type NpcLayoutConfig,
|
||||
type NpcLayoutPart,
|
||||
} from './npcVisualShared';
|
||||
|
||||
const TILE_SIZE = 32;
|
||||
const HAND_TILE_SIZE = 16;
|
||||
const IDLE_FRAME_MS = 140;
|
||||
|
||||
function mergeLayoutConfig(layoutConfig?: Partial<NpcLayoutConfig>): NpcLayoutConfig {
|
||||
if (!layoutConfig) return DEFAULT_NPC_LAYOUT_CONFIG;
|
||||
|
||||
return {
|
||||
body: { ...DEFAULT_NPC_LAYOUT_CONFIG.body, ...layoutConfig.body },
|
||||
head: { ...DEFAULT_NPC_LAYOUT_CONFIG.head, ...layoutConfig.head },
|
||||
facialHair: { ...DEFAULT_NPC_LAYOUT_CONFIG.facialHair, ...layoutConfig.facialHair },
|
||||
hair: { ...DEFAULT_NPC_LAYOUT_CONFIG.hair, ...layoutConfig.hair },
|
||||
headgear: { ...DEFAULT_NPC_LAYOUT_CONFIG.headgear, ...layoutConfig.headgear },
|
||||
hand: { ...DEFAULT_NPC_LAYOUT_CONFIG.hand, ...layoutConfig.hand },
|
||||
mainHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.mainHand, ...layoutConfig.mainHand },
|
||||
offHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.offHand, ...layoutConfig.offHand },
|
||||
};
|
||||
}
|
||||
|
||||
function LayerSprite({
|
||||
src,
|
||||
frameIndex,
|
||||
tileSize = TILE_SIZE,
|
||||
x = 0,
|
||||
y = 0,
|
||||
zIndex = 0,
|
||||
}: {
|
||||
src: string;
|
||||
frameIndex: number;
|
||||
tileSize?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
backgroundImage: `url("${encodeURI(src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${frameIndex * tileSize}px 0px`,
|
||||
imageRendering: 'pixelated',
|
||||
zIndex,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasSprite({
|
||||
spec,
|
||||
x = 0,
|
||||
y = 0,
|
||||
zIndex = 0,
|
||||
}: {
|
||||
spec: AtlasTileSpec;
|
||||
x?: number;
|
||||
y?: number;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
const tileWidth = spec.tileWidth ?? TILE_SIZE;
|
||||
const tileHeight = spec.tileHeight ?? TILE_SIZE;
|
||||
const col = spec.frameIndex % spec.columns;
|
||||
const row = Math.floor(spec.frameIndex / spec.columns);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x - (tileWidth - TILE_SIZE) / 2 + (spec.renderOffsetX ?? 0)}px`,
|
||||
top: `${y - (tileHeight - TILE_SIZE) + (spec.renderOffsetY ?? 0)}px`,
|
||||
width: `${tileWidth}px`,
|
||||
height: `${tileHeight}px`,
|
||||
backgroundImage: `url("${encodeURI(spec.src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${col * tileWidth}px -${row * tileHeight}px`,
|
||||
imageRendering: 'pixelated',
|
||||
zIndex,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MedievalNpcAnimator({
|
||||
encounter,
|
||||
visualSpec,
|
||||
layoutConfig,
|
||||
onPartPointerDown,
|
||||
selectedPart,
|
||||
className,
|
||||
scale = 2.4,
|
||||
facing = 'right',
|
||||
}: {
|
||||
encounter?: Encounter;
|
||||
visualSpec?: MedievalNpcVisualSpec;
|
||||
layoutConfig?: Partial<NpcLayoutConfig>;
|
||||
onPartPointerDown?: (part: NpcLayoutPart, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
selectedPart?: NpcLayoutPart | null;
|
||||
className?: string;
|
||||
scale?: number;
|
||||
facing?: 'left' | 'right';
|
||||
}) {
|
||||
const [frameCursor, setFrameCursor] = useState(0);
|
||||
const visual = visualSpec ?? buildMedievalNpcVisual(encounter ?? {
|
||||
npcName: '预览角色',
|
||||
npcDescription: '用于预览的角色外形。',
|
||||
npcAvatar: '预',
|
||||
context: '预览',
|
||||
});
|
||||
const bodyFrame = visual.bodyFrames[frameCursor % visual.bodyFrames.length] ?? 0;
|
||||
const headFrame = visual.headFrame;
|
||||
const hairFrame = visual.hairFrame;
|
||||
const handFrame = visual.handFrame;
|
||||
const facialFrame = visual.facialHairFrame ?? 0;
|
||||
const bobOffsets = [0, 1, 1, -1];
|
||||
const bobY = bobOffsets[frameCursor % bobOffsets.length] ?? 0;
|
||||
const layout = mergeLayoutConfig(layoutConfig);
|
||||
|
||||
const getPartClassName = (part: NpcLayoutPart) =>
|
||||
onPartPointerDown
|
||||
? `cursor-grab ${selectedPart === part ? 'drop-shadow-[0_0_10px_rgba(16,185,129,0.7)]' : ''}`
|
||||
: '';
|
||||
|
||||
const getPartHandlers = (part: NpcLayoutPart) =>
|
||||
onPartPointerDown
|
||||
? {
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => onPartPointerDown(part, event),
|
||||
}
|
||||
: {};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameCursor(prev => (prev + 1) % 4);
|
||||
}, IDLE_FRAME_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${TILE_SIZE * 2.6}px`,
|
||||
height: `${TILE_SIZE * 3.1}px`,
|
||||
transform: `translateY(${bobY}px) scale(${scale})`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: '50%', bottom: 0, width: `${TILE_SIZE}px`, height: `${TILE_SIZE}px`, transform: 'translateX(-50%)' }}>
|
||||
<div className={getPartClassName('body')} style={{ position: 'absolute', left: `${layout.body.x}px`, top: `${layout.body.y}px` }} {...getPartHandlers('body')}>
|
||||
<LayerSprite src={visual.bodySrc} frameIndex={bodyFrame} zIndex={1} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={getPartClassName('hand')}
|
||||
style={{ position: 'absolute', left: `${layout.hand.x}px`, top: `${layout.hand.y}px`, width: `${HAND_TILE_SIZE}px`, height: `${HAND_TILE_SIZE}px`, zIndex: 5 }}
|
||||
{...getPartHandlers('hand')}
|
||||
>
|
||||
{visual.mainHand && (
|
||||
<div className={getPartClassName('mainHand')} style={{ position: 'absolute', left: `${layout.mainHand.x}px`, top: `${layout.mainHand.y}px` }} {...getPartHandlers('mainHand')}>
|
||||
<AtlasSprite spec={visual.mainHand} zIndex={11} />
|
||||
</div>
|
||||
)}
|
||||
<LayerSprite src={visual.handSrc} frameIndex={handFrame} tileSize={HAND_TILE_SIZE} zIndex={12} />
|
||||
</div>
|
||||
|
||||
<div className={getPartClassName('head')} style={{ position: 'absolute', left: `${layout.head.x}px`, top: `${layout.head.y}px` }} {...getPartHandlers('head')}>
|
||||
<LayerSprite src={visual.headSrc} frameIndex={headFrame} zIndex={6} />
|
||||
</div>
|
||||
{visual.facialHairSrc && (
|
||||
<div className={getPartClassName('facialHair')} style={{ position: 'absolute', left: `${layout.facialHair.x}px`, top: `${layout.facialHair.y}px` }} {...getPartHandlers('facialHair')}>
|
||||
<LayerSprite src={visual.facialHairSrc} frameIndex={facialFrame} zIndex={7} />
|
||||
</div>
|
||||
)}
|
||||
<div className={getPartClassName('hair')} style={{ position: 'absolute', left: `${layout.hair.x}px`, top: `${layout.hair.y}px` }} {...getPartHandlers('hair')}>
|
||||
<LayerSprite src={visual.hairSrc} frameIndex={hairFrame} zIndex={8} />
|
||||
</div>
|
||||
{visual.headgear && (
|
||||
<div className={getPartClassName('headgear')} style={{ position: 'absolute', left: `${layout.headgear.x}px`, top: `${layout.headgear.y}px` }} {...getPartHandlers('headgear')}>
|
||||
<AtlasSprite spec={visual.headgear} zIndex={9} />
|
||||
</div>
|
||||
)}
|
||||
{visual.offHand && (
|
||||
<div className={getPartClassName('offHand')} style={{ position: 'absolute', left: `${layout.offHand.x}px`, top: `${layout.offHand.y}px` }} {...getPartHandlers('offHand')}>
|
||||
<AtlasSprite spec={visual.offHand} zIndex={10} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
600
src/components/NpcModals.tsx
Normal file
600
src/components/NpcModals.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import {
|
||||
formatCurrency,
|
||||
getCurrencyName,
|
||||
getInventoryItemValue,
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import {
|
||||
buildInventoryItemDescription,
|
||||
getInventoryTagLabels,
|
||||
} from '../data/itemPresentation';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getGiftCandidates,
|
||||
getRarityLabel,
|
||||
} from '../data/npcInteractions';
|
||||
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
|
||||
import { GameState, InventoryItem } from '../types';
|
||||
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface NpcModalsProps {
|
||||
gameState: GameState;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
}
|
||||
|
||||
type TradeDetailState = {
|
||||
itemId: string;
|
||||
source: 'buy' | 'sell';
|
||||
} | null;
|
||||
|
||||
function getNpcEncounterKey(encounter: NonNullable<GameState['currentEncounter']>) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
}
|
||||
|
||||
function getItemVisualSrc(item: InventoryItem) {
|
||||
return getInventoryItemVisualSrc(item);
|
||||
}
|
||||
|
||||
function buildTradeUseEffectText(
|
||||
effect: ReturnType<typeof resolveInventoryItemUseEffect> | null,
|
||||
) {
|
||||
if (!effect) return null;
|
||||
|
||||
const parts = [
|
||||
effect.hpRestore > 0 ? `生命 +${effect.hpRestore}` : null,
|
||||
effect.manaRestore > 0 ? `灵力 +${effect.manaRestore}` : null,
|
||||
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return parts.join(' / ') || '无直接效果';
|
||||
}
|
||||
|
||||
function TradeItemRow({
|
||||
item,
|
||||
selected,
|
||||
unitPrice,
|
||||
currencyName,
|
||||
onClick,
|
||||
}: {
|
||||
item: InventoryItem;
|
||||
selected: boolean;
|
||||
unitPrice: number;
|
||||
currencyName: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
|
||||
selected
|
||||
? 'border-emerald-400/45 bg-emerald-500/10'
|
||||
: 'border-white/8 bg-black/20 hover:border-white/15'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
|
||||
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">{item.name}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{item.category} / {getRarityLabel(item.rarity)} / 单价 {unitPrice} {currencyName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] text-white">
|
||||
x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeQuantityStepper({
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onChange,
|
||||
}: {
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onChange: (quantity: number) => void;
|
||||
}) {
|
||||
const safeMax = Math.max(1, maxQuantity);
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">数量</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">最多 {safeMax}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(quantity - 1)}
|
||||
disabled={quantity <= 1}
|
||||
className={`h-8 w-8 rounded-lg border text-sm ${
|
||||
quantity > 1
|
||||
? 'border-white/12 bg-white/6 text-white'
|
||||
: 'border-white/8 bg-black/20 text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">{quantity}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(quantity + 1)}
|
||||
disabled={quantity >= safeMax}
|
||||
className={`h-8 w-8 rounded-lg border text-sm ${
|
||||
quantity < safeMax
|
||||
? 'border-white/12 bg-white/6 text-white'
|
||||
: 'border-white/8 bg-black/20 text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
|
||||
const currencyName = getCurrencyName(
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const tradeModal = npcUi.tradeModal;
|
||||
const tradeNpcState = tradeModal
|
||||
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
|
||||
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState)
|
||||
: null;
|
||||
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
|
||||
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
|
||||
? gameState.playerInventory.find(item => item.id === tradeModal?.selectedPlayerItemId) ?? null
|
||||
: null;
|
||||
const tradeMode = tradeModal?.mode ?? 'buy';
|
||||
const activeTradeItem = tradeMode === 'buy' ? selectedTradeNpcItem : selectedTradePlayerItem;
|
||||
const activeTradeUnitPrice = tradeModal && activeTradeItem && tradeNpcState
|
||||
? tradeMode === 'buy'
|
||||
? getNpcPurchasePrice(activeTradeItem, tradeNpcState.affinity)
|
||||
: getNpcBuybackPrice(activeTradeItem, tradeNpcState.affinity)
|
||||
: 0;
|
||||
const activeTradeMaxQuantity = activeTradeItem?.quantity ?? 0;
|
||||
const activeTradeQuantity = tradeModal
|
||||
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity)))
|
||||
: 1;
|
||||
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
|
||||
const canConfirmTrade = Boolean(
|
||||
activeTradeItem &&
|
||||
activeTradeMaxQuantity > 0 &&
|
||||
activeTradeQuantity >= 1 &&
|
||||
activeTradeQuantity <= activeTradeMaxQuantity &&
|
||||
(tradeMode === 'sell' || gameState.playerCurrency >= activeTradeTotalPrice)
|
||||
);
|
||||
const tradeItemList = tradeMode === 'buy'
|
||||
? (tradeNpcState?.inventory ?? [])
|
||||
: gameState.playerInventory;
|
||||
const tradeDetailItem = tradeDetail
|
||||
? (tradeDetail.source === 'buy' ? tradeNpcState?.inventory ?? [] : gameState.playerInventory)
|
||||
.find(item => item.id === tradeDetail.itemId) ?? null
|
||||
: null;
|
||||
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter
|
||||
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter)
|
||||
: null;
|
||||
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
|
||||
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
|
||||
const giftCandidates = npcUi.giftModal
|
||||
? getGiftCandidates(gameState.playerInventory, npcUi.giftModal.encounter, {
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
})
|
||||
: [];
|
||||
|
||||
const handleTradeItemClick = (item: InventoryItem) => {
|
||||
if (tradeMode === 'buy') {
|
||||
npcUi.selectTradeNpcItem(item.id);
|
||||
setTradeDetail({ itemId: item.id, source: 'buy' });
|
||||
return;
|
||||
}
|
||||
|
||||
npcUi.selectTradePlayerItem(item.id);
|
||||
setTradeDetail({ itemId: item.id, source: 'sell' });
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{tradeModal && tradeNpcState && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-3 backdrop-blur-sm sm:p-4"
|
||||
onClick={npcUi.closeTradeModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">交易</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{tradeModal.encounter.npcName} / 你当前{currencyName}:{gameState.playerCurrency}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={npcUi.closeTradeModal}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => npcUi.setTradeMode('buy')}
|
||||
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
||||
tradeMode === 'buy'
|
||||
? 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
购买物品
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => npcUi.setTradeMode('sell')}
|
||||
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
||||
tradeMode === 'sell'
|
||||
? 'border-sky-400/45 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
出售物品
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400">
|
||||
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
|
||||
<span>{tradeItemList.length} 件</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
|
||||
{tradeItemList.length > 0 ? tradeItemList.map(item => (
|
||||
<div key={item.id}>
|
||||
<TradeItemRow
|
||||
item={item}
|
||||
selected={tradeMode === 'buy'
|
||||
? tradeModal.selectedNpcItemId === item.id
|
||||
: tradeModal.selectedPlayerItemId === item.id}
|
||||
unitPrice={tradeMode === 'buy'
|
||||
? getNpcPurchasePrice(item, tradeNpcState.affinity)
|
||||
: getNpcBuybackPrice(item, tradeNpcState.affinity)}
|
||||
currencyName={currencyName}
|
||||
onClick={() => handleTradeItemClick(item)}
|
||||
/>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||||
{tradeMode === 'buy' ? '对方暂时没有可出售的物品。' : '你当前没有可出售的物品。'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 p-3">
|
||||
{activeTradeItem ? (
|
||||
<div className="space-y-3">
|
||||
<TradeQuantityStepper
|
||||
quantity={activeTradeQuantity}
|
||||
maxQuantity={activeTradeMaxQuantity}
|
||||
onChange={npcUi.setTradeQuantity}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-200">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{tradeMode === 'buy' ? '购买总价' : '出售总价'}</span>
|
||||
<span className="font-semibold text-white">
|
||||
{formatCurrency(activeTradeTotalPrice, gameState.worldType)}
|
||||
</span>
|
||||
</div>
|
||||
{tradeMode === 'buy' && gameState.playerCurrency < activeTradeTotalPrice && (
|
||||
<div className="mt-2 text-xs text-rose-300">
|
||||
当前货币不足,还差 {formatCurrency(activeTradeTotalPrice - gameState.playerCurrency, gameState.worldType)}。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-8 text-center text-sm text-zinc-500">
|
||||
请选择一件物品,右侧会显示数量、价格与详情。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={npcUi.closeTradeModal}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canConfirmTrade}
|
||||
onClick={npcUi.confirmTrade}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canConfirmTrade ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tradeModal && tradeDetail && tradeDetailItem && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm"
|
||||
onClick={() => setTradeDetail(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">物品详情</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTradeDetail(null)}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
|
||||
<PixelIcon src={getItemVisualSrc(tradeDetailItem)} className="h-12 w-12" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-white">{tradeDetailItem.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{tradeDetailItem.category} / {getRarityLabel(tradeDetailItem.rarity)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-300">
|
||||
<div>库存: {tradeDetailItem.quantity}</div>
|
||||
<div>估值: {getInventoryItemValue(tradeDetailItem)}</div>
|
||||
<div>
|
||||
{tradeDetail.source === 'buy'
|
||||
? `购买价格: ${formatCurrency(getNpcPurchasePrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`
|
||||
: `回收价格: ${formatCurrency(getNpcBuybackPrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-zinc-300">
|
||||
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
|
||||
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||||
{tradeDetailEquipSlot ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}` : '不可装备'}
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||||
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||||
标签:{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tradeDetailEffectText && (
|
||||
<div className="rounded-lg border border-emerald-400/15 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100">
|
||||
使用效果:{tradeDetailEffectText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTradeDetail(null)}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{npcUi.giftModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={npcUi.closeGiftModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] 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()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">赠送礼物</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
|
||||
</div>
|
||||
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</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}
|
||||
type="button"
|
||||
onClick={() => npcUi.selectGiftItem(candidate.item.id)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.item.id ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<PixelIcon src={getItemVisualSrc(candidate.item)} className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{candidate.item.name}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div>
|
||||
{candidate.attributeInsight?.reasonText && (
|
||||
<div className="mt-1 text-[10px] text-rose-200/80">
|
||||
属性共振:{candidate.attributeInsight.reasonText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] text-rose-100">
|
||||
好感 +{candidate.affinityGain}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||||
当前没有适合送出的礼物。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 px-5 pb-5">
|
||||
<button type="button" onClick={npcUi.closeGiftModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={!npcUi.giftModal.selectedItemId} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.giftModal.selectedItemId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
确认赠礼
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{npcUi.recruitModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={npcUi.closeRecruitModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">调整同行位置</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。</div>
|
||||
</div>
|
||||
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</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;
|
||||
return (
|
||||
<button
|
||||
key={companion.npcId}
|
||||
type="button"
|
||||
onClick={() => npcUi.selectRecruitRelease(companion.npcId)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.recruitModal?.selectedReleaseNpcId === companion.npcId ? 'border-amber-400/60 bg-amber-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
|
||||
>
|
||||
<div className="text-sm text-white">{character.name}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{character.title}</div>
|
||||
</button>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||||
当前没有可替换的同行角色。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 px-5 pb-5">
|
||||
<button type="button" onClick={npcUi.closeRecruitModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={!npcUi.recruitModal.selectedReleaseNpcId} onClick={npcUi.confirmRecruit} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||||
确认招募
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
20
src/components/PixelIcon.tsx
Normal file
20
src/components/PixelIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PixelIconProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function PixelIcon({ src, alt = '', className = '', style }: PixelIconProps) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
className={`shrink-0 object-contain ${className}`.trim()}
|
||||
style={{ imageRendering: 'pixelated', ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
src/components/ResolvedAssetImage.tsx
Normal file
27
src/components/ResolvedAssetImage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
|
||||
type ResolvedAssetImageProps = Omit<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetImage({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src);
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
if (!finalSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img {...rest} src={finalSrc} alt={alt} />;
|
||||
}
|
||||
275
src/components/SelectionCustomizationModals.tsx
Normal file
275
src/components/SelectionCustomizationModals.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldGenerationMode,
|
||||
} from '../types';
|
||||
|
||||
type BaseModalProps = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function SelectionModal({
|
||||
isOpen,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer = null,
|
||||
}: BaseModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-semibold text-white">{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterDraftModal(props: {
|
||||
isOpen: boolean;
|
||||
characterLabel: string;
|
||||
draftName: string;
|
||||
draftBackstory: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onBackstoryChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const {
|
||||
isOpen,
|
||||
characterLabel,
|
||||
draftName,
|
||||
draftBackstory,
|
||||
onNameChange,
|
||||
onBackstoryChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
error = null,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<SelectionModal
|
||||
isOpen={isOpen}
|
||||
title="角色自定义"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
确认进入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
当前角色:{characterLabel}
|
||||
</div>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">角色名字</div>
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="输入一个更贴合这次旅程的称呼"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">背景补充</div>
|
||||
<textarea
|
||||
value={draftBackstory}
|
||||
onChange={(event) => onBackstoryChange(event.target.value)}
|
||||
rows={6}
|
||||
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectionModal>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomWorldCreatorModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error?: string | null;
|
||||
} & (
|
||||
| {
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
creatorIntent?: never;
|
||||
onCreatorIntentChange?: never;
|
||||
generationMode?: never;
|
||||
onGenerationModeChange?: never;
|
||||
}
|
||||
| {
|
||||
draft?: never;
|
||||
onDraftChange?: never;
|
||||
creatorIntent: CustomWorldCreatorIntent;
|
||||
onCreatorIntentChange: (value: CustomWorldCreatorIntent) => void;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
onGenerationModeChange: (value: CustomWorldGenerationMode) => void;
|
||||
}
|
||||
);
|
||||
|
||||
function hasCreatorIntentProps(
|
||||
props: CustomWorldCreatorModalProps,
|
||||
): props is Extract<
|
||||
CustomWorldCreatorModalProps,
|
||||
{ creatorIntent: CustomWorldCreatorIntent }
|
||||
> {
|
||||
return 'creatorIntent' in props;
|
||||
}
|
||||
|
||||
export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isGenerating,
|
||||
progress,
|
||||
progressLabel,
|
||||
error = null,
|
||||
} = props;
|
||||
|
||||
const draftText = hasCreatorIntentProps(props)
|
||||
? props.creatorIntent.rawSettingText
|
||||
: props.draft;
|
||||
|
||||
const updateDraftText = (value: string) => {
|
||||
if (hasCreatorIntentProps(props)) {
|
||||
props.onCreatorIntentChange({
|
||||
...props.creatorIntent,
|
||||
rawSettingText: value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
props.onDraftChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectionModal
|
||||
isOpen={isOpen}
|
||||
title="创建自定义世界"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isGenerating}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? '生成中...' : '开始生成'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{hasCreatorIntentProps(props) ? (
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">生成模式</div>
|
||||
<select
|
||||
value={props.generationMode}
|
||||
onChange={(event) =>
|
||||
props.onGenerationModeChange(
|
||||
event.target.value as CustomWorldGenerationMode,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40"
|
||||
>
|
||||
<option value="fast">快速</option>
|
||||
<option value="full">完整</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="text-sm leading-7 text-zinc-300">
|
||||
用几句话描述世界观、核心矛盾、时代气质和你想体验的叙事方向。系统会据此生成可游玩的自定义世界。
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={draftText}
|
||||
onChange={(event) => updateDraftText(event.target.value)}
|
||||
rows={8}
|
||||
placeholder="例:一个被潮雾与失落列岛切碎的边境世界,旧盟约、沉船秘术与灯塔守望者纠缠在一起……"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
|
||||
{isGenerating ? (
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
|
||||
<span>{progressLabel}</span>
|
||||
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectionModal>
|
||||
);
|
||||
}
|
||||
267
src/components/SkillEffectPreview.tsx
Normal file
267
src/components/SkillEffectPreview.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
||||
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
|
||||
import { getScenePreset } from '../data/scenePresets';
|
||||
import { buildSkillEffects } from '../hooks/useCombatFlow';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatActionMode,
|
||||
CombatVisualEffect,
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { GameCanvas } from './GameCanvas';
|
||||
|
||||
export interface SkillEffectPreviewProps {
|
||||
mode: 'player' | 'npc';
|
||||
worldType: WorldType;
|
||||
character: Character;
|
||||
skill: CharacterSkillDefinition | null;
|
||||
targetMonsterId?: string | null;
|
||||
npcEncounter?: Encounter | null;
|
||||
targetCharacter?: Character | null;
|
||||
}
|
||||
|
||||
const PLAYER_X = 0;
|
||||
|
||||
function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefinition) {
|
||||
if (typeof skill.releaseDelayMs === 'number') return skill.releaseDelayMs;
|
||||
const animationDuration = getCharacterAnimationDurationMs(character, getSkillCasterAnimation(skill));
|
||||
return Math.min(260, Math.max(120, Math.round(animationDuration * 0.45)));
|
||||
}
|
||||
|
||||
function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: string | null) {
|
||||
const previewMonster = createSceneHostileNpcsFromIds(
|
||||
worldType,
|
||||
targetMonsterId ? [targetMonsterId] : [],
|
||||
PLAYER_X,
|
||||
)[0];
|
||||
|
||||
return previewMonster
|
||||
? {
|
||||
...previewMonster,
|
||||
xMeters: 3.2,
|
||||
animation: 'idle' as const,
|
||||
action: `${previewMonster.name}站稳架势,等待受击`,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function resetNpcPreviewMonster(monster: SceneHostileNpc) {
|
||||
return {
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
action: `${monster.name}准备出招`,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function SkillEffectPreview({
|
||||
mode,
|
||||
worldType,
|
||||
character,
|
||||
skill,
|
||||
targetMonsterId,
|
||||
npcEncounter,
|
||||
targetCharacter,
|
||||
}: SkillEffectPreviewProps) {
|
||||
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
|
||||
const fallbackTargetCharacter = useMemo(
|
||||
() => targetCharacter ?? ROLE_TEMPLATE_CHARACTERS.find(candidate => candidate.id !== character.id) ?? ROLE_TEMPLATE_CHARACTERS[0] ?? character,
|
||||
[character, targetCharacter],
|
||||
);
|
||||
|
||||
const initialMonsters = useMemo(() => {
|
||||
if (mode === 'player') {
|
||||
const monster = buildPreviewTargetMonster(worldType, targetMonsterId);
|
||||
return monster ? [monster] : [];
|
||||
}
|
||||
|
||||
if (!npcEncounter) return [];
|
||||
return [
|
||||
createNpcBattleMonster(
|
||||
npcEncounter,
|
||||
buildInitialNpcState(npcEncounter, worldType),
|
||||
'fight',
|
||||
{
|
||||
worldType,
|
||||
},
|
||||
),
|
||||
];
|
||||
}, [mode, npcEncounter, targetMonsterId, worldType]);
|
||||
|
||||
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
|
||||
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
|
||||
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(initialMonsters);
|
||||
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
|
||||
const [replayTick, setReplayTick] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSceneMonsters(initialMonsters);
|
||||
setPlayerAnimation(AnimationState.IDLE);
|
||||
setPlayerActionMode('idle');
|
||||
setActiveCombatEffects([]);
|
||||
setIsPlaying(false);
|
||||
}, [initialMonsters, skill?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skill || !scenePreset) return;
|
||||
|
||||
let active = true;
|
||||
const timers: number[] = [];
|
||||
const casterAnimation = getSkillCasterAnimation(skill);
|
||||
const delivery = getSkillDelivery(skill);
|
||||
const attackerFacing = mode === 'player' ? 'right' : 'left';
|
||||
const primaryMonster = initialMonsters[0] ?? null;
|
||||
|
||||
if (mode === 'player') {
|
||||
setPlayerAnimation(casterAnimation);
|
||||
setPlayerActionMode(delivery);
|
||||
setSceneMonsters(initialMonsters.map(monster => ({
|
||||
...monster,
|
||||
action: `${monster.name}正面承受${skill.name}的预览`,
|
||||
})));
|
||||
} else {
|
||||
setPlayerAnimation(AnimationState.IDLE);
|
||||
setPlayerActionMode('idle');
|
||||
setSceneMonsters(initialMonsters.map(monster => ({
|
||||
...resetNpcPreviewMonster(monster),
|
||||
action: `${monster.name}施展${skill.name}`,
|
||||
characterAnimation: casterAnimation,
|
||||
combatMode: delivery,
|
||||
})));
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
|
||||
const phases = primaryMonster
|
||||
? buildSkillEffects(
|
||||
{
|
||||
character,
|
||||
xMeters: mode === 'player' ? PLAYER_X : primaryMonster.xMeters,
|
||||
origin: mode === 'player' ? 'player' : 'monster',
|
||||
facing: attackerFacing,
|
||||
monsterId: mode === 'player' ? undefined : primaryMonster.id,
|
||||
},
|
||||
{
|
||||
xMeters: mode === 'player' ? primaryMonster.xMeters : PLAYER_X,
|
||||
origin: mode === 'player' ? 'monster' : 'player',
|
||||
monsterId: mode === 'player' ? primaryMonster.id : undefined,
|
||||
},
|
||||
skill,
|
||||
)
|
||||
: {
|
||||
cast: [] as CombatVisualEffect[],
|
||||
travel: [] as CombatVisualEffect[],
|
||||
impact: [] as CombatVisualEffect[],
|
||||
castDurationMs: 0,
|
||||
travelDurationMs: 0,
|
||||
impactDurationMs: 0,
|
||||
};
|
||||
|
||||
const releaseDelay = (skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(character, skill)
|
||||
: getCharacterAnimationDurationMs(character, casterAnimation);
|
||||
|
||||
let delay = releaseDelay;
|
||||
|
||||
const schedule = (taskDelay: number, task: () => void) => {
|
||||
timers.push(window.setTimeout(() => {
|
||||
if (!active) return;
|
||||
task();
|
||||
}, taskDelay));
|
||||
};
|
||||
|
||||
if (phases.cast.length > 0) {
|
||||
schedule(delay, () => setActiveCombatEffects(phases.cast));
|
||||
delay += phases.castDurationMs;
|
||||
}
|
||||
|
||||
if (phases.travel.length > 0) {
|
||||
schedule(delay, () => setActiveCombatEffects(phases.travel));
|
||||
delay += phases.travelDurationMs;
|
||||
}
|
||||
|
||||
if (phases.impact.length > 0) {
|
||||
schedule(delay, () => {
|
||||
setActiveCombatEffects(phases.impact);
|
||||
if (mode === 'player') {
|
||||
setSceneMonsters(current => current.map(monster => ({
|
||||
...monster,
|
||||
action: `${monster.name}被${skill.name}命中`,
|
||||
})));
|
||||
}
|
||||
});
|
||||
delay += phases.impactDurationMs;
|
||||
}
|
||||
|
||||
schedule(delay, () => {
|
||||
setActiveCombatEffects([]);
|
||||
setPlayerAnimation(AnimationState.IDLE);
|
||||
setPlayerActionMode('idle');
|
||||
setSceneMonsters(initialMonsters.map(monster => resetNpcPreviewMonster(monster)));
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
timers.forEach(timerId => window.clearTimeout(timerId));
|
||||
};
|
||||
}, [character, initialMonsters, mode, replayTick, scenePreset, skill]);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{skill?.name ?? '未选择技能'}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplayTick(value => value + 1)}
|
||||
disabled={!skill || isPlaying}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-200 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>{isPlaying ? '播放中' : '重播预览'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<div className="h-[300px]">
|
||||
<GameCanvas
|
||||
scrollWorld={false}
|
||||
animationState={playerAnimation}
|
||||
playerCharacter={fallbackTargetCharacter && mode === 'npc' ? fallbackTargetCharacter : character}
|
||||
encounter={null}
|
||||
currentScenePreset={scenePreset}
|
||||
worldType={worldType}
|
||||
customWorldProfile={null}
|
||||
storyEngineMemory={null}
|
||||
sceneHostileNpcs={sceneHostileNpcs}
|
||||
playerX={PLAYER_X}
|
||||
playerOffsetY={0}
|
||||
playerFacing="right"
|
||||
playerActionMode={mode === 'player' ? playerActionMode : 'idle'}
|
||||
inBattle
|
||||
playerHp={180}
|
||||
playerMaxHp={180}
|
||||
activeCombatEffects={activeCombatEffects}
|
||||
onSceneNameClick={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1113
src/components/asset-studio/characterAssetWorkflowModel.ts
Normal file
1113
src/components/asset-studio/characterAssetWorkflowModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
271
src/components/asset-studio/characterAssetWorkflowPersistence.ts
Normal file
271
src/components/asset-studio/characterAssetWorkflowPersistence.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
ASSET_API_PATHS,
|
||||
postApiJson,
|
||||
} from '../../editor/shared/editorApiClient';
|
||||
import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
|
||||
export const CHARACTER_VISUAL_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualGenerate;
|
||||
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
|
||||
ASSET_API_PATHS.characterWorkflowCache;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualPublish;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
|
||||
export const CHARACTER_ANIMATION_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationGenerate;
|
||||
export const CHARACTER_ANIMATION_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationPublish;
|
||||
export const CHARACTER_ANIMATION_JOB_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationJobs;
|
||||
export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationImportVideo;
|
||||
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationTemplates;
|
||||
|
||||
export type CharacterVisualSourceMode =
|
||||
| 'text-to-image'
|
||||
| 'image-to-image'
|
||||
| 'upload';
|
||||
|
||||
export type CharacterAnimationStrategy =
|
||||
| 'image-sequence'
|
||||
| 'image-to-video'
|
||||
| 'motion-transfer'
|
||||
| 'reference-to-video';
|
||||
|
||||
export type CharacterMotionTransferModel =
|
||||
| 'wan2.2-animate-move'
|
||||
| 'wan2.2-animate-mix';
|
||||
|
||||
export type CharacterVisualDraft = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type CharacterAssetWorkflowCache = {
|
||||
characterId: string;
|
||||
cacheScopeId?: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
animationPromptTextByKey?: Record<string, string>;
|
||||
visualDrafts: CharacterVisualDraft[];
|
||||
selectedVisualDraftId: string;
|
||||
selectedAnimation: string;
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type CharacterVisualGenerationPayload = {
|
||||
characterId: string;
|
||||
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
promptText: string;
|
||||
referenceImageDataUrls: string[];
|
||||
candidateCount: number;
|
||||
imageModel: string;
|
||||
size: string;
|
||||
};
|
||||
|
||||
export type CharacterVisualPublishPayload = {
|
||||
characterId: string;
|
||||
sourceMode: CharacterVisualSourceMode;
|
||||
promptText: string;
|
||||
selectedPreviewSource: string;
|
||||
previewSources: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
updateCharacterOverride?: boolean;
|
||||
};
|
||||
|
||||
export type CharacterAnimationGenerationPayload = {
|
||||
characterId: string;
|
||||
strategy: CharacterAnimationStrategy;
|
||||
animation: string;
|
||||
promptText: string;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
visualSource: string;
|
||||
referenceImageDataUrls: string[];
|
||||
referenceVideoDataUrls: string[];
|
||||
lastFrameImageDataUrl?: string;
|
||||
frameCount: number;
|
||||
fps: number;
|
||||
durationSeconds: number;
|
||||
loop: boolean;
|
||||
useChromaKey: boolean;
|
||||
resolution: string;
|
||||
ratio: string;
|
||||
imageSequenceModel: string;
|
||||
videoModel: string;
|
||||
referenceVideoModel: string;
|
||||
motionTransferModel: CharacterMotionTransferModel;
|
||||
};
|
||||
|
||||
export type CharacterAnimationDraftPayload = {
|
||||
framesDataUrls: string[];
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
frameCount?: number;
|
||||
applyChromaKey?: boolean;
|
||||
sampleStartRatio?: number;
|
||||
sampleEndRatio?: number;
|
||||
previewVideoPath?: string;
|
||||
};
|
||||
|
||||
export type CharacterAnimationTemplate = {
|
||||
id: string;
|
||||
label: string;
|
||||
animation: string;
|
||||
promptSuffix: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type CharacterAssetJobStatus = {
|
||||
taskId: string;
|
||||
kind: 'visual' | 'animation';
|
||||
status: 'queued' | 'running' | 'completed' | 'failed';
|
||||
characterId: string;
|
||||
animation?: string;
|
||||
strategy?: CharacterAnimationStrategy;
|
||||
model: string;
|
||||
prompt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
result?: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export async function generateCharacterVisualCandidates(
|
||||
payload: CharacterVisualGenerationPayload,
|
||||
) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
taskId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
drafts: CharacterVisualDraft[];
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterWorkflowCache(
|
||||
characterId: string,
|
||||
cacheScopeId?: string,
|
||||
) {
|
||||
return fetchJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache | null;
|
||||
}>(
|
||||
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}${
|
||||
cacheScopeId
|
||||
? `?cacheScopeId=${encodeURIComponent(cacheScopeId)}`
|
||||
: ''
|
||||
}`,
|
||||
'读取角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCharacterWorkflowCache(
|
||||
payload: CharacterAssetWorkflowCache,
|
||||
) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache;
|
||||
saveMessage: string;
|
||||
}>(
|
||||
CHARACTER_WORKFLOW_CACHE_API_PATH,
|
||||
payload,
|
||||
'保存角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
return fetchJson<CharacterAssetJobStatus>(
|
||||
`${CHARACTER_VISUAL_JOB_API_PATH}/${encodeURIComponent(taskId)}`,
|
||||
'读取角色主形象任务状态失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishCharacterVisualAsset(
|
||||
payload: CharacterVisualPublishPayload,
|
||||
) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
assetId: string;
|
||||
portraitPath: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
}>(CHARACTER_VISUAL_PUBLISH_API_PATH, payload, '发布角色主形象失败');
|
||||
}
|
||||
|
||||
export async function generateCharacterAnimationDraft(
|
||||
payload: CharacterAnimationGenerationPayload,
|
||||
) {
|
||||
return postApiJson<
|
||||
| {
|
||||
ok: true;
|
||||
taskId: string;
|
||||
strategy: 'image-sequence';
|
||||
model: string;
|
||||
prompt: string;
|
||||
imageSources: string[];
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
taskId: string;
|
||||
strategy: 'image-to-video' | 'motion-transfer' | 'reference-to-video';
|
||||
model: string;
|
||||
prompt: string;
|
||||
previewVideoPath: string;
|
||||
}
|
||||
>(CHARACTER_ANIMATION_GENERATE_API_PATH, payload, '生成角色动作草稿失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterAnimationJobStatus(taskId: string) {
|
||||
return fetchJson<CharacterAssetJobStatus>(
|
||||
`${CHARACTER_ANIMATION_JOB_API_PATH}/${encodeURIComponent(taskId)}`,
|
||||
'读取角色动作任务状态失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterAnimationTemplates() {
|
||||
return fetchJson<{
|
||||
ok: true;
|
||||
templates: CharacterAnimationTemplate[];
|
||||
}>(CHARACTER_ANIMATION_TEMPLATES_API_PATH, '读取动作模板列表失败');
|
||||
}
|
||||
|
||||
export async function importCharacterAnimationVideo(payload: {
|
||||
characterId: string;
|
||||
animation: string;
|
||||
videoSource: string;
|
||||
sourceLabel?: string;
|
||||
}) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
importedVideoPath: string;
|
||||
draftId: string;
|
||||
saveMessage: string;
|
||||
}>(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, payload, '导入动作视频失败');
|
||||
}
|
||||
|
||||
export async function publishCharacterAnimationAssets(payload: {
|
||||
characterId: string;
|
||||
visualAssetId: string;
|
||||
animations: Record<string, CharacterAnimationDraftPayload>;
|
||||
updateCharacterOverride?: boolean;
|
||||
}) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
animationSetId: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
animationMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
}>(CHARACTER_ANIMATION_PUBLISH_API_PATH, payload, '发布角色基础动作失败');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
|
||||
|
||||
describe('buildDefaultRolePromptBundle', () => {
|
||||
it('uses model-generated role descriptions directly', () => {
|
||||
const result = buildDefaultRolePromptBundle({
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '边路同行者',
|
||||
visualDescription:
|
||||
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
|
||||
actionDescription:
|
||||
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
|
||||
sceneVisualDescription:
|
||||
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
|
||||
description: '熟悉裂潮边路的灰炬向导。',
|
||||
});
|
||||
|
||||
expect(result.visualPromptText).toBe(
|
||||
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
|
||||
);
|
||||
expect(result.animationPromptText).toBe(
|
||||
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
|
||||
);
|
||||
expect(result.scenePromptText).toBe(
|
||||
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to existing entity descriptions without assembling new rules', () => {
|
||||
const result = buildDefaultRolePromptBundle({
|
||||
name: '顾潮音',
|
||||
title: '港口守望者',
|
||||
role: '场景角色',
|
||||
description: '总在潮雾港高处盯着来往船影的守望者。',
|
||||
personality: '寡言、敏锐、先看人再开口。',
|
||||
combatStyle: '长枪封线后借高差压制。',
|
||||
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
|
||||
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
|
||||
tags: ['潮雾港', '守望', '旧案'],
|
||||
});
|
||||
|
||||
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
|
||||
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
|
||||
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
|
||||
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
|
||||
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
|
||||
expect(result.visualPromptText).not.toContain('提示词');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from '../../prompts/customWorldRolePromptDefaults';
|
||||
75
src/components/asset-studio/projectPixelStyleReference.ts
Normal file
75
src/components/asset-studio/projectPixelStyleReference.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const PROJECT_PIXEL_STYLE_REFERENCE_SOURCES = [
|
||||
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
] as const;
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function buildProjectPixelStyleReferenceBoard(
|
||||
sources = PROJECT_PIXEL_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
277
src/components/auth/AccountModal.test.tsx
Normal file
277
src/components/auth/AccountModal.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '138****8000',
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
user?: AuthUser;
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
initialSection?:
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs'
|
||||
| null;
|
||||
}) {
|
||||
return render(
|
||||
<AccountModal
|
||||
user={overrides?.user ?? baseUser}
|
||||
isOpen
|
||||
initialSection={overrides?.initialSection ?? null}
|
||||
platformTheme="light"
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
sessions={overrides?.sessions ?? []}
|
||||
auditLogs={overrides?.auditLogs ?? []}
|
||||
loadingRiskBlocks={false}
|
||||
loadingSessions={false}
|
||||
loadingAuditLogs={false}
|
||||
isHydratingSettings={false}
|
||||
isPersistingSettings={false}
|
||||
settingsError={null}
|
||||
onClose={vi.fn()}
|
||||
onPlatformThemeChange={vi.fn()}
|
||||
onLogout={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
|
||||
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
||||
onChangePassword={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
|
||||
expect(screen.getByText('设置与账号安全')).toBeTruthy();
|
||||
expect(screen.queryByText('138****8000')).toBeNull();
|
||||
expect(screen.queryByText('选择要管理的内容')).toBeNull();
|
||||
expect(
|
||||
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText(/^安全状态$/)).toBeNull();
|
||||
expect(screen.queryByText(/^登录设备$/)).toBeNull();
|
||||
expect(screen.queryByText(/^操作记录$/)).toBeNull();
|
||||
expect(screen.queryByText('当前账号状态')).toBeNull();
|
||||
expect(screen.queryByText('当前主题')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText('新手机号')).toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
||||
);
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
|
||||
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('nested settings panels keep back navigation without an extra close action', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
accountHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
||||
);
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
const changePhoneHeader =
|
||||
changePhoneDialog.firstElementChild as HTMLElement | null;
|
||||
expect(
|
||||
within(changePhoneDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
|
||||
expect(document.activeElement).not.toBe(accountTrigger);
|
||||
|
||||
await user.click(accountTrigger);
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountBackButton = within(accountDialog).getByRole('button', {
|
||||
name: '返回',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(accountBackButton);
|
||||
});
|
||||
|
||||
const changePhoneTrigger = within(accountDialog).getByRole('button', {
|
||||
name: '更换手机号',
|
||||
});
|
||||
await user.click(changePhoneTrigger);
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
|
||||
name: '返回',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(changePhoneBackButton);
|
||||
});
|
||||
|
||||
await user.click(changePhoneBackButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(changePhoneTrigger);
|
||||
});
|
||||
|
||||
await user.click(accountBackButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(accountTrigger);
|
||||
});
|
||||
});
|
||||
|
||||
test('account panel includes merged security devices and audit sections', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal({
|
||||
riskBlocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护',
|
||||
detail: '检测到异常验证行为,已开启保护。',
|
||||
remainingSeconds: 600,
|
||||
expiresAt: '2026-04-20T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
clientType: 'mobile',
|
||||
clientRuntime: 'ios',
|
||||
clientPlatform: 'wechat',
|
||||
clientLabel: 'iPhone 15 Pro',
|
||||
deviceDisplayName: 'iPhone 15 Pro / 微信',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-20T07:30:00.000Z',
|
||||
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
||||
expiresAt: '2026-04-27T09:00:00.000Z',
|
||||
ipMasked: '10.0.*.*',
|
||||
},
|
||||
],
|
||||
auditLogs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
eventType: 'phone_login',
|
||||
title: '登录成功',
|
||||
detail: '通过手机号验证码完成登录。',
|
||||
createdAt: '2026-04-20T08:00:00.000Z',
|
||||
ipMasked: '10.0.*.*',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出登录' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('legacy nested section requests now open the merged account panel', () => {
|
||||
renderAccountModal({ initialSection: 'security' });
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
});
|
||||
1023
src/components/auth/AccountModal.tsx
Normal file
1023
src/components/auth/AccountModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
472
src/components/auth/AuthGate.test.tsx
Normal file
472
src/components/auth/AuthGate.test.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
authEntry: authMocks.authEntry,
|
||||
bindWechatPhone: vi.fn(),
|
||||
changePassword: authMocks.changePassword,
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone: vi.fn(),
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGameSettings', () => ({
|
||||
useGameSettings: () => ({
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
hasHydratedSettings: true,
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
}));
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.authEntry.mockResolvedValue(mockUser);
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||
authMocks.resetPassword.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function ProtectedActionButton({
|
||||
onAuthenticated,
|
||||
}: {
|
||||
onAuthenticated: () => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authUi?.requireAuth(onAuthenticated);
|
||||
}}
|
||||
>
|
||||
进入作品
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformTabStateProbe() {
|
||||
const [tab, setTab] = useState<'home' | 'create'>('home');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>当前Tab:{tab === 'home' ? '首页' : '创作'}</div>
|
||||
<button type="button" onClick={() => setTab('create')}>
|
||||
创作
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutStateProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
|
||||
<div>
|
||||
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void authUi?.logout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate waits for access token refresh before exposing restored user content', async () => {
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
|
||||
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
|
||||
|
||||
resolveToken('jwt-restored-token');
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={onAuthenticated} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'123456',
|
||||
);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<PlatformTabStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前Tab:首页')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('正在校验登录状态...')).toBeNull();
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveToken('jwt-refreshed-token');
|
||||
await tokenPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logout withdraws user context before backend request finishes', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
let resolveLogout!: () => void;
|
||||
const logoutPromise = new Promise<void>((resolve) => {
|
||||
resolveLogout = resolve;
|
||||
});
|
||||
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '退出登录' }));
|
||||
|
||||
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
|
||||
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveLogout();
|
||||
await logoutPromise;
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.sendPhoneLoginCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'login',
|
||||
{
|
||||
challengeId: undefined,
|
||||
answer: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login modal resets draft state every time it is reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'password'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '获取验证码' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await within(firstDialog).findByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
|
||||
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '忘记密码' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入作品' }));
|
||||
|
||||
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(
|
||||
within(reopenedDialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).queryByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate separates sms and password login by tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'password'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(within(dialog).queryByLabelText('密码')).toBeNull();
|
||||
|
||||
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
|
||||
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('tab', { name: '密码登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||
});
|
||||
});
|
||||
763
src/components/auth/AuthGate.tsx
Normal file
763
src/components/auth/AuthGate.tsx
Normal file
@@ -0,0 +1,763 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
ensureStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
authEntry,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AuthStatus =
|
||||
| 'checking'
|
||||
| 'recovering'
|
||||
| 'unauthenticated'
|
||||
| 'pending_bind_phone'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [availableLoginMethods, setAvailableLoginMethods] = useState<
|
||||
AuthLoginMethod[]
|
||||
>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
|
||||
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
|
||||
const hasRenderedPlatformContentRef = useRef(false);
|
||||
const canKeepPlatformContentMounted =
|
||||
hasRenderedPlatformContentRef.current &&
|
||||
(status === 'checking' || status === 'recovering');
|
||||
const readyUser =
|
||||
status === 'ready' || canKeepPlatformContentMounted ? user : null;
|
||||
const settings = useGameSettings(readyUser?.id ?? null);
|
||||
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
|
||||
|
||||
if (status === 'ready' || status === 'unauthenticated') {
|
||||
hasRenderedPlatformContentRef.current = true;
|
||||
}
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, []);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(() => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const logoutCurrentSession = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
try {
|
||||
await logoutAuthUser();
|
||||
} catch (logoutError) {
|
||||
setError(
|
||||
logoutError instanceof Error
|
||||
? logoutError.message
|
||||
: '退出登录失败,请刷新页面确认状态。',
|
||||
);
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
const logoutAllSessions = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
try {
|
||||
await logoutAllAuthSessions();
|
||||
} catch (logoutError) {
|
||||
setError(
|
||||
logoutError instanceof Error
|
||||
? logoutError.message
|
||||
: '退出全部设备失败,请刷新页面确认状态。',
|
||||
);
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
const closeLoginModal = useCallback(() => {
|
||||
pendingProtectedActionRef.current = null;
|
||||
setShowLoginModal(false);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const openLoginModal = useCallback(
|
||||
(postLoginAction?: (() => void) | null) => {
|
||||
if (readyUser) {
|
||||
postLoginAction?.();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||
setShowLoginModal(true);
|
||||
},
|
||||
[readyUser],
|
||||
);
|
||||
|
||||
const requireAuth = useCallback(
|
||||
(action: () => void) => {
|
||||
openLoginModal(action);
|
||||
},
|
||||
[openLoginModal],
|
||||
);
|
||||
|
||||
const openSettingsModal = useCallback(
|
||||
(section?: PlatformSettingsSection) => {
|
||||
if (readyUser) {
|
||||
setInitialSettingsSection(section ?? null);
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
openLoginModal();
|
||||
},
|
||||
[openLoginModal, readyUser],
|
||||
);
|
||||
|
||||
const openAccountModal = useCallback(() => {
|
||||
openSettingsModal('account');
|
||||
}, [openSettingsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const hydrate = async () => {
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods(options.availableLoginMethods);
|
||||
return options;
|
||||
};
|
||||
|
||||
const resolveGuestFallback = async () => {
|
||||
try {
|
||||
const options = await loadLoginOptions();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods([]);
|
||||
setUser(null);
|
||||
setError(
|
||||
optionsError instanceof Error
|
||||
? optionsError.message
|
||||
: '读取登录方式失败,请稍后再试。',
|
||||
);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureStoredAccessToken();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus(
|
||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||
? 'pending_bind_phone'
|
||||
: 'ready',
|
||||
);
|
||||
setError(callbackResult?.error ?? '');
|
||||
} catch {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await resolveGuestFallback();
|
||||
}
|
||||
};
|
||||
|
||||
void hydrate();
|
||||
|
||||
const handleAuthStateChange = () => {
|
||||
setStatus('checking');
|
||||
void hydrate();
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
};
|
||||
}, [activateReadyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyUser) {
|
||||
setShowSettingsModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowLoginModal(false);
|
||||
|
||||
const pendingAction = pendingProtectedActionRef.current;
|
||||
pendingProtectedActionRef.current = null;
|
||||
pendingAction?.();
|
||||
}, [readyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSettingsModal || status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
setLoadingRiskBlocks(true);
|
||||
setLoadingSessions(true);
|
||||
setLoadingAuditLogs(true);
|
||||
void getAuthRiskBlocks()
|
||||
.then((nextBlocks) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setRiskBlocks(nextBlocks);
|
||||
})
|
||||
.catch((blockError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingRiskBlocks(false);
|
||||
});
|
||||
void getAuthSessions()
|
||||
.then((nextSessions) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setSessions(nextSessions);
|
||||
})
|
||||
.catch((sessionError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingSessions(false);
|
||||
});
|
||||
|
||||
void getAuthAuditLogs()
|
||||
.then((nextLogs) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setAuditLogs(nextLogs);
|
||||
})
|
||||
.catch((auditError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingAuditLogs(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [showSettingsModal, status]);
|
||||
|
||||
const authUiValue = useMemo(
|
||||
() => ({
|
||||
user: readyUser,
|
||||
// 平台内容在 checking/recovering 阶段可以继续挂载,避免闪烁;
|
||||
// 但受保护请求只能在真实 ready 且存在用户时再启动。
|
||||
canAccessProtectedData: status === 'ready' && Boolean(readyUser),
|
||||
openLoginModal,
|
||||
requireAuth,
|
||||
openSettingsModal,
|
||||
openAccountModal,
|
||||
logout: logoutCurrentSession,
|
||||
musicVolume: settings.musicVolume,
|
||||
setMusicVolume: settings.setMusicVolume,
|
||||
platformTheme: settings.platformTheme,
|
||||
setPlatformTheme: settings.setPlatformTheme,
|
||||
isHydratingSettings: settings.isHydratingSettings,
|
||||
isPersistingSettings: settings.isPersistingSettings,
|
||||
settingsError: settings.settingsError,
|
||||
}),
|
||||
[
|
||||
openAccountModal,
|
||||
openLoginModal,
|
||||
openSettingsModal,
|
||||
readyUser,
|
||||
requireAuth,
|
||||
logoutCurrentSession,
|
||||
status,
|
||||
settings.isHydratingSettings,
|
||||
settings.isPersistingSettings,
|
||||
settings.musicVolume,
|
||||
settings.platformTheme,
|
||||
settings.setMusicVolume,
|
||||
settings.setPlatformTheme,
|
||||
settings.settingsError,
|
||||
],
|
||||
);
|
||||
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
|
||||
>
|
||||
正在校验登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'recovering' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
|
||||
>
|
||||
正在恢复登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'pending_bind_phone' && user) {
|
||||
return (
|
||||
<BindPhoneScreen
|
||||
user={user}
|
||||
platformTheme={settings.platformTheme}
|
||||
sendingCode={sendingCode}
|
||||
binding={bindingPhone}
|
||||
error={error}
|
||||
captchaChallenge={bindCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'bind_phone',
|
||||
captcha,
|
||||
);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setBindCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setBindingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await bindWechatPhone(phone, code);
|
||||
setBindCaptchaChallenge(null);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (bindError) {
|
||||
setError(
|
||||
bindError instanceof Error
|
||||
? bindError.message
|
||||
: '绑定手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setBindingPhone(false);
|
||||
}
|
||||
}}
|
||||
onLogout={async () => {
|
||||
await logoutCurrentSession();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
status !== 'ready' &&
|
||||
status !== 'unauthenticated' &&
|
||||
!canKeepPlatformContentMounted
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
|
||||
>
|
||||
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
|
||||
<div className="text-base font-medium text-[var(--platform-text-strong)]">
|
||||
登录状态异常
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--primary mt-5"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
重新尝试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider value={authUiValue}>
|
||||
<div className="relative">
|
||||
<div className={`platform-theme ${platformThemeClass}`}>
|
||||
{readyUser ? (
|
||||
<AccountModal
|
||||
user={readyUser}
|
||||
isOpen={showSettingsModal}
|
||||
initialSection={initialSettingsSection}
|
||||
platformTheme={settings.platformTheme}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
isHydratingSettings={settings.isHydratingSettings}
|
||||
isPersistingSettings={settings.isPersistingSettings}
|
||||
settingsError={settings.settingsError}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
onPlatformThemeChange={settings.setPlatformTheme}
|
||||
onLogout={logoutCurrentSession}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter(
|
||||
(session) => session.sessionId !== sessionId,
|
||||
),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={logoutAllSessions}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge =
|
||||
getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
onChangePassword={async (currentPassword, newPassword) => {
|
||||
const nextUser = await changePassword(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<LoginScreen
|
||||
isOpen={showLoginModal}
|
||||
platformTheme={settings.platformTheme}
|
||||
availableLoginMethods={availableLoginMethods}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onClose={closeLoginModal}
|
||||
onSendCode={async (phone, scene, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, scene, captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge =
|
||||
getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onPhoneSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setStoredLastLoginPhone(phone);
|
||||
setLoginCaptchaChallenge(null);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onPasswordSubmit={async (phone, password) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await authEntry(phone, password);
|
||||
setStoredLastLoginPhone(phone);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onResetPassword={async (phone, code, newPassword) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await resetPassword(phone, code, newPassword);
|
||||
setStoredLastLoginPhone(phone);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (resetError) {
|
||||
setError(
|
||||
resetError instanceof Error
|
||||
? resetError.message
|
||||
: '重置密码失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
34
src/components/auth/AuthUiContext.ts
Normal file
34
src/components/auth/AuthUiContext.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
|
||||
export type PlatformSettingsSection =
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs';
|
||||
|
||||
type AuthUiContextValue = {
|
||||
user: AuthUser | null;
|
||||
canAccessProtectedData: boolean;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: PlatformTheme;
|
||||
setPlatformTheme: (theme: PlatformTheme) => void;
|
||||
isHydratingSettings: boolean;
|
||||
isPersistingSettings: boolean;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);
|
||||
|
||||
export function useAuthUi() {
|
||||
return useContext(AuthUiContext);
|
||||
}
|
||||
182
src/components/auth/BindPhoneScreen.tsx
Normal file
182
src/components/auth/BindPhoneScreen.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
user: AuthUser;
|
||||
platformTheme: PlatformTheme;
|
||||
sendingCode: boolean;
|
||||
binding: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onLogout: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function BindPhoneScreen({
|
||||
user,
|
||||
platformTheme,
|
||||
sendingCode,
|
||||
binding,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onLogout,
|
||||
}: BindPhoneScreenProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
账号激活
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost h-11 px-4 text-sm"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
返回其他登录方式
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
|
||||
type CaptchaChallengeFieldProps = {
|
||||
challenge: AuthCaptchaChallenge | null;
|
||||
answer: string;
|
||||
onAnswerChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CaptchaChallengeField({
|
||||
challenge,
|
||||
answer,
|
||||
onAnswerChange,
|
||||
}: CaptchaChallengeFieldProps) {
|
||||
if (!challenge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-banner platform-banner--info grid gap-3">
|
||||
<div className="text-sm leading-6">{challenge.promptText}</div>
|
||||
<img
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
|
||||
/>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
value={answer}
|
||||
placeholder="输入图形验证码"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
609
src/components/auth/LoginScreen.tsx
Normal file
609
src/components/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
type LoginTab = 'phone' | 'password';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
scene: SmsScene,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
||||
onResetPassword: (
|
||||
phone: string,
|
||||
code: string,
|
||||
newPassword: string,
|
||||
) => Promise<void>;
|
||||
onStartWechatLogin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
isOpen,
|
||||
platformTheme,
|
||||
availableLoginMethods,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onPhoneSubmit,
|
||||
onPasswordSubmit,
|
||||
onResetPassword,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
|
||||
const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [resetPhone, setResetPhone] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
const passwordLoginEnabled = availableLoginMethods.includes('password');
|
||||
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
|
||||
setIsResetPanelOpen(false);
|
||||
setPhone(getStoredLastLoginPhone());
|
||||
setPassword('');
|
||||
setCode('');
|
||||
setResetPhone('');
|
||||
setResetCode('');
|
||||
setResetPasswordValue('');
|
||||
setCaptchaAnswer('');
|
||||
setCooldownSeconds(0);
|
||||
setResetCooldownSeconds(0);
|
||||
setHint('');
|
||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeLoginTab === 'phone' &&
|
||||
!phoneLoginEnabled &&
|
||||
passwordLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeLoginTab === 'password' &&
|
||||
!passwordLoginEnabled &&
|
||||
phoneLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('phone');
|
||||
}
|
||||
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetCooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setResetCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [resetCooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submitDisabled = loggingIn || sendingCode;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-login-dialog-title"
|
||||
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="auth-login-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isResetPanelOpen ? (
|
||||
<PasswordResetPanel
|
||||
phone={resetPhone}
|
||||
code={resetCode}
|
||||
password={resetPasswordValue}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
cooldownSeconds={resetCooldownSeconds}
|
||||
error={error}
|
||||
onPhoneChange={setResetPhone}
|
||||
onCodeChange={setResetCode}
|
||||
onPasswordChange={setResetPasswordValue}
|
||||
onBack={() => setIsResetPanelOpen(false)}
|
||||
onSendCode={async () => {
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
{phoneLoginEnabled && passwordLoginEnabled ? (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="tablist"
|
||||
aria-label="登录方式"
|
||||
>
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'phone'}
|
||||
onClick={() => setActiveLoginTab('phone')}
|
||||
>
|
||||
短信登录
|
||||
</LoginTabButton>
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'password'}
|
||||
onClick={() => setActiveLoginTab('password')}
|
||||
>
|
||||
密码登录
|
||||
</LoginTabButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordLoginEnabled && activeLoginTab === 'password' ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitDisabled || !phone.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="self-end text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<WechatButton
|
||||
loading={wechatLoading}
|
||||
disabled={submitDisabled}
|
||||
onClick={onStartWechatLogin}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
captchaAnswer={captchaAnswer}
|
||||
captchaChallenge={captchaChallenge}
|
||||
cooldownSeconds={cooldownSeconds}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
onCaptchaAnswerChange={setCaptchaAnswer}
|
||||
onSendCode={async () => {
|
||||
setHint('');
|
||||
const result = await onSendCode(phone, 'login', {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
}}
|
||||
onSubmit={() => onPhoneSubmit(phone, code)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginTabButton({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
children: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
|
||||
active
|
||||
? 'text-[var(--platform-text-strong)]'
|
||||
: 'text-[var(--platform-text-muted)]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? (
|
||||
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCodeForm({
|
||||
phone,
|
||||
code,
|
||||
captchaAnswer,
|
||||
captchaChallenge,
|
||||
cooldownSeconds,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
error,
|
||||
hint,
|
||||
submitLabel,
|
||||
enabled,
|
||||
showPhoneField,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onCaptchaAnswerChange,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: {
|
||||
phone: string;
|
||||
code: string;
|
||||
captchaAnswer: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
cooldownSeconds: number;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
error: string;
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
showPhoneField: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onCaptchaAnswerChange: (value: string) => void;
|
||||
onSendCode: () => Promise<void>;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
{showPhoneField ? (
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={onCaptchaAnswerChange}
|
||||
/>
|
||||
|
||||
{hint ? <SuccessBanner message={hint} /> : null}
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordResetPanel({
|
||||
phone,
|
||||
code,
|
||||
password,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
cooldownSeconds,
|
||||
error,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onPasswordChange,
|
||||
onBack,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: {
|
||||
phone: string;
|
||||
code: string;
|
||||
password: string;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
cooldownSeconds: number;
|
||||
error: string;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onSendCode: () => Promise<void>;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>新密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
placeholder="设置新密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
loggingIn || !phone.trim() || !code.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : '重置密码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function WechatButton({
|
||||
loading,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || disabled}
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void onClick()}
|
||||
>
|
||||
{loading ? '跳转中' : '微信登录'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { BigFishAgentWorkspace } from './BigFishAgentWorkspace';
|
||||
|
||||
const baseSession: BigFishSessionSnapshotResponse = {
|
||||
sessionId: 'big-fish-session-1',
|
||||
currentTurn: 3,
|
||||
progressPercent: 64,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
gameplayPromise: {
|
||||
key: 'gameplayPromise',
|
||||
label: '玩法承诺',
|
||||
value: '从微光小鱼一路吞噬成长为深海巨兽',
|
||||
status: 'confirmed',
|
||||
},
|
||||
ecologyVisualTheme: {
|
||||
key: 'ecologyVisualTheme',
|
||||
label: '生态视觉主题',
|
||||
value: '幽蓝珊瑚海沟',
|
||||
status: 'confirmed',
|
||||
},
|
||||
growthLadder: {
|
||||
key: 'growthLadder',
|
||||
label: '成长阶梯',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
riskTempo: {
|
||||
key: 'riskTempo',
|
||||
label: '风险节奏',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
assetSlots: [],
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
requiredLevelCount: 8,
|
||||
publishReady: false,
|
||||
blockers: [],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '爽点和生态已经清楚,继续补剩余关键词。',
|
||||
createdAt: '2026-04-24T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '爽点和生态已经清楚,继续补剩余关键词。',
|
||||
publishReady: false,
|
||||
updatedAt: '2026-04-24T10:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
});
|
||||
|
||||
test('big fish workspace submits quick keyword fill request after two turns', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishAgentWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '补充剩余设定' }));
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('big fish workspace hides keyword fill before two turns', () => {
|
||||
render(
|
||||
<BigFishAgentWorkspace
|
||||
session={{ ...baseSession, currentTurn: 1 }}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
|
||||
});
|
||||
123
src/components/big-fish-creation/BigFishAgentWorkspace.tsx
Normal file
123
src/components/big-fish-creation/BigFishAgentWorkspace.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
BigFishAnchorItemResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
ExecuteBigFishActionRequest,
|
||||
SendBigFishMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentAnchorView,
|
||||
type CreationAgentSessionView,
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from '../creation-agent';
|
||||
|
||||
type BigFishAgentWorkspaceProps = {
|
||||
session: BigFishSessionSnapshotResponse | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendBigFishMessageRequest) => void;
|
||||
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
|
||||
};
|
||||
|
||||
const BIG_FISH_AGENT_THEME: CreationAgentTheme = {
|
||||
accentTextClass: 'text-cyan-100/85',
|
||||
accentBgClass: 'bg-cyan-200',
|
||||
accentButtonClass: 'bg-cyan-200 shadow-cyan-950/20',
|
||||
userBubbleClass: 'bg-cyan-600 text-white',
|
||||
heroClass:
|
||||
'border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.22),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.96),rgba(13,24,38,0.96))]',
|
||||
anchorGridClass: 'grid gap-2 sm:grid-cols-4',
|
||||
};
|
||||
|
||||
function mapBigFishAnchor(
|
||||
anchor: BigFishAnchorItemResponse,
|
||||
): CreationAgentAnchorView {
|
||||
return {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: anchor.value,
|
||||
status: anchor.status,
|
||||
};
|
||||
}
|
||||
|
||||
function mapBigFishSession(
|
||||
session: BigFishSessionSnapshotResponse,
|
||||
): CreationAgentSessionView {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
session.anchorPack.gameplayPromise,
|
||||
session.anchorPack.ecologyVisualTheme,
|
||||
session.anchorPack.growthLadder,
|
||||
session.anchorPack.riskTempo,
|
||||
].map(mapBigFishAnchor),
|
||||
messages: session.messages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function BigFishAgentWorkspace({
|
||||
session,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
}: BigFishAgentWorkspaceProps) {
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapBigFishSession(session) : null}
|
||||
theme={BIG_FISH_AGENT_THEME}
|
||||
loadingText="正在准备大鱼吃小鱼共创工作区..."
|
||||
composerPlaceholder="说说这局的生态、成长或爽点..."
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
||||
text,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'big_fish_compile_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前已经成形的大鱼吃小鱼设定。',
|
||||
);
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
||||
...quickActionMessage,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BigFishAgentWorkspace;
|
||||
173
src/components/big-fish-result/BigFishResultView.test.tsx
Normal file
173
src/components/big-fish-result/BigFishResultView.test.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { BigFishResultView } from './BigFishResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
function createSession(): BigFishSessionSnapshotResponse {
|
||||
return {
|
||||
sessionId: 'big-fish-session-1',
|
||||
currentTurn: 2,
|
||||
progressPercent: 88,
|
||||
stage: 'asset_refining',
|
||||
anchorPack: {
|
||||
gameplayPromise: {
|
||||
key: 'gameplayPromise',
|
||||
label: '玩法承诺',
|
||||
value: '弱小逆袭',
|
||||
status: 'confirmed',
|
||||
},
|
||||
ecologyVisualTheme: {
|
||||
key: 'ecologyVisualTheme',
|
||||
label: '生态与视觉母题',
|
||||
value: '深海谜境',
|
||||
status: 'confirmed',
|
||||
},
|
||||
growthLadder: {
|
||||
key: 'growthLadder',
|
||||
label: '成长阶梯',
|
||||
value: '8 级进化',
|
||||
status: 'confirmed',
|
||||
},
|
||||
riskTempo: {
|
||||
key: 'riskTempo',
|
||||
label: '风险节奏',
|
||||
value: '平衡',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
draft: {
|
||||
title: '深海谜境',
|
||||
subtitle: '逐级吞噬成长',
|
||||
coreFun: '弱小逆袭',
|
||||
ecologyTheme: '深海谜境',
|
||||
levels: [
|
||||
{
|
||||
level: 1,
|
||||
name: '荧潮幼体',
|
||||
oneLineFantasy: '在深海荧光裂谷中寻找第一个同伴。',
|
||||
silhouetteDirection: '圆润鱼苗',
|
||||
sizeRatio: 1,
|
||||
visualPromptSeed: '深海荧光幼体',
|
||||
motionPromptSeed: '轻微摆尾',
|
||||
mergeSourceLevel: null,
|
||||
preyWindow: [1],
|
||||
threatWindow: [2],
|
||||
isFinalLevel: false,
|
||||
},
|
||||
],
|
||||
background: {
|
||||
theme: '深海谜境',
|
||||
colorMood: '深蓝与青绿',
|
||||
foregroundHints: '漂浮微粒',
|
||||
midgroundComposition: '中央留白',
|
||||
backgroundDepth: '深海纵深',
|
||||
safePlayAreaHint: '中央 70%',
|
||||
spawnEdgeHint: '四周边缘',
|
||||
backgroundPromptSeed: '深海谜境背景',
|
||||
},
|
||||
runtimeParams: {
|
||||
levelCount: 1,
|
||||
mergeCountPerUpgrade: 3,
|
||||
spawnTargetCount: 12,
|
||||
leaderMoveSpeed: 160,
|
||||
followerCatchUpSpeed: 120,
|
||||
offscreenCullSeconds: 3,
|
||||
preySpawnDeltaLevels: [1],
|
||||
threatSpawnDeltaLevels: [1],
|
||||
winLevel: 1,
|
||||
},
|
||||
},
|
||||
assetSlots: [
|
||||
{
|
||||
slotId: 'big-fish-asset-level-main',
|
||||
assetKind: 'level_main_image',
|
||||
level: 1,
|
||||
motionKey: null,
|
||||
status: 'ready',
|
||||
assetUrl:
|
||||
'/generated-big-fish-assets/big-fish-session-1/level-main-image/level-1/image.png',
|
||||
promptSnapshot: '深海荧光幼体',
|
||||
updatedAt: '2026-04-23T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
slotId: 'big-fish-asset-background',
|
||||
assetKind: 'stage_background',
|
||||
level: null,
|
||||
motionKey: null,
|
||||
status: 'ready',
|
||||
assetUrl:
|
||||
'/generated-big-fish-assets/big-fish-session-1/stage-background/image.png',
|
||||
promptSnapshot: '深海谜境背景',
|
||||
updatedAt: '2026-04-23T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: true,
|
||||
requiredLevelCount: 1,
|
||||
publishReady: false,
|
||||
blockers: ['还缺少 2 个基础动作'],
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: '主图占位图已生成。',
|
||||
publishReady: false,
|
||||
updatedAt: '2026-04-23T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('BigFishResultView', () => {
|
||||
test('renders generated formal previews with accurate status copy', () => {
|
||||
render(
|
||||
<BigFishResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('主图 已生成')).toBeTruthy();
|
||||
expect(screen.getByAltText('荧潮幼体')).toBeTruthy();
|
||||
expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows publish failures in a dismissible modal', () => {
|
||||
const onDismissError = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishResultView
|
||||
session={createSession()}
|
||||
error="big_fish 发布校验未通过:还缺少 16 个基础动作"
|
||||
onBack={() => {}}
|
||||
onDismissError={onDismissError}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
expect(screen.getByText('发布失败')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
575
src/components/big-fish-result/BigFishResultView.tsx
Normal file
575
src/components/big-fish-result/BigFishResultView.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Play,
|
||||
Sparkles,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishAssetSlotResponse,
|
||||
BigFishGameDraftResponse,
|
||||
BigFishLevelBlueprintResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
ExecuteBigFishActionRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type BigFishAssetStudioTarget =
|
||||
| {
|
||||
kind: 'level_main_image';
|
||||
level: BigFishLevelBlueprintResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'level_motion';
|
||||
level: BigFishLevelBlueprintResponse;
|
||||
motionKey: 'idle_float' | 'move_swim';
|
||||
}
|
||||
| {
|
||||
kind: 'stage_background';
|
||||
};
|
||||
|
||||
type BigFishResultViewProps = {
|
||||
session: BigFishSessionSnapshotResponse;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onDismissError?: () => void;
|
||||
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
|
||||
onStartTestRun: () => void;
|
||||
};
|
||||
|
||||
function findAssetSlot(
|
||||
slots: BigFishAssetSlotResponse[],
|
||||
assetKind: string,
|
||||
level?: number,
|
||||
motionKey?: string,
|
||||
) {
|
||||
return slots.find((slot) => {
|
||||
if (slot.assetKind !== assetKind) {
|
||||
return false;
|
||||
}
|
||||
if (level !== undefined && slot.level !== level) {
|
||||
return false;
|
||||
}
|
||||
if (motionKey !== undefined && slot.motionKey !== motionKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function assetReadyLabel(slot: BigFishAssetSlotResponse | undefined) {
|
||||
if (slot?.status !== 'ready') {
|
||||
return '待生成';
|
||||
}
|
||||
return isBigFishPlaceholderAsset(slot) ? '占位已生成' : '已生成';
|
||||
}
|
||||
|
||||
function buildLevelAssetPreview(slot: BigFishAssetSlotResponse | undefined) {
|
||||
if (slot?.assetUrl) {
|
||||
return slot.assetUrl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBigFishPlaceholderAsset(slot: BigFishAssetSlotResponse | undefined) {
|
||||
return Boolean(slot?.assetUrl?.includes('/generated-big-fish/'));
|
||||
}
|
||||
|
||||
function buildStudioAssetPreview(
|
||||
slots: BigFishAssetSlotResponse[],
|
||||
target: BigFishAssetStudioTarget,
|
||||
) {
|
||||
if (target.kind === 'stage_background') {
|
||||
return buildLevelAssetPreview(findAssetSlot(slots, 'stage_background'));
|
||||
}
|
||||
if (target.kind === 'level_main_image') {
|
||||
return buildLevelAssetPreview(
|
||||
findAssetSlot(slots, 'level_main_image', target.level.level),
|
||||
);
|
||||
}
|
||||
return buildLevelAssetPreview(
|
||||
findAssetSlot(
|
||||
slots,
|
||||
'level_motion',
|
||||
target.level.level,
|
||||
target.motionKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishAssetStudioModal({
|
||||
draft,
|
||||
target,
|
||||
previewUrl,
|
||||
isBusy,
|
||||
onClose,
|
||||
onExecuteAction,
|
||||
}: {
|
||||
draft: BigFishGameDraftResponse;
|
||||
target: BigFishAssetStudioTarget;
|
||||
previewUrl?: string | null;
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
|
||||
}) {
|
||||
const title =
|
||||
target.kind === 'stage_background'
|
||||
? '场地背景工坊'
|
||||
: target.kind === 'level_main_image'
|
||||
? `Lv.${target.level.level} 主图工坊`
|
||||
: `Lv.${target.level.level} 动作工坊`;
|
||||
const prompt =
|
||||
target.kind === 'stage_background'
|
||||
? draft.background.backgroundPromptSeed
|
||||
: target.kind === 'level_main_image'
|
||||
? target.level.visualPromptSeed
|
||||
: `${target.level.motionPromptSeed} / ${target.motionKey}`;
|
||||
|
||||
const execute = () => {
|
||||
if (target.kind === 'stage_background') {
|
||||
onExecuteAction({ action: 'big_fish_generate_stage_background' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.kind === 'level_main_image') {
|
||||
onExecuteAction({
|
||||
action: 'big_fish_generate_level_main_image',
|
||||
level: target.level.level,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onExecuteAction({
|
||||
action: 'big_fish_generate_level_motion',
|
||||
level: target.level.level,
|
||||
motionKey: target.motionKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
{target.kind === 'stage_background'
|
||||
? draft.background.theme
|
||||
: target.level.oneLineFantasy}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
PROMPT
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{prompt}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex aspect-[9/5] items-center justify-center overflow-hidden rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40 text-sm text-[var(--platform-text-base)]">
|
||||
{previewUrl ? (
|
||||
<ResolvedAssetImage
|
||||
src={previewUrl}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
'AI 资产候选预览'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={execute}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
生成并应用正式图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishLevelCard({
|
||||
level,
|
||||
slots,
|
||||
isBusy,
|
||||
onOpenStudio,
|
||||
}: {
|
||||
level: BigFishLevelBlueprintResponse;
|
||||
slots: BigFishAssetSlotResponse[];
|
||||
isBusy: boolean;
|
||||
onOpenStudio: (target: BigFishAssetStudioTarget) => void;
|
||||
}) {
|
||||
const mainImageSlot = findAssetSlot(
|
||||
slots,
|
||||
'level_main_image',
|
||||
level.level,
|
||||
);
|
||||
const idleSlot = findAssetSlot(
|
||||
slots,
|
||||
'level_motion',
|
||||
level.level,
|
||||
'idle_float',
|
||||
);
|
||||
const moveSlot = findAssetSlot(
|
||||
slots,
|
||||
'level_motion',
|
||||
level.level,
|
||||
'move_swim',
|
||||
);
|
||||
const previewUrl = buildLevelAssetPreview(mainImageSlot);
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-white/78">
|
||||
<div className="flex gap-3 p-3">
|
||||
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white">
|
||||
{previewUrl ? (
|
||||
<ResolvedAssetImage
|
||||
src={previewUrl}
|
||||
alt={level.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Waves className="h-8 w-8 text-cyan-100/72" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-black tracking-[0.18em] text-cyan-700">
|
||||
LV.{level.level}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{level.name}
|
||||
</div>
|
||||
</div>
|
||||
{level.isFinalLevel ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
|
||||
终局
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
|
||||
{level.oneLineFantasy}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-[var(--platform-text-soft)]">
|
||||
<span>猎物 {level.preyWindow.join('/') || '-'}</span>
|
||||
<span>威胁 {level.threatWindow.join('/') || '-'}</span>
|
||||
<span>主图 {assetReadyLabel(mainImageSlot)}</span>
|
||||
<span>
|
||||
动作 {[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onOpenStudio({ kind: 'level_main_image', level });
|
||||
}}
|
||||
className="rounded-full bg-cyan-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
主图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onOpenStudio({
|
||||
kind: 'level_motion',
|
||||
level,
|
||||
motionKey: 'idle_float',
|
||||
});
|
||||
}}
|
||||
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
|
||||
>
|
||||
待机
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onOpenStudio({
|
||||
kind: 'level_motion',
|
||||
level,
|
||||
motionKey: 'move_swim',
|
||||
});
|
||||
}}
|
||||
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
|
||||
>
|
||||
移动
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function BigFishResultView({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onDismissError,
|
||||
onExecuteAction,
|
||||
onStartTestRun,
|
||||
}: BigFishResultViewProps) {
|
||||
const [studioTarget, setStudioTarget] =
|
||||
useState<BigFishAssetStudioTarget | null>(null);
|
||||
const draft = session.draft;
|
||||
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
|
||||
const backgroundPreviewUrl = buildLevelAssetPreview(backgroundSlot);
|
||||
const blockers = useMemo(
|
||||
() => session.assetCoverage.blockers.filter(Boolean),
|
||||
[session.assetCoverage.blockers],
|
||||
);
|
||||
const studioPreviewUrl = useMemo(() => {
|
||||
if (!studioTarget) {
|
||||
return null;
|
||||
}
|
||||
return buildStudioAssetPreview(session.assetSlots, studioTarget);
|
||||
}, [session.assetSlots, studioTarget]);
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
还没有可编辑的玩法草稿
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onStartTestRun();
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onExecuteAction({ action: 'big_fish_publish_game' });
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{draft.title}
|
||||
</div>
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-cyan-50/76">
|
||||
{draft.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78">
|
||||
<span className="rounded-full bg-white/10 px-3 py-1">
|
||||
{draft.coreFun}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1">
|
||||
{draft.ecologyTheme}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1">
|
||||
{draft.runtimeParams.levelCount} 级
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{draft.levels.map((level) => (
|
||||
<BigFishLevelCard
|
||||
key={level.level}
|
||||
level={level}
|
||||
slots={session.assetSlots}
|
||||
isBusy={isBusy}
|
||||
onOpenStudio={setStudioTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="min-h-0 space-y-3 overflow-y-auto">
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
场地背景
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--platform-text-soft)]">
|
||||
{assetReadyLabel(backgroundSlot)}
|
||||
</div>
|
||||
</div>
|
||||
<ImagePlus className="h-5 w-5 text-cyan-600" />
|
||||
</div>
|
||||
<div className="mt-3 aspect-[9/16] overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]">
|
||||
{backgroundPreviewUrl ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewUrl}
|
||||
alt={`${draft.background.theme} 场地背景`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setStudioTarget({ kind: 'stage_background' });
|
||||
}}
|
||||
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
生成背景
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
发布校验
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-[var(--platform-text-base)]">
|
||||
<div>
|
||||
主图 {session.assetCoverage.levelMainImageReadyCount}/
|
||||
{session.assetCoverage.requiredLevelCount}
|
||||
</div>
|
||||
<div>
|
||||
动作 {session.assetCoverage.levelMotionReadyCount}/
|
||||
{session.assetCoverage.requiredLevelCount * 2}
|
||||
</div>
|
||||
<div>
|
||||
背景 {session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
|
||||
</div>
|
||||
</div>
|
||||
{blockers.length > 0 ? (
|
||||
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
|
||||
{blockers.slice(0, 4).map((blocker) => (
|
||||
<div key={blocker}>{blocker}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm font-semibold text-emerald-600">
|
||||
已达到发布条件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{studioTarget ? (
|
||||
<BigFishAssetStudioModal
|
||||
draft={draft}
|
||||
target={studioTarget}
|
||||
previewUrl={studioPreviewUrl}
|
||||
isBusy={isBusy}
|
||||
onClose={() => {
|
||||
setStudioTarget(null);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
onExecuteAction(payload);
|
||||
setStudioTarget(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<BigFishResultErrorModal
|
||||
message={error}
|
||||
onClose={() => {
|
||||
onDismissError?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishResultErrorModal({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open
|
||||
title="发布失败"
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
showCloseButton={false}
|
||||
size="sm"
|
||||
zIndexClassName="z-[160]"
|
||||
overlayClassName="bg-slate-950/58"
|
||||
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
|
||||
bodyClassName="p-5"
|
||||
footer={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
)}
|
||||
footerClassName="border-t-0 px-5 pb-5 pt-0"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default BigFishResultView;
|
||||
349
src/components/big-fish-runtime/BigFishRuntimeShell.tsx
Normal file
349
src/components/big-fish-runtime/BigFishRuntimeShell.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type PointerEvent } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishAssetSlotResponse,
|
||||
BigFishRuntimeEntityResponse,
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type TouchOrigin = {
|
||||
pointerId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type BigFishRuntimeShellProps = {
|
||||
run: BigFishRuntimeSnapshotResponse | null;
|
||||
assetSlots?: BigFishAssetSlotResponse[];
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeVector(x: number, y: number) {
|
||||
const length = Math.hypot(x, y);
|
||||
if (length <= 0.001) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
const capped = Math.min(1, length);
|
||||
return {
|
||||
x: (x / length) * capped,
|
||||
y: (y / length) * capped,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDirectionFromOrigin(
|
||||
origin: TouchOrigin,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
const deadZone = 12;
|
||||
const deltaX = clientX - origin.x;
|
||||
const deltaY = clientY - origin.y;
|
||||
if (Math.hypot(deltaX, deltaY) < deadZone) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
return normalizeVector(deltaX, deltaY);
|
||||
}
|
||||
|
||||
function projectEntity(
|
||||
entity: BigFishRuntimeEntityResponse,
|
||||
run: BigFishRuntimeSnapshotResponse,
|
||||
) {
|
||||
const viewportWidth = 360;
|
||||
const viewportHeight = 640;
|
||||
const worldWidth = 420;
|
||||
const worldHeight = 760;
|
||||
const x =
|
||||
viewportWidth / 2 +
|
||||
((entity.position.x - run.cameraCenter.x) / worldWidth) * viewportWidth;
|
||||
const y =
|
||||
viewportHeight / 2 +
|
||||
((entity.position.y - run.cameraCenter.y) / worldHeight) * viewportHeight;
|
||||
|
||||
return {
|
||||
left: `${clamp(x, -40, viewportWidth + 40)}px`,
|
||||
top: `${clamp(y, -40, viewportHeight + 40)}px`,
|
||||
width: `${Math.max(22, entity.radius * 2.2)}px`,
|
||||
height: `${Math.max(22, entity.radius * 2.2)}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function findBigFishAssetSlot(
|
||||
slots: BigFishAssetSlotResponse[],
|
||||
assetKind: string,
|
||||
level?: number,
|
||||
motionKey?: string,
|
||||
) {
|
||||
return slots.find((slot) => {
|
||||
if (slot.assetKind !== assetKind || slot.status !== 'ready') {
|
||||
return false;
|
||||
}
|
||||
if (level !== undefined && slot.level !== level) {
|
||||
return false;
|
||||
}
|
||||
if (motionKey !== undefined && slot.motionKey !== motionKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRuntimeEntityAsset(
|
||||
entity: BigFishRuntimeEntityResponse,
|
||||
assetSlots: BigFishAssetSlotResponse[],
|
||||
) {
|
||||
return (
|
||||
findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'move_swim') ??
|
||||
findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'idle_float') ??
|
||||
findBigFishAssetSlot(assetSlots, 'level_main_image', entity.level)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) {
|
||||
if (run.status === 'won') {
|
||||
return {
|
||||
title: '通关完成',
|
||||
message: `已成长到 Lv.${run.playerLevel},本轮生态征服完成。`,
|
||||
tone: 'from-emerald-300/28 via-cyan-300/18 to-white/10',
|
||||
};
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
return {
|
||||
title: '本轮失败',
|
||||
message: '己方鱼群已经耗尽,重新调整路线再来一次。',
|
||||
tone: 'from-rose-300/30 via-orange-300/16 to-white/10',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function BigFishEntityDot({
|
||||
entity,
|
||||
run,
|
||||
owned,
|
||||
assetSlots,
|
||||
}: {
|
||||
entity: BigFishRuntimeEntityResponse;
|
||||
run: BigFishRuntimeSnapshotResponse;
|
||||
owned: boolean;
|
||||
assetSlots: BigFishAssetSlotResponse[];
|
||||
}) {
|
||||
const projected = projectEntity(entity, run);
|
||||
const isLeader = run.leaderEntityId === entity.entityId;
|
||||
const assetSlot = resolveRuntimeEntityAsset(entity, assetSlots);
|
||||
const entityImageSrc = assetSlot?.assetUrl?.trim() || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border shadow-lg transition-all ${
|
||||
entityImageSrc
|
||||
? owned
|
||||
? isLeader
|
||||
? 'border-cyan-50 shadow-cyan-950/40'
|
||||
: 'border-cyan-100/80 shadow-cyan-950/28'
|
||||
: entity.level > run.playerLevel
|
||||
? 'border-rose-100/80 shadow-rose-950/28'
|
||||
: 'border-emerald-100/80 shadow-emerald-950/24'
|
||||
: owned
|
||||
? isLeader
|
||||
? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30'
|
||||
: 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24'
|
||||
: entity.level > run.playerLevel
|
||||
? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24'
|
||||
: 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20'
|
||||
}`}
|
||||
style={projected}
|
||||
>
|
||||
{entityImageSrc ? (
|
||||
<>
|
||||
<ResolvedAssetImage
|
||||
src={entityImageSrc}
|
||||
alt={`Lv.${entity.level} 实体`}
|
||||
className={`h-full w-full object-cover ${
|
||||
owned && isLeader ? 'scale-110' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,transparent_32%,rgba(2,6,23,0.18)_72%,rgba(2,6,23,0.36)_100%)]" />
|
||||
</>
|
||||
) : null}
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-white [text-shadow:0_1px_2px_rgba(2,6,23,0.9)]">
|
||||
{entity.level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BigFishRuntimeShell({
|
||||
run,
|
||||
assetSlots = [],
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitInput,
|
||||
}: BigFishRuntimeShellProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(null);
|
||||
const [stick, setStick] = useState({ x: 0, y: 0 });
|
||||
const stickRef = useRef(stick);
|
||||
|
||||
useEffect(() => {
|
||||
stickRef.current = stick;
|
||||
}, [stick]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const current = stickRef.current;
|
||||
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
|
||||
onSubmitInput(current);
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [onSubmitInput]);
|
||||
|
||||
const submitDirection = (direction: SubmitBigFishInputRequest) => {
|
||||
setStick(direction);
|
||||
onSubmitInput(direction);
|
||||
};
|
||||
|
||||
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setTouchOrigin({
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
submitDirection({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const updateTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
submitDirection(
|
||||
resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY),
|
||||
);
|
||||
};
|
||||
|
||||
const endTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
setTouchOrigin(null);
|
||||
submitDirection({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在进入玩法
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusLabel =
|
||||
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
|
||||
const settlementCopy = resolveSettlementCopy(run);
|
||||
const backgroundAsset =
|
||||
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative h-full w-full max-w-[430px] touch-none overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]"
|
||||
onPointerDown={beginTouchControl}
|
||||
onPointerMove={updateTouchControl}
|
||||
onPointerUp={endTouchControl}
|
||||
onPointerCancel={endTouchControl}
|
||||
>
|
||||
{backgroundAsset ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundAsset}
|
||||
alt="大鱼吃小鱼场地背景"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(8,47,73,0.2),rgba(2,6,23,0.6))]" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:32px_32px] opacity-30" />
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 flex w-full items-center justify-between px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
|
||||
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 h-[640px] w-[360px] -translate-x-1/2 -translate-y-1/2">
|
||||
{run.wildEntities.map((entity) => (
|
||||
<BigFishEntityDot
|
||||
key={entity.entityId}
|
||||
entity={entity}
|
||||
run={run}
|
||||
owned={false}
|
||||
assetSlots={assetSlots}
|
||||
/>
|
||||
))}
|
||||
{run.ownedEntities.map((entity) => (
|
||||
<BigFishEntityDot
|
||||
key={entity.entityId}
|
||||
entity={entity}
|
||||
run={run}
|
||||
owned
|
||||
assetSlots={assetSlots}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{settlementCopy ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
|
||||
<div
|
||||
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
|
||||
>
|
||||
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
|
||||
{settlementCopy.title}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
|
||||
{settlementCopy.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
||||
{isBusy ? <div>同步中...</div> : null}
|
||||
{error ? <div className="text-rose-200">{error}</div> : null}
|
||||
{run.eventLog.slice(-3).map((event) => (
|
||||
<div key={event} className="rounded-full bg-black/22 px-3 py-1">
|
||||
{event}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BigFishRuntimeShell;
|
||||
58
src/components/common/UnifiedModal.test.tsx
Normal file
58
src/components/common/UnifiedModal.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
test('renders an accessible platform modal', () => {
|
||||
render(
|
||||
<UnifiedModal open title="统一弹窗" onClose={() => {}} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
|
||||
expect(screen.getByText('窗口内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('closes through backdrop and escape', () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('respects closeDisabled for every default close path', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UnifiedModal
|
||||
open
|
||||
title="生成中"
|
||||
onClose={onClose}
|
||||
closeDisabled
|
||||
portal={false}
|
||||
>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
220
src/components/common/UnifiedModal.tsx
Normal file
220
src/components/common/UnifiedModal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
type UnifiedModalVariant = 'platform' | 'pixel';
|
||||
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||
|
||||
type UnifiedModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
variant?: UnifiedModalVariant;
|
||||
size?: UnifiedModalSize;
|
||||
closeDisabled?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
closeLabel?: string;
|
||||
portal?: boolean;
|
||||
zIndexClassName?: string;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
footerClassName?: string;
|
||||
panelStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
const PLATFORM_SIZE_CLASS: Record<UnifiedModalSize, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
|
||||
};
|
||||
|
||||
const PIXEL_SIZE_CLASS: Record<UnifiedModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
fullscreen: 'max-w-[min(96vw,64rem)]',
|
||||
};
|
||||
|
||||
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getPanelStyle(
|
||||
variant: UnifiedModalVariant,
|
||||
panelStyle: CSSProperties | undefined,
|
||||
) {
|
||||
if (variant !== 'pixel') {
|
||||
return panelStyle;
|
||||
}
|
||||
|
||||
return {
|
||||
...getNineSliceStyle(UI_CHROME.modalPanel),
|
||||
...panelStyle,
|
||||
};
|
||||
}
|
||||
|
||||
function UnifiedModalContent({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
variant = 'platform',
|
||||
size = 'md',
|
||||
closeDisabled = false,
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
closeLabel = '关闭',
|
||||
zIndexClassName = 'z-[90]',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
headerClassName,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
panelStyle,
|
||||
}: Omit<UnifiedModalProps, 'portal'>) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || closeDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeDisabled, onClose, open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPixel = variant === 'pixel';
|
||||
const sizeClassName = isPixel
|
||||
? PIXEL_SIZE_CLASS[size]
|
||||
: PLATFORM_SIZE_CLASS[size];
|
||||
|
||||
const overlayClasses = isPixel
|
||||
? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
|
||||
: 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
|
||||
|
||||
const panelClasses = isPixel
|
||||
? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
|
||||
: 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
|
||||
|
||||
const headerClasses = isPixel
|
||||
? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
|
||||
: 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
|
||||
const titleClasses = isPixel
|
||||
? 'truncate text-sm font-semibold text-white'
|
||||
: 'text-base font-semibold text-[var(--platform-text-strong)]';
|
||||
|
||||
const descriptionClasses = isPixel
|
||||
? 'mt-1 text-xs leading-5 text-zinc-400'
|
||||
: 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
|
||||
|
||||
const bodyClasses = isPixel
|
||||
? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
|
||||
: 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
|
||||
|
||||
const footerClasses = isPixel
|
||||
? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
|
||||
: 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
|
||||
const closeButtonClasses = isPixel
|
||||
? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45'
|
||||
: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
closeOnBackdrop &&
|
||||
!closeDisabled &&
|
||||
event.target === event.currentTarget
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
|
||||
style={getPanelStyle(variant, panelStyle)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={joinClassNames(headerClasses, headerClassName)}>
|
||||
<div className="min-w-0">
|
||||
<div id={titleId} className={titleClasses}>
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div id={descriptionId} className={descriptionClasses}>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
onClick={onClose}
|
||||
disabled={closeDisabled}
|
||||
className={closeButtonClasses}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={joinClassNames(bodyClasses, bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className={joinClassNames(footerClasses, footerClassName)}>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一模态窗口外壳。
|
||||
* 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。
|
||||
*/
|
||||
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
|
||||
if (!portal || typeof document === 'undefined') {
|
||||
return <UnifiedModalContent {...props} />;
|
||||
}
|
||||
|
||||
return createPortal(<UnifiedModalContent {...props} />, document.body);
|
||||
}
|
||||
551
src/components/creation-agent/CreationAgentWorkspace.test.tsx
Normal file
551
src/components/creation-agent/CreationAgentWorkspace.test.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as creationAgentServices from '../../services/creation-agent';
|
||||
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from './CreationAgentWorkspace';
|
||||
|
||||
const testTheme: CreationAgentTheme = {
|
||||
accentTextClass: 'text-emerald-100',
|
||||
accentBgClass: 'bg-emerald-300',
|
||||
accentButtonClass: 'bg-emerald-200',
|
||||
userBubbleClass: 'bg-emerald-600 text-white',
|
||||
heroClass: 'border border-emerald-100/20 bg-slate-900',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function ensureScrollApis() {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
if (!HTMLElement.prototype.scrollTo) {
|
||||
HTMLElement.prototype.scrollTo = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
test('creation agent workspace keeps initial chat progress at zero percent', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
anchors: [],
|
||||
messages: [],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const progressbar = screen.getByRole('progressbar');
|
||||
|
||||
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
|
||||
expect(
|
||||
(progressbar.firstElementChild as HTMLElement | null)?.style.width,
|
||||
).toBe('0%');
|
||||
});
|
||||
|
||||
test('creation agent workspace filters duplicate recommended replies', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 40,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: '',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先把方向收一下。',
|
||||
},
|
||||
],
|
||||
recommendedReplies: [
|
||||
'',
|
||||
'继续补充冲突',
|
||||
'继续补充冲突',
|
||||
' 先确定玩家身份 ',
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('creation agent workspace renders streaming assistant text', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个潮湿压抑的海上世界。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/那我先顺着这个方向收一下/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace renders waiting dots before first streamed token', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个潮湿压抑的海上世界。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText=""
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('creation-agent-waiting-dots')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace appends streaming assistant message after stable message list', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 40,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-user-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个潮湿压抑的海上世界。',
|
||||
},
|
||||
{
|
||||
id: 'message-assistant-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '我先接住这个方向。',
|
||||
},
|
||||
{
|
||||
id: 'message-user-2',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '开场我想先撞上一场假航灯事故。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText="那我就把开场事故往沉船旧案上收。"
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const bubbles = screen
|
||||
.getByTestId('creation-agent-message-list')
|
||||
.querySelectorAll('.whitespace-pre-wrap');
|
||||
const bubbleTexts = Array.from(bubbles).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
);
|
||||
|
||||
expect(bubbleTexts).toEqual([
|
||||
'我想做一个潮湿压抑的海上世界。',
|
||||
'我先接住这个方向。',
|
||||
'开场我想先撞上一场假航灯事故。',
|
||||
'那我就把开场事故往沉船旧案上收。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('creation agent workspace hides anchors and primary action before completed progress', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 99,
|
||||
anchors: [
|
||||
{
|
||||
key: 'worldPromise',
|
||||
label: '世界承诺',
|
||||
value: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
status: 'confirmed',
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '我们继续把设定收住。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '生成结果页' })).toBeNull();
|
||||
expect(screen.queryByText('世界承诺')).toBeNull();
|
||||
expect(screen.queryByText('一个被潮雾改写航线秩序的群岛世界。')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation agent workspace shows primary and progress actions at completed progress', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '设定已经可以进入生成。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成结果页' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '总结当前设定' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '补充剩余设定' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: 2,
|
||||
progressPercent: 60,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '继续把设定收束到可生成状态。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('统一共创')).toBeNull();
|
||||
expect(screen.getByText('创作进度')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
const scrollToSpy = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollToSpy;
|
||||
|
||||
const { rerender } = render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先确定一下世界方向。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const messageList = screen.getByTestId('creation-agent-message-list');
|
||||
let scrollTop = 120;
|
||||
|
||||
Object.defineProperty(messageList, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
Object.defineProperty(messageList, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 240,
|
||||
});
|
||||
Object.defineProperty(messageList, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value) => {
|
||||
scrollTop = Number(value);
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.scroll(messageList);
|
||||
scrollToSpy.mockClear();
|
||||
|
||||
rerender(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先确定一下世界方向。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText="继续往下收束开场冲突。"
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation agent workspace appends parsed document text into composer', async () => {
|
||||
ensureScrollApis();
|
||||
|
||||
vi.spyOn(
|
||||
creationAgentServices,
|
||||
'parseCreationAgentDocumentInput',
|
||||
).mockResolvedValue({
|
||||
document: {
|
||||
fileName: '世界设定.md',
|
||||
contentType: 'text/markdown',
|
||||
sizeBytes: 24,
|
||||
text: '第一章:潮湿的港口',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
anchors: [],
|
||||
messages: [],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
|
||||
target: {
|
||||
value: '已有方向',
|
||||
},
|
||||
});
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
fireEvent.change(input!, {
|
||||
target: {
|
||||
files: [new File(['unused'], '世界设定.md', { type: 'text/markdown' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
|
||||
).toBe('已有方向\n\n第一章:潮湿的港口');
|
||||
});
|
||||
});
|
||||
|
||||
test('creation agent workspace shows document parse error near composer', async () => {
|
||||
ensureScrollApis();
|
||||
|
||||
vi.spyOn(
|
||||
creationAgentServices,
|
||||
'parseCreationAgentDocumentInput',
|
||||
).mockRejectedValue(new Error('暂时只支持 txt、md、csv、json 文本文档。'));
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
anchors: [],
|
||||
messages: [],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
fireEvent.change(input!, {
|
||||
target: {
|
||||
files: [new File(['unused'], '世界设定.docx')],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('暂时只支持 txt、md、csv、json 文本文档。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
612
src/components/creation-agent/CreationAgentWorkspace.tsx
Normal file
612
src/components/creation-agent/CreationAgentWorkspace.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type CreationAgentProgressCopy,
|
||||
normalizeCreationAgentProgress,
|
||||
parseCreationAgentDocumentInput,
|
||||
resolveCreationAgentProgressHint,
|
||||
} from '../../services/creation-agent';
|
||||
|
||||
export type CreationAgentAnchorView = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreationAgentMessageView = {
|
||||
id: string;
|
||||
role: string;
|
||||
kind?: string;
|
||||
text: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type CreationAgentOperationView = {
|
||||
operationId?: string;
|
||||
type?: string;
|
||||
status: string;
|
||||
phaseLabel: string;
|
||||
phaseDetail?: string;
|
||||
progress: number;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type CreationAgentSessionView = {
|
||||
sessionId: string;
|
||||
title?: string | null;
|
||||
assistantSummary?: string | null;
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
anchors: CreationAgentAnchorView[];
|
||||
messages: CreationAgentMessageView[];
|
||||
recommendedReplies?: string[];
|
||||
};
|
||||
|
||||
export type CreationAgentTheme = {
|
||||
accentTextClass: string;
|
||||
accentBgClass: string;
|
||||
accentButtonClass: string;
|
||||
userBubbleClass: string;
|
||||
heroClass: string;
|
||||
anchorGridClass?: string;
|
||||
};
|
||||
|
||||
export type CreationAgentQuickAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
minTurn?: number;
|
||||
minProgress?: number;
|
||||
showWhenComplete?: boolean;
|
||||
};
|
||||
|
||||
type CreationAgentWorkspaceProps = {
|
||||
session: CreationAgentSessionView | null;
|
||||
theme: CreationAgentTheme;
|
||||
loadingText: string;
|
||||
composerPlaceholder: string;
|
||||
primaryActionLabel: string;
|
||||
progressCopy?: CreationAgentProgressCopy;
|
||||
activeOperation?: CreationAgentOperationView | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
quickActions?: CreationAgentQuickAction[];
|
||||
onBack: () => void;
|
||||
onSubmitText: (text: string, quickActionKey?: string) => void;
|
||||
onPrimaryAction: () => void;
|
||||
onQuickAction?: (action: CreationAgentQuickAction) => void;
|
||||
};
|
||||
|
||||
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
||||
const DOCUMENT_INPUT_ACCEPT =
|
||||
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
|
||||
|
||||
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
|
||||
return [
|
||||
...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean)),
|
||||
].slice(0, 3);
|
||||
}
|
||||
|
||||
function CreationAgentOperationBanner({
|
||||
operation,
|
||||
}: {
|
||||
operation: CreationAgentOperationView | null | undefined;
|
||||
}) {
|
||||
const [visibleOperation, setVisibleOperation] =
|
||||
useState<CreationAgentOperationView | null>(operation ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleOperation(operation ?? null);
|
||||
|
||||
if (operation?.status !== 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setVisibleOperation((current) =>
|
||||
current?.operationId === operation.operationId ? null : current,
|
||||
);
|
||||
}, 1200);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [operation]);
|
||||
|
||||
if (!visibleOperation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFailed = visibleOperation.status === 'failed';
|
||||
const isRunning =
|
||||
visibleOperation.status === 'running' ||
|
||||
visibleOperation.status === 'queued';
|
||||
const bannerToneClass = isFailed
|
||||
? 'platform-banner--danger'
|
||||
: isRunning
|
||||
? 'platform-banner--info'
|
||||
: 'platform-banner--success';
|
||||
const progress = normalizeCreationAgentProgress(visibleOperation.progress);
|
||||
const progressFillStyle = isFailed
|
||||
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
|
||||
: isRunning
|
||||
? { background: 'var(--platform-button-primary-fill)' }
|
||||
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold">
|
||||
{visibleOperation.phaseLabel}
|
||||
</div>
|
||||
<div className="text-xs opacity-80">{progress}%</div>
|
||||
</div>
|
||||
{visibleOperation.phaseDetail ? (
|
||||
<div className="mt-1 text-xs opacity-80">
|
||||
{visibleOperation.phaseDetail}
|
||||
</div>
|
||||
) : null}
|
||||
{visibleOperation.error ? (
|
||||
<div className="mt-2 text-sm opacity-90">{visibleOperation.error}</div>
|
||||
) : null}
|
||||
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-300"
|
||||
style={{
|
||||
width: `${Math.max(8, progress)}%`,
|
||||
...progressFillStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreationAgentMessageBubble({
|
||||
message,
|
||||
theme,
|
||||
recommendedReplies,
|
||||
isStreaming = false,
|
||||
onRecommendedReply,
|
||||
}: {
|
||||
message: CreationAgentMessageView;
|
||||
theme: CreationAgentTheme;
|
||||
recommendedReplies?: string[];
|
||||
isStreaming?: boolean;
|
||||
onRecommendedReply: (text: string) => void;
|
||||
}) {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const visibleRecommendedReplies =
|
||||
isUser || isStreaming ? [] : uniqueRecommendedReplies(recommendedReplies);
|
||||
const bubbleToneClass = isUser
|
||||
? theme.userBubbleClass
|
||||
: isSystem
|
||||
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
|
||||
: 'platform-subpanel text-[var(--platform-text-strong)]';
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
|
||||
>
|
||||
{isStreaming ? (
|
||||
message.text ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{message.text}
|
||||
<span
|
||||
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="creation-agent-waiting-dots"
|
||||
aria-label="等待回复"
|
||||
className="flex items-center gap-1.5 py-1"
|
||||
>
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">{message.text}</div>
|
||||
)}
|
||||
{visibleRecommendedReplies.length > 0 ? (
|
||||
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
||||
<button
|
||||
key={`recommended-reply-${replyIndex}-${reply}`}
|
||||
type="button"
|
||||
onClick={() => onRecommendedReply(reply)}
|
||||
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
|
||||
>
|
||||
{reply}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowQuickAction(
|
||||
action: CreationAgentQuickAction,
|
||||
session: CreationAgentSessionView,
|
||||
progress: number,
|
||||
) {
|
||||
if (action.showWhenComplete && progress < 100) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof action.minTurn === 'number' &&
|
||||
session.currentTurn < action.minTurn
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof action.minProgress === 'number' && progress < action.minProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMessageListNearBottom(container: HTMLDivElement) {
|
||||
return (
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
||||
AUTO_SCROLL_FOLLOW_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
function scrollMessageListToBottom(container: HTMLDivElement) {
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'auto',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
export function CreationAgentWorkspace({
|
||||
session,
|
||||
theme,
|
||||
loadingText,
|
||||
composerPlaceholder,
|
||||
primaryActionLabel,
|
||||
progressCopy,
|
||||
activeOperation = null,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
quickActions = [],
|
||||
onBack,
|
||||
onSubmitText,
|
||||
onPrimaryAction,
|
||||
onQuickAction,
|
||||
}: CreationAgentWorkspaceProps) {
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const [documentInputError, setDocumentInputError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
||||
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messageListRef.current;
|
||||
if (!container || !shouldAutoScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollMessageListToBottom(container);
|
||||
}, [
|
||||
session?.sessionId,
|
||||
session?.messages,
|
||||
streamingReplyText,
|
||||
isStreamingReply,
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{error || loadingText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = normalizeCreationAgentProgress(session.progressPercent);
|
||||
const progressFillWidth = progress <= 0 ? '0%' : `${Math.max(6, progress)}%`;
|
||||
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
|
||||
const canShowPrimaryAction = progress >= 100;
|
||||
const visibleQuickActions = quickActions.filter((action) =>
|
||||
shouldShowQuickAction(action, session, progress),
|
||||
);
|
||||
const streamingMessageId = `streaming-assistant-${session.sessionId}`;
|
||||
// 用户消息提交后、首个流式文本到达前,也要立刻展示等待气泡。
|
||||
const shouldShowStreamingReply = isStreamingReply;
|
||||
const displayedMessages = shouldShowStreamingReply
|
||||
? [
|
||||
...session.messages,
|
||||
{
|
||||
id: streamingMessageId,
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: streamingReplyText,
|
||||
} satisfies CreationAgentMessageView,
|
||||
]
|
||||
: session.messages;
|
||||
const lastAssistantMessageIndex = session.messages.reduce(
|
||||
(lastIndex, message, index) =>
|
||||
message.role === 'assistant' ? index : lastIndex,
|
||||
-1,
|
||||
);
|
||||
|
||||
const armAutoScrollToBottom = () => {
|
||||
shouldAutoScrollRef.current = true;
|
||||
};
|
||||
|
||||
const handleMessageListScroll = () => {
|
||||
const container = messageListRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldAutoScrollRef.current = isMessageListNearBottom(container);
|
||||
};
|
||||
|
||||
const submitRecommendedReply = (text: string) => {
|
||||
armAutoScrollToBottom();
|
||||
onSubmitText(text);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const text = draftText.trim();
|
||||
if (!text || isBusy || isParsingDocumentInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
armAutoScrollToBottom();
|
||||
onSubmitText(text);
|
||||
setDraftText('');
|
||||
setDocumentInputError(null);
|
||||
};
|
||||
|
||||
const appendDocumentInputText = (text: string) => {
|
||||
setDraftText((current) => {
|
||||
const currentText = current.trimEnd();
|
||||
const nextText = text.trim();
|
||||
|
||||
return currentText ? `${currentText}\n\n${nextText}` : nextText;
|
||||
});
|
||||
};
|
||||
|
||||
const openDocumentInputPicker = () => {
|
||||
documentInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDocumentInputChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.target.value = '';
|
||||
|
||||
if (!file || isBusy || isParsingDocumentInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsParsingDocumentInput(true);
|
||||
setDocumentInputError(null);
|
||||
|
||||
try {
|
||||
const response = await parseCreationAgentDocumentInput(file);
|
||||
appendDocumentInputText(response.document.text);
|
||||
} catch (parseError) {
|
||||
setDocumentInputError(
|
||||
parseError instanceof Error
|
||||
? parseError.message
|
||||
: '解析文档失败,请重新选择文件。',
|
||||
);
|
||||
} finally {
|
||||
setIsParsingDocumentInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="返回"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
{canShowPrimaryAction ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onPrimaryAction}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{primaryActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasHeroCopy ? (
|
||||
<div className="mt-6">
|
||||
{session.title ? (
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{session.title}
|
||||
</div>
|
||||
) : null}
|
||||
{session.assistantSummary ? (
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
|
||||
{session.assistantSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
|
||||
创作进度
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white/88">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 overflow-hidden rounded-full bg-white/12"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${theme.accentBgClass}`}
|
||||
style={{ width: progressFillWidth }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-white/64">
|
||||
{resolveCreationAgentProgressHint(progress, progressCopy)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleQuickActions.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{visibleQuickActions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onQuickAction?.(action)}
|
||||
className="rounded-full border border-white/14 bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/78 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CreationAgentOperationBanner operation={activeOperation} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
ref={messageListRef}
|
||||
data-testid="creation-agent-message-list"
|
||||
onScroll={handleMessageListScroll}
|
||||
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4"
|
||||
>
|
||||
{displayedMessages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
|
||||
暂无消息
|
||||
</div>
|
||||
) : (
|
||||
displayedMessages.map((message, index) => (
|
||||
<CreationAgentMessageBubble
|
||||
key={message.id || `message-${index}`}
|
||||
message={message}
|
||||
theme={theme}
|
||||
isStreaming={message.id === streamingMessageId}
|
||||
recommendedReplies={
|
||||
message.id !== streamingMessageId &&
|
||||
index === lastAssistantMessageIndex
|
||||
? session.recommendedReplies
|
||||
: []
|
||||
}
|
||||
onRecommendedReply={submitRecommendedReply}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{documentInputError || error ? (
|
||||
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{documentInputError || error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-[var(--platform-subpanel-border)] p-3">
|
||||
<div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2">
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
accept={DOCUMENT_INPUT_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleDocumentInputChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
isParsingDocumentInput ? '正在解析文档' : '上传文档'
|
||||
}
|
||||
title={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
|
||||
aria-busy={isParsingDocumentInput}
|
||||
disabled={isBusy || isParsingDocumentInput}
|
||||
onClick={openDocumentInputPicker}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Paperclip
|
||||
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<textarea
|
||||
value={draftText}
|
||||
disabled={isBusy || isParsingDocumentInput}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDraftText(event.target.value);
|
||||
setDocumentInputError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder={composerPlaceholder}
|
||||
className="min-h-[3rem] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="发送"
|
||||
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
|
||||
onClick={submit}
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreationAgentWorkspace;
|
||||
1
src/components/creation-agent/index.ts
Normal file
1
src/components/creation-agent/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './CreationAgentWorkspace';
|
||||
@@ -0,0 +1,205 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||||
|
||||
const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
currentTurn: 4,
|
||||
anchorContent: {
|
||||
worldPromise:
|
||||
'一个被潮雾改写航线秩序的群岛世界,所有通路都要向未知代价借路,体验压迫、潮湿、悬疑。',
|
||||
playerFantasy:
|
||||
'玩家是被迫返乡的旧航路继承人,目标是查清沉船夜背后的真相,失败会再次失去唯一还活着的旧友。',
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 58,
|
||||
lastAssistantReply: '世界和玩家视角已经有了,下一步我想把最明面的冲突钉住。',
|
||||
stage: 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook', 'player_premise'],
|
||||
missingKeys: ['theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element'],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先告诉我你想做一个怎样的世界。',
|
||||
createdAt: '2026-04-17T12:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-17T12:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace sends summary request from progress area', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={baseSession}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '总结当前设定' }));
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请总结一下当前已经成形的世界设定。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace enables quick fill after at least two turns and submits quick fill request', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={baseSession}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '补充剩余设定' }));
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace hides quick fill before two turns', () => {
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
currentTurn: 1,
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补全剩余设定' })).toBeNull();
|
||||
});
|
||||
|
||||
test('workspace exposes draft action when progress reaches 100', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
progressPercent: 100,
|
||||
stage: 'foundation_review',
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成游戏设定草稿' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
});
|
||||
|
||||
test('workspace hides draft action before progress reaches 100', () => {
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
progressPercent: 99,
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '生成游戏设定草稿' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('workspace submits recommended reply from thread', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
recommendedReplies: ['继续补充这个世界的核心冲突'],
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '继续补充这个世界的核心冲突' }),
|
||||
);
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '继续补充这个世界的核心冲突',
|
||||
quickFillRequested: false,
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||||
|
||||
test('custom world agent workspace renders minimum loop chat layout', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
currentTurn: 3,
|
||||
anchorContent: {
|
||||
worldPromise:
|
||||
'一个被潮雾改写航线秩序的群岛世界,所有人都要为每一次借路付出代价,体验压迫、悬疑、带一点海上传奇感。',
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 42,
|
||||
lastAssistantReply: '我先把世界底色收住了,接下来想确认玩家会怎么被卷进来。',
|
||||
stage: 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: [
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先告诉我你想做一个怎样的世界。',
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('创作进度');
|
||||
expect(html).toContain('42%');
|
||||
expect(html).toContain('输入消息');
|
||||
expect(html).toContain('总结当前设定');
|
||||
expect(html).toContain('补充剩余设定');
|
||||
expect(html).not.toContain('世界共创');
|
||||
expect(html).not.toContain(
|
||||
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
|
||||
);
|
||||
expect(html).not.toContain('Agent');
|
||||
expect(html).not.toContain('刷新');
|
||||
expect(html).not.toContain('当前轮次');
|
||||
expect(html).not.toContain('当前状态');
|
||||
expect(html).not.toContain('草稿抽屉');
|
||||
expect(html).not.toContain('快捷动作');
|
||||
});
|
||||
218
src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx
Normal file
218
src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
isCreationAgentOperationBusy,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentAnchorView,
|
||||
type CreationAgentOperationView,
|
||||
type CreationAgentSessionView,
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from '../creation-agent';
|
||||
|
||||
type CustomWorldAgentWorkspaceProps = {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
activeOperation: CustomWorldAgentOperationRecord | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
|
||||
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
|
||||
};
|
||||
|
||||
const CUSTOM_WORLD_AGENT_THEME: CreationAgentTheme = {
|
||||
accentTextClass: 'text-emerald-100/86',
|
||||
accentBgClass: 'bg-emerald-300',
|
||||
accentButtonClass: 'bg-emerald-200 shadow-emerald-950/20',
|
||||
userBubbleClass:
|
||||
'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]',
|
||||
heroClass:
|
||||
'border border-emerald-100/18 bg-[radial-gradient(circle_at_top_left,rgba(52,211,153,0.2),transparent_32%),linear-gradient(135deg,rgba(6,78,59,0.95),rgba(24,33,39,0.96))]',
|
||||
anchorGridClass: 'grid gap-2 sm:grid-cols-2 xl:grid-cols-4',
|
||||
};
|
||||
|
||||
function stringifyAnchorValue(value: unknown): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item): string => stringifyAnchorValue(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return Object.values(value as Record<string, unknown>)
|
||||
.map((item): string => stringifyAnchorValue(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
function buildCustomWorldAnchor(
|
||||
key: string,
|
||||
label: string,
|
||||
value: unknown,
|
||||
): CreationAgentAnchorView {
|
||||
const text = stringifyAnchorValue(value);
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value: text,
|
||||
status: text ? 'confirmed' : 'missing',
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomWorldSession(
|
||||
session: CustomWorldAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
// 自定义世界 Agent 聊天页顶部只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
buildCustomWorldAnchor(
|
||||
'worldPromise',
|
||||
'世界承诺',
|
||||
session.anchorContent.worldPromise,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'playerFantasy',
|
||||
'玩家幻想',
|
||||
session.anchorContent.playerFantasy,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'themeBoundary',
|
||||
'主题边界',
|
||||
session.anchorContent.themeBoundary,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'playerEntryPoint',
|
||||
'开局切入',
|
||||
session.anchorContent.playerEntryPoint,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'coreConflict',
|
||||
'核心冲突',
|
||||
session.anchorContent.coreConflict,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'keyRelationships',
|
||||
'关键关系',
|
||||
session.anchorContent.keyRelationships,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'hiddenLines',
|
||||
'暗线',
|
||||
session.anchorContent.hiddenLines,
|
||||
),
|
||||
buildCustomWorldAnchor(
|
||||
'iconicElements',
|
||||
'标志元素',
|
||||
session.anchorContent.iconicElements,
|
||||
),
|
||||
],
|
||||
messages: session.messages,
|
||||
recommendedReplies: session.recommendedReplies,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomWorldOperation(
|
||||
operation: CustomWorldAgentOperationRecord | null,
|
||||
): CreationAgentOperationView | null {
|
||||
if (!operation || operation.type === 'process_message') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
type: operation.type,
|
||||
status: operation.status,
|
||||
phaseLabel: operation.phaseLabel,
|
||||
phaseDetail: operation.phaseDetail,
|
||||
progress: operation.progress,
|
||||
error: operation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWorldAgentWorkspace({
|
||||
session,
|
||||
activeOperation,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
}: CustomWorldAgentWorkspaceProps) {
|
||||
const isBusy =
|
||||
isCreationAgentOperationBusy(activeOperation) || isStreamingReply;
|
||||
|
||||
const submitMessage = (text: string, quickFillRequested = false) => {
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('custom-world'),
|
||||
text,
|
||||
quickFillRequested,
|
||||
extraPayload: {
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapCustomWorldSession(session) : null}
|
||||
theme={CUSTOM_WORLD_AGENT_THEME}
|
||||
loadingText="正在恢复"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成游戏设定草稿"
|
||||
activeOperation={mapCustomWorldOperation(activeOperation)}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
submitMessage(text);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前已经成形的世界设定。',
|
||||
);
|
||||
submitMessage(
|
||||
quickActionMessage.text,
|
||||
quickActionMessage.quickFillRequested,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
const baseDraftItem: CustomWorldWorkSummary = {
|
||||
workId: 'draft:session-1',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '补齐关键锚点',
|
||||
summary: '玩家是失职返乡的守灯人。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'object_refining',
|
||||
stageLabel: '待完善草稿',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
sessionId: 'session-1',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
};
|
||||
|
||||
test('creation hub reflects updated draft title summary and counts after rerender', () => {
|
||||
const { rerender } = render(
|
||||
<CustomWorldCreationHub
|
||||
items={[baseDraftItem]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
||||
expect(screen.getByText('角色 3')).toBeTruthy();
|
||||
expect(screen.getByText('地点 4')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /角色扮演 RPG/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /大鱼吃小鱼/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /拼图玩法/u })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
...baseDraftItem,
|
||||
title: '潮雾列岛·回潮版',
|
||||
summary: '世界总卡和角色网已经继续长出了新的支线。',
|
||||
playableNpcCount: 5,
|
||||
landmarkCount: 6,
|
||||
updatedAt: new Date('2026-04-14T10:10:00.000Z').toISOString(),
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('角色 5')).toBeTruthy();
|
||||
expect(screen.getByText('地点 6')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[baseDraftItem]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '沉钟拼图',
|
||||
summary: '拼图作品会与其他创作作品一起展示。',
|
||||
themeTags: ['潮雾', '沉钟'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('沉钟拼图')).toBeTruthy();
|
||||
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows RPG public work code from published library entry', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
...baseDraftItem,
|
||||
workId: 'published:world-public-1',
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '潮雾列岛已发布版',
|
||||
profileId: 'world-public-1',
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
]}
|
||||
rpgLibraryEntries={[
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-public-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
profile: {} as never,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: '潮雾列岛已发布版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '已经发布的群岛世界作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
|
||||
expect(screen.getByText('CW-00000001')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onDeletePublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openedItems: CustomWorldWorkSummary[] = [];
|
||||
const persistedDraft = {
|
||||
...baseDraftItem,
|
||||
workId: 'draft:profile-1',
|
||||
sourceType: 'published_profile' as const,
|
||||
sessionId: null,
|
||||
profileId: 'profile-1',
|
||||
title: '可继续整理的草稿',
|
||||
};
|
||||
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[persistedDraft]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={(item) => {
|
||||
openedItems.push(item);
|
||||
}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }),
|
||||
);
|
||||
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
test('creation hub work code copy button copies without opening the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '沉钟拼图',
|
||||
summary: '拼图作品会与其他创作作品一起展示。',
|
||||
themeTags: ['潮雾', '沉钟'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={onOpenPuzzleDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('已复制')).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
|
||||
test('creation hub draft card renders compiled work summary fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:session-1',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '一个被潮雾切开的列岛世界',
|
||||
subtitle: '补齐关键锚点',
|
||||
summary:
|
||||
'玩家是失职返乡的守灯人 · 核心冲突:守灯会与沉船商盟争夺航道解释权',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-04-13T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'clarifying',
|
||||
stageLabel: '补齐关键锚点',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'session-1',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('角色扮演 RPG');
|
||||
expect(html).toContain('大鱼吃小鱼');
|
||||
expect(html).toContain('拼图玩法');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '一张被切成拼图的潮雾港口主视觉。',
|
||||
themeTags: ['潮雾', '港口'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
|
||||
playCount: 12,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('潮雾拼图');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('作品号');
|
||||
expect(html).toContain('PZ-PROFILE1');
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
});
|
||||
258
src/components/custom-world-home/CustomWorldCreationHub.tsx
Normal file
258
src/components/custom-world-home/CustomWorldCreationHub.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
type CustomWorldWorkFilter,
|
||||
CustomWorldWorkTabs,
|
||||
} from './CustomWorldWorkTabs';
|
||||
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
} from './creationWorkShelf';
|
||||
|
||||
type CustomWorldCreationHubProps = {
|
||||
items: CustomWorldWorkSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
createError?: string | null;
|
||||
createBusy?: boolean;
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenDraft: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
deletingWorkId?: string | null;
|
||||
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onExperiencePuzzle?: ((profileId: string) => void) | null;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
};
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="platform-subpanel flex min-h-[14rem] flex-col items-center justify-center rounded-[1.6rem] px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldCreationHub({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
createError = null,
|
||||
createBusy = false,
|
||||
onCreateType,
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
onDeletePublished = null,
|
||||
deletingWorkId = null,
|
||||
onExperienceRpg = null,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onExperienceBigFish = null,
|
||||
onDeleteBigFish = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onExperiencePuzzle = null,
|
||||
onDeletePuzzle = null,
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
useState<CustomWorldWorkFilter>('all');
|
||||
const shelfItems = useMemo(
|
||||
() =>
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: items,
|
||||
rpgLibraryEntries,
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
items,
|
||||
onDeleteBigFish,
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
],
|
||||
);
|
||||
const draftCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'draft',
|
||||
).length;
|
||||
const publishedCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'published',
|
||||
).length;
|
||||
const filteredItems = useMemo(
|
||||
() =>
|
||||
shelfItems.filter((entry) =>
|
||||
activeFilter === 'all' ? true : entry.status === activeFilter,
|
||||
),
|
||||
[activeFilter, shelfItems],
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source.item.profileId) {
|
||||
onEnterPublished(item.source.item.profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildExperienceAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canExperience) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperiencePuzzle?.(sourceItem.profileId);
|
||||
};
|
||||
}
|
||||
case 'big-fish': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperienceBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperienceRpg?.(sourceItem);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeletePuzzle?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'big-fish': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeletePublished?.(sourceItem);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
|
||||
<div className="space-y-4 xl:space-y-3">
|
||||
<CustomWorldCreationStartCard
|
||||
busy={createBusy}
|
||||
error={createError}
|
||||
onCreateType={onCreateType}
|
||||
/>
|
||||
|
||||
<CustomWorldWorkTabs
|
||||
activeFilter={activeFilter}
|
||||
draftCount={draftCount}
|
||||
publishedCount={publishedCount}
|
||||
onChange={setActiveFilter}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
|
||||
>
|
||||
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredItems.map((item) => (
|
||||
<CustomWorldWorkCard
|
||||
key={`${item.kind}-${item.id}`}
|
||||
item={item}
|
||||
onOpen={() => handleOpenShelfItem(item)}
|
||||
onExperience={buildExperienceAction(item)}
|
||||
onDelete={buildDeleteAction(item)}
|
||||
deleteBusy={deletingWorkId === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : shelfItems.length === 0 ? (
|
||||
<EmptyState title="还没有作品" />
|
||||
) : (
|
||||
<EmptyState title="当前筛选下没有内容" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { CustomWorldWorkFilter };
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
PLATFORM_CREATION_TYPES,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationStartCard({
|
||||
busy = false,
|
||||
error = null,
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 xl:items-end">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
|
||||
直接选择游戏创作模板,立刻进入对应的共创工作台。
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
|
||||
{busy ? '正在开启' : '选择模板'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
|
||||
<span
|
||||
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<span className="text-base leading-none text-white/40">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/components/custom-world-home/CustomWorldWorkCard.tsx
Normal file
205
src/components/custom-world-home/CustomWorldWorkCard.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '最近更新';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
type CustomWorldWorkCardProps = {
|
||||
item: CreationWorkShelfItem;
|
||||
onOpen: () => void;
|
||||
onExperience?: (() => void) | null;
|
||||
onDelete?: (() => void) | null;
|
||||
deleteBusy?: boolean;
|
||||
};
|
||||
|
||||
const BADGE_TONE_CLASS: Record<
|
||||
CreationWorkShelfItem['badges'][number]['tone'],
|
||||
string
|
||||
> = {
|
||||
warm: 'platform-pill--warm',
|
||||
success: 'platform-pill--success',
|
||||
neutral: 'platform-pill--neutral',
|
||||
};
|
||||
|
||||
export function CustomWorldWorkCard({
|
||||
item,
|
||||
onOpen,
|
||||
onExperience = null,
|
||||
onDelete = null,
|
||||
deleteBusy = false,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!item.publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${item.openActionLabel}《${item.title}》`}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}}
|
||||
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
|
||||
>
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.badges.map((badge) => (
|
||||
<span
|
||||
key={`${item.id}-${badge.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(item.updatedAt)}
|
||||
</span>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">…</span>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v5" />
|
||||
<path d="M14 11v5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-0 xl:mt-3">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
{item.publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyPublicWorkCode();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
|
||||
aria-label={`复制作品号 ${item.publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
<span className="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
{copyState !== 'idle' ? (
|
||||
<span className="shrink-0">
|
||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||
{onExperience ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onExperience();
|
||||
}}
|
||||
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
|
||||
>
|
||||
体验
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/custom-world-home/CustomWorldWorkTabs.tsx
Normal file
50
src/components/custom-world-home/CustomWorldWorkTabs.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
|
||||
|
||||
const FILTER_OPTIONS: Array<{
|
||||
id: CustomWorldWorkFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'draft', label: '草稿' },
|
||||
{ id: 'published', label: '已发布' },
|
||||
];
|
||||
|
||||
type CustomWorldWorkTabsProps = {
|
||||
activeFilter: CustomWorldWorkFilter;
|
||||
draftCount: number;
|
||||
publishedCount: number;
|
||||
onChange: (filter: CustomWorldWorkFilter) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldWorkTabs({
|
||||
activeFilter,
|
||||
draftCount,
|
||||
publishedCount,
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
? draftCount
|
||||
: option.id === 'published'
|
||||
? publishedCount
|
||||
: draftCount + publishedCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${
|
||||
activeFilter === option.id ? 'platform-tab--active' : ''
|
||||
}`}
|
||||
>
|
||||
{option.label} {count}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
src/components/custom-world-home/creationWorkShelf.ts
Normal file
271
src/components/custom-world-home/creationWorkShelf.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
||||
|
||||
export type CreationWorkShelfBadge = {
|
||||
id: string;
|
||||
label: string;
|
||||
tone: CreationWorkShelfBadgeTone;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfMetric = {
|
||||
id: string;
|
||||
label: string;
|
||||
tone?: CreationWorkShelfBadgeTone;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfSource =
|
||||
| {
|
||||
kind: 'rpg';
|
||||
item: CustomWorldWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish';
|
||||
item: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfItem = {
|
||||
id: string;
|
||||
kind: CreationWorkShelfKind;
|
||||
status: CreationWorkShelfStatus;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs: string[];
|
||||
publicWorkCode: string | null;
|
||||
typeLabel: string;
|
||||
openActionLabel: string;
|
||||
canExperience: boolean;
|
||||
canDelete: boolean;
|
||||
badges: CreationWorkShelfBadge[];
|
||||
metrics: CreationWorkShelfMetric[];
|
||||
source: CreationWorkShelfSource;
|
||||
};
|
||||
|
||||
export function buildCreationWorkShelfItems(params: {
|
||||
rpgItems: CustomWorldWorkSummary[];
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems: BigFishWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
canDeleteBigFish?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg = false,
|
||||
canDeleteBigFish = false,
|
||||
canDeletePuzzle = false,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||
),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
function mapRpgWorkToShelfItem(
|
||||
item: CustomWorldWorkSummary,
|
||||
canDelete: boolean,
|
||||
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
): CreationWorkShelfItem {
|
||||
const isDraft = item.status === 'draft';
|
||||
const libraryEntry = item.profileId
|
||||
? libraryEntries.find((entry) => entry.profileId === item.profileId)
|
||||
: null;
|
||||
const badges: CreationWorkShelfBadge[] = [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||
];
|
||||
if (item.stageLabel) {
|
||||
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
|
||||
}
|
||||
|
||||
const metrics: CreationWorkShelfMetric[] = [
|
||||
{
|
||||
id: 'playable-npc-count',
|
||||
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
|
||||
},
|
||||
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
|
||||
];
|
||||
if (item.roleVisualReadyCount) {
|
||||
metrics.push({
|
||||
id: 'role-visual-ready-count',
|
||||
label: `主图 ${item.roleVisualReadyCount}`,
|
||||
tone: 'warm',
|
||||
});
|
||||
}
|
||||
if (item.roleAnimationReadyCount) {
|
||||
metrics.push({
|
||||
id: 'role-animation-ready-count',
|
||||
label: `动作 ${item.roleAnimationReadyCount}`,
|
||||
tone: 'success',
|
||||
});
|
||||
}
|
||||
if (item.roleAssetSummaryLabel) {
|
||||
metrics.push({
|
||||
id: 'role-asset-summary',
|
||||
label: item.roleAssetSummaryLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'rpg',
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
||||
publicWorkCode:
|
||||
item.status === 'published'
|
||||
? (libraryEntry?.publicWorkCode ?? null)
|
||||
: null,
|
||||
typeLabel: 'RPG',
|
||||
openActionLabel: isDraft
|
||||
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
||||
? '继续完善'
|
||||
: '继续创作'
|
||||
: '查看详情',
|
||||
canExperience: item.status === 'published' && item.canEnterWorld,
|
||||
canDelete,
|
||||
badges,
|
||||
metrics,
|
||||
source: { kind: 'rpg', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapBigFishWorkToShelfItem(
|
||||
item: BigFishWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'big-fish',
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode: null,
|
||||
typeLabel: '大鱼',
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canExperience: item.status === 'published',
|
||||
canDelete,
|
||||
badges: [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: '大鱼', tone: 'neutral' },
|
||||
],
|
||||
metrics: [
|
||||
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
|
||||
{
|
||||
id: 'level-main-image-ready-count',
|
||||
label: `主图 ${item.levelMainImageReadyCount}`,
|
||||
},
|
||||
{
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
id: 'background-ready',
|
||||
label: '背景已就绪',
|
||||
tone: 'success' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
source: { kind: 'big-fish', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapPuzzleWorkToShelfItem(
|
||||
item: PuzzleWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus;
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'puzzle',
|
||||
status,
|
||||
title: item.levelName,
|
||||
subtitle: item.authorDisplayName,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode:
|
||||
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
|
||||
typeLabel: '拼图',
|
||||
openActionLabel:
|
||||
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||
canExperience: status === 'published',
|
||||
canDelete,
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '拼图', tone: 'neutral' },
|
||||
...item.themeTags.slice(0, 2).map((tag) => ({
|
||||
id: `tag:${tag}`,
|
||||
label: tag,
|
||||
tone: 'neutral' as const,
|
||||
})),
|
||||
],
|
||||
metrics: [
|
||||
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount}` },
|
||||
],
|
||||
source: { kind: 'puzzle', item },
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatusBadge(
|
||||
status: CreationWorkShelfStatus,
|
||||
): CreationWorkShelfBadge {
|
||||
return {
|
||||
id: 'status',
|
||||
label: status === 'draft' ? '草稿' : '已发布',
|
||||
tone: status === 'draft' ? 'warm' : 'success',
|
||||
};
|
||||
}
|
||||
|
||||
function getShelfItemTime(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
22
src/components/customWorldNpcVisualDefaults.ts
Normal file
22
src/components/customWorldNpcVisualDefaults.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
buildMedievalNpcVisual,
|
||||
parseCustomWorldNpcVisualFromSpec,
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import type { CustomWorldNpc } from '../types';
|
||||
|
||||
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>;
|
||||
|
||||
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc' as const,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.name.slice(0, 1) || '角',
|
||||
context: npc.role,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultCustomWorldNpcVisual(npc: EditableNpcSource) {
|
||||
return parseCustomWorldNpcVisualFromSpec(buildMedievalNpcVisual(buildCustomWorldNpcEncounter(npc)));
|
||||
}
|
||||
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal file
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {motion} from 'motion/react';
|
||||
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
import type {Character, CombatVisualEffect, SceneHostileNpc} from '../../types';
|
||||
import {getEntityEffectBottom} from './GameCanvasShared';
|
||||
|
||||
interface GameCanvasEffectLayerProps {
|
||||
activeCombatEffects: CombatVisualEffect[];
|
||||
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
|
||||
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
playerCharacter: Character | null;
|
||||
groundBottom: string;
|
||||
stageLiftPx: number;
|
||||
playerOffsetY: number;
|
||||
stageRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function useCombatEffectFrames(effect: CombatVisualEffect) {
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(0);
|
||||
|
||||
if (effect.frames.length <= 1) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameIndex(prev => Math.min(prev + 1, effect.frames.length - 1));
|
||||
}, Math.max(50, Math.round(1000 / effect.fps)));
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [effect.fps, effect.frames, effect.id]);
|
||||
|
||||
return Math.min(frameIndex, Math.max(0, effect.frames.length - 1));
|
||||
}
|
||||
|
||||
function TravelingSpriteCombatEffect({
|
||||
effect,
|
||||
startLeft,
|
||||
endLeft,
|
||||
startBottom,
|
||||
endBottom,
|
||||
stageRef,
|
||||
}: {
|
||||
effect: CombatVisualEffect;
|
||||
startLeft: string;
|
||||
endLeft: string;
|
||||
startBottom: string;
|
||||
endBottom: string;
|
||||
stageRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const frameIndex = useCombatEffectFrames(effect);
|
||||
const startMarkerRef = useRef<HTMLDivElement>(null);
|
||||
const endMarkerRef = useRef<HTMLDivElement>(null);
|
||||
const [vector, setVector] = useState({x: 0, y: 0});
|
||||
const [measured, setMeasured] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setMeasured(false);
|
||||
let cancelled = false;
|
||||
|
||||
const measure = () => {
|
||||
const stage = stageRef.current;
|
||||
const startEl = startMarkerRef.current;
|
||||
const endEl = endMarkerRef.current;
|
||||
if (cancelled) return;
|
||||
if (!stage || !startEl || !endEl) {
|
||||
setVector({x: 0, y: 0});
|
||||
setMeasured(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const startRect = startEl.getBoundingClientRect();
|
||||
const endRect = endEl.getBoundingClientRect();
|
||||
const startX = startRect.left + startRect.width / 2 - stageRect.left;
|
||||
const startY = startRect.top + startRect.height / 2 - stageRect.top;
|
||||
const endX = endRect.left + endRect.width / 2 - stageRect.left;
|
||||
const endY = endRect.top + endRect.height / 2 - stageRect.top;
|
||||
setVector({x: endX - startX, y: endY - startY});
|
||||
setMeasured(true);
|
||||
};
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(measure);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [effect.id, endBottom, endLeft, stageRef, startBottom, startLeft]);
|
||||
|
||||
const half = effect.sizePx / 2;
|
||||
const markerBox: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: effect.sizePx,
|
||||
height: effect.sizePx,
|
||||
marginLeft: -half,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={startMarkerRef} aria-hidden style={{...markerBox, left: startLeft, bottom: startBottom}} />
|
||||
<div ref={endMarkerRef} aria-hidden style={{...markerBox, left: endLeft, bottom: endBottom}} />
|
||||
{measured && (
|
||||
<motion.div
|
||||
initial={{x: 0, y: 0, opacity: 0.98}}
|
||||
animate={{x: vector.x, y: vector.y, opacity: [1, 1, 0.94]}}
|
||||
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: startLeft,
|
||||
bottom: startBottom,
|
||||
width: `${effect.sizePx}px`,
|
||||
height: `${effect.sizePx}px`,
|
||||
zIndex: effect.zIndex ?? 24,
|
||||
marginLeft: `-${half}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={effect.frames[frameIndex]}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
style={{
|
||||
imageRendering: 'pixelated',
|
||||
transform: effect.facing === 'left'
|
||||
? `scaleX(-1) scale(${effect.scale ?? 1})`
|
||||
: `scale(${effect.scale ?? 1})`,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpriteCombatEffect({
|
||||
effect,
|
||||
startLeft,
|
||||
endLeft,
|
||||
startBottom,
|
||||
endBottom,
|
||||
}: {
|
||||
effect: CombatVisualEffect;
|
||||
startLeft: string;
|
||||
endLeft?: string;
|
||||
startBottom: string;
|
||||
endBottom?: string;
|
||||
}) {
|
||||
const frameIndex = useCombatEffectFrames(effect);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{left: startLeft, bottom: startBottom, opacity: 0.98}}
|
||||
animate={{
|
||||
left: endLeft ?? startLeft,
|
||||
bottom: endBottom ?? startBottom,
|
||||
opacity: [1, 1, 0.94],
|
||||
}}
|
||||
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
width: `${effect.sizePx}px`,
|
||||
height: `${effect.sizePx}px`,
|
||||
zIndex: effect.zIndex ?? 24,
|
||||
marginLeft: `-${effect.sizePx / 2}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={effect.frames[frameIndex]}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
style={{
|
||||
imageRendering: 'pixelated',
|
||||
transform: effect.facing === 'left'
|
||||
? `scaleX(-1) scale(${effect.scale ?? 1})`
|
||||
: `scale(${effect.scale ?? 1})`,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameCanvasEffectLayer({
|
||||
activeCombatEffects,
|
||||
getPlayerEffectLeft,
|
||||
getHostileNpcEffectLeft,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
stageRef,
|
||||
}: GameCanvasEffectLayerProps) {
|
||||
return (
|
||||
<>
|
||||
{activeCombatEffects.map(effect => {
|
||||
const startLeft = effect.startOrigin === 'player'
|
||||
? getPlayerEffectLeft(effect.startX, effect.startOffsetX ?? 0)
|
||||
: getHostileNpcEffectLeft(effect.startX, effect.startHostileNpcId ?? effect.startMonsterId, effect.startOffsetX ?? 0);
|
||||
const endLeft = effect.endOrigin === 'player'
|
||||
? getPlayerEffectLeft(effect.endX ?? effect.startX, effect.endOffsetX ?? effect.startOffsetX ?? 0)
|
||||
: effect.endOrigin === 'hostile_npc' || effect.endOrigin === 'monster'
|
||||
? getHostileNpcEffectLeft(effect.endX ?? effect.startX, effect.endHostileNpcId ?? effect.endMonsterId, effect.endOffsetX ?? effect.startOffsetX ?? 0)
|
||||
: undefined;
|
||||
const startBottom = `calc(${getEntityEffectBottom({
|
||||
origin: effect.startOrigin,
|
||||
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
anchorOffsetY: effect.startAnchorOffsetY ?? 0,
|
||||
})} + ${effect.startYOffset}px)`;
|
||||
const endBottom = `calc(${getEntityEffectBottom({
|
||||
origin: effect.endOrigin ?? effect.startOrigin,
|
||||
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
anchorOffsetY: effect.endAnchorOffsetY ?? effect.startAnchorOffsetY ?? 0,
|
||||
})} + ${(effect.endYOffset ?? effect.startYOffset)}px)`;
|
||||
|
||||
const useTravelingPath = Boolean(
|
||||
effect.traveling
|
||||
&& endLeft
|
||||
&& endBottom
|
||||
&& (startLeft !== endLeft || startBottom !== endBottom),
|
||||
);
|
||||
|
||||
if (useTravelingPath && endLeft && endBottom) {
|
||||
return (
|
||||
<TravelingSpriteCombatEffect
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
startLeft={startLeft}
|
||||
endLeft={endLeft}
|
||||
startBottom={startBottom}
|
||||
endBottom={endBottom}
|
||||
stageRef={stageRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SpriteCombatEffect
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
startLeft={startLeft}
|
||||
endLeft={endLeft}
|
||||
startBottom={startBottom}
|
||||
endBottom={endBottom}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
188
src/components/game-canvas/GameCanvasEntityLayer.test.tsx
Normal file
188
src/components/game-canvas/GameCanvasEntityLayer.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type SceneHostileNpc,
|
||||
} from '../../types';
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
import {
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
ENTITY_CONTAINER_REM,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMirroredStageEntityLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
MONSTER_COMBAT_HP_TOP_PX,
|
||||
} from './GameCanvasShared';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'npc-liu',
|
||||
kind: 'npc',
|
||||
npcName: '柳无声',
|
||||
npcDescription: '桥口旧识',
|
||||
npcAvatar: '/npc-liu.png',
|
||||
context: '断桥',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
|
||||
return {
|
||||
id: 'npc-liu',
|
||||
name: '柳无声',
|
||||
action: '对峙',
|
||||
description: '桥口旧识',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
encounter: createEncounter(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEntityLayer(effectNpcId: string | null) {
|
||||
return renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
isSceneTransitionExiting={false}
|
||||
transitionSweepPx={320}
|
||||
sceneTransitionExitDurationS={0.2}
|
||||
sceneTransitionEntryDurationS={0.2}
|
||||
companionAnchorLeft="10%"
|
||||
companionAnchorBottom="20%"
|
||||
playerBottomOffsetPx={0}
|
||||
sceneTransitionPhase="idle"
|
||||
inBattle={false}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={
|
||||
effectNpcId
|
||||
? {
|
||||
eventId: 'effect-1',
|
||||
npcId: effectNpcId,
|
||||
delta: 3,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sceneCombatants={[createHostileNpc()]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('GameCanvasEntityLayer', () => {
|
||||
it('uses mirrored stage anchors for player and opponent containers', () => {
|
||||
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
|
||||
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
|
||||
});
|
||||
|
||||
it('lowers large monster sprites to the shared scene ground line', () => {
|
||||
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 62})).toBe(-78);
|
||||
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 46})).toBe(-68);
|
||||
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 37})).toBe(-52);
|
||||
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28);
|
||||
});
|
||||
|
||||
it('uses scene npc visual anchors instead of template character foot offsets', () => {
|
||||
const sceneNpcEncounter = createEncounter({
|
||||
characterId: 'hero',
|
||||
monsterPresetId: 'monster-20',
|
||||
imageSrc: '/generated-custom-world-npc/shark.png',
|
||||
});
|
||||
const character = createCharacter();
|
||||
|
||||
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
|
||||
.toBe('calc(18% + 68px - 78px)');
|
||||
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
|
||||
.toBe(-10);
|
||||
});
|
||||
|
||||
it('lowers scene npc custom visuals even without character ids', () => {
|
||||
const sceneNpcEncounter = createEncounter({
|
||||
visual: {
|
||||
race: 'elf',
|
||||
bodyColor: 'blue',
|
||||
headIndex: 0,
|
||||
hairColorIndex: 1,
|
||||
hairStyleFrame: 2,
|
||||
facialHairEnabled: false,
|
||||
facialHairColorIndex: 0,
|
||||
facialHairStyleFrame: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-78);
|
||||
});
|
||||
|
||||
it('keeps combat hp bars above character and monster silhouettes', () => {
|
||||
expect(getNpcCombatHpTop('hero', null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
|
||||
expect(getNpcCombatHpTop(null, 'monster-20')).toBe(MONSTER_COMBAT_HP_TOP_PX);
|
||||
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
|
||||
});
|
||||
|
||||
it('renders affinity effect on the matching hostile npc', () => {
|
||||
const html = renderEntityLayer('npc-liu');
|
||||
|
||||
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
|
||||
expect(html).toContain('aria-label="好感度变化 +3"');
|
||||
});
|
||||
|
||||
it('does not render affinity effect on a different npc', () => {
|
||||
const html = renderEntityLayer('npc-other');
|
||||
|
||||
expect(html).not.toContain('npc-affinity-effect-npc-liu');
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
});
|
||||
468
src/components/game-canvas/GameCanvasEntityLayer.tsx
Normal file
468
src/components/game-canvas/GameCanvasEntityLayer.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import {motion} from 'motion/react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
|
||||
import {RESOLVED_ENTITY_X_METERS} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CompanionRenderState,
|
||||
type Encounter,
|
||||
type SceneHostileNpc,
|
||||
type ScenePresetInfo,
|
||||
type WorldType,
|
||||
} from '../../types';
|
||||
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
||||
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
||||
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
||||
import {ResolvedAssetImage} from '../ResolvedAssetImage';
|
||||
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
||||
import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
GENERIC_NPC_SCENE_SCALE,
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
getCompanionSlotOffset,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
getSceneEntityZIndex,
|
||||
HpBar,
|
||||
mapHostileNpcAnimationToCharacterState,
|
||||
MONSTER_RENDER_OFFSETS,
|
||||
ROLE_CHARACTER_FRAME_CLASS,
|
||||
RoleCharacterSprite,
|
||||
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
||||
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
|
||||
SceneEncounterNpcSprite,
|
||||
SceneEntityButton,
|
||||
} from './GameCanvasShared';
|
||||
|
||||
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
|
||||
|
||||
interface GameCanvasEntityLayerProps {
|
||||
companions: CompanionRenderState[];
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
sceneTransitionToken: number;
|
||||
isSceneTransitionEntering: boolean;
|
||||
isSceneTransitionExiting: boolean;
|
||||
transitionSweepPx: number;
|
||||
sceneTransitionExitDurationS: number;
|
||||
sceneTransitionEntryDurationS: number;
|
||||
companionAnchorLeft: string;
|
||||
companionAnchorBottom: string;
|
||||
playerBottomOffsetPx: number;
|
||||
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
||||
inBattle: boolean;
|
||||
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
|
||||
playerLeft: string;
|
||||
playerCharacter: Character | null;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
effectivePlayerFacing: 'left' | 'right';
|
||||
effectivePlayerAnimationState: AnimationState;
|
||||
shouldShowPlayerDialogueIcon: boolean;
|
||||
dialogueIndicator?: {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
npcAffinityEffect?: {
|
||||
eventId: string;
|
||||
npcId: string;
|
||||
delta: number;
|
||||
} | null;
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
monsters: MonsterSpriteConfig[];
|
||||
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
|
||||
groundBottom: string;
|
||||
stageLiftPx: number;
|
||||
encounter: Encounter | null;
|
||||
sideAnchor: string;
|
||||
cameraAnchorX: number;
|
||||
monsterAnchorMeters: number;
|
||||
playerX: number;
|
||||
}
|
||||
|
||||
export function GameCanvasEntityLayer({
|
||||
companions,
|
||||
currentScenePreset,
|
||||
sceneTransitionToken,
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionExitDurationS,
|
||||
sceneTransitionEntryDurationS,
|
||||
companionAnchorLeft,
|
||||
companionAnchorBottom,
|
||||
playerBottomOffsetPx,
|
||||
sceneTransitionPhase,
|
||||
inBattle,
|
||||
onEntitySelect = null,
|
||||
playerLeft,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
effectivePlayerFacing,
|
||||
effectivePlayerAnimationState,
|
||||
shouldShowPlayerDialogueIcon,
|
||||
dialogueIndicator = null,
|
||||
npcAffinityEffect = null,
|
||||
sceneCombatants,
|
||||
monsters,
|
||||
getHostileNpcOuterLeft,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
encounter,
|
||||
sideAnchor,
|
||||
cameraAnchorX,
|
||||
monsterAnchorMeters,
|
||||
playerX,
|
||||
}: GameCanvasEntityLayerProps) {
|
||||
const shouldRenderPeacefulEncounter =
|
||||
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{companions.map(companion => {
|
||||
const slotOffset = getCompanionSlotOffset(companion.slot);
|
||||
return (
|
||||
<motion.div
|
||||
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
|
||||
className="absolute"
|
||||
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
|
||||
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
|
||||
transition={{
|
||||
duration: isSceneTransitionExiting
|
||||
? sceneTransitionExitDurationS
|
||||
: isSceneTransitionEntering
|
||||
? sceneTransitionEntryDurationS
|
||||
: 0.18,
|
||||
ease: 'linear',
|
||||
delay: isSceneTransitionEntering
|
||||
? (companion.slot === 'upper'
|
||||
? SCENE_TRANSITION_UPPER_COMPANION_DELAY_S
|
||||
: SCENE_TRANSITION_LOWER_COMPANION_DELAY_S)
|
||||
: 0,
|
||||
}}
|
||||
style={{
|
||||
left: companionAnchorLeft,
|
||||
bottom: companionAnchorBottom,
|
||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${slotOffset.left}px`,
|
||||
bottom: `${slotOffset.bottom}px`,
|
||||
transform: `translate(${companion.entryOffsetX ?? 0}px, ${companion.entryOffsetY ?? 0}px)`,
|
||||
transition: companion.transitionMs
|
||||
? `transform ${companion.transitionMs}ms linear`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
|
||||
ariaLabel={`查看${companion.character.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
>
|
||||
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
|
||||
</div>
|
||||
)}
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
|
||||
<RoleCharacterSprite
|
||||
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
|
||||
character={companion.character}
|
||||
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
<motion.div
|
||||
key={`player-${currentScenePreset?.id ?? 'none'}-${sceneTransitionToken}`}
|
||||
className="absolute"
|
||||
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
|
||||
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
|
||||
transition={{
|
||||
duration: isSceneTransitionExiting
|
||||
? sceneTransitionExitDurationS
|
||||
: isSceneTransitionEntering
|
||||
? sceneTransitionEntryDurationS
|
||||
: 0.18,
|
||||
ease: 'linear',
|
||||
}}
|
||||
style={{
|
||||
left: playerLeft,
|
||||
bottom: companionAnchorBottom,
|
||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{inBattle && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
>
|
||||
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
|
||||
</div>
|
||||
)}
|
||||
<SceneEntityButton
|
||||
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
|
||||
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
|
||||
className="relative block"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
{playerCharacter && (
|
||||
<RoleCharacterSprite
|
||||
state={effectivePlayerAnimationState}
|
||||
character={playerCharacter}
|
||||
facing={effectivePlayerFacing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowPlayerDialogueIcon && (
|
||||
<div className="absolute -top-9 right-1">
|
||||
<DialogueBubbleIcon
|
||||
active={dialogueIndicator?.activeSpeaker === 'player'}
|
||||
flip={effectivePlayerFacing === 'left'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map(hostileNpc => {
|
||||
const npcEncounter = hostileNpc.encounter;
|
||||
if (!npcEncounter) return null;
|
||||
const config = monsters.find(item => item.id === hostileNpc.id);
|
||||
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
|
||||
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
|
||||
const npcMonsterConfig = !npcCharacter && npcEncounter?.monsterPresetId
|
||||
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
||||
: null;
|
||||
const npcSceneSpriteFacing =
|
||||
npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
const npcCombatHpTop = getNpcCombatHpTop(
|
||||
npcCharacter ? npcEncounter?.characterId : null,
|
||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||
);
|
||||
const hostileNpcBottomOffsetPx =
|
||||
npcMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
|
||||
const opponentBottom = npcCharacter
|
||||
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
|
||||
: `calc(${groundBottom} + ${stageLiftPx}px)`;
|
||||
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
|
||||
const entityBottomOffsetPx = npcCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx,
|
||||
npcEncounter,
|
||||
npcCharacter,
|
||||
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
|
||||
)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hostileNpc.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: getHostileNpcOuterLeft(hostileNpc),
|
||||
bottom: entityBottom,
|
||||
zIndex: getSceneEntityZIndex(entityBottomOffsetPx),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
|
||||
ariaLabel={`查看${hostileNpc.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${npcCombatHpTop}px`}}
|
||||
>
|
||||
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
|
||||
</div>
|
||||
)}
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
{npcCharacter ? (
|
||||
<RoleCharacterSprite
|
||||
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
||||
character={npcCharacter}
|
||||
facing={npcSceneSpriteFacing}
|
||||
/>
|
||||
) : npcMonsterConfig ? (
|
||||
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={npcMonsterConfig}
|
||||
animation={hostileNpc.animation}
|
||||
flip={hostileNpc.facing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MedievalNpcAnimator
|
||||
encounter={npcEncounter}
|
||||
className="origin-bottom drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
facing={npcSceneSpriteFacing}
|
||||
scale={GENERIC_NPC_SCENE_SCALE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
|
||||
<div className="absolute -top-9 left-1">
|
||||
<DialogueBubbleIcon
|
||||
active={dialogueIndicator.activeSpeaker === 'npc'}
|
||||
flip={npcSceneSpriteFacing === 'left'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
|
||||
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{shouldRenderPeacefulEncounter &&
|
||||
(() => {
|
||||
if (!encounter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCampCompanionEncounter =
|
||||
encounter.specialBehavior === 'initial_companion'
|
||||
|| encounter.specialBehavior === 'camp_companion';
|
||||
const peacefulAnchorX = RESOLVED_ENTITY_X_METERS;
|
||||
const isPeacefulEncounterMoving =
|
||||
(!isCampCompanionEncounter && sceneTransitionPhase !== 'idle')
|
||||
|| Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01;
|
||||
const towardPeacefulPlayer = getFacingTowardPlayer(peacefulAnchorX, playerX);
|
||||
const peacefulResolvedCharacter =
|
||||
encounter.kind !== 'treasure' && encounter.characterId
|
||||
? getCharacterById(encounter.characterId)
|
||||
: null;
|
||||
const peacefulMonsterConfig = !peacefulResolvedCharacter &&
|
||||
encounter.kind === 'npc' && encounter.monsterPresetId
|
||||
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
|
||||
: null;
|
||||
const peacefulHostileBottomOffsetPx =
|
||||
peacefulMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(encounter);
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
peacefulAnchorX,
|
||||
cameraAnchorX,
|
||||
monsterAnchorMeters,
|
||||
),
|
||||
bottom: encounter.characterId
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
encounter,
|
||||
getCharacterById(encounter.characterId),
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
|
||||
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
|
||||
transition: isCampCompanionEncounter
|
||||
? 'bottom 180ms ease'
|
||||
: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
|
||||
ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
{encounter.kind === 'treasure' ? (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
|
||||
<ResolvedAssetImage
|
||||
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
|
||||
alt={encounter.npcName}
|
||||
className="h-12 w-12 object-contain"
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
/>
|
||||
</div>
|
||||
) : peacefulResolvedCharacter &&
|
||||
!encounter.visual &&
|
||||
!encounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
character={peacefulResolvedCharacter}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
/>
|
||||
) : peacefulMonsterConfig ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={peacefulMonsterConfig}
|
||||
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
||||
flip={towardPeacefulPlayer === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={encounter}
|
||||
state={AnimationState.IDLE}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
|
||||
<div className="absolute -top-9 left-1">
|
||||
<DialogueBubbleIcon
|
||||
active={dialogueIndicator.activeSpeaker === 'npc'}
|
||||
flip={peacefulNpcSpriteFacing === 'left'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
|
||||
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/components/game-canvas/GameCanvasOverlayLayer.tsx
Normal file
36
src/components/game-canvas/GameCanvasOverlayLayer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {motion} from 'motion/react';
|
||||
|
||||
interface GameCanvasOverlayLayerProps {
|
||||
escapeLead: number;
|
||||
}
|
||||
|
||||
export function GameCanvasOverlayLayer({escapeLead}: GameCanvasOverlayLayerProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20" />
|
||||
{escapeLead > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, rgba(80, 180, 255, ${0.05 + escapeLead * 0.12}) 0%, rgba(0,0,0,0) 42%, rgba(0,0,0,0.18) 100%)`,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-x-0 top-4 text-center"
|
||||
animate={{opacity: [0.45, 0.95, 0.45], scale: [1, 1.03, 1]}}
|
||||
transition={{
|
||||
duration: Math.max(0.5, 1.1 - escapeLead * 0.4),
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<span className="rounded-full border border-sky-300/30 bg-sky-950/65 px-3 py-1 text-[10px] font-bold tracking-[0.25em] text-sky-100">
|
||||
{escapeLead > 0.72 ? 'Escaped pursuit' : 'Creating distance'}
|
||||
</span>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
231
src/components/game-canvas/GameCanvasRuntime.tsx
Normal file
231
src/components/game-canvas/GameCanvasRuntime.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
|
||||
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
||||
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
|
||||
import {AnimationState, WorldType} from '../../types';
|
||||
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
|
||||
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
|
||||
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
|
||||
import {GameCanvasSceneLayer} from './GameCanvasSceneLayer';
|
||||
import {
|
||||
type GameCanvasProps,
|
||||
getCharacterBottomOffsetPx,
|
||||
getMirroredStageEntityLeft,
|
||||
getMonsterWorldLeft,
|
||||
getPlayerWorldLeft,
|
||||
HOSTILE_NPC_SCENE_INSET_PX,
|
||||
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
||||
SCENE_TRANSITION_SPEED_PX_PER_S,
|
||||
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
|
||||
} from './GameCanvasShared';
|
||||
|
||||
export function GameCanvasRuntime({
|
||||
scrollWorld,
|
||||
animationState,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
currentScenePreset,
|
||||
worldType,
|
||||
customWorldProfile = null,
|
||||
storyEngineMemory = null,
|
||||
sceneHostileNpcs,
|
||||
playerX,
|
||||
playerOffsetY,
|
||||
playerFacing,
|
||||
playerActionMode = 'idle',
|
||||
inBattle,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
activeCombatEffects = [],
|
||||
companions = [],
|
||||
dialogueIndicator = null,
|
||||
npcAffinityEffect = null,
|
||||
onEntitySelect = null,
|
||||
onSceneNameClick = null,
|
||||
sceneTransitionPhase = 'idle',
|
||||
sceneTransitionToken = 0,
|
||||
onSceneTransitionDurationsChange = null,
|
||||
}: GameCanvasProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [stageOuterWidth, setStageOuterWidth] = useState(0);
|
||||
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
|
||||
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
|
||||
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
|
||||
const resolvedWorldType = worldType
|
||||
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
|
||||
: null;
|
||||
const activeSceneActBackground =
|
||||
currentScenePreset?.id
|
||||
? resolveActiveSceneActBackgroundImage({
|
||||
profile: customWorldProfile,
|
||||
sceneId: currentScenePreset.id,
|
||||
storyEngineMemory,
|
||||
})
|
||||
: null;
|
||||
const backgroundSrc = activeSceneActBackground
|
||||
|| currentScenePreset?.imageSrc
|
||||
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
||||
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
|
||||
const groundBottom = '18%';
|
||||
const stageLiftPx = 68;
|
||||
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
|
||||
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
|
||||
const closestHostileNpcDistance = sceneHostileNpcs.length > 0
|
||||
? Math.min(...sceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
|
||||
: Infinity;
|
||||
const escapeLead = scrollWorld ? Math.max(0, Math.min(1, (closestHostileNpcDistance - 1.2) / 3.4)) : 0;
|
||||
const sideAnchor = '15%';
|
||||
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
|
||||
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
|
||||
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
|
||||
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
|
||||
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
|
||||
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
|
||||
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
|
||||
const companionAnchorBottom = `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px)`;
|
||||
const playerBottomOffsetPx = getCharacterBottomOffsetPx(stageLiftPx, playerCharacter, playerOffsetY);
|
||||
const playerLeft = playerActionMode === 'melee' && !scrollWorld
|
||||
? playerMeleeLeft
|
||||
: scrollWorld
|
||||
? playerWorldLeft
|
||||
: playerStageLeft;
|
||||
const monsterAnchorMeters = 3.2;
|
||||
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
|
||||
if (!scrollWorld && hostileNpc.animation !== 'attack') {
|
||||
return opponentStageLeft;
|
||||
}
|
||||
|
||||
const baseLeft =
|
||||
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
|
||||
? monsterMeleeLeft
|
||||
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
|
||||
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
|
||||
};
|
||||
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
|
||||
const base = playerActionMode === 'melee' && !scrollWorld
|
||||
? playerMeleeLeft
|
||||
: getPlayerWorldLeft(sideAnchor, effectX, cameraAnchorX);
|
||||
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
|
||||
};
|
||||
const getHostileNpcEffectLeft = (effectX: number, hostileNpcId?: string, offsetPx = 0) => {
|
||||
const effectHostileNpc = hostileNpcId ? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
|
||||
const base = effectHostileNpc
|
||||
? getHostileNpcOuterLeft(effectHostileNpc)
|
||||
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
|
||||
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
|
||||
};
|
||||
const isSceneTransitionExiting = sceneTransitionPhase === 'exiting';
|
||||
const isSceneTransitionEntering = sceneTransitionPhase === 'entering';
|
||||
const effectivePlayerAnimationState = sceneTransitionPhase === 'idle' ? animationState : AnimationState.RUN;
|
||||
const effectivePlayerFacing = sceneTransitionPhase === 'idle' ? playerFacing : 'right';
|
||||
const shouldShowPlayerDialogueIcon =
|
||||
Boolean(dialogueIndicator?.showPlayer)
|
||||
&& sceneTransitionPhase === 'idle'
|
||||
&& effectivePlayerAnimationState !== AnimationState.RUN;
|
||||
const transitionSweepPx = Math.max(stageOuterWidth + SCENE_TRANSITION_SPRITE_CLEARANCE_PX, 320);
|
||||
const sceneTransitionTravelDurationS = transitionSweepPx / SCENE_TRANSITION_SPEED_PX_PER_S;
|
||||
const sceneTransitionExitDurationS = sceneTransitionTravelDurationS;
|
||||
const sceneTransitionEntryDurationS = sceneTransitionTravelDurationS;
|
||||
const sceneTransitionEntryTotalDurationS =
|
||||
sceneTransitionEntryDurationS + SCENE_TRANSITION_LOWER_COMPANION_DELAY_S;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const measure = () => setStageOuterWidth(stage.clientWidth);
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundLoadFailed(false);
|
||||
}, [backgroundSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
onSceneTransitionDurationsChange?.({
|
||||
exitMs: Math.round(sceneTransitionExitDurationS * 1000),
|
||||
entryMs: Math.round(sceneTransitionEntryTotalDurationS * 1000),
|
||||
});
|
||||
}, [
|
||||
onSceneTransitionDurationsChange,
|
||||
sceneTransitionEntryTotalDurationS,
|
||||
sceneTransitionExitDurationS,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextSceneTitle = currentScenePreset?.name ?? null;
|
||||
const previousSceneTitle = previousSceneTitleRef.current;
|
||||
|
||||
if (nextSceneTitle && previousSceneTitle && previousSceneTitle !== nextSceneTitle) {
|
||||
setSceneTitleSpinToken(current => current + 1);
|
||||
}
|
||||
|
||||
previousSceneTitleRef.current = nextSceneTitle;
|
||||
}, [currentScenePreset?.name]);
|
||||
|
||||
return (
|
||||
<div ref={stageRef} className="relative h-full w-full overflow-hidden bg-black">
|
||||
<GameCanvasSceneLayer
|
||||
backgroundLoadFailed={backgroundLoadFailed}
|
||||
backgroundSrc={backgroundSrc}
|
||||
currentScenePreset={currentScenePreset}
|
||||
resolvedWorldType={resolvedWorldType}
|
||||
sceneTitleSpinToken={sceneTitleSpinToken}
|
||||
onSceneNameClick={onSceneNameClick}
|
||||
onBackgroundLoadError={() => setBackgroundLoadFailed(true)}
|
||||
/>
|
||||
<GameCanvasEntityLayer
|
||||
companions={companions}
|
||||
currentScenePreset={currentScenePreset}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
isSceneTransitionEntering={isSceneTransitionEntering}
|
||||
isSceneTransitionExiting={isSceneTransitionExiting}
|
||||
transitionSweepPx={transitionSweepPx}
|
||||
sceneTransitionExitDurationS={sceneTransitionExitDurationS}
|
||||
sceneTransitionEntryDurationS={sceneTransitionEntryDurationS}
|
||||
companionAnchorLeft={companionAnchorLeft}
|
||||
companionAnchorBottom={companionAnchorBottom}
|
||||
playerBottomOffsetPx={playerBottomOffsetPx}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
inBattle={inBattle}
|
||||
onEntitySelect={onEntitySelect}
|
||||
playerLeft={playerLeft}
|
||||
playerCharacter={playerCharacter}
|
||||
playerHp={playerHp}
|
||||
playerMaxHp={playerMaxHp}
|
||||
effectivePlayerFacing={effectivePlayerFacing}
|
||||
effectivePlayerAnimationState={effectivePlayerAnimationState}
|
||||
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={npcAffinityEffect}
|
||||
sceneCombatants={sceneHostileNpcs}
|
||||
monsters={monsters}
|
||||
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
|
||||
groundBottom={groundBottom}
|
||||
stageLiftPx={stageLiftPx}
|
||||
encounter={encounter}
|
||||
sideAnchor={sideAnchor}
|
||||
cameraAnchorX={cameraAnchorX}
|
||||
monsterAnchorMeters={monsterAnchorMeters}
|
||||
playerX={playerX}
|
||||
/>
|
||||
<GameCanvasEffectLayer
|
||||
activeCombatEffects={activeCombatEffects}
|
||||
getPlayerEffectLeft={getPlayerEffectLeft}
|
||||
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
|
||||
sceneCombatants={sceneHostileNpcs}
|
||||
playerCharacter={playerCharacter}
|
||||
groundBottom={groundBottom}
|
||||
stageLiftPx={stageLiftPx}
|
||||
playerOffsetY={playerOffsetY}
|
||||
stageRef={stageRef}
|
||||
/>
|
||||
<GameCanvasOverlayLayer escapeLead={escapeLead} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/components/game-canvas/GameCanvasSceneLayer.tsx
Normal file
117
src/components/game-canvas/GameCanvasSceneLayer.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import {AnimatePresence, motion} from 'motion/react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {type ScenePresetInfo, WorldType} from '../../types';
|
||||
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
|
||||
import {PixelIcon} from '../PixelIcon';
|
||||
import { SCENE_TITLE_GEAR_FILTER } from './GameCanvasShared';
|
||||
|
||||
interface GameCanvasSceneLayerProps {
|
||||
backgroundLoadFailed: boolean;
|
||||
backgroundSrc: string;
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
resolvedWorldType: WorldType | null;
|
||||
sceneTitleSpinToken: number;
|
||||
onSceneNameClick?: (() => void) | null;
|
||||
onBackgroundLoadError: () => void;
|
||||
}
|
||||
|
||||
export function GameCanvasSceneLayer({
|
||||
backgroundLoadFailed,
|
||||
backgroundSrc,
|
||||
currentScenePreset,
|
||||
resolvedWorldType,
|
||||
sceneTitleSpinToken,
|
||||
onSceneNameClick = null,
|
||||
onBackgroundLoadError,
|
||||
}: GameCanvasSceneLayerProps) {
|
||||
const {
|
||||
resolvedUrl: resolvedBackgroundSrc,
|
||||
shouldResolve: shouldResolveBackground,
|
||||
} = useResolvedAssetReadUrl(backgroundSrc);
|
||||
// 签名地址未返回前先显示渐变底色,避免浏览器直接访问私有原图触发 403。
|
||||
const displayBackgroundSrc =
|
||||
resolvedBackgroundSrc || (!shouldResolveBackground ? backgroundSrc : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
{!backgroundLoadFailed && displayBackgroundSrc ? (
|
||||
<img
|
||||
src={displayBackgroundSrc}
|
||||
alt={currentScenePreset?.name || 'Scene background'}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
onError={onBackgroundLoadError}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
resolvedWorldType === WorldType.WUXIA
|
||||
? 'linear-gradient(180deg, #d97706 0%, #451a03 100%)'
|
||||
: resolvedWorldType === WorldType.XIANXIA
|
||||
? 'linear-gradient(180deg, #1d4ed8 0%, #0f172a 100%)'
|
||||
: 'linear-gradient(180deg, #0f766e 0%, #0b1120 100%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-overlay [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.14),transparent_20%),radial-gradient(circle_at_80%_30%,rgba(255,255,255,0.08),transparent_18%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.06),transparent_22%)]" />
|
||||
|
||||
{currentScenePreset && (
|
||||
<div className="absolute left-1/2 top-3 z-20 -translate-x-1/2">
|
||||
<motion.div
|
||||
key={`scene-title-gear-left-${sceneTitleSpinToken}`}
|
||||
initial={{rotate: 0}}
|
||||
animate={{rotate: sceneTitleSpinToken === 0 ? 0 : -180}}
|
||||
transition={{duration: 0.92, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="pointer-events-none absolute left-0 top-1/2 -translate-x-[46%] -translate-y-1/2"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.settings}
|
||||
className="h-[2.35rem] w-[2.35rem] opacity-95"
|
||||
style={{filter: SCENE_TITLE_GEAR_FILTER}}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
key={`scene-title-gear-right-${sceneTitleSpinToken}`}
|
||||
initial={{rotate: 0}}
|
||||
animate={{rotate: sceneTitleSpinToken === 0 ? 0 : 180}}
|
||||
transition={{duration: 0.92, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="pointer-events-none absolute right-0 top-1/2 translate-x-[46%] -translate-y-1/2"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.settings}
|
||||
className="h-[2.35rem] w-[2.35rem] opacity-95"
|
||||
style={{filter: SCENE_TITLE_GEAR_FILTER}}
|
||||
/>
|
||||
</motion.div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSceneNameClick ?? undefined}
|
||||
className="pixel-nine-slice pixel-pressable relative z-10 min-w-[168px] max-w-[min(68vw,320px)] text-center text-[11px] font-bold tracking-[0.18em] text-white"
|
||||
style={getNineSliceStyle(UI_CHROME.sceneTitle, {paddingX: 16, paddingY: 4})}
|
||||
>
|
||||
<span className="block overflow-hidden" style={{perspective: '480px'}}>
|
||||
<span className="relative block h-[1.1rem] overflow-hidden leading-[1.1rem]">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
key={currentScenePreset.name}
|
||||
initial={{y: '115%', rotateX: -55, opacity: 0.15, filter: 'blur(1.4px)'}}
|
||||
animate={{y: '0%', rotateX: 0, opacity: 1, filter: 'blur(0px)'}}
|
||||
exit={{y: '-115%', rotateX: 55, opacity: 0.15, filter: 'blur(1.4px)'}}
|
||||
transition={{duration: 0.82, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="absolute inset-0 flex items-center justify-center whitespace-nowrap"
|
||||
>
|
||||
{currentScenePreset.name}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
499
src/components/game-canvas/GameCanvasShared.tsx
Normal file
499
src/components/game-canvas/GameCanvasShared.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
|
||||
import {
|
||||
buildMedievalNpcVisual,
|
||||
buildMedievalNpcVisualFromCustomWorldVisual,
|
||||
} from '../../data/medievalNpcVisuals';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CombatActionMode,
|
||||
CombatVisualEffect,
|
||||
CompanionRenderState,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
ScenePresetInfo,
|
||||
StoryEngineMemoryState,
|
||||
StoryNpcAffinityEffect,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {CharacterAnimator} from '../CharacterAnimator';
|
||||
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
||||
|
||||
export type GameCanvasEntitySelection =
|
||||
| {kind: 'player'}
|
||||
| {kind: 'companion'; companion: CompanionRenderState}
|
||||
| {kind: 'npc'; encounter: Encounter; battleState?: SceneHostileNpc};
|
||||
|
||||
export interface GameCanvasProps {
|
||||
scrollWorld: boolean;
|
||||
animationState: AnimationState;
|
||||
playerCharacter: Character | null;
|
||||
encounter: Encounter | null;
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
sceneHostileNpcs: SceneHostileNpc[];
|
||||
playerX: number;
|
||||
playerOffsetY: number;
|
||||
playerFacing: 'left' | 'right';
|
||||
playerActionMode?: CombatActionMode;
|
||||
inBattle: boolean;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana?: number;
|
||||
playerMaxMana?: number;
|
||||
activeCombatEffects?: CombatVisualEffect[];
|
||||
companions?: CompanionRenderState[];
|
||||
npcStates?: unknown;
|
||||
dialogueIndicator?: {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
||||
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
|
||||
onSceneNameClick?: (() => void) | null;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
sceneTransitionToken?: number;
|
||||
onSceneTransitionDurationsChange?: ((durations: {exitMs: number; entryMs: number}) => void) | null;
|
||||
}
|
||||
|
||||
export const MONSTER_RENDER_OFFSETS: Record<string, {x: number; y: number}> = {
|
||||
'monster-06': {x: -18, y: 14},
|
||||
};
|
||||
export const ENTITY_CONTAINER_REM = 7;
|
||||
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
|
||||
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
|
||||
export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
|
||||
export const GENERIC_NPC_SCENE_SCALE = 1.72;
|
||||
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 78;
|
||||
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
objectPosition: 'center bottom',
|
||||
};
|
||||
export const CHARACTER_COMBAT_HP_TOP_PX = -48;
|
||||
export const MONSTER_COMBAT_HP_TOP_PX = -44;
|
||||
export const GENERIC_NPC_COMBAT_HP_TOP_PX = -48;
|
||||
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
|
||||
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
|
||||
export type HostileNpcSceneAnchorConfig = {
|
||||
frameHeight: number;
|
||||
};
|
||||
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
|
||||
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
|
||||
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
|
||||
export const CHAT_BUBBLE_FRAME_COUNT = 12;
|
||||
export const CHAT_BUBBLE_ACTIVE_FRAMES = [0, 1, 2, 3, 4, 5];
|
||||
export const CHAT_BUBBLE_INACTIVE_FRAMES = [6, 7, 8, 9, 10, 11];
|
||||
export const SCENE_TITLE_GEAR_FILTER =
|
||||
'sepia(1) saturate(2.1) hue-rotate(338deg) brightness(0.94) contrast(1.08) drop-shadow(0 6px 12px rgba(0, 0, 0, 0.42))';
|
||||
export const SCENE_TRANSITION_SPRITE_CLEARANCE_PX = 168;
|
||||
export const SCENE_TRANSITION_REFERENCE_STAGE_WIDTH_PX = 400;
|
||||
export const SCENE_TRANSITION_REFERENCE_DURATION_S = 5;
|
||||
export const SCENE_TRANSITION_SPEED_PX_PER_S =
|
||||
(SCENE_TRANSITION_REFERENCE_STAGE_WIDTH_PX + SCENE_TRANSITION_SPRITE_CLEARANCE_PX)
|
||||
/ SCENE_TRANSITION_REFERENCE_DURATION_S;
|
||||
export const SCENE_TRANSITION_UPPER_COMPANION_DELAY_S = 0.43;
|
||||
export const SCENE_TRANSITION_LOWER_COMPANION_DELAY_S = 0.93;
|
||||
|
||||
export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
|
||||
return slot === 'upper'
|
||||
? {left: -56, bottom: 66}
|
||||
: {left: -34, bottom: 10};
|
||||
}
|
||||
|
||||
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
|
||||
if (animation === 'move') return AnimationState.RUN;
|
||||
if (animation === 'attack') return AnimationState.ATTACK;
|
||||
return AnimationState.IDLE;
|
||||
}
|
||||
|
||||
export function HpBar({
|
||||
hp,
|
||||
maxHp,
|
||||
tone,
|
||||
}: {
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
tone: 'emerald' | 'rose';
|
||||
}) {
|
||||
const ratio = Math.max(0, Math.min(1, maxHp > 0 ? hp / maxHp : 0));
|
||||
const fill = tone === 'emerald' ? 'from-emerald-400 to-green-300' : 'from-rose-500 to-red-400';
|
||||
|
||||
return (
|
||||
<div className="w-11">
|
||||
<div className="h-1 overflow-hidden rounded-full border border-white/10 bg-black/55 shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
|
||||
<div className={`h-full bg-gradient-to-r ${fill}`} style={{width: `${ratio * 100}%`}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlayerWorldLeft(
|
||||
sideAnchor: string,
|
||||
playerX: number,
|
||||
cameraAnchorX: number,
|
||||
) {
|
||||
return `calc(${sideAnchor} + ${(playerX - cameraAnchorX) * METERS_TO_PIXELS * 0.65}px)`;
|
||||
}
|
||||
|
||||
export function getMirroredStageEntityLeft(
|
||||
sideAnchor: string,
|
||||
side: 'player' | 'opponent',
|
||||
) {
|
||||
return side === 'player'
|
||||
? sideAnchor
|
||||
: `calc(100% - ${sideAnchor} - ${ENTITY_CONTAINER_REM}rem)`;
|
||||
}
|
||||
|
||||
export function getMonsterWorldLeft(
|
||||
sideAnchor: string,
|
||||
monsterX: number,
|
||||
cameraAnchorX: number,
|
||||
monsterAnchorMeters: number,
|
||||
) {
|
||||
return `calc(100% - ${sideAnchor} + ${((monsterX - cameraAnchorX) - monsterAnchorMeters) * METERS_TO_PIXELS * 0.75}px - ${ENTITY_CONTAINER_REM}rem)`;
|
||||
}
|
||||
|
||||
export function getCharacterOpponentBottom(
|
||||
groundBottom: string,
|
||||
stageLiftPx: number,
|
||||
character: Character | null | undefined,
|
||||
) {
|
||||
const groundOffset = character?.groundOffsetY ?? 22;
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`;
|
||||
}
|
||||
|
||||
export function hasEncounterCustomSceneVisual(encounter: Encounter | null | undefined) {
|
||||
return Boolean(
|
||||
encounter?.visual
|
||||
|| encounter?.imageSrc?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getEncounterCharacterGroundOffset(
|
||||
encounter: Encounter | null | undefined,
|
||||
character: Character | null | undefined,
|
||||
) {
|
||||
if (hasEncounterCustomSceneVisual(encounter)) {
|
||||
// 场景 NPC 的 AI 形象通常是方图或组合视觉,不能沿用模板角色脚底偏移。
|
||||
return SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX;
|
||||
}
|
||||
|
||||
return character?.groundOffsetY ?? 22;
|
||||
}
|
||||
|
||||
export function getEncounterCharacterOpponentBottom(
|
||||
groundBottom: string,
|
||||
stageLiftPx: number,
|
||||
encounter: Encounter | null | undefined,
|
||||
character: Character | null | undefined,
|
||||
) {
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px - ${getEncounterCharacterGroundOffset(encounter, character)}px)`;
|
||||
}
|
||||
|
||||
export function getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx: number,
|
||||
encounter: Encounter | null | undefined,
|
||||
character: Character | null | undefined,
|
||||
extraOffsetPx = 0,
|
||||
) {
|
||||
return stageLiftPx - getEncounterCharacterGroundOffset(encounter, character) + extraOffsetPx;
|
||||
}
|
||||
|
||||
export function getSceneNpcVisualBottomOffsetPx(encounter: Encounter | null | undefined) {
|
||||
return hasEncounterCustomSceneVisual(encounter)
|
||||
? -SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function getHostileNpcSceneBottomOffsetPx(
|
||||
monster: HostileNpcSceneAnchorConfig | null | undefined,
|
||||
) {
|
||||
if (!monster) return 0;
|
||||
|
||||
// 怪物动画帧和角色立绘不是同一套脚底锚点,大帧需要更明显地下沉到场景地面线。
|
||||
if (monster.frameHeight >= 58) return -78;
|
||||
if (monster.frameHeight >= 42) return -68;
|
||||
if (monster.frameHeight >= 34) return -52;
|
||||
return -28;
|
||||
}
|
||||
|
||||
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
|
||||
if (monsterPresetId) return MONSTER_COMBAT_HP_TOP_PX;
|
||||
return characterId ? CHARACTER_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
|
||||
}
|
||||
|
||||
export function getSceneEntityZIndex(bottomOffsetPx: number) {
|
||||
return Math.max(1, Math.min(9, 9 - Math.round(bottomOffsetPx / 16)));
|
||||
}
|
||||
|
||||
export function getCharacterBottomOffsetPx(
|
||||
stageLiftPx: number,
|
||||
character: Character | null | undefined,
|
||||
extraOffsetPx = 0,
|
||||
) {
|
||||
const groundOffset = character?.groundOffsetY ?? 22;
|
||||
return stageLiftPx - groundOffset + extraOffsetPx;
|
||||
}
|
||||
|
||||
export function getEntityEffectBottom({
|
||||
origin,
|
||||
hostileNpcId,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
anchorOffsetY = 0,
|
||||
}: {
|
||||
origin: 'player' | 'hostile_npc' | 'monster';
|
||||
hostileNpcId?: string;
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
playerCharacter: Character | null;
|
||||
groundBottom: string;
|
||||
stageLiftPx: number;
|
||||
playerOffsetY: number;
|
||||
anchorOffsetY?: number;
|
||||
}) {
|
||||
if (origin === 'player') {
|
||||
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px + ${anchorOffsetY}px)`;
|
||||
}
|
||||
|
||||
const targetHostileNpc = hostileNpcId
|
||||
? sceneCombatants.find(hostileNpc => hostileNpc.id === hostileNpcId)
|
||||
: null;
|
||||
|
||||
if (!targetHostileNpc) {
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px + ${anchorOffsetY}px)`;
|
||||
}
|
||||
|
||||
if (targetHostileNpc.encounter?.characterId) {
|
||||
return getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY,
|
||||
targetHostileNpc.encounter,
|
||||
getCharacterById(targetHostileNpc.encounter.characterId),
|
||||
);
|
||||
}
|
||||
|
||||
const genericNpcTargetOffset =
|
||||
targetHostileNpc.encounter
|
||||
&& !targetHostileNpc.encounter.characterId
|
||||
&& !targetHostileNpc.encounter.monsterPresetId
|
||||
? GENERIC_NPC_EFFECT_TARGET_OFFSET_PX
|
||||
: 0;
|
||||
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px + ${((targetHostileNpc.yOffset ?? 0) + genericNpcTargetOffset + anchorOffsetY)}px)`;
|
||||
}
|
||||
|
||||
export function RoleCharacterSprite({
|
||||
character,
|
||||
state,
|
||||
facing,
|
||||
}: {
|
||||
character: Character;
|
||||
state: AnimationState;
|
||||
facing: 'left' | 'right';
|
||||
}) {
|
||||
if (character.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
|
||||
className="origin-bottom"
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}>
|
||||
<CharacterAnimator
|
||||
state={state}
|
||||
character={character}
|
||||
className={ROLE_CHARACTER_SPRITE_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneEncounterNpcSprite({
|
||||
encounter,
|
||||
state,
|
||||
facing,
|
||||
className,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
state: AnimationState;
|
||||
facing: 'left' | 'right';
|
||||
className?: string;
|
||||
}) {
|
||||
const rawEncounterImageSrc = encounter.imageSrc?.trim() ?? '';
|
||||
const {
|
||||
resolvedUrl: resolvedEncounterImageSrc,
|
||||
shouldResolve: shouldResolveEncounterImage,
|
||||
} = useResolvedAssetReadUrl(rawEncounterImageSrc);
|
||||
const displayEncounterImageSrc =
|
||||
resolvedEncounterImageSrc
|
||||
|| (!shouldResolveEncounterImage ? rawEncounterImageSrc : '');
|
||||
|
||||
if (encounter.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(encounter.visual)}
|
||||
className={`origin-bottom ${className ?? ''}`.trim()}
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (rawEncounterImageSrc && shouldResolveEncounterImage && !displayEncounterImageSrc) {
|
||||
return <div className={`h-full w-full ${className ?? ''}`.trim()} />;
|
||||
}
|
||||
|
||||
if (displayEncounterImageSrc) {
|
||||
const transform = `${facing === 'left' ? 'scaleX(-1) ' : ''}scale(${ROLE_CHARACTER_SCENE_IMAGE_SCALE})`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={displayEncounterImageSrc}
|
||||
alt={encounter.npcName}
|
||||
className={`h-full w-full origin-bottom object-contain ${className ?? ''}`.trim()}
|
||||
style={{
|
||||
...DEFAULT_IMAGE_STYLE,
|
||||
transform,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeCustomWorldCharacter =
|
||||
encounter.characterId ? getCharacterById(encounter.characterId) : null;
|
||||
if (runtimeCustomWorldCharacter?.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(runtimeCustomWorldCharacter.visual)}
|
||||
className={`origin-bottom ${className ?? ''}`.trim()}
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeCustomWorldCharacter) {
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}
|
||||
>
|
||||
<CharacterAnimator
|
||||
state={state}
|
||||
character={runtimeCustomWorldCharacter}
|
||||
className={ROLE_CHARACTER_SPRITE_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisual({
|
||||
id: encounter.id ?? encounter.npcName,
|
||||
npcName: encounter.npcName,
|
||||
npcDescription: encounter.npcDescription,
|
||||
npcAvatar: encounter.npcAvatar,
|
||||
context: encounter.context,
|
||||
} as Encounter)}
|
||||
className={`origin-bottom ${className ?? ''}`.trim()}
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogueBubbleIcon({
|
||||
active = false,
|
||||
flip = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
flip?: boolean;
|
||||
}) {
|
||||
const frameSequence = active ? CHAT_BUBBLE_ACTIVE_FRAMES : CHAT_BUBBLE_INACTIVE_FRAMES;
|
||||
const [frameCursor, setFrameCursor] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameCursor(0);
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameCursor(prev => (prev + 1) % frameSequence.length);
|
||||
}, active ? 120 : 180);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [active, frameSequence.length]);
|
||||
|
||||
const frameIndex = frameSequence[frameCursor] ?? frameSequence[0] ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
style={{
|
||||
width: `${CHAT_BUBBLE_FRAME_WIDTH}px`,
|
||||
height: `${CHAT_BUBBLE_FRAME_HEIGHT}px`,
|
||||
backgroundImage: `url("${CHAT_BUBBLE_SPRITE_SRC}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${frameIndex * CHAT_BUBBLE_FRAME_WIDTH}px 0px`,
|
||||
backgroundSize: `${CHAT_BUBBLE_FRAME_WIDTH * CHAT_BUBBLE_FRAME_COUNT}px ${CHAT_BUBBLE_FRAME_HEIGHT}px`,
|
||||
imageRendering: 'pixelated',
|
||||
transform: `${flip ? 'scaleX(-1) ' : ''}scale(${active ? 1.15 : 1})`,
|
||||
transformOrigin: 'center',
|
||||
filter: active
|
||||
? 'drop-shadow(0 0 8px rgba(251, 191, 36, 0.45))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.45))',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneEntityButton({
|
||||
onClick,
|
||||
ariaLabel,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
onClick?: (() => void) | null;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!onClick) {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
className={`group touch-manipulation transition-transform duration-150 hover:scale-[1.02] focus-visible:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${className ?? ''}`}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
59
src/components/game-canvas/NpcAffinityEffectBadge.tsx
Normal file
59
src/components/game-canvas/NpcAffinityEffectBadge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type { StoryNpcAffinityEffect } from '../../types';
|
||||
|
||||
interface NpcAffinityEffectBadgeProps {
|
||||
effect: StoryNpcAffinityEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天结算后的好感度浮出特效。
|
||||
* 仅负责表现层,不承担任何数值计算。
|
||||
*/
|
||||
export function NpcAffinityEffectBadge({
|
||||
effect,
|
||||
}: NpcAffinityEffectBadgeProps) {
|
||||
const isPositive = effect.delta > 0;
|
||||
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={effect.eventId}
|
||||
initial={{ opacity: 0, y: 24, scale: 0.8 }}
|
||||
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
|
||||
transition={{ duration: 1.45, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
|
||||
data-testid={`npc-affinity-effect-${effect.npcId}`}
|
||||
aria-label={`好感度变化 ${deltaText}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
|
||||
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
|
||||
<div className="relative flex items-center gap-1 text-rose-50">
|
||||
<Heart className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="text-xs font-semibold tracking-[0.08em]">
|
||||
{deltaText}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
|
||||
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
|
||||
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
|
||||
{deltaText}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full border ${
|
||||
isPositive
|
||||
? 'border-rose-200/45 bg-rose-500/18'
|
||||
: 'border-slate-200/35 bg-slate-700/30'
|
||||
}`}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
44
src/components/npcRenderUtils.ts
Normal file
44
src/components/npcRenderUtils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Encounter, FacingDirection } from '../types';
|
||||
|
||||
const DEFAULT_NPC_SCENE_OVERLAY_OFFSETS = {
|
||||
hpTop: -40,
|
||||
nameTop: -20,
|
||||
dialogueTop: -56,
|
||||
};
|
||||
|
||||
const GENERIC_NPC_SCENE_OVERLAY_OFFSETS = {
|
||||
hpTop: -24,
|
||||
nameTop: -8,
|
||||
dialogueTop: -48,
|
||||
};
|
||||
|
||||
export const GENERIC_NPC_SCENE_FOOT_OFFSET_PX = -30;
|
||||
|
||||
export function isGenericNpcEncounter(encounter: Encounter | null | undefined) {
|
||||
return Boolean(encounter && encounter.kind !== 'treasure' && !encounter.characterId && !encounter.monsterPresetId);
|
||||
}
|
||||
|
||||
export function invertFacing(facing: FacingDirection): FacingDirection {
|
||||
return facing === 'left' ? 'right' : 'left';
|
||||
}
|
||||
|
||||
export function getRenderableNpcFacing(
|
||||
encounter: Encounter | null | undefined,
|
||||
facing: FacingDirection,
|
||||
options?: { medievalVisual?: boolean },
|
||||
): FacingDirection {
|
||||
const medieval =
|
||||
options?.medievalVisual ??
|
||||
Boolean(encounter && encounter.kind !== 'treasure' && isGenericNpcEncounter(encounter));
|
||||
return medieval ? invertFacing(facing) : facing;
|
||||
}
|
||||
|
||||
export function getNpcSceneFootOffset(encounter: Encounter | null | undefined) {
|
||||
return isGenericNpcEncounter(encounter) ? GENERIC_NPC_SCENE_FOOT_OFFSET_PX : 0;
|
||||
}
|
||||
|
||||
export function getNpcSceneOverlayOffsets(encounter: Encounter | null | undefined) {
|
||||
return isGenericNpcEncounter(encounter)
|
||||
? GENERIC_NPC_SCENE_OVERLAY_OFFSETS
|
||||
: DEFAULT_NPC_SCENE_OVERLAY_OFFSETS;
|
||||
}
|
||||
28
src/components/npcVisualShared.ts
Normal file
28
src/components/npcVisualShared.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import npcLayoutConfigJson from '../data/npcLayoutConfig.json';
|
||||
|
||||
export type NpcLayoutPart =
|
||||
| 'body'
|
||||
| 'head'
|
||||
| 'facialHair'
|
||||
| 'hair'
|
||||
| 'headgear'
|
||||
| 'hand'
|
||||
| 'mainHand'
|
||||
| 'offHand';
|
||||
|
||||
export type NpcLayoutConfig = Record<NpcLayoutPart, { x: number; y: number }>;
|
||||
|
||||
export const DEFAULT_NPC_LAYOUT_CONFIG = npcLayoutConfigJson as NpcLayoutConfig;
|
||||
|
||||
export function cloneNpcLayoutConfig(layout: NpcLayoutConfig): NpcLayoutConfig {
|
||||
return {
|
||||
body: { ...layout.body },
|
||||
head: { ...layout.head },
|
||||
facialHair: { ...layout.facialHair },
|
||||
hair: { ...layout.hair },
|
||||
headgear: { ...layout.headgear },
|
||||
hand: { ...layout.hand },
|
||||
mainHand: { ...layout.mainHand },
|
||||
offHand: { ...layout.offHand },
|
||||
};
|
||||
}
|
||||
119
src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
Normal file
119
src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
|
||||
|
||||
export interface PlatformEntryCreationTypeModalProps {
|
||||
isOpen: boolean;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSelectRpg: () => void;
|
||||
onSelectBigFish: () => void;
|
||||
onSelectPuzzle: () => void;
|
||||
}
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
item: (typeof PLATFORM_CREATION_TYPES)[number];
|
||||
busy: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { item, busy, onSelect } = props;
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
|
||||
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`platform-pill px-3 ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<span className="text-lg leading-none text-white/45">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 text-xl font-black leading-tight text-inherit">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-sm ${
|
||||
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台入口创作类型弹层。
|
||||
* 多玩法入口统一在这里分流,避免把非 RPG 玩法写进 RPG 命名脚本。
|
||||
*/
|
||||
export function PlatformEntryCreationTypeModal({
|
||||
isOpen,
|
||||
isBusy,
|
||||
error,
|
||||
onClose,
|
||||
onSelectRpg,
|
||||
onSelectBigFish,
|
||||
onSelectPuzzle,
|
||||
}: PlatformEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={isOpen}
|
||||
title="选择创作类型"
|
||||
description="先选玩法类型,再进入对应创作工作台。"
|
||||
onClose={onClose}
|
||||
closeDisabled={isBusy}
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
17
src/components/platform-entry/PlatformEntryFlowShell.tsx
Normal file
17
src/components/platform-entry/PlatformEntryFlowShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PlatformEntryFlowShellImpl } from './PlatformEntryFlowShellImpl';
|
||||
import type {
|
||||
PlatformEntryFlowShellProps,
|
||||
SelectionStage,
|
||||
} from './platformEntryTypes';
|
||||
|
||||
export type { PlatformEntryFlowShellProps, SelectionStage };
|
||||
|
||||
/**
|
||||
* 平台入口通用壳层。
|
||||
* RPG、Big Fish 等玩法创作入口在这里并列分流。
|
||||
*/
|
||||
export function PlatformEntryFlowShell(props: PlatformEntryFlowShellProps) {
|
||||
return <PlatformEntryFlowShellImpl {...props} />;
|
||||
}
|
||||
|
||||
export default PlatformEntryFlowShell;
|
||||
2593
src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
Normal file
2593
src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
Normal file
File diff suppressed because it is too large
Load Diff
9
src/components/platform-entry/PlatformEntryHomeView.tsx
Normal file
9
src/components/platform-entry/PlatformEntryHomeView.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 平台首页视图的通用出口。
|
||||
* 当前先复用成熟的首页实现,但避免让 `platform-entry` 继续直接依赖 RPG 命名组件。
|
||||
*/
|
||||
export {
|
||||
RpgEntryHomeView as PlatformEntryHomeView,
|
||||
type RpgEntryHomeViewProps as PlatformEntryHomeViewProps,
|
||||
type PlatformHomeTab,
|
||||
} from '../rpg-entry/RpgEntryHomeView';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 平台作品详情视图的通用出口。
|
||||
* 这里保留平台语义封装,减少 Big Fish 继续挂在 RPG 命名路径上的误导。
|
||||
*/
|
||||
export {
|
||||
RpgEntryWorldDetailView as PlatformEntryWorldDetailView,
|
||||
type RpgEntryWorldDetailViewProps as PlatformEntryWorldDetailViewProps,
|
||||
} from '../rpg-entry/RpgEntryWorldDetailView';
|
||||
9
src/components/platform-entry/index.ts
Normal file
9
src/components/platform-entry/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
PlatformEntryFlowShell,
|
||||
type PlatformEntryFlowShellProps,
|
||||
type SelectionStage,
|
||||
} from './PlatformEntryFlowShell';
|
||||
export {
|
||||
PlatformEntryCreationTypeModal,
|
||||
type PlatformEntryCreationTypeModalProps,
|
||||
} from './PlatformEntryCreationTypeModal';
|
||||
55
src/components/platform-entry/platformEntryCreationTypes.ts
Normal file
55
src/components/platform-entry/platformEntryCreationTypes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type PlatformCreationTypeId =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'puzzle'
|
||||
| 'airp'
|
||||
| 'visual-novel';
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
|
||||
*/
|
||||
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演 RPG',
|
||||
subtitle: 'Agent 共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'big-fish',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '实时成长玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图玩法',
|
||||
subtitle: '图像锚点共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
9
src/components/platform-entry/platformEntryShared.ts
Normal file
9
src/components/platform-entry/platformEntryShared.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 平台入口共享 helper 的通用封装层。
|
||||
* 先复用既有实现,同时把多玩法入口依赖从 RPG 命名中隔离出来。
|
||||
*/
|
||||
export {
|
||||
buildCreationHubFallbackItems,
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from '../rpg-entry/rpgEntryShared';
|
||||
42
src/components/platform-entry/platformEntryTypes.ts
Normal file
42
src/components/platform-entry/platformEntryTypes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
| 'big-fish-agent-workspace'
|
||||
| 'big-fish-generating'
|
||||
| 'big-fish-result'
|
||||
| 'big-fish-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-result'
|
||||
| 'puzzle-gallery-detail'
|
||||
| 'puzzle-runtime'
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
|
||||
|
||||
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
|
||||
|
||||
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export type SyncedAgentDraftResult = {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
profile: CustomWorldProfile | null;
|
||||
};
|
||||
|
||||
export type PlatformEntryFlowShellProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { TextStreamOptions } from '../../services/aiTypes';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
type CreationAgentMessageLike = {
|
||||
clientMessageId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type CreationAgentSessionLike = {
|
||||
sessionId: string;
|
||||
draft?: unknown;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
kind?: string;
|
||||
text: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type CreationAgentClientAdapter<
|
||||
TSession extends CreationAgentSessionLike,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
TActionPayload,
|
||||
TActionResponse,
|
||||
> = {
|
||||
createSession: (payload: TCreatePayload) => Promise<TCreateResponse>;
|
||||
getSession: (sessionId: string) => Promise<TCreateResponse>;
|
||||
streamMessage: (
|
||||
sessionId: string,
|
||||
payload: TMessagePayload,
|
||||
options?: TextStreamOptions,
|
||||
) => Promise<TSession>;
|
||||
executeAction: (
|
||||
sessionId: string,
|
||||
payload: TActionPayload,
|
||||
) => Promise<TActionResponse>;
|
||||
selectSession: (response: TCreateResponse) => TSession;
|
||||
};
|
||||
|
||||
type PlatformCreationAgentFlowControllerOptions<
|
||||
TSession extends CreationAgentSessionLike,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
TActionPayload,
|
||||
TActionResponse,
|
||||
> = {
|
||||
client: CreationAgentClientAdapter<
|
||||
TSession,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload,
|
||||
TActionPayload,
|
||||
TActionResponse
|
||||
>;
|
||||
createPayload: TCreatePayload;
|
||||
workspaceStage: SelectionStage;
|
||||
resultStage: SelectionStage;
|
||||
platformStage: SelectionStage;
|
||||
isCompileAction: (payload: TActionPayload) => boolean;
|
||||
resolveErrorMessage: (error: unknown, fallback: string) => string;
|
||||
errorMessages: {
|
||||
open: string;
|
||||
restoreMissingSession: string;
|
||||
restore: string;
|
||||
submit: string;
|
||||
execute: string;
|
||||
};
|
||||
enterCreateTab: () => void;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
onSessionOpened?: () => void;
|
||||
onActionComplete?: (params: {
|
||||
payload: TActionPayload;
|
||||
response: TActionResponse;
|
||||
session: TSession;
|
||||
setSession: (session: TSession) => void;
|
||||
}) => Promise<void> | void;
|
||||
beforeExecuteAction?: (params: {
|
||||
payload: TActionPayload;
|
||||
session: TSession;
|
||||
}) => void;
|
||||
onActionError?: (params: {
|
||||
payload: TActionPayload;
|
||||
error: unknown;
|
||||
errorMessage: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
function buildOptimisticMessage<TMessagePayload extends CreationAgentMessageLike>(
|
||||
payload: TMessagePayload,
|
||||
) {
|
||||
return {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: payload.text.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量作品 Agent 创作流程的通用前端控制器。
|
||||
* 这里只处理跨玩法一致的会话、流式消息、忙碌态与草稿恢复,玩法结果页和运行态动作留给外层。
|
||||
*/
|
||||
export function usePlatformCreationAgentFlowController<
|
||||
TSession extends CreationAgentSessionLike,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
TActionPayload,
|
||||
TActionResponse,
|
||||
>(
|
||||
options: PlatformCreationAgentFlowControllerOptions<
|
||||
TSession,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload,
|
||||
TActionPayload,
|
||||
TActionResponse
|
||||
>,
|
||||
) {
|
||||
const [session, setSession] = useState<TSession | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [streamingReplyText, setStreamingReplyText] = useState('');
|
||||
const [isStreamingReply, setIsStreamingReply] = useState(false);
|
||||
|
||||
const openWorkspace = useCallback(async () => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
|
||||
try {
|
||||
const response = await options.client.createSession(options.createPayload);
|
||||
setSession(options.client.selectSession(response));
|
||||
options.enterCreateTab();
|
||||
options.onSessionOpened?.();
|
||||
options.setSelectionStage(options.workspaceStage);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.open),
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [isBusy, options]);
|
||||
|
||||
const restoreDraft = useCallback(
|
||||
async (sessionId: string | null | undefined) => {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
setError(options.errorMessages.restoreMissingSession);
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
|
||||
try {
|
||||
const response = await options.client.getSession(normalizedSessionId);
|
||||
const nextSession = options.client.selectSession(response);
|
||||
setSession(nextSession);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(
|
||||
nextSession.draft ? options.resultStage : options.workspaceStage,
|
||||
);
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.restore),
|
||||
);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(options.platformStage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
async (payload: TMessagePayload) => {
|
||||
if (!session || isStreamingReply) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optimisticMessage = buildOptimisticMessage(payload);
|
||||
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(true);
|
||||
setSession((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
messages: [...current.messages, optimisticMessage],
|
||||
updatedAt: optimisticMessage.createdAt,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const nextSession = await options.client.streamMessage(
|
||||
session.sessionId,
|
||||
payload,
|
||||
{
|
||||
onUpdate: setStreamingReplyText,
|
||||
},
|
||||
);
|
||||
setSession(nextSession);
|
||||
setStreamingReplyText('');
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.submit),
|
||||
);
|
||||
} finally {
|
||||
setIsStreamingReply(false);
|
||||
}
|
||||
},
|
||||
[isStreamingReply, options, session],
|
||||
);
|
||||
|
||||
const executeAction = useCallback(
|
||||
async (payload: TActionPayload) => {
|
||||
if (!session || isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
options.beforeExecuteAction?.({ payload, session });
|
||||
const response = await options.client.executeAction(
|
||||
session.sessionId,
|
||||
payload,
|
||||
);
|
||||
await options.onActionComplete?.({
|
||||
payload,
|
||||
response,
|
||||
session,
|
||||
setSession,
|
||||
});
|
||||
if (options.isCompileAction(payload)) {
|
||||
options.setSelectionStage(options.resultStage);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
const errorMessage = options.resolveErrorMessage(
|
||||
caughtError,
|
||||
options.errorMessages.execute,
|
||||
);
|
||||
setError(errorMessage);
|
||||
options.onActionError?.({
|
||||
payload,
|
||||
error: caughtError,
|
||||
errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, session],
|
||||
);
|
||||
|
||||
const leaveFlow = useCallback(() => {
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(options.platformStage);
|
||||
}, [options]);
|
||||
|
||||
const resetTransientState = useCallback(() => {
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
session,
|
||||
setSession,
|
||||
error,
|
||||
setError,
|
||||
isBusy,
|
||||
setIsBusy,
|
||||
streamingReplyText,
|
||||
setStreamingReplyText,
|
||||
isStreamingReply,
|
||||
setIsStreamingReply,
|
||||
openWorkspace,
|
||||
restoreDraft,
|
||||
submitMessage,
|
||||
executeAction,
|
||||
leaveFlow,
|
||||
resetTransientState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口 bootstrap 通用封装。
|
||||
* 现阶段逻辑仍复用既有实现,但对外暴露平台语义命名。
|
||||
*/
|
||||
export { useRpgEntryBootstrap as usePlatformEntryBootstrap } from '../rpg-entry/useRpgEntryBootstrap';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口详情态编排通用封装。
|
||||
* 通过平台语义出口避免 Big Fish 直接依赖 RPG 命名 hook。
|
||||
*/
|
||||
export { useRpgEntryLibraryDetail as usePlatformEntryLibraryDetail } from '../rpg-entry/useRpgEntryLibraryDetail';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口导航通用封装。
|
||||
* 多玩法统一从 `platform-entry` 暴露,RPG 目录只保留兼容与 RPG 专属能力。
|
||||
*/
|
||||
export { useRpgEntryNavigation as usePlatformEntryNavigation } from '../rpg-entry/useRpgEntryNavigation';
|
||||
@@ -0,0 +1,104 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
|
||||
|
||||
const baseSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 3,
|
||||
progressPercent: 62,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雾港遗迹拼图',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '潮雾中的灯塔与断桥',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '画面主体已经清楚,继续收束剩余关键词。',
|
||||
createdAt: '2026-04-24T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '画面主体已经清楚,继续收束剩余关键词。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-24T10:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
});
|
||||
|
||||
test('puzzle workspace submits quick keyword fill request after two turns', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '补充剩余设定' }));
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle workspace hides keyword fill before two turns', () => {
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={{ ...baseSession, currentTurn: 1 }}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
|
||||
});
|
||||
139
src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
Normal file
139
src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentOperationRecord,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentOperationView,
|
||||
type CreationAgentSessionView,
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from '../creation-agent';
|
||||
|
||||
type PuzzleAgentWorkspaceProps = {
|
||||
session: PuzzleAgentSessionSnapshot | null;
|
||||
activeOperation?: PuzzleAgentOperationRecord | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
};
|
||||
|
||||
const PUZZLE_AGENT_THEME: CreationAgentTheme = {
|
||||
accentTextClass: 'text-amber-100/84',
|
||||
accentBgClass: 'bg-amber-200',
|
||||
accentButtonClass: 'bg-amber-200 shadow-amber-950/20',
|
||||
userBubbleClass: 'bg-amber-600 text-white',
|
||||
heroClass:
|
||||
'border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.96),rgba(20,24,35,0.96))]',
|
||||
anchorGridClass: 'grid gap-2 sm:grid-cols-2 xl:grid-cols-5',
|
||||
};
|
||||
|
||||
function mapPuzzleSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
session.anchorPack.themePromise,
|
||||
session.anchorPack.visualSubject,
|
||||
session.anchorPack.visualMood,
|
||||
session.anchorPack.compositionHooks,
|
||||
session.anchorPack.tagsAndForbidden,
|
||||
],
|
||||
messages: session.messages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mapPuzzleOperation(
|
||||
operation: PuzzleAgentOperationRecord | null | undefined,
|
||||
): CreationAgentOperationView | null {
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
type: operation.type,
|
||||
status: operation.status,
|
||||
phaseLabel: operation.phaseLabel,
|
||||
phaseDetail: operation.phaseDetail,
|
||||
progress: operation.progress,
|
||||
error: operation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图 Agent 共创工作区只保留品类适配,聊天 UI 与进度管理统一走 CreationAgentWorkspace。
|
||||
*/
|
||||
export function PuzzleAgentWorkspace({
|
||||
session,
|
||||
activeOperation = null,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
}: PuzzleAgentWorkspaceProps) {
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapPuzzleSession(session) : null}
|
||||
theme={PUZZLE_AGENT_THEME}
|
||||
loadingText="正在准备拼图共创工作区..."
|
||||
composerPlaceholder="说说题材、主体、气质或你不希望出现的元素..."
|
||||
primaryActionLabel="生成结果页"
|
||||
activeOperation={mapPuzzleOperation(activeOperation)}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||
text,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'compile_puzzle_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前已经成形的拼图设定。',
|
||||
);
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||
...quickActionMessage,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleAgentWorkspace;
|
||||
@@ -0,0 +1,98 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
const detailItem = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '拼图玩家',
|
||||
levelName: '奇幻拼图',
|
||||
summary: '一张用于公开分享的拼图作品。',
|
||||
themeTags: ['奇幻'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 7,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows and copies puzzle public work code in detail view', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleGalleryDetailView
|
||||
item={detailItem}
|
||||
onBack={vi.fn()}
|
||||
onStartGame={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('作品号')).toBeTruthy();
|
||||
expect(screen.getByText('PZ-EPUBLIC1')).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
||||
});
|
||||
|
||||
test('falls back to legacy selection copy when clipboard api rejects', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => {
|
||||
throw new Error('clipboard denied');
|
||||
});
|
||||
const execCommand = vi.fn(() => true);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleGalleryDetailView
|
||||
item={detailItem}
|
||||
onBack={vi.fn()}
|
||||
onStartGame={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
||||
expect(execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(await screen.findByText('已复制')).toBeTruthy();
|
||||
});
|
||||
161
src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx
Normal file
161
src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleGalleryDetailViewProps = {
|
||||
item: PuzzleWorkSummary;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit?: (() => void) | null;
|
||||
onStartGame: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 拼图广场详情页。
|
||||
* 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。
|
||||
*/
|
||||
export function PuzzleGalleryDetailView({
|
||||
item,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit = null,
|
||||
onStartGame,
|
||||
}: PuzzleGalleryDetailViewProps) {
|
||||
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const copyPublicWorkCode = () => {
|
||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
修改作品
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onStartGame}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
进入第 1 关
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{item.levelName}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-amber-50/82">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<UserRound className="h-4 w-4" />
|
||||
{item.authorDisplayName}
|
||||
</span>
|
||||
<span>{item.playCount} 次游玩</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPublicWorkCode}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/14 bg-white/10 px-3 py-1 text-sm text-amber-50/86"
|
||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
<span className="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{publicWorkCode}</span>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
{copyState !== 'idle' ? (
|
||||
<span className="shrink-0 text-xs">
|
||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1.05fr)_minmax(18rem,0.95fr)]">
|
||||
<section className="min-h-0 overflow-hidden rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="aspect-square overflow-hidden">
|
||||
{item.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={item.coverImageSrc}
|
||||
alt={item.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))] text-sm text-white/66">
|
||||
暂无封面
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-3 overflow-y-auto">
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
题材标签
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.themeTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
关卡摘要
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleGalleryDetailView;
|
||||
476
src/components/puzzle-result/PuzzleResultView.tsx
Normal file
476
src/components/puzzle-result/PuzzleResultView.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
author: AuthUser | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
};
|
||||
|
||||
type PuzzleImageStudioModalProps = {
|
||||
draft: PuzzleResultDraft;
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (promptText?: string | null) => void;
|
||||
onSelectCandidate: (candidateId: string) => void;
|
||||
};
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return value
|
||||
.split(/[\n,,、]/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
|
||||
if (!session.resultPreview) {
|
||||
return [];
|
||||
}
|
||||
return session.resultPreview.blockers.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function PuzzleImageStudioModal({
|
||||
draft,
|
||||
isBusy,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onSelectCandidate,
|
||||
}: PuzzleImageStudioModalProps) {
|
||||
const [promptText, setPromptText] = useState(draft.summary);
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-5xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
拼图图片工坊
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
用当前锚点生成候选图,再选择一张作为正式图。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
PROMPT
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
disabled={isBusy}
|
||||
rows={7}
|
||||
onChange={(event) => {
|
||||
setPromptText(event.target.value);
|
||||
}}
|
||||
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onGenerate(promptText.trim() || undefined);
|
||||
}}
|
||||
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-2.5 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
生成 2 张候选图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
当前正式图
|
||||
</div>
|
||||
<div className="mt-3 aspect-square overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
|
||||
{draft.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={draft.coverImageSrc}
|
||||
alt={draft.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-white/66">
|
||||
还没有正式图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0">
|
||||
{draft.candidates.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{draft.candidates.map((candidate) => (
|
||||
<button
|
||||
key={candidate.candidateId}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onSelectCandidate(candidate.candidateId);
|
||||
}}
|
||||
className={`overflow-hidden rounded-[1.35rem] border text-left transition ${
|
||||
candidate.selected
|
||||
? 'border-amber-500 bg-amber-50 shadow-[0_18px_45px_rgba(180,83,9,0.14)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/78'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={candidate.imageSrc}
|
||||
alt={draft.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
候选图 {candidate.candidateId.split('-').pop()}
|
||||
</div>
|
||||
{candidate.selected ? (
|
||||
<span className="rounded-full bg-amber-600 px-2.5 py-1 text-[0.68rem] font-semibold text-white">
|
||||
已应用
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="line-clamp-3 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{candidate.actualPrompt || candidate.prompt}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[22rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
先生成候选图,再从这里确认正式图片。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图结果页最小工作台。
|
||||
* 支持标题、摘要、标签编辑,候选图生成与发布,不额外扩成大表单系统。
|
||||
*/
|
||||
export function PuzzleResultView({
|
||||
session,
|
||||
author,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onExecuteAction,
|
||||
}: PuzzleResultViewProps) {
|
||||
const draft = session.draft;
|
||||
const preview = session.resultPreview;
|
||||
const [isStudioOpen, setIsStudioOpen] = useState(false);
|
||||
const [levelName, setLevelName] = useState(draft?.levelName ?? '');
|
||||
const [summary, setSummary] = useState(draft?.summary ?? '');
|
||||
const [themeTagsText, setThemeTagsText] = useState(
|
||||
draft?.themeTags.join(',') ?? '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setLevelName(draft.levelName);
|
||||
setSummary(draft.summary);
|
||||
setThemeTagsText(draft.themeTags.join(','));
|
||||
}, [draft]);
|
||||
|
||||
const tagList = useMemo(
|
||||
() => normalizeThemeTagInput(themeTagsText),
|
||||
[themeTagsText],
|
||||
);
|
||||
const blockers = useMemo(() => publishBlockedReason(session), [session]);
|
||||
const qualityFindings = preview?.qualityFindings ?? [];
|
||||
const publishReady = Boolean(preview?.publishReady);
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
还没有可编辑的拼图草稿
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="返回"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsStudioOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
图片工坊
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !levelName.trim()}
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'publish_puzzle_work',
|
||||
levelName: levelName.trim(),
|
||||
summary: summary.trim(),
|
||||
themeTags: tagList,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
发布到广场
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
拼图结果页
|
||||
</div>
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-amber-50/76">
|
||||
标题、标签和正式图都会进入广场与后续关卡链。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<section className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
正式封面
|
||||
</div>
|
||||
<div className="mt-3 aspect-square overflow-hidden rounded-[1.35rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
|
||||
{draft.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={draft.coverImageSrc}
|
||||
alt={levelName || draft.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-white/66">
|
||||
还没有正式图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsStudioOpen(true);
|
||||
}}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-2.5 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
生成或更换图片
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
关卡名
|
||||
</div>
|
||||
<input
|
||||
value={levelName}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
setLevelName(event.target.value);
|
||||
}}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
Agent 理解摘要
|
||||
</div>
|
||||
<textarea
|
||||
value={summary}
|
||||
disabled={isBusy}
|
||||
rows={5}
|
||||
onChange={(event) => {
|
||||
setSummary(event.target.value);
|
||||
}}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
题材标签
|
||||
</div>
|
||||
<input
|
||||
value={themeTagsText}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
setThemeTagsText(event.target.value);
|
||||
}}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tagList.length > 0 ? (
|
||||
tagList.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-[var(--platform-text-soft)]">
|
||||
输入 3 到 6 个中文短标签,使用逗号分隔。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="min-h-0 space-y-3 overflow-y-auto">
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
作者预览
|
||||
</div>
|
||||
<div className="mt-3 rounded-[1.1rem] bg-white/76 px-4 py-4">
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{author?.displayName || '玩家'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
关卡 HUD 将显示作者名与关卡名。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
发布校验
|
||||
</div>
|
||||
{publishReady ? (
|
||||
<div className="mt-3 rounded-[1.1rem] bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
已达到发布条件
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{blockers.length > 0 ? (
|
||||
blockers.map((message) => (
|
||||
<div
|
||||
key={message}
|
||||
className="rounded-[1rem] bg-amber-50 px-3 py-2 text-sm text-amber-700"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-[1rem] bg-white/76 px-3 py-2 text-sm text-[var(--platform-text-base)]">
|
||||
等待完善结果页
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{qualityFindings.length > 0 ? (
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
质量提醒
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{qualityFindings.map((finding) => (
|
||||
<div
|
||||
key={finding.id}
|
||||
className="rounded-[1rem] bg-white/76 px-3 py-2 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
{finding.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{isStudioOpen ? (
|
||||
<PuzzleImageStudioModal
|
||||
draft={draft}
|
||||
isBusy={isBusy}
|
||||
onClose={() => {
|
||||
setIsStudioOpen(false);
|
||||
}}
|
||||
onGenerate={(promptText) => {
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
promptText,
|
||||
candidateCount: 2,
|
||||
});
|
||||
}}
|
||||
onSelectCandidate={(candidateId) => {
|
||||
onExecuteAction({
|
||||
action: 'select_puzzle_image',
|
||||
candidateId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleResultView;
|
||||
397
src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
Normal file
397
src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
run: PuzzleRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
|
||||
onAdvanceNextLevel: () => void;
|
||||
};
|
||||
|
||||
type PuzzleBoardPieceViewModel = {
|
||||
pieceId: string;
|
||||
row: number;
|
||||
col: number;
|
||||
correctRow: number;
|
||||
correctCol: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function boardCellKey(position: PuzzleCellPosition) {
|
||||
return `${position.row}:${position.col}`;
|
||||
}
|
||||
|
||||
function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||||
return Array.from({ length: board.rows * board.cols }, (_, index) => ({
|
||||
row: Math.floor(index / board.cols),
|
||||
col: index % board.cols,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPieceLabel(pieceId: string) {
|
||||
const fallback = pieceId.slice(-2).toUpperCase();
|
||||
return fallback || '块';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
||||
*/
|
||||
export function PuzzleRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSwapPieces,
|
||||
onDragPiece,
|
||||
onAdvanceNextLevel,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [dragState, setDragState] = useState<{
|
||||
pieceId: string;
|
||||
pointerId: number;
|
||||
dragging: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
} | null>(null);
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const board = currentLevel?.board ?? null;
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
|
||||
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
|
||||
if (!board) {
|
||||
return [];
|
||||
}
|
||||
return board.pieces.map((piece) => ({
|
||||
pieceId: piece.pieceId,
|
||||
row: piece.currentRow,
|
||||
col: piece.currentCol,
|
||||
correctRow: piece.correctRow,
|
||||
correctCol: piece.correctCol,
|
||||
label: buildPieceLabel(piece.pieceId),
|
||||
}));
|
||||
}, [board]);
|
||||
|
||||
const mergedCellKeys = useMemo(() => {
|
||||
if (!board) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set(
|
||||
board.mergedGroups.flatMap((group) =>
|
||||
group.occupiedCells.map((cell) => boardCellKey(cell)),
|
||||
),
|
||||
);
|
||||
}, [board]);
|
||||
|
||||
const pieceByCell = useMemo(() => {
|
||||
const map = new Map<string, PuzzleBoardPieceViewModel>();
|
||||
for (const piece of pieces) {
|
||||
map.set(`${piece.row}:${piece.col}`, piece);
|
||||
}
|
||||
return map;
|
||||
}, [pieces]);
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在进入拼图关卡
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePieceClick = (pieceId: string) => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPieceId) {
|
||||
setSelectedPieceId(pieceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPieceId === pieceId) {
|
||||
setSelectedPieceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSwapPieces({
|
||||
firstPieceId: selectedPieceId,
|
||||
secondPieceId: pieceId,
|
||||
});
|
||||
setSelectedPieceId(null);
|
||||
};
|
||||
|
||||
const resolveBoardCellFromPointer = (clientX: number, clientY: number) => {
|
||||
const boardElement = boardRef.current;
|
||||
if (!boardElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = boardElement.getBoundingClientRect();
|
||||
if (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeX = clientX - rect.left;
|
||||
const relativeY = clientY - rect.top;
|
||||
const col = Math.min(
|
||||
board.cols - 1,
|
||||
Math.max(0, Math.floor((relativeX / rect.width) * board.cols)),
|
||||
);
|
||||
const row = Math.min(
|
||||
board.rows - 1,
|
||||
Math.max(0, Math.floor((relativeY / rect.height) * board.rows)),
|
||||
);
|
||||
|
||||
return { row, col };
|
||||
};
|
||||
|
||||
const handlePiecePointerUp = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const currentDragState = dragState;
|
||||
if (!currentDragState || currentDragState.pieceId !== pieceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (currentDragState.dragging) {
|
||||
const targetCell = resolveBoardCellFromPointer(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
if (targetCell) {
|
||||
onDragPiece({
|
||||
pieceId,
|
||||
targetRow: targetCell.row,
|
||||
targetCol: targetCell.col,
|
||||
});
|
||||
}
|
||||
setSelectedPieceId(null);
|
||||
setDragState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDragState(null);
|
||||
handlePieceClick(pieceId);
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div className="relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(251,191,36,0.18),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(249,115,22,0.16),transparent_26%),linear-gradient(180deg,#2d160e,#020617)]">
|
||||
{currentLevel.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={currentLevel.coverImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
|
||||
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
|
||||
PUZZLE
|
||||
</div>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
<div className="text-xs text-white/74">
|
||||
{currentLevel.authorDisplayName} · 第 {currentLevel.levelIndex} 关 ·{' '}
|
||||
{statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="grid aspect-square w-full max-w-[min(92vw,92vh)] rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{buildBoardCells(board).map((cell) => {
|
||||
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
|
||||
const occupied = Boolean(piece);
|
||||
const isMerged = mergedCellKeys.has(boardCellKey(cell));
|
||||
const isSelected = piece?.pieceId === selectedPieceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${cell.row}:${cell.col}`}
|
||||
className="relative p-1"
|
||||
>
|
||||
<div
|
||||
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
|
||||
occupied
|
||||
? isSelected
|
||||
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
|
||||
: isMerged
|
||||
? 'border-emerald-200/55 bg-emerald-300/26 text-white'
|
||||
: 'border-white/18 bg-white/12 text-white'
|
||||
: 'border-white/8 bg-black/18 text-white/20'
|
||||
}`}
|
||||
onPointerDown={(event) => {
|
||||
if (!piece || isBusy) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragState({
|
||||
pieceId: piece.pieceId,
|
||||
pointerId: event.pointerId,
|
||||
dragging: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
});
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
!piece ||
|
||||
!dragState ||
|
||||
dragState.pieceId !== piece.pieceId ||
|
||||
dragState.pointerId !== event.pointerId ||
|
||||
dragState.dragging
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - dragState.startX;
|
||||
const deltaY = event.clientY - dragState.startY;
|
||||
if (Math.hypot(deltaX, deltaY) >= 8) {
|
||||
setDragState((current) =>
|
||||
current && current.pieceId === piece.pieceId
|
||||
? {
|
||||
...current,
|
||||
dragging: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (piece) {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDragState(null);
|
||||
}}
|
||||
>
|
||||
{piece ? (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
|
||||
{resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
|
||||
backgroundPosition: `${
|
||||
board.cols > 1
|
||||
? (piece.correctCol / (board.cols - 1)) * 100
|
||||
: 0
|
||||
}% ${
|
||||
board.rows > 1
|
||||
? (piece.correctRow / (board.rows - 1)) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
|
||||
{piece.label}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-4 py-4">
|
||||
<div className="max-w-[18rem] rounded-[1.1rem] bg-black/28 px-4 py-3 text-xs leading-6 text-white/74 backdrop-blur">
|
||||
{selectedPieceId
|
||||
? '已选择一块,再点另一块可交换;也可以直接拖到目标位置。'
|
||||
: '点击两块可交换,拖动单块或合并块到目标格继续推进。'}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{error ? (
|
||||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{nextAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onAdvanceNextLevel}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
下一关
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
|
||||
{isBusy
|
||||
? '同步中...'
|
||||
: currentLevel.status === 'cleared'
|
||||
? '等待下一关候选'
|
||||
: '完成整张图即可通关'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleRuntimeShell;
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
|
||||
import {
|
||||
type AnimationState,
|
||||
type Character,
|
||||
} from '../../types';
|
||||
import { CORE_ACTIONS } from './roleAssetStudioModel';
|
||||
|
||||
type ActionButtonProps = {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky' | 'green';
|
||||
};
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type StatusBadgeProps = {
|
||||
tone: 'green' | 'amber' | 'zinc';
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type TextAreaProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type CharacterAnimatorProps = {
|
||||
state: AnimationState;
|
||||
character: Character;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
imageClassName?: string;
|
||||
playbackRate?: number;
|
||||
};
|
||||
|
||||
export function RpgCreationRoleAnimationSection(props: {
|
||||
ActionButton: (props: ActionButtonProps) => ReactNode;
|
||||
CharacterAnimator: ComponentType<CharacterAnimatorProps>;
|
||||
Field: (props: FieldProps) => ReactNode;
|
||||
Section: (props: SectionProps) => ReactNode;
|
||||
StatusBadge: (props: StatusBadgeProps) => ReactNode;
|
||||
TextArea: (props: TextAreaProps) => ReactNode;
|
||||
animationPreviewFrameStyle: CSSProperties;
|
||||
animationPreviewPlaybackRate: number;
|
||||
animationPreviewViewportStyle: CSSProperties;
|
||||
animationPromptText: string;
|
||||
generatingAnimationMap: Partial<Record<AnimationState, boolean>>;
|
||||
hasGeneratedAnimation: (animation: AnimationState) => boolean;
|
||||
isSelectedAnimationGenerating: boolean;
|
||||
previewCharacter: Character | null;
|
||||
previewImageSrc: string;
|
||||
selectedAnimation: AnimationState;
|
||||
selectedAnimationStatus: string | null;
|
||||
shouldUseSelectedAnimationPreview: boolean;
|
||||
syncBusy: boolean;
|
||||
animationPointCost: number;
|
||||
workingRoleImageSrc?: string;
|
||||
workingRoleGeneratedVisualAssetId?: string;
|
||||
workingRoleName: string;
|
||||
onAnimationPromptChange: (value: string) => void;
|
||||
onGenerateAnimation: () => void;
|
||||
onPlaybackRateChange: (value: number) => void;
|
||||
onSelectAnimation: (animation: AnimationState) => void;
|
||||
}) {
|
||||
const {
|
||||
ActionButton,
|
||||
CharacterAnimator,
|
||||
Field,
|
||||
Section,
|
||||
StatusBadge,
|
||||
TextArea,
|
||||
animationPreviewFrameStyle,
|
||||
animationPreviewPlaybackRate,
|
||||
animationPreviewViewportStyle,
|
||||
animationPromptText,
|
||||
generatingAnimationMap,
|
||||
hasGeneratedAnimation,
|
||||
isSelectedAnimationGenerating,
|
||||
previewCharacter,
|
||||
previewImageSrc,
|
||||
selectedAnimation,
|
||||
selectedAnimationStatus,
|
||||
shouldUseSelectedAnimationPreview,
|
||||
syncBusy,
|
||||
animationPointCost,
|
||||
workingRoleGeneratedVisualAssetId,
|
||||
workingRoleImageSrc,
|
||||
workingRoleName,
|
||||
onAnimationPromptChange,
|
||||
onGenerateAnimation,
|
||||
onPlaybackRateChange,
|
||||
onSelectAnimation,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Section title="动作">
|
||||
<div className="space-y-4">
|
||||
<div className="platform-role-studio__preview rounded-3xl p-4">
|
||||
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
|
||||
{shouldUseSelectedAnimationPreview && previewCharacter ? (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={animationPreviewViewportStyle}
|
||||
>
|
||||
<div style={animationPreviewFrameStyle}>
|
||||
<CharacterAnimator
|
||||
state={selectedAnimation}
|
||||
character={previewCharacter}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={workingRoleName}
|
||||
className="max-h-[28rem] w-full object-contain pixelated"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 text-sm text-zinc-500">暂无动作预览</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="预览速度">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0.25"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
value={animationPreviewPlaybackRate}
|
||||
onChange={(event) =>
|
||||
onPlaybackRateChange(Number.parseFloat(event.target.value) || 0.75)
|
||||
}
|
||||
className="w-full accent-sky-400"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-zinc-400">
|
||||
<span>0.25x</span>
|
||||
<span>{animationPreviewPlaybackRate.toFixed(2)}x</span>
|
||||
<span>1.50x</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{CORE_ACTIONS.map((item) => {
|
||||
const isSelected = item.animation === selectedAnimation;
|
||||
const isReady = hasGeneratedAnimation(item.animation);
|
||||
const isGenerating = generatingAnimationMap[item.animation] === true;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.animation}
|
||||
type="button"
|
||||
onClick={() => onSelectAnimation(item.animation)}
|
||||
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
|
||||
{isGenerating
|
||||
? '后台生成中'
|
||||
: isSelected
|
||||
? '当前预览'
|
||||
: '点击切换'}
|
||||
<span>{item.required ? '必需动作' : '可选动作'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
|
||||
>
|
||||
{isGenerating
|
||||
? '生成中'
|
||||
: isReady
|
||||
? '已生成'
|
||||
: item.required
|
||||
? '待生成'
|
||||
: (item.fallbackStatusLabel ?? '可选')}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Field label="动作描述">
|
||||
<TextArea
|
||||
value={animationPromptText}
|
||||
onChange={onAnimationPromptChange}
|
||||
rows={5}
|
||||
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={<RefreshCcw className="h-4 w-4" />}
|
||||
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
|
||||
subLabel={`消耗${animationPointCost}叙世币`}
|
||||
onClick={onGenerateAnimation}
|
||||
disabled={
|
||||
isSelectedAnimationGenerating ||
|
||||
!workingRoleImageSrc ||
|
||||
!workingRoleGeneratedVisualAssetId ||
|
||||
syncBusy
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedAnimationStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{selectedAnimationStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationRoleAnimationSection;
|
||||
@@ -0,0 +1,53 @@
|
||||
export function RpgCreationRoleAssetStudioFooter(props: {
|
||||
isSavingToRole: boolean;
|
||||
saveStatus: string | null;
|
||||
syncBusy: boolean;
|
||||
workingRoleGeneratedVisualAssetId?: string;
|
||||
workingRoleImageSrc?: string;
|
||||
onSaveToRole: () => void;
|
||||
}) {
|
||||
const {
|
||||
isSavingToRole,
|
||||
saveStatus,
|
||||
syncBusy,
|
||||
workingRoleGeneratedVisualAssetId,
|
||||
workingRoleImageSrc,
|
||||
onSaveToRole,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
|
||||
<div className="space-y-3">
|
||||
{saveStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{saveStatus}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveToRole}
|
||||
disabled={
|
||||
isSavingToRole ||
|
||||
syncBusy ||
|
||||
!workingRoleImageSrc ||
|
||||
!workingRoleGeneratedVisualAssetId
|
||||
}
|
||||
className={`rounded-full border border-emerald-400/30 bg-emerald-500/10 px-5 py-2 text-sm font-semibold text-emerald-100 transition-colors hover:bg-emerald-500/20 ${
|
||||
isSavingToRole ||
|
||||
syncBusy ||
|
||||
!workingRoleImageSrc ||
|
||||
!workingRoleGeneratedVisualAssetId
|
||||
? 'cursor-not-allowed opacity-45'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSavingToRole || syncBusy ? '保存中...' : '保存到当前角色'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationRoleAssetStudioFooter;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import {
|
||||
RpgCreationRoleAssetStudioModal as RpgCreationRoleAssetStudioModalImpl,
|
||||
} from './RpgCreationRoleAssetStudioModalImpl';
|
||||
|
||||
/**
|
||||
* 工作包 C 完成后,角色资产工坊 façade 已直接桥接 RPG 创作目录下的真实实现。
|
||||
* 旧入口仍保留兼容导出,后续视觉/动作工作流继续在该目录内部演进。
|
||||
*/
|
||||
export type RpgCreationRoleAssetStudioModalProps = ComponentProps<
|
||||
typeof RpgCreationRoleAssetStudioModalImpl
|
||||
>;
|
||||
|
||||
export function RpgCreationRoleAssetStudioModal(
|
||||
props: RpgCreationRoleAssetStudioModalProps,
|
||||
) {
|
||||
return <RpgCreationRoleAssetStudioModalImpl {...props} />;
|
||||
}
|
||||
|
||||
export default RpgCreationRoleAssetStudioModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { ImagePlus, RefreshCcw } from 'lucide-react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type ActionButtonProps = {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky' | 'green';
|
||||
};
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type TextAreaProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function RpgCreationRoleVisualSection(props: {
|
||||
ActionButton: (props: ActionButtonProps) => ReactNode;
|
||||
Field: (props: FieldProps) => ReactNode;
|
||||
Section: (props: SectionProps) => ReactNode;
|
||||
TextArea: (props: TextAreaProps) => ReactNode;
|
||||
handleReferenceImageUpload: (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => Promise<void>;
|
||||
hasGeneratedVisualPreview: boolean;
|
||||
isApplyingVisual: boolean;
|
||||
isGeneratingVisuals: boolean;
|
||||
previewImageSrc: string;
|
||||
referenceImageDataUrls: string[];
|
||||
selectedTemplatePortrait?: string | null;
|
||||
selectedTemplateName?: string | null;
|
||||
syncBusy: boolean;
|
||||
visualPointCost: number;
|
||||
visualPromptText: string;
|
||||
visualStatus: string | null;
|
||||
workingRoleName: string;
|
||||
onClearReferenceImages: () => void;
|
||||
onGenerateVisuals: () => void;
|
||||
onVisualPromptChange: (value: string) => void;
|
||||
}) {
|
||||
const {
|
||||
ActionButton,
|
||||
Field,
|
||||
Section,
|
||||
TextArea,
|
||||
handleReferenceImageUpload,
|
||||
hasGeneratedVisualPreview,
|
||||
isApplyingVisual,
|
||||
isGeneratingVisuals,
|
||||
previewImageSrc,
|
||||
referenceImageDataUrls,
|
||||
selectedTemplateName,
|
||||
selectedTemplatePortrait,
|
||||
syncBusy,
|
||||
visualPointCost,
|
||||
visualPromptText,
|
||||
visualStatus,
|
||||
workingRoleName,
|
||||
onClearReferenceImages,
|
||||
onGenerateVisuals,
|
||||
onVisualPromptChange,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Section title="角色形象">
|
||||
<div className="space-y-4">
|
||||
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
|
||||
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
|
||||
{previewImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={workingRoleName}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplatePortrait ? (
|
||||
<ResolvedAssetImage
|
||||
src={selectedTemplatePortrait}
|
||||
alt={selectedTemplateName ?? workingRoleName}
|
||||
className="max-h-[20rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-6 text-center text-sm text-zinc-500">
|
||||
暂无角色形象预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="形象描述">
|
||||
<TextArea
|
||||
value={visualPromptText}
|
||||
onChange={onVisualPromptChange}
|
||||
rows={6}
|
||||
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="参考图">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageUpload(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
{referenceImageDataUrls.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{referenceImageDataUrls.map((imageSrc, index) => (
|
||||
<div
|
||||
key={`${imageSrc}-${index}`}
|
||||
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`reference-${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<ActionButton
|
||||
label="清空参考图"
|
||||
onClick={onClearReferenceImages}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={
|
||||
hasGeneratedVisualPreview ? (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
) : (
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
label={
|
||||
isGeneratingVisuals
|
||||
? '生成中...'
|
||||
: hasGeneratedVisualPreview
|
||||
? '重新生成角色形象'
|
||||
: '生成角色形象'
|
||||
}
|
||||
subLabel={`消耗${visualPointCost}叙世币`}
|
||||
onClick={onGenerateVisuals}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visualStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{visualStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationRoleVisualSection;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AnimationState } from '../../types';
|
||||
|
||||
export type EditableCustomWorldRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CustomWorldAiActionConfig = {
|
||||
animation: AnimationState;
|
||||
label: string;
|
||||
templateId: string;
|
||||
fps: number;
|
||||
frameCount: number;
|
||||
durationSeconds: number;
|
||||
loop: boolean;
|
||||
required: boolean;
|
||||
fallbackStatusLabel?: string;
|
||||
};
|
||||
|
||||
export const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
{
|
||||
animation: AnimationState.RUN,
|
||||
label: '奔跑',
|
||||
templateId: 'run',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.ATTACK,
|
||||
label: '攻击',
|
||||
templateId: 'attack_slash',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.IDLE,
|
||||
label: '待机',
|
||||
templateId: 'idle',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
required: false,
|
||||
fallbackStatusLabel: '默认静止',
|
||||
},
|
||||
{
|
||||
animation: AnimationState.DIE,
|
||||
label: '死亡',
|
||||
templateId: 'die',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
required: false,
|
||||
fallbackStatusLabel: '默认倒地动画',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
} from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
|
||||
/**
|
||||
* 工作包 C 第一轮先把发布相关 API 出口收口到独立 client。
|
||||
* 后续场景资产工坊复用时,可以直接沿用这里的发布边界。
|
||||
*/
|
||||
export const roleAssetStudioPublishClient = {
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user