214
src/components/MedievalNpcAnimator.tsx
Normal file
214
src/components/MedievalNpcAnimator.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AtlasTileSpec, buildMedievalNpcVisual, MedievalNpcVisualSpec } from '../data/medievalNpcVisuals';
|
||||
import { Encounter } from '../types';
|
||||
import {
|
||||
DEFAULT_NPC_LAYOUT_CONFIG,
|
||||
type NpcLayoutConfig,
|
||||
type NpcLayoutPart,
|
||||
} from './npcVisualShared';
|
||||
|
||||
const TILE_SIZE = 32;
|
||||
const HAND_TILE_SIZE = 16;
|
||||
const IDLE_FRAME_MS = 140;
|
||||
|
||||
function mergeLayoutConfig(layoutConfig?: Partial<NpcLayoutConfig>): NpcLayoutConfig {
|
||||
if (!layoutConfig) return DEFAULT_NPC_LAYOUT_CONFIG;
|
||||
|
||||
return {
|
||||
body: { ...DEFAULT_NPC_LAYOUT_CONFIG.body, ...layoutConfig.body },
|
||||
head: { ...DEFAULT_NPC_LAYOUT_CONFIG.head, ...layoutConfig.head },
|
||||
facialHair: { ...DEFAULT_NPC_LAYOUT_CONFIG.facialHair, ...layoutConfig.facialHair },
|
||||
hair: { ...DEFAULT_NPC_LAYOUT_CONFIG.hair, ...layoutConfig.hair },
|
||||
headgear: { ...DEFAULT_NPC_LAYOUT_CONFIG.headgear, ...layoutConfig.headgear },
|
||||
hand: { ...DEFAULT_NPC_LAYOUT_CONFIG.hand, ...layoutConfig.hand },
|
||||
mainHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.mainHand, ...layoutConfig.mainHand },
|
||||
offHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.offHand, ...layoutConfig.offHand },
|
||||
};
|
||||
}
|
||||
|
||||
function LayerSprite({
|
||||
src,
|
||||
frameIndex,
|
||||
tileSize = TILE_SIZE,
|
||||
x = 0,
|
||||
y = 0,
|
||||
zIndex = 0,
|
||||
}: {
|
||||
src: string;
|
||||
frameIndex: number;
|
||||
tileSize?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
backgroundImage: `url("${encodeURI(src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${frameIndex * tileSize}px 0px`,
|
||||
imageRendering: 'pixelated',
|
||||
zIndex,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasSprite({
|
||||
spec,
|
||||
x = 0,
|
||||
y = 0,
|
||||
zIndex = 0,
|
||||
}: {
|
||||
spec: AtlasTileSpec;
|
||||
x?: number;
|
||||
y?: number;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
const tileWidth = spec.tileWidth ?? TILE_SIZE;
|
||||
const tileHeight = spec.tileHeight ?? TILE_SIZE;
|
||||
const col = spec.frameIndex % spec.columns;
|
||||
const row = Math.floor(spec.frameIndex / spec.columns);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x - (tileWidth - TILE_SIZE) / 2 + (spec.renderOffsetX ?? 0)}px`,
|
||||
top: `${y - (tileHeight - TILE_SIZE) + (spec.renderOffsetY ?? 0)}px`,
|
||||
width: `${tileWidth}px`,
|
||||
height: `${tileHeight}px`,
|
||||
backgroundImage: `url("${encodeURI(spec.src)}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: `-${col * tileWidth}px -${row * tileHeight}px`,
|
||||
imageRendering: 'pixelated',
|
||||
zIndex,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MedievalNpcAnimator({
|
||||
encounter,
|
||||
visualSpec,
|
||||
layoutConfig,
|
||||
onPartPointerDown,
|
||||
selectedPart,
|
||||
className,
|
||||
scale = 2.4,
|
||||
facing = 'right',
|
||||
}: {
|
||||
encounter?: Encounter;
|
||||
visualSpec?: MedievalNpcVisualSpec;
|
||||
layoutConfig?: Partial<NpcLayoutConfig>;
|
||||
onPartPointerDown?: (part: NpcLayoutPart, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
selectedPart?: NpcLayoutPart | null;
|
||||
className?: string;
|
||||
scale?: number;
|
||||
facing?: 'left' | 'right';
|
||||
}) {
|
||||
const [frameCursor, setFrameCursor] = useState(0);
|
||||
const visual = visualSpec ?? buildMedievalNpcVisual(encounter ?? {
|
||||
npcName: '预览角色',
|
||||
npcDescription: '用于预览的角色外形。',
|
||||
npcAvatar: '预',
|
||||
context: '预览',
|
||||
});
|
||||
const bodyFrame = visual.bodyFrames[frameCursor % visual.bodyFrames.length] ?? 0;
|
||||
const headFrame = visual.headFrame;
|
||||
const hairFrame = visual.hairFrame;
|
||||
const handFrame = visual.handFrame;
|
||||
const facialFrame = visual.facialHairFrame ?? 0;
|
||||
const bobOffsets = [0, 1, 1, -1];
|
||||
const bobY = bobOffsets[frameCursor % bobOffsets.length] ?? 0;
|
||||
const layout = mergeLayoutConfig(layoutConfig);
|
||||
|
||||
const getPartClassName = (part: NpcLayoutPart) =>
|
||||
onPartPointerDown
|
||||
? `cursor-grab ${selectedPart === part ? 'drop-shadow-[0_0_10px_rgba(16,185,129,0.7)]' : ''}`
|
||||
: '';
|
||||
|
||||
const getPartHandlers = (part: NpcLayoutPart) =>
|
||||
onPartPointerDown
|
||||
? {
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => onPartPointerDown(part, event),
|
||||
}
|
||||
: {};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameCursor(prev => (prev + 1) % 4);
|
||||
}, IDLE_FRAME_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${TILE_SIZE * 2.6}px`,
|
||||
height: `${TILE_SIZE * 3.1}px`,
|
||||
transform: `translateY(${bobY}px) scale(${scale})`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: '50%', bottom: 0, width: `${TILE_SIZE}px`, height: `${TILE_SIZE}px`, transform: 'translateX(-50%)' }}>
|
||||
<div className={getPartClassName('body')} style={{ position: 'absolute', left: `${layout.body.x}px`, top: `${layout.body.y}px` }} {...getPartHandlers('body')}>
|
||||
<LayerSprite src={visual.bodySrc} frameIndex={bodyFrame} zIndex={1} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={getPartClassName('hand')}
|
||||
style={{ position: 'absolute', left: `${layout.hand.x}px`, top: `${layout.hand.y}px`, width: `${HAND_TILE_SIZE}px`, height: `${HAND_TILE_SIZE}px`, zIndex: 5 }}
|
||||
{...getPartHandlers('hand')}
|
||||
>
|
||||
{visual.mainHand && (
|
||||
<div className={getPartClassName('mainHand')} style={{ position: 'absolute', left: `${layout.mainHand.x}px`, top: `${layout.mainHand.y}px` }} {...getPartHandlers('mainHand')}>
|
||||
<AtlasSprite spec={visual.mainHand} zIndex={11} />
|
||||
</div>
|
||||
)}
|
||||
<LayerSprite src={visual.handSrc} frameIndex={handFrame} tileSize={HAND_TILE_SIZE} zIndex={12} />
|
||||
</div>
|
||||
|
||||
<div className={getPartClassName('head')} style={{ position: 'absolute', left: `${layout.head.x}px`, top: `${layout.head.y}px` }} {...getPartHandlers('head')}>
|
||||
<LayerSprite src={visual.headSrc} frameIndex={headFrame} zIndex={6} />
|
||||
</div>
|
||||
{visual.facialHairSrc && (
|
||||
<div className={getPartClassName('facialHair')} style={{ position: 'absolute', left: `${layout.facialHair.x}px`, top: `${layout.facialHair.y}px` }} {...getPartHandlers('facialHair')}>
|
||||
<LayerSprite src={visual.facialHairSrc} frameIndex={facialFrame} zIndex={7} />
|
||||
</div>
|
||||
)}
|
||||
<div className={getPartClassName('hair')} style={{ position: 'absolute', left: `${layout.hair.x}px`, top: `${layout.hair.y}px` }} {...getPartHandlers('hair')}>
|
||||
<LayerSprite src={visual.hairSrc} frameIndex={hairFrame} zIndex={8} />
|
||||
</div>
|
||||
{visual.headgear && (
|
||||
<div className={getPartClassName('headgear')} style={{ position: 'absolute', left: `${layout.headgear.x}px`, top: `${layout.headgear.y}px` }} {...getPartHandlers('headgear')}>
|
||||
<AtlasSprite spec={visual.headgear} zIndex={9} />
|
||||
</div>
|
||||
)}
|
||||
{visual.offHand && (
|
||||
<div className={getPartClassName('offHand')} style={{ position: 'absolute', left: `${layout.offHand.x}px`, top: `${layout.offHand.y}px` }} {...getPartHandlers('offHand')}>
|
||||
<AtlasSprite spec={visual.offHand} zIndex={10} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user