ergod
Built with Ergod

ASCII 3D World — lit & animated

Shared 2026-09-09 · 7 views
About this creation

A creation shared by its maker. The page below runs in a sandbox that cannot read anything of yours or talk to any server. Read the source before you run it anywhere else.

Interactive page
Open full page
Show source
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASCII 3D World — lit & animated</title>
<style>
  html,body{
    margin:0;height:100%;background:#050805;color:#9ef09e;
    font-family:"Cascadia Code",Menlo,Consolas,"DejaVu Sans Mono",monospace;
    display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;overflow:hidden;
  }
  #view{ font-size:8px;line-height:8px;white-space:pre; letter-spacing:0;
         text-shadow:0 0 3px rgba(120,255,140,.22); user-select:none; }
  #map{ font-size:10px;line-height:10px;white-space:pre;color:#4f8f4f;opacity:.75; }
  #hud{ font-size:12px;color:#7fcf7f;letter-spacing:1px; }
  b{color:#d6ffd6;}
</style>
</head>
<body>
  <pre id="view"></pre>
  <pre id="map"></pre>
  <div id="hud"><b>W/S</b> move &nbsp; <b>A/D</b> turn &nbsp; <b>Q/E</b> strafe &nbsp; <b>↑↓←→</b> also work</div>
<script>
const W = 120, H = 46, MAP = 26, FOV = Math.PI/3, MAXD = 24;

/* ---------- procedural world ---------- */
let _s = 1337;
const rnd = () => { _s = (_s*1103515245+12345)&0x7fffffff; return _s/0x7fffffff; };
function makeMap(){
  const m = Array.from({length:MAP},()=>new Array(MAP).fill(0));
  for(let i=0;i<MAP;i++){ m[0][i]=1; m[MAP-1][i]=1; m[i][0]=1; m[i][MAP-1]=1; }
  for(let i=0;i<60;i++){ const x=2+(rnd()*(MAP-4)|0), y=2+(rnd()*(MAP-4)|0); m[y][x]= rnd()<0.45?2 : rnd()<0.7?3 : 1; }
  for(let y=3;y<=8;y++) for(let x=3;x<=9;x++) if(rnd()<0.6) m[y][x]=2;
  for(let i=0;i<10;i++){ const x=4+(rnd()*(MAP-8)|0), y=4+(rnd()*(MAP-8)|0); m[y][x]=4; }
  for(let y=15;y<=21;y++) for(let x=16;x<=23;x++) if(rnd()<0.55) m[y][x]=3;
  return m;
}
const map = makeMap();

/* ---------- sprite models ---------- */
function empties(){ const o=[]; for(let y=1;y<MAP-1;y++)for(let x=1;x<MAP-1;x++) if(map[y][x]===0) o.push([x+.5,y+.5]); return o; }
const pool = empties();
const grab = () => pool.length? pool.splice((rnd()*pool.length)|0,1)[0] : [12.5,12.5];
const TREE = [" /\\ ","/!!\\","\\!!/"," || "," |#|"," |#|"," '-' "];
const TORCH= [" ** ","/##\\"," |#|"," |*|"," '-' "];
const TORCH_HOT=[" @* ","/#@\\"," |#|"," |@|"," '-' "];
const CHEST= ["____","|##|","|##|","|__|"];
const CRYST= [" /\\ ","/%%\\","%%%"," \\/ "];
const STATUE=["/o\\","|O|","/|\\"," | ","/ \\"];
const KINDS = {tree:TREE,torch:TORCH,chest:CHEST,crystal:CRYST,statue:STATUE};
const plan = [["tree",1],["tree",1],["tree",1],["torch",1],["torch",1],["chest",.7],["crystal",.9],["statue",1.2],["statue",1.2]];
const SPRITES = plan.map(([k,h],i)=>({p:grab(),art:KINDS[k],kind:k,h,ph:i*1.7}));

/* ---------- lighting ---------- */
const WARM="#ffb14e";
const LIGHTS = SPRITES.filter(s=>s.kind==="torch").map(s=>({x:s.p[0],y:s.p[1],r:5.5,i:1.0,ph:s.ph}));
let T = 0;
function lightAt(wx,wy){
  let amt=0;
  for(const L of LIGHTS){ const d=Math.hypot(wx-L.x,wy-L.y); if(d<L.r) amt += (1-d/L.r)*L.i*(0.7+0.3*Math.sin(T*9+L.ph)); }
  return Math.min(1,amt);
}

/* ---------- shading helpers ---------- */
const GRAY = ["#2a3a2a","#3a4a3a","#52634f","#74865f","#9fb083"];
const MAT = [
  {c:["#0c3a14","#1f6f2c","#56d35f"], ch:"@#%*=+:-. "}, // 0 unused
  {c:["#0e3320","#1c7a3e","#52e07a"], ch:"@#%*=+:-. "}, // 1 stone
  {c:["#3a2410","#9c5618","#e89a3c"], ch:"O0Qoc+=:. "}, // 2 brick
  {c:["#163a18","#39a843","#7ef07a"], ch:"BHNhv|::. "}, // 3 foliage
  {c:["#2a2f3a","#6f7d8c","#c3d2dd"], ch:"@#%*=+:-. "}, // 4 pillar
];
function blend(a,b,t){ const pa=parseInt(a.slice(1),16),pb=parseInt(b.slice(1),16);
  const r=((pa>>16)+((pb>>16)-(pa>>16))*t)|0,g=(((pa>>8)&255)+(((pb>>8)&255)-((pa>>8)&255))*t)|0,bl=((pa&255)+((pb&255)-(pa&255))*t)|0;
  return "#"+((1<<24)+(r<<16)+(g<<8)+bl).toString(16).slice(1); }
function fog(d){ let t=d/MAXD; return t<0?0:t>1?1:t; }
function wallChar(d,ch){ let t=d/MAXD,i=Math.floor(t*(ch.length-1)); if(i<0)i=0; if(i>=ch.length)i=ch.length-1; return ch[i]; }
function wallColor(mat,d,tier){ return blend(MAT[mat].c[tier], GRAY[tier], fog(d)*0.85); }

/* ---------- raycast (DDA) ---------- */
const player={x:2.5,y:2.5,dir:0};
const zbuf=new Float32Array(W);
function castColumn(cx){
  const ray=player.dir-FOV/2+(cx/(W-1))*FOV;
  const rdx=Math.cos(ray), rdy=Math.sin(ray);
  let mapX=Math.floor(player.x), mapY=Math.floor(player.y);
  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=(player.x-mapX)*ddx;}else{stepX=1;sideX=(mapX+1-player.x)*ddx;}
  if(rdy<0){stepY=-1;sideY=(player.y-mapY)*ddy;}else{stepY=1;sideY=(mapY+1-player.y)*ddy;}
  let hit=0,side=0,type=0;
  for(let g=0;g<128&&!hit;g++){
    if(sideX<sideY){sideX+=ddx;mapX+=stepX;side=0;}else{sideY+=ddy;mapY+=stepY;side=1;}
    if(mapX<0||mapY<0||mapX>=MAP||mapY>=MAP){hit=1;type=1;break;}
    if(map[mapY][mapX]>0){hit=1;type=map[mapY][mapX];}
  }
  const perp=Math.max(side===0?(sideX-ddx):(sideY-ddy),.0001);
  return {perp,type,side,hx:player.x+rdx*perp,hy:player.y+rdy*perp};
}

