invaders
Version:
A 1980s-arcade-style game written using HTML5, Canvas, and Web Audio
1,398 lines (1,261 loc) • 45.2 kB
HTML
<!--
Andromeda Invaders 0.9.0-dev
Copyright (c) 2022-2023 Susam Pal
Source: https://github.com/susam/invaders
License: MIT
-->
<html lang="en">
<head>
<title>Andromeda Invaders</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
body {
background: #111;
margin: 0;
}
canvas, button {
background: #000;
border: 1px solid #333;
box-sizing: border-box;
}
canvas {
display: block;
margin: 0 auto;
image-rendering: pixelated;
}
div {
text-align: center;
}
button {
color: #999;
font-size: 16px;
font-weight: bold;
vertical-align: middle;
user-select: none;
-webkit-user-select: none;
}
</style>
<script>
// Metadata for info screen.
const NAME = 'Andromeda Invaders'
const VERSION = '0.8.0'
const COPYRIGHT = 'Copyright (c) 2022-2023 Susam Pal'
const LICENSE = 'Licensed under the MIT License'
const SOURCE_URL = 'https://github.com/susam/invaders'
// Lifecycle states.
const INITIALIZED = 'initialized'
const PLAYING = 'playing'
const PAUSED = 'paused'
const ENDED = 'ended'
const INFO = 'info'
const PLAY_SYMBOL = '►'
const PAUSE_SYMBOL = '■'
const LOGGING_ENABLED = false
const SCAFFOLDING_ENABLED = false
// Graphics dimensions.
const PADDING = 10
const CANVAS_WIDTH = 640
const CANVAS_HEIGHT = 480
const FONT_SCALE = 2
const FONT_WIDTH = 8 * FONT_SCALE
const FONT_HEIGHT = 16 * FONT_SCALE
const FONT_TOP_BLANK = 2 * FONT_SCALE
const SCORE_Y = PADDING - FONT_TOP_BLANK
const SPACE_Y = 40
const PLAYER_WIDTH = 50
const PLAYER_HEIGHT = 30
const PLAYER_HEAD_SIZE = 10
const PLAYER_Y = CANVAS_HEIGHT - PADDING - PLAYER_HEIGHT
const PLAYER_MAX_X = CANVAS_WIDTH - PADDING - PLAYER_WIDTH
const PLAYER_MOVE_STEP = 5
const PULSE_WIDTH = 4
const PULSE_HEIGHT = 30
const PULSE_X_OFFSET = (PLAYER_WIDTH - PULSE_WIDTH) / 2
const PULSE_Y_OFFSET = 0
const PULSE_MOVE_STEP = 10
const SHIP_WIDTH = 50
const SHIP_HEIGHT = 20
const SHIP_ROOF_WIDTH = 30
const SHIP_ROOF_HEIGHT = 5
const SHIP_ROOF_OFFSET = (SHIP_WIDTH - SHIP_ROOF_WIDTH) / 2
const SHIP_BODY_HEIGHT = SHIP_HEIGHT - 2 * SHIP_ROOF_HEIGHT
const SHIP_LANE_HEIGHT = 35
const SHIP_MAX_Y = PLAYER_Y - SHIP_ROOF_HEIGHT
const SHIP_MAX_X = CANVAS_WIDTH - PADDING - SHIP_WIDTH
const BOULDER_WIDTH = 6
const BOULDER_HEIGHT = 6
const BOULDER_X_OFFSET = (SHIP_WIDTH - BOULDER_WIDTH) / 2
const BOULDER_Y_OFFSET = SHIP_HEIGHT - BOULDER_HEIGHT
// Color palettes.
const SHIP_COLORS = ['#f60', '#c30', '#900']
const PLAYER_COLORS = ['#0c0', '#390', '#660']
// Audio parameters.
const BACKGROUND_VOLUME = 1
const HIT_VOLUME = 1
const HIT_DURATION = 0.125
// C major scale notes.
const C2 = 65.41
const E2 = 82.41
const G2 = 98.00
const A2 = 110.00
const C3 = 130.81
const D3 = 146.83
const E3 = 164.81
const F3 = 174.61
const G3 = 196.00
const A3 = 220.00
const B3 = 246.94
const C4 = 261.63
const E4 = 329.63
const F4 = 349.23
const G4 = 392.00
const A4 = 440.00
const B4 = 493.88
const C5 = 523.25
const D5 = 587.33
const F5 = 698.46
const B5 = 987.77
// Time intervals.
const REFRESH_INTERVAL = 20
const ACTION_REPEAT_INTERVAL = 20
const AUTO_SHORT_DELAY = 1000
const AUTO_LONG_DELAY = 5000
const AUTO_PLAY_INTERVAL = 20
// Time logging and display.
let initTime
let startTime
let timeUntilPreviousPause
// Graphics and audio contexts.
let canvas
let gCtx
let aCtx = null
let compressor
let gTimer
let aTimer
let scaledCanvasWidth
let scaledCanvasHeight
// HTML buttons.
let buttonsPlay
let buttonsMore
let buttonLeft
let buttonRight
let buttonEnter
let buttonPause
let buttonMute
let buttonList
// Game states.
let shipsAlive
let nearestShipAlive
let state
let level
let shipDescent = 0
// User interface states.
let leftTimer
let rightTimer
let leftOn = false
let rightOn = false
let muted = false
// Auto-play.
let autoSoon
let autoTimer
let autoAllowLeft = true
// Game objects visible on screen.
const player = {}
const pulse = {}
let ships
let boulders
// Utility functions.
function log () {
if (LOGGING_ENABLED) {
const args = Array.prototype.slice.call(arguments)
const prefix = ('time: ' + ((Date.now() - initTime) / 1000) +
'; state: ' + state +
'; level: ' + level +
'; score: ' + player.score +
'; health: ' + player.health +
'; ships: ' + shipsAlive +
'; nearest: ' + nearestShipAlive +
'; descent: ' + shipDescent.toFixed(3) +
'; x: ' + player.x +
'; pulse: ' + pulse.x + ',' + pulse.y +
'; ')
console.log(prefix + args.join(' '))
}
}
function random (min, max) {
return min + Math.floor(Math.random() * (max - min + 1))
}
function bounded (x, min, max) {
return Math.max(Math.min(x, max), min)
}
// Game initialization.
function init () {
state = INITIALIZED
initTime = Date.now()
autoSoon = window.location.hash === '#auto'
initGraphics()
initButtons()
initEventListeners()
resizeUI()
resetGame()
drawGame()
buttonPause.innerHTML = PLAY_SYMBOL
log('initialized canvas')
}
function initGraphics () {
canvas = document.getElementById('canvas')
gCtx = canvas.getContext('2d')
canvas.width = CANVAS_WIDTH
canvas.height = CANVAS_HEIGHT
}
function initButtons () {
buttonsPlay = document.getElementById('play')
buttonsMore = document.getElementById('more')
buttonLeft = document.getElementById('left')
buttonRight = document.getElementById('right')
buttonEnter = document.getElementById('enter')
buttonPause = document.getElementById('pause')
buttonMute = document.getElementById('mute')
buttonList = [
buttonLeft, buttonRight, buttonEnter, buttonPause, buttonMute
]
}
function initAudio () {
if (aCtx !== null) {
return
}
if (typeof window.AudioContext === 'undefined') {
window.AudioContext = window.webkitAudioContext
}
aCtx = new window.AudioContext()
compressor = aCtx.createDynamicsCompressor()
compressor.connect(aCtx.destination)
log('initialized audio')
}
function initEventListeners () {
window.addEventListener('resize', resizeUI)
document.addEventListener('keydown', readKeyDown)
document.addEventListener('keyup', readKeyUp)
buttonLeft.addEventListener('mousedown', actionStartLeft)
buttonLeft.addEventListener('mouseup', actionStopLeft)
buttonLeft.addEventListener('touchstart', actionStartLeft)
buttonLeft.addEventListener('touchend', actionStopLeft)
buttonRight.addEventListener('mousedown', actionStartRight)
buttonRight.addEventListener('mouseup', actionStopRight)
buttonRight.addEventListener('touchstart', actionStartRight)
buttonRight.addEventListener('touchend', actionStopRight)
buttonEnter.addEventListener('click', actionEnter)
buttonEnter.addEventListener('touchstart', actionEnter)
buttonPause.addEventListener('click', actionPause)
buttonPause.addEventListener('touchstart', actionPause)
buttonMute.addEventListener('click', actionMute)
buttonMute.addEventListener('touchstart', actionMute)
}
// Resize canvas and buttons on browser resize.
function resizeUI () {
document.body.style.padding = PADDING + 'px'
if (window.innerWidth > window.innerHeight) {
resizeElements('inline-block', [5], 40, 10)
log('resized UI for wide screen')
} else {
resizeElements('block', [2, 3], 60, 30)
log('resized UI for tall screen')
}
}
function resizeElements (buttonsDivStyle, buttonGroups,
buttonHeight, buttonPadding) {
const buttonRows = buttonGroups.length
const buttonsHeight = buttonRows * (buttonPadding + buttonHeight)
const availableHeight = window.innerHeight - 2 * PADDING - buttonsHeight
const availableWidth = window.innerWidth - 2 * PADDING
buttonsPlay.style.display = buttonsDivStyle
buttonsMore.style.display = buttonsDivStyle
scaledCanvasHeight = Math.min(availableHeight, availableWidth * 3 / 4)
scaledCanvasWidth = scaledCanvasHeight * 4 / 3
canvas.style.width = scaledCanvasWidth + 'px'
canvas.style.height = scaledCanvasHeight + 'px'
let start = 0
for (let i = 0; i < buttonGroups.length; i++) {
const len = buttonGroups[i]
resizeButtons(start, len, buttonHeight, buttonPadding)
start += len
}
}
function resizeButtons (start, len, height, padding) {
const scaledWidth = (scaledCanvasWidth - (len - 1) * padding) / len
const width = Math.max(scaledWidth, 40)
for (let i = start; i < start + len; i++) {
const button = buttonList[i]
button.style.height = height + 'px'
button.style.width = width + 'px'
button.style.marginTop = padding + 'px'
if (i === start + len - 1) {
button.style.marginRight = '0'
} else {
button.style.marginRight = padding + 'px'
}
}
}
// Functions to set/change game states.
function resetGame () {
player.score = 0
level = 1
resetPlayer()
resetPulse()
resetShips()
resetBoulders()
startTime = -1
timeUntilPreviousPause = 0
clearTimeout(autoTimer)
const autoDelay = autoSoon ? AUTO_SHORT_DELAY : AUTO_LONG_DELAY
autoTimer = setTimeout(autoPlay, autoDelay)
autoSoon = false
log('reset game')
}
function newLevel () {
if (level === 1000) {
level = 1
} else {
level++
}
resetPulse()
resetShips()
resetBoulders()
drawGame()
log('created game level', level)
}
function autoPlay () {
if (state === ENDED || state === INFO) {
stopRight()
stopLeft()
return
} else if (state === PAUSED) {
stopRight()
stopLeft()
} else if (state === INITIALIZED) {
startRight()
} else if (shipsAlive > 0) {
autoMove()
}
autoTimer = setTimeout(autoPlay, AUTO_PLAY_INTERVAL)
}
function autoMove () {
const SCAN_WIDTH = 2 * PLAYER_MOVE_STEP
const tBlock = []
const lBlock = []
const rBlock = []
// Scan airspace four boulders that are too close.
for (let i = 0; i < boulders.length; i++) {
const boulder = boulders[i]
if (boulder.live &&
boulder.y > PLAYER_Y - 12 * boulder.speed - BOULDER_HEIGHT &&
boulder.y < CANVAS_HEIGHT - PADDING &&
boulder.x >= player.x - SCAN_WIDTH - BOULDER_WIDTH &&
boulder.x <= player.x + PLAYER_WIDTH + SCAN_WIDTH) {
const param = [i, boulder.x, boulder.y, boulder.speed]
if (boulder.x <= player.x - BOULDER_WIDTH) {
lBlock.push(param)
} else if (boulder.x >= player.x + PLAYER_WIDTH) {
rBlock.push(param)
} else {
tBlock.push(param)
if (boulder.x < PADDING + PLAYER_WIDTH) {
lBlock.push(param)
} else if (boulder.x > PLAYER_MAX_X) {
rBlock.push(param)
}
}
}
}
// Estimated hit spot position of nearest ship.
const startX = player.x + PULSE_X_OFFSET
const startY = PLAYER_Y - PULSE_HEIGHT
const ship = ships[nearestShipAlive]
const aimX = ship.x + BOULDER_X_OFFSET - PULSE_WIDTH
const aimY = ship.y + SHIP_HEIGHT / 2
const relativeSpeed = PULSE_MOVE_STEP + 1 / (1 + shipsAlive)
const travelTime = Math.floor((startY - aimY) / relativeSpeed)
const shiftedAimX = aimX + (ship.direction * ship.speed * travelTime)
// Movement constants and variables.
const STAY = 'stay'
const LEFT = 'left'
const RIGHT = 'right'
let decision = STAY
let canClearLeft = true
let canClearRight = true
let remark = 'n/a'
if (tBlock.length !== 0) {
// Check which way player cannot move to clear the top boulders.
for (let i = 0; i < tBlock.length; i++) {
const x = tBlock[i][1]
const y = tBlock[i][2]
const s = tBlock[i][3]
const lSteps = Math.ceil((player.x + PLAYER_WIDTH - x) / PLAYER_MOVE_STEP)
const rSteps = Math.ceil((x + BOULDER_WIDTH - player.x) / PLAYER_MOVE_STEP)
const impactSteps = Math.ceil((player.y - y - BOULDER_HEIGHT) / s)
canClearLeft &&= lSteps < impactSteps
canClearRight &&= rSteps < impactSteps
}
// Decide next move.
if (lBlock.length === 0 && canClearLeft && autoAllowLeft) {
decision = LEFT
remark = 'clear boulder'
} else if (rBlock.length === 0 && canClearRight) {
autoAllowLeft = false
decision = RIGHT
remark = 'clear boulder'
} else {
const boulderX = tBlock[0][1]
const pulseX = player.x + PULSE_X_OFFSET
if (pulseX + PULSE_WIDTH <= boulderX) {
decision = RIGHT
} else if (pulseX >= boulderX + BOULDER_WIDTH) {
decision = LEFT
}
remark = 'aim boulder'
}
} else if (startX < shiftedAimX && rBlock.length === 0) {
decision = RIGHT
autoAllowLeft = true
remark = 'aim ship'
} else if (startX > shiftedAimX && lBlock.length === 0) {
decision = LEFT
autoAllowLeft = true
remark = 'aim ship'
}
log('clearance: ' + autoAllowLeft + ',' +
canClearLeft + ',' + canClearRight + '; ' +
(tBlock.length === 0 ? '' : 'tBlock: ' + tBlock.join(' ') + '; ') +
(lBlock.length === 0 ? '' : 'lBlock: ' + lBlock.join(' ') + '; ') +
(rBlock.length === 0 ? '' : 'rBlock: ' + rBlock.join(' ') + '; ') +
'decision: ' + decision + ' (' + remark + ')')
if (decision === STAY) {
stopLeft()
stopRight()
} else if (decision === LEFT) {
stopRight()
startLeft()
} else if (decision === RIGHT) {
stopLeft()
startRight()
}
}
function playGame () {
state = PLAYING
startTime = Date.now()
initAudio()
startMusic()
animate()
buttonPause.innerHTML = PAUSE_SYMBOL
log('started game')
}
function startGame () {
clearTimeout(autoTimer)
playGame()
}
function resumeGame () {
playGame()
}
function stopGame () {
state = ENDED
clearTimeout(gTimer)
clearTimeout(aTimer)
buttonPause.innerHTML = PLAY_SYMBOL
log('stopped game')
}
function pauseGame () {
state = PAUSED
timeUntilPreviousPause += Date.now() - startTime
clearTimeout(gTimer)
clearTimeout(aTimer)
buttonPause.innerHTML = PLAY_SYMBOL
log('paused game')
}
function restartGame () {
if (state === PLAYING) {
stopGame()
}
state = INITIALIZED
resetGame()
drawGame()
buttonPause.innerHTML = PLAY_SYMBOL
log('restarted game')
}
// Functions to reset game object states.
function shipColor (ship) {
return SHIP_COLORS[SHIP_COLORS.length - ship.health]
}
function playerColor () {
return PLAYER_COLORS[PLAYER_COLORS.length - player.health]
}
function resetPlayer () {
player.x = PADDING
player.y = PLAYER_Y
player.health = PLAYER_COLORS.length
player.repairySinceScore = 0
log('reset player; x: ' + player.x + '; y: ' + player.y)
}
function resetPulse () {
if (player.health <= 0) {
return
}
pulse.x = player.x + PULSE_X_OFFSET
pulse.y = player.y + PULSE_Y_OFFSET
pulse.color = playerColor()
pulse.live = true
log('reset pulse')
}
function resetShips () {
const shipSpeed = Math.min(Math.floor((level + 6) / 3), 8)
shipsAlive = (level <= 2) ? (3 * level) : 10
ships = []
shipDescent = 0
for (let i = 0; i < shipsAlive; i++) {
const ship = {}
ship.x = random(PADDING, SHIP_MAX_X)
ship.y = SPACE_Y + Math.floor(shipDescent) + SHIP_LANE_HEIGHT * i
ship.direction = random(0, 1) === 0 ? -1 : 1
ship.health = SHIP_COLORS.length
ship.speed = shipSpeed
ship.repaired = 0
ships.push(ship)
log('ship ' + i + '; x: ' + ship.x + '; y: ' + ship.y +
'; height: ' + SHIP_HEIGHT)
}
updateNearestShipAlive()
log('reset', shipsAlive, 'ships with speed', shipSpeed)
}
function resetBoulders () {
boulders = []
for (let i = 0; i < shipsAlive; i++) {
const boulder = {}
resetBoulder(boulder, ships[i])
boulders.push(boulder)
}
log('reset boulders')
}
function resetBoulder (boulder, ship) {
const minSpeed = Math.min(Math.floor((level + 4) / 3), 8)
const maxSpeed = Math.min(Math.floor((level + 11) / 3), 10)
if (ship.health > 0) {
repairShip(ship)
boulder.x = ship.x + BOULDER_X_OFFSET
boulder.y = ship.y + BOULDER_Y_OFFSET
boulder.color = shipColor(ship)
boulder.speed = random(minSpeed, maxSpeed)
boulder.live = true
} else {
boulder.live = false
}
}
// Health recovery rules.
function repairPlayer () {
const repairy = player.score - player.repairySinceScore
if (player.health > 0 && player.health < PLAYER_COLORS.length &&
repairy >= 100) {
player.health++
log('increased player health; repairy =', player.score, '-',
player.repairySinceScore, '=', repairy)
player.repairySinceScore = player.score
}
}
function repairShip (ship) {
if (ship.health > 0 && ship.health < SHIP_COLORS.length) {
ship.repaired++
}
if (ship.repaired === 10) {
ship.health++
ship.repaired = 0
}
}
// Animation loop.
function animate () {
movePulse()
moveShips()
moveBoulders()
drawGame()
if (player.health <= 0) {
stopGame()
return
}
if (shipsAlive === 0) {
newLevel()
}
checkShipHits()
checkBoulderHits()
checkPlayerHit()
repairPlayer()
gTimer = setTimeout(animate, REFRESH_INTERVAL)
}
// Move game objects.
function movePulse () {
if (!pulse.live) {
resetPulse()
} else if (pulse.y < -PULSE_HEIGHT) {
pulse.live = false
} else {
pulse.y -= PULSE_MOVE_STEP
}
if (player.health > 0 && pulse.y + PULSE_HEIGHT >= PLAYER_Y) {
pulse.x = player.x + PULSE_X_OFFSET
pulse.color = playerColor()
}
}
function moveShips () {
if (shipsAlive > 0 &&
SPACE_Y + shipDescent + nearestShipAlive * SHIP_LANE_HEIGHT <
SHIP_MAX_Y) {
shipDescent += 1 / (1 + shipsAlive)
}
for (let i = 0; i < ships.length; i++) {
const ship = ships[i]
if (ship.health <= 0) {
continue
}
ship.x += ship.direction * ship.speed
if (ship.x < PADDING) {
ship.x = PADDING
ship.direction *= -1
} else if (ship.x > SHIP_MAX_X) {
ship.x = SHIP_MAX_X
ship.direction *= -1
}
const minY = SPACE_Y + Math.floor(shipDescent) + SHIP_LANE_HEIGHT * i
const maxY = minY + 10
if (ship.y < SHIP_MAX_Y) {
ship.y += random(-1, 1)
ship.y = bounded(ship.y, minY, maxY)
if (random(1, 100) === 1) {
ship.direction *= -1
}
} else {
ship.y = SHIP_MAX_Y
ship.speed = Math.max(4, ship.speed)
ship.direction = player.x >= ship.x ? 1 : -1
}
}
}
function moveBoulders () {
for (let i = 0; i < boulders.length; i++) {
const boulder = boulders[i]
const ship = ships[i]
boulder.y += boulder.speed
if (!boulder.live) {
resetBoulder(boulder, ship)
} else if (boulder.y > CANVAS_HEIGHT) {
boulder.live = false
}
if (boulder.y - ship.y <= 5 * BOULDER_HEIGHT) {
boulder.x = ship.x + BOULDER_X_OFFSET
}
}
}
// Render game state to canvas.
function drawBackground () {
gCtx.fillStyle = '#000'
gCtx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
}
function drawGame () {
drawBackground()
drawScore()
drawTime()
drawPlayer()
drawShips()
drawBoulders()
drawPulse()
drawScaffolding()
}
let urlMinY
let urlMaxY
function drawInfo () {
state = INFO
drawBackground()
let textY = (CANVAS_HEIGHT - 7 * FONT_HEIGHT) / 2
drawCenterText(NAME + ' v' + VERSION, textY, false)
textY += 2 * FONT_HEIGHT
drawCenterText(COPYRIGHT, textY, false)
textY += 2 * FONT_HEIGHT
drawCenterText(LICENSE, textY, false)
textY += 2 * FONT_HEIGHT
drawCenterText(SOURCE_URL, textY, true)
urlMinY = textY
urlMaxY = textY + FONT_HEIGHT
}
function drawScore () {
const text = player.score + ' (L' + level + ')'
drawText(text, PADDING, SCORE_Y)
}
function drawTime () {
if (startTime === -1) {
return
}
const ms = timeUntilPreviousPause + Date.now() - startTime
const ds = Math.floor(ms / 100) % 10
const ss = Math.floor(ms / 1000) % 60
const mm = Math.floor(ms / 1000 / 60) % 60
const hh = Math.floor(ms / 1000 / 60 / 60)
const ssStr = (ss < 10 ? '0' : '') + ss
const mmStr = (mm < 10 ? '0' : '') + mm
const hhStr = (hh < 10 ? '0' : '') + hh
let text
if (hh > 0) {
text = hhStr + ':' + mmStr + ':' + ssStr + '.' + ds
} else if (mm > 0) {
text = mmStr + ':' + ssStr + '.' + ds
} else {
text = ss + '.' + ds
}
const x = CANVAS_WIDTH - PADDING - FONT_WIDTH * text.length
drawText(text, x, SCORE_Y)
}
function drawShips () {
for (let i = 0; i < ships.length; i++) {
const ship = ships[i]
if (ship.health <= 0) {
continue
}
gCtx.fillStyle = shipColor(ship)
gCtx.fillRect(
ship.x + SHIP_ROOF_OFFSET,
ship.y,
SHIP_ROOF_WIDTH,
SHIP_ROOF_HEIGHT)
gCtx.fillRect(
ship.x,
ship.y + SHIP_ROOF_HEIGHT,
SHIP_WIDTH,
SHIP_BODY_HEIGHT)
gCtx.fillRect(
ship.x + SHIP_ROOF_OFFSET,
ship.y + SHIP_ROOF_HEIGHT + SHIP_BODY_HEIGHT,
SHIP_ROOF_WIDTH,
SHIP_ROOF_HEIGHT)
}
}
function drawBoulders () {
for (let i = 0; i < boulders.length; i++) {
const boulder = boulders[i]
if (!boulder.live) {
continue
}
gCtx.fillStyle = boulder.color
gCtx.fillRect(boulder.x, boulder.y, BOULDER_WIDTH, BOULDER_HEIGHT)
}
}
function drawPlayer () {
if (player.health <= 0) {
return
}
gCtx.fillStyle = playerColor()
gCtx.fillRect(
player.x + 2 * PLAYER_HEAD_SIZE,
player.y,
PLAYER_HEAD_SIZE,
PLAYER_HEAD_SIZE)
gCtx.fillRect(
player.x + PLAYER_HEAD_SIZE,
player.y + PLAYER_HEAD_SIZE,
PLAYER_WIDTH - 2 * PLAYER_HEAD_SIZE,
PLAYER_HEAD_SIZE)
gCtx.fillRect(
player.x,
player.y + 2 * PLAYER_HEAD_SIZE,
PLAYER_WIDTH,
PLAYER_HEAD_SIZE)
}
function drawPulse () {
if (!pulse.live) {
return
}
gCtx.fillStyle = pulse.color
gCtx.fillRect(pulse.x, pulse.y, PULSE_WIDTH, PULSE_HEIGHT)
}
function drawScaffolding () {
if (!SCAFFOLDING_ENABLED) {
return
}
gCtx.fillStyle = '#090'
gCtx.fillRect(0, SHIP_MAX_Y, CANVAS_WIDTH, 1)
for (let i = 0; i <= 10; i++) {
const y = SPACE_Y + SHIP_LANE_HEIGHT * i
gCtx.fillRect(0, y, CANVAS_WIDTH, 1)
}
gCtx.fillStyle = '#660'
for (let i = 0; i <= 10; i++) {
const y = SPACE_Y + SHIP_LANE_HEIGHT * i
gCtx.fillRect(0, y, CANVAS_WIDTH, 1)
}
gCtx.fillStyle = '#f00'
for (let i = 0; i <= 10; i++) {
const y = SPACE_Y + Math.floor(shipDescent) + SHIP_LANE_HEIGHT * i
gCtx.fillRect(0, y, CANVAS_WIDTH, 1)
}
}
// Info screen displayed when a game ends.
function infoCursor (e) {
const cursorY = e.offsetY * CANVAS_HEIGHT / scaledCanvasHeight
if (cursorY >= urlMinY && cursorY <= urlMaxY) {
canvas.style.cursor = 'pointer'
} else {
canvas.style.cursor = 'auto'
}
}
function infoClick (e) {
const cursorY = e.offsetY * CANVAS_HEIGHT / scaledCanvasHeight
if (cursorY >= urlMinY && cursorY <= urlMaxY) {
window.location = SOURCE_URL
}
}
function enableClickableInfo () {
canvas.addEventListener('mousemove', infoCursor)
canvas.addEventListener('click', infoClick)
}
function disableClickableInfo () {
canvas.removeEventListener('mousemove', infoCursor)
canvas.removeEventListener('click', infoClick)
}
function drawChar (c, x, y, s) {
const bitmap = FONTMAP[c]
if (typeof bitmap === 'undefined') {
throw new Error('No bitmap for ' + c)
}
for (let i = 0; i < bitmap.length; i++) {
for (let j = 0; j < 8; j++) {
const mask = 1 << (7 - j)
if ((bitmap[i] & mask) !== 0) {
gCtx.fillRect(x + s * j, y + s * i, s, s)
}
}
}
}
function drawText (line, x, y) {
gCtx.fillStyle = PLAYER_COLORS[0]
for (let i = 0; i < line.length; i++) {
drawChar(line.charAt(i), x + i * FONT_WIDTH, y, FONT_SCALE)
}
}
function drawCenterText (text, textY, underline) {
const width = text.length * FONT_WIDTH
const textX = Math.round((CANVAS_WIDTH - width) / 2)
drawText(text, textX, textY)
if (underline) {
gCtx.fillRect(textX, textY + FONT_HEIGHT, width, FONT_SCALE)
}
}
// Audio
function startMusic () {
log('audio state is', aCtx.state)
if (aCtx.state === 'suspended') {
aCtx.resume().then(function () {
log('audio has resumed; audio state is', aCtx.state)
playMusic()
})
} else {
playMusic()
}
}
function playMusic () {
const chords = [
[C2, C3, A3, E3, C4], // A minor
[E2, E3, G3, B3, E4], // E minor
[A2, D3, A3, F3, A4], // D minor
[G2, G3, B3, D3, G4] // G major
]
const interval = 1 / bounded(level, 2, 12)
playChordProgression(chords, interval, BACKGROUND_VOLUME)
// If the browser suspends audio when the game starts and a
// human player pauses and resumes the game, two invocations
// of aCtx.resume() callbacks would interleave. The following
// clearTimeout() avoids two music loops running
// simultaneously in such a case.
clearTimeout(aTimer)
aTimer = setTimeout(playMusic, chords.length * interval * 1000)
}
function playChordProgression (chords, duration, volume) {
for (let i = 0; i < chords.length; i++) {
playChord(chords[i], i * duration, duration, volume)
}
}
function playChord (frequencies, delay, duration, volume) {
if (aCtx.state === 'suspended') {
return
}
const gain = aCtx.createGain()
const timeConstant = Math.min(duration, 0.200) / 3
const beginTime = aCtx.currentTime + delay
const endTime = beginTime + 10 * timeConstant
gain.connect(compressor)
for (let i = 0; i < frequencies.length; i++) {
const oscillator = aCtx.createOscillator()
oscillator.frequency.value = frequencies[i]
gain.gain.setValueAtTime(muted ? 0 : volume, beginTime)
gain.gain.setTargetAtTime(0, beginTime, timeConstant)
oscillator.connect(gain)
oscillator.start(beginTime)
oscillator.stop(endTime)
setTimeout(function () {
oscillator.disconnect()
gain.disconnect()
}, (endTime + 1) * 1000)
}
}
function overlap (x1, y1, w1, h1, x2, y2, w2, h2) {
return x1 + w1 > x2 && x1 < x2 + w2 &&
y1 + h1 > y2 && y1 < y2 + h2
}
// Collision detection.
function checkShipHits () {
for (let i = 0; i < ships.length; i++) {
const ship = ships[i]
if (ship.health <= 0) {
continue
}
const hitBetweenRoofs = overlap(
pulse.x, pulse.y, PULSE_WIDTH, PULSE_HEIGHT,
ship.x + SHIP_ROOF_OFFSET, ship.y, SHIP_ROOF_WIDTH, SHIP_HEIGHT)
const hitBetweenWings = overlap(
pulse.x, pulse.y, PULSE_WIDTH, PULSE_HEIGHT,
ship.x, ship.y + SHIP_ROOF_HEIGHT, SHIP_WIDTH, SHIP_BODY_HEIGHT)
if (hitBetweenRoofs || hitBetweenWings) {
// F major
playChord([F3, C4, F4, A4, F5], 0, HIT_DURATION, HIT_VOLUME)
pulse.live = false
ship.health--
if (ship.health <= 0) {
shipsAlive--
updateNearestShipAlive()
}
player.score += (SHIP_COLORS.length - ship.health) * 10
log('ship', i, 'is hit')
}
}
}
function checkBoulderHits () {
for (let i = 0; i < boulders.length; i++) {
const boulder = boulders[i]
if (!boulder.live) {
continue
}
const hit = overlap(
pulse.x, pulse.y, PULSE_WIDTH, PULSE_HEIGHT,
boulder.x, boulder.y, BOULDER_WIDTH, BOULDER_HEIGHT)
if (hit) {
// C major
playChord([C3, C4, E4, G4, C5], 0, HIT_DURATION, HIT_VOLUME)
boulder.live = false
pulse.live = false
player.score++
log('boulder', i, 'is hit')
}
}
}
function checkPlayerHit () {
let playerHit = false
// Hit by boulders.
for (let i = 0; i < boulders.length; i++) {
const boulder = boulders[i]
if (!boulder.live) {
continue
}
const headHit = overlap(
boulder.x, boulder.y, BOULDER_WIDTH, BOULDER_HEIGHT,
player.x + 2 * PLAYER_HEAD_SIZE,
player.y,
PLAYER_HEAD_SIZE,
PLAYER_HEAD_SIZE)
const bodyHit = overlap(
boulder.x, boulder.y, BOULDER_WIDTH, BOULDER_HEIGHT,
player.x + PLAYER_HEAD_SIZE,
player.y + PLAYER_HEAD_SIZE,
PLAYER_WIDTH - 2 * PLAYER_HEAD_SIZE,
PLAYER_HEAD_SIZE)
const baseHit = overlap(
boulder.x, boulder.y, BOULDER_WIDTH, BOULDER_HEIGHT,
player.x,
player.y + 2 * PLAYER_HEAD_SIZE,
PLAYER_WIDTH,
PLAYER_HEAD_SIZE)
if (headHit || bodyHit || baseHit) {
playerHit = true
boulder.live = false
player.health--
player.repairySinceScore = player.score
log('player hit by boulder', i)
}
}
// Hit by ship.
if (nearestShipAlive >= 0) {
const ship = ships[nearestShipAlive]
const hitByWing = overlap(
player.x + 2 * PLAYER_HEAD_SIZE, player.y,
PLAYER_HEAD_SIZE, PLAYER_HEAD_SIZE,
ship.x, ship.y + SHIP_ROOF_HEIGHT, SHIP_WIDTH, SHIP_BODY_HEIGHT)
const hitByBody = overlap(
player.x + 2 * PLAYER_HEAD_SIZE, player.y,
PLAYER_HEAD_SIZE, PLAYER_HEAD_SIZE,
ship.x + SHIP_ROOF_OFFSET, ship.y, SHIP_ROOF_WIDTH, SHIP_HEIGHT)
if (hitByWing || hitByBody) {
playerHit = true
ship.health = 0
player.health = 0
log('player hit by ship', nearestShipAlive)
}
}
if (playerHit) {
playChord([B3, B4, D5, F5, B5], 0, 4 * HIT_DURATION, HIT_VOLUME)
}
}
function updateNearestShipAlive () {
for (let i = ships.length - 1; i >= 0; i--) {
if (ships[i].health > 0) {
nearestShipAlive = i
return
}
}
nearestShipAlive = -1
}
// User input handling.
function readKeyDown (e) {
if (e.code === 'KeyA' || e.code === 'ArrowLeft') {
actionStartLeft(e)
} else if (e.code === 'KeyD' || e.code === 'ArrowRight') {
actionStartRight(e)
} else if (e.code === 'KeyP' || e.code === 'Space') {
actionPause(e)
} else if (e.code === 'KeyE' || e.code === 'Enter') {
actionEnter(e)
} else if (e.code === 'KeyM') {
actionMute(e)
} else if (e.code === 'KeyF') {
actionFullScreen(e)
}
}
function readKeyUp (e) {
if (e.code === 'KeyA' || e.code === 'ArrowLeft') {
actionStopLeft(e)
} else if (e.code === 'KeyD' || e.code === 'ArrowRight') {
actionStopRight(e)
}
}
function actionStartLeft (e) {
e.preventDefault()
startLeft()
}
function actionStartRight (e) {
e.preventDefault()
startRight()
}
function actionStopLeft (e) {
e.preventDefault()
stopLeft()
}
function actionStopRight (e) {
e.preventDefault()
stopRight()
}
function startLeft () {
if (!leftOn) {
leftOn = true
moveLeft()
}
}
function startRight () {
if (!rightOn) {
rightOn = true
moveRight()
}
}
function stopLeft () {
leftOn = false
clearTimeout(leftTimer)
}
function stopRight () {
rightOn = false
clearTimeout(rightTimer)
}
function moveLeft () {
if (state === INITIALIZED) {
startGame()
return
} else if (state !== PLAYING || player.x === PADDING) {
return
}
player.x -= PLAYER_MOVE_STEP
drawGame()
leftTimer = setTimeout(moveLeft, ACTION_REPEAT_INTERVAL)
}
function moveRight () {
if (state === INITIALIZED) {
startGame()
} else if (state !== PLAYING || player.x === PLAYER_MAX_X) {
return
}
player.x += PLAYER_MOVE_STEP
drawGame()
rightTimer = setTimeout(moveRight, ACTION_REPEAT_INTERVAL)
}
function actionEnter (e) {
e.preventDefault()
if (state === INFO) {
disableClickableInfo()
restartGame()
} else {
stopGame()
drawInfo()
enableClickableInfo()
}
}
function actionMute (e) {
e.preventDefault()
muted = !muted
}
function actionPause (e) {
e.preventDefault()
if (state === INITIALIZED) {
startGame()
} else if (state === PLAYING) {
pauseGame()
} else if (state === PAUSED) {
resumeGame()
} else if (state === ENDED) {
drawInfo()
enableClickableInfo()
} else if (state === INFO) {
disableClickableInfo()
restartGame()
}
}
function actionFullScreen (e) {
if (document.fullscreenElement) {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
} else {
if (canvas.requestFullscreen) {
canvas.requestFullscreen()
} else if (canvas.webkitRequestFullscreen) {
canvas.webkitRequestFullscreen()
} else if (canvas.msRequestFullscreen) {
canvas.msRequestFullscreen()
}
}
}
// Bitmaps for drawing text on canvas.
const FONTMAP = {
/* eslint-disable indent, quote-props */
' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
'(': [0x00, 0x00, 0x0c, 0x18, 0x30, 0x30, 0x30, 0x30,
0x30, 0x30, 0x18, 0x0c, 0x00, 0x00, 0x00, 0x00],
')': [0x00, 0x00, 0x30, 0x18, 0x0c, 0x0c, 0x0c, 0x0c,
0x0c, 0x0c, 0x18, 0x30, 0x00, 0x00, 0x00, 0x00],
'-': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
'.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00],
'/': [0x00, 0x00, 0x00, 0x02, 0x06, 0x0c, 0x18, 0x30,
0x60, 0xc0, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00],
'0': [0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xce, 0xde, 0xf6,
0xe6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'1': [0x00, 0x00, 0x18, 0x38, 0x78, 0x18, 0x18, 0x18,
0x18, 0x18, 0x18, 0x7e, 0x00, 0x00, 0x00, 0x00],
'2': [0x00, 0x00, 0x7c, 0xc6, 0x06, 0x0c, 0x18, 0x30,
0x60, 0xc0, 0xc6, 0xfe, 0x00, 0x00, 0x00, 0x00],
'3': [0x00, 0x00, 0x7c, 0xc6, 0x06, 0x06, 0x3c, 0x06,
0x06, 0x06, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'4': [0x00, 0x00, 0x0c, 0x1c, 0x3c, 0x6c, 0xcc, 0xcc,
0xfe, 0x0c, 0x0c, 0x1e, 0x00, 0x00, 0x00, 0x00],
'5': [0x00, 0x00, 0xfe, 0xc0, 0xc0, 0xc0, 0xfc, 0x06,
0x06, 0x06, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'6': [0x00, 0x00, 0x7c, 0xc6, 0xc0, 0xc0, 0xfc, 0xc6,
0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'7': [0x00, 0x00, 0xfe, 0xc6, 0x06, 0x06, 0x0c, 0x18,
0x30, 0x30, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00],
'8': [0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0x7c, 0xc6,
0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'9': [0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e,
0x06, 0x06, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
':': [0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00,
0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00],
'A': [0x00, 0x00, 0x38, 0x6c, 0xc6, 0xc6, 0xc6, 0xfe,
0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00],
'C': [0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc0, 0xc0, 0xc0,
0xc0, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'I': [0x00, 0x00, 0x3c, 0x18, 0x18, 0x18, 0x18, 0x18,
0x18, 0x18, 0x18, 0x3c, 0x00, 0x00, 0x00, 0x00],
'L': [0x00, 0x00, 0xf0, 0x60, 0x60, 0x60, 0x60, 0x60,
0x60, 0x62, 0x66, 0xfe, 0x00, 0x00, 0x00, 0x00],
'M': [0x00, 0x00, 0x82, 0xc6, 0xee, 0xfe, 0xfe, 0xd6,
0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00],
'P': [0x00, 0x00, 0xfc, 0x66, 0x66, 0x66, 0x66, 0x7c,
0x60, 0x60, 0x60, 0xf0, 0x00, 0x00, 0x00, 0x00],
'S': [0x00, 0x00, 0x7c, 0xc6, 0xc6, 0x60, 0x38, 0x0c,
0x06, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'T': [0x00, 0x00, 0x7e, 0x7e, 0x5a, 0x18, 0x18, 0x18,
0x18, 0x18, 0x18, 0x3c, 0x00, 0x00, 0x00, 0x00],
'a': [0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x0c, 0x7c,
0xcc, 0xcc, 0xcc, 0x76, 0x00, 0x00, 0x00, 0x00],
'b': [0x00, 0x00, 0xe0, 0x60, 0x60, 0x7c, 0x66, 0x66,
0x66, 0x66, 0x66, 0x7c, 0x00, 0x00, 0x00, 0x00],
'c': [0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xc6, 0xc0,
0xc0, 0xc0, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'd': [0x00, 0x00, 0x1c, 0x0c, 0x0c, 0x7c, 0xcc, 0xcc,
0xcc, 0xcc, 0xcc, 0x76, 0x00, 0x00, 0x00, 0x00],
'e': [0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xc6, 0xc6,
0xfe, 0xc0, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'f': [0x00, 0x00, 0x1c, 0x36, 0x30, 0x7c, 0x30, 0x30,
0x30, 0x30, 0x30, 0x78, 0x00, 0x00, 0x00, 0x00],
'g': [0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0xcc, 0xcc,
0xcc, 0xcc, 0xcc, 0x7c, 0x0c, 0xcc, 0x78, 0x00],
'h': [0x00, 0x00, 0xe0, 0x60, 0x60, 0x6c, 0x76, 0x66,
0x66, 0x66, 0x66, 0xe6, 0x00, 0x00, 0x00, 0x00],
'i': [0x00, 0x00, 0x18, 0x18, 0x00, 0x38, 0x18, 0x18,
0x18, 0x18, 0x18, 0x3c, 0x00, 0x00, 0x00, 0x00],
'l': [0x00, 0x00, 0x38, 0x18, 0x18, 0x18, 0x18, 0x18,
0x18, 0x18, 0x18, 0x3c, 0x00, 0x00, 0x00, 0x00],
'm': [0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0xfe, 0xd6,
0xd6, 0xd6, 0xd6, 0xc6, 0x00, 0x00, 0x00, 0x00],
'n': [0x00, 0x00, 0x00, 0x00, 0x00, 0xdc, 0x66, 0x66,
0x66, 0x66, 0x66, 0x66, 0x00, 0x00, 0x00, 0x00],
'o': [0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xc6, 0xc6,
0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
'p': [0x00, 0x00, 0x00, 0x00, 0x00, 0xdc, 0x66, 0x66,
0x66, 0x66, 0x66, 0x7c, 0x60, 0x60, 0xf0, 0x00],
'r': [0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0x76, 0x60,
0x60, 0x60, 0x60, 0xf0, 0x00, 0x00, 0x00, 0x00],
's': [0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xc6, 0x60,
0x38, 0x0c, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00],
't': [0x00, 0x00, 0x10, 0x30, 0x30, 0xfc, 0x30, 0x30,
0x30, 0x30, 0x34, 0x18, 0x00, 0x00, 0x00, 0x00],
'u': [0x00, 0x00, 0x00, 0x00, 0x00, 0xcc, 0xcc, 0xcc,
0xcc, 0xcc, 0xcc, 0x76, 0x00, 0x00, 0x00, 0x00],
'v': [0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0xc6, 0xc6,
0xc6, 0x6c, 0x38, 0x10, 0x00, 0x00, 0x00, 0x00],
'y': [0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0xc6, 0xc6,
0xc6, 0xc6, 0xc6, 0x7e, 0x06, 0x0c, 0xf8, 0x00]
/* eslint-enable indent, quote-props */
}
window.addEventListener('load', init)
</script>
</head>
<body>
<canvas id="canvas">
</canvas>
<div>
<div id="play">
<button id="left" title="Move Left">←</button><!--
--><button id="right" title="Move Right">→</button>
</div><div id="more">
<button id="enter" title="Restart">↵</button><!--
--><button id="pause" title="Play/Pause">►</button><!--
--><button id="mute" title="Mute/Unmute">♫</button>
</div>
</div>
</body>
</html>