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
+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);
}