Files
Genarrative/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx
2026-05-01 20:29:09 +08:00

245 lines
7.8 KiB
TypeScript

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;