threetris/index.html
type-two 6daade952e THREETRIS: POV matrix tetris — catch bricks, rotate mid-flight, fit the wall
Single-file Three.js game, no build step. Bullet-time catch phase, 3D
rotation vs 2D silhouette matching, digital rain, worker-driven loop so
the sim survives hidden-tab throttling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:30:50 +10:00

323 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>THREETRIS</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #000; cursor: crosshair; }
#hud { position: fixed; inset: 0; pointer-events: none; font-family: "Courier New", monospace; color: #00ff41; text-shadow: 0 0 8px #00ff41; }
#score { position: absolute; top: 16px; left: 20px; font-size: 22px; }
#health { position: absolute; top: 16px; right: 20px; font-size: 22px; letter-spacing: 4px; }
#msg { position: absolute; top: 38%; width: 100%; text-align: center; font-size: 34px; letter-spacing: 6px; opacity: 0; transition: opacity .15s; }
#keys { position: absolute; bottom: 14px; width: 100%; text-align: center; font-size: 13px; opacity: .6; }
#flash { position: fixed; inset: 0; background: radial-gradient(ellipse at center, transparent 40%, rgba(255,0,60,.55)); opacity: 0; pointer-events: none; transition: opacity .1s; }
#over { position: fixed; inset: 0; display: none; place-items: center; text-align: center; font-family: "Courier New", monospace; color: #00ff41; text-shadow: 0 0 12px #00ff41; background: rgba(0,0,0,.7); }
#over h1 { font-size: 52px; letter-spacing: 12px; margin: 0 0 12px; }
</style>
</head>
<body>
<div id="hud">
<div id="score">SCORE 0</div>
<div id="health"></div>
<div id="msg"></div>
<div id="keys">MOUSE move &nbsp;·&nbsp; CLICK catch / throw &nbsp;·&nbsp; W/S pitch &nbsp; A/D yaw &nbsp; Q/E roll (mid-flight too)</div>
</div>
<div id="flash"></div>
<div id="over"><div><h1>SYSTEM FAILURE</h1><div id="finalScore"></div><div style="margin-top:20px">CLICK TO RE-ENTER</div></div></div>
<script type="importmap">{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js" } }</script>
<script type="module">
import * as THREE from 'three';
const GREEN = 0x00ff41, DIM = 0x0a3d1a;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.012);
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.1, 400);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
addEventListener('resize', () => { camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); });
// ---- environment: grids + digital rain ----
const grids = [];
for (const y of [-6, 6]) {
const g = new THREE.GridHelper(400, 40, GREEN, DIM);
g.position.y = y; g.material.transparent = true; g.material.opacity = 0.35;
scene.add(g); grids.push(g);
}
const rainCanvas = document.createElement('canvas');
rainCanvas.width = 512; rainCanvas.height = 256;
const rctx = rainCanvas.getContext('2d');
const rainTex = new THREE.CanvasTexture(rainCanvas);
const rainCols = Array.from({length: 64}, () => Math.random()*32|0);
function drawRain() {
rctx.fillStyle = 'rgba(0,0,0,0.18)'; rctx.fillRect(0,0,512,256);
rctx.fillStyle = '#00ff41'; rctx.font = '8px monospace';
rainCols.forEach((y,i) => {
rctx.fillText(String.fromCharCode(0x30A0 + Math.random()*96), i*8, y*8);
rainCols[i] = y*8 > 256 && Math.random() > .96 ? 0 : y+1;
});
rainTex.needsUpdate = true;
}
setInterval(drawRain, 80);
const rainPlane = new THREE.Mesh(
new THREE.PlaneGeometry(300, 150),
new THREE.MeshBasicMaterial({ map: rainTex, transparent: true, opacity: 0.28, depthWrite: false, fog: false }));
rainPlane.position.z = -180;
scene.add(rainPlane);
// reach reticle
const reticle = new THREE.Mesh(new THREE.RingGeometry(0.34, 0.4, 32),
new THREE.MeshBasicMaterial({ color: GREEN, transparent: true, opacity: 0.5 }));
scene.add(reticle);
// ---- tetromino shapes (flat cells; 3D rotation changes the silhouette) ----
const SHAPES = {
I: [[0,0],[1,0],[2,0],[3,0]], O: [[0,0],[1,0],[0,1],[1,1]],
T: [[0,0],[1,0],[2,0],[1,1]], L: [[0,0],[0,1],[0,2],[1,0]],
J: [[1,0],[1,1],[1,2],[0,0]], S: [[1,0],[2,0],[0,1],[1,1]],
Z: [[0,0],[1,0],[1,1],[2,1]],
};
const cubeGeo = new THREE.BoxGeometry(1, 1, 1);
const edgeGeo = new THREE.EdgesGeometry(cubeGeo);
function makeBrick(cells) {
const g = new THREE.Group();
const cx = cells.reduce((s,c)=>s+c[0],0)/cells.length, cy = cells.reduce((s,c)=>s+c[1],0)/cells.length;
for (const [x,y] of cells) {
const m = new THREE.Mesh(cubeGeo, new THREE.MeshBasicMaterial({ color: 0x032b10 }));
m.add(new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({ color: GREEN })));
m.position.set(x-cx, y-cy, 0);
g.add(m);
}
g.userData.cells = cells.map(([x,y]) => new THREE.Vector3(x-Math.round(cx), y-Math.round(cy), 0));
return g;
}
// silhouette of cells under quaternion q, as normalized "x,y" set
function silhouette(cells, q) {
const pts = cells.map(c => c.clone().applyQuaternion(q).round());
const mx = Math.min(...pts.map(p=>p.x)), my = Math.min(...pts.map(p=>p.y));
return new Set(pts.map(p => `${p.x-mx},${p.y-my}`));
}
const setsEqual = (a,b) => a.size === b.size && [...a].every(k => b.has(k));
const AXES = { w:[1,0,0,-1], s:[1,0,0,1], a:[0,1,0,-1], d:[0,1,0,1], q:[0,0,1,1], e:[0,0,1,-1] };
function randomOrientation() {
const q = new THREE.Quaternion();
const keys = Object.keys(AXES);
for (let i = 0, n = 1+Math.random()*3|0; i < n; i++) {
const [x,y,z,s] = AXES[keys[Math.random()*6|0]];
q.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(x,y,z), s*Math.PI/2));
}
return q;
}
// ---- wall with hole ----
function makeWall(holeCells) {
const g = new THREE.Group();
const hole = new Set([...holeCells]);
const hw = Math.max(...[...hole].map(k=>+k.split(',')[0]))+1, hh = Math.max(...[...hole].map(k=>+k.split(',')[1]))+1;
const ox = -(hw>>1), oy = -(hh>>1); // hole roughly centered on origin
const mat = new THREE.MeshBasicMaterial({ color: 0x011c09 });
const edgeMat = new THREE.LineBasicMaterial({ color: GREEN, transparent: true, opacity: 0.7 });
for (let x = -8; x <= 8; x++) for (let y = -5; y <= 5; y++) {
if (hole.has(`${x-ox},${y-oy}`)) continue;
const m = new THREE.Mesh(cubeGeo, mat);
m.add(new THREE.LineSegments(edgeGeo, edgeMat));
m.position.set(x, y, 0); m.scale.z = 0.6;
g.add(m);
}
g.userData.holeCenter = new THREE.Vector2(ox + hw/2 - 0.5, oy + hh/2 - 0.5);
g.userData.holeSil = hole;
return g;
}
// ---- game state ----
const $ = id => document.getElementById(id);
let score, health, level, state, brick, wall, targetQ, timers, debris, timeScale = 1;
const mouse = new THREE.Vector2();
const head = new THREE.Vector3();
addEventListener('mousemove', e => mouse.set(e.clientX/innerWidth*2-1, -(e.clientY/innerHeight*2-1)));
function showMsg(t, ms = 900) {
$('msg').textContent = t; $('msg').style.opacity = 1;
clearTimeout(showMsg.t); showMsg.t = setTimeout(() => $('msg').style.opacity = 0, ms);
}
function setHealth(h) { health = h; $('health').textContent = '▮'.repeat(h) + '▯'.repeat(3-h); }
function addScore(n) { score += n; $('score').textContent = 'SCORE ' + score; }
function damage(why) {
setHealth(health - 1);
$('flash').style.opacity = 1; setTimeout(() => $('flash').style.opacity = 0, 250);
showMsg(why);
if (health <= 0) { state = 'over'; $('finalScore').textContent = 'SCORE ' + score; $('over').style.display = 'grid'; }
}
function clearRound() {
if (brick) scene.remove(brick); brick = null;
if (wall) scene.remove(wall); wall = null;
}
function reset() {
score = 0; level = 0; timers = []; debris = [];
clearRound();
addScore(0); setHealth(3);
$('over').style.display = 'none';
spawnIncoming(1000);
}
function after(ms, fn) { timers.push({ t: ms/1000, fn }); }
function spawnIncoming(delay = 800) {
state = 'wait';
after(delay, () => {
const names = Object.keys(SHAPES);
brick = makeBrick(SHAPES[names[Math.random()*names.length|0]]);
brick.quaternion.copy(randomOrientation());
targetQ = brick.quaternion.clone();
const a = Math.random()*Math.PI*2, r = Math.random()*2.5;
brick.userData.aim = new THREE.Vector3(Math.cos(a)*r, Math.sin(a)*r, 2);
brick.position.set((Math.random()-0.5)*30, (Math.random()-0.5)*16, -130);
brick.userData.vel = brick.userData.aim.clone().sub(brick.position).normalize().multiplyScalar(26 + level*2.5);
scene.add(brick);
state = 'incoming';
showMsg('INCOMING', 700);
});
}
function startHold() {
state = 'hold';
addScore(25);
showMsg('CAUGHT — FIT IT', 900);
wall = makeWall(silhouette(brick.userData.cells, randomOrientation()));
wall.position.z = -120;
scene.add(wall);
}
function throwBrick() {
state = 'thrown';
const hc = wall.userData.holeCenter;
brick.userData.vel = new THREE.Vector3(hc.x, hc.y, wall.position.z)
.sub(brick.position).normalize().multiplyScalar(70);
}
function shatter(g) {
g.children.slice().forEach(m => {
scene.attach(m);
m.userData.vel = new THREE.Vector3((Math.random()-.5)*14, Math.random()*10, (Math.random()-.5)*8);
m.userData.spin = new THREE.Vector3(Math.random()*8, Math.random()*8, Math.random()*8);
m.userData.life = 1.2;
debris.push(m);
});
scene.remove(g);
}
addEventListener('keydown', e => {
const ax = AXES[e.key.toLowerCase()];
if (!ax || !brick || !(state === 'hold' || state === 'thrown')) return;
targetQ.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(ax[0],ax[1],ax[2]), ax[3]*Math.PI/2)).normalize();
});
addEventListener('mousedown', () => {
if (state === 'over') { reset(); return; }
if (state === 'incoming' && brick && brick.position.z > -16) {
const d = Math.hypot(brick.position.x - head.x, brick.position.y - head.y);
if (d < 2.6) { startHold(); return; }
}
if (state === 'hold') throwBrick();
});
// ---- main loop ----
const clock = new THREE.Clock();
// rAF and setTimeout are throttled in hidden tabs; a worker timer isn't, so the sim keeps running
const ticker = new Worker(URL.createObjectURL(new Blob(['setInterval(() => postMessage(0), 33)'])));
ticker.onmessage = () => { if (document.hidden) tick(); };
document.addEventListener('visibilitychange', () => { if (!document.hidden) requestAnimationFrame(tick); });
let lastTick = 0;
function tick() {
if (performance.now() - lastTick < 8) return; // dedupe overlapping loops
lastTick = performance.now();
if (!document.hidden) requestAnimationFrame(tick);
const raw = Math.min(clock.getDelta(), 0.05);
// bullet time while a brick is nearly on you
const slow = state === 'incoming' && brick && brick.position.z > -30;
timeScale += ((slow ? 0.3 : 1) - timeScale) * raw * 6;
const dt = raw * timeScale;
head.set(mouse.x*3.2, mouse.y*2.2, 0);
camera.position.lerp(head, 1 - Math.exp(-raw*10));
camera.lookAt(camera.position.x*0.5, camera.position.y*0.5, -100);
reticle.position.set(head.x, head.y, -4);
grids.forEach(g => g.position.z = (g.position.z + dt*12) % 10);
for (const t of timers.slice()) { t.t -= raw; if (t.t <= 0) { timers.splice(timers.indexOf(t),1); t.fn(); } }
if (brick) {
brick.quaternion.slerp(targetQ, 1 - Math.exp(-raw*14));
if (state === 'incoming') {
brick.position.addScaledVector(brick.userData.vel, dt);
if (brick.position.z > 1.5) {
const hit = Math.hypot(brick.position.x - head.x, brick.position.y - head.y) < 1.4;
scene.remove(brick); brick = null;
if (hit) damage('IMPACT'); else showMsg('MISSED', 700);
if (state !== 'over') spawnIncoming();
}
} else if (state === 'hold') {
brick.position.lerp(new THREE.Vector3(head.x + 1.7, head.y - 1.1, -5.5), 1 - Math.exp(-raw*8));
} else if (state === 'thrown' && wall) {
brick.position.addScaledVector(brick.userData.vel, dt);
if (brick.position.z <= wall.position.z + 0.5) {
const holeSil = wall.userData.holeSil;
if (setsEqual(silhouette(brick.userData.cells, targetQ), holeSil)) {
addScore(100); level++; showMsg('FIT ✓');
brick.position.x = wall.userData.holeCenter.x; brick.position.y = wall.userData.holeCenter.y;
wall.userData.dead = true;
after(1.2, () => { if (brick) scene.remove(brick), brick = null; });
state = 'pass';
} else {
showMsg('WRONG SHAPE');
shatter(brick); brick = null;
state = 'incoming-wall';
}
}
}
}
if (brick && state === 'pass') brick.position.z -= 70*dt;
if (wall) {
wall.position.z += (10 + level*1.2) * dt;
if (!wall.userData.dead && wall.position.z > -2.5) {
scene.remove(wall); wall = null;
if (brick) scene.remove(brick), brick = null;
damage('WALL IMPACT');
if (state !== 'over') spawnIncoming();
} else if (wall.position.z > 8) {
scene.remove(wall); wall = null;
if (state !== 'over') spawnIncoming();
}
}
for (const m of debris.slice()) {
m.userData.life -= dt;
m.userData.vel.y -= 20*dt;
m.position.addScaledVector(m.userData.vel, dt);
m.rotation.x += m.userData.spin.x*dt; m.rotation.y += m.userData.spin.y*dt;
if (m.userData.life <= 0) { scene.remove(m); debris.splice(debris.indexOf(m),1); }
}
renderer.render(scene, camera);
}
// debug/cheat hooks (also used by automated playtests)
window._m = (x, y) => mouse.set(x, y);
window._auto = () => { // rotate held/flying brick to fit the hole
if (!brick || !wall) return false;
const qs = [new THREE.Quaternion()];
for (let i = 0; i < qs.length && i < 300; i++) {
if (setsEqual(silhouette(brick.userData.cells, qs[i]), wall.userData.holeSil)) { targetQ.copy(qs[i]); return true; }
for (const a of Object.values(AXES))
qs.push(qs[i].clone().premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(a[0],a[1],a[2]), a[3]*Math.PI/2)));
}
return false;
};
Object.defineProperty(window, '_g', { get: () => ({ state, score, health, level, brickZ: brick && +brick.position.z.toFixed(1), brickXY: brick && [+brick.position.x.toFixed(1), +brick.position.y.toFixed(1)], head: [+head.x.toFixed(1), +head.y.toFixed(1)], wallZ: wall && +wall.position.z.toFixed(1) }) });
reset();
tick();
</script>
</body>
</html>