forked from CarrotSkin/carrotskin
Add Docker configuration with standalone output mode for containerized deployment. Implement texture deletion API with proper error handling and user feedback. Fix skin viewer sizing issues by using explicit dimensions and removing conflicting layout properties. Add captcha ID parameter to registration flow. Improve profile page UX including Yggdrasil password reset display and character card editing controls.
274 lines
8.2 KiB
TypeScript
274 lines
8.2 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { SkinViewer as SkinViewer3D, WalkingAnimation, RunningAnimation, FlyingAnimation, IdleAnimation } from 'skinview3d';
|
||
|
||
interface SkinViewerProps {
|
||
skinUrl: string;
|
||
capeUrl?: string;
|
||
isSlim?: boolean;
|
||
width?: number;
|
||
height?: number;
|
||
className?: string;
|
||
autoRotate?: boolean;
|
||
walking?: boolean;
|
||
running?: boolean; // 新增:跑步动画
|
||
jumping?: boolean; // 新增:跳跃动画
|
||
rotation?: boolean; // 新增:旋转控制
|
||
isExternalPreview?: boolean; // 新增:是否为外部预览
|
||
onImageLoaded?: () => void; // 新增:图片加载完成回调
|
||
}
|
||
|
||
export default function SkinViewer({
|
||
skinUrl,
|
||
capeUrl,
|
||
isSlim = false,
|
||
width = 300,
|
||
height = 300,
|
||
className = '',
|
||
autoRotate = true,
|
||
walking = false,
|
||
running = false,
|
||
jumping = false,
|
||
rotation = true,
|
||
isExternalPreview = false, // 新增:默认为false
|
||
onImageLoaded, // 新增:图片加载完成回调
|
||
}: 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);
|
||
if (onImageLoaded) {
|
||
onImageLoaded();
|
||
}
|
||
};
|
||
|
||
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 });
|
||
|
||
// 使用传入的宽高参数初始化
|
||
const canvas = canvasRef.current;
|
||
const viewer = new SkinViewer3D({
|
||
canvas: canvas,
|
||
width: width,
|
||
height: height,
|
||
skin: skinUrl,
|
||
cape: capeUrl,
|
||
model: isSlim ? 'slim' : 'default',
|
||
zoom: 1.0,
|
||
});
|
||
|
||
viewerRef.current = viewer;
|
||
|
||
// 设置背景和控制选项
|
||
viewer.background = null; // 透明背景
|
||
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; // 启用缩放
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, running, jumping, rotation, imageLoaded, hasError, isExternalPreview]);
|
||
|
||
// 控制动画效果 - 参考 Blessing Skin 的实现
|
||
useEffect(() => {
|
||
if (!viewerRef.current) return;
|
||
|
||
const viewer = viewerRef.current;
|
||
|
||
// 外部预览时只使用静止动画,禁用所有其他动画
|
||
if (isExternalPreview) {
|
||
viewer.animation = new IdleAnimation();
|
||
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;
|
||
}
|
||
|
||
}, [walking, running, jumping, autoRotate, isExternalPreview]);
|
||
|
||
// 当皮肤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 {
|
||
viewerRef.current.resetCape();
|
||
} 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={{
|
||
width: width,
|
||
height: height
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|