diff --git a/assets/archer.png b/assets/archer.png new file mode 100644 index 0000000..a9f3a64 Binary files /dev/null and b/assets/archer.png differ diff --git a/assets/axe.png b/assets/axe.png new file mode 100644 index 0000000..36be0b5 Binary files /dev/null and b/assets/axe.png differ diff --git a/assets/buckler.png b/assets/buckler.png new file mode 100644 index 0000000..364f8d8 Binary files /dev/null and b/assets/buckler.png differ diff --git a/assets/cobra.png b/assets/cobra.png new file mode 100644 index 0000000..ee7f45f Binary files /dev/null and b/assets/cobra.png differ diff --git a/assets/dagger.png b/assets/dagger.png new file mode 100644 index 0000000..300f2c6 Binary files /dev/null and b/assets/dagger.png differ diff --git a/assets/dragon.png b/assets/dragon.png new file mode 100644 index 0000000..8435b39 Binary files /dev/null and b/assets/dragon.png differ diff --git a/assets/dungeon lord.png b/assets/dungeon lord.png new file mode 100644 index 0000000..04f7cd0 Binary files /dev/null and b/assets/dungeon lord.png differ diff --git a/assets/fountain.png b/assets/fountain.png new file mode 100644 index 0000000..d53dd62 Binary files /dev/null and b/assets/fountain.png differ diff --git a/assets/hero.png b/assets/hero.png new file mode 100644 index 0000000..272e730 Binary files /dev/null and b/assets/hero.png differ diff --git a/assets/house questgiver.png b/assets/house questgiver.png new file mode 100644 index 0000000..55a9bcf Binary files /dev/null and b/assets/house questgiver.png differ diff --git a/assets/kite shield.png b/assets/kite shield.png new file mode 100644 index 0000000..005f929 Binary files /dev/null and b/assets/kite shield.png differ diff --git a/assets/kobold.png b/assets/kobold.png new file mode 100644 index 0000000..54fe177 Binary files /dev/null and b/assets/kobold.png differ diff --git a/assets/mysterious figure.png b/assets/mysterious figure.png new file mode 100644 index 0000000..1db619a Binary files /dev/null and b/assets/mysterious figure.png differ diff --git a/assets/ogre.png b/assets/ogre.png new file mode 100644 index 0000000..ccdb0ab Binary files /dev/null and b/assets/ogre.png differ diff --git a/assets/orc.png b/assets/orc.png new file mode 100644 index 0000000..19b35be Binary files /dev/null and b/assets/orc.png differ diff --git a/assets/peasant1.png b/assets/peasant1.png new file mode 100644 index 0000000..959b8be Binary files /dev/null and b/assets/peasant1.png differ diff --git a/assets/peasant2.png b/assets/peasant2.png new file mode 100644 index 0000000..838d913 Binary files /dev/null and b/assets/peasant2.png differ diff --git a/assets/questgiver outside.png b/assets/questgiver outside.png new file mode 100644 index 0000000..389f406 Binary files /dev/null and b/assets/questgiver outside.png differ diff --git a/assets/rat.png b/assets/rat.png new file mode 100644 index 0000000..617bdac Binary files /dev/null and b/assets/rat.png differ diff --git a/assets/skeleton archer.png b/assets/skeleton archer.png new file mode 100644 index 0000000..e2047ce Binary files /dev/null and b/assets/skeleton archer.png differ diff --git a/assets/spellbook.png b/assets/spellbook.png new file mode 100644 index 0000000..3e1a9c8 Binary files /dev/null and b/assets/spellbook.png differ diff --git a/assets/sword.png b/assets/sword.png new file mode 100644 index 0000000..efcf833 Binary files /dev/null and b/assets/sword.png differ diff --git a/assets/wooden shield.png b/assets/wooden shield.png new file mode 100644 index 0000000..a1ca9e4 Binary files /dev/null and b/assets/wooden shield.png differ diff --git a/assets/zappy laser.png b/assets/zappy laser.png new file mode 100644 index 0000000..df39dd4 Binary files /dev/null and b/assets/zappy laser.png differ diff --git a/index.html b/index.html index 665f06b..6134257 100644 --- a/index.html +++ b/index.html @@ -20,8 +20,8 @@
-
- +
+
diff --git a/src/Entity.js b/src/Entity.js index e034f5b..f65be10 100644 --- a/src/Entity.js +++ b/src/Entity.js @@ -32,6 +32,7 @@ export class Player extends Entity { this.xp = 0; this.maxXp = 10; this.gold = 0; + this.poisoned = false; } gainXp(amount) { @@ -55,7 +56,7 @@ export class Player extends Entity { let dmg = Math.floor(this.stats.str / 5); // Base damage if (dmg < 1) dmg = 1; if (this.equipment.weapon) { - dmg += this.equipment.weapon.stats.damage || 0; + dmg += (this.equipment.weapon.stats.damage || 0) + (this.equipment.weapon.modifier || 0); } return dmg; } @@ -63,7 +64,7 @@ export class Player extends Entity { getDefense() { let def = 0; if (this.equipment.shield) { - def += this.equipment.shield.stats.defense || 0; + def += (this.equipment.shield.stats.defense || 0) + (this.equipment.shield.modifier || 0); } // Could add armor here later return def; @@ -91,6 +92,13 @@ export class Archer extends Monster { } } +export class Cobra extends Monster { + constructor(x, y, name, symbol, color, level) { + super(x, y, name, symbol, color, level); + this.poisonChance = 0.3; // 30% chance to poison on hit + } +} + export class Shopkeeper extends Entity { constructor(x, y, name, shopName, inventory) { super(x, y, name, '$', '#ffd700'); @@ -112,3 +120,53 @@ export class WanderingQuestGiver extends Entity { this.quest = null; // { target: string, required: number, current: number, completed: false } } } + +export class Farmer extends Entity { + constructor(x, y, name) { + super(x, y, name, 'f', '#ffa500'); // Orange 'f' + this.quotes = [ + "You're growing like a weed!", + "Potatoes! Boil 'em, mash 'em, stick 'em in a stew!", + "The Dungeon Lord steals our crops to feed his monsters!", + "That Dungeon Lord is nothing but trouble!", + "You look undernourished, have a carrot!" + ]; + } + + getRandomQuote() { + return this.quotes[Math.floor(Math.random() * this.quotes.length)]; + } +} + +export class Priest extends Entity { + constructor(x, y, name) { + super(x, y, name, 'P', '#ffffff'); // White 'P' + this.quotes = [ + "The sun's light blesses our crops.", + "I remember when you were just a baby, now you're a hero!", + "Come back if you get injured.", + "Blessings upon you.", + "Don't forget to say your prayers!" + ]; + } + + getRandomQuote() { + return this.quotes[Math.floor(Math.random() * this.quotes.length)]; + } +} + +export class Gravekeeper extends Entity { + constructor(x, y, name) { + super(x, y, name, 'G', '#808080'); // Gray 'G' + this.quotes = [ + "Guarding this place is a grave responsibility.", + "Hope you don't end up here!", + "Got this shovel from a lass named Rosella.", + "I won't let the Dungeon Lord's monsters disturb the dead!" + ]; + } + + getRandomQuote() { + return this.quotes[Math.floor(Math.random() * this.quotes.length)]; + } +} diff --git a/src/Game.js b/src/Game.js index 0680a5b..ac59b42 100644 --- a/src/Game.js +++ b/src/Game.js @@ -2,14 +2,24 @@ import { Map } from './Map.js'; import { UI } from './UI.js'; import { Player } from './Entity.js'; -import { Monster, Shopkeeper, QuestGiver, WanderingQuestGiver, Archer } from './Entity.js'; +import { Monster, Shopkeeper, QuestGiver, WanderingQuestGiver, Archer, Cobra, Farmer, Priest, Gravekeeper } from './Entity.js'; import { Item } from './Item.js'; export class Game { constructor() { - console.log("Game Version 1.3 Loaded - Persistent Levels"); + console.log("Game Version 1.4 Loaded - Asset Support"); this.canvas = document.getElementById('game-canvas'); this.ctx = this.canvas.getContext('2d'); + + // Handle resizing + this.resizeCanvas(); + window.addEventListener('resize', () => { + this.resizeCanvas(); + this.render(); + }); + + this.assets = {}; + this.loadAssets(); this.ui = new UI(this.ctx, this); // Pass game instance to UI for callbacks this.player = new Player(10, 10); this.monsters = []; @@ -21,8 +31,33 @@ export class Game { this.dungeonLevels = {}; // Persistent storage for each depth this.tileSize = 32; + this.mapWidth = 80; + this.mapHeight = 80; - this.gameState = 'PLAY'; // 'PLAY', 'SHOP', 'TARGETING', 'INVENTORY' + this.gameState = 'INTRO'; // 'INTRO', 'PLAY', 'SHOP', 'TARGETING', 'INVENTORY', 'OVERVIEW' + this.storyText = [ + "Once upon a time the people of Oakhaven found a baby left", + "on the temple steps--you! The villagers raised you as", + "their own, always wondering what mysterious traveler had", + "blessed them with a child. You grew up strong and brave,", + "working hard in the fields and protecting the town from", + "wolf packs and bandits. After one particularly tough", + "fight, your friends even started calling you Hero!", + "", + "Recently a solar eclipse darkened the sky. As the", + "villagers stood watching this cosmic event, a forgotten", + "dry well in the corner of town burst open and monsters", + "swarmed out! They declared that Oakhaven was now ruled", + "by the Dungeon Lord, who would be taking whatever he", + "wanted from the fields and the shops! Under this", + "oppression food is becoming scarce and the situation", + "will soon be desperate. Of all villagers, you have the", + "best chance to defeat the Dungeon Lord and free your home!", + "", + "Take up arms, and become the Hero you were destined to be!", + "", + "[ Click anywhere to start your adventure ]" + ]; this.currentShopkeeper = null; this.shopSelection = 0; this.shopMode = 'BUY'; // 'BUY' or 'SELL' @@ -45,6 +80,90 @@ export class Game { // Bind input window.addEventListener('keydown', (e) => this.handleInput(e)); + this.canvas.addEventListener('mousedown', (e) => this.handleCanvasClick(e)); + + // Start real-time timers + this.startPoisonTimer(); + } + + loadAssets() { + const assetNames = [ + 'archer', 'axe', 'buckler', 'cobra', 'dagger', 'dragon', + 'dungeon lord', 'fountain', 'hero', 'house questgiver', + 'kite shield', 'kobold', 'mysterious figure', 'ogre', + 'orc', 'peasant1', 'peasant2', 'questgiver outside', + 'rat', 'skeleton archer', 'spellbook', 'sword', + 'wooden shield', 'zappy laser' + ]; + + assetNames.forEach(name => { + const img = new Image(); + img.src = `assets/${name}.png`; + img.onload = () => { + this.assets[name] = img; + this.render(); // Re-render when an asset loads + }; + }); + } + + resizeCanvas() { + const container = document.getElementById('viewport-container'); + if (container) { + this.canvas.width = container.clientWidth; + this.canvas.height = container.clientHeight; + } + } + + startPoisonTimer() { + setInterval(() => { + if (this.player && this.player.poisoned && !this.gameOver) { + const damage = Math.max(1, Math.floor(this.player.maxHp * 0.05)); // 5% of max HP + this.player.hp -= damage; + this.ui.log(`The poison burns... You lose ${damage} HP.`); + this.ui.updateStats(this.player, this.depth); + + if (this.player.hp <= 0) { + this.ui.log("You have succumbed to the poison!"); + this.ui.log("GAME OVER"); + this.gameOver = true; + setTimeout(() => alert("Game Over! Refresh to restart."), 100); + } + this.render(); + } + }, 30000); // Every 30 seconds + } + + updateExplored() { + if (!this.map || !this.map.explored) return; + + const radius = 3.5; + const startX = Math.max(0, Math.floor(this.player.x - radius)); + const endX = Math.min(this.map.width - 1, Math.ceil(this.player.x + radius)); + const startY = Math.max(0, Math.floor(this.player.y - radius)); + const endY = Math.min(this.map.height - 1, Math.ceil(this.player.y + radius)); + + for (let y = startY; y <= endY; y++) { + for (let x = startX; x <= endX; x++) { + const dist = Math.sqrt(Math.pow(this.player.x - x, 2) + Math.pow(this.player.y - y, 2)); + if (dist <= radius) { + // Check LOS for exploration too, so we don't explore through walls + if (this.map.hasLineOfSight(this.player.x, this.player.y, x, y)) { + this.map.explored[y][x] = true; + } + } + } + } + + // Also explore anything permanently lit + if (this.map.permanentlyLit) { + for (let y = 0; y < this.map.height; y++) { + for (let x = 0; x < this.map.width; x++) { + if (this.map.permanentlyLit[y][x]) { + this.map.explored[y][x] = true; + } + } + } + } } start() { @@ -65,8 +184,8 @@ export class Game { // Reposition player if (this.depth === 0) { if (direction === 'up') { - this.player.x = 43; - this.player.y = 8; + this.player.x = 63; + this.player.y = 13; } else { this.player.x = Math.floor(this.map.width / 2); this.player.y = Math.floor(this.map.height / 2); @@ -87,7 +206,7 @@ export class Game { } else { // Generate NEW level this.items = []; - this.map = new Map(50, 50); + this.map = new Map(this.mapWidth, this.mapHeight); if (this.depth === 0) { this.map.generateTown(); @@ -119,6 +238,7 @@ export class Game { } this.spawnMonsters(); // Always respawn monsters + this.updateExplored(); this.render(); this.ui.updateStats(this.player, this.depth); @@ -169,8 +289,8 @@ export class Game { spawnStairs() { if (this.depth === 0) { // Town: Stairs Down in the Dungeon Entrance building - const sx = 43; // Center X - const sy = 8; // Center Y + const sx = 63; // Center X + const sy = 13; // Center Y this.map.tiles[sy][sx] = '>'; return; } @@ -214,24 +334,29 @@ export class Game { { 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); + weaponShopInventory.forEach(entry => entry.item.identified = true); + const weaponSmith = new Shopkeeper(59, 33, "Smith", "Weapon Shop", weaponShopInventory); this.monsters.push(weaponSmith); // General Store (West) const generalStoreInventory = [ { item: new Item("Potion", "potion", 1, { heal: 5 }), price: 20 }, + { item: new Item("Antidote", "antidote", 1, {}), price: 30 }, { 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 } + { item: new Item("Spellbook: Return", "spellbook", 1, { spell: "Return" }), price: 500 }, + { item: new Item("Spellbook: Detect Traps", "spellbook", 1, { spell: "Detect Traps" }), price: 150 }, + { item: new Item("Spellbook: Cure Poison", "spellbook", 1, { spell: "Cure Poison" }), price: 250 } ]; - const merchant = new Shopkeeper(9, 18, "Merchant", "General Store", generalStoreInventory); + generalStoreInventory.forEach(entry => entry.item.identified = true); + const merchant = new Shopkeeper(19, 33, "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!"); + const q1 = new QuestGiver(23, 57, "Villager", "Please bring back my Golden Locket! It was stolen by an orc!"); + const q2 = new QuestGiver(40, 57, "Old Man", "Please bring back my Silver Chalice! It was stolen by an orc!"); + const q3 = new QuestGiver(57, 57, "Woman", "Please bring back my Ruby Ring! It was stolen by an orc!"); this.monsters.push(q1, q2, q3); // Spawn Wandering Quest Givers @@ -247,6 +372,16 @@ export class Game { this.monsters.push(wqg); } + // Spawn Farmers near fields + this.monsters.push(new Farmer(21, 19, "Farmer Giles")); + this.monsters.push(new Farmer(54, 48, "Farmer Maggot")); + + // Spawn Priest in Temple + this.monsters.push(new Priest(40, 11, "Father Sun")); + + // Spawn Gravekeeper in Graveyard + this.monsters.push(new Gravekeeper(11, 9, "Mort")); + return; } @@ -273,7 +408,8 @@ export class Game { 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.4) monster = new Monster(mx, my, "Orc", "o", "#008000", 3); + else if (type < 0.6) monster = new Cobra(mx, my, "Cobra", "c", "#ffff00", 4); 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 { @@ -287,6 +423,7 @@ export class Game { // Set Monster Speeds if (monster.name === "Rat") monster.speed = 1.0; else if (monster.name === "Kobold") monster.speed = 1.0; + else if (monster.name === "Cobra") monster.speed = 0.9; 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; @@ -330,6 +467,7 @@ export class Game { if (Math.random() < 0.2) tier++; // Chance for higher tier if (type < 0.3) item = new Item("Potion", "potion", 1, { heal: 5 + this.depth * 2 }); + else if (type < 0.35) item = new Item("Antidote", "antidote", 1, {}); 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 }); @@ -348,14 +486,18 @@ export class Game { } else if (type < 0.8) { // Spellbooks const spellRoll = Math.random(); - if (spellRoll < 0.25) { + if (spellRoll < 0.16) { item = new Item("Spellbook: Fireball", "spellbook", 1, { spell: "Fireball" }); - } else if (spellRoll < 0.5) { + } else if (spellRoll < 0.32) { item = new Item("Spellbook: Light", "spellbook", 1, { spell: "Light" }); - } else if (spellRoll < 0.75) { + } else if (spellRoll < 0.48) { item = new Item("Spellbook: Heal", "spellbook", 1, { spell: "Heal" }); - } else { + } else if (spellRoll < 0.64) { item = new Item("Spellbook: Return", "spellbook", 1, { spell: "Return" }); + } else if (spellRoll < 0.8) { + item = new Item("Spellbook: Detect Traps", "spellbook", 1, { spell: "Detect Traps" }); + } else { + item = new Item("Spellbook: Cure Poison", "spellbook", 1, { spell: "Cure Poison" }); } } else { item = new Item("Potion", "potion", 1, { heal: 5 + this.depth * 2 }); @@ -363,6 +505,20 @@ export class Game { item.x = ix; item.y = iy; + + // Roll for Enchantment / Curse (20% enchanted, 20% cursed) + if (item.type === 'weapon' || item.type === 'shield' || item.type === 'armor') { + const roll = Math.random(); + if (roll < 0.2) { + // Enchanted + item.modifier = Math.floor(Math.random() * 3) + 1; + } else if (roll < 0.4) { + // Cursed + item.modifier = -(Math.floor(Math.random() * 2) + 1); + item.isCursed = true; + } + } + this.items.push(item); } } @@ -371,6 +527,12 @@ export class Game { handleInput(e) { if (this.gameOver) return; + if (this.gameState === 'INTRO') { + this.gameState = 'PLAY'; + this.render(); + return; + } + if (this.gameState === 'SHOP') { this.handleShopInput(e); return; @@ -381,6 +543,14 @@ export class Game { return; } + if (this.gameState === 'OVERVIEW') { + if (e.key === 'm' || e.key === 'M' || e.key === 'Escape') { + this.gameState = 'PLAY'; + this.render(); + } + return; + } + if (this.gameState === 'TARGETING') { this.handleTargetingInput(e); return; @@ -405,6 +575,11 @@ export class Game { 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 'm': case 'M': + this.gameState = 'OVERVIEW'; + this.render(); + handled = true; + break; case 'Enter': case '>': if (this.map.tiles[this.player.y][this.player.x] === '>') { @@ -451,6 +626,16 @@ export class Game { else this.ui.log("You don't know that spell!"); handled = true; break; + case '6': + if (this.player.spells.includes('Detect Traps')) this.castDetectTrapsSpell(); + else this.ui.log("You don't know that spell!"); + handled = true; + break; + case '7': + if (this.player.spells.includes('Cure Poison')) this.castCurePoisonSpell(); + else this.ui.log("You don't know that spell!"); + handled = true; + break; } if (handled) { @@ -465,18 +650,18 @@ export class Game { handleShopInput(e) { e.preventDefault(); - const inventory = this.shopMode === 'BUY' ? this.currentShopkeeper.inventory : this.player.inventory; + const list = this.shopMode === 'BUY' ? this.currentShopkeeper.inventory : this.getGroupedInventory(); switch (e.key) { case 'ArrowUp': case 'NumPad8': this.shopSelection--; - if (this.shopSelection < 0) this.shopSelection = inventory.length - 1; + if (this.shopSelection < 0) this.shopSelection = list.length - 1; break; case 'ArrowDown': case 'NumPad2': this.shopSelection++; - if (this.shopSelection >= inventory.length) this.shopSelection = 0; + if (this.shopSelection >= list.length) this.shopSelection = 0; break; case 'Tab': this.shopMode = this.shopMode === 'BUY' ? 'SELL' : 'BUY'; @@ -485,9 +670,12 @@ export class Game { case 'Enter': case ' ': if (this.shopMode === 'BUY') { - this.buyItem(inventory[this.shopSelection]); + this.buyItem(list[this.shopSelection]); } else { - this.sellItem(this.shopSelection); + if (list[this.shopSelection]) { + const actualIndex = list[this.shopSelection].indices[0]; + this.sellItem(actualIndex); + } } break; case 'Escape': @@ -522,25 +710,141 @@ export class Game { this.render(); } + getGroupedInventory() { + const groups = []; + this.player.inventory.forEach((item, index) => { + const group = groups.find(g => g.name === item.name && g.type === item.type && JSON.stringify(g.stats) === JSON.stringify(item.stats)); + if (group) { + group.count++; + group.indices.push(index); + } else { + groups.push({ + name: item.name, + type: item.type, + stats: item.stats, + item: item, // Representative item + count: 1, + indices: [index] + }); + } + }); + return groups; + } + + handleCanvasClick(e) { + if (this.gameOver) return; + + if (this.gameState === 'INTRO') { + this.gameState = 'PLAY'; + this.render(); + return; + } + + // Get relative mouse coordinates on canvas + const rect = this.canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + if (this.gameState === 'TARGETING') { + // Calculate which tile was clicked + const viewWidth = Math.ceil(this.canvas.width / this.tileSize); + const viewHeight = Math.ceil(this.canvas.height / this.tileSize); + const startX = this.player.x - Math.floor(viewWidth / 2); + const startY = this.player.y - Math.floor(viewHeight / 2); + + const clickedMapX = Math.floor(mouseX / this.tileSize) + startX; + const clickedMapY = Math.floor(mouseY / this.tileSize) + startY; + + // Calculate direction from player to clicked tile + const dx = Math.sign(clickedMapX - this.player.x); + const dy = Math.sign(clickedMapY - this.player.y); + + if (dx !== 0 || dy !== 0) { + this.castSpell(this.targetingSpell, dx, dy); + this.gameState = 'PLAY'; + this.targetingSpell = null; + this.render(); + } + } else if (this.gameState === 'PLAY') { + // Click player to toggle inventory + const viewWidth = Math.ceil(this.canvas.width / this.tileSize); + const viewHeight = Math.ceil(this.canvas.height / this.tileSize); + const startX = this.player.x - Math.floor(viewWidth / 2); + const startY = this.player.y - Math.floor(viewHeight / 2); + + const clickedMapX = Math.floor(mouseX / this.tileSize) + startX; + const clickedMapY = Math.floor(mouseY / this.tileSize) + startY; + + if (clickedMapX === this.player.x && clickedMapY === this.player.y) { + this.toggleInventory(); + } + + // Click signs + if (this.map.signs) { + const sign = this.map.signs.find(s => s.x === clickedMapX && s.y === clickedMapY); + if (sign) { + this.ui.showPopup(sign.text, 1000); + } + } + } else if (this.gameState === 'INVENTORY' || this.gameState === 'SHOP') { + // Check if clicking inside the box items + const width = 500; + const height = this.gameState === 'INVENTORY' ? 400 : 400; // Match UI.js dimensions + const bx = (this.canvas.width - width) / 2; + const by = (this.canvas.height - height) / 2; + + if (mouseX >= bx + 20 && mouseX <= bx + width - 20) { + const isSelling = this.gameState === 'SHOP' && this.shopMode === 'SELL'; + const isInventory = this.gameState === 'INVENTORY'; + + let list; + if (isInventory || isSelling) { + list = this.getGroupedInventory(); + } else { + list = this.currentShopkeeper.inventory; + } + + const startY = by + 70; + const clickedIndex = Math.floor((mouseY - startY + 5) / 30); + + if (clickedIndex >= 0 && clickedIndex < list.length) { + if (isInventory) { + const actualIndex = list[clickedIndex].indices[0]; + this.useItem(actualIndex); + } else { + if (this.shopMode === 'BUY') { + this.buyItem(list[clickedIndex]); + } else { + const actualIndex = list[clickedIndex].indices[0]; + this.sellItem(actualIndex); + } + } + this.render(); + } + } + } + } + handleInventoryInput(e) { e.preventDefault(); - const inventory = this.player.inventory; + const groupedItems = this.getGroupedInventory(); switch (e.key) { case 'ArrowUp': case 'NumPad8': this.inventorySelection--; - if (this.inventorySelection < 0) this.inventorySelection = inventory.length - 1; + if (this.inventorySelection < 0) this.inventorySelection = groupedItems.length - 1; break; case 'ArrowDown': case 'NumPad2': this.inventorySelection++; - if (this.inventorySelection >= inventory.length) this.inventorySelection = 0; + if (this.inventorySelection >= groupedItems.length) this.inventorySelection = 0; break; case 'Enter': case ' ': - if (inventory[this.inventorySelection]) { - this.useItem(this.inventorySelection); + if (groupedItems[this.inventorySelection]) { + const actualIndex = groupedItems[this.inventorySelection].indices[0]; + this.useItem(actualIndex); } break; case 'Escape': @@ -561,9 +865,10 @@ export class Game { // Copy visual props newItem.symbol = entry.item.symbol; newItem.color = entry.item.color; + newItem.identified = true; // Shop items are identified this.player.inventory.push(newItem); - this.ui.log(`You bought ${entry.item.name} for ${entry.price} gold.`); + this.ui.log(`You bought ${newItem.getDisplayName()} for ${entry.price} gold.`); this.ui.updateInventory(this.player); this.ui.updateStats(this.player, this.depth); } else { @@ -581,8 +886,15 @@ export class Game { return; } + // Cannot sell cursed items while equipped + if (item.isCursed && item.identified) { + if (this.player.equipment.weapon === item || this.player.equipment.shield === item || this.player.equipment.armor === item) { + this.ui.log("You cannot sell a cursed item you are currently wearing!"); + 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; @@ -598,7 +910,7 @@ export class Game { this.player.gold += sellPrice; this.player.inventory.splice(index, 1); - this.ui.log(`You sold ${item.name} for ${sellPrice} gold.`); + this.ui.log(`You sold ${item.getDisplayName()} for ${sellPrice} gold.`); // Reset selection if last item was sold if (this.shopSelection >= this.player.inventory.length) { @@ -655,6 +967,49 @@ export class Game { this.ui.showPopup(msg, 2000); this.ui.log(`${targetMonster.name} says: "${msg}"`); } + } else if (targetMonster instanceof Farmer) { + const quote = targetMonster.getRandomQuote(); + this.ui.showPopup(quote, 2000); + this.ui.log(`${targetMonster.name} says: "${quote}"`); + } else if (targetMonster instanceof Priest) { + // Father Sun's Identify & Uncurse services + const unidentifiedItems = this.player.inventory.filter(i => !i.identified); + const cursedEquipped = Object.values(this.player.equipment).filter(i => i && i.isCursed && i.identified); + + if (unidentifiedItems.length > 0) { + if (this.player.gold >= 50) { + this.player.gold -= 50; + unidentifiedItems.forEach(i => i.identified = true); + this.ui.showPopup("Father Sun identifies your mysterious items!", 2000); + this.ui.log(`Father Sun says: "The sun reveals the truth of your gear." (50g paid)`); + this.ui.updateStats(this.player, this.depth); + this.ui.updateInventory(this.player); + } else { + this.ui.log(`Father Sun says: "I would identify your gear, but you lack the 50 gold donation."`); + } + } else if (cursedEquipped.length > 0) { + if (this.player.gold >= 100) { + this.player.gold -= 100; + cursedEquipped.forEach(i => { + i.isCursed = false; + this.ui.log(`The curse is lifted from your ${i.name}!`); + }); + this.ui.showPopup("The curses are lifted!", 2000); + this.ui.log(`Father Sun says: "May you be free from these dark shackles." (100g paid)`); + this.ui.updateStats(this.player, this.depth); + this.ui.updateInventory(this.player); + } else { + this.ui.log(`Father Sun says: "I would lift your curses, but you lack the 100 gold donation."`); + } + } else { + const quote = targetMonster.getRandomQuote(); + this.ui.showPopup(quote, 2000); + this.ui.log(`${targetMonster.name} says: "${quote}"`); + } + } else if (targetMonster instanceof Gravekeeper) { + const quote = targetMonster.getRandomQuote(); + this.ui.showPopup(quote, 2000); + this.ui.log(`${targetMonster.name} says: "${quote}"`); } else { this.attack(this.player, targetMonster); } @@ -662,21 +1017,56 @@ export class Game { this.player.x = newX; this.player.y = newY; + // Check for trap + const trap = this.map.traps.find(t => t.x === newX && t.y === newY); + if (trap) { + trap.revealed = true; + const damage = Math.floor(this.depth * 1.5) + Math.floor(Math.random() * 3) + 2; + this.player.hp -= damage; + this.ui.log(`*SNAP* You triggered a trap! You take ${damage} damage.`); + this.ui.showPopup("TRAP!", 1000); + this.ui.updateStats(this.player, this.depth); + + if (this.player.hp <= 0) { + this.ui.log("The trap was lethal!"); + this.ui.log("GAME OVER"); + this.gameOver = true; + setTimeout(() => alert("Game Over! Refresh to restart."), 100); + } + } + + // Check for fountain + const fountain = this.map.fountains.find(f => f.x === newX && f.y === newY); + if (fountain && !fountain.used) { + if (this.player.hp < this.player.maxHp || this.player.poisoned) { + this.player.hp = this.player.maxHp; + this.player.poisoned = false; + fountain.used = true; + this.ui.log("You drink from the glowing fountain. You feel completely restored!"); + this.ui.showPopup("RESTORED!", 1000); + this.ui.updateStats(this.player, this.depth); + } else { + this.ui.log("You drink from the fountain. It's refreshing."); + } + } + // 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!"); + // Quietly blocked } + this.updateExplored(); + // Temple Healing (Town only) - if (this.depth === 0 && this.player.x === 25 && this.player.y === 12) { - if (this.player.hp < this.player.maxHp) { + if (this.depth === 0 && this.player.x === 40 && this.player.y === 14) { + if (this.player.hp < this.player.maxHp || this.player.poisoned) { this.player.hp = this.player.maxHp; - this.ui.log("You enter the temple and feel refreshed. HP fully restored!"); + this.player.poisoned = false; + this.ui.log("You enter the temple and feel refreshed. HP fully restored and poison cured!"); this.ui.updateStats(this.player, this.depth); } else { this.ui.log("You enter the temple. It is peaceful here."); @@ -790,6 +1180,7 @@ export class Game { } } } + this.updateExplored(); this.render(); } @@ -841,6 +1232,55 @@ export class Game { this.render(); } + castDetectTrapsSpell() { + const manaCost = 2; + 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 Detect Traps!"); + + let foundSomething = false; + this.map.traps.forEach(trap => { + if (!trap.revealed) { + // 50% chance to detect each unrevealed trap on the current level + if (Math.random() < 0.5) { + trap.revealed = true; + foundSomething = true; + } + } + }); + + if (foundSomething) { + this.ui.log("You sense danger nearby..."); + } else { + this.ui.log("You sense nothing unusual."); + } + this.render(); + } + + castCurePoisonSpell() { + const manaCost = 3; + if (this.player.mana < manaCost) { + this.ui.log("Not enough mana!"); + return; + } + + if (!this.player.poisoned) { + this.ui.log("You are not poisoned."); + return; + } + + this.player.mana -= manaCost; + this.player.poisoned = false; + this.ui.updateStats(this.player, this.depth); + this.ui.log("You cast Cure Poison! The toxins leave your body."); + this.render(); + } + animateProjectile(projectile) { const interval = setInterval(() => { const newX = projectile.x + projectile.dx; @@ -930,6 +1370,7 @@ export class Game { itemData.dropped = true; const questItem = new Item(itemData.name, "quest", 1, {}); + questItem.identified = true; questItem.x = monster.x; questItem.y = monster.y; this.items.push(questItem); @@ -943,6 +1384,7 @@ export class Game { 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.identified = true; map.x = monster.x; map.y = monster.y; this.items.push(map); @@ -998,18 +1440,55 @@ export class Game { if (!item) return; if (item.type === 'weapon') { - this.player.equipment.weapon = item; - this.ui.log(`You equipped ${item.name}.`); + if (this.player.equipment.weapon === item) { + if (item.isCursed && item.identified) { + this.ui.log(`You cannot unequip the cursed ${item.name}!`); + return; + } + this.player.equipment.weapon = null; + this.ui.log(`You unequipped ${item.getDisplayName()}.`); + } else { + this.player.equipment.weapon = item; + this.ui.log(`You equipped ${item.getDisplayName()}.`); + if (!item.identified) { + item.identified = true; + if (item.modifier !== 0 || item.isCursed) { + this.ui.log(`It's a ${item.getDisplayName()}!`); + } + } + } } else if (item.type === 'shield') { - this.player.equipment.shield = item; - this.ui.log(`You equipped ${item.name}.`); + if (this.player.equipment.shield === item) { + if (item.isCursed && item.identified) { + this.ui.log(`You cannot unequip the cursed ${item.name}!`); + return; + } + this.player.equipment.shield = null; + this.ui.log(`You unequipped ${item.getDisplayName()}.`); + } else { + this.player.equipment.shield = item; + this.ui.log(`You equipped ${item.getDisplayName()}.`); + if (!item.identified) { + item.identified = true; + if (item.modifier !== 0 || item.isCursed) { + this.ui.log(`It's a ${item.getDisplayName()}!`); + } + } + } } 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); } + } else if (item.type === 'antidote') { + if (this.player.poisoned) { + this.player.poisoned = false; + this.ui.log("You drink the Antidote. The poison is cured!"); + this.player.inventory.splice(index, 1); + } else { + this.ui.log("You aren't poisoned."); + } } else if (item.type === 'spellbook') { const spell = item.stats.spell; if (this.player.spells.includes(spell)) { @@ -1029,7 +1508,7 @@ export class Game { attack(attacker, defender, presetDamage = null) { // Shopkeepers and QuestGivers are invincible/pacifist - if (defender instanceof Shopkeeper || defender instanceof QuestGiver || defender instanceof WanderingQuestGiver) return; + if (defender instanceof Shopkeeper || defender instanceof QuestGiver || defender instanceof WanderingQuestGiver || defender instanceof Farmer || defender instanceof Priest || defender instanceof Gravekeeper) return; // Peasants are invincible if (defender.name === "Peasant") { @@ -1062,6 +1541,15 @@ export class Game { defender.hp -= damage; this.ui.log(`${attacker.name} hits ${defender.name} for ${damage} damage.`); + // Poison logic + if (attacker instanceof Cobra && defender === this.player && !this.player.poisoned) { + if (Math.random() < attacker.poisonChance) { + this.player.poisoned = true; + this.ui.log("You have been poisoned by the Cobra!"); + this.ui.showPopup("POISONED!", 2000); + } + } + if (defender.hp <= 0) { if (defender === this.player) { this.ui.log("GAME OVER"); @@ -1075,7 +1563,7 @@ export class Game { updateMonsters() { for (const monster of this.monsters) { - if (monster instanceof Shopkeeper || monster instanceof QuestGiver) continue; // Shopkeepers and QuestGivers don't move + if (monster instanceof Shopkeeper || monster instanceof QuestGiver || monster instanceof Farmer || monster instanceof Priest || monster instanceof Gravekeeper) continue; // Stationary NPCs don't move // Speed check: Does the monster move this turn? if (monster.speed !== undefined && Math.random() > monster.speed) continue; @@ -1100,12 +1588,12 @@ export class Game { const dy = this.player.y - monster.y; const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < 8) { // Aggro range + if (dist < 8 && this.map.hasLineOfSight(monster.x, monster.y, this.player.x, this.player.y)) { // Aggro range + LOS // 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) { + if ((isOrthogonal || isDiagonal) && this.map.hasLineOfSight(monster.x, monster.y, this.player.x, this.player.y)) { this.monsterShoot(monster); continue; } @@ -1148,6 +1636,14 @@ export class Game { this.ui.drawInventory(this.player, this.inventorySelection); } + if (this.gameState === 'OVERVIEW') { + this.ui.drawOverviewMap(this.map, this.player); + } + + if (this.gameState === 'INTRO') { + this.ui.drawIntro(this.storyText); + } + this.ui.updateStats(this.player, this.depth); } } diff --git a/src/Item.js b/src/Item.js index 92201f6..507c44c 100644 --- a/src/Item.js +++ b/src/Item.js @@ -1,7 +1,7 @@ export class Item { constructor(name, type, level, stats) { this.name = name; - this.type = type; // 'weapon', 'armor', 'potion' + this.type = type; // 'weapon', 'armor', 'shield', 'potion', 'spellbook', 'antidote', 'map', 'quest' this.level = level; this.stats = stats || {}; // { damage: 5 } or { defense: 2 } this.x = 0; @@ -9,12 +9,38 @@ export class Item { this.symbol = '?'; this.color = '#ffff00'; + // Enchantment / Curse properties + this.modifier = 0; + this.isCursed = false; + this.identified = false; + + // Auto-identify non-equippables + if (['potion', 'antidote', 'spellbook', 'map', 'quest'].includes(type)) { + this.identified = true; + } + if (type === 'weapon') this.symbol = ')'; if (type === 'armor') this.symbol = '['; if (type === 'shield') this.symbol = ']'; if (type === 'potion') this.symbol = '!'; + if (type === 'antidote') { this.symbol = '!'; this.color = '#00ff00'; } 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'; } } + + getDisplayName() { + if (!this.identified) { + if (this.modifier !== 0 || this.isCursed) { + return `A Mysterious ${this.name}`; + } + return this.name; + } + + let name = this.name; + if (this.isCursed) name = "Cursed " + name; + if (this.modifier > 0) name += ` (+${this.modifier})`; + if (this.modifier < 0) name += ` (${this.modifier})`; + return name; + } } diff --git a/src/Map.js b/src/Map.js index d164fae..b1f7eeb 100644 --- a/src/Map.js +++ b/src/Map.js @@ -5,23 +5,33 @@ export class Map { this.tiles = []; this.rooms = []; this.permanentlyLit = []; // New grid for spell effects + this.explored = []; // Track which tiles the player has seen + this.traps = []; // New array for level traps + this.fountains = []; // New array for healing fountains + this.signs = []; // New array for interactive signs } generate() { this.isTown = false; this.rooms = []; this.permanentlyLit = []; + this.explored = []; + this.traps = []; + this.fountains = []; + this.signs = []; // Initialize with walls and dark for (let y = 0; y < this.height; y++) { this.tiles[y] = []; this.permanentlyLit[y] = []; + this.explored[y] = []; for (let x = 0; x < this.width; x++) { this.tiles[y][x] = '#'; this.permanentlyLit[y][x] = false; + this.explored[y][x] = false; } } - const MAX_ROOMS = 10; + const MAX_ROOMS = 30; const MIN_SIZE = 6; const MAX_SIZE = 12; @@ -63,19 +73,51 @@ export class Map { this.rooms.push(newRoom); } } + + this.generateTraps(); + this.generateFountain(); + } + + generateTraps() { + const numTraps = Math.floor(Math.random() * 6); // 0-5 + for (let i = 0; i < numTraps; i++) { + if (this.rooms.length === 0) break; + const room = this.rooms[Math.floor(Math.random() * this.rooms.length)]; + const tx = Math.floor(Math.random() * room.w) + room.x; + const ty = Math.floor(Math.random() * room.h) + room.y; + + // Avoid placing trap exactly on player starting stairs if possible + this.traps.push({ x: tx, y: ty, revealed: false }); + } + } + + generateFountain() { + if (Math.random() < 0.25) { // 25% chance + if (this.rooms.length === 0) return; + const room = this.rooms[Math.floor(Math.random() * this.rooms.length)]; + const fx = Math.floor(Math.random() * room.w) + room.x; + const fy = Math.floor(Math.random() * room.h) + room.y; + this.fountains.push({ x: fx, y: fy, used: false }); + } } generateTown() { this.isTown = true; this.rooms = []; this.permanentlyLit = []; + this.explored = []; + this.traps = []; + this.fountains = []; + this.signs = []; // Fill with grass/floor for (let y = 0; y < this.height; y++) { this.tiles[y] = []; this.permanentlyLit[y] = []; + this.explored[y] = []; for (let x = 0; x < this.width; x++) { this.tiles[y][x] = '.'; this.permanentlyLit[y][x] = true; // Town is all lit + this.explored[y][x] = true; // Town is fully explored } } @@ -108,21 +150,55 @@ export class Map { }; // 1. Temple (North Center) - drawBuilding(20, 5, 10, 8, 'bottom'); + drawBuilding(35, 10, 10, 8, 'bottom'); + this.signs.push({ x: 41, y: 18, text: "Temple of the Sun" }); // 2. General Store (West) - drawBuilding(5, 15, 8, 6, 'right'); + drawBuilding(15, 30, 8, 6, 'right'); + this.signs.push({ x: 23, y: 31, text: "Oakhaven General Goods" }); // 3. Weapon Shop (East) - drawBuilding(37, 15, 8, 6, 'left'); + drawBuilding(55, 30, 8, 6, 'left'); + this.signs.push({ x: 54, y: 31, text: "Oakhaven Blacksmith" }); // 4. Dungeon Entrance (North East) - drawBuilding(40, 5, 6, 6, 'bottom'); + drawBuilding(60, 10, 6, 6, 'bottom'); // 5. Houses (South) - drawBuilding(10, 30, 6, 5, 'top'); - drawBuilding(22, 30, 6, 5, 'top'); - drawBuilding(34, 30, 6, 5, 'top'); + drawBuilding(20, 55, 6, 5, 'top'); + drawBuilding(37, 55, 6, 5, 'top'); + drawBuilding(54, 55, 6, 5, 'top'); + + // 6. Cosmetic Fields (Farmed land) + const drawField = (x, y, w, h) => { + for (let fy = y; fy < y + h; fy++) { + for (let fx = x; fx < x + w; fx++) { + // Alternating brown and green for a "plowed" look + this.tiles[fy][fx] = (fx + fy) % 2 === 0 ? '"' : ','; + } + } + }; + + drawField(10, 15, 10, 8); // West field + drawField(55, 45, 12, 6); // East field + + // 7. Graveyard (North West) + const drawGraveyard = (x, y, w, h) => { + for (let gy = y; gy < y + h; gy++) { + for (let gx = x; gx < x + w; gx++) { + if (gy === y || gy === y + h - 1 || gx === x || gx === x + w - 1) { + this.tiles[gy][gx] = '#'; // Fence/Wall + } else { + // Randomly place tombstones + this.tiles[gy][gx] = Math.random() < 0.2 ? 't' : '.'; + } + } + } + // Entrance + this.tiles[y + h - 1][Math.floor(x + w / 2)] = '.'; + }; + + drawGraveyard(5, 5, 12, 8); } @@ -151,6 +227,41 @@ export class Map { return this.tiles[y][x] === '.' || this.tiles[y][x] === '>' || this.tiles[y][x] === '<'; } + hasLineOfSight(x1, y1, x2, y2) { + let dx = Math.abs(x2 - x1); + let dy = Math.abs(y2 - y1); + let x = x1; + let y = y1; + let n = 1 + dx + dy; + let x_inc = (x2 > x1) ? 1 : -1; + let y_inc = (y2 > y1) ? 1 : -1; + let error = dx - dy; + dx *= 2; + dy *= 2; + + for (; n > 0; --n) { + // Check if current tile blocks light (only wall '#' blocks light) + if (this.tiles[y][x] === '#' && (x !== x1 || y !== y1) && (x !== x2 || y !== y2)) { + return false; + } + + if (error > 0) { + x += x_inc; + error -= dy; + } else if (error < 0) { + y += y_inc; + error += dx; + } else { + // Diagonal move + x += x_inc; + y += y_inc; + error += dx - dy; + n--; + } + } + return true; + } + isLit(x, y) { // Town is always fully lit if (this.isTown) return true; diff --git a/src/UI.js b/src/UI.js index dacf267..4393110 100644 --- a/src/UI.js +++ b/src/UI.js @@ -1,3 +1,6 @@ +import { Farmer, Gravekeeper } from './Entity.js'; + + export class UI { constructor(ctx, game) { console.log("UI Version 1.1 Loaded"); @@ -5,7 +8,8 @@ export class UI { this.game = game; this.messageLog = document.getElementById('message-log'); this.spellsList = document.getElementById('spells-list'); - this.inventoryLink = document.getElementById('inventory-link'); + this.inventoryLink = docugment.getElementById('inventory-link'); + this.inventoryLabel = document.querySelector('#inventory-panel .group-box-label'); if (this.inventoryLink) { this.inventoryLink.onclick = (e) => { @@ -14,6 +18,11 @@ export class UI { }; } + if (this.inventoryLabel) { + this.inventoryLabel.style.cursor = 'pointer'; + this.inventoryLabel.onclick = () => this.game.toggleInventory(); + } + this.popup = null; this.popupTimeout = null; } @@ -50,12 +59,18 @@ export class UI { const tile = map.tiles[mapY][mapX]; 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) + const inRadius = dist <= 3.5; + const isExplored = map.explored[mapY][mapX]; - if (isLit || inRadius) { - this.drawTile(x * tileSize, y * tileSize, tile, tileSize); + // Tiles are visible if lit, in radius, or remembered + if (isLit || inRadius || isExplored) { + // Check if it's currently in Line of Sight to draw it "bright" + const hasLOS = map.hasLineOfSight(player.x, player.y, mapX, mapY); + const isCurrentlyVisible = (isLit || inRadius) && hasLOS; + + this.drawTile(x * tileSize, y * tileSize, tile, tileSize, !isCurrentlyVisible); } else { - // Draw nothing or a very dark tile for "unseen" areas + // Draw nothing for unseen areas this.ctx.fillStyle = '#000000'; this.ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize); } @@ -63,12 +78,91 @@ export class UI { } } + // Draw Traps + if (map.traps) { + for (const trap of map.traps) { + if (!trap.revealed) continue; + + const isLit = map.isLit(trap.x, trap.y); + const dist = Math.sqrt(Math.pow(player.x - trap.x, 2) + Math.pow(player.y - trap.y, 2)); + const inRadius = dist <= 3.5; + + if (!(isLit || inRadius)) continue; + if (!map.hasLineOfSight(player.x, player.y, trap.x, trap.y)) continue; + + const screenX = (trap.x - startX) * tileSize; + const screenY = (trap.y - startY) * tileSize; + if (screenX >= -tileSize && screenX < this.ctx.canvas.width && + screenY >= -tileSize && screenY < this.ctx.canvas.height) { + this.ctx.fillStyle = '#ff0000'; + this.ctx.fillText('^', screenX + tileSize / 4, screenY); + } + } + } + + // Draw Fountains + if (map.fountains) { + for (const fountain of map.fountains) { + const isLit = map.isLit(fountain.x, fountain.y); + const dist = Math.sqrt(Math.pow(player.x - fountain.x, 2) + Math.pow(player.y - fountain.y, 2)); + const inRadius = dist <= 3.5; + + if (!(isLit || inRadius)) continue; + if (!map.hasLineOfSight(player.x, player.y, fountain.x, fountain.y)) continue; + + const screenX = (fountain.x - startX) * tileSize; + const screenY = (fountain.y - startY) * tileSize; + if (screenX >= -tileSize && screenX < this.ctx.canvas.width && + screenY >= -tileSize && screenY < this.ctx.canvas.height) { + + const asset = this.game.assets['fountain']; + if (asset && !fountain.used) { + this.ctx.drawImage(asset, screenX, screenY, tileSize, tileSize); + } else { + this.ctx.fillStyle = fountain.used ? '#808080' : '#00ffff'; + this.ctx.fillText('&', screenX + tileSize / 4, screenY); + } + } + } + } + + // Draw Signs + if (map.signs) { + for (const sign of map.signs) { + const isLit = map.isLit(sign.x, sign.y); + const dist = Math.sqrt(Math.pow(player.x - sign.x, 2) + Math.pow(player.y - sign.y, 2)); + const inRadius = dist <= 3.5; + const isExplored = map.explored[sign.y][sign.x]; + + if (!(isLit || inRadius || isExplored)) continue; + + // Only show bright if in LOS + const hasLOS = map.hasLineOfSight(player.x, player.y, sign.x, sign.y); + const isCurrentlyVisible = (isLit || inRadius) && hasLOS; + + const screenX = (sign.x - startX) * tileSize; + const screenY = (sign.y - startY) * tileSize; + if (screenX >= -tileSize && screenX < this.ctx.canvas.width && + screenY >= -tileSize && screenY < this.ctx.canvas.height) { + this.ctx.fillStyle = isCurrentlyVisible ? '#8b4513' : '#4a250a'; // Darker brown if not visible + this.ctx.fillRect(screenX + 4, screenY + 4, tileSize - 8, tileSize - 8); + this.ctx.fillStyle = isCurrentlyVisible ? '#ffffff' : '#888888'; + this.ctx.font = `bold ${tileSize/2}px monospace`; + this.ctx.textAlign = 'center'; + this.ctx.fillText('?', screenX + tileSize / 2, screenY + tileSize / 1.5); + } + } + } + // 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 inRadius = dist <= 3.5; + + if (!(isLit || inRadius)) continue; + if (!map.hasLineOfSight(player.x, player.y, item.x, item.y)) continue; const screenX = (item.x - startX) * tileSize; const screenY = (item.y - startY) * tileSize; @@ -83,7 +177,10 @@ export class UI { 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 inRadius = dist <= 3.5; + + if (!(isLit || inRadius)) continue; + if (!map.hasLineOfSight(player.x, player.y, monster.x, monster.y)) continue; const screenX = (monster.x - startX) * tileSize; const screenY = (monster.y - startY) * tileSize; @@ -120,7 +217,10 @@ export class UI { } updateStats(player, depth) { - document.getElementById('stat-hp').textContent = player.hp; + const hpElem = document.getElementById('stat-hp'); + hpElem.textContent = player.hp; + hpElem.style.color = player.poisoned ? '#00ff00' : '#ffffff'; + document.getElementById('stat-max-hp').textContent = player.maxHp; document.getElementById('stat-str').textContent = player.stats.str; document.getElementById('stat-level').textContent = player.level; @@ -131,6 +231,11 @@ export class UI { document.getElementById('stat-gold').textContent = player.gold; document.getElementById('stat-mana').textContent = `${player.mana}/${player.maxMana}`; document.getElementById('stat-int').textContent = player.stats.int; + + const statusElem = document.getElementById('stat-status'); + if (statusElem) { + statusElem.textContent = player.poisoned ? "POISONED" : ""; + } } updateInventory(player) { @@ -140,6 +245,106 @@ export class UI { } } + drawOverviewMap(map, player) { + const width = 500; + const height = 500; + const x = (this.ctx.canvas.width - width) / 2; + const y = (this.ctx.canvas.height - height) / 2; + + // Background + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + this.ctx.fillRect(x, y, width, height); + this.ctx.strokeStyle = '#ffffff'; + this.ctx.lineWidth = 2; + this.ctx.strokeRect(x, y, width, height); + + // Title + this.ctx.fillStyle = '#ffffff'; + this.ctx.font = 'bold 20px monospace'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('MAP OVERVIEW', x + width / 2, y + 30); + + // Map Scale + const padding = 50; + const mapAreaWidth = width - padding * 2; + const mapAreaHeight = height - padding * 2; + const scaleX = mapAreaWidth / map.width; + const scaleY = mapAreaHeight / map.height; + const scale = Math.min(scaleX, scaleY); + + const offsetX = x + (width - (map.width * scale)) / 2; + const offsetY = y + (height - (map.height * scale)) / 2; + + for (let my = 0; my < map.height; my++) { + for (let mx = 0; mx < map.width; mx++) { + if (map.explored[my][mx]) { + const tile = map.tiles[my][mx]; + if (tile === '#') { + this.ctx.fillStyle = '#555555'; + } else if (tile === '>') { + this.ctx.fillStyle = '#ffffff'; // Highlight stairs down + } else if (tile === '<') { + this.ctx.fillStyle = '#ffffff'; // Highlight stairs up + } else if (tile === 't') { + // Tombstone + this.ctx.fillStyle = '#222222'; + } else { + this.ctx.fillStyle = '#222222'; + } + this.ctx.fillRect(offsetX + mx * scale, offsetY + my * scale, scale, scale); + } + } + } + + // Draw Player Position + this.ctx.fillStyle = '#ffff00'; + this.ctx.beginPath(); + this.ctx.arc(offsetX + player.x * scale + scale / 2, offsetY + player.y * scale + scale / 2, scale * 1.5, 0, Math.PI * 2); + this.ctx.fill(); + + // Legend/Instructions + this.ctx.fillStyle = '#888888'; + this.ctx.font = '12px monospace'; + this.ctx.fillText('Yellow: You | White: Stairs | [M] to Close', x + width / 2, y + height - 15); + + this.ctx.textAlign = 'start'; + } + + drawIntro(textLines) { + const width = 750; + const height = 550; + const x = (this.ctx.canvas.width - width) / 2; + const y = (this.ctx.canvas.height - height) / 2; + + // Shadow + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + this.ctx.fillRect(x + 10, y + 10, width, height); + + // Background + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(x, y, width, height); + this.ctx.strokeStyle = '#ffffff'; + this.ctx.lineWidth = 3; + this.ctx.strokeRect(x, y, width, height); + + // Title + this.ctx.fillStyle = '#ffff00'; + this.ctx.font = 'bold 24px "Courier New", monospace'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('THE LEGEND OF OAKHAVEN', x + width / 2, y + 40); + + // Text + this.ctx.fillStyle = '#ffffff'; + this.ctx.font = '16px "Courier New", monospace'; + let lineY = y + 80; + textLines.forEach(line => { + this.ctx.fillText(line, x + width / 2, lineY); + lineY += 20; + }); + + this.ctx.textAlign = 'start'; + } + drawInventory(player, selectedIndex) { const ctx = this.ctx; const width = 500; @@ -165,14 +370,19 @@ export class UI { ctx.textAlign = 'left'; let itemY = y + 70; - if (player.inventory.length === 0) { + const groupedItems = this.game.getGroupedInventory(); + + if (groupedItems.length === 0) { ctx.fillStyle = '#888888'; ctx.textAlign = 'center'; ctx.fillText('Empty', x + width / 2, itemY + 20); } else { - player.inventory.forEach((item, index) => { + groupedItems.forEach((group, index) => { const isSelected = index === selectedIndex; - const isEquipped = player.equipment.weapon === item || player.equipment.armor === item || player.equipment.shield === item; + const isEquipped = group.indices.some(idx => { + const item = player.inventory[idx]; + return player.equipment.weapon === item || player.equipment.armor === item || player.equipment.shield === item; + }); if (isSelected) { ctx.fillStyle = '#333333'; @@ -180,13 +390,32 @@ export class UI { ctx.fillStyle = '#ffff00'; ctx.fillText('>', x + 25, itemY + 10); } else { - ctx.fillStyle = '#aaaaaa'; + // Color based on quality if identified + if (!group.item.identified) { + ctx.fillStyle = '#aaaaaa'; // Gray for unidentified + } else if (group.item.isCursed) { + ctx.fillStyle = '#ff4444'; // Red for cursed + } else if (group.item.modifier > 0) { + ctx.fillStyle = '#00ffff'; // Cyan for enchanted + } else { + ctx.fillStyle = '#aaaaaa'; + } } - let displayName = item.name; + let displayName = group.item.getDisplayName(); + if (group.count > 1) displayName += ` (${group.count})`; if (isEquipped) displayName += " (E)"; - ctx.fillText(displayName, x + 50, itemY + 10); + // Draw Icon if available + const assetName = this.getAssetName(group.item); + const asset = this.game.assets[assetName]; + if (asset) { + this.ctx.drawImage(asset, x + 50, itemY, 20, 20); + this.ctx.fillText(displayName, x + 80, itemY + 10); + } else { + this.ctx.fillText(displayName, x + 50, itemY + 10); + } + itemY += 30; }); } @@ -195,7 +424,7 @@ export class UI { 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); + ctx.fillText('1:Laser|2:Fire|3:Light|4:Heal|5:Ret|6:Det|7:Cure|Up/Down:Sel|Enter:Use|Esc/i:Close', x + width / 2, y + height - 20); // Reset ctx.textAlign = 'start'; @@ -207,48 +436,63 @@ export class UI { player.spells.forEach((spell, index) => { const li = document.createElement('li'); li.textContent = `${index + 1}. ${spell}`; + li.style.cursor = 'pointer'; + li.style.padding = '2px'; + li.onmouseover = () => li.style.backgroundColor = 'rgba(255,255,255,0.1)'; + li.onmouseout = () => li.style.backgroundColor = 'transparent'; + li.onclick = () => { + if (spell === 'zappy laser') this.game.startTargeting('zappy laser'); + else if (spell === 'Fireball') this.game.startTargeting('Fireball'); + else if (spell === 'Light') this.game.castLightSpell(); + else if (spell === 'Heal') this.game.castHealSpell(); + else if (spell === 'Return') this.game.castReturnSpell(); + else if (spell === 'Detect Traps') this.game.castDetectTrapsSpell(); + else if (spell === 'Cure Poison') this.game.castCurePoisonSpell(); + }; this.spellsList.appendChild(li); }); } - drawTile(screenX, screenY, tile, size) { + drawTile(screenX, screenY, tile, size, isMemorized = false) { + const ctx = this.ctx; if (tile === '#') { - this.ctx.fillStyle = '#808080'; // Gray wall - this.ctx.fillRect(screenX, screenY, size, size); - // Add a bevel effect for walls - this.ctx.strokeStyle = '#ffffff'; - this.ctx.lineWidth = 2; - this.ctx.beginPath(); - this.ctx.moveTo(screenX, screenY + size); - this.ctx.lineTo(screenX, screenY); - this.ctx.lineTo(screenX + size, screenY); - this.ctx.stroke(); - - this.ctx.strokeStyle = '#404040'; - this.ctx.beginPath(); - this.ctx.moveTo(screenX + size, screenY); - this.ctx.lineTo(screenX + size, screenY + size); - this.ctx.lineTo(screenX, screenY + size); - this.ctx.stroke(); + ctx.fillStyle = isMemorized ? '#404040' : '#808080'; // Darker gray for memorized walls + ctx.fillRect(screenX, screenY, size, size); + + // Wall highlights + ctx.strokeStyle = isMemorized ? '#606060' : '#ffffff'; + ctx.lineWidth = 1; + ctx.strokeRect(screenX + 1, screenY + 1, size - 2, size - 2); } else if (tile === '>') { // Stairs Down - this.ctx.fillStyle = '#202020'; - this.ctx.fillRect(screenX, screenY, size, size); - this.ctx.fillStyle = '#ffffff'; - this.ctx.fillText('>', screenX + size / 4, screenY); + ctx.fillStyle = '#202020'; + ctx.fillRect(screenX, screenY, size, size); + ctx.fillStyle = isMemorized ? '#888888' : '#ffffff'; + ctx.fillText('>', screenX + size / 4, screenY); } else if (tile === '<') { // Stairs Up - this.ctx.fillStyle = '#202020'; - this.ctx.fillRect(screenX, screenY, size, size); - this.ctx.fillStyle = '#ffffff'; - this.ctx.fillText('<', screenX + size / 4, screenY); + ctx.fillStyle = '#202020'; + ctx.fillRect(screenX, screenY, size, size); + ctx.fillStyle = isMemorized ? '#888888' : '#ffffff'; + ctx.fillText('<', screenX + size / 4, screenY); + } else if (tile === '"' || tile === ',') { + // Field + if (tile === '"') ctx.fillStyle = isMemorized ? '#3d2b25' : '#5d4037'; + else ctx.fillStyle = isMemorized ? '#1b4a1e' : '#2e7d32'; + ctx.fillRect(screenX, screenY, size, size); + } else if (tile === 't') { + // Tombstone + ctx.fillStyle = '#202020'; + ctx.fillRect(screenX, screenY, size, size); + ctx.fillStyle = isMemorized ? '#404040' : '#888888'; + ctx.fillText('†', screenX + size / 4, screenY); } else { // Floor - this.ctx.fillStyle = '#202020'; - this.ctx.fillRect(screenX, screenY, size, size); - this.ctx.fillStyle = '#404040'; - this.ctx.fillText('.', screenX + size / 4, screenY); + ctx.fillStyle = '#202020'; + ctx.fillRect(screenX, screenY, size, size); + ctx.fillStyle = isMemorized ? '#222222' : '#404040'; // Very dark for memorized floor + ctx.fillText('.', screenX + size / 4, screenY); } } @@ -297,8 +541,53 @@ export class UI { } drawEntity(screenX, screenY, entity, size) { - this.ctx.fillStyle = entity.color; - this.ctx.fillText(entity.symbol, screenX + size / 4, screenY); + const assetName = this.getAssetName(entity); + const asset = this.game.assets[assetName]; + + if (asset) { + this.ctx.drawImage(asset, screenX, screenY, size, size); + } else { + this.ctx.fillStyle = entity.color; + this.ctx.fillText(entity.symbol, screenX + size / 4, screenY); + } + } + + getAssetName(entity) { + if (!entity) return null; + const name = entity.name || ""; + + if (name === 'Player') return 'hero'; + if (name === 'Rat') return 'rat'; + if (name === 'Kobold') return 'kobold'; + if (name === 'Orc') return 'orc'; + if (name === 'Skeleton Archer') return 'skeleton archer'; + if (name === 'Elite Archer' || name === 'Sniper' || name === 'Archer') return 'archer'; + if (name === 'Ogre' || name === 'Troll') return 'ogre'; + if (name === 'Cobra') return 'cobra'; + if (name === 'Dragon') return 'dragon'; + if (name === 'The Dungeon Lord') return 'dungeon lord'; + + // Town NPCs + if (name === 'Peasant' || name.startsWith('Traveler') || name.startsWith('Farmer') || entity instanceof Farmer) { + // Consistent but "random" assignment based on coordinates + return (entity.x + entity.y) % 2 === 0 ? 'peasant1' : 'peasant2'; + } + if (name === 'Villager' || name === 'Old Man' || name === 'Woman') return 'house questgiver'; + if (name === 'Priest' || name === 'Father Sun' || name === 'Smith' || name === 'Merchant' || name === 'Mort' || entity instanceof Gravekeeper) return 'mysterious figure'; + + // Items + if (name === 'Dagger') return 'dagger'; + if (name.includes('Sword') || name.includes('Blade')) return 'sword'; + if (name.includes('Axe') || name.includes('Hammer')) return 'axe'; + if (name === 'Buckler') return 'buckler'; + if (name === 'Wooden Shield') return 'wooden shield'; + if (name.includes('Shield')) return 'kite shield'; + if (entity.type === 'spellbook' || name.includes('Spellbook')) return 'spellbook'; + + // Projectiles / Special + if (name === 'zappy laser') return 'zappy laser'; + + return null; } drawShop(shopkeeper, player, selectedIndex, mode = 'BUY') { @@ -326,7 +615,7 @@ export class UI { ctx.textAlign = 'left'; let itemY = y + 70; - const list = mode === 'BUY' ? shopkeeper.inventory : player.inventory; + const list = mode === 'BUY' ? shopkeeper.inventory : this.game.getGroupedInventory(); if (list.length === 0) { ctx.fillStyle = '#888888'; @@ -335,7 +624,8 @@ export class UI { } else { list.forEach((entry, index) => { const isSelected = index === selectedIndex; - const item = mode === 'BUY' ? entry.item : entry; + const item = mode === 'BUY' ? entry.item : entry.item; + const count = mode === 'SELL' ? entry.count : 1; let price = 0; if (mode === 'BUY') { @@ -355,10 +645,29 @@ export class UI { ctx.fillStyle = '#ffff00'; ctx.fillText('>', x + 25, itemY + 10); } else { - ctx.fillStyle = '#aaaaaa'; + if (mode === 'SELL') { + if (!item.identified) ctx.fillStyle = '#aaaaaa'; + else if (item.isCursed) ctx.fillStyle = '#ff4444'; + else if (item.modifier > 0) ctx.fillStyle = '#00ffff'; + else ctx.fillStyle = '#aaaaaa'; + } else { + ctx.fillStyle = '#aaaaaa'; + } + } + + // Draw Icon if available + const assetName = this.getAssetName(item); + const asset = this.game.assets[assetName]; + let itemName = item.getDisplayName ? item.getDisplayName() : item.name; + if (count > 1) itemName += ` (${count})`; + + if (asset) { + this.ctx.drawImage(asset, x + 50, itemY, 20, 20); + this.ctx.fillText(`${itemName}`, x + 80, itemY + 10); + } else { + this.ctx.fillText(`${itemName}`, x + 50, itemY + 10); } - ctx.fillText(`${item.name}`, x + 50, itemY + 10); ctx.textAlign = 'right'; ctx.fillText(`${price}g`, x + width - 50, itemY + 10); ctx.textAlign = 'left'; @@ -375,7 +684,7 @@ export class UI { // Instructions ctx.fillStyle = '#888888'; ctx.font = '12px monospace'; - ctx.fillText('Tab: Toggle Buy/Sell | Up/Down: Select | Enter: Action | Esc: Exit', x + width / 2, y + height - 20); + ctx.fillText('Tab: Mode | Up/Down: Sel | Enter: Action | Esc: Exit', x + width / 2, y + height - 20); // Reset ctx.textAlign = 'start'; diff --git a/style.css b/style.css index 44cdf08..585582a 100644 --- a/style.css +++ b/style.css @@ -8,8 +8,8 @@ } body { - background-color: #008080; /* Classic teal desktop */ - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* Fallback to modern sans */ + background-color: var(--win-bg); /* Match window background */ + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; display: flex; justify-content: center; align-items: center; @@ -21,15 +21,13 @@ body { /* Windows 3.1 / 95 Style Window */ .window { background-color: var(--win-bg); - border: 2px solid var(--win-white); - border-right-color: var(--win-gray-dark); - border-bottom-color: var(--win-gray-dark); - box-shadow: 2px 2px 0 #000; - padding: 2px; + border: none; /* Remove outer borders for full screen */ + box-shadow: none; + padding: 0; display: flex; flex-direction: column; - width: 900px; - height: 700px; + width: 100vw; + height: 100vh; } .title-bar { @@ -79,10 +77,13 @@ body { display: flex; justify-content: center; align-items: center; + border: none; /* Remove border for cleaner look */ } #game-canvas { image-rendering: pixelated; + width: 100%; + height: 100%; } #sidebar { @@ -113,11 +114,14 @@ body { } #message-log { - height: 100px; - padding: 5px; + height: 120px; + padding: 10px; overflow-y: auto; font-family: 'Courier New', Courier, monospace; - font-size: 14px; + font-size: 15px; + background: #000; + color: #00ff00; /* Matrix/Classic terminal style */ + border: 2px solid var(--win-gray-dark); } .status-bar {