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>
390 lines
12 KiB
JavaScript
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);
|
|
}
|
|
}
|