// ═══════════════════════════════════════════════════════ // ITEMS — decorative floating "?" bonus boxes // Purely ambient trackside dressing (no gameplay effect): // they hover just beyond the road edge, spin and bob, and // glow warmly in the sunset light. The original build gave // every box its own real PointLight (10 extra lights!); this // version uses emissive materials + one additive glow sprite // per box instead — zero additional lights. // ═══════════════════════════════════════════════════════ import * as THREE from 'three'; import { TRACK_WIDTH, CURB_W } from './config.js'; import { frame } from './track.js'; // ── Shared assets (built once) ── let _shared = null; function getShared() { if (_shared) return _shared; // "?" decal texture — warm amber tile with a bold question mark const qCanvas = document.createElement('canvas'); qCanvas.width = qCanvas.height = 128; const qctx = qCanvas.getContext('2d'); const grad = qctx.createLinearGradient(0, 0, 0, 128); grad.addColorStop(0, '#ffc85e'); grad.addColorStop(1, '#f09120'); qctx.fillStyle = grad; qctx.fillRect(0, 0, 128, 128); qctx.strokeStyle = 'rgba(60,30,5,0.9)'; qctx.lineWidth = 8; qctx.strokeRect(4, 4, 120, 120); // Corner rivets qctx.fillStyle = 'rgba(60,30,5,0.8)'; for (const [rx, ry] of [[16, 16], [112, 16], [16, 112], [112, 112]]) { qctx.beginPath(); qctx.arc(rx, ry, 5, 0, Math.PI * 2); qctx.fill(); } qctx.fillStyle = '#fff7e8'; qctx.strokeStyle = 'rgba(60,30,5,0.85)'; qctx.lineWidth = 6; qctx.font = '900 84px Arial'; qctx.textAlign = 'center'; qctx.textBaseline = 'middle'; qctx.strokeText('?', 64, 70); qctx.fillText('?', 64, 70); const decalTex = new THREE.CanvasTexture(qCanvas); decalTex.anisotropy = 4; // Soft radial glow sprite (additive, replaces PointLights) const gCanvas = document.createElement('canvas'); gCanvas.width = gCanvas.height = 128; const gctx = gCanvas.getContext('2d'); const rg = gctx.createRadialGradient(64, 64, 4, 64, 64, 64); rg.addColorStop(0, 'rgba(255,190,90,0.55)'); rg.addColorStop(0.4, 'rgba(255,150,50,0.22)'); rg.addColorStop(1, 'rgba(255,120,30,0)'); gctx.fillStyle = rg; gctx.fillRect(0, 0, 128, 128); const glowTex = new THREE.CanvasTexture(gCanvas); _shared = { decalTex, glowTex, boxGeo: new THREE.BoxGeometry(1.3, 1.3, 1.3), lidGeo: new THREE.BoxGeometry(1.36, 0.18, 1.36), decalGeo: new THREE.PlaneGeometry(1.05, 1.05), glowGeo: new THREE.PlaneGeometry(3.6, 3.6), boxMat: new THREE.MeshPhongMaterial({ color: 0xffa832, emissive: 0x7a4a00, emissiveIntensity: 0.55, shininess: 90, specular: 0xffeecc, }), lidMat: new THREE.MeshPhongMaterial({ color: 0x8a5a18, emissive: 0x3a2404, emissiveIntensity: 0.4, shininess: 60, }), glowMat: new THREE.MeshBasicMaterial({ map: glowTex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, }), }; return _shared; } // ═══════════════════════════════════════════════════════ // createItemBoxes(scene) — place decorative boxes along the // track, alternating sides, just beyond the curb so they // never interfere with racing. Returns an array of boxes // whose userData carries animation state. // ═══════════════════════════════════════════════════════ export function createItemBoxes(scene) { const S = getShared(); const boxes = []; const COUNT = 10; for (let i = 0; i < COUNT; i++) { const t = (i + 0.5) / COUNT; // offset from start line & sector marks const { point, side } = frame(t); const sideSign = i % 2 === 0 ? 1 : -1; const lateral = sideSign * (TRACK_WIDTH / 2 + CURB_W + 2.2); const box = new THREE.Group(); const body = new THREE.Mesh(S.boxGeo, S.boxMat); body.castShadow = true; box.add(body); // Lid seams top & bottom give the cube a "crate" read for (const y of [0.62, -0.62]) { const lid = new THREE.Mesh(S.lidGeo, S.lidMat); lid.position.y = y; box.add(lid); } // "?" decals on the four vertical faces const decalMat = new THREE.MeshBasicMaterial({ map: S.decalTex, transparent: true }); for (const [pos, rotY] of [ [[0, 0, 0.66], 0], [[0, 0, -0.66], Math.PI], [[0.66, 0, 0], Math.PI / 2], [[-0.66, 0, 0], -Math.PI / 2], ]) { const q = new THREE.Mesh(S.decalGeo, decalMat); q.position.set(...pos); q.rotation.y = rotY; box.add(q); } // Additive glow halo (billboarded cheaply by facing up-track) const glow = new THREE.Mesh(S.glowGeo, S.glowMat); glow.renderOrder = 5; box.add(glow); box.userData.glow = glow; box.position.set(point.x + side.x * lateral, 1.7, point.z + side.z * lateral); box.userData.baseY = 1.7; box.userData.phase = i * 1.37; // desync bobbing box.userData.spinRate = 0.6 + (i % 3) * 0.25; scene.add(box); boxes.push(box); } return boxes; } // ═══════════════════════════════════════════════════════ // updateItemBoxes(boxes, elapsed, camera) — bob, spin, pulse. // Call once per frame from the game loop. Cheap: no allocs. // ═══════════════════════════════════════════════════════ export function updateItemBoxes(boxes, elapsed, camera) { if (!boxes.length) return; // Pulse the shared box material once (all boxes pulse together) const S = getShared(); S.boxMat.emissiveIntensity = 0.45 + 0.25 * Math.sin(elapsed * 2.2); for (const box of boxes) { const ph = box.userData.phase; box.position.y = box.userData.baseY + Math.sin(elapsed * 1.6 + ph) * 0.25; box.rotation.y = elapsed * box.userData.spinRate + ph; // Billboard the glow halo toward the camera when available if (camera && box.userData.glow) box.userData.glow.lookAt(camera.position); } }