/* global React, THREE */ const { useState, useEffect, useRef, useCallback } = React; // ── Weezevent — URLs filtrées par contexte // 👉 Remplacer chaque URL par le widget filtré correspondant (Weezevent > Canaux de vente > Modules de billetterie > Nouveau widget filtré) const WZ_POPUP_OPTS = "width=650,height=600,top=100,left=100,toolbar=no,resizable=yes,scrollbars=yes,status=no"; const WZ = { regular: "https://widget.weezevent.com/ticket/fb1dc096-5edd-4db7-8052-6df2b0efdc1f?id_evenement=2093971&locale=fr-FR&code=29313", viptable: "https://widget.weezevent.com/ticket/d7cd9430-5d05-4adf-b005-adeb8797f0b3?id_evenement=2093971&locale=fr-FR&code=29313", all: "https://widget.weezevent.com/ticket/E2093971/?code=29313&locale=fr-FR&width_auto=1&color_primary=0032FA", }; function openWeezevent(key) { const url = WZ[key] || WZ.all; const w = window.open(url, "Billetterie Weezevent", WZ_POPUP_OPTS); if (w) w.focus(); } // ── Table catalog ────────────────────────────────────────────────────────── const TABLE_TYPES = { vip: { label: "Table VIP", forfait: 600, color: 0xffc23d, accent: "#ffc23d", desc: "Table VIP bord de piscine. Accueil personnalisé, service dédié. La bouteille est incluse dans le forfait 600€.", bonus: "🍾 1 bouteille incluse dans le forfait 600€" }, free: { label: "Table Lounge", forfait: 0, color: 0x6dd5ff, accent: "#6dd5ff", desc: "Coin lounge avec coussins, transats et petite table. Accès libre — premiers arrivés, premiers servis ! Aucun paiement requis." }, }; function buildTables() { const tables = []; const POOL_W = 36, POOL_D = 10; const FRONT_Z = POOL_D / 2 + 2.5; // positive Z — front of pool const BACK_Z = -(POOL_D / 2 + 2.8); // negative Z — building side const LEFT_X = -(POOL_W / 2 + 5); const RIGHT_X = POOL_W / 2 + 5; // ── 10 VIP tables ── // 4 on front side (positive Z, facing pool) [-15, -5, 5, 15].forEach((x, i) => { tables.push({ id: `vip-f${i+1}`, type: "vip", x, z: FRONT_Z, label: `VIP ${i+1}` }); }); // 2 on building side — côte à côte (VIP 5 & 6 ensemble) tables.push({ id: "vip-b1", type: "vip", x: -8, z: BACK_Z, label: "VIP 5" }); tables.push({ id: "vip-b2", type: "vip", x: -2, z: BACK_Z, label: "VIP 6" }); // 2 × 2 on short ends — 2 tables on each face tables.push({ id: "vip-e1a", type: "vip", x: LEFT_X, z: 3, label: "VIP 7" }); tables.push({ id: "vip-e1b", type: "vip", x: LEFT_X, z: -3, label: "VIP 9" }); tables.push({ id: "vip-e2a", type: "vip", x: RIGHT_X, z: 3, label: "VIP 8" }); tables.push({ id: "vip-e2b", type: "vip", x: RIGHT_X, z: -3, label: "VIP 10" }); // ── 34 free lounge tables (transats + coussins + petites tables) ── // Group A — Derrière les VIP avant (positive Z), 3 rangées = 13 tables [ { xs: [-19, -11, -3, 5, 13], z: 10 }, { xs: [-16, -7, 2, 11], z: 15 }, { xs: [-13, -3, 7, 17], z: 20 }, ].forEach(({ xs, z }) => xs.forEach(x => tables.push({ type: "free", x, z }))); // Number the free tables let n = 1; tables.forEach(t => { if (t.type === "free") { t.id = `free-${n}`; t.label = `Lounge ${n++}`; } }); return tables; } const ALL_TABLES = buildTables(); // ── 3D Scene component ──────────────────────────────────────────────────── function BookingScene({ selectedId, reservedIds, hoveredId, onSelect, onHover }) { const canvasRef = useRef(null); const stateRef = useRef(null); // holds {scene, camera, renderer, tables, ...} // Init scene once useEffect(() => { const canvas = canvasRef.current; if (!canvas || !window.THREE) return; const THREE = window.THREE; // ── Procedural texture helpers ── function makeGradientTexture(stops) { const c = document.createElement("canvas"); c.width = 2; c.height = 512; const ctx = c.getContext("2d"); const g = ctx.createLinearGradient(0, 0, 0, 512); stops.forEach(([offset, color]) => g.addColorStop(offset, color)); ctx.fillStyle = g; ctx.fillRect(0, 0, 2, 512); return new THREE.CanvasTexture(c); } function makeGrassTexture() { const c = document.createElement("canvas"); c.width = 512; c.height = 512; const ctx = c.getContext("2d"); ctx.fillStyle = "#5cb058"; ctx.fillRect(0, 0, 512, 512); for (let i = 0; i < 8000; i++) { const x = Math.random() * 512, y = Math.random() * 512; const r = Math.random(); ctx.fillStyle = r < 0.45 ? "#4d9c4a" : r < 0.85 ? "#6ec169" : "#82d97a"; ctx.fillRect(x, y, 1 + Math.random() * 2, 1 + Math.random() * 2); } // Darker grass strokes ctx.strokeStyle = "#3d8a3a"; for (let i = 0; i < 600; i++) { ctx.lineWidth = 0.5 + Math.random(); const x = Math.random() * 512, y = Math.random() * 512; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.random() * 4 - 2, y + 1 + Math.random() * 4); ctx.stroke(); } const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(40, 40); return tex; } function makeWoodTexture() { const c = document.createElement("canvas"); c.width = 512; c.height = 256; const ctx = c.getContext("2d"); ctx.fillStyle = "#a86b3a"; ctx.fillRect(0, 0, 512, 256); // Plank lines for (let i = 0; i < 8; i++) { ctx.fillStyle = i % 2 === 0 ? "#9e623a" : "#b3744c"; ctx.fillRect(0, i * 32, 512, 32); ctx.fillStyle = "rgba(60,30,15,0.5)"; ctx.fillRect(0, i * 32, 512, 1); } // Wood grain ctx.strokeStyle = "rgba(60,30,15,0.25)"; ctx.lineWidth = 0.5; for (let i = 0; i < 200; i++) { const y = Math.random() * 256; ctx.beginPath(); ctx.moveTo(0, y); ctx.bezierCurveTo(128, y + (Math.random() - 0.5) * 6, 384, y + (Math.random() - 0.5) * 6, 512, y); ctx.stroke(); } const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(8, 3); return tex; } function makeWaterNormalMap() { const size = 256; const c = document.createElement("canvas"); c.width = size; c.height = size; const ctx = c.getContext("2d"); const img = ctx.createImageData(size, size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const nx = Math.sin(x * 0.18 + Math.cos(y * 0.13) * 2) + Math.sin(y * 0.09); const ny = Math.cos(y * 0.16 + Math.sin(x * 0.11) * 2) + Math.cos(x * 0.08); img.data[i] = 128 + nx * 40; img.data[i + 1] = 128 + ny * 40; img.data[i + 2] = 255; img.data[i + 3] = 255; } } ctx.putImageData(img, 0, 0); const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(4, 2); return tex; } function makeStripedPoufTexture() { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const ctx = c.getContext("2d"); ctx.fillStyle = "#e8e0d0"; ctx.fillRect(0, 0, 256, 64); ctx.fillStyle = "#7eaad0"; for (let i = 0; i < 8; i++) ctx.fillRect(i * 32, 0, 16, 64); const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; return tex; } const scene = new THREE.Scene(); // Gradient sky background scene.background = makeGradientTexture([ [0, "#4ea3d8"], [0.55, "#a3d8ee"], [0.85, "#f5e9c8"], [1, "#f5d4a8"], ]); scene.fog = new THREE.Fog(0xa3d8ee, 80, 170); // ── Backdrop panorama photo (real venue photo behind the forest) ── const loader = new THREE.TextureLoader(); loader.load("assets/IMG_5360.jpeg", (tex) => { tex.encoding = THREE.sRGBEncoding; const bdGeo = new THREE.PlaneGeometry(220, 55); const bdMat = new THREE.MeshBasicMaterial({ map: tex, fog: false }); const bd = new THREE.Mesh(bdGeo, bdMat); bd.position.set(0, 22, -88); scene.add(bd); }); const camera = new THREE.PerspectiveCamera(42, canvas.clientWidth / canvas.clientHeight, 0.1, 250); // Vue d'ensemble : côté positif (où sont les tables), légèrement en hauteur camera.position.set(0, 28, 55); camera.lookAt(0, 0, 8); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.outputEncoding = THREE.sRGBEncoding; renderer.toneMapping = THREE.LinearToneMapping; renderer.toneMappingExposure = 1.0; // ── Lighting ── const sun = new THREE.DirectionalLight(0xfff2c8, 2.1); sun.position.set(25, 45, 18); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.left = -50; sun.shadow.camera.right = 50; sun.shadow.camera.top = 50; sun.shadow.camera.bottom = -50; sun.shadow.bias = -0.0005; sun.shadow.radius = 4; scene.add(sun); scene.add(new THREE.AmbientLight(0xfff5e0, 0.35)); const hemi = new THREE.HemisphereLight(0xa3d8ee, 0x5cb058, 0.55); scene.add(hemi); // Subtle warm fill light const fill = new THREE.DirectionalLight(0xffb877, 0.4); fill.position.set(-20, 15, -10); scene.add(fill); // ── Ground (textured grass) ── const grassTex = makeGrassTexture(); const ground = new THREE.Mesh( new THREE.PlaneGeometry(200, 200), new THREE.MeshStandardMaterial({ map: grassTex, roughness: 0.95 }) ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); // Wood deck around pool — 4 strips forming a frame (no center, so water is visible) const woodTex = makeWoodTexture(); const deckMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.85 }); const deckHalfW = 21, deckHalfD = 8; const deckStripT = 3; // border thickness // North band (negative Z) const deckN = new THREE.Mesh(new THREE.BoxGeometry(deckHalfW * 2, 0.2, deckStripT), deckMat); deckN.position.set(0, 0.05, -deckHalfD + deckStripT / 2); deckN.receiveShadow = true; scene.add(deckN); // South band (positive Z) const deckS = new THREE.Mesh(new THREE.BoxGeometry(deckHalfW * 2, 0.2, deckStripT), deckMat); deckS.position.set(0, 0.05, deckHalfD - deckStripT / 2); deckS.receiveShadow = true; scene.add(deckS); // West band const deckWest = new THREE.Mesh( new THREE.BoxGeometry(deckHalfW - 18 + 3, 0.2, (deckHalfD - deckStripT) * 2), deckMat ); deckWest.position.set(-(18 + (deckHalfW - 18 - 3) / 2 + 1.5), 0.05, 0); deckWest.receiveShadow = true; scene.add(deckWest); // East band const deckEast = new THREE.Mesh( new THREE.BoxGeometry(deckHalfW - 18 + 3, 0.2, (deckHalfD - deckStripT) * 2), deckMat ); deckEast.position.set(18 + (deckHalfW - 18 - 3) / 2 + 1.5, 0.05, 0); deckEast.receiveShadow = true; scene.add(deckEast); // ── Pool with realistic water ── // Underwater tiled floor (gives depth perception through the transparent water) const tilesCanvas = document.createElement("canvas"); tilesCanvas.width = 256; tilesCanvas.height = 256; const tctx = tilesCanvas.getContext("2d"); tctx.fillStyle = "#7ec5d8"; tctx.fillRect(0, 0, 256, 256); tctx.strokeStyle = "#5a9eb0"; tctx.lineWidth = 2; for (let i = 0; i < 8; i++) { tctx.beginPath(); tctx.moveTo(i * 32, 0); tctx.lineTo(i * 32, 256); tctx.moveTo(0, i * 32); tctx.lineTo(256, i * 32); tctx.stroke(); } // Faint darker streaks tctx.fillStyle = "rgba(40,90,110,0.18)"; for (let i = 0; i < 50; i++) { tctx.fillRect(Math.random() * 256, Math.random() * 256, 2 + Math.random() * 6, 1); } const tilesTex = new THREE.CanvasTexture(tilesCanvas); tilesTex.wrapS = tilesTex.wrapT = THREE.RepeatWrapping; tilesTex.repeat.set(10, 3); const poolBox = new THREE.Group(); // Inner pool walls (light tiled) const innerMat = new THREE.MeshStandardMaterial({ map: tilesTex, roughness: 0.65, color: 0xa0d8e0 }); const floorMesh = new THREE.Mesh(new THREE.BoxGeometry(35.6, 0.1, 9.6), innerMat); floorMesh.position.y = -1.55; floorMesh.receiveShadow = true; poolBox.add(floorMesh); // 4 walls of the pool (so it feels like a real basin) const wallW = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.6, 9.6), innerMat); wallW.position.set(-17.8, -0.75, 0); poolBox.add(wallW); const wallE = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.6, 9.6), innerMat); wallE.position.set(17.8, -0.75, 0); poolBox.add(wallE); const wallN = new THREE.Mesh(new THREE.BoxGeometry(35.6, 1.6, 0.4), innerMat); wallN.position.set(0, -0.75, -4.8); poolBox.add(wallN); const wallS = new THREE.Mesh(new THREE.BoxGeometry(35.6, 1.6, 0.4), innerMat); wallS.position.set(0, -0.75, 4.8); poolBox.add(wallS); scene.add(poolBox); // Water surface — turquoise/cyan vif, simple et bien visible const waterNormal = makeWaterNormalMap(); const poolMat = new THREE.MeshLambertMaterial({ color: 0x14b6dc, emissive: 0x0a85a8, emissiveIntensity: 0.55, normalMap: waterNormal, }); const pool = new THREE.Mesh(new THREE.PlaneGeometry(35.6, 9.6, 32, 8), poolMat); pool.rotation.x = -Math.PI / 2; pool.position.y = 0.05; pool.receiveShadow = true; scene.add(pool); // Glints layer on top for sun sparkles const glintsTex = makeWaterNormalMap(); glintsTex.repeat.set(8, 4); const glintsMat = new THREE.MeshBasicMaterial({ color: 0xa8e8f5, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false, }); const glints = new THREE.Mesh(new THREE.PlaneGeometry(35.6, 9.6), glintsMat); glints.rotation.x = -Math.PI / 2; glints.position.y = 0.08; scene.add(glints); // Pool rim (stone coping) — 4 strips forming a hollow frame around the water const rimMat = new THREE.MeshStandardMaterial({ color: 0xeae5d8, roughness: 0.85 }); const rimT = 0.7; // thickness (strip width) const rimY = 0.16; const rimH = 0.32; const poolHalfW = 17.8, poolHalfD = 4.8; [ // North strip (negative Z) { size: [poolHalfW * 2 + rimT * 2, rimH, rimT], pos: [0, rimY, -poolHalfD - rimT / 2] }, // South strip (positive Z) { size: [poolHalfW * 2 + rimT * 2, rimH, rimT], pos: [0, rimY, poolHalfD + rimT / 2] }, // West strip { size: [rimT, rimH, poolHalfD * 2], pos: [-poolHalfW - rimT / 2, rimY, 0] }, // East strip { size: [rimT, rimH, poolHalfD * 2], pos: [poolHalfW + rimT / 2, rimY, 0] }, ].forEach(({ size, pos }) => { const strip = new THREE.Mesh(new THREE.BoxGeometry(...size), rimMat); strip.position.set(...pos); strip.receiveShadow = true; scene.add(strip); }); // Pool floats (inflatables for extra realism) function createFloat(x, z, color, type) { let mesh; if (type === "ring") { mesh = new THREE.Mesh( new THREE.TorusGeometry(0.55, 0.18, 12, 24), new THREE.MeshStandardMaterial({ color, roughness: 0.8 }) ); mesh.rotation.x = Math.PI / 2; } else { mesh = new THREE.Mesh( new THREE.CapsuleGeometry ? new THREE.CapsuleGeometry(0.35, 0.7, 6, 10) : new THREE.SphereGeometry(0.45, 8, 6), new THREE.MeshStandardMaterial({ color, roughness: 0.85 }) ); mesh.rotation.x = Math.PI / 2; } mesh.position.set(x, 0.05, z); mesh.castShadow = true; return mesh; } scene.add(createFloat(-10, 1, 0xff6f4e, "ring")); scene.add(createFloat(5, -2, 0xffcc55, "ring")); scene.add(createFloat(11, 1.5, 0xff85b8, "ring")); scene.add(createFloat(-3, 2.5, 0x9ef03a, "capsule")); // ── Trees: varied conifers + deciduous ── function createTree(x, z, type) { const group = new THREE.Group(); const scale = 0.85 + Math.random() * 0.5; const trunkH = 1.4 + Math.random() * 1.6; const trunk = new THREE.Mesh( new THREE.CylinderGeometry(0.28 * scale, 0.45 * scale, trunkH, 7), new THREE.MeshStandardMaterial({ color: 0x5a3a22, roughness: 0.95 }) ); trunk.position.y = trunkH / 2; trunk.castShadow = true; group.add(trunk); if (type === "conifer") { for (let i = 0; i < 3; i++) { const coneH = (3.2 - i * 0.4) * scale; const coneR = (2.1 - i * 0.45) * scale; const cone = new THREE.Mesh( new THREE.ConeGeometry(coneR, coneH, 7), new THREE.MeshStandardMaterial({ color: i === 0 ? 0x2a6638 : i === 1 ? 0x357a48 : 0x40925a, roughness: 0.95, }) ); cone.position.y = trunkH + coneH * 0.4 + i * (coneH * 0.55); cone.castShadow = true; group.add(cone); } } else { // Deciduous: 4-6 sphere clusters const clusters = 4 + Math.floor(Math.random() * 3); const palette = [0x2f7a44, 0x3a8a52, 0x4d9d5f, 0x6bb070]; for (let i = 0; i < clusters; i++) { const r = (0.85 + Math.random() * 0.7) * scale; const sphere = new THREE.Mesh( new THREE.SphereGeometry(r, 9, 7), new THREE.MeshStandardMaterial({ color: palette[i % 4], roughness: 0.95 }) ); sphere.position.set( (Math.random() - 0.5) * 2 * scale, trunkH + r * 0.7 + Math.random() * 1.8, (Math.random() - 0.5) * 2 * scale ); sphere.castShadow = true; group.add(sphere); } } group.position.set(x, 0, z); group.rotation.y = Math.random() * Math.PI * 2; group._sway = Math.random() * Math.PI * 2; return group; } // Tall cedar/fir tree generator — narrow, dense, very vertical (like the photos) function createCedar(x, z, scale) { const group = new THREE.Group(); const s = scale * (0.95 + Math.random() * 0.4); const trunkH = 2 + Math.random() * 1.5; const trunk = new THREE.Mesh( new THREE.CylinderGeometry(0.28 * s, 0.55 * s, trunkH, 7), new THREE.MeshStandardMaterial({ color: 0x4a3220, roughness: 0.95 }) ); trunk.position.y = trunkH / 2; trunk.castShadow = true; group.add(trunk); // Stacked cones — narrow + tall to look like cedar/fir const levels = 5; const baseH = 3.2 * s; const baseR = 2.4 * s; for (let i = 0; i < levels; i++) { const r = baseR * (1 - i * 0.16); const h = baseH * (1 - i * 0.08); const cone = new THREE.Mesh( new THREE.ConeGeometry(r, h, 8), new THREE.MeshStandardMaterial({ color: i === 0 ? 0x223e2a : i === 1 ? 0x2a4d34 : i === 2 ? 0x305a3c : i === 3 ? 0x386b46 : 0x4a8554, roughness: 0.96, }) ); cone.position.y = trunkH + i * (baseH * 0.5); cone.castShadow = true; group.add(cone); } group.position.set(x, 0, z); group.rotation.y = Math.random() * Math.PI * 2; group._sway = Math.random() * Math.PI * 2; return group; } const trees = []; // ── Cèdres / sapins hauts derrière la piscine (côté négatif Z, où il n'y a pas de tables) // Dense forest backdrop — z décalé à -27 pour rester derrière le bâtiment (mur arrière à z=-21.5) for (let i = 0; i < 22; i++) { const x = -50 + (i * 100) / 22 + (Math.random() - 0.5) * 4; const z = -27 + (Math.random() - 0.5) * 4; // range [-29, -25] — bien derrière le bâtiment const tree = createCedar(x, z, 1.4 + Math.random() * 0.6); scene.add(tree); trees.push(tree); } // Second row of cedars further behind (depth) for (let i = 0; i < 18; i++) { const x = -55 + (i * 110) / 18 + (Math.random() - 0.5) * 5; const z = -38 + (Math.random() - 0.5) * 8; const tree = createCedar(x, z, 1.6 + Math.random() * 0.7); scene.add(tree); trees.push(tree); } // Far row (silhouettes, simpler/fog-blurred) for (let i = 0; i < 14; i++) { const x = -65 + (i * 130) / 14; const z = -55; const tree = createCedar(x, z, 2.0 + Math.random() * 0.5); scene.add(tree); trees.push(tree); } // A few accent trees on the sides (left only — right side cleared for concert stage) [ [-38, -8], [-38, -2], [-50, 5], [-44, 12], ].forEach(([x, z]) => { const tree = createCedar(x, z, 1.1 + Math.random() * 0.4); scene.add(tree); trees.push(tree); }); // A small mix of deciduous trees on the table side (positive Z) for variety // Right side (positive X) cleared for concert stage visibility [ [-44, 28, "deciduous"], [-30, 30, "deciduous"], [-15, 32, "deciduous"], [15, 32, "deciduous"], ].forEach(([x, z, t]) => { const tree = createTree(x, z, t); scene.add(tree); trees.push(tree); }); // ── Pool ladders (échelles métalliques) ── function createLadder(x, z) { const group = new THREE.Group(); const metal = new THREE.MeshStandardMaterial({ color: 0xc0c8d0, metalness: 0.9, roughness: 0.25 }); // Two vertical rails curving over the edge const railGeo = new THREE.TorusGeometry(0.25, 0.04, 6, 12, Math.PI); const railL = new THREE.Mesh(railGeo, metal); railL.position.set(x - 0.3, 0.3, z); railL.rotation.x = Math.PI / 2; group.add(railL); const railR = new THREE.Mesh(railGeo, metal); railR.position.set(x + 0.3, 0.3, z); railR.rotation.x = Math.PI / 2; group.add(railR); // Vertical posts going up const postGeo = new THREE.CylinderGeometry(0.04, 0.04, 1.4, 6); const postL = new THREE.Mesh(postGeo, metal); postL.position.set(x - 0.3, 0.85, z + 0.1); group.add(postL); const postR = new THREE.Mesh(postGeo, metal); postR.position.set(x + 0.3, 0.85, z + 0.1); group.add(postR); // Top handle const topGeo = new THREE.CylinderGeometry(0.04, 0.04, 0.6, 6); const top = new THREE.Mesh(topGeo, metal); top.position.set(x, 1.55, z + 0.1); top.rotation.z = Math.PI / 2; group.add(top); return group; } // 2 ladders on the table side, 2 on the swimmer side scene.add(createLadder(-12, 5.2)); scene.add(createLadder(12, 5.2)); scene.add(createLadder(-12, -5.2)); scene.add(createLadder(12, -5.2)); // ── Planters with bushes near deck ── function createPlanter(x, z) { const group = new THREE.Group(); const pot = new THREE.Mesh( new THREE.CylinderGeometry(0.5, 0.6, 0.7, 12), new THREE.MeshStandardMaterial({ color: 0x6a4a30, roughness: 0.9 }) ); pot.position.y = 0.35; pot.castShadow = true; group.add(pot); const plant = new THREE.Mesh( new THREE.SphereGeometry(0.7, 9, 7), new THREE.MeshStandardMaterial({ color: 0x3a8a52, roughness: 0.95 }) ); plant.position.y = 1.1; plant.castShadow = true; group.add(plant); // Some smaller leaves around for (let i = 0; i < 3; i++) { const leaf = new THREE.Mesh( new THREE.SphereGeometry(0.35, 7, 5), new THREE.MeshStandardMaterial({ color: 0x4d9d5f, roughness: 0.95 }) ); leaf.position.set( Math.cos(i * 2.1) * 0.5, 1 + Math.random() * 0.4, Math.sin(i * 2.1) * 0.5 ); leaf.castShadow = true; group.add(leaf); } group.position.set(x, 0, z); return group; } [ [-21, 4.5], [21, 4.5], [-21, -4.5], [21, -4.5], [-30, 8], [30, 8], [-30, -8], [30, -8], ].forEach(([x, z]) => scene.add(createPlanter(x, z))); // ── Tall grass tufts (small green spikes around grass area) ── for (let i = 0; i < 40; i++) { const x = (Math.random() - 0.5) * 100; const z = (Math.random() - 0.5) * 70; if (Math.abs(x) < 25 && Math.abs(z) < 12) continue; // Avoid forest zone (negative Z, where trees are) if (z < -15) continue; const tuft = new THREE.Mesh( new THREE.ConeGeometry(0.15, 0.5, 5), new THREE.MeshStandardMaterial({ color: 0x5cc964, roughness: 0.95 }) ); tuft.position.set(x, 0.25, z); tuft.castShadow = true; scene.add(tuft); } // ── Bushes (low foliage scattered for detail) ── for (let i = 0; i < 25; i++) { const x = (Math.random() - 0.5) * 90; const z = (Math.random() - 0.5) * 60; // Skip if too close to pool/deck if (Math.abs(x) < 24 && Math.abs(z) < 14) continue; const bush = new THREE.Mesh( new THREE.SphereGeometry(0.4 + Math.random() * 0.5, 7, 5), new THREE.MeshStandardMaterial({ color: 0x3a8a52, roughness: 0.95 }) ); bush.position.set(x, 0.3, z); bush.castShadow = true; scene.add(bush); } // ── Wooden pathway from building to pool ── const pathMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.9 }); const path = new THREE.Mesh(new THREE.BoxGeometry(3, 0.08, 14), pathMat); path.position.set(-25, 0.04, -10); path.receiveShadow = true; scene.add(path); // ── Hedge / low wall behind table area ── for (let i = -18; i <= 18; i += 3) { const hedge = new THREE.Mesh( new THREE.BoxGeometry(2.5, 1.4, 1.5), new THREE.MeshStandardMaterial({ color: 0x2d6e3e, roughness: 0.95 }) ); hedge.position.set(i, 0.7, 22); hedge.castShadow = true; scene.add(hedge); } // ── Parasols around the deck (matches the venue photos) ── function createParasol(x, z, color, rotation) { const group = new THREE.Group(); const pole = new THREE.Mesh( new THREE.CylinderGeometry(0.06, 0.06, 3.2, 8), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.85 }) ); pole.position.y = 1.6; pole.castShadow = true; group.add(pole); const canopy = new THREE.Mesh( new THREE.ConeGeometry(1.7, 0.55, 12, 1, false), new THREE.MeshStandardMaterial({ color, roughness: 0.85, side: THREE.DoubleSide }) ); canopy.position.y = 3.35; canopy.castShadow = true; group.add(canopy); // Tassel ring const ring = new THREE.Mesh( new THREE.TorusGeometry(1.7, 0.03, 6, 24), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.85 }) ); ring.rotation.x = Math.PI / 2; ring.position.y = 3.1; group.add(ring); group.position.set(x, 0, z); group.rotation.y = rotation || 0; return group; } // Parasols seulement sur le bord côté tables + 2 sur le bord opposé (pour baigneurs) [ [-19, 6.5, 0x1a1a1a], [-13, 6.5, 0xd14a3a], [-3, 6.5, 0x1a1a1a], [7, 6.5, 0xd14a3a], [13, 6.5, 0x1a1a1a], [19, 6.5, 0xd14a3a], // 2 sur le bord arrière pour les baigneurs qui sortent [-12, -6.5, 0x1a1a1a], [12, -6.5, 0xd14a3a], ].forEach(([x, z, color]) => scene.add(createParasol(x, z, color, Math.random() * 0.3))); // ── Orange striped lounger texture (matches real venue photos) ── function makeLoungerTexture() { const c = document.createElement("canvas"); c.width = 128; c.height = 256; const ctx = c.getContext("2d"); const stripeW = 32; const colors = ["#f2a24a", "#ffffff", "#f2a24a", "#ffffff"]; for (let i = 0; i < 4; i++) { ctx.fillStyle = colors[i]; ctx.fillRect(0, i * stripeW * 2, 128, stripeW * 2); } const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; return tex; } const loungerTex = makeLoungerTexture(); // ── Lounge chairs along the pool edge ── function createLounger(x, z, rotation) { const group = new THREE.Group(); const frame = new THREE.Mesh( new THREE.BoxGeometry(0.75, 0.12, 1.95), new THREE.MeshStandardMaterial({ color: 0x7a4a28, roughness: 0.8 }) ); frame.position.y = 0.2; frame.castShadow = true; group.add(frame); // Orange striped cushion const cushion = new THREE.Mesh( new THREE.BoxGeometry(0.7, 0.16, 1.9), new THREE.MeshStandardMaterial({ map: loungerTex, roughness: 0.9 }) ); cushion.position.y = 0.34; cushion.castShadow = true; group.add(cushion); // Raised backrest const back = new THREE.Mesh( new THREE.BoxGeometry(0.7, 0.6, 0.16), new THREE.MeshStandardMaterial({ map: loungerTex, roughness: 0.9 }) ); back.position.set(0, 0.52, -0.88); back.rotation.x = -0.42; back.castShadow = true; group.add(back); // Small pillow at head const pillow = new THREE.Mesh( new THREE.BoxGeometry(0.55, 0.1, 0.35), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95 }) ); pillow.position.set(0, 0.47, -0.7); pillow.castShadow = true; group.add(pillow); group.position.set(x, 0, z); group.rotation.y = rotation || 0; return group; } // Chaises longues — côté avant (behind front VIP) + quelques-unes côté bâtiment [ // Front side, behind VIP row (z = 8.5-9) [-20, 8.5, 0.05], [-13, 9, -0.05], [-7, 8.5, 0.08], [0, 9, 0.05], [7, 8.5, -0.08], [13, 9, 0.05], [20, 8.5, 0.1], // Building side terrace (z = -9) [-16, -9, Math.PI + 0.05], [-8, -9, Math.PI - 0.05], [8, -9, Math.PI + 0.08], [16, -9, Math.PI], ].forEach(([x, z, r]) => scene.add(createLounger(x, z, r))); // ── Building — long chalet-style running along the pool (negative Z side) ── const building = new THREE.Group(); const buildingW = 44, buildingD = 7, buildingH = 4.2; const woodWallMat = new THREE.MeshStandardMaterial({ color: 0xa07850, roughness: 0.88 }); const darkWoodMat = new THREE.MeshStandardMaterial({ color: 0x4a3220, roughness: 0.9 }); // Main body const mainWall = new THREE.Mesh(new THREE.BoxGeometry(buildingW, buildingH, buildingD), woodWallMat); mainWall.position.y = buildingH / 2; mainWall.castShadow = true; building.add(mainWall); // Gable roof — two sloping panels const roofMat2 = new THREE.MeshStandardMaterial({ color: 0x3a2515, roughness: 0.95 }); const roofHalf = new THREE.Mesh( new THREE.BoxGeometry(buildingW + 2, 0.28, buildingD / 2 + 1.8), roofMat2 ); roofHalf.position.set(0, buildingH + 1.05, buildingD / 4 + 0.6); roofHalf.rotation.x = 0.38; roofHalf.castShadow = true; building.add(roofHalf); const roofHalf2 = roofHalf.clone(); roofHalf2.position.set(0, buildingH + 1.05, -(buildingD / 4 + 0.6)); roofHalf2.rotation.x = -0.38; building.add(roofHalf2); // Ridge beam const ridge = new THREE.Mesh( new THREE.BoxGeometry(buildingW + 2, 0.35, 0.45), roofMat2 ); ridge.position.y = buildingH + 2.05; building.add(ridge); // Front canopy / overhang (toward pool = positive local Z) const canopy = new THREE.Mesh( new THREE.BoxGeometry(buildingW + 2, 0.22, 4.5), darkWoodMat ); canopy.position.set(0, buildingH + 0.1, buildingD / 2 + 2.25); canopy.castShadow = true; building.add(canopy); // Canopy support columns for (let cx = -20; cx <= 20; cx += 8) { const col = new THREE.Mesh( new THREE.CylinderGeometry(0.12, 0.14, buildingH + 0.2, 8), darkWoodMat ); col.position.set(cx, (buildingH + 0.2) / 2, buildingD / 2 + 0.12); col.castShadow = true; building.add(col); } // End columns [-buildingW / 2 + 0.5, buildingW / 2 - 0.5].forEach(cx => { const col = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, buildingH + 0.2, 8), darkWoodMat); col.position.set(cx, (buildingH + 0.2) / 2, 0); col.castShadow = true; building.add(col); }); // Windows const winMat = new THREE.MeshStandardMaterial({ color: 0x6dd5ff, roughness: 0.2, metalness: 0.5, emissive: 0x223a4a, emissiveIntensity: 0.25 }); for (let wx = -18; wx <= 18; wx += 9) { const win = new THREE.Mesh(new THREE.BoxGeometry(2.0, 1.5, 0.1), winMat); win.position.set(wx, buildingH / 2 + 0.3, buildingD / 2 + 0.06); building.add(win); } // Central door const doorMat2 = new THREE.MeshStandardMaterial({ color: 0x2a1a10, roughness: 0.8 }); const door2 = new THREE.Mesh(new THREE.BoxGeometry(2.2, 3.2, 0.1), doorMat2); door2.position.set(0, 1.6, buildingD / 2 + 0.06); building.add(door2); // Terrace stone floor in front of building const terrace = new THREE.Mesh( new THREE.BoxGeometry(buildingW, 0.12, 5), new THREE.MeshStandardMaterial({ color: 0xc8b89a, roughness: 0.92 }) ); terrace.position.set(0, 0.06, buildingD / 2 + 2.5); terrace.receiveShadow = true; building.add(terrace); // Position building: along the negative Z side of the pool building.position.set(0, 0, -18); scene.add(building); // ── Concert stage — far behind VIP 8 (right short end, positive X side) ── const stage = new THREE.Group(); const stageMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.75 }); const metalMat = new THREE.MeshStandardMaterial({ color: 0x999999, metalness: 0.85, roughness: 0.25 }); const darkMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.85 }); // Platform const platform = new THREE.Mesh(new THREE.BoxGeometry(16, 1.4, 11), stageMat); platform.position.y = 0.7; platform.castShadow = true; stage.add(platform); // Steps const steps = new THREE.Mesh(new THREE.BoxGeometry(4, 0.7, 2.5), new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.8 })); steps.position.set(0, 0.35, 6.5); stage.add(steps); // Backdrop wall const backwall = new THREE.Mesh(new THREE.BoxGeometry(16, 9, 0.4), darkMat); backwall.position.set(0, 5.9, -5.8); backwall.castShadow = true; stage.add(backwall); // LED screen (glowing) const ledScreen = new THREE.Mesh(new THREE.BoxGeometry(13, 6.5, 0.1), new THREE.MeshStandardMaterial({ color: 0x100828, emissive: 0x5030e0, emissiveIntensity: 1.2, roughness: 0.2 })); ledScreen.position.set(0, 6.2, -5.6); stage.add(ledScreen); // Truss bar const truss = new THREE.Mesh(new THREE.BoxGeometry(18, 0.5, 0.5), metalMat); truss.position.set(0, 11, -4); stage.add(truss); // Vertical truss poles [-8, 8].forEach(x => { const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 11, 8), metalMat); pole.position.set(x, 5.5, -4); pole.castShadow = true; stage.add(pole); }); // Spotlights on truss [-6, -3, 0, 3, 6].forEach(x => { const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.28, 0.65, 8), new THREE.MeshStandardMaterial({ color: 0xf0c030, emissive: 0xf0c030, emissiveIntensity: 0.9 })); spot.rotation.z = Math.PI; spot.position.set(x, 10.7, -4); stage.add(spot); }); // Speaker stacks (left & right) [-9, 9].forEach(x => { const spk = new THREE.Mesh(new THREE.BoxGeometry(2, 5.5, 2), darkMat); spk.position.set(x, 1.4 + 2.75, -3.5); spk.castShadow = true; stage.add(spk); const spkTop = new THREE.Mesh(new THREE.BoxGeometry(1.8, 3, 1.8), darkMat); spkTop.position.set(x, 1.4 + 5.5 + 1.5, -3.5); spkTop.castShadow = true; stage.add(spkTop); }); // Stage ambient glow light const stageLight = new THREE.PointLight(0x6040ff, 2.5, 30); stageLight.position.set(0, 8, -2); stage.add(stageLight); // Position: far right side, behind VIP 8 (x=23), facing the pool (rotation -PI/2) stage.position.set(56, 0, 3); stage.rotation.y = -Math.PI / 2; scene.add(stage); // expose for animation loop const animEnv = { waterNormal, trees, poolMat }; // ── Tables ── const tableGroup = new THREE.Group(); const tableMeshes = []; // for raycasting ALL_TABLES.forEach((t) => { const type = TABLE_TYPES[t.type]; const isVIP = t.type === "vip"; const isFree = t.type === "free"; const group = new THREE.Group(); group.position.set(t.x, 0, t.z); // Base platform / mat (used for raycast) const matW = isVIP ? 5 : 3.5; const matD = isVIP ? 2.4 : 3; const mat = new THREE.Mesh( new THREE.BoxGeometry(matW, 0.1, matD), new THREE.MeshStandardMaterial({ color: type.color, roughness: 0.7, emissive: 0x000000 }) ); mat.position.y = 0.18; mat.receiveShadow = true; mat.userData = { tableId: t.id }; group.add(mat); tableMeshes.push(mat); if (isFree) { // Casual lounge: 3 bean bags (sphere-ish) + 1 low round table const beanMat = new THREE.MeshStandardMaterial({ color: 0xd8cfc0, roughness: 0.95 }); const stripedBeanMat = new THREE.MeshStandardMaterial({ color: 0x8bb6d8, roughness: 0.9 }); [ { x: -1.0, z: -0.7, m: beanMat }, { x: 1.0, z: -0.7, m: stripedBeanMat }, { x: 0, z: 0.9, m: beanMat }, ].forEach(p => { const bean = new THREE.Mesh( new THREE.SphereGeometry(0.55, 9, 7), p.m ); bean.scale.y = 0.55; bean.position.set(p.x, 0.42, p.z); bean.castShadow = true; bean.userData = { tableId: t.id }; group.add(bean); tableMeshes.push(bean); }); // Small low round table const lowT = new THREE.Mesh( new THREE.CylinderGeometry(0.5, 0.5, 0.22, 14), new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.8 }) ); lowT.position.y = 0.33; lowT.castShadow = true; lowT.userData = { tableId: t.id }; group.add(lowT); tableMeshes.push(lowT); } else { // VIP: rectangular table + cushions const tableTop = new THREE.Mesh( new THREE.BoxGeometry(matW * 0.8, 0.15, matD * 0.4), new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.85 }) ); tableTop.position.y = 0.45; tableTop.castShadow = true; tableTop.userData = { tableId: t.id }; group.add(tableTop); tableMeshes.push(tableTop); // Cushions on each side const cushionMat = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.95 }); const cushionGeo = new THREE.BoxGeometry(matW * 0.45, 0.5, matD * 0.7); const back = new THREE.Mesh(cushionGeo, cushionMat); back.position.set(0, 0.55, -matD * 0.4); back.castShadow = true; back.userData = { tableId: t.id }; group.add(back); tableMeshes.push(back); const front = new THREE.Mesh(cushionGeo, cushionMat); front.position.set(0, 0.55, matD * 0.4); front.castShadow = true; front.userData = { tableId: t.id }; group.add(front); tableMeshes.push(front); if (isVIP) { // Extra side cushions for VIPs const sideGeo = new THREE.BoxGeometry(matW * 0.15, 0.5, matD * 0.6); const left = new THREE.Mesh(sideGeo, cushionMat); left.position.set(-matW * 0.4, 0.55, 0); left.castShadow = true; left.userData = { tableId: t.id }; group.add(left); tableMeshes.push(left); const right = new THREE.Mesh(sideGeo, cushionMat); right.position.set(matW * 0.4, 0.55, 0); right.castShadow = true; right.userData = { tableId: t.id }; group.add(right); tableMeshes.push(right); } } // Floating sticker above each table with its label (sprite with canvas texture) const stickerCanvas = document.createElement("canvas"); stickerCanvas.width = 256; stickerCanvas.height = 96; const sctx = stickerCanvas.getContext("2d"); sctx.fillStyle = type.accent; sctx.strokeStyle = "#1a0d3a"; sctx.lineWidth = 6; const r = 24; sctx.beginPath(); sctx.moveTo(r, 0); sctx.lineTo(256 - r, 0); sctx.quadraticCurveTo(256, 0, 256, r); sctx.lineTo(256, 96 - r); sctx.quadraticCurveTo(256, 96, 256 - r, 96); sctx.lineTo(r, 96); sctx.quadraticCurveTo(0, 96, 0, 96 - r); sctx.lineTo(0, r); sctx.quadraticCurveTo(0, 0, r, 0); sctx.closePath(); sctx.fill(); sctx.stroke(); sctx.fillStyle = "#1a0d3a"; sctx.font = "bold 36px system-ui, sans-serif"; sctx.textAlign = "center"; sctx.textBaseline = "middle"; sctx.fillText(t.label.toUpperCase(), 128, 50); const stickerTex = new THREE.CanvasTexture(stickerCanvas); const stickerMat = new THREE.SpriteMaterial({ map: stickerTex, transparent: true }); const sticker = new THREE.Sprite(stickerMat); sticker.position.y = 3; sticker.scale.set(3, 1.1, 1); sticker.userData = { tableId: t.id, isSticker: true }; group.add(sticker); tableGroup.add(group); // Store reference to the group for camera animation t._object3d = group; t._sticker = sticker; t._mat = mat; }); scene.add(tableGroup); // ── Simple Orbit Controls (inline, sans dépendance externe) ── function makeOrbitControls(cam, dom) { const c = { target: new THREE.Vector3(0, 0, 8), distance: 60, targetDistance: 60, theta: 0, phi: 0.85, targetTheta: 0, targetPhi: 0.85, minDistance: 12, maxDistance: 95, minPhi: 0.18, maxPhi: Math.PI * 0.48, damping: 0.08, isDragging: false, startX: 0, startY: 0, }; const onDown = (e) => { if (e.button === 2) return; // ignore right click c.isDragging = true; c.startX = e.clientX; c.startY = e.clientY; dom.setPointerCapture && dom.setPointerCapture(e.pointerId); }; const onMove = (e) => { if (!c.isDragging) return; const dx = e.clientX - c.startX; const dy = e.clientY - c.startY; c.targetTheta -= dx * 0.005; c.targetPhi -= dy * 0.005; c.targetPhi = Math.max(c.minPhi, Math.min(c.maxPhi, c.targetPhi)); c.startX = e.clientX; c.startY = e.clientY; }; const onUp = (e) => { c.isDragging = false; dom.releasePointerCapture && e.pointerId != null && dom.releasePointerCapture(e.pointerId); }; const onWheel = (e) => { e.preventDefault(); const factor = 1 + e.deltaY * 0.0012; c.targetDistance *= factor; c.targetDistance = Math.max(c.minDistance, Math.min(c.maxDistance, c.targetDistance)); }; dom.addEventListener("pointerdown", onDown); window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); dom.addEventListener("wheel", onWheel, { passive: false }); dom.addEventListener("contextmenu", (e) => e.preventDefault()); c.update = () => { c.theta += (c.targetTheta - c.theta) * c.damping; c.phi += (c.targetPhi - c.phi) * c.damping; c.distance += (c.targetDistance - c.distance) * c.damping; const sp = Math.sin(c.phi); cam.position.set( c.target.x + c.distance * sp * Math.sin(c.theta), c.target.y + c.distance * Math.cos(c.phi), c.target.z + c.distance * sp * Math.cos(c.theta) ); cam.lookAt(c.target); }; c.dispose = () => { dom.removeEventListener("pointerdown", onDown); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); dom.removeEventListener("wheel", onWheel); }; return c; } const controls = makeOrbitControls(camera, canvas); // ── Raycaster for picking ── const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); let lastHoverId = null; // Track click vs drag (OrbitControls also captures these events) let downX = 0, downY = 0, downTime = 0; function onPointerDown(e) { downX = e.clientX; downY = e.clientY; downTime = Date.now(); } function onPointerUp(e) { const dx = e.clientX - downX; const dy = e.clientY - downY; const dist = Math.sqrt(dx * dx + dy * dy); // If pointer barely moved AND was a short tap → treat as click for selection if (dist < 6 && Date.now() - downTime < 400) { const rect = canvas.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(tableMeshes, false); if (hits.length && state.onSelectRef.current) { state.onSelectRef.current(hits[0].object.userData.tableId); } } } function onMouseMove(e) { const rect = canvas.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(tableMeshes, false); const id = hits.length ? hits[0].object.userData.tableId : null; if (id !== lastHoverId) { lastHoverId = id; canvas.style.cursor = id ? "pointer" : "grab"; if (state.onHoverRef.current) state.onHoverRef.current(id); } } canvas.addEventListener("mousemove", onMouseMove); canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointerup", onPointerUp); // ── Resize handling ── function resize() { const w = canvas.clientWidth, h = canvas.clientHeight; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } const ro = new ResizeObserver(resize); ro.observe(canvas); // ── Animation loop ── let raf; // When a table is selected, animate the controls.target to focus on that table // (camera keeps user's orbit angle but the look-at smoothly transitions) const targetLook = new THREE.Vector3(0, 0, 8); const tmpTime = { t: 0 }; function tick() { tmpTime.t += 0.016; // Smooth target lerp toward where we want to look if (controls) { controls.target.lerp(targetLook, 0.08); controls.update(); } else { camera.lookAt(targetLook); } // Pool: animate water normal map UV (ripple effect) animEnv.waterNormal.offset.x = tmpTime.t * 0.04; animEnv.waterNormal.offset.y = tmpTime.t * 0.025; animEnv.poolMat.emissiveIntensity = Math.sin(tmpTime.t * 1.3) * 0.08 + 0.30; // Trees: subtle wind sway animEnv.trees.forEach((tr) => { tr.rotation.z = Math.sin(tmpTime.t * 0.8 + tr._sway) * 0.015; }); // Sticker bob ALL_TABLES.forEach((t, i) => { if (t._sticker) t._sticker.position.y = 3 + Math.sin(tmpTime.t * 2 + i) * 0.08; }); renderer.render(scene, camera); raf = requestAnimationFrame(tick); } tick(); const state = { scene, camera, renderer, tableMeshes, controls, targetLook, onSelectRef: { current: onSelect }, onHoverRef: { current: onHover }, }; stateRef.current = state; return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener("mousemove", onMouseMove); canvas.removeEventListener("pointerdown", onPointerDown); canvas.removeEventListener("pointerup", onPointerUp); if (controls) controls.dispose(); renderer.dispose(); scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose()); else obj.material.dispose(); } }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync select/hover handlers refs without re-init useEffect(() => { if (stateRef.current) stateRef.current.onSelectRef.current = onSelect; }, [onSelect]); useEffect(() => { if (stateRef.current) stateRef.current.onHoverRef.current = onHover; }, [onHover]); // React to selectedId / reservedIds / hoveredId: update materials + camera target useEffect(() => { if (!stateRef.current || !window.THREE) return; const THREE = window.THREE; ALL_TABLES.forEach((t) => { if (!t._mat) return; const type = TABLE_TYPES[t.type]; const isSelected = selectedId === t.id; const isHovered = hoveredId === t.id; const isReserved = reservedIds.includes(t.id); let color = type.color; let emissive = 0x000000; let emissiveIntensity = 0; if (isReserved) { color = 0x444444; } else if (isSelected) { emissive = type.color; emissiveIntensity = 0.6; } else if (isHovered) { emissive = type.color; emissiveIntensity = 0.25; } t._mat.material.color.setHex(color); t._mat.material.emissive.setHex(emissive); t._mat.material.emissiveIntensity = emissiveIntensity; }); // Camera focus : avec OrbitControls, on ne déplace que le target (look-at) // Comme ça l'utilisateur garde son angle/distance d'orbit et peut tourner autour. const target = stateRef.current.targetLook; if (selectedId) { const t = ALL_TABLES.find((x) => x.id === selectedId); if (t) target.set(t.x, 1, t.z); } else { target.set(0, 0, 8); } }, [selectedId, hoveredId, reservedIds]); return ; } // ── Booking UI overlay + page ───────────────────────────────────────────── function PageBooking({ setPage }) { const [selectedId, setSelectedId] = useState(null); const [hoveredId, setHoveredId] = useState(null); const [reservedIds, setReservedIds] = useState([]); // simulated reserved tables const [confirmedTable, setConfirmedTable] = useState(null); const [entryQty, setEntryQty] = useState(1); // for entrée simple option const selected = selectedId ? ALL_TABLES.find((t) => t.id === selectedId) : null; const selectedType = selected ? TABLE_TYPES[selected.type] : null; const isReserved = selected && reservedIds.includes(selected.id); const total = selected && selectedType ? selectedType.forfait : 0; const reserve = () => { if (!selected || isReserved) return; setReservedIds([...reservedIds, selected.id]); setConfirmedTable({ id: selected.id, label: selected.label, type: selected.type, total }); setSelectedId(null); }; const reserveEntry = () => { setConfirmedTable({ id: "entry", label: `${entryQty} entrée${entryQty > 1 ? "s" : ""} simple`, type: "entry", total: 20 * entryQty }); }; // Stats const remainingByType = { vip: 10 - reservedIds.filter((id) => id.startsWith("vip-")).length, free: 13 - reservedIds.filter((id) => id.startsWith("free-")).length, }; return (
Accès complet à l'événement, main stage et piscine. Sans table réservée.
Les tables lounge ne sont pas réservables — elles sont attribuées sur place aux premiers arrivés.
Conseil : arrivez tôt pour choisir votre coin ! Les tables sont disponibles dès l'ouverture des portes (11h00).
{confirmedTable.label} {confirmedTable.total > 0 ? ` — ${confirmedTable.total} €` : ""} {confirmedTable.type === "vip" && ( 🍾 Bouteille incluse dans le forfait 600€ )}
Finalisez votre paiement sécurisé sur Weezevent. Votre table sera confirmée par email dès réception du règlement.