commit b947fbb7fb77b6b695902cd08e9e2d3c305664c9 Author: Volodia Date: Thu Jun 18 12:55:17 2026 +0300 initial: dungeon crawler with Bun + TypeScript modular architecture - 19 TypeScript modules under src/ (constants, entities, room, game, render) - Canvas 2D rendering with dark fantasy palette - Room-based navigation, random 7x7 map generation - Two combat modes: ranged (pistol) and melee (knife) - Wall collision with door opening support - Minimap, HP bar, enemy AI - Bun build pipeline: src/main.ts -> dist/main.js + dist/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f460e7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/*.map +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd6fa58 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Dungeon Crawl — Ranged / Melee + +A dark fantasy dungeon crawler inspired by *The Binding of Isaac*. +Built with TypeScript and Canvas 2D, bundled with **Bun**. + +## Quick Start + +```bash +bun install # install deps (none currently required) +bun start # build + prepare dist/ +open dist/index.html +``` + +Or for development with watch mode: + +```bash +bun run dev # watches src/ and rebuilds dist/main.js +# then open dist/index.html manually +``` + +## Controls + +| Key | Action | +|---|---| +| WASD | Move | +| Arrow keys | Attack in direction | +| Space | Attack in facing direction | +| Tab / Q | Switch weapon (Ranged ↔ Melee) | +| R | Restart (on Game Over / Victory) | + +## Weapons + +| Mode | Weapon | Speed | Damage | Effect | +|---|---|---|---|---| +| Ranged | Pistol | Fast (10cd) | 1 per shot | Ranged projectiles | +| Melee | Knife | Slow (22cd) | 2 + knockback | Wide swing arc | + +## Room Types + +| Type | Description | +|---|---| +| Spawn | Starting room, no enemies | +| Normal | 2–4 enemies | +| Treasure | No enemies, loot room | +| Boss | 1 boss enemy, clearing wins the game | + +## Project Structure + +``` +src/ +├── main.ts Entry point +├── constants.ts Grid, canvas, tile/door constants +├── types.ts Shared TypeScript types +├── math.ts Utility: shuffle, rand, dist, overlap +├── input.ts Keyboard state (global KEYS map) +├── doors.ts Door geometry helpers +├── room/ +│ ├── Room.ts Room class (tiles, enemies, tears) +│ ├── RoomMap.ts Map generation (connected 7×7 grid) +│ └── tiles.ts Tile builders (wall/floor/door placement) +├── entities/ +│ ├── Player.ts Player stat block +│ ├── Enemy.ts Enemy stat block + enemy type logic +│ ├── Tear.ts Ranged projectile +│ └── MeleeSwing.ts Melee hitbox +├── game/ +│ ├── Game.ts Main orchestrator (loop, tick, render) +│ ├── collision.ts Wall collision (isBlocked, collidesWall) +│ ├── transitions.ts Room transition detection +│ └── spawner.ts Enemy placement logic +└── render/ + ├── roomRenderer.ts Tile rendering + ├── entityRenderer.ts Player, enemy, tear, melee drawing + ├── hudRenderer.ts HP bar, mode indicator, HUD + └── minimapRenderer.ts Minimap drawing +``` + +## Build System + +- **Bun** — runtime + bundler +- `bun build` — bundles `src/main.ts` → `dist/main.js` +- `build.ts` — production build with minification + HTML copy +- No external libraries required (pure Canvas 2D) + +## Architecture + +- **Game** owns the game loop (`requestAnimationFrame`), the world + (`RoomMap`, `Player`), and dispatches to render functions. +- **Tick** processes input → movement → attack → enemy AI → room + clear check → transitions → win condition. +- **Transitions** check player tile position + keypress against door + geometry; call `Game.enterRoom()` which resets room state. +- **Collision** allows bounding-box overlap at door openings (row/col + outside normal bounds). +- **Rendering** is split into 4 pure functions that receive `ctx` + data. diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..9aa1180 --- /dev/null +++ b/build.ts @@ -0,0 +1,35 @@ +import { build } from "bun"; + +const result = await build({ + entrypoints: ["./src/main.ts"], + outdir: "./dist", + target: "browser", + minify: true, + sourcemap: "external", +}); + +if (!result.success) { + console.error("Build failed", result.logs); + process.exit(1); +} + +// Copy HTML template with correct script reference +const html = ` + + + +Dungeon Crawl — Ranged / Melee + + + + + + +`; + +Bun.write("./dist/index.html", html); +console.log("Build complete → dist/"); diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..eb11217 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,16 @@ + + + + +Dungeon Crawl — Ranged / Melee + + + + + + + \ No newline at end of file diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..096fe59 --- /dev/null +++ b/dist/main.js @@ -0,0 +1,3 @@ +var H={};function l(){window.addEventListener("keydown",(q)=>{if(H[q.key]=!0,["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].includes(q.key))q.preventDefault()}),window.addEventListener("keyup",(q)=>{H[q.key]=!1}),window.addEventListener("blur",()=>{for(let q in H)delete H[q]})}var N=880,b=660,j=44,K=15,M=11,s=660,D=484,u=110,v=80,Y=0,m=1,W=2,P=0,a=1,k={up:[0,-1],down:[0,1],left:[-1,0],right:[1,0]},g={up:"bottom",down:"top",left:"right",right:"left"},B={up:{cols:[6,7,8],row:0,cx:7,cy:0},down:{cols:[6,7,8],row:10,cx:7,cy:10},left:{col:0,rows:[4,5,6],cx:0,cy:5},right:{col:14,rows:[4,5,6],cx:14,cy:5}},O=3,r=8,o=4;function E(q){let J=[];for(let Q=0;Q0;J--){let Q=Math.random()*J|0;[q[J],q[Q]]=[q[Q],q[J]]}return q}function $0(q,J){return Math.random()*(J-q)+q}function S(q,J){return Math.floor($0(q,J+1))}function x(q,J){return q.xJ.x&&q.yJ.y}class f{c;r;type;doors={up:!1,down:!1,left:!1,right:!1};visited=!1;cleared=!1;enemies=[];tears=[];tiles;constructor(q,J,Q){this.c=q,this.r=J,this.type=Q,this.tiles=E()}buildTiles(){this.tiles=E(this.cleared||this.type==="spawn"?this.doors:void 0)}}class I{rooms=new Map;key(q,J){return q+","+J}get(q,J){return this.rooms.get(this.key(q,J))}has(q,J){return this.rooms.has(this.key(q,J))}add(q,J,Q){let V=new f(q,J,Q);return this.rooms.set(this.key(q,J),V),V}hasBoss(){for(let q of this.rooms.values())if(q.type==="boss")return!0;return!1}generate(){this.add(0,0,"spawn");let q=[[0,0]],J=1,Q=r+S(0,o),V=[["up",0,-1],["down",0,1],["left",-1,0],["right",1,0]];while(q.length>0&&J=Q)break;let G=z+Z,A=F+_;if(Math.abs(G)>O||Math.abs(A)>O)continue;if(this.has(G,A))continue;let L="normal";if(!this.hasBoss()&&(J===Q-1||Math.random()<0.2&&J>=3))L="boss";else if(Math.random()<0.12&&J>=2)L="treasure";this.add(G,A,L);let p=X;this.get(z,F).doors[p]=!0,this.get(G,A).doors[g[p]]=!0,q.push([G,A]),J++,U=!0}if(!U)q.splice($,1)}if(!this.hasBoss()){let $=[...this.rooms.values()].filter((z)=>z.type==="normal");if($.length>0)$[S(0,$.length-1)].type="boss"}}}class T{x=0;y=0;w=26;h=26;speed=3.2;hp=6;maxHp=6;mode=P;facing="up";moveDir="up";atkCD=0;invTimer=0;transCD=0;get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}}var z0={normal:{w:32,hp:3,speed:1.15,damage:1},fast:{w:26,hp:2,speed:1.9,damage:1},boss:{w:46,hp:10,speed:0.9,damage:2}};class i{x;y;type;w;h;hp;maxHp;speed;damage;knx=0;kny=0;hitTimer=0;atkTimer=0;constructor(q,J,Q){this.x=q,this.y=J,this.type=Q;let V=z0[Q];this.w=V.w,this.h=V.w,this.hp=V.hp,this.maxHp=V.hp,this.speed=V.speed,this.damage=V.damage}get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}get alive(){return this.hp>0}}class d{x;y;dx;dy;r=5;speed=7;damage=1;life=80;constructor(q,J,Q,V){this.x=q,this.y=J,this.dx=Q,this.dy=V}get alive(){return this.life>0}}class n{dir;life=10;damage=2;kb=10;box;constructor(q,J,Q){this.dir=Q;let V=22,$=50,[z,F]=k[Q];this.box={x:q+(z>0?V:z<0?-V-$:-$/2),y:J+(F>0?V:F<0?-V-$:-$/2),w:$,h:$}}get alive(){return this.life>0}}function j0(q,J,Q){if(Q<0&&q.doors.up&&B.up.cols.includes(J))return!1;if(Q>=M&&q.doors.down&&B.down.cols.includes(J))return!1;if(J<0&&q.doors.left&&B.left.rows.includes(Q))return!1;if(J>=K&&q.doors.right&&B.right.rows.includes(Q))return!1;if(Q<0||Q>=M||J<0||J>=K)return!0;return q.tiles[Q][J]===Y}function C(q,J,Q,V){let $=Math.floor((q.x-Q)/w),z=Math.floor((q.x+q.w-Q)/w),F=Math.floor((q.y-V)/w),U=Math.floor((q.y+q.h-V)/w);for(let X=F;X<=U;X++)for(let Z=$;Z<=z;Z++)if(j0(J,Z,X))return!0;return!1}var w=44;var F0={up:["w","W","ArrowUp"],down:["s","S","ArrowDown"],left:["a","A","ArrowLeft"],right:["d","D","ArrowRight"]};function R(q){for(let J of F0[q])if(H[J])return!0;return!1}function c(q){if(q.gameOver||q.won)return;if(q.player.transCD>0)return;let{player:J,curRoom:Q}=q;if(!Q.cleared)return;let V=Math.floor((J.x-u)/j),$=Math.floor((J.y-v)/j);if($===0&&Q.doors.up&&B.up.cols.includes(V)&&R("up")){if(q.roomMap.has(q.cc,q.cr-1)){q.cr--,q.enterRoom("down");return}}if($===M-1&&Q.doors.down&&B.down.cols.includes(V)&&R("down")){if(q.roomMap.has(q.cc,q.cr+1)){q.cr++,q.enterRoom("up");return}}if(V===0&&Q.doors.left&&B.left.rows.includes($)&&R("left")){if(q.roomMap.has(q.cc-1,q.cr)){q.cc--,q.enterRoom("right");return}}if(V===K-1&&Q.doors.right&&B.right.rows.includes($)&&R("right")){if(q.roomMap.has(q.cc+1,q.cr)){q.cc++,q.enterRoom("left");return}}}function t(q,J){for(let Q=0;Q0&&$.hitTimer%4<2;if(q.save(),q.fillStyle="rgba(0,0,0,0.3)",q.beginPath(),q.ellipse($.x+2,$.y+$.h/4,$.w/3,4,0,0,Math.PI*2),q.fill(),$.type==="boss")U0(q,$,z);else if($.type==="fast")Z0(q,$,z);else B0(q,$,z);q.restore()}X0(q,Q);for(let $ of J.tears){if(!$.alive)continue;q.save(),q.fillStyle="#6699cc",q.beginPath(),q.arc($.x,$.y,$.r,0,Math.PI*2),q.fill(),q.fillStyle="#99bbee",q.beginPath(),q.arc($.x-1.5,$.y-1.5,$.r-2,0,Math.PI*2),q.fill(),q.restore()}if(V&&V.alive)v0(q,V)}function U0(q,J,Q){if(q.fillStyle=Q?"#ddd":"#5a0a0a",q.beginPath(),q.arc(J.x,J.y,J.w/2,0,Math.PI*2),q.fill(),q.fillStyle="#4a0808",q.beginPath(),q.arc(J.x-3,J.y-3,J.w/2-4,0,Math.PI*2),q.fill(),q.fillStyle=Q?"#000":"#ff3333",q.beginPath(),q.arc(J.x-8,J.y-8,5,0,Math.PI*2),q.fill(),q.beginPath(),q.arc(J.x+8,J.y-8,5,0,Math.PI*2),q.fill(),q.fillStyle="#000",q.beginPath(),q.arc(J.x-8,J.y-8,2.5,0,Math.PI*2),q.fill(),q.beginPath(),q.arc(J.x+8,J.y-8,2.5,0,Math.PI*2),q.fill(),q.fillStyle=Q?"#bbb":"#3a0505",q.beginPath(),q.moveTo(J.x-16,J.y-J.w/2+4),q.lineTo(J.x-8,J.y-J.w/2-16),q.lineTo(J.x,J.y-J.w/2+4),q.fill(),q.beginPath(),q.moveTo(J.x-4,J.y-J.w/2+4),q.lineTo(J.x+4,J.y-J.w/2-16),q.lineTo(J.x+12,J.y-J.w/2+4),q.fill(),J.hp0&&J.invTimer%6<3,V=J.mode===0?"#2a6a9a":"#9a3a2a";q.fillStyle=Q?"#ddd":V,q.fillRect(J.x-J.w/2,J.y-J.h/2,J.w,J.h),q.fillStyle=Q?"#ccc":"rgba(0,0,0,0.3)",q.fillRect(J.x-J.w/2+3,J.y-J.h/2+3,J.w-6,J.h-6);let[$,z]=k[J.facing],F=J.x+$*(J.w/2+4),U=J.y+z*(J.h/2+4);if(J.mode===0)H0(q,J,F,U,$,z,Q);else N0(q,F,U,$,z,Q);q.fillStyle="#fff";let X=J.x+$*5,Z=J.y+z*5;q.fillRect(X-5,Z-4,4,5),q.fillRect(X+1,Z-4,4,5),q.fillStyle="#111",q.fillRect(X-4+$,Z-3+z,2,3),q.fillRect(X+2+$,Z-3+z,2,3),q.restore()}function H0(q,J,Q,V,$,z,F){q.strokeStyle=F?"#999":"#555",q.lineWidth=3,q.lineCap="round",q.beginPath(),q.moveTo(Q,V),q.lineTo(Q+$*14+z*2,V+z*14+$*2),q.stroke(),q.fillStyle=F?"#aaa":"#444",q.save();let U=z!==0?Math.PI/2*(z<0?-1:1):$<0?Math.PI:0;if(q.translate(J.x+$*8,J.y+z*8),q.rotate(U),q.fillRect(-7,-4,14,8),q.restore(),J.atkCD>8&&J.mode===0)q.fillStyle="rgba(255,200,50,0.6)",q.beginPath(),q.arc(Q+$*16,V+z*16,6,0,Math.PI*2),q.fill(),q.fillStyle="rgba(255,255,200,0.4)",q.beginPath(),q.arc(Q+$*18,V+z*18,8,0,Math.PI*2),q.fill()}function N0(q,J,Q,V,$,z){q.strokeStyle=z?"#bbb":"#ccc",q.lineWidth=2;let F=J+V*6,U=Q+$*6;q.beginPath(),q.moveTo(F,U),q.lineTo(F+V*16-$*6,U+$*16+V*6),q.lineTo(F+V*16+$*6,U+$*16-V*6),q.closePath(),q.fillStyle=z?"#ddd":"#d4d4d4",q.fill(),q.stroke(),q.fillStyle=z?"#a99":"#5a3a1a",q.fillRect(F-V*3-$*3,U-$*3-V*3,8,8),q.fillStyle=z?"#bbb":"#888",q.fillRect(F-V*2-$*5,U-$*2-V*5,5,12)}function v0(q,J){let Q=J.life/10;q.save(),q.globalAlpha=Q*0.35,q.fillStyle="#cc8844",q.fillRect(J.box.x,J.box.y,J.box.w,J.box.h),q.globalAlpha=Q,q.strokeStyle="#ddbb88",q.lineWidth=2,q.strokeRect(J.box.x,J.box.y,J.box.w,J.box.h),q.globalAlpha=Q*0.8,q.strokeStyle="#ffcc88",q.lineWidth=3;let[V,$]=k[J.dir];q.beginPath(),q.moveTo(J.box.x+J.box.w/2-V*18,J.box.y+J.box.h/2-$*18),q.lineTo(J.box.x+J.box.w/2+V*18,J.box.y+J.box.h/2+$*18),q.stroke(),q.restore()}function J0(q,J,Q){G0(q,J),u0(q,J),K0(q,Q),M0(q,Q)}function G0(q,J){q.fillStyle="#111",q.fillRect(20,20,140,14),q.fillStyle="#2a0a0a",q.fillRect(22,22,136,10);let F=Math.max(0,J.hp/J.maxHp),U=F>0.5?"#993333":F>0.25?"#994422":"#663322";q.fillStyle=U,q.fillRect(22,22,136*F,10),q.strokeStyle="#333",q.lineWidth=1,q.strokeRect(20,20,140,14),q.fillStyle="#bbb",q.font="10px monospace",q.textAlign="center",q.fillText(`HP ${J.hp}/${J.maxHp}`,90,31)}function u0(q,J){let Q=b-46;q.textAlign="center";let V=J.mode===P?"RANGED":"MELEE",$=J.mode===P?"#4488cc":"#cc6644";if(q.fillStyle="#0d0d0d",q.fillRect(N/2-95,Q-18,190,34),q.strokeStyle=$,q.lineWidth=2,q.strokeRect(N/2-95,Q-18,190,34),q.fillStyle=$,q.font="bold 17px monospace",q.fillText(`[ ${V} ]`,N/2,Q+8),q.fillStyle="#555",q.font="11px monospace",q.fillText("[Tab/Q] switch",N/2,Q-26),J.mode===P)q.strokeStyle="#88bbdd",q.lineWidth=2,q.beginPath(),q.moveTo(N/2-82,Q-4),q.lineTo(N/2-72,Q-4),q.stroke(),q.fillStyle="#88bbdd",q.fillRect(N/2-82,Q-8,10,8);else q.fillStyle="#ddbb88",q.beginPath(),q.moveTo(N/2-82,Q-10),q.lineTo(N/2-74,Q-2),q.lineTo(N/2-82,Q+4),q.fill()}function K0(q,J){let Q=J.enemies.filter((V)=>V.alive).length;if(q.textAlign="left",Q>0)q.fillStyle="#aa4444",q.font="13px monospace",q.fillText(`▶ ${Q}`,20,b-18);else if(!J.cleared&&J.type!=="spawn")q.fillStyle="#886633",q.font="13px monospace",q.fillText("Clear the room",20,b-18)}function M0(q,J){if(!J.visited)return;q.textAlign="right";let V={spawn:"START",normal:"",treasure:"TREASURE",boss:"BOSS"}[J.type];if(V)q.fillStyle="#555",q.font="11px monospace",q.fillText(V,N-20,v+D+30)}var h=14,A0=2;function Q0(q,J,Q,V){let $=h+A0,z=N-180,F=12;q.fillStyle="rgba(0,0,0,0.75)",q.fillRect(z-8,4,$*7+16,$*7+16),q.strokeStyle="#333",q.lineWidth=1,q.strokeRect(z-8,4,$*7+16,$*7+16);for(let U=-3;U<=3;U++)for(let X=-3;X<=3;X++){let Z=J.get(Q+X,V+U);if(!Z)continue;let _=z+(X+3)*$,G=12+(U+3)*$,A="#141414";if(Z.visited)A=Z.type==="spawn"?"#2a5a2a":Z.type==="boss"?"#5a1a1a":Z.type==="treasure"?"#5a5a1a":"#555";if(q.fillStyle=A,q.fillRect(_,G,h,h),Z.visited){if(q.strokeStyle="rgba(255,255,255,0.12)",q.lineWidth=1,Z.doors.up)q.fillRect(_+$/2-2,G-2,4,3);if(Z.doors.down)q.fillRect(_+$/2-2,G+h-1,4,3);if(Z.doors.left)q.fillRect(_-2,G+$/2-2,3,4);if(Z.doors.right)q.fillRect(_+h-1,G+$/2-2,3,4)}if(X===0&&U===0)q.strokeStyle="#ddd",q.lineWidth=2,q.strokeRect(_-1.5,G-1.5,h+3,h+3)}}class y{canvas;ctx;roomMap=new I;player=new T;cc=0;cr=0;meleeSwing=null;gameOver=!1;won=!1;constructor(q){this.canvas=q,this.ctx=q.getContext("2d"),this.enterRoom("up"),this.loop()}get curRoom(){return this.roomMap.get(this.cc,this.cr)}toggleMode(){this.player.mode=this.player.mode===P?a:P}restart(){this.gameOver=!1,this.won=!1,this.roomMap=new I,this.player=new T,this.cc=0,this.cr=0,this.meleeSwing=null,this.enterRoom("up")}enterRoom(q){let J=this.curRoom;J.visited=!0;let Q=B[q],[V,$]=k[q];if(this.player.x=u+Q.cx*j+j/2-V*j,this.player.y=v+Q.cy*j+j/2-$*j,this.player.facing=q,this.player.invTimer=20,this.player.transCD=15,this.meleeSwing=null,J.buildTiles(),J.enemies=[],J.tears=[],!J.cleared&&J.type!=="spawn")this.spawnEnemies(J,q);else J.cleared=!0,J.buildTiles()}spawnEnemies(q,J){let Q=q.type==="boss"?1:q.type==="treasure"?0:2+Math.floor(Math.random()*3);for(let V=0;Vthis.loop())}tick(){let q=this.curRoom,J=this.player;if(J.invTimer>0)J.invTimer--;if(J.atkCD>0)J.atkCD--;if(J.transCD>0)J.transCD--;this.processMovement(J),this.processAttack(q,J),this.updateMelee(q),this.updateTears(q);let Q=this.updateEnemies(q,J);if(q.enemies.length>0&&Q===0&&!q.cleared)q.cleared=!0,q.buildTiles();if(c(this),!this.gameOver){if([...this.roomMap.rooms.values()].find(($)=>$.type==="boss")?.cleared)this.won=!0}}processMovement(q){let J=0,Q=0;if(H.w||H.W)Q=-1;if(H.s||H.S)Q=1;if(H.a||H.A)J=-1;if(H.d||H.D)J=1;if(J!==0||Q!==0){let V=Math.hypot(J,Q);if(J/=V,Q/=V,Q<0)q.moveDir="up";else if(Q>0)q.moveDir="down";if(J<0)q.moveDir="left";else if(J>0)q.moveDir="right";let $=J*q.speed,z=Q*q.speed;if(q.x+=$,C(q.box,this.curRoom,u,v))q.x-=$;if(q.y+=z,C(q.box,this.curRoom,u,v))q.y-=z}}processAttack(q,J){let Q=0,V=0;if(H.ArrowUp)Q=0,V=-1;else if(H.ArrowDown)Q=0,V=1;else if(H.ArrowLeft)Q=-1,V=0;else if(H.ArrowRight)Q=1,V=0;else if(H[" "]||H.Space)[Q,V]=k[J.moveDir];if((Q!==0||V!==0)&&J.atkCD<=0){let $=Math.hypot(Q,V);Q/=$,V/=$;let z=V<0?"up":V>0?"down":Q<0?"left":"right";if(J.facing=z,J.atkCD=J.mode===P?10:22,J.mode===P)q.tears.push(new d(J.x,J.y,Q,V));else this.meleeSwing=new n(J.x,J.y,z)}}updateMelee(q){if(this.meleeSwing&&!this.meleeSwing.alive)this.meleeSwing=null;if(!this.meleeSwing)return;this.meleeSwing.life--;for(let J of q.enemies){if(!J.alive||J.hitTimer>0)continue;if(x(J.box,this.meleeSwing.box)){J.hp-=this.meleeSwing.damage,J.hitTimer=10;let[Q,V]=k[this.meleeSwing.dir];J.knx=Q*this.meleeSwing.kb,J.kny=V*this.meleeSwing.kb}}}updateTears(q){for(let J of q.tears){if(!J.alive)continue;J.x+=J.dx*J.speed,J.y+=J.dy*J.speed,J.life--;let Q=Math.floor((J.x-u)/j),V=Math.floor((J.y-v)/j);if(Q<0||Q>=K||V<0||V>=M||J.life<=0){J.life=0;continue}if(q.tiles[V][Q]===Y){J.life=0;continue}for(let $ of q.enemies){if(!$.alive)continue;if(Math.hypot(J.x-$.x,J.y-$.y)<$.w/2+J.r){$.hp-=J.damage,$.hitTimer=8,J.life=0;break}}}q.tears=q.tears.filter((J)=>J.alive)}updateEnemies(q,J){let Q=0;for(let V of q.enemies){if(!V.alive)continue;if(Q++,V.hitTimer>0)V.hitTimer--;if(Math.abs(V.knx)>0.1||Math.abs(V.kny)>0.1){V.x+=V.knx*3,V.y+=V.kny*3,V.knx*=0.85,V.kny*=0.85;continue}V.knx=0,V.kny=0;let $=J.x-V.x,z=J.y-V.y,F=Math.hypot($,z);if(F>0&&F<500){let U=V.speed,X=$/F*U,Z=z/F*U;if(V.x+=X,C(V.box,q,u,v))V.x-=X;if(V.y+=Z,C(V.box,q,u,v))V.y-=Z}if(V.atkTimer>0)V.atkTimer--;if(Math.hypot(V.x-J.x,V.y-J.y)<(V.w+J.w)/2&&J.invTimer<=0&&V.atkTimer<=0){if(J.hp-=V.damage,J.invTimer=60,V.atkTimer=30,J.hp<=0)return this.gameOver=!0,Q}}return Q}render(){let q=this.ctx;if(q.fillStyle="#0a0a0f",q.fillRect(0,0,N,b),t(q,this.curRoom),q0(q,this.curRoom,this.player,this.meleeSwing),J0(q,this.player,this.curRoom),Q0(q,this.roomMap,this.cc,this.cr),this.gameOver)this.drawOverlay("#c33","GAME OVER");else if(this.won)this.drawOverlay("#3c3","VICTORY")}drawOverlay(q,J){let Q=this.ctx;Q.fillStyle="rgba(0,0,0,0.8)",Q.fillRect(0,0,N,b),Q.fillStyle=q,Q.font="bold 56px monospace",Q.textAlign="center",Q.fillText(J,N/2,b/2-20),Q.fillStyle="#888",Q.font="18px monospace",Q.fillText("[R] restart",N/2,b/2+40)}}l();window.addEventListener("keydown",(q)=>{let J=window.__game;if(!J)return;if((q.key==="Tab"||q.key==="q"||q.key==="Q")&&!J.gameOver&&!J.won)q.preventDefault(),J.toggleMode();if(q.key==="r"||q.key==="R"){if(J.gameOver||J.won)J.restart()}});window.addEventListener("load",()=>{let q=document.getElementById("game");if(!q){document.body.innerHTML='

Error: canvas element not found

';return}let J=new y(q);window.__game=J}); + +//# debugId=8155D39DF19FF28B64756E2164756E21 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cb6adb5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,131 @@ +# Architecture + +## Overview + +The game is a single-page Canvas 2D application. The source is written +in TypeScript and split into ~19 modules under `src/`, bundled by Bun +into a single `dist/main.js`. The HTML shell in `dist/index.html` loads +the bundle. + +## Module Dependency Graph + +``` +main.ts + ├── input.ts (global KEYS, setupInput) + └── Game.ts + ├── constants.ts (all shared numeric constants) + ├── types.ts (Dir, RoomType, CombatMode, Box, Doors) + ├── math.ts (shuffle, rand, ri, dist, overlap) + ├── RoomMap.ts → Room.ts, tiles.ts, doors.ts + ├── Player.ts + ├── Enemy.ts + ├── Tear.ts + ├── MeleeSwing.ts + ├── collision.ts → Room.ts, tiles.ts + ├── transitions.ts → Game.ts (type only), input.ts + ├── spawner.ts → Room.ts, Enemy.ts + └── render/* + → roomRenderer.ts, entityRenderer.ts, + hudRenderer.ts, minimapRenderer.ts +``` + +## Game Loop + +Every frame (`requestAnimationFrame`): + +1. **Timers** — decrement `invTimer`, `atkCD`, `transCD` +2. **Movement** — read WASD, apply velocity, resolve wall collisions +3. **Attack** — read arrow keys / Space, fire tear or create melee swing +4. **Melee Update** — decrement swing life, apply damage + knockback +5. **Tear Update** — move projectiles, check wall/enemy collisions +6. **Enemy AI** — chase player, contact damage with cooldown +7. **Room Clear** — if all enemies dead → `room.cleared = true`, rebuild tiles with doors +8. **Transition Check** — if player on door tile + key pressed → load adjacent room +9. **Win Check** — if boss room cleared → `won = true` +10. **Render** — draw room tiles → entities → HUD → minimap → overlays + +## Transition System + +Room transitions are the most complex subsystem. Key design: + +- **Door geometry**: `DOOR` constant defines 3-tile-wide openings at + each cardinal edge (top: cols 6-8, row 0; bottom: cols 6-8, row 10; + left: rows 4-6, col 0; right: rows 4-6, col 14). +- **Detection**: `checkTransition()` computes the player's tile position + (`col, row`). If they're on a door tile AND pressing the matching + movement key, the transition fires. +- **Cooldown**: `transCD = 15` prevents re-entry within 15 frames. +- **Movement lock**: Transition only works in cleared rooms + (`room.cleared === true`). +- **Collision bypass**: `isBlocked()` returns `false` for out-of-bounds + tiles at door openings, letting the player's bounding box extend + beyond the room boundary. + +## Collision System + +- `isBlocked(room, col, row)` — per-tile check. Returns `true` for + wall tiles and out-of-bounds positions, except at door openings. +- `collidesWall(box, room, ox, oy)` — iterates all tiles covered by + the entity's bounding box. If any tile is blocked, returns `true`. +- Movement resolves axis-independently: apply X, revert if collision; + apply Y, revert if collision. + +## Room Generation + +`RoomMap.generate()` uses a random walk: + +1. Start at (0,0) with a spawn room. +2. Maintain a frontier list of rooms that have room to expand. +3. Each step, pick a random frontier room, shuffle directions, and + attempt to add a new room in an unoccupied adjacent cell within + the 7×7 grid. +4. Room types are assigned probabilistically (boss at ~20%, treasure + at ~12%, rest normal). +5. If no boss room was generated, one normal room is promoted. + +## Rendering + +Rendering is split into 4 stateless functions, each taking +`CanvasRenderingContext2D` as the first argument: + +- **roomRenderer** — tile loop with wall/door/floor styles +- **entityRenderer** — enemies (3 types), player (body + weapon), + tears, melee swing arc +- **hudRenderer** — HP bar, mode indicator, enemy count, room label +- **minimapRenderer** — 7×7 grid with visited/current room highlights + +## Weapon Visuals + +- **Pistol (ranged)**: Barrel line + body rect, rotated toward facing + direction. Muzzle flash circle at `atkCD > 8`. +- **Knife (melee)**: Triangular blade + handle + guard rects, offset + in facing direction. + +## Enemy Types + +| Type | Size | HP | Speed | Damage | Visual | +|--------|------|----|-------|--------|--------| +| Normal | 32px | 3 | 1.15 | 1 | Brown block, yellow eyes | +| Fast | 26px | 2 | 1.9 | 1 | Red circle, small eyes | +| Boss | 46px | 10 | 0.9 | 2 | Large red circle, horns, HP bar | + +## State Management + +- `GAME_OVER` / `WON` — boolean flags checked in loop and render. +- `KEYS` — global mutable map, reset on window blur. +- Room state (`cleared`, `visited`, `enemies[]`, `tears[]`) — per-room. +- Player state (`hp`, `mode`, `facing`, `transCD`, etc.) — single + `Player` instance. +- Cooldowns (`invTimer`, `atkCD`, `transCD`) decrement each frame. + +## Build Pipeline + +``` +bun build src/main.ts --outdir dist --target browser --minify +cp src/index.html dist/ +``` + +- Bundler: Bun's native bundler (esbuild under the hood). +- Target: browser (ES module → IIFE/global wrapper). +- Result: single `dist/main.js` (~30 KB) + `dist/index.html`. +- Open `dist/index.html` directly in any modern browser. diff --git a/index.html b/index.html new file mode 100644 index 0000000..21de224 --- /dev/null +++ b/index.html @@ -0,0 +1,702 @@ + + + + +Dungeon Crawl — Ranged / Melee + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0282ed2 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "dungeon-crawl", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun build src/main.ts --outdir dist --target browser --watch", + "build": "bun run build.ts", + "serve": "bun build src/main.ts --outdir dist --target browser && cp src/index.html dist/", + "start": "bun run build && echo 'open dist/index.html'" + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e13d96c --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,48 @@ +// Canvas & grid dimensions +export const CW = 880; +export const CH = 660; +export const TILE = 44; +export const COLS = 15; +export const ROWS = 11; +export const RW = COLS * TILE; +export const RH = ROWS * TILE; +export const OX = (CW - RW) / 2; +export const OY = 80; + +// Tile types +export const T_WALL = 0; +export const T_FLOOR = 1; +export const T_DOOR = 2; + +// Combat modes +export const MODE_RANGED = 0; +export const MODE_MELEE = 1; + +// Direction vectors +export const DIR: Record = { + up: [0, -1], + down: [0, 1], + left: [-1, 0], + right: [1, 0], +}; + +// Opposite direction lookup +export const OPP: Record = { + up: 'bottom', + down: 'top', + left: 'right', + right: 'left', +}; + +// Door opening geometry (3 tiles wide at each cardinal edge) +export const DOOR = { + up: { cols: [6, 7, 8], row: 0, cx: 7, cy: 0 }, + down: { cols: [6, 7, 8], row: 10, cx: 7, cy: 10 }, + left: { col: 0, rows: [4, 5, 6], cx: 0, cy: 5 }, + right: { col: 14, rows: [4, 5, 6], cx: 14, cy: 5 }, +} as const; + +// Grid generation bounds +export const MAP_RADIUS = 3; +export const MIN_ROOMS = 8; +export const EXTRA_ROOMS = 4; diff --git a/src/doors.ts b/src/doors.ts new file mode 100644 index 0000000..b5c1506 --- /dev/null +++ b/src/doors.ts @@ -0,0 +1,15 @@ +import { DOOR, DIR, OPP } from './constants'; +import type { Dir, Doors } from './types'; + +export { DOOR, DIR, OPP }; +export type { Dir, Doors }; + +/** Return the direction opposite to the given one */ +export function oppositeDir(d: Dir): Dir { + return OPP[d] as Dir; +} + +/** Return the [dc, dr] offset for a direction */ +export function dirOffset(d: Dir): [number, number] { + return DIR[d]; +} diff --git a/src/entities/Enemy.ts b/src/entities/Enemy.ts new file mode 100644 index 0000000..d805a7f --- /dev/null +++ b/src/entities/Enemy.ts @@ -0,0 +1,54 @@ +import { rand } from '../math'; +import type { Box } from '../types'; + +export type EnemyType = 'normal' | 'fast' | 'boss'; + +/** Stats table indexed by enemy type */ +const STATS: Record = { + normal: { w: 32, hp: 3, speed: 1.15, damage: 1 }, + fast: { w: 26, hp: 2, speed: 1.9, damage: 1 }, + boss: { w: 46, hp: 10, speed: 0.9, damage: 2 }, +}; + +export class Enemy { + x: number; + y: number; + type: EnemyType; + w: number; + h: number; + hp: number; + maxHp: number; + speed: number; + damage: number; + knx = 0; + kny = 0; + hitTimer = 0; + atkTimer = 0; + + constructor(x: number, y: number, type: EnemyType) { + this.x = x; + this.y = y; + this.type = type; + const s = STATS[type]; + this.w = s.w; + this.h = s.w; + this.hp = s.hp; + this.maxHp = s.hp; + this.speed = s.speed; + this.damage = s.damage; + } + + get box(): Box { + return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; + } + + get alive(): boolean { + return this.hp > 0; + } +} + +/** Pick a random enemy type, weighted */ +export function randomEnemyType(bossRoom: boolean): EnemyType { + if (bossRoom) return 'boss'; + return Math.random() < 0.3 ? 'fast' : 'normal'; +} diff --git a/src/entities/MeleeSwing.ts b/src/entities/MeleeSwing.ts new file mode 100644 index 0000000..d6f9401 --- /dev/null +++ b/src/entities/MeleeSwing.ts @@ -0,0 +1,27 @@ +import type { Dir, Box } from '../types'; +import { DIR } from '../constants'; + +export class MeleeSwing { + dir: Dir; + life = 10; + damage = 2; + kb = 10; + box: Box; + + constructor(x: number, y: number, dir: Dir) { + this.dir = dir; + const d = 22; + const s = 50; + const [dx, dy] = DIR[dir]; + this.box = { + x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2), + y: y + (dy > 0 ? d : dy < 0 ? -d - s : -s / 2), + w: s, + h: s, + }; + } + + get alive(): boolean { + return this.life > 0; + } +} diff --git a/src/entities/Player.ts b/src/entities/Player.ts new file mode 100644 index 0000000..540e233 --- /dev/null +++ b/src/entities/Player.ts @@ -0,0 +1,22 @@ +import { MODE_RANGED, MODE_MELEE } from '../constants'; +import type { CombatMode, Box, Dir } from '../types'; + +export class Player { + x = 0; + y = 0; + w = 26; + h = 26; + speed = 3.2; + hp = 6; + maxHp = 6; + mode: CombatMode = MODE_RANGED; + facing: Dir = 'up'; + moveDir: Dir = 'up'; + atkCD = 0; + invTimer = 0; + transCD = 0; + + get box(): Box { + return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; + } +} diff --git a/src/entities/Tear.ts b/src/entities/Tear.ts new file mode 100644 index 0000000..ace9e45 --- /dev/null +++ b/src/entities/Tear.ts @@ -0,0 +1,21 @@ +export class Tear { + x: number; + y: number; + dx: number; + dy: number; + r = 5; + speed = 7; + damage = 1; + life = 80; + + constructor(x: number, y: number, dx: number, dy: number) { + this.x = x; + this.y = y; + this.dx = dx; + this.dy = dy; + } + + get alive(): boolean { + return this.life > 0; + } +} diff --git a/src/game/Game.ts b/src/game/Game.ts new file mode 100644 index 0000000..285781c --- /dev/null +++ b/src/game/Game.ts @@ -0,0 +1,334 @@ +import { CW, CH, OX, OY, TILE, COLS, ROWS, DIR, DOOR } from '../constants'; +import { T_WALL } from '../room/tiles'; +import { MODE_RANGED, MODE_MELEE } from '../constants'; +import type { Dir } from '../types'; +import { KEYS } from '../input'; +import { overlap } from '../math'; +import { RoomMap } from '../room/RoomMap'; +import { Room } from '../room/Room'; +import { Player } from '../entities/Player'; +import { Enemy } from '../entities/Enemy'; +import { Tear } from '../entities/Tear'; +import { MeleeSwing } from '../entities/MeleeSwing'; +import { collidesWall } from './collision'; +import { checkTransition } from './transitions'; +import { drawRoom } from '../render/roomRenderer'; +import { drawEntities } from '../render/entityRenderer'; +import { drawHUD } from '../render/hudRenderer'; +import { drawMinimap } from '../render/minimapRenderer'; + +export class Game { + // Canvas + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + + // World + roomMap = new RoomMap(); + player = new Player(); + cc = 0; + cr = 0; + meleeSwing: MeleeSwing | null = null; + + // State + gameOver = false; + won = false; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + this.enterRoom('up'); + this.loop(); + } + + get curRoom(): Room { + return this.roomMap.get(this.cc, this.cr)!; + } + + toggleMode(): void { + this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED; + } + + restart(): void { + this.gameOver = false; + this.won = false; + this.roomMap = new RoomMap(); + this.player = new Player(); + this.cc = 0; + this.cr = 0; + this.meleeSwing = null; + this.enterRoom('up'); + } + + /** Place the player inside the current room after entering via a door */ + enterRoom(fromDir: Dir): void { + const room = this.curRoom; + room.visited = true; + + const d = DOOR[fromDir]; + const [ddc, ddr] = DIR[fromDir]; + + this.player.x = OX + d.cx * TILE + TILE / 2 - ddc * TILE; + this.player.y = OY + d.cy * TILE + TILE / 2 - ddr * TILE; + this.player.facing = fromDir; + this.player.invTimer = 20; + this.player.transCD = 15; + + this.meleeSwing = null; + room.buildTiles(); + room.enemies = []; + room.tears = []; + + if (!room.cleared && room.type !== 'spawn') { + this.spawnEnemies(room, fromDir); + } else { + room.cleared = true; + room.buildTiles(); + } + } + + // -------- SPAWNING -------- + + private spawnEnemies(room: Room, entryDir: Dir): void { + const count = room.type === 'boss' ? 1 : room.type === 'treasure' ? 0 : 2 + Math.floor(Math.random() * 3); + + for (let i = 0; i < count; i++) { + let tries = 0; + let x: number, y: number, ok: boolean; + const type = room.type === 'boss' ? 'boss' as const + : Math.random() < 0.3 ? 'fast' as const : 'normal' as const; + + do { + x = OX + 2 * TILE + Math.random() * (COLS - 4) * TILE; + y = OY + 2 * TILE + Math.random() * (ROWS - 4) * TILE; + ok = true; + + const ed = DOOR[entryDir]; + const dx = OX + ed.cx * TILE + TILE / 2; + const dy = OY + ed.cy * TILE + TILE / 2; + if (Math.hypot(x - dx, y - dy) < 180) ok = false; + + for (const e of room.enemies) { + if (Math.hypot(x - e.x, y - e.y) < 60) { ok = false; break; } + } + if (Math.hypot(x - this.player.x, y - this.player.y) < 150) ok = false; + + tries++; + } while (!ok && tries < 100); + + room.enemies.push(new Enemy(x, y, type)); + } + } + + // -------- GAME LOOP -------- + + private loop(): void { + if (!this.gameOver && !this.won) this.tick(); + this.render(); + requestAnimationFrame(() => this.loop()); + } + + private tick(): void { + const room = this.curRoom; + const p = this.player; + + if (p.invTimer > 0) p.invTimer--; + if (p.atkCD > 0) p.atkCD--; + if (p.transCD > 0) p.transCD--; + + this.processMovement(p); + this.processAttack(room, p); + this.updateMelee(room); + this.updateTears(room); + + const aliveCount = this.updateEnemies(room, p); + + if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) { + room.cleared = true; + room.buildTiles(); + } + + checkTransition(this); + + if (!this.gameOver) { + const bossRoom = [...this.roomMap.rooms.values()].find(r => r.type === 'boss'); + if (bossRoom?.cleared) this.won = true; + } + } + + private processMovement(p: Player): void { + let mx = 0, my = 0; + if (KEYS['w'] || KEYS['W']) my = -1; + if (KEYS['s'] || KEYS['S']) my = 1; + if (KEYS['a'] || KEYS['A']) mx = -1; + if (KEYS['d'] || KEYS['D']) mx = 1; + + if (mx !== 0 || my !== 0) { + const len = Math.hypot(mx, my); + mx /= len; + my /= len; + + if (my < 0) p.moveDir = 'up'; + else if (my > 0) p.moveDir = 'down'; + if (mx < 0) p.moveDir = 'left'; + else if (mx > 0) p.moveDir = 'right'; + + const dx = mx * p.speed; + const dy = my * p.speed; + p.x += dx; + if (collidesWall(p.box, this.curRoom, OX, OY)) p.x -= dx; + p.y += dy; + if (collidesWall(p.box, this.curRoom, OX, OY)) p.y -= dy; + } + } + + private processAttack(room: Room, p: Player): void { + let ax = 0, ay = 0; + if (KEYS['ArrowUp']) { ax = 0; ay = -1; } + else if (KEYS['ArrowDown']) { ax = 0; ay = 1; } + else if (KEYS['ArrowLeft']) { ax = -1; ay = 0; } + else if (KEYS['ArrowRight']) { ax = 1; ay = 0; } + else if (KEYS[' '] || KEYS['Space']) { + [ax, ay] = DIR[p.moveDir]; + } + + if ((ax !== 0 || ay !== 0) && p.atkCD <= 0) { + const len = Math.hypot(ax, ay); + ax /= len; + ay /= len; + + const dn: Dir = ay < 0 ? 'up' : ay > 0 ? 'down' : ax < 0 ? 'left' : 'right'; + p.facing = dn; + p.atkCD = p.mode === MODE_RANGED ? 10 : 22; + + if (p.mode === MODE_RANGED) { + room.tears.push(new Tear(p.x, p.y, ax, ay)); + } else { + this.meleeSwing = new MeleeSwing(p.x, p.y, dn); + } + } + } + + private updateMelee(room: Room): void { + if (this.meleeSwing && !this.meleeSwing.alive) this.meleeSwing = null; + if (!this.meleeSwing) return; + + this.meleeSwing.life--; + for (const e of room.enemies) { + if (!e.alive || e.hitTimer > 0) continue; + if (overlap(e.box, this.meleeSwing.box)) { + e.hp -= this.meleeSwing.damage; + e.hitTimer = 10; + const [dx, dy] = DIR[this.meleeSwing.dir]; + e.knx = dx * this.meleeSwing.kb; + e.kny = dy * this.meleeSwing.kb; + } + } + } + + private updateTears(room: Room): void { + for (const t of room.tears) { + if (!t.alive) continue; + t.x += t.dx * t.speed; + t.y += t.dy * t.speed; + t.life--; + + const col = Math.floor((t.x - OX) / TILE); + const row = Math.floor((t.y - OY) / TILE); + if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) { + t.life = 0; + continue; + } + if (room.tiles[row][col] === T_WALL) { + t.life = 0; + continue; + } + + for (const e of room.enemies) { + if (!e.alive) continue; + if (Math.hypot(t.x - e.x, t.y - e.y) < e.w / 2 + t.r) { + e.hp -= t.damage; + e.hitTimer = 8; + t.life = 0; + break; + } + } + } + room.tears = room.tears.filter(t => t.alive); + } + + private updateEnemies(room: Room, p: Player): number { + let aliveCount = 0; + + for (const e of room.enemies) { + if (!e.alive) continue; + aliveCount++; + + if (e.hitTimer > 0) e.hitTimer--; + + if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) { + e.x += e.knx * 3; + e.y += e.kny * 3; + e.knx *= 0.85; + e.kny *= 0.85; + continue; + } + e.knx = 0; + e.kny = 0; + + const dx = p.x - e.x; + const dy = p.y - e.y; + const d = Math.hypot(dx, dy); + if (d > 0 && d < 500) { + const s = e.speed; + const mx = (dx / d) * s; + const my = (dy / d) * s; + e.x += mx; + if (collidesWall(e.box, room, OX, OY)) e.x -= mx; + e.y += my; + if (collidesWall(e.box, room, OX, OY)) e.y -= my; + } + + if (e.atkTimer > 0) e.atkTimer--; + if (Math.hypot(e.x - p.x, e.y - p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) { + p.hp -= e.damage; + p.invTimer = 60; + e.atkTimer = 30; + if (p.hp <= 0) { + this.gameOver = true; + return aliveCount; + } + } + } + + return aliveCount; + } + + // -------- RENDERING -------- + + private render(): void { + const ctx = this.ctx; + ctx.fillStyle = '#0a0a0f'; + ctx.fillRect(0, 0, CW, CH); + + drawRoom(ctx, this.curRoom); + drawEntities(ctx, this.curRoom, this.player, this.meleeSwing); + drawHUD(ctx, this.player, this.curRoom); + drawMinimap(ctx, this.roomMap, this.cc, this.cr); + + if (this.gameOver) this.drawOverlay('#c33', 'GAME OVER'); + else if (this.won) this.drawOverlay('#3c3', 'VICTORY'); + } + + private drawOverlay(color: string, text: string): void { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(0,0,0,0.8)'; + ctx.fillRect(0, 0, CW, CH); + ctx.fillStyle = color; + ctx.font = 'bold 56px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(text, CW / 2, CH / 2 - 20); + ctx.fillStyle = '#888'; + ctx.font = '18px monospace'; + ctx.fillText('[R] restart', CW / 2, CH / 2 + 40); + } +} diff --git a/src/game/collision.ts b/src/game/collision.ts new file mode 100644 index 0000000..153fdd3 --- /dev/null +++ b/src/game/collision.ts @@ -0,0 +1,33 @@ +import { ROWS, COLS, DOOR } from '../constants'; +import { T_WALL } from '../room/tiles'; +import type { Room } from '../room/Room'; +import type { Box } from '../types'; + +/** Check if a given tile is blocked for movement */ +export function isBlocked(room: Room, col: number, row: number): boolean { + // Allow passing through the room boundary at door openings + if (row < 0 && room.doors.up && DOOR.up.cols.includes(col)) return false; + if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col)) return false; + if (col < 0 && room.doors.left && DOOR.left.rows.includes(row)) return false; + if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row)) return false; + + if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return true; + return room.tiles[row][col] === T_WALL; +} + +/** Test whether an entity's bounding box overlaps any wall tile */ +export function collidesWall(box: Box, room: Room, ox: number, oy: number): boolean { + const l = Math.floor((box.x - ox) / TILE_SIZE); + const r = Math.floor((box.x + box.w - ox) / TILE_SIZE); + const t = Math.floor((box.y - oy) / TILE_SIZE); + const b = Math.floor((box.y + box.h - oy) / TILE_SIZE); + + for (let row = t; row <= b; row++) { + for (let col = l; col <= r; col++) { + if (isBlocked(room, col, row)) return true; + } + } + return false; +} + +const TILE_SIZE = 44; // matches TILE in constants — duplicated to avoid circular dep diff --git a/src/game/transitions.ts b/src/game/transitions.ts new file mode 100644 index 0000000..63704e2 --- /dev/null +++ b/src/game/transitions.ts @@ -0,0 +1,72 @@ +import { OX, OY, TILE, COLS, ROWS, DOOR } from '../constants'; +import type { Dir } from '../types'; +import type { Game } from './Game'; +import { KEYS } from '../input'; + +/** Entry direction → movement keys that trigger a transition */ +const ENTRY_KEYS: Record = { + up: ['w', 'W', 'ArrowUp'], + down: ['s', 'S', 'ArrowDown'], + left: ['a', 'A', 'ArrowLeft'], + right: ['d', 'D', 'ArrowRight'], +}; + +function keyPressed(dir: Dir): boolean { + for (const k of ENTRY_KEYS[dir]) { + if (KEYS[k]) return true; + } + return false; +} + +/** + * Called every frame from Game.tick(). + * If the player stands on a cleared room's door tile and presses + * the matching movement key, transition into the adjacent room. + */ +export function checkTransition(game: Game): void { + if (game.gameOver || game.won) return; + if (game.player.transCD > 0) return; + + const p = game.player; + const room = game.curRoom; + if (!room.cleared) return; + + const col = Math.floor((p.x - OX) / TILE); + const row = Math.floor((p.y - OY) / TILE); + + // Top door + if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && keyPressed('up')) { + if (game.roomMap.has(game.cc, game.cr - 1)) { + game.cr--; + game.enterRoom('down'); + return; + } + } + + // Bottom door + if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && keyPressed('down')) { + if (game.roomMap.has(game.cc, game.cr + 1)) { + game.cr++; + game.enterRoom('up'); + return; + } + } + + // Left door + if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && keyPressed('left')) { + if (game.roomMap.has(game.cc - 1, game.cr)) { + game.cc--; + game.enterRoom('right'); + return; + } + } + + // Right door + if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && keyPressed('right')) { + if (game.roomMap.has(game.cc + 1, game.cr)) { + game.cc++; + game.enterRoom('left'); + return; + } + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..ddce2b6 --- /dev/null +++ b/src/index.html @@ -0,0 +1,17 @@ + + + + + +Dungeon Crawl — Ranged / Melee + + + + + + + diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 0000000..8df1134 --- /dev/null +++ b/src/input.ts @@ -0,0 +1,18 @@ +// Global keyboard state (shared mutable map) +export const KEYS: Record = {}; + +export function setupInput(): void { + window.addEventListener('keydown', (e: KeyboardEvent) => { + KEYS[e.key] = true; + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) { + e.preventDefault(); + } + }); + window.addEventListener('keyup', (e: KeyboardEvent) => { + KEYS[e.key] = false; + }); + window.addEventListener('blur', () => { + // Reset all keys on window blur to avoid stuck keys + for (const k in KEYS) delete KEYS[k]; + }); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0531b23 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,32 @@ +import { setupInput, KEYS } from './input'; +import { Game } from './game/Game'; + +// Global key bindings (mode toggle, restart) handled here +setupInput(); + +window.addEventListener('keydown', (e: KeyboardEvent) => { + const game = (window as any).__game as Game | undefined; + if (!game) return; + + // Toggle combat mode + if ((e.key === 'Tab' || e.key === 'q' || e.key === 'Q') && !game.gameOver && !game.won) { + e.preventDefault(); + game.toggleMode(); + } + + // Restart + if (e.key === 'r' || e.key === 'R') { + if (game.gameOver || game.won) game.restart(); + } +}); + +// Bootstrap +window.addEventListener('load', () => { + const canvas = document.getElementById('game') as HTMLCanvasElement; + if (!canvas) { + document.body.innerHTML = '

Error: canvas element not found

'; + return; + } + const game = new Game(canvas); + (window as any).__game = game; +}); diff --git a/src/math.ts b/src/math.ts new file mode 100644 index 0000000..36cd66e --- /dev/null +++ b/src/math.ts @@ -0,0 +1,30 @@ +/** Fisher-Yates shuffle (mutates array in place) */ +export function shuffle(a: T[]): T[] { + for (let i = a.length - 1; i > 0; i--) { + const j = (Math.random() * i) | 0; + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +/** Random float in [a, b) */ +export function rand(a: number, b: number): number { + return Math.random() * (b - a) + a; +} + +/** Random integer in [a, b] inclusive */ +export function ri(a: number, b: number): number { + return Math.floor(rand(a, b + 1)); +} + +/** Euclidean distance */ +export function dist(x1: number, y1: number, x2: number, y2: number): number { + return Math.hypot(x2 - x1, y2 - y1); +} + +/** Axis-aligned bounding box overlap test */ +export function overlap(a: { x: number; y: number; w: number; h: number }, + b: { x: number; y: number; w: number; h: number }): boolean { + return a.x < b.x + b.w && a.x + a.w > b.x && + a.y < b.y + b.h && a.y + a.h > b.y; +} diff --git a/src/render/entityRenderer.ts b/src/render/entityRenderer.ts new file mode 100644 index 0000000..5803ce0 --- /dev/null +++ b/src/render/entityRenderer.ts @@ -0,0 +1,270 @@ +import { DIR, OX, OY, TILE } from '../constants'; +import type { Room } from '../room/Room'; +import type { Player } from '../entities/Player'; +import type { MeleeSwing } from '../entities/MeleeSwing'; + +/** Draw enemies, player, tears, and the melee swing arc */ +export function drawEntities( + ctx: CanvasRenderingContext2D, + room: Room, + player: Player, + meleeSwing: MeleeSwing | null, +): void { + // --- ENEMIES --- + for (const e of room.enemies) { + if (!e.alive) continue; + const flash = e.hitTimer > 0 && e.hitTimer % 4 < 2; + + ctx.save(); + + // Shadow + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.beginPath(); + ctx.ellipse(e.x + 2, e.y + e.h / 4, e.w / 3, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + if (e.type === 'boss') { + drawBoss(ctx, e, flash); + } else if (e.type === 'fast') { + drawFastEnemy(ctx, e, flash); + } else { + drawNormalEnemy(ctx, e, flash); + } + + ctx.restore(); + } + + // --- PLAYER --- + drawPlayer(ctx, player); + + // --- TEARS --- + for (const t of room.tears) { + if (!t.alive) continue; + ctx.save(); + ctx.fillStyle = '#6699cc'; + ctx.beginPath(); + ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#99bbee'; + ctx.beginPath(); + ctx.arc(t.x - 1.5, t.y - 1.5, t.r - 2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + // --- MELEE SWING --- + if (meleeSwing && meleeSwing.alive) { + drawMeleeSwing(ctx, meleeSwing); + } +} + +function drawBoss(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void { + ctx.fillStyle = flash ? '#ddd' : '#5a0a0a'; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#4a0808'; + ctx.beginPath(); + ctx.arc(e.x - 3, e.y - 3, e.w / 2 - 4, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = flash ? '#000' : '#ff3333'; + ctx.beginPath(); + ctx.arc(e.x - 8, e.y - 8, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(e.x + 8, e.y - 8, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.arc(e.x - 8, e.y - 8, 2.5, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(e.x + 8, e.y - 8, 2.5, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = flash ? '#bbb' : '#3a0505'; + ctx.beginPath(); + ctx.moveTo(e.x - 16, e.y - e.w / 2 + 4); + ctx.lineTo(e.x - 8, e.y - e.w / 2 - 16); + ctx.lineTo(e.x, e.y - e.w / 2 + 4); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(e.x - 4, e.y - e.w / 2 + 4); + ctx.lineTo(e.x + 4, e.y - e.w / 2 - 16); + ctx.lineTo(e.x + 12, e.y - e.w / 2 + 4); + ctx.fill(); + + if (e.hp < e.maxHp) { + ctx.fillStyle = '#222'; + ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44, 4); + ctx.fillStyle = '#c33'; + ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44 * (e.hp / e.maxHp), 4); + } +} + +function drawFastEnemy(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void { + ctx.fillStyle = flash ? '#ddd' : '#992222'; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#771111'; + ctx.beginPath(); + ctx.arc(e.x - 1, e.y - 1, e.w / 2 - 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); + ctx.arc(e.x - 5, e.y - 4, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(e.x + 5, e.y - 4, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.arc(e.x - 5, e.y - 5, 1.5, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(e.x + 5, e.y - 5, 1.5, 0, Math.PI * 2); + ctx.fill(); +} + +function drawNormalEnemy(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void { + ctx.fillStyle = flash ? '#ccc' : '#5a4a2e'; + ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h); + ctx.fillStyle = '#4a3a1e'; + ctx.fillRect(e.x - e.w / 2 + 3, e.y - e.h / 2 + 3, e.w - 6, e.h - 6); + ctx.fillStyle = '#332816'; + ctx.fillRect(e.x - e.w / 2 + 6, e.y - e.h / 2 + 6, e.w - 12, e.h - 12); + ctx.fillStyle = '#ffcc66'; + ctx.fillRect(e.x - 7, e.y - 5, 5, 5); + ctx.fillRect(e.x + 2, e.y - 5, 5, 5); + ctx.fillStyle = '#000'; + ctx.fillRect(e.x - 6, e.y - 4, 3, 3); + ctx.fillRect(e.x + 3, e.y - 4, 3, 3); +} + +function drawPlayer(ctx: CanvasRenderingContext2D, p: Player): void { + ctx.save(); + + const flash = p.invTimer > 0 && p.invTimer % 6 < 3; + const bodyColor = p.mode === 0 ? '#2a6a9a' : '#9a3a2a'; + + ctx.fillStyle = flash ? '#ddd' : bodyColor; + ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + ctx.fillStyle = flash ? '#ccc' : 'rgba(0,0,0,0.3)'; + ctx.fillRect(p.x - p.w / 2 + 3, p.y - p.h / 2 + 3, p.w - 6, p.h - 6); + + // Weapon + const [fx, fy] = DIR[p.facing]; + const wx = p.x + fx * (p.w / 2 + 4); + const wy = p.y + fy * (p.h / 2 + 4); + + if (p.mode === 0) { + drawPistol(ctx, p, wx, wy, fx, fy, flash); + } else { + drawKnife(ctx, wx, wy, fx, fy, flash); + } + + // Eyes + ctx.fillStyle = '#fff'; + const ex = p.x + fx * 5; + const ey = p.y + fy * 5; + ctx.fillRect(ex - 5, ey - 4, 4, 5); + ctx.fillRect(ex + 1, ey - 4, 4, 5); + ctx.fillStyle = '#111'; + ctx.fillRect(ex - 4 + fx, ey - 3 + fy, 2, 3); + ctx.fillRect(ex + 2 + fx, ey - 3 + fy, 2, 3); + + ctx.restore(); +} + +function drawPistol( + ctx: CanvasRenderingContext2D, + p: Player, + wx: number, wy: number, + fx: number, fy: number, + flash: boolean, +): void { + ctx.strokeStyle = flash ? '#999' : '#555'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + + // Barrel + ctx.beginPath(); + ctx.moveTo(wx, wy); + ctx.lineTo(wx + fx * 14 + fy * 2, wy + fy * 14 + fx * 2); + ctx.stroke(); + + // Body + ctx.fillStyle = flash ? '#aaa' : '#444'; + ctx.save(); + const angle = fy !== 0 ? (Math.PI / 2) * (fy < 0 ? -1 : 1) : fx < 0 ? Math.PI : 0; + ctx.translate(p.x + fx * 8, p.y + fy * 8); + ctx.rotate(angle); + ctx.fillRect(-7, -4, 14, 8); + ctx.restore(); + + // Muzzle flash + if (p.atkCD > 8 && p.mode === 0) { + ctx.fillStyle = 'rgba(255,200,50,0.6)'; + ctx.beginPath(); + ctx.arc(wx + fx * 16, wy + fy * 16, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,200,0.4)'; + ctx.beginPath(); + ctx.arc(wx + fx * 18, wy + fy * 18, 8, 0, Math.PI * 2); + ctx.fill(); + } +} + +function drawKnife( + ctx: CanvasRenderingContext2D, + wx: number, wy: number, + fx: number, fy: number, + flash: boolean, +): void { + ctx.strokeStyle = flash ? '#bbb' : '#ccc'; + ctx.lineWidth = 2; + + // Blade triangle + const kx = wx + fx * 6; + const ky = wy + fy * 6; + ctx.beginPath(); + ctx.moveTo(kx, ky); + ctx.lineTo(kx + fx * 16 - fy * 6, ky + fy * 16 + fx * 6); + ctx.lineTo(kx + fx * 16 + fy * 6, ky + fy * 16 - fx * 6); + ctx.closePath(); + ctx.fillStyle = flash ? '#ddd' : '#d4d4d4'; + ctx.fill(); + ctx.stroke(); + + // Handle + ctx.fillStyle = flash ? '#a99' : '#5a3a1a'; + ctx.fillRect(kx - fx * 3 - fy * 3, ky - fy * 3 - fx * 3, 8, 8); + + // Guard + ctx.fillStyle = flash ? '#bbb' : '#888'; + ctx.fillRect(kx - fx * 2 - fy * 5, ky - fy * 2 - fx * 5, 5, 12); +} + +function drawMeleeSwing(ctx: CanvasRenderingContext2D, s: MeleeSwing): void { + const alpha = s.life / 10; + ctx.save(); + ctx.globalAlpha = alpha * 0.35; + ctx.fillStyle = '#cc8844'; + ctx.fillRect(s.box.x, s.box.y, s.box.w, s.box.h); + ctx.globalAlpha = alpha; + ctx.strokeStyle = '#ddbb88'; + ctx.lineWidth = 2; + ctx.strokeRect(s.box.x, s.box.y, s.box.w, s.box.h); + ctx.globalAlpha = alpha * 0.8; + ctx.strokeStyle = '#ffcc88'; + ctx.lineWidth = 3; + + const [dx, dy] = DIR[s.dir]; + ctx.beginPath(); + ctx.moveTo(s.box.x + s.box.w / 2 - dx * 18, s.box.y + s.box.h / 2 - dy * 18); + ctx.lineTo(s.box.x + s.box.w / 2 + dx * 18, s.box.y + s.box.h / 2 + dy * 18); + ctx.stroke(); + ctx.restore(); +} diff --git a/src/render/hudRenderer.ts b/src/render/hudRenderer.ts new file mode 100644 index 0000000..0baf3cf --- /dev/null +++ b/src/render/hudRenderer.ts @@ -0,0 +1,100 @@ +import { CW, CH, OY, RH } from '../constants'; +import { MODE_RANGED, MODE_MELEE } from '../constants'; +import type { Player } from '../entities/Player'; +import type { Room } from '../room/Room'; + +/** Draw the HUD: HP bar, mode indicator, enemy count, room label */ +export function drawHUD(ctx: CanvasRenderingContext2D, player: Player, room: Room): void { + drawHPBar(ctx, player); + drawModeIndicator(ctx, player); + drawEnemyCount(ctx, room); + drawRoomLabel(ctx, room); +} + +function drawHPBar(ctx: CanvasRenderingContext2D, p: Player): void { + const bx = 20, by = 20, bw = 140, bh = 14; + ctx.fillStyle = '#111'; + ctx.fillRect(bx, by, bw, bh); + ctx.fillStyle = '#2a0a0a'; + ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4); + + const ratio = Math.max(0, p.hp / p.maxHp); + const color = ratio > 0.5 ? '#993333' : ratio > 0.25 ? '#994422' : '#663322'; + ctx.fillStyle = color; + ctx.fillRect(bx + 2, by + 2, (bw - 4) * ratio, bh - 4); + + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + ctx.strokeRect(bx, by, bw, bh); + ctx.fillStyle = '#bbb'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(`HP ${p.hp}/${p.maxHp}`, bx + bw / 2, by + bh - 3); +} + +function drawModeIndicator(ctx: CanvasRenderingContext2D, p: Player): void { + const my = CH - 46; + ctx.textAlign = 'center'; + + const label = p.mode === MODE_RANGED ? 'RANGED' : 'MELEE'; + const color = p.mode === MODE_RANGED ? '#4488cc' : '#cc6644'; + + ctx.fillStyle = '#0d0d0d'; + ctx.fillRect(CW / 2 - 95, my - 18, 190, 34); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34); + + ctx.fillStyle = color; + ctx.font = 'bold 17px monospace'; + ctx.fillText(`[ ${label} ]`, CW / 2, my + 8); + + ctx.fillStyle = '#555'; + ctx.font = '11px monospace'; + ctx.fillText('[Tab/Q] switch', CW / 2, my - 26); + + // Small weapon icon + if (p.mode === MODE_RANGED) { + ctx.strokeStyle = '#88bbdd'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(CW / 2 - 82, my - 4); + ctx.lineTo(CW / 2 - 72, my - 4); + ctx.stroke(); + ctx.fillStyle = '#88bbdd'; + ctx.fillRect(CW / 2 - 82, my - 8, 10, 8); + } else { + ctx.fillStyle = '#ddbb88'; + ctx.beginPath(); + ctx.moveTo(CW / 2 - 82, my - 10); + ctx.lineTo(CW / 2 - 74, my - 2); + ctx.lineTo(CW / 2 - 82, my + 4); + ctx.fill(); + } +} + +function drawEnemyCount(ctx: CanvasRenderingContext2D, room: Room): void { + const alive = room.enemies.filter(e => e.alive).length; + ctx.textAlign = 'left'; + if (alive > 0) { + ctx.fillStyle = '#aa4444'; + ctx.font = '13px monospace'; + ctx.fillText(`\u25B6 ${alive}`, 20, CH - 18); + } else if (!room.cleared && room.type !== 'spawn') { + ctx.fillStyle = '#886633'; + ctx.font = '13px monospace'; + ctx.fillText('Clear the room', 20, CH - 18); + } +} + +function drawRoomLabel(ctx: CanvasRenderingContext2D, room: Room): void { + if (!room.visited) return; + ctx.textAlign = 'right'; + const labels: Record = { spawn: 'START', normal: '', treasure: 'TREASURE', boss: 'BOSS' }; + const label = labels[room.type]; + if (label) { + ctx.fillStyle = '#555'; + ctx.font = '11px monospace'; + ctx.fillText(label, CW - 20, OY + RH + 30); + } +} diff --git a/src/render/minimapRenderer.ts b/src/render/minimapRenderer.ts new file mode 100644 index 0000000..3f1dba6 --- /dev/null +++ b/src/render/minimapRenderer.ts @@ -0,0 +1,59 @@ +import { CW } from '../constants'; +import type { RoomMap } from '../room/RoomMap'; + +const CELL = 14; +const GAP = 2; + +/** Draw the 7×7 minimap in the top-right corner */ +export function drawMinimap( + ctx: CanvasRenderingContext2D, + map: RoomMap, + cc: number, + cr: number, +): void { + const cs = CELL + GAP; + const mx = CW - 180; + const my = 12; + + ctx.fillStyle = 'rgba(0,0,0,0.75)'; + ctx.fillRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16); + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + ctx.strokeRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16); + + for (let r = -3; r <= 3; r++) { + for (let c = -3; c <= 3; c++) { + const room = map.get(cc + c, cr + r); + if (!room) continue; + + const x = mx + (c + 3) * cs; + const y = my + (r + 3) * cs; + + let color = '#141414'; + if (room.visited) { + color = room.type === 'spawn' ? '#2a5a2a' + : room.type === 'boss' ? '#5a1a1a' + : room.type === 'treasure' ? '#5a5a1a' + : '#555'; + } + ctx.fillStyle = color; + ctx.fillRect(x, y, CELL, CELL); + + if (room.visited) { + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; + if (room.doors.up) ctx.fillRect(x + cs / 2 - 2, y - 2, 4, 3); + if (room.doors.down) ctx.fillRect(x + cs / 2 - 2, y + CELL - 1, 4, 3); + if (room.doors.left) ctx.fillRect(x - 2, y + cs / 2 - 2, 3, 4); + if (room.doors.right) ctx.fillRect(x + CELL - 1, y + cs / 2 - 2, 3, 4); + } + + // Highlight current room + if (c === 0 && r === 0) { + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = 2; + ctx.strokeRect(x - 1.5, y - 1.5, CELL + 3, CELL + 3); + } + } + } +} diff --git a/src/render/roomRenderer.ts b/src/render/roomRenderer.ts new file mode 100644 index 0000000..73aa613 --- /dev/null +++ b/src/render/roomRenderer.ts @@ -0,0 +1,50 @@ +import { OX, OY, TILE, COLS, ROWS, RW, RH } from '../constants'; +import { T_WALL, T_DOOR } from '../room/tiles'; +import type { Room } from '../room/Room'; + +/** Draw the tile grid and wall overlays for a room */ +export function drawRoom(ctx: CanvasRenderingContext2D, room: Room): void { + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const x = OX + c * TILE; + const y = OY + r * TILE; + const t = room.tiles[r][c]; + + if (t === T_WALL) { + ctx.fillStyle = '#1a1a24'; + ctx.fillRect(x, y, TILE, TILE); + ctx.fillStyle = '#242436'; + ctx.fillRect(x + 2, y + 2, TILE - 4, TILE - 4); + ctx.fillStyle = '#1e1e2c'; + ctx.fillRect(x + 4, y + 4, TILE - 8, TILE - 8); + + ctx.strokeStyle = '#161620'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, y + TILE / 2); + ctx.lineTo(x + TILE, y + TILE / 2); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x + TILE / 2, y); + ctx.lineTo(x + TILE / 2, y + TILE / 2); + ctx.stroke(); + } else if (t === T_DOOR) { + ctx.fillStyle = '#0d0d14'; + ctx.fillRect(x, y, TILE, TILE); + ctx.fillStyle = '#2a1e0e'; + ctx.fillRect(x + 6, y + 6, TILE - 12, TILE - 12); + ctx.fillStyle = '#3a2e14'; + ctx.fillRect(x + 10, y + 10, TILE - 20, TILE - 20); + } else { + const dark = (r + c) % 2 === 0; + ctx.fillStyle = dark ? '#2e2e24' : '#353528'; + ctx.fillRect(x, y, TILE, TILE); + } + } + } + + // Border stroke + ctx.strokeStyle = 'rgba(0,0,0,0.3)'; + ctx.lineWidth = 2; + ctx.strokeRect(OX, OY, RW, RH); +} diff --git a/src/room/Room.ts b/src/room/Room.ts new file mode 100644 index 0000000..e03b2ce --- /dev/null +++ b/src/room/Room.ts @@ -0,0 +1,28 @@ +import type { RoomType, Doors } from '../types'; +import { buildTiles } from './tiles'; +import { Enemy } from '../entities/Enemy'; +import { Tear } from '../entities/Tear'; + +export class Room { + c: number; + r: number; + type: RoomType; + doors: Doors = { up: false, down: false, left: false, right: false }; + visited = false; + cleared = false; + enemies: Enemy[] = []; + tears: Tear[] = []; + tiles: number[][]; + + constructor(c: number, r: number, type: RoomType) { + this.c = c; + this.r = r; + this.type = type; + this.tiles = buildTiles(); + } + + /** Rebuild base tiles and optionally place doors if cleared */ + buildTiles(): void { + this.tiles = buildTiles(this.cleared || this.type === 'spawn' ? this.doors : undefined); + } +} diff --git a/src/room/RoomMap.ts b/src/room/RoomMap.ts new file mode 100644 index 0000000..393e6b3 --- /dev/null +++ b/src/room/RoomMap.ts @@ -0,0 +1,89 @@ +import { MAP_RADIUS, MIN_ROOMS, EXTRA_ROOMS } from '../constants'; +import { Room } from './Room'; +import type { RoomType } from '../types'; +import { shuffle, ri } from '../math'; +import { OPP } from '../doors'; + +export class RoomMap { + rooms: Map = new Map(); + + key(c: number, r: number): string { + return c + ',' + r; + } + + get(c: number, r: number): Room | undefined { + return this.rooms.get(this.key(c, r)); + } + + has(c: number, r: number): boolean { + return this.rooms.has(this.key(c, r)); + } + + private add(c: number, r: number, type: RoomType): Room { + const room = new Room(c, r, type); + this.rooms.set(this.key(c, r), room); + return room; + } + + hasBoss(): boolean { + for (const room of this.rooms.values()) { + if (room.type === 'boss') return true; + } + return false; + } + + /** Generate a connected 7×7 grid of rooms using a random walk */ + generate(): void { + this.add(0, 0, 'spawn'); + const frontier: [number, number][] = [[0, 0]]; + let count = 1; + const target = MIN_ROOMS + ri(0, EXTRA_ROOMS); + const dirs: [string, number, number][] = [ + ['up', 0, -1], + ['down', 0, 1], + ['left', -1, 0], + ['right', 1, 0], + ]; + + while (frontier.length > 0 && count < target) { + const idx = ri(0, frontier.length - 1); + const [cr, cc] = frontier[idx]; + shuffle(dirs); + let added = false; + + for (const [_d, dc, dr] of dirs) { + if (count >= target) break; + const nc = cr + dc; + const nr = cc + dr; + + if (Math.abs(nc) > MAP_RADIUS || Math.abs(nr) > MAP_RADIUS) continue; + if (this.has(nc, nr)) continue; + + let type: RoomType = 'normal'; + if (!this.hasBoss() && (count === target - 1 || (Math.random() < 0.2 && count >= 3))) { + type = 'boss'; + } else if (Math.random() < 0.12 && count >= 2) { + type = 'treasure'; + } + + this.add(nc, nr, type); + const dir = _d as keyof typeof OPP; + this.get(cr, cc)!.doors[dir] = true; + this.get(nc, nr)!.doors[OPP[dir] as keyof typeof OPP] = true; + frontier.push([nc, nr]); + count++; + added = true; + } + + if (!added) frontier.splice(idx, 1); + } + + // Ensure at least one boss room exists + if (!this.hasBoss()) { + const normals = [...this.rooms.values()].filter(r => r.type === 'normal'); + if (normals.length > 0) { + normals[ri(0, normals.length - 1)].type = 'boss'; + } + } + } +} diff --git a/src/room/tiles.ts b/src/room/tiles.ts new file mode 100644 index 0000000..30dd378 --- /dev/null +++ b/src/room/tiles.ts @@ -0,0 +1,25 @@ +import { T_WALL, T_FLOOR, T_DOOR, COLS, ROWS, DOOR } from '../constants'; +import type { Doors } from '../types'; + +export { T_WALL, T_FLOOR, T_DOOR }; + +/** Build a fresh tile grid (all edge tiles = wall, interior = floor) */ +export function buildTiles(doorState?: Doors): number[][] { + const tiles: number[][] = []; + for (let r = 0; r < ROWS; r++) { + tiles[r] = []; + for (let c = 0; c < COLS; c++) { + tiles[r][c] = (r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1) ? T_WALL : T_FLOOR; + } + } + if (doorState) placeDoors(tiles, doorState); + return tiles; +} + +/** Mark door tiles on an existing tile grid */ +export function placeDoors(tiles: number[][], doors: Doors): void { + if (doors.up) for (const c of DOOR.up.cols) tiles[DOOR.up.row][c] = T_DOOR; + if (doors.down) for (const c of DOOR.down.cols) tiles[DOOR.down.row][c] = T_DOOR; + if (doors.left) for (const r of DOOR.left.rows) tiles[r][DOOR.left.col] = T_DOOR; + if (doors.right) for (const r of DOOR.right.rows) tiles[r][DOOR.right.col] = T_DOOR; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..961eff7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,17 @@ +export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss'; +export type Dir = 'up' | 'down' | 'left' | 'right'; +export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE + +export interface Box { + x: number; + y: number; + w: number; + h: number; +} + +export interface Doors { + up: boolean; + down: boolean; + left: boolean; + right: boolean; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bcfac72 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}