- 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
271 lines
7.4 KiB
TypeScript
271 lines
7.4 KiB
TypeScript
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();
|
|
}
|