source
<!DOCTYPE html>
<!-- theres a freeze when finishing level for some reason (generating new level?) -->
<html>
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Wolfenstein 3D: Dithered Fog</title>
<style>
body { background: #111; color: #ddd; font-family: 'Courier New', monospace; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; }
#screen { border: 4px solid #333; image-rendering: pixelated; background: #000; width: 800px; height: 600px; cursor: none; }
#hud { display: flex; justify-content: space-between; width: 800px; padding: 12px; background: #222; font-size: 20px; font-weight: bold; border: 2px solid #333; border-top: 0; box-sizing: border-box;}
.red { color: #ff5555; } .green { color: #55ff55; } .gold { color: gold; }
#msg { position: absolute; top: 40%; font-size: 60px; text-shadow: 4px 4px #000; display: none; pointer-events: none; z-index: 10; font-family: impact, sans-serif; text-transform: uppercase; color: #fff;}
</style>
</head>
<body>
<canvas id="screen" width="320" height="240"></canvas>
<div id="hud">
<span class="green">HP: <span id="health-disp">100</span>%</span>
<span class="gold">FLOOR <span id="level-disp">1</span></span>
</div>
<div id="msg"></div>
<script>
// --- CONSTANTS ---
const SCREEN_W = 320;
const SCREEN_H = 240;
const TEX_SIZE = 64;
const TICK = 1000/30;
// --- FOG CONFIGURATION ---
// 4x4 Ordered Dither Matrix
const BAYER = [
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
];
// Start fogging at 1.5 blocks away, become solid black at ~7 blocks
const FOG_START = 7.0;
const FOG_DENSITY = 2.8;
// --- GLOBAL STATE ---
const canvas = document.getElementById('screen');
const ctx = canvas.getContext('2d', { alpha: false });
const imgData = ctx.createImageData(SCREEN_W, SCREEN_H);
const buf = new Uint32Array(imgData.data.buffer);
let keys = {};
let map = [];
let mapW = 32;
let mapH = 32;
let levelNum = 1;
let sprites = [];
let projectiles = [];
let zBuffer = new Array(SCREEN_W).fill(0);
// Textures
let texWall, texFloor, texCeil;
let player = {
x: 0, y: 0,
dirX: -1, dirY: 0,
planeX: 0, planeY: 0.66,
speed: 0.12, rotSpeed: 0.06,
health: 100
};
let gameState = "PLAY";
let flashTimer = 0;
// --- ASSET GENERATION ---
const toCol = (r, g, b) => (255 << 24) | (b << 16) | (g << 8) | r;
function genTexture(type) {
let data = new Uint32Array(TEX_SIZE * TEX_SIZE);
for(let y=0; y<TEX_SIZE; y++) {
for(let x=0; x<TEX_SIZE; x++) {
let c;
if(type === 0) { // Bricks
let isMortar = (y%16 < 2) || ((Math.floor(y/16)%2===0 ? x : x+16) % 32 < 2);
let noise = Math.random() * 20;
c = isMortar ? toCol(50,50,50) : toCol(140+noise, 60+noise, 60+noise);
} else if (type === 1) { // Floor
let tile = ((x/16)|0 + (y/16)|0) % 2;
let noise = Math.random() * 10;
let b = tile ? 80 : 100;
c = toCol(b+noise, b+noise, b+noise);
} else { // Ceiling
let grain = (x%4) * 5 + Math.random()*10;
c = toCol(60+grain, 40+grain, 20);
}
data[y*TEX_SIZE + x] = c;
}
}
return data;
}
// --- LEVEL GENERATION ---
function generateLevel(level) {
let size = 30 + level * 4;
mapW = size; mapH = size;
map = [];
for(let y=0; y<size; y++) map.push(new Array(size).fill(1));
let floorTiles = [];
let walkers = [{x: (size/2)|0, y: (size/2)|0}];
let maxFloors = Math.floor(size*size * 0.35);
while(floorTiles.length < maxFloors && walkers.length > 0) {
for(let i = walkers.length-1; i>=0; i--) {
let w = walkers[i];
if(w.x>1 && w.x<size-2 && w.y>1 && w.y<size-2) {
if(map[w.y][w.x] === 1) {
map[w.y][w.x] = 0;
floorTiles.push({x: w.x, y: w.y});
}
}
let dir = Math.floor(Math.random() * 4);
if(dir===0) w.y--; else if(dir===1) w.y++;
else if(dir===2) w.x--; else w.x++;
if(Math.random() < 0.05 && walkers.length < 5) walkers.push({x: w.x, y: w.y});
if(Math.random() < 0.02 && walkers.length > 1) walkers.splice(i, 1);
}
}
sprites = [];
player.x = floorTiles[0].x + 0.5;
player.y = floorTiles[0].y + 0.5;
player.dirX = -1; player.dirY = 0; player.planeX = 0; player.planeY = 0.66;
let exitTile = floorTiles[floorTiles.length-1];
sprites.push({x: exitTile.x+0.5, y: exitTile.y+0.5, type: 4, active: true});
for(let i=1; i<floorTiles.length-1; i++) {
let t = floorTiles[i];
if(Math.random() < 0.05 + (level*0.01)) {
if(Math.sqrt((t.x-player.x)**2 + (t.y-player.y)**2) > 5)
sprites.push({x: t.x+0.5, y: t.y+0.5, type: 2, active: true, state: 0, timer: 0});
} else if (Math.random() < 0.02) {
sprites.push({x: t.x+0.5, y: t.y+0.5, type: 3, active: true});
}
}
document.getElementById('level-disp').innerText = level;
document.getElementById('msg').style.display = "none";
}
function init() {
texWall = genTexture(0);
texFloor = genTexture(1);
texCeil = genTexture(2);
generateLevel(levelNum);
window.addEventListener('keydown', e => keys[e.code] = true);
window.addEventListener('keyup', e => keys[e.code] = false);
setInterval(gameLoop, TICK);
}
// --- LOGIC ---
function hasLineOfSight(x1, y1, x2, y2) {
let dist = Math.sqrt((x2-x1)**2 + (y2-y1)**2);
let steps = Math.ceil(dist * 2);
let dx = (x2-x1)/steps, dy = (y2-y1)/steps;
let cx = x1, cy = y1;
for(let i=0; i<steps; i++) {
cx += dx; cy += dy;
if(map[Math.floor(cy)][Math.floor(cx)] === 1) return false;
}
return true;
}
function nextLevel() {
levelNum++;
gameState = "TRANSITION";
let msg = document.getElementById('msg');
msg.innerHTML = "<span class='gold'>FLOOR CLEARED</span>";
msg.style.display = 'block';
msg.style.left = "200px";
setTimeout(() => { generateLevel(levelNum); gameState = "PLAY"; }, 2000);
}
function update() {
if(gameState !== "PLAY") return;
let rot = 0;
if (keys['ArrowRight'] || keys['KeyE']) rot = -player.rotSpeed;
if (keys['ArrowLeft'] || keys['KeyQ']) rot = player.rotSpeed;
if (rot) {
let oldDirX = player.dirX;
player.dirX = player.dirX * Math.cos(rot) - player.dirY * Math.sin(rot);
player.dirY = oldDirX * Math.sin(rot) + player.dirY * Math.cos(rot);
let oldPlaneX = player.planeX;
player.planeX = player.planeX * Math.cos(rot) - player.planeY * Math.sin(rot);
player.planeY = oldPlaneX * Math.sin(rot) + player.planeY * Math.cos(rot);
}
let moveX = 0, moveY = 0;
if (keys['KeyW'] || keys['ArrowUp']) { moveX += player.dirX; moveY += player.dirY; }
if (keys['KeyS'] || keys['ArrowDown']) { moveX -= player.dirX; moveY -= player.dirY; }
if (keys['KeyD']) { moveX += player.dirY; moveY -= player.dirX; }
if (keys['KeyA']) { moveX -= player.dirY; moveY += player.dirX; }
if (moveX || moveY) {
let len = Math.sqrt(moveX*moveX + moveY*moveY);
moveX = (moveX/len)*player.speed; moveY = (moveY/len)*player.speed;
if(map[Math.floor(player.y)][Math.floor(player.x+moveX*3)] === 0) player.x += moveX;
if(map[Math.floor(player.y+moveY*3)][Math.floor(player.x)] === 0) player.y += moveY;
}
if (keys['Space']) {
keys['Space'] = false; flashTimer = 4;
sprites.forEach(s => {
if(s.type === 2 && s.active) {
let dx = s.x - player.x, dy = s.y - player.y;
let spriteDir = Math.atan2(dy, dx) - Math.atan2(player.dirY, player.dirX);
while(spriteDir < -Math.PI) spriteDir += 2*Math.PI;
while(spriteDir > Math.PI) spriteDir -= 2*Math.PI;
if (Math.abs(spriteDir) < 0.25 && Math.sqrt(dx*dx+dy*dy) < 8 && hasLineOfSight(player.x, player.y, s.x, s.y)) {
s.active = false;
}
}
});
}
for(let i = projectiles.length - 1; i >= 0; i--) {
let p = projectiles[i];
p.x += p.dx; p.y += p.dy;
if(map[Math.floor(p.y)][Math.floor(p.x)] === 1) { projectiles.splice(i, 1); continue; }
if(Math.sqrt((p.x-player.x)**2 + (p.y-player.y)**2) < 0.3) {
player.health -= 10;
projectiles.splice(i, 1);
for(let j=0; j<buf.length; j+=4) buf[j] |= 0xFF0000FF;
}
}
sprites.forEach(s => {
if(!s.active) return;
let dx = player.x - s.x, dy = player.y - s.y, dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 0.6) {
if(s.type === 3) { player.health = Math.min(100, player.health + 25); s.active = false; }
if(s.type === 4) nextLevel();
}
if(s.type === 2) {
if (dist < 12 && hasLineOfSight(s.x, s.y, player.x, player.y)) {
if(s.state === 0) { s.state = 1; s.timer = 15; }
if(s.state === 1) {
if(dist > 3) { s.x += (dx/dist)*0.04; s.y += (dy/dist)*0.04; }
if(s.timer > 0) s.timer--;
else {
projectiles.push({ x: s.x, y: s.y, dx: (dx/dist)*0.25, dy: (dy/dist)*0.25 });
s.timer = 40;
}
}
} else s.state = 0;
}
});
document.getElementById('health-disp').innerText = Math.floor(player.health);
if(player.health <= 0) {
gameState = "LOSE";
let msg = document.getElementById('msg');
msg.innerHTML = "<span class='red'>YOU DIED</span><br><span style='font-size:30px'>REFRESH TO RESTART</span>";
msg.style.display = 'block';
msg.style.left = "280px";
}
}
// --- RENDERER ---
function draw() {
buf.fill(0);
// 1. FLOOR & CEILING CASTING WITH FOG
for(let y = 0; y < SCREEN_H; y++) {
let isFloor = y > SCREEN_H / 2;
let rayDirX0 = player.dirX - player.planeX;
let rayDirY0 = player.dirY - player.planeY;
let rayDirX1 = player.dirX + player.planeX;
let rayDirY1 = player.dirY + player.planeY;
let p = isFloor ? (y - SCREEN_H / 2) : (SCREEN_H / 2 - y);
let posZ = 0.5 * SCREEN_H;
let rowDist = posZ / p;
let floorStepX = rowDist * (rayDirX1 - rayDirX0) / SCREEN_W;
let floorStepY = rowDist * (rayDirY1 - rayDirY0) / SCREEN_W;
let floorX = player.x + rowDist * rayDirX0;
let floorY = player.y + rowDist * rayDirY0;
// Calculate Base Fog Value for this row
// (dist - start) * density.
let fogVal = (rowDist - FOG_START) * FOG_DENSITY;
for(let x = 0; x < SCREEN_W; x++) {
// BAYER DITHER CHECK
// Get bayer value (0-15) based on screen coordinates modulo 4
// ((y & 3) << 2) + (x & 3) is optimized (y%4)*4 + (x%4)
let bayerThreshold = BAYER[((y & 3) << 2) + (x & 3)];
if (fogVal > bayerThreshold) {
// Render Black (Full Opacity)
buf[y * SCREEN_W + x] = 0xFF000000;
} else {
let tx = Math.floor(TEX_SIZE * (floorX - Math.floor(floorX))) & (TEX_SIZE - 1);
let ty = Math.floor(TEX_SIZE * (floorY - Math.floor(floorY))) & (TEX_SIZE - 1);
let color = isFloor ? texFloor[TEX_SIZE * ty + tx] : texCeil[TEX_SIZE * ty + tx];
buf[y * SCREEN_W + x] = color;
}
floorX += floorStepX;
floorY += floorStepY;
}
}
// 2. WALL CASTING WITH FOG
for(let x = 0; x < SCREEN_W; x++) {
let cameraX = 2 * x / SCREEN_W - 1;
let rayDirX = player.dirX + player.planeX * cameraX;
let rayDirY = player.dirY + player.planeY * cameraX;
let mapX = Math.floor(player.x), mapY = Math.floor(player.y);
let sideDistX, sideDistY, deltaDistX = Math.abs(1/rayDirX), deltaDistY = Math.abs(1/rayDirY);
let stepX, stepY, hit = 0, side;
if (rayDirX < 0) { stepX = -1; sideDistX = (player.x - mapX) * deltaDistX; }
else { stepX = 1; sideDistX = (mapX + 1.0 - player.x) * deltaDistX; }
if (rayDirY < 0) { stepY = -1; sideDistY = (player.y - mapY) * deltaDistY; }
else { stepY = 1; sideDistY = (mapY + 1.0 - player.y) * deltaDistY; }
while (hit === 0) {
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; }
else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
if (map[mapY][mapX] > 0) hit = 1;
}
let perpWallDist = (side === 0) ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
zBuffer[x] = perpWallDist;
let lineHeight = Math.floor(SCREEN_H / perpWallDist);
let drawStart = -lineHeight / 2 + SCREEN_H / 2;
if(drawStart < 0) drawStart = 0;
let drawEnd = lineHeight / 2 + SCREEN_H / 2;
if(drawEnd >= SCREEN_H) drawEnd = SCREEN_H - 1;
let wallX = (side == 0) ? player.y + perpWallDist * rayDirY : player.x + perpWallDist * rayDirX;
wallX -= Math.floor(wallX);
let texX = Math.floor(wallX * TEX_SIZE) & (TEX_SIZE - 1);
if(side == 0 && rayDirX > 0) texX = TEX_SIZE - texX - 1;
if(side == 1 && rayDirY < 0) texX = TEX_SIZE - texX - 1;
let step = 1.0 * TEX_SIZE / lineHeight;
let texPos = (drawStart - SCREEN_H / 2 + lineHeight / 2) * step;
// Calculate Fog Value for this wall strip
let fogVal = (perpWallDist - FOG_START) * FOG_DENSITY;
for(let y = Math.floor(drawStart); y < Math.floor(drawEnd); y++) {
// BAYER DITHER CHECK
let bayerThreshold = BAYER[((y & 3) << 2) + (x & 3)];
if (fogVal > bayerThreshold) {
buf[y * SCREEN_W + x] = 0xFF000000;
} else {
let texY = Math.floor(texPos) & (TEX_SIZE - 1);
texPos += step;
let color = texWall[TEX_SIZE * texY + texX];
if(side === 1) {
color = ((color >>> 1) & 0x7F7F7F7F) | 0xFF000000;
}
buf[y * SCREEN_W + x] = color;
}
}
}
ctx.putImageData(imgData, 0, 0);
// 3. SPRITES
// Sprites use Canvas API, so we simulate fog with CSS brightness filter
// because manual pixel rasterization for sprites is too slow/complex here.
let renderList = sprites.filter(s => s.active).map(s => ({...s, isProj: false}));
projectiles.forEach(p => renderList.push({x: p.x, y: p.y, type: 5, isProj: true}));
renderList.forEach(s => s.dist = ((player.x - s.x)**2 + (player.y - s.y)**2));
renderList.sort((a,b) => b.dist - a.dist);
renderList.forEach(s => {
let dx = s.x - player.x, dy = s.y - player.y;
let invDet = 1.0 / (player.planeX * player.dirY - player.dirX * player.planeY);
let transformX = invDet * (player.dirY * dx - player.dirX * dy);
let transformY = invDet * (-player.planeY * dx + player.planeX * dy);
let spriteScreenX = Math.floor((SCREEN_W / 2) * (1 + transformX / transformY));
let spriteScale = Math.abs(Math.floor(SCREEN_H / transformY));
let div = s.isProj ? 4 : 1;
let size = spriteScale / div;
if(transformY > 0 && transformY < zBuffer[Math.min(Math.max(spriteScreenX, 0), SCREEN_W-1)]) {
let xPos = spriteScreenX - size/2;
let yPos = (SCREEN_H/2) - size/2 + (s.isProj ? size : 0);
// FOG: Apply brightness filter based on distance
// We use transformY (distance) to dim the sprite
let dist = transformY;
// Match the fog start/density logic roughly
let dim = Math.max(0, 100 - (dist - FOG_START) * 20);
ctx.filter = `brightness(${dim}%)`;
if (dim > 0) { // Don't draw if fully black
if(s.type === 2) {
ctx.fillStyle = s.state > 0 ? "#ff0000" : "#800000";
ctx.fillRect(xPos, yPos, size, size);
ctx.fillStyle = "#000";
ctx.fillRect(xPos+size*0.2, yPos+size*0.3, size*0.2, size*0.2);
ctx.fillRect(xPos+size*0.6, yPos+size*0.3, size*0.2, size*0.2);
}
else if(s.type === 3) {
ctx.fillStyle = "#00ff00"; ctx.fillRect(xPos, yPos, size, size);
ctx.fillStyle = "#fff"; ctx.fillRect(xPos+size*0.4, yPos+size*0.2, size*0.2, size*0.6);
ctx.fillRect(xPos+size*0.2, yPos+size*0.4, size*0.6, size*0.2);
}
else if(s.type === 4) {
ctx.fillStyle = "gold"; ctx.fillRect(xPos, yPos, size, size);
ctx.strokeStyle = "#fff"; ctx.lineWidth=2; ctx.strokeRect(xPos, yPos, size, size);
}
else if(s.type === 5) {
ctx.fillStyle = "orange"; ctx.beginPath(); ctx.arc(xPos+size/2, yPos+size/2, size/2, 0, 6.28); ctx.fill();
}
}
ctx.filter = "none"; // Reset filter
}
});
if(flashTimer > 0) { ctx.fillStyle = `rgba(255,255,200,${flashTimer/5})`; ctx.fillRect(0,0,SCREEN_W,SCREEN_H); flashTimer--; }
ctx.fillStyle = "#555"; ctx.fillRect(SCREEN_W/2 - 20, SCREEN_H - 120, 40, 120);
ctx.fillStyle = "#222"; ctx.fillRect(SCREEN_W/2 - 5, SCREEN_H - 120, 10, 80);
}
function gameLoop() { update(); draw(); }
init();
</script>
</body>
</html>
remixes
no remixes yet... email to remix@gameslop.net
