← back to Games Agentabrams
games/tetris/index.html
512 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Abrams Blocks — Tetris</title>
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; margin: 0; }
body {
display: flex; align-items: center; justify-content: center;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: radial-gradient(120% 120% at 50% 0%, #1b2030 0%, #0c0e15 70%);
color: #e8e9ef; overflow: hidden; user-select: none; touch-action: none;
}
#wrap {
display: flex; gap: 18px; padding: 16px;
align-items: flex-start; max-width: 100%;
}
canvas { display: block; }
#board {
background: #0a0c12;
border: 1px solid #2b3140; border-radius: 10px;
box-shadow: 0 0 0 1px #000 inset, 0 20px 60px rgba(0,0,0,.5);
}
#side {
display: flex; flex-direction: column; gap: 14px; width: 138px;
}
.card {
background: linear-gradient(180deg, #171b26, #12151d);
border: 1px solid #2b3140; border-radius: 10px; padding: 10px 12px;
}
.card h2 {
margin: 0 0 6px; font-size: 9.5px; font-weight: 700; letter-spacing: 2px;
opacity: .45; text-transform: uppercase;
}
.stat { font-size: 22px; font-weight: 700; letter-spacing: 1px; line-height: 1.1; }
.stat.small { font-size: 15px; }
.pcanvas { display: block; margin: 0 auto; }
#keys { font-size: 9.5px; opacity: .4; line-height: 1.9; letter-spacing: .5px; }
#keys b { color: #7c5cff; font-weight: 700; }
#brand { font-size: 10px; letter-spacing: 2px; opacity: .5; text-align: center; }
#brand .dot { color: #7c5cff; }
#btns { display: flex; gap: 8px; }
#btns button {
flex: 1; background: #1d2230; border: 1px solid #2f3648; color: #cfd3df;
border-radius: 8px; padding: 7px 0; font: inherit; font-size: 11px; cursor: pointer;
}
#btns button:hover { background: #262d40; }
/* overlay */
#overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(8,10,16,.72); backdrop-filter: blur(2px); z-index: 5;
}
#overlay.hidden { display: none; }
#panel {
text-align: center; padding: 28px 34px; border-radius: 14px;
background: linear-gradient(180deg, #1a1f2c, #12151d);
border: 1px solid #313a52; box-shadow: 0 24px 70px rgba(0,0,0,.6);
max-width: 320px;
}
#panel .title { font-size: 26px; font-weight: 800; letter-spacing: 2px; margin-bottom: 4px; }
#panel .title .dot { color: #7c5cff; }
#panel .sub { font-size: 11px; opacity: .55; line-height: 1.8; margin: 10px 0 18px; }
#panel button {
background: #7c5cff; border: 0; color: #fff; font: inherit; font-weight: 700;
letter-spacing: 1px; padding: 11px 26px; border-radius: 10px; cursor: pointer; font-size: 13px;
}
#panel button:hover { background: #8f72ff; }
#panel .score { font-size: 13px; opacity: .8; margin-bottom: 4px; }
#panel .score b { color: #ffd166; }
@media (max-width: 620px) {
#wrap { gap: 10px; padding: 8px; }
#side { width: 84px; gap: 8px; }
.card { padding: 7px 8px; }
.stat { font-size: 16px; }
#keys { display: none; }
}
</style>
</head>
<body>
<div id="wrap" style="position:relative">
<canvas id="board"></canvas>
<div id="side">
<div id="brand">AGENT<span class="dot">·</span>ABRAMS</div>
<div class="card">
<h2>Score</h2><div class="stat" id="score">0</div>
</div>
<div class="card">
<h2>Best</h2><div class="stat small" id="best">0</div>
</div>
<div class="card" style="display:flex;gap:12px;justify-content:space-between">
<div><h2>Lines</h2><div class="stat small" id="lines">0</div></div>
<div><h2>Level</h2><div class="stat small" id="level">1</div></div>
</div>
<div class="card">
<h2>Next</h2><canvas id="next" class="pcanvas"></canvas>
</div>
<div class="card">
<h2>Hold</h2><canvas id="hold" class="pcanvas"></canvas>
</div>
<div id="btns">
<button id="pauseBtn">Pause</button>
<button id="muteBtn">🔊</button>
</div>
<div id="keys">
<b>← →</b> move<br>
<b>↑ / X</b> rotate cw<br>
<b>Z</b> rotate ccw<br>
<b>↓</b> soft drop<br>
<b>Space</b> hard drop<br>
<b>C</b> hold<br>
<b>P</b> pause · <b>M</b> mute
</div>
</div>
<div id="overlay">
<div id="panel">
<div class="title">ABRAMS<span class="dot">·</span>BLOCKS</div>
<div class="sub" id="panelSub">
Stack the falling tetrominoes, clear lines, chase the level.<br>
Arrows to move · ↑/Z rotate · Space hard-drop · C hold.
</div>
<div class="score" id="panelScore" style="display:none"></div>
<button id="startBtn">Play</button>
</div>
</div>
</div>
<script>
(() => {
"use strict";
// ---------- constants ----------
const COLS = 10, ROWS = 20, HIDDEN = 2; // 2 hidden rows above the top
const TOTAL_ROWS = ROWS + HIDDEN;
const COLORS = {
I: "#38e1e1", O: "#f5d442", T: "#b46bff", S: "#5be36a",
Z: "#ff5d6c", J: "#4d7dff", L: "#ff9f43", G: "#2a3145"
};
// SRS piece definitions — each is a list of 4 rotation states, cells as [x,y] in a 4x4 box
const SHAPES = {
I: [[[0,1],[1,1],[2,1],[3,1]], [[2,0],[2,1],[2,2],[2,3]], [[0,2],[1,2],[2,2],[3,2]], [[1,0],[1,1],[1,2],[1,3]]],
J: [[[0,0],[0,1],[1,1],[2,1]], [[1,0],[2,0],[1,1],[1,2]], [[0,1],[1,1],[2,1],[2,2]], [[1,0],[1,1],[0,2],[1,2]]],
L: [[[2,0],[0,1],[1,1],[2,1]], [[1,0],[1,1],[1,2],[2,2]], [[0,1],[1,1],[2,1],[0,2]], [[0,0],[1,0],[1,1],[1,2]]],
O: [[[1,0],[2,0],[1,1],[2,1]], [[1,0],[2,0],[1,1],[2,1]], [[1,0],[2,0],[1,1],[2,1]], [[1,0],[2,0],[1,1],[2,1]]],
S: [[[1,0],[2,0],[0,1],[1,1]], [[1,0],[1,1],[2,1],[2,2]], [[1,1],[2,1],[0,2],[1,2]], [[0,0],[0,1],[1,1],[1,2]]],
T: [[[1,0],[0,1],[1,1],[2,1]], [[1,0],[1,1],[2,1],[1,2]], [[0,1],[1,1],[2,1],[1,2]], [[1,0],[0,1],[1,1],[1,2]]],
Z: [[[0,0],[1,0],[1,1],[2,1]], [[2,0],[1,1],[2,1],[1,2]], [[0,1],[1,1],[1,2],[2,2]], [[1,0],[0,1],[1,1],[0,2]]]
};
// SRS wall-kick data (JLSTZ). I-piece has its own table.
const KICKS = {
"0>1":[[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]], "1>0":[[0,0],[1,0],[1,-1],[0,2],[1,2]],
"1>2":[[0,0],[1,0],[1,-1],[0,2],[1,2]], "2>1":[[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
"2>3":[[0,0],[1,0],[1,1],[0,-2],[1,-2]], "3>2":[[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
"3>0":[[0,0],[-1,0],[-1,-1],[0,2],[-1,2]], "0>3":[[0,0],[1,0],[1,1],[0,-2],[1,-2]]
};
const KICKS_I = {
"0>1":[[0,0],[-2,0],[1,0],[-2,-1],[1,2]], "1>0":[[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
"1>2":[[0,0],[-1,0],[2,0],[-1,2],[2,-1]], "2>1":[[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
"2>3":[[0,0],[2,0],[-1,0],[2,1],[-1,-2]], "3>2":[[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
"3>0":[[0,0],[1,0],[-2,0],[1,-2],[-2,1]], "0>3":[[0,0],[-1,0],[2,0],[-1,2],[2,-1]]
};
const TYPES = ["I","J","L","O","S","T","Z"];
// ---------- canvas sizing ----------
const board = document.getElementById("board");
const bctx = board.getContext("2d");
let CELL = 30; // px, recomputed on resize
const nextCv = document.getElementById("next"), nctx = nextCv.getContext("2d");
const holdCv = document.getElementById("hold"), hctx = holdCv.getContext("2d");
function fitCanvas() {
const maxH = Math.min(window.innerHeight - 40, 720);
const maxW = Math.min(window.innerWidth - 190, 480);
CELL = Math.max(14, Math.floor(Math.min(maxH / ROWS, maxW / COLS)));
const dpr = window.devicePixelRatio || 1;
board.width = COLS * CELL * dpr; board.height = ROWS * CELL * dpr;
board.style.width = COLS * CELL + "px"; board.style.height = ROWS * CELL + "px";
bctx.setTransform(dpr,0,0,dpr,0,0);
const pc = Math.max(12, Math.floor(CELL * 0.62));
for (const [cv,ctx] of [[nextCv,nctx],[holdCv,hctx]]) {
cv.width = 4*pc*dpr; cv.height = 4*pc*dpr;
cv.style.width = 4*pc+"px"; cv.style.height = 4*pc+"px";
ctx.setTransform(dpr,0,0,dpr,0,0);
}
nctx._c = pc; hctx._c = pc;
draw();
}
window.addEventListener("resize", fitCanvas);
// ---------- audio (procedural, zero-dep) ----------
let audioCtx = null, muted = (localStorage.getItem("blocks.muted") === "1");
function ac() { if (!audioCtx) { try { audioCtx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){} } return audioCtx; }
function beep(freq, dur, type="square", vol=0.05) {
if (muted) return; const c = ac(); if (!c) return;
const o = c.createOscillator(), g = c.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(vol, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
o.connect(g); g.connect(c.destination); o.start(); o.stop(c.currentTime + dur);
}
const S = {
move: () => beep(220, .03, "square", .03),
rotate:() => beep(330, .04, "square", .035),
drop: () => beep(140, .08, "sawtooth", .05),
lock: () => beep(180, .05, "triangle", .04),
clear: (n) => { const base=[440,554,659,880][Math.min(n-1,3)]; beep(base,.10,"square",.06); setTimeout(()=>beep(base*1.5,.12,"square",.05),90); },
hold: () => beep(400, .05, "triangle", .04),
over: () => { beep(200,.2,"sawtooth",.06); setTimeout(()=>beep(120,.35,"sawtooth",.06),160); },
level: () => { beep(660,.08,"square",.05); setTimeout(()=>beep(990,.12,"square",.05),80); }
};
// ---------- game state ----------
let grid, cur, nextQ, holdType, canHold, bag;
let score, best, lines, level, dropMs, dropAcc, lastT;
let state = "menu"; // menu | playing | paused | over
best = +(localStorage.getItem("blocks.best") || 0);
function newBag() {
const b = TYPES.slice();
for (let i=b.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [b[i],b[j]]=[b[j],b[i]]; }
return b;
}
function pullType() {
if (!bag || bag.length === 0) bag = newBag();
return bag.pop();
}
function spawn(type) {
const p = { type, rot: 0, x: 3, y: 0 };
// I and O spawn a touch higher/centered per SRS box; y offset into hidden area
p.y = (type === "I") ? -1 : 0;
return p;
}
function cellsOf(p, rot = p.rot, x = p.x, y = p.y) {
return SHAPES[p.type][((rot % 4) + 4) % 4].map(([cx,cy]) => [x+cx, y+cy]);
}
function collides(p, rot = p.rot, x = p.x, y = p.y) {
for (const [cx,cy] of cellsOf(p, rot, x, y)) {
if (cx < 0 || cx >= COLS || cy >= ROWS) return true;
if (cy >= 0 && grid[cy][cx]) return true;
}
return false;
}
function reset() {
grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
bag = newBag();
nextQ = [pullType(), pullType(), pullType()];
holdType = null; canHold = true;
score = 0; lines = 0; level = 1;
dropMs = levelSpeed(1); dropAcc = 0;
nextPiece();
}
function levelSpeed(lv) { return Math.max(60, Math.round(800 * Math.pow(0.83, lv - 1))); }
function nextPiece() {
const t = nextQ.shift();
nextQ.push(pullType());
cur = spawn(t);
canHold = true;
if (collides(cur)) { gameOver(); }
}
function move(dx) {
if (!collides(cur, cur.rot, cur.x + dx, cur.y)) { cur.x += dx; S.move(); draw(); return true; }
return false;
}
function rotate(dir) {
const from = cur.rot, to = ((from + dir) % 4 + 4) % 4;
const table = cur.type === "I" ? KICKS_I : KICKS;
if (cur.type === "O") { return; }
const kicks = table[`${from}>${to}`] || [[0,0]];
for (const [kx,ky] of kicks) {
if (!collides(cur, to, cur.x + kx, cur.y - ky)) {
cur.rot = to; cur.x += kx; cur.y -= ky; S.rotate(); draw(); return;
}
}
}
function softDrop() {
if (!collides(cur, cur.rot, cur.x, cur.y + 1)) { cur.y++; score += 1; updateHUD(); draw(); }
else lock();
}
function hardDrop() {
let d = 0;
while (!collides(cur, cur.rot, cur.x, cur.y + 1)) { cur.y++; d++; }
score += d * 2; S.drop();
lock();
}
function hold() {
if (!canHold) return;
S.hold();
if (holdType == null) { holdType = cur.type; nextPiece(); }
else { const t = holdType; holdType = cur.type; cur = spawn(t); if (collides(cur)) gameOver(); }
canHold = false;
draw();
}
function lock() {
for (const [cx,cy] of cellsOf(cur)) { if (cy >= 0) grid[cy][cx] = cur.type; }
S.lock();
// clear lines
let cleared = 0;
for (let y = ROWS - 1; y >= 0; y--) {
if (grid[y].every(c => c)) {
grid.splice(y, 1); grid.unshift(Array(COLS).fill(null)); cleared++; y++;
}
}
if (cleared > 0) {
const pts = [0, 100, 300, 500, 800][cleared] * level;
score += pts; lines += cleared;
const newLevel = Math.floor(lines / 10) + 1;
if (newLevel > level) { level = newLevel; dropMs = levelSpeed(level); S.level(); }
else S.clear(cleared);
}
updateHUD();
if (state === "playing") nextPiece();
draw();
}
function ghostY() {
let y = cur.y;
while (!collides(cur, cur.rot, cur.x, y + 1)) y++;
return y;
}
// ---------- rendering ----------
function roundedCell(ctx, x, y, s, color, alpha = 1) {
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
const r = Math.max(2, s * 0.14), pad = Math.max(1, s * 0.06);
const px = x + pad, py = y + pad, ps = s - pad * 2;
ctx.beginPath();
ctx.roundRect(px, py, ps, ps, r);
ctx.fill();
// top highlight
ctx.globalAlpha = alpha * 0.28; ctx.fillStyle = "#ffffff";
ctx.beginPath(); ctx.roundRect(px, py, ps, ps * 0.42, r); ctx.fill();
ctx.globalAlpha = 1;
}
function draw() {
bctx.clearRect(0, 0, COLS * CELL, ROWS * CELL);
// grid dots
bctx.strokeStyle = "rgba(255,255,255,0.03)";
bctx.lineWidth = 1;
for (let x = 1; x < COLS; x++) { bctx.beginPath(); bctx.moveTo(x*CELL,0); bctx.lineTo(x*CELL,ROWS*CELL); bctx.stroke(); }
for (let y = 1; y < ROWS; y++) { bctx.beginPath(); bctx.moveTo(0,y*CELL); bctx.lineTo(COLS*CELL,y*CELL); bctx.stroke(); }
// settled
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
if (grid[y][x]) roundedCell(bctx, x*CELL, y*CELL, CELL, COLORS[grid[y][x]]);
}
if (cur && (state === "playing" || state === "paused")) {
// ghost
const gy = ghostY();
for (const [cx,cy] of cellsOf(cur, cur.rot, cur.x, gy)) {
if (cy >= 0) roundedCell(bctx, cx*CELL, cy*CELL, CELL, COLORS[cur.type], 0.18);
}
// active
for (const [cx,cy] of cellsOf(cur)) {
if (cy >= 0) roundedCell(bctx, cx*CELL, cy*CELL, CELL, COLORS[cur.type]);
}
}
if (state === "paused") {
bctx.fillStyle = "rgba(8,10,16,.6)";
bctx.fillRect(0,0,COLS*CELL,ROWS*CELL);
bctx.fillStyle = "#e8e9ef"; bctx.font = `700 ${CELL*0.7}px ui-monospace, monospace`;
bctx.textAlign = "center"; bctx.textBaseline = "middle";
bctx.fillText("PAUSED", COLS*CELL/2, ROWS*CELL/2);
}
drawPreview(nctx, nextQ && nextQ[0]);
drawPreview(hctx, holdType, !canHold && holdType);
}
function drawPreview(ctx, type, dim) {
const pc = ctx._c || 18;
ctx.clearRect(0,0,4*pc,4*pc);
if (!type) return;
const cells = SHAPES[type][0];
const xs = cells.map(c=>c[0]), ys = cells.map(c=>c[1]);
const minx = Math.min(...xs), maxx = Math.max(...xs);
const miny = Math.min(...ys), maxy = Math.max(...ys);
const w = maxx - minx + 1, h = maxy - miny + 1;
const ox = (4 - w) / 2 - minx, oy = (4 - h) / 2 - miny;
for (const [cx,cy] of cells) roundedCell(ctx, (cx+ox)*pc, (cy+oy)*pc, pc, COLORS[type], dim ? 0.35 : 1);
}
function updateHUD() {
document.getElementById("score").textContent = score.toLocaleString();
document.getElementById("lines").textContent = lines;
document.getElementById("level").textContent = level;
if (score > best) { best = score; localStorage.setItem("blocks.best", best); }
document.getElementById("best").textContent = best.toLocaleString();
}
// ---------- loop ----------
function tick(t) {
if (state !== "playing") { lastT = t; requestAnimationFrame(tick); return; }
const dt = t - lastT; lastT = t;
dropAcc += dt;
if (dropAcc >= dropMs) {
dropAcc = 0;
if (!collides(cur, cur.rot, cur.x, cur.y + 1)) cur.y++;
else lock();
draw();
}
requestAnimationFrame(tick);
}
// ---------- state transitions ----------
const overlay = document.getElementById("overlay");
const panelSub = document.getElementById("panelSub");
const panelScore = document.getElementById("panelScore");
const startBtn = document.getElementById("startBtn");
function startGame() {
ac();
reset(); updateHUD();
state = "playing"; lastT = performance.now(); dropAcc = 0;
overlay.classList.add("hidden");
draw();
}
function gameOver() {
state = "over"; S.over();
panelScore.style.display = "block";
panelScore.innerHTML = `Score <b>${score.toLocaleString()}</b> · Best <b>${best.toLocaleString()}</b> · Lines ${lines}`;
document.querySelector("#panel .title").innerHTML = 'GAME<span class="dot">·</span>OVER';
panelSub.innerHTML = "The stack topped out.<br>Clear lines faster to survive higher levels.";
startBtn.textContent = "Play Again";
overlay.classList.remove("hidden");
}
function togglePause() {
if (state === "playing") { state = "paused"; draw(); document.getElementById("pauseBtn").textContent = "Resume"; }
else if (state === "paused") { state = "playing"; lastT = performance.now(); document.getElementById("pauseBtn").textContent = "Pause"; draw(); }
}
// ---------- input ----------
const KEYMAP = {
ArrowLeft: () => move(-1), ArrowRight: () => move(1),
ArrowDown: () => softDrop(),
ArrowUp: () => rotate(1), x: () => rotate(1), X: () => rotate(1),
z: () => rotate(-1), Z: () => rotate(-1),
c: () => hold(), C: () => hold(),
" ": () => hardDrop()
};
window.addEventListener("keydown", (e) => {
if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"," "].includes(e.key)) e.preventDefault();
if (e.key === "p" || e.key === "P" || e.key === "Escape") { if (state==="playing"||state==="paused") togglePause(); return; }
if (e.key === "m" || e.key === "M") { toggleMute(); return; }
if (e.repeat && (e.key===" "||e.key==="c"||e.key==="C"||e.key==="z"||e.key==="Z"||e.key==="x"||e.key==="X"||e.key==="ArrowUp")) return;
if (state !== "playing") { if ((e.key===" "||e.key==="Enter") && state!=="playing") startBtn.click(); return; }
const fn = KEYMAP[e.key]; if (fn) fn();
});
function toggleMute() {
muted = !muted; localStorage.setItem("blocks.muted", muted ? "1" : "0");
document.getElementById("muteBtn").textContent = muted ? "🔇" : "🔊";
}
document.getElementById("pauseBtn").addEventListener("click", () => { if (state==="playing"||state==="paused") togglePause(); });
document.getElementById("muteBtn").addEventListener("click", toggleMute);
startBtn.addEventListener("click", startGame);
// auto-pause when tab/frame loses focus
window.addEventListener("blur", () => { if (state === "playing") togglePause(); });
// ---------- touch controls ----------
let tStart = null, tMoved = false, lastTapT = 0;
const SWIPE = 24;
board.addEventListener("touchstart", (e) => {
if (state === "menu" || state === "over") { startBtn.click(); return; }
if (state === "paused") { togglePause(); return; }
tStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, t: performance.now(), cx: cur.x }; tMoved = false;
}, { passive: true });
board.addEventListener("touchmove", (e) => {
if (!tStart || state !== "playing") return;
const dx = e.touches[0].clientX - tStart.x, dy = e.touches[0].clientY - tStart.y;
if (Math.abs(dx) > Math.abs(dy)) {
const target = tStart.cx + Math.round(dx / (CELL));
while (cur.x < target) { if (!move(1)) break; }
while (cur.x > target) { if (!move(-1)) break; }
if (Math.abs(dx) > SWIPE) tMoved = true;
} else if (dy > SWIPE * 2) { softDrop(); tStart.y = e.touches[0].clientY; tMoved = true; }
}, { passive: true });
board.addEventListener("touchend", (e) => {
if (!tStart || state !== "playing") { tStart = null; return; }
const dur = performance.now() - tStart.t;
const dy = (e.changedTouches[0].clientY - tStart.y);
if (!tMoved && dur < 250) {
// tap = rotate; double-tap-ish fast downward fling handled above
rotate(1);
} else if (dy > CELL * 6 && dur < 260) {
hardDrop();
}
tStart = null;
}, { passive: true });
// ---------- boot ----------
document.getElementById("muteBtn").textContent = muted ? "🔇" : "🔊";
document.getElementById("best").textContent = best.toLocaleString();
// idle empty board behind the menu — init grid BEFORE first draw (fitCanvas draws)
grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
fitCanvas();
draw();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>