/* ---------- sprite projection ---------- */
function projSprite(s){
  let rx=s.p[0]-player.x, ry=s.p[1]-player.y;
  const cd=Math.cos(player.dir), sd=Math.sin(player.dir);
  const inv=rx*cd+ry*sd, trv=-rx*sd+ry*cd;
  if(inv<=.15) return null;
  const screenX=W/2*(1+trv/inv/Math.tan(FOV/2));
  return {screenX,inv,trv,s};
}

/* ---------- build frame ---------- */
function render(){
  const scr=new Array(W*H).fill(" "), col=new Array(W*H).fill("#0a1a0a");
  const set=(x,y,ch,c)=>{ if(x<0||x>=W||y<0||y>=H)return; const i=y*W+x; scr[i]=ch; col[i]=c; };
  const horizon=Math.floor(H/2);

  // ceiling / floor (dark, slight vertical gradient)
  for(let y=0;y<H;y++){
    const idx = y<horizon ? Math.min(GRAY.length-1,Math.floor((horizon-y)/horizon*(GRAY.length-1)))
                          : Math.min(GRAY.length-1,Math.floor((y-horizon)/(H-1-horizon)*(GRAY.length-1)));
    const c = y<horizon ? blend("#0c2414",GRAY[idx],.5) : blend("#081a0e",GRAY[idx],.45);
    for(let x=0;x<W;x++) set(x,y, ".:-=+*#%@"[idx], c);
  }

  // walls (with lighting)
  for(let cx=0;cx<W;cx++){
    const {perp,type,side,hx,hy}=castColumn(cx); zbuf[cx]=perp;
    const lh=Math.floor((H/perp)*0.55);
    let st=Math.max(0,Math.floor(horizon-lh/2)), en=Math.min(H-1,Math.floor(horizon+lh/2));
    const tier= side===1?0:2;
    let c=wallColor(type,perp,tier);
    const la=lightAt(hx,hy);
    if(la>0) c=blend(c, WARM, la*0.85);
    const ch=wallChar(perp, MAT[type].ch);
    for(let y=st;y<=en;y++) set(cx,y,ch,c);
  }

  // sprites (animated + lit)
  const list=SPRITES.map(projSprite).filter(Boolean).sort((a,b)=>b.inv-a.inv);
  for(const P of list){
    const s=P.s, wcols=s.art[0].length;
    // animated art
    let art=s.art, xoff=0, tintBase="#9fffb0";
    if(s.kind==="torch"){ art = (Math.sin(T*11+s.ph)>0?TORCH_HOT:TORCH); tintBase="#ffd089"; }
    else if(s.kind==="crystal"){ tintBase=blend("#3aa6c8","#aef6ff",0.5+0.5*Math.sin(T*4+s.ph)); }
    else if(s.kind==="tree"){ xoff=Math.round(Math.sin(T*1.6+s.ph)); }
    else if(s.kind==="statue"){ art = (P.trv<0? s.art.map(r=>r.split("").reverse().join("")) : s.art); }
    const drawH=Math.max(3,Math.round((H/P.inv)*0.55*s.h));
    const drawW=Math.max(3,Math.round(drawH*(wcols/art.length)*0.9));
    const yBase=Math.round(horizon+Math.min(H-1,Math.floor((H/P.inv)*0.27)));
    const yTop=Math.round(yBase-drawH);
    const la=lightAt(s.p[0],s.p[1]);
    const tint = la>0 ? blend(tintBase,WARM,la*0.7) : blend(tintBase,GRAY[2],fog(P.inv)*0.7);
    for(let dy=0;dy<art.length;dy++){
      const row=art[dy];
      for(let dx=0;dx<wcols;dx++){
        const g=row[dx]; if(g===" "||g===".") continue;
        const sx=Math.round(P.screenX + xoff + (dx-(wcols-1)/2)*drawW/wcols);
        const sy=Math.round(yTop + dy*(drawH/art.length));
        if(sx<0||sx>=W||sy<0||sy>=H) continue;
        if(P.inv < zbuf[sx]-0.05) set(sx,sy,g,tint);
      }
    }
  }

  // emit
  const out=new Array(H);
  for(let y=0;y<H;y++){ let line=""; for(let x=0;x<W;x++){ const i=y*W+x; line+=`<span style="color:${col[i]}">${scr[i]}</span>`; } out[y]=line; }
  return out.join("\n");
}

