← back to Games Agentabrams
games/invaders/NeonInvaders.aa
892 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>NEON INVADERS</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #05070c;
overflow: hidden;
touch-action: none;
-webkit-user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent;
font-family: "Courier New", ui-monospace, monospace;
}
#wrap {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background:
radial-gradient(ellipse at 50% 30%, #101a2b 0%, #0a0e14 55%, #05070c 100%);
}
#title {
color: #39ff14;
font-weight: bold;
letter-spacing: 0.42em;
text-transform: uppercase;
font-size: clamp(14px, 3.6vw, 26px);
text-shadow: 0 0 8px #39ff14, 0 0 18px rgba(57,255,20,.5);
padding: 8px 0 6px;
text-align: center;
}
#stage {
position: relative;
border: 1px solid rgba(57,255,20,.28);
box-shadow: 0 0 22px rgba(57,255,20,.15), inset 0 0 40px rgba(10,20,40,.6);
background: #060a12;
border-radius: 6px;
overflow: hidden;
}
canvas { display: block; touch-action: none; }
#touch {
position: absolute; inset: 0;
display: none;
pointer-events: none;
}
.tbtn {
position: absolute;
pointer-events: auto;
display: flex; align-items: center; justify-content: center;
color: #7ffcff;
border: 1px solid rgba(127,252,255,.4);
background: rgba(10,20,35,.35);
border-radius: 12px;
font-size: 26px; font-weight: bold;
text-shadow: 0 0 6px #7ffcff;
backdrop-filter: blur(2px);
user-select: none;
}
.tbtn:active { background: rgba(127,252,255,.22); }
#bLeft { left: 12px; bottom: 14px; width: 62px; height: 62px; }
#bRight { left: 88px; bottom: 14px; width: 62px; height: 62px; }
#bFire { right: 14px; bottom: 14px; width: 78px; height: 78px;
color:#ff4fd8; border-color:rgba(255,79,216,.5); text-shadow:0 0 6px #ff4fd8; }
#hint {
color: rgba(160,200,220,.55);
font-size: clamp(9px, 1.9vw, 12px);
letter-spacing: .12em;
padding: 8px 6px 10px;
text-align: center;
}
</style>
</head>
<body>
<div id="wrap">
<div id="title">Neon Invaders</div>
<div id="stage">
<canvas id="c"></canvas>
<div id="touch">
<div class="tbtn" id="bLeft">◀</div>
<div class="tbtn" id="bRight">▶</div>
<div class="tbtn" id="bFire">FIRE</div>
</div>
</div>
<div id="hint">← → / A D MOVE • SPACE FIRE • P PAUSE</div>
</div>
<script>
(function () {
"use strict";
var BEST_KEY = "abrams-invaders-best";
function boot() {
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var stage = document.getElementById("stage");
var touchLayer = document.getElementById("touch");
// Logical resolution (game world). Canvas is scaled to fit.
var W = 480, H = 640;
var DPR = 1;
function fitCanvas() {
// Available space inside the wrap, minus title/hint chrome.
var availW = Math.min(window.innerWidth - 12, 520);
var availH = window.innerHeight - 120;
if (availW < 200) availW = 200;
if (availH < 300) availH = 300;
var scale = Math.min(availW / W, availH / H);
if (!isFinite(scale) || scale <= 0) scale = 1;
var cssW = Math.round(W * scale);
var cssH = Math.round(H * scale);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
stage.style.width = cssW + "px";
stage.style.height = cssH + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
// ---- Audio (lazy) ----
var actx = null, muted = false;
function ensureAudio() {
if (actx || muted) return;
try {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { muted = true; return; }
actx = new AC();
} catch (e) { muted = true; actx = null; }
}
function blip(type, freq, dur, vol) {
if (!actx) return;
try {
if (actx.state === "suspended") { actx.resume().catch(function(){}); }
var o = actx.createOscillator();
var g = actx.createGain();
o.type = type;
o.frequency.value = freq;
var t = actx.currentTime;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, 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);
} catch (e) { /* never throw from audio */ }
}
function sShoot() { blip("square", 880, 0.09, 0.05); }
function sHit() { blip("triangle", 320, 0.10, 0.06); }
function sBoom() { blip("sawtooth", 120, 0.32, 0.08); }
function sUfo() { blip("sine", 1200, 0.14, 0.05); }
function sStep(i) { blip("square", 200 + (i % 4) * 30, 0.05, 0.03); }
// ---- Game state ----
var STATE = { MENU: 0, PLAY: 1, WAVE: 2, OVER: 3, PAUSED: 4 };
var state = STATE.MENU;
var best = 0;
try {
var stored = localStorage.getItem(BEST_KEY);
if (stored != null) { var n = parseInt(stored, 10); if (!isNaN(n)) best = n; }
} catch (e) { best = 0; }
var score = 0, lives = 3, wave = 1;
var player, bullets, bombs, invaders, bunkers, ufo, particles;
var invDir, invStepTimer, invStepInterval, invDropPending;
var fireCooldown, ufoTimer, stepIndex;
var flashTimer = 0;
var keys = {};
var touch = { left: false, right: false, fire: false };
// ---- Entity setup ----
function makePlayer() {
return { x: W / 2, y: H - 46, w: 40, h: 18, speed: 260, cool: 0 };
}
function makeBunkers() {
var arr = [];
var count = 4;
var bw = 58, bh = 40;
var gap = (W - count * bw) / (count + 1);
var by = H - 130;
for (var b = 0; b < count; b++) {
var bx = gap + b * (bw + gap);
var cellsX = 7, cellsY = 5;
var cw = bw / cellsX, ch = bh / cellsY;
var cells = [];
for (var yy = 0; yy < cellsY; yy++) {
for (var xx = 0; xx < cellsX; xx++) {
// carve an arch shape
var isNotch = (yy >= 3) && (xx >= 2 && xx <= 4);
if (isNotch) continue;
cells.push({
x: bx + xx * cw, y: by + yy * ch,
w: cw, h: ch, hp: 3
});
}
}
arr.push({ cells: cells });
}
return arr;
}
function buildInvaders() {
invaders = [];
var cols = 8, rows = 5;
var cellW = 44, cellH = 34;
var gridW = cols * cellW;
var startX = (W - gridW) / 2 + cellW / 2;
var startY = 70;
for (var r = 0; r < rows; r++) {
for (var col = 0; col < cols; col++) {
var type = r === 0 ? 2 : (r < 3 ? 1 : 0);
invaders.push({
x: startX + col * cellW,
y: startY + r * cellH,
w: 28, h: 20,
type: type,
alive: true,
row: r, col: col
});
}
}
invDir = 1;
invStepTimer = 0;
invStepInterval = Math.max(0.18, 0.75 - (wave - 1) * 0.06);
invDropPending = false;
stepIndex = 0;
}
function startGame() {
score = 0; lives = 3; wave = 1;
player = makePlayer();
bullets = []; bombs = []; particles = [];
bunkers = makeBunkers();
ufo = null;
fireCooldown = 0;
ufoTimer = rand(8, 16);
buildInvaders();
state = STATE.PLAY;
}
function nextWave() {
wave++;
player.x = W / 2;
bullets = []; bombs = [];
bunkers = makeBunkers();
ufo = null;
ufoTimer = rand(8, 16);
buildInvaders();
state = STATE.PLAY;
flashTimer = 0.6;
}
function rand(a, b) { return a + Math.random() * (b - a); }
// ---- Particles ----
function burst(x, y, color, n) {
for (var i = 0; i < n; i++) {
var a = Math.random() * Math.PI * 2;
var sp = rand(30, 160);
particles.push({
x: x, y: y,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
life: rand(0.3, 0.7), max: 0.7, color: color
});
}
}
// ---- Collision ----
function overlap(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
// ---- Update ----
function update(dt) {
if (state !== STATE.PLAY) {
// still animate particles on OVER for a moment
updateParticles(dt);
return;
}
if (flashTimer > 0) flashTimer -= dt;
// player movement
var mv = 0;
if (keys.left || touch.left) mv -= 1;
if (keys.right || touch.right) mv += 1;
player.x += mv * player.speed * dt;
var half = player.w / 2;
if (player.x < half) player.x = half;
if (player.x > W - half) player.x = W - half;
// firing
if (player.cool > 0) player.cool -= dt;
if ((keys.fire || touch.fire) && player.cool <= 0) {
bullets.push({ x: player.x, y: player.y - 12, w: 3, h: 14, vy: -520 });
player.cool = 0.34;
sShoot();
}
// bullets
for (var i = bullets.length - 1; i >= 0; i--) {
var bl = bullets[i];
bl.y += bl.vy * dt;
if (bl.y + bl.h < 0) { bullets.splice(i, 1); continue; }
}
// invader movement (grid step)
updateInvaders(dt);
// bombs (from invaders)
for (var j = bombs.length - 1; j >= 0; j--) {
var bo = bombs[j];
bo.y += bo.vy * dt;
bo.t = (bo.t || 0) + dt;
if (bo.y > H) { bombs.splice(j, 1); continue; }
}
// UFO
updateUfo(dt);
// collisions
handleCollisions();
updateParticles(dt);
// win condition
var anyAlive = false;
for (var k = 0; k < invaders.length; k++) { if (invaders[k].alive) { anyAlive = true; break; } }
if (!anyAlive) {
state = STATE.WAVE;
waveDelay = 1.4;
}
}
var waveDelay = 0;
function updateInvaders(dt) {
var aliveList = [];
var minX = 1e9, maxX = -1e9, maxY = -1e9;
for (var i = 0; i < invaders.length; i++) {
var inv = invaders[i];
if (!inv.alive) continue;
aliveList.push(inv);
if (inv.x - inv.w / 2 < minX) minX = inv.x - inv.w / 2;
if (inv.x + inv.w / 2 > maxX) maxX = inv.x + inv.w / 2;
if (inv.y + inv.h / 2 > maxY) maxY = inv.y + inv.h / 2;
}
if (aliveList.length === 0) return;
// Speed scales up as fewer remain.
var frac = aliveList.length / invaders.length;
var interval = invStepInterval * (0.35 + 0.65 * frac);
invStepTimer += dt;
if (invStepTimer >= interval) {
invStepTimer = 0;
stepIndex++;
sStep(stepIndex);
var stepX = 12;
var willHit = (invDir > 0 && maxX + stepX >= W - 6) ||
(invDir < 0 && minX - stepX <= 6);
if (willHit) {
invDir *= -1;
for (var d = 0; d < aliveList.length; d++) aliveList[d].y += 18;
} else {
for (var m = 0; m < aliveList.length; m++) aliveList[m].x += stepX * invDir;
}
// reached player level -> game over
for (var q = 0; q < aliveList.length; q++) {
if (aliveList[q].y + aliveList[q].h / 2 >= player.y - 6) {
loseAllLives();
return;
}
}
}
// random bomb drops from bottom-most invaders per column
var dropChance = (0.35 + wave * 0.12) * dt;
if (Math.random() < dropChance && aliveList.length) {
// find a random column's lowest alive invader
var shooter = aliveList[(Math.random() * aliveList.length) | 0];
// ensure it's the lowest in its column
var lowest = shooter;
for (var c = 0; c < aliveList.length; c++) {
if (aliveList[c].col === shooter.col && aliveList[c].y > lowest.y) lowest = aliveList[c];
}
bombs.push({ x: lowest.x, y: lowest.y + 12, w: 5, h: 12, vy: rand(170, 210) + wave * 8, t: 0 });
}
}
function updateUfo(dt) {
if (ufo) {
ufo.x += ufo.vx * dt;
if ((ufo.vx > 0 && ufo.x > W + 40) || (ufo.vx < 0 && ufo.x < -40)) {
ufo = null;
}
} else {
ufoTimer -= dt;
if (ufoTimer <= 0) {
var fromLeft = Math.random() < 0.5;
ufo = {
x: fromLeft ? -30 : W + 30,
y: 42, w: 40, h: 16,
vx: fromLeft ? 90 : -90,
value: [50, 100, 150, 300][(Math.random() * 4) | 0]
};
ufoTimer = rand(12, 22);
}
}
}
function updateParticles(dt) {
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.life -= dt;
if (p.life <= 0) { particles.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 120 * dt;
}
}
function damageBunkerAt(x, y, w, h) {
for (var b = 0; b < bunkers.length; b++) {
var cells = bunkers[b].cells;
for (var i = cells.length - 1; i >= 0; i--) {
var cl = cells[i];
if (overlap(x, y, w, h, cl.x, cl.y, cl.w, cl.h)) {
cl.hp--;
burst(cl.x + cl.w / 2, cl.y + cl.h / 2, "#39ff14", 4);
if (cl.hp <= 0) cells.splice(i, 1);
return true;
}
}
}
return false;
}
function handleCollisions() {
// player bullets
for (var i = bullets.length - 1; i >= 0; i--) {
var bl = bullets[i];
var bx = bl.x - bl.w / 2, by = bl.y;
// bunkers
if (damageBunkerAt(bx, by, bl.w, bl.h)) { bullets.splice(i, 1); continue; }
// ufo
if (ufo && overlap(bx, by, bl.w, bl.h, ufo.x - ufo.w / 2, ufo.y - ufo.h / 2, ufo.w, ufo.h)) {
addScore(ufo.value);
burst(ufo.x, ufo.y, "#ff4fd8", 22);
sUfo();
ufo = null;
bullets.splice(i, 1);
continue;
}
// invaders
var hitInv = false;
for (var k = 0; k < invaders.length; k++) {
var inv = invaders[k];
if (!inv.alive) continue;
if (overlap(bx, by, bl.w, bl.h, inv.x - inv.w / 2, inv.y - inv.h / 2, inv.w, inv.h)) {
inv.alive = false;
addScore((inv.type + 1) * 10);
burst(inv.x, inv.y, invColor(inv.type), 16);
sHit();
hitInv = true;
break;
}
}
if (hitInv) { bullets.splice(i, 1); continue; }
}
// enemy bombs
for (var j = bombs.length - 1; j >= 0; j--) {
var bo = bombs[j];
var ox = bo.x - bo.w / 2, oy = bo.y;
if (damageBunkerAt(ox, oy, bo.w, bo.h)) { bombs.splice(j, 1); continue; }
if (overlap(ox, oy, bo.w, bo.h, player.x - player.w / 2, player.y - player.h / 2, player.w, player.h)) {
bombs.splice(j, 1);
playerHit();
continue;
}
}
}
function invColor(type) {
return type === 2 ? "#ff4fd8" : (type === 1 ? "#7ffcff" : "#39ff14");
}
function addScore(v) {
score += v;
if (score > best) {
best = score;
try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* ignore */ }
}
}
function playerHit() {
lives--;
burst(player.x, player.y, "#ffd23f", 26);
sBoom();
flashTimer = 0.35;
if (lives <= 0) {
gameOver();
} else {
player.x = W / 2;
player.cool = 0.6;
}
}
function loseAllLives() {
lives = 0;
burst(player.x, player.y, "#ffd23f", 30);
sBoom();
gameOver();
}
function gameOver() {
state = STATE.OVER;
}
// ---- Render ----
function draw() {
ctx.clearRect(0, 0, W, H);
// starfield backdrop
drawStars();
if (flashTimer > 0 && (state === STATE.PLAY || state === STATE.WAVE)) {
ctx.fillStyle = "rgba(255,210,63," + Math.min(0.25, flashTimer * 0.4) + ")";
ctx.fillRect(0, 0, W, H);
}
if (state === STATE.MENU) {
drawMenu();
drawParticles();
return;
}
// bunkers
for (var b = 0; b < bunkers.length; b++) {
var cells = bunkers[b].cells;
for (var i = 0; i < cells.length; i++) {
var cl = cells[i];
var a = cl.hp / 3;
ctx.fillStyle = "rgba(57,255,20," + (0.35 + a * 0.55) + ")";
ctx.fillRect(cl.x + 0.5, cl.y + 0.5, cl.w - 1, cl.h - 1);
}
}
// invaders
for (var k = 0; k < invaders.length; k++) {
if (invaders[k].alive) drawInvader(invaders[k]);
}
// ufo
if (ufo) drawUfo(ufo);
// bullets
ctx.save();
ctx.shadowBlur = 8; ctx.shadowColor = "#ffffff";
ctx.fillStyle = "#ffffff";
for (var m = 0; m < bullets.length; m++) {
var bl = bullets[m];
ctx.fillRect(bl.x - bl.w / 2, bl.y, bl.w, bl.h);
}
ctx.restore();
// bombs
ctx.save();
ctx.shadowBlur = 8; ctx.shadowColor = "#ff4fd8";
for (var n = 0; n < bombs.length; n++) {
var bo = bombs[n];
ctx.fillStyle = ((bo.t * 20) | 0) % 2 ? "#ff4fd8" : "#ffb0ec";
ctx.fillRect(bo.x - bo.w / 2, bo.y, bo.w, bo.h);
}
ctx.restore();
// player
if (state === STATE.PLAY || state === STATE.WAVE || state === STATE.PAUSED) drawPlayer();
drawParticles();
drawHUD();
if (state === STATE.OVER) drawGameOver();
if (state === STATE.PAUSED) drawPaused();
if (state === STATE.WAVE) drawWaveBanner();
}
var stars = [];
function initStars() {
stars = [];
for (var i = 0; i < 60; i++) {
stars.push({ x: Math.random() * W, y: Math.random() * H, s: Math.random() * 1.6 + 0.4, t: Math.random() * 6.28 });
}
}
function drawStars() {
for (var i = 0; i < stars.length; i++) {
var st = stars[i];
var tw = 0.4 + 0.6 * Math.abs(Math.sin(st.t + performance.now() / 900));
ctx.fillStyle = "rgba(120,180,220," + (tw * 0.5) + ")";
ctx.fillRect(st.x, st.y, st.s, st.s);
}
}
function drawPlayer() {
var px = player.x, py = player.y, w = player.w, h = player.h;
ctx.save();
ctx.shadowBlur = 12; ctx.shadowColor = "#39ff14";
ctx.fillStyle = "#39ff14";
// cannon body
ctx.beginPath();
ctx.moveTo(px - w / 2, py + h / 2);
ctx.lineTo(px + w / 2, py + h / 2);
ctx.lineTo(px + w / 2, py + h / 2 - 6);
ctx.lineTo(px + 8, py + h / 2 - 6);
ctx.lineTo(px + 4, py - h / 2 + 2);
ctx.lineTo(px - 4, py - h / 2 + 2);
ctx.lineTo(px - 8, py + h / 2 - 6);
ctx.lineTo(px - w / 2, py + h / 2 - 6);
ctx.closePath();
ctx.fill();
ctx.fillRect(px - 2, py - h / 2 - 4, 4, 6);
ctx.restore();
}
function drawInvader(inv) {
var x = inv.x - inv.w / 2, y = inv.y - inv.h / 2, w = inv.w, h = inv.h;
var col = invColor(inv.type);
var frame = stepIndex % 2;
ctx.save();
ctx.shadowBlur = 8; ctx.shadowColor = col;
ctx.fillStyle = col;
var px = w / 8, py = h / 5;
// simple 8x5 sprite bitmaps
var A = [
[0,0,1,0,0,1,0,0],
[0,1,1,1,1,1,1,0],
[1,1,0,1,1,0,1,1],
[1,1,1,1,1,1,1,1],
frame ? [0,1,0,0,0,0,1,0] : [1,0,1,0,0,1,0,1]
];
for (var ry = 0; ry < 5; ry++) {
for (var rx = 0; rx < 8; rx++) {
if (A[ry][rx]) ctx.fillRect(x + rx * px, y + ry * py, px + 0.5, py + 0.5);
}
}
ctx.restore();
}
function drawUfo(u) {
ctx.save();
ctx.shadowBlur = 12; ctx.shadowColor = "#ff4fd8";
ctx.fillStyle = "#ff4fd8";
ctx.beginPath();
ctx.ellipse(u.x, u.y, u.w / 2, u.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffd23f";
ctx.beginPath();
ctx.ellipse(u.x, u.y - 3, u.w / 4, u.h / 3, 0, Math.PI, 0);
ctx.fill();
ctx.restore();
}
function drawParticles() {
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var a = Math.max(0, p.life / p.max);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
}
ctx.globalAlpha = 1;
}
function drawHUD() {
ctx.save();
ctx.font = "bold 15px 'Courier New', monospace";
ctx.textBaseline = "top";
ctx.fillStyle = "#39ff14";
ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
ctx.textAlign = "left";
ctx.fillText("SCORE " + pad(score, 5), 12, 10);
ctx.textAlign = "center";
ctx.fillStyle = "#7ffcff"; ctx.shadowColor = "#7ffcff";
ctx.fillText("WAVE " + wave, W / 2, 10);
ctx.textAlign = "right";
ctx.fillStyle = "#ffd23f"; ctx.shadowColor = "#ffd23f";
ctx.fillText("BEST " + pad(best, 5), W - 12, 10);
ctx.restore();
// lives
for (var i = 0; i < lives; i++) {
var lx = 20 + i * 26, ly = H - 22;
ctx.save();
ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
ctx.fillStyle = "#39ff14";
ctx.beginPath();
ctx.moveTo(lx - 8, ly + 6);
ctx.lineTo(lx + 8, ly + 6);
ctx.lineTo(lx + 4, ly - 4);
ctx.lineTo(lx - 4, ly - 4);
ctx.closePath();
ctx.fill();
ctx.fillRect(lx - 1, ly - 8, 2, 4);
ctx.restore();
}
}
function drawMenu() {
centerText("NEON INVADERS", W / 2, H / 2 - 70, "bold 34px 'Courier New', monospace", "#39ff14", 8, 0.12);
centerText("DEFEND THE GRID", W / 2, H / 2 - 30, "14px 'Courier New', monospace", "#7ffcff", 4, 0.3);
var blink = (Math.floor(performance.now() / 500) % 2) === 0;
if (blink) centerText("PRESS SPACE / TAP TO START", W / 2, H / 2 + 30, "bold 15px 'Courier New', monospace", "#ffd23f", 6, 0.1);
centerText("BEST " + pad(best, 5), W / 2, H / 2 + 80, "14px 'Courier New', monospace", "#ff4fd8", 4, 0.2);
}
function drawGameOver() {
ctx.fillStyle = "rgba(5,7,12,.6)";
ctx.fillRect(0, 0, W, H);
centerText("GAME OVER", W / 2, H / 2 - 50, "bold 34px 'Courier New', monospace", "#ff4fd8", 10, 0.1);
centerText("SCORE " + pad(score, 5), W / 2, H / 2, "16px 'Courier New', monospace", "#39ff14", 5, 0.15);
centerText("BEST " + pad(best, 5), W / 2, H / 2 + 26, "16px 'Courier New', monospace", "#ffd23f", 5, 0.15);
var blink = (Math.floor(performance.now() / 500) % 2) === 0;
if (blink) centerText("PRESS SPACE / TAP TO RESTART", W / 2, H / 2 + 70, "bold 14px 'Courier New', monospace", "#7ffcff", 5, 0.08);
}
function drawPaused() {
ctx.fillStyle = "rgba(5,7,12,.55)";
ctx.fillRect(0, 0, W, H);
centerText("PAUSED", W / 2, H / 2 - 10, "bold 30px 'Courier New', monospace", "#7ffcff", 8, 0.2);
centerText("PRESS P TO RESUME", W / 2, H / 2 + 30, "13px 'Courier New', monospace", "#ffd23f", 4, 0.1);
}
function drawWaveBanner() {
centerText("WAVE " + wave + " CLEARED", W / 2, H / 2 - 10, "bold 26px 'Courier New', monospace", "#39ff14", 8, 0.14);
}
function centerText(txt, x, y, font, color, glow, spacing) {
ctx.save();
ctx.font = font;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowBlur = glow || 0;
ctx.shadowColor = color;
ctx.fillStyle = color;
if (spacing) {
// manual letter spacing
var total = 0, i;
var widths = [];
for (i = 0; i < txt.length; i++) {
var wch = ctx.measureText(txt[i]).width + spacing * 20;
widths.push(wch); total += wch;
}
var cx = x - total / 2;
ctx.textAlign = "left";
for (i = 0; i < txt.length; i++) {
ctx.fillText(txt[i], cx, y);
cx += widths[i];
}
} else {
ctx.fillText(txt, x, y);
}
ctx.restore();
}
function pad(n, len) {
var s = String(Math.max(0, n | 0));
while (s.length < len) s = "0" + s;
return s;
}
// ---- Input ----
function onKeyDown(e) {
var k = e.key;
if (k === "ArrowLeft" || k === "a" || k === "A") { keys.left = true; e.preventDefault(); }
else if (k === "ArrowRight" || k === "d" || k === "D") { keys.right = true; e.preventDefault(); }
else if (k === " " || k === "Spacebar" || k === "ArrowUp") {
keys.fire = true; e.preventDefault();
ensureAudio();
if (state === STATE.MENU || state === STATE.OVER) startGame();
}
else if (k === "p" || k === "P") {
if (state === STATE.PLAY) state = STATE.PAUSED;
else if (state === STATE.PAUSED) state = STATE.PLAY;
}
else if (k === "m" || k === "M") { muted = !muted; }
}
function onKeyUp(e) {
var k = e.key;
if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = false;
else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = false;
else if (k === " " || k === "Spacebar" || k === "ArrowUp") keys.fire = false;
}
window.addEventListener("keydown", onKeyDown, { passive: false });
window.addEventListener("keyup", onKeyUp);
// Touch controls
function bindTouch(el, key) {
function down(ev) {
ev.preventDefault();
ensureAudio();
touch[key] = true;
if (key === "fire" && (state === STATE.MENU || state === STATE.OVER)) startGame();
}
function up(ev) { ev.preventDefault(); touch[key] = false; }
el.addEventListener("touchstart", down, { passive: false });
el.addEventListener("touchend", up, { passive: false });
el.addEventListener("touchcancel", up, { passive: false });
el.addEventListener("mousedown", down);
el.addEventListener("mouseup", up);
el.addEventListener("mouseleave", up);
}
bindTouch(document.getElementById("bLeft"), "left");
bindTouch(document.getElementById("bRight"), "right");
bindTouch(document.getElementById("bFire"), "fire");
// Tap on canvas also starts / fires on touch devices
canvas.addEventListener("touchstart", function (ev) {
ev.preventDefault();
ensureAudio();
if (state === STATE.MENU || state === STATE.OVER) startGame();
}, { passive: false });
canvas.addEventListener("mousedown", function () {
ensureAudio();
if (state === STATE.MENU || state === STATE.OVER) startGame();
});
// Show touch controls when a touch is detected
function enableTouchUI() {
touchLayer.style.display = "block";
window.removeEventListener("touchstart", enableTouchUI);
}
window.addEventListener("touchstart", enableTouchUI, { passive: true });
if (("ontouchstart" in window) || (navigator.maxTouchPoints > 0)) {
touchLayer.style.display = "block";
}
window.addEventListener("resize", fitCanvas);
// ---- Loop ----
var last = performance.now();
function frame(now) {
var dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05; // clamp for tab-switch spikes
if (state === STATE.WAVE) {
updateParticles(dt);
waveDelay -= dt;
if (waveDelay <= 0) nextWave();
} else {
update(dt);
}
draw();
requestAnimationFrame(frame);
}
// ---- Init ----
initStars();
fitCanvas();
// initialize placeholder arrays for menu particle draws
particles = [];
bullets = []; bombs = []; invaders = []; bunkers = []; player = makePlayer();
requestAnimationFrame(frame);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
</script>
</body>
</html>