import * as THREE from "three"; export interface TrayItem3D { id: string; color: string; } export interface TrayConfig3D { capacity: number; cellSize: number; // 棋盘格子大小(用于计算篮子尺寸) matchDuration?: number; // 消除动画时长 ms,默认 300 rearrangeDuration?: number; // 重排动画时长 ms,默认 200 shakeIntensity?: number; // 抖动强度,默认 0.1 colorToHex: (color: string) => number; // 颜色标识 → Three.js hex 颜色 } /** * Three.js 场景内 Mesh 模式的篮子/托盘渲染器。 * 在 3D 场景中用 Mesh 排列表示收集的物品。 * 适用于需要完全沉浸式 3D 体验的项目。 */ export class TrayRenderer3D { private scene: THREE.Scene; private config: Required; private group!: THREE.Group; private bgMesh!: THREE.Mesh; private items: Map = new Map(); private slotPositions: THREE.Vector3[] = []; // 动画状态 private animations: Array<{ mesh: THREE.Mesh; startPos: THREE.Vector3; endPos: THREE.Vector3; startScale: number; endScale: number; startOpacity: number; endOpacity: number; elapsed: number; duration: number; onComplete?: () => void; }> = []; // 震动状态 private shakeElapsed = 0; private shakeDuration = 0; private baseGroupX = 0; constructor(scene: THREE.Scene, config: TrayConfig3D) { this.scene = scene; this.config = { capacity: config.capacity, cellSize: config.cellSize, matchDuration: config.matchDuration ?? 300, rearrangeDuration: config.rearrangeDuration ?? 200, shakeIntensity: config.shakeIntensity ?? 0.1, colorToHex: config.colorToHex, }; } /** * 初始化篮子:创建背景和槽位 * @param position 篮子中心的世界坐标 */ setup(position: THREE.Vector3): void { this.group = new THREE.Group(); this.group.position.copy(position); this.baseGroupX = position.x; const slotSize = this.config.cellSize * 0.6; const gap = slotSize * 0.2; const totalWidth = this.config.capacity * slotSize + (this.config.capacity - 1) * gap; const height = slotSize * 1.2; // 创建背景板 const bgGeo = new THREE.BoxGeometry(totalWidth + slotSize * 0.4, 0.1, height); const bgMat = new THREE.MeshStandardMaterial({ color: 0x16213e, transparent: true, opacity: 0.9, roughness: 0.8, }); this.bgMesh = new THREE.Mesh(bgGeo, bgMat); this.bgMesh.position.y = -0.05; this.bgMesh.receiveShadow = true; this.group.add(this.bgMesh); // 计算每个槽位的局部位置 const startX = -(totalWidth / 2) + slotSize / 2; for (let i = 0; i < this.config.capacity; i++) { const x = startX + i * (slotSize + gap); this.slotPositions.push(new THREE.Vector3(x, slotSize * 0.3, 0)); } this.scene.add(this.group); } /** * 获取指定槽位的世界坐标 */ getSlotWorldPos(index: number): THREE.Vector3 { if (index < 0 || index >= this.slotPositions.length) { return this.group.position.clone(); } const local = this.slotPositions[index]; return local.clone().applyMatrix4(this.group.matrixWorld); } /** * 添加物品到指定槽位 */ addItem(item: TrayItem3D, index: number): THREE.Mesh { const slotSize = this.config.cellSize * 0.6; const itemSize = slotSize * 0.7; // 创建物品 Mesh(球体表示) const geo = new THREE.SphereGeometry(itemSize / 2, 16, 12); const mat = new THREE.MeshStandardMaterial({ color: this.config.colorToHex(item.color), transparent: true, opacity: 1, roughness: 0.4, metalness: 0.1, }); const mesh = new THREE.Mesh(geo, mat); mesh.castShadow = true; // 设置到目标槽位 const targetPos = this.slotPositions[index] || new THREE.Vector3(); mesh.position.copy(targetPos); mesh.scale.setScalar(0); // 初始缩放为 0(弹入动画) this.group.add(mesh); this.items.set(item.id, mesh); // 弹入动画 this.animations.push({ mesh, startPos: targetPos.clone(), endPos: targetPos.clone(), startScale: 0, endScale: 1, startOpacity: 1, endOpacity: 1, elapsed: 0, duration: this.config.rearrangeDuration / 1000, }); return mesh; } /** * 重新排列所有物品到正确位置 */ rearrangeItems(tray: TrayItem3D[]): void { for (let i = 0; i < tray.length; i++) { const mesh = this.items.get(tray[i].id); if (!mesh) continue; const targetPos = this.slotPositions[i]; if (!targetPos) continue; // 如果位置已经正确,跳过 if (mesh.position.distanceTo(targetPos) < 0.001) continue; this.animations.push({ mesh, startPos: mesh.position.clone(), endPos: targetPos.clone(), startScale: 1, endScale: 1, startOpacity: 1, endOpacity: 1, elapsed: 0, duration: this.config.rearrangeDuration / 1000, }); } } /** * 消除匹配的物品(放大 + 淡出) */ removeMatchedItems(matchedIds: string[], onComplete: () => void): void { let completed = 0; const total = matchedIds.length; for (const id of matchedIds) { const mesh = this.items.get(id); if (!mesh) { completed++; if (completed >= total) onComplete(); continue; } this.animations.push({ mesh, startPos: mesh.position.clone(), endPos: mesh.position.clone(), startScale: 1, endScale: 1.5, startOpacity: 1, endOpacity: 0, elapsed: 0, duration: this.config.matchDuration / 1000, onComplete: () => { this.group.remove(mesh); mesh.geometry.dispose(); (mesh.material as THREE.Material).dispose(); this.items.delete(id); completed++; if (completed >= total) onComplete(); }, }); } } /** * 满溢抖动 */ shake(): void { this.shakeElapsed = 0; this.shakeDuration = 0.3; // 300ms } /** * 每帧更新(处理动画和震动) * @param dt 帧间隔时间(秒) */ update(dt: number): void { // 更新物品动画 for (let i = this.animations.length - 1; i >= 0; i--) { const anim = this.animations[i]; anim.elapsed += dt; const t = Math.min(anim.elapsed / anim.duration, 1); // easeOutBack for scale, easeOut for position const easeT = 1 - Math.pow(1 - t, 3); anim.mesh.position.lerpVectors(anim.startPos, anim.endPos, easeT); const scale = anim.startScale + (anim.endScale - anim.startScale) * easeT; anim.mesh.scale.setScalar(scale); const mat = anim.mesh.material as THREE.MeshStandardMaterial; if (mat.transparent) { mat.opacity = anim.startOpacity + (anim.endOpacity - anim.startOpacity) * easeT; } if (t >= 1) { anim.onComplete?.(); this.animations.splice(i, 1); } } // 更新震动 if (this.shakeElapsed < this.shakeDuration) { this.shakeElapsed += dt; const progress = this.shakeElapsed / this.shakeDuration; const decay = 1 - progress; const offset = Math.sin(progress * Math.PI * 6) * this.config.shakeIntensity * decay; this.group.position.x = this.baseGroupX + offset; } else if (this.group.position.x !== this.baseGroupX) { this.group.position.x = this.baseGroupX; } } /** * 销毁所有资源 */ destroy(): void { this.items.forEach((mesh) => { mesh.geometry.dispose(); (mesh.material as THREE.Material).dispose(); }); this.items.clear(); this.animations = []; if (this.bgMesh) { this.bgMesh.geometry.dispose(); (this.bgMesh.material as THREE.Material).dispose(); } this.scene.remove(this.group); } }