/* Quantum Arcade — 3D rendering. * * Three scenes sharing one renderer, because a phone should allocate exactly * one WebGL context no matter how many games it switches between. * * Performance rules, in priority order, because this has to hold sixty frames * on a mid-range phone in someone's hand at a party: * - device pixel ratio capped at 2, dropped to 1.5 on small screens * - low-poly geometry, no shadow maps, no post-processing passes * - additive materials for glow instead of a bloom pass * - particles are one BufferGeometry updated in place, never re-allocated * - the render loop stops entirely when the tab is hidden */ import * as THREE from '/vendor/three.module.min.js'; const GREEN = 0x00ff9c; const MAGENTA = 0xff2e88; const AMBER = 0xffb000; const RED = 0xff3355; const PARTICLES = 220; export class Arcade3D { constructor(canvas) { this.canvas = canvas; this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false, // FXAA-free; the aesthetic is crisp anyway alpha: true, powerPreference: 'high-performance', }); this.renderer.setClearColor(0x000000, 0); this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 400); this.clock = new THREE.Clock(); this.scenes = { rocket: this.buildRocket(), orbital: this.buildOrbital(), tower: this.buildTower(), }; this.active = 'rocket'; this.progress = 0; this.crashed = false; this.shake = 0; this.resize(); window.addEventListener('resize', () => this.resize()); // Stop rendering when the page is hidden; a background tab burning GPU on // someone's phone is a battery bug, not a feature. document.addEventListener('visibilitychange', () => { if (document.hidden) this.stop(); else this.start(); }); } resize() { const w = this.canvas.clientWidth; const h = this.canvas.clientHeight; if (!w || !h) return; const cap = w < 500 ? 1.5 : 2; this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, cap)); this.renderer.setSize(w, h, false); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); } /* ---------- shared pieces ---------- */ // A wireframe floor grid that scrolls toward the camera, which is what sells // the sensation of speed. makeGrid() { const grid = new THREE.GridHelper(120, 40, GREEN, 0x0a7a52); grid.material.transparent = true; grid.material.opacity = 0.28; grid.position.y = -6; return grid; } // One BufferGeometry of points, recycled every frame. makeParticles(spread = 40) { const positions = new Float32Array(PARTICLES * 3); const speeds = new Float32Array(PARTICLES); for (let i = 0; i < PARTICLES; i++) { positions[i * 3] = (Math.random() - 0.5) * spread; positions[i * 3 + 1] = (Math.random() - 0.5) * spread; positions[i * 3 + 2] = (Math.random() - 0.5) * spread; speeds[i] = 0.4 + Math.random() * 1.6; } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const mat = new THREE.PointsMaterial({ color: GREEN, size: 0.22, transparent: true, opacity: 0.75, blending: THREE.AdditiveBlending, depthWrite: false, }); const points = new THREE.Points(geo, mat); points.userData.speeds = speeds; points.userData.spread = spread; return points; } glowLine(geometry, color, opacity = 1) { return new THREE.LineSegments( new THREE.EdgesGeometry(geometry), new THREE.LineBasicMaterial({ color, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false, })); } /* ---------- rocket: a climb against gravity ---------- */ buildRocket() { const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x000000, 0.012); const grid = this.makeGrid(); scene.add(grid); const particles = this.makeParticles(50); scene.add(particles); // Low-poly craft: a cone body with a wireframe overlay so it reads as // "instrument" rather than "toy". const body = new THREE.Mesh( new THREE.ConeGeometry(0.7, 2.4, 6), new THREE.MeshBasicMaterial({ color: 0x00291c })); const wire = this.glowLine(new THREE.ConeGeometry(0.7, 2.4, 6), GREEN); const craft = new THREE.Group(); craft.add(body, wire); scene.add(craft); // Exhaust: a stretched cone that grows with thrust. const plume = new THREE.Mesh( new THREE.ConeGeometry(0.45, 2.2, 6), new THREE.MeshBasicMaterial({ color: GREEN, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false, })); plume.rotation.x = Math.PI; plume.position.y = -1.9; craft.add(plume); // The planet you are leaving, which shrinks as you climb. const planet = this.glowLine(new THREE.IcosahedronGeometry(9, 1), 0x0a7a52, 0.5); planet.position.y = -18; scene.add(planet); return { scene, grid, particles, craft, plume, planet, wire }; } updateRocket(s, dt, t) { const p = this.progress; s.craft.position.y = -2 + p * 9; s.craft.rotation.y += dt * 0.6; // Increasing strain: the craft trembles harder the higher it goes. s.craft.position.x = Math.sin(t * 9) * p * 0.28; s.craft.rotation.z = Math.sin(t * 7) * p * 0.14; const thrust = 0.6 + p * 2.6; s.plume.scale.set(1 + p * 0.5, thrust, 1 + p * 0.5); s.plume.position.y = -1.2 - thrust * 0.55; s.plume.material.opacity = 0.45 + Math.random() * 0.35; s.planet.position.y = -18 - p * 26; s.planet.rotation.y += dt * 0.1; s.grid.position.z = (s.grid.position.z + dt * (6 + p * 60)) % 3; s.grid.position.y = -6 - p * 4; const colour = this.crashed ? RED : GREEN; s.wire.material.color.setHex(colour); s.plume.material.color.setHex(colour); this.camera.position.set(0, s.craft.position.y + 1.5, 11 - p * 2); this.camera.lookAt(0, s.craft.position.y, 0); } /* ---------- orbital: a decaying orbit ---------- */ buildOrbital() { const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x000000, 0.02); const particles = this.makeParticles(44); scene.add(particles); const planet = this.glowLine(new THREE.IcosahedronGeometry(4, 1), GREEN, 0.85); scene.add(planet); const core = new THREE.Mesh( new THREE.IcosahedronGeometry(3.85, 1), new THREE.MeshBasicMaterial({ color: 0x00160f })); scene.add(core); // The orbit ring the craft is riding, which tightens as it decays. const ring = new THREE.Mesh( new THREE.TorusGeometry(9, 0.035, 6, 90), new THREE.MeshBasicMaterial({ color: GREEN, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false, })); ring.rotation.x = Math.PI / 2.35; scene.add(ring); const craft = new THREE.Mesh( new THREE.OctahedronGeometry(0.42), new THREE.MeshBasicMaterial({ color: MAGENTA })); scene.add(craft); // A fading trail behind the craft. const trailPos = new Float32Array(60 * 3); const trailGeo = new THREE.BufferGeometry(); trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3)); const trail = new THREE.Line(trailGeo, new THREE.LineBasicMaterial({ color: MAGENTA, transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false, })); scene.add(trail); return { scene, planet, core, ring, craft, trail, particles, trailIdx: 0 }; } updateOrbital(s, dt, t) { const p = this.progress; const radius = 9 - p * 4.6; // the orbit decays inward const angle = t * (1.1 + p * 5.5); s.craft.position.set( Math.cos(angle) * radius, Math.sin(angle * 0.55) * 1.2, Math.sin(angle) * radius); const pos = s.trail.geometry.attributes.position.array; // Shift the trail back one vertex and write the head. pos.copyWithin(3, 0, pos.length - 3); pos[0] = s.craft.position.x; pos[1] = s.craft.position.y; pos[2] = s.craft.position.z; s.trail.geometry.attributes.position.needsUpdate = true; s.ring.scale.setScalar(radius / 9); s.planet.rotation.y += dt * 0.25; s.planet.rotation.x += dt * 0.08; const colour = this.crashed ? RED : MAGENTA; s.craft.material.color.setHex(colour); s.trail.material.color.setHex(colour); s.planet.material.color.setHex(this.crashed ? RED : GREEN); this.camera.position.set(0, 8 + p * 3, 20 - p * 5); this.camera.lookAt(0, 0, 0); } /* ---------- tower: a stack that wobbles ---------- */ buildTower() { const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x000000, 0.014); const grid = this.makeGrid(); scene.add(grid); const particles = this.makeParticles(46); scene.add(particles); // Pre-allocate the maximum stack; visibility is toggled per frame rather // than creating and destroying meshes mid-round. const MAX = 26; const blocks = []; const geo = new THREE.BoxGeometry(2.4, 0.7, 2.4); for (let i = 0; i < MAX; i++) { const group = new THREE.Group(); group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x00170f }))); group.add(this.glowLine(geo, GREEN, 0.9)); group.visible = false; scene.add(group); blocks.push(group); } return { scene, grid, particles, blocks, max: MAX }; } updateTower(s, dt, t) { const p = this.progress; const count = Math.max(1, Math.floor(p * s.max)); for (let i = 0; i < s.max; i++) { const b = s.blocks[i]; b.visible = i < count; if (!b.visible) continue; const h = i / s.max; // Sway grows with height, and violently once it collapses. const amp = h * h * (this.crashed ? 3.4 : 0.9); b.position.set( Math.sin(t * 2.2 + i * 0.45) * amp, -5 + i * 0.74, Math.cos(t * 1.8 + i * 0.35) * amp * 0.6); b.rotation.y = t * 0.15 + i * 0.08; b.rotation.z = Math.sin(t * 2 + i * 0.4) * amp * 0.05; const top = i === count - 1; const colour = this.crashed ? RED : (top ? AMBER : GREEN); b.children[1].material.color.setHex(colour); b.children[1].material.opacity = top ? 1 : 0.45 + h * 0.4; } s.grid.position.z = (s.grid.position.z + dt * 4) % 3; const height = -5 + count * 0.74; this.camera.position.set(0, height * 0.55 + 3, 15 - p * 2); this.camera.lookAt(0, height * 0.5, 0); } /* ---------- particles ---------- */ updateParticles(points, dt) { const pos = points.geometry.attributes.position.array; const speeds = points.userData.speeds; const spread = points.userData.spread; const half = spread / 2; const rush = 2 + this.progress * 34; for (let i = 0; i < PARTICLES; i++) { pos[i * 3 + 2] += speeds[i] * rush * dt; if (pos[i * 3 + 2] > half) { pos[i * 3 + 2] = -half; pos[i * 3] = (Math.random() - 0.5) * spread; pos[i * 3 + 1] = (Math.random() - 0.5) * spread; } } points.geometry.attributes.position.needsUpdate = true; points.material.color.setHex(this.crashed ? RED : GREEN); } /* ---------- public API ---------- */ setGame(game) { if (this.scenes[game]) this.active = game; } // progress is 0..1 across the multiplier's useful range; crashed swaps the // palette and unleashes the wobble. setState(progress, crashed) { this.progress = Math.max(0, Math.min(1, progress)); if (crashed && !this.crashed) this.shake = 1; // kick the camera once this.crashed = crashed; } start() { if (this.raf) return; this.clock.getDelta(); // discard time spent hidden const loop = () => { this.raf = requestAnimationFrame(loop); this.frame(); }; this.raf = requestAnimationFrame(loop); } stop() { if (this.raf) cancelAnimationFrame(this.raf); this.raf = null; } frame() { const dt = Math.min(this.clock.getDelta(), 0.05); // clamp after a stall const t = this.clock.elapsedTime; const s = this.scenes[this.active]; if (this.active === 'rocket') this.updateRocket(s, dt, t); else if (this.active === 'orbital') this.updateOrbital(s, dt, t); else this.updateTower(s, dt, t); this.updateParticles(s.particles, dt); // Camera shake on the crash, decaying fast. if (this.shake > 0.001) { this.camera.position.x += (Math.random() - 0.5) * this.shake * 1.6; this.camera.position.y += (Math.random() - 0.5) * this.shake * 1.6; this.shake *= 0.86; } this.renderer.render(s.scene, this.camera); } }