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>
This commit is contained in:
389
cmd/arcade/static/scene3d.js
Normal file
389
cmd/arcade/static/scene3d.js
Normal file
@@ -0,0 +1,389 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user