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
This commit is contained in:
Volodia
2026-06-18 12:55:17 +03:00
commit b947fbb7fb
30 changed files with 2376 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/*.map
*.log
.DS_Store
+95
View File
@@ -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 | 24 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.
+35
View File
@@ -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 = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>`;
Bun.write("./dist/index.html", html);
console.log("Build complete → dist/");
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>
+3
View File
File diff suppressed because one or more lines are too long
+131
View File
@@ -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.
+702
View File
@@ -0,0 +1,702 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script>
// ============================================================
// CONSTANTS
// ============================================================
const CW=880,CH=660,TILE=44,COLS=15,ROWS=11;
const RW=COLS*TILE,RH=ROWS*TILE;
const OX=(CW-RW)/2,OY=80;
const T_WALL=0,T_FLOOR=1,T_DOOR=2;
const MODE_RANGED=0,MODE_MELEE=1;
const DIR={
up:[0,-1], down:[0,1], left:[-1,0], right:[1,0]
};
const OPP={up:'bottom',down:'top',left:'right',right:'left'};
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 }
};
let KEYS={},GAME_OVER=false,WON=false;
// ============================================================
// UTILITY
// ============================================================
function shuffle(a){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}
function rand(a,b){return Math.random()*(b-a)+a}
function ri(a,b){return Math.floor(rand(a,b+1))}
function dist(x1,y1,x2,y2){return Math.hypot(x2-x1,y2-y1)}
function overlap(a,b){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}
// ============================================================
// ROOM
// ============================================================
class Room{
constructor(c,r,type){
this.c=c;this.r=r;this.type=type;
this.doors={up:false,down:false,left:false,right:false};
this.visited=false;this.cleared=false;
this.enemies=[];this.tears=[];
this.tiles=[];this.buildTiles();
}
buildTiles(){
for(let r=0;r<ROWS;r++){
this.tiles[r]=[];
for(let c=0;c<COLS;c++){
this.tiles[r][c]=(r===0||r===ROWS-1||c===0||c===COLS-1)?T_WALL:T_FLOOR;
}
}
if(this.cleared)this.placeDoors();
}
placeDoors(){
if(this.doors.up)for(const c of DOOR.up.cols)this.tiles[DOOR.up.row][c]=T_DOOR;
if(this.doors.down)for(const c of DOOR.down.cols)this.tiles[DOOR.down.row][c]=T_DOOR;
if(this.doors.left)for(const r of DOOR.left.rows)this.tiles[r][DOOR.left.col]=T_DOOR;
if(this.doors.right)for(const r of DOOR.right.rows)this.tiles[r][DOOR.right.col]=T_DOOR;
}
}
// ============================================================
// ROOM MAP
// ============================================================
class RoomMap{
constructor(){this.rooms={};this.generate()}
key(c,r){return c+','+r}
get(c,r){return this.rooms[this.key(c,r)]}
has(c,r){return !!this.get(c,r)}
add(c,r,t){const o=new Room(c,r,t);this.rooms[this.key(c,r)]=o;return o}
hasBoss(){return Object.values(this.rooms).some(r=>r.type==='boss')}
generate(){
this.add(0,0,'spawn');
let frontier=[[0,0]],count=1,target=8+ri(0,4);
const dirs=[['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,nr=cc+dr;
if(Math.abs(nc)>3||Math.abs(nr)>3||this.has(nc,nr))continue;
let type='normal';
if((count===target-1||(Math.random()<0.2&&count>=3))&&!this.hasBoss())type='boss';
else if(Math.random()<0.12&&count>=2)type='treasure';
this.add(nc,nr,type);
this.get(cr,cc).doors[d]=true;
this.get(nc,nr).doors[OPP[d]]=true;
frontier.push([nc,nr]);count++;added=true;
}
if(!added)frontier.splice(idx,1);
}
if(!this.hasBoss()){
const cs=Object.values(this.rooms).filter(r=>r.type==='normal');
if(cs.length>0)cs[ri(0,cs.length-1)].type='boss';
}
}
}
// ============================================================
// ENTITIES
// ============================================================
class Player{
constructor(){
this.x=0;this.y=0;this.w=26;this.h=26;
this.speed=3.2;this.hp=6;this.maxHp=6;
this.mode=MODE_RANGED;this.facing='up';this.moveDir='up';
this.atkCD=0;this.invTimer=0;this.transCD=0;
}
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
}
class Enemy{
constructor(x,y,type){
this.x=x;this.y=y;this.type=type;
this.w=type==='boss'?46:type==='fast'?26:32;
this.h=this.w;
this.hp=type==='boss'?10:type==='fast'?2:3;
this.maxHp=this.hp;
this.speed=type==='boss'?0.9:type==='fast'?1.9:1.15;
this.damage=type==='boss'?2:1;
this.knx=0;this.kny=0;this.hitTimer=0;this.atkTimer=0;
}
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 Tear{
constructor(x,y,dx,dy){
this.x=x;this.y=y;this.dx=dx;this.dy=dy;
this.r=5;this.speed=7;this.damage=1;this.life=80;
}
get alive(){return this.life>0}
}
class MeleeSwing{
constructor(x,y,dir){
this.dir=dir;this.life=10;this.damage=2;this.kb=10;
const d=22,s=50;
switch(dir){
case'up': this.box={x:x-s/2,y:y-d-s,w:s,h:s};break;
case'down': this.box={x:x-s/2,y:y+d,w:s,h:s};break;
case'left': this.box={x:x-d-s,y:y-s/2,w:s,h:s};break;
case'right':this.box={x:x+d,y:y-s/2,w:s,h:s};break;
}
}
get alive(){return this.life>0}
}
// ============================================================
// GAME
// ============================================================
let game=null;
class Game{
constructor(){
this.canvas=document.getElementById('game');
this.ctx=this.canvas.getContext('2d');
this.map=new RoomMap();
this.player=new Player();
this.cc=0;this.cr=0;this.meleeSwing=null;
this.setupInput();
this.enterRoom('up');
this.update();
}
get cur(){return this.map.get(this.cc,this.cr)}
setupInput(){
window.addEventListener('keydown',e=>{
if((e.key==='Tab'||e.key==='q'||e.key==='Q')&&!GAME_OVER&&!WON){
e.preventDefault();this.player.mode=this.player.mode===MODE_RANGED?MODE_MELEE:MODE_RANGED;
}
if(e.key==='r'||e.key==='R'){if(GAME_OVER||WON)this.restart()}
KEYS[e.key]=true;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
});
window.addEventListener('keyup',e=>{KEYS[e.key]=false});
window.addEventListener('blur',()=>{KEYS={}});
}
restart(){
GAME_OVER=false;WON=false;
this.map=new RoomMap();this.player=new Player();
this.cc=0;this.cr=0;this.meleeSwing=null;
this.enterRoom('up');
}
enterRoom(fromDir){
const room=this.cur;
room.visited=true;
this.entryDir=fromDir;
const d=DOOR[fromDir],[ddc,ddr]=DIR[fromDir];
// place player 1 tile inside from the door center
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; // prevent re-transition for 15 frames
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();
}
}
spawnEnemies(room,entryDir){
const count=room.type==='boss'?1:room.type==='treasure'?0:2+ri(0,2);
for(let i=0;i<count;i++){
let tries=0,x,y,ok;
const type=room.type==='boss'?'boss':Math.random()<0.3?'fast':'normal';
do{
x=OX+2*TILE+rand(0,COLS-4)*TILE;
y=OY+2*TILE+rand(0,ROWS-4)*TILE;
ok=true;
const ed=DOOR[entryDir],dx=OX+ed.cx*TILE+TILE/2,dy=OY+ed.cy*TILE+TILE/2;
if(dist(x,y,dx,dy)<180)ok=false;
for(const e of room.enemies)if(dist(x,y,e.x,e.y)<60)ok=false;
if(dist(x,y,this.player.x,this.player.y)<150)ok=false;
tries++;
}while(!ok&&tries<100);
room.enemies.push(new Enemy(x,y,type));
}
}
isBlocked(room,col,row){
// 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;
}
collidesWall(ent,room){
const l=Math.floor((ent.x-ent.w/2-OX)/TILE);
const r=Math.floor((ent.x+ent.w/2-OX)/TILE);
const t=Math.floor((ent.y-ent.h/2-OY)/TILE);
const b=Math.floor((ent.y+ent.h/2-OY)/TILE);
for(let row=t;row<=b;row++)
for(let col=l;col<=r;col++)
if(this.isBlocked(room,col,row))return true;
return false;
}
// --- TRANSITION: player stands on a door tile and moves into it ---
checkTransition(){
if(GAME_OVER||WON)return;
if(this.player.transCD>0)return;
const p=this.player,room=this.cur;
if(!room.cleared)return;
const col=Math.floor((p.x-OX)/TILE),row=Math.floor((p.y-OY)/TILE);
// top door
if(row===0&&room.doors.up&&DOOR.up.cols.includes(col)&&(KEYS['w']||KEYS['W']||KEYS['ArrowUp'])){
if(this.map.has(this.cc,this.cr-1)){this.cr--;this.enterRoom('down');return}
}
// bottom door
if(row===ROWS-1&&room.doors.down&&DOOR.down.cols.includes(col)&&(KEYS['s']||KEYS['S']||KEYS['ArrowDown'])){
if(this.map.has(this.cc,this.cr+1)){this.cr++;this.enterRoom('up');return}
}
// left door
if(col===0&&room.doors.left&&DOOR.left.rows.includes(row)&&(KEYS['a']||KEYS['A']||KEYS['ArrowLeft'])){
if(this.map.has(this.cc-1,this.cr)){this.cc--;this.enterRoom('right');return}
}
// right door
if(col===COLS-1&&room.doors.right&&DOOR.right.rows.includes(row)&&(KEYS['d']||KEYS['D']||KEYS['ArrowRight'])){
if(this.map.has(this.cc+1,this.cr)){this.cc++;this.enterRoom('left');return}
}
}
update(){
if(!GAME_OVER&&!WON)this.tick();
this.render();
requestAnimationFrame(()=>this.update());
}
tick(){
const room=this.cur,p=this.player;
if(p.invTimer>0)p.invTimer--;
if(p.atkCD>0)p.atkCD--;
if(p.transCD>0)p.transCD--;
// --- MOVEMENT ---
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,dy=my*p.speed;
p.x+=dx;if(this.collidesWall(p,room))p.x-=dx;
p.y+=dy;if(this.collidesWall(p,room))p.y-=dy;
}
// --- ATTACK ---
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']){const[fx,fy]=DIR[p.moveDir];ax=fx;ay=fy}
if((ax!==0||ay!==0)&&p.atkCD<=0){
const len=Math.hypot(ax,ay);ax/=len;ay/=len;
const dn=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);
}
// --- MELEE ---
if(this.meleeSwing&&!this.meleeSwing.alive)this.meleeSwing=null;
if(this.meleeSwing){
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;
}
}
}
// --- TEARS ---
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),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(dist(t.x,t.y,e.x,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);
// --- ENEMY AI ---
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,dy=p.y-e.y,d=Math.hypot(dx,dy);
if(d>0&&d<500){
const s=e.speed,mx=dx/d*s,my=dy/d*s;
e.x+=mx;if(this.collidesWall(e,room))e.x-=mx;
e.y+=my;if(this.collidesWall(e,room))e.y-=my;
}
if(e.atkTimer>0)e.atkTimer--;
if(dist(e.x,e.y,p.x,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){GAME_OVER=true;return}
}
}
// --- ROOM CLEARED ---
if(room.enemies.length>0&&aliveCount===0&&!room.cleared){
room.cleared=true;room.buildTiles();
}
// --- TRANSITION ---
this.checkTransition();
// --- WIN ---
if(!GAME_OVER){
const br=Object.values(this.map.rooms).find(r=>r.type==='boss');
if(br&&br.cleared)WON=true;
}
}
// ============================================================
// RENDER
// ============================================================
render(){
const ctx=this.ctx;
ctx.fillStyle='#0a0a0f';
ctx.fillRect(0,0,CW,CH);
this.drawRoom();
this.drawEntities();
this.drawHUD();
this.drawMinimap();
if(GAME_OVER)this.drawOverlay('#c33','GAME OVER');
else if(WON)this.drawOverlay('#3c3','VICTORY');
}
drawOverlay(c,t){
const ctx=this.ctx;
ctx.fillStyle='rgba(0,0,0,0.8)';ctx.fillRect(0,0,CW,CH);
ctx.fillStyle=c;ctx.font='bold 56px monospace';ctx.textAlign='center';ctx.fillText(t,CW/2,CH/2-20);
ctx.fillStyle='#888';ctx.font='18px monospace';ctx.fillText('[R] restart',CW/2,CH/2+40);
}
drawRoom(){
const ctx=this.ctx,room=this.cur;
// floor
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const x=OX+c*TILE,y=OY+r*TILE,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);
// brick lines
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 d=(r+c)%2===0;
ctx.fillStyle=d?'#2e2e24':'#353528';
ctx.fillRect(x,y,TILE,TILE);
}
}
// wall overlay gradient at edges
const grad=ctx.createLinearGradient(OX,OY,OX+RW,OY);
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=2;
ctx.strokeRect(OX,OY,RW,RH);
}
drawEntities(){
const ctx=this.ctx,room=this.cur,p=this.player;
// --- ENEMIES ---
for(const e of room.enemies){
if(!e.alive)continue;
const fl=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'){
ctx.fillStyle=fl?'#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();
// eyes
ctx.fillStyle=fl?'#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();
// horns
ctx.fillStyle=fl?'#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();
// HP
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);
}
}else if(e.type==='fast'){
ctx.fillStyle=fl?'#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();
}else{
ctx.fillStyle=fl?'#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);
}
ctx.restore();
}
// --- PLAYER ---
ctx.save();
const ifl=p.invTimer>0&&p.invTimer%6<3;
const pCol=p.mode===MODE_RANGED?'#2a6a9a':'#9a3a2a';
ctx.fillStyle=ifl?'#ddd':pCol;
// body shape
ctx.fillRect(p.x-p.w/2,p.y-p.h/2,p.w,p.h);
ctx.fillStyle=ifl?'#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),wy=p.y+fy*(p.h/2+4);
if(p.mode===MODE_RANGED){
// PISTOL
ctx.strokeStyle=ifl?'#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=ifl?'#aaa':'#444';
const pw=14,ph=8;
ctx.save();
const rot=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.rect(-pw/2,-ph/2,pw,ph);
ctx.fill();
ctx.restore();
// muzzle flash on attack
if(p.atkCD>8&&p.mode===MODE_RANGED){
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();
}
}else{
// KNIFE
ctx.strokeStyle=ifl?'#bbb':'#ccc';
ctx.lineWidth=2;
// blade
ctx.beginPath();
const kx=wx+fx*6,ky=wy+fy*6;
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=ifl?'#ddd':'#d4d4d4';
ctx.fill();
ctx.strokeStyle='#999';ctx.stroke();
// handle
ctx.fillStyle=ifl?'#a99':'#5a3a1a';
ctx.fillRect(kx-fx*3-fy*3,ky-fy*3-fx*3,8,8);
// guard
ctx.fillStyle=ifl?'#bbb':'#888';
ctx.fillRect(kx-fx*2-fy*5,ky-fy*2-fx*5,5,12);
}
// eyes
ctx.fillStyle='#fff';
const ex=p.x+fx*5,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();
// --- 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(this.meleeSwing&&this.meleeSwing.alive){
const s=this.meleeSwing,a=s.life/10;
ctx.save();
ctx.globalAlpha=a*0.35;
ctx.fillStyle='#cc8844';ctx.fillRect(s.box.x,s.box.y,s.box.w,s.box.h);
ctx.globalAlpha=a;
ctx.strokeStyle='#ddbb88';ctx.lineWidth=2;ctx.strokeRect(s.box.x,s.box.y,s.box.w,s.box.h);
ctx.globalAlpha=a*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();
}
}
drawHUD(){
const ctx=this.ctx,p=this.player;
// --- HP BAR ---
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 hpRatio=Math.max(0,p.hp/p.maxHp);
const hpCol=hpRatio>0.5?'#993333':hpRatio>0.25?'#994422':'#663322';
ctx.fillStyle=hpCol;ctx.fillRect(bx+2,by+2,(bw-4)*hpRatio,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);
// --- MODE INDICATOR ---
const my=CH-46;
ctx.textAlign='center';
const mText=p.mode===MODE_RANGED?'RANGED':'MELEE';
const mCol=p.mode===MODE_RANGED?'#4488cc':'#cc6644';
ctx.fillStyle='#0d0d0d';ctx.fillRect(CW/2-95,my-18,190,34);
ctx.strokeStyle=mCol;ctx.lineWidth=2;ctx.strokeRect(CW/2-95,my-18,190,34);
ctx.fillStyle=mCol;ctx.font='bold 17px monospace';ctx.fillText(`[ ${mText} ]`,CW/2,my+8);
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText('[Tab] switch',CW/2,my-26);
// weapon icon in mode indicator
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();
}
// --- ENEMY COUNT ---
ctx.textAlign='left';
const room=this.cur;
const alive=room.enemies.filter(e=>e.alive).length;
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);
}
// --- ROOM TYPE ---
if(room.visited){
ctx.textAlign='right';
const tn={spawn:'START',normal:'',treasure:'TREASURE',boss:'BOSS'}[room.type];
if(tn){
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText(tn,CW-20,OY+RH+30);
}
}
}
drawMinimap(){
const ctx=this.ctx;
const mx=CW-180,my=12,cell=14,gap=2,cs=cell+gap;
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=this.map.get(this.cc+c,this.cr+r);
if(!room)continue;
const x=mx+(c+3)*cs,y=my+(r+3)*cs;
let color='#141414';
if(room.visited){
const cl={spawn:'#2a5a2a',boss:'#5a1a1a',treasure:'#5a5a1a'}[room.type]||'#555';
color=cl;
}
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);
}
if(c===0&&r===0){
ctx.strokeStyle='#ddd';ctx.lineWidth=2;ctx.strokeRect(x-1.5,y-1.5,cell+3,cell+3);
}
}
}
}
// ============================================================
// START
// ============================================================
window.addEventListener('load',()=>{game=new Game()});
</script>
</body>
</html>
+12
View File
@@ -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'"
}
}
+48
View File
@@ -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<string, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
// Opposite direction lookup
export const OPP: Record<string, string> = {
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;
+15
View File
@@ -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];
}
+54
View File
@@ -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<EnemyType, { w: number; hp: number; speed: number; damage: number }> = {
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';
}
+27
View File
@@ -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;
}
}
+22
View File
@@ -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 };
}
}
+21
View File
@@ -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;
}
}
+334
View File
@@ -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);
}
}
+33
View File
@@ -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
+72
View File
@@ -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<string, string[]> = {
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;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
// Global keyboard state (shared mutable map)
export const KEYS: Record<string, boolean> = {};
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];
});
}
+32
View File
@@ -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 = '<p style="color:red">Error: canvas element not found</p>';
return;
}
const game = new Game(canvas);
(window as any).__game = game;
});
+30
View File
@@ -0,0 +1,30 @@
/** Fisher-Yates shuffle (mutates array in place) */
export function shuffle<T>(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;
}
+270
View File
@@ -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();
}
+100
View File
@@ -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<string, string> = { 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);
}
}
+59
View File
@@ -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);
}
}
}
}
+50
View File
@@ -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);
}
+28
View File
@@ -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);
}
}
+89
View File
@@ -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<string, Room> = 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';
}
}
}
}
+25
View File
@@ -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;
}
+17
View File
@@ -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;
}
+17
View File
@@ -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"]
}