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.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 = ({ 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
; } return (
{`${character.name} { if (!hasRenderError) { setHasRenderError(true); } }} />
); };