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