LyssaGame/src/Game.js

733 lines
26 KiB
JavaScript

import { Map } from './Map.js';
import { UI } from './UI.js';
import { Player } from './Entity.js';
import { Monster, Shopkeeper } 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;
this.gameState = 'PLAY'; // 'PLAY', 'SHOP', 'TARGETING'
this.currentShopkeeper = null;
this.shopSelection = 0;
this.targetingSpell = null;
this.projectiles = [];
// Bind input
window.addEventListener('keydown', (e) => this.handleInput(e));
}
start() {
console.log("Game Started");
this.generateLevel('down');
this.ui.log("Welcome to the Town of Oakhaven!");
}
generateLevel(direction = 'down') {
this.monsters = [];
this.items = [];
if (this.depth === 0) {
this.map.generateTown();
// Spawn player in center (default) or at stairs if coming up
if (direction === 'up') {
// Coming up from dungeon, spawn at dungeon entrance
this.player.x = 43;
this.player.y = 8;
} else {
this.player.x = Math.floor(this.map.width / 2);
this.player.y = Math.floor(this.map.height / 2);
}
} else {
this.map.generate();
// Spawn Player
if (this.map.rooms.length > 0) {
if (direction === 'down') {
// Start at first room (Stairs Up location)
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);
} else {
// Start at last room (Stairs Down location)
const endRoom = this.map.rooms[this.map.rooms.length - 1];
this.player.x = Math.floor(endRoom.x + endRoom.w / 2);
this.player.y = Math.floor(endRoom.y + endRoom.h / 2);
}
}
}
this.spawnMonsters();
this.spawnItems();
this.spawnStairs();
this.render();
this.ui.updateStats(this.player, this.depth);
this.ui.updateInventory(this.player);
this.ui.updateSpells(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('down');
}
prevLevel() {
if (this.depth === 0) {
this.ui.log("You are already in town.");
return;
}
this.depth--;
if (this.depth === 0) {
this.ui.log("You return to the Town of Oakhaven.");
} else {
this.ui.log(`You ascend to level ${this.depth}...`);
}
this.generateLevel('up');
}
spawnStairs() {
if (this.depth === 0) {
// Town: Stairs Down in the Dungeon Entrance building
const sx = 43; // Center X
const sy = 8; // Center Y
this.map.tiles[sy][sx] = '>';
return;
}
// Dungeon Levels
if (this.map.rooms.length > 0) {
// Stairs Up (<) in the first room (where you start when going down)
const startRoom = this.map.rooms[0];
const ux = Math.floor(startRoom.x + startRoom.w / 2);
const uy = Math.floor(startRoom.y + startRoom.h / 2);
// Ensure we don't overwrite player if they are standing there (visual only, logic handles it)
this.map.tiles[uy][ux] = '<';
// Stairs Down (>) in the last room (unless max depth)
if (this.depth < this.maxDepth) {
const endRoom = this.map.rooms[this.map.rooms.length - 1];
const dx = Math.floor(Math.random() * endRoom.w) + endRoom.x;
const dy = Math.floor(Math.random() * endRoom.h) + endRoom.y;
this.map.tiles[dy][dx] = '>';
}
}
}
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);
}
}
// Spawn Shopkeepers
// Weapon Shop (East)
const weaponShopInventory = [
{ item: new Item("Dagger", "weapon", 1, { damage: 4 }), price: 50 },
{ item: new Item("Short Sword", "weapon", 2, { damage: 6 }), price: 100 },
{ item: new Item("Buckler", "shield", 1, { defense: 1 }), price: 40 },
{ item: new Item("Wooden Shield", "shield", 2, { defense: 2 }), price: 80 }
];
const weaponSmith = new Shopkeeper(41, 18, "Smith", "Weapon Shop", weaponShopInventory);
this.monsters.push(weaponSmith);
// General Store (West)
const generalStoreInventory = [
{ item: new Item("Potion", "potion", 1, { heal: 5 }), price: 20 }
];
const merchant = new Shopkeeper(9, 18, "Merchant", "General Store", generalStoreInventory);
this.monsters.push(merchant);
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;
if (this.gameState === 'SHOP') {
this.handleShopInput(e);
return;
}
if (this.gameState === 'TARGETING') {
this.handleTargetingInput(e);
return;
}
let dx = 0;
let dy = 0;
let handled = false;
switch (e.key) {
case 'ArrowUp': dy = -1; handled = true; break;
case 'ArrowDown': dy = 1; handled = true; break;
case 'ArrowLeft': dx = -1; handled = true; break;
case 'ArrowRight': dx = 1; handled = true; break;
case 'NumPad8': dy = -1; handled = true; break;
case 'NumPad2': dy = 1; handled = true; break;
case 'NumPad4': dx = -1; handled = true; break;
case 'NumPad6': dx = 1; handled = true; break;
case 'NumPad7': dx = -1; dy = -1; handled = true; break;
case 'NumPad9': dx = 1; dy = -1; handled = true; break;
case 'NumPad1': dx = -1; dy = 1; handled = true; break;
case 'NumPad3': dx = 1; dy = 1; handled = true; break;
case '.': case 'NumPad5': handled = true; break; // Wait
case 'Enter':
case '>':
if (this.map.tiles[this.player.y][this.player.x] === '>') {
this.nextLevel();
} else if (this.map.tiles[this.player.y][this.player.x] === '<') {
this.prevLevel();
} else {
this.ui.log("There are no stairs here.");
}
handled = true;
break;
case '<':
case ',':
if (this.map.tiles[this.player.y][this.player.x] === '<') {
this.prevLevel();
} else {
this.ui.log("There are no stairs up here.");
}
handled = true;
break;
case 'g': this.pickupItem(); handled = true; break;
case '1': this.startTargeting('Magic Arrow'); handled = true; break;
case '2': this.startTargeting('Fireball'); handled = true; break;
}
if (handled) {
e.preventDefault();
}
if (dx !== 0 || dy !== 0 || e.key === '.' || e.key === 'NumPad5') {
this.movePlayer(dx, dy);
this.render();
}
}
handleShopInput(e) {
e.preventDefault();
const inventory = this.currentShopkeeper.inventory;
switch (e.key) {
case 'ArrowUp':
case 'NumPad8':
this.shopSelection--;
if (this.shopSelection < 0) this.shopSelection = inventory.length - 1;
break;
case 'ArrowDown':
case 'NumPad2':
this.shopSelection++;
if (this.shopSelection >= inventory.length) this.shopSelection = 0;
break;
case 'Enter':
case ' ':
this.buyItem(inventory[this.shopSelection]);
break;
case 'Escape':
this.closeShop();
break;
}
this.render();
}
openShop(shopkeeper) {
this.gameState = 'SHOP';
this.currentShopkeeper = shopkeeper;
this.shopSelection = 0;
this.ui.log(`You talk to ${shopkeeper.name}.`);
this.render();
}
closeShop() {
this.gameState = 'PLAY';
this.currentShopkeeper = null;
this.render();
}
buyItem(entry) {
if (this.player.gold >= entry.price) {
this.player.gold -= entry.price;
// Clone item to avoid reference issues if buying multiple
const newItem = new Item(entry.item.name, entry.item.type, entry.item.level, entry.item.stats);
// Copy visual props
newItem.symbol = entry.item.symbol;
newItem.color = entry.item.color;
this.player.inventory.push(newItem);
this.ui.log(`You bought ${entry.item.name} for ${entry.price} gold.`);
this.ui.updateInventory(this.player);
this.ui.updateStats(this.player, this.depth);
} else {
this.ui.log("You don't have enough gold!");
}
}
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) {
if (targetMonster instanceof Shopkeeper) {
this.openShop(targetMonster);
} else {
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 {
console.log(`Blocked at ${newX},${newY}. Tile: '${this.map.tiles[newY][newX]}'`);
if (dx !== 0 || dy !== 0) this.ui.log("Blocked!");
}
// Temple Healing (Town only)
if (this.depth === 0 && this.player.x === 25 && this.player.y === 12) {
if (this.player.hp < this.player.maxHp) {
this.player.hp = this.player.maxHp;
this.ui.log("You enter the temple and feel refreshed. HP fully restored!");
this.ui.updateStats(this.player, this.depth);
} else {
this.ui.log("You enter the temple. It is peaceful here.");
}
}
// Monsters turn
this.updateMonsters();
}
handleTargetingInput(e) {
e.preventDefault();
let dx = 0;
let dy = 0;
switch (e.key) {
case 'ArrowUp': case 'NumPad8': dy = -1; break;
case 'ArrowDown': case 'NumPad2': dy = 1; break;
case 'ArrowLeft': case 'NumPad4': dx = -1; break;
case 'ArrowRight': 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 'Escape':
this.gameState = 'PLAY';
this.ui.log("Canceled.");
this.render();
return;
}
if (dx !== 0 || dy !== 0) {
this.castSpell(this.targetingSpell, dx, dy);
this.gameState = 'PLAY';
this.targetingSpell = null;
}
}
startTargeting(spellName) {
this.targetingSpell = spellName;
this.gameState = 'TARGETING';
this.ui.log(`Select direction for ${spellName}...`);
}
castSpell(spellName, dx, dy) {
let manaCost = 0;
let damage = 0;
let symbol = '*';
let color = '#ffffff';
if (spellName === 'Magic Arrow') {
manaCost = 2;
damage = Math.floor(this.player.stats.int / 2) + Math.floor(Math.random() * 4) + 1;
symbol = '*';
color = '#aaaaff';
} else if (spellName === 'Fireball') {
manaCost = 5;
damage = this.player.stats.int + Math.floor(Math.random() * 6) + 1;
symbol = 'o';
color = '#ff4400';
}
if (this.player.mana < manaCost) {
this.ui.log("Not enough mana!");
return;
}
this.player.mana -= manaCost;
this.ui.updateStats(this.player, this.depth);
// Create Projectile
const projectile = {
x: this.player.x,
y: this.player.y,
dx: dx,
dy: dy,
symbol: symbol,
color: color,
damage: damage,
name: spellName
};
this.projectiles.push(projectile);
this.animateProjectile(projectile);
}
animateProjectile(projectile) {
const interval = setInterval(() => {
const newX = projectile.x + projectile.dx;
const newY = projectile.y + projectile.dy;
// Check collision
if (!this.map.isWalkable(newX, newY)) {
clearInterval(interval);
this.removeProjectile(projectile);
this.ui.log(`${projectile.name} hits the wall.`);
this.render();
return;
}
const monster = this.monsters.find(m => m.x === newX && m.y === newY);
if (monster) {
clearInterval(interval);
this.removeProjectile(projectile);
// Shopkeepers are immune
if (monster instanceof Shopkeeper) {
this.ui.log(`${projectile.name} hits ${monster.name} but does nothing.`);
} else {
// Deal damage
monster.hp -= projectile.damage;
this.ui.log(`${projectile.name} hits ${monster.name} for ${projectile.damage} damage.`);
if (monster.hp <= 0) {
this.killMonster(monster);
}
}
this.render();
return;
}
projectile.x = newX;
projectile.y = newY;
this.render();
// Range limit (optional, but good for safety)
if (Math.abs(projectile.x - this.player.x) > 20 || Math.abs(projectile.y - this.player.y) > 20) {
clearInterval(interval);
this.removeProjectile(projectile);
this.render();
}
}, 50); // Speed of projectile
}
removeProjectile(projectile) {
const index = this.projectiles.indexOf(projectile);
if (index > -1) {
this.projectiles.splice(index, 1);
}
}
killMonster(monster) {
this.ui.log(`${monster.name} dies!`);
this.monsters = this.monsters.filter(m => m !== monster);
const gold = monster.level * (Math.floor(Math.random() * 5) + 1);
this.player.gold += gold;
this.ui.log(`You kill the ${monster.name}! It drops ${gold} gold.`);
if (monster.name === "The Dungeon Lord") {
const map = new Item("Ancient Map", "map", 1, {});
map.x = monster.x;
map.y = monster.y;
this.items.push(map);
this.ui.log("The Dungeon Lord drops an Ancient Map!");
}
if (this.player.gainXp(monster.level * 5)) {
this.ui.log(`You reached level ${this.player.level}!`);
}
}
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) {
// Shopkeepers are invincible/pacifist
if (defender instanceof Shopkeeper) return;
// 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 instanceof Shopkeeper) continue; // Shopkeepers don't move
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.projectiles, this.tileSize);
if (this.gameState === 'SHOP') {
this.ui.drawShop(this.currentShopkeeper, this.player, this.shopSelection);
}
this.ui.updateStats(this.player, this.depth);
}
}