Files
aharrison-fullerandClaude Opus 4.6 c55d7a7475 Add browser-based Doom clone with raycasting engine
Built a complete FPS game playable in the browser using vanilla JS and
HTML5 Canvas. Features a raycasting renderer with 8 textured wall types,
3 weapons (pistol, shotgun, plasma gun), 3 enemy types with AI
(imp, demon, baron), 3 levels of increasing difficulty, HUD with
minimap, pickups, doors, explosive barrels, and procedural audio.
Includes GitHub Actions workflow for GitHub Pages deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:27:43 +00:00

456 lines
16 KiB
JavaScript

// Main game controller
class Game {
constructor() {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.raycaster = new Raycaster(this.canvas);
this.player = new Player();
this.state = STATE_MENU;
this.currentLevel = 0;
this.entities = [];
this.enemies = [];
this.map = null;
this.message = '';
this.messageTimer = 0;
this.lastTime = 0;
this.minimapCanvas = document.getElementById('minimap-canvas');
this.minimapCtx = this.minimapCanvas.getContext('2d');
this.pointerLocked = false;
this.setupInput();
this.setupUI();
}
setupInput() {
document.addEventListener('keydown', (e) => {
this.player.keys[e.key.toLowerCase()] = true;
if (e.key === 'Escape') {
if (this.state === STATE_PLAYING) {
this.state = STATE_PAUSED;
document.exitPointerLock();
} else if (this.state === STATE_PAUSED) {
this.state = STATE_PLAYING;
this.canvas.requestPointerLock();
}
}
if (e.key.toLowerCase() === 'e' && this.state === STATE_PLAYING) {
const result = this.player.tryInteract(this.map);
if (result === 'door_opened') {
this.showMessage('Door opened');
} else if (result === 'exit_reached') {
this.levelComplete();
}
}
if (e.key === 'Enter' && this.state === STATE_GAME_OVER) {
this.restartLevel();
}
});
document.addEventListener('keyup', (e) => {
this.player.keys[e.key.toLowerCase()] = false;
});
document.addEventListener('mousemove', (e) => {
if (this.pointerLocked && this.state === STATE_PLAYING) {
this.player.mouseDX += e.movementX;
}
});
document.addEventListener('mousedown', (e) => {
if (e.button === 0) {
this.player.mouseDown = true;
if (this.state === STATE_PLAYING && !this.pointerLocked) {
this.canvas.requestPointerLock();
}
}
});
document.addEventListener('mouseup', (e) => {
if (e.button === 0) this.player.mouseDown = false;
});
document.addEventListener('pointerlockchange', () => {
this.pointerLocked = document.pointerLockElement === this.canvas;
if (!this.pointerLocked && this.state === STATE_PLAYING) {
this.state = STATE_PAUSED;
}
});
// Prevent context menu
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
setupUI() {
document.getElementById('btn-new-game').addEventListener('click', () => {
Audio.init();
Audio.resume();
this.startNewGame();
});
document.getElementById('btn-controls').addEventListener('click', () => {
document.getElementById('controls-info').classList.toggle('hidden');
});
document.getElementById('btn-next-level').addEventListener('click', () => {
this.nextLevel();
});
document.getElementById('btn-play-again').addEventListener('click', () => {
this.startNewGame();
});
}
startNewGame() {
this.player.fullReset();
this.currentLevel = 0;
this.loadLevel(0);
this.state = STATE_PLAYING;
document.getElementById('title-screen').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
document.getElementById('minimap-container').classList.remove('hidden');
document.getElementById('level-complete').classList.add('hidden');
document.getElementById('game-over').classList.add('hidden');
document.getElementById('game-won').classList.add('hidden');
this.canvas.requestPointerLock();
this.showMessage(Levels[0].name);
}
loadLevel(levelIndex) {
const level = Levels[levelIndex];
// Deep copy map so doors can be opened
this.map = level.map.map(row => [...row]);
this.player.x = level.playerStart.x;
this.player.y = level.playerStart.y;
this.player.angle = level.playerStart.angle;
this.player.kills = 0;
// Create entities
this.entities = [];
this.enemies = [];
for (const eDef of level.entities) {
if (eDef.type.startsWith('imp') || eDef.type.startsWith('demon') || eDef.type.startsWith('baron')) {
const enemy = new Enemy(eDef.type, eDef.x, eDef.y);
this.entities.push(enemy);
this.enemies.push(enemy);
} else if (eDef.type === ENTITY_BARREL || eDef.type === ENTITY_LAMP) {
this.entities.push(new Decoration(eDef.type, eDef.x, eDef.y));
} else {
this.entities.push(new Pickup(eDef.type, eDef.x, eDef.y));
}
}
// Setup minimap
this.minimapCanvas.width = this.map[0].length * 5;
this.minimapCanvas.height = this.map.length * 5;
}
restartLevel() {
this.player.health = 100;
this.player.armor = 0;
this.player.ammo = 50;
this.player.kills = 0;
this.loadLevel(this.currentLevel);
this.state = STATE_PLAYING;
document.getElementById('game-over').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
document.getElementById('minimap-container').classList.remove('hidden');
this.canvas.requestPointerLock();
this.showMessage(Levels[this.currentLevel].name);
}
levelComplete() {
this.state = STATE_LEVEL_COMPLETE;
document.exitPointerLock();
const level = Levels[this.currentLevel];
const totalEnemies = this.enemies.length;
const killPercent = totalEnemies > 0 ? Math.floor((this.player.kills / totalEnemies) * 100) : 100;
document.getElementById('level-stats').innerHTML =
`Kills: ${this.player.kills}/${totalEnemies} (${killPercent}%)<br>` +
`Health: ${this.player.health}%`;
document.getElementById('level-complete').classList.remove('hidden');
this.player.totalKills += this.player.kills;
}
nextLevel() {
this.currentLevel++;
if (this.currentLevel >= Levels.length) {
this.gameWon();
return;
}
this.loadLevel(this.currentLevel);
this.state = STATE_PLAYING;
document.getElementById('level-complete').classList.add('hidden');
this.canvas.requestPointerLock();
this.showMessage(Levels[this.currentLevel].name);
}
gameWon() {
this.state = STATE_GAME_WON;
document.getElementById('hud').classList.add('hidden');
document.getElementById('minimap-container').classList.add('hidden');
document.getElementById('level-complete').classList.add('hidden');
document.getElementById('final-stats').innerHTML =
`Total Kills: ${this.player.totalKills}<br>Score: ${this.player.score}`;
document.getElementById('game-won').classList.remove('hidden');
}
gameOver() {
this.state = STATE_GAME_OVER;
document.exitPointerLock();
document.getElementById('game-over').classList.remove('hidden');
}
showMessage(text) {
this.message = text;
this.messageTimer = 2000;
const el = document.getElementById('message-display');
el.textContent = text;
el.classList.remove('hidden');
}
update(dt) {
if (this.state !== STATE_PLAYING) return;
// Clamp dt to prevent huge jumps
dt = Math.min(dt, 50);
// Update player
this.player.update(dt, this.map);
// Handle shooting
if (this.player.mouseDown) {
const def = this.player.weaponSystem.currentDef;
if (def.autoFire || !this._wasFiring) {
const result = this.player.weaponSystem.fire(this.player, this.enemies, this.player.ammo);
if (result.ammoUsed > 0) {
this.player.ammo -= result.ammoUsed;
}
}
}
this._wasFiring = this.player.mouseDown;
// Update enemies
for (const enemy of this.enemies) {
const result = enemy.update(dt, this.player, this.map, this.enemies);
if (result && result.type === 'attack') {
this.player.takeDamage(result.damage);
this.showDamageOverlay();
}
}
// Check pickups
for (const entity of this.entities) {
if (!entity.active || entity.isEnemy || entity.isDecoration) continue;
const dx = entity.x - this.player.x;
const dy = entity.y - this.player.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.7) {
this.handlePickup(entity);
}
}
// Count kills
let killCount = 0;
for (const enemy of this.enemies) {
if (enemy.state === 'dead') killCount++;
}
this.player.kills = killCount;
// Check if player reached exit (by walking into it)
const cellX = Math.floor(this.player.x);
const cellY = Math.floor(this.player.y);
// Check adjacent cells for exit
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const cy = cellY + dy;
const cx = cellX + dx;
if (cy >= 0 && cy < this.map.length && cx >= 0 && cx < this.map[0].length) {
if (this.map[cy][cx] === WALL_EXIT) {
const distToExit = Math.sqrt((this.player.x - (cx + 0.5)) ** 2 + (this.player.y - (cy + 0.5)) ** 2);
if (distToExit < 1.2) {
// Check if player presses E near exit
}
}
}
}
}
// Message timer
if (this.messageTimer > 0) {
this.messageTimer -= dt;
if (this.messageTimer <= 0) {
document.getElementById('message-display').classList.add('hidden');
}
}
// Check death
if (this.player.health <= 0) {
this.gameOver();
}
// Update HUD
this.updateHUD();
}
handlePickup(pickup) {
let pickedUp = false;
switch (pickup.type) {
case ENTITY_PICKUP_HEALTH:
if (this.player.health < this.player.maxHealth) {
this.player.heal(25);
this.showMessage('+25 Health');
pickedUp = true;
}
break;
case ENTITY_PICKUP_ARMOR:
if (this.player.armor < this.player.maxArmor) {
this.player.addArmor(50);
this.showMessage('+50 Armor');
pickedUp = true;
}
break;
case ENTITY_PICKUP_AMMO:
if (this.player.ammo < this.player.maxAmmo) {
this.player.addAmmo(20);
this.showMessage('+20 Ammo');
pickedUp = true;
}
break;
case ENTITY_PICKUP_SHOTGUN:
this.player.weaponSystem.unlock('shotgun');
this.player.weaponSystem.switchTo('shotgun');
this.player.addAmmo(10);
this.showMessage('Got Shotgun!');
pickedUp = true;
break;
case ENTITY_PICKUP_PLASMA:
this.player.weaponSystem.unlock('plasma');
this.player.weaponSystem.switchTo('plasma');
this.player.addAmmo(30);
this.showMessage('Got Plasma Gun!');
pickedUp = true;
break;
}
if (pickedUp) {
pickup.active = false;
Audio.playPickup();
this.player.score += 50;
}
}
showDamageOverlay() {
const overlay = document.getElementById('damage-overlay');
overlay.classList.remove('hidden');
// Force reflow for animation restart
overlay.offsetHeight;
overlay.style.animation = 'none';
overlay.offsetHeight;
overlay.style.animation = '';
setTimeout(() => overlay.classList.add('hidden'), 300);
}
updateHUD() {
document.getElementById('health-value').textContent = this.player.health;
document.getElementById('armor-value').textContent = this.player.armor;
document.getElementById('ammo-value').textContent = this.player.ammo;
document.getElementById('kills-value').textContent = this.player.kills;
document.getElementById('weapon-name').textContent = this.player.weaponSystem.currentDef.name;
document.getElementById('health-bar').style.width = this.player.health + '%';
document.getElementById('armor-bar').style.width = this.player.armor + '%';
// Color health bar based on health
const hBar = document.getElementById('health-bar');
if (this.player.health > 60) {
hBar.style.background = 'linear-gradient(to right, #0a0, #0f0)';
} else if (this.player.health > 30) {
hBar.style.background = 'linear-gradient(to right, #aa0, #ff0)';
} else {
hBar.style.background = 'linear-gradient(to right, #a00, #f00)';
}
}
render() {
// Render 3D view
this.raycaster.render(this.player, this.map, this.entities);
// Draw weapon on top
this.player.weaponSystem.drawWeapon(this.ctx);
// Draw minimap
this.drawMinimap();
}
drawMinimap() {
const ctx = this.minimapCtx;
const scale = 5;
ctx.clearRect(0, 0, this.minimapCanvas.width, this.minimapCanvas.height);
// Draw map
for (let y = 0; y < this.map.length; y++) {
for (let x = 0; x < this.map[y].length; x++) {
if (this.map[y][x] > 0) {
switch (this.map[y][x]) {
case WALL_DOOR: ctx.fillStyle = '#a80'; break;
case WALL_EXIT: ctx.fillStyle = '#f00'; break;
default: ctx.fillStyle = '#666'; break;
}
ctx.fillRect(x * scale, y * scale, scale, scale);
} else {
ctx.fillStyle = '#222';
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
// Draw enemies
for (const enemy of this.enemies) {
if (enemy.state === 'dead') continue;
ctx.fillStyle = '#f00';
ctx.fillRect(enemy.x * scale - 1, enemy.y * scale - 1, 3, 3);
}
// Draw player
ctx.fillStyle = '#0f0';
ctx.fillRect(this.player.x * scale - 2, this.player.y * scale - 2, 4, 4);
// Draw player direction
ctx.strokeStyle = '#0f0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(this.player.x * scale, this.player.y * scale);
ctx.lineTo(
this.player.x * scale + Math.cos(this.player.angle) * 10,
this.player.y * scale + Math.sin(this.player.angle) * 10
);
ctx.stroke();
}
gameLoop(timestamp) {
const dt = this.lastTime ? timestamp - this.lastTime : 16;
this.lastTime = timestamp;
if (this.state === STATE_PLAYING || this.state === STATE_PAUSED) {
this.update(dt);
this.render();
}
requestAnimationFrame((t) => this.gameLoop(t));
}
start() {
requestAnimationFrame((t) => this.gameLoop(t));
}
}