From c55d7a747555b220dccafc0bbb369797e505a1d6 Mon Sep 17 00:00:00 2001 From: Adam Harrison-Fuller Date: Mon, 16 Mar 2026 21:27:43 +0000 Subject: [PATCH] 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 --- .github/workflows/deploy.yml | 34 +++ .gitignore | 4 + index.html | 94 ++++++++ js/audio.js | 253 +++++++++++++++++++ js/constants.js | 45 ++++ js/enemies.js | 300 +++++++++++++++++++++++ js/game.js | 455 +++++++++++++++++++++++++++++++++++ js/levels.js | 177 ++++++++++++++ js/main.js | 10 + js/player.js | 177 ++++++++++++++ js/raycaster.js | 240 ++++++++++++++++++ js/sprites.js | 400 ++++++++++++++++++++++++++++++ js/textures.js | 324 +++++++++++++++++++++++++ js/weapons.js | 321 ++++++++++++++++++++++++ styles.css | 289 ++++++++++++++++++++++ 15 files changed, 3123 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 index.html create mode 100644 js/audio.js create mode 100644 js/constants.js create mode 100644 js/enemies.js create mode 100644 js/game.js create mode 100644 js/levels.js create mode 100644 js/main.js create mode 100644 js/player.js create mode 100644 js/raycaster.js create mode 100644 js/sprites.js create mode 100644 js/textures.js create mode 100644 js/weapons.js create mode 100644 styles.css diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..42c9214 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,34 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: '.' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f63c8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.swp +*.swo +*~ diff --git a/index.html b/index.html new file mode 100644 index 0000000..7e0b127 --- /dev/null +++ b/index.html @@ -0,0 +1,94 @@ + + + + + + DOOM Clone + + + +
+ +
+

DOOM

+

CLONE

+

A Browser-Based FPS

