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:
318
docs/skinview3d-usage.md
Normal file
318
docs/skinview3d-usage.md
Normal 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
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -147,15 +147,15 @@ export default function EnhancedButton({
|
||||
const buttonVariants = {
|
||||
initial: { scale: 1 },
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.2, ease: "easeOut" }
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.2 }
|
||||
},
|
||||
tap: {
|
||||
scale: 0.98,
|
||||
scale: 0.98,
|
||||
transition: { duration: 0.1 }
|
||||
},
|
||||
loading: {
|
||||
scale: 0.95,
|
||||
loading: {
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,23 +181,21 @@ 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>
|
||||
<span className="text-2xl">🧥</span>
|
||||
</motion.div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium">披风</p>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user