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:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DOOM Clone</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container">
|
||||
<canvas id="game-canvas"></canvas>
|
||||
<div id="title-screen">
|
||||
<h1>DOOM</h1>
|
||||
<h2>CLONE</h2>
|
||||
<p class="subtitle">A Browser-Based FPS</p>
|
||||
<div class="menu">
|
||||
<button id="btn-new-game" class="menu-btn">NEW GAME</button>
|
||||
<button id="btn-controls" class="menu-btn">CONTROLS</button>
|
||||
</div>
|
||||
<div id="controls-info" class="hidden">
|
||||
<p>WASD - Move</p>
|
||||
<p>Mouse - Look</p>
|
||||
<p>Left Click - Shoot</p>
|
||||
<p>1/2/3 - Switch Weapons</p>
|
||||
<p>E - Open Doors / Interact</p>
|
||||
<p>ESC - Pause</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hud" class="hidden">
|
||||
<div id="hud-left">
|
||||
<div class="hud-item">
|
||||
<span class="hud-label">HEALTH</span>
|
||||
<div class="hud-bar"><div id="health-bar" class="bar-fill health-fill"></div></div>
|
||||
<span id="health-value" class="hud-value">100</span>
|
||||
</div>
|
||||
<div class="hud-item">
|
||||
<span class="hud-label">ARMOR</span>
|
||||
<div class="hud-bar"><div id="armor-bar" class="bar-fill armor-fill"></div></div>
|
||||
<span id="armor-value" class="hud-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hud-center">
|
||||
<div id="crosshair">+</div>
|
||||
<div id="weapon-display"></div>
|
||||
<div id="damage-overlay" class="hidden"></div>
|
||||
</div>
|
||||
<div id="hud-right">
|
||||
<div class="hud-item">
|
||||
<span class="hud-label">AMMO</span>
|
||||
<span id="ammo-value" class="hud-value ammo-text">999</span>
|
||||
</div>
|
||||
<div class="hud-item">
|
||||
<span class="hud-label">WEAPON</span>
|
||||
<span id="weapon-name" class="hud-value weapon-text">PISTOL</span>
|
||||
</div>
|
||||
<div class="hud-item">
|
||||
<span class="hud-label">KILLS</span>
|
||||
<span id="kills-value" class="hud-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="minimap-container" class="hidden">
|
||||
<canvas id="minimap-canvas"></canvas>
|
||||
</div>
|
||||
<div id="message-display" class="hidden"></div>
|
||||
<div id="level-complete" class="hidden">
|
||||
<h2>LEVEL COMPLETE</h2>
|
||||
<p id="level-stats"></p>
|
||||
<button id="btn-next-level" class="menu-btn">NEXT LEVEL</button>
|
||||
</div>
|
||||
<div id="game-over" class="hidden">
|
||||
<h2>YOU DIED</h2>
|
||||
<p>Press ENTER to restart</p>
|
||||
</div>
|
||||
<div id="game-won" class="hidden">
|
||||
<h2>VICTORY!</h2>
|
||||
<p>You have conquered all levels!</p>
|
||||
<p id="final-stats"></p>
|
||||
<button id="btn-play-again" class="menu-btn">PLAY AGAIN</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/constants.js"></script>
|
||||
<script src="js/textures.js"></script>
|
||||
<script src="js/sprites.js"></script>
|
||||
<script src="js/levels.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/raycaster.js"></script>
|
||||
<script src="js/weapons.js"></script>
|
||||
<script src="js/enemies.js"></script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/game.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+253
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
+289
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user