Files
casino/cmd/arcade/static/scene3d.js
drjones 5c3393b989 feat(ui): 3D scenes, mobile-first layout, portfolio charts
Three WebGL scenes on one renderer and one context, since a phone should
not allocate a context per game: a rocket straining against gravity, a
decaying orbit, and a tower that sways harder the higher it stacks. The
loop stops when the tab is hidden.

Navigation moves to the bottom, where a thumb already is. New Stats view
with balance history, cash-out rate, and a distribution chart that plots
observed crash points against what the published maths predicts — the
honest version of a hot-numbers board.

Charts are hand-built SVG, ~300 lines, rather than a library that would
cost more to load than the 3D engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:26:35 +00:00

390 lines
12 KiB
JavaScript

/* 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);
}
}