Add initial index.html MVP stub: playable basic ship with momentum thrust, laser fire, simple wrapping terrain, one enemy type, particles, radar, HUD, pause/gameover states. Foundation for Phase 1 per REQUIREMENTS.md
This commit is contained in:
+446
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Defender Browser Clone - MVP Stub</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
color: #0f0;
|
||||
font-family: 'Courier New', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
canvas {
|
||||
border: 2px solid #0f0;
|
||||
image-rendering: crisp-edges;
|
||||
box-shadow: 0 0 20px #0f0;
|
||||
}
|
||||
.info {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
}
|
||||
.controls {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<div class="controls">
|
||||
<strong>Controls:</strong> Arrow Keys = Thrust ship | SPACE = Fire laser | P = Pause | R = Restart (on game over)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
|
||||
// Game constants
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
const WORLD_WIDTH = 4096; // wrapping world
|
||||
const GROUND_Y = HEIGHT - 80;
|
||||
|
||||
// Game state
|
||||
let gameState = 'PLAYING'; // PLAYING, PAUSED, GAME_OVER
|
||||
let score = 0;
|
||||
let lives = 3;
|
||||
let wave = 1;
|
||||
let cameraX = 0; // world scroll offset
|
||||
|
||||
// Player
|
||||
let player = {
|
||||
x: 200,
|
||||
y: HEIGHT / 2,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: 12,
|
||||
facing: 1 // 1 right, -1 left
|
||||
};
|
||||
|
||||
// Input
|
||||
const keys = {};
|
||||
window.addEventListener('keydown', e => {
|
||||
keys[e.key] = true;
|
||||
if (e.key === ' ' && gameState === 'PLAYING') e.preventDefault();
|
||||
if (e.key.toLowerCase() === 'p') togglePause();
|
||||
if (e.key.toLowerCase() === 'r' && gameState === 'GAME_OVER') restart();
|
||||
});
|
||||
window.addEventListener('keyup', e => keys[e.key] = false);
|
||||
|
||||
// Bullets
|
||||
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') {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Simple enemy stub (one lander-like)
|
||||
let enemies = [{
|
||||
x: 600,
|
||||
y: 180,
|
||||
vx: 1.2,
|
||||
vy: 0.3,
|
||||
type: 'lander',
|
||||
size: 10
|
||||
}];
|
||||
|
||||
function togglePause() {
|
||||
if (gameState === 'PLAYING') gameState = 'PAUSED';
|
||||
else if (gameState === 'PAUSED') gameState = 'PLAYING';
|
||||
}
|
||||
|
||||
function restart() {
|
||||
score = 0;
|
||||
lives = 3;
|
||||
wave = 1;
|
||||
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 = [];
|
||||
gameState = 'PLAYING';
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (gameState !== 'PLAYING') return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
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 = 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;
|
||||
|
||||
// Clamp Y
|
||||
if (player.y < 60) player.y = 60;
|
||||
if (player.y > GROUND_Y - 20) player.y = GROUND_Y - 20;
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Update enemies (very basic)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Collision: bullets vs enemies
|
||||
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!
|
||||
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
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Player vs enemy collision (simple)
|
||||
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) {
|
||||
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;
|
||||
player.y = HEIGHT / 2;
|
||||
player.vx = player.vy = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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--;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.fillStyle = '#000011';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Stars (simple, static for perf)
|
||||
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);
|
||||
}
|
||||
|
||||
// Draw terrain (from camera perspective)
|
||||
ctx.strokeStyle = '#0a0';
|
||||
ctx.lineWidth = 3;
|
||||
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);
|
||||
}
|
||||
}
|
||||
ctx.lineTo(WIDTH + 20, HEIGHT);
|
||||
ctx.lineTo(-20, HEIGHT);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = '#020';
|
||||
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);
|
||||
}
|
||||
|
||||
// Draw bullets
|
||||
ctx.fillStyle = '#0ff';
|
||||
for (let b of bullets) {
|
||||
const sx = b.x - cameraX;
|
||||
ctx.fillRect(sx - 2, b.y - 1, 6, 2);
|
||||
}
|
||||
|
||||
// Draw player ship (triangle)
|
||||
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.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.beginPath();
|
||||
ctx.moveTo(-8, 0);
|
||||
ctx.lineTo(-18, -3);
|
||||
ctx.lineTo(-18, 3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Particles
|
||||
for (let p of particles) {
|
||||
ctx.globalAlpha = Math.max(0, p.life / 30);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(p.x - cameraX, p.y, 2, 2);
|
||||
}
|
||||
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);
|
||||
// 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.restore();
|
||||
}
|
||||
|
||||
// Radar stub (top bar)
|
||||
ctx.fillStyle = '#112';
|
||||
ctx.fillRect(0, 0, WIDTH, 18);
|
||||
ctx.strokeStyle = '#0f0';
|
||||
ctx.strokeRect(0, 0, WIDTH, 18);
|
||||
// blips
|
||||
ctx.fillStyle = '#0f0';
|
||||
ctx.fillRect( (player.x % WORLD_WIDTH) / WORLD_WIDTH * WIDTH - 2 , 6, 5, 5);
|
||||
for (let e of enemies) {
|
||||
ctx.fillStyle = '#f80';
|
||||
ctx.fillRect( (e.x % WORLD_WIDTH) / WORLD_WIDTH * WIDTH - 1 , 7, 3, 3);
|
||||
}
|
||||
|
||||
// Overlays
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
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
|
||||
gameLoop();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user