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>
This commit is contained in:
2026-03-16 21:27:43 +00:00
co-authored by Claude Opus 4.6
commit c55d7a7475
15 changed files with 3123 additions and 0 deletions
+253
View File
@@ -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;
}
};
+45
View File
@@ -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';
+300
View File
@@ -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;
}
}
+455
View File
@@ -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}%)<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));
}
}
+177
View File
@@ -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 },
]
}
];
+10
View File
@@ -0,0 +1,10 @@
// Entry point
window.addEventListener('DOMContentLoaded', () => {
// Initialize procedural assets
initTextures();
initSprites();
// Create and start game
const game = new Game();
game.start();
});
+177
View File
@@ -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;
}
}
+240
View File
@@ -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();
}
}
}
+400
View File
@@ -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');
}
+324
View File
@@ -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;
}
}
+321
View File
@@ -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();
}
}