245 lines
7.8 KiB
TypeScript
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;
|