threetris/index.html
type-two 4badc984d7 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>
2026-07-28 22:45:58 +10:00

463 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
#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; }
#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; }
#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; }
.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); }
.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>
</head>
<body>
<div id="hud">
<div id="score">SCORE 0</div>
<div id="level"></div>
<div id="health"></div>
<div id="msg"></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 id="flash"></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",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
} }</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const GREEN = 0x00ff41, RED = 0xff3344, 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, 500);
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); });
scene.add(new THREE.AmbientLight(0xffffff, 1.6)); // for the GLB sentinel; everything else is MeshBasic
// ---- 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 = [];
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 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');
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.22, depthWrite: false, fog: false }));
rainPlane.position.z = -165;
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);
// ---- 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) ----
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]],
};
function makeBrick(cells, agent = false) {
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, agent ? agentMat : brickMat);
m.add(new THREE.LineSegments(edgeGeo, agent ? agentEdge : brickEdge));
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));
g.userData.agent = agent;
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
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, wallMat);
m.add(new THREE.LineSegments(edgeGeo, wallEdge));
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, combo, state = 'title', 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, red = false) {
$('msg').textContent = t; $('msg').style.opacity = 1;
$('msg').className = red ? 'red' : '';
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 setLevel(l) { level = l; $('level').textContent = 'LEVEL ' + (l+1) + (combo > 1 ? ' ×' + combo : ''); }
function damage(why) {
setHealth(health - 1); combo = 0; setLevel(level);
sfx.hit();
$('flash').style.opacity = 1; setTimeout(() => $('flash').style.opacity = 0, 250);
showMsg(why, 900, true);
if (health <= 0) {
state = 'over'; say('failure'); music.pause();
$('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; combo = 0; timers = []; debris = [];
clearRound();
addScore(0); setHealth(3); setLevel(0);
$('over').style.display = 'none';
if (music.paused) music.play().catch(()=>{});
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);
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());
targetQ = brick.quaternion.clone();
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.position.set((Math.random()-0.5)*30, (Math.random()-0.5)*16, -130);
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);
state = 'incoming';
if (agent) showMsg('AGENT — DODGE', 900, true); else { showMsg('INCOMING', 700); say('incoming'); }
});
}
function startHold() {
state = 'hold';
addScore(25); sfx.catch();
showMsg('CAUGHT — FIT IT', 900);
wall = makeWall(silhouette(brick.userData.cells, randomOrientation()));
wall.position.z = -120;
scene.add(wall);
}
function throwBrick() {
state = 'thrown'; sfx.throw();
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) {
sfx.shatter();
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;
sfx.rotate();
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 === 'title') {
initAudio(); say('wake');
$('title').style.display = 'none';
reset();
return;
}
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) {
if (brick.userData.agent) { shatter(brick); brick = null; damage('NEVER TOUCH AN AGENT'); if (state !== 'over') spawnIncoming(); }
else 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);
flyby(dt);
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') {
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);
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;
scene.remove(brick); brick = null;
if (hit) damage('IMPACT');
else if (agent) { addScore(10); sfx.dodge(); showMsg('DODGED +10', 700); }
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) {
if (setsEqual(silhouette(brick.userData.cells, targetQ), wall.userData.holeSil)) {
combo++; setLevel(level + 1);
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;
wall.userData.dead = true;
after(1200, () => { if (brick) scene.remove(brick), brick = null; });
state = 'pass';
} else {
showMsg('WRONG SHAPE', 900, true);
combo = 0; setLevel(level);
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, 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'; };
tick();
</script>
</body>
</html>