/* ---------- minimap ---------- */
function drawMap(){
  let s="";
  for(let y=0;y<MAP;y++){ let line="";
    for(let x=0;x<MAP;x++){
      if(Math.floor(player.x)===x&&Math.floor(player.y)===y){ const a="^>v<"; line+=a[((Math.round(player.dir/(Math.PI/2))%4)+4)%4]; }
      else if(map[y][x]>0) line+="#"; else line+="·";
    } s+=line+"\n"; }
  return s;
}

/* ---------- input + loop ---------- */
const keys={};
addEventListener("keydown",e=>{ keys[e.key.toLowerCase()]=true; if(["arrowup","arrowdown","arrowleft","arrowright"].includes(e.key.toLowerCase())) e.preventDefault(); });
addEventListener("keyup",e=>{ keys[e.key.toLowerCase()]=false; });
function tryMove(nx,ny){
  if(nx>0.3&&nx<MAP-0.3&&map[Math.floor(player.y)][Math.floor(nx)]===0) player.x=nx;
  if(ny>0.3&&ny<MAP-0.3&&map[Math.floor(ny)][Math.floor(player.x)]===0) player.y=ny;
}
function move(dt){
  const sp=2.8*dt, rot=2.4*dt;
  if(keys["w"]||keys["arrowup"]){ tryMove(player.x+Math.cos(player.dir)*sp, player.y+Math.sin(player.dir)*sp); }
  if(keys["s"]||keys["arrowdown"]){ tryMove(player.x-Math.cos(player.dir)*sp, player.y-Math.sin(player.dir)*sp); }
  if(keys["a"]||keys["arrowleft"]) player.dir-=rot;
  if(keys["d"]||keys["arrowright"])player.dir+=rot;
  if(keys["q"]){ tryMove(player.x+Math.cos(player.dir+Math.PI/2)*sp, player.y+Math.sin(player.dir+Math.PI/2)*sp); }
  if(keys["e"]){ tryMove(player.x+Math.cos(player.dir-Math.PI/2)*sp, player.y+Math.sin(player.dir-Math.PI/2)*sp); }
}
let last=performance.now();
function loop(now){
  const dt=Math.min(0.05,(now-last)/1000); last=now; T=now/1000; move(dt);
  document.getElementById("view").innerHTML=render();
  document.getElementById("map").textContent=drawMap();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>