Created
January 12, 2026 04:32
-
-
Save Jchronos/452cf1f53fb81af49d7de896580087a0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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