This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user