Star Blaster
by MagicStar571708 lines56.9 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Star Blaster</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #000;
display: flex; justify-content: center; align-items: center;
overflow: hidden;
font-family: monospace;
}
canvas { display: block; image-rendering: pixelated; outline: none; cursor: default; }
</style>
</head>
<body>
<canvas id="c" tabindex="0"></canvas>
<script>
// โโ SETUP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 480, H = 640;
canvas.width = W;
canvas.height = H;
function resize() {
const s = Math.min(window.innerWidth / W, window.innerHeight / H);
canvas.style.width = (W * s) + 'px';
canvas.style.height = (H * s) + 'px';
}
window.addEventListener('resize', resize);
resize();
// โโ SOUND ENGINE (Web Audio API, no external files) โโโโโโโโโโโโโโโโโโโโโโโโ
const AudioContext = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
function getAudio() {
if (!audioCtx) audioCtx = new AudioContext();
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
// Generic envelope helper
function playTone({ frequency = 440, type = 'square', gainPeak = 0.3,
attack = 0.005, decay = 0.1, sustainLevel = 0,
release = 0.05, duration = 0.15,
freqEnd = null, detune = 0 } = {}) {
try {
const ac = getAudio();
const osc = ac.createOscillator();
const env = ac.createGain();
osc.connect(env);
env.connect(ac.destination);
osc.type = type;
osc.frequency.setValueAtTime(frequency, ac.currentTime);
if (freqEnd !== null)
osc.frequency.linearRampToValueAtTime(freqEnd, ac.currentTime + duration);
osc.detune.setValueAtTime(detune, ac.currentTime);
const t = ac.currentTime;
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(gainPeak, t + attack);
env.gain.linearRampToValueAtTime(sustainLevel, t + attack + decay);
env.gain.linearRampToValueAtTime(0, t + attack + decay + release);
osc.start(t);
osc.stop(t + duration + 0.05);
} catch(e) {}
}
function playNoise({ gainPeak = 0.15, duration = 0.12, filterFreq = 800 } = {}) {
try {
const ac = getAudio();
const bufSize = ac.sampleRate * duration;
const buffer = ac.createBuffer(1, bufSize, ac.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ac.createBufferSource();
src.buffer = buffer;
const filter = ac.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterFreq, ac.currentTime);
filter.Q.setValueAtTime(0.8, ac.currentTime);
const env = ac.createGain();
src.connect(filter);
filter.connect(env);
env.connect(ac.destination);
const t = ac.currentTime;
env.gain.setValueAtTime(gainPeak, t);
env.gain.exponentialRampToValueAtTime(0.001, t + duration);
src.start(t);
src.stop(t + duration);
} catch(e) {}
}
// โโ NAMED SOUND EFFECTS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function sfxShoot() {
playTone({ frequency: 880, freqEnd: 440, type: 'square',
gainPeak: 0.18, attack: 0.002, decay: 0.06,
sustainLevel: 0, release: 0.02, duration: 0.09 });
}
function sfxEnemyHit() {
// Punchy boom: low thud body + mid crack + noise tail
try {
const ac = getAudio();
const t = ac.currentTime;
// Sub-bass thud โ pitch drops fast like a kick drum
const osc1 = ac.createOscillator();
const env1 = ac.createGain();
osc1.connect(env1); env1.connect(ac.destination);
osc1.type = 'sine';
osc1.frequency.setValueAtTime(180, t);
osc1.frequency.exponentialRampToValueAtTime(40, t + 0.18);
env1.gain.setValueAtTime(0.55, t);
env1.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
osc1.start(t); osc1.stop(t + 0.25);
// Mid crack layer
const osc2 = ac.createOscillator();
const env2 = ac.createGain();
osc2.connect(env2); env2.connect(ac.destination);
osc2.type = 'sawtooth';
osc2.frequency.setValueAtTime(320, t);
osc2.frequency.exponentialRampToValueAtTime(80, t + 0.1);
env2.gain.setValueAtTime(0.3, t);
env2.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
osc2.start(t); osc2.stop(t + 0.15);
// Noise burst
playNoise({ gainPeak: 0.25, duration: 0.14, filterFreq: 500 });
} catch(e) {}
}
function sfxBossHit() {
// Heavier boom thud
try {
const ac = getAudio();
const t = ac.currentTime;
const osc = ac.createOscillator();
const env = ac.createGain();
osc.connect(env); env.connect(ac.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(140, t);
osc.frequency.exponentialRampToValueAtTime(30, t + 0.25);
env.gain.setValueAtTime(0.5, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
osc.start(t); osc.stop(t + 0.32);
playNoise({ gainPeak: 0.22, duration: 0.18, filterFreq: 280 });
} catch(e) {}
}
function sfxPlayerHit() {
playTone({ frequency: 120, freqEnd: 40, type: 'sawtooth',
gainPeak: 0.4, attack: 0.005, decay: 0.18,
sustainLevel: 0, release: 0.08, duration: 0.26 });
playNoise({ gainPeak: 0.3, duration: 0.20, filterFreq: 200 });
}
function sfxBossDie() {
stopMusic();
currentTrack = 'win';
playWinMusic();
}
function sfxGameOver() {
stopMusic();
currentTrack = 'lose';
playLoseMusic();
}
function sfxEnemyBullet() {
playTone({ frequency: 320, freqEnd: 160, type: 'square',
gainPeak: 0.12, attack: 0.002, decay: 0.07,
sustainLevel: 0, release: 0.02, duration: 0.09 });
}
function sfxLaser() {
// Big descending zap โ scary and powerful
try {
const ac = getAudio();
const t = ac.currentTime;
// Main beam tone โ high to low screech
const osc = ac.createOscillator();
const env = ac.createGain();
osc.connect(env); env.connect(ac.destination);
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(1200, t);
osc.frequency.exponentialRampToValueAtTime(80, t + 0.35);
env.gain.setValueAtTime(0.5, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
osc.start(t); osc.stop(t + 0.45);
// High crackle layer
const osc2 = ac.createOscillator();
const env2 = ac.createGain();
osc2.connect(env2); env2.connect(ac.destination);
osc2.type = 'square';
osc2.frequency.setValueAtTime(2400, t);
osc2.frequency.exponentialRampToValueAtTime(200, t + 0.2);
env2.gain.setValueAtTime(0.2, t);
env2.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
osc2.start(t); osc2.stop(t + 0.3);
// Noise burst
playNoise({ gainPeak: 0.3, duration: 0.25, filterFreq: 1200 });
} catch(e) {}
}
const LEVELS = [
{ enemyCount: 8, enemySpeed: 55, fireRate: 2200, spawnDelay: 900 },
{ enemyCount: 12, enemySpeed: 70, fireRate: 1800, spawnDelay: 750 },
{ enemyCount: 16, enemySpeed: 90, fireRate: 1400, spawnDelay: 600 },
{ enemyCount: 22, enemySpeed: 110, fireRate: 1100, spawnDelay: 450 },
{ enemyCount: 30, enemySpeed: 140, fireRate: 800, spawnDelay: 300 },
];
// Boss config
const BOSS_MAX_HP = 100; // 100 hits to kill (each bullet = 1%)
const BOSS_W = 100;
const BOSS_H = 90;
const BOSS_FIRE_RATE = 900; // ms between triple shots
const LASER_INTERVAL = 10000; // ms between laser charges
const LASER_WARN_AT = 5000; // ms before fire to speak warning
const LASER_CHARGE_MS = 2000; // ms of targeting beam before firing
const LASER_FIRE_MS = 3000; // ms the fired beam stays visible
const BOSS_SPEED_X = 130;
const BOSS_ENTRY_Y = 130; // final resting Y after entry
// โโ STATE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let state, stars;
function initStars() {
stars = [];
for (let i = 0; i < 180; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.2 + 0.3,
a: Math.random() * 0.7 + 0.3,
speed: Math.random() * 1.2 + 0.4,
});
}
}
function freshState(startLevel) {
return {
phase: 'playing',
level: startLevel || 1,
score: 0,
lives: 3,
player: { x: W/2, y: H - 80, w: 36, h: 48, invTimer: 0 },
bullets: [],
enemyBullets: [],
enemies: [],
particles: [],
spawnCount: 0,
spawnTimer: 0,
fireTimer: 0,
playerFire: 0,
// Boss
boss: null,
slowMo: 0, // countdown frames of slow motion (boss death cinematic)
banner: '',
bannerTimer: 0,
overlay: null,
};
}
function cfg(s) { return LEVELS[s.level - 1]; }
// โโ INPUT โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const keys = {};
const ARROW_KEYS = new Set(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ']);
document.addEventListener('keydown', e => {
keys[e.key] = true;
if (ARROW_KEYS.has(e.key)) e.preventDefault();
startMusic();
if (e.key === 'm' || e.key === 'M') { toggleMute(); return; }
if (inMenu) {
const m = menuState;
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') m.selectedLevel = Math.max(1, m.selectedLevel - 1);
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') m.selectedLevel = Math.min(6, m.selectedLevel + 1);
if (e.key === 'Enter' || e.key === ' ') startFromMenu(m.selectedLevel);
return;
}
if ((e.key === 'r' || e.key === 'R') && state && state.overlay) restartGame();
});
document.addEventListener('keyup', e => { keys[e.key] = false; });
canvas.addEventListener('click', () => canvas.focus());
canvas.addEventListener('pointerdown', e => {
canvas.focus();
startMusic();
if (inMenu) {
// Detect click on a level card
const scale = canvas.offsetWidth / W;
const cx = e.offsetX / scale;
const cy = e.offsetY / scale;
const picked = menuHitTest(cx, cy);
if (picked !== null) { menuState.selectedLevel = picked; startFromMenu(picked); }
return;
}
if (state && state.overlay) restartGame();
});
// โโ MENU STATE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let menuState = { phase: 'menu', selectedLevel: 1 };
let inMenu = true;
function startFromMenu(level) {
inMenu = false;
state = freshState(level);
spawnLevel();
}
function goToMenu() {
inMenu = true;
menuState.selectedLevel = 1;
currentTrack = null; // release any win/lose lock so music restarts
}
function restartGame() {
goToMenu();
}
// โโ HELPERS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function rand(a, b) { return a + Math.random() * (b - a); }
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
// โโ SPAWNING โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function spawnLevel() {
const s = state;
s.spawnCount = 0;
s.spawnTimer = 0;
s.fireTimer = 0;
s.boss = null;
s.enemyBullets = [];
s.enemies = [];
s.banner = 'LEVEL ' + s.level;
s.bannerTimer = 120;
s.phase = 'playing';
if (s.level === 6) {
// Small delay then boss enters
s.bossEntryTimer = 180; // frames before boss appears
}
}
function spawnEnemy() {
const s = state;
s.spawnCount++;
s.enemies.push({
x: rand(30, W - 30), y: -30, w: 36, h: 44,
vx: rand(-50, 50),
vy: cfg(s).enemySpeed,
});
}
function spawnBoss() {
state.boss = {
x: W / 2,
y: -BOSS_H,
w: BOSS_W,
h: BOSS_H,
vx: BOSS_SPEED_X,
hp: BOSS_MAX_HP,
fireTimer: 0,
entering: true,
flashTimer: 0,
laserTimer: LASER_INTERVAL, // counts down to next laser
laserState: 'idle', // 'idle' | 'charging' | 'firing'
laserChargeTimer: 0, // counts down during charge phase
laserTargetX: 0, // locked player X when laser fires
warnedThisCycle: false, // so we only speak once per cycle
shotToggle: false, // alternates between 3 and 4 bullet shots
};
}
// โโ DRAWING โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawStars(scroll) {
stars.forEach(st => {
ctx.globalAlpha = st.a;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(st.x, (st.y + scroll * st.speed) % H, st.r, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
}
function drawPlayer(p) {
if (p.invTimer > 0 && Math.floor(p.invTimer / 4) % 2 === 0) return;
const x = p.x, y = p.y;
// Engine glow
ctx.save();
ctx.globalAlpha = 0.7 + 0.3 * Math.sin(Date.now() / 80);
const eg = ctx.createRadialGradient(x, y + 26, 0, x, y + 26, 14);
eg.addColorStop(0, '#ff8800'); eg.addColorStop(1, 'transparent');
ctx.fillStyle = eg;
ctx.beginPath(); ctx.ellipse(x, y + 26, 8, 12, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.fillStyle = '#005588';
ctx.beginPath(); ctx.moveTo(x, y+10); ctx.lineTo(x-20, y+24); ctx.lineTo(x-14, y+24); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(x, y+10); ctx.lineTo(x+20, y+24); ctx.lineTo(x+14, y+24); ctx.closePath(); ctx.fill();
const bg = ctx.createLinearGradient(x-10, y-24, x+10, y+24);
bg.addColorStop(0, '#00eeff'); bg.addColorStop(1, '#0077aa');
ctx.fillStyle = bg;
ctx.beginPath(); ctx.moveTo(x, y-24); ctx.lineTo(x-12, y+24); ctx.lineTo(x+12, y+24); ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(180,255,255,0.85)';
ctx.beginPath(); ctx.ellipse(x, y-4, 5, 9, 0, 0, Math.PI * 2); ctx.fill();
}
function drawEnemy(e) {
const x = e.x, y = e.y;
ctx.strokeStyle = '#ff6688'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x-5, y-22); ctx.lineTo(x-5, y-10); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x+5, y-22); ctx.lineTo(x+5, y-10); ctx.stroke();
ctx.fillStyle = '#ffaacc';
ctx.beginPath(); ctx.arc(x-5, y-22, 3, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(x+5, y-22, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#880011';
ctx.beginPath(); ctx.moveTo(x-8,y); ctx.lineTo(x-20,y+8); ctx.lineTo(x-18,y+18); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(x+8,y); ctx.lineTo(x+20,y+8); ctx.lineTo(x+18,y+18); ctx.closePath(); ctx.fill();
const eg = ctx.createLinearGradient(x, y-18, x, y+22);
eg.addColorStop(0, '#ff4466'); eg.addColorStop(1, '#aa0022');
ctx.fillStyle = eg;
ctx.beginPath(); ctx.moveTo(x,y-18); ctx.lineTo(x-14,y+22); ctx.lineTo(x+14,y+22); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#ffff00';
ctx.beginPath(); ctx.arc(x, y+4, 7, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(x, y+4, 4, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x-2, y+2, 1.5, 0, Math.PI*2); ctx.fill();
}
function drawBoss(b) {
const x = b.x, y = b.y;
const flash = b.flashTimer > 0;
// Outer glow
ctx.save();
const pulseA = 0.3 + 0.2 * Math.sin(Date.now() / 150);
const glow = ctx.createRadialGradient(x, y, 10, x, y, 80);
glow.addColorStop(0, `rgba(255,0,60,${pulseA})`);
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(x, y, 80, 0, Math.PI*2); ctx.fill();
ctx.restore();
// Big antennae (3)
ctx.strokeStyle = flash ? '#ffffff' : '#ff3366';
ctx.lineWidth = 3;
for (let i = -1; i <= 1; i++) {
ctx.beginPath();
ctx.moveTo(x + i * 20, y - 42);
ctx.lineTo(x + i * 20, y - 20);
ctx.stroke();
ctx.fillStyle = flash ? '#fff' : '#ff88bb';
ctx.beginPath(); ctx.arc(x + i * 20, y - 44, 5, 0, Math.PI*2); ctx.fill();
}
// Wings
const wc = flash ? '#ffffff' : '#880011';
ctx.fillStyle = wc;
ctx.beginPath();
ctx.moveTo(x - 20, y - 10);
ctx.lineTo(x - 55, y + 10);
ctx.lineTo(x - 50, y + 38);
ctx.lineTo(x - 20, y + 30);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + 20, y - 10);
ctx.lineTo(x + 55, y + 10);
ctx.lineTo(x + 50, y + 38);
ctx.lineTo(x + 20, y + 30);
ctx.closePath(); ctx.fill();
// Body
const bodyGrad = ctx.createLinearGradient(x - 30, y - 38, x + 30, y + 42);
bodyGrad.addColorStop(0, flash ? '#ffffff' : '#ff2255');
bodyGrad.addColorStop(0.5, flash ? '#ffcccc' : '#cc0033');
bodyGrad.addColorStop(1, flash ? '#ff8888' : '#660011');
ctx.fillStyle = bodyGrad;
ctx.beginPath();
ctx.moveTo(x, y - 38);
ctx.lineTo(x - 30, y - 10);
ctx.lineTo(x - 28, y + 42);
ctx.lineTo(x + 28, y + 42);
ctx.lineTo(x + 30, y - 10);
ctx.closePath(); ctx.fill();
// Big eye
ctx.fillStyle = flash ? '#ffffff' : '#ffff00';
ctx.beginPath(); ctx.arc(x, y + 8, 18, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(x, y + 8, 11, 0, Math.PI*2); ctx.fill();
// Angry pupil
const pupilShift = Math.sin(Date.now() / 200) * 4;
ctx.fillStyle = flash ? '#ff0000' : '#ff0044';
ctx.beginPath(); ctx.arc(x + pupilShift, y + 8, 6, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x + pupilShift - 3, y + 4, 2.5, 0, Math.PI*2); ctx.fill();
// Angry eyebrows
ctx.strokeStyle = flash ? '#ffffff' : '#ff2244';
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x - 18, y - 10); ctx.lineTo(x - 6, y - 4); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x + 18, y - 10); ctx.lineTo(x + 6, y - 4); ctx.stroke();
if (b.flashTimer > 0) b.flashTimer--;
}
function drawBossHealthBar(b) {
if (!b) return;
const pct = b.hp / BOSS_MAX_HP;
const barW = W - 40;
const barH = 14;
const barX = 20;
const barY = 40;
// Label
ctx.fillStyle = '#ff4466';
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'left';
ctx.fillText('๐พ ALIEN KING', barX, barY - 3);
// Background
ctx.fillStyle = '#330011';
ctx.beginPath(); ctx.roundRect(barX, barY, barW, barH, 4); ctx.fill();
// Fill โ color shifts redโorangeโyellow as boss gets low
const r = 255;
const g = Math.floor(pct * 180);
const hpGrad = ctx.createLinearGradient(barX, 0, barX + barW * pct, 0);
hpGrad.addColorStop(0, `rgb(${r},${g},0)`);
hpGrad.addColorStop(1, `rgb(255,80,0)`);
ctx.fillStyle = hpGrad;
ctx.beginPath(); ctx.roundRect(barX, barY, barW * pct, barH, 4); ctx.fill();
// Border
ctx.strokeStyle = '#ff2244';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.roundRect(barX, barY, barW, barH, 4); ctx.stroke();
// Percentage text
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText(Math.ceil(pct * 100) + '%', barX + barW / 2, barY + barH - 2);
}
// โโ MENU โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const LEVEL_LABELS = ['1','2','3','4','5','6 ๐'];
const LEVEL_DESCS = [
'Easy start',
'Picking up',
'Getting hot',
'Danger zone',
'Maximum chaos',
'Boss Level',
];
const CARD_W = 64, CARD_H = 72, CARD_GAP = 10;
const CARDS_TOTAL_W = 6 * CARD_W + 5 * CARD_GAP;
const CARDS_X = (W - CARDS_TOTAL_W) / 2;
const CARDS_Y = 300;
function menuHitTest(cx, cy) {
for (let i = 0; i < 6; i++) {
const x = CARDS_X + i * (CARD_W + CARD_GAP);
if (cx >= x && cx <= x + CARD_W && cy >= CARDS_Y && cy <= CARDS_Y + CARD_H) return i + 1;
}
return null;
}
function drawMenu() {
const m = menuState;
// Background
ctx.fillStyle = '#000010';
ctx.fillRect(0, 0, W, H);
drawStars(scroll);
// Title
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Glow title
ctx.shadowColor = '#00ffff';
ctx.shadowBlur = 24;
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 52px monospace';
ctx.fillText('STAR BLASTER', W / 2, 110);
ctx.shadowBlur = 0;
ctx.fillStyle = '#aaffff';
ctx.font = '16px monospace';
ctx.fillText('choose your starting level', W / 2, 160);
// Draw a mini ship as decoration
ctx.save();
ctx.translate(W / 2, 220);
ctx.scale(1.8, 1.8);
ctx.fillStyle = '#005588';
ctx.beginPath(); ctx.moveTo(0,10); ctx.lineTo(-20,24); ctx.lineTo(-14,24); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(0,10); ctx.lineTo(20,24); ctx.lineTo(14,24); ctx.closePath(); ctx.fill();
const bg2 = ctx.createLinearGradient(-10,-24,10,24);
bg2.addColorStop(0,'#00eeff'); bg2.addColorStop(1,'#0077aa');
ctx.fillStyle = bg2;
ctx.beginPath(); ctx.moveTo(0,-24); ctx.lineTo(-12,24); ctx.lineTo(12,24); ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(180,255,255,0.85)';
ctx.beginPath(); ctx.ellipse(0,-4,5,9,0,0,Math.PI*2); ctx.fill();
ctx.restore();
// Level cards
// Difficulty color ramp: green โ yellow-green โ yellow โ orange โ orange-red โ red
const DIFF_COLORS = [
'#00cc44', // 1 โ green
'#88cc00', // 2 โ yellow-green
'#ddcc00', // 3 โ yellow
'#ff8800', // 4 โ orange
'#ff4400', // 5 โ orange-red
'#ff1133', // 6 โ red
];
const DIFF_DARK = [
'#003311', // 1
'#223300', // 2
'#332e00', // 3
'#331a00', // 4
'#330d00', // 5
'#330008', // 6
];
for (let i = 0; i < 6; i++) {
const lvl = i + 1;
const x = CARDS_X + i * (CARD_W + CARD_GAP);
const y = CARDS_Y;
const sel = m.selectedLevel === lvl;
const color = DIFF_COLORS[i];
const dark = DIFF_DARK[i];
// Card background
ctx.save();
if (sel) {
ctx.shadowColor = color;
ctx.shadowBlur = 20;
}
ctx.fillStyle = sel ? dark : '#0a0a1a';
ctx.beginPath();
ctx.roundRect(x, y, CARD_W, CARD_H, 8);
ctx.fill();
// Border โ full color when selected, dim tint when not
ctx.strokeStyle = sel ? color : color + '55';
ctx.lineWidth = sel ? 2.5 : 1.5;
ctx.beginPath();
ctx.roundRect(x, y, CARD_W, CARD_H, 8);
ctx.stroke();
ctx.restore();
// Level number โ full color always, brighter when selected
ctx.fillStyle = sel ? '#ffffff' : color;
ctx.font = `bold ${lvl === 6 ? 18 : 24}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(LEVEL_LABELS[i], x + CARD_W / 2, y + 28);
// Description
ctx.fillStyle = sel ? '#ffffff' : '#556677';
ctx.font = '9px monospace';
const words = LEVEL_DESCS[i].split(' ');
let line = '', lineY = y + 50;
for (const w of words) {
const test = line ? line + ' ' + w : w;
if (ctx.measureText(test).width > CARD_W - 8) {
ctx.fillText(line, x + CARD_W / 2, lineY);
line = w; lineY += 12;
} else { line = test; }
}
if (line) ctx.fillText(line, x + CARD_W / 2, lineY);
}
// Instructions
ctx.fillStyle = '#445566';
ctx.font = '13px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('โ โ to select ENTER or click to start', W / 2, CARDS_Y + CARD_H + 36);
// Mute hint bottom-left
ctx.textAlign = 'left';
ctx.fillStyle = musicMuted ? '#ff4444' : '#336644';
ctx.fillText(musicMuted ? '๐ M: unmute' : '๐ M: mute', 10, H - 10);
ctx.restore();
}
function drawBossLaser(b, player) {
if (b.laserState === 'idle') return;
const x = b.laserTargetX;
const beamTop = b.y + BOSS_H / 2; // bottom edge of boss
if (b.laserState === 'charging') {
const pulse = 0.4 + 0.6 * Math.abs(Math.sin(Date.now() / 80));
const chargeProgress = 1 - (b.laserChargeTimer / LASER_CHARGE_MS);
// Dashed targeting line from boss down to bottom of screen
ctx.save();
ctx.setLineDash([8, 8]);
ctx.lineDashOffset = -Date.now() / 30;
ctx.strokeStyle = `rgba(255, 60, 60, ${pulse})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(x, beamTop);
ctx.lineTo(x, H);
ctx.stroke();
ctx.setLineDash([]);
// Charge progress bar just below boss
ctx.fillStyle = `rgba(255, 60, 60, 0.6)`;
ctx.fillRect(x - 20, beamTop + 4, 40 * chargeProgress, 4);
// Target diamond at bottom
ctx.fillStyle = `rgba(255, 60, 60, ${pulse})`;
ctx.beginPath();
ctx.moveTo(x, H - 20);
ctx.lineTo(x - 10, H - 30);
ctx.lineTo(x, H - 40);
ctx.lineTo(x + 10, H - 30);
ctx.closePath();
ctx.fill();
ctx.restore();
} else if (b.laserState === 'firing') {
// Full beam from boss to bottom of screen
const alpha = b.laserFireTimer / LASER_FIRE_MS;
ctx.save();
ctx.shadowColor = '#ff0000';
ctx.shadowBlur = 30;
const grad = ctx.createLinearGradient(x - 22, 0, x + 22, 0);
grad.addColorStop(0, `rgba(255,0,0,0)`);
grad.addColorStop(0.3, `rgba(255,80,80,${alpha})`);
grad.addColorStop(0.5, `rgba(255,255,255,${alpha})`);
grad.addColorStop(0.7, `rgba(255,80,80,${alpha})`);
grad.addColorStop(1, `rgba(255,0,0,0)`);
ctx.fillStyle = grad;
ctx.fillRect(x - 22, beamTop, 44, H - beamTop);
ctx.shadowBlur = 0;
ctx.restore();
}
}
function drawBullet(b) {
const g = ctx.createLinearGradient(b.x, b.y - 10, b.x, b.y + 4);
g.addColorStop(0, '#ffffff'); g.addColorStop(1, '#00ffff');
ctx.fillStyle = g;
ctx.shadowColor = '#00ffff'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.roundRect(b.x - 2, b.y - 10, 4, 14, 2); ctx.fill();
ctx.shadowBlur = 0;
}
function drawEnemyBullet(b) {
const g = ctx.createLinearGradient(b.x, b.y, b.x, b.y + 12);
g.addColorStop(0, '#ff2244'); g.addColorStop(1, '#ffaacc');
ctx.fillStyle = g;
ctx.shadowColor = '#ff2244'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.roundRect(b.x - 2, b.y, 4, 12, 2); ctx.fill();
ctx.shadowBlur = 0;
}
function drawParticle(p) {
ctx.globalAlpha = p.life / p.maxLife;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (p.life / p.maxLife), 0, Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
}
function drawHUD(s) {
const hudY = s.boss ? 68 : 26;
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'left';
ctx.fillText('SCORE: ' + s.score, 10, hudY);
ctx.fillStyle = '#ffff00';
ctx.textAlign = 'center';
ctx.fillText('LEVEL ' + s.level, W / 2, hudY);
for (let i = 0; i < s.lives; i++) {
ctx.save();
ctx.translate(W - 14 - i * 22, hudY - 8);
ctx.scale(0.45, 0.45);
ctx.fillStyle = '#00cfff';
ctx.beginPath(); ctx.moveTo(0,-20); ctx.lineTo(-10,20); ctx.lineTo(10,20); ctx.closePath(); ctx.fill();
ctx.restore();
}
// Mute indicator โ bottom-left corner
ctx.font = '13px monospace';
ctx.textAlign = 'left';
ctx.fillStyle = musicMuted ? '#ff4444' : '#336644';
ctx.fillText(musicMuted ? '๐ M: unmute' : '๐ M: mute', 10, H - 10);
}
function drawBanner(s) {
if (s.bannerTimer <= 0) return;
const a = Math.min(1, s.bannerTimer / 30);
ctx.globalAlpha = a;
ctx.fillStyle = s.level === 6 ? '#ff4466' : '#00ffff';
ctx.font = 'bold 40px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = s.level === 6 ? '#660011' : '#003366';
ctx.shadowBlur = 14;
ctx.fillText(s.banner, W / 2, H / 2);
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.textBaseline = 'alphabetic';
}
function drawOverlay(s) {
if (!s.overlay) return;
ctx.fillStyle = 'rgba(0,0,0,0.78)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 44px monospace';
ctx.fillStyle = s.overlay.titleColor || '#ffffff';
ctx.shadowColor = '#000'; ctx.shadowBlur = 10;
ctx.fillText(s.overlay.title, W / 2, H / 2 - 80);
ctx.font = '22px monospace';
ctx.fillStyle = '#00ffff';
ctx.fillText('FINAL SCORE: ' + s.score, W / 2, H / 2 - 20);
if (Math.floor(Date.now() / 500) % 2 === 0) {
ctx.font = 'bold 24px monospace';
ctx.fillStyle = '#ffff00';
ctx.fillText('[ CLICK OR PRESS R ]', W / 2, H / 2 + 60);
}
ctx.shadowBlur = 0;
ctx.textBaseline = 'alphabetic';
}
// โโ EXPLOSION โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const EXPLOSION_COLORS = ['#ffdd00','#ff8800','#ff3300','#ffffff','#ffaaaa'];
const EXPLOSION_COLORS_BIG = ['#ffdd00','#ff9900','#ff4400','#ffffff','#ffccaa','#ff6600','#ffff00'];
function explode(s, x, y, big, epic) {
const count = epic ? 120 : big ? 60 : 22;
const colors = (epic || big) ? EXPLOSION_COLORS_BIG : EXPLOSION_COLORS;
const speedLo = epic ? 0.4 : big ? 1.5 : 1.5;
const speedHi = epic ? 3.5 : big ? 8 : 5;
const rLo = epic ? 4 : big ? 3 : 2;
const rHi = epic ? 12 : big ? 8 : 5;
const lifeLo = epic ? 180 : big ? 50 : 30;
const lifeHi = epic ? 280 : big ? 70 : 50;
for (let i = 0; i < count; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(speedLo, speedHi);
const life = lifeLo + randInt(0, lifeHi - lifeLo);
s.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: rand(rLo, rHi),
color: colors[randInt(0, colors.length - 1)],
life,
maxLife: life,
});
}
}
// โโ RECT COLLISION โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function hit(ax, ay, aw, ah, bx, by, bw, bh) {
return ax - aw/2 < bx + bw/2 &&
ax + aw/2 > bx - bw/2 &&
ay - ah/2 < by + bh/2 &&
ay + ah/2 > by - bh/2;
}
function speakRobotic(text) {
try {
// Web Speech API for the actual words โ nothing beats real speech synthesis for clarity
if (window.speechSynthesis) {
window.speechSynthesis.cancel();
const utt = new SpeechSynthesisUtterance(text);
utt.pitch = 0.1; // as low as possible
utt.rate = 0.8; // slow and deliberate
utt.volume = 1.0;
// Wait for voices to load, then pick the most robotic-sounding one
const setVoice = () => {
const voices = window.speechSynthesis.getVoices();
// Prefer a male English voice with a mechanical quality
const pick = voices.find(v => /david|mark|daniel|alex|google uk english male/i.test(v.name) && /en/i.test(v.lang))
|| voices.find(v => /en/i.test(v.lang) && !v.name.includes('Female'))
|| voices[0];
if (pick) utt.voice = pick;
window.speechSynthesis.speak(utt);
};
if (window.speechSynthesis.getVoices().length > 0) {
setVoice();
} else {
window.speechSynthesis.onvoiceschanged = setVoice;
}
}
// Simultaneously play a robotic buzz underneath to sell the robot feel
const ac = getAudio();
const t = ac.currentTime + 0.05;
const dur = 1.2; // roughly matches "charging laser" duration
const osc = ac.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(90, t);
osc.frequency.linearRampToValueAtTime(70, t + dur);
const lfo = ac.createOscillator();
lfo.frequency.value = 8; // 8Hz tremolo
const lfoGain = ac.createGain();
lfoGain.gain.value = 20;
lfo.connect(lfoGain);
lfoGain.connect(osc.frequency);
const filter = ac.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 300;
filter.Q.value = 2;
osc.connect(filter);
const env = ac.createGain();
filter.connect(env);
env.connect(ac.destination);
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(0.08, t + 0.05);
env.gain.setValueAtTime(0.08, t + dur - 0.1);
env.gain.linearRampToValueAtTime(0, t + dur);
osc.start(t); osc.stop(t + dur + 0.1);
lfo.start(t); lfo.stop(t + dur + 0.1);
} catch(e) {}
}
// โโ UPDATE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let scroll = 0;
let lastTime = 0;
function update(dt) {
const s = state;
if (s.phase === 'gameover' || s.phase === 'win') return;
// During boss death cinematic โ only run particles and slow-mo, nothing else
if (s.phase === 'bossdie') {
if (s.slowMo > 0) { s.slowMo--; dt *= 0.15; }
s.particles.forEach(p => { p.x += p.vx * dt * 60; p.y += p.vy * dt * 60; p.vy += 0.08 * dt * 60; p.life--; });
s.particles = s.particles.filter(p => p.life > 0);
return;
}
// Slow-motion during boss death cinematic
if (s.slowMo > 0) {
s.slowMo--;
dt *= 0.15;
}
// Scroll only when not in boss level (or boss still entering)
const isBossLevel = s.level === 6;
const bossActive = isBossLevel && s.boss && !s.boss.entering;
if (!bossActive) scroll += 80 * dt;
// โโ Player movement
const p = s.player;
const spd = 280;
let dx = 0, dy = 0;
if (keys['ArrowLeft'] || keys['a'] || keys['A']) dx -= spd;
if (keys['ArrowRight'] || keys['d'] || keys['D']) dx += spd;
if (keys['ArrowUp'] || keys['w'] || keys['W']) dy -= spd;
if (keys['ArrowDown'] || keys['s'] || keys['S']) dy += spd;
p.x = clamp(p.x + dx * dt, 22, W - 22);
p.y = clamp(p.y + dy * dt, 40, H - 40);
if (p.invTimer > 0) p.invTimer--;
// โโ Player auto-fire (only while playing or levelclear)
if (s.phase === 'playing') {
s.playerFire -= dt * 1000;
if (s.playerFire <= 0) {
s.playerFire = 200;
s.bullets.push({ x: p.x, y: p.y - 28, vy: -520 });
sfxShoot();
}
}
// โโ BOSS LEVEL LOGIC โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (isBossLevel) {
// Only advance boss entry/logic while actively playing
if (s.phase === 'playing') {
if (!s.boss && s.bossEntryTimer > 0) {
s.bossEntryTimer--;
if (s.bossEntryTimer === 0) spawnBoss();
}
if (s.boss) {
const b = s.boss;
// Entry slide-in
if (b.entering) {
b.y = Math.min(b.y + 160 * dt, BOSS_ENTRY_Y);
if (b.y >= BOSS_ENTRY_Y) b.entering = false;
} else {
// Horizontal patrol
b.x += b.vx * dt;
if (b.x > W - BOSS_W / 2 - 10) { b.x = W - BOSS_W / 2 - 10; b.vx = -Math.abs(b.vx); }
if (b.x < BOSS_W / 2 + 10) { b.x = BOSS_W / 2 + 10; b.vx = Math.abs(b.vx); }
// Triple-shot fire
b.fireTimer -= dt * 1000;
if (b.fireTimer <= 0) {
b.fireTimer = BOSS_FIRE_RATE;
const bulletVy = 340;
const spreadVx = 120;
s.enemyBullets.push({ x: b.x, y: b.y + BOSS_H / 2, vy: bulletVy, vx: 0 });
s.enemyBullets.push({ x: b.x - BOSS_W * 0.35, y: b.y + BOSS_H / 2, vy: bulletVy, vx: -spreadVx });
s.enemyBullets.push({ x: b.x + BOSS_W * 0.35, y: b.y + BOSS_H / 2, vy: bulletVy, vx: spreadVx });
if (b.shotToggle) {
s.enemyBullets.push({ x: b.x + BOSS_W * 0.35, y: b.y + BOSS_H / 2, vy: bulletVy, vx: spreadVx * 2 });
}
b.shotToggle = !b.shotToggle;
sfxEnemyBullet();
}
// โโ Laser countdown
if (b.laserState === 'idle') {
b.laserTimer -= dt * 1000;
// 10 seconds before firing โ speak warning
if (!b.warnedThisCycle && b.laserTimer <= LASER_WARN_AT) {
b.warnedThisCycle = true;
speakRobotic('Charging laser.');
}
// Time to charge โ lock target onto player's current position
if (b.laserTimer <= 0) {
b.laserState = 'charging';
b.laserChargeTimer = LASER_CHARGE_MS;
b.laserTargetX = p.x; // locked โ player must move away from this X
}
} else if (b.laserState === 'charging') {
b.laserChargeTimer -= dt * 1000;
if (b.laserChargeTimer <= 0) {
// FIRE โ instant kill if player is within beam width
b.laserState = 'firing';
b.laserFireTimer = LASER_FIRE_MS;
const beamHalfW = 22;
if (Math.abs(p.x - b.laserTargetX) < beamHalfW) {
// instant kill โ bypass invTimer, lose all lives
s.lives = 1;
takeDamage(s);
}
sfxLaser();
}
} else if (b.laserState === 'firing') {
b.laserFireTimer -= dt * 1000;
// Kill player if they're still in the beam during the 3-second burn
const beamHalfW = 22;
if (p.invTimer === 0 && Math.abs(p.x - b.laserTargetX) < beamHalfW) {
s.lives = 1;
takeDamage(s);
}
if (b.laserFireTimer <= 0) {
b.laserState = 'idle';
b.laserTimer = LASER_INTERVAL;
b.warnedThisCycle = false;
}
}
// Bullet hits boss
s.bullets = s.bullets.filter(bullet => {
if (hit(bullet.x, bullet.y, 4, 14, b.x, b.y, b.w, b.h)) {
b.hp = Math.max(0, b.hp - 2);
b.flashTimer = 6;
explode(s, bullet.x, bullet.y, false);
s.score += 50;
sfxBossHit();
if (b.hp <= 0) {
s.phase = 'bossdie'; // lock phase immediately โ no more damage/spawning
s.slowMo = 300;
explode(s, b.x, b.y, false, true);
explode(s, b.x - 35, b.y + 25, false, true);
explode(s, b.x + 35, b.y - 15, false, true);
[200, 450, 750, 1100, 1500].forEach(delay => {
setTimeout(() => {
if (state === s && s.boss) {
explode(s, s.boss.x + rand(-50, 50), s.boss.y + rand(-40, 40), false, true);
}
}, delay);
});
sfxBossDie();
s.boss = null;
s.score += 5000;
setTimeout(() => {
if (state === s) {
s.phase = 'win';
s.overlay = { title: '๐ YOU WIN! ๐', titleColor: '#ffff00' };
}
}, 3800);
}
return false;
}
return true;
});
// Boss rams player โ only if player is vulnerable and game still playing
if (s.phase === 'playing' && p.invTimer === 0 && s.boss &&
hit(b.x, b.y, b.w - 10, b.h - 10, p.x, p.y, p.w - 8, p.h - 8)) {
takeDamage(s);
}
}
}
}
// Move boss bullets regardless (so they clear off screen)
s.enemyBullets = s.enemyBullets.filter(b => {
b.x += (b.vx || 0) * dt;
b.y += b.vy * dt;
return b.y < H + 20 && b.x > -20 && b.x < W + 20;
});
} else {
// โโ NORMAL LEVEL LOGIC โ only while playing โโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (s.phase === 'playing') {
if (s.spawnCount < cfg(s).enemyCount) {
s.spawnTimer -= dt * 1000;
if (s.spawnTimer <= 0) {
s.spawnTimer = cfg(s).spawnDelay;
spawnEnemy();
}
}
if (s.enemies.length > 0) {
s.fireTimer -= dt * 1000;
if (s.fireTimer <= 0) {
s.fireTimer = cfg(s).fireRate;
const shooter = s.enemies[randInt(0, s.enemies.length - 1)];
if (shooter) { s.enemyBullets.push({ x: shooter.x, y: shooter.y + 22, vy: 300 + s.level * 30, vx: 0 }); sfxEnemyBullet(); }
}
}
}
s.enemyBullets = s.enemyBullets.filter(b => {
b.x += (b.vx || 0) * dt;
b.y += b.vy * dt;
return b.y < H + 20;
});
s.enemies = s.enemies.filter(e => {
e.x += e.vx * dt; e.y += e.vy * dt;
if (e.x < 20 || e.x > W - 20) e.vx *= -1;
return e.y < H + 60;
});
// Bullet vs enemy โ only while playing
if (s.phase === 'playing') {
s.bullets = s.bullets.filter(b => {
let hitsomething = false;
s.enemies = s.enemies.filter(e => {
if (!hitsomething && hit(b.x, b.y, 4, 14, e.x, e.y, e.w, e.h)) {
hitsomething = true;
explode(s, e.x, e.y, false);
sfxEnemyHit();
s.score += 100 * s.level;
return false;
}
return true;
});
return !hitsomething;
});
// Level clear check
if (s.spawnCount >= cfg(s).enemyCount && s.enemies.length === 0) {
s.phase = 'levelclear'; // lock immediately before setTimeout fires
s.score += 500;
s.banner = 'LEVEL CLEAR! +500';
s.bannerTimer = 120;
const capturedState = s;
setTimeout(() => {
// Only advance if this exact state object is still active AND not gameover
if (state === capturedState && capturedState.phase === 'levelclear') {
capturedState.level++;
spawnLevel();
}
}, 2000);
}
}
}
// โโ Move player bullets upward (shared)
s.bullets = s.bullets.filter(b => { b.y += b.vy * dt; return b.y > -20; });
// โโ Enemy bullet vs player โ only while actively playing (not levelclear/bossdie)
if (s.phase === 'playing' && p.invTimer === 0) {
// Use a flag so multiple hits in one frame only count once
let hitThisFrame = false;
s.enemyBullets = s.enemyBullets.filter(b => {
if (!hitThisFrame && hit(b.x, b.y, 4, 12, p.x, p.y, p.w - 8, p.h - 8)) {
hitThisFrame = true;
takeDamage(s);
return false;
}
return true;
});
// Normal enemy ram โ only if player not already hit this frame
if (!isBossLevel && !hitThisFrame) {
s.enemies = s.enemies.filter(e => {
if (!hitThisFrame && hit(e.x, e.y, e.w, e.h, p.x, p.y, p.w - 8, p.h - 8)) {
hitThisFrame = true;
explode(s, e.x, e.y, false);
takeDamage(s);
return false;
}
return true;
});
}
}
// โโ Particles (run always so explosions finish playing out)
s.particles.forEach(p => { p.x += p.vx * dt * 60; p.y += p.vy * dt * 60; p.vy += 0.08 * dt * 60; p.life--; });
s.particles = s.particles.filter(p => p.life > 0);
if (s.bannerTimer > 0) s.bannerTimer--;
}
function takeDamage(s) {
s.lives--;
s.player.invTimer = 120;
explode(s, s.player.x, s.player.y, false);
sfxPlayerHit();
if (s.lives <= 0) {
s.lives = 0;
s.phase = 'gameover';
s.overlay = { title: '๐ GAME OVER', titleColor: '#ff4466' };
sfxGameOver();
}
}
// โโ DRAW โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function draw() {
const s = state;
ctx.fillStyle = '#000010';
ctx.fillRect(0, 0, W, H);
drawStars(scroll);
s.particles.forEach(drawParticle);
s.enemies.forEach(drawEnemy);
if (s.boss) drawBoss(s.boss);
if (s.boss) drawBossLaser(s.boss, s.player);
s.bullets.forEach(drawBullet);
s.enemyBullets.forEach(drawEnemyBullet);
drawPlayer(s.player);
// HUD โ boss bar first so score/lives sit below it
if (s.boss) drawBossHealthBar(s.boss);
drawHUD(s);
drawBanner(s);
drawOverlay(s);
}
// โโ LOOP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
updateMusic();
if (inMenu) {
scroll += 40 * dt;
drawMenu();
} else {
update(dt);
draw();
}
requestAnimationFrame(loop);
}
// โโ MUSIC โ pure Web Audio API, no external libraries โโโโโโโโโโโโโโโโโโโโโ
// Self-contained sequencer: schedules notes ahead using AudioContext clock,
// works in any sandboxed iframe with no network access needed.
let musicStarted = false;
let musicMuted = false;
let currentTrack = null; // 'menu' | 'boss' | 'win' | 'lose'
// master gain for mute toggle
let musicGain = null;
// currently running scheduler interval
let musicScheduler = null;
// sequence state
let seqState = null;
// note name โ frequency
const NOTE_FREQ = (()=>{
const notes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const map = {};
for (let oct = 0; oct <= 8; oct++) {
notes.forEach((n, i) => {
const midi = (oct + 1) * 12 + i;
map[n + oct] = 440 * Math.pow(2, (midi - 69) / 12);
});
}
// aliases
map['Bb0']=map['A#0']; map['Bb1']=map['A#1']; map['Bb2']=map['A#2'];
map['Bb3']=map['A#3']; map['Bb4']=map['A#4']; map['Bb5']=map['A#5'];
map['Eb3']=map['D#3']; map['Eb4']=map['D#4']; map['Eb5']=map['D#5'];
map['Ab2']=map['G#2']; map['Ab3']=map['G#3'];
return map;
})();
function midiNote(name) { return NOTE_FREQ[name] || 440; }
function getMusicAC() {
return getAudio(); // reuse existing Web Audio context
}
function initMusicGain() {
if (musicGain) return;
const ac = getMusicAC();
musicGain = ac.createGain();
musicGain.gain.value = musicMuted ? 0 : 0.5;
musicGain.connect(ac.destination);
}
function toggleMute() {
musicMuted = !musicMuted;
if (musicGain) musicGain.gain.value = musicMuted ? 0 : 0.5;
}
// Play a single note via Web Audio
function musicNote(freq, startTime, duration, type='triangle', gainVal=0.15, attack=0.04, release=0.1) {
try {
const ac = getMusicAC();
initMusicGain();
const osc = ac.createOscillator();
const env = ac.createGain();
osc.connect(env);
env.connect(musicGain);
osc.type = type;
osc.frequency.value = freq;
const end = startTime + duration;
env.gain.setValueAtTime(0, startTime);
env.gain.linearRampToValueAtTime(gainVal, startTime + attack);
env.gain.setValueAtTime(gainVal, end - release);
env.gain.linearRampToValueAtTime(0, end);
osc.start(startTime);
osc.stop(end + 0.05);
} catch(e) {}
}
// Play noise burst (snare/hat)
function musicNoise(startTime, duration, gainVal=0.08, filterFreq=2000) {
try {
const ac = getMusicAC();
initMusicGain();
const bufSize = Math.floor(ac.sampleRate * duration);
const buf = ac.createBuffer(1, bufSize, ac.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ac.createBufferSource();
src.buffer = buf;
const filter = ac.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = filterFreq;
const env = ac.createGain();
src.connect(filter); filter.connect(env); env.connect(musicGain);
env.gain.setValueAtTime(gainVal, startTime);
env.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
src.start(startTime);
src.stop(startTime + duration);
} catch(e) {}
}
// Kick drum
function musicKick(startTime, gainVal=0.4) {
try {
const ac = getMusicAC();
initMusicGain();
const osc = ac.createOscillator();
const env = ac.createGain();
osc.connect(env); env.connect(musicGain);
osc.type = 'sine';
osc.frequency.setValueAtTime(150, startTime);
osc.frequency.exponentialRampToValueAtTime(30, startTime + 0.15);
env.gain.setValueAtTime(gainVal, startTime);
env.gain.exponentialRampToValueAtTime(0.001, startTime + 0.25);
osc.start(startTime); osc.stop(startTime + 0.3);
} catch(e) {}
}
function stopMusic() {
if (musicScheduler) { clearInterval(musicScheduler); musicScheduler = null; }
seqState = null;
currentTrack = null;
}
function startMusic() {
if (musicStarted) return;
musicStarted = true;
try {
const ac = getMusicAC();
if (ac.state === 'suspended') ac.resume();
initMusicGain();
playMenuMusic();
} catch(e) { musicStarted = false; }
}
// โโ MENU THEME โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Ambient Am โ F โ C โ Em, slow arp, bass pulse, hi-hat shimmer
function playMenuMusic() {
if (currentTrack === 'menu') return;
stopMusic();
currentTrack = 'menu';
const BPM = 90;
const BEAT = 60 / BPM; // seconds per beat
const BAR = BEAT * 4; // 4/4
const LOOP = BAR * 8; // 8-bar loop
// chord roots & notes per bar-pair
const CHORDS = [
['A3','C4','E4'], // bars 0-1: Am
['F3','A3','C4'], // bars 2-3: F
['C3','E3','G3'], // bars 4-5: C
['E3','G3','B3'], // bars 6-7: Em
];
const ARP = [
['A4','C5','E5','A5'],
['F4','A4','C5','F5'],
['C4','E4','G4','C5'],
['E4','G4','B4','E5'],
];
const BASS = ['A2','F2','C2','E2'];
const LOOKAHEAD = 0.12; // seconds ahead to schedule
const TICK = 50; // ms scheduler interval
const ac = getMusicAC();
let nextBeat = ac.currentTime + 0.05;
let beat = 0;
const totalBeats = 32; // 8 bars ร 4 beats
musicScheduler = setInterval(() => {
if (currentTrack !== 'menu') return;
const now = ac.currentTime;
while (nextBeat < now + LOOKAHEAD) {
const bar = Math.floor(beat / 4) % 8;
const chordIdx = Math.floor(bar / 2);
const beatInBar= beat % 4;
// Pad chord โ on beat 0 of each chord change
if (bar % 2 === 0 && beatInBar === 0) {
CHORDS[chordIdx].forEach(n => {
musicNote(midiNote(n), nextBeat, BAR * 1.8, 'triangle', 0.06, 0.5, 0.4);
});
}
// Bass โ on beat 0 of chord change
if (bar % 2 === 0 && beatInBar === 0) {
musicNote(midiNote(BASS[chordIdx]), nextBeat, BAR * 1.6, 'triangle', 0.12, 0.08, 0.3);
}
// Hi-hat โ every beat
musicNoise(nextBeat, 0.06, 0.03, 6000);
// Arp โ 4 notes per beat (16th notes)
const arpRow = ARP[chordIdx];
for (let i = 0; i < 4; i++) {
const t = nextBeat + i * (BEAT / 4);
musicNote(midiNote(arpRow[i]), t, BEAT * 0.18, 'sine', 0.05, 0.01, 0.08);
}
beat = (beat + 1) % totalBeats;
nextBeat += BEAT;
}
}, TICK);
}
// โโ BOSS THEME โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Panicky 178 BPM, driving kick/snare, alarm tritone, frantic lead
function playBossMusic() {
if (currentTrack === 'boss') return;
stopMusic();
currentTrack = 'boss';
const BPM = 178;
const BEAT = 60 / BPM;
const SIXTEENTH= BEAT / 4;
// Bass line notes (16th-note grid, null = rest)
const BASS_LINE = [
'A1','A1', null,'C2', 'E2','D2', null,'C2',
'A1',null, 'Bb1','A1','G1',null, 'F1','G1',
'A1',null, 'E2','F2', 'E2','D2', null,'A1',
'G1','A1', 'Bb1','B1',null,null, null,null,
];
// Lead melody (16th-note grid)
const LEAD_LINE = [
'A4','C5','E5','D5', 'C5',null,'A4','G4',
'A4','Bb4','A4','G4','F4',null,'G4','A4',
'E5','D5','C5','B4', 'A4',null,'C5','E5',
'F5','E5','D5','C5', 'B4','A4','G4','A4',
'A5',null,'G5','F5', 'E5','D5','C5','B4',
'A4',null,'G4','F4', 'E4',null,'G4','A4',
'A4','C5','Eb5','E5','G5','F5','E5','D5',
'C5','B4','Bb4','A4','G4',null,'A4','E4',
];
// Kick pattern (16th grid, 1=kick)
const KICK_PAT = [1,0,1,0, 0,1,0,0, 1,0,1,0, 0,1,0,0];
// Snare pattern
const SNARE_PAT= [0,0,0,1, 0,1,0,0, 0,0,1,0, 1,0,0,1];
// Hat pattern (32nds packed as 16th pairs)
const HAT_PAT = [1,1,0,1, 1,1,0,1, 1,0,1,1, 1,0,1,1];
const LOOKAHEAD = 0.12;
const TICK = 30;
const ac = getMusicAC();
let nextStep = ac.currentTime + 0.05;
let step = 0;
const bassLen = BASS_LINE.length;
const leadLen = LEAD_LINE.length;
const drumLen = KICK_PAT.length;
musicScheduler = setInterval(() => {
if (currentTrack !== 'boss') return;
const now = ac.currentTime;
while (nextStep < now + LOOKAHEAD) {
const di = step % drumLen;
const bi = step % bassLen;
const li = step % leadLen;
if (KICK_PAT[di]) musicKick(nextStep, 0.35);
if (SNARE_PAT[di]) musicNoise(nextStep, 0.1, 0.12, 1500);
if (HAT_PAT[di]) musicNoise(nextStep, 0.03, 0.05, 8000);
if (BASS_LINE[bi]) {
musicNote(midiNote(BASS_LINE[bi]), nextStep, SIXTEENTH * 0.8,
'sawtooth', 0.13, 0.005, 0.05);
}
if (LEAD_LINE[li]) {
musicNote(midiNote(LEAD_LINE[li]), nextStep, SIXTEENTH * 0.7,
'sawtooth', 0.08, 0.005, 0.04);
}
// Alarm tritone on specific steps
if (step % 8 === 0 && step > 15) {
const alarmFreq = (Math.floor(step / 8) % 2 === 0) ? midiNote('A4') : midiNote('Eb5');
musicNote(alarmFreq, nextStep, SIXTEENTH * 1.5, 'square', 0.06, 0.005, 0.05);
}
step++;
nextStep += SIXTEENTH;
}
}, TICK);
}
// โโ WIN MUSIC โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function playWinMusic() {
stopMusic();
currentTrack = 'win';
const ac = getMusicAC();
initMusicGain();
const t = ac.currentTime + 0.05;
const BPM = 120, BEAT = 60 / BPM;
// Triumphant major melody: C E G C B G E F A C B G E C
const mel = ['C5','E5','G5','C6','B5','G5','E5','F5','A5','C6','B5','A5','G5','C5'];
const dur = [1,1,1,2, 1,1,1,1, 1,2,1,1,1,3];
let cursor = t;
mel.forEach((n, i) => {
musicNote(midiNote(n), cursor, dur[i]*BEAT*0.85, 'triangle', 0.14, 0.02, 0.15);
cursor += dur[i] * BEAT;
});
// Harmony (thirds below)
const harm = ['E4','G4','B4','E5','D5','B4','G4','A4','C5','E5','D5','C5','B4','E4'];
cursor = t;
harm.forEach((n, i) => {
musicNote(midiNote(n), cursor, dur[i]*BEAT*0.8, 'sine', 0.07, 0.03, 0.2);
cursor += dur[i] * BEAT;
});
// Bass hits
const bass = ['C3','F3','C3','G3','C3','A3','F3','G3','C3'];
const bdur = [2, 2, 2, 2, 2, 2, 2, 2, 4 ];
cursor = t;
bass.forEach((n, i) => {
musicNote(midiNote(n), cursor, bdur[i]*BEAT*0.7, 'triangle', 0.16, 0.04, 0.2);
cursor += bdur[i] * BEAT;
});
// Tambourine
for (let i = 0; i < 32; i++) {
musicNoise(t + i * BEAT * 0.5, 0.06, 0.04, 5000);
}
// After win music ends, go back to menu music
setTimeout(() => {
if (currentTrack === 'win') { currentTrack = null; playMenuMusic(); }
}, cursor - ac.currentTime + 500);
}
// โโ LOSE MUSIC โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function playLoseMusic() {
stopMusic();
currentTrack = 'lose';
const ac = getMusicAC();
initMusicGain();
const t = ac.currentTime + 0.05;
const BPM = 60, BEAT = 60 / BPM;
// Descending minor: A G F E D C B A G F E A
const mel = ['A4','G4','F4','E4','D4','C4','B3','A3','G3','F3','E3','A2'];
const dur = [1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 2, 4 ];
let cursor = t;
mel.forEach((n, i) => {
musicNote(midiNote(n), cursor, dur[i]*BEAT*0.85, 'sine', 0.12, 0.06, 0.4);
cursor += dur[i] * BEAT;
});
// Cello harmony
const cello = ['C3','Bb2','Ab2','G2','F2','E2'];
const cdur = [2, 2, 2, 2, 4, 4 ];
cursor = t;
cello.forEach((n, i) => {
musicNote(midiNote(n), cursor, cdur[i]*BEAT*0.9, 'sawtooth', 0.06, 0.12, 0.5);
cursor += cdur[i] * BEAT;
});
// Pad chords
[['A2','C3','E3'],['D3','F3','A3'],['E2','G2','B2'],['A2','C3','E3']].forEach((ch, i) => {
ch.forEach(n => musicNote(midiNote(n), t + i*4*BEAT, 3.8*BEAT, 'triangle', 0.04, 0.35, 0.5));
});
// Sparse kick
for (let i = 0; i < 4; i++) {
musicKick(t + i * 4 * BEAT, 0.25);
}
}
// โโ MUSIC TRANSITIONS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function updateMusic() {
if (!musicStarted) return;
if (currentTrack === 'win' || currentTrack === 'lose') return;
if (state && (state.phase === 'gameover' || state.phase === 'win' || state.phase === 'bossdie')) return;
if (inMenu || (state && state.level < 6)) {
if (currentTrack !== 'menu') playMenuMusic();
} else if (state && state.level === 6) {
if (currentTrack !== 'boss') playBossMusic();
}
}
// โโ START โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
initStars();
state = freshState(1);
canvas.focus();
requestAnimationFrame(loop);
</script>
</body>
</html>Game Source: Star Blaster
Creator: MagicStar57
Libraries: none
Complexity: complex (1708 lines, 56.9 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: star-blaster-magicstar57" to link back to the original. Then publish at arcadelab.ai/publish.