← back to Games Agentabrams
games/tower-stack/AbramsStack.aa
515 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Abrams Stack</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a18; }
#wrap {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at 50% 0%, #1a1240 0%, #0a0a18 60%, #050510 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
user-select: none;
}
canvas { display: block; touch-action: none; }
/* HUD */
#hud {
position: absolute; top: 0; left: 0; right: 0;
display: flex; justify-content: space-between; align-items: flex-start;
padding: 18px 20px; pointer-events: none; z-index: 5;
}
.stat { color: #fff; text-shadow: 0 2px 12px rgba(0,0,0,.6); }
.stat .label { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; opacity: .55; }
.stat .val { font-size: 34px; font-weight: 800; line-height: 1; margin-top: 2px;
background: linear-gradient(180deg,#fff,#b9c4ff); -webkit-background-clip: text; background-clip: text; color: transparent; }
.stat.right { text-align: right; }
#combo {
position:absolute; top: 78px; left: 50%; transform: translateX(-50%);
font-size: 15px; font-weight: 700; letter-spacing: 1px; color: #ffe27a;
text-shadow: 0 0 18px rgba(255,210,80,.7); opacity: 0; transition: opacity .2s; pointer-events:none; z-index:5;
}
/* Mute */
#mute {
position: absolute; top: 16px; right: 50%; transform: translateX(50%);
width: 42px; height: 42px; border-radius: 12px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.14);
color: #fff; font-size: 19px; cursor: pointer; z-index: 6;
display:flex; align-items:center; justify-content:center; backdrop-filter: blur(6px);
transition: background .15s, transform .1s;
}
#mute:hover { background: rgba(255,255,255,.14); }
#mute:active { transform: translateX(50%) scale(.9); }
/* Overlay */
#overlay {
position: absolute; inset: 0; z-index: 10;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px;
background: radial-gradient(ellipse at 50% 40%, rgba(30,20,70,.55), rgba(5,5,16,.9));
backdrop-filter: blur(3px);
transition: opacity .35s;
}
#overlay.hidden { opacity: 0; pointer-events: none; }
#title {
font-size: clamp(42px, 12vw, 92px); font-weight: 900; letter-spacing: -1px; line-height: .95;
background: linear-gradient(120deg,#7af0ff,#a97bff 45%,#ff7ac6 90%);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 4px 26px rgba(160,110,255,.45));
}
#subtitle { margin-top: 14px; font-size: 15px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,.6); }
#big {
margin: 26px 0 6px; font-size: clamp(34px,9vw,60px); font-weight: 900; color:#fff;
text-shadow: 0 0 30px rgba(140,110,255,.6);
}
#ovBest { font-size: 14px; letter-spacing: 2px; color:#8fe0ff; text-transform: uppercase; }
#hint {
margin-top: 34px; font-size: 15px; color: rgba(255,255,255,.82);
padding: 14px 26px; border: 1px solid rgba(255,255,255,.16); border-radius: 50px;
background: rgba(255,255,255,.05); animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{ transform: scale(1); opacity:.85 } 50%{ transform: scale(1.05); opacity:1 } }
#credit { position:absolute; bottom: 16px; font-size: 11px; letter-spacing: 2px; color: rgba(255,255,255,.28); text-transform: uppercase; }
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
<div id="hud">
<div class="stat">
<div class="label">Height</div>
<div class="val" id="score">0</div>
</div>
<div class="stat right">
<div class="label">Best</div>
<div class="val" id="best">0</div>
</div>
</div>
<div id="combo"></div>
<button id="mute" aria-label="Toggle sound">🔊</button>
<div id="overlay">
<div id="title">ABRAMS<br>STACK</div>
<div id="subtitle">stack it high</div>
<div id="big" style="display:none"></div>
<div id="ovBest"></div>
<div id="hint">Tap / Click / Space to drop</div>
</div>
<div id="credit">Pure Canvas 2D · Zero Dependencies</div>
</div>
<script>
(function () {
"use strict";
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var scoreEl = document.getElementById("score");
var bestEl = document.getElementById("best");
var comboEl = document.getElementById("combo");
var overlay = document.getElementById("overlay");
var titleEl = document.getElementById("title");
var subtitleEl = document.getElementById("subtitle");
var bigEl = document.getElementById("big");
var ovBestEl = document.getElementById("ovBest");
var hintEl = document.getElementById("hint");
var muteBtn = document.getElementById("mute");
// ---------- Sizing (DPR aware, iframe responsive) ----------
// Cap to a centered ~2:3 portrait column on desktop (letterboxed); full-width on phones.
var W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
H = window.innerHeight;
W = Math.min(window.innerWidth, Math.round(H * 0.66));
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener("resize", resize);
resize();
// ---------- Persistent best ----------
var best = 0;
try { best = parseInt(localStorage.getItem("abramsStackBest") || "0", 10) || 0; } catch (e) {}
bestEl.textContent = best;
// ---------- Audio (procedural, optional) ----------
var muted = false;
try { muted = localStorage.getItem("abramsStackMuted") === "1"; } catch (e) {}
muteBtn.textContent = muted ? "🔇" : "🔊";
var actx = null;
function ensureAudio() {
if (muted) return;
if (!actx) {
try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; }
}
if (actx && actx.state === "suspended") actx.resume();
}
function tone(freq, dur, type, vol, glideTo) {
if (muted || !actx) return;
var t = actx.currentTime;
var o = actx.createOscillator();
var g = actx.createGain();
o.type = type || "sine";
o.frequency.setValueAtTime(freq, t);
if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol || 0.2, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(actx.destination);
o.start(t); o.stop(t + dur + 0.02);
}
function sndDrop() { tone(220, 0.14, "sine", 0.22, 150); }
function sndSlice() { tone(140, 0.12, "sawtooth", 0.14, 90); }
function sndPerfect(n) {
if (muted || !actx) return;
var base = 520 + Math.min(n, 8) * 40;
tone(base, 0.14, "triangle", 0.22);
setTimeout(function () { tone(base * 1.5, 0.16, "triangle", 0.18); }, 70);
}
function sndOver() {
tone(300, 0.5, "sawtooth", 0.2, 70);
setTimeout(function () { tone(160, 0.5, "sine", 0.15, 60); }, 90);
}
muteBtn.addEventListener("click", function (e) {
e.stopPropagation();
muted = !muted;
muteBtn.textContent = muted ? "🔇" : "🔊";
try { localStorage.setItem("abramsStackMuted", muted ? "1" : "0"); } catch (er) {}
if (!muted) ensureAudio();
});
// ---------- Game state ----------
var STATE_START = 0, STATE_PLAY = 1, STATE_OVER = 2;
var state = STATE_START;
var BLOCK_H = 46; // logical pixel height of each block
var baseWidth = 0; // starting block width
var blocks = []; // placed blocks {x, w, hue}
var current = null; // moving block {x, w, dir, hue}
var speed = 0;
var camY = 0; // camera vertical offset (target)
var camYCur = 0; // smoothed
var score = 0;
var combo = 0;
var particles = [];
var slices = []; // falling sliced-off pieces
var flash = 0; // perfect flash intensity
function hueForLevel(n) { return (200 + n * 9) % 360; }
function groundY() { return H - 90; } // y of top of base row in world space (before camera)
function reset() {
blocks = [];
slices = [];
particles = [];
score = 0;
combo = 0;
camY = 0; camYCur = 0;
flash = 0;
baseWidth = Math.min(W * 0.5, 300);
speed = Math.max(2.6, W / 300);
var bx = (W - baseWidth) / 2;
blocks.push({ x: bx, w: baseWidth, hue: hueForLevel(0) });
spawnCurrent();
}
function spawnCurrent() {
var prev = blocks[blocks.length - 1];
var fromLeft = blocks.length % 2 === 0;
var startX = fromLeft ? -prev.w : W;
current = {
x: fromLeft ? -prev.w * 0.6 : W - prev.w * 0.4,
w: prev.w,
dir: fromLeft ? 1 : -1,
hue: hueForLevel(blocks.length)
};
// speed scales with height
speed = Math.max(2.6, W / 300) * (1 + blocks.length * 0.035);
}
// world Y (bottom-up) for the top of a block at index i (i=0 is base)
function blockWorldY(i) { return groundY() - i * BLOCK_H; }
function showCombo(txt) {
comboEl.textContent = txt;
comboEl.style.opacity = "1";
clearTimeout(showCombo._t);
showCombo._t = setTimeout(function () { comboEl.style.opacity = "0"; }, 800);
}
function drop() {
if (state !== STATE_PLAY) return;
var prev = blocks[blocks.length - 1];
var cx = current.x, cw = current.w;
var overlapL = Math.max(cx, prev.x);
var overlapR = Math.min(cx + cw, prev.x + prev.w);
var overlap = overlapR - overlapL;
if (overlap <= 0) {
// total miss -> game over. add the whole falling block as a slice
slices.push({ x: cx, w: cw, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: current.dir * 1.5, rot: 0, vr: current.dir * 0.05 });
gameOver();
return;
}
// perfect detection
var diff = Math.abs(cx - prev.x);
var perfect = diff < 4;
if (perfect) {
combo++;
flash = 1;
sndPerfect(combo);
// snap to perfect alignment (no shrink), small bonus width back on combo
cx = prev.x;
showCombo(combo > 1 ? "PERFECT ×" + combo + " +" + (combo) : "PERFECT!");
spawnParticles(cx + cw / 2, blockWorldY(blocks.length - 1) - BLOCK_H / 2, current.hue, 22, true);
} else {
combo = 0;
sndDrop();
// slice off the overhang
if (cx < prev.x) {
// overhang on the left
var offW = prev.x - cx;
slices.push({ x: cx, w: offW, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: -1.4, rot: 0, vr: -0.06 });
} else {
var offW2 = (cx + cw) - (prev.x + prev.w);
if (offW2 > 0) slices.push({ x: prev.x + prev.w, w: offW2, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: 1.4, rot: 0, vr: 0.06 });
}
sndSlice();
cx = overlapL;
cw = overlap;
spawnParticles(overlapL + overlap / 2, blockWorldY(blocks.length - 1) - BLOCK_H / 2, current.hue, 8, false);
}
blocks.push({ x: cx, w: cw, hue: current.hue });
score++;
scoreEl.textContent = score;
// camera pans up as tower grows (keep top few visible)
var visibleRows = Math.max(4, Math.floor((H - 200) / BLOCK_H));
if (blocks.length > visibleRows) {
camY = (blocks.length - visibleRows) * BLOCK_H;
}
spawnCurrent();
}
function spawnParticles(x, worldY, hue, count, sparkle) {
for (var i = 0; i < count; i++) {
var a = Math.random() * Math.PI * 2;
var sp = (sparkle ? 2 + Math.random() * 4 : 1 + Math.random() * 2.5);
particles.push({
x: x, wy: worldY,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - (sparkle ? 1.5 : 0.5),
life: 1, hue: hue, size: 2 + Math.random() * (sparkle ? 4 : 2)
});
}
}
function gameOver() {
state = STATE_OVER;
sndOver();
if (score > best) {
best = score;
try { localStorage.setItem("abramsStackBest", String(best)); } catch (e) {}
bestEl.textContent = best;
}
titleEl.style.display = "none";
subtitleEl.style.display = "none";
bigEl.style.display = "block";
bigEl.textContent = "Height " + score;
ovBestEl.textContent = (score >= best && score > 0 ? "★ NEW BEST ★" : "Best " + best);
hintEl.textContent = "Tap / Click / Space to retry";
overlay.classList.remove("hidden");
}
function startGame() {
ensureAudio();
overlay.classList.add("hidden");
titleEl.style.display = "";
subtitleEl.style.display = "";
bigEl.style.display = "none";
reset();
state = STATE_PLAY;
}
// ---------- Input ----------
function onAction(e) {
if (e && e.target === muteBtn) return;
if (e) e.preventDefault();
ensureAudio();
if (state === STATE_PLAY) {
drop();
} else {
startGame();
}
}
window.addEventListener("pointerdown", onAction, { passive: false });
window.addEventListener("keydown", function (e) {
if (e.code === "Space" || e.key === " " || e.code === "Enter") {
e.preventDefault();
onAction();
}
});
// ---------- Render helpers ----------
function roundRect(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawBlock(x, screenY, w, hue, glow) {
var top = "hsl(" + hue + ",78%,64%)";
var bot = "hsl(" + hue + ",70%,44%)";
var grad = ctx.createLinearGradient(0, screenY, 0, screenY + BLOCK_H);
grad.addColorStop(0, top);
grad.addColorStop(1, bot);
if (glow) {
ctx.save();
ctx.shadowColor = "hsla(" + hue + ",90%,65%,0.9)";
ctx.shadowBlur = 26;
}
ctx.fillStyle = grad;
roundRect(x, screenY, w, BLOCK_H - 3, 7);
ctx.fill();
if (glow) ctx.restore();
// top highlight
ctx.fillStyle = "hsla(" + hue + ",90%,80%,0.5)";
roundRect(x + 3, screenY + 2, w - 6, 5, 3);
ctx.fill();
}
// ---------- Loop ----------
var lastT = performance.now();
function frame(now) {
var dt = Math.min((now - lastT) / 16.6667, 3); // normalized to 60fps
lastT = now;
// smooth camera
camYCur += (camY - camYCur) * Math.min(1, 0.12 * dt);
if (flash > 0) flash = Math.max(0, flash - 0.05 * dt);
// update moving block
if (state === STATE_PLAY && current) {
current.x += current.dir * speed * dt;
var prev = blocks[blocks.length - 1];
// bounce within a range slightly beyond the tower so it fully crosses
var leftLimit = -current.w * 0.9;
var rightLimit = W - current.w * 0.1;
if (current.x < leftLimit) { current.x = leftLimit; current.dir = 1; }
if (current.x > rightLimit) { current.x = rightLimit; current.dir = -1; }
}
// update particles
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.x += p.vx * dt;
p.wy += p.vy * dt;
p.vy += 0.18 * dt;
p.life -= 0.02 * dt;
if (p.life <= 0) particles.splice(i, 1);
}
// update slices (falling debris)
for (var s = slices.length - 1; s >= 0; s--) {
var sl = slices[s];
sl.vy += 0.55 * dt;
sl.y += sl.vy * dt;
sl.x += sl.vx * dt;
sl.rot += sl.vr * dt;
if (sl.y - camYCur > H + 200) slices.splice(s, 1);
}
draw();
requestAnimationFrame(frame);
}
function draw() {
ctx.clearRect(0, 0, W, H);
// subtle drifting bg glow tied to height
var topHue = hueForLevel(blocks.length);
var g = ctx.createRadialGradient(W / 2, H * 0.2, 40, W / 2, H * 0.2, Math.max(W, H));
g.addColorStop(0, "hsla(" + topHue + ",55%,22%,0.55)");
g.addColorStop(1, "rgba(5,5,16,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
function worldToScreen(wy) { return wy - camYCur; }
// placed blocks
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i];
var wy = blockWorldY(i); // world y of top of this block
var sy = worldToScreen(wy) - BLOCK_H;
if (sy > H + BLOCK_H || sy < -BLOCK_H) continue;
drawBlock(b.x, sy, b.w, b.hue, i === blocks.length - 1 && state === STATE_PLAY ? false : false);
}
// moving block
if (state === STATE_PLAY && current) {
var wyc = blockWorldY(blocks.length);
var syc = worldToScreen(wyc) - BLOCK_H;
drawBlock(current.x, syc, current.w, current.hue, true);
}
// slices (debris)
for (var s = 0; s < slices.length; s++) {
var sl = slices[s];
var sy2 = worldToScreen(sl.y);
ctx.save();
ctx.translate(sl.x + sl.w / 2, sy2 + BLOCK_H / 2);
ctx.rotate(sl.rot);
ctx.globalAlpha = 0.9;
var gr = ctx.createLinearGradient(0, -BLOCK_H / 2, 0, BLOCK_H / 2);
gr.addColorStop(0, "hsl(" + sl.hue + ",78%,60%)");
gr.addColorStop(1, "hsl(" + sl.hue + ",70%,42%)");
ctx.fillStyle = gr;
roundRect(-sl.w / 2, -BLOCK_H / 2, sl.w, BLOCK_H - 3, 6);
ctx.fill();
ctx.restore();
}
// particles
ctx.globalAlpha = 1;
for (var p = 0; p < particles.length; p++) {
var pa = particles[p];
ctx.globalAlpha = Math.max(0, pa.life);
ctx.fillStyle = "hsl(" + pa.hue + ",90%,68%)";
var py = pa.wy - camYCur;
ctx.beginPath();
ctx.arc(pa.x, py, pa.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// perfect flash overlay
if (flash > 0) {
ctx.fillStyle = "rgba(255,255,255," + (flash * 0.18) + ")";
ctx.fillRect(0, 0, W, H);
}
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>