Shadows
Qwen 3.6 35b a3b · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 121,800 bytes · SHA-256 670974de01db
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shadows of the Abyss</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; color: #c8c0b8; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; overflow: hidden; }
#app { width: 100vw; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.screen { display: none; width: 100%; height: 100%; flex-direction: column; align-items: center; justify-content: center; }
.screen.active { display: flex; }
/* Class Selection */
#class-select { background: radial-gradient(ellipse at center, #1a1020 0%, #0a0a0f 70%); }
#class-select h1 { font-size: 3rem; color: #d4a050; text-shadow: 0 0 30px #8b4513; margin-bottom: 2rem; letter-spacing: 4px; }
.class-cards { display: flex; gap: 2rem; margin-top: 2rem; }
.class-card { background: linear-gradient(145deg, #1a1520, #0f0a15); border: 2px solid #2a2030; border-radius: 12px; padding: 2rem; width: 280px; cursor: pointer; transition: all 0.3s; text-align: center; }
.class-card:hover { border-color: #d4a050; transform: translateY(-8px); box-shadow: 0 12px 40px rgba(212,160,80,0.2); }
.class-card h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.class-card .theme { color: #887766; font-style: italic; margin-bottom: 1rem; }
.class-card .stats { font-size: 0.85rem; color: #665544; line-height: 1.6; }
.class-card .cards-preview { margin-top: 1rem; font-size: 0.8rem; color: #554433; }
/* Map Screen */
#map-screen { background: #0a0a0f; }
#map-container { width: 900px; height: 600px; position: relative; overflow: hidden; }
#map-canvas { width: 100%; height: 100%; }
.map-info { position: absolute; top: 10px; left: 10px; background: rgba(10,10,15,0.9); padding: 8px 16px; border-radius: 8px; font-size: 0.9rem; border: 1px solid #2a2030; }
.map-info span { margin-right: 16px; }
.map-info .gold { color: #d4a050; }
.map-info .hp { color: #c04040; }
.map-info .act { color: #d4a050; }
.map-back { position: absolute; bottom: 10px; left: 10px; background: #1a1520; border: 1px solid #2a2030; color: #887766; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.map-back:hover { border-color: #d4a050; color: #d4a050; }
/* Combat Screen */
#combat-screen { background: radial-gradient(ellipse at bottom, #15101a 0%, #0a0a0f 60%); }
#combat-canvas { width: 1000px; height: 500px; }
#combat-hud { width: 1000px; height: 200px; position: relative; }
#end-turn-btn { position: absolute; right: 10px; top: 10px; background: linear-gradient(145deg, #2a1a10, #1a0f08); border: 2px solid #5a3a20; color: #d4a050; padding: 8px 20px; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: bold; transition: all 0.2s; }
#end-turn-btn:hover { border-color: #d4a050; box-shadow: 0 0 15px rgba(212,160,80,0.3); }
#end-turn-btn:disabled { opacity: 0.4; cursor: default; }
.energy-display { position: absolute; right: 10px; top: 50px; font-size: 1.2rem; color: #d4a050; }
.turn-display { position: absolute; right: 10px; top: 75px; font-size: 0.9rem; color: #887766; }
/* Card Hand */
#card-hand { position: absolute; bottom: 0; left: 0; right: 0; height: 200px; display: flex; justify-content: center; align-items: flex-end; gap: 8px; padding: 0 20px; }
.card { width: 140px; height: 190px; background: linear-gradient(145deg, #1e1828, #15101e); border: 2px solid #2a2035; border-radius: 10px; cursor: pointer; transition: all 0.2s; position: relative; flex-shrink: 0; display: flex; flex-direction: column; user-select: none; }
.card:hover { transform: translateY(-20px); z-index: 10; border-color: #d4a050; box-shadow: 0 8px 30px rgba(212,160,80,0.25); }
.card.playable { border-color: #3a5a3a; }
.card.playable:hover { border-color: #5a8a5a; }
.card.unplayable { opacity: 0.5; }
.card .card-cost { position: absolute; top: -8px; left: -8px; width: 30px; height: 30px; background: #1a3a5a; border: 2px solid #3a6a9a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #6ab0e0; font-size: 1rem; }
.card .card-type { position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: bold; }
.card .card-type.attack { background: #5a2020; color: #e06060; }
.card .card-type.skill { background: #203050; color: #6090d0; }
.card .card-type.power { background: #302050; color: #a080d0; }
.card .card-name { text-align: center; font-size: 0.7rem; font-weight: bold; color: #d4a050; padding: 16px 6px 4px; line-height: 1.1; }
.card .card-desc { text-align: center; font-size: 0.6rem; color: #887766; padding: 4px 8px; flex-grow: 1; display: flex; align-items: center; justify-content: center; line-height: 1.3; }
.card .card-rarity { text-align: center; font-size: 0.55rem; color: #554433; padding-bottom: 6px; }
.card-rarity.common { color: #668866; }
.card-rarity.uncommon { color: #6666aa; }
.card-rarity.rare { color: #aa8833; }
/* Enemies */
#enemy-zone { width: 1000px; display: flex; justify-content: center; gap: 40px; }
.enemy { text-align: center; padding: 10px; }
.enemy-sprite { width: 80px; height: 100px; margin: 0 auto 8px; background: #1a1520; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; border: 2px solid #2a2030; }
.enemy .intent-icon { font-size: 1.2rem; margin-bottom: 4px; }
.enemy .hp-bar { width: 100px; height: 8px; background: #1a1010; border-radius: 4px; overflow: hidden; margin: 0 auto; }
.enemy .hp-fill { height: 100%; background: linear-gradient(90deg, #8b2020, #c04040); transition: width 0.3s; }
.enemy .block-indicator { font-size: 0.75rem; color: #6090d0; }
.enemy .intent-text { font-size: 0.7rem; color: #887766; margin-top: 2px; }
.enemy .name { font-size: 0.85rem; color: #d4a050; margin-bottom: 4px; }
/* Player Zone */
#player-zone { display: flex; align-items: center; gap: 20px; margin-bottom: 8px; }
.player-sprite { width: 70px; height: 90px; background: #1a1520; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 2rem; border: 2px solid #2a2030; }
.player-stats { font-size: 0.85rem; }
.player-stats .hp { color: #c04040; }
.player-stats .block { color: #6090d0; }
.player-stats .strength { color: #e0a040; }
/* Power Zone */
#power-zone { display: flex; gap: 8px; margin-bottom: 4px; min-height: 30px; }
.power-card { background: #1a1528; border: 1px solid #3a2a50; border-radius: 6px; padding: 4px 10px; font-size: 0.7rem; color: #a080d0; }
/* Status effects */
#status-zone { display: flex; gap: 6px; margin-bottom: 4px; min-height: 24px; }
.status-badge { background: #1a1520; border: 1px solid #2a2030; border-radius: 4px; padding: 2px 8px; font-size: 0.65rem; }
.status-badge.debuff { color: #c06060; border-color: #4a2020; }
.status-badge.buff { color: #60c060; border-color: #204a20; }
.status-badge.neutral { color: #a0a060; border-color: #3a3a20; }
/* Card Reward */
#reward-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(5,5,10,0.85); display: none; align-items: center; justify-content: center; z-index: 100; }
#reward-overlay.active { display: flex; }
#reward-container { background: linear-gradient(145deg, #1a1525, #0f0a18); border: 2px solid #2a2035; border-radius: 16px; padding: 2rem; text-align: center; }
#reward-container h2 { color: #d4a050; margin-bottom: 1.5rem; }
.reward-cards { display: flex; gap: 1.5rem; justify-content: center; margin-bottom: 1.5rem; }
.reward-card { width: 160px; height: 210px; background: linear-gradient(145deg, #1e1828, #15101e); border: 2px solid #2a2035; border-radius: 10px; cursor: pointer; transition: all 0.2s; display: flex; flex-direction: column; padding: 10px; }
.reward-card:hover { border-color: #d4a050; transform: translateY(-10px); box-shadow: 0 8px 30px rgba(212,160,80,0.2); }
.reward-card .card-cost { position: absolute; top: -8px; left: -8px; width: 30px; height: 30px; background: #1a3a5a; border: 2px solid #3a6a9a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #6ab0e0; font-size: 1rem; }
.reward-card .card-name { text-align: center; font-size: 0.8rem; font-weight: bold; color: #d4a050; padding: 24px 4px 4px; }
.reward-card .card-desc { text-align: center; font-size: 0.65rem; color: #887766; padding: 4px; flex-grow: 1; display: flex; align-items: center; justify-content: center; line-height: 1.3; }
.reward-card .card-rarity { text-align: center; font-size: 0.6rem; color: #554433; padding-bottom: 4px; }
.skip-btn { background: #1a1520; border: 1px solid #2a2030; color: #887766; padding: 8px 24px; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.skip-btn:hover { border-color: #d4a050; color: #d4a050; }
/* Shop */
#shop-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(5,5,10,0.85); display: none; align-items: center; justify-content: center; z-index: 100; }
#shop-overlay.active { display: flex; }
#shop-container { background: linear-gradient(145deg, #1a1525, #0f0a18); border: 2px solid #2a2035; border-radius: 16px; padding: 2rem; text-align: center; max-width: 900px; }
#shop-container h2 { color: #d4a050; margin-bottom: 1.5rem; }
.shop-section { margin-bottom: 1.5rem; }
.shop-section h3 { color: #887766; font-size: 0.9rem; margin-bottom: 0.5rem; }
.shop-items { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.shop-item { width: 140px; background: #1a1520; border: 2px solid #2a2030; border-radius: 10px; padding: 10px; cursor: pointer; transition: all 0.2s; text-align: center; }
.shop-item:hover { border-color: #d4a050; }
.shop-item .price { color: #d4a050; font-size: 0.8rem; margin-top: 4px; }
.shop-item .item-name { font-size: 0.75rem; color: #d4a050; margin-bottom: 4px; }
.shop-item .item-desc { font-size: 0.6rem; color: #887766; line-height: 1.2; }
.shop-item.disabled { opacity: 0.4; cursor: default; }
.remove-card-btn { background: #1a1520; border: 1px solid #2a2030; color: #887766; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; margin-top: 1rem; }
.remove-card-btn:hover { border-color: #d4a050; color: #d4a050; }
.shop-close { background: #1a1520; border: 1px solid #2a2030; color: #887766; padding: 8px 24px; border-radius: 6px; cursor: pointer; font-size: 0.9rem; margin-top: 1rem; }
.shop-close:hover { border-color: #d4a050; color: #d4a050; }
/* Event */
#event-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(5,5,10,0.85); display: none; align-items: center; justify-content: center; z-index: 100; }
#event-overlay.active { display: flex; }
#event-container { background: linear-gradient(145deg, #1a1525, #0f0a18); border: 2px solid #2a2035; border-radius: 16px; padding: 2rem; text-align: center; max-width: 600px; }
#event-container h2 { color: #d4a050; margin-bottom: 1rem; }
#event-text { color: #c8c0b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.event-choices { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.event-choice { background: #1a1520; border: 2px solid #2a2030; color: #c8c0b8; padding: 10px 16px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; text-align: left; min-width: 200px; transition: all 0.2s; }
.event-choice:hover { border-color: #d4a050; color: #d4a050; }
.event-choice .choice-label { font-weight: bold; color: #d4a050; margin-bottom: 4px; }
.event-choice .choice-desc { color: #887766; font-size: 0.75rem; }
/* Rest Site */
#rest-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(5,5,10,0.85); display: none; align-items: center; justify-content: center; z-index: 100; }
#rest-overlay.active { display: flex; }
#rest-container { background: linear-gradient(145deg, #1a1525, #0f0a18); border: 2px solid #2a2035; border-radius: 16px; padding: 2rem; text-align: center; max-width: 500px; }
#rest-container h2 { color: #d4a050; margin-bottom: 1rem; }
.rest-choices { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.rest-choice { background: #1a1520; border: 2px solid #2a2030; color: #c8c0b8; padding: 14px 20px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; text-align: left; min-width: 150px; transition: all 0.2s; }
.rest-choice:hover { border-color: #d4a050; color: #d4a050; }
.rest-choice .choice-label { font-weight: bold; color: #d4a050; margin-bottom: 4px; }
.rest-choice .choice-desc { color: #887766; font-size: 0.75rem; }
/* Card removal */
#remove-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(5,5,10,0.85); display: none; align-items: center; justify-content: center; z-index: 100; }
#remove-overlay.active { display: flex; }
#remove-container { background: linear-gradient(145deg, #1a1525, #0f0a18); border: 2px solid #2a2035; border-radius: 16px; padding: 2rem; text-align: center; max-width: 700px; }
#remove-container h2 { color: #d4a050; margin-bottom: 1rem; }
#remove-list { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-bottom: 1rem; }
.remove-item { background: #1a1520; border: 1px solid #2a2030; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.75rem; color: #c8c0b8; transition: all 0.2s; }
.remove-item:hover { border-color: #d4a050; color: #d4a050; }
/* Potion bar */
#potion-bar { display: flex; gap: 6px; margin-bottom: 4px; }
.potion-slot { width: 36px; height: 36px; background: #1a1520; border: 1px solid #2a2030; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; cursor: pointer; position: relative; }
.potion-slot:hover { border-color: #d4a050; }
.potion-slot .potion-tooltip { display: none; position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%); background: #0f0a18; border: 1px solid #2a2030; padding: 4px 8px; border-radius: 4px; font-size: 0.6rem; color: #c8c0b8; white-space: nowrap; z-index: 50; }
.potion-slot:hover .potion-tooltip { display: block; }
/* Game Over / Victory */
#gameover-screen, #victory-screen { background: rgba(5,5,10,0.95); }
#gameover-screen h1 { font-size: 3rem; color: #c04040; text-shadow: 0 0 30px #8b2020; }
#victory-screen h1 { font-size: 3rem; color: #d4a050; text-shadow: 0 0 30px #8b6513; }
.restart-btn { background: linear-gradient(145deg, #2a1a10, #1a0f08); border: 2px solid #5a3a20; color: #d4a050; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-size: 1.1rem; margin-top: 2rem; }
.restart-btn:hover { border-color: #d4a050; box-shadow: 0 0 20px rgba(212,160,80,0.3); }
/* Deck info */
#deck-info { position: absolute; top: 10px; right: 10px; background: rgba(10,10,15,0.9); padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; border: 1px solid #2a2030; }
#deck-info span { margin-right: 12px; color: #887766; }
/* Combat log */
#combat-log { position: absolute; top: 10px; left: 10px; width: 250px; max-height: 200px; overflow-y: auto; background: rgba(10,10,15,0.85); padding: 8px; border-radius: 8px; font-size: 0.7rem; color: #887766; border: 1px solid #2a2030; }
#combat-log .log-entry { margin-bottom: 2px; }
#combat-log .log-damage { color: #c06060; }
#combat-log .log-heal { color: #60c060; }
#combat-log .log-block { color: #6090d0; }
#combat-log .log-gold { color: #d4a050; }
/* Map node styles */
.map-node { position: absolute; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; cursor: pointer; transition: all 0.3s; border: 2px solid #2a2030; z-index: 10; }
.map-node:hover { transform: scale(1.2); }
.map-node.player { background: #d4a050; border-color: #e8b860; box-shadow: 0 0 15px rgba(212,160,80,0.5); }
.map-node.enemy { background: #2a1a10; border-color: #5a3a20; color: #c06060; }
.map-node.elite { background: #3a1a10; border-color: #8a3a20; color: #e06040; box-shadow: 0 0 8px rgba(140,60,20,0.3); }
.map-node.boss { background: #2a0a20; border-color: #6a2060; color: #a040a0; box-shadow: 0 0 12px rgba(120,40,120,0.4); }
.map-node.rest { background: #1a2a10; border-color: #3a5a20; color: #60a040; }
.map-node.shop { background: #2a2a10; border-color: #5a5a20; color: #d4a050; }
.map-node.event { background: #1a1a2a; border-color: #3a3a5a; color: #8080c0; }
.map-node.start { background: #1a2a2a; border-color: #3a5a5a; color: #60a0a0; }
.map-node.visited { opacity: 0.4; }
.map-node.current { border-color: #d4a050; box-shadow: 0 0 10px rgba(212,160,80,0.4); }
.map-node.locked { opacity: 0.2; cursor: default; }
.map-node.locked:hover { transform: none; }
/* Paths between nodes */
.map-path { position: absolute; height: 2px; background: #2a2030; z-index: 5; transform-origin: left center; }
</style>
</head>
<body>
<div id="app"></div>
<script>
'use strict';
// ── const: CARD_TYPES ──
// ===================== CARD DEFINITIONS =====================
const CARD_TYPES = { ATTACK: "attack", SKILL: "skill", POWER: "power" };
// ── const: RARITY ──
const RARITY = { COMMON: "common", UNCOMMON: "uncommon", RARE: "rare" };
// ── const: BASIC_CARDS ──
// Basic cards (all classes)
const BASIC_CARDS = {
strike: { name: "Strike", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 6 damage.", upgDesc: "Deal 8 damage.", upgraded: false, exhaust: false },
defend: { name: "Defend", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 5 Block.", upgDesc: "Gain 8 Block.", upgraded: false, exhaust: false }
};
// ── const: BLOOD_REAPER_CARDS ──
// Blood Reaper cards
const BLOOD_REAPER_CARDS = {
sanguine_strike: { name: "Sanguine Strike", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 8 damage. Heal 2 HP.", upgDesc: "Deal 10 damage. Heal 3 HP.", upgraded: false, exhaust: false, heal: 2, dmg: 8 },
hemorrhage: { name: "Hemorrhage", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 5 damage. Apply 3 Bleed.", upgDesc: "Deal 6 damage. Apply 4 Bleed.", upgraded: false, exhaust: false, dmg: 5, bleed: 3 },
bloodletting: { name: "Bloodletting", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.UNCOMMON, desc: "Lose 3 HP. Deal 12 damage.", upgDesc: "Lose 2 HP. Deal 15 damage.", upgraded: false, exhaust: false, dmg: 12, selfDmg: 3 },
crimson_eruption: { name: "Crimson Eruption", type: CARD_TYPES.ATTACK, cost: 2, rarity: RARITY.RARE, desc: "Deal 8 damage to ALL enemies. Lose 2 HP.", upgDesc: "Deal 10 damage to ALL enemies. Lose 2 HP.", upgraded: false, exhaust: false, dmg: 8, selfDmg: 2, allEnemies: true },
crimson_shield: { name: "Crimson Shield", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 6 Block. Lose 2 HP.", upgDesc: "Gain 8 Block. Lose 2 HP.", upgraded: false, exhaust: false, block: 6, selfDmg: 2 },
blood_ward: { name: "Blood Ward", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 3 Block. Gain 1 Regeneration.", upgDesc: "Gain 4 Block. Gain 2 Regeneration.", upgraded: false, exhaust: false, block: 3, regen: 1 },
sacrificial_barrier: { name: "Sacrificial Barrier", type: CARD_TYPES.SKILL, cost: 2, rarity: RARITY.UNCOMMON, desc: "Gain 10 Block. Lose 5 HP.", upgDesc: "Gain 13 Block. Lose 5 HP.", upgraded: false, exhaust: false, block: 10, selfDmg: 5 },
vital_surge: { name: "Vital Surge", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.RARE, desc: "Gain Block equal to missing HP. Exhaust.", upgDesc: "Gain Block = missing HP + 3. Exhaust.", upgraded: false, exhaust: true, block: 0 },
life_tap: { name: "Life Tap", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.COMMON, desc: "Draw 2 cards. Lose 3 HP.", upgDesc: "Draw 3 cards. Lose 3 HP.", upgraded: false, exhaust: false, draw: 2, selfDmg: 3 },
rend_flesh: { name: "Rend Flesh", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.UNCOMMON, desc: "Apply 5 Bleed to ALL enemies. Lose 2 HP.", upgDesc: "Apply 7 Bleed to ALL enemies. Lose 2 HP.", upgraded: false, exhaust: false, bleed: 5, selfDmg: 2, allEnemies: true },
soul_siphon: { name: "Soul Siphon", type: CARD_TYPES.POWER, cost: 2, rarity: RARITY.UNCOMMON, desc: "At start of turn: gain 1 Energy, lose 1 HP.", upgDesc: "At start of turn: gain 1 Energy, lose 1 HP. (Cost 1)", upgraded: false, exhaust: false, power: "soul_siphon", energyGain: 1, selfDmg: 1 },
blood_pact: { name: "Blood Pact", type: CARD_TYPES.POWER, cost: 1, rarity: RARITY.RARE, desc: "Whenever you lose HP, gain 1 Strength.", upgDesc: "Whenever you lose HP, gain 2 Strength.", upgraded: false, exhaust: false, power: "blood_pact", strengthGain: 1 }
};
// ── const: SHADOWMANCER_CARDS ──
// Shadowmancer cards
const SHADOWMANCER_CARDS = {
shadow_bolt: { name: "Shadow Bolt", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 6 damage. Apply 1 Weak.", upgDesc: "Deal 8 damage. Apply 2 Weak.", upgraded: false, exhaust: false, dmg: 6, weak: 1 },
grasp_of_despair: { name: "Grasp of Despair", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 4 damage. Apply 1 Vulnerable.", upgDesc: "Deal 5 damage. Apply 2 Vulnerable.", upgraded: false, exhaust: false, dmg: 4, vulnerable: 1 },
nightmare_lash: { name: "Nightmare Lash", type: CARD_TYPES.ATTACK, cost: 2, rarity: RARITY.UNCOMMON, desc: "Deal 7 damage. If enemy has Weak, deal +7.", upgDesc: "Deal 9 damage. If Weak, deal +9.", upgraded: false, exhaust: false, dmg: 7, weakBonus: 7 },
eternal_darkness: { name: "Eternal Darkness", type: CARD_TYPES.ATTACK, cost: 2, rarity: RARITY.RARE, desc: "Deal 3 damage to random enemy 3 times.", upgDesc: "Deal 4 damage to random enemy 3 times.", upgraded: false, exhaust: false, dmg: 3, multiHit: 3 },
cloak_of_shadows: { name: "Cloak of Shadows", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 5 Block. Draw 1 card.", upgDesc: "Gain 7 Block. Draw 1 card.", upgraded: false, exhaust: false, block: 5, draw: 1 },
haunting_mist: { name: "Haunting Mist", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 7 Block. Apply 1 Weak to ALL enemies.", upgDesc: "Gain 9 Block. Apply 1 Weak to ALL enemies.", upgraded: false, exhaust: false, block: 7, weak: 1, allEnemies: true },
phantom_armor: { name: "Phantom Armor", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.UNCOMMON, desc: "Gain 8 Block. Next turn gain 2 Dexterity.", upgDesc: "Gain 10 Block. Next turn gain 3 Dexterity.", upgraded: false, exhaust: false, block: 8, dexterityNext: 2 },
whispering_shroud: { name: "Whispering Shroud", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.RARE, desc: "Gain 4 Block. Next turn gain 4 Block.", upgDesc: "Gain 5 Block. Next turn gain 5 Block.", upgraded: false, exhaust: false, block: 4, blockNextTurn: 4 },
shadowform: { name: "Shadowform", type: CARD_TYPES.SKILL, cost: 2, rarity: RARITY.UNCOMMON, desc: "Gain 2 Intangible. Exhaust.", upgDesc: "Gain 3 Intangible. Exhaust.", upgraded: false, exhaust: true, intangible: 2 },
curse_of_misfortune: { name: "Curse of Misfortune", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Enemy loses 3 Strength. Apply 1 Vulnerable.", upgDesc: "Enemy loses 4 Strength. Apply 2 Vulnerable.", upgraded: false, exhaust: false, strengthLoss: 3, vulnerable: 1 },
dark_ritual: { name: "Dark Ritual", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.UNCOMMON, desc: "Gain 2 Energy. Shuffle Dazed into draw pile.", upgDesc: "Gain 2 Energy. Shuffle Dazed into discard.", upgraded: false, exhaust: false, energyGain: 2 },
umbral_mastery: { name: "Umbral Mastery", type: CARD_TYPES.POWER, cost: 2, rarity: RARITY.RARE, desc: "At start of turn: apply 1 Weak to ALL enemies.", upgDesc: "At start of turn: apply 2 Weak to ALL enemies.", upgraded: false, exhaust: false, power: "umbral_mastery", weak: 1, allEnemies: true }
};
// ── const: HELLFORGED_CARDS ──
// Hellforged cards
const HELLFORGED_CARDS = {
firebolt: { name: "Firebolt", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 7 damage. Apply 2 Burn.", upgDesc: "Deal 9 damage. Apply 3 Burn.", upgraded: false, exhaust: false, dmg: 7, burn: 2 },
hellfire_blast: { name: "Hellfire Blast", type: CARD_TYPES.ATTACK, cost: 2, rarity: RARITY.UNCOMMON, desc: "Deal 9 damage to ALL enemies.", upgDesc: "Deal 12 damage to ALL enemies.", upgraded: false, exhaust: false, dmg: 9, allEnemies: true },
searing_strike: { name: "Searing Strike", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 6 damage. Gain 1 Energy next turn.", upgDesc: "Deal 8 damage. Gain 1 Energy next turn.", upgraded: false, exhaust: false, dmg: 6, energyNext: 1 },
immolate: { name: "Immolate", type: CARD_TYPES.ATTACK, cost: 2, rarity: RARITY.RARE, desc: "Deal 12 damage. Exhaust.", upgDesc: "Deal 16 damage. Exhaust.", upgraded: false, exhaust: true, dmg: 12 },
obsidian_skin: { name: "Obsidian Skin", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 7 Block.", upgDesc: "Gain 10 Block.", upgraded: false, exhaust: false, block: 7 },
flame_ward: { name: "Flame Ward", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 5 Block. Apply 2 Burn to random enemy.", upgDesc: "Gain 6 Block. Apply 3 Burn to random enemy.", upgraded: false, exhaust: false, block: 5, burn: 2 },
demonic_resilience: { name: "Demonic Resilience", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.UNCOMMON, desc: "Gain 4 Block. Heal 2 HP.", upgDesc: "Gain 5 Block. Heal 3 HP.", upgraded: false, exhaust: false, block: 4, heal: 2 },
brimstone_shield: { name: "Brimstone Shield", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.RARE, desc: "Gain 3 Block. Gain 2 Strength this turn.", upgDesc: "Gain 4 Block. Gain 3 Strength this turn.", upgraded: false, exhaust: false, block: 3, strength: 2 },
summon_imp: { name: "Summon Imp", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Add Imp Claw to hand. Exhaust.", upgDesc: "Add Imp Claw+ to hand. Exhaust.", upgraded: false, exhaust: true, impClaw: true },
infernal_pact: { name: "Infernal Pact", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.UNCOMMON, desc: "Lose 2 HP. Gain 2 Energy. Add Imp Claw.", upgDesc: "Lose 1 HP. Gain 2 Energy. Add Imp Claw+.", upgraded: false, exhaust: false, selfDmg: 2, energyGain: 2, impClaw: true },
hellfire_aura: { name: "Hellfire Aura", type: CARD_TYPES.POWER, cost: 2, rarity: RARITY.RARE, desc: "At start of turn: deal 3 damage to ALL enemies.", upgDesc: "At start of turn: deal 5 damage to ALL enemies.", upgraded: false, exhaust: false, power: "hellfire_aura", dmg: 3, allEnemies: true },
soul_burn: { name: "Soul Burn", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.UNCOMMON, desc: "Apply 8 Burn to enemy. Draw 1 card.", upgDesc: "Apply 10 Burn to enemy. Draw 1 card.", upgraded: false, exhaust: false, burn: 8, draw: 1 }
};
// ── const: COLORLESS_CARDS ──
// Colorless cards (shop)
const COLORLESS_CARDS = {
flash_of_insight: { name: "Flash of Insight", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Draw 2 cards.", upgDesc: "Draw 3 cards.", upgraded: false, exhaust: false, draw: 2 },
iron_will: { name: "Iron Will", type: CARD_TYPES.SKILL, cost: 1, rarity: RARITY.COMMON, desc: "Gain 8 Block.", upgDesc: "Gain 11 Block.", upgraded: false, exhaust: false, block: 8 },
quick_slash: { name: "Quick Slash", type: CARD_TYPES.ATTACK, cost: 1, rarity: RARITY.COMMON, desc: "Deal 7 damage.", upgDesc: "Deal 9 damage.", upgraded: false, exhaust: false, dmg: 7 },
potent_brew: { name: "Potent Brew", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.UNCOMMON, desc: "Gain 1 Energy. Exhaust.", upgDesc: "Gain 2 Energy. Exhaust.", upgraded: false, exhaust: true, energyGain: 1 }
};
// ── const: CURSE_CARDS ──
// Curses
const CURSE_CARDS = {
wound: { name: "Wound", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.COMMON, desc: "Ethereal. Exhaust. Curses clog your deck.", upgDesc: "", upgraded: false, exhaust: true, curse: true, ethereal: true },
doubt: { name: "Doubt", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.COMMON, desc: "At end of turn, lose 1 HP.", upgDesc: "", upgraded: false, exhaust: false, curse: true, endTurnDmg: 1 },
dazed: { name: "Dazed", type: CARD_TYPES.SKILL, cost: 0, rarity: RARITY.COMMON, desc: "Ethereal. Exhaust. Shuffled into draw pile.", upgDesc: "", upgraded: false, exhaust: true, curse: true, ethereal: true }
};
// ── const: IMP_CLAW ──
// Imp Claw token
const IMP_CLAW = { name: "Imp Claw", type: CARD_TYPES.ATTACK, cost: 0, rarity: RARITY.COMMON, desc: "Deal 4 damage.", upgDesc: "", upgraded: false, exhaust: true, dmg: 4, token: true };
// ── const: IMP_CLAW_PLUS ──
const IMP_CLAW_PLUS = { name: "Imp Claw+", type: CARD_TYPES.ATTACK, cost: 0, rarity: RARITY.COMMON, desc: "Deal 6 damage.", upgDesc: "", upgraded: true, exhaust: true, dmg: 6, token: true };
// ── const: CLASS_CARD_POOLS ──
// All card pools by class
const CLASS_CARD_POOLS = {
blood_reaper: { ...BASIC_CARDS, ...BLOOD_REAPER_CARDS },
shadowmancer: { ...BASIC_CARDS, ...SHADOWMANCER_CARDS },
hellforged: { ...BASIC_CARDS, ...HELLFORGED_CARDS }
};
// ── const: STARTING_DECKS ──
// Starting decks
const STARTING_DECKS = {
blood_reaper: [
{ id: "strike" }, { id: "strike" }, { id: "strike" }, { id: "strike" }, { id: "strike" },
{ id: "defend" }, { id: "defend" }, { id: "defend" }, { id: "defend" },
{ id: "sanguine_strike" }
],
shadowmancer: [
{ id: "strike" }, { id: "strike" }, { id: "strike" }, { id: "strike" }, { id: "strike" },
{ id: "defend" }, { id: "defend" }, { id: "defend" }, { id: "defend" },
{ id: "shadow_bolt" }
],
hellforged: [
{ id: "strike" }, { id: "strike" }, { id: "strike" }, { id: "strike" }, { id: "strike" },
{ id: "defend" }, { id: "defend" }, { id: "defend" }, { id: "defend" },
{ id: "firebolt" }
]
};
// ── function: getCardPool ──
// Card pool for rewards by class (excluding curses and tokens)
function getCardPool(cls) {
const pool = CLASS_CARD_POOLS[cls];
return Object.entries(pool).filter(([k, v]) => !v.curse && !v.token).map(([k, v]) => ({ id: k, ...v }));
}
// ── const: ENEMIES ──
// ===================== ENEMY DEFINITIONS =====================
const ENEMIES = {
// Act 1
skeleton: { name: "Skeleton Warrior", hp: 22, maxHp: 22, sprite: "💀", act: 1,
intents: [
{ type: "attack", value: 6 }, { type: "attack", value: 6 }, { type: "defend", value: 8 }
] },
zombie_brute: { name: "Zombie Brute", hp: 28, maxHp: 28, sprite: "🧟", act: 1,
intents: [
{ type: "attack", value: 8 }, { type: "debuff", debuff: "weak", value: 2 }, { type: "attack", value: 8 }
] },
dark_cultist: { name: "Dark Cultist", hp: 20, maxHp: 20, sprite: "🔮", act: 1,
intents: [
{ type: "buff", buff: "strength", value: 3 }, { type: "attack", value: 5 }, { type: "attack", value: 5 }
] },
rat_swarm: { name: "Rat Swarm", hp: 15, maxHp: 15, sprite: "🐀", act: 1,
intents: [
{ type: "multiAttack", hits: 2, value: 3 }, { type: "multiAttack", hits: 2, value: 3 }
] },
tomb_guardian: { name: "Tomb Guardian", hp: 42, maxHp: 42, sprite: "🗿", act: 1, elite: true,
intents: [
{ type: "shieldBash", value: 6, block: 8 }, { type: "multiAttack", hits: 3, value: 4 }, { type: "attack", value: 12 }
] },
blood_witch: { name: "Blood Witch", hp: 36, maxHp: 36, sprite: "🧙♀️", act: 1, elite: true,
intents: [
{ type: "drainLife", value: 5, heal: 4 }, { type: "debuff", debuff: "vulnerable", value: 1 }, { type: "bloodRitual", value: 7, heal: 4 }
] },
lich_king: { name: "Lich King", hp: 65, maxHp: 65, sprite: "👑", act: 1, boss: true,
phase1: true, summonMinions: true, maxMinions: 2,
intents: [
{ type: "summon" }, { type: "attack", value: 8 }, { type: "defend", value: 12 }
] },
// Act 2
wraith: { name: "Wraith", hp: 30, maxHp: 30, sprite: "👻", act: 2,
intents: [
{ type: "attackApply", value: 7, debuff: "weak", debuffValue: 1 }, { type: "attack", value: 7 }, { type: "defend", value: 10 }
] },
corrupted_beast: { name: "Corrupted Beast", hp: 38, maxHp: 38, sprite: "🐺", act: 2,
intents: [
{ type: "attack", value: 12 }, { type: "selfBuff", buff: "block", value: 5, debuff: "vulnerable" }, { type: "attack", value: 12 }
] },
thorned_horror: { name: "Thorned Horror", hp: 25, maxHp: 25, sprite: "🌹", act: 2,
intents: [
{ type: "multiAttack", hits: 3, value: 4 }, { type: "debuff", debuff: "bleed", value: 3 }, { type: "attack", value: 8 }
] },
witch_doctor: { name: "Witch Doctor", hp: 22, maxHp: 22, sprite: "🧙", act: 2,
intents: [
{ type: "buffAlly", block: 4, regen: 2 }, { type: "attackApply", value: 6, debuff: "poison", debuffValue: 2 }, { type: "attack", value: 6 }
] },
treant_protector: { name: "Treant Protector", hp: 50, maxHp: 50, sprite: "🌳", act: 2, elite: true,
intents: [
{ type: "attackBlock", value: 9, block: 6 }, { type: "debuff", debuff: "vulnerable", value: 2 }, { type: "multiAttack", hits: 2, value: 10 }
] },
plague_shaman: { name: "Plague Shaman", hp: 42, maxHp: 42, sprite: "🦠", act: 2, elite: true,
intents: [
{ type: "debuff", debuff: "poison", value: 4 }, { type: "summonPlagueRat" }, { type: "attack", value: 8 }
] },
eldritch_treant: { name: "Eldritch Treant", hp: 75, maxHp: 75, sprite: "🌿", act: 2, boss: true,
phase1: true,
intents: [
{ type: "attackApply", value: 10, debuff: "weak", debuffValue: 2 }, { type: "defendRegen", value: 15, regen: 2 }, { type: "multiAttack", hits: 2, value: 8 }
] },
// Act 3
hellhound: { name: "Hellhound", hp: 26, maxHp: 26, sprite: "🐕", act: 3,
intents: [
{ type: "attackApply", value: 8, debuff: "burn", debuffValue: 2 }, { type: "attack", value: 8 }, { type: "defend", value: 9 }
] },
shadow_demon: { name: "Shadow Demon", hp: 30, maxHp: 30, sprite: "😈", act: 3,
intents: [
{ type: "attackApply", value: 7, debuff: "weak", debuffValue: 1 }, { type: "attack", value: 7 }, { type: "buffSelf", buff: "intangible" }
] },
infernal_imp: { name: "Infernal Imp", hp: 18, maxHp: 18, sprite: "👹", act: 3,
intents: [
{ type: "attack", value: 5 }, { type: "attack", value: 5 }, { type: "summonImp" }
] },
abyssal_cultist: { name: "Abyssal Cultist", hp: 24, maxHp: 24, sprite: "🕯️", act: 3,
intents: [
{ type: "buffAllEnemies", buff: "strength", value: 2 }, { type: "attack", value: 7 }, { type: "attack", value: 7 }
] },
flamespawn: { name: "Flamespawn", hp: 48, maxHp: 48, sprite: "🔥", act: 3, elite: true,
intents: [
{ type: "attackApply", value: 9, debuff: "burn", debuffValue: 3 }, { type: "defendBuff", value: 10, buff: "strength", buffValue: 2 }, { type: "multiAttack", hits: 2, value: 8 }
] },
dread_knight: { name: "Dread Knight", hp: 55, maxHp: 55, sprite: "⚔️", act: 3, elite: true,
intents: [
{ type: "attack", value: 12 }, { type: "debuff", debuff: "bleed", value: 3 }, { type: "defendHeal", value: 12, heal: 4 }
] },
archdemon: { name: "Archdemon", hp: 85, maxHp: 85, sprite: "👿", act: 3, boss: true,
phase1: true, summonHellhound: true,
intents: [
{ type: "summonHellhound" }, { type: "attackApply", value: 12, debuff: "burn", debuffValue: 2 }, { type: "defendBuff", value: 15, buff: "strength", buffValue: 3 }
] }
};
// ── const: FLOOR_GROUPS ──
// Enemy groups per floor
const FLOOR_GROUPS = {
1: {
2: [{ type: "skeleton" }, { type: "zombie_brute" }],
3: [{ type: "dark_cultist" }],
4: "event",
5: "shop",
6: [{ type: "rat_swarm" }],
7: "rest",
8: [{ type: "skeleton" }, { type: "zombie_brute" }],
9: [{ type: "tomb_guardian" }, { type: "skeleton" }],
10: "event",
11: [{ type: "dark_cultist" }, { type: "rat_swarm" }],
12: [{ type: "blood_witch" }, { type: "zombie_brute" }],
13: [{ type: "dark_cultist" }, { type: "dark_cultist" }],
14: "rest",
15: [{ type: "lich_king" }]
},
2: {
2: [{ type: "wraith" }, { type: "corrupted_beast" }],
3: [{ type: "thorned_horror" }],
4: "event",
5: "shop",
6: [{ type: "witch_doctor" }],
7: "rest",
8: [{ type: "wraith" }, { type: "thorned_horror" }],
9: [{ type: "treant_protector" }, { type: "wraith" }],
10: "event",
11: [{ type: "corrupted_beast" }, { type: "thorned_horror" }],
12: [{ type: "plague_shaman" }, { type: "corrupted_beast" }],
13: [{ type: "wraith" }, { type: "wraith" }],
14: "rest",
15: [{ type: "eldritch_treant" }]
},
3: {
2: [{ type: "hellhound" }, { type: "shadow_demon" }],
3: [{ type: "infernal_imp" }],
4: "event",
5: "shop",
6: [{ type: "abyssal_cultist" }],
7: "rest",
8: [{ type: "hellhound" }, { type: "infernal_imp" }],
9: [{ type: "flamespawn" }, { type: "hellhound" }],
10: "event",
11: [{ type: "shadow_demon" }, { type: "infernal_imp" }],
12: [{ type: "dread_knight" }, { type: "shadow_demon" }],
13: [{ type: "abyssal_cultist" }, { type: "abyssal_cultist" }],
14: "rest",
15: [{ type: "archdemon" }]
}
};
// ── const: BOSS_RELICS ──
// Boss relics
const BOSS_RELICS = [
{ name: "Cursed Crown", desc: "Gain 1 Energy at start of each turn. Curses can no longer be removed.", effect: "cursed_crown" },
{ name: "Ectoplasm", desc: "Gain 1 Energy. Lose all Gold and gain no more Gold this run.", effect: "ectoplasm" },
{ name: "Philosopher's Stone", desc: "Gain 1 Energy. All enemies start combat with 1 Strength.", effect: "philosophers_stone" },
{ name: "Black Blood", desc: "At start of combat, lose 10% max HP. Whenever you play an Attack, heal 1 HP.", effect: "black_blood" },
{ name: "Shadow Amulet", desc: "Start each turn with 1 Intangible. Reduce max HP by 15.", effect: "shadow_amulet" }
];
// ── const: RELIC_POOL ──
// Relic pool
const RELIC_POOL = [
{ name: "Bloodstone", desc: "Whenever you lose HP from a card, heal 1 HP.", rarity: "common", effect: "bloodstone" },
{ name: "Demon Horns", desc: "At start of combat, add random Imp Claw to hand.", rarity: "common", effect: "demon_horns" },
{ name: "Shadow Cloak", desc: "At start of combat, gain 1 Intangible.", rarity: "rare", effect: "shadow_cloak" },
{ name: "Cursed Amulet", desc: "At start of combat, apply 1 Weak to ALL enemies.", rarity: "common", effect: "cursed_amulet" },
{ name: "Tome of Dark Rituals", desc: "Whenever you play a Power card, draw 1 card.", rarity: "uncommon", effect: "tome_dark_rituals" },
{ name: "Bleeding Edge", desc: "At start of your turn, apply 1 Bleed to a random enemy.", rarity: "common", effect: "bleeding_edge" },
{ name: "Soul Ember", desc: "Whenever you apply Burn to an enemy, deal 2 additional damage.", rarity: "rare", effect: "soul_ember" },
{ name: "Eternal Hourglass", desc: "At start of your turn, if no cards in hand, gain 1 Energy.", rarity: "uncommon", effect: "eternal_hourglass" },
{ name: "Ashen Armor", desc: "At end of combat, heal 6 HP.", rarity: "common", effect: "ashen_armor" },
{ name: "Greedy Imp", desc: "Enemies drop 10 extra Gold.", rarity: "common", effect: "greedy_imp" }
];
// ── const: POTION_DEFS ──
// Potions
const POTION_DEFS = {
blood_vial: { name: "Blood Vial", icon: "🩸", desc: "Heal 15 HP." },
firebomb: { name: "Firebomb", icon: "💣", desc: "Deal 10 damage to ALL enemies." },
dex_tonic: { name: "Dex Tonic", icon: "💪", desc: "Gain 3 Dexterity for 1 turn." },
energy_elixir: { name: "Energy Elixir", icon: "⚡", desc: "Gain 2 Energy this turn." },
cursed_brew: { name: "Cursed Brew", icon: "🍷", desc: "Apply 3 Vulnerable to all enemies." },
bleeding_flask: { name: "Bleeding Flask", icon: "🧪", desc: "Apply 6 Bleed to an enemy." },
imp_bottle: { name: "Imp-in-a-Bottle", icon: "🍾", desc: "Add Imp Claw+ to hand." },
smoke_bomb: { name: "Smoke Bomb", icon: "💨", desc: "Escape from non-boss combat." }
};
// ── const: EVENTS ──
// Events
const EVENTS = [
{
name: "The Forgotten Altar",
text: "A blood-stained altar hums with forbidden power.",
choices: [
{ label: "Offer 15 HP", desc: "Obtain a random Rare relic.", effect: "offer_hp" },
{ label: "Pray", desc: "Heal 10 HP, gain Doubt.", effect: "pray" },
{ label: "Leave", desc: "Nothing.", effect: "leave" }
]
},
{
name: "Shadowy Peddler",
text: "A hooded figure offers you a trade.",
choices: [
{ label: "Trade a card", desc: "Remove a card, gain 100 Gold.", effect: "trade_card" },
{ label: "Buy wares", desc: "Lose 50 Gold, get Potion + Common relic.", effect: "buy_wares" },
{ label: "Attack", desc: "Fight Shadow Thief, get 75 Gold.", effect: "attack_peddler" }
]
},
{
name: "Cursed Spring",
text: "A spring bubbles with dark, swirling water.",
choices: [
{ label: "Drink", desc: "Lose 10 HP, remove all Curses.", effect: "drink" },
{ label: "Bottle it", desc: "Obtain Cursed Brew potion.", effect: "bottle" },
{ label: "Leave", desc: "Nothing.", effect: "leave" }
]
},
{
name: "Restless Dead",
text: "Ghosts offer you a bargain in exchange for power.",
choices: [
{ label: "Accept", desc: "Lose 20 HP, upgrade 2 random cards.", effect: "accept" },
{ label: "Refuse", desc: "Nothing.", effect: "leave" }
]
},
{
name: "Impish Gambler",
text: "A grinning imp challenges you to a game of chance.",
choices: [
{ label: "Bet 30 Gold", desc: "50% to double or lose it all.", effect: "bet_gold" },
{ label: "Wager a card", desc: "Remove a card; if Curse, gain 100 Gold.", effect: "bet_card" },
{ label: "Leave", desc: "Nothing.", effect: "leave" }
]
},
{
name: "Ancient Library",
text: "Dusty tomes contain forgotten knowledge.",
choices: [
{ label: "Study", desc: "Gain 1 Energy next combat, lose 5 HP.", effect: "study" },
{ label: "Read forbidden page", desc: "Add random Power card, gain Doubt.", effect: "read_forbidden" },
{ label: "Leave", desc: "Nothing.", effect: "leave" }
]
}
];
// ── const: ACT_NAMES ──
// Act names
const ACT_NAMES = { 1: "The Crypts", 2: "Blighted Woods", 3: "Abyssal Spire" };
// ── function: createPlayer ──
// ===================== GAME ENGINE =====================
function createPlayer(cls) {
return {
class: cls,
hp: 70, maxHp: 70,
energy: 3, maxEnergy: 3,
block: 0,
strength: 0, dexterity: 0,
deck: [],
drawPile: [],
discardPile: [],
hand: [],
exhausted: [],
potions: [],
relics: [],
gold: 99,
// Temporary buffs
energyNext: 0,
intangible: 0,
intangibleStacks: 0,
// Class-specific
bloodReaperFirstAttackDmg: false,
// Combat flags
firstAttackCostsHp: true
};
}
// ── function: createEnemy ──
function createEnemy(type, isMinion) {
const base = ENEMIES[type];
if (!base) return null;
const e = {
type: type,
name: base.name,
hp: base.hp,
maxHp: base.maxHp,
sprite: base.sprite,
block: 0,
intentIndex: 0,
intent: null,
isMinion: isMinion || false,
isElite: base.elite || false,
isBoss: base.boss || false,
strength: 0,
weak: 0,
vulnerable: 0,
bleed: 0,
burn: 0,
poison: 0,
regen: 0,
intangible: 0,
act: base.act,
phase1: base.phase1 || false,
summonMinions: base.summonMinions || false,
maxMinions: base.maxMinions || 0,
minionCount: 0,
summonHellhound: base.summonHellhound || false,
intents: base.intents || [],
buffStrength: 0,
buffBlock: 0,
buffRegen: 0,
buffIntangible: 0,
selfVulnerable: 0
};
return e;
}
// ── function: deepCopy ──
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
// ── function: shuffleArray ──
function shuffleArray(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// ── function: getCardDef ──
function getCardDef(id) {
// Check all card pools
for (const pool of [BASIC_CARDS, BLOOD_REAPER_CARDS, SHADOWMANCER_CARDS, HELLFORGED_CARDS, COLORLESS_CARDS, CURSE_CARDS]) {
if (pool[id]) return { ...pool[id], id };
}
return null;
}
// ── function: createCombatState ──
function createCombatState(player, enemies) {
const combat = {
player: deepCopy(player),
enemies: enemies.map(e => deepCopy(e)),
turn: 1,
phase: "player", // player, enemy, combat_end, combat_start
log: [],
selectedEnemy: null,
pendingEnergyNext: 0,
pendingBlockNext: 0,
tempStrength: 0,
tempDexterity: 0,
goldReward: 0,
rewardType: null,
endedTurn: false
};
return combat;
}
// ── function: addLog ──
function addLog(combat, msg, cls) {
combat.log.push({ msg, cls: cls || "" });
}
// ── function: getEffectiveDmg ──
function getEffectiveDmg(baseDmg, player) {
let dmg = baseDmg + player.strength;
// Apply intangible check (for player taking damage)
return Math.max(0, dmg);
}
// ── function: getEffectiveBlock ──
function getEffectiveBlock(baseBlock, player) {
return baseBlock + player.dexterity;
}
// ── function: applyWeakToEnemy ──
function applyWeakToEnemy(enemy, stacks) {
enemy.weak = Math.max(enemy.weak, stacks);
}
// ── function: applyVulnerableToEnemy ──
function applyVulnerableToEnemy(enemy, stacks) {
enemy.vulnerable = Math.max(enemy.vulnerable, stacks);
}
// ── function: applyBleedToEnemy ──
function applyBleedToEnemy(enemy, stacks) {
enemy.bleed += stacks;
}
// ── function: applyBurnToEnemy ──
function applyBurnToEnemy(enemy, stacks) {
enemy.burn += stacks;
}
// ── function: applyPoisonToPlayer ──
function applyPoisonToPlayer(player, stacks) {
if (!player.poison) player.poison = 0;
player.poison = Math.max(player.poison || 0, stacks);
}
// ── function: applyWeakToPlayer ──
function applyWeakToPlayer(player, stacks) {
if (!player.weak) player.weak = 0;
player.weak = Math.max(player.weak || 0, stacks);
}
// ── function: applyVulnerableToPlayer ──
function applyVulnerableToPlayer(player, stacks) {
if (!player.vulnerable) player.vulnerable = 0;
player.vulnerable = Math.max(player.vulnerable || 0, stacks);
}
// ── function: applyBleedToPlayer ──
function applyBleedToPlayer(player, stacks) {
if (!player.bleed) player.bleed = 0;
player.bleed += stacks;
}
// ── function: applyBurnToPlayer ──
function applyBurnToPlayer(player, stacks) {
if (!player.burn) player.burn = 0;
player.burn += stacks;
}
// ── function: applyRegenToPlayer ──
function applyRegenToPlayer(player, stacks) {
if (!player.regen) player.regen = 0;
player.regen += stacks;
}
// ── function: applyIntangibleToPlayer ──
function applyIntangibleToPlayer(player, stacks) {
if (!player.intangible) player.intangible = 0;
player.intangible += stacks;
}
// ── function: reduceDebuffDuration ──
function reduceDebuffDuration(debuff, amount) {
if (debuff && debuff > 0) {
debuff = Math.max(0, debuff - amount);
}
return debuff;
}
// ── function: getCardName ──
function getCardName(card) {
return card.upgraded ? card.name + "+" : card.name;
}
// ── function: getCardDesc ──
function getCardDesc(card) {
return card.upgraded ? card.upgDesc : card.desc;
}
// ── function: isCardPlayable ──
function isCardPlayable(card, energy) {
if (card.exhaust) return card.cost <= energy;
return card.cost <= energy;
}
// ── function: hasCurse ──
function hasCurse(deck) {
return deck.some(c => c.curse);
}
// ── function: removeCurseFromDeck ──
function removeCurseFromDeck(combat) {
const idx = combat.player.deck.findIndex(c => c.curse);
if (idx >= 0) {
combat.player.deck.splice(idx, 1);
return true;
}
return false;
}
// ── function: upgradeRandomCards ──
function upgradeRandomCards(combat, count) {
let upgraded = 0;
for (let i = 0; i < count && upgraded < count; i++) {
const nonUpgraded = combat.player.deck.filter(c => !c.curse && !c.upgraded);
if (nonUpgraded.length === 0) break;
const card = nonUpgraded[Math.floor(Math.random() * nonUpgraded.length)];
card.upgraded = true;
upgraded++;
}
return upgraded;
}
// ── function: getRandomPotion ──
function getRandomPotion() {
const keys = Object.keys(POTION_DEFS);
return keys[Math.floor(Math.random() * keys.length)];
}
// ── function: getRandomRelic ──
function getRandomRelic() {
return RELIC_POOL[Math.floor(Math.random() * RELIC_POOL.length)];
}
// ── function: getRandomRareRelic ──
function getRandomRareRelic() {
const rares = RELIC_POOL.filter(r => r.rarity === "rare");
if (rares.length > 0) return rares[Math.floor(Math.random() * rares.length)];
return getRandomRelic();
}
// ── function: getRandomUncommonRelic ──
function getRandomUncommonRelic() {
const uncommons = RELIC_POOL.filter(r => r.rarity === "uncommon");
if (uncommons.length > 0) return uncommons[Math.floor(Math.random() * uncommons.length)];
return getRandomRelic();
}
// ── function: getRandomCommonRelic ──
function getRandomCommonRelic() {
const commons = RELIC_POOL.filter(r => r.rarity === "common");
if (commons.length > 0) return commons[Math.floor(Math.random() * commons.length)];
return getRandomRelic();
}
// ── function: getRandomEvent ──
function getRandomEvent() {
return EVENTS[Math.floor(Math.random() * EVENTS.length)];
}
// ── function: pickBossRelics ──
function pickBossRelics() {
return shuffleArray(BOSS_RELICS).slice(0, 3);
}
// ── function: weightedCardPick ──
function weightedCardPick(pool) {
const common = pool.filter(c => c.rarity === "common");
const uncommon = pool.filter(c => c.rarity === "uncommon");
const rare = pool.filter(c => c.rarity === "rare");
const roll = Math.random();
let chosen;
if (roll < 0.10) {
chosen = rare.length > 0 ? rare[Math.floor(Math.random() * rare.length)] : common[Math.floor(Math.random() * common.length)];
} else if (roll < 0.40) {
chosen = uncommon.length > 0 ? uncommon[Math.floor(Math.random() * uncommon.length)] : common[Math.floor(Math.random() * common.length)];
} else {
chosen = common[Math.floor(Math.random() * common.length)];
}
return { ...chosen };
}
// ── function: pickCardReward ──
function pickCardReward(pool, isElite, isBoss) {
let options;
if (isBoss) {
// At least one rare
const rares = pool.filter(c => c.rarity === "rare");
const commons = pool.filter(c => c.rarity === "common");
const uncommons = pool.filter(c => c.rarity === "uncommon");
options = [];
if (rares.length > 0) options.push({ ...rares[Math.floor(Math.random() * rares.length)] });
const remaining = 2;
for (let i = 0; i < remaining; i++) {
const r = Math.random();
if (r < 0.6) {
if (commons.length > 0) options.push({ ...commons[Math.floor(Math.random() * commons.length)] });
} else if (r < 0.9) {
if (uncommons.length > 0) options.push({ ...uncommons[Math.floor(Math.random() * uncommons.length)] });
} else {
if (rares.length > 0) options.push({ ...rares[Math.floor(Math.random() * rares.length)] });
}
}
} else if (isElite) {
options = [];
for (let i = 0; i < 3; i++) {
const r = Math.random();
if (r < 0.20) {
const rares = pool.filter(c => c.rarity === "rare");
if (rares.length > 0) { options.push({ ...rares[Math.floor(Math.random() * rares.length)] }); continue; }
}
options.push(weightedCardPick(pool));
}
} else {
options = [];
for (let i = 0; i < 3; i++) {
options.push(weightedCardPick(pool));
}
}
return options.slice(0, 3);
}
// ── function: getShopCards ──
function getShopCards(cls) {
const pool = getCardPool(cls);
const cards = [];
for (let i = 0; i < 3; i++) {
cards.push(weightedCardPick(pool));
}
// Add colorless
const colorless = Object.entries(COLORLESS_CARDS).map(([k, v]) => ({ id: k, ...v }));
cards.push({ ...colorless[Math.floor(Math.random() * colorless.length)] });
return cards;
}
// ── function: getShopRelics ──
function getShopRelics() {
return [getRandomRelic(), getRandomRelic()];
}
// ── function: getShopPotions ──
function getShopPotions() {
return [getRandomPotion(), getRandomPotion()];
}
// ── function: getShopColorless ──
function getShopColorless() {
const colorless = Object.entries(COLORLESS_CARDS).map(([k, v]) => ({ id: k, ...v }));
return colorless[Math.floor(Math.random() * colorless.length)];
}
// ── function: getGoldReward ──
function getGoldReward(isElite, isBoss) {
if (isBoss) return 50;
if (isElite) return 25 + Math.floor(Math.random() * 11);
return 10 + Math.floor(Math.random() * 11);
}
// ── function: getPotionDropChance ──
function getPotionDropChance(isElite) {
if (isBoss) return 2;
if (isElite) return Math.random() < 0.7 ? 1 : 0;
return Math.random() < 0.4 ? 1 : 0;
}
// ── function: getRelicDrop ──
function getRelicDrop(isElite) {
if (isElite) return getRandomRelic();
return null;
}
// ── function: getPlayerBlock ──
function getPlayerBlock(combat) {
return combat.player.block;
}
// ── function: getEnemyBlock ──
function getEnemyBlock(enemy) {
return enemy.block || 0;
}
// ── function: calcDamage ──
function calcDamage(dmg, enemy) {
let d = dmg;
if (enemy.weak > 0) d = Math.floor(d * 0.75);
return Math.max(0, d);
}
// ── function: calcDamageTaken ──
function calcDamageTaken(dmg, enemy) {
let d = dmg;
if (enemy.vulnerable > 0) d = Math.floor(d * 1.5);
return Math.max(0, d);
}
// ── function: applyDamageToEnemy ──
function applyDamageToEnemy(combat, enemyIdx, dmg, isBleed, isBurn) {
const enemy = combat.enemies[enemyIdx];
if (!enemy) return 0;
// Check intangible
if (enemy.intangible > 0) {
return 0;
}
let effectiveDmg = calcDamageTaken(dmg, enemy);
// Soul Ember relic bonus for burn
if (isBurn && combat.player.relics.some(r => r.effect === "soul_ember")) {
effectiveDmg += 2;
}
// Apply block first
const block = getEnemyBlock(enemy);
if (block > 0) {
if (effectiveDmg <= block) {
enemy.block -= effectiveDmg;
return 0;
}
effectiveDmg -= block;
enemy.block = 0;
}
enemy.hp -= effectiveDmg;
return effectiveDmg;
}
// ── function: applyDamageToPlayer ──
function applyDamageToPlayer(combat, dmg) {
const p = combat.player;
// Check intangible
if (p.intangible > 0) {
return 0;
}
let effectiveDmg = dmg;
// Apply block
const block = getPlayerBlock(combat);
if (block > 0) {
if (effectiveDmg <= block) {
p.block -= effectiveDmg;
return 0;
}
effectiveDmg -= block;
p.block = 0;
}
// Bloodstone relic
if (combat.player.relics.some(r => r.effect === "bloodstone")) {
// Will be handled separately
}
p.hp -= effectiveDmg;
return effectiveDmg;
}
// ── function: processStatusEffects ──
function processStatusEffects(combat, isPlayerTurn) {
// Player turn start effects
if (isPlayerTurn) {
// Player bleed/poison at start of turn
if (combat.player.bleed > 0) {
const dmg = combat.player.bleed;
combat.player.hp -= dmg;
combat.player.bleed = Math.floor(dmg / 2);
addLog(combat, `Player loses ${dmg} HP from Bleed.`, "log-damage");
}
if (combat.player.poison > 0) {
const dmg = combat.player.poison;
combat.player.hp -= dmg;
combat.player.poison = Math.max(0, combat.player.poison - 1);
addLog(combat, `Player loses ${dmg} HP from Poison.`, "log-damage");
}
// Regeneration at end of player's previous turn (handled at end of turn)
// Intangible decrement at start of player turn
if (combat.player.intangible > 0) {
combat.player.intangible--;
}
// Weak/Vulnerable decrement
if (combat.player.weak > 0) combat.player.weak--;
if (combat.player.vulnerable > 0) combat.player.vulnerable--;
// Enemy status effects at start of their turn
combat.enemies.forEach((e, idx) => {
if (e.bleed > 0) {
const dmg = e.bleed;
e.hp -= dmg;
e.bleed = Math.floor(dmg / 2);
addLog(combat, `${e.name} loses ${dmg} HP from Bleed.`, "log-damage");
}
if (e.burn > 0) {
const dmg = e.burn;
e.hp -= dmg;
e.burn = Math.floor(dmg / 2);
addLog(combat, `${e.name} loses ${dmg} HP from Burn.`, "log-damage");
}
if (e.poison > 0) {
const dmg = e.poison;
e.hp -= dmg;
e.poison = Math.max(0, e.poison - 1);
addLog(combat, `${e.name} loses ${dmg} HP from Poison.`, "log-damage");
}
if (e.weak > 0) e.weak--;
if (e.vulnerable > 0) e.vulnerable--;
if (e.intangible > 0) e.intangible--;
// Regen
if (e.regen > 0) {
e.hp = Math.min(e.maxHp, e.hp + e.regen);
e.regen--;
}
// Boss phase transitions
if (e.isBoss && e.phase1) {
if (e.hp <= Math.floor(e.maxHp * 0.5)) {
e.phase1 = false;
addLog(combat, `${e.name} enters Phase 2!`, "log-damage");
}
}
});
}
}
// ── function: startCombat ──
function startCombat(player, enemyTypes, isBoss) {
const enemies = enemyTypes.map(t => createEnemy(t, false));
const validEnemies = enemies.filter(e => e !== null);
if (validEnemies.length === 0) return null;
const combat = createCombatState(player, validEnemies);
if (!combat) return null;
combat.isBoss = isBoss || false;
combat.player.drawPile = [...combat.player.deck];
combat.player.discardPile = [];
combat.player.hand = [];
combat.player.exhausted = [];
combat.player.block = 0;
combat.player.energy = combat.player.maxEnergy;
if (combat.player.relics.some(r => r.effect === "cursed_amulet")) {
combat.enemies.forEach(e => { if (e.weak === 0) e.weak = 1; });
}
if (combat.player.relics.some(r => r.effect === "demon_horns")) {
const imp = { ...IMP_CLAW };
combat.player.hand.push(imp);
}
if (combat.player.relics.some(r => r.effect === "shadow_cloak")) {
combat.player.intangible = 1;
}
if (combat.player.relics.some(r => r.effect === "philosophers_stone")) {
combat.enemies.forEach(e => { e.strength += 1; });
}
if (combat.player.relics.some(r => r.effect === "black_blood")) {
combat.player.hp = Math.max(1, combat.player.hp - Math.floor(combat.player.maxHp * 0.1));
combat.player.blackBloodActive = true;
}
if (combat.player.relics.some(r => r.effect === "shadow_amulet")) {
combat.player.intangible = 1;
combat.player.maxHp -= 15;
}
combat.enemies.forEach(e => { e.intent = e.intents[0]; });
drawCards(combat, 5);
processStartOfCombat(combat);
return combat;
}
// ── function: processStartOfCombat ──
function processStartOfCombat(combat) {
// Hellfire Aura power
combat.player.relics = combat.player.relics || [];
const powers = combat.player.hand.filter(c => c.power || c.exhaust);
// Check for Soul Siphon power (will be handled at start of turn)
}
// ── function: drawCards ──
function drawCards(combat, count) {
let drawn = 0;
while (drawn < count) {
if (combat.player.drawPile.length === 0) {
if (combat.player.discardPile.length === 0) break;
// Shuffle discard into draw, excluding exhausted
combat.player.drawPile = shuffleArray(combat.player.discardPile.filter(c => !c.exhaust));
combat.player.discardPile = combat.player.discardPile.filter(c => c.exhaust);
}
if (combat.player.drawPile.length === 0) break;
const card = combat.player.drawPile.pop();
combat.player.hand.push(card);
drawn++;
}
return drawn;
}
// ── function: playCard ──
function playCard(combat, cardIdx, targetEnemyIdx) {
const card = combat.player.hand[cardIdx];
if (!card || card.cost > combat.player.energy) return false;
combat.player.energy -= card.cost;
combat.player.hand.splice(cardIdx, 1);
let logMsg = `Played ${getCardName(card)} `;
// Process card effects
if (card.dmg) {
// Attack card
let totalDmg = card.dmg;
// Hellforged: first attack that costs HP
if (combat.player.bloodReaperFirstAttackDmg && card.selfDmg && combat.player.relics.some(r => r.name === "Sacrificial Dagger")) {
totalDmg = card.dmg; // No change to damage
}
if (card.allEnemies) {
combat.enemies.forEach((e, idx) => {
const actualDmg = applyDamageToEnemy(combat, idx, totalDmg, false, false);
addLog(combat, `${e.name} takes ${actualDmg} damage.`, "log-damage");
});
} else {
const target = targetEnemyIdx !== null ? targetEnemyIdx : 0;
const actualDmg = applyDamageToEnemy(combat, target, totalDmg, false, false);
addLog(combat, `${combat.enemies[target].name} takes ${actualDmg} damage.`, "log-damage");
}
// Apply debuffs
if (card.weak > 0) {
if (card.allEnemies) {
combat.enemies.forEach(e => applyWeakToEnemy(e, card.weak));
} else {
const target = targetEnemyIdx !== null ? targetEnemyIdx : 0;
applyWeakToEnemy(combat.enemies[target], card.weak);
}
}
if (card.vulnerable > 0) {
if (card.allEnemies) {
combat.enemies.forEach(e => applyVulnerableToEnemy(e, card.vulnerable));
} else {
const target = targetEnemyIdx !== null ? targetEnemyIdx : 0;
applyVulnerableToEnemy(combat.enemies[target], card.vulnerable);
}
}
if (card.bleed > 0) {
if (card.allEnemies) {
combat.enemies.forEach(e => applyBleedToEnemy(e, card.bleed));
} else {
const target = targetEnemyIdx !== null ? targetEnemyIdx : 0;
applyBleedToEnemy(combat.enemies[target], card.bleed);
}
}
if (card.burn > 0) {
if (card.allEnemies) {
combat.enemies.forEach(e => applyBurnToEnemy(e, card.burn));
} else {
const target = targetEnemyIdx !== null ? targetEnemyIdx : 0;
applyBurnToEnemy(combat.enemies[target], card.burn);
}
}
// Nightime Lash bonus
if (card.weakBonus) {
const target = targetEnemyIdx !== null ? targetEnemyIdx : 0;
if (combat.enemies[target].weak > 0) {
applyDamageToEnemy(combat, target, card.weakBonus, false, false);
addLog(combat, `${combat.enemies[target].name} takes ${card.weakBonus} extra damage from Weak!`, "log-damage");
}
}
// Multi-hit
if (card.multiHit) {
for (let i = 0; i < card.multiHit; i++) {
const target = targetEnemyIdx !== null ? targetEnemyIdx : Math.floor(Math.random() * combat.enemies.length);
if (combat.enemies[target]) {
applyDamageToEnemy(combat, target, card.dmg, false, false);
}
}
}
// Drain life
if (card.heal && card.dmg) {
combat.player.hp = Math.min(combat.player.maxHp, combat.player.hp + card.heal);
addLog(combat, `Healed ${card.heal} HP.`, "log-heal");
}
// Bloodletting self damage
if (card.selfDmg) {
let selfDmg = card.selfDmg;
// Sacrificial Dagger reduces first HP-costing attack
if (combat.player.class === "blood_reaper" && combat.player.firstAttackCostsHp && combat.player.relics.some(r => r.name === "Sacrificial Dagger")) {
selfDmg = Math.max(0, selfDmg - 1);
combat.player.firstAttackCostsHp = false;
}
applySelfDamage(combat, selfDmg);
}
// Blood Pact power
if (combat.player.relics.some(r => r.effect === "blood_pact")) {
// This is handled in applySelfDamage
}
// Searing Strike: gain energy next turn
if (card.energyNext) {
combat.player.energyNext += card.energyNext;
}
}
if (card.block) {
let block = getEffectiveBlock(card.block, combat.player);
combat.player.block += block;
logMsg += `+${block} Block. `;
// Blood Ward regen
if (card.regen) {
applyRegenToPlayer(combat.player, card.regen);
addLog(combat, `Gained ${card.regen} Regeneration.`, "log-heal");
}
// Phantom Armor: next turn dexterity
if (card.dexterityNext) {
combat.player.tempDexterity += card.dexterityNext;
}
// Whispering Shroud: next turn block
if (card.blockNextTurn) {
combat.player.pendingBlockNext += card.blockNextTurn;
}
}
if (card.draw) {
const drawn = drawCards(combat, card.draw);
addLog(combat, `Drew ${drawn} card(s).`, "log-block");
}
if (card.energyGain) {
combat.player.energy += card.energyGain;
addLog(combat, `Gained ${card.energyGain} Energy.`, "log-gold");
}
if (card.curse) {
if (card.name === "Doubt") {
// Doubt stays in hand, handled at end of turn
} else {
// Ethereal curses go to exhaust
combat.player.exhausted.push(card);
}
}
// Exhaust
if (card.exhaust) {
combat.player.exhausted.push(card);
addLog(combat, `${getCardName(card)} is Exhausted.`, "");
}
// Summon Imp
if (card.impClaw) {
const imp = card.upgraded ? { ...IMP_CLAW_PLUS } : { ...IMP_CLAW };
combat.player.hand.push(imp);
addLog(combat, "Imp Claw added to hand.", "log-block");
}
// Dark Ritual: shuffle Dazed
if (card.energyGain && card.id === "dark_ritual") {
const dazed = { ...CURSE_CARDS.dazed };
if (card.upgraded) {
combat.player.discardPile.push(dazed);
} else {
combat.player.drawPile.push(dazed);
}
addLog(combat, "Dazed shuffled into draw pile.", "log-block");
}
// Tome of Dark Rituals: draw on Power card play
if (combat.player.relics.some(r => r.effect === "tome_dark_rituals") && card.type === CARD_TYPES.POWER) {
drawCards(combat, 1);
addLog(combat, "Drew 1 card from Tome.", "log-block");
}
addLog(combat, logMsg, "");
// Check for enemy death
checkEnemyDeaths(combat);
return true;
}
// ── function: applySelfDamage ──
function applySelfDamage(combat, dmg) {
const p = combat.player;
// Bloodstone: heal 1 HP when losing HP from card
const hasBloodstone = p.relics.some(r => r.effect === "bloodstone");
const hasBloodPact = p.relics.some(r => r.effect === "blood_pact");
const strengthGain = hasBloodPact ? (p.relics.find(r => r.effect === "blood_pact")?.strengthGain || 1) : 0;
// Apply intangible
if (p.intangible > 0) {
dmg = 1;
}
// Apply block
if (p.block > 0) {
if (dmg <= p.block) {
p.block -= dmg;
dmg = 0;
} else {
dmg -= p.block;
p.block = 0;
}
}
p.hp -= dmg;
if (dmg > 0) {
addLog(combat, `Lost ${dmg} HP from self-damage.`, "log-damage");
}
// Bloodstone heal
if (hasBloodstone && dmg > 0) {
p.hp = Math.min(p.maxHp, p.hp + 1);
addLog(combat, "Bloodstone heals 1 HP.", "log-heal");
}
// Blood Pact: gain strength when losing HP
if (strengthGain > 0) {
p.strength += strengthGain;
addLog(combat, `Blood Pact: gained ${strengthGain} Strength.`, "log-block");
}
// Black Blood: heal when playing attack
if (p.blackBloodActive) {
// This is handled in playCard for attacks
}
}
// ── function: checkEnemyDeaths ──
function checkEnemyDeaths(combat) {
combat.enemies = combat.enemies.filter(e => e.hp > 0);
}
// ── function: endPlayerTurn ──
function endPlayerTurn(combat) {
// Discard hand to discard pile
combat.player.discardPile.push(...combat.player.hand);
combat.player.hand = [];
// Process end-of-turn effects
processEndOfPlayerTurn(combat);
// Reset energy
combat.player.energy = combat.player.maxEnergy + combat.player.energyNext;
combat.player.energyNext = 0;
combat.phase = "enemy";
combat.endedTurn = true;
// Process enemy turns
processEnemyTurn(combat);
}
// ── function: processEndOfPlayerTurn ──
function processEndOfPlayerTurn(combat) {
const p = combat.player;
// Doubt curse: lose 1 HP per Doubt in hand
const doubts = combat.player.discardPile.filter(c => c.name === "Doubt");
// Actually Doubt stays in hand until discarded, but we discard at end of turn
// The Doubt effect triggers at end of turn while in hand
// Since we already discarded, check discard
// Actually, let me handle Doubt differently - it triggers while in hand at end of turn
// Let me re-check: the card says "At end of your turn, lose 1 HP"
// So it should trigger before discarding
}
// ── function: processEndOfTurn ──
function processEndOfTurn(combat) {
const p = combat.player;
// Doubt curse
const doubts = p.hand.filter(c => c.name === "Doubt");
doubts.forEach(() => {
p.hp -= 1;
addLog(combat, "Doubt: lose 1 HP.", "log-damage");
});
// Regeneration
if (p.regen > 0) {
p.hp = Math.min(p.maxHp, p.hp + p.regen);
p.regen--;
addLog(combat, `Regeneration heals ${p.regen + 1} HP.`, "log-heal");
}
// Burn at end of player turn
if (p.burn > 0) {
const dmg = p.burn;
p.hp -= dmg;
p.burn = Math.floor(dmg / 2);
addLog(combat, `Player loses ${dmg} HP from Burn.`, "log-damage");
}
// Poison at end of player turn (per spec: start of turn)
// Actually per spec: Poison at start of turn
// Burn at end of turn
// Bleed at end of turn (per spec: start of turn)
}
// ── function: processEnemyTurn ──
function processEnemyTurn(combat) {
let pendingActions = [];
combat.enemies.forEach((e, idx) => {
if (e.hp <= 0) return;
// Hellfire Aura power (if it exists as a power card in play)
// This would be tracked separately
const intent = e.intent;
switch (intent.type) {
case "attack": {
let dmg = intent.value + e.strength;
const actualDmg = applyDamageToPlayer(combat, dmg);
addLog(combat, `${e.name} attacks for ${dmg} damage.`, "log-damage");
break;
}
case "defend": {
e.block += intent.value;
addLog(combat, `${e.name} gains ${intent.value} Block.`, "log-block");
break;
}
case "multiAttack": {
for (let i = 0; i < intent.hits; i++) {
let dmg = intent.value + e.strength;
const actualDmg = applyDamageToPlayer(combat, dmg);
addLog(combat, `${e.name} hits for ${dmg}.`, "log-damage");
}
break;
}
case "debuff": {
if (intent.debuff === "weak") {
applyWeakToPlayer(combat.player, intent.value);
addLog(combat, `${e.name} applies ${intent.value} Weak.`, "log-damage");
} else if (intent.debuff === "vulnerable") {
applyVulnerableToPlayer(combat.player, intent.value);
addLog(combat, `${e.name} applies ${intent.value} Vulnerable.`, "log-damage");
} else if (intent.debuff === "bleed") {
applyBleedToPlayer(combat.player, intent.value);
addLog(combat, `${e.name} applies ${intent.value} Bleed.`, "log-damage");
} else if (intent.debuff === "burn") {
applyBurnToPlayer(combat.player, intent.value);
addLog(combat, `${e.name} applies ${intent.value} Burn.`, "log-damage");
} else if (intent.debuff === "poison") {
applyPoisonToPlayer(combat.player, intent.value);
addLog(combat, `${e.name} applies ${intent.value} Poison.`, "log-damage");
}
break;
}
case "attackApply": {
let dmg = intent.value + e.strength;
applyDamageToPlayer(combat, dmg);
addLog(combat, `${e.name} attacks for ${dmg}.`, "log-damage");
if (intent.debuff) {
if (intent.debuff === "weak") applyWeakToPlayer(combat.player, intent.debuffValue);
if (intent.debuff === "burn") applyBurnToPlayer(combat.player, intent.debuffValue);
if (intent.debuff === "poison") applyPoisonToPlayer(combat.player, intent.debuffValue);
}
break;
}
case "shieldBash": {
let dmg = intent.value + e.strength;
applyDamageToPlayer(combat, dmg);
e.block += intent.block;
addLog(combat, `${e.name} attacks for ${dmg} and gains ${intent.block} Block.`, "log-damage");
break;
}
case "drainLife": {
let dmg = intent.value + e.strength;
applyDamageToPlayer(combat, dmg);
e.hp = Math.min(e.maxHp, e.hp + intent.heal);
addLog(combat, `${e.name} drains ${intent.heal} HP.`, "log-heal");
break;
}
case "bloodRitual": {
let dmg = intent.value + e.strength;
applyDamageToPlayer(combat, dmg);
e.hp = Math.min(e.maxHp, e.hp + intent.heal);
addLog(combat, `${e.name} performs blood ritual.`, "log-heal");
break;
}
case "buff": {
if (intent.buff === "strength") {
e.strength += intent.value;
addLog(combat, `${e.name} gains ${intent.value} Strength.`, "log-block");
}
break;
}
case "buffAlly": {
if (intent.block) {
e.block += intent.block;
}
if (intent.regen) {
e.regen += intent.regen;
}
addLog(combat, `${e.name} buffs itself.`, "log-block");
break;
}
case "buffAllEnemies": {
combat.enemies.forEach(e => {
if (intent.buff === "strength") e.strength += intent.value;
});
addLog(combat, `${e.name} buffs all enemies.`, "log-block");
break;
}
case "buffSelf": {
if (intent.buff === "intangible") {
e.intangible += 1;
addLog(combat, `${e.name} gains Intangible.`, "log-block");
}
break;
}
case "selfBuff": {
if (intent.buff === "block") {
e.block += intent.value;
}
if (intent.debuff) {
e[selfVulnerable] = 1;
}
break;
}
case "defend": {
e.block += intent.value;
addLog(combat, `${e.name} gains ${intent.value} Block.`, "log-block");
break;
}
case "defendRegen": {
e.block += intent.value;
e.regen += intent.regen;
addLog(combat, `${e.name} gains ${intent.value} Block and ${intent.regen} Regen.`, "log-block");
break;
}
case "defendBuff": {
e.block += intent.value;
if (intent.buff === "strength") {
e.strength += intent.buffValue;
}
addLog(combat, `${e.name} gains ${intent.value} Block.`, "log-block");
break;
}
case "defendHeal": {
e.block += intent.value;
e.hp = Math.min(e.maxHp, e.hp + intent.heal);
addLog(combat, `${e.name} gains ${intent.value} Block and heals.`, "log-heal");
break;
}
case "summon": {
if (e.minionCount < e.maxMinions) {
const minion = createEnemy("skeleton", true);
minion.hp = 15;
minion.maxHp = 15;
combat.enemies.push(minion);
e.minionCount++;
addLog(combat, `${e.name} summons a Skeleton!`, "log-damage");
} else {
// Skip summon, attack instead
let dmg = 8 + e.strength;
applyDamageToPlayer(combat, dmg);
addLog(combat, `${e.name} attacks for ${dmg}.`, "log-damage");
}
break;
}
case "summonPlagueRat": {
const rat = createEnemy("rat_swarm", true);
rat.hp = 18;
rat.maxHp = 18;
combat.enemies.push(rat);
addLog(combat, `${e.name} summons a Plague Rat!`, "log-damage");
break;
}
case "summonHellhound": {
const hound = createEnemy("hellhound", true);
combat.enemies.push(hound);
addLog(combat, `${e.name} summons a Hellhound!`, "log-damage");
break;
}
case "summonImp": {
const imp = createEnemy("infernal_imp", true);
combat.enemies.push(imp);
addLog(combat, `${e.name} summons another Imp!`, "log-damage");
break;
}
}
// Advance intent
e.intentIndex = (e.intentIndex + 1) % e.intents.length;
e.intent = e.intents[e.intentIndex];
});
// Check player death
if (combat.player.hp <= 0) {
combat.phase = "game_over";
return;
}
// Check all enemies dead
if (combat.enemies.every(e => e.hp <= 0)) {
combat.phase = "combat_end";
return;
}
// Start next player turn
combat.turn++;
combat.phase = "player";
combat.endedTurn = false;
// Start of player turn effects
startPlayerTurn(combat);
}
// ── function: startPlayerTurn ──
function startPlayerTurn(combat) {
const p = combat.player;
// Energy from Searing Strike
if (p.energyNext > 0) {
p.energy += p.energyNext;
p.energyNext = 0;
}
// Eternal Hourglass
if (p.relics.some(r => r.effect === "eternal_hourglass") && p.hand.length === 0) {
p.energy += 1;
addLog(combat, "Eternal Hourglass: +1 Energy.", "log-gold");
}
// Bleeding Edge
if (p.relics.some(r => r.effect === "bleeding_edge") && combat.enemies.length > 0) {
const randomEnemy = combat.enemies[Math.floor(Math.random() * combat.enemies.length)];
applyBleedToEnemy(randomEnemy, 1);
addLog(combat, "Bleeding Edge: 1 Bleed applied.", "log-damage");
}
// Soul Siphon power
const siphon = p.hand.find(c => c.power === "soul_siphon") || p.exhausted.find(c => c.power === "soul_siphon");
if (siphon) {
// Soul Siphon is a power, not in hand. Need to track powers separately.
// For now, let's track powers in a separate array
}
// Draw cards
drawCards(combat, 5);
// Process status effects
processStatusEffects(combat, true);
// Check player death
if (combat.player.hp <= 0) {
combat.phase = "game_over";
}
}
// ── function: getNextIntent ──
function getNextIntent(enemy) {
enemy.intentIndex = (enemy.intentIndex + 1) % enemy.intents.length;
return enemy.intents[enemy.intentIndex];
}
// ── function: isPlayerDead ──
// Check if player is dead
function isPlayerDead(combat) {
return combat.player.hp <= 0;
}
// ── function: areAllEnemiesDead ──
// Check if all enemies are dead
function areAllEnemiesDead(combat) {
return combat.enemies.every(e => e.hp <= 0);
}
// ── function: renderClassSelect ──
// ===================== UI RENDERING =====================
function renderClassSelect() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="screen active" id="class-select">
<h1>SHADOWS OF THE ABYSS</h1>
<p style="color:#887766; font-style:italic; margin-bottom:1rem;">Choose your cursed path</p>
<div class="class-cards">
<div class="class-card" onclick="selectClass('blood_reaper')">
<h2 style="color:#c04040;">Blood Reaper</h2>
<div class="theme">Sacrifice HP for power, life steal, Bleed</div>
<div class="stats">
Max HP: 70 | Energy: 3/turn<br>
Signature: Sanguine Strike
</div>
<div class="cards-preview">5× Strike, 4× Defend, Sanguine Strike</div>
</div>
<div class="class-card" onclick="selectClass('shadowmancer')">
<h2 style="color:#6060c0;">Shadowmancer</h2>
<div class="theme">Debuffs (Weak, Vulnerable), Intangible, card manipulation</div>
<div class="stats">
Max HP: 70 | Energy: 3/turn<br>
Signature: Shadow Bolt
</div>
<div class="cards-preview">5× Strike, 4× Defend, Shadow Bolt</div>
</div>
<div class="class-card" onclick="selectClass('hellforged')">
<h2 style="color:#c08020;">Hellforged</h2>
<div class="theme">Fire damage, Burn, summoning Imps, energy manipulation</div>
<div class="stats">
Max HP: 70 | Energy: 3/turn<br>
Signature: Firebolt
</div>
<div class="cards-preview">5× Strike, 4× Defend, Firebolt</div>
</div>
</div>
</div>
`;
}
// ── function: renderMap ──
function renderMap() {
const app = document.getElementById("app");
const act = gameState.currentAct;
const floor = gameState.currentFloor;
app.innerHTML = `
<div class="screen active" id="map-screen">
<div class="map-info">
<span class="act">Act ${act}: ${ACT_NAMES[act]}</span>
<span class="gold">Gold: ${gameState.player.gold}</span>
<span class="hp">HP: ${gameState.player.hp}/${gameState.player.maxHp}</span>
</div>
<div id="map-container">
<canvas id="map-canvas" width="900" height="600"></canvas>
</div>
<button class="map-back" onclick="returnToClassSelect()">Return to Title</button>
</div>
`;
drawMap();
}
// ── function: drawMapNode ──
function drawMapNode(ctx, x, y, type, isCurrent, isVisited, isStart) {
let color, icon, borderColor;
switch (type) {
case "enemy": color = "#2a1a10"; icon = "⚔️"; borderColor = "#5a3a20"; break;
case "elite": color = "#3a1a10"; icon = "👹"; borderColor = "#8a3a20"; break;
case "boss": color = "#2a0a20"; icon = "💀"; borderColor = "#6a2060"; break;
case "rest": color = "#1a2a10"; icon = "🏕️"; borderColor = "#3a5a20"; break;
case "shop": color = "#2a2a10"; icon = "🏪"; borderColor = "#5a5a20"; break;
case "event": color = "#1a1a2a"; icon = "❓"; borderColor = "#3a3a5a"; break;
case "start": color = "#1a2a2a"; icon = "🏠"; borderColor = "#3a5a5a"; break;
default: color = "#1a1a2a"; icon = "❓"; borderColor = "#3a3a5a";
}
// Draw connection line
ctx.strokeStyle = isVisited ? "#3a3040" : "#2a2030";
ctx.lineWidth = 2;
// Draw node circle
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fillStyle = isCurrent ? "#d4a050" : color;
ctx.fill();
ctx.strokeStyle = isCurrent ? "#e8b860" : borderColor;
ctx.lineWidth = isCurrent ? 3 : 2;
ctx.stroke();
// Draw icon
ctx.font = "14px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = isCurrent ? "#000" : "#c8c0b8";
ctx.fillText(icon, x, y);
// Floor number
ctx.font = "9px sans-serif";
ctx.fillStyle = isCurrent ? "#000" : "#887766";
const floorNum = Math.round((520 - y) / 38) + 2;
ctx.fillText(floorNum, x, y + 26);
}
// ── function: renderCombat ──
function renderCombat(combat) {
const app = document.getElementById("app");
app.innerHTML = `
<div class="screen active" id="combat-screen">
<div id="combat-log"></div>
<div id="enemy-zone"></div>
<div id="combat-canvas"></div>
<div id="combat-hud">
<div id="player-zone"></div>
<div id="status-zone"></div>
<div id="power-zone"></div>
<div id="potion-bar"></div>
<div class="energy-display">Energy: <span id="energy-val">3</span></div>
<div class="turn-display">Turn: <span id="turn-val">1</span></div>
<button id="end-turn-btn" onclick="endTurn()">End Turn</button>
<div id="card-hand"></div>
</div>
</div>
`;
renderCombatUI(combat);
}
// ── function: renderCombatUI ──
function renderCombatUI(combat) {
// Enemies
const enemyZone = document.getElementById("enemy-zone");
if (enemyZone) {
enemyZone.innerHTML = combat.enemies.map((e, idx) => `
<div class="enemy" onclick="selectTarget(${idx})" style="${combat.selectedEnemy === idx ? 'border: 2px solid #d4a050;' : ''}">
<div class="name">${e.name}</div>
<div class="intent-icon">${e.intent ? getIntentIcon(e.intent) : ''}</div>
<div class="intent-text">${e.intent ? getIntentText(e.intent) : ''}</div>
<div class="enemy-sprite">${e.sprite}</div>
<div class="hp-bar"><div class="hp-fill" style="width:${Math.max(0, (e.hp / e.maxHp) * 100)}%"></div></div>
<div style="font-size:0.7rem; color:#c8c0b8;">${e.hp}/${e.maxHp}</div>
${e.block > 0 ? `<div class="block-indicator">🛡️ ${e.block}</div>` : ''}
${e.weak > 0 ? `<div style="font-size:0.6rem; color:#c06060;">Weak ${e.weak}</div>` : ''}
${e.vulnerable > 0 ? `<div style="font-size:0.6rem; color:#c080c0;">Vuln ${e.vulnerable}</div>` : ''}
${e.bleed > 0 ? `<div style="font-size:0.6rem; color:#c06040;">Bleed ${e.bleed}</div>` : ''}
${e.burn > 0 ? `<div style="font-size:0.6rem; color:#c04020;">Burn ${e.burn}</div>` : ''}
</div>
`).join('');
}
// Player zone
const playerZone = document.getElementById("player-zone");
if (playerZone) {
const p = combat.player;
playerZone.innerHTML = `
<div class="player-sprite">${getClassSprite(p.class)}</div>
<div class="player-stats">
<div class="hp">❤️ ${p.hp}/${p.maxHp}</div>
<div class="block">🛡️ ${p.block}</div>
<div class="strength">💪 ${p.strength}</div>
<div style="font-size:0.75rem; color:#665544;">${getDeckInfo(p)}</div>
</div>
`;
}
// Status zone
const statusZone = document.getElementById("status-zone");
if (statusZone) {
const p = combat.player;
const statuses = [];
if (p.weak > 0) statuses.push({ label: `Weak ${p.weak}`, type: "debuff" });
if (p.vulnerable > 0) statuses.push({ label: `Vuln ${p.vulnerable}`, type: "debuff" });
if (p.bleed > 0) statuses.push({ label: `Bleed ${p.bleed}`, type: "debuff" });
if (p.burn > 0) statuses.push({ label: `Burn ${p.burn}`, type: "debuff" });
if (p.poison > 0) statuses.push({ label: `Poison ${p.poison}`, type: "debuff" });
if (p.regen > 0) statuses.push({ label: `Regen ${p.regen}`, type: "buff" });
if (p.intangible > 0) statuses.push({ label: `Intangible ${p.intangible}`, type: "buff" });
statusZone.innerHTML = statuses.map(s => `<span class="status-badge ${s.type}">${s.label}</span>`).join('');
}
// Power zone
const powerZone = document.getElementById("power-zone");
if (powerZone) {
const powers = combat.player.hand.filter(c => c.power || c.exhaust);
// Track powers separately - for now show from hand
powerZone.innerHTML = '';
}
// Potion bar
const potionBar = document.getElementById("potion-bar");
if (potionBar) {
const potions = combat.player.potions || [];
potionBar.innerHTML = potions.map((potionKey, idx) => {
const def = POTION_DEFS[potionKey];
return `<div class="potion-slot" onclick="usePotion(${idx})">
${def.icon}
<div class="potion-tooltip">${def.name}: ${def.desc}</div>
</div>`;
}).join('');
for (let i = potions.length; i < 3; i++) {
potionBar.innerHTML += `<div class="potion-slot"></div>`;
}
}
// Energy and turn display
const energyVal = document.getElementById("energy-val");
if (energyVal) energyVal.textContent = combat.player.energy;
const turnVal = document.getElementById("turn-val");
if (turnVal) turnVal.textContent = combat.turn;
// Card hand
const cardHand = document.getElementById("card-hand");
if (cardHand) {
cardHand.innerHTML = combat.player.hand.map((card, idx) => {
const playable = card.cost <= combat.player.energy && !card.curse;
const rarityClass = card.rarity || "common";
return `<div class="card ${playable ? 'playable' : 'unplayable'}" onclick="playCardFromHand(${idx})" title="${getCardName(card)}: ${getCardDesc(card)}">
<div class="card-cost">${card.cost}</div>
<div class="card-type ${card.type}">${card.type === 'attack' ? '⚔' : card.type === 'skill' ? '★' : '◆'}</div>
<div class="card-name">${getCardName(card)}</div>
<div class="card-desc">${getCardDesc(card)}</div>
<div class="card-rarity ${rarityClass}">${card.rarity || ''}</div>
</div>`;
}).join('');
}
// Combat log
const combatLog = document.getElementById("combat-log");
if (combatLog) {
combatLog.innerHTML = combat.log.slice(-20).map(l => `<div class="log-entry ${l.cls}">${l.msg}</div>`).join('');
combatLog.scrollTop = combatLog.scrollHeight;
}
}
// ── function: getIntentIcon ──
function getIntentIcon(intent) {
if (!intent) return "";
switch (intent.type) {
case "attack": return "⚔️";
case "defend": return "🛡️";
case "multiAttack": return "⚔️⚔️";
case "debuff": return "💫";
case "buff": return "✨";
case "summon": return "👻";
case "drainLife": return "🩸";
case "bloodRitual": return "🩸";
case "attackApply": return "⚔️";
case "shieldBash": return "⚔️🛡️";
case "defendRegen": return "🛡️✨";
case "defendBuff": return "🛡️✨";
case "defendHeal": return "🛡️💚";
case "buffAlly": return "✨";
case "buffAllEnemies": return "✨";
case "buffSelf": return "✨";
case "selfBuff": return "✨";
case "summonPlagueRat": return "🐀";
case "summonHellhound": return "🐕";
case "summonImp": return "👹";
default: return "❓";
}
}
// ── function: getIntentText ──
function getIntentText(intent) {
if (!intent) return "";
switch (intent.type) {
case "attack": return `Attack ${intent.value}`;
case "defend": return `Defend ${intent.value}`;
case "multiAttack": return `Multi ${intent.hits}×${intent.value}`;
case "debuff": return `${intent.debuff} ${intent.value}`;
case "buff": return `Buff ${intent.buff} ${intent.value}`;
case "summon": return "Summon";
case "drainLife": return `Drain ${intent.value} (+${intent.heal})`;
case "bloodRitual": return `Blood ${intent.value} (+${intent.heal})`;
case "attackApply": return `Attack ${intent.value} + ${intent.debuff}`;
case "shieldBash": return `Bash ${intent.value} (+${intent.block} Block)`;
case "defendRegen": return `Defend ${intent.value} (+${intent.regen} Regen)`;
case "defendBuff": return `Defend ${intent.value} (+${intent.buffValue} ${intent.buff})`;
case "defendHeal": return `Defend ${intent.value} (+${intent.heal})`;
default: return "";
}
}
// ── function: getClassSprite ──
function getClassSprite(cls) {
switch (cls) {
case "blood_reaper": return "🔴";
case "shadowmancer": return "🟣";
case "hellforged": return "🟠";
default: return "⚫";
}
}
// ── function: getDeckInfo ──
function getDeckInfo(player) {
const draw = player.drawPile.length;
const discard = player.discardPile.length;
const exhaust = player.exhausted.length;
return `Deck: ${draw} | Discard: ${discard} | Exhaust: ${exhaust}`;
}
// ── function: renderCardReward ──
function renderCardReward(options, isElite, isBoss) {
const overlay = document.getElementById("reward-overlay");
if (!overlay) {
const app = document.getElementById("app");
app.innerHTML += `
<div class="screen active" id="reward-overlay">
<div id="reward-container">
<h2>${isBoss ? "Boss Defeated!" : isElite ? "Elite Defeated!" : "Combat Victory!"}</h2>
<div class="reward-cards" id="reward-cards"></div>
<button class="skip-btn" onclick="skipReward()">Skip</button>
</div>
</div>
`;
return;
}
const rewardCards = document.getElementById("reward-cards");
if (rewardCards) {
rewardCards.innerHTML = options.map((card, idx) => {
const rarityClass = card.rarity || "common";
return `<div class="reward-card" onclick="chooseReward(${idx})">
<div class="card-cost">${card.cost}</div>
<div class="card-name">${getCardName(card)}</div>
<div class="card-desc">${getCardDesc(card)}</div>
<div class="card-rarity ${rarityClass}">${card.rarity || ''}</div>
</div>`;
}).join('');
}
overlay.classList.add("active");
}
// ── function: renderShop ──
function renderShop() {
const cls = gameState.player.class;
const shopCards = getShopCards(cls);
const shopRelics = getShopRelics();
const shopPotions = getShopPotions();
const shopColorless = getShopColorless();
const overlay = document.getElementById("shop-overlay");
if (!overlay) {
const app = document.getElementById("app");
app.innerHTML += `
<div class="screen active" id="shop-overlay">
<div id="shop-container">
<h2>🏪 Shop</h2>
<p style="color:#d4a050; margin-bottom:1rem;">Gold: ${gameState.player.gold}</p>
<div class="shop-section">
<h3>Class Cards</h3>
<div class="shop-items" id="shop-cards"></div>
</div>
<div class="shop-section">
<h3>Colorless</h3>
<div class="shop-items" id="shop-colorless"></div>
</div>
<div class="shop-section">
<h3>Relics</h3>
<div class="shop-items" id="shop-relics"></div>
</div>
<div class="shop-section">
<h3>Potions</h3>
<div class="shop-items" id="shop-potions"></div>
</div>
<div class="shop-section">
<button class="remove-card-btn" onclick="showCardRemoval()">Remove Card (75g)</button>
</div>
<button class="shop-close" onclick="closeShop()">Leave Shop</button>
</div>
</div>
`;
}
const shopCardsEl = document.getElementById("shop-cards");
if (shopCardsEl) {
shopCardsEl.innerHTML = shopCards.map((card, idx) => {
const price = card.rarity === "rare" ? 150 : card.rarity === "uncommon" ? 75 : 50;
const canAfford = gameState.player.gold >= price;
const rarityClass = card.rarity || "common";
return `<div class="shop-item ${canAfford ? '' : 'disabled'}" onclick="${canAfford ? `buyShopCard(${idx})` : ''}">
<div class="item-name">${getCardName(card)}</div>
<div class="item-desc">${getCardDesc(card)}</div>
<div class="price">${price}g</div>
</div>`;
}).join('');
}
const shopColorlessEl = document.getElementById("shop-colorless");
if (shopColorlessEl) {
const price = 60;
const canAfford = gameState.player.gold >= price;
shopColorlessEl.innerHTML = `<div class="shop-item ${canAfford ? '' : 'disabled'}" onclick="${canAfford ? 'buyShopColorless()' : ''}">
<div class="item-name">${getCardName(shopColorless)}</div>
<div class="item-desc">${getCardDesc(shopColorless)}</div>
<div class="price">${price}g</div>
</div>`;
}
const shopRelicsEl = document.getElementById("shop-relics");
if (shopRelicsEl) {
shopRelicsEl.innerHTML = shopRelics.map((relic, idx) => {
const canAfford = gameState.player.gold >= 150;
return `<div class="shop-item ${canAfford ? '' : 'disabled'}" onclick="${canAfford ? `buyShopRelic(${idx})` : ''}">
<div class="item-name">${relic.name}</div>
<div class="item-desc">${relic.desc}</div>
<div class="price">150g</div>
</div>`;
}).join('');
}
const shopPotionsEl = document.getElementById("shop-potions");
if (shopPotionsEl) {
shopPotionsEl.innerHTML = shopPotions.map((potionKey, idx) => {
const def = POTION_DEFS[potionKey];
const canAfford = gameState.player.gold >= 50;
return `<div class="shop-item ${canAfford ? '' : 'disabled'}" onclick="${canAfford ? `buyShopPotion(${idx})` : ''}">
<div class="item-name">${def.icon} ${def.name}</div>
<div class="item-desc">${def.desc}</div>
<div class="price">50g</div>
</div>`;
}).join('');
}
overlay.classList.add("active");
}
// ── function: renderEvent ──
function renderEvent(event) {
const overlay = document.getElementById("event-overlay");
if (!overlay) {
const app = document.getElementById("app");
app.innerHTML += `
<div class="screen active" id="event-overlay">
<div id="event-container">
<h2>${event.name}</h2>
<div id="event-text"></div>
<div class="event-choices" id="event-choices"></div>
</div>
</div>
`;
}
const eventText = document.getElementById("event-text");
if (eventText) eventText.textContent = event.text;
const eventChoices = document.getElementById("event-choices");
if (eventChoices) {
eventChoices.innerHTML = event.choices.map((choice, idx) => `
<div class="event-choice" onclick="chooseEventChoice(${idx})">
<div class="choice-label">${choice.label}</div>
<div class="choice-desc">${choice.desc}</div>
</div>
`).join('');
}
overlay.classList.add("active");
}
// ── function: renderRestSite ──
function renderRestSite() {
const overlay = document.getElementById("rest-overlay");
if (!overlay) {
const app = document.getElementById("app");
app.innerHTML += `
<div class="screen active" id="rest-overlay">
<div id="rest-container">
<h2>Rest Site</h2>
<p style="color:#887766; margin-bottom:1rem;">Choose your path</p>
<div class="rest-choices">
<div class="rest-choice" onclick="chooseRest('rest')">
<div class="choice-label">🏕️ Rest</div>
<div class="choice-desc">Heal 25% max HP (${Math.floor(gameState.player.maxHp * 0.25)} HP)</div>
</div>
<div class="rest-choice" onclick="chooseRest('forge')">
<div class="choice-label">⚒️ Forge</div>
<div class="choice-desc">Upgrade a random card</div>
</div>
<div class="rest-choice" onclick="chooseRest('meditate')">
<div class="choice-label">🧘 Meditate</div>
<div class="choice-desc">Remove a card from deck</div>
</div>
</div>
</div>
</div>
`;
}
overlay.classList.add("active");
}
// ── function: renderBossReward ──
function renderBossReward(relicOptions) {
const overlay = document.getElementById("reward-overlay");
if (!overlay) {
const app = document.getElementById("app");
app.innerHTML += `
<div class="screen active" id="reward-overlay">
<div id="reward-container">
<h2>👑 Boss Defeated!</h2>
<p style="color:#d4a050; margin-bottom:1rem;">Choose a boss relic</p>
<div class="reward-cards" id="reward-cards"></div>
</div>
</div>
`;
}
const rewardCards = document.getElementById("reward-cards");
if (rewardCards) {
rewardCards.innerHTML = relicOptions.map((relic, idx) => `
<div class="reward-card" onclick="chooseBossRelic(${idx})" style="cursor:pointer;">
<div class="card-name" style="font-size:0.85rem;">${relic.name}</div>
<div class="card-desc" style="font-size:0.7rem;">${relic.desc}</div>
</div>
`).join('');
}
overlay.classList.add("active");
}
// ── function: renderGameOver ──
function renderGameOver() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="screen active" id="gameover-screen">
<h1>☠️ DEFEAT</h1>
<p style="color:#887766; margin-top:1rem;">Your journey ends in the ${ACT_NAMES[gameState.currentAct]}</p>
<p style="color:#d4a050; margin-top:0.5rem;">Floor reached: ${gameState.currentFloor}</p>
<button class="restart-btn" onclick="returnToClassSelect()">Try Again</button>
</div>
`;
}
// ── function: renderVictory ──
function renderVictory() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="screen active" id="victory-screen">
<h1>✨ VICTORY ✨</h1>
<p style="color:#d4a050; margin-top:1rem; font-size:1.2rem;">You have defeated the Archdemon!</p>
<p style="color:#887766; margin-top:1rem;">The Abyss is sealed... for now.</p>
<button class="restart-btn" onclick="returnToClassSelect()">Play Again</button>
</div>
`;
}
// ── function: renderCardRemoval ──
function renderCardRemoval() {
const overlay = document.getElementById("remove-overlay");
if (!overlay) {
const app = document.getElementById("app");
app.innerHTML += `
<div class="screen active" id="remove-overlay">
<div id="remove-container">
<h2>Remove a Card</h2>
<p style="color:#887766; margin-bottom:1rem;">Choose one card to remove (75g)</p>
<div id="remove-list"></div>
<button class="skip-btn" onclick="cancelCardRemoval()">Cancel</button>
</div>
</div>
`;
}
const removeList = document.getElementById("remove-list");
if (removeList) {
removeList.innerHTML = gameState.player.deck.filter(c => !c.curse).map((card, idx) => `
<div class="remove-item" onclick="confirmCardRemoval(${idx})">${getCardName(card)}</div>
`).join('');
}
overlay.classList.add("active");
}
// ── const: gameState ──
const gameState = {
screen: "class-select",
player: null,
currentAct: 1,
currentFloor: 2,
combat: null,
rewardCards: [],
shopItems: null,
eventChoice: null,
restChoice: null,
bossRelicOptions: null,
mapScroll: 0
};
// ── function: initGame ──
function initGame() {
renderClassSelect();
}
// ── function: selectClass ──
function selectClass(cls) {
gameState.player = createPlayer(cls);
gameState.currentAct = 1;
gameState.currentFloor = 2;
const startDeck = STARTING_DECKS[cls];
gameState.player.deck = startDeck.map(s => {
const def = getCardDef(s.id);
return { ...def };
});
renderMap();
}
// ── function: returnToClassSelect ──
function returnToClassSelect() {
gameState.player = null;
gameState.currentAct = 1;
gameState.currentFloor = 2;
renderClassSelect();
}
// ── function: advanceMap ──
function advanceMap() {
gameState.currentFloor++;
if (gameState.currentFloor > 15) {
if (gameState.currentAct < 3) {
gameState.currentAct++;
gameState.currentFloor = 2;
renderMap();
} else {
renderVictory();
}
return;
}
const act = gameState.currentAct;
const floor = gameState.currentFloor;
const groups = FLOOR_GROUPS[act];
const group = groups[floor];
if (typeof group === "string") {
handleMapNode(group, null);
} else if (Array.isArray(group)) {
handleMapNode(group[0].type, [group[0], group[1]]);
}
}
// ── function: handleMapNode ──
function handleMapNode(type, branch) {
const nodeType = getTypeFromNodeOrEnemy(type);
if (nodeType === "enemy") {
startEnemyCombat(branch);
} else if (nodeType === "elite") {
startEliteCombat(branch);
} else if (nodeType === "boss") {
startBossCombat();
} else if (nodeType === "rest") {
renderRestSite();
} else if (nodeType === "shop") {
openShop();
} else if (nodeType === "event") {
openEvent();
} else {
gameState.currentFloor++;
renderMap();
}
}
// ── function: startEnemyCombat ──
function startEnemyCombat(branch) {
let enemyTypes;
if (branch) {
enemyTypes = branch.map(b => b.type);
} else {
const act = gameState.currentAct;
const actEnemies = Object.entries(ENEMIES).filter(([k, v]) => v.act === act && !v.elite && !v.boss);
enemyTypes = [actEnemies[Math.floor(Math.random() * actEnemies.length)][0]];
}
const enemies = enemyTypes.map(t => createEnemy(t, false));
gameState.combat = startCombat(gameState.player, enemies, false);
if (!gameState.combat) {
console.error("startCombat returned null! player:", gameState.player?.class, "enemies:", enemyTypes);
return;
}
gameState.combat.goldReward = getGoldReward(false, false);
renderCombat(gameState.combat);
}
// ── function: startEliteCombat ──
function startEliteCombat(branch) {
let enemyTypes;
if (branch && branch[0] && branch[0].type === "elite") {
enemyTypes = [branch[0].type];
} else {
const act = gameState.currentAct;
const actElites = Object.entries(ENEMIES).filter(([k, v]) => v.act === act && v.elite);
enemyTypes = [actElites[Math.floor(Math.random() * actElites.length)][0]];
}
const enemies = enemyTypes.map(t => createEnemy(t, false));
gameState.combat = startCombat(gameState.player, enemies, false);
gameState.combat.goldReward = getGoldReward(false, false);
gameState.combat.isElite = true;
renderCombat(gameState.combat);
}
// ── function: startBossCombat ──
function startBossCombat() {
const act = gameState.currentAct;
const bossKey = Object.entries(ENEMIES).find(([k, v]) => v.act === act && v.boss);
const bossType = bossKey ? bossKey[0] : "archdemon";
const enemies = [createEnemy(bossType, false)];
gameState.combat = startCombat(gameState.player, enemies, true);
gameState.combat.goldReward = getGoldReward(false, true);
gameState.combat.isBoss = true;
renderCombat(gameState.combat);
}
// ── function: openShop ──
function openShop() { renderShop(); }
// ── function: openEvent ──
function openEvent() { renderEvent(getRandomEvent()); }
// ── function: closeShop ──
function closeShop() {
const overlay = document.getElementById("shop-overlay");
if (overlay) overlay.classList.remove("active");
gameState.currentFloor++;
renderMap();
}
// ── function: buyShopCard ──
function buyShopCard(idx) {
const shopCards = getShopCards(gameState.player.class);
const card = shopCards[idx];
const price = card.rarity === "rare" ? 150 : card.rarity === "uncommon" ? 75 : 50;
if (gameState.player.gold >= price) {
gameState.player.gold -= price;
gameState.player.deck.push({ ...card });
renderShop();
}
}
// ── function: buyShopColorless ──
function buyShopColorless() {
const card = getShopColorless();
if (gameState.player.gold >= 60) {
gameState.player.gold -= 60;
gameState.player.deck.push({ ...card });
renderShop();
}
}
// ── function: buyShopRelic ──
function buyShopRelic(idx) {
const relics = getShopRelics();
const relic = relics[idx];
if (gameState.player.gold >= 150) {
gameState.player.gold -= 150;
gameState.player.relics.push({ ...relic });
renderShop();
}
}
// ── function: buyShopPotion ──
function buyShopPotion(idx) {
const potions = getShopPotions();
const potionKey = potions[idx];
if (gameState.player.gold >= 50) {
gameState.player.gold -= 50;
if (!gameState.player.potions) gameState.player.potions = [];
if (gameState.player.potions.length < 3) {
gameState.player.potions.push(potionKey);
renderShop();
}
}
}
// ── function: showCardRemoval ──
function showCardRemoval() {
if (gameState.player.gold >= 75) renderCardRemoval();
}
// ── function: confirmCardRemoval ──
function confirmCardRemoval(idx) {
const nonCurseCards = gameState.player.deck.filter(c => !c.curse);
if (idx < nonCurseCards.length && gameState.player.gold >= 75) {
gameState.player.gold -= 75;
const card = nonCurseCards[idx];
const origIdx = gameState.player.deck.indexOf(card);
gameState.player.deck.splice(origIdx, 1);
closeShop();
}
}
// ── function: cancelCardRemoval ──
function cancelCardRemoval() {
const overlay = document.getElementById("remove-overlay");
if (overlay) overlay.classList.remove("active");
}
// ── function: chooseEventChoice ──
function chooseEventChoice(idx) {
const overlay = document.getElementById("event-overlay");
if (!overlay) return;
const event = gameState.eventChoice || getRandomEvent();
const choice = event.choices[idx];
switch (choice.effect) {
case "offer_hp":
gameState.player.hp -= 15;
gameState.player.relics.push(getRandomRareRelic());
break;
case "pray":
gameState.player.hp = Math.min(gameState.player.maxHp, gameState.player.hp + 10);
gameState.player.deck.push({ ...CURSE_CARDS.doubt });
break;
case "leave":
break;
case "trade_card": {
const nonCurse = gameState.player.deck.filter(c => !c.curse);
if (nonCurse.length > 0) {
const card = nonCurse[Math.floor(Math.random() * nonCurse.length)];
const ci = gameState.player.deck.indexOf(card);
gameState.player.deck.splice(ci, 1);
}
gameState.player.gold += 100;
break;
}
case "buy_wares":
gameState.player.gold -= 50;
gameState.player.potions = gameState.player.potions || [];
if (gameState.player.potions.length < 3) gameState.player.potions.push(getRandomPotion());
gameState.player.relics.push(getRandomCommonRelic());
break;
case "attack_peddler":
const enemies = [createEnemy("infernal_imp", false)];
gameState.combat = startCombat(gameState.player, enemies, false);
gameState.combat.goldReward = 75;
renderCombat(gameState.combat);
return;
case "drink":
gameState.player.hp -= 10;
while (removeCurseFromDeck({ player: gameState.player })) {}
break;
case "bottle":
gameState.player.potions = gameState.player.potions || [];
if (gameState.player.potions.length < 3) gameState.player.potions.push("cursed_brew");
break;
case "accept":
gameState.player.hp -= 20;
upgradeRandomCards({ player: gameState.player }, 2);
break;
case "bet_gold":
if (gameState.player.gold >= 30) {
gameState.player.gold -= 30;
if (Math.random() < 0.5) gameState.player.gold += 60;
}
break;
case "bet_card": {
const nonCurse = gameState.player.deck.filter(c => !c.curse);
if (nonCurse.length > 0) {
const card = nonCurse[Math.floor(Math.random() * nonCurse.length)];
const ci = gameState.player.deck.indexOf(card);
gameState.player.deck.splice(ci, 1);
}
break;
}
case "study":
gameState.player.hp -= 5;
gameState.player.tempEnergyNext = 1;
break;
case "read_forbidden": {
const pool = getCardPool(gameState.player.class);
const powers = pool.filter(c => c.type === CARD_TYPES.POWER);
if (powers.length > 0) {
const power = powers[Math.floor(Math.random() * powers.length)];
gameState.player.deck.push({ ...power });
}
gameState.player.deck.push({ ...CURSE_CARDS.doubt });
break;
}
}
overlay.classList.remove("active");
gameState.currentFloor++;
renderMap();
}
// ── function: chooseRest ──
function chooseRest(choice) {
const overlay = document.getElementById("rest-overlay");
if (!overlay) return;
switch (choice) {
case "rest":
const heal = Math.floor(gameState.player.maxHp * 0.25);
gameState.player.hp = Math.min(gameState.player.maxHp, gameState.player.hp + heal);
break;
case "forge":
upgradeRandomCards({ player: gameState.player }, 1);
break;
case "meditate":
showCardRemoval();
break;
}
overlay.classList.remove("active");
gameState.currentFloor++;
renderMap();
}
// ── function: chooseReward ──
function chooseReward(idx) {
const overlay = document.getElementById("reward-overlay");
if (overlay) overlay.classList.remove("active");
const card = gameState.rewardCards[idx];
if (card) gameState.player.deck.push({ ...card });
gameState.currentFloor++;
renderMap();
}
// ── function: skipReward ──
function skipReward() {
const overlay = document.getElementById("reward-overlay");
if (overlay) overlay.classList.remove("active");
gameState.currentFloor++;
renderMap();
}
// ── function: chooseBossRelic ──
function chooseBossRelic(idx) {
const overlay = document.getElementById("reward-overlay");
if (overlay) overlay.classList.remove("active");
const relic = gameState.bossRelicOptions[idx];
if (relic) gameState.player.relics.push({ ...relic });
gameState.player.hp = gameState.player.maxHp;
if (gameState.currentAct < 3) {
gameState.currentAct++;
gameState.currentFloor = 2;
renderMap();
} else {
renderVictory();
}
}
// ── function: playCardFromHand ──
function playCardFromHand(idx) {
if (!gameState.combat || gameState.combat.phase !== "player") return;
const card = gameState.combat.player.hand[idx];
if (!card || card.cost > gameState.combat.player.energy) return;
if (card.name === "Doubt") return;
if (card.ethereal) return;
playCard(gameState.combat, idx, gameState.combat.selectedEnemy);
if (isPlayerDead(gameState.combat)) { renderGameOver(); return; }
if (areAllEnemiesDead(gameState.combat)) { handleCombatVictory(); return; }
renderCombatUI(gameState.combat);
}
// ── function: selectTarget ──
function selectTarget(idx) {
if (gameState.combat) {
gameState.combat.selectedEnemy = idx;
renderCombatUI(gameState.combat);
}
}
// ── function: endTurn ──
function endTurn() {
if (!gameState.combat || gameState.combat.phase !== "player") return;
endPlayerTurn(gameState.combat);
if (gameState.combat.phase === "game_over") { renderGameOver(); return; }
if (gameState.combat.phase === "combat_end") { handleCombatVictory(); return; }
renderCombatUI(gameState.combat);
}
// ── function: handleCombatVictory ──
function handleCombatVictory() {
const combat = gameState.combat;
const isElite = combat.isElite;
const isBoss = combat.isBoss;
let gold = combat.goldReward;
if (gameState.player.relics.some(r => r.effect === "greedy_imp")) gold += 10;
gameState.player.gold += gold;
const potionCount = getPotionDropChance(isElite);
for (let i = 0; i < potionCount; i++) {
const potion = getRandomPotion();
if (!gameState.player.potions) gameState.player.potions = [];
if (gameState.player.potions.length < 3) gameState.player.potions.push(potion);
}
if (isBoss) {
for (let i = 0; i < 2; i++) {
const potion = getRandomPotion();
if (!gameState.player.potions) gameState.player.potions = [];
if (gameState.player.potions.length < 3) gameState.player.potions.push(potion);
}
}
if (gameState.player.relics.some(r => r.effect === "ashen_armor")) {
gameState.player.hp = Math.min(gameState.player.maxHp, gameState.player.hp + 6);
}
const pool = getCardPool(gameState.player.class);
gameState.rewardCards = pickCardReward(pool, isElite, isBoss);
if (isBoss) {
gameState.bossRelicOptions = pickBossRelics();
renderBossReward(gameState.bossRelicOptions);
} else {
renderCardReward(gameState.rewardCards, isElite, isBoss);
}
if (isElite) {
const relic = getRelicDrop(true);
if (relic) gameState.player.relics.push({ ...relic });
}
}
// ── function: usePotion ──
function usePotion(idx) {
if (!gameState.combat || gameState.combat.phase !== "player") return;
const potions = gameState.combat.player.potions;
if (!potions || idx >= potions.length) return;
const potionKey = potions[idx];
potions.splice(idx, 1);
switch (potionKey) {
case "blood_vial":
gameState.combat.player.hp = Math.min(gameState.combat.player.maxHp, gameState.combat.player.hp + 15);
break;
case "firebomb":
gameState.combat.enemies.forEach(e => applyDamageToEnemy(gameState.combat, gameState.combat.enemies.indexOf(e), 10, false, false));
break;
case "dex_tonic":
gameState.combat.player.dexterity += 3;
break;
case "energy_elixir":
gameState.combat.player.energy += 2;
break;
case "cursed_brew":
gameState.combat.enemies.forEach(e => applyVulnerableToEnemy(e, 3));
break;
case "bleeding_flask":
if (gameState.combat.enemies.length > 0) {
const target = gameState.combat.selectedEnemy !== null ? gameState.combat.selectedEnemy : 0;
applyBleedToEnemy(gameState.combat.enemies[target], 6);
}
break;
case "imp_bottle":
gameState.combat.player.hand.push({ ...IMP_CLAW_PLUS });
break;
case "smoke_bomb":
if (!gameState.combat.isBoss) {
gameState.combat = null;
gameState.currentFloor++;
renderMap();
return;
}
break;
}
renderCombatUI(gameState.combat);
}
// ── function: getTypeFromEnemy ──
function getTypeFromEnemy(enemyType) {
const enemy = ENEMIES[enemyType];
if (!enemy) return "enemy";
if (enemy.boss) return "boss";
if (enemy.elite) return "elite";
return "enemy";
}
// ── function: drawMap ──
function drawMap() {
const container = document.getElementById("map-container");
if (!container) return;
// Remove canvas
const canvas = container.querySelector("canvas");
if (canvas) canvas.remove();
// Clear previous nodes and paths
container.querySelectorAll(".map-node, .map-path").forEach(n => n.remove());
const act = gameState.currentAct;
const floor = gameState.currentFloor;
const groups = FLOOR_GROUPS[act];
const startX = 450;
const startY = 520;
const spacingY = 38;
for (let f = 2; f <= 15; f++) {
const y = startY - (f - 2) * spacingY;
const group = groups[f];
if (typeof group === "string") {
const x = startX;
createMapNode(container, x, y, group, f === floor, f < floor, f === 2, f, group, startY);
} else if (Array.isArray(group)) {
const positions = group.map((g, i) => ({
x: i === 0 ? startX - 70 : startX + 70,
y: y,
type: typeof g === "string" ? g : getTypeFromEnemy(g.type),
floor: f,
enemyType: typeof g === "string" ? g : g.type
}));
positions.forEach(pos => {
createMapNode(container, pos.x, pos.y, pos.type, f === floor, f < floor, f === 2, f, pos.enemyType, startY);
});
}
}
// Add click handler to container for event delegation
container.onclick = (e) => {
const node = e.target.closest('.map-node');
if (node) {
const title = node.title;
const floorNum = parseInt(title.match(/Floor (\d+)/)?.[1]);
if (node.classList.contains('visited') || node.classList.contains('player')) {
gameState.currentFloor = floorNum;
const groups = FLOOR_GROUPS[gameState.currentAct];
const group = groups[floorNum];
if (typeof group === "string") {
handleMapNode(group, null);
} else if (Array.isArray(group)) {
handleMapNode(group[0].type, [group[0], group[1]]);
}
}
}
};
}
// ── function: getTypeFromNodeOrEnemy ──
function getTypeFromNodeOrEnemy(type) {
if (["enemy", "elite", "boss", "rest", "shop", "event"].includes(type)) return type;
// It's an enemy type name - look it up
const enemy = ENEMIES[type];
if (enemy) {
if (enemy.boss) return "boss";
if (enemy.elite) return "elite";
return "enemy";
}
return "enemy";
}
// ── function: createMapNode ──
function createMapNode(container, x, y, type, isCurrent, isVisited, isStart, floor, enemyType, startY) {
const node = document.createElement("div");
let icon, cls;
switch (type) {
case "enemy": icon = "⚔️"; cls = "enemy"; break;
case "elite": icon = "👹"; cls = "elite"; break;
case "boss": icon = "💀"; cls = "boss"; break;
case "rest": icon = "🏕️"; cls = "rest"; break;
case "shop": icon = "🏪"; cls = "shop"; break;
case "event": icon = "❓"; cls = "event"; break;
case "start": icon = "🏠"; cls = "start"; break;
default: icon = "❓"; cls = "event";
}
node.className = `map-node ${cls}`;
if (isCurrent) node.classList.add("current");
if (isVisited) node.classList.add("visited");
if (isStart) node.classList.add("player");
if (!isVisited && !isStart) node.classList.add("locked");
node.style.left = (x - 20) + "px";
node.style.top = (y - 20) + "px";
node.textContent = icon;
node.title = `Floor ${floor}: ${type}`;
node.style.cursor = (isVisited || isStart) ? "pointer" : "default";
node.style.zIndex = "20";
if (isVisited || isStart) {
node.onclick = (e) => {
e.stopPropagation();
if (isVisited || isStart) {
gameState.currentFloor = floor;
const groups = FLOOR_GROUPS[gameState.currentAct];
const group = groups[floor];
if (typeof group === "string") {
handleMapNode(group, null);
} else if (Array.isArray(group)) {
handleMapNode(group[0].type, [group[0], group[1]]);
}
}
};
}
container.appendChild(node);
// Draw path to previous floor
if (floor > 2) {
const prevY = startY - (floor - 3) * 38;
const prevX = 450;
const path = document.createElement("div");
path.className = "map-path";
const dx = x - prevX;
const dy = y - prevY;
const length = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
path.style.width = length + "px";
path.style.left = prevX + "px";
path.style.top = prevY + "px";
path.style.transform = `rotate(${angle}deg)`;
path.style.transformOrigin = "0 50%";
container.appendChild(path);
}
}
// ── main ──
// Main entry point - runs after all js_define units
initGame();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"5d6fec58-a06a-4f1f-a5f2-97b6f5b3d63d","tokensIn":16828667,"tokensOut":93898,"tokensTotal":16922565,"turns":168,"toolCalls":167,"failedToolCalls":4,"timestamp":"2026-07-20T22:47:32.478Z"} -->