현재 작업 상태 저장
This commit is contained in:
parent
07cc67761e
commit
6bd91b0d5e
674 changed files with 8726 additions and 298 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, type CSSProperties } from "react";
|
||||
import type { AvatarExpression, AvatarState } from "./persona";
|
||||
|
||||
export type RasterVariant = "neutral" | "sad" | "tired" | "anxious" | "warm" | "startled";
|
||||
|
|
@ -34,6 +34,9 @@ const VARIANT_FOR_EXPRESSION: Record<AvatarExpression, RasterVariant> = {
|
|||
tired: "tired",
|
||||
};
|
||||
|
||||
const LIVE2D_PARTS_ART_SETS = new Set(["seoyeon-live2d-v3"]);
|
||||
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb"]);
|
||||
|
||||
export function rasterVariantFor(expression: AvatarExpression): RasterVariant {
|
||||
return VARIANT_FOR_EXPRESSION[expression];
|
||||
}
|
||||
|
|
@ -51,37 +54,181 @@ export interface RasterBustProps {
|
|||
reduced: boolean;
|
||||
}
|
||||
|
||||
const TX = "opacity 220ms cubic-bezier(0.22,0.61,0.36,1), transform 220ms cubic-bezier(0.22,0.61,0.36,1)";
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function layerTransform(extra = ""): string {
|
||||
return `translateX(-50%)${extra ? ` ${extra}` : ""}`;
|
||||
}
|
||||
|
||||
function mouthPartFor(variant: RasterVariant): string {
|
||||
return variant === "startled" ? "mouth-startled" : `mouth-${variant}`;
|
||||
function eyeClipStyle(maskUrl: string): CSSProperties {
|
||||
return {
|
||||
WebkitMaskImage: `url("${maskUrl}")`,
|
||||
maskImage: `url("${maskUrl}")`,
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskSize: "100% 100%",
|
||||
maskSize: "100% 100%",
|
||||
};
|
||||
}
|
||||
|
||||
function browPoseFor(variant: RasterVariant): { y: number; rotate: number } {
|
||||
function eyeApertureClipStyle(
|
||||
side: "left" | "right",
|
||||
upperLidY: number,
|
||||
lowerLidY: number,
|
||||
closedEyes: number,
|
||||
): CSSProperties {
|
||||
const baseTop = side === "left" ? 30.25 : 29.0;
|
||||
const baseBottom = side === "left" ? 65.85 : 67.05;
|
||||
const top = clamp(baseTop + upperLidY * 0.18 + closedEyes * 2.4, 0, 48);
|
||||
const bottom = clamp(baseBottom - lowerLidY * 0.12 + closedEyes * 2.1, 45, 82);
|
||||
const clipPath = `inset(${top.toFixed(2)}% 0 ${bottom.toFixed(2)}% 0)`;
|
||||
return {
|
||||
WebkitClipPath: clipPath,
|
||||
clipPath,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function mouthForVariant(variant: RasterVariant): string {
|
||||
if (variant === "sad" || variant === "tired" || variant === "anxious") return "mouth-sad";
|
||||
if (variant === "warm") return "mouth-warm";
|
||||
return "mouth-neutral";
|
||||
}
|
||||
|
||||
function browPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } {
|
||||
switch (variant) {
|
||||
case "sad":
|
||||
return { y: 1.4, rotate: -0.35 };
|
||||
case "tired":
|
||||
return { y: 1.8, rotate: -0.2 };
|
||||
return { y: 1.4, leftRotate: 1.1, rightRotate: -1.1 };
|
||||
case "anxious":
|
||||
return { y: -1.1, rotate: 0.25 };
|
||||
case "warm":
|
||||
return { y: -0.7, rotate: 0.12 };
|
||||
return { y: -1.2, leftRotate: -1.4, rightRotate: 1.4 };
|
||||
case "startled":
|
||||
return { y: -3.2, rotate: 0.1 };
|
||||
return { y: -2.4, leftRotate: 0, rightRotate: 0 };
|
||||
case "warm":
|
||||
return { y: -0.6, leftRotate: -0.4, rightRotate: 0.4 };
|
||||
default:
|
||||
return { y: 0, rotate: 0 };
|
||||
return { y: 0, leftRotate: 0, rightRotate: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function psbMouthForVariant(variant: RasterVariant): string {
|
||||
switch (variant) {
|
||||
case "warm":
|
||||
return "mouth-warm";
|
||||
case "sad":
|
||||
return "mouth-sad";
|
||||
case "tired":
|
||||
return "mouth-tired";
|
||||
case "anxious":
|
||||
return "mouth-anxious";
|
||||
case "startled":
|
||||
return "mouth-startled";
|
||||
default:
|
||||
return "mouth-neutral";
|
||||
}
|
||||
}
|
||||
|
||||
function psbBrowPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } {
|
||||
switch (variant) {
|
||||
case "sad":
|
||||
return { y: 2.6, leftRotate: 2.2, rightRotate: -2.2 };
|
||||
case "tired":
|
||||
return { y: 2.0, leftRotate: 0.8, rightRotate: -0.8 };
|
||||
case "anxious":
|
||||
return { y: -2.2, leftRotate: -3.0, rightRotate: 3.0 };
|
||||
case "startled":
|
||||
return { y: -4.2, leftRotate: 0, rightRotate: 0 };
|
||||
case "warm":
|
||||
return { y: -1.0, leftRotate: -0.8, rightRotate: 0.8 };
|
||||
default:
|
||||
return { y: 0, leftRotate: 0, rightRotate: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function psbEyePoseForVariant(variant: RasterVariant): {
|
||||
scaleY: number;
|
||||
upperY: number;
|
||||
lowerY: number;
|
||||
lashY: number;
|
||||
leftLashRotate: number;
|
||||
rightLashRotate: number;
|
||||
detailY: number;
|
||||
} {
|
||||
switch (variant) {
|
||||
case "warm":
|
||||
return {
|
||||
scaleY: 0.72,
|
||||
upperY: 5.4,
|
||||
lowerY: -1.4,
|
||||
lashY: 3.0,
|
||||
leftLashRotate: -1.4,
|
||||
rightLashRotate: 1.4,
|
||||
detailY: 0.9,
|
||||
};
|
||||
case "sad":
|
||||
return {
|
||||
scaleY: 0.92,
|
||||
upperY: 1.8,
|
||||
lowerY: -0.5,
|
||||
lashY: 1.0,
|
||||
leftLashRotate: 1.2,
|
||||
rightLashRotate: -1.2,
|
||||
detailY: 0.4,
|
||||
};
|
||||
case "tired":
|
||||
return {
|
||||
scaleY: 0.56,
|
||||
upperY: 7.2,
|
||||
lowerY: -1.9,
|
||||
lashY: 4.0,
|
||||
leftLashRotate: 0.4,
|
||||
rightLashRotate: -0.4,
|
||||
detailY: 1.0,
|
||||
};
|
||||
case "anxious":
|
||||
return {
|
||||
scaleY: 1.04,
|
||||
upperY: -1.2,
|
||||
lowerY: 0.4,
|
||||
lashY: -0.8,
|
||||
leftLashRotate: -1.5,
|
||||
rightLashRotate: 1.5,
|
||||
detailY: -0.2,
|
||||
};
|
||||
case "startled":
|
||||
return {
|
||||
scaleY: 1.18,
|
||||
upperY: -5.8,
|
||||
lowerY: 1.6,
|
||||
lashY: -3.2,
|
||||
leftLashRotate: 0,
|
||||
rightLashRotate: 0,
|
||||
detailY: -0.5,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
scaleY: 1,
|
||||
upperY: 0,
|
||||
lowerY: 0,
|
||||
lashY: 0,
|
||||
leftLashRotate: 0,
|
||||
rightLashRotate: 0,
|
||||
detailY: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function psbGeneratedEyeVariantFor(variant: RasterVariant): "joy" | "sad" | "startled" | null {
|
||||
// Imagegen v1 eye parts drifted too far from the source PSB style.
|
||||
void variant;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RasterBust({
|
||||
artSet,
|
||||
affect,
|
||||
|
|
@ -96,43 +243,445 @@ export function RasterBust({
|
|||
}: RasterBustProps) {
|
||||
const variant = rasterVariantFor(affect);
|
||||
const speaking = state === "speaking" && !reduced;
|
||||
const blinkClosed = reduced ? 0 : clamp01((1 - blink) / 0.92);
|
||||
const speakOverlay = speaking ? clamp01(mouth) : 0;
|
||||
const browPose = browPoseFor(variant);
|
||||
|
||||
const stackTransform = `translate(${gazeX.toFixed(2)}px, ${(gazeY - breath).toFixed(
|
||||
2,
|
||||
)}px) rotate(${headTilt.toFixed(2)}deg)`;
|
||||
|
||||
const part = useMemo(
|
||||
() => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`,
|
||||
const raster = useMemo(
|
||||
() => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/${name}.png`,
|
||||
[artSet],
|
||||
);
|
||||
|
||||
const hairPhase = reduced ? 0 : breath;
|
||||
const hairLeftTransform = layerTransform(
|
||||
`translate(${(hairPhase * 0.36).toFixed(2)}px, ${(hairPhase * -0.05).toFixed(
|
||||
2,
|
||||
)}px) rotate(${(hairPhase * -0.16).toFixed(2)}deg)`,
|
||||
);
|
||||
const hairRightTransform = layerTransform(
|
||||
`translate(${(hairPhase * -0.32).toFixed(2)}px, ${(hairPhase * -0.04).toFixed(
|
||||
2,
|
||||
)}px) rotate(${(hairPhase * 0.15).toFixed(2)}deg)`,
|
||||
);
|
||||
const bangsTransform = layerTransform(
|
||||
`translate(${(hairPhase * 0.12).toFixed(2)}px, ${(hairPhase * 0.08).toFixed(
|
||||
2,
|
||||
)}px) rotate(${(hairPhase * 0.08).toFixed(2)}deg)`,
|
||||
);
|
||||
const eyesTransform = layerTransform(
|
||||
`translate(${(gazeX * 0.22).toFixed(2)}px, ${(gazeY * 0.16).toFixed(2)}px)`,
|
||||
);
|
||||
const browTransform = layerTransform(
|
||||
`translate(${(gazeX * 0.08).toFixed(2)}px, ${(browPose.y + gazeY * 0.05).toFixed(
|
||||
2,
|
||||
)}px) rotate(${browPose.rotate.toFixed(2)}deg)`,
|
||||
);
|
||||
if (PSB_GROUP_ART_SETS.has(artSet)) {
|
||||
const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`;
|
||||
const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.1)) : 0;
|
||||
const wideOpen = clamp01((speakingOpen - 0.34) / 0.5);
|
||||
const smallOpen = speakingOpen > 0 ? 1 - wideOpen : 0;
|
||||
const closedEyes = reduced ? 0 : clamp01((0.55 - blink) / 0.47);
|
||||
const openEyes = 1 - closedEyes;
|
||||
const irisX = reduced ? 0 : gazeX * 0.75;
|
||||
const irisY = reduced ? 0 : gazeY * 0.48;
|
||||
const hairSway = reduced ? 0 : clamp(breath * 0.48 + headTilt * 0.22 + gazeX * 0.08, -2.2, 2.2);
|
||||
const hairLag = reduced ? 0 : clamp(breath * 0.34 + headTilt * 0.18, -1.8, 1.8);
|
||||
const talkBounce = speakingOpen * 1.25;
|
||||
const browFloat = reduced ? 0 : breath * 0.22;
|
||||
const brow = psbBrowPoseForVariant(variant);
|
||||
const eyePose = psbEyePoseForVariant(variant);
|
||||
const generatedEyeVariant = psbGeneratedEyeVariantFor(variant);
|
||||
const blinkScale = 1 - closedEyes * 0.78;
|
||||
const eyeScaleY = Math.max(0.06, eyePose.scaleY * blinkScale);
|
||||
const upperLidY = eyePose.upperY + closedEyes * 10.8;
|
||||
const lowerLidY = eyePose.lowerY - closedEyes * 2.4;
|
||||
const lashY = eyePose.lashY + closedEyes * 2.1;
|
||||
const expressionMouth = psbMouthForVariant(variant);
|
||||
const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
|
||||
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
|
||||
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
|
||||
{ name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
|
||||
{ name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
|
||||
{ name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
|
||||
{ name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
|
||||
];
|
||||
const renderLayer = (
|
||||
name: string,
|
||||
className: string,
|
||||
zIndex: number,
|
||||
style: CSSProperties = {},
|
||||
) => (
|
||||
<img
|
||||
key={name}
|
||||
className={`vg-raster__layer ${className}`}
|
||||
src={part(name)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1, zIndex, transform: layerTransform(), ...style }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vg-raster"
|
||||
data-raster-art-set={artSet}
|
||||
data-raster-variant={variant}
|
||||
data-raster-speaking={speaking ? "true" : "false"}
|
||||
data-raster-mode="psb-detailed"
|
||||
style={{ transform: stackTransform, transformOrigin: "50% 58%" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{renderLayer("hair-back-base", "vg-raster__layer--hairback", 1)}
|
||||
{renderLayer("hair-back-left", "vg-raster__layer--hairback", 2, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairLag * -0.46).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "49% 5%",
|
||||
})}
|
||||
{renderLayer("hair-back-right", "vg-raster__layer--hairback", 2, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairLag * 0.46).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "51% 5%",
|
||||
})}
|
||||
{renderLayer("body", "vg-raster__layer--neck", 3)}
|
||||
{renderLayer("outfit", "vg-raster__layer--shoulders", 4)}
|
||||
{renderLayer("ear-left", "vg-raster__layer--ear", 5)}
|
||||
{renderLayer("ear-right", "vg-raster__layer--ear", 5)}
|
||||
{renderLayer("face-base", "vg-raster__layer--face", 6)}
|
||||
{renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })}
|
||||
{renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })}
|
||||
{generatedEyeVariant ? (
|
||||
<>
|
||||
{renderLayer(`eyegen-${generatedEyeVariant}-eye-left`, "vg-raster__layer--eyes", 8, {
|
||||
opacity: openEyes,
|
||||
transform: layerTransform(`translate(${irisX.toFixed(2)}px, ${irisY.toFixed(2)}px)`),
|
||||
transformOrigin: "41.1% 32.2%",
|
||||
})}
|
||||
{renderLayer(`eyegen-${generatedEyeVariant}-eye-right`, "vg-raster__layer--eyes", 8, {
|
||||
opacity: openEyes,
|
||||
transform: layerTransform(`translate(${irisX.toFixed(2)}px, ${irisY.toFixed(2)}px)`),
|
||||
transformOrigin: "60.6% 31%",
|
||||
})}
|
||||
{renderLayer("eyelid-upper-left", "vg-raster__layer--eyelid", 13, {
|
||||
opacity: closedEyes,
|
||||
transform: layerTransform(`translateY(${(closedEyes * 10.8).toFixed(2)}px)`),
|
||||
transformOrigin: "39.7% 31.6%",
|
||||
})}
|
||||
{renderLayer("eyelid-upper-right", "vg-raster__layer--eyelid", 13, {
|
||||
opacity: closedEyes,
|
||||
transform: layerTransform(`translateY(${(closedEyes * 10.8).toFixed(2)}px)`),
|
||||
transformOrigin: "62.1% 30.2%",
|
||||
})}
|
||||
{renderLayer(`eyegen-${generatedEyeVariant}-brow-left`, "vg-raster__layer--brow", 14, {
|
||||
transform: layerTransform(
|
||||
`translateY(${(browFloat - talkBounce * 0.2).toFixed(2)}px) rotate(${(-browFloat * 0.06).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "41.3% 27.1%",
|
||||
})}
|
||||
{renderLayer(`eyegen-${generatedEyeVariant}-brow-right`, "vg-raster__layer--brow", 14, {
|
||||
transform: layerTransform(
|
||||
`translateY(${(browFloat - talkBounce * 0.2).toFixed(2)}px) rotate(${(browFloat * 0.06).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "59.6% 25.9%",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{["eye-white-left", "eye-white-right"].map((name) =>
|
||||
renderLayer(name, "vg-raster__layer--eyes", 8, {
|
||||
opacity: openEyes,
|
||||
transform: layerTransform(`scaleY(${eyeScaleY.toFixed(3)})`),
|
||||
transformOrigin: name.endsWith("left") ? "41.1% 32.2%" : "60.6% 31%",
|
||||
...eyeApertureClipStyle(name.endsWith("left") ? "left" : "right", upperLidY, lowerLidY, closedEyes),
|
||||
}),
|
||||
)}
|
||||
{eyeDetailLayers.map(({ name, mask, zIndex }) =>
|
||||
renderLayer(name, "vg-raster__layer--eyes", zIndex, {
|
||||
opacity: openEyes,
|
||||
transform: layerTransform(
|
||||
`translate(${irisX.toFixed(2)}px, ${(irisY + eyePose.detailY).toFixed(2)}px) scaleY(${eyeScaleY.toFixed(3)})`,
|
||||
),
|
||||
transformOrigin: name.endsWith("left") ? "41.8% 31.9%" : "59.8% 30.8%",
|
||||
...eyeClipStyle(part(mask)),
|
||||
...eyeApertureClipStyle(name.endsWith("left") ? "left" : "right", upperLidY, lowerLidY, closedEyes),
|
||||
}),
|
||||
)}
|
||||
{renderLayer("lash-left", "vg-raster__layer--eyes", 12, {
|
||||
transform: layerTransform(
|
||||
`translateY(${lashY.toFixed(2)}px) rotate(${(eyePose.leftLashRotate + closedEyes * 0.3).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "41.4% 29.8%",
|
||||
})}
|
||||
{renderLayer("lash-right", "vg-raster__layer--eyes", 12, {
|
||||
transform: layerTransform(
|
||||
`translateY(${lashY.toFixed(2)}px) rotate(${(eyePose.rightLashRotate - closedEyes * 0.3).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "59.9% 29%",
|
||||
})}
|
||||
{renderLayer("eyelid-upper-left", "vg-raster__layer--eyelid", 13, {
|
||||
opacity: 0.72 + closedEyes * 0.28,
|
||||
transform: layerTransform(`translateY(${upperLidY.toFixed(2)}px)`),
|
||||
transformOrigin: "39.7% 31.6%",
|
||||
})}
|
||||
{renderLayer("eyelid-upper-right", "vg-raster__layer--eyelid", 13, {
|
||||
opacity: 0.72 + closedEyes * 0.28,
|
||||
transform: layerTransform(`translateY(${upperLidY.toFixed(2)}px)`),
|
||||
transformOrigin: "62.1% 30.2%",
|
||||
})}
|
||||
{renderLayer("eyelid-lower-left", "vg-raster__layer--eyelid", 13, {
|
||||
opacity: 0.78,
|
||||
transform: layerTransform(`translateY(${lowerLidY.toFixed(2)}px)`),
|
||||
transformOrigin: "41% 33.9%",
|
||||
})}
|
||||
{renderLayer("eyelid-lower-right", "vg-raster__layer--eyelid", 13, {
|
||||
opacity: 0.78,
|
||||
transform: layerTransform(`translateY(${lowerLidY.toFixed(2)}px)`),
|
||||
transformOrigin: "60.8% 32.4%",
|
||||
})}
|
||||
{renderLayer("brow-left", "vg-raster__layer--brow", 14, {
|
||||
transform: layerTransform(
|
||||
`translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.leftRotate - speakingOpen * 0.35 - browFloat * 0.08).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "40% 27%",
|
||||
})}
|
||||
{renderLayer("brow-right", "vg-raster__layer--brow", 14, {
|
||||
transform: layerTransform(
|
||||
`translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.rightRotate + speakingOpen * 0.35 + browFloat * 0.08).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "60% 26%",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{renderLayer("nose", "vg-raster__layer--nose", 15)}
|
||||
{renderLayer("hair-side-left-1", "vg-raster__layer--hair", 16, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairSway * -0.8).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "37.2% 6.4%",
|
||||
})}
|
||||
{renderLayer("hair-side-right-1", "vg-raster__layer--hair", 16, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairSway * 0.8).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "63.3% 6.3%",
|
||||
})}
|
||||
{renderLayer("hair-side-left-2", "vg-raster__layer--hair", 17, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairSway * -1.16).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "31.7% 32.1%",
|
||||
})}
|
||||
{renderLayer("hair-side-right-2", "vg-raster__layer--hair", 17, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairSway * 1.16).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "66.1% 33.5%",
|
||||
})}
|
||||
{renderLayer("hair-bangs", "vg-raster__layer--bangs", 18, {
|
||||
transform: layerTransform(
|
||||
`rotate(${(hairSway * 0.28).toFixed(2)}deg)`,
|
||||
),
|
||||
transformOrigin: "50.7% 7%",
|
||||
})}
|
||||
{renderLayer(expressionMouth, "vg-raster__layer--mouth", 19, {
|
||||
opacity: 1 - speakingOpen * 0.92,
|
||||
transform: layerTransform(`translateY(${(talkBounce * 0.18).toFixed(2)}px)`),
|
||||
transformOrigin: "50% 44%",
|
||||
})}
|
||||
{renderLayer("mouth-open-small", "vg-raster__layer--mouthopen", 20, {
|
||||
opacity: smallOpen * 0.92,
|
||||
transform: layerTransform(`translateY(${(talkBounce * 0.16).toFixed(2)}px)`),
|
||||
transformOrigin: "50% 44%",
|
||||
})}
|
||||
{renderLayer("mouth-open", "vg-raster__layer--mouthopen", 21, {
|
||||
opacity: wideOpen,
|
||||
transform: layerTransform(`translateY(${(talkBounce * 0.16).toFixed(2)}px)`),
|
||||
transformOrigin: "50% 44%",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (LIVE2D_PARTS_ART_SETS.has(artSet)) {
|
||||
const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`;
|
||||
const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.08)) : 0;
|
||||
const closedEyes = reduced ? 0 : clamp01((0.55 - blink) / 0.47);
|
||||
const openEyes = 1 - closedEyes;
|
||||
const irisX = reduced ? 0 : gazeX * 0.42;
|
||||
const irisY = reduced ? 0 : gazeY * 0.28;
|
||||
const hairSway = reduced ? 0 : breath * 0.24 + headTilt * 0.18;
|
||||
const brow = browPoseForVariant(variant);
|
||||
const expressionMouth = mouthForVariant(variant);
|
||||
const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
|
||||
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
|
||||
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
|
||||
{ name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
|
||||
{ name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
|
||||
{ name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
|
||||
{ name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vg-raster"
|
||||
data-raster-art-set={artSet}
|
||||
data-raster-variant={variant}
|
||||
data-raster-speaking={speaking ? "true" : "false"}
|
||||
data-raster-mode="live2d-parts"
|
||||
style={{ transform: stackTransform, transformOrigin: "50% 58%" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--hairback"
|
||||
src={part("hair-back")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1, zIndex: 1, transform: layerTransform(`translateX(${hairSway * -0.45}px)`) }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--shoulders"
|
||||
src={part("torso")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1, zIndex: 2, transform: layerTransform() }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--face"
|
||||
src={part("head-faceless")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1, zIndex: 3, transform: layerTransform() }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--hair"
|
||||
src={part("hair-left")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1,
|
||||
zIndex: 4,
|
||||
transform: layerTransform(`translate(${hairSway * -0.9}px, ${Math.abs(hairSway) * 0.18}px) rotate(${hairSway * -0.22}deg)`),
|
||||
transformOrigin: "37% 30%",
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--hair"
|
||||
src={part("hair-right")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1,
|
||||
zIndex: 4,
|
||||
transform: layerTransform(`translate(${hairSway * 0.9}px, ${Math.abs(hairSway) * 0.18}px) rotate(${hairSway * 0.22}deg)`),
|
||||
transformOrigin: "63% 30%",
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--bangs"
|
||||
src={part("hair-front")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1,
|
||||
zIndex: 8,
|
||||
transform: layerTransform(`translateY(${hairSway * 0.18}px) rotate(${hairSway * 0.08}deg)`),
|
||||
transformOrigin: "50% 18%",
|
||||
}}
|
||||
/>
|
||||
{["eye-white-left", "eye-white-right", "lash-left", "lash-right"].map((name) => (
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyes"
|
||||
key={name}
|
||||
src={part(name)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: openEyes, zIndex: name.startsWith("lash") ? 12 : 9, transform: layerTransform() }}
|
||||
/>
|
||||
))}
|
||||
{eyeDetailLayers.map(
|
||||
({ name, mask, zIndex }) => (
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyes"
|
||||
key={name}
|
||||
src={part(name)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: openEyes,
|
||||
zIndex,
|
||||
transform: layerTransform(`translate(${irisX.toFixed(2)}px, ${irisY.toFixed(2)}px)`),
|
||||
...eyeClipStyle(part(mask)),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{["eyelid-left-closed", "eyelid-right-closed"].map((name) => (
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyelid"
|
||||
key={name}
|
||||
src={part(name)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: closedEyes, zIndex: 13, transform: layerTransform() }}
|
||||
/>
|
||||
))}
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--brow"
|
||||
src={part("brow-left")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1,
|
||||
zIndex: 14,
|
||||
transform: layerTransform(`translateY(${brow.y}px) rotate(${brow.leftRotate}deg)`),
|
||||
transformOrigin: "40% 28%",
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--brow"
|
||||
src={part("brow-right")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1,
|
||||
zIndex: 14,
|
||||
transform: layerTransform(`translateY(${brow.y}px) rotate(${brow.rightRotate}deg)`),
|
||||
transformOrigin: "60% 28%",
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--nose"
|
||||
src={part("nose")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1, zIndex: 15, transform: layerTransform() }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--mouth"
|
||||
src={part(expressionMouth)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1 - speakingOpen, zIndex: 16, transform: layerTransform() }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--mouthopen"
|
||||
src={part("mouth-open")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: speakingOpen, zIndex: 17, transform: layerTransform() }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -145,124 +694,13 @@ export function RasterBust({
|
|||
>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--base"
|
||||
src={part("base-faceless")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--hair"
|
||||
src={part("hair-left")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: reduced ? 0 : 0.16,
|
||||
transform: hairLeftTransform,
|
||||
transformOrigin: "34% 28%",
|
||||
transition: TX,
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--hair"
|
||||
src={part("hair-right")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: reduced ? 0 : 0.16,
|
||||
transform: hairRightTransform,
|
||||
transformOrigin: "66% 28%",
|
||||
transition: TX,
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--bangs"
|
||||
src={part("hair-bangs")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: reduced ? 0 : 0.2,
|
||||
transform: bangsTransform,
|
||||
transformOrigin: "50% 22%",
|
||||
transition: TX,
|
||||
}}
|
||||
/>
|
||||
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--brow"
|
||||
src={part(`brow-${variant}`)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: 1, transform: browTransform, transformOrigin: "50% 30%", transition: TX }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyes"
|
||||
src={part(`eyes-${variant}`)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1 - blinkClosed * 0.95,
|
||||
transform: eyesTransform,
|
||||
transformOrigin: "50% 32%",
|
||||
transition: TX,
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyelid"
|
||||
src={part("eyelid-closed")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ opacity: blinkClosed, transform: layerTransform(), transition: TX }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--nose"
|
||||
src={part("nose-neutral")}
|
||||
src={raster("neutral")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ transform: layerTransform() }}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--mouth"
|
||||
src={part(mouthPartFor(variant))}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: 1 - speakOverlay * 0.78,
|
||||
transform: layerTransform(),
|
||||
transition: TX,
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--mouthopen"
|
||||
src={part("mouth-open")}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: speakOverlay,
|
||||
transform: layerTransform(`translateY(${(speakOverlay * 1.2).toFixed(2)}px)`),
|
||||
transformOrigin: "50% 40%",
|
||||
transition: TX,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue