Skip to content

Instantly share code, notes, and snippets.

@Jchronos
Created January 12, 2026 04:32
Show Gist options
  • Select an option

  • Save Jchronos/452cf1f53fb81af49d7de896580087a0 to your computer and use it in GitHub Desktop.

Select an option

Save Jchronos/452cf1f53fb81af49d7de896580087a0 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Dungeon Ascension: V36 - Enhanced Level Design</title>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
:root {
--bg: #050405;
--panel: #1a1621;
--border: #4a3d54;
--accent: #ffcc00;
--text: #d0c8d6;
--hp: #d94e4e;
--xp: #3b9cfa;
--mana: #3b9cfa;
--slot-size: 48px;
--chest-brown: #b5651d;
--chest-gold: #ffd700;
--chest-silver: #c0c0c0;
}
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: 'Press Start 2P', cursive; overflow: hidden;
touch-action: none; user-select: none;
display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;
}
#game-container {
position: relative; width: 100%; max-width: 450px; height: 100%; max-height: 850px;
display: flex; flex-direction: column; background: #000;
box-shadow: 0 0 50px rgba(0,0,0,0.8); border: 2px solid #222;
}
#canvas-wrapper { position: relative; flex-grow: 1; overflow: hidden; background: #110f14; }
canvas { width: 100%; height: 100%; image-rendering: pixelated; display: block; }
/* HUD */
#ui-layer {
position: absolute; top:0; left:0; width:100%; height:100%; pointer-events: none; z-index: 10;
display: flex; flex-direction: column;
}
#hud-top {
background: linear-gradient(to bottom, rgba(0,0,0,0.9), transparent);
padding: 12px; display: flex; justify-content: space-between; align-items: flex-start;
text-shadow: 2px 2px 0 #000;
}
.hud-col { display: flex; flex-direction: column; gap: 6px; }
.bar-frame { width: 100px; height: 10px; background: #222; border: 2px solid #000; position: relative; }
.bar-fill { height: 100%; transition: width 0.2s ease-out; }
#hp-fill { background: var(--hp); }
#mana-fill { background: var(--mana); }
#xp-fill { background: var(--xp); }
.hud-txt { font-size: 10px; color: #fff; }
/* Point Alert */
#point-alert {
position: absolute;
top: 35px;
right: 120px;
padding: 4px 8px;
background: rgba(100, 0, 150, 0.9);
border: 2px solid #fff;
color: #fff;
font-size: 8px;
text-shadow: 1px 1px 0 #000;
pointer-events: none;
animation: pulse 1s infinite;
display: none;
z-index: 15;
white-space: nowrap;
transform: translate(50%, 0);
}
@keyframes pulse {
0% { opacity: 1; border-color: #fff; }
50% { opacity: 0.7; border-color: #f0f; }
100% { opacity: 1; border-color: #fff; }
}
/* CONTROLS */
#controls-area {
height: 200px;
background: var(--panel); border-top: 4px solid var(--border);
display: flex; padding: 10px; box-sizing: border-box;
position: relative;
}
.control-row {
display: flex; justify-content: space-between; align-items: center; height: 100%; width:100%;
gap: 10px;
}
/* D-PAD */
.dpad {
display: grid;
grid-template-columns: repeat(3, 50px);
grid-template-rows: repeat(3, 45px);
gap: 4px;
}
.btn-d {
background: #352b40; border: 2px solid #4a3d54; border-radius: 6px;
color: #888; font-size: 20px; display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 0 #1e1824; cursor: pointer;
}
.btn-d:active { transform: translateY(4px); box-shadow: none; background: #4a3d54; }
/* ACTIONS STACK */
.actions {
display: flex;
flex-direction: column;
gap: 6px;
width: 130px;
margin-left: auto;
padding-top: 15px;
}
.btn-act {
width: 100%; height: 35px;
background: #4a3d14; border: 2px solid #7a5e14; border-radius: 6px;
color: #ffd; font-size: 9px;
display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 4px 0 #2b220b; cursor: pointer; text-shadow: 1px 1px 0 #000;
text-align: center;
}
.btn-act:active { transform: translateY(4px); box-shadow: none; }
.btn-inv { background: #2d2d3d; border-color: #445; box-shadow: 0 4px 0 #181821; }
.btn-quit { background: #454545; border-color: #777; box-shadow: 0 4px 0 #282828; }
/* SHOOT BUTTON */
#btn-shoot {
position: absolute;
right: 140px;
top: 10px;
width: 60px;
height: 60px;
border-radius: 50%;
opacity: 0.9;
z-index: 20;
background: var(--panel);
border: 2px solid var(--border);
color: var(--accent);
box-shadow: 0 4px 0 #1e1824;
font-size: 8px;
line-height: 1.1;
display: none;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
}
#btn-shoot:active {
transform: translateY(4px);
box-shadow: none;
background: #2a2631;
}
/* MODALS - FIXED POSITIONING */
.modal {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
z-index: 100; background: rgba(10,8,12,0.98);
display: none;
flex-direction: column; padding: 20px; align-items: center; justify-content: center;
overflow: hidden;
}
.modal-title { font-size: 18px; color: var(--accent); margin-bottom: 20px; text-shadow: 0 2px 0 #000; text-align:center; }
/* INVENTORY/STATS CONTAINER */
#menu-inv, #menu-merchant { align-items: center; }
#modal-content {
width: 100%; max-width: 400px;
max-height: 70vh;
background: #111; padding: 10px; border: 2px solid #333;
overflow-y: auto;
}
/* TABS */
#modal-tabs { display: flex; margin-bottom: 10px; border-bottom: 2px solid var(--border); }
.tab-btn {
flex-grow: 1; padding: 10px 0; background: #222; border: none;
color: #888; font-size: 10px; font-family: inherit; cursor: pointer;
border-right: 1px solid #111;
}
.tab-btn.active { background: #333; color: var(--accent); border-bottom: 2px solid var(--accent); margin-bottom: -2px; }
.tab-content { display: none; padding: 10px 0; }
.tab-content.active { display: block; }
/* STATS GRID */
#stats-container { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
#attribute-grid .stat-row, #derived-stats-grid .stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0; border-bottom: 1px dotted #222;
font-size: 9px;
}
.stat-val { color: #fff; font-size: 10px; }
.stat-btn {
background: var(--accent); color: #000; border: none;
width: 18px; height: 18px; line-height: 10px;
font-size: 14px; border-radius: 4px; cursor: pointer;
}
.stat-btn:disabled { background: #555; cursor: default; }
/* INVENTORY & GEAR */
#equip-slots {
display: grid;
grid-template-columns: var(--slot-size) 1fr var(--slot-size);
gap: 10px;
padding: 10px;
border: 1px solid #333;
background: #1e1e24;
justify-content: center;
}
#equip-helm { grid-column: 1 / 2; grid-row: 1; margin-bottom: 10px; }
#equip-necklace { grid-column: 2 / 3; grid-row: 1; margin: 0 auto 10px auto; }
#equip-weapon { grid-column: 3 / 4; grid-row: 1; margin-bottom: 10px; }
#equip-armor { grid-column: 2 / 3; grid-row: 2; margin: 0 auto; }
#equip-grieves { grid-column: 2 / 3; grid-row: 3; margin: 10px auto; }
#equip-ring, #equip-ring2 { grid-row: 2 / 4; margin: auto; }
#equip-ring { grid-column: 1 / 2; }
#equip-ring2 { grid-column: 3 / 4; }
.equip-slot {
width: var(--slot-size); height: var(--slot-size);
background: #333; border: 1px dashed #444;
display: flex; align-items: center; justify-content: center;
position: relative; font-size: 20px; cursor: pointer;
}
.equip-slot-name {
position: absolute; top: 1px;
font-size: 6px; color: #888; z-index: 2;
text-align: center; width: 100%;
}
/* New Button Location CSS */
#btn-equip-best-inv {
width: 100%; height: 30px;
background: #235; border: 2px solid #457; color: #fff; font-size: 8px; margin-top: 10px;
cursor: pointer; box-shadow: 0 3px 0 #124;
}
#btn-equip-best-inv:active { transform: translateY(3px); box-shadow: none; }
.inv-grid, #merchant-grid {
display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px;
max-height: 200px;
overflow-y: auto;
background: #222; padding: 10px; border: 1px solid #333;
}
.inv-slot {
width: var(--slot-size); height: var(--slot-size);
background: #333; border: 1px solid #444; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 20px; position: relative;
}
.inv-slot.selected { border-color: var(--accent); background: #444; }
.inv-qty { position: absolute; bottom: 2px; right: 2px; font-size: 8px; color: #fff; }
.inv-usage {
position: absolute; top: 2px; left: 2px; font-size: 8px;
color: #0f0; background: #0009; padding: 1px 3px; border-radius: 2px;
}
#item-info-panel, #gear-info-panel, #merchant-info-panel {
margin-top: 15px; padding: 10px; background: #222; border: 1px solid #444;
display: flex; flex-direction: column; align-items: center;
}
#item-info-panel div:nth-child(2), #gear-info-panel div:nth-child(2) {
font-size: 9px;
text-align: center;
color: #aaa;
}
/* MERCHANT SPECIFIC STYLES */
#merchant-dialogue { font-size: 9px; color: #ccc; margin-bottom: 15px; }
.merchant-item {
background: #333; color: #fff; padding: 8px; margin-bottom: 5px;
display: flex; justify-content: space-between; align-items: center; font-size: 9px;
border: 1px solid #555; cursor: pointer;
}
.merchant-item.selected { border-color: var(--accent); background: #444; }
.price { color: var(--accent); }
/* MAIN MENU STYLES */
#menu-start {
background: linear-gradient(rgba(10,8,12,0.98), var(--panel));
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
}
#menu-start h1 {
font-size: 32px;
color: var(--accent);
margin-bottom: 10px;
text-shadow: 4px 4px 0 #521, -2px -2px 0 #000;
text-align: center;
}
#menu-start p {
color: #777;
font-size: 10px;
margin-bottom: 40px;
text-align: center;
}
.class-card {
width: 100%;
max-width: 300px;
height: 70px;
margin-bottom: 15px;
background: #2d2d3d;
border: 4px solid var(--border);
box-shadow: 0 6px 0 #181821;
cursor: pointer;
display: flex;
align-items: center;
padding: 0 15px;
transition: all 0.1s ease;
}
.class-card:hover {
background: #353545;
border-color: var(--accent);
}
.class-card:active {
transform: translateY(6px);
box-shadow: none;
background: #444;
}
.class-card > div:first-child {
font-size: 36px;
margin-right: 20px;
text-shadow: 2px 2px 0 #000;
}
.class-card > div:last-child {
text-align: left;
}
/* Menu Instruction */
.menu-instruction {
color: #aaa;
margin-bottom: 20px;
font-size: 12px;
text-align: center;
font-family: 'Press Start 2P', cursive;
}
/* Close Button */
.btn-close {
background: #444; border: 2px solid #666; color: #fff;
padding: 10px 30px; margin-top: 20px; font-size: 10px;
cursor: pointer; font-family: inherit;
}
.btn-close:active { transform: translateY(2px); }
</style>
</head>
<body>
<div id="game-container">
<div id="ui-layer">
<div id="hud-top">
<div class="hud-col">
<div class="hud-txt">HP <span id="hp-txt">100/100</span></div>
<div class="bar-frame"><div id="hp-fill" class="bar-fill" style="width:100%"></div></div>
<div class="hud-txt" style="color:var(--mana); margin-top: 5px;">MN <span id="mana-txt">10/10</span></div>
<div class="bar-frame" style="border-color:#112"><div id="mana-fill" class="bar-fill" style="width:100%"></div></div>
</div>
<div class="hud-col" style="align-items:center;">
<div class="hud-txt" style="color:var(--accent)">DEPTH <span id="depth-txt">1</span></div>
<div class="hud-sub" id="gold-txt" style="font-size:8px;color:#aaa">0 G</div>
</div>
<div class="hud-col" style="align-items:flex-end">
<div class="hud-txt">LVL <span id="lvl-txt">1</span></div>
<div class="bar-frame" style="width:80px; border-color:#223"><div id="xp-fill" class="bar-fill" style="width:0%"></div></div>
</div>
</div>
<div id="point-alert"></div>
</div>
<div id="canvas-wrapper">
<canvas id="gameCanvas" width="384" height="480"></canvas>
<canvas id="spriteSheet" width="192" height="192" style="display:none"></canvas>
</div>
<div id="controls-area">
<div class="control-row">
<div class="dpad">
<div></div>
<div class="btn-d"
onmousedown="startMove(0,-1)" onmouseup="stopMove()"
ontouchstart="startMove(0,-1)" ontouchend="stopMove()">▲</div>
<div></div>
<div class="btn-d"
onmousedown="startMove(-1,0)" onmouseup="stopMove()"
ontouchstart="startMove(-1,0)" ontouchend="stopMove()">◀</div>
<div></div>
<div class="btn-d"
onmousedown="startMove(1,0)" onmouseup="stopMove()"
ontouchstart="startMove(1,0)" ontouchend="stopMove()">▶</div>
<div></div>
<div class="btn-d"
onmousedown="startMove(0,1)" onmouseup="stopMove()"
ontouchstart="startMove(0,1)" ontouchend="stopMove()">▼</div>
<div></div>
</div>
<div class="actions">
<div class="btn-act" onclick="interact()">⚔️ ATK</div>
<div class="btn-act btn-inv" onclick="openInventory('stats')">🎒 INV <div id="alert-stats" class="alert-icon"></div></div>
<div class="btn-act btn-quit" onclick="quitGame()">🚪 QUIT</div>
</div>
</div>
<button id="btn-shoot" onclick="shoot()">🔥 SHOOT</button>
</div>
<div id="menu-start" class="modal" style="display: flex;">
<h1>DUNGEON<br>ASCENSION</h1>
<p>V36: ENHANCED LEVEL DESIGN</p>
<div class="menu-instruction">Select a class to begin your adventure</div>
<div class="class-card" onclick="startGame('warrior')">
<div>🛡️</div>
<div><div style="color:#eaa">WARRIOR</div><div style="font-size:8px; color:#888">High HP & Defense (Tank)</div></div>
</div>
<div class="class-card" onclick="startGame('rogue')">
<div>🗡️</div>
<div><div style="color:#8f8">ROGUE</div><div style="font-size:8px; color:#888">High Crit & Dodge (Middle)</div></div>
</div>
<div class="class-card" onclick="startGame('mage')">
<div>🔥</div>
<div><div style="color:#f9f">MAGE</div><div style="font-size:8px; color:#888">High Damage & Mana (Glass Cannon)</div></div>
</div>
</div>
<div id="menu-inv" class="modal">
<div class="modal-title">HERO MENU</div>
<div id="modal-content">
<div id="modal-tabs">
<button class="tab-btn active" data-tab="stats" onclick="switchTab('stats')">🛡️ STATS</button>
<button class="tab-btn" data-tab="inventory" onclick="switchTab('inventory')">🎒 INVENTORY</button>
<button class="tab-btn" data-tab="gear" onclick="switchTab('gear')">⚔️ GEAR</button>
</div>
<div id="tab-stats" class="tab-content active">
<div style="margin-bottom:10px; color:#888; font-size:10px">
POINTS AVAILABLE: <span id="stat-pts" style="color:#fff">0</span>
<div style="font-size:8px; color:#555; margin-top:5px;">Click + to spend points</div>
</div>
<div id="stats-container">
<div id="attribute-grid"></div>
<div id="derived-stats-grid"></div>
</div>
</div>
<div id="tab-inventory" class="tab-content">
<div class="inv-grid" id="inv-container"></div>
<button id="btn-equip-best-inv" onclick="equipBestItems()">✨ EQUIP BEST ITEMS (ALL SLOTS)</button>
<div id="item-info-panel">
<div id="item-name">No item selected</div>
<div id="item-stats-desc" style="font-size:9px;">Select an item from your bag to see its details and action button.</div>
<button id="btn-use-item" class="btn-close" style="padding:10px 20px; margin-top:10px; display:none" onclick="handleItemAction()">USE / EQUIP</button>
</div>
</div>
<div id="tab-gear" class="tab-content">
<div id="equip-slots">
<div id="equip-helm" data-slot="helm" class="equip-slot" onclick="selectItem(player.equip.helm, 'helm')"><span class="equip-slot-name">HELM</span><span class="equip-icon"></span></div>
<div id="equip-necklace" data-slot="necklace" class="equip-slot" onclick="selectItem(player.equip.necklace, 'necklace')"><span class="equip-slot-name">NECKLACE</span><span class="equip-icon"></span></div>
<div id="equip-weapon" data-slot="weapon" class="equip-slot" onclick="selectItem(player.equip.weapon, 'weapon')"><span class="equip-slot-name">WEAPON</span><span class="equip-icon"></span></div>
<div id="equip-ring" data-slot="ring" class="equip-slot" onclick="selectItem(player.equip.ring, 'ring')"><span class="equip-slot-name">RING (L)</span><span class="equip-icon"></span></div>
<div id="equip-armor" data-slot="armor" class="equip-slot" onclick="selectItem(player.equip.armor, 'armor')"><span class="equip-slot-name">ARMOR</span><span class="equip-icon"></span></div>
<div id="equip-ring2" data-slot="ring2" class="equip-slot" onclick="selectItem(player.equip.ring2, 'ring2')"><span class="equip-slot-name">RING (R)</span><span class="equip-icon"></span></div>
<div id="equip-grieves" data-slot="grieves" class="equip-slot" onclick="selectItem(player.equip.grieves, 'grieves')"><span class="equip-slot-name">GRIEVES</span><span class="equip-icon"></span></div>
</div>
<div id="gear-info-panel">
<div id="gear-name">Select an equipped item.</div>
<div id="gear-stats-desc" style="font-size:9px;">This tab shows your currently equipped gear and allows you to unequip items.</div>
<button id="btn-unequip-item" class="btn-close" style="padding:10px 20px; margin-top:10px; display:none" onclick="handleItemAction()">UNEQUIP</button>
</div>
</div>
</div>
<button class="btn-close" onclick="closeModal('menu-inv')">CLOSE</button>
</div>
<div id="menu-merchant" class="modal">
<h2 class="modal-title">THE WANDERING SHOPKEEPER</h2>
<p id="merchant-dialogue">"Welcome, weary traveler. I have wares for gold, and gold for wares. Trade with me!"</p>
<div id="modal-content" style="max-height: 70vh;">
<div id="modal-tabs">
<button class="tab-btn active" data-tab="buy" onclick="renderMerchant('buy')">💰 BUY ITEMS</button>
<button class="tab-btn" data-tab="sell" onclick="renderMerchant('sell')">🛍️ SELL ITEMS</button>
</div>
<div id="tab-buy" class="tab-content active">
<div id="merchant-buy-list" style="max-height: 250px; overflow-y: auto;"></div>
</div>
<div id="tab-sell" class="tab-content">
<div id="merchant-sell-list" style="max-height: 250px; overflow-y: auto;"></div>
</div>
<div id="merchant-info-panel">
<div id="merchant-item-name">No item selected</div>
<div id="merchant-stats-desc">Select an item above to see its value.</div>
<button id="btn-merchant-action" class="btn-close" style="padding:10px 20px; margin-top:10px; display:none" onclick="handleMerchantAction()">ACTION</button>
</div>
</div>
<button class="btn-close" onclick="closeModal('menu-merchant')">FAREWELL</button>
</div>
</div>
<script>
// ============================================
// FIXED VERSION WITH ALL BUG FIXES IMPLEMENTED
// ============================================
const cvs = document.getElementById('gameCanvas');
const ctx = cvs.getContext('2d');
const SS = document.getElementById('spriteSheet');
const S_CTX = SS.getContext('2d');
const TS = 32;
const W_MAP = 48, H_MAP = 48;
// Game State
let gameState = "MENU";
let map = [], fog = [], entities = [], particles = [], floaters = [], torches = [];
let projectiles = [];
let floor = 1, camX = 0, camY = 0;
let selItem = null;
let activeTab = 'stats';
let regenTimer = 0;
let merchantWares = [];
let lastDirection = {dx: 1, dy: 0};
let containers = [];
let bossDefeated = false;
// D-Pad movement state
let moveState = { dx: 0, dy: 0 };
let moveInterval = null;
let canMove = true;
const MOVE_DELAY = 200;
const ATTACK_DELAY = 400;
// Global tick counter
let lastFrameTime = performance.now();
let lastMoveTime = 0;
let lastAttackTime = 0;
// Storage for floor data
let floorData = {};
// Track dropped items per floor for uniqueness
let droppedItemsThisFloor = [];
// Level up notification queue
let levelUpQueue = [];
let isShowingLevelUp = false;
const BASE_PLAYER_STATE = {
x:1, y:1, rx:32, ry:32, class:'warrior',
hp:100, maxHp:100, mana:10, maxMana:10, xp:0, lvl:1, nextLvl:100, gold:0,
str:5, vit:5, agi:5, points:0,
atk:0, def:0, crit:0, regen:0, reflect: 0,
inv: [], equip: { weapon:null, armor:null, helm:null, ring:null, ring2:null, necklace:null, grieves:null },
statusEffects: {} // FIX: Added for class abilities
};
let player = {...BASE_PLAYER_STATE};
const PAL = {
bg: "#050405", floor: "#221e2b", floorBit: "#332d3d",
wall: "#15101a", wallTop: "#2a2233",
gold: "#ffcc00", blood: "#d94e4e", merchant: "#964b00",
projectileFire: "#ff8c00",
projectileFrost: "#4abaf5",
projectileShadow: "#9f5df5",
projectilePoison: "#50c878",
projectileLightning: "#ffea00"
};
/** ENHANCED ITEM DEFINITIONS WITH MANA POTION */
const ITEM_DEFS = {
'potion': { name: "Healing Potion", type: "potion", val: 40, icon: "🧪", stackable: true, sort: 1, baseValue: 50 },
'mana_potion': { name: "Mana Potion", type: "potion", val: 20, icon: "🧪", iconColor: "#00f", stackable: true, sort: 2, baseValue: 75, restores: 'mana' },
'simple_club': { name: "Simple Club", type: "weapon", slot: "weapon", atk: 1, icon: "🏏", stackable: false, sort: 10, baseValue: 10 },
'iron_sword': { name: "Iron Sword", type: "weapon", slot: "weapon", atk: 3, icon: "🗡️", stackable: false, sort: 10, baseValue: 50 },
// Wands with enhanced effects
'wand_fire': { name: "Wand of Fire", type: "weapon", slot: "weapon", atk: 2, icon: "🪄", iconColor: "#ff0000", stackable: false, sort: 11, baseValue: 120,
effect: { maxMana: 10, atk: 2, rangedCost: 2 }, isRanged: true, projColor: PAL.projectileFire, status: 'burn', element: 'fire' },
'wand_frost': { name: "Wand of Frost", type: "weapon", slot: "weapon", atk: 1, icon: "🪄", iconColor: "#00ccff", stackable: false, sort: 12, baseValue: 150,
effect: { maxMana: 15, def: 1, rangedCost: 3 }, isRanged: true, projColor: PAL.projectileFrost, status: 'frozen', element: 'ice' },
'wand_shadow': { name: "Wand of Shadow", type: "weapon", slot: "weapon", atk: 3, icon: "🪄", iconColor: "#6600cc", stackable: false, sort: 13, baseValue: 200,
effect: { maxMana: 5, crit: 5, rangedCost: 2 }, isRanged: true, projColor: PAL.projectileShadow, status: 'poison', element: 'poison' },
'wand_lightning': { name: "Wand of Lightning", type: "weapon", slot: "weapon", atk: 2, icon: "🪄", iconColor: "#ffff00", stackable: false, sort: 14, baseValue: 180,
effect: { maxMana: 12, atk: 1, rangedCost: 2 }, isRanged: true, projColor: PAL.projectileLightning, status: 'stun', element: 'lightning' },
// Other Weapons
'dagger_of_speed': { name: "Dagger of Speed", type: "weapon", slot: "weapon", atk: 1, icon: "🔪", stackable: false, sort: 15, baseValue: 80,
effect: { agi: 2, crit: 5 } },
'cloth_armor': { name: "Cloth Armor", type: "armor", slot: "armor", def: 1, icon: "🎽", stackable: false, sort: 20, baseValue: 15 },
'steel_armor': { name: "Steel Armor", type: "armor", slot: "armor", def: 2, icon: "🛡️", stackable: false, sort: 20, baseValue: 60 },
'mail_armor': { name: "Mail Armor", type: "armor", slot: "armor", def: 3, crit: -5, icon: "⛓️", stackable: false, sort: 21, baseValue: 100 },
'plate_armor': { name: "Plate Armor", type: "armor", slot: "armor", def: 5, icon: "🥋", stackable: false, sort: 22, baseValue: 180 },
'leather_helm': { name: "Leather Helm", type: "helm", slot: "helm", def: 1, icon: "🎩", stackable: false, sort: 25, baseValue: 20 },
'iron_helm': { name: "Iron Helm", type: "helm", slot: "helm", def: 2, icon: "⛑️", stackable: false, sort: 26, baseValue: 50 },
'leather_grieves': { name: "Leather Grieves", type: "grieves", slot: "grieves", def: 1, icon: "👢", stackable: false, sort: 28, baseValue: 20 },
'iron_grieves': { name: "Iron Grieves", type: "grieves", slot: "grieves", def: 2, icon: "🦿", stackable: false, sort: 29, baseValue: 50 },
'iron_ring': { name: "Iron Ring", type: "ring", slot: "ring", crit: 5, icon: "💍", stackable: false, sort: 30, baseValue: 40 },
'mana_ring': { name: "Mana Ring", type: "ring", slot: "ring2", effect: { maxMana: 10, regen: 1 }, icon: "🌀", stackable: false, sort: 31, baseValue: 80 },
'ring_of_might': { name: "Ring of Might", type: "ring", slot: "ring", effect: { str: 2, maxHp: 10 }, icon: "💎", stackable: false, sort: 32, baseValue: 150 },
'ring_of_furor': { name: "Ring of Furor", type: "ring", slot: "ring2", effect: { agi: 3, crit: 10 }, icon: "⚡", stackable: false, sort: 33, baseValue: 180 },
'amulet_life': { name: "Amulet of Life", type: "necklace", slot: "necklace", effect: { regen: 2, def: 1 }, icon: "📿", stackable: false, sort: 35, baseValue: 100 },
'amulet_of_thorns': { name: "Amulet of Thorns", type: "necklace", slot: "necklace", def: 1, icon: "📿", stackable: false, sort: 36, baseValue: 150,
effect: { reflect: 0.1 } },
'gold_pile': { name: "Gold Pile", type: "currency", val: 0, icon: "💰", stackable: true, sort: 50 }
};
/** CLASS SPECIFIC LOOT */
const CLASS_SPECIFIC_ITEMS = {
warrior: ['iron_sword', 'steel_armor', 'plate_armor', 'iron_helm', 'iron_grieves', 'ring_of_might'],
rogue: ['dagger_of_speed', 'mail_armor', 'ring_of_furor', 'amulet_of_thorns'],
mage: ['wand_fire', 'wand_frost', 'wand_shadow', 'wand_lightning', 'mana_ring', 'amulet_life']
};
/** ========== FIXED: PIXEL DUNGEON-STYLE MOVEMENT ========== */
function startMove(dx, dy) {
if (!canMove) return;
moveState = { dx, dy };
// Immediate movement on first press
attemptMove();
// Start continuous movement interval if not already running
if (!moveInterval) {
moveInterval = setInterval(() => {
if (moveState.dx !== 0 || moveState.dy !== 0) {
attemptMove();
}
}, MOVE_DELAY);
}
}
function stopMove() {
moveState = { dx: 0, dy: 0 };
if (moveInterval) {
clearInterval(moveInterval);
moveInterval = null;
}
}
function attemptMove() {
if (!canMove) return;
const now = Date.now();
if (now - lastMoveTime < MOVE_DELAY) return;
// Call input directly (no attack cooldown for movement)
input(moveState.dx, moveState.dy);
lastMoveTime = now;
}
/** GAME FUNCTIONS */
function bakeSprites() {
SS.width = 192;
SS.height = 192;
S_CTX.imageSmoothingEnabled = false;
const r = (ox, oy, x, y, w, h, c) => {
S_CTX.fillStyle=c;
S_CTX.fillRect(ox + x, oy + y, w, h);
};
// Walls
r(0,0, 0,0,32,32, "#1a1520");
r(0,0, 2,2,10,6, "#2a2530"); r(0,0, 14,4,8,8, "#2a2530"); r(0,0, 24,2,6,6, "#2a2530");
r(0,0, 6,10,7,9, "#3a3540"); r(0,0, 18,12,9,7, "#3a3540"); r(0,0, 2,20,8,10, "#3a3540");
r(0,0, 12,20,10,8, "#4a4550"); r(0,0, 24,18,6,10, "#4a4550");
r(0,0, 5,5,3,2, "#4a4550"); r(0,0, 17,7,4,3, "#4a4550"); r(0,0, 26,5,2,2, "#4a4550");
r(0,0, 9,13,4,3, "#4a4550"); r(0,0, 21,15,3,4, "#4a4550");
r(0,0, 1,1,30,1, "#0f0c14"); r(0,0, 1,1,1,30, "#0f0c14");
// Floor
r(32,0, 0,0,32,32, PAL.floor); r(32,0, 4,4,4,4, PAL.floorBit); r(32,0, 18,20,4,4, PAL.floorBit);
// Player Sprites
// Warrior
r(0,32, 8,4,16,14,"#aaa"); r(0,32, 12,8,8,4,"#222"); r(0,32, 6,18,20,14,"#888"); r(0,32, 10,18,12,14,"#933");
r(0,32, 11,7,2,2,"#fff"); r(0,32, 19,7,2,2,"#fff");
r(0,32, 13,10,6,2,"#b22");
r(0,32, 6,4,4,14,"#666"); r(0,32, 22,4,4,14,"#666");
// Rogue
r(32,32, 8,4,16,12,"#384"); r(32,32, 12,8,8,4,"#111"); r(32,32, 8,16,16,14,"#543"); r(32,32, 2,18,4,8,"#ccc");
r(32,32, 11,7,2,2,"#0f0"); r(32,32, 19,7,2,2,"#0f0");
r(32,32, 10,4,12,3,"#262");
r(32,32, 15,12,2,4,"#fff"); r(32,32, 13,14,6,2,"#fff");
// Mage
r(64,32, 10,2,12,12,"#639");
r(64,32, 8,14,16,16,"#426");
r(64,32, 13,5,2,2,"#fff");
r(64,32, 17,5,2,2,"#fff");
r(64,32, 14,8,4,2,"#9cf");
r(64,32, 15,9,2,1,"#cff");
r(64,32, 13,10,6,1,"#f0f");
r(64,32, 14,12,4,3,"#852");
r(64,32, 24,4,2,24,"#974");
r(64,32, 22,2,6,6,"#f0f");
r(64,32, 23,3,4,4,"#d4f");
r(64,32, 24,4,2,2,"#fff");
r(64,32, 8,14,2,16,"#639");
r(64,32, 22,14,2,16,"#639");
r(64,32, 14,16,4,4,"#f0f");
r(64,32, 12,20,8,2,"#639");
r(64,32, 14,22,4,6,"#528");
// Enemy Sprites
// Orc
r(32,64, 6,4,20,12,"#274");
r(32,64, 8,6,4,4,"#000");
r(32,64, 20,6,4,4,"#000");
r(32,64, 12,10,8,2,"#f00");
r(32,64, 4,16,24,14,"#163");
r(32,64, 10,18,12,4,"#555");
r(32,64, 26,12,4,16,"#654");
r(32,64, 28,8,2,4,"#ff0");
// Skeleton - FIXED: Added legs
r(0,64, 10,2,12,10,"#ddd"); // Head/upper body
r(0,64, 12,6,3,3,"#000"); // Left eye
r(0,64, 17,6,3,3,"#000"); // Right eye
r(0,64, 14,14,4,12,"#ccc"); // Torso
r(0,64, 10,16,12,2,"#ccc"); // Pelvis
// Legs
r(0,64, 12,22,3,10,"#bbb"); // Left leg
r(0,64, 17,22,3,10,"#bbb"); // Right leg
r(0,64, 12,28,3,4,"#aaa"); // Left foot
r(0,64, 17,28,3,4,"#aaa"); // Right foot
// NEW ENEMY: Ghost (Pixel Dungeon-inspired)
r(96,0, 8,4,16,12, "#b0ffff"); // Lighter core
r(96,0, 12,8,4,4, "#ffffff"); // Bright center
r(96,0, 10,14,12,10, "#0088cc"); // Lower translucent part
r(96,0, 14,6,4,2, "#000000"); // Eyes
r(96,0, 20,6,4,2, "#000000");
r(96,0, 12,10,8,1, "#ffffff"); // Mouth
r(96,0, 6,18,20,8, "#006699"); // Wispy tail
// Merchant
r(64,64, 8,4,16,16,"#ff0"); r(64,64, 10,18,12,14,PAL.merchant); r(64,64, 10,8,12,4,"#000");
// Bosses
r(96,64, 4,4,24,24, "#c00"); r(96,64, 8,8,16,8,"#000");
r(128,64, 2,2,28,28, "#fff"); r(128,64, 10,6,4,4,"#000"); r(128,64, 18,6,4,4,"#000");
// Containers
// Chest
r(0,96, 0,0,32,32, PAL.floor);
r(0,96, 8,12,16,12, "#8b4513");
r(0,96, 8,8,16,4, "#a0522d");
r(0,96, 14,10,4,2, "#ffd700");
r(0,96, 10,20,12,2, "#654321");
// Bag
r(32,96, 0,0,32,32, PAL.floor);
r(32,96, 10,14,12,10, "#5d4037");
r(32,96, 12,12,8,2, "#795548");
r(32,96, 14,10,4,2, "#d7ccc8");
// Crate
r(64,96, 0,0,32,32, PAL.floor);
r(64,96, 8,12,16,12, "#795548");
r(64,96, 8,8,16,2, "#5d4037");
r(64,96, 8,16,16,2, "#5d4037");
r(64,96, 8,12,2,12, "#5d4037");
r(64,96, 22,12,2,12, "#5d4037");
// Ladder
r(96,96, 0,0,32,32, PAL.floor);
const LADDER_SUPPORT = "#443b4d";
const LADDER_RUNG = "#776b7b";
r(96,96, 10, 0, 4, 32, LADDER_SUPPORT);
r(96,96, 18, 0, 4, 32, LADDER_SUPPORT);
r(96,96, 10, 4, 12, 4, LADDER_RUNG);
r(96,96, 10, 12, 12, 4, LADDER_RUNG);
r(96,96, 10, 20, 12, 4, LADDER_RUNG);
// Torch
r(128,96, 0,0,32,32, "#1a1520");
const STICK = "#725740";
const FIRE_ORANGE = "#ff8c00";
const FIRE_YELLOW = "#ffff00";
r(128,96, 14, 10, 4, 12, STICK);
r(128,96, 12, 22, 8, 2, STICK);
r(128,96, 13, 8, 6, 6, FIRE_ORANGE);
r(128,96, 15, 6, 2, 4, FIRE_YELLOW);
}
function createItem(key, floorLevel) {
const base = ITEM_DEFS[key];
if (!base) return null;
const item = {...base, id: Math.random() + Date.now()};
const scale = Math.max(1, Math.floor(floorLevel/2));
item.usage = 0;
item.upgradeTier = 0;
item.baseAtk = item.atk || 0;
item.baseDef = item.def || 0;
item.baseCrit = item.crit || 0;
item.upgradeBonuses = {atk: 0, def: 0, crit: 0};
if (item.atk) item.atk = (item.atk || 0) + scale;
if (item.def) item.def = (item.def || 0) + scale;
if (item.crit) item.crit = (item.crit || 0) + scale * 2;
item.baseValue = (item.baseValue || 1) + scale * 5;
item.identified = true;
return item;
}
function getBaseAttr(cls) {
if (cls === 'warrior') return { str: 8, vit: 8, agi: 2 };
if (cls === 'rogue') return { str: 4, vit: 4, agi: 8 };
if (cls === 'mage') return { str: 2, vit: 4, agi: 5 };
return { str: 5, vit: 5, agi: 5 };
};
function startGame(cls) {
console.log("Starting game with class:", cls);
player = {...BASE_PLAYER_STATE};
floor = 1;
floorData = {};
gameState = "PLAYING";
bossDefeated = false;
droppedItemsThisFloor = [];
// FIX: Reset level-up notifications
levelUpQueue = [];
isShowingLevelUp = false;
floaters = floaters.filter(f => !f.t.includes("LEVEL UP"));
player.class = cls;
const initialAttrs = getBaseAttr(cls);
player.str = initialAttrs.str;
player.vit = initialAttrs.vit;
player.agi = initialAttrs.agi;
if (cls === 'mage') {
player.maxMana = 25;
player.mana = 25;
}
player.inv = [
{...ITEM_DEFS.potion, qty:3, id: Math.random() + Date.now()},
{...createItem('simple_club', 0), qty:1},
{...createItem('cloth_armor', 0), qty:1},
{...createItem('leather_helm', 0), qty:1},
{...createItem('leather_grieves', 0), qty:1},
];
if(cls === 'mage') {
player.inv.push({...createItem('wand_fire', 0), qty:1});
}
recalcStats(true);
document.getElementById('menu-start').style.display = 'none';
document.getElementById('controls-area').style.display = 'flex';
loadLevel(floor);
player.rx = player.x * TS;
player.ry = player.y * TS;
const tx = player.rx - cvs.width/2 + TS/2;
const ty = player.ry - cvs.height/2 + TS/2;
camX = tx;
camY = ty;
requestAnimationFrame(updateProjectiles);
}
function recalcStats(heal = false) {
const baseAttrs = getBaseAttr(player.class);
const spentStr = player.str - baseAttrs.str;
const spentVit = player.vit - baseAttrs.vit;
const spentAgi = player.agi - baseAttrs.agi;
player.str = baseAttrs.str;
player.vit = baseAttrs.vit;
player.agi = baseAttrs.agi;
player.str += spentStr;
player.vit += spentVit;
player.agi += spentAgi;
// BALANCE FIX: Reduced player damage scaling from 1.5 to 1.2
let baseAtk = Math.floor(player.str * 1.2);
let baseDef = Math.floor(player.vit * 1.0);
let baseCrit = player.agi * 2;
let baseMaxHp = 50 + (player.vit * 10);
let baseMaxMana = 10 + (player.lvl * 2) + (player.class === 'mage' ? 15 : 0);
let baseRegen = 0;
let baseReflect = 0;
const slots = Object.keys(player.equip);
slots.forEach(slot => {
const item = player.equip[slot];
if (!item) return;
baseAtk += (item.atk || 0);
baseDef += (item.def || 0);
baseCrit += (item.crit || 0);
baseAtk += (item.upgradeBonuses.atk || 0);
baseDef += (item.upgradeBonuses.def || 0);
baseCrit += (item.upgradeBonuses.crit || 0);
if (item.effect) {
baseMaxMana += (item.effect.maxMana || 0);
baseRegen += (item.effect.regen || 0);
baseAtk += (item.effect.atk || 0);
baseDef += (item.effect.def || 0);
baseMaxHp += (item.effect.maxHp || 0);
baseReflect += (item.effect.reflect || 0);
}
});
player.atk = baseAtk;
player.def = baseDef;
player.crit = baseCrit;
player.maxHp = baseMaxHp;
player.maxMana = baseMaxMana;
player.regen = baseRegen;
player.reflect = baseReflect;
if(heal) {
player.hp = player.maxHp;
player.mana = player.maxMana;
}
player.hp = Math.min(player.hp, player.maxHp);
player.hp = Math.max(player.hp, 0);
player.mana = Math.min(player.mana, player.maxMana);
player.mana = Math.max(player.mana, 0);
updateHUD();
refreshStatsUI();
updateRangedButton();
}
function updateRangedButton() {
const shootBtn = document.getElementById('btn-shoot');
// FIX: Show class ability button for all classes
shootBtn.style.display = 'flex';
// Set button text based on class
if (player.class === 'rogue') {
shootBtn.innerHTML = '🗡️ THROW KNIFE<br>(3 MN)';
} else if (player.class === 'warrior') {
shootBtn.innerHTML = '⚔️ RAGE<br>(5 MN)';
} else if (player.class === 'mage') {
shootBtn.innerHTML = '🔥 MAGIC MISSILE<br>(4 MN)';
} else {
// Fallback for weapon-based ranged
const weapon = player.equip.weapon;
if (weapon && weapon.isRanged) {
const cost = weapon.effect.rangedCost || 1;
const element = weapon.element || 'fire';
const icons = {fire: '🔥', ice: '❄️', poison: '☠️', lightning: '⚡'};
shootBtn.innerHTML = `${icons[element] || '🔥'} SHOOT<br>(${cost} MN)`;
} else {
shootBtn.style.display = 'none';
}
}
}
function empty() {
let ex, ey, t=1000;
while(t-->0) {
ex=Math.floor(Math.random()*(W_MAP-2))+1;
ey=Math.floor(Math.random()*(H_MAP-2))+1;
if(map[ey][ex]===0) {
if(!entities.find(e=>e.x===ex && e.y===ey) && (player.x!==ex || player.y!==ey)) {
return {x:ex, y:ey};
}
}
}
for(let y=1; y<H_MAP-1; y++) for(let x=1; x<W_MAP-1; x++)
if(map[y][x]===0 && !entities.find(e=>e.x===x && e.y===y) && (player.x!==x || player.y!==y)) return {x:x, y:y};
return {x:1, y:1};
}
function loadLevel(targetFloor) {
projectiles = [];
bossDefeated = false;
droppedItemsThisFloor = [];
const direction = targetFloor > floor ? 'down' : 'up';
if (floorData[floor] && map.length > 0) {
const currentLadder = entities.find(e => e.x === player.x && e.y === player.y && e.type === 'ladder');
floorData[floor].entities = JSON.parse(JSON.stringify(entities));
if (!currentLadder) {
floorData[floor].playerPos = {x: player.x, y: player.y};
}
floorData[floor].torches = torches;
}
floor = targetFloor;
if (floorData[floor]) {
const data = floorData[floor];
map = data.map;
entities = data.entities;
torches = data.torches;
let targetLadder = null;
if (floor === 1) {
targetLadder = entities.find(e => e.type === 'ladder');
} else if (direction === 'down') {
targetLadder = entities.find(e => e.type === 'ladder' && e.descend === false);
} else {
targetLadder = entities.find(e => e.type === 'ladder' && e.descend === true);
}
if (targetLadder) {
player.x = targetLadder.x;
player.y = targetLadder.y;
} else if (data.playerPos) {
player.x = data.playerPos.x;
player.y = data.playerPos.y;
} else {
let p = empty();
player.x = p.x; player.y = p.y;
}
} else {
generateLevel();
floorData[floor] = {
map: JSON.parse(JSON.stringify(map)),
entities: JSON.parse(JSON.stringify(entities)),
playerPos: {x: player.x, y: player.y},
torches: torches,
};
}
player.rx = player.x * TS;
player.ry = player.y * TS;
updateFog();
// Check if this is a shop floor (Level 6)
if (floor === 6) {
setTimeout(() => openMerchant(), 500);
}
}
/** ========== IMPROVED LEVEL GENERATION ========== */
function generateLevel() {
map = []; fog = []; torches = []; entities = []; containers = [];
// FLOOR PROGRESSION EVENTS: Level 5 → Boss, Level 6 → Shop
const isBossLevel = floor === 5;
const isMerchantLevel = floor === 6;
for(let y=0; y<H_MAP; y++) {
map[y] = []; fog[y] = [];
for(let x=0; x<W_MAP; x++) { map[y][x]=1; fog[y][x]=2; }
}
// Create rooms
const roomCount = 5 + Math.floor(Math.random() * 3);
const rooms = [];
for(let i=0; i<roomCount; i++) {
const roomWidth = 6 + Math.floor(Math.random() * 5);
const roomHeight = 6 + Math.floor(Math.random() * 5);
const roomX = 2 + Math.floor(Math.random() * (W_MAP - roomWidth - 4));
const roomY = 2 + Math.floor(Math.random() * (H_MAP - roomHeight - 4));
for(let y=roomY; y<roomY+roomHeight; y++) {
for(let x=roomX; x<roomX+roomWidth; x++) {
if(x>=0 && x<W_MAP && y>=0 && y<H_MAP) {
map[y][x] = 0;
}
}
}
rooms.push({x: roomX, y: roomY, width: roomWidth, height: roomHeight, centerX: roomX + Math.floor(roomWidth/2), centerY: roomY + Math.floor(roomHeight/2)});
}
// Connect rooms
for(let i=0; i<rooms.length-1; i++) {
connectRooms(rooms[i], rooms[i+1]);
}
for(let i=0; i<rooms.length; i++) {
if(Math.random() < 0.3 && i > 0 && i < rooms.length-1) {
connectRooms(rooms[i], rooms[Math.floor(Math.random() * rooms.length)]);
}
}
if(rooms.length > 0) {
const firstRoom = rooms[0];
player.x = firstRoom.centerX;
player.y = firstRoom.centerY;
} else {
let p = empty();
player.x = p.x; player.y = p.y;
}
player.rx = player.x * TS;
player.ry = player.y * TS;
if (isMerchantLevel) {
spawnMerchantRoom(empty);
}
else if (isBossLevel) {
spawnBoss(empty);
}
else {
// ENEMY SCALING: based on floor and player level
const scale = Math.max(floor, player.lvl);
const hpMultiplier = 15;
const atkMultiplier = 3;
const enemyCount = 10 + floor * 2;
for(let i=0; i<enemyCount; i++) {
let attempts = 0;
let pos;
do {
pos = empty();
attempts++;
const distToPlayer = Math.abs(pos.x - player.x) + Math.abs(pos.y - player.y);
if(attempts > 50 || distToPlayer >= 5) break;
} while(true);
let enemyRoll = Math.random();
let enemyType;
// NEW ENEMY: Ghost spawns with 20% chance, replacing some skeletons
if (enemyRoll < 0.2 && floor >= 3) {
enemyType = 'ghost';
} else if (enemyRoll < 0.6) {
enemyType = 'skeleton';
} else {
enemyType = 'orc';
}
let hp, atk;
if (enemyType === 'ghost') {
// Glass cannon: low health, high damage, high XP
hp = 5 + scale * 5;
atk = 8 + scale * 4;
} else if (enemyType === 'orc') {
hp = 10 + scale * hpMultiplier + 10;
atk = 1 + scale * atkMultiplier + 1;
} else { // skeleton
hp = 10 + scale * hpMultiplier;
atk = 1 + scale * atkMultiplier;
}
entities.push({
type: enemyType,
x:pos.x, y:pos.y, rx:pos.x*TS, ry:pos.y*TS,
hp, maxHp:hp, atk,
xp: enemyType === 'ghost' ? 25 + floor*5 : 10 + floor*3,
statusEffects: {},
movementSpeed: enemyType === 'ghost' ? 1.5 : 1
});
}
// LADDER BEHAVIOR: FIXED - only interactable via action button
let l = empty();
entities.push({
type:'ladder',
x:l.x, y:l.y, rx:l.x*TS, ry:l.y*TS,
interact: true,
descend: true,
hp: undefined,
atk: undefined
});
if (floor > 1) {
let lUp = empty();
entities.push({
type:'ladder',
x:lUp.x, y:lUp.y, rx:lUp.x*TS, ry:lUp.y*TS,
interact: true,
descend: false,
hp: undefined,
atk: undefined
});
}
// REDUCED LOOT DENSITY
const containerCount = Math.floor((8 + floor) * 0.3);
const containerTypes = ['chest', 'bag', 'crate'];
const itemKeys = Object.keys(ITEM_DEFS).filter(k => k!=='potion' && k!=='mana_potion' && k!=='gold_pile' && ITEM_DEFS[k].baseValue < 250);
const placedLootPositions = [];
for(let i=0; i<containerCount; i++) {
let attempts = 0;
let pos;
do {
pos = empty();
attempts++;
let tooClose = placedLootPositions.some(p => Math.abs(p.x - pos.x) + Math.abs(p.y - pos.y) < 3);
if(attempts > 30 || !tooClose) break;
} while(true);
placedLootPositions.push(pos);
let containerType = containerTypes[Math.floor(Math.random() * containerTypes.length)];
let itemKey;
let lootRoll = Math.random();
// CLASS-SPECIFIC LOOT: 20% chance for class-specific item
if (lootRoll < 0.2) {
let classItems = CLASS_SPECIFIC_ITEMS[player.class];
if (classItems && classItems.length > 0) {
itemKey = classItems[Math.floor(Math.random() * classItems.length)];
} else {
itemKey = itemKeys[Math.floor(Math.random() * itemKeys.length)];
}
} else if (lootRoll < 0.6) {
// Ensure uniqueness: filter out already dropped items
let availableItems = itemKeys.filter(key => !droppedItemsThisFloor.includes(key));
if (availableItems.length === 0) {
availableItems = itemKeys;
}
itemKey = availableItems[Math.floor(Math.random() * availableItems.length)];
} else if (lootRoll < 0.8) {
itemKey = 'potion';
} else if (lootRoll < 0.9) {
itemKey = 'mana_potion';
} else {
itemKey = 'gold_pile';
}
droppedItemsThisFloor.push(itemKey);
let item = null;
if (itemKey === 'gold_pile') {
item = {...ITEM_DEFS[itemKey], val: Math.floor(Math.random() * 10 * floor) + 5, qty: 1, id: Math.random() + Date.now()};
} else {
item = createItem(itemKey, floor);
}
if (!item) continue;
entities.push({
type: 'container',
containerType: containerType,
x:pos.x, y:pos.y, rx:pos.x*TS, ry:pos.y*TS,
interact: true,
item: item,
qty: item.qty || 1,
opened: false
});
}
// REDUCED FLOOR LOOT
const floorLootCount = Math.floor((8 + floor) * 0.2);
for(let i=0; i<floorLootCount; i++) {
let attempts = 0;
let pos;
do {
pos = empty();
attempts++;
let tooClose = placedLootPositions.some(p => Math.abs(p.x - pos.x) + Math.abs(p.y - pos.y) < 2);
if(attempts > 20 || !tooClose) break;
} while(true);
placedLootPositions.push(pos);
let lootRoll = Math.random();
let itemKey;
if (lootRoll < 0.4) {
itemKey = 'gold_pile';
} else if (lootRoll < 0.6) {
itemKey = 'potion';
} else if (lootRoll < 0.8) {
itemKey = 'mana_potion';
} else {
let availableItems = itemKeys.filter(key => !droppedItemsThisFloor.includes(key));
if (availableItems.length === 0) {
availableItems = itemKeys;
}
itemKey = availableItems[Math.floor(Math.random() * availableItems.length)];
}
droppedItemsThisFloor.push(itemKey);
let item = null;
if (itemKey === 'gold_pile') {
item = {...ITEM_DEFS[itemKey], val: Math.floor(Math.random() * 5 * floor) + 1, qty: 1, id: Math.random() + Date.now()};
} else {
item = createItem(itemKey, floor);
}
if (!item) continue;
entities.push({
type: 'loot',
x:pos.x, y:pos.y, rx:pos.x*TS, ry:pos.y*TS,
interact: false,
item: item,
qty: item.qty || 1,
icon: item.icon
});
}
// Add torches
for(let i=0; i<5; i++) {
if(rooms.length > i) {
let room = rooms[i];
torches.push({x: room.centerX, y: room.centerY});
}
}
}
}
function connectRooms(room1, room2) {
let x1 = room1.centerX;
let y1 = room1.centerY;
let x2 = room2.centerX;
let y2 = room2.centerY;
if(Math.random() > 0.5) {
// Horizontal then vertical
let startX = Math.min(x1, x2);
let endX = Math.max(x1, x2);
for(let x=startX; x<=endX; x++) {
if(x>=0 && x<W_MAP && y1>=0 && y1<H_MAP) {
map[y1][x] = 0;
if(y1+1 < H_MAP) map[y1+1][x] = 0;
}
}
let startY = Math.min(y1, y2);
let endY = Math.max(y1, y2);
for(let y=startY; y<=endY; y++) {
if(x2>=0 && x2<W_MAP && y>=0 && y<H_MAP) {
map[y][x2] = 0;
if(x2+1 < W_MAP) map[y][x2+1] = 0;
}
}
} else {
// Vertical then horizontal
let startY = Math.min(y1, y2);
let endY = Math.max(y1, y2);
for(let y=startY; y<=endY; y++) {
if(x1>=0 && x1<W_MAP && y>=0 && y<H_MAP) {
map[y][x1] = 0;
if(x1+1 < W_MAP) map[y][x1+1] = 0;
}
}
let startX = Math.min(x1, x2);
let endX = Math.max(x1, x2);
for(let x=startX; x<=endX; x++) {
if(x>=0 && x<W_MAP && y2>=0 && y2<H_MAP) {
map[y2][x] = 0;
if(y2+1 < H_MAP) map[y2+1][x] = 0;
}
}
}
}
function spawnBoss(emptyFunc) {
const scale = Math.max(floor, player.lvl);
const bossType = Math.random() > 0.5 ? 'ogre_boss' : 'gskull_boss';
const bossName = bossType === 'ogre_boss' ? `Ogre Brute F${floor}` : `Giant Skeleton F${floor}`;
let pos = emptyFunc();
const hp = 200 + scale * 50;
const atk = 10 + scale * 5;
entities.push({
type: 'boss',
name: bossName,
icon: bossType === 'ogre_boss' ? "👹" : "💀",
x: pos.x, y: pos.y, rx: pos.x*TS, ry: pos.y*TS,
hp, maxHp: hp, atk,
xp: 300 + Math.ceil(floor/8) * 300,
interact: false,
guaranteedLoot: true,
statusEffects: {}
});
const bossLoot = ['wand_shadow', 'plate_armor', 'ring_of_might', 'ring_of_furor', 'amulet_of_thorns'];
const itemKey = bossLoot[Math.floor(Math.random() * bossLoot.length)];
let item = createItem(itemKey, floor + 1);
if (item) {
let lootPos = emptyFunc();
entities.push({
type: 'container',
containerType: 'chest',
x: lootPos.x, y: lootPos.y, rx: lootPos.x * TS, ry: lootPos.y * TS,
interact: true, item: item, qty: 1, opened: false
});
}
// Ladder down - FIXED: only interactable via action button
let l = emptyFunc();
entities.push({
type:'ladder',
x:l.x, y:l.y, rx:l.x*TS, ry:l.y*TS,
interact: true,
descend: true,
hp: undefined,
atk: undefined
});
if (floor > 1) {
let lUp = emptyFunc();
entities.push({
type:'ladder',
x:lUp.x, y:lUp.y, rx:lUp.x*TS, ry:lUp.y*TS,
interact: true,
descend: false,
hp: undefined,
atk: undefined
});
}
addFloat(`BOSS LEVEL ${floor}!`, player.rx, player.ry, "#f00");
}
function spawnMerchantRoom(emptyFunc) {
const cx = W_MAP>>1;
const cy = H_MAP>>1;
for (let y = cy - 2; y <= cy + 2; y++) {
for (let x = cx - 2; x <= cx + 2; x++) {
if (y >= 0 && y < H_MAP && x >= 0 && x < W_MAP) {
map[y][x] = 0;
}
}
}
entities.push({
type: 'merchant',
name: 'Shopkeeper',
icon: '🧑‍💼',
x: cx, y: cy, rx: cx * TS, ry: cy * TS,
interact: true,
});
// Shop floor ladder is always available
let l = emptyFunc();
entities.push({
type:'ladder',
x:l.x, y:l.y, rx:l.x*TS, ry:l.y*TS,
interact: true,
descend: true,
hp: undefined,
atk: undefined
});
if (floor > 1) {
let lUp = emptyFunc();
entities.push({
type:'ladder',
x:lUp.x, y:lUp.y, rx:lUp.x*TS, ry:lUp.y*TS,
interact: true,
descend: false,
hp: undefined,
atk: undefined
});
}
const allEquipKeys = Object.keys(ITEM_DEFS).filter(k => ITEM_DEFS[k].slot && ITEM_DEFS[k].baseValue > 50);
merchantWares = [];
const potion = {...ITEM_DEFS.potion, qty: Math.floor(floor * 0.5) + 3};
potion.id = Math.random();
potion.buyPrice = Math.floor(potion.baseValue * 2);
merchantWares.push(potion);
const manaPotion = {...ITEM_DEFS.mana_potion, qty: Math.floor(floor * 0.5) + 2};
manaPotion.id = Math.random();
manaPotion.buyPrice = Math.floor(manaPotion.baseValue * 2);
merchantWares.push(manaPotion);
for (let i = 0; i < 4; i++) {
const key = allEquipKeys[Math.floor(Math.random() * allEquipKeys.length)];
let item = createItem(key, floor + 1);
item.buyPrice = Math.floor(item.baseValue * 2);
merchantWares.push(item);
}
addFloat(`MERCHANT LEVEL ${floor}!`, player.rx, player.ry, PAL.merchant);
}
/** ========== FIXED LEVEL-UP NOTIFICATIONS ========== */
function showNextLevelUp() {
if (levelUpQueue.length === 0 || isShowingLevelUp) return;
isShowingLevelUp = true;
addFloat("LEVEL UP!", player.rx, player.ry - TS, "#0ff", true);
setTimeout(() => {
isShowingLevelUp = false;
levelUpQueue.shift();
if (levelUpQueue.length > 0) {
showNextLevelUp();
}
}, 1000);
}
function checkLevelUp() {
while (player.xp >= player.nextLvl) {
player.lvl++;
player.points += 2;
player.xp -= player.nextLvl;
player.nextLvl = Math.floor(player.nextLvl * 2);
levelUpQueue.push(player.lvl);
recalcStats(true);
document.getElementById('alert-stats').style.borderColor = "#0ff";
}
showNextLevelUp();
}
/** ========== GAME LOOP AND RENDERING ========== */
function loop() {
requestAnimationFrame(loop);
ctx.fillStyle = "#000";
ctx.fillRect(0,0,cvs.width, cvs.height);
if (gameState !== "PLAYING") {
return;
}
player.rx += (player.x*TS - player.rx)*0.2;
player.ry += (player.y*TS - player.ry)*0.2;
let tx = player.rx - cvs.width/2 + TS/2;
let ty = player.ry - cvs.height/2 + TS/2;
camX += (tx - camX) * 0.1;
camY += (ty - camY) * 0.1;
ctx.save();
ctx.translate(-Math.floor(camX), -Math.floor(camY));
let cx = Math.floor(camX/TS), cy = Math.floor(camY/TS);
let cw = (cvs.width/TS)+1, ch = (cvs.height/TS)+1;
// Tile Rendering
for(let y=cy; y<=cy+ch; y++) {
for(let x=cx; x<=cx+cw; x++) {
if(y>=0 && y<H_MAP && x>=0 && x<W_MAP) {
let px = x*TS, py = y*TS, vis = fog[y][x];
if(vis === 2) continue;
if(map[y][x]===1) {
ctx.drawImage(SS, 0, 0, 32, 32, px, py, TS, TS);
if(torches.find(o=>o.x===x && o.y===y)) {
ctx.globalAlpha=0.8+Math.random()*0.2;
ctx.drawImage(SS, 128, 96, 32, 32, px, py, TS, TS);
ctx.globalAlpha=1;
}
} else {
ctx.drawImage(SS, 32, 0, 32, 32, px, py, TS, TS);
}
if(vis === 1) {
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(px,py,TS,TS);
}
}
}
}
// Entity Rendering
const visibleEntities = entities.filter(e =>
e.y >= 0 && e.y < H_MAP && e.x >= 0 && e.x < W_MAP && fog[e.y][e.x] === 0 && !e.markedForRemoval
);
visibleEntities.sort((a, b) => a.y - b.y);
visibleEntities.sort((a, b) => {
const typeOrder = (type) => {
if (type === 'container' || type === 'loot' || type === 'merchant') return 0;
if (type === 'ladder') return 1;
return 2;
};
return typeOrder(a.type) - typeOrder(b.type);
});
for (const e of visibleEntities) {
if (e.hp <= 0 && !e.interact) continue;
e.rx += (e.x*TS - e.rx)*0.2; e.ry += (e.y*TS - e.ry)*0.2;
let sx=0, sy=64;
let iconToDraw = null;
if(e.type==='orc') sx=32;
else if(e.type==='skeleton') sx=0;
else if(e.type==='ghost') {
sx=96; sy=0;
}
else if(e.type==='ladder') {
sx=96; sy=96;
}
else if(e.type==='merchant') {
sx=64; sy=64;
}
else if(e.type==='boss') {
sx = e.name.includes('Ogre') ? 96 : 128; sy=64;
}
else if(e.type==='container') {
if (e.containerType === 'chest') { sx = 0; sy = 96; }
else if (e.containerType === 'bag') { sx = 32; sy = 96; }
else if (e.containerType === 'crate') { sx = 64; sy = 96; }
}
else if(e.type==='loot') {
iconToDraw = e.icon;
}
if (iconToDraw) {
ctx.font = `${TS*0.75}px serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#000";
ctx.fillText(iconToDraw, Math.floor(e.rx) + TS/2 + 2, Math.floor(e.ry) + TS/2 + 2);
ctx.fillStyle = e.item.type === 'currency' ? PAL.gold : "#ff0";
ctx.fillText(iconToDraw, Math.floor(e.rx) + TS/2, Math.floor(e.ry) + TS/2);
if (e.qty > 1) {
ctx.font = "8px 'Press Start 2P'";
ctx.fillStyle = "#fff";
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(e.qty, Math.floor(e.rx) + TS - 2, Math.floor(e.ry) + TS - 2);
}
} else {
// Apply transparency for ghost
if (e.type === 'ghost') {
ctx.globalAlpha = 0.7;
}
ctx.drawImage(SS, sx, sy, 32, 32, Math.floor(e.rx), Math.floor(e.ry), TS, TS);
if (e.type === 'ghost') {
ctx.globalAlpha = 1;
}
if (e.type === 'container' && e.opened) {
ctx.globalAlpha = 0.5;
ctx.drawImage(SS, sx, sy, 32, 32, Math.floor(e.rx), Math.floor(e.ry), TS, TS);
ctx.globalAlpha = 1;
}
// Only show health bar for enemies with HP (not ladders)
if(e.hp !== undefined && e.maxHp && e.hp > 0 && e.type !== 'ladder') {
ctx.fillStyle="red";
ctx.fillRect(Math.floor(e.rx), Math.floor(e.ry)-4, 32*(e.hp/e.maxHp), 2);
}
}
}
// Player Rendering
let psx=0;
if(player.class==='rogue') psx=32; if(player.class==='mage') psx=64;
ctx.drawImage(SS, psx, 32, 32, 32, Math.floor(player.rx), Math.floor(player.ry), TS, TS);
// Draw projectiles
for (let p of projectiles) {
ctx.fillStyle = p.color;
ctx.fillRect(Math.floor(p.rx) + TS/2 - 2, Math.floor(p.ry) + TS/2 - 2, 4, 4);
}
// Particle and Floater Rendering
particles.forEach((p,i) => { p.x+=p.vx; p.y+=p.vy; p.l--; ctx.fillStyle=p.c; ctx.fillRect(p.x, p.y, 2, 2); if(p.l<=0) particles.splice(i,1); });
// FLOATER RENDER
ctx.font = "10px 'Press Start 2P'"; ctx.textAlign="center";
for(let i = floaters.length - 1; i >= 0; i--) {
const f = floaters[i];
f.y -= 0.5;
f.l--;
ctx.globalAlpha = Math.max(0, f.l / 40);
ctx.fillStyle="#000";
ctx.fillText(f.t, f.x + 1, f.y + 1);
ctx.fillStyle=f.c;
ctx.fillText(f.t, f.x, f.y + (f.b ? Math.sin(Date.now()/50)*2 : 0));
if(f.l <= 0) {
floaters.splice(i, 1);
}
}
ctx.globalAlpha = 1;
ctx.restore();
}
function updateProjectiles(currentTime) {
requestAnimationFrame(updateProjectiles);
if (gameState !== "PLAYING") return;
const deltaTime = (currentTime - lastFrameTime) / 1000;
lastFrameTime = currentTime;
for (let i = projectiles.length - 1; i >= 0; i--) {
let p = projectiles[i];
// FIXED: Turn-based projectile movement - move one tile per frame
if (p.lastMove === undefined) p.lastMove = 0;
p.lastMove += deltaTime;
// Move one tile every 0.2 seconds (adjustable for turn pacing)
if (p.lastMove >= 0.2) {
p.rx += p.dx * TS;
p.ry += p.dy * TS;
p.distance += TS;
p.lastMove = 0;
// Update grid position
p.x = Math.floor(p.rx / TS);
p.y = Math.floor(p.ry / TS);
}
// Check for out of bounds
if (p.distance > p.range * TS ||
p.x < 0 || p.x >= W_MAP ||
p.y < 0 || p.y >= H_MAP) {
spawnPart(p.rx + TS/2, p.ry + TS/2, 5, p.color);
projectiles.splice(i, 1);
continue;
}
// Check for wall collision
if (map[p.y] && map[p.y][p.x] === 1) {
spawnPart(p.rx + TS/2, p.ry + TS/2, 10, p.color);
projectiles.splice(i, 1);
continue;
}
// FIXED: Projectile collision detection - only with enemies
let target = entities.find(e =>
!e.markedForRemoval &&
e.hp > 0 && e.type !== 'ladder' && e.type !== 'container' && e.type !== 'loot' && e.type !== 'merchant' &&
Math.abs(e.x - p.x) <= 1 &&
Math.abs(e.y - p.y) <= 1
);
if (target) {
let crit = (Math.random()*100 < p.critChance);
let dmg = Math.floor(p.damage * (crit?1.5:1));
target.hp -= dmg;
addFloat(dmg, target.rx+16, target.ry, "#fff", true);
spawnPart(target.rx + TS/2, target.ry + TS/2, 12, p.color);
spawnPart(p.rx + TS/2, p.ry + TS/2, 8, p.color);
projectiles.splice(i, 1);
if(target.hp <= 0) {
handleDeath(target);
}
continue;
}
}
}
/** ========== BASIC GAME FUNCTIONS ========== */
function input(dx, dy) {
if(player.hp <= 0) return;
let nx = player.x+dx, ny = player.y+dy;
lastDirection.dx = dx;
lastDirection.dy = dy;
if(nx < 0 || nx >= W_MAP || ny < 0 || ny >= H_MAP) return;
if(map[ny][nx]===1) {
return;
}
let loot = entities.find(e => !e.markedForRemoval && e.x===nx && e.y===ny && e.type === 'loot');
if (loot) {
pickupFloorLoot(loot);
player.x = nx;
player.y = ny;
tick();
return;
}
let target = entities.find(e => !e.markedForRemoval && e.x===nx && e.y===ny && e.type !== 'loot');
if (target) {
if (target.type === 'container') {
openContainer(target);
player.x = nx;
player.y = ny;
tick();
return;
} else if (target.interact) {
player.x = nx;
player.y = ny;
if (target.type === 'merchant') openMerchant();
tick();
return;
} else {
// FIXED: Attack cooldown to prevent spam
const now = Date.now();
if (now - lastAttackTime < ATTACK_DELAY) return;
// FIXED: Prevent attacking dead enemies
if (target.hp > 0 && !target.markedForRemoval) {
combat(player, target);
lastAttackTime = now;
}
}
} else {
player.x = nx;
player.y = ny;
tick();
}
}
function interact() {
if(player.hp <= 0) return;
// FIXED: Attack cooldown to prevent spam
const now = Date.now();
if (now - lastAttackTime < ATTACK_DELAY) return;
// FIXED: Ladder interaction - only when standing on ladder AND pressing action button
let ladder = entities.find(e => !e.markedForRemoval && e.x === player.x && e.y === player.y && e.type === 'ladder');
if (ladder) {
// Ladder activation logic
let targetFloor;
if (ladder.descend === true) {
targetFloor = floor + 1;
addFloat("DESCENDING...", player.rx, player.ry, "#fff");
checkLevelUp();
}
else if (ladder.descend === false && floor > 1) {
targetFloor = floor - 1;
addFloat("ASCENDING...", player.rx, player.ry, "#fff");
} else {
addFloat("Cannot go back up!", player.rx, player.ry, "#888");
return;
}
setTimeout(() => loadLevel(targetFloor), 500);
return;
}
// FIXED: Prevent attacking dead enemies
let target = entities.find(t => !t.markedForRemoval && t.hp > 0 && t.type !== 'ladder' && Math.abs(t.x-player.x)+Math.abs(t.y-player.y)===1);
if(target) {
combat(player, target);
lastAttackTime = now;
tick();
return;
}
let e = entities.find(e => !e.markedForRemoval && e.x===player.x && e.y===player.y && e.interact);
if(e) {
if(e.type === 'merchant') {
openMerchant();
} else if (e.type === 'container') {
openContainer(e);
tick();
}
} else {
addFloat("Nothing to ATK/Interact", player.rx, player.ry, "#888");
}
}
function combat(src, dst) {
// FIXED: Ladders cannot be attacked
if (dst.type === 'ladder') return;
if (dst.hp <= 0 || dst.markedForRemoval) {
return;
}
let crit = (Math.random()*100 < (src.crit||5));
let raw = src.atk || 1;
// Apply rage multiplier for warriors
if (src === player && player.statusEffects && player.statusEffects.rage) {
raw = Math.floor(raw * player.statusEffects.rage.damageMultiplier);
}
// Apply defense multiplier if target has rage
let defenseMultiplier = 1;
if (dst === player && player.statusEffects && player.statusEffects.rage) {
defenseMultiplier = player.statusEffects.rage.defenseMultiplier;
}
let dmg = Math.floor(raw * (crit?1.5:1) * defenseMultiplier);
// Apply player's reflect damage
if (src !== player && player.reflect > 0) {
let reflectDmg = Math.floor(dmg * player.reflect);
src.hp -= reflectDmg;
addFloat(`Reflect ${reflectDmg}`, src.rx+16, src.ry, "#f8a", true);
}
dst.hp -= dmg;
addFloat(dmg, dst.rx+16, dst.ry, src===player ? "#fff" : "#d94e4e", true);
spawnPart(dst.rx+16, dst.ry+16, 5, PAL.blood);
if(dst.hp <= 0) {
if(dst === player) {
addFloat("DEAD", player.rx, player.ry, "#f00");
levelUpQueue = [];
isShowingLevelUp = false;
floaters = floaters.filter(f => !f.t.includes("LEVEL UP"));
stopMove();
setTimeout(() => {
if(confirm("You died! Return to the character select screen?")) {
quitGame();
} else {
location.reload();
}
}, 2000);
} else {
handleDeath(dst);
}
}
}
/** ========== FIXED ENEMY DEATH HANDLING ========== */
function handleDeath(target) {
player.xp += target.xp;
player.gold += Math.floor(Math.random()*5)+1 + (target.type === 'boss' ? floor*10 : 0);
// FIX 1: IMMEDIATELY remove collision and clean up enemy state
target.markedForRemoval = true;
target.hp = 0; // Ensure HP is 0
spawnPart(target.rx + TS/2, target.ry + TS/2, 10, PAL.blood);
addFloat("DEFEATED!", target.rx, target.ry, "#0f0", true);
// FIX 2: Tile becomes walkable immediately - enemy collision is now cleared
checkLevelUp();
updateHUD();
if (target.type === 'boss') {
bossDefeated = true;
addFloat("BOSS DEFEATED!", target.rx, target.ry, "#0f0", true);
}
// FIX: Release player/turn locks by ensuring canMove remains true
canMove = true;
}
/** ========== CLEANUP FUNCTION FOR MARKED ENTITIES ========== */
function cleanupMarkedEntities() {
for (let i = entities.length - 1; i >= 0; i--) {
if (entities[i].markedForRemoval) {
entities.splice(i, 1);
}
}
}
function tick() {
// FIX: Clean up marked entities FIRST before processing anything
cleanupMarkedEntities();
// Handle status effect turns
if (player.statusEffects) {
if (player.statusEffects.rage) {
player.statusEffects.rage.turns--;
if (player.statusEffects.rage.turns <= 0) {
delete player.statusEffects.rage;
addFloat("Rage ended", player.rx, player.ry, "#888");
}
}
}
regenTimer++;
if (regenTimer >= 20) {
if (player.hp < player.maxHp) {
player.hp = Math.min(player.maxHp, player.hp + (player.regen / 5));
}
if (player.mana < player.maxMana) {
player.mana = Math.min(player.maxMana, player.mana + (player.regen / 5));
}
regenTimer = 0;
}
updateFog();
// FIX: Filter out marked entities BEFORE processing
const entitiesToProcess = entities.filter(e =>
!e.markedForRemoval &&
!e.interact &&
e.hp > 0 &&
e.type !== 'loot' &&
e.type !== 'container' &&
e.type !== 'merchant' &&
e.type !== 'ladder'
);
for (const e of entitiesToProcess) {
let dist = Math.abs(e.x-player.x) + Math.abs(e.y-player.y);
// FIX: Different attack styles based on enemy type
if(dist === 1) {
// Each enemy type has different attack behavior
if (e.type === 'orc') {
// Orc: strong melee attack with chance of knockback
let crit = (Math.random()*100 < 20); // 20% crit chance for orcs
let dmg = Math.floor(e.atk * (crit ? 1.5 : 1));
player.hp -= Math.max(1, dmg - Math.floor(player.def/2));
addFloat(dmg, player.rx+16, player.ry, "#d94e4e", true);
spawnPart(player.rx+16, player.ry+16, 5, PAL.blood);
if (crit) {
addFloat("CRIT!", e.rx, e.ry, "#f00", true);
// Orc crit causes knockback
let dx = Math.sign(player.x - e.x);
let dy = Math.sign(player.y - e.y);
let knockbackX = player.x + dx;
let knockbackY = player.y + dy;
if (knockbackX >= 0 && knockbackX < W_MAP &&
knockbackY >= 0 && knockbackY < H_MAP &&
map[knockbackY][knockbackX] === 0) {
const blocking = entities.find(o =>
!o.markedForRemoval &&
o !== e && o !== player &&
o.x === knockbackX && o.y === knockbackY
);
if (!blocking) {
player.x = knockbackX;
player.y = knockbackY;
addFloat("Knocked back!", player.rx, player.ry, "#f00");
}
}
}
continue; // Skip movement if attacking
} else if (e.type === 'ghost') {
// Ghost: life-draining attack that restores its health
let dmg = Math.floor(e.atk);
let drain = Math.floor(dmg * 0.5); // Ghost drains 50% of damage
player.hp -= Math.max(1, dmg - Math.floor(player.def/2));
e.hp = Math.min(e.maxHp, e.hp + drain);
addFloat(dmg, player.rx+16, player.ry, "#4abaf5", true);
addFloat(`+${drain}`, e.rx, e.ry, "#0f0", true);
spawnPart(player.rx+16, player.ry+16, 5, "#4abaf5");
continue;
} else {
// Skeleton: standard melee attack
combat(e, player);
continue;
}
}
// Ranged attack for ghosts at distance
if (e.type === 'ghost' && dist <= 3 && dist > 1) {
// Ghost can shoot projectiles
let dx = Math.sign(player.x - e.x);
let dy = Math.sign(player.y - e.y);
projectiles.push({
x: e.x,
y: e.y,
rx: e.x * TS,
ry: e.y * TS,
dx: dx,
dy: dy,
speed: 15,
damage: Math.floor(e.atk * 0.7),
critChance: 10,
type: 'ghost_shot',
color: "#4abaf5",
status: 'frozen',
element: 'ice',
range: 5,
distance: 0,
fromEnemy: true,
lastMove: 0
});
continue; // Skip movement after shooting
}
if(dist < 7 && dist > 0) {
let dx = Math.sign(player.x - e.x);
let dy = Math.sign(player.y - e.y);
let targetX = e.x + dx;
let targetY = e.y + dy;
let moved = false;
// FIXED: Check if target tile is blocked by container or other enemy
const isTileBlocked = (x, y, checkingEntity) => {
// Check walls (for non-ghosts)
if (checkingEntity.type !== 'ghost' && map[y][x] === 1) return true;
// Check containers
const containerHere = entities.find(o =>
!o.markedForRemoval &&
(o.type === 'container' || o.type === 'loot' || o.type === 'merchant') &&
o.x === x && o.y === y
);
if (containerHere) return true;
// Check other enemies (except the one moving)
const otherEnemy = entities.find(o =>
!o.markedForRemoval &&
o !== checkingEntity &&
o.hp > 0 &&
(o.type === 'orc' || o.type === 'skeleton' || o.type === 'ghost' || o.type === 'boss') &&
o.x === x && o.y === y
);
if (otherEnemy) return true;
return false;
};
// Ghost can move through walls occasionally (30% chance)
if (e.type === 'ghost' && Math.random() < 0.3) {
// Ghost can phase through walls but not containers
if(targetX > 0 && targetX < W_MAP-1 && targetY > 0 && targetY < H_MAP-1) {
const containerHere = entities.find(o =>
!o.markedForRemoval &&
(o.type === 'container' || o.type === 'loot' || o.type === 'merchant') &&
o.x === targetX && o.y === e.y
);
if (!containerHere) {
e.x = targetX;
moved = true;
}
}
if(!moved && targetY > 0 && targetY < H_MAP-1 && targetX > 0 && targetX < W_MAP-1) {
const containerHere = entities.find(o =>
!o.markedForRemoval &&
(o.type === 'container' || o.type === 'loot' || o.type === 'merchant') &&
o.x === e.x && o.y === targetY
);
if (!containerHere) {
e.y = targetY;
}
}
} else {
// Normal enemy movement with collision checking
if(Math.random() < 0.5 && targetX > 0 && targetX < W_MAP-1 && !isTileBlocked(targetX, e.y, e)) {
e.x = targetX;
moved = true;
}
if(!moved && targetY > 0 && targetY < H_MAP-1 && !isTileBlocked(e.x, targetY, e)) {
e.y = targetY;
}
}
}
}
const boss = entities.find(e => !e.markedForRemoval && e.type === 'boss');
if (boss && boss.hp > 0) {
let dx = Math.sign(player.x - boss.x);
let dy = Math.sign(player.y - boss.y);
let targetX = boss.x + dx;
let targetY = boss.y + dy;
let moved = false;
// FIXED: Boss collision checking
const isTileBlockedForBoss = (x, y) => {
if (map[y][x] === 1) return true;
const containerHere = entities.find(o =>
!o.markedForRemoval &&
(o.type === 'container' || o.type === 'loot' || o.type === 'merchant') &&
o.x === x && o.y === y
);
if (containerHere) return true;
const otherEnemy = entities.find(o =>
!o.markedForRemoval &&
o !== boss &&
o.hp > 0 &&
(o.type === 'orc' || o.type === 'skeleton' || o.type === 'ghost') &&
o.x === x && o.y === y
);
if (otherEnemy) return true;
return false;
};
if (Math.random() < 0.5) {
if (dx !== 0 && targetX > 0 && targetX < W_MAP-1 && !isTileBlockedForBoss(targetX, boss.y)) {
boss.x = targetX; moved = true;
}
if (!moved && dy !== 0 && targetY > 0 && targetY < H_MAP-1 && !isTileBlockedForBoss(boss.x, targetY)) {
boss.y = targetY;
}
} else {
if (dy !== 0 && targetY > 0 && targetY < H_MAP-1 && !isTileBlockedForBoss(boss.x, targetY)) {
boss.y = targetY; moved = true;
}
if (!moved && dx !== 0 && targetX > 0 && targetX < W_MAP-1 && !isTileBlockedForBoss(targetX, boss.y)) {
boss.x = targetX;
}
}
let dist = Math.abs(boss.x-player.x) + Math.abs(boss.y-player.y);
if(dist === 1) combat(boss, player);
}
updateHUD();
}
function updateFog() {
for(let y=0; y<H_MAP; y++) for(let x=0; x<W_MAP; x++) if(fog[y][x] === 0) fog[y][x] = 1;
const cast = (cx, cy, r, force=false) => {
for(let y=cy-r; y<=cy+r; y++) for(let x=cx-r; x<=cx+r; x++) {
if(y>=0 && y<H_MAP && x>=0 && x<W_MAP && ((x-cx)**2 + (y-cy)**2 < r*r)) {
let prev = fog[y][x];
fog[y][x] = 0;
if(!force && cx===player.x && prev===2 && map[y][x]===1 && Math.random()>0.85 && !torches.find(t=>Math.abs(t.x-x)<3 && Math.abs(t.y-y)<3)) torches.push({x,y});
}
}
};
cast(player.x, player.y, 8);
torches.forEach(t => cast(t.x, t.y, 3, true));
}
function updateHUD() {
const maxHp = player.maxHp > 0 ? player.maxHp : 100;
const currentHp = player.hp >= 0 ? player.hp : 0;
const maxMana = player.maxMana > 0 ? player.maxMana : 10;
const currentMana = player.mana >= 0 ? player.mana : 0;
const nextLvl = player.nextLvl > 0 ? player.nextLvl : 100;
const currentXp = player.xp >= 0 ? player.xp : 0;
document.getElementById('hp-fill').style.width = (currentHp/maxHp*100)+"%";
document.getElementById('hp-txt').innerText = `${Math.floor(currentHp)}/${maxHp}`;
document.getElementById('mana-fill').style.width = (currentMana/maxMana*100)+"%";
document.getElementById('mana-txt').innerText = `${Math.floor(currentMana)}/${maxMana}`;
document.getElementById('xp-fill').style.width = (currentXp/nextLvl*100)+"%";
document.getElementById('lvl-txt').innerText = player.lvl || 1;
document.getElementById('gold-txt').innerText = (player.gold || 0) + " G";
document.getElementById('depth-txt').innerText = floor || 1;
document.getElementById('point-alert').style.display = player.points > 0 ? 'block' : 'none';
if (player.points > 0) {
document.getElementById('point-alert').innerHTML = `+${player.points} PTS`;
}
}
function openContainer(container) {
if (container.opened) {
addFloat("Already opened!", player.rx, player.ry, "#888");
return;
}
container.opened = true;
const item = container.item;
const qty = container.qty || 1;
if (item.type === 'currency') {
player.gold += qty;
addFloat(`+${qty} G`, player.rx, player.ry, PAL.gold);
updateHUD();
} else {
let stackable = item.stackable;
let pickedUp = false;
if (stackable) {
let stack = player.inv.find(i => i.name === item.name && i.type === item.type);
if(stack) {
stack.qty += qty;
pickedUp = true;
}
}
if (!pickedUp) {
player.inv.push({...item, qty: qty, id: Math.random() + Date.now()});
}
addFloat(`GOT ${item.name}${qty > 1 ? ` (x${qty})` : ''}`, player.rx, player.ry, "#ff0");
document.getElementById('alert-stats').style.borderColor = "#ffcc00";
}
}
function pickupFloorLoot(lootEntity) {
const item = lootEntity.item;
const qty = lootEntity.qty || 1;
if (item.type === 'currency') {
player.gold += qty;
addFloat(`+${qty} G`, player.rx, player.ry, PAL.gold);
updateHUD();
} else {
let stackable = item.stackable;
let pickedUp = false;
if (stackable) {
let stack = player.inv.find(i => i.name === item.name && i.type === item.type);
if(stack) {
stack.qty += qty;
pickedUp = true;
}
}
if (!pickedUp) {
player.inv.push({...item, qty: qty, id: Math.random() + Date.now()});
}
addFloat(`GOT ${item.name}${qty > 1 ? ` (x${qty})` : ''}`, player.rx, player.ry, "#ff0");
document.getElementById('alert-stats').style.borderColor = "#ffcc00";
}
lootEntity.markedForRemoval = true;
}
function shoot() {
if(player.hp <= 0) return;
// FIXED: Attack cooldown for shooting too
const now = Date.now();
if (now - lastAttackTime < ATTACK_DELAY) return;
// FIX: Implement class-specific abilities
if (player.class === 'rogue') {
// Rogue: Throwing Knife (ranged attack)
if (player.mana < 3) {
addFloat("NO MANA", player.rx, player.ry, PAL.mana);
return;
}
player.mana -= 3;
updateHUD();
let finalDx = lastDirection.dx;
let finalDy = lastDirection.dy;
if (finalDx === 0 && finalDy === 0) {
finalDx = 1;
}
// Rogue throwing knife has higher crit chance
let knifeCrit = player.crit + 20; // +20% crit for throwing knife
projectiles.push({
x: player.x,
y: player.y,
rx: player.x * TS,
ry: player.y * TS,
dx: finalDx,
dy: finalDy,
speed: 20,
damage: Math.floor(player.atk * 0.8), // 80% of normal damage
critChance: knifeCrit,
type: 'throwing_knife',
color: "#fff",
status: 'bleed', // Causes bleeding damage over time
element: 'physical',
range: 6,
distance: 0,
lastMove: 0
});
addFloat("Throwing Knife!", player.rx, player.ry, "#fff");
} else if (player.class === 'warrior') {
// Warrior: Rage ability
if (player.mana < 5) {
addFloat("NO RAGE", player.rx, player.ry, PAL.mana);
return;
}
player.mana -= 5;
// Apply rage effect - temporary damage boost
if (!player.statusEffects) player.statusEffects = {};
player.statusEffects.rage = {
turns: 5,
damageMultiplier: 1.5,
defenseMultiplier: 0.8 // Takes more damage while enraged
};
addFloat("RAGE ACTIVATED!", player.rx, player.ry, "#f00", true);
updateHUD();
} else if (player.class === 'mage') {
// Mage: Magic Missile
if (player.mana < 4) {
addFloat("NO MANA", player.rx, player.ry, PAL.mana);
return;
}
player.mana -= 4;
updateHUD();
let finalDx = lastDirection.dx;
let finalDy = lastDirection.dy;
if (finalDx === 0 && finalDy === 0) {
finalDx = 1;
}
// Mage magic missile homes in on nearest enemy
let nearestEnemy = entities.find(e =>
!e.markedForRemoval &&
e.hp > 0 &&
e.type !== 'ladder' &&
e.type !== 'container' &&
e.type !== 'loot' &&
e.type !== 'merchant'
);
// If there's an enemy, home in on it
if (nearestEnemy) {
finalDx = Math.sign(nearestEnemy.x - player.x);
finalDy = Math.sign(nearestEnemy.y - player.y);
// If directly aligned, use original direction
if (finalDx === 0 && finalDy === 0) {
finalDx = lastDirection.dx || 1;
}
}
projectiles.push({
x: player.x,
y: player.y,
rx: player.x * TS,
ry: player.y * TS,
dx: finalDx,
dy: finalDy,
speed: 15,
damage: Math.floor(player.atk * 1.2), // 120% of normal damage
critChance: player.crit,
type: 'magic_missile',
color: "#ff00ff",
status: 'stun', // Chance to stun
element: 'arcane',
range: 8,
distance: 0,
lastMove: 0,
homing: true // This projectile will adjust direction
});
addFloat("Magic Missile!", player.rx, player.ry, "#ff00ff");
}
lastAttackTime = now;
tick();
}
function spawnPart(x,y,n,c) {
for(let i=0;i<n;i++) {
particles.push({
x: x + (Math.random()*4 - 2),
y: y + (Math.random()*4 - 2),
vx: (Math.random()-0.5) * 2,
vy: (Math.random()-0.5) * 2,
c,
l:20
});
}
}
function addFloat(t, x, y, c, b=false) {
floaters.push({t, x, y, c, l:40, b});
}
function quitGame() {
gameState = "MENU";
document.getElementById('menu-start').style.display = 'flex';
document.getElementById('controls-area').style.display = 'none';
closeModal('menu-inv');
closeModal('menu-merchant');
// FIXED: Reset level-up notifications on quit
levelUpQueue = [];
isShowingLevelUp = false;
floaters = floaters.filter(f => !f.t.includes("LEVEL UP"));
// Stop any ongoing movement
stopMove();
}
/** ========== UI FUNCTIONS ========== */
function openInventory(tab = 'stats') {
document.getElementById('menu-inv').style.display = 'flex';
selItem = null;
renderInv();
updateItemInfo();
switchTab(tab);
}
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
if (btn.getAttribute('data-tab') === tab) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
document.querySelectorAll('.tab-content').forEach(content => {
if (content.id === `tab-${tab}`) {
content.classList.add('active');
} else {
content.classList.remove('active');
}
});
if (tab === 'stats') {
refreshStatsUI();
document.getElementById('alert-stats').style.borderColor = "transparent";
}
selItem = null;
updateItemInfo();
renderInv();
}
function refreshStatsUI() {
const attrGrid = document.getElementById('attribute-grid');
const derivedGrid = document.getElementById('derived-stats-grid');
attrGrid.innerHTML = `<h3>Attributes</h3>`;
derivedGrid.innerHTML = `<h3>Derived Stats</h3>`;
const baseAttrs = getBaseAttr(player.class);
const statsData = [
{ key: 'str', name: 'STR (Attack)', color: '#eaa', base: baseAttrs.str },
{ key: 'vit', name: 'VIT (HP/Def)', color: '#aea', base: baseAttrs.vit },
{ key: 'agi', name: 'AGI (Crit/Dodge)', color: '#aae', base: baseAttrs.agi }
];
statsData.forEach(stat => {
const row = document.createElement('div');
row.className = 'stat-row';
row.innerHTML = `
<div style="width:100px; color:${stat.color}">${stat.name}</div>
<div class="stat-val" id="val-${stat.key}">${player[stat.key]}</div>
<button class="stat-btn" onclick="addStat('${stat.key}')" ${player.points <= 0 ? 'disabled' : ''}>+</button>
`;
attrGrid.appendChild(row);
});
const totals = [
{ name: 'Max HP', val: player.maxHp, color: PAL.hp, decimals: 0 },
{ name: 'Max Mana', val: player.maxMana, color: PAL.mana, decimals: 0 },
{ name: 'Attack', val: player.atk, color: '#ffcc00', decimals: 0 },
{ name: 'Defense', val: player.def, color: '#fff', decimals: 0 },
{ name: 'Crit Chance', val: player.crit, suffix: '%', color: '#aae', decimals: 0 },
{ name: 'Dodge Chance', val: player.agi, suffix: '%', color: '#aae', decimals: 0 },
{ name: 'HP Regen', val: player.regen * 4, suffix: '/s', color: '#0f0', decimals: 1 },
{ name: 'Reflect Dmg', val: player.reflect * 100, suffix: '%', color: '#f8a', decimals: 0 },
];
totals.forEach(t => {
const valText = (t.decimals !== undefined ? t.val.toFixed(t.decimals) : t.val) + (t.suffix || '');
const totalRow = document.createElement('div');
totalRow.className = 'stat-row';
totalRow.innerHTML = `
<div style="width:100px; color:#ccc">${t.name}</div>
<div class="stat-val" style="color:${t.color || '#ffcc00'}">${valText}</div>
<div></div>
`;
derivedGrid.appendChild(totalRow);
});
document.getElementById('stat-pts').innerText = player.points;
}
function addStat(statKey) {
if (player.points > 0) {
player[statKey]++;
player.points--;
recalcStats(false);
}
}
function closeModal(id) {
document.getElementById(id).style.display = 'none';
}
function updateItemInfo() {
const invNameDiv = document.getElementById('item-name');
const invDescDiv = document.getElementById('item-stats-desc');
const invActionBtn = document.getElementById('btn-use-item');
const gearNameDiv = document.getElementById('gear-name');
const gearDescDiv = document.getElementById('gear-stats-desc');
const gearActionBtn = document.getElementById('btn-unequip-item');
invNameDiv.innerText = "No item selected";
invDescDiv.innerHTML = "Select an item from your bag.";
invActionBtn.style.display = 'none';
gearNameDiv.innerText = "Select an equipped item.";
gearDescDiv.innerHTML = "This tab shows your currently equipped gear and allows you to unequip items.";
gearActionBtn.style.display = 'none';
if (!selItem) return;
const item = selItem.item;
let stats = [];
if (item.type === 'potion') {
if (item.restores === 'mana') {
stats.push(`Restores ${item.val} MP`);
} else {
stats.push(`Heals ${item.val} HP`);
}
} else if (item.type === 'currency') {
stats.push(`Value: ${item.qty} Gold`);
} else {
if (item.atk) stats.push(`ATK: +${item.atk}`);
if (item.def) stats.push(`DEF: +${item.def}`);
if (item.crit) stats.push(`CRT: +${item.crit}%`);
if (item.effect) {
if (item.effect.maxMana) stats.push(`+${item.effect.maxMana} Max Mana`);
if (item.effect.regen) stats.push(`+${item.effect.regen} Regen Rate`);
if (item.effect.reflect) stats.push(`+${(item.effect.reflect * 100).toFixed(0)}% Reflect`);
if (item.effect.str) stats.push(`+${item.effect.str} STR`);
if (item.effect.agi) stats.push(`+${item.effect.agi} AGI`);
if (item.effect.rangedCost) stats.push(`Ranged Cost: ${item.effect.rangedCost} MN`);
}
if (item.element) {
const elementNames = {fire: 'Fire', ice: 'Ice', poison: 'Poison', lightning: 'Lightning'};
stats.push(`<span style="color:#ff0">${elementNames[item.element] || item.element}</span>`);
}
}
const descHtml = stats.join(' | ');
if (selItem.location === 'bag') {
invNameDiv.innerText = item.name || "Unknown Item";
invDescDiv.innerHTML = descHtml;
if (item.type === 'potion') {
invActionBtn.innerText = 'DRINK';
invActionBtn.style.display = 'block';
} else if (item.slot) {
const displaySlot = selItem.targetSlot.toUpperCase().replace('RING2', 'RING R').replace('RING', 'RING L');
invActionBtn.innerText = `EQUIP (${displaySlot})`;
invActionBtn.style.display = 'block';
}
} else {
gearNameDiv.innerText = item.name || "Unknown Item";
gearDescDiv.innerHTML = descHtml;
gearActionBtn.innerText = `UNEQUIP (${selItem.location.toUpperCase().replace('RING2', 'RING R').replace('RING', 'RING L')})`;
gearActionBtn.style.display = 'block';
}
}
function handleItemAction() {
if (!selItem) return;
const item = selItem.item;
let turnTaken = false;
if (selItem.location === 'bag' && item.type === 'potion') {
if (item.restores === 'mana') {
if (player.mana < player.maxMana) {
player.mana = Math.min(player.maxMana, player.mana + item.val);
addFloat("MANA RESTORED", player.rx, player.ry, "#00f");
player.inv[selItem.index].qty--;
if(player.inv[selItem.index].qty <= 0) player.inv.splice(selItem.index, 1);
turnTaken = true;
} else {
addFloat("Full Mana!", player.rx, player.ry, "#888");
}
} else {
if (player.hp < player.maxHp) {
player.hp = Math.min(player.maxHp, player.hp + item.val);
addFloat("HEAL", player.rx, player.ry, "#0f0");
player.inv[selItem.index].qty--;
if(player.inv[selItem.index].qty <= 0) player.inv.splice(selItem.index, 1);
turnTaken = true;
} else {
addFloat("Full HP!", player.rx, player.ry, "#888");
}
}
} else if (selItem.location === 'bag' && item.slot) {
const slot = selItem.targetSlot || item.slot;
const oldItem = player.equip[slot];
player.equip[slot] = item;
player.inv.splice(selItem.index, 1);
if (oldItem) {
player.inv.push(oldItem);
}
turnTaken = true;
} else if (selItem.location !== 'bag' && item.slot) {
const slot = selItem.location;
const unequippedItem = player.equip[slot];
player.equip[slot] = null;
if (unequippedItem) {
player.inv.push(unequippedItem);
}
turnTaken = true;
}
selItem = null;
recalcStats();
renderInv();
updateItemInfo();
if (turnTaken) {
tick();
}
}
function selectItem(item, location, index = -1) {
if (!item) {
selItem = null;
} else if (selItem && selItem.item.id === item.id && selItem.location === location) {
selItem = null;
} else {
let targetSlot = item.slot;
if (location === 'bag' && item.type === 'ring') {
if (!player.equip.ring) {
targetSlot = 'ring';
} else if (!player.equip.ring2) {
targetSlot = 'ring2';
} else {
targetSlot = 'ring';
}
}
selItem = { item, location, index, targetSlot };
}
updateItemInfo();
renderInv();
}
function renderInv() {
const equipSlots = ['helm', 'necklace', 'weapon', 'ring', 'armor', 'ring2', 'grieves'];
equipSlots.forEach(slot => {
const item = player.equip[slot];
const slotDiv = document.getElementById(`equip-${slot}`);
if (!slotDiv) return;
const iconSpan = slotDiv.querySelector('.equip-icon');
const isSelected = selItem && item && selItem.item.id === item.id;
if (item) {
iconSpan.innerText = item.icon || "?";
slotDiv.style.border = isSelected && activeTab === 'gear' ? '2px solid #fff' : '1px solid #ffcc00';
slotDiv.onclick = () => selectItem(item, slot);
} else {
iconSpan.innerText = "";
slotDiv.style.border = '1px dashed #444';
slotDiv.onclick = () => selectItem(null, slot);
}
});
let c = document.getElementById('inv-container');
c.innerHTML = "";
player.inv.sort((a, b) => (a.sort || 99) - (b.sort || 99));
player.inv.forEach((it, i) => {
let div = document.createElement('div');
div.className = "inv-slot";
const isSelected = selItem && selItem.item.id === it.id;
if(isSelected && activeTab === 'inventory') div.classList.add('selected');
else div.classList.remove('selected');
div.innerHTML = `${it.icon}<span class="inv-qty">${it.qty}</span>`;
div.onclick = () => selectItem(it, 'bag', i);
c.appendChild(div);
});
}
function equipBestItems() {
const allSlots = ['weapon', 'armor', 'helm', 'grieves', 'ring', 'ring2', 'necklace'];
let turnTaken = false;
allSlots.forEach(slot => {
let bestItem = player.equip[slot];
let bestScore = -1;
let bestIndex = -1;
// Determine primary stat for this slot
const getItemScore = (item) => {
if (!item) return -1;
let score = 0;
if (slot === 'weapon') {
// Weapon: prioritize attack, then crit
score = (item.atk || 0) * 10 + (item.crit || 0) * 2;
if (item.effect) {
score += (item.effect.atk || 0) * 10;
score += (item.effect.crit || 0) * 2;
// Bonus for ranged weapons
if (item.isRanged) score += 5;
}
} else if (slot === 'armor' || slot === 'helm' || slot === 'grieves') {
// Defense items: prioritize defense
score = (item.def || 0) * 10;
if (item.effect) {
score += (item.effect.def || 0) * 10;
// Bonus for HP/regen effects
score += (item.effect.maxHp || 0) * 0.5;
score += (item.effect.regen || 0) * 3;
}
} else if (slot === 'ring' || slot === 'ring2' || slot === 'necklace') {
// Accessories: balanced scoring
score = (item.atk || 0) * 8 + (item.def || 0) * 8 + (item.crit || 0) * 3;
if (item.effect) {
score += (item.effect.atk || 0) * 8;
score += (item.effect.def || 0) * 8;
score += (item.effect.crit || 0) * 3;
score += (item.effect.str || 0) * 5;
score += (item.effect.agi || 0) * 5;
score += (item.effect.maxHp || 0) * 0.3;
score += (item.effect.maxMana || 0) * 0.5;
score += (item.effect.regen || 0) * 2;
score += (item.effect.reflect || 0) * 20;
}
}
return score;
};
// Get current item score
if (bestItem) {
bestScore = getItemScore(bestItem);
}
// Check inventory for better items
player.inv.forEach((item, index) => {
if (item.slot === slot) {
const itemScore = getItemScore(item);
if (itemScore > bestScore) {
bestScore = itemScore;
bestItem = item;
bestIndex = index;
}
}
});
// Equip if found better item
if (bestIndex !== -1) {
const oldItem = player.equip[slot];
player.equip[slot] = bestItem;
player.inv.splice(bestIndex, 1);
if (oldItem) {
player.inv.push(oldItem);
}
turnTaken = true;
}
});
if (turnTaken) {
recalcStats();
renderInv();
tick();
addFloat("Best Items Equipped", player.rx, player.ry, "#0fa");
} else {
addFloat("No Better Items Found", player.rx, player.ry, "#888");
}
}
function openMerchant() {
document.getElementById('menu-merchant').style.display = 'flex';
if (floorData[floor]) {
floorData[floor].merchantVisited = true;
}
renderMerchant('buy');
}
function renderMerchant(tab) {
document.querySelectorAll('#menu-merchant .tab-btn').forEach(btn => {
if (btn.getAttribute('data-tab') === tab) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
document.getElementById('tab-buy').style.display = tab === 'buy' ? 'block' : 'none';
document.getElementById('tab-sell').style.display = tab === 'sell' ? 'block' : 'none';
selItem = null;
document.getElementById('merchant-item-name').innerText = "No item selected";
document.getElementById('merchant-stats-desc').innerText = `Select an item to ${tab}.`;
document.getElementById('btn-merchant-action').style.display = 'none';
const buyList = document.getElementById('merchant-buy-list');
const sellList = document.getElementById('merchant-sell-list');
buyList.innerHTML = '';
sellList.innerHTML = '';
if (tab === 'buy') {
merchantWares.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'merchant-item';
div.innerHTML = `<span>${item.icon} ${item.name} (x${item.qty||1})</span> <span class="price">${item.buyPrice} G</span>`;
div.onclick = () => selectMerchantItem(item, 'buy', index);
if (selItem && selItem.item === item && selItem.location === 'buy') div.classList.add('selected');
buyList.appendChild(div);
});
}
if (tab === 'sell') {
player.inv.forEach((item, index) => {
if (item.type === 'currency') return;
const isEquipped = Object.values(player.equip).some(e => e && e.id === item.id);
if (isEquipped) return;
const sellValue = Math.floor((item.baseValue || 1) * 0.5 * (item.qty || 1));
const div = document.createElement('div');
div.className = 'merchant-item';
div.innerHTML = `<span>${item.icon} ${item.name} (x${item.qty||1})</span> <span class="price">${sellValue} G</span>`;
div.onclick = () => selectMerchantItem(item, 'sell', index);
if (selItem && selItem.item.id === item.id && selItem.location === 'sell') div.classList.add('selected');
sellList.appendChild(div);
});
}
}
function selectMerchantItem(item, location, index) {
if (selItem && selItem.item.id === item.id && selItem.location === location) {
selItem = null;
} else {
selItem = { item, location, index };
}
renderMerchant(location);
const infoName = document.getElementById('merchant-item-name');
const infoDesc = document.getElementById('merchant-stats-desc');
const actionBtn = document.getElementById('btn-merchant-action');
if (selItem) {
infoName.innerText = selItem.item.name;
if (location === 'buy') {
const price = selItem.item.buyPrice;
infoDesc.innerText = `Buy for ${price} G.`;
actionBtn.innerText = 'BUY';
actionBtn.style.display = player.gold >= price ? 'block' : 'none';
} else {
const value = Math.floor((selItem.item.baseValue || 1) * 0.5 * (selItem.item.qty || 1));
infoDesc.innerText = `Sell for ${value} G.`;
actionBtn.innerText = 'SELL';
actionBtn.style.display = 'block';
}
} else {
infoName.innerText = "No item selected";
infoDesc.innerText = `Select an item to ${location}.`;
actionBtn.style.display = 'none';
}
}
function handleMerchantAction() {
if (!selItem) return;
if (selItem.location === 'buy') {
const item = merchantWares[selItem.index];
const price = item.buyPrice;
if (player.gold >= price) {
player.gold -= price;
const qty = item.qty || 1;
let stack = player.inv.find(i => i.name === item.name && i.type === item.type);
if (item.stackable && stack) {
stack.qty += qty;
} else {
let newItem = {...item};
if (!item.stackable) newItem.id = Math.random() + Date.now();
player.inv.push(newItem);
}
if (item.stackable) {
item.qty--;
if (item.qty <= 0) {
merchantWares.splice(selItem.index, 1);
}
} else {
merchantWares.splice(selItem.index, 1);
}
addFloat(`BOUGHT ${item.name}`, player.rx, player.ry, PAL.gold);
updateHUD();
}
} else if (selItem.location === 'sell') {
const item = selItem.item;
const value = Math.floor((item.baseValue || 1) * 0.5 * (item.qty || 1));
player.gold += value;
player.inv.splice(selItem.index, 1);
addFloat(`SOLD ${item.name}`, player.rx, player.ry, PAL.gold);
updateHUD();
}
selItem = null;
renderMerchant(selItem ? selItem.location : 'buy');
tick();
}
/** ========== INITIALIZE GAME ========== */
bakeSprites();
// Show menu on load
document.getElementById('menu-start').style.display = 'flex';
document.getElementById('controls-area').style.display = 'none';
// Start game loops
requestAnimationFrame(loop);
requestAnimationFrame(updateProjectiles);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment