Work in progress
commit
fbcc3a91d9
|
|
@ -0,0 +1,64 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Castle of the Winds Clone</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="game-container" class="window">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">Castle of the Winds Clone</div>
|
||||
<div class="title-bar-controls">
|
||||
<button aria-label="Minimize"></button>
|
||||
<button aria-label="Maximize"></button>
|
||||
<button aria-label="Close"></button>
|
||||
</div>
|
||||
</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>
|
||||
<div id="sidebar">
|
||||
<div id="stats-panel" class="group-box">
|
||||
<div class="group-box-label">Stats</div>
|
||||
<div id="stats-content">
|
||||
<div>Floor: <span id="stat-floor">1</span></div>
|
||||
<div>Gold: <span id="stat-gold">0</span></div>
|
||||
<div>Level: <span id="stat-level">1</span></div>
|
||||
<div>XP: <span id="stat-xp">0</span>/<span id="stat-max-xp">10</span></div>
|
||||
<br>
|
||||
<div>STR: <span id="stat-str">10</span></div>
|
||||
<div>AC: <span id="stat-ac">0</span></div>
|
||||
<div>INT: <span id="stat-int">10</span></div>
|
||||
<div>DEX: <span id="stat-dex">10</span></div>
|
||||
<div>CON: <span id="stat-con">10</span></div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="inventory-panel" class="group-box">
|
||||
<div class="group-box-label">Inventory</div>
|
||||
<ul id="inventory-list">
|
||||
<!-- Items will go here -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="message-log" class="inset-border">
|
||||
<div>Welcome to the Castle of the Winds Clone!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<p class="status-bar-field">Ready</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
export class Entity {
|
||||
constructor(x, y, name, symbol, color) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.name = name;
|
||||
this.symbol = symbol;
|
||||
this.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
export class Player extends Entity {
|
||||
constructor(x, y) {
|
||||
super(x, y, 'Player', '@', '#ffffff');
|
||||
this.hp = 10;
|
||||
this.maxHp = 10;
|
||||
this.inventory = [];
|
||||
this.equipment = {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
shield: null
|
||||
};
|
||||
this.stats = {
|
||||
str: 10,
|
||||
dex: 10
|
||||
};
|
||||
this.level = 1;
|
||||
this.xp = 0;
|
||||
this.maxXp = 10;
|
||||
this.gold = 0;
|
||||
}
|
||||
|
||||
gainXp(amount) {
|
||||
this.xp += amount;
|
||||
if (this.xp >= this.maxXp) {
|
||||
this.level++;
|
||||
this.xp -= this.maxXp;
|
||||
this.maxXp = Math.floor(this.maxXp * 1.5);
|
||||
this.maxHp += 5;
|
||||
this.hp = this.maxHp;
|
||||
this.stats.str += 2;
|
||||
return true; // Leveled up
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getDamage() {
|
||||
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;
|
||||
}
|
||||
return dmg;
|
||||
}
|
||||
|
||||
getDefense() {
|
||||
let def = 0;
|
||||
if (this.equipment.shield) {
|
||||
def += this.equipment.shield.stats.defense || 0;
|
||||
}
|
||||
// Could add armor here later
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
export class Monster extends Entity {
|
||||
constructor(x, y, name, symbol, color, level) {
|
||||
super(x, y, name, symbol, color);
|
||||
this.level = level;
|
||||
this.hp = level * 5;
|
||||
this.maxHp = this.hp;
|
||||
}
|
||||
|
||||
getDefense() {
|
||||
return Math.floor(this.level / 2); // Slight natural armor for higher level monsters
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
import { Map } from './Map.js';
|
||||
import { UI } from './UI.js';
|
||||
import { Player } from './Entity.js';
|
||||
|
||||
import { Monster } from './Entity.js';
|
||||
import { Item } from './Item.js';
|
||||
|
||||
export class Game {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('game-canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.ui = new UI(this.ctx, this); // Pass game instance to UI for callbacks
|
||||
this.map = new Map(50, 50);
|
||||
this.player = new Player(10, 10);
|
||||
this.monsters = [];
|
||||
this.items = [];
|
||||
this.gameOver = false;
|
||||
this.monsters = [];
|
||||
this.items = [];
|
||||
this.gameOver = false;
|
||||
this.depth = 0; // Start in Town
|
||||
this.maxDepth = 3;
|
||||
|
||||
this.tileSize = 32;
|
||||
|
||||
// Bind input
|
||||
window.addEventListener('keydown', (e) => this.handleInput(e));
|
||||
}
|
||||
|
||||
start() {
|
||||
console.log("Game Started");
|
||||
this.generateLevel();
|
||||
this.ui.log("Welcome to the Town of Oakhaven!");
|
||||
}
|
||||
|
||||
generateLevel() {
|
||||
this.monsters = [];
|
||||
this.items = [];
|
||||
|
||||
if (this.depth === 0) {
|
||||
this.map.generateTown();
|
||||
// Spawn player in center
|
||||
this.player.x = Math.floor(this.map.width / 2);
|
||||
this.player.y = Math.floor(this.map.height / 2);
|
||||
} else {
|
||||
this.map.generate();
|
||||
// Spawn Player in first room
|
||||
if (this.map.rooms.length > 0) {
|
||||
const startRoom = this.map.rooms[0];
|
||||
this.player.x = Math.floor(startRoom.x + startRoom.w / 2);
|
||||
this.player.y = Math.floor(startRoom.y + startRoom.h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
this.spawnMonsters();
|
||||
this.spawnItems();
|
||||
this.spawnStairs();
|
||||
|
||||
this.render();
|
||||
this.ui.updateStats(this.player, this.depth);
|
||||
this.ui.updateInventory(this.player);
|
||||
}
|
||||
|
||||
nextLevel() {
|
||||
if (this.depth >= this.maxDepth) {
|
||||
this.ui.log("You have reached the bottom! You Win!");
|
||||
setTimeout(() => alert("You Win!"), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.depth++;
|
||||
this.ui.log(`You descend to level ${this.depth}...`);
|
||||
this.generateLevel();
|
||||
}
|
||||
|
||||
spawnStairs() {
|
||||
if (this.depth === 0) {
|
||||
// Stairs in a random building or just random spot
|
||||
if (this.map.rooms.length > 0) {
|
||||
const room = this.map.rooms[0];
|
||||
const sx = Math.floor(Math.random() * room.w) + room.x;
|
||||
const sy = Math.floor(Math.random() * room.h) + room.y;
|
||||
this.map.tiles[sy][sx] = '>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn stairs in the last room
|
||||
if (this.map.rooms.length > 0) {
|
||||
const room = this.map.rooms[this.map.rooms.length - 1];
|
||||
const sx = Math.floor(Math.random() * room.w) + room.x;
|
||||
const sy = Math.floor(Math.random() * room.h) + room.y;
|
||||
this.map.tiles[sy][sx] = '>';
|
||||
}
|
||||
}
|
||||
|
||||
spawnMonsters() {
|
||||
if (this.depth === 0) {
|
||||
// Spawn Peasants in Town
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mx = Math.floor(Math.random() * this.map.width);
|
||||
const my = Math.floor(Math.random() * this.map.height);
|
||||
if (this.map.isWalkable(mx, my)) {
|
||||
const peasant = new Monster(mx, my, "Peasant", "p", "#00ffff", 1);
|
||||
this.monsters.push(peasant);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip first room
|
||||
for (let i = 1; i < this.map.rooms.length; i++) {
|
||||
const room = this.map.rooms[i];
|
||||
// Spawn 1-3 monsters per room
|
||||
const count = Math.floor(Math.random() * 3) + 1;
|
||||
for (let j = 0; j < count; j++) {
|
||||
const mx = Math.floor(Math.random() * room.w) + room.x;
|
||||
const my = Math.floor(Math.random() * room.h) + room.y;
|
||||
|
||||
const type = Math.random();
|
||||
let monster;
|
||||
|
||||
// Difficulty scaling
|
||||
if (this.depth === 1) {
|
||||
if (type < 0.7) monster = new Monster(mx, my, "Rat", "r", "#a0a0a0", 1);
|
||||
else monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2);
|
||||
} else if (this.depth === 2) {
|
||||
if (type < 0.4) monster = new Monster(mx, my, "Rat", "r", "#a0a0a0", 1);
|
||||
else if (type < 0.8) monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2);
|
||||
else monster = new Monster(mx, my, "Orc", "o", "#008000", 3);
|
||||
} else {
|
||||
if (type < 0.3) monster = new Monster(mx, my, "Kobold", "k", "#00ff00", 2);
|
||||
else if (type < 0.7) monster = new Monster(mx, my, "Orc", "o", "#008000", 3);
|
||||
else monster = new Monster(mx, my, "Ogre", "O", "#ff0000", 4);
|
||||
}
|
||||
|
||||
this.monsters.push(monster);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn Boss on final level
|
||||
if (this.depth === this.maxDepth) {
|
||||
const room = this.map.rooms[this.map.rooms.length - 1]; // Last room
|
||||
const mx = Math.floor(Math.random() * room.w) + room.x;
|
||||
const my = Math.floor(Math.random() * room.h) + room.y;
|
||||
// Boss: Level 10, High HP
|
||||
const boss = new Monster(mx, my, "The Dungeon Lord", "D", "#ff00ff", 10);
|
||||
boss.hp = 100;
|
||||
boss.maxHp = 100;
|
||||
this.monsters.push(boss);
|
||||
}
|
||||
}
|
||||
|
||||
spawnItems() {
|
||||
if (this.depth === 0) return; // No items in town
|
||||
|
||||
// Spawn items in random rooms
|
||||
for (let i = 0; i < this.map.rooms.length; i++) {
|
||||
const room = this.map.rooms[i];
|
||||
if (Math.random() < 0.5) {
|
||||
const ix = Math.floor(Math.random() * room.w) + room.x;
|
||||
const iy = Math.floor(Math.random() * room.h) + room.y;
|
||||
|
||||
// Random item
|
||||
const type = Math.random();
|
||||
let item;
|
||||
|
||||
// Better loot deeper
|
||||
let tier = this.depth;
|
||||
if (Math.random() < 0.2) tier++; // Chance for higher tier
|
||||
|
||||
if (type < 0.3) item = new Item("Potion", "potion", 1, { heal: 5 });
|
||||
else if (type < 0.5) {
|
||||
if (tier <= 1) item = new Item("Dagger", "weapon", 1, { damage: 4 });
|
||||
else if (tier === 2) item = new Item("Short Sword", "weapon", 2, { damage: 6 });
|
||||
else item = new Item("Long Sword", "weapon", 3, { damage: 8 });
|
||||
} else if (type < 0.7) {
|
||||
if (tier <= 1) item = new Item("Buckler", "shield", 1, { defense: 1 });
|
||||
else if (tier === 2) item = new Item("Wooden Shield", "shield", 2, { defense: 2 });
|
||||
else item = new Item("Kite Shield", "shield", 3, { defense: 3 });
|
||||
} else {
|
||||
if (tier >= 3) item = new Item("Great Axe", "weapon", 4, { damage: 12 });
|
||||
else item = new Item("Short Sword", "weapon", 2, { damage: 6 });
|
||||
}
|
||||
|
||||
item.x = ix;
|
||||
item.y = iy;
|
||||
this.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
if (this.gameOver) return;
|
||||
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': dy = -1; break;
|
||||
case 'ArrowDown': dy = 1; break;
|
||||
case 'ArrowLeft': dx = -1; break;
|
||||
case 'ArrowRight': dx = 1; break;
|
||||
case 'NumPad8': dy = -1; break;
|
||||
case 'NumPad2': dy = 1; break;
|
||||
case 'NumPad4': dx = -1; break;
|
||||
case 'NumPad6': dx = 1; break;
|
||||
case 'NumPad7': dx = -1; dy = -1; break;
|
||||
case 'NumPad9': dx = 1; dy = -1; break;
|
||||
case 'NumPad1': dx = -1; dy = 1; break;
|
||||
case 'NumPad3': dx = 1; dy = 1; break;
|
||||
case '.': case 'NumPad5': break; // Wait
|
||||
case 'Enter':
|
||||
case '>':
|
||||
console.log(`Trying to descend. Player at ${this.player.x},${this.player.y}. Tile: '${this.map.tiles[this.player.y][this.player.x]}'`);
|
||||
if (this.map.tiles[this.player.y][this.player.x] === '>') {
|
||||
this.nextLevel();
|
||||
} else {
|
||||
this.ui.log("There are no stairs here.");
|
||||
}
|
||||
break;
|
||||
case 'g': this.pickupItem(); break;
|
||||
}
|
||||
|
||||
if (dx !== 0 || dy !== 0 || e.key === '.' || e.key === 'NumPad5') {
|
||||
this.movePlayer(dx, dy);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
movePlayer(dx, dy) {
|
||||
const newX = this.player.x + dx;
|
||||
const newY = this.player.y + dy;
|
||||
|
||||
// Check for monster
|
||||
const targetMonster = this.monsters.find(m => m.x === newX && m.y === newY);
|
||||
if (targetMonster) {
|
||||
this.attack(this.player, targetMonster);
|
||||
} else if (this.map.isWalkable(newX, newY)) {
|
||||
this.player.x = newX;
|
||||
this.player.y = newY;
|
||||
|
||||
// Check for item
|
||||
const item = this.items.find(i => i.x === newX && i.y === newY);
|
||||
if (item) {
|
||||
this.ui.log(`You see a ${item.name}. (Press 'g' to get)`);
|
||||
}
|
||||
} else {
|
||||
if (dx !== 0 || dy !== 0) this.ui.log("Blocked!");
|
||||
}
|
||||
|
||||
// Monsters turn
|
||||
this.updateMonsters();
|
||||
}
|
||||
|
||||
pickupItem() {
|
||||
const itemIndex = this.items.findIndex(i => i.x === this.player.x && i.y === this.player.y);
|
||||
if (itemIndex !== -1) {
|
||||
const item = this.items[itemIndex];
|
||||
this.items.splice(itemIndex, 1);
|
||||
this.player.inventory.push(item);
|
||||
this.ui.log(`You picked up ${item.name}.`);
|
||||
this.ui.updateInventory(this.player);
|
||||
this.render();
|
||||
} else {
|
||||
this.ui.log("There is nothing here to pick up.");
|
||||
}
|
||||
}
|
||||
|
||||
useItem(index) {
|
||||
const item = this.player.inventory[index];
|
||||
if (!item) return;
|
||||
|
||||
if (item.type === 'weapon') {
|
||||
this.player.equipment.weapon = item;
|
||||
this.ui.log(`You equipped ${item.name}.`);
|
||||
} else if (item.type === 'shield') {
|
||||
this.player.equipment.shield = item;
|
||||
this.ui.log(`You equipped ${item.name}.`);
|
||||
} else if (item.type === 'potion') {
|
||||
if (item.stats.heal) {
|
||||
this.player.hp = Math.min(this.player.hp + item.stats.heal, this.player.maxHp);
|
||||
this.ui.log(`You drank ${item.name} and recovered ${item.stats.heal} HP.`);
|
||||
// Remove potion
|
||||
this.player.inventory.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.ui.updateInventory(this.player);
|
||||
this.ui.updateStats(this.player, this.depth);
|
||||
this.render(); // Re-render to show equipped status if we add that
|
||||
}
|
||||
|
||||
attack(attacker, defender) {
|
||||
// Peasants are invincible
|
||||
if (defender.name === "Peasant") {
|
||||
const quotes = [
|
||||
"This is our town.",
|
||||
"The Dungeon Lord is oppressing us! Please help!",
|
||||
"You can get healing at the temple.",
|
||||
"There are things to buy in the shops.",
|
||||
"It's unpleasant for peasants at present.",
|
||||
"Hello, hero!"
|
||||
];
|
||||
const quote = quotes[Math.floor(Math.random() * quotes.length)];
|
||||
this.ui.showPopup(quote, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
let damage = 0;
|
||||
if (attacker === this.player) {
|
||||
damage = this.player.getDamage() + Math.floor(Math.random() * 2);
|
||||
} else {
|
||||
damage = 1 + Math.floor(Math.random() * 2); // Monster damage
|
||||
}
|
||||
|
||||
// Apply defense
|
||||
const defense = defender.getDefense ? defender.getDefense() : 0;
|
||||
damage = Math.max(1, damage - defense); // Always take at least 1 damage
|
||||
|
||||
defender.hp -= damage;
|
||||
this.ui.log(`${attacker.name} hits ${defender.name} for ${damage} damage.`);
|
||||
|
||||
if (defender.hp <= 0) {
|
||||
this.ui.log(`${defender.name} dies!`);
|
||||
if (defender === this.player) {
|
||||
this.ui.log("GAME OVER");
|
||||
this.gameOver = true;
|
||||
setTimeout(() => alert("Game Over! Refresh to restart."), 100);
|
||||
} else {
|
||||
// Remove monster
|
||||
this.monsters = this.monsters.filter(m => m !== defender);
|
||||
|
||||
// Gold Drop
|
||||
const gold = defender.level * (Math.floor(Math.random() * 5) + 1);
|
||||
this.player.gold += gold;
|
||||
this.ui.log(`You kill the ${defender.name}! It drops ${gold} gold.`);
|
||||
|
||||
// Boss Drop
|
||||
if (defender.name === "The Dungeon Lord") {
|
||||
const map = new Item("Ancient Map", "map", 1, {});
|
||||
map.x = defender.x;
|
||||
map.y = defender.y;
|
||||
this.items.push(map);
|
||||
this.ui.log("The Dungeon Lord drops an Ancient Map!");
|
||||
}
|
||||
|
||||
// Award XP
|
||||
if (this.player.gainXp(defender.level * 5)) {
|
||||
this.ui.log(`You reached level ${this.player.level}!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateMonsters() {
|
||||
for (const monster of this.monsters) {
|
||||
if (monster.name === "Peasant") {
|
||||
// Random walk (Slow: 10% chance to move)
|
||||
if (Math.random() < 0.1) {
|
||||
const dx = Math.floor(Math.random() * 3) - 1;
|
||||
const dy = Math.floor(Math.random() * 3) - 1;
|
||||
const newX = monster.x + dx;
|
||||
const newY = monster.y + dy;
|
||||
if (this.map.isWalkable(newX, newY) && !this.monsters.some(m => m.x === newX && m.y === newY) && (newX !== this.player.x || newY !== this.player.y)) {
|
||||
monster.x = newX;
|
||||
monster.y = newY;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Simple chase logic
|
||||
const dx = this.player.x - monster.x;
|
||||
const dy = this.player.y - monster.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 8) { // Aggro range
|
||||
let moveX = 0;
|
||||
let moveY = 0;
|
||||
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
moveX = dx > 0 ? 1 : -1;
|
||||
} else {
|
||||
moveY = dy > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
const newX = monster.x + moveX;
|
||||
const newY = monster.y + moveY;
|
||||
|
||||
if (newX === this.player.x && newY === this.player.y) {
|
||||
this.attack(monster, this.player);
|
||||
} else if (this.map.isWalkable(newX, newY)) {
|
||||
// Check if occupied by another monster
|
||||
if (!this.monsters.some(m => m.x === newX && m.y === newY)) {
|
||||
monster.x = newX;
|
||||
monster.y = newY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ui.renderMap(this.map, this.player, this.monsters, this.items, this.tileSize);
|
||||
this.ui.updateStats(this.player, this.depth);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export class Item {
|
||||
constructor(name, type, level, stats) {
|
||||
this.name = name;
|
||||
this.type = type; // 'weapon', 'armor', 'potion'
|
||||
this.level = level;
|
||||
this.stats = stats || {}; // { damage: 5 } or { defense: 2 }
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.symbol = '?';
|
||||
this.color = '#ffff00';
|
||||
|
||||
if (type === 'weapon') this.symbol = ')';
|
||||
if (type === 'armor') this.symbol = '[';
|
||||
if (type === 'shield') this.symbol = ']';
|
||||
if (type === 'potion') this.symbol = '!';
|
||||
if (type === 'map') { this.symbol = '?'; this.color = '#ffd700'; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
export class Map {
|
||||
constructor(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.tiles = [];
|
||||
this.rooms = [];
|
||||
}
|
||||
|
||||
generate() {
|
||||
this.rooms = [];
|
||||
// Initialize with walls
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
this.tiles[y] = [];
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
this.tiles[y][x] = '#';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ROOMS = 10;
|
||||
const MIN_SIZE = 6;
|
||||
const MAX_SIZE = 12;
|
||||
|
||||
for (let i = 0; i < MAX_ROOMS; i++) {
|
||||
const w = Math.floor(Math.random() * (MAX_SIZE - MIN_SIZE + 1)) + MIN_SIZE;
|
||||
const h = Math.floor(Math.random() * (MAX_SIZE - MIN_SIZE + 1)) + MIN_SIZE;
|
||||
const x = Math.floor(Math.random() * (this.width - w - 1)) + 1;
|
||||
const y = Math.floor(Math.random() * (this.height - h - 1)) + 1;
|
||||
|
||||
const newRoom = { x, y, w, h };
|
||||
|
||||
// Check overlap
|
||||
let failed = false;
|
||||
for (const other of this.rooms) {
|
||||
if (newRoom.x <= other.x + other.w && newRoom.x + newRoom.w >= other.x &&
|
||||
newRoom.y <= other.y + other.h && newRoom.y + newRoom.h >= other.y) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
this.createRoom(newRoom);
|
||||
|
||||
if (this.rooms.length > 0) {
|
||||
const prev = this.rooms[this.rooms.length - 1];
|
||||
const newCenter = { x: Math.floor(newRoom.x + newRoom.w / 2), y: Math.floor(newRoom.y + newRoom.h / 2) };
|
||||
const prevCenter = { x: Math.floor(prev.x + prev.w / 2), y: Math.floor(prev.y + prev.h / 2) };
|
||||
|
||||
if (Math.random() < 0.5) {
|
||||
this.createHKorridor(prevCenter.x, newCenter.x, prevCenter.y);
|
||||
this.createVKorridor(prevCenter.y, newCenter.y, newCenter.x);
|
||||
} else {
|
||||
this.createVKorridor(prevCenter.y, newCenter.y, prevCenter.x);
|
||||
this.createHKorridor(prevCenter.x, newCenter.x, newCenter.y);
|
||||
}
|
||||
}
|
||||
|
||||
this.rooms.push(newRoom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateTown() {
|
||||
this.rooms = [];
|
||||
// Fill with grass/floor
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
this.tiles[y] = [];
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
this.tiles[y][x] = '.';
|
||||
}
|
||||
}
|
||||
|
||||
// Create buildings
|
||||
const numBuildings = 5;
|
||||
for (let i = 0; i < numBuildings; i++) {
|
||||
const w = Math.floor(Math.random() * 6) + 5;
|
||||
const h = Math.floor(Math.random() * 6) + 5;
|
||||
const x = Math.floor(Math.random() * (this.width - w - 2)) + 1;
|
||||
const y = Math.floor(Math.random() * (this.height - h - 2)) + 1;
|
||||
|
||||
// Check overlap (simple check against other buildings)
|
||||
// For now, just place them. If they overlap, they merge.
|
||||
|
||||
// Draw Walls
|
||||
for (let by = y; by < y + h; by++) {
|
||||
for (let bx = x; bx < x + w; bx++) {
|
||||
if (by === y || by === y + h - 1 || bx === x || bx === x + w - 1) {
|
||||
this.tiles[by][bx] = '#';
|
||||
} else {
|
||||
this.tiles[by][bx] = '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Door (randomly on one side)
|
||||
if (Math.random() < 0.5) {
|
||||
// Horizontal wall door
|
||||
const doorX = Math.floor(Math.random() * (w - 2)) + x + 1;
|
||||
const doorY = Math.random() < 0.5 ? y : y + h - 1;
|
||||
this.tiles[doorY][doorX] = '.';
|
||||
} else {
|
||||
// Vertical wall door
|
||||
const doorY = Math.floor(Math.random() * (h - 2)) + y + 1;
|
||||
const doorX = Math.random() < 0.5 ? x : x + w - 1;
|
||||
this.tiles[doorY][doorX] = '.';
|
||||
}
|
||||
|
||||
// Add to rooms list so we can spawn things inside if we want
|
||||
this.rooms.push({ x, y, w, h });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createRoom(room) {
|
||||
for (let y = room.y; y < room.y + room.h; y++) {
|
||||
for (let x = room.x; x < room.x + room.w; x++) {
|
||||
this.tiles[y][x] = '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createHKorridor(x1, x2, y) {
|
||||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||||
this.tiles[y][x] = '.';
|
||||
}
|
||||
}
|
||||
|
||||
createVKorridor(y1, y2, x) {
|
||||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
||||
this.tiles[y][x] = '.';
|
||||
}
|
||||
}
|
||||
|
||||
isWalkable(x, y) {
|
||||
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return false;
|
||||
return this.tiles[y][x] === '.' || this.tiles[y][x] === '>';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
export class UI {
|
||||
constructor(ctx, game) {
|
||||
this.ctx = ctx;
|
||||
this.game = game;
|
||||
this.messageLog = document.getElementById('message-log');
|
||||
this.inventoryList = document.getElementById('inventory-list');
|
||||
this.popup = null;
|
||||
this.popupTimeout = null;
|
||||
}
|
||||
|
||||
log(message) {
|
||||
if (!this.messageLog) return;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = message;
|
||||
this.messageLog.appendChild(div);
|
||||
this.messageLog.scrollTop = this.messageLog.scrollHeight;
|
||||
}
|
||||
|
||||
renderMap(map, player, monsters, items, tileSize) {
|
||||
// Fill background
|
||||
this.ctx.fillStyle = '#000000';
|
||||
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
||||
|
||||
this.ctx.font = `${tileSize}px monospace`;
|
||||
this.ctx.textBaseline = 'top';
|
||||
|
||||
// Calculate viewport (center on player)
|
||||
const viewWidth = Math.ceil(this.ctx.canvas.width / tileSize);
|
||||
const viewHeight = Math.ceil(this.ctx.canvas.height / tileSize);
|
||||
|
||||
const startX = player.x - Math.floor(viewWidth / 2);
|
||||
const startY = player.y - Math.floor(viewHeight / 2);
|
||||
|
||||
for (let y = 0; y < viewHeight; y++) {
|
||||
for (let x = 0; x < viewWidth; x++) {
|
||||
const mapX = startX + x;
|
||||
const mapY = startY + y;
|
||||
|
||||
if (mapX >= 0 && mapX < map.width && mapY >= 0 && mapY < map.height) {
|
||||
const tile = map.tiles[mapY][mapX];
|
||||
this.drawTile(x * tileSize, y * tileSize, tile, tileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Items
|
||||
if (items) {
|
||||
for (const item of items) {
|
||||
const screenX = (item.x - startX) * tileSize;
|
||||
const screenY = (item.y - startY) * tileSize;
|
||||
if (screenX >= -tileSize && screenX < this.ctx.canvas.width &&
|
||||
screenY >= -tileSize && screenY < this.ctx.canvas.height) {
|
||||
this.drawEntity(screenX, screenY, item, tileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Monsters
|
||||
for (const monster of monsters) {
|
||||
const screenX = (monster.x - startX) * tileSize;
|
||||
const screenY = (monster.y - startY) * tileSize;
|
||||
// Only draw if visible
|
||||
if (screenX >= -tileSize && screenX < this.ctx.canvas.width &&
|
||||
screenY >= -tileSize && screenY < this.ctx.canvas.height) {
|
||||
this.drawEntity(screenX, screenY, monster, tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Player
|
||||
const playerScreenX = (player.x - startX) * tileSize;
|
||||
const playerScreenY = (player.y - startY) * tileSize;
|
||||
|
||||
this.drawEntity(playerScreenX, playerScreenY, player, tileSize);
|
||||
|
||||
this.drawPopup();
|
||||
}
|
||||
|
||||
updateStats(player, depth) {
|
||||
document.getElementById('stat-hp').textContent = player.hp;
|
||||
document.getElementById('stat-max-hp').textContent = player.maxHp;
|
||||
document.getElementById('stat-str').textContent = player.stats.str;
|
||||
document.getElementById('stat-level').textContent = player.level;
|
||||
document.getElementById('stat-xp').textContent = player.xp;
|
||||
document.getElementById('stat-max-xp').textContent = player.maxXp;
|
||||
document.getElementById('stat-ac').textContent = player.getDefense();
|
||||
document.getElementById('stat-floor').textContent = depth;
|
||||
document.getElementById('stat-gold').textContent = player.gold;
|
||||
}
|
||||
|
||||
updateInventory(player) {
|
||||
this.inventoryList.innerHTML = '';
|
||||
player.inventory.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = item.name;
|
||||
if (player.equipment.weapon === item || player.equipment.armor === item || player.equipment.shield === item) {
|
||||
li.textContent += " (E)";
|
||||
}
|
||||
li.style.cursor = 'pointer';
|
||||
li.onclick = () => {
|
||||
this.game.useItem(index);
|
||||
};
|
||||
this.inventoryList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
drawTile(screenX, screenY, tile, size) {
|
||||
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();
|
||||
|
||||
} else if (tile === '>') {
|
||||
// Stairs
|
||||
this.ctx.fillStyle = '#202020';
|
||||
this.ctx.fillRect(screenX, screenY, size, size);
|
||||
this.ctx.fillStyle = '#ffffff';
|
||||
this.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);
|
||||
}
|
||||
}
|
||||
|
||||
showPopup(text, duration) {
|
||||
this.popup = text;
|
||||
if (this.popupTimeout) clearTimeout(this.popupTimeout);
|
||||
this.game.render();
|
||||
|
||||
this.popupTimeout = setTimeout(() => {
|
||||
this.popup = null;
|
||||
this.game.render();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
drawPopup() {
|
||||
if (!this.popup) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
ctx.font = '16px "Courier New", monospace';
|
||||
const textMetrics = ctx.measureText(this.popup);
|
||||
const textWidth = textMetrics.width;
|
||||
|
||||
const width = textWidth + 40; // Add padding
|
||||
const height = 50;
|
||||
const x = (this.ctx.canvas.width - width) / 2;
|
||||
const y = (this.ctx.canvas.height - height) / 2 - 50; // Slightly above center
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x, y, width, height);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(this.popup, x + width / 2, y + height / 2);
|
||||
|
||||
// Reset text align
|
||||
ctx.textAlign = 'start';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
drawEntity(screenX, screenY, entity, size) {
|
||||
this.ctx.fillStyle = entity.color;
|
||||
this.ctx.fillText(entity.symbol, screenX + size / 4, screenY);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Game } from './Game.js';
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const game = new Game();
|
||||
game.start();
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
:root {
|
||||
--win-bg: #c0c0c0;
|
||||
--win-text: #000000;
|
||||
--win-gray-light: #dfdfdf;
|
||||
--win-gray-dark: #808080;
|
||||
--win-blue: #000080;
|
||||
--win-white: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #008080; /* Classic teal desktop */
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* Fallback to modern sans */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 900px;
|
||||
height: 700px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
background: var(--win-blue);
|
||||
padding: 3px 2px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--win-white);
|
||||
font-weight: bold;
|
||||
font-family: sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.title-bar-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.window-body {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Inset Border (Sunken look) */
|
||||
.inset-border {
|
||||
border: 2px solid var(--win-gray-dark);
|
||||
border-right-color: var(--win-white);
|
||||
border-bottom-color: var(--win-white);
|
||||
background: var(--win-white); /* Canvas background */
|
||||
}
|
||||
|
||||
#main-layout {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0; /* Fix flex overflow */
|
||||
}
|
||||
|
||||
#viewport-container {
|
||||
flex: 1;
|
||||
background: #000; /* Game background */
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Group Box */
|
||||
.group-box {
|
||||
border: 2px solid var(--win-gray-light);
|
||||
border-top-color: var(--win-white);
|
||||
border-left-color: var(--win-white);
|
||||
border-right-color: var(--win-gray-dark);
|
||||
border-bottom-color: var(--win-gray-dark);
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.group-box-label {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
background: var(--win-bg);
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
#message-log {
|
||||
height: 100px;
|
||||
padding: 5px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
border-top: 1px solid var(--win-gray-dark);
|
||||
padding: 2px 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
Loading…
Reference in New Issue