zelda-dungeon/renderer.js
2026-07-03 01:08:48 +00:00

963 lines
25 KiB
JavaScript

'use strict';
// === SETUP ===
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// === CONSTANTS ===
const TILE = 32;
const COLS = 20;
const ROWS = 15;
const T_FLOOR = 0;
const T_WALL = 1;
const T_TORCH = 5;
const MOVE_COOLDOWN = 100;
const ATTACK_COOLDOWN = 400;
const ATTACK_ANIM = 250;
const ENEMY_MOVE_INTERVAL = 800;
const ENEMY_ATTACK_INTERVAL = 1500;
const HIT_FLASH = 200;
const MESSAGE_DURATION = 2000;
const COLORS = {
floor: '#C4A265',
floorDark: '#A8884D',
wall: '#505050',
wallTop: '#6E6E6E',
wallFront: '#5A5A5A',
wallBottom: '#383838',
playerBody: '#228B22',
playerTunic: '#1A6B1A',
playerHead: '#FFD700',
playerEyes: '#000080',
playerHair: '#8B4513',
rock: '#888888',
rockDark: '#666666',
rockLight: '#AAAAAA',
target: '#FFD700',
targetGlow: 'rgba(255,215,0,0.3)',
chestClosed: '#8B4513',
chestGold: '#DAA520',
chestOpen: '#A0522D',
swordBlade: '#C0C0C0',
swordHandle: '#8B4513',
swordGem: '#FF0000',
goblin: '#8B0000',
goblinLight: '#CC2222',
goblinEye: '#00FF00',
goblinTeeth: '#FFFFFF',
torchStick: '#654321',
torchFlame: '#FF8C00',
torchGlow: 'rgba(255,140,0,0.15)',
door: '#654321',
doorFrame: '#8B7355',
doorKnob: '#FFD700',
heart: '#FF0000',
heartEmpty: '#444444',
msgText: '#FFFFFF',
overlay: 'rgba(0,0,0,0.75)',
hitFlash: 'rgba(255,0,0,0.3)',
};
const DIRS = [
{ x: 0, y: 1 },
{ x: 0, y: -1 },
{ x: -1, y: 0 },
{ x: 1, y: 0 },
];
const DIR_NAMES = ['Down', 'Up', 'Left', 'Right'];
// === HELPER FUNCTIONS ===
function createBorderLayout(walls, torches) {
const layout = [];
for (let y = 0; y < ROWS; y++) {
const row = [];
for (let x = 0; x < COLS; x++) {
if (y === 0 || y === ROWS - 1 || x === 0 || x === COLS - 1) {
row.push(T_WALL);
} else {
row.push(T_FLOOR);
}
}
layout.push(row);
}
for (const [wx, wy] of walls) {
if (wy >= 0 && wy < ROWS && wx >= 0 && wx < COLS) {
layout[wy][wx] = T_WALL;
}
}
for (const [tx, ty] of torches) {
if (ty >= 0 && ty < ROWS && tx >= 0 && tx < COLS) {
layout[ty][tx] = T_TORCH;
}
}
return layout;
}
function drawHeart(ctx, cx, cy, size, filled) {
ctx.save();
ctx.fillStyle = filled ? COLORS.heart : COLORS.heartEmpty;
ctx.beginPath();
const s = size * 0.5;
ctx.moveTo(cx, cy + s * 0.3);
ctx.bezierCurveTo(cx, cy - s * 0.5, cx - s * 1.2, cy - s * 0.5, cx - s, cy + s * 0.1);
ctx.bezierCurveTo(cx - s, cy + s * 0.7, cx, cy + s * 1.1, cx, cy + s * 1.1);
ctx.bezierCurveTo(cx, cy + s * 0.7, cx + s, cy + s * 0.1, cx + s, cy + s * 0.1);
ctx.bezierCurveTo(cx + s * 1.2, cy - s * 0.5, cx, cy - s * 0.5, cx, cy + s * 0.3);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// === ROOM DEFINITIONS ===
const ROOMS = [
// Room 1: The Stone Chamber (Rock Puzzle)
{
name: 'The Stone Chamber',
layout: createBorderLayout(
[[5,1],[6,1],[7,1],[8,1],[9,1],[5,2],[6,2],[7,2],[8,2],[9,2],[5,3],[6,3],[7,3],[8,3],[9,3],[5,4],[6,4],[7,4],[8,4],[9,4],[5,7],[6,7],[7,7],[8,7],[9,7],[5,8],[6,8],[7,8],[8,8],[9,8],[5,9],[6,9],[7,9],[8,9],[9,9],[5,10],[6,10],[7,10],[8,10],[9,10]],
[[2,1],[17,1],[2,13],[17,13]]
),
playerStart: { x: 1, y: 1 },
rocks: [{ x: 11, y: 4 }],
targets: [{ x: 11, y: 3 }],
chests: [],
enemies: [],
exit: { x: 10, y: 14, dir: 0 },
torches: [[2, 1], [17, 1], [2, 13], [17, 13]],
exitOpen: false,
},
// Room 2: The Armory (Sword Chest)
{
name: 'The Armory',
layout: createBorderLayout(
[[9,6],[10,6],[11,6],[9,8],[10,8],[11,8]],
[[2,1],[17,1],[2,13],[17,13]]
),
playerStart: { x: 1, y: 7 },
rocks: [],
targets: [],
chests: [{ x: 10, y: 7, opened: false }],
enemies: [],
exit: { x: 18, y: 7, dir: 3 },
torches: [[2, 1], [17, 1], [2, 13], [17, 13]],
exitOpen: true,
},
// Room 3: The Goblin Lair (Boss Fight)
{
name: 'The Goblin Lair',
layout: createBorderLayout(
[[5,1],[6,1],[7,1],[8,1],[9,1],[10,1],[11,1],[12,1],[13,1],[5,12],[6,12],[7,12],[8,12],[9,12],[10,12],[11,12],[12,12],[13,12]],
[[2,1],[17,1],[2,13],[17,13]]
),
playerStart: { x: 1, y: 7 },
rocks: [],
targets: [],
chests: [],
enemies: [{ x: 16, y: 7, hp: 3, maxHp: 3, moveTimer: 0, attackTimer: 0, hitTimer: 0 }],
exit: null,
torches: [[2, 1], [17, 1], [2, 13], [17, 13]],
exitOpen: false,
},
];
// === GAME STATE ===
let game = {
roomIdx: 0,
layout: null,
player: {
x: 0, y: 0,
hp: 12, maxHp: 12,
hasSword: false,
dir: 0,
moveTimer: 0,
attackTimer: 0,
attackAnim: 0,
hitTimer: 0,
},
rocks: [],
targets: [],
chests: [],
enemies: [],
exit: null,
exitOpen: false,
torches: [],
message: '',
messageTimer: 0,
won: false,
dead: false,
moveCooldown: 0,
};
// === INPUT ===
const keys = {};
document.addEventListener('keydown', (e) => {
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Enter'].includes(e.code)) {
e.preventDefault();
}
keys[e.code] = true;
if (e.code === 'Space') {
if (game.won || game.dead) return;
tryAttack();
}
if (e.code === 'Enter' && (game.won || game.dead)) {
restart();
}
});
document.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
// === GAME LOGIC ===
function loadRoom(idx) {
game.roomIdx = idx;
const room = ROOMS[idx];
game.layout = room.layout.map(row => [...row]);
game.player.x = room.playerStart.x;
game.player.y = room.playerStart.y;
game.rocks = room.rocks.map(r => ({ ...r }));
game.targets = room.targets.map(t => ({ ...t }));
game.chests = room.chests.map(c => ({ ...c }));
game.enemies = room.enemies.map(e => ({ ...e, moveTimer: ENEMY_MOVE_INTERVAL, attackTimer: ENEMY_ATTACK_INTERVAL, hitTimer: 0 }));
game.exit = room.exit ? { ...room.exit } : null;
game.exitOpen = room.exitOpen;
game.torches = room.torches ? room.torches.map(t => [...t]) : [];
game.message = room.name;
game.messageTimer = MESSAGE_DURATION;
}
function tryMove(dx, dy) {
if (game.won || game.dead) return;
const px = game.player.x;
const py = game.player.y;
const nx = px + dx;
const ny = py + dy;
if (dx === 0 && dy === 0) return;
if (dy === 1 && game.player.dir !== 0) game.player.dir = 0;
else if (dy === -1 && game.player.dir !== 1) game.player.dir = 1;
else if (dx === -1 && game.player.dir !== 2) game.player.dir = 2;
else if (dx === 1 && game.player.dir !== 3) game.player.dir = 3;
if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) return;
// Check exit before wall check so doors on border walls work
if (game.exit && game.exit.x === nx && game.exit.y === ny) {
tryExit();
return;
}
if (game.layout[ny][nx] === T_WALL) return;
const rock = game.rocks.find(r => r.x === nx && r.y === ny);
if (rock) {
const rnx = nx + dx;
const rny = ny + dy;
if (rnx < 0 || rnx >= COLS || rny < 0 || rny >= ROWS) return;
if (game.layout[rny][rnx] === T_WALL) return;
if (game.rocks.find(r => r.x === rnx && r.y === rny)) return;
if (game.enemies.find(e => e.x === rnx && e.y === rny)) return;
if (game.exit && game.exit.x === rnx && game.exit.y === rny) return;
if (game.chests.find(c => c.x === rnx && c.y === rny)) return;
rock.x = rnx;
rock.y = rny;
}
if (game.enemies.find(e => e.x === nx && e.y === ny)) return;
game.player.x = nx;
game.player.y = ny;
const chest = game.chests.find(c => c.x === nx && c.y === ny);
if (chest && !chest.opened) {
openChest(chest);
return;
}
checkPuzzle();
}
function openChest(chest) {
chest.opened = true;
game.player.hasSword = true;
game.player.hp = Math.min(game.player.hp + 6, game.player.maxHp);
game.message = 'You found the Master Sword! (+6 HP)';
game.messageTimer = MESSAGE_DURATION;
}
function tryAttack() {
if (game.player.attackTimer > 0) return;
if (!game.player.hasSword) {
game.message = 'You need a weapon!';
game.messageTimer = MESSAGE_DURATION;
return;
}
game.player.attackAnim = ATTACK_ANIM;
game.player.attackTimer = ATTACK_COOLDOWN;
const dir = DIRS[game.player.dir];
const ax = game.player.x + dir.x;
const ay = game.player.y + dir.y;
for (const enemy of game.enemies) {
if (enemy.x === ax && enemy.y === ay) {
enemy.hp -= 1;
enemy.hitTimer = HIT_FLASH;
if (enemy.hp <= 0) {
game.message = 'Enemy defeated!';
game.messageTimer = MESSAGE_DURATION;
}
}
}
game.enemies = game.enemies.filter(e => e.hp > 0);
if (game.enemies.length === 0 && !game.exit) {
game.won = true;
}
}
function updateEnemies(dt) {
for (const enemy of [...game.enemies]) {
enemy.moveTimer -= dt;
enemy.attackTimer -= dt;
if (enemy.hitTimer > 0) enemy.hitTimer -= dt;
if (enemy.moveTimer <= 0) {
enemy.moveTimer = ENEMY_MOVE_INTERVAL;
const dx = game.player.x - enemy.x;
const dy = game.player.y - enemy.y;
let bestDir = null;
let bestDist = Math.abs(dx) + Math.abs(dy);
for (let d = 0; d < 4; d++) {
const nx = enemy.x + DIRS[d].x;
const ny = enemy.y + DIRS[d].y;
if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) continue;
if (game.layout[ny][nx] === T_WALL) continue;
if (game.rocks.find(r => r.x === nx && r.y === ny)) continue;
if (game.enemies.find(e => e !== enemy && e.x === nx && e.y === ny)) continue;
if (game.chests.find(c => c.x === nx && c.y === ny)) continue;
const newDist = Math.abs(game.player.x - nx) + Math.abs(game.player.y - ny);
if (newDist < bestDist) {
bestDist = newDist;
bestDir = d;
}
}
if (bestDir !== null) {
enemy.x += DIRS[bestDir].x;
enemy.y += DIRS[bestDir].y;
}
}
if (Math.abs(enemy.x - game.player.x) + Math.abs(enemy.y - game.player.y) === 1) {
if (enemy.attackTimer <= 0) {
enemy.attackTimer = ENEMY_ATTACK_INTERVAL;
game.player.hp -= 2;
game.player.hitTimer = HIT_FLASH;
game.message = 'You were hit!';
game.messageTimer = MESSAGE_DURATION;
if (game.player.hp <= 0) {
game.player.hp = 0;
game.dead = true;
}
}
}
}
}
function tryExit() {
if (!game.exitOpen) {
game.message = 'The door is locked!';
game.messageTimer = MESSAGE_DURATION;
return;
}
const nextRoom = game.roomIdx + 1;
if (nextRoom >= ROOMS.length) {
game.won = true;
return;
}
loadRoom(nextRoom);
}
function checkPuzzle() {
if (game.targets.length === 0) return;
const allSolved = game.targets.every(t => game.rocks.some(r => r.x === t.x && r.y === t.y));
if (allSolved) {
game.exitOpen = true;
game.message = 'The door has opened!';
game.messageTimer = MESSAGE_DURATION;
}
}
function showMessage(text) {
game.message = text;
game.messageTimer = MESSAGE_DURATION;
}
function restart() {
game.won = false;
game.dead = false;
game.player.hp = game.player.maxHp;
game.player.hasSword = false;
game.player.attackTimer = 0;
game.player.attackAnim = 0;
game.player.hitTimer = 0;
game.message = '';
game.messageTimer = 0;
loadRoom(0);
}
// === RENDERING ===
function drawFloor(layout) {
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (layout[y][x] === T_FLOOR) {
const shade = ((x + y) % 2 === 0) ? COLORS.floor : COLORS.floorDark;
ctx.fillStyle = shade;
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
}
}
}
}
function drawWalls(layout) {
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (layout[y][x] === T_WALL) {
const px = x * TILE;
const py = y * TILE;
ctx.fillStyle = COLORS.wall;
ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = COLORS.wallTop;
ctx.fillRect(px, py, TILE, 3);
ctx.fillStyle = COLORS.wallFront;
ctx.fillRect(px, py + 3, TILE, TILE - 6);
ctx.fillStyle = COLORS.wallBottom;
ctx.fillRect(px, py + TILE - 3, TILE, 3);
ctx.strokeStyle = '#404040';
ctx.lineWidth = 0.5;
ctx.strokeRect(px + 0.5, py + 0.5, TILE - 1, TILE - 1);
}
}
}
}
function drawTorches(layout) {
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (layout[y][x] === T_TORCH) {
const cx = x * TILE + TILE / 2;
const cy = y * TILE + TILE / 2;
ctx.fillStyle = COLORS.torchGlow;
ctx.beginPath();
ctx.arc(cx, cy, TILE * 1.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = COLORS.floor;
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
ctx.fillStyle = COLORS.torchStick;
ctx.fillRect(cx - 2, cy + 2, 4, TILE * 0.4);
ctx.fillStyle = COLORS.torchFlame;
ctx.beginPath();
ctx.ellipse(cx, cy - 4, 4, 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#FFFF00';
ctx.beginPath();
ctx.ellipse(cx, cy - 3, 2, 3, 0, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
function drawTargets() {
for (const target of game.targets) {
const cx = target.x * TILE + TILE / 2;
const cy = target.y * TILE + TILE / 2;
const occupied = game.rocks.some(r => r.x === target.x && r.y === target.y);
ctx.fillStyle = COLORS.targetGlow;
ctx.beginPath();
ctx.arc(cx, cy, TILE * 0.7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = occupied ? '#00FF00' : COLORS.target;
ctx.beginPath();
ctx.arc(cx, cy, TILE * 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = occupied ? '#00AA00' : '#CC9900';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, TILE * 0.3, 0, Math.PI * 2);
ctx.stroke();
}
}
function drawRocks() {
for (const rock of game.rocks) {
const px = rock.x * TILE;
const py = rock.y * TILE;
const inset = 3;
ctx.fillStyle = COLORS.rock;
ctx.beginPath();
ctx.roundRect(px + inset, py + inset, TILE - inset * 2, TILE - inset * 2, 4);
ctx.fill();
ctx.fillStyle = COLORS.rockLight;
ctx.beginPath();
ctx.roundRect(px + inset + 1, py + inset + 1, (TILE - inset * 2) * 0.5, (TILE - inset * 2) * 0.4, 2);
ctx.fill();
ctx.fillStyle = COLORS.rockDark;
ctx.beginPath();
ctx.roundRect(px + inset + (TILE - inset * 2) * 0.4, py + inset + (TILE - inset * 2) * 0.5, (TILE - inset * 2) * 0.4, (TILE - inset * 2) * 0.4, 2);
ctx.fill();
ctx.strokeStyle = '#555555';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(px + inset, py + inset, TILE - inset * 2, TILE - inset * 2, 4);
ctx.stroke();
}
}
function drawChests() {
for (const chest of game.chests) {
const px = chest.x * TILE;
const py = chest.y * TILE;
if (chest.opened) {
ctx.fillStyle = COLORS.chestOpen;
ctx.fillRect(px + 2, py + 10, TILE - 4, TILE - 12);
ctx.fillStyle = COLORS.chestGold;
ctx.fillRect(px + 4, py + 12, TILE - 8, TILE - 14);
ctx.fillStyle = COLORS.chestClosed;
ctx.fillRect(px + 2, py + 2, TILE - 4, 6);
ctx.fillStyle = COLORS.chestGold;
ctx.fillRect(px + 4, py + 3, TILE - 8, 4);
} else {
ctx.fillStyle = COLORS.chestClosed;
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
ctx.fillStyle = COLORS.chestGold;
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
ctx.fillStyle = '#FFD700';
ctx.beginPath();
ctx.arc(px + TILE / 2, py + TILE / 2, 3, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#B8860B';
ctx.lineWidth = 1;
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
}
}
}
function drawExit() {
if (!game.exit) return;
const px = game.exit.x * TILE;
const py = game.exit.y * TILE;
ctx.fillStyle = COLORS.doorFrame;
ctx.fillRect(px - 1, py - 1, TILE + 2, TILE + 2);
if (game.exitOpen) {
ctx.fillStyle = '#1A1A1A';
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
} else {
ctx.fillStyle = COLORS.door;
ctx.fillRect(px + 2, py + 2, TILE - 4, TILE - 4);
ctx.strokeStyle = '#8B7355';
ctx.lineWidth = 1;
ctx.strokeRect(px + 2, py + 2, TILE - 4, TILE - 4);
ctx.fillStyle = COLORS.doorKnob;
ctx.beginPath();
const knobX = game.exit.dir === 3 ? px + TILE - 7 : px + 7;
ctx.arc(knobX, py + TILE / 2, 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
const arrowX = game.exit.dir === 3 ? px + TILE + 10 : px - 10;
ctx.fillText(game.exit.dir === 3 ? '→' : game.exit.dir === 2 ? '←' : game.exit.dir === 0 ? '↓' : '↑', arrowX, py + TILE / 2 + 4);
ctx.textAlign = 'left';
}
function drawEnemies() {
for (const enemy of game.enemies) {
const px = enemy.x * TILE;
const py = enemy.y * TILE;
const isHit = enemy.hitTimer > 0;
ctx.save();
if (isHit) {
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.02) * 0.5;
}
ctx.fillStyle = isHit ? '#FFFFFF' : COLORS.goblin;
ctx.beginPath();
ctx.roundRect(px + 3, py + 4, TILE - 6, TILE - 8, 3);
ctx.fill();
ctx.fillStyle = COLORS.goblinLight;
ctx.beginPath();
ctx.roundRect(px + 4, py + 5, (TILE - 6) * 0.6, (TILE - 8) * 0.4, 2);
ctx.fill();
// Eyes
ctx.fillStyle = COLORS.goblinEye;
ctx.beginPath();
ctx.arc(px + TILE / 2 - 4, py + 10, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(px + TILE / 2 + 4, py + 10, 2.5, 0, Math.PI * 2);
ctx.fill();
// Pupils
ctx.fillStyle = '#003300';
ctx.beginPath();
ctx.arc(px + TILE / 2 - 4, py + 10, 1.2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(px + TILE / 2 + 4, py + 10, 1.2, 0, Math.PI * 2);
ctx.fill();
// Teeth
ctx.fillStyle = COLORS.goblinTeeth;
ctx.fillRect(px + TILE / 2 - 3, py + 16, 2, 2);
ctx.fillRect(px + TILE / 2 + 1, py + 16, 2, 2);
// HP bar
const barW = TILE - 4;
const hpRatio = enemy.hp / enemy.maxHp;
ctx.fillStyle = '#333333';
ctx.fillRect(px + 2, py - 4, barW, 4);
ctx.fillStyle = hpRatio > 0.5 ? '#00FF00' : hpRatio > 0.25 ? '#FFAA00' : '#FF0000';
ctx.fillRect(px + 2, py - 4, barW * hpRatio, 4);
ctx.restore();
}
}
function drawPlayer() {
const p = game.player;
const px = p.x * TILE;
const py = p.y * TILE;
const isHit = p.hitTimer > 0;
ctx.save();
if (isHit) {
ctx.globalAlpha = 0.5 + Math.sin(Date.now() * 0.03) * 0.5;
}
const bodyColor = isHit ? '#FF6666' : COLORS.playerBody;
// Body
ctx.fillStyle = bodyColor;
ctx.fillRect(px + 6, py + 14, TILE - 12, TILE - 16);
// Tunic detail
ctx.fillStyle = COLORS.playerTunic;
ctx.fillRect(px + 8, py + 16, TILE - 16, TILE - 20);
// Head
ctx.fillStyle = COLORS.playerHead;
ctx.beginPath();
ctx.arc(px + TILE / 2, py + 10, 7, 0, Math.PI * 2);
ctx.fill();
// Hair
ctx.fillStyle = COLORS.playerHair;
ctx.beginPath();
ctx.arc(px + TILE / 2, py + 8, 7, Math.PI, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = COLORS.playerEyes;
const eyeOffX = DIRS[p.dir].x * 2;
const eyeOffY = DIRS[p.dir].y * 2;
ctx.fillRect(px + TILE / 2 - 3 + eyeOffX, py + 9 + eyeOffY, 2, 2);
ctx.fillRect(px + TILE / 2 + 1 + eyeOffX, py + 9 + eyeOffY, 2, 2);
// Sword indicator
if (p.hasSword) {
ctx.fillStyle = COLORS.swordBlade;
const sx = px + TILE / 2 + DIRS[p.dir].x * 10;
const sy = py + TILE / 2 + DIRS[p.dir].y * 10;
ctx.fillRect(sx - 2, sy - 2, 4, 4);
ctx.fillStyle = COLORS.swordHandle;
const hx = px + TILE / 2 + DIRS[p.dir].x * 5;
const hy = py + TILE / 2 + DIRS[p.dir].y * 5;
ctx.fillRect(hx - 1.5, hy - 1.5, 3, 3);
}
ctx.restore();
}
function drawAttackAnim() {
const p = game.player;
if (p.attackAnim <= 0 || !p.hasSword) return;
const px = p.x * TILE;
const py = p.y * TILE;
const dir = DIRS[p.dir];
ctx.save();
ctx.globalAlpha = p.attackAnim / ATTACK_ANIM;
const ax = px + TILE / 2 + dir.x * TILE * 0.6;
const ay = py + TILE / 2 + dir.y * TILE * 0.6;
// Sword blade
ctx.fillStyle = COLORS.swordBlade;
if (dir.x !== 0) {
ctx.fillRect(ax - 3, ay - TILE * 0.4, 6, TILE * 0.8);
} else {
ctx.fillRect(ax - TILE * 0.4, ay - 3, TILE * 0.8, 6);
}
// Slash effect
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.globalAlpha = p.attackAnim / ATTACK_ANIM * 0.7;
if (dir.x !== 0) {
ctx.beginPath();
ctx.moveTo(ax, ay - TILE * 0.5);
ctx.lineTo(ax + dir.x * 8, ay);
ctx.lineTo(ax, ay + TILE * 0.5);
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(ax - TILE * 0.5, ay);
ctx.lineTo(ax, ay + dir.y * 8);
ctx.lineTo(ax + TILE * 0.5, ay);
ctx.stroke();
}
ctx.restore();
}
function drawHUD() {
// Hearts
const totalHearts = Math.ceil(game.player.maxHp / 2);
const fullHearts = Math.floor(game.player.hp / 2);
const halfHeart = game.player.hp % 2 === 1;
for (let i = 0; i < totalHearts; i++) {
const hx = 12 + i * 22;
const hy = 12;
drawHeart(ctx, hx, hy, 14, i < fullHearts);
if (i === fullHearts && halfHeart) {
ctx.save();
ctx.beginPath();
ctx.clip();
drawHeart(ctx, hx, hy, 14, true);
ctx.restore();
}
}
// Sword icon
if (game.player.hasSword) {
ctx.fillStyle = COLORS.swordBlade;
ctx.fillRect(canvas.width - 30, 6, 4, 14);
ctx.fillStyle = COLORS.swordHandle;
ctx.fillRect(canvas.width - 32, 20, 8, 4);
ctx.fillStyle = COLORS.swordGem;
ctx.beginPath();
ctx.arc(canvas.width - 28, 6, 2, 0, Math.PI * 2);
ctx.fill();
}
// Room name
ctx.fillStyle = '#CCCCCC';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText(ROOMS[game.roomIdx].name, canvas.width / 2, canvas.height - 8);
ctx.textAlign = 'left';
}
function drawMessages() {
if (game.messageTimer <= 0) return;
ctx.save();
ctx.globalAlpha = Math.min(1, game.messageTimer / 300);
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const textW = ctx.measureText(game.message).width;
const boxW = textW + 20;
const boxX = (canvas.width - boxW) / 2;
const boxY = canvas.height / 2 - 30;
ctx.fillRect(boxX, boxY, boxW, 24);
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 1;
ctx.strokeRect(boxX, boxY, boxW, 24);
ctx.fillStyle = COLORS.msgText;
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText(game.message, canvas.width / 2, boxY + 17);
ctx.textAlign = 'left';
ctx.restore();
}
function drawDeathScreen() {
ctx.fillStyle = COLORS.overlay;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FF0000';
ctx.font = 'bold 36px monospace';
ctx.textAlign = 'center';
ctx.fillText('YOU DIED', canvas.width / 2, canvas.height / 2 - 20);
ctx.fillStyle = '#AAAAAA';
ctx.font = '16px monospace';
ctx.fillText('Press ENTER to restart', canvas.width / 2, canvas.height / 2 + 20);
ctx.textAlign = 'left';
}
function drawWinScreen() {
ctx.fillStyle = COLORS.overlay;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 36px monospace';
ctx.textAlign = 'center';
ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2 - 40);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px monospace';
ctx.fillText('The dungeon has been conquered!', canvas.width / 2, canvas.height / 2);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px monospace';
ctx.fillText('Press ENTER to play again', canvas.width / 2, canvas.height / 2 + 35);
ctx.textAlign = 'left';
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (game.dead) {
drawFloor(game.layout);
drawWalls(game.layout);
drawTorches(game.layout);
drawDeathScreen();
return;
}
if (game.won) {
drawFloor(game.layout);
drawWalls(game.layout);
drawTorches(game.layout);
drawWinScreen();
return;
}
drawFloor(game.layout);
drawTargets();
drawWalls(game.layout);
drawTorches(game.layout);
drawExit();
drawRocks();
drawChests();
drawEnemies();
drawPlayer();
drawAttackAnim();
// Hit flash overlay
if (game.player.hitTimer > 0) {
ctx.fillStyle = COLORS.hitFlash;
ctx.globalAlpha = game.player.hitTimer / HIT_FLASH;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 1;
}
drawMessages();
drawHUD();
}
// === GAME LOOP ===
let lastTime = 0;
function gameLoop(timestamp) {
const dt = timestamp - lastTime;
lastTime = timestamp;
if (!game.won && !game.dead) {
// Handle movement input
if (game.player.moveTimer <= 0) {
let dx = 0, dy = 0;
if (keys['ArrowUp'] || keys['KeyW']) dy = -1;
else if (keys['ArrowDown'] || keys['KeyS']) dy = 1;
if (keys['ArrowLeft'] || keys['KeyA']) dx = -1;
else if (keys['ArrowRight'] || keys['KeyD']) dx = 1;
if (dx !== 0 || dy !== 0) {
tryMove(dx, dy);
game.player.moveTimer = MOVE_COOLDOWN;
}
}
// Update timers
if (game.player.moveTimer > 0) game.player.moveTimer -= dt;
if (game.player.attackTimer > 0) game.player.attackTimer -= dt;
if (game.player.attackAnim > 0) game.player.attackAnim -= dt;
if (game.player.hitTimer > 0) game.player.hitTimer -= dt;
if (game.messageTimer > 0) game.messageTimer -= dt;
// Update enemies
updateEnemies(dt);
// Check death
if (game.player.hp <= 0) {
game.player.hp = 0;
game.dead = true;
}
}
render();
requestAnimationFrame(gameLoop);
}
// === INIT ===
function init() {
loadRoom(0);
requestAnimationFrame(gameLoop);
}
init();