← back to Games Agentabrams
games/missile/MissileCommand.aa
887 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>MISSILE COMMAND</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #0a0e14;
overflow: hidden;
touch-action: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
#wrap {
position: fixed;
inset: 0;
display: block;
background: #0a0e14;
}
canvas {
display: block;
width: 100%;
height: 100%;
background: #0a0e14;
cursor: crosshair;
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
</div>
<script>
(function () {
"use strict";
function boot() {
var canvas = document.getElementById("game");
if (!canvas || !canvas.getContext) return;
var ctx = canvas.getContext("2d");
if (!ctx) return;
var BEST_KEY = "abrams-missile-best";
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; }
// Logical design resolution; we scale to fit the iframe.
var W = 900, H = 600;
var dpr = 1;
function resize() {
var cw = window.innerWidth || document.documentElement.clientWidth || W;
var ch = window.innerHeight || document.documentElement.clientHeight || H;
dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
canvas.width = Math.max(1, Math.floor(cw * dpr));
canvas.height = Math.max(1, Math.floor(ch * dpr));
canvas.style.width = cw + "px";
canvas.style.height = ch + "px";
W = cw;
H = ch;
}
window.addEventListener("resize", resize, { passive: true });
resize();
// ---- Audio (lazy) ----
var audioCtx = null;
var audioOK = true;
function ensureAudio() {
if (audioCtx || !audioOK) return;
try {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { audioOK = false; return; }
audioCtx = new AC();
} catch (e) { audioOK = false; audioCtx = null; }
}
function blip(type, freq, dur, vol) {
if (!audioOK) return;
try {
ensureAudio();
if (!audioCtx) return;
if (audioCtx.state === "suspended" && audioCtx.resume) { audioCtx.resume(); }
var o = audioCtx.createOscillator();
var g = audioCtx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, audioCtx.currentTime);
g.gain.setValueAtTime(vol, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
o.connect(g);
g.connect(audioCtx.destination);
o.start();
o.stop(audioCtx.currentTime + dur + 0.02);
} catch (e) { /* never throw */ }
}
function sfxLaunch() { blip("square", 660, 0.12, 0.06); }
function sfxExplode() {
blip("sawtooth", 180, 0.30, 0.09);
blip("triangle", 90, 0.35, 0.06);
}
function sfxCityHit() { blip("sawtooth", 70, 0.5, 0.12); }
// ---- Game state ----
var STATE_START = 0, STATE_PLAY = 1, STATE_WAVEEND = 2, STATE_OVER = 3;
var state = STATE_START;
var cities = []; // {x, alive}
var bases = []; // {x, ammo, maxAmmo}
var enemies = []; // incoming missiles
var friendlies = []; // counter-missiles
var blasts = []; // explosions
var particles = [];
var wave = 1;
var score = 0;
var waveEnemiesTotal = 0;
var waveEnemiesSpawned = 0;
var spawnTimer = 0;
var spawnInterval = 1.2;
var waveEndTimer = 0;
var bonusStep = 0;
var bonusCityIndex = 0;
var bonusAmmoBase = 0;
var bonusAmmoLeft = 0;
var flashTimer = 0;
var GROUND_H = 70; // reserved from bottom in logical coords (relative to H)
function groundY() { return H - 64; }
function cityBaseCount() { return 6; }
function setupField() {
cities = [];
bases = [];
var n = cityBaseCount();
// 3 bases + 6 cities arranged: base, 2 cities, base, 2 cities, base? classic: bases at far L, mid, far R
// We'll spread bases and cities across width.
var margin = W * 0.06;
var usable = W - margin * 2;
// base positions: left, center, right
var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
// city positions between bases
var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
for (var i = 0; i < basePositions.length; i++) {
bases.push({ x: margin + basePositions[i] * usable, ammo: 10, maxAmmo: 10 });
}
for (var j = 0; j < cityFractions.length; j++) {
cities.push({ x: margin + cityFractions[j] * usable, alive: true });
}
}
function repositionField() {
// Recompute x positions on resize while preserving alive/ammo.
var margin = W * 0.06;
var usable = W - margin * 2;
var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
for (var i = 0; i < bases.length && i < basePositions.length; i++) {
bases[i].x = margin + basePositions[i] * usable;
}
for (var j = 0; j < cities.length && j < cityFractions.length; j++) {
cities[j].x = margin + cityFractions[j] * usable;
}
}
function startWave(w) {
wave = w;
enemies = [];
friendlies = [];
blasts = [];
particles = [];
waveEnemiesTotal = 8 + Math.floor(w * 2.5);
waveEnemiesSpawned = 0;
spawnTimer = 0.5;
spawnInterval = Math.max(0.35, 1.3 - w * 0.07);
// refill ammo
for (var i = 0; i < bases.length; i++) {
bases[i].ammo = bases[i].maxAmmo;
}
state = STATE_PLAY;
}
function newGame() {
score = 0;
wave = 1;
setupField();
startWave(1);
}
function aliveCities() {
var c = 0;
for (var i = 0; i < cities.length; i++) if (cities[i].alive) c++;
return c;
}
function enemySpeed() {
return (28 + wave * 5) * (H / 600);
}
function spawnEnemy() {
var speed = enemySpeed() * (0.8 + Math.random() * 0.5);
var sx = Math.random() * W;
// target a city or base
var targets = [];
for (var i = 0; i < cities.length; i++) if (cities[i].alive) targets.push(cities[i].x);
for (var j = 0; j < bases.length; j++) targets.push(bases[j].x);
if (targets.length === 0) targets = [W / 2];
var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 40;
var ty = groundY();
var canSplit = wave >= 3 && Math.random() < Math.min(0.5, 0.15 + wave * 0.03);
var smart = wave >= 5 && Math.random() < Math.min(0.4, (wave - 4) * 0.06);
enemies.push({
x: sx, y: -10,
tx: tx, ty: ty,
sx: sx, sy: -10,
speed: speed,
split: canSplit,
splitY: H * (0.35 + Math.random() * 0.2),
smart: smart,
trail: [],
dead: false
});
}
function spawnSplit(e) {
var count = 2 + (Math.random() < 0.3 ? 1 : 0);
for (var i = 0; i < count; i++) {
var speed = e.speed * (0.9 + Math.random() * 0.3);
var targets = [];
for (var c = 0; c < cities.length; c++) if (cities[c].alive) targets.push(cities[c].x);
for (var b = 0; b < bases.length; b++) targets.push(bases[b].x);
if (targets.length === 0) targets = [W / 2];
var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 60;
enemies.push({
x: e.x, y: e.y,
tx: tx, ty: groundY(),
sx: e.x, sy: e.y,
speed: speed,
split: false,
splitY: 0,
smart: false,
trail: [],
dead: false
});
}
}
function launchAt(px, py) {
// choose nearest base with ammo
var best_i = -1, bestD = Infinity;
for (var i = 0; i < bases.length; i++) {
if (bases[i].ammo <= 0) continue;
var d = Math.abs(bases[i].x - px);
if (d < bestD) { bestD = d; best_i = i; }
}
if (best_i < 0) return; // no ammo anywhere
var b = bases[best_i];
b.ammo--;
var ox = b.x;
var oy = groundY();
var dx = px - ox, dy = py - oy;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var spd = 620 * (H / 600);
friendlies.push({
x: ox, y: oy,
vx: dx / dist * spd,
vy: dy / dist * spd,
tx: px, ty: py,
trail: [],
dead: false
});
sfxLaunch();
}
function addBlast(x, y, maxR) {
blasts.push({ x: x, y: y, r: 4, maxR: maxR, growing: true, life: 0 });
sfxExplode();
}
function addParticles(x, y, color, n) {
for (var i = 0; i < n; i++) {
var a = Math.random() * Math.PI * 2;
var s = 30 + Math.random() * 120;
particles.push({
x: x, y: y,
vx: Math.cos(a) * s,
vy: Math.sin(a) * s - 30,
life: 0.5 + Math.random() * 0.6,
maxLife: 1,
color: color
});
}
}
function destroyCity(c) {
if (!c.alive) return;
c.alive = false;
addParticles(c.x, groundY() - 8, "#ff5a7a", 26);
flashTimer = 0.35;
sfxCityHit();
}
// ---- Input ----
function pointerToLogical(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var x = (clientX - rect.left) / rect.width * W;
var y = (clientY - rect.top) / rect.height * H;
return { x: x, y: y };
}
function handleFire(clientX, clientY) {
ensureAudio();
if (state === STATE_START) { newGame(); return; }
if (state === STATE_OVER) { newGame(); return; }
if (state === STATE_WAVEEND) { return; }
if (state !== STATE_PLAY) return;
var p = pointerToLogical(clientX, clientY);
if (p.y > groundY() - 6) p.y = groundY() - 6;
launchAt(p.x, p.y);
}
canvas.addEventListener("mousedown", function (e) {
e.preventDefault();
handleFire(e.clientX, e.clientY);
});
canvas.addEventListener("touchstart", function (e) {
e.preventDefault();
if (e.changedTouches && e.changedTouches.length) {
var t = e.changedTouches[0];
handleFire(t.clientX, t.clientY);
}
}, { passive: false });
window.addEventListener("keydown", function (e) {
if (e.key === " " || e.key === "Enter") {
if (state === STATE_START || state === STATE_OVER) newGame();
}
});
// ---- Update ----
function update(dt) {
flashTimer = Math.max(0, flashTimer - dt);
if (state === STATE_PLAY) {
// spawn
if (waveEnemiesSpawned < waveEnemiesTotal) {
spawnTimer -= dt;
if (spawnTimer <= 0) {
spawnEnemy();
waveEnemiesSpawned++;
spawnTimer = spawnInterval * (0.6 + Math.random() * 0.8);
}
}
}
// update enemies
var gy = groundY();
for (var i = enemies.length - 1; i >= 0; i--) {
var e = enemies[i];
if (e.dead) { enemies.splice(i, 1); continue; }
var dx = e.tx - e.x, dy = e.ty - e.y;
var dd = Math.sqrt(dx * dx + dy * dy) || 1;
var step = e.speed * dt;
// smart bombs slightly weave / retarget toward alive cities
e.x += dx / dd * step;
e.y += dy / dd * step;
e.trail.push({ x: e.x, y: e.y });
if (e.trail.length > 16) e.trail.shift();
// split
if (e.split && e.y >= e.splitY) {
e.split = false;
spawnSplit(e);
}
if (e.y >= e.ty - 2 || e.y >= gy) {
// hit ground: destroy nearest city/base if near
e.dead = true;
addParticles(e.x, gy - 4, "#ff8a5a", 10);
// check city hits
for (var c = 0; c < cities.length; c++) {
if (cities[c].alive && Math.abs(cities[c].x - e.x) < W * 0.035) {
destroyCity(cities[c]);
}
}
for (var b = 0; b < bases.length; b++) {
if (Math.abs(bases[b].x - e.x) < W * 0.03) {
bases[b].ammo = 0; // knocked out for the wave visually
}
}
enemies.splice(i, 1);
}
}
// update friendlies
for (var f = friendlies.length - 1; f >= 0; f--) {
var fm = friendlies[f];
if (fm.dead) { friendlies.splice(f, 1); continue; }
fm.x += fm.vx * dt;
fm.y += fm.vy * dt;
fm.trail.push({ x: fm.x, y: fm.y });
if (fm.trail.length > 22) fm.trail.shift();
var ddx = fm.tx - fm.x, ddy = fm.ty - fm.y;
// reached target when passing it
if (ddx * fm.vx + ddy * fm.vy <= 0) {
fm.dead = true;
addBlast(fm.tx, fm.ty, Math.max(46, W * 0.06));
friendlies.splice(f, 1);
}
}
// update blasts
for (var bl = blasts.length - 1; bl >= 0; bl--) {
var B = blasts[bl];
B.life += dt;
if (B.growing) {
B.r += (B.maxR) * dt * 3.2;
if (B.r >= B.maxR) { B.r = B.maxR; B.growing = false; }
} else {
B.r -= B.maxR * dt * 1.9;
if (B.r <= 0) { blasts.splice(bl, 1); continue; }
}
// destroy enemies inside blast
for (var ei = enemies.length - 1; ei >= 0; ei--) {
var en = enemies[ei];
var edx = en.x - B.x, edy = en.y - B.y;
if (edx * edx + edy * edy <= B.r * B.r) {
en.dead = true;
score += 25;
addParticles(en.x, en.y, "#7dffea", 8);
enemies.splice(ei, 1);
}
}
}
// update particles
for (var p = particles.length - 1; p >= 0; p--) {
var pt = particles[p];
pt.life -= dt;
if (pt.life <= 0) { particles.splice(p, 1); continue; }
pt.vy += 140 * dt;
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
}
// check wave end / game over
if (state === STATE_PLAY) {
if (aliveCities() === 0) {
// let remaining blasts fade a touch, then game over
state = STATE_OVER;
if (score > best) {
best = score;
try { localStorage.setItem(BEST_KEY, String(best)); } catch (e2) {}
}
} else if (waveEnemiesSpawned >= waveEnemiesTotal && enemies.length === 0 && friendlies.length === 0) {
// wave clear -> bonus phase
state = STATE_WAVEEND;
waveEndTimer = 0;
bonusStep = 0;
bonusCityIndex = 0;
bonusAmmoLeft = 0;
for (var bb = 0; bb < bases.length; bb++) bonusAmmoLeft += bases[bb].ammo;
bonusAmmoBase = bonusAmmoLeft;
}
}
if (state === STATE_WAVEEND) {
waveEndTimer += dt;
// tally city bonus (100 each) then ammo bonus (10 each)
// simple timed tally for feel
if (bonusStep === 0 && waveEndTimer > 0.4) {
bonusStep = 1;
} else if (bonusStep === 1) {
if (waveEndTimer > 0.5 + bonusCityIndex * 0.12) {
if (bonusCityIndex < cities.length) {
if (cities[bonusCityIndex].alive) { score += 100; blip("sine", 880, 0.08, 0.05); }
bonusCityIndex++;
} else {
bonusStep = 2;
waveEndTimer = 0;
}
}
} else if (bonusStep === 2) {
if (waveEndTimer > 0.05 && bonusAmmoLeft > 0) {
if (waveEndTimer > 0.02) {
score += 5;
bonusAmmoLeft--;
waveEndTimer = 0;
blip("sine", 1200, 0.04, 0.04);
}
} else if (bonusAmmoLeft <= 0) {
bonusStep = 3;
waveEndTimer = 0;
}
} else if (bonusStep === 3) {
if (waveEndTimer > 1.0) {
startWave(wave + 1);
}
}
}
}
// ---- Draw ----
var starField = null;
function buildStars() {
starField = [];
var n = 70;
for (var i = 0; i < n; i++) {
starField.push({
x: Math.random(),
y: Math.random() * 0.75,
s: Math.random() * 1.6 + 0.3,
tw: Math.random() * Math.PI * 2
});
}
}
buildStars();
var tGlobal = 0;
function drawBackground() {
// gradient sky
var g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, "#0a0e14");
g.addColorStop(0.6, "#0b1220");
g.addColorStop(1, "#0d1a2b");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// stars
for (var i = 0; i < starField.length; i++) {
var st = starField[i];
var a = 0.4 + 0.5 * Math.abs(Math.sin(tGlobal * 1.5 + st.tw));
ctx.fillStyle = "rgba(180,220,255," + a.toFixed(3) + ")";
ctx.fillRect(st.x * W, st.y * H, st.s, st.s);
}
}
function drawGround() {
var gy = groundY();
// ground glow line
ctx.save();
ctx.strokeStyle = "#1de9b6";
ctx.shadowColor = "#1de9b6";
ctx.shadowBlur = 14;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, gy + 10);
ctx.lineTo(W, gy + 10);
ctx.stroke();
ctx.restore();
// ground fill
ctx.fillStyle = "#08131c";
ctx.fillRect(0, gy + 10, W, H - (gy + 10));
}
function drawCity(c) {
var gy = groundY();
var cw = W * 0.05;
var bx = c.x - cw / 2;
if (!c.alive) {
// rubble
ctx.save();
ctx.fillStyle = "#20303c";
for (var r = 0; r < 4; r++) {
var rw = cw * (0.2 + Math.random() * 0.1);
ctx.fillRect(bx + r * (cw / 4), gy - 4 - Math.random() * 5, rw, 5 + Math.random() * 4);
}
ctx.restore();
return;
}
ctx.save();
ctx.shadowColor = "#36e0ff";
ctx.shadowBlur = 12;
// skyline: a few towers
var towers = [
{ w: cw * 0.20, h: 20 },
{ w: cw * 0.16, h: 32 },
{ w: cw * 0.22, h: 26 },
{ w: cw * 0.16, h: 16 }
];
var tx = bx;
var hue = "#2ad4ff";
for (var t = 0; t < towers.length; t++) {
var tw = towers[t].w;
var th = towers[t].h * (H / 600);
ctx.fillStyle = hue;
ctx.globalAlpha = 0.9;
ctx.fillRect(tx, gy - th, tw, th);
tx += tw + cw * 0.02;
}
ctx.globalAlpha = 1;
ctx.restore();
// little windows
ctx.fillStyle = "rgba(255,255,180,0.5)";
var wx = bx + 3;
for (var wxi = 0; wxi < 5; wxi++) {
ctx.fillRect(wx + wxi * (cw / 6), gy - 10, 1.5, 1.5);
}
}
function drawBase(b) {
var gy = groundY();
ctx.save();
var lowAmmo = b.ammo <= 0;
var col = lowAmmo ? "#5a6b78" : "#ffd23f";
ctx.shadowColor = col;
ctx.shadowBlur = lowAmmo ? 0 : 14;
ctx.fillStyle = col;
// triangular battery
ctx.beginPath();
var bw = W * 0.03;
ctx.moveTo(b.x - bw, gy + 8);
ctx.lineTo(b.x + bw, gy + 8);
ctx.lineTo(b.x, gy - 16);
ctx.closePath();
ctx.fill();
ctx.restore();
// ammo count
ctx.fillStyle = lowAmmo ? "#7a8792" : "#fff3c4";
ctx.font = "bold " + Math.round(11 * (H / 600) + 6) + "px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(String(b.ammo), b.x, gy + 12);
}
function drawEnemies() {
for (var i = 0; i < enemies.length; i++) {
var e = enemies[i];
// trail
ctx.save();
var col = e.smart ? "#ff4de0" : "#ff5a5a";
ctx.strokeStyle = col;
ctx.shadowColor = col;
ctx.shadowBlur = 8;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(e.sx, e.sy);
for (var t = 0; t < e.trail.length; t++) {
ctx.lineTo(e.trail[t].x, e.trail[t].y);
}
ctx.lineTo(e.x, e.y);
ctx.stroke();
// head
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(e.x, e.y, e.smart ? 3.4 : 2.6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function drawFriendlies() {
for (var i = 0; i < friendlies.length; i++) {
var f = friendlies[i];
ctx.save();
ctx.strokeStyle = "#7dffea";
ctx.shadowColor = "#7dffea";
ctx.shadowBlur = 10;
ctx.lineWidth = 1.8;
ctx.beginPath();
if (f.trail.length) {
ctx.moveTo(f.trail[0].x, f.trail[0].y);
for (var t = 1; t < f.trail.length; t++) ctx.lineTo(f.trail[t].x, f.trail[t].y);
ctx.lineTo(f.x, f.y);
}
ctx.stroke();
// target marker
ctx.strokeStyle = "rgba(125,255,234,0.5)";
ctx.shadowBlur = 0;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(f.tx - 5, f.ty); ctx.lineTo(f.tx + 5, f.ty);
ctx.moveTo(f.tx, f.ty - 5); ctx.lineTo(f.tx, f.ty + 5);
ctx.stroke();
ctx.restore();
}
}
function drawBlasts() {
for (var i = 0; i < blasts.length; i++) {
var B = blasts[i];
var hueT = (tGlobal * 6 + i) % 3;
var color = hueT < 1 ? "#fff6a8" : (hueT < 2 ? "#7dffea" : "#ff8ad4");
ctx.save();
ctx.globalCompositeOperation = "lighter";
// outer ring
ctx.strokeStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 20;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
ctx.stroke();
// inner fill
var grad = ctx.createRadialGradient(B.x, B.y, 0, B.x, B.y, B.r);
grad.addColorStop(0, "rgba(255,255,255,0.5)");
grad.addColorStop(0.5, "rgba(125,255,234,0.25)");
grad.addColorStop(1, "rgba(125,255,234,0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function drawParticles() {
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var a = Math.max(0, Math.min(1, p.life));
ctx.save();
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.shadowColor = p.color;
ctx.shadowBlur = 6;
ctx.fillRect(p.x, p.y, 2.2, 2.2);
ctx.restore();
}
}
function drawHUD() {
ctx.save();
ctx.textBaseline = "top";
var fs = Math.round(14 * (H / 600) + 6);
ctx.font = "bold " + fs + "px monospace";
ctx.textAlign = "left";
ctx.fillStyle = "#7dffea";
ctx.shadowColor = "#7dffea";
ctx.shadowBlur = 8;
ctx.fillText("SCORE " + score, 14, 12);
ctx.textAlign = "center";
ctx.fillStyle = "#ffd23f";
ctx.shadowColor = "#ffd23f";
ctx.fillText("WAVE " + wave, W / 2, 12);
ctx.textAlign = "right";
ctx.fillStyle = "#ff8ad4";
ctx.shadowColor = "#ff8ad4";
ctx.fillText("BEST " + best, W - 14, 12);
ctx.restore();
}
function drawTitleText(lines, subLines) {
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
var cy = H * 0.4;
var big = Math.round(Math.min(W * 0.075, 54));
ctx.font = "bold " + big + "px monospace";
// letter-spaced uppercase title with glow
for (var li = 0; li < lines.length; li++) {
var txt = lines[li];
ctx.fillStyle = "#7dffea";
ctx.shadowColor = "#36e0ff";
ctx.shadowBlur = 24;
drawSpaced(txt.toUpperCase(), W / 2, cy + li * (big * 1.2), big * 0.18);
}
ctx.shadowBlur = 0;
var sm = Math.round(Math.min(W * 0.03, 18));
ctx.font = "bold " + sm + "px monospace";
ctx.fillStyle = "#ffd23f";
var sy = cy + lines.length * (big * 1.2) + 24;
for (var si = 0; si < subLines.length; si++) {
ctx.shadowColor = "#ffd23f";
ctx.shadowBlur = 10;
ctx.fillText(subLines[si], W / 2, sy + si * (sm * 1.6));
}
ctx.restore();
}
function drawSpaced(text, cx, cy, extra) {
// measure with tracking
var widths = [];
var total = 0;
for (var i = 0; i < text.length; i++) {
var w = ctx.measureText(text[i]).width;
widths.push(w);
total += w + (i < text.length - 1 ? extra : 0);
}
var x = cx - total / 2;
for (var j = 0; j < text.length; j++) {
ctx.textAlign = "left";
ctx.fillText(text[j], x, cy);
x += widths[j] + extra;
}
ctx.textAlign = "center";
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawBackground();
// field
var i;
for (i = 0; i < cities.length; i++) drawCity(cities[i]);
for (i = 0; i < bases.length; i++) drawBase(bases[i]);
drawGround();
drawEnemies();
drawFriendlies();
drawBlasts();
drawParticles();
// city-hit flash
if (flashTimer > 0) {
ctx.save();
ctx.fillStyle = "rgba(255,60,110," + (flashTimer * 0.6).toFixed(3) + ")";
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
drawHUD();
if (state === STATE_START) {
drawTitleText(["Missile", "Command"], ["TAP / CLICK TO DEFEND", "PROTECT YOUR 6 CITIES"]);
} else if (state === STATE_OVER) {
drawTitleText(["Game", "Over"], ["SCORE " + score, "TAP TO PLAY AGAIN"]);
} else if (state === STATE_WAVEEND) {
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
var big = Math.round(Math.min(W * 0.05, 34));
ctx.font = "bold " + big + "px monospace";
ctx.fillStyle = "#ffd23f";
ctx.shadowColor = "#ffd23f";
ctx.shadowBlur = 18;
drawSpaced("WAVE " + wave + " CLEAR", W / 2, H * 0.4, big * 0.14);
ctx.shadowBlur = 8;
var sm = Math.round(Math.min(W * 0.028, 16));
ctx.font = "bold " + sm + "px monospace";
ctx.fillStyle = "#7dffea";
ctx.shadowColor = "#7dffea";
ctx.fillText("BONUS TALLY...", W / 2, H * 0.4 + big + 20);
ctx.restore();
}
}
// ---- Loop ----
var last = 0;
var running = true;
function frame(now) {
if (!running) return;
if (!last) last = now;
var dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05; // clamp
tGlobal += dt;
// keep field positions in sync with size
repositionField();
try {
update(dt);
draw();
} catch (err) {
// never let a frame throw a pageerror
running = false;
}
window.requestAnimationFrame(frame);
}
window.requestAnimationFrame(frame);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
</script>
</body>
</html>