+ + +
+ + + + + + +
+ + + + + + + + + + + + + diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..d59e131 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,253 @@ +// Procedural audio using Web Audio API +const Audio = { + ctx: null, + enabled: true, + + init() { + try { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } catch (e) { + this.enabled = false; + } + }, + + resume() { + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + }, + + // Pistol shot - short, sharp + playPistol() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const noise = this.createNoise(0.08); + + osc.type = 'square'; + osc.frequency.setValueAtTime(150, now); + osc.frequency.exponentialRampToValueAtTime(50, now + 0.08); + + gain.gain.setValueAtTime(0.3, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1); + + osc.connect(gain); + gain.connect(ctx.destination); + noise.connect(ctx.destination); + + osc.start(now); + osc.stop(now + 0.1); + }, + + // Shotgun blast - loud, wide + playShotgun() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const noise = this.createNoise(0.2); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(100, now); + osc.frequency.exponentialRampToValueAtTime(30, now + 0.15); + + gain.gain.setValueAtTime(0.4, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2); + + osc.connect(gain); + gain.connect(ctx.destination); + noise.connect(ctx.destination); + + osc.start(now); + osc.stop(now + 0.2); + }, + + // Plasma shot - sci-fi energy + playPlasma() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(800, now); + osc.frequency.exponentialRampToValueAtTime(200, now + 0.15); + + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(1200, now); + osc2.frequency.exponentialRampToValueAtTime(300, now + 0.12); + + gain.gain.setValueAtTime(0.2, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); + + osc.connect(gain); + osc2.connect(gain); + gain.connect(ctx.destination); + + osc.start(now); + osc.stop(now + 0.15); + osc2.start(now); + osc2.stop(now + 0.15); + }, + + // Enemy hurt + playEnemyHurt() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(200, now); + osc.frequency.exponentialRampToValueAtTime(80, now + 0.15); + + gain.gain.setValueAtTime(0.15, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.15); + }, + + // Enemy death + playEnemyDeath() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(300, now); + osc.frequency.exponentialRampToValueAtTime(30, now + 0.4); + + gain.gain.setValueAtTime(0.2, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.4); + }, + + // Player hurt + playPlayerHurt() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'square'; + osc.frequency.setValueAtTime(120, now); + osc.frequency.exponentialRampToValueAtTime(60, now + 0.2); + + gain.gain.setValueAtTime(0.2, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.2); + }, + + // Pickup sound + playPickup() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(400, now); + osc.frequency.setValueAtTime(600, now + 0.05); + osc.frequency.setValueAtTime(800, now + 0.1); + + gain.gain.setValueAtTime(0.15, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.15); + }, + + // Door open + playDoorOpen() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(80, now); + osc.frequency.linearRampToValueAtTime(120, now + 0.3); + + gain.gain.setValueAtTime(0.1, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.3); + }, + + // Explosion + playExplosion() { + if (!this.enabled || !this.ctx) return; + const ctx = this.ctx; + const now = ctx.currentTime; + + const noise = this.createNoise(0.4); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(60, now); + osc.frequency.exponentialRampToValueAtTime(20, now + 0.4); + + gain.gain.setValueAtTime(0.3, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); + + osc.connect(gain); + gain.connect(ctx.destination); + noise.connect(ctx.destination); + + osc.start(now); + osc.stop(now + 0.4); + }, + + createNoise(duration) { + const ctx = this.ctx; + const bufferSize = ctx.sampleRate * duration; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * Math.max(0, 1 - i / bufferSize); + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const gain = ctx.createGain(); + gain.gain.value = 0.15; + source.connect(gain); + source.start(); + return gain; + } +}; diff --git a/js/constants.js b/js/constants.js new file mode 100644 index 0000000..7c0465b --- /dev/null +++ b/js/constants.js @@ -0,0 +1,45 @@ +// Game constants +const SCREEN_WIDTH = 640; +const SCREEN_HEIGHT = 480; +const TILE_SIZE = 64; +const FOV = Math.PI / 3; // 60 degrees +const HALF_FOV = FOV / 2; +const NUM_RAYS = SCREEN_WIDTH; +const DELTA_ANGLE = FOV / NUM_RAYS; +const MAX_DEPTH = 20; +const WALL_HEIGHT_SCALE = TILE_SIZE * 3; +const PLAYER_SPEED = 3.0; +const PLAYER_ROT_SPEED = 0.003; +const PLAYER_SIZE = 0.3; +const MOUSE_SENSITIVITY = 0.003; + +// Wall types +const WALL_EMPTY = 0; +const WALL_STONE = 1; +const WALL_BRICK = 2; +const WALL_METAL = 3; +const WALL_TECH = 4; +const WALL_DOOR = 5; +const WALL_EXIT = 6; +const WALL_WOOD = 7; +const WALL_HELL = 8; + +// Entity types +const ENTITY_ENEMY_IMP = 'imp'; +const ENTITY_ENEMY_DEMON = 'demon'; +const ENTITY_ENEMY_BARON = 'baron'; +const ENTITY_PICKUP_HEALTH = 'health'; +const ENTITY_PICKUP_ARMOR = 'armor'; +const ENTITY_PICKUP_AMMO = 'ammo'; +const ENTITY_PICKUP_SHOTGUN = 'shotgun_pickup'; +const ENTITY_PICKUP_PLASMA = 'plasma_pickup'; +const ENTITY_BARREL = 'barrel'; +const ENTITY_LAMP = 'lamp'; + +// Game states +const STATE_MENU = 'menu'; +const STATE_PLAYING = 'playing'; +const STATE_PAUSED = 'paused'; +const STATE_LEVEL_COMPLETE = 'level_complete'; +const STATE_GAME_OVER = 'game_over'; +const STATE_GAME_WON = 'game_won'; diff --git a/js/enemies.js b/js/enemies.js new file mode 100644 index 0000000..61cebd9 --- /dev/null +++ b/js/enemies.js @@ -0,0 +1,300 @@ +// Enemy definitions and AI +const EnemyDefs = { + [ENTITY_ENEMY_IMP]: { + health: 40, + speed: 1.2, + damage: 8, + attackRange: 8, + attackRate: 1500, + sprite: 'imp', + deadSprite: 'deadImp', + score: 100, + sightRange: 12, + }, + [ENTITY_ENEMY_DEMON]: { + health: 80, + speed: 1.8, + damage: 15, + attackRange: 2.5, + attackRate: 1000, + sprite: 'demon', + deadSprite: 'deadDemon', + score: 200, + sightRange: 10, + }, + [ENTITY_ENEMY_BARON]: { + health: 200, + speed: 1.0, + damage: 25, + attackRange: 10, + attackRate: 2000, + sprite: 'baron', + deadSprite: 'deadBaron', + score: 500, + sightRange: 15, + } +}; + +class Enemy { + constructor(type, x, y) { + this.type = type; + this.x = x; + this.y = y; + const def = EnemyDefs[type]; + this.health = def.health; + this.maxHealth = def.health; + this.speed = def.speed; + this.damage = def.damage; + this.attackRange = def.attackRange; + this.attackRate = def.attackRate; + this.sightRange = def.sightRange; + this.score = def.score; + this.active = true; + this.state = 'idle'; // idle, chase, attack, hurt, dead + this.lastAttackTime = 0; + this.hurtTimer = 0; + this.deathTimer = 0; + this.spriteCanvas = Sprites[def.sprite]; + this.deadSpriteCanvas = Sprites[def.deadSprite]; + this.angle = Math.random() * Math.PI * 2; + this.alertTimer = 0; + this.moveTimer = 0; + this.isEnemy = true; + } + + takeDamage(amount) { + if (this.state === 'dead') return; + this.health -= amount; + this.hurtTimer = 150; + this.state = 'chase'; // Always chase when hit + + if (this.health <= 0) { + this.health = 0; + this.state = 'dead'; + this.deathTimer = 500; + Audio.playEnemyDeath(); + } else { + Audio.playEnemyHurt(); + } + } + + update(dt, player, map, enemies) { + if (this.state === 'dead') { + if (this.deathTimer > 0) { + this.deathTimer -= dt; + if (this.deathTimer <= 0) { + // Switch to dead sprite + this.spriteCanvas = this.deadSpriteCanvas; + } + } + return; + } + + if (this.hurtTimer > 0) { + this.hurtTimer -= dt; + } + + const dx = player.x - this.x; + const dy = player.y - this.y; + const distToPlayer = Math.sqrt(dx * dx + dy * dy); + const angleToPlayer = Math.atan2(dy, dx); + + // Check line of sight + const canSeePlayer = this.hasLineOfSight(player, map) && distToPlayer < this.sightRange; + + switch (this.state) { + case 'idle': + // Wander slightly + this.moveTimer += dt; + if (this.moveTimer > 2000) { + this.angle = Math.random() * Math.PI * 2; + this.moveTimer = 0; + } + // Move slowly in current direction + this.tryMove(Math.cos(this.angle) * this.speed * 0.2 * dt / 1000, + Math.sin(this.angle) * this.speed * 0.2 * dt / 1000, map, enemies); + + if (canSeePlayer) { + this.state = 'chase'; + } + break; + + case 'chase': + this.angle = angleToPlayer; + + if (distToPlayer > this.attackRange) { + // Move toward player + const moveSpeed = this.speed * dt / 1000; + const moveX = Math.cos(angleToPlayer) * moveSpeed; + const moveY = Math.sin(angleToPlayer) * moveSpeed; + this.tryMove(moveX, moveY, map, enemies); + } else { + this.state = 'attack'; + } + + // Lose interest if can't see player for a while + if (!canSeePlayer) { + this.alertTimer += dt; + if (this.alertTimer > 5000) { + this.state = 'idle'; + this.alertTimer = 0; + } + } else { + this.alertTimer = 0; + } + break; + + case 'attack': + this.angle = angleToPlayer; + + if (distToPlayer > this.attackRange * 1.2) { + this.state = 'chase'; + break; + } + + const now = performance.now(); + if (now - this.lastAttackTime >= this.attackRate) { + this.lastAttackTime = now; + // Attack player + if (canSeePlayer) { + return { type: 'attack', damage: this.damage, enemy: this }; + } + } + break; + } + return null; + } + + tryMove(moveX, moveY, map, enemies) { + // Check wall collision + const newX = this.x + moveX; + const newY = this.y + moveY; + const margin = 0.3; + + // Check map bounds + const mapH = map.length; + const mapW = map[0].length; + + // Check X movement + const cellX = Math.floor(newX); + const cellY = Math.floor(this.y); + if (cellX >= 0 && cellX < mapW && cellY >= 0 && cellY < mapH && + map[cellY][cellX] === 0) { + // Check margin + const cXp = Math.floor(newX + margin); + const cXn = Math.floor(newX - margin); + const okXp = cXp >= 0 && cXp < mapW && map[cellY][cXp] === 0; + const okXn = cXn >= 0 && cXn < mapW && map[cellY][cXn] === 0; + if (okXp && okXn) this.x = newX; + } + + // Check Y movement + const cellX2 = Math.floor(this.x); + const cellY2 = Math.floor(newY); + if (cellX2 >= 0 && cellX2 < mapW && cellY2 >= 0 && cellY2 < mapH && + map[cellY2][cellX2] === 0) { + const cYp = Math.floor(newY + margin); + const cYn = Math.floor(newY - margin); + const okYp = cYp >= 0 && cYp < mapH && map[cYp][cellX2] === 0; + const okYn = cYn >= 0 && cYn < mapH && map[cYn][cellX2] === 0; + if (okYp && okYn) this.y = newY; + } + + // Avoid other enemies + for (const other of enemies) { + if (other === this || other.state === 'dead') continue; + const edx = other.x - this.x; + const edy = other.y - this.y; + const eDist = Math.sqrt(edx * edx + edy * edy); + if (eDist < 0.6) { + this.x -= edx * 0.05; + this.y -= edy * 0.05; + } + } + } + + hasLineOfSight(player, map) { + const dx = player.x - this.x; + const dy = player.y - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const steps = Math.floor(dist * 3); + + if (steps <= 0) return true; + + const stepX = dx / steps; + const stepY = dy / steps; + + for (let i = 1; i < steps; i++) { + const checkX = Math.floor(this.x + stepX * i); + const checkY = Math.floor(this.y + stepY * i); + if (checkY >= 0 && checkY < map.length && checkX >= 0 && checkX < map[0].length) { + const cell = map[checkY][checkX]; + if (cell > 0 && cell !== WALL_DOOR) return false; + } + } + return true; + } +} + +// Pickup entity +class Pickup { + constructor(type, x, y) { + this.type = type; + this.x = x; + this.y = y; + this.active = true; + this.bobOffset = Math.random() * Math.PI * 2; + this.isEnemy = false; + + switch (type) { + case ENTITY_PICKUP_HEALTH: + this.spriteCanvas = Sprites.health; + break; + case ENTITY_PICKUP_ARMOR: + this.spriteCanvas = Sprites.armor; + break; + case ENTITY_PICKUP_AMMO: + this.spriteCanvas = Sprites.ammo; + break; + case ENTITY_PICKUP_SHOTGUN: + this.spriteCanvas = Sprites.shotgun_pickup; + break; + case ENTITY_PICKUP_PLASMA: + this.spriteCanvas = Sprites.plasma_pickup; + break; + } + } +} + +// Decoration entity (barrel, lamp) +class Decoration { + constructor(type, x, y) { + this.type = type; + this.x = x; + this.y = y; + this.active = true; + this.isEnemy = false; + this.isDecoration = true; + this.health = type === ENTITY_BARREL ? 20 : Infinity; + + switch (type) { + case ENTITY_BARREL: + this.spriteCanvas = Sprites.barrel; + break; + case ENTITY_LAMP: + this.spriteCanvas = Sprites.lamp; + break; + } + } + + takeDamage(amount) { + if (this.type !== ENTITY_BARREL) return; + this.health -= amount; + if (this.health <= 0) { + this.active = false; + Audio.playExplosion(); + return { type: 'explosion', x: this.x, y: this.y, radius: 3, damage: 40 }; + } + return null; + } +} diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..a67fbdb --- /dev/null +++ b/js/game.js @@ -0,0 +1,455 @@ +// 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}%)
` + + `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}
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)); + } +} diff --git a/js/levels.js b/js/levels.js new file mode 100644 index 0000000..a4df953 --- /dev/null +++ b/js/levels.js @@ -0,0 +1,177 @@ +// Level definitions +// Map: 2D array where numbers correspond to wall types (0 = empty) +// Entities: array of {type, x, y} for enemies and pickups + +const Levels = [ + // LEVEL 1: Military Base - Introduction level + { + name: "Military Base", + map: [ + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,1], + [1,0,0,0,2,2,0,0,0,1,1,0,0,0,3,3,0,0,0,1], + [1,0,0,0,2,2,0,0,0,1,1,0,0,0,3,3,0,0,0,1], + [1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], + [1,1,1,1,1,5,1,1,0,0,0,0,1,1,5,1,1,1,1,1], + [1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], + [1,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1], + [1,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1], + [1,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], + [1,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + ], + playerStart: { x: 2.5, y: 2.5, angle: 0 }, + entities: [ + // Enemies + { type: ENTITY_ENEMY_IMP, x: 15.5, y: 2.5 }, + { type: ENTITY_ENEMY_IMP, x: 5.5, y: 11.5 }, + { type: ENTITY_ENEMY_IMP, x: 14.5, y: 11.5 }, + { type: ENTITY_ENEMY_IMP, x: 10.5, y: 14.5 }, + { type: ENTITY_ENEMY_DEMON, x: 10.5, y: 17.5 }, + // Pickups + { type: ENTITY_PICKUP_HEALTH, x: 8.5, y: 1.5 }, + { type: ENTITY_PICKUP_AMMO, x: 1.5, y: 8.5 }, + { type: ENTITY_PICKUP_AMMO, x: 18.5, y: 8.5 }, + { type: ENTITY_PICKUP_SHOTGUN, x: 10.5, y: 7.5 }, + { type: ENTITY_PICKUP_ARMOR, x: 1.5, y: 11.5 }, + // Decorations + { type: ENTITY_LAMP, x: 5.5, y: 7.5 }, + { type: ENTITY_LAMP, x: 14.5, y: 7.5 }, + { type: ENTITY_BARREL, x: 17.5, y: 12.5 }, + { type: ENTITY_BARREL, x: 2.5, y: 17.5 }, + ] + }, + + // LEVEL 2: Research Lab - Medium difficulty + { + name: "Research Lab", + map: [ + [4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4], + [4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,4], + [4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,4], + [4,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0,4], + [4,0,0,0,0,4,0,0,0,3,3,3,3,0,0,0,4,0,0,0,0,4], + [4,4,5,4,4,4,0,0,0,3,0,0,3,0,0,0,4,4,5,4,4,4], + [4,0,0,0,0,0,0,0,0,3,0,0,3,0,0,0,0,0,0,0,0,4], + [4,0,0,0,0,0,0,0,0,5,0,0,5,0,0,0,0,0,0,0,0,4], + [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4], + [4,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,4], + [4,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,4], + [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4], + [4,0,0,0,0,0,0,0,0,4,4,4,4,0,0,0,0,0,0,0,0,4], + [4,4,4,5,4,4,0,0,0,4,0,0,4,0,0,0,4,4,5,4,4,4], + [4,0,0,0,0,4,0,0,0,5,0,0,5,0,0,0,4,0,0,0,0,4], + [4,0,0,0,0,4,0,0,0,4,0,0,4,0,0,0,4,0,0,0,0,4], + [4,0,0,0,0,4,0,0,0,4,0,0,4,0,0,0,4,0,0,0,0,4], + [4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,4], + [4,0,0,0,0,4,0,0,0,0,6,0,0,0,0,0,4,0,0,0,0,4], + [4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4], + ], + playerStart: { x: 2.5, y: 2.5, angle: Math.PI / 4 }, + entities: [ + // Enemies + { type: ENTITY_ENEMY_IMP, x: 10.5, y: 1.5 }, + { type: ENTITY_ENEMY_IMP, x: 19.5, y: 2.5 }, + { type: ENTITY_ENEMY_IMP, x: 2.5, y: 7.5 }, + { type: ENTITY_ENEMY_IMP, x: 19.5, y: 7.5 }, + { type: ENTITY_ENEMY_DEMON, x: 10.5, y: 8.5 }, + { type: ENTITY_ENEMY_DEMON, x: 5.5, y: 11.5 }, + { type: ENTITY_ENEMY_IMP, x: 16.5, y: 11.5 }, + { type: ENTITY_ENEMY_IMP, x: 2.5, y: 15.5 }, + { type: ENTITY_ENEMY_DEMON, x: 19.5, y: 15.5 }, + { type: ENTITY_ENEMY_IMP, x: 10.5, y: 16.5 }, + // Pickups + { type: ENTITY_PICKUP_HEALTH, x: 19.5, y: 1.5 }, + { type: ENTITY_PICKUP_HEALTH, x: 1.5, y: 6.5 }, + { type: ENTITY_PICKUP_AMMO, x: 20.5, y: 6.5 }, + { type: ENTITY_PICKUP_AMMO, x: 1.5, y: 18.5 }, + { type: ENTITY_PICKUP_PLASMA, x: 10.5, y: 10.5 }, + { type: ENTITY_PICKUP_ARMOR, x: 20.5, y: 18.5 }, + { type: ENTITY_PICKUP_HEALTH, x: 10.5, y: 14.5 }, + // Decorations + { type: ENTITY_LAMP, x: 6.5, y: 8.5 }, + { type: ENTITY_LAMP, x: 15.5, y: 8.5 }, + { type: ENTITY_BARREL, x: 7.5, y: 1.5 }, + { type: ENTITY_BARREL, x: 14.5, y: 1.5 }, + { type: ENTITY_BARREL, x: 7.5, y: 18.5 }, + { type: ENTITY_BARREL, x: 14.5, y: 18.5 }, + ] + }, + + // LEVEL 3: Hell - Hard difficulty + { + name: "Gates of Hell", + map: [ + [8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8], + [8,0,0,0,0,0,8,0,0,0,0,0,0,0,0,0,0,8,0,0,0,0,0,8], + [8,0,0,0,0,0,8,0,0,0,0,0,0,0,0,0,0,8,0,0,0,0,0,8], + [8,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0,0,8], + [8,0,0,0,0,0,8,0,0,0,7,7,7,7,0,0,0,8,0,0,0,0,0,8], + [8,0,0,0,0,0,8,0,0,0,7,0,0,7,0,0,0,8,0,0,0,0,0,8], + [8,8,8,5,8,8,8,0,0,0,5,0,0,5,0,0,0,8,8,8,5,8,8,8], + [8,0,0,0,0,0,0,0,0,0,7,0,0,7,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,7,7,7,7,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,8,8,8,0,0,0,0,0,0,0,0,0,8,8,8,0,0,0,0,8], + [8,0,0,0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,0,0,8], + [8,0,0,0,8,0,0,0,0,8,0,0,0,0,8,0,0,0,8,0,0,0,0,8], + [8,0,0,0,8,0,0,0,0,8,0,0,0,0,8,0,0,0,8,0,0,0,0,8], + [8,0,0,0,8,0,0,0,0,8,0,0,0,0,8,0,0,0,8,0,0,0,0,8], + [8,0,0,0,8,8,5,8,8,8,0,0,0,0,8,8,5,8,8,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8], + [8,8,8,8,8,8,8,8,8,0,0,0,0,0,0,8,8,8,8,8,8,8,8,8], + [8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8], + [8,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,8], + [8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8], + ], + playerStart: { x: 2.5, y: 2.5, angle: 0 }, + entities: [ + // Many enemies + { type: ENTITY_ENEMY_IMP, x: 21.5, y: 2.5 }, + { type: ENTITY_ENEMY_IMP, x: 10.5, y: 2.5 }, + { type: ENTITY_ENEMY_IMP, x: 14.5, y: 2.5 }, + { type: ENTITY_ENEMY_DEMON, x: 2.5, y: 7.5 }, + { type: ENTITY_ENEMY_DEMON, x: 21.5, y: 7.5 }, + { type: ENTITY_ENEMY_IMP, x: 4.5, y: 10.5 }, + { type: ENTITY_ENEMY_IMP, x: 19.5, y: 10.5 }, + { type: ENTITY_ENEMY_DEMON, x: 12.5, y: 9.5 }, + { type: ENTITY_ENEMY_IMP, x: 6.5, y: 13.5 }, + { type: ENTITY_ENEMY_IMP, x: 17.5, y: 13.5 }, + { type: ENTITY_ENEMY_DEMON, x: 12.5, y: 15.5 }, + { type: ENTITY_ENEMY_BARON, x: 12.5, y: 21.5 }, + { type: ENTITY_ENEMY_IMP, x: 2.5, y: 18.5 }, + { type: ENTITY_ENEMY_IMP, x: 21.5, y: 18.5 }, + { type: ENTITY_ENEMY_DEMON, x: 5.5, y: 21.5 }, + { type: ENTITY_ENEMY_DEMON, x: 19.5, y: 21.5 }, + // Pickups + { type: ENTITY_PICKUP_HEALTH, x: 4.5, y: 1.5 }, + { type: ENTITY_PICKUP_HEALTH, x: 21.5, y: 5.5 }, + { type: ENTITY_PICKUP_HEALTH, x: 12.5, y: 17.5 }, + { type: ENTITY_PICKUP_AMMO, x: 1.5, y: 1.5 }, + { type: ENTITY_PICKUP_AMMO, x: 22.5, y: 1.5 }, + { type: ENTITY_PICKUP_AMMO, x: 1.5, y: 17.5 }, + { type: ENTITY_PICKUP_AMMO, x: 22.5, y: 17.5 }, + { type: ENTITY_PICKUP_ARMOR, x: 12.5, y: 5.5 }, + { type: ENTITY_PICKUP_HEALTH, x: 8.5, y: 20.5 }, + { type: ENTITY_PICKUP_HEALTH, x: 16.5, y: 20.5 }, + // Decorations + { type: ENTITY_LAMP, x: 9.5, y: 9.5 }, + { type: ENTITY_LAMP, x: 14.5, y: 9.5 }, + { type: ENTITY_BARREL, x: 8.5, y: 17.5 }, + { type: ENTITY_BARREL, x: 16.5, y: 17.5 }, + { type: ENTITY_BARREL, x: 3.5, y: 21.5 }, + { type: ENTITY_BARREL, x: 20.5, y: 21.5 }, + ] + } +]; diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..a6714e9 --- /dev/null +++ b/js/main.js @@ -0,0 +1,10 @@ +// Entry point +window.addEventListener('DOMContentLoaded', () => { + // Initialize procedural assets + initTextures(); + initSprites(); + + // Create and start game + const game = new Game(); + game.start(); +}); diff --git a/js/player.js b/js/player.js new file mode 100644 index 0000000..aa08179 --- /dev/null +++ b/js/player.js @@ -0,0 +1,177 @@ +// Player class +class Player { + constructor() { + this.x = 2; + this.y = 2; + this.angle = 0; + this.health = 100; + this.maxHealth = 100; + this.armor = 0; + this.maxArmor = 100; + this.ammo = 50; + this.maxAmmo = 200; + this.kills = 0; + this.totalKills = 0; + this.score = 0; + this.weaponSystem = new WeaponSystem(); + this.currentMap = null; + + // Input state + this.keys = {}; + this.mouseDown = false; + this.mouseDX = 0; + + // Head bob + this.bobPhase = 0; + this.isMoving = false; + } + + reset(startPos) { + this.x = startPos.x; + this.y = startPos.y; + this.angle = startPos.angle; + this.health = 100; + this.armor = 0; + this.ammo = 50; + this.kills = 0; + this.weaponSystem = new WeaponSystem(); + } + + fullReset() { + this.health = 100; + this.armor = 0; + this.ammo = 50; + this.kills = 0; + this.totalKills = 0; + this.score = 0; + this.weaponSystem = new WeaponSystem(); + } + + update(dt, map) { + this.currentMap = map; + + // Mouse look + this.angle += this.mouseDX * MOUSE_SENSITIVITY; + this.mouseDX = 0; + + // Movement + let moveX = 0; + let moveY = 0; + const speed = PLAYER_SPEED * dt / 1000; + + this.isMoving = false; + + if (this.keys['w'] || this.keys['arrowup']) { + moveX += Math.cos(this.angle) * speed; + moveY += Math.sin(this.angle) * speed; + this.isMoving = true; + } + if (this.keys['s'] || this.keys['arrowdown']) { + moveX -= Math.cos(this.angle) * speed; + moveY -= Math.sin(this.angle) * speed; + this.isMoving = true; + } + if (this.keys['a'] || this.keys['arrowleft']) { + moveX += Math.cos(this.angle - Math.PI / 2) * speed; + moveY += Math.sin(this.angle - Math.PI / 2) * speed; + this.isMoving = true; + } + if (this.keys['d'] || this.keys['arrowright']) { + moveX -= Math.cos(this.angle - Math.PI / 2) * speed; + moveY -= Math.sin(this.angle - Math.PI / 2) * speed; + this.isMoving = true; + } + + // Apply movement with collision + this.move(moveX, moveY, map); + + // Head bob + if (this.isMoving) { + this.bobPhase += dt * 0.008; + } + + // Weapon switching + if (this.keys['1']) this.weaponSystem.switchTo('pistol'); + if (this.keys['2']) this.weaponSystem.switchTo('shotgun'); + if (this.keys['3']) this.weaponSystem.switchTo('plasma'); + + // Weapon update + this.weaponSystem.update(dt); + } + + move(moveX, moveY, map) { + const margin = PLAYER_SIZE; + + // Separate X and Y collision for sliding along walls + const newX = this.x + moveX; + const newY = this.y + moveY; + + // Check X + if (this.isPassable(newX + (moveX > 0 ? margin : -margin), this.y, map) && + this.isPassable(newX + (moveX > 0 ? margin : -margin), this.y + margin, map) && + this.isPassable(newX + (moveX > 0 ? margin : -margin), this.y - margin, map)) { + this.x = newX; + } + + // Check Y + if (this.isPassable(this.x, newY + (moveY > 0 ? margin : -margin), map) && + this.isPassable(this.x + margin, newY + (moveY > 0 ? margin : -margin), map) && + this.isPassable(this.x - margin, newY + (moveY > 0 ? margin : -margin), map)) { + this.y = newY; + } + } + + isPassable(x, y, map) { + const cellX = Math.floor(x); + const cellY = Math.floor(y); + if (cellY < 0 || cellY >= map.length || cellX < 0 || cellX >= map[0].length) return false; + const cell = map[cellY][cellX]; + return cell === 0; + } + + takeDamage(amount) { + // Armor absorbs some damage + if (this.armor > 0) { + const armorAbsorb = Math.min(this.armor, Math.floor(amount * 0.6)); + this.armor -= armorAbsorb; + amount -= armorAbsorb; + } + this.health -= amount; + if (this.health < 0) this.health = 0; + Audio.playPlayerHurt(); + } + + heal(amount) { + this.health = Math.min(this.maxHealth, this.health + amount); + } + + addArmor(amount) { + this.armor = Math.min(this.maxArmor, this.armor + amount); + } + + addAmmo(amount) { + this.ammo = Math.min(this.maxAmmo, this.ammo + amount); + } + + // Try to interact with a door nearby + tryInteract(map) { + // Check cells in front of the player + for (let dist = 0.5; dist <= 1.5; dist += 0.5) { + const checkX = Math.floor(this.x + Math.cos(this.angle) * dist); + const checkY = Math.floor(this.y + Math.sin(this.angle) * dist); + + if (checkY >= 0 && checkY < map.length && checkX >= 0 && checkX < map[0].length) { + if (map[checkY][checkX] === WALL_DOOR) { + // Open door + map[checkY][checkX] = 0; + Audio.playDoorOpen(); + return 'door_opened'; + } + if (map[checkY][checkX] === WALL_EXIT) { + return 'exit_reached'; + } + } + } + return null; + } +} diff --git a/js/raycaster.js b/js/raycaster.js new file mode 100644 index 0000000..a4f92e2 --- /dev/null +++ b/js/raycaster.js @@ -0,0 +1,240 @@ +// Raycasting engine +class Raycaster { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.canvas.width = SCREEN_WIDTH; + this.canvas.height = SCREEN_HEIGHT; + this.imageData = this.ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT); + this.depthBuffer = new Float32Array(SCREEN_WIDTH); + } + + castRays(player, map) { + const rays = []; + for (let i = 0; i < NUM_RAYS; i++) { + const rayAngle = player.angle - HALF_FOV + i * DELTA_ANGLE; + const ray = this.castSingleRay(player.x, player.y, rayAngle, map); + // Fix fisheye distortion + ray.correctedDist = ray.distance * Math.cos(rayAngle - player.angle); + rays.push(ray); + this.depthBuffer[i] = ray.correctedDist; + } + return rays; + } + + castSingleRay(originX, originY, angle, map) { + const sinA = Math.sin(angle); + const cosA = Math.cos(angle); + + // Horizontal intersections + let hDist = Infinity, hX = 0, hY = 0, hTex = 0, hOffset = 0; + { + const yDir = sinA > 0 ? 1 : -1; + const firstY = sinA > 0 ? Math.floor(originY) + 1 : Math.floor(originY); + const firstX = originX + (firstY - originY) / sinA * cosA; + const stepY = yDir; + const stepX = stepY / sinA * cosA; + + let mapX = firstX; + let mapY = firstY; + + for (let i = 0; i < MAX_DEPTH * 2; i++) { + const cellX = Math.floor(mapX); + const cellY = sinA > 0 ? Math.floor(mapY) : Math.floor(mapY) - 1; + + if (cellX < 0 || cellY < 0 || cellY >= map.length || cellX >= map[0].length) break; + + if (map[cellY] && map[cellY][cellX] > 0) { + hTex = map[cellY][cellX]; + hX = mapX; + hY = mapY; + hDist = Math.sqrt((mapX - originX) ** 2 + (mapY - originY) ** 2); + hOffset = mapX - Math.floor(mapX); + break; + } + mapX += stepX; + mapY += stepY; + } + } + + // Vertical intersections + let vDist = Infinity, vX = 0, vY = 0, vTex = 0, vOffset = 0; + { + const xDir = cosA > 0 ? 1 : -1; + const firstX = cosA > 0 ? Math.floor(originX) + 1 : Math.floor(originX); + const firstY = originY + (firstX - originX) / cosA * sinA; + const stepX = xDir; + const stepY = stepX / cosA * sinA; + + let mapX = firstX; + let mapY = firstY; + + for (let i = 0; i < MAX_DEPTH * 2; i++) { + const cellX = cosA > 0 ? Math.floor(mapX) : Math.floor(mapX) - 1; + const cellY = Math.floor(mapY); + + if (cellX < 0 || cellY < 0 || cellY >= map.length || cellX >= map[0].length) break; + + if (map[cellY] && map[cellY][cellX] > 0) { + vTex = map[cellY][cellX]; + vX = mapX; + vY = mapY; + vDist = Math.sqrt((mapX - originX) ** 2 + (mapY - originY) ** 2); + vOffset = mapY - Math.floor(mapY); + break; + } + mapX += stepX; + mapY += stepY; + } + } + + if (hDist < vDist) { + return { distance: hDist, wallType: hTex, textureOffset: hOffset, hitX: hX, hitY: hY, side: 0 }; + } else { + return { distance: vDist, wallType: vTex, textureOffset: vOffset, hitX: vX, hitY: vY, side: 1 }; + } + } + + render(player, map, entities) { + const data = this.imageData.data; + + // Clear to black + data.fill(0); + + // Draw ceiling and floor gradients + for (let y = 0; y < SCREEN_HEIGHT; y++) { + for (let x = 0; x < SCREEN_WIDTH; x++) { + const idx = (y * SCREEN_WIDTH + x) * 4; + if (y < SCREEN_HEIGHT / 2) { + // Ceiling - dark gray gradient + const shade = Math.floor(30 * (1 - y / (SCREEN_HEIGHT / 2))); + data[idx] = shade; + data[idx + 1] = shade; + data[idx + 2] = shade + 5; + data[idx + 3] = 255; + } else { + // Floor - dark gradient + const shade = Math.floor(40 * ((y - SCREEN_HEIGHT / 2) / (SCREEN_HEIGHT / 2))); + data[idx] = shade; + data[idx + 1] = shade; + data[idx + 2] = shade; + data[idx + 3] = 255; + } + } + } + + // Cast rays and draw walls + const rays = this.castRays(player, map); + + for (let x = 0; x < NUM_RAYS; x++) { + const ray = rays[x]; + if (ray.correctedDist <= 0) continue; + + const wallHeight = Math.min(SCREEN_HEIGHT * 2, WALL_HEIGHT_SCALE / ray.correctedDist); + const wallTop = Math.floor((SCREEN_HEIGHT - wallHeight) / 2); + const wallBottom = Math.floor(wallTop + wallHeight); + + // Get texture data + const texData = Textures.data[ray.wallType]; + const texX = Math.floor(ray.textureOffset * 64) % 64; + + // Shade based on distance and side + const distShade = Math.max(0.15, 1 - ray.correctedDist / MAX_DEPTH); + const sideShade = ray.side === 1 ? 0.8 : 1.0; + const shade = distShade * sideShade; + + for (let y = Math.max(0, wallTop); y < Math.min(SCREEN_HEIGHT, wallBottom); y++) { + const texY = Math.floor(((y - wallTop) / wallHeight) * 64) % 64; + const texIdx = (texY * 64 + texX) * 4; + const idx = (y * SCREEN_WIDTH + x) * 4; + + if (texData) { + data[idx] = Math.floor(texData[texIdx] * shade); + data[idx + 1] = Math.floor(texData[texIdx + 1] * shade); + data[idx + 2] = Math.floor(texData[texIdx + 2] * shade); + } else { + data[idx] = Math.floor(100 * shade); + data[idx + 1] = Math.floor(100 * shade); + data[idx + 2] = Math.floor(100 * shade); + } + data[idx + 3] = 255; + } + } + + this.ctx.putImageData(this.imageData, 0, 0); + + // Draw sprites (entities) on top using canvas 2D + this.renderSprites(player, entities); + } + + renderSprites(player, entities) { + // Calculate distance and angle for each entity + const visibleSprites = []; + + for (const entity of entities) { + if (!entity.active) continue; + + const dx = entity.x - player.x; + const dy = entity.y - player.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 0.3 || dist > MAX_DEPTH) continue; + + let angle = Math.atan2(dy, dx) - player.angle; + // Normalize angle + while (angle > Math.PI) angle -= 2 * Math.PI; + while (angle < -Math.PI) angle += 2 * Math.PI; + + // Check if in view + if (Math.abs(angle) > HALF_FOV + 0.2) continue; + + visibleSprites.push({ entity, dist, angle }); + } + + // Sort back to front + visibleSprites.sort((a, b) => b.dist - a.dist); + + const ctx = this.ctx; + + for (const { entity, dist, angle } of visibleSprites) { + const screenX = (0.5 + angle / FOV) * SCREEN_WIDTH; + + // Sprite size based on distance + const spriteHeight = Math.min(SCREEN_HEIGHT, WALL_HEIGHT_SCALE / dist); + const spriteWidth = spriteHeight * (entity.spriteCanvas.width / entity.spriteCanvas.height); + + const drawX = screenX - spriteWidth / 2; + const drawY = (SCREEN_HEIGHT - spriteHeight) / 2; + + // Only draw if not fully behind a wall + const centerCol = Math.floor(screenX); + if (centerCol >= 0 && centerCol < SCREEN_WIDTH && this.depthBuffer[centerCol] < dist) { + // Check if mostly occluded + let visibleCols = 0; + const startCol = Math.max(0, Math.floor(drawX)); + const endCol = Math.min(SCREEN_WIDTH - 1, Math.floor(drawX + spriteWidth)); + for (let c = startCol; c <= endCol; c += 4) { + if (this.depthBuffer[c] >= dist) visibleCols++; + } + if (visibleCols === 0) continue; + } + + // Distance-based darkening + const shade = Math.max(0.15, 1 - dist / MAX_DEPTH); + ctx.save(); + ctx.globalAlpha = shade; + + // Clip sprite columns against depth buffer + ctx.beginPath(); + for (let col = Math.max(0, Math.floor(drawX)); col < Math.min(SCREEN_WIDTH, Math.ceil(drawX + spriteWidth)); col++) { + if (this.depthBuffer[col] >= dist) { + ctx.rect(col, drawY, 1, spriteHeight); + } + } + ctx.clip(); + + ctx.drawImage(entity.spriteCanvas, drawX, drawY, spriteWidth, spriteHeight); + ctx.restore(); + } + } +} diff --git a/js/sprites.js b/js/sprites.js new file mode 100644 index 0000000..74878f9 --- /dev/null +++ b/js/sprites.js @@ -0,0 +1,400 @@ +// Procedural sprite generation for enemies and pickups +const SpriteGen = { + createCanvas(w, h) { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + return canvas; + }, + + // Imp enemy - brown/tan demon + impSprite() { + const canvas = this.createCanvas(64, 64); + const ctx = canvas.getContext('2d'); + + // Body + ctx.fillStyle = '#8B4513'; + ctx.fillRect(20, 20, 24, 30); + + // Head + ctx.fillStyle = '#A0522D'; + ctx.beginPath(); + ctx.arc(32, 16, 10, 0, Math.PI * 2); + ctx.fill(); + + // Eyes (glowing red) + ctx.fillStyle = '#f00'; + ctx.fillRect(27, 13, 3, 3); + ctx.fillRect(34, 13, 3, 3); + + // Horns + ctx.fillStyle = '#654321'; + ctx.beginPath(); + ctx.moveTo(24, 8); ctx.lineTo(22, 0); ctx.lineTo(27, 8); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(37, 8); ctx.lineTo(42, 0); ctx.lineTo(40, 8); + ctx.fill(); + + // Arms + ctx.fillStyle = '#8B4513'; + ctx.fillRect(12, 22, 8, 6); + ctx.fillRect(44, 22, 8, 6); + + // Claws + ctx.fillStyle = '#654321'; + ctx.fillRect(10, 28, 3, 5); + ctx.fillRect(14, 28, 3, 5); + ctx.fillRect(47, 28, 3, 5); + ctx.fillRect(51, 28, 3, 5); + + // Legs + ctx.fillStyle = '#7B3F00'; + ctx.fillRect(22, 50, 8, 14); + ctx.fillRect(34, 50, 8, 14); + + return canvas; + }, + + // Demon - pink/red bigger enemy + demonSprite() { + const canvas = this.createCanvas(64, 64); + const ctx = canvas.getContext('2d'); + + // Body (larger) + ctx.fillStyle = '#C0392B'; + ctx.fillRect(14, 16, 36, 34); + + // Head + ctx.fillStyle = '#E74C3C'; + ctx.beginPath(); + ctx.arc(32, 14, 12, 0, Math.PI * 2); + ctx.fill(); + + // Jaw + ctx.fillStyle = '#C0392B'; + ctx.fillRect(22, 18, 20, 8); + + // Teeth + ctx.fillStyle = '#fff'; + for (let i = 0; i < 5; i++) { + ctx.fillRect(24 + i * 4, 22, 2, 4); + } + + // Eyes + ctx.fillStyle = '#ff0'; + ctx.fillRect(25, 10, 4, 4); + ctx.fillRect(35, 10, 4, 4); + ctx.fillStyle = '#f00'; + ctx.fillRect(26, 11, 2, 2); + ctx.fillRect(36, 11, 2, 2); + + // Arms/claws + ctx.fillStyle = '#C0392B'; + ctx.fillRect(6, 20, 8, 20); + ctx.fillRect(50, 20, 8, 20); + + // Legs + ctx.fillStyle = '#922B21'; + ctx.fillRect(18, 50, 10, 14); + ctx.fillRect(36, 50, 10, 14); + + return canvas; + }, + + // Baron of Hell - tall, green/brown + baronSprite() { + const canvas = this.createCanvas(64, 64); + const ctx = canvas.getContext('2d'); + + // Body + ctx.fillStyle = '#2E4A1E'; + ctx.fillRect(16, 10, 32, 38); + + // Head + ctx.fillStyle = '#3D5E2A'; + ctx.beginPath(); + ctx.arc(32, 10, 12, 0, Math.PI * 2); + ctx.fill(); + + // Horns (large) + ctx.fillStyle = '#5A3E1B'; + ctx.beginPath(); + ctx.moveTo(22, 2); ctx.lineTo(16, -8); ctx.lineTo(26, 4); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(42, 2); ctx.lineTo(48, -8); ctx.lineTo(38, 4); + ctx.fill(); + + // Eyes (green fire) + ctx.fillStyle = '#0f0'; + ctx.fillRect(26, 7, 4, 4); + ctx.fillRect(34, 7, 4, 4); + + // Chest detail + ctx.fillStyle = '#4A6E2E'; + ctx.fillRect(22, 18, 20, 4); + + // Arms (muscular) + ctx.fillStyle = '#2E4A1E'; + ctx.fillRect(6, 14, 10, 24); + ctx.fillRect(48, 14, 10, 24); + + // Fists (glowing) + ctx.fillStyle = '#0f0'; + ctx.fillRect(6, 38, 10, 8); + ctx.fillRect(48, 38, 10, 8); + + // Legs + ctx.fillStyle = '#1E3A0E'; + ctx.fillRect(20, 48, 10, 16); + ctx.fillRect(34, 48, 10, 16); + + // Hooves + ctx.fillStyle = '#5A3E1B'; + ctx.fillRect(18, 60, 14, 4); + ctx.fillRect(32, 60, 14, 4); + + return canvas; + }, + + // Health pickup + healthPickup() { + const canvas = this.createCanvas(32, 32); + const ctx = canvas.getContext('2d'); + + // Vial + ctx.fillStyle = '#06f'; + ctx.fillRect(10, 8, 12, 20); + + // Cross + ctx.fillStyle = '#fff'; + ctx.fillRect(13, 12, 6, 2); + ctx.fillRect(15, 10, 2, 6); + + // Cap + ctx.fillStyle = '#888'; + ctx.fillRect(10, 6, 12, 3); + + // Glow + ctx.fillStyle = 'rgba(0, 100, 255, 0.3)'; + ctx.beginPath(); + ctx.arc(16, 18, 12, 0, Math.PI * 2); + ctx.fill(); + + return canvas; + }, + + // Armor pickup + armorPickup() { + const canvas = this.createCanvas(32, 32); + const ctx = canvas.getContext('2d'); + + // Shield + ctx.fillStyle = '#080'; + ctx.beginPath(); + ctx.moveTo(16, 4); + ctx.lineTo(26, 10); + ctx.lineTo(24, 24); + ctx.lineTo(16, 28); + ctx.lineTo(8, 24); + ctx.lineTo(6, 10); + ctx.closePath(); + ctx.fill(); + + // Inner shield + ctx.fillStyle = '#0a0'; + ctx.beginPath(); + ctx.moveTo(16, 8); + ctx.lineTo(22, 12); + ctx.lineTo(21, 22); + ctx.lineTo(16, 25); + ctx.lineTo(11, 22); + ctx.lineTo(10, 12); + ctx.closePath(); + ctx.fill(); + + return canvas; + }, + + // Ammo pickup + ammoPickup() { + const canvas = this.createCanvas(32, 32); + const ctx = canvas.getContext('2d'); + + // Box + ctx.fillStyle = '#8B7355'; + ctx.fillRect(6, 10, 20, 16); + + // Label + ctx.fillStyle = '#ff0'; + ctx.font = 'bold 8px monospace'; + ctx.fillText('AMMO', 7, 21); + + // Bullets poking out + ctx.fillStyle = '#da0'; + ctx.fillRect(8, 6, 3, 6); + ctx.fillRect(13, 4, 3, 8); + ctx.fillRect(18, 6, 3, 6); + + // Bullet tips + ctx.fillStyle = '#a80'; + ctx.fillRect(8, 4, 3, 3); + ctx.fillRect(13, 2, 3, 3); + ctx.fillRect(18, 4, 3, 3); + + return canvas; + }, + + // Shotgun pickup + shotgunPickup() { + const canvas = this.createCanvas(48, 32); + const ctx = canvas.getContext('2d'); + + // Barrel + ctx.fillStyle = '#555'; + ctx.fillRect(4, 12, 30, 4); + + // Stock + ctx.fillStyle = '#7a5230'; + ctx.fillRect(34, 10, 12, 8); + + // Pump + ctx.fillStyle = '#444'; + ctx.fillRect(14, 10, 8, 8); + + // Glow + ctx.fillStyle = 'rgba(255, 200, 0, 0.3)'; + ctx.beginPath(); + ctx.arc(24, 16, 14, 0, Math.PI * 2); + ctx.fill(); + + return canvas; + }, + + // Plasma gun pickup + plasmaPickup() { + const canvas = this.createCanvas(48, 32); + const ctx = canvas.getContext('2d'); + + // Body + ctx.fillStyle = '#336'; + ctx.fillRect(6, 10, 28, 10); + + // Barrel + ctx.fillStyle = '#448'; + ctx.fillRect(2, 12, 6, 6); + + // Energy cell + ctx.fillStyle = '#08f'; + ctx.fillRect(34, 8, 10, 14); + ctx.fillStyle = '#0af'; + ctx.fillRect(36, 10, 6, 10); + + // Glow + ctx.fillStyle = 'rgba(0, 150, 255, 0.3)'; + ctx.beginPath(); + ctx.arc(24, 16, 14, 0, Math.PI * 2); + ctx.fill(); + + return canvas; + }, + + // Barrel (explosive) + barrelSprite() { + const canvas = this.createCanvas(32, 48); + const ctx = canvas.getContext('2d'); + + // Barrel body + ctx.fillStyle = '#4a4'; + ctx.fillRect(6, 8, 20, 36); + + // Top + ctx.fillStyle = '#5b5'; + ctx.beginPath(); + ctx.ellipse(16, 10, 10, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + // Hazard symbol + ctx.fillStyle = '#ff0'; + ctx.beginPath(); + ctx.moveTo(16, 18); + ctx.lineTo(22, 30); + ctx.lineTo(10, 30); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#000'; + ctx.font = 'bold 8px sans-serif'; + ctx.fillText('!', 14, 29); + + // Bands + ctx.fillStyle = '#383'; + ctx.fillRect(6, 14, 20, 2); + ctx.fillRect(6, 36, 20, 2); + + return canvas; + }, + + // Lamp sprite + lampSprite() { + const canvas = this.createCanvas(16, 48); + const ctx = canvas.getContext('2d'); + + // Pole + ctx.fillStyle = '#666'; + ctx.fillRect(6, 16, 4, 32); + + // Base + ctx.fillStyle = '#555'; + ctx.fillRect(2, 44, 12, 4); + + // Light + ctx.fillStyle = '#ff8'; + ctx.beginPath(); + ctx.arc(8, 12, 6, 0, Math.PI * 2); + ctx.fill(); + + // Glow + ctx.fillStyle = 'rgba(255, 255, 100, 0.3)'; + ctx.beginPath(); + ctx.arc(8, 12, 10, 0, Math.PI * 2); + ctx.fill(); + + return canvas; + }, + + // Dead enemy sprite + deadSprite(color) { + const canvas = this.createCanvas(64, 32); + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = color; + ctx.fillRect(8, 10, 48, 12); + + // Blood pool + ctx.fillStyle = 'rgba(150, 0, 0, 0.6)'; + ctx.beginPath(); + ctx.ellipse(32, 22, 24, 8, 0, 0, Math.PI * 2); + ctx.fill(); + + return canvas; + } +}; + +const Sprites = {}; + +function initSprites() { + Sprites.imp = SpriteGen.impSprite(); + Sprites.demon = SpriteGen.demonSprite(); + Sprites.baron = SpriteGen.baronSprite(); + Sprites.health = SpriteGen.healthPickup(); + Sprites.armor = SpriteGen.armorPickup(); + Sprites.ammo = SpriteGen.ammoPickup(); + Sprites.shotgun_pickup = SpriteGen.shotgunPickup(); + Sprites.plasma_pickup = SpriteGen.plasmaPickup(); + Sprites.barrel = SpriteGen.barrelSprite(); + Sprites.lamp = SpriteGen.lampSprite(); + Sprites.deadImp = SpriteGen.deadSprite('#8B4513'); + Sprites.deadDemon = SpriteGen.deadSprite('#C0392B'); + Sprites.deadBaron = SpriteGen.deadSprite('#2E4A1E'); +} diff --git a/js/textures.js b/js/textures.js new file mode 100644 index 0000000..ea01095 --- /dev/null +++ b/js/textures.js @@ -0,0 +1,324 @@ +// Procedural texture generation +const TextureGen = { + size: 64, + + createCanvas() { + const canvas = document.createElement('canvas'); + canvas.width = this.size; + canvas.height = this.size; + return canvas; + }, + + // Stone wall texture + stoneWall() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#555'; + ctx.fillRect(0, 0, 64, 64); + + // Stone blocks + const blockColors = ['#4a4a4a', '#505050', '#585858', '#464646']; + for (let y = 0; y < 4; y++) { + for (let x = 0; x < 4; x++) { + const offset = (y % 2) ? 8 : 0; + ctx.fillStyle = blockColors[Math.floor(Math.random() * blockColors.length)]; + ctx.fillRect(x * 16 + offset, y * 16, 15, 15); + ctx.fillStyle = '#333'; + ctx.fillRect(x * 16 + offset, y * 16 + 15, 16, 1); + ctx.fillRect(x * 16 + offset + 15, y * 16, 1, 16); + } + } + // Noise + this.addNoise(ctx, 0.15); + return canvas; + }, + + // Brick wall texture + brickWall() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#8b4513'; + ctx.fillRect(0, 0, 64, 64); + + for (let row = 0; row < 8; row++) { + const offset = (row % 2) ? 16 : 0; + for (let col = -1; col < 3; col++) { + const shade = 0.85 + Math.random() * 0.3; + const r = Math.floor(139 * shade); + const g = Math.floor(69 * shade); + const b = Math.floor(19 * shade); + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(col * 32 + offset + 1, row * 8 + 1, 30, 6); + } + // Mortar lines + ctx.fillStyle = '#666'; + ctx.fillRect(0, row * 8, 64, 1); + } + this.addNoise(ctx, 0.1); + return canvas; + }, + + // Metal wall texture + metalWall() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#667'; + ctx.fillRect(0, 0, 64, 64); + + // Panels + ctx.fillStyle = '#778'; + ctx.fillRect(2, 2, 28, 28); + ctx.fillRect(34, 2, 28, 28); + ctx.fillRect(2, 34, 28, 28); + ctx.fillRect(34, 34, 28, 28); + + // Rivets + ctx.fillStyle = '#99a'; + for (const [x, y] of [[4,4],[28,4],[4,28],[28,28],[36,4],[60,4],[36,28],[60,28],[4,36],[28,36],[4,60],[28,60],[36,36],[60,36],[36,60],[60,60]]) { + ctx.fillRect(x, y, 2, 2); + } + + // Highlights + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + ctx.fillRect(2, 2, 28, 1); + ctx.fillRect(34, 2, 28, 1); + ctx.fillRect(2, 34, 28, 1); + ctx.fillRect(34, 34, 28, 1); + + this.addNoise(ctx, 0.08); + return canvas; + }, + + // Tech wall texture + techWall() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#334'; + ctx.fillRect(0, 0, 64, 64); + + // Circuit pattern + ctx.strokeStyle = '#0a5'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(10, 0); ctx.lineTo(10, 20); ctx.lineTo(30, 20); ctx.lineTo(30, 40); + ctx.moveTo(50, 0); ctx.lineTo(50, 15); ctx.lineTo(35, 15); ctx.lineTo(35, 50); + ctx.moveTo(0, 45); ctx.lineTo(20, 45); ctx.lineTo(20, 64); + ctx.moveTo(45, 35); ctx.lineTo(64, 35); + ctx.moveTo(45, 55); ctx.lineTo(64, 55); + ctx.stroke(); + + // Nodes + ctx.fillStyle = '#0f8'; + for (const [x, y] of [[10, 20],[30, 40],[50, 15],[35, 50],[20, 45]]) { + ctx.fillRect(x - 1, y - 1, 3, 3); + } + + // Screen + ctx.fillStyle = '#041'; + ctx.fillRect(42, 42, 18, 18); + ctx.fillStyle = '#0f4'; + ctx.font = '8px monospace'; + ctx.fillText('OK', 46, 54); + + this.addNoise(ctx, 0.05); + return canvas; + }, + + // Door texture + doorTexture() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#654'; + ctx.fillRect(0, 0, 64, 64); + + // Door panels + ctx.fillStyle = '#543'; + ctx.fillRect(4, 4, 24, 26); + ctx.fillRect(36, 4, 24, 26); + ctx.fillRect(4, 34, 24, 26); + ctx.fillRect(36, 34, 24, 26); + + // Door frame highlights + ctx.fillStyle = '#876'; + ctx.fillRect(0, 0, 64, 2); + ctx.fillRect(0, 0, 2, 64); + ctx.fillStyle = '#432'; + ctx.fillRect(0, 62, 64, 2); + ctx.fillRect(62, 0, 2, 64); + + // Handle + ctx.fillStyle = '#aa8'; + ctx.fillRect(52, 30, 4, 6); + + this.addNoise(ctx, 0.1); + return canvas; + }, + + // Exit texture + exitTexture() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#300'; + ctx.fillRect(0, 0, 64, 64); + + // Frame + ctx.fillStyle = '#f00'; + ctx.fillRect(0, 0, 64, 3); + ctx.fillRect(0, 61, 64, 3); + ctx.fillRect(0, 0, 3, 64); + ctx.fillRect(61, 0, 3, 64); + + // EXIT text + ctx.fillStyle = '#f00'; + ctx.font = 'bold 14px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('EXIT', 32, 37); + + // Glow + ctx.fillStyle = 'rgba(255, 0, 0, 0.15)'; + ctx.fillRect(5, 5, 54, 54); + + return canvas; + }, + + // Wood texture + woodWall() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#7a5230'; + ctx.fillRect(0, 0, 64, 64); + + // Wood grain + ctx.strokeStyle = 'rgba(0,0,0,0.15)'; + ctx.lineWidth = 1; + for (let i = 0; i < 20; i++) { + const y = Math.random() * 64; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.bezierCurveTo(20, y + (Math.random() - 0.5) * 8, 40, y + (Math.random() - 0.5) * 8, 64, y + (Math.random() - 0.5) * 4); + ctx.stroke(); + } + + // Plank dividers + ctx.fillStyle = '#5a3a1a'; + ctx.fillRect(0, 15, 64, 2); + ctx.fillRect(0, 31, 64, 2); + ctx.fillRect(0, 47, 64, 2); + + this.addNoise(ctx, 0.1); + return canvas; + }, + + // Hell texture + hellWall() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#411'; + ctx.fillRect(0, 0, 64, 64); + + // Lava cracks + ctx.strokeStyle = '#f50'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(10, 0); ctx.lineTo(15, 20); ctx.lineTo(8, 40); ctx.lineTo(20, 64); + ctx.moveTo(40, 0); ctx.lineTo(35, 25); ctx.lineTo(50, 45); ctx.lineTo(45, 64); + ctx.stroke(); + + ctx.strokeStyle = '#fa0'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(10, 0); ctx.lineTo(15, 20); ctx.lineTo(8, 40); ctx.lineTo(20, 64); + ctx.moveTo(40, 0); ctx.lineTo(35, 25); ctx.lineTo(50, 45); ctx.lineTo(45, 64); + ctx.stroke(); + + // Skulls/faces suggestion + ctx.fillStyle = '#633'; + ctx.beginPath(); + ctx.arc(32, 32, 8, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#200'; + ctx.fillRect(28, 30, 3, 3); + ctx.fillRect(34, 30, 3, 3); + ctx.fillRect(30, 36, 5, 2); + + this.addNoise(ctx, 0.15); + return canvas; + }, + + // Floor texture + floorTexture() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#333'; + ctx.fillRect(0, 0, 64, 64); + + // Tile pattern + ctx.fillStyle = '#3a3a3a'; + ctx.fillRect(0, 0, 31, 31); + ctx.fillRect(33, 33, 31, 31); + ctx.fillStyle = '#2e2e2e'; + ctx.fillRect(33, 0, 31, 31); + ctx.fillRect(0, 33, 31, 31); + + ctx.fillStyle = '#282828'; + ctx.fillRect(31, 0, 2, 64); + ctx.fillRect(0, 31, 64, 2); + + this.addNoise(ctx, 0.1); + return canvas; + }, + + // Ceiling texture + ceilingTexture() { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#222'; + ctx.fillRect(0, 0, 64, 64); + + // Light panel effect + ctx.fillStyle = '#2a2a2a'; + ctx.fillRect(8, 8, 48, 48); + ctx.fillStyle = '#252525'; + ctx.fillRect(16, 16, 32, 32); + + this.addNoise(ctx, 0.08); + return canvas; + }, + + addNoise(ctx, intensity) { + const imageData = ctx.getImageData(0, 0, this.size, this.size); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const noise = (Math.random() - 0.5) * 255 * intensity; + data[i] = Math.max(0, Math.min(255, data[i] + noise)); + data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + noise)); + data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + noise)); + } + ctx.putImageData(imageData, 0, 0); + } +}; + +// Generate all textures at startup +const Textures = {}; + +function initTextures() { + Textures[WALL_STONE] = TextureGen.stoneWall(); + Textures[WALL_BRICK] = TextureGen.brickWall(); + Textures[WALL_METAL] = TextureGen.metalWall(); + Textures[WALL_TECH] = TextureGen.techWall(); + Textures[WALL_DOOR] = TextureGen.doorTexture(); + Textures[WALL_EXIT] = TextureGen.exitTexture(); + Textures[WALL_WOOD] = TextureGen.woodWall(); + Textures[WALL_HELL] = TextureGen.hellWall(); + Textures.floor = TextureGen.floorTexture(); + Textures.ceiling = TextureGen.ceilingTexture(); + + // Pre-extract image data for fast pixel access + Textures.data = {}; + for (const key of Object.keys(Textures)) { + if (key === 'data') continue; + const canvas = Textures[key]; + const ctx = canvas.getContext('2d'); + Textures.data[key] = ctx.getImageData(0, 0, 64, 64).data; + } +} diff --git a/js/weapons.js b/js/weapons.js new file mode 100644 index 0000000..b930616 --- /dev/null +++ b/js/weapons.js @@ -0,0 +1,321 @@ +// Weapon system +const WeaponDefs = { + pistol: { + name: 'PISTOL', + damage: 15, + fireRate: 300, // ms between shots + ammoPerShot: 1, + spread: 0.02, + projectiles: 1, + range: 15, + color: '#ff0', + muzzleColor: '#ff8', + autoFire: false, + }, + shotgun: { + name: 'SHOTGUN', + damage: 10, + fireRate: 700, + ammoPerShot: 2, + spread: 0.08, + projectiles: 6, + range: 10, + color: '#fa0', + muzzleColor: '#ff4', + autoFire: false, + }, + plasma: { + name: 'PLASMA GUN', + damage: 20, + fireRate: 120, + ammoPerShot: 1, + spread: 0.03, + projectiles: 1, + range: 18, + color: '#0af', + muzzleColor: '#0ff', + autoFire: true, + } +}; + +class WeaponSystem { + constructor() { + this.weapons = { + pistol: { unlocked: true, def: WeaponDefs.pistol }, + shotgun: { unlocked: false, def: WeaponDefs.shotgun }, + plasma: { unlocked: false, def: WeaponDefs.plasma }, + }; + this.current = 'pistol'; + this.lastFireTime = 0; + this.firing = false; + this.animFrame = 0; + this.animTimer = 0; + this.muzzleFlash = 0; + } + + get currentWeapon() { + return this.weapons[this.current]; + } + + get currentDef() { + return this.currentWeapon.def; + } + + switchTo(weaponName) { + if (this.weapons[weaponName] && this.weapons[weaponName].unlocked) { + this.current = weaponName; + this.animFrame = 0; + return true; + } + return false; + } + + unlock(weaponName) { + if (this.weapons[weaponName]) { + this.weapons[weaponName].unlocked = true; + } + } + + canFire(ammo) { + const now = performance.now(); + return ammo >= this.currentDef.ammoPerShot && + now - this.lastFireTime >= this.currentDef.fireRate; + } + + fire(player, enemies, ammo) { + if (!this.canFire(ammo)) return { hit: false, ammoUsed: 0 }; + + this.lastFireTime = performance.now(); + this.muzzleFlash = 4; + this.animFrame = 1; + + // Play sound + switch (this.current) { + case 'pistol': Audio.playPistol(); break; + case 'shotgun': Audio.playShotgun(); break; + case 'plasma': Audio.playPlasma(); break; + } + + const def = this.currentDef; + let totalHits = []; + + for (let p = 0; p < def.projectiles; p++) { + const spread = (Math.random() - 0.5) * def.spread; + const rayAngle = player.angle + spread; + + // Check each enemy for hit + for (const enemy of enemies) { + if (!enemy.active || enemy.health <= 0) continue; + + const dx = enemy.x - player.x; + const dy = enemy.y - player.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > def.range) continue; + + // Check if enemy is in the firing cone + let enemyAngle = Math.atan2(dy, dx); + let angleDiff = enemyAngle - rayAngle; + while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; + while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; + + // Hit detection - wider at closer range + const hitWidth = 0.4 / dist; + if (Math.abs(angleDiff) < hitWidth) { + // Check line of sight (simplified - check if wall is closer) + const blocked = this.isBlocked(player, enemy, player.currentMap); + if (!blocked) { + totalHits.push({ enemy, damage: def.damage, dist }); + } + } + } + } + + // Apply damage to closest hit per projectile + totalHits.sort((a, b) => a.dist - b.dist); + const hitEnemies = new Set(); + let hitResults = []; + + for (const hit of totalHits) { + if (!hitEnemies.has(hit.enemy) || this.current === 'shotgun') { + hit.enemy.takeDamage(hit.damage); + hitEnemies.add(hit.enemy); + hitResults.push(hit); + } + } + + return { hit: hitResults.length > 0, ammoUsed: def.ammoPerShot, hits: hitResults }; + } + + isBlocked(player, enemy, map) { + if (!map) return false; + const dx = enemy.x - player.x; + const dy = enemy.y - player.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const steps = Math.floor(dist * 4); + const stepX = dx / steps; + const stepY = dy / steps; + + for (let i = 1; i < steps; i++) { + const checkX = Math.floor(player.x + stepX * i); + const checkY = Math.floor(player.y + stepY * i); + if (checkY >= 0 && checkY < map.length && checkX >= 0 && checkX < map[0].length) { + if (map[checkY][checkX] > 0 && map[checkY][checkX] !== WALL_DOOR) { + return true; + } + } + } + return false; + } + + update(dt) { + if (this.muzzleFlash > 0) this.muzzleFlash--; + + this.animTimer += dt; + if (this.animTimer > 50) { + this.animTimer = 0; + if (this.animFrame > 0) { + this.animFrame++; + if (this.animFrame > 4) this.animFrame = 0; + } + } + } + + drawWeapon(ctx) { + const centerX = SCREEN_WIDTH / 2; + const baseY = SCREEN_HEIGHT; + const bobX = Math.sin(performance.now() / 200) * 3; + const bobY = Math.abs(Math.cos(performance.now() / 200)) * 3; + + // Recoil animation + let recoilY = 0; + if (this.animFrame > 0) { + recoilY = this.animFrame === 1 ? -20 : this.animFrame === 2 ? -15 : this.animFrame === 3 ? -8 : 0; + } + + ctx.save(); + ctx.translate(bobX, bobY + recoilY); + + switch (this.current) { + case 'pistol': + this.drawPistol(ctx, centerX, baseY); + break; + case 'shotgun': + this.drawShotgunWeapon(ctx, centerX, baseY); + break; + case 'plasma': + this.drawPlasmaGun(ctx, centerX, baseY); + break; + } + + // Muzzle flash + if (this.muzzleFlash > 0) { + this.drawMuzzleFlash(ctx, centerX, baseY - 200); + } + + ctx.restore(); + } + + drawPistol(ctx, cx, by) { + // Barrel + ctx.fillStyle = '#555'; + ctx.fillRect(cx - 8, by - 200, 16, 80); + // Slide + ctx.fillStyle = '#444'; + ctx.fillRect(cx - 10, by - 180, 20, 60); + // Grip + ctx.fillStyle = '#3a2a1a'; + ctx.fillRect(cx - 12, by - 120, 24, 80); + // Trigger guard + ctx.fillStyle = '#444'; + ctx.beginPath(); + ctx.arc(cx, by - 115, 8, 0, Math.PI); + ctx.fill(); + // Sight + ctx.fillStyle = '#666'; + ctx.fillRect(cx - 3, by - 205, 6, 8); + // Highlight + ctx.fillStyle = '#666'; + ctx.fillRect(cx - 8, by - 200, 2, 80); + } + + drawShotgunWeapon(ctx, cx, by) { + // Barrels (double) + ctx.fillStyle = '#444'; + ctx.fillRect(cx - 14, by - 240, 12, 120); + ctx.fillRect(cx + 2, by - 240, 12, 120); + // Barrel openings + ctx.fillStyle = '#222'; + ctx.beginPath(); + ctx.arc(cx - 8, by - 240, 5, 0, Math.PI * 2); + ctx.arc(cx + 8, by - 240, 5, 0, Math.PI * 2); + ctx.fill(); + // Pump + ctx.fillStyle = '#7a5230'; + ctx.fillRect(cx - 16, by - 160, 32, 30); + // Stock + ctx.fillStyle = '#5a3a1a'; + ctx.fillRect(cx - 12, by - 130, 24, 100); + // Receiver + ctx.fillStyle = '#555'; + ctx.fillRect(cx - 14, by - 120, 28, 20); + // Highlight + ctx.fillStyle = '#555'; + ctx.fillRect(cx - 14, by - 240, 2, 120); + } + + drawPlasmaGun(ctx, cx, by) { + // Main body + ctx.fillStyle = '#336'; + ctx.fillRect(cx - 18, by - 200, 36, 80); + // Barrel + ctx.fillStyle = '#448'; + ctx.fillRect(cx - 10, by - 240, 20, 50); + // Energy core (glowing) + const pulse = Math.sin(performance.now() / 100) * 0.3 + 0.7; + ctx.fillStyle = `rgba(0, 150, 255, ${pulse})`; + ctx.beginPath(); + ctx.arc(cx, by - 160, 12, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = `rgba(100, 200, 255, ${pulse * 0.5})`; + ctx.beginPath(); + ctx.arc(cx, by - 160, 16, 0, Math.PI * 2); + ctx.fill(); + // Barrel opening + ctx.fillStyle = '#0af'; + ctx.beginPath(); + ctx.arc(cx, by - 240, 6, 0, Math.PI * 2); + ctx.fill(); + // Grip + ctx.fillStyle = '#224'; + ctx.fillRect(cx - 14, by - 120, 28, 80); + // Details + ctx.fillStyle = '#08f'; + ctx.fillRect(cx - 16, by - 190, 2, 40); + ctx.fillRect(cx + 14, by - 190, 2, 40); + } + + drawMuzzleFlash(ctx, cx, y) { + const def = this.currentDef; + const size = this.current === 'shotgun' ? 40 : 25; + + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + + // Outer glow + const gradient = ctx.createRadialGradient(cx, y, 0, cx, y, size); + gradient.addColorStop(0, def.muzzleColor); + gradient.addColorStop(0.5, def.color); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.fillRect(cx - size, y - size, size * 2, size * 2); + + // Inner flash + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(cx, y, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c8c04e8 --- /dev/null +++ b/styles.css @@ -0,0 +1,289 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #000; + overflow: hidden; + font-family: 'Courier New', monospace; + color: #fff; + cursor: none; +} + +#game-container { + position: relative; + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +#game-canvas { + display: block; + image-rendering: pixelated; + width: 100vw; + height: 100vh; +} + +/* Title Screen */ +#title-screen { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.85); + z-index: 10; +} + +#title-screen h1 { + font-size: 8rem; + color: #c00; + text-shadow: 0 0 30px #f00, 0 0 60px #900; + letter-spacing: 0.3em; + animation: flicker 3s infinite alternate; +} + +#title-screen h2 { + font-size: 3rem; + color: #888; + letter-spacing: 0.5em; + margin-top: -1rem; +} + +.subtitle { + color: #666; + margin-top: 1rem; + font-size: 1.1rem; +} + +@keyframes flicker { + 0%, 90%, 100% { opacity: 1; } + 92% { opacity: 0.8; } + 94% { opacity: 1; } + 96% { opacity: 0.7; } + 98% { opacity: 1; } +} + +.menu { + margin-top: 3rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.menu-btn { + font-family: 'Courier New', monospace; + font-size: 1.5rem; + padding: 0.8rem 3rem; + background: transparent; + color: #c00; + border: 2px solid #c00; + cursor: pointer; + letter-spacing: 0.2em; + transition: all 0.2s; +} + +.menu-btn:hover { + background: #c00; + color: #fff; + box-shadow: 0 0 20px rgba(200, 0, 0, 0.5); +} + +#controls-info { + margin-top: 2rem; + text-align: left; + line-height: 2; + color: #aaa; +} + +/* HUD */ +#hud { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 1rem 2rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + z-index: 5; + pointer-events: none; +} + +#hud-left, #hud-right { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +#hud-center { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + pointer-events: none; +} + +#crosshair { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-size: 1.5rem; + color: rgba(255, 255, 255, 0.6); + text-shadow: 0 0 5px rgba(255, 255, 255, 0.3); + z-index: 6; +} + +#weapon-display { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 300px; + height: 300px; + z-index: 5; +} + +#damage-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(ellipse at center, transparent 40%, rgba(255, 0, 0, 0.4)); + pointer-events: none; + z-index: 4; + animation: damage-fade 0.3s forwards; +} + +@keyframes damage-fade { + from { opacity: 1; } + to { opacity: 0; } +} + +.hud-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.hud-label { + font-size: 0.8rem; + color: #888; + width: 60px; +} + +.hud-bar { + width: 120px; + height: 12px; + background: #333; + border: 1px solid #555; +} + +.bar-fill { + height: 100%; + transition: width 0.3s; +} + +.health-fill { + background: linear-gradient(to right, #c00, #f00); + width: 100%; +} + +.armor-fill { + background: linear-gradient(to right, #00a, #00f); + width: 0%; +} + +.hud-value { + font-size: 1.5rem; + font-weight: bold; + color: #fff; + min-width: 40px; + text-align: right; +} + +.ammo-text { + color: #ff0; +} + +.weapon-text { + color: #0f0; + font-size: 1rem; +} + +/* Minimap */ +#minimap-container { + position: absolute; + top: 1rem; + right: 1rem; + border: 2px solid #444; + background: rgba(0, 0, 0, 0.7); + z-index: 5; +} + +#minimap-canvas { + display: block; +} + +/* Messages */ +#message-display { + position: absolute; + top: 20%; + left: 50%; + transform: translateX(-50%); + font-size: 1.5rem; + color: #ff0; + text-shadow: 0 0 10px rgba(255, 255, 0, 0.5); + z-index: 6; + transition: opacity 0.5s; +} + +/* Level Complete / Game Over */ +#level-complete, #game-over, #game-won { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.85); + z-index: 10; +} + +#level-complete h2, #game-won h2 { + font-size: 4rem; + color: #0f0; + text-shadow: 0 0 20px rgba(0, 255, 0, 0.5); +} + +#game-over h2 { + font-size: 5rem; + color: #c00; + text-shadow: 0 0 30px #f00; +} + +#level-complete p, #game-over p, #game-won p { + margin-top: 1rem; + color: #aaa; + font-size: 1.2rem; +} + +.hidden { + display: none !important; +}