← back to Games Agentabrams
games/whack-a-mole/AbramsWhack.aa
729 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 Whack</title>
<style>
:root{
--bg0:#080012;
--bg1:#12002a;
--neon:#00f0ff;
--neon2:#ff2bd6;
--neon3:#b14bff;
--good:#33ff9e;
--bad:#ff3b5c;
--gold:#ffd23f;
--ink:#eafcff;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;width:100%;overflow:hidden;}
body{
font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;
background:
radial-gradient(circle at 20% 10%, rgba(177,75,255,.28), transparent 45%),
radial-gradient(circle at 85% 90%, rgba(0,240,255,.24), transparent 45%),
linear-gradient(160deg,var(--bg1),var(--bg0));
color:var(--ink);
user-select:none;
display:flex;
align-items:center;
justify-content:center;
}
#app{
position:relative;
width:100%;
height:100%;
max-width:900px;
max-height:900px;
margin:auto;
display:flex;
flex-direction:column;
padding:clamp(8px,2vmin,20px);
}
/* HUD */
#hud{
display:flex;
align-items:center;
justify-content:space-between;
gap:clamp(6px,1.5vmin,14px);
flex-wrap:wrap;
padding:clamp(6px,1.4vmin,12px) clamp(8px,2vmin,16px);
border-radius:16px;
background:rgba(10,0,25,.45);
border:1px solid rgba(0,240,255,.25);
box-shadow:0 0 24px rgba(0,240,255,.12) inset;
backdrop-filter:blur(4px);
}
.stat{
display:flex;
flex-direction:column;
align-items:center;
line-height:1.05;
min-width:0;
}
.stat .label{
font-size:clamp(8px,1.6vmin,11px);
letter-spacing:.14em;
text-transform:uppercase;
opacity:.7;
}
.stat .val{
font-size:clamp(16px,4vmin,30px);
font-weight:800;
font-variant-numeric:tabular-nums;
text-shadow:0 0 10px currentColor;
}
#score .val{color:var(--neon);}
#best .val{color:var(--gold);}
#time .val{color:var(--good);}
#combo .val{color:var(--neon2);}
#time.low .val{color:var(--bad); animation:pulse .6s infinite;}
@keyframes pulse{50%{opacity:.35;}}
#lives{display:flex;gap:4px;font-size:clamp(14px,3vmin,22px);}
#muteBtn{
cursor:pointer;
border:1px solid rgba(255,255,255,.3);
background:rgba(255,255,255,.06);
color:var(--ink);
border-radius:10px;
padding:clamp(4px,1vmin,8px) clamp(6px,1.4vmin,10px);
font-size:clamp(12px,2.6vmin,18px);
line-height:1;
transition:.15s;
}
#muteBtn:hover{background:rgba(0,240,255,.15);border-color:var(--neon);}
#title{
text-align:center;
margin:clamp(4px,1.2vmin,10px) 0;
font-size:clamp(18px,5vmin,40px);
font-weight:900;
letter-spacing:.06em;
background:linear-gradient(90deg,var(--neon),var(--neon2),var(--neon3),var(--neon));
background-size:300% 100%;
-webkit-background-clip:text;
background-clip:text;
color:transparent;
animation:shift 6s linear infinite;
filter:drop-shadow(0 0 12px rgba(255,43,214,.4));
}
@keyframes shift{to{background-position:300% 0;}}
/* Board */
#boardWrap{
flex:1;
display:flex;
align-items:center;
justify-content:center;
min-height:0;
}
#board{
display:grid;
grid-template-columns:repeat(3,1fr);
grid-template-rows:repeat(3,1fr);
gap:clamp(6px,2vmin,18px);
width:min(100%,min(72vh,560px));
aspect-ratio:1/1;
}
.hole{
position:relative;
border-radius:50%;
background:
radial-gradient(circle at 50% 38%, #1a0033 0%, #0c0018 60%, #05000d 100%);
box-shadow:
0 0 0 2px rgba(0,240,255,.18),
inset 0 10px 26px rgba(0,0,0,.9),
inset 0 -6px 14px rgba(177,75,255,.18);
overflow:hidden;
cursor:pointer;
display:flex;
align-items:center;
justify-content:center;
transition:box-shadow .12s;
}
.hole::after{ /* rim glow */
content:"";
position:absolute;
inset:0;
border-radius:50%;
box-shadow:inset 0 6px 18px rgba(0,240,255,.10);
pointer-events:none;
}
.hole.hit{
box-shadow:
0 0 24px 6px rgba(51,255,158,.6),
inset 0 10px 26px rgba(0,0,0,.9);
}
.hole.badhit{
box-shadow:
0 0 24px 6px rgba(255,59,92,.7),
inset 0 10px 26px rgba(0,0,0,.9);
}
/* the pop target */
.target{
position:absolute;
left:50%;
bottom:-72%;
transform:translateX(-50%);
width:78%;
height:78%;
border-radius:50% 50% 46% 46%;
display:flex;
align-items:center;
justify-content:center;
font-size:clamp(22px,9vmin,64px);
transition:bottom .13s cubic-bezier(.2,.9,.3,1.4), transform .13s;
pointer-events:none;
will-change:bottom;
}
.target.up{ bottom:8%; }
.target.mole{
background:radial-gradient(circle at 50% 35%, var(--neon3), #4a007a 70%, #22003a);
box-shadow:0 0 18px rgba(177,75,255,.7), inset 0 -8px 12px rgba(0,0,0,.5), inset 0 6px 10px rgba(255,255,255,.15);
border:2px solid rgba(0,240,255,.5);
}
.target.bomb{
background:radial-gradient(circle at 50% 35%, #ff5a78, #b3001d 72%, #4a000c);
box-shadow:0 0 20px rgba(255,59,92,.85), inset 0 -8px 12px rgba(0,0,0,.5);
border:2px solid rgba(255,210,63,.7);
}
.target.gold{
background:radial-gradient(circle at 50% 35%, #fff2a8, var(--gold) 68%, #b98900);
box-shadow:0 0 26px rgba(255,210,63,.9), inset 0 -8px 12px rgba(0,0,0,.35);
border:2px solid #fff;
}
.target.whacked{ transform:translateX(-50%) scale(.55) rotate(8deg); opacity:0; transition:transform .18s, opacity .18s; }
/* floating score popup */
.pop{
position:absolute;
left:50%;
top:30%;
transform:translate(-50%,-50%);
font-weight:900;
font-size:clamp(14px,4.5vmin,30px);
pointer-events:none;
text-shadow:0 0 8px currentColor;
animation:floatUp .7s ease-out forwards;
z-index:5;
}
@keyframes floatUp{
0%{opacity:0; transform:translate(-50%,-30%) scale(.6);}
20%{opacity:1;}
100%{opacity:0; transform:translate(-50%,-160%) scale(1.1);}
}
/* ripple on whack */
.ripple{
position:absolute;
inset:0;
border-radius:50%;
pointer-events:none;
animation:rip .45s ease-out forwards;
}
@keyframes rip{
from{box-shadow:0 0 0 0 rgba(255,255,255,.6);}
to{box-shadow:0 0 0 40px rgba(255,255,255,0);}
}
/* Overlay */
#overlay{
position:absolute;
inset:0;
display:flex;
align-items:center;
justify-content:center;
background:rgba(4,0,14,.82);
backdrop-filter:blur(6px);
z-index:20;
text-align:center;
padding:20px;
}
#overlay.hidden{display:none;}
.panel{
max-width:440px;
width:100%;
background:linear-gradient(160deg, rgba(28,0,54,.9), rgba(8,0,20,.9));
border:1px solid rgba(0,240,255,.4);
border-radius:22px;
padding:clamp(20px,4vmin,36px);
box-shadow:0 0 40px rgba(177,75,255,.35), inset 0 0 30px rgba(0,240,255,.08);
}
.panel h1{
font-size:clamp(26px,7vmin,46px);
font-weight:900;
letter-spacing:.04em;
background:linear-gradient(90deg,var(--neon),var(--neon2));
-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 0 10px rgba(0,240,255,.5));
margin-bottom:8px;
}
.panel p{opacity:.85; font-size:clamp(12px,2.6vmin,15px); line-height:1.5; margin-bottom:16px;}
.legend{
display:flex;justify-content:center;gap:16px;flex-wrap:wrap;
margin-bottom:18px;font-size:clamp(11px,2.4vmin,14px);
}
.legend span{display:inline-flex;align-items:center;gap:6px;}
.dot{width:16px;height:16px;border-radius:50%;display:inline-block;box-shadow:0 0 8px currentColor;}
.dot.m{background:var(--neon3);color:var(--neon3);}
.dot.b{background:var(--bad);color:var(--bad);}
.dot.g{background:var(--gold);color:var(--gold);}
#finalScore{
font-size:clamp(28px,8vmin,52px);
font-weight:900;
color:var(--neon);
text-shadow:0 0 16px var(--neon);
margin:6px 0;
font-variant-numeric:tabular-nums;
}
#finalBest{color:var(--gold);font-weight:700;margin-bottom:16px;}
#newBest{
color:var(--gold);font-weight:900;letter-spacing:.1em;
animation:pulse .8s infinite; margin-bottom:8px; font-size:clamp(13px,3vmin,18px);
}
#newBest.hidden{display:none;}
.btn{
cursor:pointer;
border:none;
border-radius:14px;
padding:clamp(12px,2.6vmin,16px) clamp(24px,5vmin,40px);
font-size:clamp(15px,3.4vmin,22px);
font-weight:800;
letter-spacing:.05em;
color:#08001a;
background:linear-gradient(90deg,var(--neon),var(--neon2));
box-shadow:0 6px 24px rgba(255,43,214,.4);
transition:transform .1s, box-shadow .15s;
}
.btn:hover{transform:translateY(-2px); box-shadow:0 10px 30px rgba(0,240,255,.5);}
.btn:active{transform:translateY(1px) scale(.98);}
/* screen shake */
@keyframes shake{
0%,100%{transform:translate(0,0);}
25%{transform:translate(-4px,2px);}
50%{transform:translate(4px,-2px);}
75%{transform:translate(-3px,-1px);}
}
#app.shake{animation:shake .28s;}
</style>
</head>
<body>
<div id="app">
<div id="hud">
<div id="score" class="stat"><span class="label">Score</span><span class="val">0</span></div>
<div id="combo" class="stat"><span class="label">Combo</span><span class="val">x1</span></div>
<div id="title" style="flex:1;">ABRAMS WHACK</div>
<div id="time" class="stat"><span class="label">Time</span><span class="val">45</span></div>
<div id="best" class="stat"><span class="label">Best</span><span class="val">0</span></div>
<div id="livesWrap" class="stat"><span class="label">Lives</span><span id="lives" class="val"></span></div>
<button id="muteBtn" title="Mute / unmute">🔊</button>
</div>
<div id="boardWrap">
<div id="board"></div>
</div>
<div id="overlay">
<div class="panel">
<h1 id="ovTitle">ABRAMS WHACK</h1>
<div id="newBest" class="hidden">★ NEW HIGH SCORE ★</div>
<div id="finalScore" style="display:none;">0</div>
<div id="finalBest" style="display:none;"></div>
<p id="ovDesc">Whack the neon moles before they vanish. Hit fast for combo multipliers.
Dodge the red bombs — they cost a life. Grab the gold ones for big points.</p>
<div class="legend">
<span><i class="dot m"></i>Mole +pts</span>
<span><i class="dot g"></i>Gold jackpot</span>
<span><i class="dot b"></i>Bomb −life</span>
</div>
<button id="startBtn" class="btn">START</button>
</div>
</div>
</div>
<script>
(function(){
"use strict";
// ---------- Config ----------
const ROUND_TIME = 45; // seconds
const HOLES = 9; // 3x3
const START_LIVES = 3;
const BASE_POINTS = 10;
const GOLD_POINTS = 50;
const MISS_PENALTY = 5; // missed mole
const BOMB_PENALTY = 15; // whacking a bomb
const COMBO_WINDOW = 1400; // ms to keep combo alive between hits
const MAX_COMBO = 8;
// ---------- State ----------
let board, holes = [];
let score = 0, best = 0, timeLeft = ROUND_TIME, lives = START_LIVES;
let combo = 0, lastHitAt = 0;
let running = false;
let spawnTimer = null, tickTimer = null;
let elapsed = 0; // seconds into round (for difficulty ramp)
let muted = false;
// per-hole occupancy: {kind, el, timeout, whacked}
const occ = new Array(HOLES).fill(null);
// ---------- DOM ----------
const $ = s => document.querySelector(s);
const app = $("#app");
const scoreVal = $("#score .val");
const comboVal = $("#combo .val");
const timeStat = $("#time");
const timeVal = $("#time .val");
const bestVal = $("#best .val");
const livesEl = $("#lives");
const overlay = $("#overlay");
const ovTitle = $("#ovTitle");
const ovDesc = $("#ovDesc");
const finalScore = $("#finalScore");
const finalBest = $("#finalBest");
const newBest = $("#newBest");
const startBtn = $("#startBtn");
const muteBtn = $("#muteBtn");
// ---------- Persistence ----------
const LS_KEY = "abramsWhack.best";
try { best = parseInt(localStorage.getItem(LS_KEY) || "0", 10) || 0; } catch(e){ best = 0; }
try { muted = localStorage.getItem("abramsWhack.muted") === "1"; } catch(e){}
bestVal.textContent = best;
muteBtn.textContent = muted ? "🔇" : "🔊";
// ---------- Audio (procedural Web Audio) ----------
let actx = null;
function audio(){
if (muted) return null;
if (!actx){
try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ return null; }
}
if (actx.state === "suspended") actx.resume();
return actx;
}
function tone(freq, dur, type, gain, slideTo){
const ac = audio(); if(!ac) return;
const o = ac.createOscillator();
const g = ac.createGain();
o.type = type || "sine";
o.frequency.setValueAtTime(freq, ac.currentTime);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, ac.currentTime + dur);
g.gain.setValueAtTime(0.0001, ac.currentTime);
g.gain.exponentialRampToValueAtTime(gain || 0.2, ac.currentTime + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
o.connect(g); g.connect(ac.destination);
o.start(); o.stop(ac.currentTime + dur + 0.02);
}
function noiseBurst(dur, gain){
const ac = audio(); if(!ac) return;
const n = Math.floor(ac.sampleRate * dur);
const buf = ac.createBuffer(1, n, ac.sampleRate);
const data = buf.getChannelData(0);
for (let i=0;i<n;i++) data[i] = (Math.random()*2-1) * (1 - i/n);
const src = ac.createBufferSource(); src.buffer = buf;
const g = ac.createGain(); g.gain.value = gain || 0.25;
const f = ac.createBiquadFilter(); f.type="highpass"; f.frequency.value=800;
src.connect(f); f.connect(g); g.connect(ac.destination);
src.start();
}
const sfx = {
whack(c){ tone(420 + Math.min(c,MAX_COMBO)*55, 0.12, "square", 0.22, 180); },
gold(){ tone(660,0.1,"triangle",0.25,1320); setTimeout(()=>tone(990,0.14,"triangle",0.22,1760),70); },
bomb(){ noiseBurst(0.28,0.35); tone(120,0.3,"sawtooth",0.3,40); },
miss(){ tone(200,0.14,"sine",0.15,120); },
pop(){ tone(300,0.06,"sine",0.08,500); },
over(){ tone(440,0.2,"triangle",0.2,110); setTimeout(()=>tone(220,0.4,"triangle",0.2,80),160); },
start(){ tone(330,0.1,"triangle",0.2,660); setTimeout(()=>tone(660,0.14,"triangle",0.2,990),100); }
};
// ---------- Build board ----------
function buildBoard(){
board = $("#board");
board.innerHTML = "";
holes = [];
for (let i=0;i<HOLES;i++){
const h = document.createElement("div");
h.className = "hole";
h.dataset.i = i;
h.addEventListener("pointerdown", onHolePress, {passive:false});
board.appendChild(h);
holes.push(h);
}
}
// ---------- Difficulty ramp ----------
// returns {upTime, gap} in ms. As elapsed grows, moles stay up shorter & spawn faster.
function difficulty(){
const t = elapsed / ROUND_TIME; // 0..1
const upTime = Math.max(520, 1150 - t*760); // 1150 -> 520 ms visible
const gap = Math.max(340, 900 - t*620); // spawn interval
return { upTime, gap };
}
function chooseKind(){
// gold rare, bomb scales up a bit with difficulty
const t = elapsed / ROUND_TIME;
const r = Math.random();
if (r < 0.06) return "gold";
if (r < 0.06 + 0.14 + t*0.12) return "bomb"; // 14% -> ~26%
return "mole";
}
// ---------- Spawn ----------
function spawn(){
if (!running) return;
// find a free hole
const free = [];
for (let i=0;i<HOLES;i++) if (!occ[i]) free.push(i);
if (free.length){
// sometimes spawn 2 at once late-game
const t = elapsed / ROUND_TIME;
const count = (t > 0.5 && Math.random() < 0.3 && free.length > 1) ? 2 : 1;
for (let k=0;k<count && free.length;k++){
const idx = free.splice(Math.floor(Math.random()*free.length),1)[0];
placeTarget(idx);
}
}
const { gap } = difficulty();
spawnTimer = setTimeout(spawn, gap + Math.random()*120);
}
function placeTarget(i){
const kind = chooseKind();
const { upTime } = difficulty();
const el = document.createElement("div");
el.className = "target " + kind;
el.textContent = kind === "bomb" ? "💣" : (kind === "gold" ? "⭐" : "🐹");
holes[i].appendChild(el);
sfx.pop();
// force reflow then raise
// eslint-disable-next-line no-unused-expressions
el.offsetHeight;
requestAnimationFrame(()=> el.classList.add("up"));
const record = { kind, el, whacked:false, timeout:null };
occ[i] = record;
record.timeout = setTimeout(()=>{
if (record.whacked) return;
// duck back down
el.classList.remove("up");
// a mole that escaped un-whacked = miss penalty (bombs escaping are fine)
if (kind === "mole" || kind === "gold"){
onMissed();
}
setTimeout(()=>{ if (el.parentNode) el.parentNode.removeChild(el); }, 180);
if (occ[i] === record) occ[i] = null;
}, upTime);
}
// ---------- Interaction ----------
function onHolePress(e){
e.preventDefault();
if (!running) return;
const i = +this.dataset.i;
const rec = occ[i];
if (!rec || rec.whacked){
return; // whacked empty hole -> nothing (no penalty for empties)
}
whack(i, rec, this);
}
function whack(i, rec, holeEl){
rec.whacked = true;
clearTimeout(rec.timeout);
rec.el.classList.add("whacked");
ripple(holeEl);
setTimeout(()=>{ if (rec.el.parentNode) rec.el.parentNode.removeChild(rec.el); }, 200);
if (occ[i] === rec) occ[i] = null;
const now = performance.now();
if (rec.kind === "bomb"){
// penalty + life loss
combo = 0; lastHitAt = 0;
score = Math.max(0, score - BOMB_PENALTY);
loseLife();
holeEl.classList.add("badhit");
setTimeout(()=>holeEl.classList.remove("badhit"),200);
floatPop(holeEl, "−"+BOMB_PENALTY, "var(--bad)");
sfx.bomb();
shake();
updateHUD();
return;
}
// good hit -> combo
if (now - lastHitAt < COMBO_WINDOW) combo = Math.min(MAX_COMBO, combo + 1);
else combo = 1;
lastHitAt = now;
const mult = combo;
let gained;
if (rec.kind === "gold"){
gained = GOLD_POINTS * mult;
sfx.gold();
floatPop(holeEl, "+"+gained, "var(--gold)");
} else {
gained = BASE_POINTS * mult;
sfx.whack(combo);
floatPop(holeEl, "+"+gained, "var(--good)");
}
score += gained;
holeEl.classList.add("hit");
setTimeout(()=>holeEl.classList.remove("hit"),160);
updateHUD();
}
function onMissed(){
combo = 0; lastHitAt = 0;
score = Math.max(0, score - MISS_PENALTY);
sfx.miss();
updateHUD();
}
function loseLife(){
lives = Math.max(0, lives - 1);
renderLives();
if (lives <= 0) endRound();
}
// ---------- FX ----------
function ripple(holeEl){
const r = document.createElement("div");
r.className = "ripple";
holeEl.appendChild(r);
setTimeout(()=>{ if (r.parentNode) r.parentNode.removeChild(r); }, 460);
}
function floatPop(holeEl, text, color){
const p = document.createElement("div");
p.className = "pop";
p.textContent = text;
p.style.color = color;
holeEl.appendChild(p);
setTimeout(()=>{ if (p.parentNode) p.parentNode.removeChild(p); }, 720);
}
function shake(){
app.classList.remove("shake");
// reflow
void app.offsetWidth;
app.classList.add("shake");
}
// ---------- HUD ----------
function updateHUD(){
scoreVal.textContent = score;
comboVal.textContent = "x" + Math.max(1, combo);
comboVal.style.transform = combo > 1 ? "scale(1.15)" : "scale(1)";
}
function renderLives(){
livesEl.textContent = "❤".repeat(lives) + "♡".repeat(Math.max(0,START_LIVES-lives));
}
function renderTime(){
timeVal.textContent = timeLeft;
if (timeLeft <= 10) timeStat.classList.add("low");
else timeStat.classList.remove("low");
}
// ---------- Round control ----------
function startRound(){
// reset
score = 0; combo = 0; lastHitAt = 0; lives = START_LIVES;
timeLeft = ROUND_TIME; elapsed = 0; running = true;
clearAll();
updateHUD(); renderLives(); renderTime();
bestVal.textContent = best;
overlay.classList.add("hidden");
sfx.start();
// ticking clock
tickTimer = setInterval(()=>{
timeLeft--; elapsed++;
renderTime();
if (timeLeft <= 0) endRound();
}, 1000);
// start spawning
spawn();
}
function endRound(){
if (!running) return;
running = false;
clearTimeout(spawnTimer);
clearInterval(tickTimer);
clearAll();
sfx.over();
// high score
const isNew = score > best;
if (isNew){
best = score;
try { localStorage.setItem(LS_KEY, String(best)); } catch(e){}
}
bestVal.textContent = best;
// show overlay in game-over mode
ovTitle.textContent = "GAME OVER";
ovDesc.style.display = "none";
finalScore.style.display = "block";
finalScore.textContent = score;
finalBest.style.display = "block";
finalBest.textContent = "Best: " + best;
newBest.classList.toggle("hidden", !isNew);
startBtn.textContent = "PLAY AGAIN";
overlay.classList.remove("hidden");
}
function clearAll(){
for (let i=0;i<HOLES;i++){
const rec = occ[i];
if (rec){ clearTimeout(rec.timeout); if (rec.el && rec.el.parentNode) rec.el.parentNode.removeChild(rec.el); }
occ[i] = null;
}
holes.forEach(h=>{
h.classList.remove("hit","badhit");
// strip leftover pops/ripples
Array.from(h.querySelectorAll(".pop,.ripple,.target")).forEach(n=>n.remove());
});
}
// ---------- Mute ----------
muteBtn.addEventListener("click", (e)=>{
e.stopPropagation();
muted = !muted;
muteBtn.textContent = muted ? "🔇" : "🔊";
try { localStorage.setItem("abramsWhack.muted", muted ? "1":"0"); } catch(e){}
if (!muted) audio(); // wake context
});
// ---------- Start button ----------
startBtn.addEventListener("click", ()=>{ audio(); startRound(); });
// keyboard: space/enter to start when not running
window.addEventListener("keydown", (e)=>{
if (!running && (e.code === "Space" || e.code === "Enter")){
e.preventDefault(); audio(); startRound();
}
});
// prevent iOS double-tap zoom on board taps handled via pointerdown already
document.addEventListener("gesturestart", e=>e.preventDefault());
// ---------- Init ----------
buildBoard();
updateHUD(); renderLives(); renderTime();
})();
</script>
</body>
</html>