Files
Genarrative/src/components/MedievalNpcAnimator.tsx
高物 c49c64896a
Some checks failed
CI / verify (push) Has been cancelled
初始仓库迁移
2026-04-04 23:57:06 +08:00

215 lines
7.5 KiB
TypeScript

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>
);
}