215 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|