Created
September 6, 2026 12:54
-
-
Save sandro-pasquali/34ab8588d220919a1639ee2d021cf5c1 to your computer and use it in GitHub Desktop.
Missile Command
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Single html page Missile Command game.