/** * 汽车360°全景展示应用 * 支持触摸滑动、自动旋转、惯性效果 */ class Car360Viewer { constructor() { // 配置 this.config = { totalFrames: 400, // 总帧数 framesPerSwipe: 1, // 每像素滑动切换的帧数 inertiaFriction: 0.95, // 惯性摩擦系数 minVelocity: 0.1, // 最小速度阈值 autoRotateSpeed: 80, // 自动旋转速度(ms/帧) autoRotateDelay: 3000, // 停止操作后自动旋转延迟(ms) imagePath: './images/', // 图片路径 imagePrefix: 'car_', // 图片前缀 imageExt: '.jpg' // 图片扩展名 }; // 状态 this.state = { currentFrame: 0, isDragging: false, startX: 0, lastX: 0, velocity: 0, images: [], loaded: false, animationId: null, autoRotateTimer: null, autoRotateInterval: null }; // DOM元素 this.elements = { canvas: document.getElementById('carCanvas'), loadingScreen: document.getElementById('loadingScreen'), loadingProgress: document.getElementById('loadingProgress'), loadingText: document.getElementById('loadingText') }; this.ctx = this.elements.canvas.getContext('2d'); this.init(); } init() { this.setupCanvas(); this.bindEvents(); this.loadImages(); } setupCanvas() { const rect = this.elements.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; this.elements.canvas.width = rect.width * dpr; this.elements.canvas.height = rect.height * dpr; this.ctx.scale(dpr, dpr); // 监听窗口大小变化 window.addEventListener('resize', () => { const newRect = this.elements.canvas.getBoundingClientRect(); this.elements.canvas.width = newRect.width * dpr; this.elements.canvas.height = newRect.height * dpr; this.ctx.scale(dpr, dpr); this.renderFrame(this.state.currentFrame); }); } bindEvents() { const viewer = this.elements.canvas; // 触摸事件 viewer.addEventListener('touchstart', this.handleStart.bind(this), { passive: false }); viewer.addEventListener('touchmove', this.handleMove.bind(this), { passive: false }); viewer.addEventListener('touchend', this.handleEnd.bind(this), { passive: false }); } handleStart(e) { e.preventDefault(); this.stopInertia(); this.stopAutoRotate(); const touch = e.touches[0]; this.state.isDragging = true; this.state.startX = touch.clientX; this.state.lastX = touch.clientX; this.state.velocity = 0; } handleMove(e) { if (!this.state.isDragging) return; e.preventDefault(); const touch = e.touches[0]; const deltaX = touch.clientX - this.state.lastX; // 计算速度 this.state.velocity = deltaX; // 根据滑动距离更新帧 const frameDelta = Math.round(deltaX / 5); if (frameDelta !== 0) { this.state.lastX = touch.clientX; this.updateFrame(frameDelta); } } handleEnd(e) { if (!this.state.isDragging) return; this.state.isDragging = false; // 启动惯性动画 if (Math.abs(this.state.velocity) > this.config.minVelocity) { this.startInertia(); } else { // 没有惯性,延迟启动自动旋转 this.scheduleAutoRotate(); } } startInertia() { const animate = () => { this.state.velocity *= this.config.inertiaFriction; if (Math.abs(this.state.velocity) > this.config.minVelocity) { const frameDelta = Math.round(this.state.velocity / 5); if (frameDelta !== 0) { this.updateFrame(frameDelta); } this.state.animationId = requestAnimationFrame(animate); } else { // 惯性结束,启动自动旋转 this.scheduleAutoRotate(); } }; this.state.animationId = requestAnimationFrame(animate); } stopInertia() { if (this.state.animationId) { cancelAnimationFrame(this.state.animationId); this.state.animationId = null; } this.state.velocity = 0; } updateFrame(delta) { let newFrame = this.state.currentFrame + delta; // 循环处理 while (newFrame < 0) newFrame += this.config.totalFrames; newFrame = newFrame % this.config.totalFrames; this.state.currentFrame = newFrame; this.renderFrame(newFrame); } async loadImages() { // 首先尝试加载文件系统图片 try { const images = []; let loadedCount = 0; this.updateLoadingProgress(0, '正在加载图片资源...'); for (let i = 0; i < this.config.totalFrames; i++) { const img = new Image(); const frameNum = String(i + 1).padStart(3, '0'); img.src = `${this.config.imagePath}${this.config.imagePrefix}${frameNum}${this.config.imageExt}`; await new Promise((resolve, reject) => { img.onload = () => { loadedCount++; const progress = Math.round((loadedCount / this.config.totalFrames) * 100); this.updateLoadingProgress(progress, `加载中 ${loadedCount}/${this.config.totalFrames}`); resolve(img); }; img.onerror = () => reject(new Error(`Failed to load frame ${i}`)); }); images.push(img); } this.updateLoadingProgress(100, '加载完成'); this.state.images = images; this.state.loaded = true; setTimeout(() => { this.hideLoading(); this.renderFrame(0); }, 500); console.log('Loaded', images.length, 'frames from files'); return; } catch (error) { console.log('File load failed:', error.message); } // 尝试从 IndexedDB 加载 this.updateLoadingProgress(0, '正在从缓存加载...'); const indexedDBFrames = await this.loadFromIndexedDB(); if (indexedDBFrames && indexedDBFrames.length > 0) { this.state.images = indexedDBFrames; this.state.loaded = true; this.config.totalFrames = indexedDBFrames.length; this.updateLoadingProgress(100, '加载完成'); setTimeout(() => { this.hideLoading(); this.renderFrame(0); }, 500); return; } // 演示模式 console.log('Using demo mode'); this.updateLoadingProgress(100, '进入演示模式'); setTimeout(() => { this.hideLoading(); this.startDemoMode(); }, 500); } updateLoadingProgress(percent, text) { if (this.elements.loadingProgress) { this.elements.loadingProgress.style.width = `${percent}%`; } if (this.elements.loadingText) { this.elements.loadingText.textContent = text; } } hideLoading() { if (this.elements.loadingScreen) { this.elements.loadingScreen.classList.add('hidden'); } // 加载完成后启动自动旋转 this.startAutoRotate(); } scheduleAutoRotate() { // 清除之前的定时器 if (this.state.autoRotateTimer) { clearTimeout(this.state.autoRotateTimer); } // 延迟启动自动旋转 this.state.autoRotateTimer = setTimeout(() => { this.startAutoRotate(); }, this.config.autoRotateDelay); } startAutoRotate() { // 清除之前的自动旋转 this.stopAutoRotate(); // 启动新的自动旋转 this.state.autoRotateInterval = setInterval(() => { this.updateFrame(1); }, this.config.autoRotateSpeed); } stopAutoRotate() { if (this.state.autoRotateTimer) { clearTimeout(this.state.autoRotateTimer); this.state.autoRotateTimer = null; } if (this.state.autoRotateInterval) { clearInterval(this.state.autoRotateInterval); this.state.autoRotateInterval = null; } } loadFromIndexedDB() { return new Promise((resolve) => { const request = indexedDB.open('Car360DB', 1); request.onerror = () => { console.log('IndexedDB error'); resolve(null); }; request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('frames')) { db.createObjectStore('frames', { keyPath: 'id' }); } }; request.onsuccess = async () => { const db = request.result; try { if (!db.objectStoreNames.contains('frames')) { db.close(); resolve(null); return; } const tx = db.transaction('frames', 'readonly'); const store = tx.objectStore('frames'); const getRequest = store.get('frames'); getRequest.onsuccess = async () => { if (getRequest.result && getRequest.result.data && getRequest.result.data.length > 0) { console.log('Loading', getRequest.result.data.length, 'frames from IndexedDB'); const images = getRequest.result.data.map(dataUrl => { const img = new Image(); img.src = dataUrl; return img; }); await Promise.all(images.map(img => new Promise(res => { img.onload = () => res(); img.onerror = () => res(); }) )); db.close(); resolve(images); } else { console.log('No frames data in IndexedDB'); db.close(); resolve(null); } }; getRequest.onerror = () => { console.log('Failed to get frames from IndexedDB'); db.close(); resolve(null); }; } catch (e) { console.log('IndexedDB exception:', e); db.close(); resolve(null); } }; }); } startDemoMode() { // 演示模式:生成占位图形 this.state.loaded = true; this.renderFrame(0); } renderFrame(frameIndex) { const canvas = this.elements.canvas; const ctx = this.ctx; const rect = canvas.getBoundingClientRect(); const width = rect.width; const height = rect.height; // 清空画布 ctx.clearRect(0, 0, width, height); const img = this.state.images[frameIndex]; if (img && img.complete && img.naturalWidth > 0) { // 绘制真实图片 const scale = Math.max(width / img.naturalWidth, height / img.naturalHeight); const x = (width - img.naturalWidth * scale) / 2; const y = (height - img.naturalHeight * scale) / 2; ctx.drawImage(img, x, y, img.naturalWidth * scale, img.naturalHeight * scale); } else { // 演示模式:绘制占位图形 this.drawDemoFrame(ctx, width, height, frameIndex); } } drawDemoFrame(ctx, width, height, frameIndex) { const angle = (frameIndex / this.config.totalFrames) * Math.PI * 2; // 背景渐变 const gradient = ctx.createLinearGradient(0, 0, width, height); gradient.addColorStop(0, '#1a1a2e'); gradient.addColorStop(1, '#16213e'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, height); // 网格效果 ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)'; ctx.lineWidth = 1; const gridSize = 30; for (let x = 0; x < width; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } for (let y = 0; y < height; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } // 绘制示意汽车(简化的3D效果) const centerX = width / 2; const centerY = height / 2; // 车身主体 ctx.save(); ctx.translate(centerX, centerY); // 阴影 ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.beginPath(); ctx.ellipse(0, height * 0.25, width * 0.35, height * 0.08, 0, 0, Math.PI * 2); ctx.fill(); // 车身渐变 const bodyGradient = ctx.createLinearGradient(0, -height * 0.3, 0, height * 0.2); bodyGradient.addColorStop(0, '#4a4a5a'); bodyGradient.addColorStop(0.5, '#2a2a3a'); bodyGradient.addColorStop(1, '#1a1a2a'); // 车身形状(根据角度变化) const perspective = Math.cos(angle); const bodyWidth = width * 0.35 * Math.abs(perspective) + width * 0.15; const bodyHeight = height * 0.18; ctx.fillStyle = bodyGradient; ctx.beginPath(); ctx.roundRect(-bodyWidth, -bodyHeight, bodyWidth * 2, bodyHeight * 2, 10); ctx.fill(); // 车顶 const roofGradient = ctx.createLinearGradient(0, -height * 0.35, 0, -height * 0.15); roofGradient.addColorStop(0, '#5a5a6a'); roofGradient.addColorStop(1, '#3a3a4a'); ctx.fillStyle = roofGradient; ctx.beginPath(); ctx.roundRect(-bodyWidth * 0.7, -height * 0.35, bodyWidth * 1.4, height * 0.2, [15, 15, 5, 5]); ctx.fill(); // 车窗 ctx.fillStyle = 'rgba(0, 212, 255, 0.3)'; ctx.beginPath(); const windowWidth = bodyWidth * 0.5; ctx.roundRect(-windowWidth, -height * 0.32, windowWidth * 2, height * 0.12, 5); ctx.fill(); // 车灯光效 const lightIntensity = 0.5 + Math.sin(angle * 2) * 0.3; // 左车灯 const leftLightGradient = ctx.createRadialGradient( -bodyWidth * 0.8, 0, 0, -bodyWidth * 0.8, 0, 30 ); leftLightGradient.addColorStop(0, `rgba(255, 220, 100, ${lightIntensity})`); leftLightGradient.addColorStop(1, 'rgba(255, 220, 100, 0)'); ctx.fillStyle = leftLightGradient; ctx.beginPath(); ctx.arc(-bodyWidth * 0.8, 0, 30, 0, Math.PI * 2); ctx.fill(); // 右车灯 const rightLightGradient = ctx.createRadialGradient( bodyWidth * 0.8, 0, 0, bodyWidth * 0.8, 0, 30 ); rightLightGradient.addColorStop(0, `rgba(255, 100, 100, ${lightIntensity})`); rightLightGradient.addColorStop(1, 'rgba(255, 100, 100, 0)'); ctx.fillStyle = rightLightGradient; ctx.beginPath(); ctx.arc(bodyWidth * 0.8, 0, 30, 0, Math.PI * 2); ctx.fill(); // 车轮 ctx.fillStyle = '#1a1a2a'; ctx.strokeStyle = '#3a3a4a'; ctx.lineWidth = 3; // 左轮 ctx.beginPath(); ctx.ellipse(-bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // 右轮 ctx.beginPath(); ctx.ellipse(bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore(); // 帧号显示 ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; ctx.font = '14px monospace'; ctx.textAlign = 'center'; ctx.fillText(`帧 ${frameIndex + 1}/${this.config.totalFrames}`, centerX, height - 20); // 演示模式提示 ctx.fillStyle = 'rgba(0, 212, 255, 0.6)'; ctx.font = '12px sans-serif'; ctx.fillText('演示模式 - 请添加实际序列帧图片到 images 文件夹', centerX, 30); } } // 初始化应用 document.addEventListener('DOMContentLoaded', () => { window.carViewer = new Car360Viewer(); });