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:
+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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user