'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();