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
+348 -218
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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>
body {
margin: 0;
@@ -37,9 +37,10 @@
<h1 style="margin-bottom: 5px; text-shadow: 0 0 10px #0f0;">DEFENDER</h1>
<canvas id="game" width="800" height="600"></canvas>
<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">
<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>
@@ -50,24 +51,55 @@
// Game constants
const WIDTH = 800;
const HEIGHT = 600;
const WORLD_WIDTH = 4096; // wrapping world
const WORLD_WIDTH = 4096;
const GROUND_Y = HEIGHT - 80;
// Game state
let gameState = 'PLAYING'; // PLAYING, PAUSED, GAME_OVER
let gameState = 'PLAYING';
let score = 0;
let lives = 3;
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
let player = {
x: 200,
y: HEIGHT / 2,
vx: 0,
vy: 0,
size: 12,
facing: 1 // 1 right, -1 left
x: 200, y: HEIGHT / 2, vx: 0, vy: 0, size: 12, facing: 1
};
// Input
@@ -77,6 +109,8 @@
if (e.key === ' ' && gameState === 'PLAYING') e.preventDefault();
if (e.key.toLowerCase() === 'p') togglePause();
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);
@@ -84,179 +118,275 @@
let bullets = [];
const MAX_BULLETS = 30;
// Simple terrain (procedural stub - sine based for demo)
function getTerrainHeight(worldX) {
const x = worldX % WORLD_WIDTH;
return GROUND_Y + Math.sin(x / 80) * 25 + Math.sin(x / 30) * 12;
}
// Particles stub
let particles = [];
function emitParticles(x, y, count, color = '#ff0') {
// Humans
let humans = [];
function spawnHumans(count) {
humans = [];
for (let i = 0; i < count; i++) {
particles.push({
x, y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4 - 1,
life: 20 + Math.random() * 15,
color
humans.push({
x: 300 + i * 250 % (WORLD_WIDTH - 400),
y: GROUND_Y - 15,
size: 6,
carried: false,
falling: false,
vy: 0
});
}
}
// Simple enemy stub (one lander-like)
let enemies = [{
x: 600,
y: 180,
vx: 1.2,
vy: 0.3,
type: 'lander',
size: 10
}];
// Enemies
let enemies = [];
function spawnWave() {
enemies = [];
const numLanders = 4 + wave * 2;
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',
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() {
if (gameState === 'PLAYING') gameState = 'PAUSED';
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() {
score = 0;
lives = 3;
wave = 1;
smartBombs = 3;
cameraX = 0;
player.x = 200;
player.y = HEIGHT / 2;
player.vx = 0;
player.vy = 0;
bullets = [];
enemies = [{ x: 600, y: 180, vx: 1.2, vy: 0.3, type: 'lander', size: 10 }];
particles = [];
player.x = 200; player.y = HEIGHT / 2; player.vx = player.vy = 0;
bullets = []; particles = [];
spawnHumans(8);
spawnWave();
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() {
if (gameState !== 'PLAYING') return;
initAudio();
// Player input & physics (simple momentum)
const thrust = 0.25;
const maxSpeed = 6;
if (keys['ArrowLeft'] || keys['a'] || keys['A']) {
player.vx -= thrust;
player.facing = -1;
}
if (keys['ArrowRight'] || keys['d'] || keys['D']) {
player.vx += thrust;
player.facing = 1;
}
// Player physics
const thrust = 0.28;
const maxSpeed = 6.5;
if (keys['ArrowLeft'] || keys['a'] || keys['A']) { player.vx -= thrust; 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['ArrowDown'] || keys['s'] || keys['S']) player.vy += thrust;
// Apply velocity with drag
player.vx *= 0.96;
player.vy *= 0.96;
player.vx *= 0.95;
player.vy *= 0.95;
player.vx = Math.max(-maxSpeed, Math.min(maxSpeed, player.vx));
player.vy = Math.max(-maxSpeed, Math.min(maxSpeed, player.vy));
player.x += player.vx;
player.y += player.vy;
// World wrap for player
if (player.x < 0) player.x += WORLD_WIDTH;
if (player.x > WORLD_WIDTH) player.x -= WORLD_WIDTH;
// Camera follows player with smoothing
const targetCam = player.x - WIDTH * 0.4;
cameraX += (targetCam - cameraX) * 0.1;
const targetCam = player.x - WIDTH * 0.38;
cameraX += (targetCam - cameraX) * 0.12;
// Clamp Y
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
if (keys[' '] && bullets.length < MAX_BULLETS) {
const bx = player.x + player.facing * 15;
bullets.push({
x: bx,
y: player.y,
vx: player.facing * 12 + player.vx * 0.5,
vy: 0,
life: 45
});
keys[' '] = false; // semi auto for stub
emitParticles(bx, player.y, 3, '#0ff');
const bx = player.x + player.facing * 18;
bullets.push({x: bx, y: player.y, vx: player.facing * 13 + player.vx * 0.4, vy: 0, life: 50});
keys[' '] = false;
playSound('laser');
emitParticles(bx, player.y, 4, '#0ff');
}
// Update bullets
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx;
b.y += b.vy;
b.life--;
if (b.life <= 0 || b.x < cameraX - 50 || b.x > cameraX + WIDTH + 50) {
bullets.splice(i, 1);
b.x += b.vx; b.y += b.vy; b.life--;
if (b.life <= 0 || b.x < cameraX - 100 || b.x > cameraX + WIDTH + 100) 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) {
e.x += e.vx;
e.y += e.vy;
// Simple bounce at edges of view or wrap
if (e.x < cameraX - 100) e.x = cameraX + WIDTH + 50;
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;
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;
if (Math.random() < 0.03) {
// Drop bomb (simple projectile)
enemies.push({x: e.x, y: e.y + 10, vx: 0, vy: 3.5, type: 'bomb', size: 5});
}
}
// 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--) {
const b = bullets[i];
for (let j = enemies.length - 1; j >= 0; j--) {
const e = enemies[j];
const dx = b.x - e.x;
const dy = b.y - e.y;
if (dx*dx + dy*dy < (e.size + 4) * (e.size + 4)) {
// Hit!
if (Math.hypot(b.x - e.x, b.y - e.y) < e.size + 5) {
bullets.splice(i, 1);
enemies.splice(j, 1);
score += 150;
emitParticles(e.x, e.y, 12, '#f80');
// Respawn enemy later or add new
if (enemies.length < 3) {
enemies.push({
x: cameraX + WIDTH + Math.random() * 200,
y: 100 + Math.random() * 200,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 1.5,
type: 'lander',
size: 10
});
score += e.type === 'lander' ? 150 : 250;
playSound('explosion');
emitParticles(e.x, e.y, 18, '#f80');
if (e.targetHuman) {
const h = e.targetHuman;
h.carried = false;
h.falling = true;
h.vy = -1.5;
e.targetHuman = null;
}
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--) {
const e = enemies[j];
const dx = player.x - e.x;
const dy = player.y - e.y;
if (dx*dx + dy*dy < (player.size + e.size) * (player.size + e.size) * 0.8) {
if (Math.hypot(player.x - e.x, player.y - e.y) < player.size + e.size * 0.8) {
enemies.splice(j, 1);
lives--;
emitParticles(player.x, player.y, 20, '#f00');
if (lives <= 0) {
gameState = 'GAME_OVER';
} else {
// brief reset pos
player.x = cameraX + 150;
playSound('explosion');
emitParticles(player.x, player.y, 25, '#f00');
if (lives <= 0) gameState = 'GAME_OVER';
else {
player.x = cameraX + 180;
player.y = HEIGHT / 2;
player.vx = player.vy = 0;
}
@@ -267,18 +397,22 @@
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.1; // gravity-ish
p.life--;
p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
// Simple terrain collision for player (stub)
const terrainAtPlayer = getTerrainHeight(player.x);
if (player.y > terrainAtPlayer - 5) {
player.y = terrainAtPlayer - 5;
player.vy = Math.min(0, player.vy * 0.5);
// Wave advance (simple)
if (enemies.length === 0 && humans.length > 0) {
wave++;
smartBombs = Math.min(5, smartBombs + 1);
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.fillRect(0, 0, WIDTH, HEIGHT);
// Stars (simple, static for perf)
// Stars
ctx.fillStyle = '#fff';
for (let i = 0; i < 80; i++) {
const sx = (i * 37 + 17) % WIDTH;
const sy = (i * 23 + 41) % (GROUND_Y - 60);
ctx.fillRect(sx, sy, 1, 1);
for (let i = 0; i < 90; i++) {
const sx = (i * 37 + (cameraX * 0.1 | 0)) % WIDTH;
const sy = (i * 23) % (GROUND_Y - 80);
ctx.fillRect(sx, sy, 1.5, 1.5);
}
// Draw terrain (from camera perspective)
// Terrain
ctx.strokeStyle = '#0a0';
ctx.lineWidth = 3;
ctx.lineWidth = 4;
ctx.beginPath();
let first = true;
for (let sx = 0; sx <= WIDTH + 20; sx += 8) {
const worldX = cameraX + sx;
const ty = getTerrainHeight(worldX);
if (first) {
ctx.moveTo(sx, ty);
first = false;
} else {
ctx.lineTo(sx, ty);
}
for (let sx = 0; sx <= WIDTH + 40; sx += 6) {
const wx = cameraX + sx;
const ty = getTerrainHeight(wx);
if (first) { ctx.moveTo(sx, ty); first = false; } else { ctx.lineTo(sx, ty); }
}
ctx.lineTo(WIDTH + 20, HEIGHT);
ctx.lineTo(-20, HEIGHT);
ctx.lineTo(WIDTH + 40, HEIGHT);
ctx.lineTo(0, HEIGHT);
ctx.closePath();
ctx.fillStyle = '#020';
ctx.fillStyle = '#030';
ctx.fill();
ctx.stroke();
// Draw enemies
ctx.fillStyle = '#f80';
for (let e of enemies) {
const sx = e.x - cameraX;
if (sx < -50 || sx > WIDTH + 50) continue;
ctx.beginPath();
ctx.arc(sx, e.y, e.size, 0, Math.PI * 2);
ctx.fill();
// simple legs or detail
ctx.fillRect(sx - 3, e.y + 6, 6, 4);
// Humans
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
}
// Draw bullets
// Enemies
for (let e of enemies) {
const sx = e.x - cameraX;
if (sx < -60 || sx > WIDTH + 60) continue;
if (e.type === 'lander' || e.type === 'bomber') {
ctx.fillStyle = e.type === 'lander' ? '#f80' : '#a0f';
ctx.beginPath();
ctx.arc(sx, e.y, e.size, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(sx - 6, e.y + 6, 12, 5);
} else if (e.type === 'bomb') {
ctx.fillStyle = '#f44';
ctx.fillRect(sx - 3, e.y - 3, 6, 8);
}
}
// Bullets
ctx.fillStyle = '#0ff';
for (let b of bullets) {
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;
ctx.save();
ctx.translate(sx, player.y);
if (player.facing < 0) ctx.scale(-1, 1);
ctx.fillStyle = '#0f0';
ctx.beginPath();
ctx.moveTo(12, 0);
ctx.lineTo(-8, -7);
ctx.lineTo(-8, 7);
ctx.moveTo(14, 0);
ctx.lineTo(-10, -9);
ctx.lineTo(-6, 0);
ctx.lineTo(-10, 9);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
// thrust flame
if (keys['ArrowLeft'] || keys['ArrowRight'] || keys['a'] || keys['d']) {
ctx.fillStyle = '#f80';
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
// Thrust
if (Math.abs(player.vx) > 0.5 || Math.abs(player.vy) > 0.5) {
ctx.fillStyle = '#fa0';
ctx.beginPath();
ctx.moveTo(-8, 0);
ctx.lineTo(-18, -3);
ctx.lineTo(-18, 3);
ctx.closePath();
ctx.moveTo(-11, 0);
ctx.lineTo(-22, Math.random() * 4 - 2);
ctx.lineTo(-22, Math.random() * 4 - 2);
ctx.fill();
}
ctx.restore();
// 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.fillRect(p.x - cameraX, p.y, 2, 2);
ctx.fillRect(p.x - cameraX, p.y, 2.5, 2.5);
}
ctx.globalAlpha = 1;
// HUD
ctx.fillStyle = '#0f0';
ctx.font = 'bold 18px monospace';
ctx.fillText(`SCORE: ${score.toString().padStart(6, '0')}`, 20, 30);
ctx.fillText(`HI: 000000`, 20, 55);
ctx.fillText(`WAVE ${wave}`, WIDTH - 120, 30);
ctx.font = 'bold 20px monospace';
ctx.fillText(`SCORE ${score.toString().padStart(6,'0')}`, 25, 35);
ctx.fillText(`WAVE ${wave}`, WIDTH - 130, 35);
ctx.fillText(`BOMBS ${smartBombs}`, WIDTH - 130, 62);
// Lives
for (let l = 0; l < lives; l++) {
ctx.save();
ctx.translate(WIDTH - 30 - l * 22, 55);
ctx.scale(0.7, 0.7);
ctx.beginPath();
ctx.moveTo(12, 0);
ctx.lineTo(-8, -7);
ctx.lineTo(-8, 7);
ctx.closePath();
ctx.fill();
ctx.translate(WIDTH - 40 - l*26, 35);
ctx.scale(0.65, 0.65);
ctx.fillStyle = '#0f0';
ctx.beginPath(); ctx.moveTo(14,0); ctx.lineTo(-10,-9); ctx.lineTo(-6,0); ctx.lineTo(-10,9); ctx.closePath(); ctx.fill();
ctx.restore();
}
// Radar stub (top bar)
// Radar
ctx.fillStyle = '#112';
ctx.fillRect(0, 0, WIDTH, 18);
ctx.strokeStyle = '#0f0';
ctx.strokeRect(0, 0, WIDTH, 18);
// blips
ctx.fillRect(0, 0, WIDTH, 22);
ctx.strokeStyle = '#0f0'; ctx.lineWidth = 1; ctx.strokeRect(0,0,WIDTH,22);
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);
ctx.fillStyle = '#f80';
for (let e of enemies) {
ctx.fillStyle = '#f80';
ctx.fillRect( (e.x % WORLD_WIDTH) / WORLD_WIDTH * WIDTH - 1 , 7, 3, 3);
const rx = ((e.x % WORLD_WIDTH) / WORLD_WIDTH) * WIDTH;
ctx.fillRect(rx - 2, 9, 4, 4);
}
// Overlays
// States
if (gameState === 'PAUSED') {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
ctx.fillStyle = '#0f0';
ctx.font = 'bold 48px monospace';
ctx.fillText('PAUSED', WIDTH/2 - 90, HEIGHT/2);
ctx.font = '16px monospace';
ctx.fillText('Press P to resume', WIDTH/2 - 90, HEIGHT/2 + 40);
ctx.fillStyle = 'rgba(0,20,0,0.75)';
ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.fillStyle = '#0f0'; ctx.font = 'bold 52px monospace';
ctx.fillText('PAUSED', WIDTH/2-110, HEIGHT/2);
}
if (gameState === 'GAME_OVER') {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
ctx.fillStyle = '#f00';
ctx.font = 'bold 56px monospace';
ctx.fillText('GAME OVER', WIDTH/2 - 160, HEIGHT/2 - 20);
ctx.fillStyle = '#0f0';
ctx.font = '20px monospace';
ctx.fillText(`FINAL SCORE: ${score}`, WIDTH/2 - 110, HEIGHT/2 + 30);
ctx.fillText('Press R to restart', WIDTH/2 - 100, HEIGHT/2 + 70);
ctx.fillStyle = 'rgba(40,0,0,0.8)';
ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.fillStyle = '#f66'; ctx.font = 'bold 52px monospace';
ctx.fillText('GAME OVER', WIDTH/2-165, HEIGHT/2-30);
ctx.fillStyle = '#0f0'; ctx.font = '22px monospace';
ctx.fillText(`FINAL: ${score}`, WIDTH/2-80, HEIGHT/2+20);
ctx.fillText('R to Restart', WIDTH/2-75, HEIGHT/2+55);
}
// Debug
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.fillText(`camX:${cameraX.toFixed(0)} px:${player.x.toFixed(0)} vy:${player.vy.toFixed(1)}`, 20, HEIGHT - 10);
ctx.fillStyle = '#555'; ctx.font = '11px monospace';
ctx.fillText(`cam:${cameraX|0} lives:${lives} wave:${wave}`, 20, HEIGHT-12);
}
function gameLoop() {
@@ -437,9 +565,11 @@
requestAnimationFrame(gameLoop);
}
// Boot
console.log('%c[Defender] MVP stub initialized. See docs/REQUIREMENTS.md for full spec.', 'color:#0f0');
emitParticles(player.x, player.y, 8, '#0f0'); // initial flair
// Init
console.log('%c[Defender UAT v0.2] Ready! Test flight, rescue, bombs, hyperspace, waves. Feedback to issues.', 'color:#0f0; font-weight:bold');
spawnHumans(8);
spawnWave();
emitParticles(player.x, player.y, 12, '#0f0');
gameLoop();
</script>
</body>