2025-12-04 20:05:13 +08:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2025-12-04 22:33:46 +08:00
|
|
|
|
import { SkinViewer as SkinViewer3D, WalkingAnimation, RunningAnimation, FlyingAnimation, IdleAnimation } from 'skinview3d';
|
2025-12-04 20:05:13 +08:00
|
|
|
|
|
|
|
|
|
|
interface SkinViewerProps {
|
|
|
|
|
|
skinUrl: string;
|
|
|
|
|
|
capeUrl?: string;
|
|
|
|
|
|
isSlim?: boolean;
|
|
|
|
|
|
width?: number;
|
|
|
|
|
|
height?: number;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
autoRotate?: boolean;
|
|
|
|
|
|
walking?: boolean;
|
2025-12-04 22:33:46 +08:00
|
|
|
|
running?: boolean; // 新增:跑步动画
|
|
|
|
|
|
jumping?: boolean; // 新增:跳跃动画
|
|
|
|
|
|
rotation?: boolean; // 新增:旋转控制
|
2025-12-05 20:07:50 +08:00
|
|
|
|
isExternalPreview?: boolean; // 新增:是否为外部预览
|
2026-01-09 17:44:21 +08:00
|
|
|
|
onImageLoaded?: () => void; // 新增:图片加载完成回调
|
2025-12-04 20:05:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function SkinViewer({
|
|
|
|
|
|
skinUrl,
|
|
|
|
|
|
capeUrl,
|
|
|
|
|
|
isSlim = false,
|
|
|
|
|
|
width = 300,
|
|
|
|
|
|
height = 300,
|
|
|
|
|
|
className = '',
|
|
|
|
|
|
autoRotate = true,
|
|
|
|
|
|
walking = false,
|
2025-12-04 22:33:46 +08:00
|
|
|
|
running = false,
|
|
|
|
|
|
jumping = false,
|
|
|
|
|
|
rotation = true,
|
2025-12-05 20:07:50 +08:00
|
|
|
|
isExternalPreview = false, // 新增:默认为false
|
2026-01-09 17:44:21 +08:00
|
|
|
|
onImageLoaded, // 新增:图片加载完成回调
|
2025-12-04 20:05:13 +08:00
|
|
|
|
}: SkinViewerProps) {
|
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
|
const viewerRef = useRef<SkinViewer3D | null>(null);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
|
const [hasError, setHasError] = useState(false);
|
|
|
|
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 预加载皮肤图片以检查是否可访问
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!skinUrl) return;
|
|
|
|
|
|
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
setHasError(false);
|
|
|
|
|
|
setImageLoaded(false);
|
|
|
|
|
|
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
|
img.crossOrigin = 'anonymous'; // 尝试跨域访问
|
|
|
|
|
|
|
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
|
console.log('皮肤图片加载成功:', skinUrl);
|
|
|
|
|
|
setImageLoaded(true);
|
|
|
|
|
|
setIsLoading(false);
|
2026-01-09 17:44:21 +08:00
|
|
|
|
if (onImageLoaded) {
|
|
|
|
|
|
onImageLoaded();
|
|
|
|
|
|
}
|
2025-12-04 20:05:13 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
img.onerror = (error) => {
|
|
|
|
|
|
console.error('皮肤图片加载失败:', skinUrl, error);
|
|
|
|
|
|
setHasError(true);
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 开始加载图片
|
|
|
|
|
|
img.src = skinUrl;
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
// 清理
|
|
|
|
|
|
img.onload = null;
|
|
|
|
|
|
img.onerror = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [skinUrl]);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化3D查看器
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!canvasRef.current || !imageLoaded || hasError) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
|
|
|
|
|
|
|
2026-02-24 11:09:37 +08:00
|
|
|
|
// 使用传入的宽高参数初始化
|
2025-12-04 20:05:13 +08:00
|
|
|
|
const canvas = canvasRef.current;
|
|
|
|
|
|
const viewer = new SkinViewer3D({
|
|
|
|
|
|
canvas: canvas,
|
2026-02-24 11:09:37 +08:00
|
|
|
|
width: width,
|
|
|
|
|
|
height: height,
|
2025-12-04 20:05:13 +08:00
|
|
|
|
skin: skinUrl,
|
|
|
|
|
|
cape: capeUrl,
|
|
|
|
|
|
model: isSlim ? 'slim' : 'default',
|
2026-02-24 11:09:37 +08:00
|
|
|
|
zoom: 1.0,
|
2025-12-04 20:05:13 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
viewerRef.current = viewer;
|
|
|
|
|
|
|
2026-02-24 11:09:37 +08:00
|
|
|
|
// 设置背景和控制选项
|
2025-12-04 20:05:13 +08:00
|
|
|
|
viewer.background = null; // 透明背景
|
2025-12-05 20:07:50 +08:00
|
|
|
|
viewer.autoRotate = false; // 完全禁用自动旋转
|
|
|
|
|
|
|
|
|
|
|
|
// 外部预览时禁用所有动画和旋转
|
|
|
|
|
|
if (isExternalPreview) {
|
|
|
|
|
|
viewer.autoRotate = false;
|
|
|
|
|
|
viewer.controls.enableRotate = false; // 禁用旋转控制
|
|
|
|
|
|
viewer.controls.enableZoom = false; // 禁用缩放
|
|
|
|
|
|
} else {
|
|
|
|
|
|
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
|
|
|
|
|
|
viewer.controls.enableRotate = rotation; // 根据参数控制旋转
|
|
|
|
|
|
viewer.controls.enableZoom = true; // 启用缩放
|
|
|
|
|
|
}
|
2025-12-04 20:05:13 +08:00
|
|
|
|
|
|
|
|
|
|
viewer.controls.enablePan = false; // 禁用平移
|
|
|
|
|
|
|
|
|
|
|
|
console.log('3D皮肤查看器初始化成功');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('3D皮肤查看器初始化失败:', error);
|
|
|
|
|
|
setHasError(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清理函数
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (viewerRef.current) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
viewerRef.current.dispose();
|
|
|
|
|
|
viewerRef.current = null;
|
|
|
|
|
|
console.log('3D皮肤查看器已清理');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('清理3D皮肤查看器失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-12-05 20:07:50 +08:00
|
|
|
|
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, running, jumping, rotation, imageLoaded, hasError, isExternalPreview]);
|
2025-12-04 22:33:46 +08:00
|
|
|
|
|
|
|
|
|
|
// 控制动画效果 - 参考 Blessing Skin 的实现
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!viewerRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const viewer = viewerRef.current;
|
|
|
|
|
|
|
2025-12-05 20:07:50 +08:00
|
|
|
|
// 外部预览时只使用静止动画,禁用所有其他动画
|
|
|
|
|
|
if (isExternalPreview) {
|
2025-12-04 22:33:46 +08:00
|
|
|
|
viewer.animation = new IdleAnimation();
|
2025-12-05 20:07:50 +08:00
|
|
|
|
viewer.autoRotate = false;
|
|
|
|
|
|
console.log('外部预览模式:启用静止动画,禁用旋转');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 根据优先级设置动画 - 参考 Blessing Skin 的 animationFactories
|
|
|
|
|
|
if (running) {
|
|
|
|
|
|
// 跑步动画
|
|
|
|
|
|
viewer.animation = new RunningAnimation();
|
|
|
|
|
|
console.log('启用跑步动画');
|
|
|
|
|
|
} else if (walking) {
|
|
|
|
|
|
// 普通步行动画
|
|
|
|
|
|
viewer.animation = new WalkingAnimation();
|
|
|
|
|
|
console.log('启用步行动画');
|
|
|
|
|
|
} else if (jumping) {
|
|
|
|
|
|
// 飞行动画作为跳跃
|
|
|
|
|
|
viewer.animation = new FlyingAnimation();
|
|
|
|
|
|
console.log('启用跳跃动画');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 静止动画
|
|
|
|
|
|
viewer.animation = new IdleAnimation();
|
|
|
|
|
|
console.log('启用静止动画');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新自动旋转状态
|
|
|
|
|
|
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
|
2025-12-04 22:33:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-05 20:07:50 +08:00
|
|
|
|
}, [walking, running, jumping, autoRotate, isExternalPreview]);
|
2025-12-04 20:05:13 +08:00
|
|
|
|
|
|
|
|
|
|
// 当皮肤URL改变时更新
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (viewerRef.current && skinUrl && imageLoaded) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('更新皮肤URL:', skinUrl);
|
|
|
|
|
|
viewerRef.current.loadSkin(skinUrl);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('更新皮肤失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [skinUrl, imageLoaded]);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听容器尺寸变化并调整viewer大小 - 参考blessingskin
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (viewerRef.current && canvasRef.current) {
|
|
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
|
|
|
|
viewerRef.current.setSize(rect.width, rect.height);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [imageLoaded]); // 当图片加载完成后调整尺寸
|
|
|
|
|
|
|
|
|
|
|
|
// 当披风URL改变时更新
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (viewerRef.current && capeUrl && imageLoaded) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('更新披风URL:', capeUrl);
|
|
|
|
|
|
viewerRef.current.loadCape(capeUrl);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('更新披风失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (viewerRef.current && !capeUrl && imageLoaded) {
|
|
|
|
|
|
try {
|
2025-12-04 22:33:46 +08:00
|
|
|
|
viewerRef.current.resetCape();
|
2025-12-04 20:05:13 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('移除披风失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [capeUrl, imageLoaded]);
|
|
|
|
|
|
|
|
|
|
|
|
// 当模型类型改变时更新
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (viewerRef.current && skinUrl && imageLoaded) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('更新模型类型:', isSlim ? 'slim' : 'default');
|
|
|
|
|
|
viewerRef.current.loadSkin(skinUrl, { model: isSlim ? 'slim' : 'default' });
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('更新模型失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isSlim, skinUrl, imageLoaded]);
|
|
|
|
|
|
|
|
|
|
|
|
// 错误状态显示
|
|
|
|
|
|
if (hasError) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`${className} flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-xl border-2 border-dashed border-red-300 dark:border-red-700`}
|
|
|
|
|
|
style={{ width: width, height: height }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-center p-4">
|
|
|
|
|
|
<div className="text-4xl mb-2">⚠️</div>
|
|
|
|
|
|
<div className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
|
|
|
|
|
|
皮肤加载失败
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs text-red-500 dark:text-red-500">
|
|
|
|
|
|
图片链接可能无效或存在访问限制
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载状态显示
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`${className} flex items-center justify-center bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 rounded-xl`}
|
|
|
|
|
|
style={{ width: width, height: height }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="animate-spin w-8 h-8 border-3 border-orange-500 border-t-transparent rounded-full mx-auto mb-3"></div>
|
|
|
|
|
|
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">
|
|
|
|
|
|
加载中...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<canvas
|
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
|
className={className}
|
|
|
|
|
|
style={{
|
2026-02-24 11:09:37 +08:00
|
|
|
|
width: width,
|
|
|
|
|
|
height: height
|
2025-12-04 20:05:13 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|