From 02edcd9d9e9514a55979ac1e23aeae024979ea95 Mon Sep 17 00:00:00 2001 From: John Kenyon Date: Fri, 26 Dec 2025 16:00:28 -0800 Subject: [PATCH] Added more features --- index.html | 6 +- src/Entity.js | 25 +- src/Game.js | 613 ++++++++++++++++++++++++++++++++++++++++++-------- src/Item.js | 2 + src/Map.js | 27 ++- src/UI.js | 182 +++++++++++---- 6 files changed, 713 insertions(+), 142 deletions(-) diff --git a/index.html b/index.html index c269d8a..665f06b 100644 --- a/index.html +++ b/index.html @@ -44,9 +44,9 @@
Inventory
- +
Spells
diff --git a/src/Entity.js b/src/Entity.js index ec59083..e034f5b 100644 --- a/src/Entity.js +++ b/src/Entity.js @@ -1,5 +1,6 @@ export class Entity { constructor(x, y, name, symbol, color) { + if (name === 'Player') console.log("Entity Version 1.2 Loaded"); this.x = x; this.y = y; this.name = name; @@ -26,7 +27,7 @@ export class Player extends Entity { }; this.mana = 10; this.maxMana = 10; - this.spells = ['Magic Arrow', 'Fireball']; + this.spells = ['zappy laser']; this.level = 1; this.xp = 0; this.maxXp = 10; @@ -75,6 +76,7 @@ export class Monster extends Entity { this.level = level; this.hp = level * 5; this.maxHp = this.hp; + this.speed = 1.0; // Default: move every turn } getDefense() { @@ -82,6 +84,13 @@ export class Monster extends Entity { } } +export class Archer extends Monster { + constructor(x, y, name, symbol, color, level) { + super(x, y, name, symbol, color, level); + this.range = 5; + } +} + export class Shopkeeper extends Entity { constructor(x, y, name, shopName, inventory) { super(x, y, name, '$', '#ffd700'); @@ -89,3 +98,17 @@ export class Shopkeeper extends Entity { this.inventory = inventory; // Array of { item: Item, price: number } } } + +export class QuestGiver extends Entity { + constructor(x, y, name, message) { + super(x, y, name, 'Q', '#ff00ff'); + this.message = message; + } +} + +export class WanderingQuestGiver extends Entity { + constructor(x, y, name) { + super(x, y, name, 'Q', '#00ff00'); + this.quest = null; // { target: string, required: number, current: number, completed: false } + } +} diff --git a/src/Game.js b/src/Game.js index 666b489..0680a5b 100644 --- a/src/Game.js +++ b/src/Game.js @@ -2,33 +2,47 @@ import { Map } from './Map.js'; import { UI } from './UI.js'; import { Player } from './Entity.js'; -import { Monster, Shopkeeper } from './Entity.js'; +import { Monster, Shopkeeper, QuestGiver, WanderingQuestGiver, Archer } from './Entity.js'; import { Item } from './Item.js'; export class Game { constructor() { + console.log("Game Version 1.3 Loaded - Persistent Levels"); 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.maxDepth = 10; + this.deepestDepthReached = 0; + this.dungeonLevels = {}; // Persistent storage for each depth this.tileSize = 32; - this.gameState = 'PLAY'; // 'PLAY', 'SHOP', 'TARGETING' + this.gameState = 'PLAY'; // 'PLAY', 'SHOP', 'TARGETING', 'INVENTORY' this.currentShopkeeper = null; this.shopSelection = 0; + this.shopMode = 'BUY'; // 'BUY' or 'SELL' + this.inventorySelection = 0; this.targetingSpell = null; this.projectiles = []; + this.questItems = [ + { name: "Golden Locket", dropped: false, activated: false }, + { name: "Silver Chalice", dropped: false, activated: false }, + { name: "Ruby Ring", dropped: false, activated: false } + ]; + + this.wanderingQuests = [ + { target: "Rat", required: 0, current: 0, completed: false, rewarded: false }, + { target: "Kobold", required: 0, current: 0, completed: false, rewarded: false }, + { target: "Orc", required: 0, current: 0, completed: false, rewarded: false } + ]; + this.wanderingQuests.forEach(q => q.required = Math.floor(Math.random() * 6) + 5); + // Bind input window.addEventListener('keydown', (e) => this.handleInput(e)); } @@ -41,40 +55,70 @@ export class Game { 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); + if (this.dungeonLevels[this.depth]) { + // Load existing level + const levelData = this.dungeonLevels[this.depth]; + this.map = levelData.map; + this.items = levelData.items; + + // Reposition player + if (this.depth === 0) { + if (direction === 'up') { + this.player.x = 43; + this.player.y = 8; } 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.player.x = Math.floor(this.map.width / 2); + this.player.y = Math.floor(this.map.height / 2); + } + } else { + if (this.map.rooms.length > 0) { + if (direction === 'down') { + 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 { + 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); + } } } + } else { + // Generate NEW level + this.items = []; + this.map = new Map(50, 50); + + if (this.depth === 0) { + this.map.generateTown(); + this.player.x = Math.floor(this.map.width / 2); + this.player.y = Math.floor(this.map.height / 2); + } else { + this.map.generate(); + if (this.map.rooms.length > 0) { + if (direction === 'down') { + 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 { + 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.spawnItems(); + this.spawnStairs(); + + // Save new level state + this.dungeonLevels[this.depth] = { + map: this.map, + items: this.items + }; } - this.spawnMonsters(); - this.spawnItems(); - this.spawnStairs(); + this.spawnMonsters(); // Always respawn monsters this.render(); this.ui.updateStats(this.player, this.depth); @@ -89,7 +133,14 @@ export class Game { return; } + // Save current level state + this.dungeonLevels[this.depth] = { + map: this.map, + items: this.items + }; + this.depth++; + this.deepestDepthReached = Math.max(this.deepestDepthReached, this.depth); this.ui.log(`You descend to level ${this.depth}...`); this.generateLevel('down'); } @@ -100,6 +151,12 @@ export class Game { return; } + // Save current level state + this.dungeonLevels[this.depth] = { + map: this.map, + items: this.items + }; + this.depth--; if (this.depth === 0) { this.ui.log("You return to the Town of Oakhaven."); @@ -162,11 +219,34 @@ export class Game { // General Store (West) const generalStoreInventory = [ - { item: new Item("Potion", "potion", 1, { heal: 5 }), price: 20 } + { item: new Item("Potion", "potion", 1, { heal: 5 }), price: 20 }, + { item: new Item("Spellbook: Fireball", "spellbook", 1, { spell: "Fireball" }), price: 300 }, + { item: new Item("Spellbook: Light", "spellbook", 1, { spell: "Light" }), price: 200 }, + { item: new Item("Spellbook: Heal", "spellbook", 1, { spell: "Heal" }), price: 400 }, + { item: new Item("Spellbook: Return", "spellbook", 1, { spell: "Return" }), price: 500 } ]; const merchant = new Shopkeeper(9, 18, "Merchant", "General Store", generalStoreInventory); this.monsters.push(merchant); + // Spawn Quest Givers in Houses + const q1 = new QuestGiver(13, 32, "Villager", "Please bring back my Golden Locket! It was stolen by an orc!"); + const q2 = new QuestGiver(25, 32, "Old Man", "Please bring back my Silver Chalice! It was stolen by an orc!"); + const q3 = new QuestGiver(37, 32, "Woman", "Please bring back my Ruby Ring! It was stolen by an orc!"); + this.monsters.push(q1, q2, q3); + + // Spawn Wandering Quest Givers + for (let i = 0; i < 3; i++) { + let wx, wy; + do { + wx = Math.floor(Math.random() * this.map.width); + wy = Math.floor(Math.random() * this.map.height); + } while (!this.map.isWalkable(wx, wy)); + + const wqg = new WanderingQuestGiver(wx, wy, `Traveler ${i + 1}`); + wqg.quest = this.wanderingQuests[i]; + this.monsters.push(wqg); + } + return; } @@ -182,20 +262,37 @@ export class Game { const type = Math.random(); let monster; - // Difficulty scaling - if (this.depth === 1) { + // Difficulty scaling for 10 levels + if (this.depth <= 2) { 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 (this.depth <= 4) { + if (type < 0.3) monster = new Monster(mx, my, "Rat", "r", "#a0a0a0", 1); + else if (type < 0.6) monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2); + else if (type < 0.8) monster = new Monster(mx, my, "Orc", "o", "#008000", 3); + else monster = new Archer(mx, my, "Skeleton Archer", "s", "#eeeeee", 3); + } else if (this.depth <= 7) { + if (type < 0.2) monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2); + else if (type < 0.5) monster = new Monster(mx, my, "Orc", "o", "#008000", 3); + else if (type < 0.8) monster = new Monster(mx, my, "Ogre", "O", "#ff0000", 5); + else monster = new Archer(mx, my, "Elite Archer", "S", "#ffffff", 5); } 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); + if (type < 0.2) monster = new Monster(mx, my, "Orc", "o", "#008000", 3); + else if (type < 0.4) monster = new Monster(mx, my, "Ogre", "O", "#ff0000", 5); + else if (type < 0.6) monster = new Monster(mx, my, "Troll", "T", "#0000ff", 7); + else if (type < 0.8) monster = new Archer(mx, my, "Sniper", "S", "#ff00ff", 8); + else monster = new Monster(mx, my, "Dragon", "W", "#ff0000", 10); } + // Set Monster Speeds + if (monster.name === "Rat") monster.speed = 1.0; + else if (monster.name === "Kobold") monster.speed = 1.0; + else if (monster.name === "Orc") monster.speed = 0.8; + else if (monster instanceof Archer) monster.speed = 0.7; + else if (monster.name === "Ogre") monster.speed = 0.5; + else if (monster.name === "Troll") monster.speed = 0.6; + else if (monster.name === "Dragon") monster.speed = 0.4; + this.monsters.push(monster); } } @@ -209,6 +306,7 @@ export class Game { const boss = new Monster(mx, my, "The Dungeon Lord", "D", "#ff00ff", 10); boss.hp = 100; boss.maxHp = 100; + boss.speed = 0.6; this.monsters.push(boss); } } @@ -228,21 +326,39 @@ export class Game { let item; // Better loot deeper - let tier = this.depth; + let tier = Math.floor(this.depth / 2) + 1; 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 (type < 0.3) item = new Item("Potion", "potion", 1, { heal: 5 + this.depth * 2 }); + else if (type < 0.45) { 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) { + else if (tier === 3) item = new Item("Long Sword", "weapon", 3, { damage: 8 }); + else if (tier === 4) item = new Item("Great Sword", "weapon", 4, { damage: 12 }); + else item = new Item("Dragon Blade", "weapon", 5, { damage: 20 }); + } else if (type < 0.6) { 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 if (tier === 3) item = new Item("Kite Shield", "shield", 3, { defense: 4 }); + else item = new Item("Tower Shield", "shield", 4, { defense: 6 }); + } else if (type < 0.7) { + if (tier >= 5) item = new Item("Great Axe", "weapon", 5, { damage: 18 }); + else if (tier >= 3) item = new Item("War Hammer", "weapon", 3, { damage: 10 }); else item = new Item("Short Sword", "weapon", 2, { damage: 6 }); + } else if (type < 0.8) { + // Spellbooks + const spellRoll = Math.random(); + if (spellRoll < 0.25) { + item = new Item("Spellbook: Fireball", "spellbook", 1, { spell: "Fireball" }); + } else if (spellRoll < 0.5) { + item = new Item("Spellbook: Light", "spellbook", 1, { spell: "Light" }); + } else if (spellRoll < 0.75) { + item = new Item("Spellbook: Heal", "spellbook", 1, { spell: "Heal" }); + } else { + item = new Item("Spellbook: Return", "spellbook", 1, { spell: "Return" }); + } + } else { + item = new Item("Potion", "potion", 1, { heal: 5 + this.depth * 2 }); } item.x = ix; @@ -260,6 +376,11 @@ export class Game { return; } + if (this.gameState === 'INVENTORY') { + this.handleInventoryInput(e); + return; + } + if (this.gameState === 'TARGETING') { this.handleTargetingInput(e); return; @@ -283,6 +404,7 @@ export class Game { 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 'i': case 'I': this.toggleInventory(); handled = true; break; case 'Enter': case '>': if (this.map.tiles[this.player.y][this.player.x] === '>') { @@ -304,8 +426,31 @@ export class Game { 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; + case '1': + if (this.player.spells.includes('zappy laser')) this.startTargeting('zappy laser'); + else this.ui.log("You don't know that spell!"); + handled = true; + break; + case '2': + if (this.player.spells.includes('Fireball')) this.startTargeting('Fireball'); + else this.ui.log("You don't know that spell!"); + handled = true; + break; + case '3': + if (this.player.spells.includes('Light')) this.castLightSpell(); + else this.ui.log("You don't know that spell!"); + handled = true; + break; + case '4': + if (this.player.spells.includes('Heal')) this.castHealSpell(); + else this.ui.log("You don't know that spell!"); + handled = true; + break; + case '5': + if (this.player.spells.includes('Return')) this.castReturnSpell(); + else this.ui.log("You don't know that spell!"); + handled = true; + break; } if (handled) { @@ -320,7 +465,7 @@ export class Game { handleShopInput(e) { e.preventDefault(); - const inventory = this.currentShopkeeper.inventory; + const inventory = this.shopMode === 'BUY' ? this.currentShopkeeper.inventory : this.player.inventory; switch (e.key) { case 'ArrowUp': @@ -333,9 +478,17 @@ export class Game { this.shopSelection++; if (this.shopSelection >= inventory.length) this.shopSelection = 0; break; + case 'Tab': + this.shopMode = this.shopMode === 'BUY' ? 'SELL' : 'BUY'; + this.shopSelection = 0; + break; case 'Enter': case ' ': - this.buyItem(inventory[this.shopSelection]); + if (this.shopMode === 'BUY') { + this.buyItem(inventory[this.shopSelection]); + } else { + this.sellItem(this.shopSelection); + } break; case 'Escape': this.closeShop(); @@ -348,6 +501,7 @@ export class Game { this.gameState = 'SHOP'; this.currentShopkeeper = shopkeeper; this.shopSelection = 0; + this.shopMode = 'BUY'; this.ui.log(`You talk to ${shopkeeper.name}.`); this.render(); } @@ -358,7 +512,48 @@ export class Game { this.render(); } + toggleInventory() { + if (this.gameState === 'INVENTORY') { + this.gameState = 'PLAY'; + } else { + this.gameState = 'INVENTORY'; + this.inventorySelection = 0; + } + this.render(); + } + + handleInventoryInput(e) { + e.preventDefault(); + const inventory = this.player.inventory; + + switch (e.key) { + case 'ArrowUp': + case 'NumPad8': + this.inventorySelection--; + if (this.inventorySelection < 0) this.inventorySelection = inventory.length - 1; + break; + case 'ArrowDown': + case 'NumPad2': + this.inventorySelection++; + if (this.inventorySelection >= inventory.length) this.inventorySelection = 0; + break; + case 'Enter': + case ' ': + if (inventory[this.inventorySelection]) { + this.useItem(this.inventorySelection); + } + break; + case 'Escape': + case 'i': + case 'I': + this.gameState = 'PLAY'; + break; + } + this.render(); + } + buyItem(entry) { + if (!entry) return; if (this.player.gold >= entry.price) { this.player.gold -= entry.price; // Clone item to avoid reference issues if buying multiple @@ -376,6 +571,44 @@ export class Game { } } + sellItem(index) { + const item = this.player.inventory[index]; + if (!item) return; + + // Cannot sell quest items or the ancient map + if (item.type === 'quest' || item.type === 'map') { + this.ui.log("This item is too precious to sell!"); + return; + } + + // Base price calculation (1/4 of buy price) + // We'll estimate price based on item level/type if it doesn't have a value + let baseValue = 40; // Default + if (item.type === 'weapon') baseValue = 100 * item.level; + if (item.type === 'shield') baseValue = 40 * item.level; + if (item.type === 'potion') baseValue = 20; + if (item.type === 'spellbook') baseValue = 400; + + const sellPrice = Math.floor(baseValue / 4); + + // Unequip if selling equipped item + if (this.player.equipment.weapon === item) this.player.equipment.weapon = null; + if (this.player.equipment.shield === item) this.player.equipment.shield = null; + + this.player.gold += sellPrice; + this.player.inventory.splice(index, 1); + + this.ui.log(`You sold ${item.name} for ${sellPrice} gold.`); + + // Reset selection if last item was sold + if (this.shopSelection >= this.player.inventory.length) { + this.shopSelection = Math.max(0, this.player.inventory.length - 1); + } + + this.ui.updateInventory(this.player); + this.ui.updateStats(this.player, this.depth); + } + movePlayer(dx, dy) { const newX = this.player.x + dx; const newY = this.player.y + dy; @@ -385,6 +618,43 @@ export class Game { if (targetMonster) { if (targetMonster instanceof Shopkeeper) { this.openShop(targetMonster); + } else if (targetMonster instanceof QuestGiver) { + // ... quest item logic ... + const itemName = targetMonster.message.match(/my (.*)!/)[1]; + const questData = this.questItems.find(qi => qi.name === itemName); + + // Activate quest if not already + if (questData) questData.activated = true; + + const itemIndex = this.player.inventory.findIndex(i => i.name === itemName); + + if (itemIndex !== -1) { + this.player.inventory.splice(itemIndex, 1); + this.player.gold += 10; + this.ui.showPopup("Thank you! Have some gold!", 2000); + this.ui.log(`${targetMonster.name} says: "Thank you! Have some gold!"`); + this.ui.log(`You received 10 gold for returning the ${itemName}.`); + this.ui.updateInventory(this.player); + this.ui.updateStats(this.player, this.depth); + } else { + this.ui.showPopup(targetMonster.message, 2000); + this.ui.log(`${targetMonster.name} says: "${targetMonster.message}"`); + } + } else if (targetMonster instanceof WanderingQuestGiver) { + const q = targetMonster.quest; + if (q.rewarded) { + this.ui.showPopup("Good luck on your travels!", 2000); + } else if (q.completed) { + this.player.gold += 10; + q.rewarded = true; + this.ui.showPopup("Great work! Here is 10 gold.", 2000); + this.ui.log(`${targetMonster.name} says: "Great work! Here is 10 gold."`); + this.ui.updateStats(this.player, this.depth); + } else { + const msg = `Please kill ${q.required} ${q.target}s. Progress: ${q.current}/${q.required}`; + this.ui.showPopup(msg, 2000); + this.ui.log(`${targetMonster.name} says: "${msg}"`); + } } else { this.attack(this.player, targetMonster); } @@ -457,7 +727,7 @@ export class Game { let symbol = '*'; let color = '#ffffff'; - if (spellName === 'Magic Arrow') { + if (spellName === 'zappy laser') { manaCost = 2; damage = Math.floor(this.player.stats.int / 2) + Math.floor(Math.random() * 4) + 1; symbol = '*'; @@ -486,13 +756,91 @@ export class Game { symbol: symbol, color: color, damage: damage, - name: spellName + name: spellName, + owner: this.player }; this.projectiles.push(projectile); this.animateProjectile(projectile); } + castLightSpell() { + const manaCost = 3; + if (this.player.mana < manaCost) { + this.ui.log("Not enough mana!"); + return; + } + + this.player.mana -= manaCost; + this.ui.updateStats(this.player, this.depth); + this.ui.log("You cast Light!"); + + // Permanently light a 5-square radius + const radius = 5; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const lx = this.player.x + dx; + const ly = this.player.y + dy; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist <= radius + 0.5) { + if (lx >= 0 && lx < this.map.width && ly >= 0 && ly < this.map.height) { + this.map.permanentlyLit[ly][lx] = true; + } + } + } + } + this.render(); + } + + castHealSpell() { + const manaCost = 4; + if (this.player.mana < manaCost) { + this.ui.log("Not enough mana!"); + return; + } + + const healAmount = 5 + Math.floor(this.player.stats.int / 2); + this.player.mana -= manaCost; + this.player.hp = Math.min(this.player.hp + healAmount, this.player.maxHp); + + this.ui.updateStats(this.player, this.depth); + this.ui.log(`You cast Heal and recover ${healAmount} HP!`); + this.render(); + } + + castReturnSpell() { + const manaCost = 6; + if (this.player.mana < manaCost) { + this.ui.log("Not enough mana!"); + return; + } + + this.player.mana -= manaCost; + this.ui.updateStats(this.player, this.depth); + + if (this.depth > 0) { + // Save current level + this.dungeonLevels[this.depth] = { + map: this.map, + items: this.items + }; + this.depth = 0; + this.ui.log("You cast Return and teleport to town!"); + this.generateLevel('down'); // Center of town + } else { + if (this.deepestDepthReached === 0) { + this.ui.log("You haven't entered the dungeon yet!"); + this.player.mana += manaCost; // Refund + return; + } + this.depth = this.deepestDepthReached; + this.ui.log(`You cast Return and teleport to level ${this.depth}!`); + this.generateLevel('down'); // Start of level + } + this.render(); + } + animateProjectile(projectile) { const interval = setInterval(() => { const newX = projectile.x + projectile.dx; @@ -508,12 +856,12 @@ export class Game { } const monster = this.monsters.find(m => m.x === newX && m.y === newY); - if (monster) { + if (monster && projectile.owner === this.player) { clearInterval(interval); this.removeProjectile(projectile); - // Shopkeepers are immune - if (monster instanceof Shopkeeper) { + // Shopkeepers and QuestGivers are immune + if (monster instanceof Shopkeeper || monster instanceof QuestGiver || monster instanceof WanderingQuestGiver) { this.ui.log(`${projectile.name} hits ${monster.name} but does nothing.`); } else { // Deal damage @@ -527,6 +875,15 @@ export class Game { return; } + // Hit player? + if (newX === this.player.x && newY === this.player.y && projectile.owner !== this.player) { + clearInterval(interval); + this.removeProjectile(projectile); + this.attack(projectile.owner, this.player, projectile.damage); + this.render(); + return; + } + projectile.x = newX; projectile.y = newY; this.render(); @@ -551,6 +908,36 @@ export class Game { killMonster(monster) { this.ui.log(`${monster.name} dies!`); this.monsters = this.monsters.filter(m => m !== monster); + + // Track wandering quests + this.wanderingQuests.forEach(q => { + if (q.target === monster.name && !q.completed) { + q.current++; + if (q.current >= q.required) { + q.completed = true; + this.ui.log(`Quest Objective Complete: ${q.required} ${q.target}s killed!`); + } + } + }); + + // Quest Item Drop logic + if (monster.name === "Orc") { + const potentialDrops = this.questItems.filter(qi => qi.activated && !qi.dropped); + if (potentialDrops.length > 0) { + // Determine if we should drop one. + if (Math.random() < 0.3) { + const itemData = potentialDrops[0]; + itemData.dropped = true; + + const questItem = new Item(itemData.name, "quest", 1, {}); + questItem.x = monster.x; + questItem.y = monster.y; + this.items.push(questItem); + this.ui.log(`The Orc was carrying a ${itemData.name}!`); + } + } + } + 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.`); @@ -566,6 +953,32 @@ export class Game { } } + monsterShoot(monster) { + const dx = Math.sign(this.player.x - monster.x); + const dy = Math.sign(this.player.y - monster.y); + + const damage = monster.level + Math.floor(Math.random() * 3); + const projectile = { + x: monster.x, + y: monster.y, + dx: dx, + dy: dy, + symbol: '-', + color: '#ffffff', + damage: damage, + name: 'Arrow', + owner: monster + }; + + // Adjust symbol based on direction + if (dy !== 0 && dx === 0) projectile.symbol = '|'; + if (dy !== 0 && dx !== 0) projectile.symbol = (dx * dy > 0) ? '\\' : '/'; + + this.projectiles.push(projectile); + this.animateProjectile(projectile); + this.ui.log(`${monster.name} fires an arrow!`); + } + pickupItem() { const itemIndex = this.items.findIndex(i => i.x === this.player.x && i.y === this.player.y); if (itemIndex !== -1) { @@ -597,6 +1010,16 @@ export class Game { // Remove potion this.player.inventory.splice(index, 1); } + } else if (item.type === 'spellbook') { + const spell = item.stats.spell; + if (this.player.spells.includes(spell)) { + this.ui.log(`You already know the ${spell} spell.`); + } else { + this.player.spells.push(spell); + this.ui.log(`You learned the ${spell} spell!`); + this.player.inventory.splice(index, 1); + this.ui.updateSpells(this.player); + } } this.ui.updateInventory(this.player); @@ -604,9 +1027,9 @@ export class Game { this.render(); // Re-render to show equipped status if we add that } - attack(attacker, defender) { - // Shopkeepers are invincible/pacifist - if (defender instanceof Shopkeeper) return; + attack(attacker, defender, presetDamage = null) { + // Shopkeepers and QuestGivers are invincible/pacifist + if (defender instanceof Shopkeeper || defender instanceof QuestGiver || defender instanceof WanderingQuestGiver) return; // Peasants are invincible if (defender.name === "Peasant") { @@ -623,11 +1046,13 @@ export class Game { 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 + let damage = presetDamage; + if (damage === null) { + 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 @@ -638,42 +1063,24 @@ export class Game { 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}!`); - } + this.killMonster(defender); } } } updateMonsters() { for (const monster of this.monsters) { - if (monster instanceof Shopkeeper) continue; // Shopkeepers don't move + if (monster instanceof Shopkeeper || monster instanceof QuestGiver) continue; // Shopkeepers and QuestGivers don't move - if (monster.name === "Peasant") { + // Speed check: Does the monster move this turn? + if (monster.speed !== undefined && Math.random() > monster.speed) continue; + + if (monster instanceof WanderingQuestGiver || monster.name === "Peasant") { // Random walk (Slow: 10% chance to move) if (Math.random() < 0.1) { const dx = Math.floor(Math.random() * 3) - 1; @@ -688,12 +1095,22 @@ export class Game { continue; } - // Simple chase logic + // Simple chase or ranged 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 + // Ranged behavior for Archers + if (monster instanceof Archer && dist > 1.5 && dist <= monster.range) { + const isOrthogonal = this.player.x === monster.x || this.player.y === monster.y; + const isDiagonal = Math.abs(dx) === Math.abs(dy); + if (isOrthogonal || isDiagonal) { + this.monsterShoot(monster); + continue; + } + } + let moveX = 0; let moveY = 0; @@ -724,7 +1141,11 @@ export class Game { 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.drawShop(this.currentShopkeeper, this.player, this.shopSelection, this.shopMode); + } + + if (this.gameState === 'INVENTORY') { + this.ui.drawInventory(this.player, this.inventorySelection); } this.ui.updateStats(this.player, this.depth); diff --git a/src/Item.js b/src/Item.js index 442a2cf..92201f6 100644 --- a/src/Item.js +++ b/src/Item.js @@ -14,5 +14,7 @@ export class Item { if (type === 'shield') this.symbol = ']'; if (type === 'potion') this.symbol = '!'; if (type === 'map') { this.symbol = '?'; this.color = '#ffd700'; } + if (type === 'quest') { this.symbol = '*'; this.color = '#ff00ff'; } + if (type === 'spellbook') { this.symbol = 'B'; this.color = '#00ffff'; } } } diff --git a/src/Map.js b/src/Map.js index d3c2772..d164fae 100644 --- a/src/Map.js +++ b/src/Map.js @@ -4,15 +4,20 @@ export class Map { this.height = height; this.tiles = []; this.rooms = []; + this.permanentlyLit = []; // New grid for spell effects } generate() { + this.isTown = false; this.rooms = []; - // Initialize with walls + this.permanentlyLit = []; + // Initialize with walls and dark for (let y = 0; y < this.height; y++) { this.tiles[y] = []; + this.permanentlyLit[y] = []; for (let x = 0; x < this.width; x++) { this.tiles[y][x] = '#'; + this.permanentlyLit[y][x] = false; } } @@ -61,12 +66,16 @@ export class Map { } generateTown() { + this.isTown = true; this.rooms = []; + this.permanentlyLit = []; // Fill with grass/floor for (let y = 0; y < this.height; y++) { this.tiles[y] = []; + this.permanentlyLit[y] = []; for (let x = 0; x < this.width; x++) { this.tiles[y][x] = '.'; + this.permanentlyLit[y][x] = true; // Town is all lit } } @@ -141,4 +150,20 @@ export class Map { if (x < 0 || x >= this.width || y < 0 || y >= this.height) return false; return this.tiles[y][x] === '.' || this.tiles[y][x] === '>' || this.tiles[y][x] === '<'; } + + isLit(x, y) { + // Town is always fully lit + if (this.isTown) return true; + + // Check permanently lit tiles (from spell) + if (this.permanentlyLit[y] && this.permanentlyLit[y][x]) return true; + + // Check if tile is inside any room + for (const room of this.rooms) { + if (x >= room.x && x < room.x + room.w && y >= room.y && y < room.y + room.h) { + return true; + } + } + return false; + } } diff --git a/src/UI.js b/src/UI.js index c70517e..dacf267 100644 --- a/src/UI.js +++ b/src/UI.js @@ -1,11 +1,19 @@ export class UI { constructor(ctx, game) { + console.log("UI Version 1.1 Loaded"); this.ctx = ctx; this.game = game; this.messageLog = document.getElementById('message-log'); - this.messageLog = document.getElementById('message-log'); - this.inventoryList = document.getElementById('inventory-list'); this.spellsList = document.getElementById('spells-list'); + this.inventoryLink = document.getElementById('inventory-link'); + + if (this.inventoryLink) { + this.inventoryLink.onclick = (e) => { + e.preventDefault(); + this.game.toggleInventory(); + }; + } + this.popup = null; this.popupTimeout = null; } @@ -40,7 +48,17 @@ export class UI { if (mapX >= 0 && mapX < map.width && mapY >= 0 && mapY < map.height) { const tile = map.tiles[mapY][mapX]; - this.drawTile(x * tileSize, y * tileSize, tile, tileSize); + const isLit = map.isLit(mapX, mapY); + const dist = Math.sqrt(Math.pow(player.x - mapX, 2) + Math.pow(player.y - mapY, 2)); + const inRadius = dist <= 3.5; // Radius of 3 (using 3.5 for better circle approximation) + + if (isLit || inRadius) { + this.drawTile(x * tileSize, y * tileSize, tile, tileSize); + } else { + // Draw nothing or a very dark tile for "unseen" areas + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize); + } } } } @@ -48,6 +66,10 @@ export class UI { // Draw Items if (items) { for (const item of items) { + const isLit = map.isLit(item.x, item.y); + const dist = Math.sqrt(Math.pow(player.x - item.x, 2) + Math.pow(player.y - item.y, 2)); + if (!(isLit || dist <= 3.5)) continue; + const screenX = (item.x - startX) * tileSize; const screenY = (item.y - startY) * tileSize; if (screenX >= -tileSize && screenX < this.ctx.canvas.width && @@ -59,6 +81,10 @@ export class UI { // Draw Monsters for (const monster of monsters) { + const isLit = map.isLit(monster.x, monster.y); + const dist = Math.sqrt(Math.pow(player.x - monster.x, 2) + Math.pow(player.y - monster.y, 2)); + if (!(isLit || dist <= 3.5)) continue; + const screenX = (monster.x - startX) * tileSize; const screenY = (monster.y - startY) * tileSize; // Only draw if visible @@ -71,6 +97,10 @@ export class UI { // Draw Projectiles if (projectiles) { for (const proj of projectiles) { + const isLit = map.isLit(proj.x, proj.y); + const dist = Math.sqrt(Math.pow(player.x - proj.x, 2) + Math.pow(player.y - proj.y, 2)); + if (!(isLit || dist <= 3.5)) continue; + const screenX = (proj.x - startX) * tileSize; const screenY = (proj.y - startY) * tileSize; if (screenX >= -tileSize && screenX < this.ctx.canvas.width && @@ -99,25 +129,76 @@ export class UI { document.getElementById('stat-ac').textContent = player.getDefense(); document.getElementById('stat-floor').textContent = depth; document.getElementById('stat-gold').textContent = player.gold; - document.getElementById('stat-gold').textContent = player.gold; document.getElementById('stat-mana').textContent = `${player.mana}/${player.maxMana}`; document.getElementById('stat-int').textContent = player.stats.int; } updateInventory(player) { - this.inventoryList.innerHTML = ''; - player.inventory.forEach((item, index) => { - const li = document.createElement('li'); - li.textContent = item.name; - if (player.equipment.weapon === item || player.equipment.armor === item || player.equipment.shield === item) { - li.textContent += " (E)"; - } - li.style.cursor = 'pointer'; - li.onclick = () => { - this.game.useItem(index); - }; - this.inventoryList.appendChild(li); - }); + // Now handled by canvas-based inventory box + if (this.inventoryLink) { + this.inventoryLink.textContent = `[ View Inventory (${player.inventory.length}) ]`; + } + } + + drawInventory(player, selectedIndex) { + const ctx = this.ctx; + const width = 500; + const height = 400; + const x = (ctx.canvas.width - width) / 2; + const y = (ctx.canvas.height - height) / 2; + + // Background + ctx.fillStyle = '#000000'; + ctx.fillRect(x, y, width, height); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, width, height); + + // Title + ctx.fillStyle = '#ffffff'; + ctx.font = '20px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('Inventory', x + width / 2, y + 30); + + // Items + ctx.font = '16px monospace'; + ctx.textAlign = 'left'; + let itemY = y + 70; + + if (player.inventory.length === 0) { + ctx.fillStyle = '#888888'; + ctx.textAlign = 'center'; + ctx.fillText('Empty', x + width / 2, itemY + 20); + } else { + player.inventory.forEach((item, index) => { + const isSelected = index === selectedIndex; + const isEquipped = player.equipment.weapon === item || player.equipment.armor === item || player.equipment.shield === item; + + if (isSelected) { + ctx.fillStyle = '#333333'; + ctx.fillRect(x + 20, itemY - 5, width - 40, 25); + ctx.fillStyle = '#ffff00'; + ctx.fillText('>', x + 25, itemY + 10); + } else { + ctx.fillStyle = '#aaaaaa'; + } + + let displayName = item.name; + if (isEquipped) displayName += " (E)"; + + ctx.fillText(displayName, x + 50, itemY + 10); + itemY += 30; + }); + } + + // Instructions + ctx.fillStyle = '#888888'; + ctx.font = '12px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('1: Laser | 2: Fireball | 3: Light | 4: Heal | 5: Return | Up/Down: Select | Enter: Use/Equip | Esc/i: Close', x + width / 2, y + height - 20); + + // Reset + ctx.textAlign = 'start'; } updateSpells(player) { @@ -220,10 +301,10 @@ export class UI { this.ctx.fillText(entity.symbol, screenX + size / 4, screenY); } - drawShop(shopkeeper, player, selectedIndex) { + drawShop(shopkeeper, player, selectedIndex, mode = 'BUY') { const ctx = this.ctx; - const width = 400; - const height = 300; + const width = 500; + const height = 400; const x = (ctx.canvas.width - width) / 2; const y = (ctx.canvas.height - height) / 2; @@ -238,44 +319,63 @@ export class UI { ctx.fillStyle = '#ffffff'; ctx.font = '20px monospace'; ctx.textAlign = 'center'; - ctx.fillText(shopkeeper.shopName, x + width / 2, y + 30); + ctx.fillText(`${shopkeeper.shopName} - ${mode}`, x + width / 2, y + 30); // Items ctx.font = '16px monospace'; ctx.textAlign = 'left'; let itemY = y + 70; - shopkeeper.inventory.forEach((entry, index) => { - const item = entry.item; - const price = entry.price; - const isSelected = index === selectedIndex; + const list = mode === 'BUY' ? shopkeeper.inventory : player.inventory; - if (isSelected) { - ctx.fillStyle = '#333333'; - ctx.fillRect(x + 20, itemY - 5, width - 40, 25); - ctx.fillStyle = '#ffff00'; - ctx.fillText('>', x + 25, itemY + 10); - } else { - ctx.fillStyle = '#aaaaaa'; - } + if (list.length === 0) { + ctx.fillStyle = '#888888'; + ctx.textAlign = 'center'; + ctx.fillText(mode === 'BUY' ? 'Sold Out' : 'Nothing to sell', x + width / 2, itemY + 20); + } else { + list.forEach((entry, index) => { + const isSelected = index === selectedIndex; + const item = mode === 'BUY' ? entry.item : entry; + + let price = 0; + if (mode === 'BUY') { + price = entry.price; + } else { + // Calc sell price + let baseValue = 40; + if (item.type === 'weapon') baseValue = 100 * item.level; + if (item.type === 'shield') baseValue = 40 * item.level; + if (item.type === 'potion') baseValue = 20; + price = Math.floor(baseValue / 4); + } - ctx.fillText(`${item.name}`, x + 50, itemY + 10); - ctx.textAlign = 'right'; - ctx.fillText(`${price}g`, x + width - 50, itemY + 10); - ctx.textAlign = 'left'; + if (isSelected) { + ctx.fillStyle = '#333333'; + ctx.fillRect(x + 20, itemY - 5, width - 40, 25); + ctx.fillStyle = '#ffff00'; + ctx.fillText('>', x + 25, itemY + 10); + } else { + ctx.fillStyle = '#aaaaaa'; + } - itemY += 30; - }); + ctx.fillText(`${item.name}`, x + 50, itemY + 10); + ctx.textAlign = 'right'; + ctx.fillText(`${price}g`, x + width - 50, itemY + 10); + ctx.textAlign = 'left'; + + itemY += 30; + }); + } // Player Gold ctx.fillStyle = '#ffd700'; ctx.textAlign = 'center'; - ctx.fillText(`Gold: ${player.gold}`, x + width / 2, y + height - 30); + ctx.fillText(`Gold: ${player.gold}`, x + width / 2, y + height - 50); // Instructions ctx.fillStyle = '#888888'; ctx.font = '12px monospace'; - ctx.fillText('Up/Down: Select | Enter: Buy | Esc: Exit', x + width / 2, y + height - 10); + ctx.fillText('Tab: Toggle Buy/Sell | Up/Down: Select | Enter: Action | Esc: Exit', x + width / 2, y + height - 20); // Reset ctx.textAlign = 'start';