DOOM
Deepseek v4 Flash Vision Exp · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 36,105 bytes · SHA-256 54bd7940a9d5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>HELLFALL — Retro FPS</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; overflow:hidden; background:#000; }
body { font-family:'Courier New',monospace; }
#game {
display:block;
width:100vw; height:100vh;
background:#000;
image-rendering:pixelated;
cursor:crosshair;
}
.overlay {
position:fixed; inset:0;
display:flex; flex-direction:column; align-items:center; justify-content:center;
text-align:center; color:#e8e8e8;
background:rgba(6,6,10,.82);
z-index:10; user-select:none;
}
.overlay.hidden { display:none; }
.title {
font-size:7vmin; font-weight:900; letter-spacing:.18em;
color:#ff4d2e; text-shadow:0 0 18px rgba(255,60,20,.7), 0 0 40px rgba(255,40,10,.4);
margin-bottom:1vmin;
}
.sub { font-size:2.4vmin; color:#8fd0ff; letter-spacing:.3em; margin-bottom:4vmin; }
.panel {
background:rgba(10,12,18,.72);
border:1px solid #33404f; border-radius:8px;
padding:2.6vmin 4vmin; max-width:700px;
box-shadow:0 0 30px rgba(0,0,0,.6) inset;
}
.panel h2 { font-size:3vmin; color:#ffcf5e; margin-bottom:1.6vmin; }
.keys { font-size:1.9vmin; line-height:2.2; color:#cfd6dd; }
.keys b { color:#7dff8a; }
.btn {
margin-top:3.4vmin; font-family:inherit; font-size:2.6vmin; font-weight:700;
letter-spacing:.16em; color:#0a0a0a; background:#ffb52e; border:none;
padding:1.4vmin 4vmin; border-radius:6px; cursor:pointer;
box-shadow:0 4px 0 #8a5b00;
}
.btn:hover { background:#ffcf5e; }
.btn:active { transform:translateY(3px); box-shadow:0 1px 0 #8a5b00; }
#deadTitle { color:#ff2f2f; font-size:8vmin; text-shadow:0 0 24px rgba(255,0,0,.8); }
#winTitle { color:#5eff8a; font-size:8vmin; text-shadow:0 0 24px rgba(60,255,120,.8); }
.stat { font-size:2.4vmin; color:#cfd6dd; line-height:1.8; }
.flash { position:fixed; inset:0; pointer-events:none; z-index:5; opacity:0; }
#hurtFlash { background:radial-gradient(ellipse at center, rgba(255,0,0,0) 45%, rgba(180,0,0,.55) 100%); }
#pickFlash { background:radial-gradient(ellipse at center, rgba(80,255,120,0) 50%, rgba(60,220,120,.35) 100%); }
#scan {
position:fixed; inset:0; pointer-events:none; z-index:4; mix-blend-mode:overlay;
background:repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0px, rgba(0,0,0,0) 2px, rgba(0,0,0,.28) 3px, rgba(0,0,0,0) 4px);
opacity:.5;
}
</style>
</head>
<body>
<canvas id="game" width="960" height="600"></canvas>
<div class="flash" id="hurtFlash"></div>
<div class="flash" id="pickFlash"></div>
<div id="scan"></div>
<div class="overlay" id="menuOverlay">
<div class="title">HELLFALL</div>
<div class="sub">A RETRO RAYCASTING FPS</div>
<div class="panel">
<h2>HOW TO PLAY</h2>
<div class="keys">
<b>W A S D</b> — Move <b>MOUSE</b> — Look / Aim<br>
<b>CLICK</b> — Fire <b>1 / 2 / 3</b> or <b>WHEEL</b> — Switch Weapon<br>
<b>SHIFT</b> — Run <b>ESC</b> — Pause / Release mouse
</div>
</div>
<button class="btn" id="startBtn">CLICK TO DESCEND</button>
</div>
<div class="overlay hidden" id="deadOverlay">
<div id="deadTitle">YOU DIED</div>
<div class="stat" id="deadStat"></div>
<button class="btn" id="respawnBtn">RESPAWN</button>
</div>
<div class="overlay hidden" id="winOverlay">
<div id="winTitle">HELL IS PURGED</div>
<div class="stat" id="winStat"></div>
<button class="btn" id="playAgainBtn">PLAY AGAIN</button>
</div>
<script>
"use strict";
/* ============================================================
HELLFALL — a retro raycasting FPS (single file)
============================================================ */
const CV = document.getElementById('game');
const CTX = CV.getContext('2d', { alpha:false });
let W = 0, H = 0;
function resize(){
W = CV.width = Math.min(window.innerWidth, 1700);
H = CV.height = Math.min(window.innerHeight, 1000);
}
window.addEventListener('resize', resize);
resize();
/* ---------- config ---------- */
const FOV = 0.66; // camera plane half-width (66 deg)
const TEX_SIZE = 64;
const MAX_DEPTH = 22;
const PLAYER_R = 0.22;
/* ---------- map ---------- */
const MAP = [
"111111111111111111111111",
"100000000000000000000001",
"100111000000000011100001",
"100111000000000011100001",
"100000000000000000000001",
"100000111000000111000001",
"100000111000000111000001",
"100000000000000000000001",
"100000000111100000000001",
"100000000111100000000001",
"100000000000000000000001",
"100111000000000001110001",
"100111000000000001110001",
"100000000000000000000001",
"100000001110000000000001",
"100000001110000011100001",
"100000000000000011100001",
"111111111111111111111111",
];
const mapW = MAP[0].length;
const mapH = MAP.length;
function solid(x,y){
x = Math.floor(x); y = Math.floor(y);
if (x<0||y<0||x>=mapW||y>=mapH) return true;
return MAP[y][x] === '1';
}
/* ---------- deterministic pseudo-random for texture gen ---------- */
let _seed = 1337;
function rnd(){ _seed = (_seed * 1103515245 + 12345) & 0x7fffffff; return _seed / 0x7fffffff; }
function rr(a,b){ return a + rnd()*(b-a); }
function ri(a,b){ return Math.floor(rr(a,b+1)); }
function makeTex(w,h,draw){
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d');
draw(g);
return c;
}
function shadeTex(canvas, amt){
const c = document.createElement('canvas');
c.width = canvas.width; c.height = canvas.height;
const g = c.getContext('2d');
g.drawImage(canvas,0,0);
g.fillStyle = `rgba(0,0,0,${amt})`;
g.fillRect(0,0,c.width,c.height);
return c;
}
/* ---------- wall textures ---------- */
const TEX = [];
function buildTextures(){
// 0: demon brick
TEX[0] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
g.fillStyle='#4a1d12'; g.fillRect(0,0,64,64);
const bh=8, bw=16;
for(let y=0;y<64;y+=bh){
const off = (y/bh)%2 ? bw/2 : 0;
for(let x=-bw;x<64;x+=bw){
const shade = 0.75 + rnd()*0.4;
g.fillStyle=`rgb(${Math.floor(120*shade)},${Math.floor(55*shade)},${Math.floor(34*shade)})`;
g.fillRect(x+off+1, y+1, bw-2, bh-2);
g.fillStyle='rgba(255,60,20,0.08)';
g.fillRect(x+off+1, y+1, bw-2, 2);
}
}
// cracks / embers
for(let i=0;i<12;i++){
g.fillStyle=`rgba(255,${ri(60,140)},20,${rr(.2,.6)})`;
const x=ri(2,58), y=ri(2,58);
g.fillRect(x,y+ri(0,4),ri(2,5),1);
}
});
// 1: stone
TEX[1] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
g.fillStyle='#3a3a40'; g.fillRect(0,0,64,64);
for(let i=0;i<220;i++){
const sh=rr(.55,1.15);
g.fillStyle=`rgb(${Math.floor(70*sh)},${Math.floor(70*sh)},${Math.floor(76*sh)})`;
g.fillRect(ri(0,63),ri(0,63),ri(2,6),ri(2,6));
}
g.strokeStyle='rgba(0,0,0,.6)'; g.lineWidth=2;
for(let y=0;y<=64;y+=16){ g.beginPath(); g.moveTo(0,y+.5); g.lineTo(64,y+.5); g.stroke(); }
});
// 2: metal panels
TEX[2] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
g.fillStyle='#4a4f52'; g.fillRect(0,0,64,64);
for(let y=0;y<64;y+=32){
for(let x=0;x<64;x+=32){
const sh=rr(.8,1.1);
g.fillStyle=`rgb(${Math.floor(90*sh)},${Math.floor(94*sh)},${Math.floor(98*sh)})`;
g.fillRect(x+2,y+2,28,28);
g.strokeStyle='#26292b'; g.lineWidth=2; g.strokeRect(x+2,y+2,28,28);
g.fillStyle='#1b1d1f';
g.fillRect(x+5,y+5,3,3); g.fillRect(x+24,y+5,3,3);
g.fillRect(x+5,y+24,3,3); g.fillRect(x+24,y+24,3,3);
}
}
});
// 3: tech (exit wall)
TEX[3] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
g.fillStyle='#14261f'; g.fillRect(0,0,64,64);
for(let i=0;i<26;i++){
g.fillStyle=`rgba(60,220,120,${rr(.05,.25)})`;
g.fillRect(ri(0,60),ri(0,60),ri(2,10),ri(1,3));
}
g.strokeStyle='#2f8f5a'; g.lineWidth=2;
g.strokeRect(4,4,56,56); g.strokeRect(9,9,46,46);
});
// 4: black rock
TEX[4] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
g.fillStyle='#1c1c20'; g.fillRect(0,0,64,64);
for(let i=0;i<160;i++){
const sh=rr(.4,1.1);
g.fillStyle=`rgb(${Math.floor(40*sh)},${Math.floor(40*sh)},${Math.floor(46*sh)})`;
g.fillRect(ri(0,60),ri(0,60),ri(3,9),ri(3,9));
}
});
}
function wallTexIndex(x,y){
const n = ((x*7 + y*13) + Math.floor(x/3)+Math.floor(y/3)) % 3;
return TEX[n];
}
/* ---------- demo demons (enemy sprites, 2 frames) ---------- */
function makeDemon(frame){
return makeTex(64,64,g=>{
// shadow
g.fillStyle='rgba(0,0,0,.4)'; g.beginPath(); g.ellipse(32,58,20,5,0,0,7); g.fill();
// body
g.fillStyle='#5a2b1e'; g.beginPath();
g.moveTo(14,52); g.quadraticCurveTo(10,26,32,22); g.quadraticCurveTo(54,26,50,52); g.closePath(); g.fill();
// belly
g.fillStyle='#7a3b26'; g.beginPath(); g.ellipse(32,44,12,12,0,0,7); g.fill();
// horns
g.fillStyle='#d8c9a0';
g.beginPath(); g.moveTo(20,22); g.lineTo(14,8); g.lineTo(26,18); g.closePath(); g.fill();
g.beginPath(); g.moveTo(44,22); g.lineTo(50,8); g.lineTo(38,18); g.closePath(); g.fill();
// head
g.fillStyle='#6b3524'; g.beginPath(); g.ellipse(32,18,11,9,0,0,7); g.fill();
// eyes (glow)
const bob = frame===1 ? 1 : 0;
g.fillStyle='#ffd23a';
g.beginPath(); g.ellipse(27,18+bob,2.6,2.6,0,0,7); g.fill();
g.beginPath(); g.ellipse(37,18+bob,2.6,2.6,0,0,7); g.fill();
// mouth
g.fillStyle='#2a0d06'; g.beginPath(); g.ellipse(32,26,5,2.6,0,0,7); g.fill();
g.fillStyle='#fff'; g.fillRect(30,24,1,3); g.fillRect(33,24,1,3);
// arms
g.fillStyle='#5a2b1e';
g.save();
if(frame===1){ g.translate(16,34); g.rotate(-.5); } else { g.translate(18,34); g.rotate(.1); }
g.fillRect(-3,0,6,18); g.restore();
g.save();
if(frame===1){ g.translate(48,34); g.rotate(.5); } else { g.translate(46,34); g.rotate(-.1); }
g.fillRect(-3,0,6,18); g.restore();
// claws
g.fillStyle='#d8c9a0';
g.fillRect(22,52,3,6); g.fillRect(27,53,3,6); g.fillRect(32,52,3,6);
});
}
const demonTex = [makeDemon(0), makeDemon(1)];
/* ---------- portal / pickup sprites ---------- */
const portalTex = makeTex(64,64,g=>{
g.fillStyle='rgba(0,0,0,0)'; g.clearRect(0,0,64,64);
const grad=g.createRadialGradient(32,32,2,32,32,30);
grad.addColorStop(0,'rgba(180,255,220,1)');
grad.addColorStop(.5,'rgba(40,220,160,.9)');
grad.addColorStop(1,'rgba(0,90,70,0)');
g.fillStyle=grad; g.beginPath(); g.arc(32,32,30,0,7); g.fill();
g.strokeStyle='rgba(255,255,255,.8)'; g.lineWidth=2;
g.beginPath(); g.arc(32,32,26,0,7); g.stroke();
for(let i=0;i<6;i++){ const a=i/6*Math.PI*2; g.strokeStyle='rgba(255,255,255,.3)';
g.beginPath(); g.moveTo(32,32); g.lineTo(32+Math.cos(a)*24,32+Math.sin(a)*24); g.stroke(); }
});
const medkitTex = makeTex(32,32,g=>{
g.fillStyle='#e8e8e8'; g.fillRect(2,4,28,24);
g.fillStyle='#b9bcc0'; g.fillRect(4,28,24,4);
g.fillStyle='#e0392e'; g.fillRect(13,8,6,18); g.fillRect(8,13,16,6);
g.strokeStyle='#888'; g.strokeRect(2.5,4.5,27,23);
});
const shellTex = makeTex(32,32,g=>{
g.fillStyle='#c8541f'; g.fillRect(2,10,28,20);
g.fillStyle='#f2d24b'; g.fillRect(4,12,24,4);
g.fillStyle='#7a3220'; g.fillRect(6,16,6,3); g.fillRect(15,16,6,3); g.fillRect(24,16,6,3);
g.strokeStyle='#6b2a14'; g.strokeRect(2.5,10.5,27,19);
});
const cellTex = makeTex(32,32,g=>{
g.fillStyle='#1a3a55'; g.fillRect(2,6,28,22);
g.fillStyle='#33c8ff'; g.save(); g.translate(16,17); g.rotate(Math.PI/4); g.fillRect(-6,-6,12,12); g.restore();
g.fillStyle='#cff6ff'; g.save(); g.translate(16,17); g.rotate(Math.PI/4); g.fillRect(-3,-3,6,6); g.restore();
g.strokeStyle='#0c2740'; g.strokeRect(2.5,6.5,27,21);
});
/* ---------- game state ---------- */
const game = {
state:'menu', // menu | playing | dead | win
time:0,
player:{ x:2.5, y:15.5, angle:0, hp:100, maxhp:100, speed:3.2 },
weapons:[
{ name:'PISTOL', key:'P', id:0, dmg:14, ammo:Infinity, maxAmmo:Infinity, cooldown:0.30, spread:0.012 },
{ name:'SHOTGUN', key:'S', id:1, dmg:11, ammo:24, maxAmmo:60, cooldown:0.70, spread:0.055, pellets:5 },
{ name:'PLASMA', key:'E', id:2, dmg:9, ammo:40, maxAmmo:200, cooldown:0.11, spread:0.006 },
],
current:0,
enemies:[], pickups:[],
exit:{ x:20.5, y:1.5, active:false },
kills:0, total:0,
keys:{}, firing:false, cooldown:0, recoil:0, muzzle:0,
hurt:0, pickupFx:0, shake:0, bob:0,
};
function resetLevel(){
game.player.x=2.5; game.player.y=15.5; game.player.angle=0;
game.player.hp=100;
game.weapons[1].ammo=24; game.weapons[2].ammo=40;
game.current=0; game.cooldown=0; game.kills=0;
game.exit.active=false; game.shake=0; game.hurt=0;
game.enemies = [
{x:5.5,y:4.5},{x:12.5,y:2.5},{x:12.5,y:5.5},{x:6.5,y:8.5},
{x:18.5,y:10.5},{x:6.5,y:12.5},{x:14.5,y:14.5},{x:10.5,y:13.5},
].map((p,i)=>({ x:p.x, y:p.y, hp:60, maxhp:60, state:'idle', atk:0,
wander:Math.random()*6.28, wanderT:0, dead:false, tex:Math.random()<.5?0:1,
speed:1.15+Math.random()*0.5, frameB:Math.random()*2 }));
game.total = game.enemies.length;
game.pickups = [
{type:'health', x:6.5,y:1.5},{type:'health', x:3.5,y:9.5},{type:'health', x:21.5,y:4.5},{type:'health', x:21.5,y:13.5},
{type:'shells', x:2.5,y:4.5},{type:'shells', x:15.5,y:8.5},{type:'shells', x:2.5,y:12.5},{type:'shells', x:21.5,y:7.5},
{type:'cells', x:11.5,y:1.5},{type:'cells', x:20.5,y:8.5},{type:'cells', x:8.5,y:16.5},{type:'cells', x:21.5,y:2.5},
].map(p=>({ ...p, taken:false, bob:Math.random()*6.28 }));
}
resetLevel();
/* ============================================================
RAYCASTING RENDERER
============================================================ */
function raycast(px,py,rdx,rdy){
let mapX=Math.floor(px), mapY=Math.floor(py);
const ddX=Math.abs(1/(rdx||1e-9)), ddY=Math.abs(1/(rdy||1e-9));
let stepX, stepY, sideX, sideY;
if(rdx<0){ stepX=-1; sideX=(px-mapX)*ddX; } else { stepX=1; sideX=(mapX+1-px)*ddX; }
if(rdy<0){ stepY=-1; sideY=(py-mapY)*ddY; } else { stepY=1; sideY=(mapY+1-py)*ddY; }
let side=0, hit=false;
for(let i=0;i<128;i++){
if(sideX<sideY){ sideX+=ddX; mapX+=stepX; side=0; }
else { sideY+=ddY; mapY+=stepY; side=1; }
if(mapX<0||mapY<0||mapX>=mapW||mapY>=mapH){ hit=true; break; }
if(MAP[mapY][mapX]==='1'){ hit=true; break; }
}
let perp = side===0 ? (sideX-ddX) : (sideY-ddY);
if(perp<=0.0001) perp=0.0001;
const wallX = side===0 ? py+perp*rdy : px+perp*rdx;
let texX = Math.floor((wallX-Math.floor(wallX))*TEX_SIZE);
if(texX<0) texX=0; if(texX>=TEX_SIZE) texX=TEX_SIZE-1;
return { dist:perp, side, mapX, mapY, texX };
}
function drawWallColumn(x, r, zbuf){
const perp = r.dist;
zbuf[x]=perp;
const lineH = H/perp;
const drawStart = H/2 - lineH/2;
const tex = wallTexIndex(r.mapX, r.mapY);
const step = TEX_SIZE/lineH;
const texPos = (drawStart > 0 ? drawStart : 0) * step; // unused, drawImage handles scaling
// draw the single texture column scaled to wall height
CTX.drawImage(tex, r.texX,0,1,TEX_SIZE, x,drawStart,1,lineH+1);
// distance + side shading
const distShade = Math.min(1, 1/(1+perp*0.22));
const dark = (1-distShade)*1.35 + (r.side?0.22:0);
CTX.fillStyle = `rgba(0,0,0,${Math.min(0.9,dark).toFixed(3)})`;
CTX.fillRect(x,drawStart,1,lineH+2);
// subtle bottom fog near walls far away is handled by dark overlay
}
function renderScene(){
zbuf = new Float32Array(W);
CTX.imageSmoothingEnabled=false;
// sky / floor backdrop
CTX.fillStyle='#06060a'; CTX.fillRect(0,0,W,H);
// ceiling gradient
let cg=CTX.createLinearGradient(0,0,0,H*0.5);
cg.addColorStop(0,'#18151f'); cg.addColorStop(1,'#2e241d');
CTX.fillStyle=cg; CTX.fillRect(0,0,W,H*0.5+1);
// floor gradient
let fg=CTX.createLinearGradient(0,H*0.5,0,H);
fg.addColorStop(0,'#3c2b1c'); fg.addColorStop(1,'#15110d');
CTX.fillStyle=fg; CTX.fillRect(0,H*0.5,W,H*0.5+1);
const p=game.player;
const dirX=Math.cos(p.angle), dirY=Math.sin(p.angle);
const planeX=-dirY*FOV, planeY=dirX*FOV;
for(let x=0;x<W;x++){
const camX=2*x/W-1;
const rdx=dirX+planeX*camX, rdy=dirY+planeY*camX;
drawWallColumn(x, raycast(p.x,p.y,rdx,rdy), zbuf);
}
}
/* ---------- sprites ---------- */
function drawSprites(){
const p=game.player;
const dirX=Math.cos(p.angle), dirY=Math.sin(p.angle);
const list=[];
for(const e of game.enemies){
if(e.dead) continue;
list.push({x:e.x,y:e.y,tex:demonTex[Math.floor(e.frameB*2)%2],scale:0.62,voff:0.05,alpha:1});
}
for(const pk of game.pickups){
if(pk.taken) continue;
const tex = pk.type==='health'?medkitTex : pk.type==='shells'?shellTex : cellTex;
list.push({x:pk.x,y:pk.y,tex,scale:0.20,voff:0.26,alpha:1});
}
list.push({x:game.exit.x,y:game.exit.y,tex:portalTex,scale:0.55,voff:0.0,
alpha:game.exit.active?1:0.30});
for(const it of list){
const tx=it.x-p.x, ty=it.y-p.y;
const depth=dirX*tx+dirY*ty;
it.depth=depth;
it.skip=(depth<=0.08);
}
list.sort((a,b)=>b.depth-a.depth);
for(const it of list){
if(it.skip) continue;
const tx=it.x-p.x, ty=it.y-p.y;
const relX=(dirX*ty - dirY*tx)/(it.depth*FOV);
const screenX=(W/2)*(1+relX);
const size=H/it.depth*it.scale;
const centerY=H/2 + it.voff*H;
const dsx=screenX-size/2, dex=screenX+size/2;
const dsy=centerY-size/2, dey=centerY+size/2;
const tw=it.tex.width;
const x0=Math.max(0,Math.floor(dsx)), x1=Math.min(W-1,Math.ceil(dex));
CTX.globalAlpha=it.alpha;
for(let sx=x0;sx<=x1;sx++){
if(it.depth < zbuf[sx]){
const texX=Math.floor((sx-dsx)/size*tw);
const y0=Math.max(0,Math.ceil(dsy)), y1=Math.min(H,Math.floor(dey));
if(y1>y0)
CTX.drawImage(it.tex, texX,0,1,tw, sx,y0,1,y1-y0);
}
}
CTX.globalAlpha=1;
}
}
/* ---------- minimap ---------- */
function drawMinimap(){
const cell=6, mw=mapW*cell, mh=mapH*cell;
const ox=W-mw-14, oy=H-mh-14;
CTX.globalAlpha=0.82;
CTX.fillStyle='rgba(5,8,12,0.85)'; CTX.fillRect(ox-3,oy-3,mw+6,mh+6);
for(let y=0;y<mapH;y++) for(let x=0;x<mapW;x++){
if(MAP[y][x]==='1'){ CTX.fillStyle='#34414f'; CTX.fillRect(ox+x*cell,oy+y*cell,cell,cell); }
}
CTX.fillStyle='#33e0ff'; CTX.fillRect(ox+game.exit.x*cell-2,oy+game.exit.y*cell-2,cell+4,cell+4);
for(const pk of game.pickups){
if(pk.taken) continue;
CTX.fillStyle= pk.type==='health'?'#5eff8a' : pk.type==='shells'?'#ffb52e':'#33c8ff';
CTX.fillRect(ox+pk.x*cell-1,oy+pk.y*cell-1,cell+2,cell+2);
}
for(const e of game.enemies){
if(e.dead) continue;
CTX.fillStyle='#ff3b30'; CTX.fillRect(ox+e.x*cell-1,oy+e.y*cell-1,cell+2,cell+2);
}
CTX.fillStyle='#ffee55'; CTX.fillRect(ox+game.player.x*cell-2,oy+game.player.y*cell-2,cell+3,cell+3);
// facing
const a=game.player.angle;
CTX.strokeStyle='#ffee55'; CTX.lineWidth=1.5;
CTX.beginPath(); CTX.moveTo(ox+game.player.x*cell+1,oy+game.player.y*cell+1);
CTX.lineTo(ox+game.player.x*cell+Math.cos(a)*cell*2, oy+game.player.y*cell+Math.sin(a)*cell*2);
CTX.stroke();
CTX.globalAlpha=1;
}
/* ---------- HUD ---------- */
function text(str,x,y,size,color,align){
CTX.font='700 '+Math.round(size)+'px "Courier New",monospace';
CTX.fillStyle=color; CTX.textAlign=align||'left'; CTX.textBaseline='alphabetic';
CTX.fillText(str,x,y);
}
function drawHUD(){
const p=game.player, w=game.weapons[game.current];
const hp=p.hp;
const big=H*0.045;
// health
text('HP', H*0.04, H*0.07, big*0.9, '#cfd6dd');
text(String(Math.max(0,Math.ceil(hp))), H*0.04, H*0.135, big*1.5,
hp<35?'#ff4d4d':hp<60?'#ffcf5e':'#8dff8a');
CTX.fillStyle='rgba(255,255,255,0.15)'; CTX.fillRect(H*0.04,H*0.15,H*0.22,H*0.014);
CTX.fillStyle=hp<35?'#ff4d4d':hp<60?'#ffcf5e':'#5eff8a';
CTX.fillRect(H*0.04,H*0.15,H*0.22*(hp/100),H*0.014);
// ammo
text('AMMO', H*0.04, H*0.22, big*0.9, '#cfd6dd');
const ammoTxt = w.ammo===Infinity?'-':String(w.ammo);
text(ammoTxt, H*0.04, H*0.285, big*1.4, w.ammo===0?'#ff4d4d':'#d6e4f0');
// weapon slots
for(let i=0;i<3;i++){
const sel = i===game.current;
const x=H*0.04+i*H*0.10;
CTX.fillStyle= sel? 'rgba(255,190,60,0.25)':'rgba(255,255,255,0.06)';
CTX.fillRect(x, H*0.32, H*0.09, H*0.05);
text(String(i+1), x+H*0.006, H*0.355, big*0.8, sel?'#ffcf5e':'#8a94a0');
text(game.weapons[i].key, x+H*0.03, H*0.355, big*0.8, sel?'#fff':'#6a7480');
}
// current weapon name
text(game.weapons[game.current].name, W/2, H*0.945, big*0.85, '#e8d9b0','center');
// demon counter
text('DEMONS', W-H*0.16, H*0.06, big*0.85, '#ff8a5e','right');
text(game.kills+'/'+game.total, W-H*0.16, H*0.115, big*1.4, '#ffcf5e','right');
if(game.exit.active){
text('EXIT OPEN', W-H*0.033, H*0.24, big*0.75, '#5eff8a','right');
}
// crosshair
const cx=W/2, cy=H/2, g=Math.max(3,H*0.008);
CTX.strokeStyle='rgba(255,255,255,0.85)'; CTX.lineWidth=Math.max(1,H*0.0022);
CTX.beginPath();
CTX.moveTo(cx-g-5,cy); CTX.lineTo(cx-g,cy); CTX.moveTo(cx+g,cy); CTX.lineTo(cx+g+5,cy);
CTX.moveTo(cx,cy-g-5); CTX.lineTo(cx,cy-g); CTX.moveTo(cx,cy+g); CTX.lineTo(cx,cy+g+5);
CTX.stroke();
CTX.fillStyle='rgba(255,255,255,0.9)'; CTX.fillRect(cx-1,cy-1,2,2);
}
/* ---------- weapon viewmodel ---------- */
function drawWeapon(){
const p=game.player, w=game.weapons[game.current];
const rec = game.recoil*H*0.06;
const sway = Math.sin(game.bob)*H*0.008;
const bx=W/2 - rec*0.4 + sway;
const baseY=H;
const up=H*0.16 + rec; // bottom offset from bottom
CTX.save();
CTX.translate(0,0);
const body='#2b2f33', dark='#17191b', metal='#41474c';
// muzzle flash
if(game.muzzle>0){
const mf=Math.min(1,game.muzzle/0.08);
CTX.globalAlpha=mf;
const gx=bx, gy=baseY-up-H*0.16;
const gr=H*(0.07+0.05*mf);
let fg=CTX.createRadialGradient(gx,gy,0,gx,gy,gr);
fg.addColorStop(0,'rgba(255,250,200,1)'); fg.addColorStop(.5,'rgba(255,180,40,0.9)');
fg.addColorStop(1,'rgba(255,80,0,0)');
CTX.fillStyle=fg; CTX.fillRect(gx-gr,gy-gr,gr*2,gr*2);
CTX.globalAlpha=1;
}
if(w.id===0){
// pistol silhouette
CTX.fillStyle=body;
CTX.fillRect(bx-H*0.035, baseY-up, H*0.07, up);
CTX.fillStyle=metal;
CTX.fillRect(bx-H*0.028, baseY-up-H*0.10, H*0.056, H*0.10);
CTX.fillStyle=dark;
CTX.fillRect(bx-H*0.02, baseY-up-H*0.02, H*0.04, H*0.02);
// hand
CTX.fillStyle='#c69a6c';
CTX.beginPath(); CTX.ellipse(bx-H*0.02, baseY-up*0.55, H*0.05,H*0.06,0.4,0,7); CTX.fill();
} else if(w.id===1){
// shotgun (double barrel)
CTX.fillStyle=dark;
CTX.fillRect(bx-H*0.055, baseY-up-H*0.20, H*0.11, H*0.20+H*0.05);
// two barrels
CTX.fillStyle=body;
CTX.fillRect(bx-H*0.05, baseY-up-H*0.20, H*0.045, H*0.20);
CTX.fillRect(bx+H*0.005, baseY-up-H*0.20, H*0.045, H*0.20);
CTX.fillStyle=metal; CTX.fillRect(bx-H*0.056, baseY-up-H*0.16, H*0.112, H*0.02);
// pump
CTX.fillStyle='#6b3a22'; CTX.fillRect(bx-H*0.05, baseY-up-H*0.10, H*0.10, H*0.03);
CTX.fillStyle='#c69a6c';
CTX.beginPath(); CTX.ellipse(bx+H*0.06, baseY-up*0.4, H*0.055,H*0.07,-0.5,0,7); CTX.fill();
} else {
// plasma cannon
CTX.fillStyle=body;
CTX.fillRect(bx-H*0.045, baseY-up-H*0.16, H*0.09, H*0.16+H*0.06);
CTX.fillStyle=dark;
CTX.fillRect(bx-H*0.05, baseY-up-H*0.22, H*0.10, H*0.07);
// glowing core
const coreW=H*0.05, coreH=H*0.05, core=bx;
const cyc=baseY-up-H*0.19;
const pulse=0.5+0.5*Math.sin(game.time*10);
let cg=CTX.createRadialGradient(core,cyc,0,core,cyc,coreH*1.3);
cg.addColorStop(0,`rgba(200,250,255,${0.7+0.3*pulse})`);
cg.addColorStop(1,'rgba(40,140,255,0.05)');
CTX.fillStyle=cg; CTX.fillRect(core-coreH*1.4,cyc-coreH*1.4,coreH*2.8,coreH*2.8);
CTX.fillStyle=game.muzzle>0?'#eaffff':'#2ab6ff';
CTX.fillRect(core-coreH/2,cyc-coreH/2,coreH,coreH);
}
CTX.restore();
}
let zbuf = new Float32Array(W);
/* ============================================================
COMBAT
============================================================ */
function fire(){
if(game.state!=='playing' || game.cooldown>0) return;
const w=game.weapons[game.current];
if(w.ammo===0){ game.cooldown=0.25; playEmpty(); return; }
if(w.ammo!==Infinity) w.ammo--;
game.cooldown=w.cooldown;
game.recoil=1; game.muzzle=0.08;
const pellets=w.pellets||1;
for(let i=0;i<pellets;i++){
const ang=game.player.angle + (Math.random()-0.5)*w.spread*2;
fireRay(ang, w.dmg);
}
playShot(w.id);
game.shake=Math.min(1, game.shake + (w.id===1?0.5:w.id===2?0.2:0.12));
}
function fireRay(ang, dmg){
const p=game.player;
const dx=Math.cos(ang), dy=Math.sin(ang);
let best=null, bestT=Infinity;
for(const e of game.enemies){
if(e.dead) continue;
const ex=e.x-p.x, ey=e.y-p.y;
const t=ex*dx+ey*dy;
if(t<0.05) continue;
const cx=ex-dx*t, cy=ey-dy*t;
if(Math.hypot(cx,cy)<0.42){
const wall=raycast(p.x,p.y,dx,dy).dist;
if(t<wall && t<bestT){ bestT=t; best=e; }
}
}
if(best) hitEnemy(best, dmg);
}
function hitEnemy(e, dmg){
e.hp-=dmg;
e.state='chase';
playHit();
if(e.hp<=0){
e.dead=true; game.kills++;
playKill();
if(game.kills>=game.total){ game.exit.active=true; playExitOpen(); }
}
}
/* ============================================================
ENEMY AI
============================================================ */
function lineOfSight(x0,y0,x1,y1){
const dx=x1-x0, dy=y1-y0, d=Math.hypot(dx,dy);
const steps=Math.ceil(d*10);
if(steps<2) return true;
for(let i=1;i<steps;i++){
const t=i/steps;
if(solid(x0+dx*t, y0+dy*t)) return false;
}
return true;
}
function moveEntity(e, dx, dy){
const r=0.25;
if(!solid(e.x+dx-r,e.y-r) && !solid(e.x+dx+r,e.y-r) && !solid(e.x+dx-r,e.y+r) && !solid(e.x+dx+r,e.y+r))
e.x+=dx;
if(!solid(e.x-r,e.y+dy-r) && !solid(e.x+r,e.y+dy-r) && !solid(e.x-r,e.y+dy+r) && !solid(e.x+r,e.y+dy+r))
e.y+=dy;
}
function updateEnemies(dt){
const p=game.player;
for(const e of game.enemies){
if(e.dead) continue;
const dx=p.x-e.x, dy=p.y-e.y;
const dist=Math.hypot(dx,dy);
const seen = dist<11 && lineOfSight(e.x,e.y,p.x,p.y);
if(seen && dist<22){
e.state='chase';
if(dist>1.05){
const sp=e.speed*dt;
moveEntity(e, dx/dist*sp, dy/dist*sp);
} else {
e.atk-=dt;
if(e.atk<=0){ e.atk=0.85+Math.random()*0.5; damagePlayer(8+ri(0,5)); playEnemyAttack(); }
}
} else {
e.state='idle';
e.wanderT-=dt;
if(e.wanderT<=0){ e.wanderT=1.5+Math.random()*3; e.wander=Math.random()*6.28; }
const sp=e.speed*0.35*dt;
moveEntity(e, Math.cos(e.wander)*sp, Math.sin(e.wander)*sp);
}
e.frameB += dt*(seen?9:2.5);
}
}
/* ============================================================
PLAYER / ITEMS
============================================================ */
function blocked(x,y){
const r=PLAYER_R;
return solid(x-r,y-r)||solid(x+r,y-r)||solid(x-r,y+r)||solid(x+r,y+r);
}
function tryMove(dx,dy){
const p=game.player;
if(!blocked(p.x+dx, p.y)) p.x+=dx;
if(!blocked(p.x, p.y+dy)) p.y+=dy;
}
function damagePlayer(n){
if(game.state!=='playing') return;
const p=game.player;
p.hp-=n;
game.hurt=1;
updateHurtFx();
if(p.hp<=0){ p.hp=0; die(); }
}
function updateHurtFx(){
document.getElementById('hurtFlash').style.opacity=Math.max(0, Math.min(0.85, game.hurt*0.9));
}
function updatePickupFx(){
let o=document.getElementById('pickFlash').style.opacity;
document.getElementById('pickFlash').style.opacity=Math.max(0, Math.min(0.5, game.pickupFx*0.8));
}
function updatePickups(dt){
const p=game.player;
for(const pk of game.pickups){
if(pk.taken) continue;
const d=Math.hypot(pk.x-p.x, pk.y-p.y);
if(d<0.7){
pk.taken=true;
if(pk.type==='health'){
p.hp=Math.min(p.maxhp, p.hp+25); playPickup();
} else if(pk.type==='shells'){
const w=game.weapons[1]; w.ammo=Math.min(w.maxAmmo,w.ammo+8); playPickup();
} else {
const w=game.weapons[2]; w.ammo=Math.min(w.maxAmmo,w.ammo+24); playPickup();
}
game.pickupFx=1;
}
}
}
function die(){
game.state='dead';
if(document.exitPointerLock) document.exitPointerLock();
document.getElementById('deadStat').textContent=
'Demons slain: '+game.kills+' / '+game.total;
document.getElementById('deadOverlay').classList.remove('hidden');
}
function respawn(){
resetLevel();
document.getElementById('deadOverlay').classList.add('hidden');
game.state='playing';
if(CV.requestPointerLock) CV.requestPointerLock();
}
function win(){
game.state='win';
if(document.exitPointerLock) document.exitPointerLock();
document.getElementById('winStat').textContent=
'Demons slain: '+game.kills+' / '+game.total+' · Purged and victorious';
document.getElementById('winOverlay').classList.remove('hidden');
}
function restart(){
resetLevel();
document.getElementById('winOverlay').classList.add('hidden');
game.state='playing';
if(CV.requestPointerLock) CV.requestPointerLock();
}
/* ============================================================
UPDATE
============================================================ */
function switchWeapon(i){
if(i<0||i>2||i===game.current) return;
game.current=i;
game.cooldown=Math.max(game.cooldown,0.25);
playSwitch();
}
function checkExit(){
if(!game.exit.active) return;
if(Math.hypot(game.exit.x-game.player.x, game.exit.y-game.player.y) < 0.75) win();
}
function update(dt){
const p=game.player, k=game.keys;
game.time+=dt;
game.bob += (Math.sin(game.time*10)<0.5 && !game.firing)?dt*0 : 0;
if(game.cooldown>0) game.cooldown-=dt;
if(game.recoil>0) game.recoil-=dt*6;
if(game.muzzle>0) game.muzzle-=dt;
if(game.shake>0){ game.shake-=dt*2.2; if(game.shake<0)game.shake=0; }
if(game.hurt>0){ game.hurt-=dt*2.2; updateHurtFx(); }
if(game.pickupFx>0){ game.pickupFx-=dt*2; updatePickupFx(); }
const run=k['ShiftLeft']||k['ShiftRight'];
const sp=(run?4.6:3.0);
const fwd=(k['KeyW']?1:0)-(k['KeyS']?1:0);
const str=(k['KeyD']?1:0)-(k['KeyA']?1:0);
if(fwd||str) game.bob += dt*(run?16:11);
const ca=Math.cos(p.angle), sa=Math.sin(p.angle);
const vx=(ca*fwd - sa*str)*sp*dt;
const vy=(sa*fwd + ca*str)*sp*dt;
if(vx||vy) tryMove(vx,vy);
if(game.firing) fire();
updateEnemies(dt);
updatePickups(dt);
checkExit();
}
/* ============================================================
AUDIO (procedural)
============================================================ */
let AC=null;
function getAC(){
if(!AC){ const ACc=window.AudioContext||window.webkitAudioContext; if(ACc) AC=new ACc(); }
if(AC && AC.state==='suspended') AC.resume();
return AC;
}
function tone(freq,dur,type,vol,slide){
if(!getAC()) return;
try{
const ac=AC, o=ac.createOscillator(), g=ac.createGain();
o.type=type||'square'; o.frequency.setValueAtTime(freq,ac.currentTime);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),ac.currentTime+dur);
g.gain.setValueAtTime(vol,ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001,ac.currentTime+dur);
o.connect(g).connect(ac.destination); o.start(); o.stop(ac.currentTime+dur);
}catch(e){}
}
function noiseBurst(dur,vol,lowpass){
if(!getAC()) return;
try{
const ac=AC, len=Math.floor(ac.sampleRate*dur);
const buf=ac.createBuffer(1,len,ac.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
const src=ac.createBufferSource(); src.buffer=buf;
const f=ac.createBiquadFilter(); f.type='lowpass'; f.frequency.value=lowpass||1200;
const g=ac.createGain(); g.gain.value=vol;
src.connect(f); f.connect(g); g.connect(ac.destination); src.start();
}catch(e){}
}
function playShot(id){
if(id===0){ noiseBurst(0.12,0.5,2200); tone(200,0.11,'square',0.25,80); }
else if(id===1){ noiseBurst(0.28,0.7,750); tone(110,0.26,'sine',0.45,40); }
else { tone(860,0.09,'sawtooth',0.28,180); noiseBurst(0.05,0.3,4500); }
}
function playHit(){ tone(320,0.06,'square',0.12,140); }
function playKill(){ tone(120,0.22,'sawtooth',0.3,45); noiseBurst(0.15,0.3,500); }
function playPickup(){ tone(520,0.09,'square',0.2,780); setTimeout(()=>tone(780,0.12,'square',0.15,1040),60); }
function playHurt(){ noiseBurst(0.16,0.4,300); tone(90,0.18,'sawtooth',0.3,50); }
function playEnemyAttack(){ tone(140,0.12,'square',0.15,60); }
function playSwitch(){ tone(600,0.04,'square',0.1,900); }
function playEmpty(){ tone(160,0.06,'square',0.12,120); }
function playExitOpen(){ tone(220,0.5,'sine',0.3,660); }
/* ============================================================
RENDER ASSEMBLY
============================================================ */
function render(){
const sh=game.shake;
const sx=sh>0?(Math.random()-0.5)*H*0.012*sh:0;
const sy=sh>0?(Math.random()-0.5)*H*0.012*sh:0;
CTX.save(); CTX.translate(sx,sy);
renderScene(); drawSprites();
if(game.state==='playing'||game.state==='paused') drawWeapon();
CTX.restore();
if(game.state==='playing'||game.state==='paused') drawHUD();
drawMinimap();
// vignette
let vg=CTX.createRadialGradient(W/2,H/2,Math.min(W,H)*0.35, W/2,H/2,Math.max(W,H)*0.75);
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.45)');
CTX.fillStyle=vg; CTX.fillRect(0,0,W,H);
}
/* ============================================================
INPUT & UI
============================================================ */
const menuTitle=document.querySelector('#menuOverlay .title');
const menuSub=document.querySelector('#menuOverlay .sub');
window.addEventListener('keydown',e=>{
game.keys[e.code]=true;
if(e.code==='Digit1') switchWeapon(0);
if(e.code==='Digit2') switchWeapon(1);
if(e.code==='Digit3') switchWeapon(2);
if(e.code==='Enter'){
if(game.state==='dead') respawn();
else if(game.state==='win') restart();
}
if(e.code==='Space') e.preventDefault();
});
window.addEventListener('keyup',e=>{ game.keys[e.code]=false; });
CV.addEventListener('mousedown',e=>{
if(game.state==='playing'){
game.firing=true;
if(document.pointerLockElement!==CV && CV.requestPointerLock) CV.requestPointerLock();
} else if(game.state==='paused' && CV.requestPointerLock){
CV.requestPointerLock();
}
});
window.addEventListener('mouseup',()=>{ game.firing=false; });
window.addEventListener('mousemove',e=>{
if(document.pointerLockElement===CV && game.state==='playing'){
game.player.angle += e.movementX*0.0022;
}
});
window.addEventListener('wheel',e=>{
if(game.state!=='playing') return;
switchWeapon((game.current + (e.deltaY>0?1:-1) + 3)%3);
},{passive:true});
CV.addEventListener('contextmenu',e=>e.preventDefault());
function showMenu(title,sub,btn){
menuTitle.textContent=title; menuSub.textContent=sub; startBtn.textContent=btn;
menuOverlay.classList.remove('hidden');
}
function pauseGame(){
game.state='paused';
game.firing=false;
showMenu('PAUSED','CLICK TO RESUME','RESUME');
}
document.addEventListener('pointerlockchange',()=>{
if(document.pointerLockElement!==CV && game.state==='playing') pauseGame();
});
function startGame(){
getAC();
if(!game.started){ resetLevel(); game.started=true; }
game.state='playing';
menuOverlay.classList.add('hidden');
if(CV.requestPointerLock) CV.requestPointerLock();
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('respawnBtn').addEventListener('click', respawn);
document.getElementById('playAgainBtn').addEventListener('click', restart);
/* ============================================================
LOOP
============================================================ */
buildTextures();
let last=performance.now();
function loop(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
if(game.state==='playing') update(dt);
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>