Skip to content

Instantly share code, notes, and snippets.

@sandro-pasquali
Created September 6, 2026 12:54
Show Gist options
  • Select an option

  • Save sandro-pasquali/34ab8588d220919a1639ee2d021cf5c1 to your computer and use it in GitHub Desktop.

Select an option

Save sandro-pasquali/34ab8588d220919a1639ee2d021cf5c1 to your computer and use it in GitHub Desktop.
Missile Command
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Missile Command</title>
<style>
html, body {
height: 100%;
}
body {
background-color: #000000;
margin: 0;
overflow: hidden;
font-family: "Courier New", Courier, monospace;
}
#viewport {
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
background-color: #000000;
cursor: none;
touch-action: none;
}
/* the playfield is a fixed 640x480 grid scaled to fit, the way the
original ran at one resolution whatever cabinet it sat in */
#stage {
position: relative;
width: 640px;
height: 480px;
background-color: #000000;
overflow: hidden;
isolation: isolate;
transform-origin: center center;
}
.sprite {
position: absolute;
left: 0;
top: 0;
width: 128px;
height: 128px;
margin: -64px 0 0 -64px;
border-radius: 50%;
pointer-events: none;
z-index: 3;
mix-blend-mode: screen;
mix-blend-mode: plus-lighter;
}
.line {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 2px;
margin-top: -1px;
transform-origin: 0 50%;
pointer-events: none;
z-index: 2;
}
.head {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 4px;
margin: -2px 0 0 -2px;
pointer-events: none;
z-index: 2;
}
.ground {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 52px;
z-index: 1;
}
.city {
position: absolute;
width: 44px;
height: 22px;
z-index: 1;
clip-path: polygon(0% 100%, 0% 55%, 9% 55%, 9% 32%, 20% 32%, 20% 55%, 30% 55%, 30% 18%, 43% 18%, 43% 0%, 57% 0%, 57% 18%, 70% 18%, 70% 55%, 80% 55%, 80% 32%, 91% 32%, 91% 55%, 100% 55%, 100% 100%);
}
.city.dead {
clip-path: polygon(0% 100%, 6% 72%, 16% 88%, 28% 66%, 40% 86%, 52% 70%, 64% 88%, 76% 70%, 88% 86%, 100% 100%);
}
.silo {
position: absolute;
width: 64px;
height: 26px;
z-index: 1;
clip-path: polygon(0% 100%, 12% 40%, 30% 12%, 50% 4%, 70% 12%, 88% 40%, 100% 100%);
}
.silo.dead {
clip-path: polygon(0% 100%, 10% 76%, 24% 90%, 38% 70%, 52% 88%, 66% 72%, 82% 90%, 100% 100%);
}
.ammo {
position: absolute;
width: 3px;
height: 3px;
margin: -1px 0 0 -1px;
z-index: 2;
}
.raider {
position: absolute;
width: 26px;
height: 11px;
margin: -5px 0 0 -13px;
z-index: 2;
}
.raider.plane {
clip-path: polygon(0% 45%, 60% 45%, 75% 12%, 85% 12%, 80% 45%, 100% 50%, 80% 56%, 85% 88%, 75% 88%, 60% 56%, 0% 56%);
}
.raider.satellite {
clip-path: polygon(0% 28%, 30% 28%, 30% 8%, 70% 8%, 70% 28%, 100% 28%, 100% 72%, 70% 72%, 70% 92%, 30% 92%, 30% 72%, 0% 72%);
}
.shard {
position: absolute;
left: 0;
top: 0;
width: 6px;
height: 2px;
margin: -1px 0 0 -3px;
z-index: 4;
pointer-events: none;
}
.ring {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
border-style: solid;
box-sizing: border-box;
z-index: 4;
pointer-events: none;
}
#flash {
position: absolute;
inset: 0;
z-index: 45;
pointer-events: none;
background-color: #ffffff;
opacity: 0;
}
.target {
position: absolute;
left: 0;
top: 0;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
z-index: 29;
pointer-events: none;
clip-path: polygon(20% 0%, 50% 30%, 80% 0%, 100% 20%, 70% 50%, 100% 80%, 80% 100%, 50% 70%, 20% 100%, 0% 80%, 30% 50%, 0% 20%);
}
#crosshair {
position: absolute;
left: 0;
top: 0;
width: 22px;
height: 22px;
margin: -11px 0 0 -11px;
z-index: 30;
pointer-events: none;
clip-path: polygon(45% 0%, 55% 0%, 55% 38%, 100% 38%, 100% 48%, 55% 48%, 55% 100%, 45% 100%, 45% 48%, 0% 48%, 0% 38%, 45% 38%);
}
#hud {
position: absolute;
left: 0;
right: 0;
top: 6px;
z-index: 40;
display: flex;
justify-content: space-between;
padding: 0 14px;
font-size: 13px;
font-weight: bold;
letter-spacing: 2px;
pointer-events: none;
white-space: pre;
}
#panel {
position: absolute;
inset: 0;
z-index: 41;
display: grid;
place-content: center;
gap: 16px;
text-align: center;
pointer-events: none;
font-weight: bold;
}
#panel-title {
font-size: 26px;
letter-spacing: 8px;
}
/* the logo is stacked and extruded down-right through three fixed colours,
so it stays the same mark whatever palette the wave is wearing */
#panel-title.logo {
font-size: 58px;
line-height: 0.92;
letter-spacing: 6px;
color: #ffd23f;
text-shadow:
1px 1px 0 #ff7a18,
2px 2px 0 #ff7a18,
3px 3px 0 #ff7a18,
4px 4px 0 #c1121f,
5px 5px 0 #c1121f,
6px 6px 0 #c1121f;
}
#panel-body {
display: grid;
gap: 6px;
font-size: 11px;
letter-spacing: 2px;
}
#panel-foot {
font-size: 11px;
letter-spacing: 3px;
}
.panel-row {
white-space: pre;
}
.legend {
display: grid;
grid-template-columns: repeat(4, 120px);
gap: 8px;
justify-content: center;
}
.legend-cell {
display: grid;
justify-items: center;
gap: 8px;
}
.legend-art {
position: relative;
width: 96px;
height: 52px;
}
.legend-art > * {
position: absolute;
}
.legend-ground {
left: 0;
top: 50px;
width: 96px;
height: 2px;
}
.legend-caption {
font-size: 9px;
letter-spacing: 1px;
line-height: 14px;
}
.panel-gap {
height: 8px;
}
.panel-slot {
display: inline-block;
width: 22px;
font-size: 20px;
}
#messages {
position: absolute;
inset: 0;
z-index: 40;
display: grid;
place-content: center;
gap: 10px;
text-align: center;
pointer-events: none;
}
#banner {
font-size: 34px;
font-weight: bold;
letter-spacing: 8px;
}
#subtitle {
font-size: 13px;
font-weight: bold;
letter-spacing: 4px;
}
/* cabinet dressing: scanlines and a soft tube vignette */
#tally {
display: none;
gap: 10px;
justify-items: center;
}
.tally-row {
display: flex;
align-items: flex-end;
gap: 12px;
}
.tally-icons {
display: flex;
align-items: flex-end;
gap: 3px;
min-height: 14px;
/* wide enough for a full rack of 30 missiles, so the two rows share
a left edge instead of each centring on its own width */
min-width: 300px;
}
.tally-count {
min-width: 34px;
text-align: right;
font-size: 17px;
font-weight: bold;
letter-spacing: 2px;
color: #ffd23f;
}
.icon-missile {
position: relative;
width: 7px;
height: 14px;
clip-path: polygon(50% 0%, 72% 22%, 72% 74%, 100% 100%, 60% 86%, 40% 86%, 0% 100%, 28% 74%, 28% 22%);
}
/* the same skyline the cities are cut from, at half size */
.icon-city {
position: relative;
width: 22px;
height: 11px;
z-index: auto;
}
#scanlines {
position: absolute;
inset: 0;
z-index: 50;
pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.34) 0 1px, rgba(0, 0, 0, 0) 1px 3px);
}
#vignette {
position: absolute;
inset: 0;
z-index: 51;
pointer-events: none;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.55) 100%);
}
</style>
</head>
<body>
<div id="viewport">
<div id="stage">
<div id="hud"><span id="hud-score">0</span><span id="hud-high">HIGH 0</span><span id="hud-wave">WAVE 1 X1</span></div>
<div id="messages">
<div id="banner"></div>
<div id="subtitle"></div>
<div id="tally">
<div class="tally-row">
<div class="tally-count" id="tally-missile-count"></div>
<div class="tally-icons" id="tally-missiles"></div>
</div>
<div class="tally-row">
<div class="tally-count" id="tally-city-count"></div>
<div class="tally-icons" id="tally-cities"></div>
</div>
</div>
</div>
<div id="panel">
<div id="panel-title"></div>
<div id="panel-body"></div>
<div id="panel-foot"></div>
</div>
<div id="crosshair"></div>
<div id="flash"></div>
<div id="scanlines"></div>
<div id="vignette"></div>
</div>
</div>
<script>
// ---------------------------------------------------------------
// playfield
// ---------------------------------------------------------------
const FIELD = { width: 640, height: 480 };
const GROUND_Y = 428;
const SILO_XS = [40, 320, 600];
const CITY_XS = [104, 168, 232, 408, 472, 536];
const CITY_HALF_W = 22;
const CITY_H = 22;
const SILO_HALF_W = 32;
const SILO_H = 26;
const AMMO_PER_SILO = 10;
// ---------------------------------------------------------------
// fireworks system: pooled sprites, hue classes, decaying trail segments
// ---------------------------------------------------------------
const SPRITE_RADIUS = 64;
const HUE_STEP = 10;
const MAX_PARTICLES = 600;
const MAX_TRAILS = 600;
const TRAIL_DECAY = 0.85;
const TRAIL_STEP = 2;
const TRACER_CHANCE = 0.2;
// the blast is the fireworks burst, so the lethal radius is derived from
// the spark physics: a spark losing 8% of its speed each frame travels
// v / 0.08 in total, and that front edge is exactly what kills
const BLAST_RESISTANCE = 0.92;
const BLAST_RADIUS = 42;
const BLAST_SPEED = BLAST_RADIUS * (1 - BLAST_RESISTANCE);
const BLAST_FRAMES = 46;
const BLAST_SPARKS = 32;
const INTERCEPTOR_SPEED = 6;
// the tally is paced so every step is legible, including a wave that ends
// with no missiles left, where the cities are all there is to count
const BONUS_LEAD = 40;
const BONUS_TICK = 4;
const BONUS_PAUSE = 30;
const BONUS_CITY_TICK = 12;
const BONUS_HOLD = 60;
// a warhead reaching the ground is not a firework: full lethal radius in
// five frames, hard debris, a shockwave, and the screen kicked off centre
const IMPACT_RADIUS = 36;
const IMPACT_RISE = 5;
const IMPACT_FRAMES = 24;
const IMPACT_SHARDS = 20;
// ---------------------------------------------------------------
// wave colours, cycled the way the arcade repainted every level
// ---------------------------------------------------------------
const PALETTES = [
{ ground: `#d4a017`, city: `#4d6bff`, enemy: `#ff3b30`, enemyHue: 0, allyHue: 180, text: `#d4a017` },
{ ground: `#2e9e4f`, city: `#ff6b3b`, enemy: `#ff2bd0`, enemyHue: 320, allyHue: 60, text: `#2e9e4f` },
{ ground: `#3f7bd4`, city: `#ffd400`, enemy: `#ff5a00`, enemyHue: 20, allyHue: 200, text: `#3f7bd4` },
{ ground: `#a03fd4`, city: `#3ad4c0`, enemy: `#ff3b7a`, enemyHue: 340, allyHue: 160, text: `#a03fd4` },
{ ground: `#c73a3a`, city: `#7ad43a`, enemy: `#ffb000`, enemyHue: 40, allyHue: 120, text: `#c73a3a` },
{ ground: `#3ad4d4`, city: `#ff3af0`, enemy: `#ff2b2b`, enemyHue: 0, allyHue: 190, text: `#3ad4d4` }
];
const TABLE_SIZE = 6;
const DEFAULT_SCORES = [
{ name: `ACE`, score: 15000 },
{ name: `BOM`, score: 12000 },
{ name: `CIT`, score: 9000 },
{ name: `DEF`, score: 6000 },
{ name: `EVE`, score: 3000 },
{ name: `FOX`, score: 1000 }
];
// the how-to-play screen is drawn from the same shapes the game uses,
// so whatever it shows is what the player will actually see
const LEGEND = [
{ art: `city`, lines: [`PROTECT`, `SIX CITIES`] },
{ art: `base`, lines: [`THREE BASES`, `TEN SHOTS`] },
{ art: `incoming`, lines: [`INCOMING`, `SHOOT IT DOWN`] },
{ art: `defence`, lines: [`YOUR MISSILE`, `CLICK TO AIM`] }
];
const stage = document.getElementById(`stage`);
const viewport = document.getElementById(`viewport`);
const crosshair = document.getElementById(`crosshair`);
const hudScore = document.getElementById(`hud-score`);
const hudHigh = document.getElementById(`hud-high`);
const hudWave = document.getElementById(`hud-wave`);
const banner = document.getElementById(`banner`);
const subtitle = document.getElementById(`subtitle`);
const flashEl = document.getElementById(`flash`);
const panel = document.getElementById(`panel`);
const panelTitle = document.getElementById(`panel-title`);
const panelBody = document.getElementById(`panel-body`);
const panelFoot = document.getElementById(`panel-foot`);
const tally = document.getElementById(`tally`);
const tallyMissiles = document.getElementById(`tally-missiles`);
const tallyCities = document.getElementById(`tally-cities`);
const tallyMissileCount = document.getElementById(`tally-missile-count`);
const tallyCityCount = document.getElementById(`tally-city-count`);
const spritePool = [];
const linePool = [];
const headPool = [];
const shardPool = [];
const ringPool = [];
const targetPool = [];
let particles = [];
let trails = [];
let interceptors = [];
let explosions = [];
let enemies = [];
let raiders = [];
let shards = [];
let flash = 0;
let shake = 0;
let frame = 0;
// one class per hue so a sprite picks its fill by class name and no
// gradient string is ever built while the game is running
function buildSpriteStyles() {
const rules = [`.sprite-white { background: radial-gradient(circle closest-side, rgba(255, 255, 255, 1) 0 55%, rgba(255, 255, 255, 0.25) 72%, rgba(255, 255, 255, 0) 100%); }`];
for (let hue = 0; hue < 360; hue += HUE_STEP) {
rules.push(`.sprite-h${hue} { background: radial-gradient(circle closest-side, rgba(255, 255, 255, 1) 5%, hsla(${hue}, 100%, 55%, 1) 40%, hsla(${hue}, 100%, 55%, 0.1) 50%); }`);
}
const sheet = document.createElement(`style`);
sheet.textContent = rules.join(``);
document.head.appendChild(sheet);
}
function acquireSprite({ variant }) {
let el = spritePool.pop();
if (!el) {
el = document.createElement(`div`);
stage.appendChild(el);
}
el.className = `sprite ${variant}`;
el.style.display = ``;
return el;
}
function releaseSprite(el) {
el.style.display = `none`;
spritePool.push(el);
}
function acquireLine({ color }) {
let el = linePool.pop();
if (!el) {
el = document.createElement(`div`);
el.className = `line`;
stage.appendChild(el);
}
el.style.backgroundColor = color;
el.style.display = ``;
return el;
}
function releaseLine(el) {
el.style.display = `none`;
linePool.push(el);
}
function acquireHead({ color }) {
let el = headPool.pop();
if (!el) {
el = document.createElement(`div`);
el.className = `head`;
stage.appendChild(el);
}
el.style.backgroundColor = color;
el.style.display = ``;
return el;
}
function releaseHead(el) {
el.style.display = `none`;
headPool.push(el);
}
function acquireShard({ color }) {
let el = shardPool.pop();
if (!el) {
el = document.createElement(`div`);
el.className = `shard`;
stage.appendChild(el);
}
el.style.backgroundColor = color;
el.style.display = ``;
return el;
}
function releaseShard(el) {
el.style.display = `none`;
shardPool.push(el);
}
function acquireRing({ color, width }) {
let el = ringPool.pop();
if (!el) {
el = document.createElement(`div`);
el.className = `ring`;
stage.appendChild(el);
}
el.style.borderColor = color;
el.style.borderWidth = `${width}px`;
el.style.display = ``;
return el;
}
function releaseRing(el) {
el.style.display = `none`;
ringPool.push(el);
}
function acquireTarget({ color }) {
let el = targetPool.pop();
if (!el) {
el = document.createElement(`div`);
el.className = `target`;
stage.appendChild(el);
}
el.style.backgroundColor = color;
el.style.visibility = `visible`;
el.style.display = ``;
return el;
}
function releaseTarget(el) {
el.style.display = `none`;
targetPool.push(el);
}
class Particle {
constructor(pos) {
this.pos = {
x: pos ? pos.x : 0,
y: pos ? pos.y : 0
};
this.vel = { x: 0, y: 0 };
this.shrink = 0.97;
this.size = 2;
this.resistance = 1;
this.gravity = 0;
this.flick = false;
this.alpha = 1;
this.fade = 0;
this.variant = `sprite-h0`;
this.tracer = false;
this.phase = 0;
this.el = null;
}
attach() {
this.el = acquireSprite({ variant: this.variant });
}
detach() {
if (this.el) {
releaseSprite(this.el);
this.el = null;
}
}
update() {
// apply resistance
this.vel.x *= this.resistance;
this.vel.y *= this.resistance;
// gravity down
this.vel.y += this.gravity;
// update position based on speed
this.pos.x += this.vel.x;
this.pos.y += this.vel.y;
// shrink
this.size *= this.shrink;
// fade out
this.alpha -= this.fade;
}
radius() {
return this.flick ? Math.random() * this.size : this.size;
}
render() {
if (!this.el || !this.exists()) {
return;
}
this.el.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px) scale(${this.radius() / SPRITE_RADIUS})`;
this.el.style.opacity = this.alpha;
}
exists() {
return this.alpha >= 0.1 && this.size >= 1;
}
}
// a segment covering the ground just crossed: rotated onto the heading,
// stretched to the distance travelled, then burned down frame by frame
function spawnTrail({ particle, frames }) {
if (trails.length >= MAX_TRAILS) {
return;
}
const speed = Math.hypot(particle.vel.x, particle.vel.y);
const half = speed * frames / 2 + particle.size;
const angle = Math.atan2(particle.vel.y, particle.vel.x);
const x = particle.pos.x - particle.vel.x * frames / 2;
const y = particle.pos.y - particle.vel.y * frames / 2;
const el = acquireSprite({ variant: particle.variant });
el.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad) scale(${half / SPRITE_RADIUS}, ${particle.size / SPRITE_RADIUS})`;
el.style.opacity = particle.alpha;
trails.push({ el: el, alpha: particle.alpha });
}
function updateParticles() {
const surviving = [];
for (const particle of particles) {
particle.update();
if (particle.exists()) {
particle.render();
if (particle.tracer && frame % TRAIL_STEP === particle.phase) {
spawnTrail({ particle: particle, frames: TRAIL_STEP });
}
surviving.push(particle);
} else {
particle.detach();
}
}
particles = surviving;
while (particles.length > MAX_PARTICLES) {
particles.shift().detach();
}
}
function updateTrails() {
const surviving = [];
for (const trail of trails) {
trail.alpha *= TRAIL_DECAY;
if (trail.alpha < 0.05) {
releaseSprite(trail.el);
} else {
trail.el.style.opacity = trail.alpha;
surviving.push(trail);
}
}
trails = surviving;
// over budget, the oldest segments are also the dimmest ones to lose
while (trails.length > MAX_TRAILS) {
releaseSprite(trails.shift().el);
}
}
// ---------------------------------------------------------------
// explosions
// ---------------------------------------------------------------
class Explosion {
constructor({ x, y, hue, hostile }) {
this.pos = { x: x, y: y };
this.hue = hue;
this.hostile = hostile;
this.age = 0;
this.radius = 0;
this.core = acquireSprite({ variant: `sprite-h${hue}` });
for (let i = 0; i < BLAST_SPARKS; i++) {
const spark = new Particle(this.pos);
const angle = Math.random() * Math.PI * 2;
// emulate a 3D effect with cosine so more sparks land in the middle
const speed = Math.cos(Math.random() * Math.PI / 2) * BLAST_SPEED;
spark.vel.x = Math.cos(angle) * speed;
spark.vel.y = Math.sin(angle) * speed;
spark.size = 4;
spark.gravity = 0.05;
spark.resistance = BLAST_RESISTANCE;
spark.shrink = Math.random() * 0.05 + 0.93;
spark.flick = true;
spark.variant = `sprite-h${hue}`;
spark.tracer = Math.random() < TRACER_CHANCE;
spark.phase = Math.floor(Math.random() * TRAIL_STEP);
spark.attach();
particles.push(spark);
}
}
update() {
this.age++;
this.radius = BLAST_RADIUS * (1 - Math.pow(BLAST_RESISTANCE, this.age));
const fade = Math.max(0, 1 - this.age / BLAST_FRAMES);
this.core.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px) scale(${this.radius / SPRITE_RADIUS})`;
this.core.style.opacity = fade * 0.4;
}
get alive() {
return this.age < BLAST_FRAMES;
}
detach() {
releaseSprite(this.core);
}
}
// ---------------------------------------------------------------
// ground strikes
// ---------------------------------------------------------------
class Impact {
constructor({ x, y, palette }) {
this.pos = { x: x, y: y };
this.hostile = true;
this.age = 0;
this.radius = 0;
this.core = acquireSprite({ variant: `sprite-white` });
// two waves: a tight white one at the kill radius, a wide faint
// pressure front that outruns it
this.rings = [
{ el: acquireRing({ color: `#ffffff`, width: 3 }), reach: IMPACT_RADIUS * 1.3, life: 12 },
{ el: acquireRing({ color: palette.enemy, width: 2 }), reach: IMPACT_RADIUS * 2.6, life: 22 }
];
for (const ring of this.rings) {
ring.el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
}
const debris = [`#ffffff`, palette.enemy, palette.ground, palette.city];
for (let i = 0; i < IMPACT_SHARDS; i++) {
spawnShard({
x: x,
y: y,
color: debris[Math.floor(Math.random() * debris.length)]
});
}
flash = Math.min(0.4, flash + 0.3);
shake = Math.max(shake, 11);
}
update() {
this.age++;
// squared ease-out: full lethal radius inside a tenth of a second
const rise = Math.min(1, this.age / IMPACT_RISE);
this.radius = IMPACT_RADIUS * (1 - (1 - rise) * (1 - rise));
const heat = this.age < 4 ? 1 : Math.max(0, 1 - (this.age - 4) / (IMPACT_FRAMES - 4));
this.core.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px) scale(${(this.radius + 4) / SPRITE_RADIUS})`;
this.core.style.opacity = heat * 0.9;
for (const ring of this.rings) {
const t = Math.min(1, this.age / ring.life);
const reach = ring.reach * (1 - Math.pow(1 - t, 3));
const size = Math.max(2, reach * 2);
ring.el.style.width = `${size}px`;
ring.el.style.height = `${size}px`;
ring.el.style.opacity = 1 - t;
}
}
get alive() {
return this.age < IMPACT_FRAMES;
}
detach() {
releaseSprite(this.core);
for (const ring of this.rings) {
releaseRing(ring.el);
}
}
}
function spawnShard({ x, y, color }) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.6;
const speed = 2 + Math.random() * 5.5;
shards.push({
el: acquireShard({ color: color }),
pos: { x: x, y: y },
vel: { x: Math.cos(angle) * speed, y: Math.sin(angle) * speed },
angle: Math.random() * 360,
spin: (Math.random() - 0.5) * 36,
length: 3 + Math.random() * 7,
life: 22 + Math.random() * 16
});
}
function updateShards() {
const flying = [];
for (const shard of shards) {
shard.life--;
if (shard.life <= 0 || shard.pos.y > FIELD.height) {
releaseShard(shard.el);
continue;
}
shard.vel.y += 0.34;
shard.pos.x += shard.vel.x;
shard.pos.y += shard.vel.y;
shard.angle += shard.spin;
shard.el.style.transform = `translate(${shard.pos.x}px, ${shard.pos.y}px) rotate(${shard.angle}deg) scaleX(${shard.length / 6})`;
shard.el.style.opacity = Math.min(1, shard.life / 12);
flying.push(shard);
}
shards = flying;
}
function applyScreenEffects() {
if (flash > 0) {
flash *= 0.7;
if (flash < 0.01) {
flash = 0;
}
flashEl.style.opacity = flash;
}
if (shake > 0) {
shake *= 0.84;
if (shake < 0.25) {
shake = 0;
}
}
const kickX = shake > 0 ? (Math.random() - 0.5) * shake * 2 : 0;
const kickY = shake > 0 ? (Math.random() - 0.5) * shake * 2 : 0;
stage.style.transform = `translate(${kickX}px, ${kickY}px) scale(${scale})`;
}
function detonate({ x, y, hue }) {
explosions.push(new Explosion({ x: x, y: y, hue: hue, hostile: false }));
playBoom();
}
function impactAt({ x, y }) {
explosions.push(new Impact({ x: x, y: y, palette: palette }));
playImpact();
}
// ---------------------------------------------------------------
// ordnance
// ---------------------------------------------------------------
class Interceptor extends Particle {
constructor({ from, to, hue }) {
super(from);
this.target = { x: to.x, y: to.y };
const dx = to.x - from.x;
const dy = to.y - from.y;
const distance = Math.max(Math.hypot(dx, dy), 0.001);
this.vel = {
x: dx / distance * INTERCEPTOR_SPEED,
y: dy / distance * INTERCEPTOR_SPEED
};
this.size = 2.5;
this.shrink = 1;
this.resistance = 1;
this.gravity = 0;
this.hue = hue;
this.variant = `sprite-h${hue}`;
this.tracer = true;
this.phase = frame % TRAIL_STEP;
this.marker = null;
}
detach() {
super.detach();
if (this.marker) {
releaseTarget(this.marker);
this.marker = null;
}
}
arrived() {
return Math.hypot(this.target.x - this.pos.x, this.target.y - this.pos.y) <= INTERCEPTOR_SPEED;
}
}
class Enemy {
constructor({ x, y, target, speed, palette, splits }) {
this.origin = { x: x, y: y };
this.pos = { x: x, y: y };
this.target = { x: target.x, y: target.y };
this.palette = palette;
this.splits = splits;
this.splitAt = 80 + Math.random() * 140;
const dx = target.x - x;
const dy = target.y - y;
const distance = Math.max(Math.hypot(dx, dy), 0.001);
this.vel = {
x: dx / distance * speed,
y: dy / distance * speed
};
this.speed = speed;
this.line = acquireLine({ color: palette.enemy });
this.head = acquireHead({ color: `#ffffff` });
}
update() {
this.pos.x += this.vel.x;
this.pos.y += this.vel.y;
}
render() {
const dx = this.pos.x - this.origin.x;
const dy = this.pos.y - this.origin.y;
const length = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
this.line.style.transform = `translate(${this.origin.x}px, ${this.origin.y}px) rotate(${angle}rad) scaleX(${length})`;
this.head.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px)`;
}
detach() {
releaseLine(this.line);
releaseHead(this.head);
}
}
class Raider {
constructor({ palette, speed, kind }) {
this.kind = kind;
this.direction = Math.random() < 0.5 ? 1 : -1;
this.pos = {
x: this.direction > 0 ? -20 : FIELD.width + 20,
y: 46 + Math.random() * 90
};
this.speed = speed;
this.palette = palette;
this.dropTimer = 30 + Math.random() * 40;
this.el = document.createElement(`div`);
this.el.className = `raider ${kind}`;
this.el.style.backgroundColor = palette.enemy;
stage.appendChild(this.el);
}
update() {
this.pos.x += this.speed * this.direction;
this.el.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px) scaleX(${this.direction})`;
}
get offscreen() {
return this.pos.x < -40 || this.pos.x > FIELD.width + 40;
}
detach() {
this.el.remove();
}
}
// ---------------------------------------------------------------
// ground installations
// ---------------------------------------------------------------
const cities = CITY_XS.map((x) => {
const el = document.createElement(`div`);
el.className = `city`;
el.style.left = `${x - CITY_HALF_W}px`;
el.style.top = `${GROUND_Y - CITY_H}px`;
stage.appendChild(el);
return { x: x, el: el, alive: true };
});
const silos = SILO_XS.map((x) => {
const el = document.createElement(`div`);
el.className = `silo`;
el.style.left = `${x - SILO_HALF_W}px`;
el.style.top = `${GROUND_Y - SILO_H}px`;
stage.appendChild(el);
// the classic ammo pyramid, drawn apex first so it empties from the top
const dots = [];
for (let row = 0; row < 4; row++) {
const count = row + 1;
for (let i = 0; i < count; i++) {
const dot = document.createElement(`div`);
dot.className = `ammo`;
dot.style.left = `${x + (i - (count - 1) / 2) * 6}px`;
dot.style.top = `${GROUND_Y - 22 + row * 5}px`;
stage.appendChild(dot);
dots.push(dot);
}
}
return { x: x, el: el, dots: dots, alive: true, ammo: AMMO_PER_SILO };
});
const ground = document.createElement(`div`);
ground.className = `ground`;
stage.appendChild(ground);
// ---------------------------------------------------------------
// sound
// ---------------------------------------------------------------
let audioCtx = null;
let noiseBuffer = null;
let crunchCurve = null;
let squareCurve = null;
let master = null;
let muted = false;
function ensureAudio() {
if (audioCtx) {
return;
}
try {
const Ctor = window.AudioContext || window.webkitAudioContext;
audioCtx = new Ctor();
// everything runs through a compressor so overlapping impacts get
// loud without tearing into digital clipping
master = audioCtx.createDynamicsCompressor();
master.threshold.value = -14;
master.ratio.value = 12;
master.attack.value = 0.002;
master.release.value = 0.18;
master.connect(audioCtx.destination);
const length = Math.floor(audioCtx.sampleRate * 1.5);
noiseBuffer = audioCtx.createBuffer(1, length, audioCtx.sampleRate);
const channel = noiseBuffer.getChannelData(0);
for (let i = 0; i < length; i++) {
channel[i] = Math.random() * 2 - 1;
}
crunchCurve = new Float32Array(1024);
squareCurve = new Float32Array(1024);
for (let i = 0; i < 1024; i++) {
const x = (i / 1023) * 2 - 1;
crunchCurve[i] = Math.tanh(x * 3.2);
// steep enough to be effectively a hard clip, which is what
// gives noise a square edge instead of a breathy one
squareCurve[i] = Math.tanh(x * 14);
}
} catch (error) {
audioCtx = null;
}
}
// a rocket motor swells and sustains - a fast attack with a fast decay is
// a gunshot. The clamp releases, then fire builds over the better part of
// a second: saturated low thrust, a mid band tearing away, sub underneath
function playLaunch() {
if (!audioCtx || muted) {
return;
}
const now = audioCtx.currentTime;
const ignite = now + 0.03;
const latch = audioCtx.createBufferSource();
const latchFilter = audioCtx.createBiquadFilter();
const latchGain = audioCtx.createGain();
latch.buffer = noiseBuffer;
latch.playbackRate.value = 2;
latchFilter.type = `bandpass`;
latchFilter.Q.value = 10;
latchFilter.frequency.value = 2600;
latchGain.gain.setValueAtTime(0.16, now);
latchGain.gain.exponentialRampToValueAtTime(0.001, now + 0.035);
latch.connect(latchFilter).connect(latchGain).connect(master);
latch.start(now);
latch.stop(now + 0.04);
const thrust = audioCtx.createBufferSource();
const thrustFilter = audioCtx.createBiquadFilter();
const thrustShaper = audioCtx.createWaveShaper();
const thrustGain = audioCtx.createGain();
thrust.buffer = noiseBuffer;
thrust.playbackRate.value = 0.55;
thrustFilter.type = `lowpass`;
thrustFilter.frequency.setValueAtTime(500, ignite);
thrustFilter.frequency.exponentialRampToValueAtTime(1200, ignite + 0.18);
thrustShaper.curve = crunchCurve;
thrustGain.gain.setValueAtTime(0.0001, ignite);
thrustGain.gain.linearRampToValueAtTime(0.2, ignite + 0.07);
thrustGain.gain.exponentialRampToValueAtTime(0.001, ignite + 0.8);
thrust.connect(thrustFilter).connect(thrustShaper).connect(thrustGain).connect(master);
thrust.start(ignite);
thrust.stop(ignite + 0.84);
const tear = audioCtx.createBufferSource();
const band = audioCtx.createBiquadFilter();
const tearShaper = audioCtx.createWaveShaper();
const tearGain = audioCtx.createGain();
tear.buffer = noiseBuffer;
tear.playbackRate.value = 0.95 + Math.random() * 0.2;
band.type = `bandpass`;
band.Q.value = 0.9;
band.frequency.setValueAtTime(1600, ignite);
band.frequency.exponentialRampToValueAtTime(520, ignite + 0.7);
tearShaper.curve = squareCurve;
tearGain.gain.setValueAtTime(0.0001, ignite);
tearGain.gain.linearRampToValueAtTime(0.075, ignite + 0.06);
tearGain.gain.exponentialRampToValueAtTime(0.001, ignite + 0.78);
tear.connect(band).connect(tearShaper).connect(tearGain).connect(master);
tear.start(ignite);
tear.stop(ignite + 0.8);
const rumble = audioCtx.createBufferSource();
const rumbleFilter = audioCtx.createBiquadFilter();
const rumbleGain = audioCtx.createGain();
rumble.buffer = noiseBuffer;
rumble.playbackRate.value = 0.35;
rumbleFilter.type = `lowpass`;
rumbleFilter.frequency.value = 130;
rumbleGain.gain.setValueAtTime(0.0001, ignite);
rumbleGain.gain.linearRampToValueAtTime(0.28, ignite + 0.1);
rumbleGain.gain.exponentialRampToValueAtTime(0.001, ignite + 0.85);
rumble.connect(rumbleFilter).connect(rumbleGain).connect(master);
rumble.start(ignite);
rumble.stop(ignite + 0.88);
}
// counting blip, short enough to fire every few frames without smearing
function playTick() {
if (!audioCtx || muted) {
return;
}
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = `square`;
osc.frequency.value = 1250;
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain).connect(master);
osc.start(now);
osc.stop(now + 0.06);
}
function playBoom() {
if (!audioCtx || muted) {
return;
}
const now = audioCtx.currentTime;
const source = audioCtx.createBufferSource();
const filter = audioCtx.createBiquadFilter();
const gain = audioCtx.createGain();
source.buffer = noiseBuffer;
filter.type = `lowpass`;
filter.frequency.setValueAtTime(1600, now);
filter.frequency.exponentialRampToValueAtTime(140, now + 0.32);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.32);
source.connect(filter).connect(gain).connect(master);
source.start(now);
source.stop(now + 0.34);
}
// three stacked layers: a crack off the top, a distorted body collapsing
// into a rumble, and a sub that drops an octave and a half under it
function playImpact() {
if (!audioCtx || muted) {
return;
}
const now = audioCtx.currentTime;
const crack = audioCtx.createBufferSource();
const crackFilter = audioCtx.createBiquadFilter();
const crackGain = audioCtx.createGain();
crack.buffer = noiseBuffer;
crack.playbackRate.value = 1.7;
crackFilter.type = `highpass`;
crackFilter.frequency.value = 1800;
crackGain.gain.setValueAtTime(0.5, now);
crackGain.gain.exponentialRampToValueAtTime(0.001, now + 0.09);
crack.connect(crackFilter).connect(crackGain).connect(master);
crack.start(now);
crack.stop(now + 0.1);
const body = audioCtx.createBufferSource();
const bodyFilter = audioCtx.createBiquadFilter();
const shaper = audioCtx.createWaveShaper();
const bodyGain = audioCtx.createGain();
body.buffer = noiseBuffer;
body.playbackRate.value = 0.8;
bodyFilter.type = `lowpass`;
bodyFilter.Q.value = 6;
bodyFilter.frequency.setValueAtTime(2600, now);
bodyFilter.frequency.exponentialRampToValueAtTime(70, now + 0.9);
shaper.curve = crunchCurve;
bodyGain.gain.setValueAtTime(0.6, now);
bodyGain.gain.exponentialRampToValueAtTime(0.001, now + 1.1);
body.connect(bodyFilter).connect(shaper).connect(bodyGain).connect(master);
body.start(now);
body.stop(now + 1.15);
const sub = audioCtx.createOscillator();
const subGain = audioCtx.createGain();
sub.type = `sine`;
sub.frequency.setValueAtTime(120, now);
sub.frequency.exponentialRampToValueAtTime(26, now + 0.55);
subGain.gain.setValueAtTime(0.5, now);
subGain.gain.exponentialRampToValueAtTime(0.001, now + 0.75);
sub.connect(subGain).connect(master);
sub.start(now);
sub.stop(now + 0.8);
}
// ---------------------------------------------------------------
// game state
// ---------------------------------------------------------------
let mode = `title`;
let paused = false;
let score = 0;
let scores = readScores();
let highScore = scores[0].score;
let wave = 1;
let multiplier = 1;
let palette = PALETTES[0];
let nextBonusAt = 10000;
let spawnQueue = 0;
let spawnTimer = 0;
let raiderTimer = 0;
let raiderQueue = 0;
let bannerTimer = 0;
let modeTimer = 0;
let bonus = null;
let pointer = { x: FIELD.width / 2, y: 220 };
let splashPage = 0;
let entry = null;
let entrySlots = [];
let missileIcons = [];
let cityIcons = [];
function readScores() {
try {
const stored = JSON.parse(window.localStorage.getItem(`missile-command-scores`));
if (!stored || stored.length === 0) {
return defaultScores();
}
return stored.slice(0, TABLE_SIZE).map((row) => {
return { name: String(row.name).slice(0, 3), score: Number(row.score) || 0 };
});
} catch (error) {
return defaultScores();
}
}
function defaultScores() {
return DEFAULT_SCORES.map((row) => {
return { name: row.name, score: row.score };
});
}
function writeScores() {
try {
window.localStorage.setItem(`missile-command-scores`, JSON.stringify(scores));
} catch (error) {
return;
}
}
function qualifies(value) {
return value > 0 && (scores.length < TABLE_SIZE || value > scores[scores.length - 1].score);
}
function rankFor(value) {
let rank = 1;
for (const row of scores) {
if (value > row.score) {
break;
}
rank++;
}
return rank;
}
// ---------------------------------------------------------------
// attract screens and initials entry
// ---------------------------------------------------------------
function clearPanelBody() {
while (panelBody.firstChild) {
panelBody.removeChild(panelBody.firstChild);
}
}
function panelRow(line) {
const row = document.createElement(`div`);
row.className = `panel-row`;
row.textContent = line;
panelBody.appendChild(row);
return row;
}
function panelGap() {
const gap = document.createElement(`div`);
gap.className = `panel-gap`;
panelBody.appendChild(gap);
}
function legendPiece({ box, className, styles }) {
const el = document.createElement(`div`);
el.className = className;
Object.assign(el.style, styles);
box.appendChild(el);
return el;
}
function drawLegendArt({ box, kind }) {
if (kind === `city`) {
legendPiece({ box: box, className: `legend-ground`, styles: { backgroundColor: palette.ground } });
legendPiece({ box: box, className: `city`, styles: { left: `26px`, top: `28px`, backgroundColor: palette.city } });
return;
}
if (kind === `base`) {
legendPiece({ box: box, className: `legend-ground`, styles: { backgroundColor: palette.ground } });
legendPiece({ box: box, className: `silo`, styles: { left: `16px`, top: `24px`, backgroundColor: palette.ground } });
for (let i = 0; i < 3; i++) {
legendPiece({ box: box, className: `ammo`, styles: { left: `${42 + i * 6}px`, top: `38px`, backgroundColor: palette.city } });
}
return;
}
if (kind === `incoming`) {
legendPiece({ box: box, className: `line`, styles: { backgroundColor: palette.enemy, transform: `translate(20px, 6px) rotate(55deg) scaleX(44)` } });
legendPiece({ box: box, className: `head`, styles: { backgroundColor: `#ffffff`, transform: `translate(45px, 42px)` } });
return;
}
legendPiece({ box: box, className: `line`, styles: { backgroundColor: `hsl(${palette.allyHue}, 100%, 60%)`, transform: `translate(30px, 50px) rotate(-60deg) scaleX(34)` } });
legendPiece({ box: box, className: `target`, styles: { backgroundColor: `#ffffff`, transform: `translate(47px, 21px)` } });
}
function renderLegend() {
const strip = document.createElement(`div`);
strip.className = `legend`;
for (const item of LEGEND) {
const cell = document.createElement(`div`);
const art = document.createElement(`div`);
const caption = document.createElement(`div`);
cell.className = `legend-cell`;
art.className = `legend-art`;
caption.className = `legend-caption`;
drawLegendArt({ box: art, kind: item.art });
for (const line of item.lines) {
const text = document.createElement(`div`);
text.textContent = line;
caption.appendChild(text);
}
cell.appendChild(art);
cell.appendChild(caption);
strip.appendChild(cell);
}
panelBody.appendChild(strip);
}
function showPanel({ title, foot, logo }) {
panelTitle.className = logo ? `logo` : ``;
if (logo) {
while (panelTitle.firstChild) {
panelTitle.removeChild(panelTitle.firstChild);
}
for (const word of title.split(` `)) {
const line = document.createElement(`div`);
line.textContent = word;
panelTitle.appendChild(line);
}
panelTitle.style.color = ``;
} else {
panelTitle.textContent = title;
panelTitle.style.color = palette.text;
}
panelFoot.textContent = foot;
panel.style.display = `grid`;
}
function hidePanel() {
panel.style.display = `none`;
}
// the cabinet alternated its attract screens; page 0 explains the game,
// page 1 is the table
function renderSplash() {
clearPanelBody();
if (splashPage === 0) {
renderLegend();
panelGap();
panelRow(`M SOUND P PAUSE`);
showPanel({ title: `MISSILE COMMAND`, foot: `CLICK TO DEFEND`, logo: true });
return;
}
for (let i = 0; i < scores.length; i++) {
panelRow(`${String(i + 1).padStart(2, ` `)} ${scores[i].name} ${String(scores[i].score).padStart(6, `0`)}`);
}
showPanel({ title: `HIGH SCORES`, foot: `CLICK TO DEFEND` });
}
function showTitle() {
mode = `title`;
modeTimer = 0;
splashPage = 0;
entry = null;
entrySlots = [];
hideTally();
say({ title: ``, sub: ``, frames: 0 });
renderSplash();
}
function showEntry() {
mode = `entry`;
modeTimer = 0;
entry = { letters: [`A`, `A`, `A`], index: 0 };
entrySlots = [];
clearPanelBody();
panelRow(`SCORE ${String(score).padStart(6, `0`)} RANK ${rankFor(score)}`);
panelGap();
const row = document.createElement(`div`);
row.className = `panel-row`;
for (let i = 0; i < 3; i++) {
const slot = document.createElement(`span`);
slot.className = `panel-slot`;
slot.textContent = entry.letters[i];
row.appendChild(slot);
entrySlots.push(slot);
}
panelBody.appendChild(row);
panelGap();
panelRow(`TYPE THREE LETTERS OR USE THE ARROW KEYS`);
showPanel({ title: `GREAT SCORE`, foot: `ENTER TO ACCEPT` });
}
function renderEntry() {
for (let i = 0; i < entrySlots.length; i++) {
entrySlots[i].textContent = entry.letters[i];
entrySlots[i].style.visibility = i === entry.index && frame % 16 < 8 ? `hidden` : `visible`;
}
}
function submitEntry() {
scores.push({ name: entry.letters.join(``), score: score });
scores.sort((a, b) => {
return b.score - a.score;
});
scores = scores.slice(0, TABLE_SIZE);
highScore = scores[0].score;
writeScores();
showTitle();
splashPage = 1;
renderSplash();
}
function handleEntryKey(event) {
const key = event.key;
if (key === `Enter`) {
submitEntry();
return;
}
if (key === `Backspace`) {
entry.index = Math.max(0, entry.index - 1);
entry.letters[entry.index] = `A`;
return;
}
if (key === `ArrowLeft`) {
entry.index = Math.max(0, entry.index - 1);
return;
}
if (key === `ArrowRight`) {
entry.index = Math.min(2, entry.index + 1);
return;
}
if (key === `ArrowUp` || key === `ArrowDown`) {
const step = key === `ArrowUp` ? 1 : -1;
const offset = entry.letters[entry.index].charCodeAt(0) - 65;
entry.letters[entry.index] = String.fromCharCode(65 + (offset + step + 26) % 26);
return;
}
if (/^[a-zA-Z]$/.test(key)) {
entry.letters[entry.index] = key.toUpperCase();
entry.index = Math.min(2, entry.index + 1);
}
}
function paintWave() {
palette = PALETTES[(wave - 1) % PALETTES.length];
ground.style.backgroundColor = palette.ground;
crosshair.style.backgroundColor = palette.city;
for (const city of cities) {
city.el.style.backgroundColor = palette.city;
}
for (const silo of silos) {
silo.el.style.backgroundColor = palette.ground;
for (const dot of silo.dots) {
dot.style.backgroundColor = palette.city;
}
}
for (const el of [banner, subtitle, hudScore, hudHigh, hudWave, panelBody, panelFoot]) {
el.style.color = palette.text;
}
}
function renderAmmo() {
for (const silo of silos) {
for (let i = 0; i < silo.dots.length; i++) {
silo.dots[i].style.display = i >= AMMO_PER_SILO - silo.ammo ? `` : `none`;
}
}
}
// the tally counts off pictures of what survived, not just numbers
function buildTally() {
missileIcons = [];
cityIcons = [];
while (tallyMissiles.firstChild) {
tallyMissiles.removeChild(tallyMissiles.firstChild);
}
while (tallyCities.firstChild) {
tallyCities.removeChild(tallyCities.firstChild);
}
for (let i = 0; i < bonus.missiles; i++) {
const icon = document.createElement(`div`);
icon.className = `icon-missile`;
icon.style.backgroundColor = palette.city;
icon.style.visibility = `hidden`;
tallyMissiles.appendChild(icon);
missileIcons.push(icon);
}
for (let i = 0; i < bonus.cities; i++) {
const icon = document.createElement(`div`);
icon.className = `city icon-city`;
icon.style.backgroundColor = palette.city;
icon.style.visibility = `hidden`;
tallyCities.appendChild(icon);
cityIcons.push(icon);
}
renderTally();
tally.style.display = `grid`;
}
function renderTally() {
tallyMissileCount.textContent = String(bonus.missilesShown);
tallyCityCount.textContent = String(bonus.citiesShown);
}
function hideTally() {
tally.style.display = `none`;
}
function say({ title, sub, frames }) {
banner.textContent = title;
subtitle.textContent = sub;
bannerTimer = frames;
}
function aliveCities() {
return cities.filter((city) => {
return city.alive;
}).length;
}
function totalAmmo() {
return silos.reduce((sum, silo) => {
return sum + silo.ammo;
}, 0);
}
function addScore(points) {
score += points;
if (score >= nextBonusAt) {
nextBonusAt += 10000;
const ruined = cities.find((city) => {
return !city.alive;
});
if (ruined) {
ruined.alive = true;
ruined.el.classList.remove(`dead`);
say({ title: `BONUS CITY`, sub: ``, frames: 90 });
}
}
if (score > highScore) {
highScore = score;
}
}
// ---------------------------------------------------------------
// waves
// ---------------------------------------------------------------
function enemySpeed() {
return Math.min(0.75 + wave * 0.14, 3);
}
function pickTarget() {
const targets = cities.filter((city) => {
return city.alive;
}).concat(silos.filter((silo) => {
return silo.alive;
}));
if (targets.length === 0) {
return { x: Math.random() * FIELD.width, y: GROUND_Y - 4 };
}
const choice = targets[Math.floor(Math.random() * targets.length)];
return { x: choice.x, y: GROUND_Y - 4 };
}
function spawnEnemy() {
enemies.push(new Enemy({
x: Math.random() * FIELD.width,
y: 0,
target: pickTarget(),
speed: enemySpeed(),
palette: palette,
splits: wave >= 3 && Math.random() < 0.35 ? 2 : 0
}));
}
function startWave() {
mode = `playing`;
multiplier = Math.min(Math.floor((wave - 1) / 2) + 1, 6);
spawnQueue = Math.min(10 + wave * 2, 26);
spawnTimer = 40;
raiderQueue = wave >= 2 ? 1 + Math.floor(wave / 5) : 0;
raiderTimer = 150 + Math.random() * 200;
for (const silo of silos) {
silo.alive = true;
silo.ammo = AMMO_PER_SILO;
silo.el.classList.remove(`dead`);
}
hideTally();
paintWave();
renderAmmo();
say({ title: `WAVE ${wave}`, sub: `X${multiplier} POINTS`, frames: 80 });
}
function startGame() {
score = 0;
wave = 1;
nextBonusAt = 10000;
for (const city of cities) {
city.alive = true;
city.el.classList.remove(`dead`);
}
hidePanel();
clearOrdnance();
startWave();
}
function endGame() {
mode = `over`;
modeTimer = 0;
hideTally();
clearOrdnance();
say({ title: `THE END`, sub: ``, frames: 0 });
}
function leaveGameOver() {
if (qualifies(score)) {
say({ title: ``, sub: ``, frames: 0 });
showEntry();
return;
}
showTitle();
}
function clearOrdnance() {
for (const enemy of enemies) {
enemy.detach();
}
for (const raider of raiders) {
raider.detach();
}
for (const interceptor of interceptors) {
interceptor.detach();
}
enemies = [];
raiders = [];
interceptors = [];
}
function waveCleared() {
return spawnQueue === 0 && raiderQueue === 0 && enemies.length === 0 && raiders.length === 0 && interceptors.length === 0 && explosions.length === 0;
}
function startBonus() {
mode = `bonus`;
bonus = {
missiles: totalAmmo(),
cities: aliveCities(),
missilesShown: 0,
citiesShown: 0,
wait: BONUS_LEAD,
settled: false
};
for (const silo of silos) {
silo.ammo = 0;
}
renderAmmo();
buildTally();
say({ title: `BONUS POINTS`, sub: ``, frames: 0 });
}
function runBonus() {
if (bonus.wait > 0) {
bonus.wait--;
return;
}
if (bonus.missilesShown < bonus.missiles) {
// every icon is laid out from the start and merely revealed, so the
// row builds left to right without ever reflowing
missileIcons[bonus.missilesShown].style.visibility = `visible`;
bonus.missilesShown++;
renderTally();
addScore(5 * multiplier);
playTick();
bonus.wait = bonus.missilesShown === bonus.missiles ? BONUS_PAUSE : BONUS_TICK;
return;
}
if (bonus.citiesShown < bonus.cities) {
cityIcons[bonus.citiesShown].style.visibility = `visible`;
bonus.citiesShown++;
renderTally();
addScore(100 * multiplier);
playBoom();
bonus.wait = BONUS_CITY_TICK;
return;
}
if (!bonus.settled) {
bonus.settled = true;
bonus.wait = BONUS_HOLD;
return;
}
if (aliveCities() === 0) {
endGame();
return;
}
wave++;
startWave();
}
// ---------------------------------------------------------------
// firing
// ---------------------------------------------------------------
function fire({ x, y, siloIndex }) {
if (mode !== `playing`) {
return;
}
const target = {
x: Math.max(6, Math.min(FIELD.width - 6, x)),
y: Math.max(14, Math.min(GROUND_Y - 30, y))
};
let silo = null;
if (siloIndex !== undefined) {
const candidate = silos[siloIndex];
if (candidate && candidate.alive && candidate.ammo > 0) {
silo = candidate;
}
} else {
for (const candidate of silos) {
if (!candidate.alive || candidate.ammo === 0) {
continue;
}
if (!silo || Math.abs(candidate.x - target.x) < Math.abs(silo.x - target.x)) {
silo = candidate;
}
}
}
if (!silo) {
return;
}
silo.ammo--;
renderAmmo();
const interceptor = new Interceptor({
from: { x: silo.x, y: GROUND_Y - SILO_H },
to: target,
hue: palette.allyHue
});
interceptor.attach();
// the aiming X sits on the target and blinks until the missile lands on it
interceptor.marker = acquireTarget({ color: `#ffffff` });
interceptor.marker.style.transform = `translate(${target.x}px, ${target.y}px)`;
interceptors.push(interceptor);
playLaunch();
}
// ---------------------------------------------------------------
// collision
// ---------------------------------------------------------------
function circleHitsBox({ cx, cy, radius, left, top, right, bottom }) {
const nearestX = Math.max(left, Math.min(cx, right));
const nearestY = Math.max(top, Math.min(cy, bottom));
return Math.hypot(cx - nearestX, cy - nearestY) <= radius;
}
function resolveHits() {
for (const explosion of explosions) {
const survivors = [];
for (const enemy of enemies) {
if (Math.hypot(enemy.pos.x - explosion.pos.x, enemy.pos.y - explosion.pos.y) <= explosion.radius) {
detonate({ x: enemy.pos.x, y: enemy.pos.y, hue: palette.enemyHue });
addScore(25 * multiplier);
enemy.detach();
} else {
survivors.push(enemy);
}
}
enemies = survivors;
const flying = [];
for (const raider of raiders) {
if (Math.hypot(raider.pos.x - explosion.pos.x, raider.pos.y - explosion.pos.y) <= explosion.radius + 10) {
detonate({ x: raider.pos.x, y: raider.pos.y, hue: palette.enemyHue });
addScore(100 * multiplier);
raider.detach();
} else {
flying.push(raider);
}
}
raiders = flying;
// only enemy detonations level the ground, exactly as in the original
if (!explosion.hostile) {
continue;
}
for (const city of cities) {
if (!city.alive) {
continue;
}
if (circleHitsBox({
cx: explosion.pos.x,
cy: explosion.pos.y,
radius: explosion.radius,
left: city.x - CITY_HALF_W,
top: GROUND_Y - CITY_H,
right: city.x + CITY_HALF_W,
bottom: GROUND_Y
})) {
city.alive = false;
city.el.classList.add(`dead`);
}
}
for (const silo of silos) {
if (!silo.alive) {
continue;
}
if (circleHitsBox({
cx: explosion.pos.x,
cy: explosion.pos.y,
radius: explosion.radius,
left: silo.x - SILO_HALF_W,
top: GROUND_Y - SILO_H,
right: silo.x + SILO_HALF_W,
bottom: GROUND_Y
})) {
silo.alive = false;
silo.ammo = 0;
silo.el.classList.add(`dead`);
renderAmmo();
}
}
}
}
// ---------------------------------------------------------------
// per-frame updates
// ---------------------------------------------------------------
function updateSpawns() {
if (spawnQueue > 0) {
spawnTimer--;
if (spawnTimer <= 0) {
const burst = Math.min(spawnQueue, 1 + Math.floor(Math.random() * 2));
for (let i = 0; i < burst; i++) {
spawnEnemy();
spawnQueue--;
}
spawnTimer = Math.max(24, 90 - wave * 4) + Math.random() * 40;
}
}
if (raiderQueue > 0) {
raiderTimer--;
if (raiderTimer <= 0) {
raiders.push(new Raider({
palette: palette,
speed: 0.8 + wave * 0.05,
kind: wave % 2 === 0 ? `satellite` : `plane`
}));
raiderQueue--;
raiderTimer = 260 + Math.random() * 200;
}
}
}
function updateEnemies() {
const surviving = [];
const spawned = [];
for (const enemy of enemies) {
enemy.update();
if (enemy.pos.y >= enemy.target.y) {
impactAt({ x: enemy.pos.x, y: GROUND_Y - 2 });
enemy.detach();
continue;
}
// MIRV: one warhead becomes three on the way down
if (enemy.splits > 0 && enemy.pos.y > enemy.splitAt) {
for (let i = 0; i < enemy.splits; i++) {
spawned.push(new Enemy({
x: enemy.pos.x,
y: enemy.pos.y,
target: pickTarget(),
speed: enemy.speed,
palette: palette,
splits: 0
}));
}
enemy.splits = 0;
}
enemy.render();
surviving.push(enemy);
}
enemies = surviving.concat(spawned);
}
function updateRaiders() {
const flying = [];
for (const raider of raiders) {
raider.update();
if (raider.offscreen) {
raider.detach();
continue;
}
raider.dropTimer--;
if (raider.dropTimer <= 0) {
enemies.push(new Enemy({
x: raider.pos.x,
y: raider.pos.y,
target: pickTarget(),
speed: enemySpeed(),
palette: palette,
splits: 0
}));
raider.dropTimer = 70 + Math.random() * 60;
}
flying.push(raider);
}
raiders = flying;
}
function updateInterceptors() {
const flying = [];
for (const interceptor of interceptors) {
interceptor.update();
if (interceptor.arrived()) {
detonate({ x: interceptor.target.x, y: interceptor.target.y, hue: interceptor.hue });
interceptor.detach();
continue;
}
interceptor.render();
if (interceptor.marker) {
interceptor.marker.style.visibility = frame % 8 < 4 ? `visible` : `hidden`;
}
if (frame % TRAIL_STEP === interceptor.phase) {
spawnTrail({ particle: interceptor, frames: TRAIL_STEP });
}
flying.push(interceptor);
}
interceptors = flying;
}
function updateExplosions() {
const burning = [];
for (const explosion of explosions) {
explosion.update();
if (explosion.alive) {
burning.push(explosion);
} else {
explosion.detach();
}
}
explosions = burning;
}
function renderHud() {
hudScore.textContent = String(score).padStart(6, `0`);
hudHigh.textContent = `HIGH ${String(highScore).padStart(6, `0`)}`;
hudWave.textContent = `WAVE ${wave} X${multiplier}`;
crosshair.style.transform = `translate(${pointer.x}px, ${pointer.y}px)`;
if (bannerTimer > 0) {
bannerTimer--;
if (bannerTimer === 0) {
banner.textContent = ``;
subtitle.textContent = ``;
}
}
}
function loop() {
if (paused) {
return;
}
frame++;
modeTimer++;
if (mode === `playing`) {
updateSpawns();
}
if (mode === `bonus`) {
runBonus();
}
updateEnemies();
updateRaiders();
updateInterceptors();
updateExplosions();
resolveHits();
updateParticles();
updateTrails();
updateShards();
applyScreenEffects();
renderHud();
if (mode === `playing` && waveCleared()) {
startBonus();
}
if (mode === `title` && modeTimer % 250 === 0) {
splashPage = (splashPage + 1) % 2;
renderSplash();
}
if (mode === `entry`) {
renderEntry();
}
if (mode === `over` && modeTimer > 170) {
leaveGameOver();
}
}
// ---------------------------------------------------------------
// input
// ---------------------------------------------------------------
let scale = 1;
let bounds = null;
function resize() {
scale = Math.min(window.innerWidth / FIELD.width, window.innerHeight / FIELD.height);
stage.style.transform = `scale(${scale})`;
bounds = stage.getBoundingClientRect();
}
function toField(event) {
if (!bounds) {
resize();
}
return {
x: (event.clientX - bounds.left) / scale,
y: (event.clientY - bounds.top) / scale
};
}
viewport.addEventListener(`pointermove`, (event) => {
pointer = toField(event);
});
viewport.addEventListener(`pointerdown`, (event) => {
event.preventDefault();
ensureAudio();
pointer = toField(event);
if (mode === `title`) {
startGame();
return;
}
if (mode === `entry`) {
if (modeTimer > 40) {
submitEntry();
}
return;
}
if (mode === `over` && modeTimer > 60) {
leaveGameOver();
return;
}
fire({ x: pointer.x, y: pointer.y });
});
viewport.addEventListener(`contextmenu`, (event) => {
event.preventDefault();
});
window.addEventListener(`keydown`, (event) => {
if (mode === `entry`) {
event.preventDefault();
handleEntryKey(event);
return;
}
const key = event.key.toLowerCase();
if (key === `1` || key === `2` || key === `3`) {
ensureAudio();
fire({ x: pointer.x, y: pointer.y, siloIndex: Number(key) - 1 });
return;
}
if (key === `m`) {
muted = !muted;
say({ title: ``, sub: muted ? `SOUND OFF` : `SOUND ON`, frames: 50 });
return;
}
if (key === `p`) {
paused = !paused;
if (paused) {
banner.textContent = `PAUSED`;
} else {
banner.textContent = ``;
}
}
});
window.addEventListener(`resize`, resize);
// ---------------------------------------------------------------
// boot
// ---------------------------------------------------------------
buildSpriteStyles();
paintWave();
renderAmmo();
resize();
showTitle();
setInterval(loop, 1000 / 50);
</script>
</body>
</html>
@sandro-pasquali

Copy link
Copy Markdown
Author

Single html page Missile Command game.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment