This commit is contained in:
248
src/components/CharacterAnimator.tsx
Normal file
248
src/components/CharacterAnimator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user