演唱会3D选座网页(HTML 开源)

演唱会3D选座网页(HTML 开源)

演唱会 3D 选座系统

一个基于 Three.js 构建的交互式 3D 演唱会选座系统,模拟"中心旋转舞台"演唱会场馆,提供沉浸式的座位预览与选择体验。

项目亮点 ✨

  • 三维可交互场馆:通过 3D 建模展示具有中心旋转舞台的演唱会场馆全貌,支持旋转、缩放和平移。

  • 真实分区与定价:按照真实演唱会布局,划分内场区(A, B, C)及看台区(200/300/400),每个区域对应不同颜色与票价。

  • 动态选座面板:点击任何区域,右侧会滑出该区域的详细座位图,支持在座位图上直接点选/取消座位。

  • 实时购物车:右上角实时显示已选座位数量与总金额,支持结算。

  • 响应式设计:适配不同屏幕尺寸,交互流畅。

技术栈 🛠️

  • **Three.js (r128)**​ - 3D 图形渲染核心

  • GSAP 3​ - 用于面板动画

  • Tailwind CSS​ - 用于快速构建现代化 UI

  • Vanilla JavaScript​ - 核心交互逻辑

如何使用 🚀

  1. 获取代码

    复制代码
    git clone <repository-url>

    或直接下载 index.html文件。

  2. 运行项目

    由于是纯前端项目,你只需要在浏览器中打开 index.html文件即可运行。

    • 推荐使用现代浏览器(Chrome, Edge, Firefox, Safari)以获得最佳体验。

    • 确保网络连接正常,以加载在线的 CDN 资源(Three.js, GSAP 等)。

  3. 交互指南

    • 操作 3D 场景:鼠标左键拖拽旋转视角,滚轮缩放,右键拖拽平移。

    • 选择区域 :将鼠标悬停在 3D 场景中的任何区域(如A1区或201看台)上,会显示提示信息。单击该区域,右侧将展开该区域的详细选座面板。

    • 选择座位:在右侧面板的座位网格中,点击任意座位即可将其加入购物车(变为绿色)。再次点击可取消选择。每单最多选择 6 个座位。

    • 下单:选座后,点击右上角的"立即下单"按钮模拟提交订单。

文件结构 📁

复制代码
encore-3d-seat-selection/
│
├── index.html              # 主HTML文件,包含所有HTML、CSS和JavaScript代码
├── README.md               # 本说明文件
│
└── (该项目为单文件实现,所有代码、样式和逻辑均内联在 index.html 中)

核心功能详解 🔍

  1. 3D 场馆构建

    • 中心圆形舞台带有装饰性圆环。

    • 内场区(A/B/C)为矩形区块,采用统一底色。

    • 看台区(200/300/400)为环绕舞台的弧形区块,通过算法生成,颜色和高度随半径变化,呈现立体看台效果。

  2. 选座逻辑

    • 座位数据在 seatData数组中定义,包含 ID、位置、价格等信息。

    • 通过光线投射(Raycaster)实现 3D 物体与鼠标的交互检测。

    • 购物车状态在 cart数组中维护,并实时更新 UI。

  3. 用户界面

    • 顶部中央显示演出信息。

    • 右上角固定显示购物车摘要。

    • 鼠标悬停时显示座位信息提示框。

    • 右侧滑动面板提供详细的座位选择界面。

