Enhance index.html to UAT-ready v0.2: humans + rescue/catch mechanics, smart bomb, hyperspace with risk, bombers, wave progression, basic audio (Web Audio synth), improved terrain/AI/particles/HUD. Full Phase 1 core loop playable for testing.

This commit is contained in:
Ty
2026-06-20 01:29:27 +00:00
parent 68e31b6290
commit 375c3e02ec
+339 -209
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Defender Browser Clone - MVP Stub</title> <title>Defender Browser Clone - UAT Ready v0.2</title>
<style> <style>
body { body {
margin: 0; margin: 0;
@@ -37,9 +37,10 @@
<h1 style="margin-bottom: 5px; text-shadow: 0 0 10px #0f0;">DEFENDER</h1> <h1 style="margin-bottom: 5px; text-shadow: 0 0 10px #0f0;">DEFENDER</h1>
<canvas id="game" width="800" height="600"></canvas> <canvas id="game" width="800" height="600"></canvas>
<div class="info"> <div class="info">
<p><strong>MVP Stub v0.1</strong> - Basic ship flight, laser, scrolling terrain stub. Expand per docs/REQUIREMENTS.md</p> <p><strong>UAT Ready v0.2</strong> - Core loop + humans/rescue, smart bomb, hyperspace, multiple enemies, waves, audio stubs, polish. Test per docs/REQUIREMENTS.md!</p>
<div class="controls"> <div class="controls">
<strong>Controls:</strong> Arrow Keys = Thrust ship &nbsp;|&nbsp; SPACE = Fire laser &nbsp;|&nbsp; P = Pause &nbsp;|&nbsp; R = Restart (on game over) <strong>Controls:</strong> Arrows/WASD = Thrust &nbsp;|&nbsp; SPACE = Laser &nbsp;|&nbsp; SHIFT = Smart Bomb &nbsp;|&nbsp; H = Hyperspace &nbsp;|&nbsp; P = Pause &nbsp;|&nbsp; R = Restart<br>
<em>Rescue: Shoot lander carrying human → catch falling human!</em>
</div> </div>
</div> </div>
@@ -50,24 +51,55 @@
// Game constants // Game constants
const WIDTH = 800; const WIDTH = 800;
const HEIGHT = 600; const HEIGHT = 600;
const WORLD_WIDTH = 4096; // wrapping world const WORLD_WIDTH = 4096;
const GROUND_Y = HEIGHT - 80; const GROUND_Y = HEIGHT - 80;
// Game state // Game state
let gameState = 'PLAYING'; // PLAYING, PAUSED, GAME_OVER let gameState = 'PLAYING';
let score = 0; let score = 0;
let lives = 3; let lives = 3;
let wave = 1; let wave = 1;
let cameraX = 0; // world scroll offset let smartBombs = 3;
let cameraX = 0;
// Audio context (simple synth)
let audioCtx;
function initAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playSound(type) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
if (type === 'laser') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(800, audioCtx.currentTime);
gain.gain.value = 0.1;
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.1);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
} else if (type === 'explosion') {
osc.type = 'square';
osc.frequency.setValueAtTime(120, audioCtx.currentTime);
gain.gain.value = 0.3;
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.4);
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
} else if (type === 'catch') {
osc.type = 'sine';
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
gain.gain.value = 0.2;
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.3);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
}
}
// Player // Player
let player = { let player = {
x: 200, x: 200, y: HEIGHT / 2, vx: 0, vy: 0, size: 12, facing: 1
y: HEIGHT / 2,
vx: 0,
vy: 0,
size: 12,
facing: 1 // 1 right, -1 left
}; };
// Input // Input
@@ -77,6 +109,8 @@
if (e.key === ' ' && gameState === 'PLAYING') e.preventDefault(); if (e.key === ' ' && gameState === 'PLAYING') e.preventDefault();
if (e.key.toLowerCase() === 'p') togglePause(); if (e.key.toLowerCase() === 'p') togglePause();
if (e.key.toLowerCase() === 'r' && gameState === 'GAME_OVER') restart(); if (e.key.toLowerCase() === 'r' && gameState === 'GAME_OVER') restart();
if (e.key.toLowerCase() === 'h' && gameState === 'PLAYING') hyperspace();
if ((e.key === 'Shift' || e.key.toLowerCase() === 'b') && gameState === 'PLAYING' && smartBombs > 0) smartBomb();
}); });
window.addEventListener('keyup', e => keys[e.key] = false); window.addEventListener('keyup', e => keys[e.key] = false);
@@ -84,179 +118,275 @@
let bullets = []; let bullets = [];
const MAX_BULLETS = 30; const MAX_BULLETS = 30;
// Simple terrain (procedural stub - sine based for demo) // Humans
function getTerrainHeight(worldX) { let humans = [];
const x = worldX % WORLD_WIDTH; function spawnHumans(count) {
return GROUND_Y + Math.sin(x / 80) * 25 + Math.sin(x / 30) * 12; humans = [];
}
// Particles stub
let particles = [];
function emitParticles(x, y, count, color = '#ff0') {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
particles.push({ humans.push({
x, y, x: 300 + i * 250 % (WORLD_WIDTH - 400),
vx: (Math.random() - 0.5) * 4, y: GROUND_Y - 15,
vy: (Math.random() - 0.5) * 4 - 1, size: 6,
life: 20 + Math.random() * 15, carried: false,
color falling: false,
vy: 0
}); });
} }
} }
// Simple enemy stub (one lander-like) // Enemies
let enemies = [{ let enemies = [];
x: 600, function spawnWave() {
y: 180, enemies = [];
vx: 1.2, const numLanders = 4 + wave * 2;
vy: 0.3, for (let i = 0; i < numLanders; i++) {
enemies.push({
x: cameraX + WIDTH + Math.random() * 300,
y: 100 + Math.random() * 250,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 1.5,
type: 'lander', type: 'lander',
size: 10 size: 10,
}]; targetHuman: null
});
}
// Add a bomber occasionally
if (wave > 1 && Math.random() > 0.5) {
enemies.push({
x: cameraX + WIDTH * 0.6,
y: 150,
vx: 2,
vy: 0,
type: 'bomber',
size: 12
});
}
}
// Particles
let particles = [];
function emitParticles(x, y, count, color = '#ff0', spread = 4) {
for (let i = 0; i < count; i++) {
particles.push({
x, y,
vx: (Math.random() - 0.5) * spread,
vy: (Math.random() - 0.5) * spread - 1,
life: 20 + Math.random() * 20,
color
});
}
}
function togglePause() { function togglePause() {
if (gameState === 'PLAYING') gameState = 'PAUSED'; if (gameState === 'PLAYING') gameState = 'PAUSED';
else if (gameState === 'PAUSED') gameState = 'PLAYING'; else if (gameState === 'PAUSED') gameState = 'PLAYING';
} }
function hyperspace() {
initAudio();
player.x = cameraX + 50 + Math.random() * (WIDTH - 100);
player.y = 80 + Math.random() * (GROUND_Y - 160);
if (Math.random() < 0.15) { // risk
lives--;
emitParticles(player.x, player.y, 25, '#f00');
if (lives <= 0) gameState = 'GAME_OVER';
} else {
emitParticles(player.x, player.y, 15, '#0ff');
}
}
function smartBomb() {
if (smartBombs <= 0) return;
initAudio();
smartBombs--;
emitParticles(player.x, player.y - 50, 40, '#ff0', 8);
playSound('explosion');
// Clear on-screen enemies
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
const sx = e.x - cameraX;
if (sx > -50 && sx < WIDTH + 50) {
enemies.splice(i, 1);
score += 100;
emitParticles(e.x, e.y, 15, '#f80');
}
}
}
function restart() { function restart() {
score = 0; score = 0;
lives = 3; lives = 3;
wave = 1; wave = 1;
smartBombs = 3;
cameraX = 0; cameraX = 0;
player.x = 200; player.x = 200; player.y = HEIGHT / 2; player.vx = player.vy = 0;
player.y = HEIGHT / 2; bullets = []; particles = [];
player.vx = 0; spawnHumans(8);
player.vy = 0; spawnWave();
bullets = [];
enemies = [{ x: 600, y: 180, vx: 1.2, vy: 0.3, type: 'lander', size: 10 }];
particles = [];
gameState = 'PLAYING'; gameState = 'PLAYING';
} }
function getTerrainHeight(worldX) {
const x = worldX % WORLD_WIDTH;
return GROUND_Y + Math.sin(x / 80) * 25 + Math.sin(x / 30) * 12 + Math.sin(x / 12) * 6;
}
function update() { function update() {
if (gameState !== 'PLAYING') return; if (gameState !== 'PLAYING') return;
initAudio();
// Player input & physics (simple momentum) // Player physics
const thrust = 0.25; const thrust = 0.28;
const maxSpeed = 6; const maxSpeed = 6.5;
if (keys['ArrowLeft'] || keys['a'] || keys['A']) { if (keys['ArrowLeft'] || keys['a'] || keys['A']) { player.vx -= thrust; player.facing = -1; }
player.vx -= thrust; if (keys['ArrowRight'] || keys['d'] || keys['D']) { player.vx += thrust; player.facing = 1; }
player.facing = -1;
}
if (keys['ArrowRight'] || keys['d'] || keys['D']) {
player.vx += thrust;
player.facing = 1;
}
if (keys['ArrowUp'] || keys['w'] || keys['W']) player.vy -= thrust; if (keys['ArrowUp'] || keys['w'] || keys['W']) player.vy -= thrust;
if (keys['ArrowDown'] || keys['s'] || keys['S']) player.vy += thrust; if (keys['ArrowDown'] || keys['s'] || keys['S']) player.vy += thrust;
// Apply velocity with drag player.vx *= 0.95;
player.vx *= 0.96; player.vy *= 0.95;
player.vy *= 0.96;
player.vx = Math.max(-maxSpeed, Math.min(maxSpeed, player.vx)); player.vx = Math.max(-maxSpeed, Math.min(maxSpeed, player.vx));
player.vy = Math.max(-maxSpeed, Math.min(maxSpeed, player.vy)); player.vy = Math.max(-maxSpeed, Math.min(maxSpeed, player.vy));
player.x += player.vx; player.x += player.vx;
player.y += player.vy; player.y += player.vy;
// World wrap for player
if (player.x < 0) player.x += WORLD_WIDTH; if (player.x < 0) player.x += WORLD_WIDTH;
if (player.x > WORLD_WIDTH) player.x -= WORLD_WIDTH; if (player.x > WORLD_WIDTH) player.x -= WORLD_WIDTH;
// Camera follows player with smoothing const targetCam = player.x - WIDTH * 0.38;
const targetCam = player.x - WIDTH * 0.4; cameraX += (targetCam - cameraX) * 0.12;
cameraX += (targetCam - cameraX) * 0.1;
// Clamp Y
if (player.y < 60) player.y = 60; if (player.y < 60) player.y = 60;
if (player.y > GROUND_Y - 20) player.y = GROUND_Y - 20; const terrainAtPlayer = getTerrainHeight(player.x);
if (player.y > terrainAtPlayer - 8) {
player.y = terrainAtPlayer - 8;
player.vy = Math.min(0, player.vy * 0.4);
}
// Fire // Fire
if (keys[' '] && bullets.length < MAX_BULLETS) { if (keys[' '] && bullets.length < MAX_BULLETS) {
const bx = player.x + player.facing * 15; const bx = player.x + player.facing * 18;
bullets.push({ bullets.push({x: bx, y: player.y, vx: player.facing * 13 + player.vx * 0.4, vy: 0, life: 50});
x: bx, keys[' '] = false;
y: player.y, playSound('laser');
vx: player.facing * 12 + player.vx * 0.5, emitParticles(bx, player.y, 4, '#0ff');
vy: 0,
life: 45
});
keys[' '] = false; // semi auto for stub
emitParticles(bx, player.y, 3, '#0ff');
} }
// Update bullets // Update bullets
for (let i = bullets.length - 1; i >= 0; i--) { for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i]; const b = bullets[i];
b.x += b.vx; b.x += b.vx; b.y += b.vy; b.life--;
b.y += b.vy; if (b.life <= 0 || b.x < cameraX - 100 || b.x > cameraX + WIDTH + 100) bullets.splice(i, 1);
b.life--; }
if (b.life <= 0 || b.x < cameraX - 50 || b.x > cameraX + WIDTH + 50) {
bullets.splice(i, 1); // Update humans
for (let h of humans) {
if (h.falling) {
h.vy += 0.18;
h.y += h.vy;
if (h.y > getTerrainHeight(h.x) - 5) {
h.y = getTerrainHeight(h.x) - 5;
h.falling = false;
h.vy = 0;
}
} }
} }
// Update enemies (very basic) // Update enemies
for (let e of enemies) { for (let e of enemies) {
if (e.type === 'lander') {
// Seek human or player
let target = {x: player.x, y: player.y};
for (let h of humans) {
if (!h.carried && !h.falling && Math.abs(h.x - e.x) < Math.abs(target.x - e.x)) {
target = h;
}
}
e.vx += (target.x - e.x) * 0.008;
e.vy += (target.y - e.y) * 0.012;
// Abduct?
for (let hi = 0; hi < humans.length; hi++) {
const h = humans[hi];
if (!h.carried && !h.falling && Math.hypot(e.x - h.x, e.y - h.y) < 18) {
h.carried = true;
e.targetHuman = h;
break;
}
}
if (e.targetHuman) {
const h = e.targetHuman;
h.x = e.x; h.y = e.y - 12;
if (h.y < 40) {
// Lost human
humans = humans.filter(hh => hh !== h);
e.targetHuman = null;
score = Math.max(0, score - 200);
}
}
} else if (e.type === 'bomber') {
e.x += e.vx; e.x += e.vx;
e.y += e.vy; if (Math.random() < 0.03) {
// Simple bounce at edges of view or wrap // Drop bomb (simple projectile)
if (e.x < cameraX - 100) e.x = cameraX + WIDTH + 50; enemies.push({x: e.x, y: e.y + 10, vx: 0, vy: 3.5, type: 'bomb', size: 5});
if (e.y < 80) e.vy = Math.abs(e.vy);
if (e.y > GROUND_Y - 30) e.vy = -Math.abs(e.vy);
// Very dumb "AI" - drift toward player sometimes
if (Math.random() < 0.02) {
e.vx += (player.x - e.x) * 0.01;
e.vy += (player.y - e.y) * 0.01;
} }
} }
// General movement clamp
e.vx *= 0.97; e.vy *= 0.97;
e.x += e.vx; e.y += e.vy;
if (e.y < 70) e.vy = 1;
if (e.y > GROUND_Y - 25) e.vy = -1.5;
}
// Collision: bullets vs enemies // Collisions: bullets vs enemies/humans
for (let i = bullets.length - 1; i >= 0; i--) { for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i]; const b = bullets[i];
for (let j = enemies.length - 1; j >= 0; j--) { for (let j = enemies.length - 1; j >= 0; j--) {
const e = enemies[j]; const e = enemies[j];
const dx = b.x - e.x; if (Math.hypot(b.x - e.x, b.y - e.y) < e.size + 5) {
const dy = b.y - e.y;
if (dx*dx + dy*dy < (e.size + 4) * (e.size + 4)) {
// Hit!
bullets.splice(i, 1); bullets.splice(i, 1);
enemies.splice(j, 1); enemies.splice(j, 1);
score += 150; score += e.type === 'lander' ? 150 : 250;
emitParticles(e.x, e.y, 12, '#f80'); playSound('explosion');
// Respawn enemy later or add new emitParticles(e.x, e.y, 18, '#f80');
if (enemies.length < 3) { if (e.targetHuman) {
enemies.push({ const h = e.targetHuman;
x: cameraX + WIDTH + Math.random() * 200, h.carried = false;
y: 100 + Math.random() * 200, h.falling = true;
vx: (Math.random() - 0.5) * 2, h.vy = -1.5;
vy: (Math.random() - 0.5) * 1.5, e.targetHuman = null;
type: 'lander',
size: 10
});
} }
break; break;
} }
} }
} }
// Player vs enemy collision (simple) // Player catch humans
for (let hi = humans.length - 1; hi >= 0; hi--) {
const h = humans[hi];
if (h.falling && Math.hypot(player.x - h.x, player.y - h.y) < 18) {
// Caught!
humans.splice(hi, 1);
score += 800;
playSound('catch');
emitParticles(player.x, player.y, 10, '#0f0');
// Respawn human later or count rescued
if (humans.length < 4) spawnHumans(2); // replenish
break;
}
}
// Player-enemy collisions
for (let j = enemies.length - 1; j >= 0; j--) { for (let j = enemies.length - 1; j >= 0; j--) {
const e = enemies[j]; const e = enemies[j];
const dx = player.x - e.x; if (Math.hypot(player.x - e.x, player.y - e.y) < player.size + e.size * 0.8) {
const dy = player.y - e.y;
if (dx*dx + dy*dy < (player.size + e.size) * (player.size + e.size) * 0.8) {
enemies.splice(j, 1); enemies.splice(j, 1);
lives--; lives--;
emitParticles(player.x, player.y, 20, '#f00'); playSound('explosion');
if (lives <= 0) { emitParticles(player.x, player.y, 25, '#f00');
gameState = 'GAME_OVER'; if (lives <= 0) gameState = 'GAME_OVER';
} else { else {
// brief reset pos player.x = cameraX + 180;
player.x = cameraX + 150;
player.y = HEIGHT / 2; player.y = HEIGHT / 2;
player.vx = player.vy = 0; player.vx = player.vy = 0;
} }
@@ -267,18 +397,22 @@
// Update particles // Update particles
for (let i = particles.length - 1; i >= 0; i--) { for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]; const p = particles[i];
p.x += p.vx; p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.life--;
p.y += p.vy;
p.vy += 0.1; // gravity-ish
p.life--;
if (p.life <= 0) particles.splice(i, 1); if (p.life <= 0) particles.splice(i, 1);
} }
// Simple terrain collision for player (stub) // Wave advance (simple)
const terrainAtPlayer = getTerrainHeight(player.x); if (enemies.length === 0 && humans.length > 0) {
if (player.y > terrainAtPlayer - 5) { wave++;
player.y = terrainAtPlayer - 5; smartBombs = Math.min(5, smartBombs + 1);
player.vy = Math.min(0, player.vy * 0.5); spawnWave();
spawnHumans(6 + wave);
score += 1000;
}
// Respawn logic stub
if (Math.random() < 0.008 && enemies.length < 8) {
spawnWave(); // occasional extra
} }
} }
@@ -286,149 +420,143 @@
ctx.fillStyle = '#000011'; ctx.fillStyle = '#000011';
ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Stars (simple, static for perf) // Stars
ctx.fillStyle = '#fff'; ctx.fillStyle = '#fff';
for (let i = 0; i < 80; i++) { for (let i = 0; i < 90; i++) {
const sx = (i * 37 + 17) % WIDTH; const sx = (i * 37 + (cameraX * 0.1 | 0)) % WIDTH;
const sy = (i * 23 + 41) % (GROUND_Y - 60); const sy = (i * 23) % (GROUND_Y - 80);
ctx.fillRect(sx, sy, 1, 1); ctx.fillRect(sx, sy, 1.5, 1.5);
} }
// Draw terrain (from camera perspective) // Terrain
ctx.strokeStyle = '#0a0'; ctx.strokeStyle = '#0a0';
ctx.lineWidth = 3; ctx.lineWidth = 4;
ctx.beginPath(); ctx.beginPath();
let first = true; let first = true;
for (let sx = 0; sx <= WIDTH + 20; sx += 8) { for (let sx = 0; sx <= WIDTH + 40; sx += 6) {
const worldX = cameraX + sx; const wx = cameraX + sx;
const ty = getTerrainHeight(worldX); const ty = getTerrainHeight(wx);
if (first) { if (first) { ctx.moveTo(sx, ty); first = false; } else { ctx.lineTo(sx, ty); }
ctx.moveTo(sx, ty);
first = false;
} else {
ctx.lineTo(sx, ty);
} }
} ctx.lineTo(WIDTH + 40, HEIGHT);
ctx.lineTo(WIDTH + 20, HEIGHT); ctx.lineTo(0, HEIGHT);
ctx.lineTo(-20, HEIGHT);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = '#020'; ctx.fillStyle = '#030';
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
// Draw enemies // Humans
ctx.fillStyle = '#f80'; ctx.fillStyle = '#88f';
for (let h of humans) {
const sx = h.x - cameraX;
if (sx < -30 || sx > WIDTH + 30) continue;
ctx.fillRect(sx - 3, h.y - 8, 6, 10);
ctx.fillRect(sx - 5, h.y - 4, 10, 4); // simple body
}
// Enemies
for (let e of enemies) { for (let e of enemies) {
const sx = e.x - cameraX; const sx = e.x - cameraX;
if (sx < -50 || sx > WIDTH + 50) continue; if (sx < -60 || sx > WIDTH + 60) continue;
if (e.type === 'lander' || e.type === 'bomber') {
ctx.fillStyle = e.type === 'lander' ? '#f80' : '#a0f';
ctx.beginPath(); ctx.beginPath();
ctx.arc(sx, e.y, e.size, 0, Math.PI * 2); ctx.arc(sx, e.y, e.size, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// simple legs or detail ctx.fillRect(sx - 6, e.y + 6, 12, 5);
ctx.fillRect(sx - 3, e.y + 6, 6, 4); } else if (e.type === 'bomb') {
ctx.fillStyle = '#f44';
ctx.fillRect(sx - 3, e.y - 3, 6, 8);
}
} }
// Draw bullets // Bullets
ctx.fillStyle = '#0ff'; ctx.fillStyle = '#0ff';
for (let b of bullets) { for (let b of bullets) {
const sx = b.x - cameraX; const sx = b.x - cameraX;
ctx.fillRect(sx - 2, b.y - 1, 6, 2); ctx.fillRect(sx - 3, b.y - 1.5, 9, 3);
} }
// Draw player ship (triangle) // Player ship
const sx = player.x - cameraX; const sx = player.x - cameraX;
ctx.save(); ctx.save();
ctx.translate(sx, player.y); ctx.translate(sx, player.y);
if (player.facing < 0) ctx.scale(-1, 1); if (player.facing < 0) ctx.scale(-1, 1);
ctx.fillStyle = '#0f0'; ctx.fillStyle = '#0f0';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(12, 0); ctx.moveTo(14, 0);
ctx.lineTo(-8, -7); ctx.lineTo(-10, -9);
ctx.lineTo(-8, 7); ctx.lineTo(-6, 0);
ctx.lineTo(-10, 9);
ctx.closePath(); ctx.closePath();
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.lineWidth = 1; // Thrust
ctx.stroke(); if (Math.abs(player.vx) > 0.5 || Math.abs(player.vy) > 0.5) {
// thrust flame ctx.fillStyle = '#fa0';
if (keys['ArrowLeft'] || keys['ArrowRight'] || keys['a'] || keys['d']) {
ctx.fillStyle = '#f80';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(-8, 0); ctx.moveTo(-11, 0);
ctx.lineTo(-18, -3); ctx.lineTo(-22, Math.random() * 4 - 2);
ctx.lineTo(-18, 3); ctx.lineTo(-22, Math.random() * 4 - 2);
ctx.closePath();
ctx.fill(); ctx.fill();
} }
ctx.restore(); ctx.restore();
// Particles // Particles
for (let p of particles) { for (let p of particles) {
ctx.globalAlpha = Math.max(0, p.life / 30); ctx.globalAlpha = Math.max(0.2, p.life / 35);
ctx.fillStyle = p.color; ctx.fillStyle = p.color;
ctx.fillRect(p.x - cameraX, p.y, 2, 2); ctx.fillRect(p.x - cameraX, p.y, 2.5, 2.5);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
// HUD // HUD
ctx.fillStyle = '#0f0'; ctx.fillStyle = '#0f0';
ctx.font = 'bold 18px monospace'; ctx.font = 'bold 20px monospace';
ctx.fillText(`SCORE: ${score.toString().padStart(6, '0')}`, 20, 30); ctx.fillText(`SCORE ${score.toString().padStart(6,'0')}`, 25, 35);
ctx.fillText(`HI: 000000`, 20, 55); ctx.fillText(`WAVE ${wave}`, WIDTH - 130, 35);
ctx.fillText(`WAVE ${wave}`, WIDTH - 120, 30); ctx.fillText(`BOMBS ${smartBombs}`, WIDTH - 130, 62);
// Lives // Lives
for (let l = 0; l < lives; l++) { for (let l = 0; l < lives; l++) {
ctx.save(); ctx.save();
ctx.translate(WIDTH - 30 - l * 22, 55); ctx.translate(WIDTH - 40 - l*26, 35);
ctx.scale(0.7, 0.7); ctx.scale(0.65, 0.65);
ctx.beginPath(); ctx.fillStyle = '#0f0';
ctx.moveTo(12, 0); ctx.beginPath(); ctx.moveTo(14,0); ctx.lineTo(-10,-9); ctx.lineTo(-6,0); ctx.lineTo(-10,9); ctx.closePath(); ctx.fill();
ctx.lineTo(-8, -7);
ctx.lineTo(-8, 7);
ctx.closePath();
ctx.fill();
ctx.restore(); ctx.restore();
} }
// Radar stub (top bar) // Radar
ctx.fillStyle = '#112'; ctx.fillStyle = '#112';
ctx.fillRect(0, 0, WIDTH, 18); ctx.fillRect(0, 0, WIDTH, 22);
ctx.strokeStyle = '#0f0'; ctx.strokeStyle = '#0f0'; ctx.lineWidth = 1; ctx.strokeRect(0,0,WIDTH,22);
ctx.strokeRect(0, 0, WIDTH, 18);
// blips
ctx.fillStyle = '#0f0'; ctx.fillStyle = '#0f0';
ctx.fillRect( (player.x % WORLD_WIDTH) / WORLD_WIDTH * WIDTH - 2 , 6, 5, 5); ctx.fillRect( ((player.x % WORLD_WIDTH) / WORLD_WIDTH) * WIDTH - 3, 8, 7, 6);
for (let e of enemies) {
ctx.fillStyle = '#f80'; ctx.fillStyle = '#f80';
ctx.fillRect( (e.x % WORLD_WIDTH) / WORLD_WIDTH * WIDTH - 1 , 7, 3, 3); for (let e of enemies) {
const rx = ((e.x % WORLD_WIDTH) / WORLD_WIDTH) * WIDTH;
ctx.fillRect(rx - 2, 9, 4, 4);
} }
// Overlays // States
if (gameState === 'PAUSED') { if (gameState === 'PAUSED') {
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillStyle = 'rgba(0,20,0,0.75)';
ctx.fillRect(0,0,WIDTH,HEIGHT); ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.fillStyle = '#0f0'; ctx.fillStyle = '#0f0'; ctx.font = 'bold 52px monospace';
ctx.font = 'bold 48px monospace'; ctx.fillText('PAUSED', WIDTH/2-110, HEIGHT/2);
ctx.fillText('PAUSED', WIDTH/2 - 90, HEIGHT/2);
ctx.font = '16px monospace';
ctx.fillText('Press P to resume', WIDTH/2 - 90, HEIGHT/2 + 40);
} }
if (gameState === 'GAME_OVER') { if (gameState === 'GAME_OVER') {
ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillStyle = 'rgba(40,0,0,0.8)';
ctx.fillRect(0,0,WIDTH,HEIGHT); ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.fillStyle = '#f00'; ctx.fillStyle = '#f66'; ctx.font = 'bold 52px monospace';
ctx.font = 'bold 56px monospace'; ctx.fillText('GAME OVER', WIDTH/2-165, HEIGHT/2-30);
ctx.fillText('GAME OVER', WIDTH/2 - 160, HEIGHT/2 - 20); ctx.fillStyle = '#0f0'; ctx.font = '22px monospace';
ctx.fillStyle = '#0f0'; ctx.fillText(`FINAL: ${score}`, WIDTH/2-80, HEIGHT/2+20);
ctx.font = '20px monospace'; ctx.fillText('R to Restart', WIDTH/2-75, HEIGHT/2+55);
ctx.fillText(`FINAL SCORE: ${score}`, WIDTH/2 - 110, HEIGHT/2 + 30);
ctx.fillText('Press R to restart', WIDTH/2 - 100, HEIGHT/2 + 70);
} }
// Debug ctx.fillStyle = '#555'; ctx.font = '11px monospace';
ctx.fillStyle = '#555'; ctx.fillText(`cam:${cameraX|0} lives:${lives} wave:${wave}`, 20, HEIGHT-12);
ctx.font = '10px monospace';
ctx.fillText(`camX:${cameraX.toFixed(0)} px:${player.x.toFixed(0)} vy:${player.vy.toFixed(1)}`, 20, HEIGHT - 10);
} }
function gameLoop() { function gameLoop() {
@@ -437,9 +565,11 @@
requestAnimationFrame(gameLoop); requestAnimationFrame(gameLoop);
} }
// Boot // Init
console.log('%c[Defender] MVP stub initialized. See docs/REQUIREMENTS.md for full spec.', 'color:#0f0'); console.log('%c[Defender UAT v0.2] Ready! Test flight, rescue, bombs, hyperspace, waves. Feedback to issues.', 'color:#0f0; font-weight:bold');
emitParticles(player.x, player.y, 8, '#0f0'); // initial flair spawnHumans(8);
spawnWave();
emitParticles(player.x, player.y, 12, '#0f0');
gameLoop(); gameLoop();
</script> </script>
</body> </body>