516 lines
17 KiB
JavaScript
516 lines
17 KiB
JavaScript
/**
|
||
* 汽车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();
|
||
});
|