备注 📝

  • 本项目为前端演示原型,座位数据为静态模拟,下单功能仅为前端交互演示,不与后端服务连接。

  • 3D 模型的精细度和灯光效果可根据实际需求进一步优化。

  • 可轻松扩展功能,如连接真实座位库存 API、增加座位状态(已售/锁定)、支持选座连选等。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>演唱会 3D 选座系统</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
    <style>
        body { margin: 0; overflow: hidden; background: #e2e8f0; font-family: 'Inter', sans-serif; }
        #canvas-container { width: 100vw; height: 100vh; }
        .glass-panel { background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.3); box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
        .seat-mini { width: 28px; height: 28px; border-radius: 4px; cursor: pointer; transition: all 0.2s; border: 1px solid #ddd; display: flex; align-items: center; justify-content: center; font-size: 10px; }
        .seat-mini:hover { transform: scale(1.1); background: #f0fdf4; }
        .seat-mini.selected { background: #10b981 !important; color: white; border-color: #059669; }
        #tooltip { pointer-events: none; transition: opacity 0.2s; }
    </style>
</head>
<body>

    <div class="absolute top-6 left-1/2 -translate-x-1/2 z-10 text-center pointer-events-none">
        <h1 class="text-3xl font-bold text-gray-800 tracking-tighter">演唱会 - 苏州站</h1>
        <p class="text-gray-500 text-sm">中心旋转舞台 · 3D 全景选座</p>
    </div>

    <div class="absolute top-6 right-6 glass-panel rounded-2xl p-4 z-10 flex items-center gap-4">
        <div class="text-right">
            <p class="text-gray-400 text-xs">已选座位</p>
            <p class="text-xl font-bold text-gray-800" id="cart-count">0</p>
        </div>
        <div class="h-8 w-px bg-gray-300"></div>
        <div class="text-right">
            <p class="text-gray-400 text-xs">预计金额</p>
            <p class="text-xl font-bold text-indigo-600" id="cart-total">¥0</p>
        </div>
        <button onclick="checkout()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-xl font-semibold transition-all">立即下单</button>
    </div>

    <div id="detail-panel" class="fixed right-0 top-0 h-full w-96 glass-panel transform translate-x-full transition-transform duration-500 z-20 overflow-y-auto p-8">
        <button onclick="closePanel()" class="mb-6 text-gray-500 hover:text-gray-800 flex items-center gap-2">
            <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"></path></svg> 返回场馆图
        </button>
        <div id="panel-content"></div>
    </div>

    <div id="tooltip" class="fixed glass-panel px-3 py-1.5 rounded-lg text-gray-800 text-xs opacity-0 z-30 shadow-xl border border-white">
        <span id="tooltip-text"></span>
    </div>

    <div id="canvas-container"></div>

    <script>
        // 区域颜色配置 (参考图片)
        const COLORS = {
            STAGE: 0xffffff,
            INNER: 0xfff1f2, // A, B, C, D 区域底色
            STAND_200: 0xfda4af, // 粉色环
            STAND_300: 0x99f6e4, // 浅绿环
            STAND_400: 0xbfdbfe  // 浅蓝外环
        };

        // 区域数据定义
        const seatData = [];
        
        // 1. 生成内场区域 (A, B, C, D) - 靠近舞台的矩形块 [cite: 169, 170, 171]
        const innerZones = [
            { id: 'A1', x: -12, z: -15, w: 10, d: 6, price: 1880 },
            { id: 'A2', x: 0, z: -15, w: 12, d: 6, price: 1880 },
            { id: 'A3', x: 12, z: -15, w: 10, d: 6, price: 1880 },
            { id: 'B1', x: 18, z: 0, w: 6, d: 15, price: 1680 },
            { id: 'B2', x: -18, z: 0, w: 6, d: 15, price: 1680 },
            { id: 'C1', x: 12, z: 15, w: 10, d: 6, price: 1280 },
            { id: 'C2', x: 0, z: 15, w: 12, d: 6, price: 1280 },
            { id: 'C3', x: -12, z: 15, w: 10, d: 6, price: 1280 }
        ];

        // 2. 环形看台生成逻辑 [cite: 174, 175, 178]
        function generateRing(startId, count, radius, color, price) {
            for (let i = 0; i < count; i++) {
                const angle = (i / count) * Math.PI * 2;
                seatData.push({
                    id: (startId + i).toString(),
                    radius: radius,
                    angle: angle,
                    color: color,
                    price: price,
                    type: 'stand'
                });
            }
        }

        // 填充数据
        generateRing(201, 20, 35, COLORS.STAND_200, 980);  // 200系列
        generateRing(301, 28, 48, COLORS.STAND_300, 680);  // 300系列
        generateRing(401, 32, 60, COLORS.STAND_400, 380);  // 400系列

        let scene, camera, renderer, controls, raycaster, mouse;
        let objects = [], cart = [];

        function init() {
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0xf1f5f9);

            camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.set(0, 100, 120);

            renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(window.devicePixelRatio);
            document.getElementById('canvas-container').appendChild(renderer.domElement);

            controls = new THREE.OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
            controls.maxPolarAngle = Math.PI / 2.2;

            // 灯光 [cite: 190, 191]
            const ambient = new THREE.AmbientLight(0xffffff, 0.7);
            scene.add(ambient);
            const directional = new THREE.DirectionalLight(0xffffff, 0.5);
            directional.position.set(10, 50, 10);
            scene.add(directional);

            raycaster = new THREE.Raycaster();
            mouse = new THREE.Vector2();

            buildVenue();
            
            window.addEventListener('mousemove', onMouseMove);
            window.addEventListener('click', onClick);
            window.addEventListener('resize', onResize);

            animate();
        }

        function buildVenue() {
            // 1. 中心舞台 (参考图中带有刻度的白色圆盘) [cite: 197, 200, 201]
            const stageGroup = new THREE.Group();
            
            const baseGeo = new THREE.CylinderGeometry(10, 11, 2, 64);
            const baseMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
            const base = new THREE.Mesh(baseGeo, baseMat);
            stageGroup.add(base);

            // 装饰环
            const ringGeo = new THREE.TorusGeometry(10, 0.2, 16, 100);
            const ringMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
            const ring = new THREE.Mesh(ringGeo, ringMat);
            ring.rotation.x = Math.PI / 2;
            ring.position.y = 1.1;
            stageGroup.add(ring);
            
            scene.add(stageGroup);

            // 2. 内场方块 (A/B/C/D)
            innerZones.forEach(zone => {
                const geo = new THREE.BoxGeometry(zone.w, 2, zone.d);
                const mat = new THREE.MeshStandardMaterial({ color: COLORS.INNER });
                const mesh = new THREE.Mesh(geo, mat);
                mesh.position.set(zone.x, 1, zone.z);
                mesh.userData = { id: zone.id, price: zone.price, name: `${zone.id} 区` };
                
                // 边框
                const edges = new THREE.EdgesGeometry(geo);
                const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xdddddd }));
                mesh.add(line);

                objects.push(mesh);
                scene.add(mesh);
                addLabel(mesh, zone.id);
            });

            // 3. 环形看台区 (200, 300, 400) [cite: 220, 221]
            seatData.forEach(data => {
                const w = data.radius * 0.15;
                const geo = new THREE.BoxGeometry(w, 4, 6);
                const mat = new THREE.MeshStandardMaterial({ color: data.color });
                const mesh = new THREE.Mesh(geo, mat);
                
                mesh.position.x = Math.sin(data.angle) * data.radius;
                mesh.position.z = Math.cos(data.angle) * data.radius;
                mesh.position.y = (data.radius - 30) * 0.4 + 2; // 随半径升高
                
                mesh.lookAt(0, mesh.position.y, 0);
                mesh.userData = { id: data.id, price: data.price, name: `${data.id} 看台` };

                objects.push(mesh);
                scene.add(mesh);
            });
        }

        function addLabel(parent, text) {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            canvas.width = 128; canvas.height = 64;
            ctx.fillStyle = '#333';
            ctx.font = 'bold 40px Arial';
            ctx.textAlign = 'center';
            ctx.fillText(text, 64, 45);
            const tex = new THREE.CanvasTexture(canvas);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex }));
            sprite.scale.set(6, 3, 1);
            sprite.position.y = 3;
            parent.add(sprite);
        }

        function onMouseMove(e) {
            mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
            
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(objects);
            const tooltip = document.getElementById('tooltip');

            if (intersects.length > 0) {
                const obj = intersects[0].object;
                document.body.style.cursor = 'pointer';
                tooltip.style.opacity = 1;
                tooltip.style.left = e.clientX + 15 + 'px';
                tooltip.style.top = e.clientY + 15 + 'px';
                document.getElementById('tooltip-text').innerText = `${obj.userData.name} - ¥${obj.userData.price}`;
            } else {
                document.body.style.cursor = 'default';
                tooltip.style.opacity = 0;
            }
        }

        function onClick() {
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(objects);
            if (intersects.length > 0) {
                const data = intersects[0].object.userData;
                showPanel(data);
            }
        }

        function showPanel(data) {
            const panel = document.getElementById('detail-panel');
            const content = document.getElementById('panel-content');
            panel.classList.remove('translate-x-full');
            
            content.innerHTML = `
                <h2 class="text-2xl font-bold text-gray-800">${data.name}</h2>
                <p class="text-indigo-600 font-bold text-lg mb-6">票价: ¥${data.price}</p>
                <div class="grid grid-cols-8 gap-2 mb-8">
                    ${Array.from({length: 48}).map((_, i) => {
                        const sid = `${data.id}-${i+1}`;
                        const active = cart.some(s => s.id === sid) ? 'selected' : '';
                        return `<div class="seat-mini ${active}" onclick="toggleSeat('${sid}', ${data.price})">${i+1}</div>`;
                    }).join('')}
                </div>
                <div class="bg-gray-50 p-4 rounded-xl border border-gray-100">
                    <p class="text-xs text-gray-400 uppercase font-bold mb-2">区域说明</p>
                    <p class="text-sm text-gray-600">该区域视野极佳,实时剩余位置:${Math.floor(Math.random()*20)+5} 个</p>
                </div>
            `;
        }

        window.toggleSeat = function(id, price) {
            const idx = cart.findIndex(s => s.id === id);
            if (idx > -1) {
                cart.splice(idx, 1);
                event.target.classList.remove('selected');
            } else {
                if (cart.length >= 6) return alert('每单限购6张');
                cart.push({id, price});
                event.target.classList.add('selected');
            }
            document.getElementById('cart-count').innerText = cart.length;
            document.getElementById('cart-total').innerText = '¥' + cart.reduce((a, b) => a + b.price, 0);
        };

        window.closePanel = () => document.getElementById('detail-panel').classList.add('translate-x-full');
        
        function checkout() {
            if (cart.length === 0) return alert('请先选择座位');
            alert(`订单提交中...\n已选: ${cart.length} 个座位\n总额: ¥${cart.reduce((a,b)=>a+b.price, 0)}`);
        }

        function onResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        }

        function animate() {
            requestAnimationFrame(animate);
            controls.update();
            renderer.render(scene, camera);
        }

        init();
    </script>
</body>
</html>
相关推荐
ZC跨境爬虫2 小时前
3D 地球卫星轨道可视化平台开发 Day10(交互升级与接口溯源)
前端·javascript·3d·自动化·交互
恋猫de小郭2 小时前
WasmGC 是什么?为什么它对 Dart 和 Kotlin 在 Web 领域很重要?
android·前端·flutter
新酱爱学习2 小时前
从一次 OpenClaw 请求抓包,聊聊 Skill 的运行原理
前端·人工智能·mcp
慕斯fuafua2 小时前
CSS——弹性盒子
前端·css
M ? A2 小时前
Vue Transition 组件转 React:VuReact 怎么处理?
前端·javascript·vue.js·经验分享·react.js·面试·vureact
小江的记录本2 小时前
【分布式】分布式一致性协议:2PC/3PC、Paxos、Raft、ZAB 核心原理、区别(2026必考Raft)
java·前端·分布式·后端·安全·面试·系统架构
huangql5202 小时前
CSS布局 (三):浮动——从文字环绕到多列布局
前端·javascript·css
大模型实验室Lab4AI2 小时前
算力赋能三维视觉创新,Lab4AI亮相 China3DV 2026
3d