import * as THREE from "three"; // ─── Easing 函数库 ─── export type EasingFn = (t: number) => number; export const Easing = { linear: (t: number) => t, easeOutQuad: (t: number) => 1 - (1 - t) * (1 - t), easeInOutQuad: (t: number) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2, easeOutCubic: (t: number) => 1 - Math.pow(1 - t, 3), easeInOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2, easeOutBack: (t: number) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, }; // ─── 动画参数接口 ─── export interface SlideOutParams { /** 要滑出的物体列表 */ objects: THREE.Object3D[]; /** 滑出方向 (dx 控制 X 轴, dz 控制 Z 轴) */ direction: { dx: number; dz: number }; /** 滑出总距离(世界单位) */ distance: number; /** 动画时长 ms(默认 350) */ duration?: number; /** 缓动函数(默认 easeOutQuad) */ easing?: EasingFn; /** 动画完成回调 */ onComplete?: () => void; } export interface FlyUpParams { /** 要飞起的物体 */ object: THREE.Object3D; /** 飞起高度(世界单位,默认 3) */ height?: number; /** 前移距离(Z 方向,默认 1.5) */ forwardDistance?: number; /** 动画时长 ms(默认 400) */ duration?: number; /** 是否渐隐(默认 true) */ fadeOut?: boolean; /** 缓动函数(默认 easeInOutQuad) */ easing?: EasingFn; /** 动画完成回调 */ onComplete?: () => void; } /** * Three.js 3D 路径动画管理器 * * 基于 requestAnimationFrame 的轻量动画系统(零第三方依赖)。 * 提供棋盘游戏常用的动画操作: * - slideOut:物体沿方向滑出(路径推出) * - flyUp:物体升起并渐隐消失(飞入篮子视觉) * - shake:物体抖动反馈(被阻挡) * - scaleOut:物体缩放消失(三消消除) * * 支持物体从 parent group 中 detach 后独立动画(不受 group shake 影响)。 */ export class AnimationManager3D { private scene: THREE.Scene; private activeRAFs: Set = new Set(); private detachedObjects: THREE.Object3D[] = []; constructor(scene: THREE.Scene) { this.scene = scene; } /** * 沿方向滑出一组物体(保持相对位置)。 * 动画期间物体会被从当前 parent detach 到 scene 根节点。 */ slideOut(params: SlideOutParams): void { const { objects, direction, distance, duration = 350, easing = Easing.easeOutQuad, onComplete, } = params; if (objects.length === 0) { onComplete?.(); return; } // Detach objects from their parent, preserve world position const worldPositions = objects.map((obj) => { const wp = new THREE.Vector3(); obj.getWorldPosition(wp); return wp; }); for (let i = 0; i < objects.length; i++) { const obj = objects[i]; obj.parent?.remove(obj); obj.position.copy(worldPositions[i]); this.scene.add(obj); this.detachedObjects.push(obj); } // Animate const startTime = performance.now(); const animate = () => { const elapsed = performance.now() - startTime; const progress = Math.min(elapsed / duration, 1); const eased = easing(progress); const offsetX = direction.dx * distance * eased; const offsetZ = direction.dz * distance * eased; for (let i = 0; i < objects.length; i++) { objects[i].position.x = worldPositions[i].x + offsetX; objects[i].position.z = worldPositions[i].z + offsetZ; } if (progress < 1) { const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } else { // 动画完成,移除物体 for (const obj of objects) { this.scene.remove(obj); this.removeFromDetached(obj); } onComplete?.(); } }; const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } /** * 物体升起并渐隐消失(飞入篮子 / 收集效果) */ flyUp(params: FlyUpParams): void { const { object, height = 3, forwardDistance = 1.5, duration = 400, fadeOut = true, easing = Easing.easeInOutQuad, onComplete, } = params; if (!object.parent) { onComplete?.(); return; } const startPos = object.position.clone(); const startScale = object.scale.x; const startTime = performance.now(); const animate = () => { const elapsed = performance.now() - startTime; const progress = Math.min(elapsed / duration, 1); const eased = easing(progress); // Move up + forward object.position.y = startPos.y + eased * height; object.position.z = startPos.z + eased * forwardDistance; // Scale down const scale = startScale * (1 - eased * 0.8); object.scale.set(scale, scale, scale); // Fade out if (fadeOut) { object.traverse((child) => { if (child instanceof THREE.Mesh) { const mat = child.material as THREE.MeshStandardMaterial; if (!mat.transparent) mat.transparent = true; mat.opacity = 1 - eased; } }); } if (progress < 1) { const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } else { object.parent?.remove(object); this.removeFromDetached(object); onComplete?.(); } }; const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } /** * 物体抖动反馈(被阻挡效果) */ shake( object: THREE.Object3D, intensity: number = 0.08, duration: number = 300, ): void { const originalX = object.position.x; const originalZ = object.position.z; const startTime = performance.now(); const animate = () => { const elapsed = performance.now() - startTime; const progress = Math.min(elapsed / duration, 1); if (progress < 1) { const decay = 1 - progress; object.position.x = originalX + Math.sin(progress * Math.PI * 6) * intensity * decay; object.position.z = originalZ + Math.cos(progress * Math.PI * 4) * intensity * decay * 0.5; const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } else { object.position.x = originalX; object.position.z = originalZ; } }; const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } /** * 物体缩放消失(三消消除效果) */ scaleOut( object: THREE.Object3D, duration: number = 300, onComplete?: () => void, ): void { const startScale = object.scale.x; const startTime = performance.now(); const animate = () => { const elapsed = performance.now() - startTime; const progress = Math.min(elapsed / duration, 1); // Scale up briefly then shrink to 0 let scale: number; if (progress < 0.3) { scale = startScale * (1 + (progress / 0.3) * 0.3); // expand to 1.3x } else { const t = (progress - 0.3) / 0.7; scale = startScale * 1.3 * (1 - t); // shrink to 0 } object.scale.set(scale, scale, scale); // Fade object.traverse((child) => { if (child instanceof THREE.Mesh) { const mat = child.material as THREE.MeshStandardMaterial; if (!mat.transparent) mat.transparent = true; mat.opacity = 1 - progress; } }); if (progress < 1) { const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } else { object.parent?.remove(object); this.removeFromDetached(object); onComplete?.(); } }; const id = requestAnimationFrame(animate); this.activeRAFs.add(id); } /** * 取消所有进行中的动画 */ cancelAll(): void { for (const id of this.activeRAFs) { cancelAnimationFrame(id); } this.activeRAFs.clear(); } /** * 清理所有被 detach 的物体(用于关卡重置) */ cleanupDetached(): void { for (const obj of this.detachedObjects) { this.scene.remove(obj); } this.detachedObjects = []; } /** * 销毁 */ dispose(): void { this.cancelAll(); this.cleanupDetached(); } // ── 内部方法 ── private removeFromDetached(obj: THREE.Object3D): void { const idx = this.detachedObjects.indexOf(obj); if (idx >= 0) this.detachedObjects.splice(idx, 1); } }