import React from "react";
import {vqIdleAvatarAnimations, vqSpeakingAvatarAnimations} from "../avatarAnimations";
import type {
VQAvatarImageLayout,
VQCharacterDefinition,
VQMouthResolver,
VQMouthShape,
} from "../types";
import {VQLipSyncedStandeeImage} from "./VQLipSyncedStandeeImage";
export type VQCharacterAvatarProps<CharacterId extends string = string> =
Readonly<{
characterId: CharacterId;
character: VQCharacterDefinition;
focused: boolean;
hasMultipleCharacters: boolean;
frame: number;
fps: number;
isSpeaking: boolean;
speakingSpeechId?: string;
speakingLocalFrame: number;
resolveMouth?: VQMouthResolver<CharacterId>;
fontFamily?: string;
imageFilter?: string;
imageFlipX?: boolean;
imageTranslateX?: number;
imageTranslateY?: number;
}>;
const mouthForAvatar = <CharacterId extends string,>({
characterId,
character,
isSpeaking,
speakingSpeechId,
speakingLocalFrame,
fps,
resolveMouth,
}: Readonly<{
characterId: CharacterId;
character: VQCharacterDefinition;
isSpeaking: boolean;
speakingSpeechId?: string;
speakingLocalFrame: number;
fps: number;
resolveMouth?: VQMouthResolver<CharacterId>;
}>): VQMouthShape => {
const speakingAnimationType =
character.avatar.speakingAnimationType ?? "gentleBob";
if (!isSpeaking || speakingAnimationType !== "rhubarbLipSync") {
return "rest";
}
return (
resolveMouth?.({
characterId,
character,
speechId: speakingSpeechId,
speakingLocalFrame,
fps,
speakingAnimationType,
}) ?? "rest"
);
};
// 用途: 立ち絵の頭幅メタデータから追加スケールを算出する。
// 使用方法: VQCharacterAvatar 内で imageLayout と実表示幅を渡し、画像transformのscaleへ反映する。
// オプションや引数詳細: sourceWidth/sourceHeadWidth/targetHeadWidth が揃わない場合は scale のみを使う。
const imageScaleForLayout = (
imageLayout: VQAvatarImageLayout | undefined,
imageWidth: number | string
) => {
const renderedHeadWidth =
typeof imageWidth === "number" &&
imageLayout?.sourceWidth &&
imageLayout?.sourceHeadWidth
? (imageLayout.sourceHeadWidth * imageWidth) / imageLayout.sourceWidth
: undefined;
const headWidthScale =
renderedHeadWidth && imageLayout?.targetHeadWidth
? imageLayout.targetHeadWidth / renderedHeadWidth
: 1;
return (imageLayout?.scale ?? 1) * headWidthScale;
};
export const VQCharacterAvatar = <CharacterId extends string,>({
characterId,
character,
focused,
hasMultipleCharacters,
frame,
fps,
isSpeaking,
speakingSpeechId,
speakingLocalFrame,
resolveMouth,
fontFamily = "system-ui, sans-serif",
imageFilter = "drop-shadow(0 18px 40px rgba(31, 42, 68, 0.22))",
imageFlipX,
imageTranslateX = 0,
imageTranslateY,
}: VQCharacterAvatarProps<CharacterId>) => {
const {avatar} = character;
const scale = hasMultipleCharacters ? 0.82 : focused ? 0.94 : 0.9;
const opacity = focused || !hasMultipleCharacters ? 1 : 0.72;
const idleAnimationType = avatar.idleAnimationType ?? "gentleBob";
const speakingAnimationType = avatar.speakingAnimationType ?? "gentleBob";
const translateY = isSpeaking
? vqSpeakingAvatarAnimations[speakingAnimationType]({
frame,
fps,
focused,
hasMultipleCharacters,
})
: vqIdleAvatarAnimations[idleAnimationType]({
frame,
fps,
focused,
hasMultipleCharacters,
});
const resolvedImageTranslateY =
imageTranslateY ?? avatar.imageLayout?.translateY ?? 0;
const imageScaleX = (imageFlipX ?? avatar.imageLayout?.flipX) ? -1 : 1;
const imageWidth = avatar.imageLayout?.width ?? 320;
const imageScale = imageScaleForLayout(avatar.imageLayout, imageWidth);
const imageTransform = `translate(${imageTranslateX}px, ${resolvedImageTranslateY}px) scale(${imageScale}) scaleX(${imageScaleX})`;
const nameplatePosition = avatar.nameplatePosition ?? "bottom";
const showNameplate = nameplatePosition !== "none";
const mouth = mouthForAvatar({
characterId,
character,
isSpeaking,
speakingSpeechId,
speakingLocalFrame,
fps,
resolveMouth,
});
const nameplate = (
<div
style={{
position: "relative",
zIndex: 2,
fontFamily,
fontSize: 24,
fontWeight: 700,
color: "#ffffff",
backgroundColor: avatar.accentColor,
padding: "6px 18px",
borderRadius: 999,
boxShadow: "0 8px 20px rgba(31, 42, 68, 0.14)",
}}
>
{character.displayName}
</div>
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
opacity,
position: "relative",
transform: `translateY(${translateY}px) scale(${scale})`,
}}
>
{showNameplate && nameplatePosition === "top" ? nameplate : null}
<VQLipSyncedStandeeImage
imagePath={avatar.imagePath}
mouthImageDir={avatar.mouthImageDir}
mouth={mouth}
width={imageWidth}
height={avatar.imageLayout?.height}
maxHeight={avatar.imageLayout?.maxHeight ?? 360}
filter={imageFilter}
transform={imageTransform}
/>
{showNameplate && nameplatePosition === "bottom" ? nameplate : null}
</div>
);
};