Added more features and art assets

main
John Kenyon 2025-12-31 19:42:46 -08:00
parent 02edcd9d9e
commit 3ea8cbc8c3
31 changed files with 1128 additions and 123 deletions

BIN
assets/archer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/axe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/buckler.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/cobra.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/dagger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

BIN
assets/dragon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/dungeon lord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/fountain.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/house questgiver.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/kite shield.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/kobold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/ogre.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/orc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/peasant1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
assets/peasant2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/rat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/skeleton archer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

BIN
assets/spellbook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
assets/sword.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/wooden shield.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/zappy laser.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -20,8 +20,8 @@
</div>
<div class="window-body">
<div id="main-layout">
<div id="viewport-container" class="inset-border">
<canvas id="game-canvas" width="640" height="480"></canvas>
<div id="viewport-container">
<canvas id="game-canvas"></canvas>
</div>
<div id="sidebar">
<div id="stats-panel" class="group-box">
@ -40,6 +40,7 @@
<br>
<div>HP: <span id="stat-hp">10</span>/<span id="stat-max-hp">10</span></div>
<div>Mana: <span id="stat-mana">10</span>/<span id="stat-max-mana">10</span></div>
<div id="stat-status" style="color: #00ff00; font-weight: bold; height: 1.2em;"></div>
</div>
</div>
<div id="inventory-panel" class="group-box">

View File

@ -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)];
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;

413
src/UI.js
View File

@ -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';

View File

@ -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 {