963 lines
25 KiB
JavaScript
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(); |