Fresh pass: agents, combos, title screen, and a MODELBEAST asset drop

- Red agent bricks that home on your head — dodge them (+10); touching
  or grabbing one costs a life. Makes movement a real decision.
- Combo multiplier on consecutive fits (100 × combo), level ramp raises
  agent frequency and speeds.
- Title screen (unlocks audio), procedural Web Audio SFX, slots for
  generated music + operator voice lines.
- Generated free/local on the MODELBEAST farm: FLUX logo, circuit brick
  texture, Matrix city backdrop; RMBG + Hunyuan3D image->GLB sentinel
  that glides across the corridor between waves; Kokoro TTS voice lines.
- Game degrades gracefully with assets/ missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-28 22:45:58 +10:00
parent 6daade952e
commit 4badc984d7
9 changed files with 200 additions and 49 deletions

View File

@ -1,16 +1,27 @@
# THREETRIS # THREETRIS
POV Matrix tetris. Bricks fly at your face in bullet time — dodge them or catch them, POV Matrix tetris. Bricks fly at your face in bullet time — catch the green ones,
then throw them back through the hole in the oncoming wall, rotating them mid-flight **dodge the red agents** (they home in on you; touching them hurts), then throw
so their silhouette fits exactly. Flat tetrominoes + full 3D rotation means pitching a caught bricks back through the hole in the oncoming wall, rotating them mid-flight
piece changes its silhouette (an I-piece end-on is a 1×1 hole). so their silhouette fits exactly. Flat tetrominoes + full 3D rotation means pitching
a piece changes its silhouette (an I-piece end-on is a 1×1 hole).
**Play:** open `index.html` over http (`python3 -m http.server`), any modern browser. **Play:** open `index.html` over http (`python3 -m http.server`), any modern browser.
No build, no deps — single file, Three.js from CDN. No build step — single file, Three.js from CDN.
- **Mouse** — move your head (dodge / reach) - **Mouse** — move your head (dodge / reach)
- **Click** — catch an incoming brick when it's close, or throw the held brick - **Click** — catch an incoming green brick, or throw the held brick
- **W/S · A/D · Q/E** — pitch / yaw / roll 90°, works while held and mid-flight - **W/S · A/D · Q/E** — pitch / yaw / roll 90°, works while held and mid-flight
- Catch +25, fit +100, 3 lives. Speed ramps per fit. - Catch +25 · fit +100 × combo · dodge an agent +10 · 3 lives. Speed and agent
frequency ramp per fit; consecutive fits build the combo multiplier.
Cheats/debug in console: `_g` (state), `_auto()` (rotate held brick to fit), `_m(x,y)` (move head). ## Assets (all generated free & local on MODELBEAST)
Everything in `assets/` came off the local render farm — FLUX (logo, circuit-board
brick texture, city backdrop, sentinel concept art), RMBG background removal +
Hunyuan3D image→GLB (the sentinel flyby), Kokoro TTS (operator voice lines),
ACE-Step (music loop). Procedural Web Audio covers the small SFX. The game degrades
gracefully if `assets/` is missing — flat colors, no music, same gameplay.
Cheats/debug in console: `_g` (state), `_auto()` (rotate held brick to fit),
`_m(x,y)` (move head), `_start()` (skip title).

BIN
assets/circuit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

BIN
assets/city.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

BIN
assets/failure.m4a Normal file

Binary file not shown.

BIN
assets/incoming.m4a Normal file

Binary file not shown.

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

BIN
assets/sentinel.glb Normal file

Binary file not shown.

BIN
assets/wake.m4a Normal file

Binary file not shown.

View File

