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:
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user