fix: update dependencies and improve animation transitions

- Updated @types/react to version 19.2.7 in package.json and package-lock.json.
- Refined animation transitions in EnhancedButton, EnhancedInput, LoadingStates, Navbar, PageTransition, SkinCard, SkinDetailModal, and SkinViewer components by removing unnecessary ease properties for smoother animations.
- Added size prop to EnhancedInput for better customization.
- Enhanced SkinDetailModal and SkinCard styles for improved UI consistency.
This commit is contained in:
lafay
2026-01-09 17:44:21 +08:00
parent f5e4c2a04b
commit a7dd3a4bc0
11 changed files with 409 additions and 94 deletions

318
docs/skinview3d-usage.md Normal file
View File

@@ -0,0 +1,318 @@
# skinview3d 使用指南
skinview3d 是一个基于 Three.js 的 Minecraft 皮肤查看器,支持 3D 皮肤预览、动画效果、披风显示等功能。
## 安装
```bash
npm install skinview3d
```
## 基本用法
### 快速开始
```javascript
import { SkinViewer } from 'skinview3d';
// 创建皮肤查看器
const skinViewer = new SkinViewer({
canvas: document.getElementById('skin_container'),
width: 300,
height: 400,
skin: 'path/to/skin.png'
});
```
### React 组件封装
项目中已经封装了 React 组件 `SkinViewer`
```tsx
import SkinViewer from '@/components/SkinViewer';
<SkinViewer
skinUrl="https://example.com/skin.png"
capeUrl="https://example.com/cape.png" // 可选
isSlim={false} // 是否为细臂模型
width={300}
height={300}
autoRotate={true} // 自动旋转
walking={false} // 步行动画
running={false} // 跑步动画
jumping={false} // 跳跃动画
rotation={true} // 允许手动旋转
/>
```
## 配置选项
### 基本配置
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `canvas` | HTMLCanvasElement | 必填 | 画布元素 |
| `width` | number | 300 | 画布宽度 |
| `height` | number | 400 | 画布高度 |
| `skin` | string | null | 皮肤图片URL |
| `cape` | string | null | 披风图片URL |
| `model` | string | 'default' | 模型类型:'default' 或 'slim' |
| `zoom` | number | 1.0 | 缩放比例 |
### 背景设置
```javascript
// 设置背景颜色
skinViewer.background = 0x5a76f3;
// 设置背景图片
skinViewer.loadBackground('path/to/background.png');
// 设置全景背景
skinViewer.loadPanorama('path/to/panorama.png');
// 透明背景
skinViewer.background = null;
```
### 相机设置
```javascript
// 设置视野角度
skinViewer.fov = 70;
// 设置缩放
skinViewer.zoom = 0.5;
```
## 动画控制
### 内置动画类型
```javascript
import {
WalkingAnimation,
RunningAnimation,
FlyingAnimation,
IdleAnimation
} from 'skinview3d';
// 步行动画
skinViewer.animation = new WalkingAnimation();
// 跑步动画
skinViewer.animation = new RunningAnimation();
// 飞行动画(可用于游泳)
skinViewer.animation = new FlyingAnimation();
// 静止动画
skinViewer.animation = new IdleAnimation();
```
### 动画控制
```javascript
// 设置动画速度
skinViewer.animation.speed = 3;
// 暂停动画
skinViewer.animation.paused = true;
// 移除动画
skinViewer.animation = null;
```
### 自动旋转
```javascript
// 启用自动旋转
skinViewer.autoRotate = true;
// 禁用自动旋转
skinViewer.autoRotate = false;
```
## 交互控制
### 鼠标控制
```javascript
// 启用旋转控制
skinViewer.controls.enableRotate = true;
// 启用缩放控制
skinViewer.controls.enableZoom = true;
// 启用平移控制
skinViewer.controls.enablePan = true;
```
## 光照设置
```javascript
// 设置相机光源强度
skinViewer.cameraLight.intensity = 0.9;
// 设置全局光源强度
skinViewer.globalLight.intensity = 0.1;
// 完全禁用阴影
globalLight.intensity = 1.0;
cameraLight.intensity = 0.0;
```
## 高级功能
### 耳朵支持
skinview3d 支持两种耳朵纹理类型:
1. **独立耳朵纹理**14×7 像素的耳朵图片
2. **皮肤内置耳朵**:皮肤纹理中包含耳朵(如 deadmau5 皮肤)
```javascript
// 使用独立耳朵纹理
skinViewer.loadEars('path/to/ears.png', {
textureType: 'standalone'
});
// 使用皮肤内置耳朵
skinViewer.loadEars('path/to/skin.png', {
textureType: 'skin'
});
```
### 名称标签
```javascript
import { NameTagObject } from 'skinview3d';
// 简单名称标签
skinViewer.nameTag = "PlayerName";
// 自定义样式名称标签
skinViewer.nameTag = new NameTagObject("PlayerName", {
textStyle: "yellow"
});
// 移除名称标签
skinViewer.nameTag = null;
```
**注意**:要正确显示名称标签,需要加载 Minecraft 字体:
```css
@font-face {
font-family: 'Minecraft';
src: url('/path/to/minecraft.woff2') format('woff2');
}
```
## 方法参考
### 皮肤管理
```javascript
// 加载皮肤
skinViewer.loadSkin('path/to/skin.png');
// 加载皮肤并指定模型
skinViewer.loadSkin('path/to/skin.png', {
model: 'slim'
});
// 重置皮肤
skinViewer.resetSkin();
```
### 披风管理
```javascript
// 加载披风
skinViewer.loadCape('path/to/cape.png');
// 加载鞘翅(使用披风纹理)
skinViewer.loadCape('path/to/cape.png', {
backEquipment: 'elytra'
});
// 移除披风
skinViewer.loadCape(null);
```
### 尺寸调整
```javascript
// 调整查看器尺寸
skinViewer.setSize(600, 800);
// 调整宽度
skinViewer.width = 600;
// 调整高度
skinViewer.height = 800;
```
## 错误处理
项目中封装的 SkinViewer 组件包含了完整的错误处理:
```typescript
// 图片加载失败处理
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
// 图片加载成功初始化3D查看器
setImageLoaded(true);
};
img.onerror = (error) => {
// 图片加载失败,显示错误状态
setHasError(true);
};
```
## 最佳实践
### 1. 预加载图片
在初始化3D查看器之前先预加载皮肤图片以确保可访问性。
### 2. 性能优化
- 及时清理不再使用的查看器实例
- 避免同时运行多个动画
- 合理设置画布尺寸
### 3. 用户体验
- 提供加载状态指示
- 显示错误信息和重试选项
- 支持键盘快捷键控制
### 4. 响应式设计
监听容器尺寸变化,动态调整查看器大小:
```javascript
useEffect(() => {
if (viewerRef.current && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
viewerRef.current.setSize(rect.width, rect.height);
}
}, [imageLoaded]);
```
## 项目中的使用示例
查看 `[src/components/SkinDetailModal.tsx](src/components/SkinDetailModal.tsx)` 了解完整的皮肤预览实现,包括:
- 动画控制面板
- 键盘快捷键支持
- 最小化/最大化功能
- 响应式布局
- 错误处理和加载状态
## 相关资源
- [skinview3d GitHub 仓库](https://github.com/bs-community/skinview3d)
- [skinview3d 在线演示](https://skinview3d-demo.vercel.app)
- [Three.js 官方文档](https://threejs.org/docs/)

2
package-lock.json generated
View File

@@ -26,7 +26,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react": "^19.2.7",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.7",

View File

@@ -27,7 +27,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react": "^19.2.7",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.7",

View File

@@ -148,7 +148,7 @@ export default function EnhancedButton({
initial: { scale: 1 },
hover: {
scale: 1.02,
transition: { duration: 0.2, ease: "easeOut" }
transition: { duration: 0.2 }
},
tap: {
scale: 0.98,

View File

@@ -33,6 +33,7 @@ interface EnhancedInputProps {
pattern?: string;
validate?: (value: string) => string | null;
onValidationChange?: (isValid: boolean) => void;
size?: 'sm' | 'md' | 'lg';
}
const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
@@ -64,6 +65,7 @@ const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
pattern,
validate,
onValidationChange,
size = 'md',
...props
}, ref) => {
const [isFocused, setIsFocused] = useState(false);
@@ -151,11 +153,11 @@ const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
initial: { scale: 1 },
focus: {
scale: 1.02,
transition: { duration: 0.2, ease: "easeOut" }
transition: { duration: 0.2 }
},
blur: {
scale: 1,
transition: { duration: 0.2, ease: "easeOut" }
transition: { duration: 0.2 }
}
};
@@ -164,12 +166,12 @@ const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
focus: {
y: -20,
scale: 0.85,
transition: { duration: 0.2, ease: "easeOut" }
transition: { duration: 0.2 }
},
blur: {
y: internalValue ? -20 : 0,
scale: internalValue ? 0.85 : 1,
transition: { duration: 0.2, ease: "easeOut" }
transition: { duration: 0.2 }
}
};
@@ -259,7 +261,7 @@ const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
transition={{ duration: 1, repeat: Infinity }}
className="w-4 h-4 border-2 border-orange-500 border-t-transparent rounded-full"
/>
</motion.div>

View File

@@ -80,8 +80,7 @@ export function LoadingDots({
y: [-8, 0, -8],
transition: {
duration: 0.6,
repeat: Infinity,
ease: "easeInOut"
repeat: Infinity
}
}
};
@@ -132,8 +131,7 @@ export function Skeleton({
x: '100%',
transition: {
duration: 1.5,
repeat: Infinity,
ease: "linear"
repeat: Infinity
}
}
};
@@ -222,8 +220,7 @@ export function LoadingProgressBar({
initial={{ width: 0 }}
animate={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
transition={{
duration: animated ? 0.5 : 0,
ease: "easeOut"
duration: animated ? 0.5 : 0
}}
/>
</motion.div>
@@ -264,7 +261,6 @@ export function PulseLoader({
transition: {
duration: 1.5,
repeat: Infinity,
ease: "easeInOut"
}
}
};

View File

@@ -126,13 +126,13 @@ export default function Navbar() {
ref={navbarRef}
style={{
y: isHidden ? navbarY : 0,
opacity: navbarOpacity
opacity: navbarOpacity,
willChange: 'transform, opacity'
}}
initial={{ y: 0 }}
animate={{ y: isHidden ? -100 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
style={{ willChange: 'transform' }}
>
{/* 滚动进度条 */}
<motion.div

View File

@@ -78,8 +78,7 @@ export default function PageTransition({ children }: PageTransitionProps) {
rotateX: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
type: "spring",
type: "spring" as const,
stiffness: 100,
damping: 15
}
@@ -91,7 +90,6 @@ export default function PageTransition({ children }: PageTransitionProps) {
rotateX: 15,
transition: {
duration: 0.3,
ease: "easeIn"
}
}
};
@@ -109,7 +107,6 @@ export default function PageTransition({ children }: PageTransitionProps) {
y: 0,
transition: {
duration: 0.3,
ease: "easeOut"
}
},
exit: {
@@ -118,7 +115,6 @@ export default function PageTransition({ children }: PageTransitionProps) {
y: -20,
transition: {
duration: 0.2,
ease: "easeIn"
}
}
});
@@ -142,8 +138,8 @@ export default function PageTransition({ children }: PageTransitionProps) {
scale: [1, 1.1, 1]
}}
transition={{
rotate: { duration: 1, repeat: Infinity, ease: "linear" },
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
rotate: { duration: 1, repeat: Infinity },
scale: { duration: 1.5, repeat: Infinity }
}}
className="w-12 h-12 border-4 border-orange-500 border-t-transparent rounded-full mx-auto mb-4"
/>

View File

@@ -78,8 +78,7 @@ export default function SkinCard({
transition: {
duration: 0.6,
delay: index * 0.1,
ease: [0.25, 0.46, 0.45, 0.94],
type: "spring",
type: "spring" as const,
stiffness: 100,
damping: 15
}
@@ -90,7 +89,6 @@ export default function SkinCard({
rotateX: 5,
transition: {
duration: 0.3,
ease: "easeOut"
}
}
});
@@ -103,7 +101,7 @@ export default function SkinCard({
transition: {
duration: 0.2,
delay: 0.1,
type: "spring",
type: "spring" as const ,
stiffness: 300,
damping: 20
}
@@ -122,7 +120,7 @@ export default function SkinCard({
transition: {
duration: 0.3,
delay: 0.2 + index * 0.05,
type: "spring",
type: "spring" as const,
stiffness: 200,
damping: 15
}
@@ -163,8 +161,8 @@ export default function SkinCard({
scale: [1, 1.1, 1]
}}
transition={{
rotate: { duration: 2, repeat: Infinity, ease: "linear" },
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
rotate: { duration: 2, repeat: Infinity },
scale: { duration: 1.5, repeat: Infinity }
}}
className="w-12 h-12 border-4 border-orange-300 dark:border-orange-600 border-t-transparent rounded-full"
/>
@@ -183,21 +181,19 @@ export default function SkinCard({
} ${isHovered ? 'scale-110' : ''}`}
autoRotate={isHovered}
walking={false}
onLoad={() => setImageLoaded(true)}
onImageLoaded={() => setImageLoaded(true)}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<motion.div
className="text-center"
animate={isHovered ? { y: [-5, 5, -5] } : {}}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
>
<motion.div
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center"
whileHover={{ scale: 1.1, rotate: 10 }}
transition={{ type: 'spring', stiffness: 300 }}
transition={{ type: 'spring' as const, stiffness: 300 }}
animate={imageLoaded ? {} : { scale: [0.8, 1, 0.8] }}
transition={imageLoaded ? {} : { duration: 1.5, repeat: Infinity }}
>
<span className="text-2xl">🧥</span>
</motion.div>
@@ -232,7 +228,7 @@ export default function SkinCard({
</motion.div>
</motion.button>
{onDownload !== false && (
{onDownload && (
<motion.button
variants={getActionButtonVariants()}
initial="initial"
@@ -250,7 +246,7 @@ export default function SkinCard({
{isDownloading ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
transition={{ duration: 1, repeat: Infinity }}
>
<ArrowDownTrayIcon className="w-5 h-5" />
</motion.div>
@@ -288,7 +284,7 @@ export default function SkinCard({
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
transition={{ type: "spring" as const , stiffness: 300 }}
>
<HeartIconSolid className="w-5 h-5" />
</motion.div>
@@ -408,13 +404,13 @@ export default function SkinCard({
</motion.span>
</div>
<div className="text-xs text-gray-400">
{texture.uploader && (
{texture.uploader_id && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.6 }}
>
by {texture.uploader.username}
by User #{texture.uploader_id}
</motion.span>
)}
</div>

View File

@@ -109,8 +109,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
type: "spring",
type: "spring" as const,
stiffness: 100,
damping: 15
}
@@ -121,8 +120,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
rotateX: 15,
y: 50,
transition: {
duration: 0.3,
ease: "easeIn"
duration: 0.3
}
},
minimized: {
@@ -132,8 +130,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
width: 280,
height: 150,
transition: {
duration: 0.4,
ease: "easeInOut"
duration: 0.4
}
}
});
@@ -145,7 +142,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
opacity: 1,
transition: {
duration: 0.2,
type: "spring",
type: "spring" as const,
stiffness: 300,
damping: 20
}
@@ -162,7 +159,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
scale: 1.02,
transition: {
duration: 0.2,
type: "spring",
type: "spring" as const,
stiffness: 400,
damping: 25
}
@@ -195,14 +192,14 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
animate={isMinimized ? "minimized" : "animate"}
exit="exit"
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className={`relative bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-2xl overflow-hidden border border-white/20 dark:border-gray-700/50 backdrop-blur-lg ${
className={`relative bg-white/98 dark:bg-gray-800/98 rounded-2xl shadow-2xl overflow-hidden border border-white/40 dark:border-gray-700/60 backdrop-blur-2xl ${
isMinimized ? 'fixed bottom-4 right-4' : 'w-full max-w-6xl h-[90vh]'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<motion.div
className="absolute top-0 left-0 right-0 bg-gradient-to-r from-white/90 to-orange-50/90 dark:from-gray-800/90 dark:to-gray-700/90 backdrop-blur-md border-b border-orange-200/50 dark:border-gray-600/50 p-4 z-10"
className="absolute top-0 left-0 right-0 bg-gradient-to-r from-white/95 via-orange-50/95 to-amber-50/95 dark:from-gray-800/95 dark:via-gray-800/95 dark:to-gray-700/95 backdrop-blur-xl border-b border-orange-200/60 dark:border-gray-600/60 p-4 z-10 shadow-lg"
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
@@ -210,7 +207,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<motion.h2
className="text-2xl font-bold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent"
className="text-3xl font-bold bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 bg-clip-text text-transparent drop-shadow-sm"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
@@ -219,10 +216,10 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
</motion.h2>
<div className="flex items-center space-x-2">
<motion.span
className={`px-3 py-1 text-sm rounded-full font-medium ${
className={`px-4 py-2 text-sm rounded-full font-semibold shadow-md ${
texture.type === 'SKIN'
? 'bg-gradient-to-r from-orange-100 to-amber-100 text-orange-800 dark:from-orange-900/30 dark:to-amber-900/30 dark:text-orange-300'
: 'bg-gradient-to-r from-purple-100 to-pink-100 text-purple-800 dark:from-purple-900/30 dark:to-pink-900/30 dark:text-purple-300'
? 'bg-gradient-to-r from-orange-100 to-amber-100 text-orange-800 dark:from-orange-900/40 dark:to-amber-900/40 dark:text-orange-300 border border-orange-200/50 dark:border-orange-700/30'
: 'bg-gradient-to-r from-purple-100 to-pink-100 text-purple-800 dark:from-purple-900/40 dark:to-pink-900/40 dark:text-purple-300 border border-purple-200/50 dark:border-purple-700/30'
}`}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
@@ -231,12 +228,12 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
{texture.type === 'SKIN' ? '皮肤' : '披风'}
</motion.span>
{texture.is_slim && (
<motion.span
className="px-3 py-1 bg-gradient-to-r from-pink-100 to-rose-100 text-pink-800 dark:from-pink-900/30 dark:to-rose-900/30 text-sm rounded-full font-medium"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, type: "spring", stiffness: 200 }}
>
<motion.span
className="px-4 py-2 bg-gradient-to-r from-pink-100 to-rose-100 text-pink-800 dark:from-pink-900/40 dark:to-rose-900/40 text-sm rounded-full font-semibold shadow-md border border-pink-200/50 dark:border-pink-700/30"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, type: "spring", stiffness: 200 }}
>
</motion.span>
)}
@@ -259,9 +256,9 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
</motion.button>
<motion.button
onClick={onClose}
className="p-2 text-gray-500 hover:text-orange-500 dark:text-gray-400 dark:hover:text-orange-400 rounded-full hover:bg-orange-100 dark:hover:bg-orange-900/20 transition-all duration-200"
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="p-3 text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 rounded-full hover:bg-red-100 dark:hover:bg-red-900/30 transition-all duration-300 shadow-md hover:shadow-lg"
whileHover={{ scale: 1.15, rotate: 90 }}
whileTap={{ scale: 0.85 }}
initial={{ opacity: 0, rotate: -180 }}
animate={{ opacity: 1, rotate: 0 }}
transition={{ delay: 0.7 }}
@@ -276,7 +273,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
<div className="flex h-full pt-20">
{/* 3D 预览区域 */}
<motion.div
className="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-orange-50/30 to-amber-50/30 dark:from-gray-900/30 dark:to-gray-800/30"
className="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-orange-50/40 via-amber-50/40 to-yellow-50/40 dark:from-gray-900/40 dark:via-gray-800/40 dark:to-gray-700/40"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3, duration: 0.5 }}
@@ -284,20 +281,22 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
<div className="w-full h-full max-w-2xl max-h-2xl">
<motion.div
animate={{
rotateY: autoRotate ? 360 : 0,
scale: isPlaying ? 1.02 : 1
scale: isPlaying ? 1.02 : 1,
y: isPlaying ? -5 : 0
}}
transition={{
rotateY: { duration: 10, repeat: Infinity, ease: "linear" },
scale: { duration: 0.2 }
scale: { duration: 0.2 },
y: { duration: 0.2 }
}}
className="relative"
>
<div className="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-amber-400/20 dark:from-orange-500/10 dark:to-amber-500/10 rounded-2xl blur-xl"></div>
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={600}
height={600}
className="w-full h-full rounded-xl shadow-2xl border-2 border-white/50 dark:border-gray-700/50"
className="w-full h-full rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10"
autoRotate={autoRotate}
walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'}
@@ -310,7 +309,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
{/* 控制面板 */}
<motion.div
className="w-80 bg-gradient-to-b from-orange-50/80 to-amber-50/80 dark:from-gray-900/80 dark:to-gray-800/80 backdrop-blur-md border-l border-orange-200/50 dark:border-gray-600/50 p-6 space-y-6 overflow-y-auto custom-scrollbar"
className="w-80 bg-gradient-to-b from-orange-50/90 via-amber-50/90 to-yellow-50/90 dark:from-gray-900/90 dark:via-gray-800/90 dark:to-gray-700/90 backdrop-blur-xl border-l border-orange-200/60 dark:border-gray-600/60 p-6 space-y-6 overflow-y-auto custom-scrollbar shadow-inner"
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
@@ -318,12 +317,12 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
{/* 动画控制 */}
<motion.div className="space-y-4">
<motion.h3
className="text-lg font-semibold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent flex items-center"
className="text-xl font-bold bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 bg-clip-text text-transparent flex items-center drop-shadow-sm"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<PlayIcon className="w-5 h-5 mr-2" />
<PlayIcon className="w-6 h-6 mr-3" />
</motion.h3>
@@ -337,18 +336,17 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
<motion.button
key={anim.key}
variants={getAnimationButtonVariants(currentAnimation === anim.key)}
initial="initial"
animate={currentAnimation === anim.key ? "active" : "animate"}
whileHover="hover"
whileTap="tap"
onClick={() => handleAnimationChange(anim.key as any)}
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
className={`p-4 rounded-xl text-sm font-semibold transition-all duration-300 transform ${
currentAnimation === anim.key
? 'bg-gradient-to-r from-orange-500 to-amber-500 text-white shadow-lg transform scale-105'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-orange-100 dark:hover:bg-orange-900/20 border border-orange-200 dark:border-gray-600 hover:border-orange-300 dark:hover:border-orange-500'
? 'bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 text-white shadow-2xl scale-105 ring-2 ring-orange-300 dark:ring-orange-500'
: 'bg-white/80 dark:bg-gray-700/80 text-gray-700 dark:text-gray-300 hover:bg-orange-50 dark:hover:bg-orange-900/30 border border-orange-200/50 dark:border-gray-600 hover:border-orange-300 dark:hover:border-orange-400 hover:scale-105 hover:shadow-lg'
}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 + i * 0.1 }}
>
{anim.label}
@@ -364,18 +362,18 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
<h3 className="text-lg font-semibold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
<h3 className="text-xl font-bold bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 bg-clip-text text-transparent drop-shadow-sm">
</h3>
{texture.description && (
<motion.div
className="bg-white/50 dark:bg-gray-700/50 rounded-lg p-3"
className="bg-white/70 dark:bg-gray-700/70 rounded-xl p-4 shadow-md border border-white/30 dark:border-gray-600/30"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 }}
>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed font-medium">
{texture.description}
</p>
</motion.div>
@@ -432,13 +430,21 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
{/* 快捷键提示 */}
<motion.div
className="bg-white/30 dark:bg-gray-700/30 rounded-lg p-3 text-xs text-gray-600 dark:text-gray-400"
className="bg-gradient-to-r from-orange-100/50 to-amber-100/50 dark:from-gray-700/50 dark:to-gray-600/50 rounded-xl p-4 text-xs text-gray-600 dark:text-gray-400 border border-orange-200/30 dark:border-gray-600/30"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.4 }}
>
<p className="font-medium mb-1">:</p>
<p> - / | 1-4 - | M - | ESC - </p>
<p className="font-bold mb-2 text-orange-700 dark:text-orange-300 flex items-center">
<span className="w-2 h-2 bg-orange-500 rounded-full mr-2"></span>
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2"></kbd>/</div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2">1-4</kbd></div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2">M</kbd></div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2">ESC</kbd></div>
</div>
</motion.div>
</motion.div>
</div>
@@ -453,11 +459,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
transition={{ delay: 0.2 }}
>
<div className="text-center">
<motion.div
animate={{ rotate: autoRotate ? 360 : 0 }}
transition={{ rotate: { duration: 5, repeat: Infinity, ease: "linear" } }}
className="w-20 h-20 mx-auto mb-2"
>
<div className="w-20 h-20 mx-auto mb-2">
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
@@ -468,7 +470,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
running={currentAnimation === 'running'}
jumping={currentAnimation === 'swimming'}
/>
</motion.div>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{texture.name}
</p>

View File

@@ -16,6 +16,7 @@ interface SkinViewerProps {
jumping?: boolean; // 新增:跳跃动画
rotation?: boolean; // 新增:旋转控制
isExternalPreview?: boolean; // 新增:是否为外部预览
onImageLoaded?: () => void; // 新增:图片加载完成回调
}
export default function SkinViewer({
@@ -31,6 +32,7 @@ export default function SkinViewer({
jumping = false,
rotation = true,
isExternalPreview = false, // 新增默认为false
onImageLoaded, // 新增:图片加载完成回调
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<SkinViewer3D | null>(null);
@@ -53,6 +55,9 @@ export default function SkinViewer({
console.log('皮肤图片加载成功:', skinUrl);
setImageLoaded(true);
setIsLoading(false);
if (onImageLoaded) {
onImageLoaded();
}
};
img.onerror = (error) => {