410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
|
|
import { Map } from './Map.js';
|
||
|
|
import { UI } from './UI.js';
|
||
|
|
import { Player } from './Entity.js';
|
||
|
|
|
||
|
|
import { Monster } from './Entity.js';
|
||
|
|
import { Item } from './Item.js';
|
||
|
|
|
||
|
|
export class Game {
|
||
|
|
constructor() {
|
||
|
|
this.canvas = document.getElementById('game-canvas');
|
||
|
|
this.ctx = this.canvas.getContext('2d');
|
||
|
|
this.ui = new UI(this.ctx, this); // Pass game instance to UI for callbacks
|
||
|
|
this.map = new Map(50, 50);
|
||
|
|
this.player = new Player(10, 10);
|
||
|
|
this.monsters = [];
|
||
|
|
this.items = [];
|
||
|
|
this.gameOver = false;
|
||
|
|
this.monsters = [];
|
||
|
|
this.items = [];
|
||
|
|
this.gameOver = false;
|
||
|
|
this.depth = 0; // Start in Town
|
||
|
|
this.maxDepth = 3;
|
||
|
|
|
||
|
|
this.tileSize = 32;
|
||
|
|
|
||
|
|
// Bind input
|
||
|
|
window.addEventListener('keydown', (e) => this.handleInput(e));
|
||
|
|
}
|
||
|
|
|
||
|
|
start() {
|
||
|
|
console.log("Game Started");
|
||
|
|
this.generateLevel();
|
||
|
|
this.ui.log("Welcome to the Town of Oakhaven!");
|
||
|
|
}
|
||
|
|
|
||
|
|
generateLevel() {
|
||
|
|
this.monsters = [];
|
||
|
|
this.items = [];
|
||
|
|
|
||
|
|
if (this.depth === 0) {
|
||
|
|
this.map.generateTown();
|
||
|
|
// Spawn player in center
|
||
|
|
this.player.x = Math.floor(this.map.width / 2);
|
||
|
|
this.player.y = Math.floor(this.map.height / 2);
|
||
|
|
} else {
|
||
|
|
this.map.generate();
|
||
|
|
// Spawn Player in first room
|
||
|
|
if (this.map.rooms.length > 0) {
|
||
|
|
const startRoom = this.map.rooms[0];
|
||
|
|
this.player.x = Math.floor(startRoom.x + startRoom.w / 2);
|
||
|
|
this.player.y = Math.floor(startRoom.y + startRoom.h / 2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.spawnMonsters();
|
||
|
|
this.spawnItems();
|
||
|
|
this.spawnStairs();
|
||
|
|
|
||
|
|
this.render();
|
||
|
|
this.ui.updateStats(this.player, this.depth);
|
||
|
|
this.ui.updateInventory(this.player);
|
||
|
|
}
|
||
|
|
|
||
|
|
nextLevel() {
|
||
|
|
if (this.depth >= this.maxDepth) {
|
||
|
|
this.ui.log("You have reached the bottom! You Win!");
|
||
|
|
setTimeout(() => alert("You Win!"), 100);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.depth++;
|
||
|
|
this.ui.log(`You descend to level ${this.depth}...`);
|
||
|
|
this.generateLevel();
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnStairs() {
|
||
|
|
if (this.depth === 0) {
|
||
|
|
// Stairs in a random building or just random spot
|
||
|
|
if (this.map.rooms.length > 0) {
|
||
|
|
const room = this.map.rooms[0];
|
||
|
|
const sx = Math.floor(Math.random() * room.w) + room.x;
|
||
|
|
const sy = Math.floor(Math.random() * room.h) + room.y;
|
||
|
|
this.map.tiles[sy][sx] = '>';
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Spawn stairs in the last room
|
||
|
|
if (this.map.rooms.length > 0) {
|
||
|
|
const room = this.map.rooms[this.map.rooms.length - 1];
|
||
|
|
const sx = Math.floor(Math.random() * room.w) + room.x;
|
||
|
|
const sy = Math.floor(Math.random() * room.h) + room.y;
|
||
|
|
this.map.tiles[sy][sx] = '>';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnMonsters() {
|
||
|
|
if (this.depth === 0) {
|
||
|
|
// Spawn Peasants in Town
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
const mx = Math.floor(Math.random() * this.map.width);
|
||
|
|
const my = Math.floor(Math.random() * this.map.height);
|
||
|
|
if (this.map.isWalkable(mx, my)) {
|
||
|
|
const peasant = new Monster(mx, my, "Peasant", "p", "#00ffff", 1);
|
||
|
|
this.monsters.push(peasant);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Skip first room
|
||
|
|
for (let i = 1; i < this.map.rooms.length; i++) {
|
||
|
|
const room = this.map.rooms[i];
|
||
|
|
// Spawn 1-3 monsters per room
|
||
|
|
const count = Math.floor(Math.random() * 3) + 1;
|
||
|
|
for (let j = 0; j < count; j++) {
|
||
|
|
const mx = Math.floor(Math.random() * room.w) + room.x;
|
||
|
|
const my = Math.floor(Math.random() * room.h) + room.y;
|
||
|
|
|
||
|
|
const type = Math.random();
|
||
|
|
let monster;
|
||
|
|
|
||
|
|
// Difficulty scaling
|
||
|
|
if (this.depth === 1) {
|
||
|
|
if (type < 0.7) monster = new Monster(mx, my, "Rat", "r", "#a0a0a0", 1);
|
||
|
|
else monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2);
|
||
|
|
} else if (this.depth === 2) {
|
||
|
|
if (type < 0.4) monster = new Monster(mx, my, "Rat", "r", "#a0a0a0", 1);
|
||
|
|
else if (type < 0.8) monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2);
|
||
|
|
else monster = new Monster(mx, my, "Orc", "o", "#008000", 3);
|
||
|
|
} else {
|
||
|
|
if (type < 0.3) monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2);
|
||
|
|
else if (type < 0.7) monster = new Monster(mx, my, "Orc", "o", "#008000", 3);
|
||
|
|
else monster = new Monster(mx, my, "Ogre", "O", "#ff0000", 4);
|
||
|
|
}
|
||
|
|
|
||
|
|
this.monsters.push(monster);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Spawn Boss on final level
|
||
|
|
if (this.depth === this.maxDepth) {
|
||
|
|
const room = this.map.rooms[this.map.rooms.length - 1]; // Last room
|
||
|
|
const mx = Math.floor(Math.random() * room.w) + room.x;
|
||
|
|
const my = Math.floor(Math.random() * room.h) + room.y;
|
||
|
|
// Boss: Level 10, High HP
|
||
|
|
const boss = new Monster(mx, my, "The Dungeon Lord", "D", "#ff00ff", 10);
|
||
|
|
boss.hp = 100;
|
||
|
|
boss.maxHp = 100;
|
||
|
|
this.monsters.push(boss);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnItems() {
|
||
|
|
if (this.depth === 0) return; // No items in town
|
||
|
|
|
||
|
|
// Spawn items in random rooms
|
||
|
|
for (let i = 0; i < this.map.rooms.length; i++) {
|
||
|
|
const room = this.map.rooms[i];
|
||
|
|
if (Math.random() < 0.5) {
|
||
|
|
const ix = Math.floor(Math.random() * room.w) + room.x;
|
||
|
|
const iy = Math.floor(Math.random() * room.h) + room.y;
|
||
|
|
|
||
|
|
// Random item
|
||
|
|
const type = Math.random();
|
||
|
|
let item;
|
||
|
|
|
||
|
|
// Better loot deeper
|
||
|
|
let tier = this.depth;
|
||
|
|
if (Math.random() < 0.2) tier++; // Chance for higher tier
|
||
|
|
|
||
|
|
if (type < 0.3) item = new Item("Potion", "potion", 1, { heal: 5 });
|
||
|
|
else if (type < 0.5) {
|
||
|
|
if (tier <= 1) item = new Item("Dagger", "weapon", 1, { damage: 4 });
|
||
|
|
else if (tier === 2) item = new Item("Short Sword", "weapon", 2, { damage: 6 });
|
||
|
|
else item = new Item("Long Sword", "weapon", 3, { damage: 8 });
|
||
|
|
} else if (type < 0.7) {
|
||
|
|
if (tier <= 1) item = new Item("Buckler", "shield", 1, { defense: 1 });
|
||
|
|
else if (tier === 2) item = new Item("Wooden Shield", "shield", 2, { defense: 2 });
|
||
|
|
else item = new Item("Kite Shield", "shield", 3, { defense: 3 });
|
||
|
|
} else {
|
||
|
|
if (tier >= 3) item = new Item("Great Axe", "weapon", 4, { damage: 12 });
|
||
|
|
else item = new Item("Short Sword", "weapon", 2, { damage: 6 });
|
||
|
|
}
|
||
|
|
|
||
|
|
item.x = ix;
|
||
|
|
item.y = iy;
|
||
|
|
this.items.push(item);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
handleInput(e) {
|
||
|
|
if (this.gameOver) return;
|
||
|
|
|
||
|
|
let dx = 0;
|
||
|
|
let dy = 0;
|
||
|
|
|
||
|
|
switch (e.key) {
|
||
|
|
case 'ArrowUp': dy = -1; break;
|
||
|
|
case 'ArrowDown': dy = 1; break;
|
||
|
|
case 'ArrowLeft': dx = -1; break;
|
||
|
|
case 'ArrowRight': dx = 1; break;
|
||
|
|
case 'NumPad8': dy = -1; break;
|
||
|
|
case 'NumPad2': dy = 1; break;
|
||
|
|
case 'NumPad4': dx = -1; break;
|
||
|
|
case 'NumPad6': dx = 1; break;
|
||
|
|
case 'NumPad7': dx = -1; dy = -1; break;
|
||
|
|
case 'NumPad9': dx = 1; dy = -1; break;
|
||
|
|
case 'NumPad1': dx = -1; dy = 1; break;
|
||
|
|
case 'NumPad3': dx = 1; dy = 1; break;
|
||
|
|
case '.': case 'NumPad5': break; // Wait
|
||
|
|
case 'Enter':
|
||
|
|
case '>':
|
||
|
|
console.log(`Trying to descend. Player at ${this.player.x},${this.player.y}. Tile: '${this.map.tiles[this.player.y][this.player.x]}'`);
|
||
|
|
if (this.map.tiles[this.player.y][this.player.x] === '>') {
|
||
|
|
this.nextLevel();
|
||
|
|
} else {
|
||
|
|
this.ui.log("There are no stairs here.");
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case 'g': this.pickupItem(); break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (dx !== 0 || dy !== 0 || e.key === '.' || e.key === 'NumPad5') {
|
||
|
|
this.movePlayer(dx, dy);
|
||
|
|
this.render();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
movePlayer(dx, dy) {
|
||
|
|
const newX = this.player.x + dx;
|
||
|
|
const newY = this.player.y + dy;
|
||
|
|
|
||
|
|
// Check for monster
|
||
|
|
const targetMonster = this.monsters.find(m => m.x === newX && m.y === newY);
|
||
|
|
if (targetMonster) {
|
||
|
|
this.attack(this.player, targetMonster);
|
||
|
|
} else if (this.map.isWalkable(newX, newY)) {
|
||
|
|
this.player.x = newX;
|
||
|
|
this.player.y = newY;
|
||
|
|
|
||
|
|
// Check for item
|
||
|
|
const item = this.items.find(i => i.x === newX && i.y === newY);
|
||
|
|
if (item) {
|
||
|
|
this.ui.log(`You see a ${item.name}. (Press 'g' to get)`);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if (dx !== 0 || dy !== 0) this.ui.log("Blocked!");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Monsters turn
|
||
|
|
this.updateMonsters();
|
||
|
|
}
|
||
|
|
|
||
|
|
pickupItem() {
|
||
|
|
const itemIndex = this.items.findIndex(i => i.x === this.player.x && i.y === this.player.y);
|
||
|
|
if (itemIndex !== -1) {
|
||
|
|
const item = this.items[itemIndex];
|
||
|
|
this.items.splice(itemIndex, 1);
|
||
|
|
this.player.inventory.push(item);
|
||
|
|
this.ui.log(`You picked up ${item.name}.`);
|
||
|
|
this.ui.updateInventory(this.player);
|
||
|
|
this.render();
|
||
|
|
} else {
|
||
|
|
this.ui.log("There is nothing here to pick up.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
useItem(index) {
|
||
|
|
const item = this.player.inventory[index];
|
||
|
|
if (!item) return;
|
||
|
|
|
||
|
|
if (item.type === 'weapon') {
|
||
|
|
this.player.equipment.weapon = item;
|
||
|
|
this.ui.log(`You equipped ${item.name}.`);
|
||
|
|
} else if (item.type === 'shield') {
|
||
|
|
this.player.equipment.shield = item;
|
||
|
|
this.ui.log(`You equipped ${item.name}.`);
|
||
|
|
} else if (item.type === 'potion') {
|
||
|
|
if (item.stats.heal) {
|
||
|
|
this.player.hp = Math.min(this.player.hp + item.stats.heal, this.player.maxHp);
|
||
|
|
this.ui.log(`You drank ${item.name} and recovered ${item.stats.heal} HP.`);
|
||
|
|
// Remove potion
|
||
|
|
this.player.inventory.splice(index, 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.ui.updateInventory(this.player);
|
||
|
|
this.ui.updateStats(this.player, this.depth);
|
||
|
|
this.render(); // Re-render to show equipped status if we add that
|
||
|
|
}
|
||
|
|
|
||
|
|
attack(attacker, defender) {
|
||
|
|
// Peasants are invincible
|
||
|
|
if (defender.name === "Peasant") {
|
||
|
|
const quotes = [
|
||
|
|
"This is our town.",
|
||
|
|
"The Dungeon Lord is oppressing us! Please help!",
|
||
|
|
"You can get healing at the temple.",
|
||
|
|
"There are things to buy in the shops.",
|
||
|
|
"It's unpleasant for peasants at present.",
|
||
|
|
"Hello, hero!"
|
||
|
|
];
|
||
|
|
const quote = quotes[Math.floor(Math.random() * quotes.length)];
|
||
|
|
this.ui.showPopup(quote, 1000);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let damage = 0;
|
||
|
|
if (attacker === this.player) {
|
||
|
|
damage = this.player.getDamage() + Math.floor(Math.random() * 2);
|
||
|
|
} else {
|
||
|
|
damage = 1 + Math.floor(Math.random() * 2); // Monster damage
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply defense
|
||
|
|
const defense = defender.getDefense ? defender.getDefense() : 0;
|
||
|
|
damage = Math.max(1, damage - defense); // Always take at least 1 damage
|
||
|
|
|
||
|
|
defender.hp -= damage;
|
||
|
|
this.ui.log(`${attacker.name} hits ${defender.name} for ${damage} damage.`);
|
||
|
|
|
||
|
|
if (defender.hp <= 0) {
|
||
|
|
this.ui.log(`${defender.name} dies!`);
|
||
|
|
if (defender === this.player) {
|
||
|
|
this.ui.log("GAME OVER");
|
||
|
|
this.gameOver = true;
|
||
|
|
setTimeout(() => alert("Game Over! Refresh to restart."), 100);
|
||
|
|
} else {
|
||
|
|
// Remove monster
|
||
|
|
this.monsters = this.monsters.filter(m => m !== defender);
|
||
|
|
|
||
|
|
// Gold Drop
|
||
|
|
const gold = defender.level * (Math.floor(Math.random() * 5) + 1);
|
||
|
|
this.player.gold += gold;
|
||
|
|
this.ui.log(`You kill the ${defender.name}! It drops ${gold} gold.`);
|
||
|
|
|
||
|
|
// Boss Drop
|
||
|
|
if (defender.name === "The Dungeon Lord") {
|
||
|
|
const map = new Item("Ancient Map", "map", 1, {});
|
||
|
|
map.x = defender.x;
|
||
|
|
map.y = defender.y;
|
||
|
|
this.items.push(map);
|
||
|
|
this.ui.log("The Dungeon Lord drops an Ancient Map!");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Award XP
|
||
|
|
if (this.player.gainXp(defender.level * 5)) {
|
||
|
|
this.ui.log(`You reached level ${this.player.level}!`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
updateMonsters() {
|
||
|
|
for (const monster of this.monsters) {
|
||
|
|
if (monster.name === "Peasant") {
|
||
|
|
// Random walk (Slow: 10% chance to move)
|
||
|
|
if (Math.random() < 0.1) {
|
||
|
|
const dx = Math.floor(Math.random() * 3) - 1;
|
||
|
|
const dy = Math.floor(Math.random() * 3) - 1;
|
||
|
|
const newX = monster.x + dx;
|
||
|
|
const newY = monster.y + dy;
|
||
|
|
if (this.map.isWalkable(newX, newY) && !this.monsters.some(m => m.x === newX && m.y === newY) && (newX !== this.player.x || newY !== this.player.y)) {
|
||
|
|
monster.x = newX;
|
||
|
|
monster.y = newY;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Simple chase logic
|
||
|
|
const dx = this.player.x - monster.x;
|
||
|
|
const dy = this.player.y - monster.y;
|
||
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
|
|
|
||
|
|
if (dist < 8) { // Aggro range
|
||
|
|
let moveX = 0;
|
||
|
|
let moveY = 0;
|
||
|
|
|
||
|
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||
|
|
moveX = dx > 0 ? 1 : -1;
|
||
|
|
} else {
|
||
|
|
moveY = dy > 0 ? 1 : -1;
|
||
|
|
}
|
||
|
|
|
||
|
|
const newX = monster.x + moveX;
|
||
|
|
const newY = monster.y + moveY;
|
||
|
|
|
||
|
|
if (newX === this.player.x && newY === this.player.y) {
|
||
|
|
this.attack(monster, this.player);
|
||
|
|
} else if (this.map.isWalkable(newX, newY)) {
|
||
|
|
// Check if occupied by another monster
|
||
|
|
if (!this.monsters.some(m => m.x === newX && m.y === newY)) {
|
||
|
|
monster.x = newX;
|
||
|
|
monster.y = newY;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
render() {
|
||
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
|
|
this.ui.renderMap(this.map, this.player, this.monsters, this.items, this.tileSize);
|
||
|
|
this.ui.updateStats(this.player, this.depth);
|
||
|
|
}
|
||
|
|
}
|