@ -8,45 +8,110 @@
html, body { margin: 0; height: 100%; overflow: hidden; background: #000; cursor: crosshair; } 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; } #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; } #score { position: absolute; top: 16px; left: 20px; font-size: 22px; }
#level { position: absolute; top: 44px; left: 20px; font-size: 14px; opacity: .7; }
#health { position: absolute; top: 16px; right: 20px; font-size: 22px; letter-spacing: 4px; } #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; } #msg { position: absolute; top: 38%; width: 100%; text-align: center; font-size: 34px; letter-spacing: 6px; opacity: 0; transition: opacity .15s; }
#msg.red { color: #ff3344; text-shadow: 0 0 8px #ff3344; }
#keys { position: absolute; bottom: 14px; width: 100%; text-align: center; font-size: 13px; opacity: .6; } #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; } #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); } .screen { position: fixed; inset: 0; display: grid; place-items: center; text-align: center; font-family: "Courier New", monospace; color: #00ff41; text-shadow: 0 0 12px #00ff41; background: rgba(0,0,0,.75); }
#over h1 { font-size: 52px; letter-spacing: 12px; margin: 0 0 12px; } .screen h1 { font-size: 52px; letter-spacing: 12px; margin: 0 0 12px; }
#title img { max-width: min(640px, 80vw); mix-blend-mode: screen; }
#over { display: none; }
.blink { animation: blink 1.2s step-end infinite; }
@keyframes blink { 50% { opacity: .25; } }
</style> </style>
</head> </head>
<body> <body>
<div id="hud"> <div id="hud">
<div id="score">SCORE 0</div> <div id="score">SCORE 0</div>
<div id="level"></div>
<div id="health"></div> <div id="health"></div>
<div id="msg"></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 id="keys">MOUSE move &nbsp;·&nbsp; CLICK catch <span style="color:#00ff41">green</span> / throw &nbsp;·&nbsp; DODGE <span style="color:#ff3344;text-shadow:0 0 8px #ff3344">red agents</span> &nbsp;·&nbsp; W/S pitch &nbsp; A/D yaw &nbsp; Q/E roll (mid-flight too)</div>
</div> </div>
<div id="flash"></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> <div id="title" class="screen"><div>
<img src="assets/logo.png" alt="" onerror="this.remove()" onload="document.querySelector('#title h1').style.display='none'">
<h1>THREETRIS</h1>
<div style="max-width:520px;margin:0 auto;line-height:1.6;opacity:.8">Bricks come at you. Catch the green, dodge the red, throw them back through the wall — rotating mid-flight so the silhouette fits.</div>
<div class="blink" style="margin-top:26px;font-size:20px">CLICK TO WAKE UP</div>
</div></div>
<div id="over" class="screen"><div><h1>SYSTEM FAILURE</h1><div id="finalScore"></div><div class="blink" 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="importmap">{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
} }</script>
<script type="module"> <script type="module">
import * as THREE from 'three'; import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const GREEN = 0x00ff41, DIM = 0x0a3d1a; const GREEN = 0x00ff41, RED = 0xff3344, DIM = 0x0a3d1a;
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.012); scene.fog = new THREE.FogExp2(0x000000, 0.012);
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.1, 400); const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.1, 500);
const renderer = new THREE.WebGLRenderer({ antialias: true }); const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight); renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement); document.body.appendChild(renderer.domElement);
addEventListener('resize', () => { camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); }); addEventListener('resize', () => { camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); });
scene.add(new THREE.AmbientLight(0xffffff, 1.6)); // for the GLB sentinel; everything else is MeshBasic
// ---- environment: grids + digital rain ---- // ---- audio: generated loop + voice lines (assets/), procedural SFX (Web Audio) ----
let AC = null, noiseBuf = null;
const clip = n => { const a = new Audio('assets/' + n); a.onerror = () => clip.missing = true; return a; };
const music = clip('music.m4a'); music.loop = true; music.volume = 0.45;
const voice = { wake: clip('wake.m4a'), incoming: clip('incoming.m4a'), failure: clip('failure.m4a'), believe: clip('believe.m4a') };
function say(n) { const a = voice[n]; if (a) { a.currentTime = 0; a.volume = 0.9; a.play().catch(()=>{}); } }
function initAudio() {
if (AC) return;
AC = new AudioContext();
noiseBuf = AC.createBuffer(1, AC.sampleRate * 0.4, AC.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random()*2 - 1;
music.play().catch(()=>{});
}
function tone(freq, dur, type = 'square', vol = 0.12, slide = 0) {
if (!AC) return;
const t = AC.currentTime, o = AC.createOscillator(), g = AC.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur);
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g).connect(AC.destination); o.start(t); o.stop(t + dur);
}
function noise(dur, vol = 0.25, freq = 800) {
if (!AC) return;
const t = AC.currentTime, s = AC.createBufferSource(), g = AC.createGain(), f = AC.createBiquadFilter();
s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.value = freq;
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
s.connect(f).connect(g).connect(AC.destination); s.start(t); s.stop(t + dur);
}
const sfx = {
catch: () => tone(220, 0.1, 'square', 0.15, 260),
rotate: () => tone(660, 0.04, 'square', 0.07),
throw: () => tone(190, 0.18, 'sawtooth', 0.13, -140),
fit: () => { tone(523, 0.12, 'sine', 0.16); setTimeout(() => tone(784, 0.14, 'sine', 0.16), 90); setTimeout(() => tone(1047, 0.2, 'sine', 0.16), 180); },
dodge: () => tone(880, 0.07, 'sine', 0.1, 220),
shatter: () => noise(0.35, 0.3, 1400),
hit: () => { noise(0.3, 0.35, 500); tone(90, 0.3, 'sawtooth', 0.2, -50); },
};
// ---- environment: grids + city backdrop + digital rain ----
const grids = []; const grids = [];
for (const y of [-6, 6]) { for (const y of [-6, 6]) {
const g = new THREE.GridHelper(400, 40, GREEN, DIM); const g = new THREE.GridHelper(400, 40, GREEN, DIM);
g.position.y = y; g.material.transparent = true; g.material.opacity = 0.35; g.position.y = y; g.material.transparent = true; g.material.opacity = 0.35;
scene.add(g); grids.push(g); scene.add(g); grids.push(g);
} }
const texLoader = new THREE.TextureLoader();
texLoader.load('assets/city.png', tex => {
tex.colorSpace = THREE.SRGBColorSpace;
const p = new THREE.Mesh(new THREE.PlaneGeometry(400, 114),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: 0.85, fog: false, depthWrite: false }));
p.position.z = -195;
scene.add(p);
});
const rainCanvas = document.createElement('canvas'); const rainCanvas = document.createElement('canvas');
rainCanvas.width = 512; rainCanvas.height = 256; rainCanvas.width = 512; rainCanvas.height = 256;
const rctx = rainCanvas.getContext('2d'); const rctx = rainCanvas.getContext('2d');
@ -64,8 +129,8 @@ function drawRain() {
setInterval(drawRain, 80); setInterval(drawRain, 80);
const rainPlane = new THREE.Mesh( const rainPlane = new THREE.Mesh(
new THREE.PlaneGeometry(300, 150), new THREE.PlaneGeometry(300, 150),
new THREE.MeshBasicMaterial({ map: rainTex, transparent: true, opacity: 0.28, depthWrite: false, fog: false })); new THREE.MeshBasicMaterial({ map: rainTex, transparent: true, opacity: 0.22, depthWrite: false, fog: false }));
rainPlane.position.z = -180; rainPlane.position.z = -165;
scene.add(rainPlane); scene.add(rainPlane);
// reach reticle // reach reticle
@ -73,6 +138,50 @@ const reticle = new THREE.Mesh(new THREE.RingGeometry(0.34, 0.4, 32),
new THREE.MeshBasicMaterial({ color: GREEN, transparent: true, opacity: 0.5 })); new THREE.MeshBasicMaterial({ color: GREEN, transparent: true, opacity: 0.5 }));
scene.add(reticle); scene.add(reticle);
// ---- shared materials (circuit texture drops in when generated) ----
const cubeGeo = new THREE.BoxGeometry(1, 1, 1);
const edgeGeo = new THREE.EdgesGeometry(cubeGeo);
const brickMat = new THREE.MeshBasicMaterial({ color: 0x0a3318 });
const agentMat = new THREE.MeshBasicMaterial({ color: 0x2b0308 });
const wallMat = new THREE.MeshBasicMaterial({ color: 0x03170a });
const brickEdge = new THREE.LineBasicMaterial({ color: GREEN });
const agentEdge = new THREE.LineBasicMaterial({ color: RED });
const wallEdge = new THREE.LineBasicMaterial({ color: GREEN, transparent: true, opacity: 0.7 });
texLoader.load('assets/circuit.png', tex => {
tex.colorSpace = THREE.SRGBColorSpace;
brickMat.map = tex; brickMat.color.set(0xbbffbb); brickMat.needsUpdate = true;
wallMat.map = tex; wallMat.color.set(0x557755); wallMat.needsUpdate = true;
});
// ---- sentinel flyby (generated image → GLB via the farm; optional) ----
let sentinel = null;
new GLTFLoader().load('assets/sentinel.glb', g => {
sentinel = g.scene;
const box = new THREE.Box3().setFromObject(sentinel);
const size = box.getSize(new THREE.Vector3()).length();
sentinel.scale.setScalar(20 / size);
sentinel.visible = false;
scene.add(sentinel);
}, undefined, () => {});
let flybyT = 14;
function flyby(dt) {
if (!sentinel) return;
if (!sentinel.visible) {
flybyT -= dt;
if (flybyT <= 0) {
sentinel.visible = true;
sentinel.userData.dir = Math.random() < 0.5 ? 1 : -1;
sentinel.position.set(-sentinel.userData.dir * 60, (Math.random()-0.5)*10, -45 - Math.random()*25);
sentinel.rotation.set(0, sentinel.userData.dir > 0 ? Math.PI/2 : -Math.PI/2, 0);
}
} else {
sentinel.position.x += sentinel.userData.dir * 14 * dt;
sentinel.position.y += Math.sin(performance.now()/400) * 0.02;
sentinel.rotation.z = Math.sin(performance.now()/700) * 0.15;
if (Math.abs(sentinel.position.x) > 65) { sentinel.visible = false; flybyT = 12 + Math.random()*16; }
}
}
// ---- tetromino shapes (flat cells; 3D rotation changes the silhouette) ---- // ---- tetromino shapes (flat cells; 3D rotation changes the silhouette) ----
const SHAPES = { const SHAPES = {
I: [[0,0],[1,0],[2,0],[3,0]], O: [[0,0],[1,0],[0,1],[1,1]], I: [[0,0],[1,0],[2,0],[3,0]], O: [[0,0],[1,0],[0,1],[1,1]],
@ -80,18 +189,17 @@ const SHAPES = {
J: [[1,0],[1,1],[1,2],[0,0]], S: [[1,0],[2,0],[0,1],[1,1]], 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]], Z: [[0,0],[1,0],[1,1],[2,1]],
}; };
const cubeGeo = new THREE.BoxGeometry(1, 1, 1); function makeBrick(cells, agent = false) {
const edgeGeo = new THREE.EdgesGeometry(cubeGeo);
function makeBrick(cells) {
const g = new THREE.Group(); 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; 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) { for (const [x,y] of cells) {
const m = new THREE.Mesh(cubeGeo, new THREE.MeshBasicMaterial({ color: 0x032b10 })); const m = new THREE.Mesh(cubeGeo, agent ? agentMat : brickMat);
m.add(new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({ color: GREEN }))); m.add(new THREE.LineSegments(edgeGeo, agent ? agentEdge : brickEdge));
m.position.set(x-cx, y-cy, 0); m.position.set(x-cx, y-cy, 0);
g.add(m); g.add(m);
} }
g.userData.cells = cells.map(([x,y]) => new THREE.Vector3(x-Math.round(cx), y-Math.round(cy), 0)); g.userData.cells = cells.map(([x,y]) => new THREE.Vector3(x-Math.round(cx), y-Math.round(cy), 0));
g.userData.agent = agent;
return g; return g;
} }
// silhouette of cells under quaternion q, as normalized "x,y" set // silhouette of cells under quaternion q, as normalized "x,y" set
@ -118,12 +226,10 @@ function makeWall(holeCells) {
const hole = new Set([...holeCells]); 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 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 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++) { for (let x = -8; x <= 8; x++) for (let y = -5; y <= 5; y++) {
if (hole.has(`${x-ox},${y-oy}`)) continue; if (hole.has(`${x-ox},${y-oy}`)) continue;
const m = new THREE.Mesh(cubeGeo, mat); const m = new THREE.Mesh(cubeGeo, wallMat);
m.add(new THREE.LineSegments(edgeGeo, edgeMat)); m.add(new THREE.LineSegments(edgeGeo, wallEdge));
m.position.set(x, y, 0); m.scale.z = 0.6; m.position.set(x, y, 0); m.scale.z = 0.6;
g.add(m); g.add(m);
} }
@ -134,32 +240,40 @@ function makeWall(holeCells) {
// ---- game state ---- // ---- game state ----
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let score, health, level, state, brick, wall, targetQ, timers, debris, timeScale = 1; let score, health, level, combo, state = 'title', brick, wall, targetQ, timers = [], debris = [], timeScale = 1;
const mouse = new THREE.Vector2(); const mouse = new THREE.Vector2();
const head = new THREE.Vector3(); const head = new THREE.Vector3();
addEventListener('mousemove', e => mouse.set(e.clientX/innerWidth*2-1, -(e.clientY/innerHeight*2-1))); addEventListener('mousemove', e => mouse.set(e.clientX/innerWidth*2-1, -(e.clientY/innerHeight*2-1)));
function showMsg(t, ms = 900) { function showMsg(t, ms = 900, red = false) {
$('msg').textContent = t; $('msg').style.opacity = 1; $('msg').textContent = t; $('msg').style.opacity = 1;
$('msg').className = red ? 'red' : '';
clearTimeout(showMsg.t); showMsg.t = setTimeout(() => $('msg').style.opacity = 0, ms); clearTimeout(showMsg.t); showMsg.t = setTimeout(() => $('msg').style.opacity = 0, ms);
} }
function setHealth(h) { health = h; $('health').textContent = '▮'.repeat(h) + '▯'.repeat(3-h); } function setHealth(h) { health = h; $('health').textContent = '▮'.repeat(h) + '▯'.repeat(3-h); }
function addScore(n) { score += n; $('score').textContent = 'SCORE ' + score; } function addScore(n) { score += n; $('score').textContent = 'SCORE ' + score; }
function setLevel(l) { level = l; $('level').textContent = 'LEVEL ' + (l+1) + (combo > 1 ? ' ×' + combo : ''); }
function damage(why) { function damage(why) {
setHealth(health - 1); setHealth(health - 1); combo = 0; setLevel(level);
sfx.hit();
$('flash').style.opacity = 1; setTimeout(() => $('flash').style.opacity = 0, 250); $('flash').style.opacity = 1; setTimeout(() => $('flash').style.opacity = 0, 250);
showMsg(why); showMsg(why, 900, true);
if (health <= 0) { state = 'over'; $('finalScore').textContent = 'SCORE ' + score; $('over').style.display = 'grid'; } if (health <= 0) {
state = 'over'; say('failure'); music.pause();
$('finalScore').textContent = 'SCORE ' + score;
$('over').style.display = 'grid';
}
} }
function clearRound() { function clearRound() {
if (brick) scene.remove(brick); brick = null; if (brick) scene.remove(brick); brick = null;
if (wall) scene.remove(wall); wall = null; if (wall) scene.remove(wall); wall = null;
} }
function reset() { function reset() {
score = 0; level = 0; timers = []; debris = []; score = 0; combo = 0; timers = []; debris = [];
clearRound(); clearRound();
addScore(0); setHealth(3); addScore(0); setHealth(3); setLevel(0);
$('over').style.display = 'none'; $('over').style.display = 'none';
if (music.paused) music.play().catch(()=>{});
spawnIncoming(1000); spawnIncoming(1000);
} }
function after(ms, fn) { timers.push({ t: ms/1000, fn }); } function after(ms, fn) { timers.push({ t: ms/1000, fn }); }
@ -168,33 +282,36 @@ function spawnIncoming(delay = 800) {
state = 'wait'; state = 'wait';
after(delay, () => { after(delay, () => {
const names = Object.keys(SHAPES); const names = Object.keys(SHAPES);
brick = makeBrick(SHAPES[names[Math.random()*names.length|0]]); const agent = level >= 1 && Math.random() < Math.min(0.18 + level*0.04, 0.5);
brick = makeBrick(SHAPES[names[Math.random()*names.length|0]], agent);
brick.quaternion.copy(randomOrientation()); brick.quaternion.copy(randomOrientation());
targetQ = brick.quaternion.clone(); targetQ = brick.quaternion.clone();
const a = Math.random()*Math.PI*2, r = Math.random()*2.5; const a = Math.random()*Math.PI*2, r = agent ? Math.random()*1.2 : Math.random()*2.5;
brick.userData.aim = new THREE.Vector3(Math.cos(a)*r, Math.sin(a)*r, 2); 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.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); brick.userData.speed = 26 + level*2.5;
brick.userData.vel = brick.userData.aim.clone().sub(brick.position).normalize().multiplyScalar(brick.userData.speed);
scene.add(brick); scene.add(brick);
state = 'incoming'; state = 'incoming';
showMsg('INCOMING', 700); if (agent) showMsg('AGENT — DODGE', 900, true); else { showMsg('INCOMING', 700); say('incoming'); }
}); });
} }
function startHold() { function startHold() {
state = 'hold'; state = 'hold';
addScore(25); addScore(25); sfx.catch();
showMsg('CAUGHT — FIT IT', 900); showMsg('CAUGHT — FIT IT', 900);
wall = makeWall(silhouette(brick.userData.cells, randomOrientation())); wall = makeWall(silhouette(brick.userData.cells, randomOrientation()));
wall.position.z = -120; wall.position.z = -120;
scene.add(wall); scene.add(wall);
} }
function throwBrick() { function throwBrick() {
state = 'thrown'; state = 'thrown'; sfx.throw();
const hc = wall.userData.holeCenter; const hc = wall.userData.holeCenter;
brick.userData.vel = new THREE.Vector3(hc.x, hc.y, wall.position.z) brick.userData.vel = new THREE.Vector3(hc.x, hc.y, wall.position.z)
.sub(brick.position).normalize().multiplyScalar(70); .sub(brick.position).normalize().multiplyScalar(70);
} }
function shatter(g) { function shatter(g) {
sfx.shatter();
g.children.slice().forEach(m => { g.children.slice().forEach(m => {
scene.attach(m); scene.attach(m);
m.userData.vel = new THREE.Vector3((Math.random()-.5)*14, Math.random()*10, (Math.random()-.5)*8); m.userData.vel = new THREE.Vector3((Math.random()-.5)*14, Math.random()*10, (Math.random()-.5)*8);
@ -208,13 +325,24 @@ function shatter(g) {
addEventListener('keydown', e => { addEventListener('keydown', e => {
const ax = AXES[e.key.toLowerCase()]; const ax = AXES[e.key.toLowerCase()];
if (!ax || !brick || !(state === 'hold' || state === 'thrown')) return; if (!ax || !brick || !(state === 'hold' || state === 'thrown')) return;
sfx.rotate();
targetQ.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(ax[0],ax[1],ax[2]), ax[3]*Math.PI/2)).normalize(); targetQ.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(ax[0],ax[1],ax[2]), ax[3]*Math.PI/2)).normalize();
}); });
addEventListener('mousedown', () => { addEventListener('mousedown', () => {
if (state === 'title') {
initAudio(); say('wake');
$('title').style.display = 'none';
reset();
return;
}
if (state === 'over') { reset(); return; } if (state === 'over') { reset(); return; }
if (state === 'incoming' && brick && brick.position.z > -16) { if (state === 'incoming' && brick && brick.position.z > -16) {
const d = Math.hypot(brick.position.x - head.x, brick.position.y - head.y); const d = Math.hypot(brick.position.x - head.x, brick.position.y - head.y);
if (d < 2.6) { startHold(); return; } if (d < 2.6) {
if (brick.userData.agent) { shatter(brick); brick = null; damage('NEVER TOUCH AN AGENT'); if (state !== 'over') spawnIncoming(); }
else startHold();
return;
}
} }
if (state === 'hold') throwBrick(); if (state === 'hold') throwBrick();
}); });
@ -242,17 +370,25 @@ function tick() {
reticle.position.set(head.x, head.y, -4); reticle.position.set(head.x, head.y, -4);
grids.forEach(g => g.position.z = (g.position.z + dt*12) % 10); grids.forEach(g => g.position.z = (g.position.z + dt*12) % 10);
flyby(dt);
for (const t of timers.slice()) { t.t -= raw; if (t.t <= 0) { timers.splice(timers.indexOf(t),1); t.fn(); } } for (const t of timers.slice()) { t.t -= raw; if (t.t <= 0) { timers.splice(timers.indexOf(t),1); t.fn(); } }
if (brick) { if (brick) {
brick.quaternion.slerp(targetQ, 1 - Math.exp(-raw*14)); brick.quaternion.slerp(targetQ, 1 - Math.exp(-raw*14));
if (state === 'incoming') { if (state === 'incoming') {
if (brick.userData.agent) { // agents steer toward your head
const want = new THREE.Vector3(head.x, head.y, 4).sub(brick.position).normalize().multiplyScalar(brick.userData.speed);
brick.userData.vel.lerp(want, Math.min(1, dt*1.1));
}
brick.position.addScaledVector(brick.userData.vel, dt); brick.position.addScaledVector(brick.userData.vel, dt);
if (brick.position.z > 1.5) { if (brick.position.z > 1.5) {
const agent = brick.userData.agent;
const hit = Math.hypot(brick.position.x - head.x, brick.position.y - head.y) < 1.4; const hit = Math.hypot(brick.position.x - head.x, brick.position.y - head.y) < 1.4;
scene.remove(brick); brick = null; scene.remove(brick); brick = null;
if (hit) damage('IMPACT'); else showMsg('MISSED', 700); if (hit) damage('IMPACT');
else if (agent) { addScore(10); sfx.dodge(); showMsg('DODGED +10', 700); }
else showMsg('MISSED', 700);
if (state !== 'over') spawnIncoming(); if (state !== 'over') spawnIncoming();
} }
} else if (state === 'hold') { } else if (state === 'hold') {
@ -260,15 +396,18 @@ function tick() {
} else if (state === 'thrown' && wall) { } else if (state === 'thrown' && wall) {
brick.position.addScaledVector(brick.userData.vel, dt); brick.position.addScaledVector(brick.userData.vel, dt);
if (brick.position.z <= wall.position.z + 0.5) { if (brick.position.z <= wall.position.z + 0.5) {
const holeSil = wall.userData.holeSil; if (setsEqual(silhouette(brick.userData.cells, targetQ), wall.userData.holeSil)) {
if (setsEqual(silhouette(brick.userData.cells, targetQ), holeSil)) { combo++; setLevel(level + 1);
addScore(100); level++; showMsg('FIT ✓'); addScore(100 * combo); sfx.fit();
showMsg(combo > 1 ? `FIT ×${combo}` : 'FIT ✓');
if (combo === 3) say('believe');
brick.position.x = wall.userData.holeCenter.x; brick.position.y = wall.userData.holeCenter.y; brick.position.x = wall.userData.holeCenter.x; brick.position.y = wall.userData.holeCenter.y;
wall.userData.dead = true; wall.userData.dead = true;
after(1.2, () => { if (brick) scene.remove(brick), brick = null; }); after(1200, () => { if (brick) scene.remove(brick), brick = null; });
state = 'pass'; state = 'pass';
} else { } else {
showMsg('WRONG SHAPE'); showMsg('WRONG SHAPE', 900, true);
combo = 0; setLevel(level);
shatter(brick); brick = null; shatter(brick); brick = null;
state = 'incoming-wall'; state = 'incoming-wall';
} }
@ -313,9 +452,10 @@ window._auto = () => { // rotate held/flying brick to fit the hole
} }
return false; 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) }) }); Object.defineProperty(window, '_g', { get: () => ({ state, score, health, level, combo, agent: !!(brick && brick.userData.agent), 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) }) });
window._start = () => { if (state === 'title') { $('title').style.display = 'none'; reset(); } };
window._fly = () => { flybyT = 0.01; return sentinel ? 'loaded' : 'no glb'; };
reset();
tick(); tick();
</script> </script>
</body> </body>