init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View 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];

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

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

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

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

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

View 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} />;
}

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

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

File diff suppressed because it is too large Load Diff

View 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, '发布角色基础动作失败');
}

View File

@@ -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('提示词');
});
});

View File

@@ -0,0 +1 @@
export * from '../../prompts/customWorldRolePromptDefaults';

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View File

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

View 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;

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

View 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;

View 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;

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

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

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

View 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;

View File

@@ -0,0 +1 @@
export * from './CreationAgentWorkspace';

View File

@@ -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: [],
}),
);
});

View File

@@ -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('快捷动作');
});

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

View File

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

View File

@@ -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('我的拼图作品');
});

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 },
};
}

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

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
/**
* 平台首页视图的通用出口。
* 当前先复用成熟的首页实现,但避免让 `platform-entry` 继续直接依赖 RPG 命名组件。
*/
export {
RpgEntryHomeView as PlatformEntryHomeView,
type RpgEntryHomeViewProps as PlatformEntryHomeViewProps,
type PlatformHomeTab,
} from '../rpg-entry/RpgEntryHomeView';

View File

@@ -0,0 +1,8 @@
/**
* 平台作品详情视图的通用出口。
* 这里保留平台语义封装,减少 Big Fish 继续挂在 RPG 命名路径上的误导。
*/
export {
RpgEntryWorldDetailView as PlatformEntryWorldDetailView,
type RpgEntryWorldDetailViewProps as PlatformEntryWorldDetailViewProps,
} from '../rpg-entry/RpgEntryWorldDetailView';

View File

@@ -0,0 +1,9 @@
export {
PlatformEntryFlowShell,
type PlatformEntryFlowShellProps,
type SelectionStage,
} from './PlatformEntryFlowShell';
export {
PlatformEntryCreationTypeModal,
type PlatformEntryCreationTypeModalProps,
} from './PlatformEntryCreationTypeModal';

View 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,
},
];

View File

@@ -0,0 +1,9 @@
/**
* 平台入口共享 helper 的通用封装层。
* 先复用既有实现,同时把多玩法入口依赖从 RPG 命名中隔离出来。
*/
export {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from '../rpg-entry/rpgEntryShared';

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

View File

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

View File

@@ -0,0 +1,5 @@
/**
* 平台入口 bootstrap 通用封装。
* 现阶段逻辑仍复用既有实现,但对外暴露平台语义命名。
*/
export { useRpgEntryBootstrap as usePlatformEntryBootstrap } from '../rpg-entry/useRpgEntryBootstrap';

View File

@@ -0,0 +1,5 @@
/**
* 平台入口详情态编排通用封装。
* 通过平台语义出口避免 Big Fish 直接依赖 RPG 命名 hook。
*/
export { useRpgEntryLibraryDetail as usePlatformEntryLibraryDetail } from '../rpg-entry/useRpgEntryLibraryDetail';

View File

@@ -0,0 +1,5 @@
/**
* 平台入口导航通用封装。
* 多玩法统一从 `platform-entry` 暴露RPG 目录只保留兼容与 RPG 专属能力。
*/
export { useRpgEntryNavigation as usePlatformEntryNavigation } from '../rpg-entry/useRpgEntryNavigation';

View File

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

View 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;

View File

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

View 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;

View 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;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '默认倒地动画',
},
];

View File

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