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:
Ty
2026-06-20 01:21:14 +00:00
parent 7f506259ad
commit 68e31b6290
+446
View File
@@ -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 &nbsp;|&nbsp; SPACE = Fire laser &nbsp;|&nbsp; P = Pause &nbsp;|&nbsp; 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>