Deckbuilder
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · f7f80b46cf
Create a beautiful and fully functional prototype of the game described in the GDD below so I can test gameplay elements. For card image assets, create an unique SVG card frame for each deck type leaving a placeholder for the main card image. Each deck type should also have a unique card back (also SVG). # Game Design Document: Shadows of Aethelgard ## 1. Game Overview **Title:** Shadows of Aethelgard **Genre:** Roguelite / Deckbuilder / Card Battler **Visual Style:** Dark Fantasy / Low Poly / Gothic Atmosphere (e.g., Bloodborne meets Magic the Gathering). **Premise:** The world of Aethelgard is being consumed by the "Void." Heroes must traverse three descending floors to slay the Void Lords and reclaim the land. **Core Loop:** Manage a card-based hero with limited Health and Energy. Each combat room presents enemies. Between rounds, use Gold to buy cards or upgrades. Defeat 3 Floors (9 Rooms + 3 Bosses) to win. ## 2. Core Mechanics (No Ambiguity) ### 2.1. Attributes * **Health (HP):** Starts at **30**. Reduced by enemy damage. * **Energy (Mana):** Starts at **5** Max. Spends per card play. Regenerates by 1 per turn (if not exhausted) or via cards. * **Defense (Block):** Passive or Active stats that reduce incoming damage by the amount shown. * **Attack:** The primary damage value of an Attack card. * **Hand Size:** Starts at **4**. Can increase via "Others" cards. * **Gold:** Starts at **0**. Gained from enemy death. Cost to buy: **1 Gold per card**. * **Floor:** Starts at 1. Win condition: Reach Floor 3 and defeat Floor Boss. ### 2.2. Card Resolution Order 1. **Player Draw:** Draw cards up to Hand Size limit. 2. **Player Play:** Place cards from Hand. * _Effect Resolution:_ Effects resolve instantly unless specified (e.g., "End of Turn"). * _Combat Damage:_ Applies immediately. If target has Defense, subtract Damage from Defense. * _Blocking:_ If a "Defense" card is active, it absorbs damage before HP. 3. **Enemy Reaction:** If an "Others" card triggers an enemy state, resolve immediately. 4. **Enemy Action:** Enemy attacks. 5. **End Turn:** Recover 1 Energy. Discard down to max if specified. ### 2.3. Economy * **Gold:** Gain **5 Gold** per standard enemy kill. **15 Gold** per Boss kill. * **Card Shop:** 1 Card per slot. * _Cost:_ Gold shown on card face. * _Rarity:_ All cards in the Core Roster are "Common" for production (can be bought multiple times). * _Duplication:_ If you own 3 copies of a card, the cost to buy the 4th is **10 Gold** (limit of 5 copies per card per deck). ### 2.4. Combat Math * **Damage Calculation:** `Card Attack` = `Final Damage`. * **Block Calculation:** `Incoming Damage` = `Max(Card Attack) - Defense`. * **Energy Cost:** Each card has a specific `Cost` (1-3). If Energy < Cost, card cannot be played. * **Status Effects:** Burn (1 dmg/turn), Poison (1 dmg/turn), Stun (Cannot act), Silence (Cannot use abilities). --- ## 3. Card Roster (The 36 Core Cards) **Instruction:** Each of the three starting classes (The Bastion, The Wither, The Shroud) has a unique **Starter Deck**. These are the only 12 unique cards defined for production to ensure a functional minimum scope. All cards are available in the Shop. ### 3.1. Class 1: The Bastion (Theme: Immovable Object) **Style:** High Defense, High Block, Low Speed. Focuses on durability and AoE. **Start Energy:** 3. **Start HP:** 35. | ID | Type | Cost | Stats (Atk/Def) | Ability | Rarity | | :--- | :--- | :--- | :--- | :--- | :--- | | **BA-A01** | Attack | 2 | 3 / 0 | **Slam:** Deals 3 damage. If you have 30 HP, draw 1 card. | Common | | **BA-A02** | Attack | 3 | 4 / 0 | **Earthquake:** Deals 4 damage to all enemies. | Common | | **BA-A03** | Attack | 1 | 2 / 0 | **Stone Fist:** Deals 2 damage. | Common | | **BA-A04** | Attack | 4 | 6 / 0 | **Boulder:** Deals 6 damage. Costs 4 Energy. | Common | | **BA-D01** | Defense | 0 | 0 / 4 | **Shield Wall:** Gain 4 Defense for this turn. | Common | | **BA-D02** | Defense | 2 | 0 / 5 | **Iron Skin:** Gain 5 Defense permanently. Discard 1 card. | Common | | **BA-D03** | Defense | 1 | 0 / 2 | **Stand Firm:** Gain 2 Defense. Blocks 1 incoming damage. | Common | | **BA-D04** | Defense | 2 | 0 / 3 | **Taunt:** Gain 3 Defense. Stun 1 enemy for 1 turn. | Common | | **BA-U01** | Other | 2 | 0 / 0 | **Search:** Draw 1 card. Discard hand if > 5 cards. | Common | | **BA-U02** | Other | 3 | 0 / 0 | **Repair:** Heal 6 HP. | Common | | **BA-U03** | Other | 1 | 0 / 0 | **Rally:** Draw 1 card. Gain 1 Energy. | Common | | **BA-U04** | Other | 2 | 0 / 0 | **Loot:** Gain 5 Gold. | Common | ### 3.2. Class 2: The Wither (Theme: Rot and Burn) **Style:** Burn Damage, Debuff Control, Status Effects. Low HP, High AoE. | ID | Type | Cost | Stats (Atk/Def) | Ability | Rarity | | :--- | :--- | :--- | :--- | :--- | :--- | | **WI-A01** | Attack | 2 | 3 / 0 | **Fireball:** Deals 3 damage. Apply 1 Burn to target. | Common | | **WI-A02** | Attack | 3 | 4 / 0 | **Inferno:** Deals 4 damage. Applies 3 Burn. Costs 3 Energy. | Common | | **WI-A03** | Attack | 1 | 2 / 0 | **Ember:** Deals 2 damage. Deals 1 damage every turn to target. | Common | | **WI-A04** | Attack | 4 | 5 / 0 | **Pyre:** Deals 5 damage. All enemies take 1 damage next turn. | Common | | **WI-D01** | Defense | 2 | 0 / 3 | **Scorch Shield:** Gain 3 Defense. Block 1 Burn damage. | Common | | **WI-D02** | Defense | 1 | 0 / 1 | **Heat Resist:** Gain 1 Defense. Gain 1 Energy. | Common | | **WI-D03** | Defense | 2 | 0 / 4 | **Smoke:** Gain 4 Defense. Block 1 Burn damage. | Common | | **WI-D04** | Defense | 3 | 0 / 2 | **Endure:** Gain 2 Defense. Heal 2 HP. | Common | | **WI-U01** | Other | 2 | 0 / 0 | **Poison:** Apply 1 Poison to target. | Common | | **WI-U02** | Other | 3 | 0 / 0 | **Burn:** Deal 3 damage + 2 Burn to all enemies. | Common | | **WI-U03** | Other | 1 | 0 / 0 | **Slow:** Draw 1 card. Target cannot move next turn (Stun). | Common | | **WI-U04** | Other | 2 | 0 / 0 | **Wither:** Heal 5 HP. Reduce enemy Attack by 1. | Common | ### 3.3. Class 3: The Shroud (Theme: Shadow and Speed) **Style:** High Speed, Evasion, Assassination. High Crit, Low Defense. | ID | Type | Cost | Stats (Atk/Def) | Ability | Rarity | | :--- | :--- | :--- | :--- | :--- | :--- | | **SH-A01** | Attack | 2 | 4 / 0 | **Backstab:** Deals 4 damage. +1 Damage if target has no Armor. | Common | | **SH-A02** | Attack | 3 | 5 / 0 | **Shadow Bolt:** Deals 5 damage. Target cannot block. | Common | | **SH-A03** | Attack | 1 | 3 / 0 | **Silence:** Deals 3 damage. Target loses ability to use cards. | Common | | **SH-A04** | Attack | 3 | 6 / 0 | **Vengeance:** Deals 6 damage. Gain 2 Energy. | Common | | **SH-D01** | Defense | 3 | 0 / 2 | **Blur:** Gain 2 Defense. Ignore next attack. | Common | | **SH-D02** | Defense | 2 | 0 / 3 | **Step:** Gain 3 Defense. Gain 1 Energy. | Common | | **SH-D03** | Defense | 1 | 0 / 1 | **Evasion:** Gain 1 Defense. Skip enemy action. | Common | | **SH-D04** | Defense | 2 | 0 / 4 | **Phantom:** Gain 4 Defense. Ghost form for 1 turn. | Common | | **SH-U01** | Other | 1 | 0 / 0 | **Dash:** Gain 1 Energy. Skip enemy attack. | Common | | **SH-U02** | Other | 2 | 0 / 0 | **Teleport:** Move to back row. Deal 2 damage. | Common | | **SH-U03** | Other | 2 | 0 / 0 | **Hide:** Gain 1 Defense. Discard 1 card. Draw 2 cards. | Common | | **SH-U04** | Other | 3 | 0 / 0 | **Assassinate:** Deal 8 damage. Cannot be blocked this turn. | Common | --- ## 4. Enemy & Boss Design **Instruction:** Enemies are not card-based. They have **Health (HP)** and **Damage**. * **Floor 1 Enemies:** 20 HP / 3 Dmg. (Goblins, Rats). * **Floor 2 Enemies:** 30 HP / 5 Dmg. (Vampire Knights, Ogres). * **Floor 3 Enemies:** 40 HP / 8 Dmg. (Void Warriors, Cultists). * **Floor Bosses:** 80 HP / 15 Dmg. Unique mechanics per floor. * _Boss 1 (The Gatekeeper):_ Blocks 5 damage per turn until HP < 20. * _Boss 2 (The Corrupted):_ Deals 20 damage if you have < 3 Energy. * _Boss 3 (The Void Lord):_ 40 HP + 10 HP per turn (Regen). Cannot block. --- ## 5. Production & Logic Notes ### 5.1. Game State Data Structure * **Hero Object:** `{ HP, MaxHP, Energy, HandSize, Gold, Defense, Deck, EnemyDecks }` * **Card Object:** `{ ID, Type, Cost, Effect, Rarity, OwnerID }` * **Enemy Object:** `{ HP, Damage, Defense, Status: { Poison, Burn } }` ### 5.2. Shop Logic * **Inventory:** `Deck[CardID]` = 0. * **Shop Display:** If `Inventory[CardID] < 5`, display card. * **Transaction:** * If `Gold >= Cost`, decrement Gold. * If `Inventory[CardID] < 5`, increment `Inventory[CardID]`. * Add `CardID` to `Deck` pool. * **Cost Scaling:** `Cost = Base + (Count * 1)`. Max Cost Cap = 50 Gold. ### 5.3. Combat Resolution Script (Pseudocode) ```pseudocode function CombatTurn(Enemy, Player) { Player.DrawCard(); Player.PlayCards(Condition: Cost <= Player.Energy); for (Card c in Player.Hand) { if (c.Type == Attack) { Enemy.TakeDamage(c.Attack); Enemy.ApplyStatus(c.Status); } if (c.Type == Defense) { Player.GainDefense(c.Defense); if (c.HasTrigger) TriggerEffect(c); } if (c.Type == Other) { Player.GainEffect(c.Effect); } } Player.DiscardToMax(); Player.RecoverEnergy(1); // Enemy Turn Player.BlockDamage(c.Defense); // Apply Player Defense Enemy.DealDamage(Player.Health); if (Player.Health <= 0) GameOver(); if (Enemy.Health <= 0) GoldReward(); } ``` ### 5.4. Win/Loss Conditions * **Win:** Reach the "Victory Portal" after defeating Boss 3. * **Loss:** Player HP <= 0 in any room (Game Over). * **Fail State:** If Gold < 0, Player must wait for shop reset. * **Game End:** After Boss 3 death, display "Floor Cleared" and return to Main Menu. --- ## 6. User Interface & Art Direction (Requirements) ### 6.1. HUD Layout 1. **Top Bar:** Gold Count | Current Floor # | Boss Health. 2. **Left Side:** Player Card Hand (Max 5 cards). 3. **Center:** Enemy Sprite + HP Bar + Attack Range Indicator. 4. **Right Side:** Energy Meter + Gold Shop Button. 5. **Bottom:** Controls (Play/End Turn). ### 6.2. Color Coding * **Attacks:** Red/Orange glow. * **Defense:** Blue/Silver glow. * **Others:** Purple/Green glow. * **Burn/DoT:** Dark Yellow/Black. * **Void/Curse:** Dark Purple/Blue. ### 6.3. Asset List Requirements 1. **Hero Sprite:** 3 Static frames per Class (Idle, Attack, Hit). 2. **Enemy Sprite:** 2 Static frames per Enemy (Idle, Hurt). 3. **Card Backgrounds:** Dark Parchment texture (Black/Gold). 4. **UI Buttons:** Etched Metal texture (Dark Grey). --- ## 7. Implementation Checklist (Developer Use) * [ ] Implement Energy System (Regen/Spent logic). * [ ] Implement Card Resolution Order (Player Action -> Enemy Action). * [ ] Implement Gold Shop System (Cost Scaling). * [ ] Implement Status Effects (Burn/Poison/Stun). * [ ] Implement "Block/Defense" Math (Incoming - Defense). * [ ] Load 36 Cards into Starter Deck (12 per class). * [ ] Generate 20 Random Enemies based on Floor HP Scale. * [ ] Test Boss Logic (Boss 1: Tank, Boss 2: Burst, Boss 3: Regen). * [ ] Verify Endgame Win Condition.
Inspect original source 61,023 bytes · SHA-256 bad0770c7c9d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shadows of Aethelgard — Prototype</title>
<style>
:root{--gold:#c9a24a;--ink:#e8dcc0;--red:#ff5a3c;--blue:#7fb2ff;--purple:#b07bff;--green:#7ddc7a;--burn:#e0b400}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#050308;color:var(--ink);font-family:Georgia,"Times New Roman",serif;overflow:hidden;user-select:none}
body::before{content:"";position:fixed;inset:0;background:radial-gradient(ellipse at 50% 15%,rgba(90,30,120,.28),transparent 60%),radial-gradient(ellipse at 50% 120%,rgba(120,10,20,.28),transparent 55%);pointer-events:none}
.hidden{display:none!important}
.screen{position:fixed;inset:0}
h1,h2,h3{font-family:"Cinzel",Georgia,serif;font-weight:700;letter-spacing:.06em;margin:0}
h3{font-size:13px;color:var(--gold);text-transform:uppercase;margin-bottom:6px}
button{font-family:"Cinzel",Georgia,serif;letter-spacing:.08em;color:#e8dcc0;border:1px solid #5b5b63;border-radius:6px;padding:9px 16px;cursor:pointer;text-transform:uppercase;font-size:12px;
background:repeating-linear-gradient(90deg,rgba(255,255,255,.035) 0 1px,transparent 1px 3px),linear-gradient(180deg,#4a4d55,#26282d 55%,#15161a);
box-shadow:inset 0 1px 0 rgba(255,255,255,.18),inset 0 -2px 3px rgba(0,0,0,.6),0 3px 8px rgba(0,0,0,.6)}
button:hover:not(:disabled){filter:brightness(1.25);border-color:var(--gold)}
button:disabled{opacity:.4;cursor:not-allowed}
button.primary{border-color:#8a6a2a;color:var(--gold);font-size:14px;padding:12px 26px}
/* MENU */
#menu{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:20px;overflow:auto}
.title{font-size:46px;color:var(--gold);text-shadow:0 0 18px rgba(201,162,74,.35),0 2px 0 #000;text-align:center}
.subtitle{font-style:italic;opacity:.75;text-align:center}
.classes{display:flex;gap:22px;flex-wrap:wrap;justify-content:center}
.classcard{width:230px;background:rgba(10,8,14,.88);border:1px solid #3a3040;border-radius:10px;padding:12px;cursor:pointer;text-align:center;transition:.2s}
.classcard:hover,.classcard.sel{border-color:var(--gold);box-shadow:0 0 22px rgba(201,162,74,.3);transform:translateY(-4px)}
.classcard .sprite{width:130px;height:160px;margin:0 auto 10px}
.classcard .ctitle{font-family:Cinzel,Georgia,serif;font-size:17px;color:var(--gold);margin-top:4px}
.classcard .theme{font-style:italic;font-size:12px;opacity:.7}
.classcard .stats{font-size:12px;margin:6px 0;opacity:.9}
.classcard .cdesc{font-size:12px;opacity:.8;line-height:1.35}
.hint{font-size:12px;opacity:.55;text-align:center}
/* GAME LAYOUT */
#game{display:grid;grid-template-rows:auto 1fr auto}
#topbar{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:8px 18px;background:linear-gradient(#0e0b12,#060408);border-bottom:1px solid #3d2f1c}
.stat{font-size:17px;color:var(--gold);font-family:Cinzel,Georgia,serif;letter-spacing:.05em}
.stat span{color:#fff}
#bossWrap{display:flex;align-items:center;gap:10px;font-family:Cinzel,Georgia,serif;font-size:12px;color:#c79bff;width:380px}
#bossWrap .bar{flex:1}
#stage{display:grid;grid-template-columns:250px 1fr 240px;min-height:0}
#heroArea{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:8px 10px 14px;position:relative}
#heroSprite{width:150px;height:190px;position:relative}
#heroSprite svg,.esprite svg{width:100%;height:100%;overflow:visible}
#heroArea .name,.enemy .name{font-family:Cinzel,Georgia,serif;font-size:15px;color:var(--gold);margin-top:6px}
#heroStats{font-size:12px;margin-top:6px;text-align:center;min-height:36px;line-height:1.5}
#heroStats b{color:#fff}
#enemyArea{display:flex;align-items:flex-end;justify-content:center;gap:26px;padding:10px;position:relative;flex-wrap:wrap}
.enemy{width:210px;text-align:center;cursor:pointer;padding:6px;border-radius:10px;border:1px solid transparent;transition:.2s;position:relative}
.enemy.target{border-color:rgba(255,90,60,.6);background:rgba(255,60,40,.06)}
.enemy.dead{opacity:.25;filter:grayscale(1);pointer-events:none}
.enemy.boss .name{color:#c79bff;font-size:17px}
.esprite{width:170px;height:180px;margin:0 auto;position:relative}
.enemy.boss .esprite{width:230px;height:240px}
.intent{font-size:12px;color:#ffb199;min-height:18px}
.statuses{font-size:11px;min-height:16px;color:var(--burn)}
#side{padding:12px;border-left:1px solid #2e2433;background:rgba(8,6,10,.75);display:flex;flex-direction:column;gap:12px;min-height:0}
.orbs{display:flex;gap:8px;flex-wrap:wrap}
.orb{width:24px;height:24px;border-radius:50%;border:2px solid #3a2f1c;background:#120e08;box-shadow:inset 0 2px 4px #000}
.orb.on{background:radial-gradient(circle at 35% 30%,#fff3c4,#e0a22a 45%,#7a4a06);border-color:#f0c35a;box-shadow:0 0 10px rgba(240,190,80,.6)}
#piles{display:flex;gap:10px;font-size:12px}
.pile{flex:1;text-align:center}
.back{width:56px;height:79px;border-radius:6px;background-size:100% 100%;box-shadow:0 2px 8px #000}
.pile .back{width:72px;height:101px;margin:0 auto 4px}
#log{flex:1;min-height:0;overflow:auto;font-size:12px;line-height:1.4;background:rgba(0,0,0,.45);border:1px solid #2e2433;padding:6px 8px;border-radius:6px}
#log p{margin:0 0 4px;border-bottom:1px dotted #2a2230;padding-bottom:3px}
#side #shopBtn{width:100%}
#handArea{display:flex;align-items:flex-end;justify-content:center;gap:14px;padding:12px 14px 10px;background:linear-gradient(transparent,rgba(0,0,0,.9));border-top:1px solid #2e2433;position:relative;min-height:250px}
#hand{display:flex;gap:14px;align-items:flex-end}
#endWrap{position:absolute;right:20px;bottom:18px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
#endWrap button{padding:12px 22px;font-size:14px;color:var(--gold);border-color:#8a6a2a}
#turnLbl{font-size:12px;opacity:.75}
#msg{position:absolute;left:50%;top:-34px;transform:translateX(-50%);font-size:13px;color:#ffb199;background:rgba(0,0,0,.7);padding:4px 12px;border-radius:4px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .3s}
#msg.show{opacity:1}
/* BARS */
.bar{height:16px;background:#120e16;border:1px solid #000;border-radius:3px;overflow:hidden;position:relative;width:190px;box-shadow:inset 0 2px 4px #000}
.bar i{display:block;height:100%;transition:width .35s}
.bar b{position:absolute;inset:0;text-align:center;font-size:11px;line-height:16px;font-weight:normal;text-shadow:0 1px 2px #000;color:#fff}
.bar.hp i{background:linear-gradient(#e84a3a,#8e1511)}
.bar.boss i{background:linear-gradient(#b16cff,#4b1a80)}
.bar.boss{width:230px}
.bar.enemyhp{width:170px;margin:6px auto 0}
.bar.enemyhp i{background:linear-gradient(#e0583c,#7a1a10)}
/* CARDS */
.card{position:relative;width:150px;aspect-ratio:240/340;container-type:inline-size;border-radius:9px;background-size:100% 100%;background-repeat:no-repeat;cursor:pointer;transition:transform .18s,filter .18s;flex:0 0 auto;color:#f1e5c6}
.card:hover{transform:translateY(-14px) scale(1.04);z-index:3}
.card.unplayable{filter:grayscale(.55) brightness(.6)}
.card.t-atk{filter:drop-shadow(0 0 8px rgba(255,90,40,.7))}
.card.t-def{filter:drop-shadow(0 0 8px rgba(120,170,255,.7))}
.card.t-oth{filter:drop-shadow(0 0 8px rgba(170,110,255,.7))}
.card.unplayable.t-atk,.card.unplayable.t-def,.card.unplayable.t-oth{filter:grayscale(.6) brightness(.6)}
.card .cname{position:absolute;top:5.2%;left:12%;right:12%;height:7%;text-align:center;font-size:7.4cqw;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 2px #000;line-height:1}
.card .ccost{position:absolute;top:2.2%;left:3%;width:15cqw;height:15cqw;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff7d6,#d8a430 50%,#5c3a08);color:#1a1000;font-weight:bold;font-size:8.5cqw;text-align:center;line-height:15cqw;box-shadow:0 0 0 1px #000,0 2px 4px #000}
.card .ctype{position:absolute;top:60.5%;left:8%;right:8%;text-align:center;font-size:5.6cqw;letter-spacing:.12em;text-transform:uppercase;font-weight:bold;text-shadow:0 1px 2px #000}
.card .cdesc{position:absolute;top:67.5%;left:9%;right:9%;height:21%;font-size:6.2cqw;line-height:1.28;text-align:center;overflow:hidden}
.card .crar{position:absolute;bottom:2.5%;left:0;right:0;text-align:center;font-size:5cqw;letter-spacing:.2em;text-transform:uppercase;opacity:.6}
.t-atk .ctype{color:#ff8a5c}
.t-def .ctype{color:#a9c8ff}
.t-oth .ctype{color:#c9a4ff}
.card .cdesc b{color:#fff3d0}
.bigcard{width:210px}
/* FLOATING */
#popLayer{position:fixed;inset:0;pointer-events:none;z-index:40}
.pop{position:absolute;font-weight:bold;font-size:24px;animation:popup 1s forwards;text-shadow:0 2px 3px #000;transform:translate(-50%,-50%);white-space:nowrap}
.pop.dmg{color:#ff5a3c}.pop.heal{color:#7ddc7a}.pop.def{color:#8fbcff}.pop.gold{color:#f2c94c}.pop.info{color:#d9c8ff;font-size:16px}
@keyframes popup{0%{opacity:0;margin-top:10px}15%{opacity:1}100%{opacity:0;margin-top:-50px}}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-7px)}75%{transform:translateX(7px)}}
.shake{animation:shake .3s}
@keyframes lunge{0%,100%{transform:translateX(0)}50%{transform:translateX(40px)}}
.lunge{animation:lunge .35s}
@keyframes glowpulse{0%,100%{filter:drop-shadow(0 0 6px rgba(160,100,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(160,100,255,.9))}}
.boss .esprite{animation:glowpulse 2.4s infinite}
/* MODAL */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.78);display:flex;align-items:center;justify-content:center;z-index:50}
.panel{background:linear-gradient(#17131b,#09070c);border:2px solid #6b5230;border-radius:12px;padding:22px;max-width:960px;max-height:92vh;overflow:auto;box-shadow:0 0 50px rgba(0,0,0,.9);text-align:center;position:relative}
.panel h2{color:var(--gold);font-size:26px;margin-bottom:10px}
.panel p{font-size:14px;line-height:1.5;opacity:.9}
.shoprow{display:flex;gap:18px;flex-wrap:wrap;justify-content:center;margin:14px 0}
.slot{display:flex;flex-direction:column;align-items:center;gap:8px}
.price{font-family:Cinzel,Georgia,serif;color:var(--gold);font-size:14px}
.price.maxed{color:#777}
.deckgrid{display:flex;flex-wrap:wrap;gap:12px;justify-content:center}
.deckgrid .card{width:120px}
.badge{font-size:11px;position:absolute;bottom:-18px;left:0;right:0;text-align:center;color:#ccc}
.actions{display:flex;gap:12px;justify-content:center;margin-top:16px;flex-wrap:wrap}
.muted{opacity:.6;font-size:12px}
</style>
</head>
<body>
<div id="menu" class="screen">
<h1 class="title">Shadows of Aethelgard</h1>
<div class="subtitle">The Void consumes the land. Descend three floors. Slay the Void Lords.</div>
<div class="classes" id="classPick"></div>
<button id="startBtn" class="primary">Begin Descent</button>
<div class="hint">Click a class to choose it · Click a card to play it · Click an enemy to target it</div>
</div>
<div id="game" class="screen hidden">
<header id="topbar">
<div class="stat">Gold <span id="gold">0</span></div>
<div class="stat" id="floorLbl">Floor 1</div>
<div id="bossWrap"><span>Floor Boss</span><div class="bar boss"><i id="bossFill"></i><b id="bossTxt">—</b></div></div>
<div style="display:flex;gap:8px"><button id="deckBtn">Deck</button><button id="menuBtn">Abandon</button></div>
</header>
<main id="stage">
<section id="heroArea">
<div id="heroSprite"></div>
<div class="name" id="heroName"></div>
<div class="bar hp" style="width:170px"><i id="hpFill"></i><b id="hpTxt"></b></div>
<div id="heroStats"></div>
</section>
<section id="enemyArea"></section>
<aside id="side">
<div><h3>Energy</h3><div class="orbs" id="orbs"></div></div>
<button id="shopBtn">Gold Shop</button>
<div><h3>Piles</h3><div id="piles"></div></div>
<div style="display:flex;flex-direction:column;min-height:0;flex:1"><h3>Chronicle</h3><div id="log"></div></div>
</aside>
</main>
<footer id="handArea">
<div id="msg"></div>
<div id="hand"></div>
<div id="endWrap"><button id="endBtn">End Turn</button><div id="turnLbl"></div></div>
</footer>
</div>
<div id="popLayer"></div>
<div id="modal" class="modal hidden"><div class="panel" id="modalPanel"></div></div>
<script>
/* ================= ART: SVG GENERATORS ================= */
const SVGNS = 'http://www.w3.org/2000/svg';
function uri(s){ return 'url("data:image/svg+xml;utf8,' + encodeURIComponent(s).replace(/'/g,'%27') + '")'; }
const FRAME_CFG = {
BASTION:{ name:'Steel and Gold', c1:'#3b4048', c2:'#15171c', trim:'#c9a24a', glow:'#9fb4c8', ink:'#d7dce3' },
WITHER: { name:'Rot and Ember', c1:'#1e2a16', c2:'#070a05', trim:'#d6b21c', glow:'#8fd13a', ink:'#e9f0c0' },
SHROUD: { name:'Void and Shadow', c1:'#241540', c2:'#07040f', trim:'#9b6cff', glow:'#b48cff', ink:'#e6dcff' }
};
// Unique SVG card frame per deck type. Art window is left as a placeholder (dashed box).
function frameSVG(cls){
const c = FRAME_CFG[cls]; let orn = '';
if(cls==='BASTION'){
// battlements + rivets
let merlons=''; for(let x=8;x<232;x+=24) merlons+=`<rect x="${x}" y="-2" width="12" height="9" fill="${c.trim}"/>`;
orn = `<path d="M8 6 H232" stroke="${c.trim}" stroke-width="3"/>${merlons}
${[[12,12],[228,12],[12,328],[228,328]].map(([x,y])=>`<circle cx="${x}" cy="${y}" r="4" fill="${c.trim}" stroke="#000"/>`).join('')}
<path d="M18 330 L120 336 L222 330" fill="none" stroke="${c.trim}" stroke-width="2" opacity=".8"/>`;
} else if(cls==='WITHER'){
// thorny vines curling down the sides + ember flecks
orn = `<path d="M10 40 C -2 110, 22 150, 8 210 S 2 290, 14 330" fill="none" stroke="#4d7a1e" stroke-width="3"/>
<path d="M230 40 C 242 110, 218 150, 232 210 S 238 290, 226 330" fill="none" stroke="#4d7a1e" stroke-width="3"/>
${[[9,70],[12,130],[8,200],[14,270],[231,80],[228,150],[233,230],[226,300]].map(([x,y],i)=>`<path d="M${x} ${y} l${i%2?7:-7} -6 l${i%2?3:-3} 9z" fill="#7d3a10"/>`).join('')}
${[[20,320],[120,334],[220,320],[60,6],[180,6]].map(([x,y])=>`<circle cx="${x}" cy="${y}" r="2.5" fill="#ffb12a"/>`).join('')}
<path d="M22 336 L40 326 L58 338 L78 326 L98 338 L120 326 L142 338 L162 326 L182 338 L202 326 L218 336" fill="none" stroke="#7d3a10" stroke-width="2"/>`;
} else {
// torn/ragged edges + star & eye emblem
orn = `<path d="M4 4 L20 1 L30 6 L44 0 L60 5 L80 1 L100 6 L124 0 L144 5 L166 1 L186 6 L206 0 L222 5 L236 2 L236 336 L222 332 L204 340 L184 333 L162 340 L140 333 L118 340 L96 333 L74 340 L52 333 L30 340 L12 333 L4 336 Z" fill="none" stroke="${c.trim}" stroke-width="1.2" opacity=".5"/>
<g transform="translate(120 4)"><path d="M-14 0 Q0 -10 14 0 Q0 10 -14 0Z" fill="none" stroke="${c.trim}" stroke-width="1.5"/><circle r="3.5" fill="${c.glow}"/></g>
${[[24,330],[216,330]].map(([x,y])=>`<path d="M${x} ${y-6} l4 6 -4 6 -4 -6z" fill="${c.trim}"/>`).join('')}`;
}
return `<svg xmlns="${SVGNS}" viewBox="0 0 240 340" width="240" height="340">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c.c1}"/><stop offset="1" stop-color="${c.c2}"/></linearGradient>
<radialGradient id="sheen" cx=".5" cy=".3" r=".8"><stop offset="0" stop-color="${c.glow}" stop-opacity=".25"/><stop offset="1" stop-color="${c.glow}" stop-opacity="0"/></radialGradient>
<pattern id="grain" width="6" height="6" patternUnits="userSpaceOnUse"><path d="M0 3 H6" stroke="#fff" stroke-opacity=".035"/></pattern>
</defs>
<rect x="2" y="2" width="236" height="336" rx="14" fill="url(#bg)" stroke="${c.trim}" stroke-width="4"/>
<rect x="2" y="2" width="236" height="336" rx="14" fill="url(#sheen)"/>
<rect x="2" y="2" width="236" height="336" rx="14" fill="url(#grain)"/>
<rect x="9" y="9" width="222" height="322" rx="10" fill="none" stroke="${c.trim}" stroke-width="1" opacity=".7"/>
${orn}
<rect x="40" y="14" width="160" height="30" rx="5" fill="#0a0808" stroke="${c.trim}" stroke-width="1.5"/>
<rect x="18" y="50" width="204" height="142" rx="4" fill="#0d0b10" stroke="${c.trim}" stroke-width="2"/>
<rect x="26" y="58" width="188" height="126" rx="2" fill="none" stroke="${c.glow}" stroke-dasharray="6 5" stroke-opacity=".55"/>
<g fill="${c.glow}" opacity=".5" font-family="Georgia,serif" text-anchor="middle">
<text x="120" y="118" font-size="13" letter-spacing="4">CARD ART</text>
<text x="120" y="136" font-size="9" letter-spacing="2">PLACEHOLDER · 188×126</text>
</g>
<rect x="18" y="196" width="204" height="22" rx="3" fill="#0a0808" stroke="${c.trim}" stroke-opacity=".6"/>
<rect x="16" y="222" width="208" height="84" rx="4" fill="#0b0a09" fill-opacity=".92" stroke="${c.trim}" stroke-width="1.2"/>
<path d="M30 318 H210" stroke="${c.trim}" stroke-opacity=".5"/>
<text x="120" y="331" text-anchor="middle" font-family="Georgia,serif" font-size="8" fill="${c.ink}" letter-spacing="3" opacity=".7">${c.name.toUpperCase().replace(/&/g,'&')}</text>
</svg>`;
}
function backSVG(cls){
const c = FRAME_CFG[cls]; let inner='';
if(cls==='BASTION'){
for(let y=20;y<330;y+=34) for(let x=14;x<240;x+=52){ inner+=`<rect x="${x+((y/34)%2)*26}" y="${y}" width="48" height="30" rx="3" fill="#2a2e35" stroke="#0a0b0e" stroke-width="2"/>`; }
inner += `<path d="M120 60 L186 88 L180 178 Q170 236 120 270 Q70 236 60 178 L54 88Z" fill="#20242b" stroke="#c9a24a" stroke-width="5"/>
<path d="M120 80 L168 100 L164 176 Q156 220 120 248 Q84 220 76 176 L72 100Z" fill="none" stroke="#c9a24a" stroke-width="1.5" opacity=".6"/>
<path d="M120 96 V236 M86 150 H154" stroke="#c9a24a" stroke-width="7"/>`;
} else if(cls==='WITHER'){
inner += `<radialGradient id="ember" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#ffd23a" stop-opacity=".9"/><stop offset=".5" stop-color="#7a3a0a" stop-opacity=".5"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>
<circle cx="120" cy="170" r="130" fill="url(#ember)"/>
${[0,1,2,3,4,5,6,7,8,9,10,11].map(i=>`<path d="M120 170 Q${120+Math.cos(i*Math.PI/6)*60} ${170+Math.sin(i*Math.PI/6)*60} ${120+Math.cos(i*Math.PI/6+.4)*110} ${170+Math.sin(i*Math.PI/6+.4)*110}" fill="none" stroke="#6fbf2a" stroke-width="2.5" opacity=".65"/>`).join('')}
<path d="M120 96 C92 96 80 124 86 150 C90 170 104 176 108 196 L132 196 C136 176 150 170 154 150 C160 124 148 96 120 96Z" fill="#0b0f08" stroke="#d6b21c" stroke-width="4"/>
<circle cx="105" cy="140" r="8" fill="#ffb12a"/><circle cx="135" cy="140" r="8" fill="#ffb12a"/>
<path d="M108 196 V226 M120 196 V232 M132 196 V226" stroke="#d6b21c" stroke-width="4"/>`;
} else {
for(let i=0;i<60;i++){ const x=(i*73)%240, y=(i*137)%340; inner+=`<circle cx="${x}" cy="${y}" r="${(i%3)*0.6+0.6}" fill="#e6dcff" opacity="${.3+(i%5)*.12}"/>`; }
inner += `<radialGradient id="vg" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#7a4bd8" stop-opacity=".9"/><stop offset="1" stop-color="#07040f" stop-opacity="0"/></radialGradient>
<circle cx="120" cy="170" r="110" fill="url(#vg)"/>
<path d="M40 170 Q120 90 200 170 Q120 250 40 170Z" fill="#0c0718" stroke="#b48cff" stroke-width="4"/>
<circle cx="120" cy="170" r="30" fill="#4a1d8f" stroke="#d9c6ff" stroke-width="3"/>
<circle cx="120" cy="170" r="11" fill="#000"/><circle cx="128" cy="164" r="3" fill="#fff"/>
<path d="M120 40 L124 58 L142 62 L124 66 L120 84 L116 66 L98 62 L116 58Z" fill="#d9c6ff" opacity=".8"/>`;
}
return `<svg xmlns="${SVGNS}" viewBox="0 0 240 340" width="240" height="340">
<defs><linearGradient id="bk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c.c1}"/><stop offset="1" stop-color="${c.c2}"/></linearGradient></defs>
<rect x="2" y="2" width="236" height="336" rx="14" fill="url(#bk)" stroke="${c.trim}" stroke-width="5"/>
<rect x="12" y="12" width="216" height="316" rx="10" fill="none" stroke="${c.trim}" stroke-width="1.5" stroke-dasharray="2 4"/>
<g>${inner}</g>
</svg>`;
}
/* ---------- Hero sprites: 3 static frames per class (idle, attack, hit) ---------- */
const HERO_PAL = {
BASTION:{ body:'#5b6370', dark:'#262a31', trim:'#c9a24a', accent:'#9fb4c8', cape:'#5a1b1b', eye:'#ffcc66' },
WITHER: { body:'#24331a', dark:'#0d130a', trim:'#d6b21c', accent:'#8fd13a', cape:'#101a0b', eye:'#ffb12a' },
SHROUD: { body:'#2c1d4a', dark:'#120a22', trim:'#9b6cff', accent:'#b48cff', cape:'#07040f', eye:'#e6dcff' }
};
function heroSVG(cls, frame){
const p = HERO_PAL[cls];
const hit = frame==='hit', atk = frame==='attack';
const tilt = hit ? -9 : 0;
const lean = atk ? 4 : 0;
// weapon / arm per class
let weapon='', arm='';
if(cls==='BASTION'){
arm = atk ? `<path d="M96 70 L140 62 L146 72 L100 84Z" fill="${p.body}"/>`
: `<path d="M96 70 L112 110 L104 116 L90 80Z" fill="${p.body}"/>`;
weapon = atk ? `<path d="M140 60 L178 56 L180 62 L142 70Z" fill="#cfd6df"/><rect x="132" y="56" width="12" height="18" fill="${p.trim}"/>`
: `<path d="M104 112 L108 178 L112 178 L110 112Z" fill="#cfd6df"/><rect x="100" y="104" width="16" height="10" fill="${p.trim}"/>`;
// tower shield (left)
arm += `<path d="M24 76 L60 66 L62 128 Q58 160 44 176 Q28 160 24 128Z" fill="#6b7480" stroke="${p.trim}" stroke-width="4"/>
<path d="M44 84 V166 M30 110 H58" stroke="${p.trim}" stroke-width="5"/>`;
} else if(cls==='WITHER'){
weapon = atk
? `<line x1="118" y1="98" x2="170" y2="72" stroke="#3b2a18" stroke-width="5"/><circle cx="174" cy="68" r="12" fill="#ffb12a"/><circle cx="174" cy="68" r="22" fill="#ff6a00" opacity=".35"/>`
: `<line x1="118" y1="98" x2="118" y2="186" stroke="#3b2a18" stroke-width="5"/><circle cx="118" cy="92" r="9" fill="#ffb12a"/><circle cx="118" cy="92" r="16" fill="#ff6a00" opacity=".3"/>`;
arm = atk ? `<path d="M90 92 L120 82 L124 92 L94 104Z" fill="${p.dark}"/>` : `<path d="M90 92 L104 128 L96 130 L84 96Z" fill="${p.dark}"/>`;
} else {
weapon = atk
? `<path d="M124 56 L176 44 L178 50 L128 66Z" fill="#d9e0ea"/><path d="M96 70 Q140 40 170 76" fill="none" stroke="${p.accent}" stroke-width="3" opacity=".7"/>`
: `<path d="M104 118 L112 150 L106 152 L98 120Z" fill="#d9e0ea"/>`;
arm = atk ? `<path d="M92 80 L124 62 L128 70 L96 90Z" fill="${p.dark}"/>` : `<path d="M92 84 L104 120 L96 122 L86 90Z" fill="${p.dark}"/>`;
}
const hood = cls==='WITHER' ? `<path d="M60 40 Q80 4 100 40 L104 60 L56 60Z" fill="${p.dark}"/>` : '';
const helm = cls==='BASTION'
? `<path d="M64 36 L90 26 L116 36 L118 62 L62 62Z" fill="#7c8590" stroke="${p.trim}" stroke-width="2"/><path d="M68 50 H112" stroke="#111" stroke-width="5"/><path d="M84 26 V14 M90 26 L94 12" stroke="${p.trim}" stroke-width="3"/>`
: cls==='WITHER'
? `<path d="M58 60 Q60 22 90 20 Q120 22 122 60 L120 62 Q90 70 60 62Z" fill="${p.body}"/><path d="M70 50 H110" stroke="${p.eye}" stroke-width="3" opacity=".9"/>`
: `<path d="M66 40 Q90 6 114 40 L110 66 Q90 74 70 66Z" fill="${p.dark}"/><path d="M74 50 Q90 56 106 50" stroke="${p.eye}" stroke-width="2.5"/>`;
const faceEye = hit ? '#ff3b3b' : p.eye;
return `<svg xmlns="${SVGNS}" viewBox="0 0 150 190" width="150" height="190">
<defs><radialGradient id="hglow" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="${p.accent}" stop-opacity=".35"/><stop offset="1" stop-color="${p.accent}" stop-opacity="0"/></radialGradient></defs>
<ellipse cx="75" cy="182" rx="56" ry="8" fill="#000" opacity=".6"/>
<ellipse cx="75" cy="110" rx="80" ry="80" fill="url(#hglow)"/>
<g transform="rotate(${tilt} 75 180) translate(${lean} 0)">
<path d="M70 80 L40 185 L62 186 L72 120 Z" fill="${p.cape}" opacity=".9"/>
<path d="M62 62 L94 58 L112 110 L118 176 L76 182 L62 176 L56 110Z" fill="${p.body}" stroke="${p.dark}" stroke-width="2"/>
<path d="M70 70 L86 68 L92 120 L72 122Z" fill="${p.accent}" opacity=".15"/>
<path d="M62 176 L76 182 L88 176 L86 186 L64 186Z" fill="${p.dark}"/>
${hood}${helm}
<path d="M64 62 L92 60 L100 62 L100 56 L62 56Z" fill="${p.dark}" opacity=".6"/>
${cls!=='BASTION' ? `<circle cx="82" cy="50" r="2.4" fill="${faceEye}"/><circle cx="98" cy="50" r="2.4" fill="${faceEye}"/>` : `<rect x="80" y="46" width="22" height="3" fill="${faceEye}"/>`}
${arm}${weapon}
${cls==='BASTION' ? `<path d="M72 120 L92 122 L90 150 L76 150Z" fill="${p.dark}" stroke="${p.trim}" stroke-opacity=".5"/>` : ''}
</g>
${hit ? `<rect x="0" y="0" width="150" height="190" fill="#ff1a1a" opacity=".35"/>` : ''}
${atk ? `<circle cx="150" cy="60" r="0" fill="none"/>` : ''}
</svg>`;
}
/* ---------- Enemy sprites: 2 static frames (idle, hurt) ---------- */
const ENEMY_STYLE = {
goblin:{ kind:'man', body:'#4f6b2a', dark:'#23300f', eye:'#ffe066', acc:'#a0c040' },
rat: { kind:'beast', body:'#5a5256', dark:'#2a2528', eye:'#ff5050', acc:'#c7a8a0' },
bat: { kind:'bat', body:'#3a1420', dark:'#170509', eye:'#ff4040', acc:'#a03040' },
vamp: { kind:'man', body:'#5c0f1c', dark:'#1d0508', eye:'#ff3344', acc:'#d9c4c0', cape:true },
ogre: { kind:'brute', body:'#6b6a2e', dark:'#2d2c12', eye:'#ffd060', acc:'#c2b060' },
hound: { kind:'beast', body:'#2b2b38', dark:'#101018', eye:'#ff7a33', acc:'#6a6a88' },
void: { kind:'man', body:'#2a1248', dark:'#0b0418', eye:'#c48cff', acc:'#7f4fd6', glow:true },
cultist:{ kind:'man', body:'#3d2a1a', dark:'#140c06', eye:'#ff9a3a', acc:'#7a5a2a', hood:true },
boss1: { kind:'boss', body:'#6e7480', dark:'#2a2d34', eye:'#ffe27a', acc:'#c9a24a', crown:false },
boss2: { kind:'boss', body:'#4f6b1f', dark:'#1a2309', eye:'#e0ff4a', acc:'#b06bff', crown:true },
boss3: { kind:'boss', body:'#2a0f4f', dark:'#07021a', eye:'#e6b8ff', acc:'#9b5cff', crown:true, glow:true }
};
function enemySVG(type, frame){
const s = ENEMY_STYLE[type]; const hurt = frame==='hurt';
const shift = hurt ? 'translate(-8 2)' : '';
const eyeC = hurt ? '#fff' : s.eye;
let body='';
if(s.kind==='beast'){
body = `<ellipse cx="85" cy="118" rx="52" ry="34" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
<path d="M36 110 Q10 100 18 84 Q34 92 42 100Z" fill="${s.dark}"/>
<path d="M124 110 L150 96 L146 116Z" fill="${s.body}"/>
<circle cx="54" cy="118" r="0"/>
<path d="M96 84 L104 60 L112 84Z" fill="${s.dark}"/><path d="M80 88 L70 62 L86 82Z" fill="${s.dark}"/>
<circle cx="104" cy="104" r="4" fill="${eyeC}"/><circle cx="118" cy="98" r="3" fill="${eyeC}"/>
<path d="M40 124 L24 132 M46 132 L30 144 M130 130 L140 146 M120 136 L124 152" stroke="${s.dark}" stroke-width="4" stroke-linecap="round"/>
${s.body && 'rat' === 'rat' ? `<path d="M140 118 Q160 130 150 150" fill="none" stroke="#b08f88" stroke-width="3"/>`:''}`;
} else if(s.kind==='bat'){
body = `<path d="M85 90 L10 60 L30 100 L4 120 L40 118 L60 150 L85 128 L110 150 L130 118 L166 120 L140 100 L160 60 Z" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
<path d="M85 92 L70 80 L78 104 L85 100 L92 104 L100 80Z" fill="${s.dark}"/>
<circle cx="78" cy="98" r="4" fill="${eyeC}"/><circle cx="94" cy="98" r="4" fill="${eyeC}"/>
<path d="M80 110 L84 118 L88 110" fill="#fff" opacity=".8"/>`;
} else if(s.kind==='brute'){
body = `<ellipse cx="85" cy="112" rx="58" ry="60" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
<ellipse cx="85" cy="62" rx="30" ry="26" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
<path d="M62 44 L56 26 L70 40Z M108 44 L114 26 L100 40Z" fill="${s.acc}"/>
<path d="M48 120 Q36 150 44 170 L58 168 Q54 146 62 126Z" fill="${s.dark}"/>
<path d="M122 120 Q134 150 126 170 L112 168 Q116 146 108 126Z" fill="${s.dark}"/>
<path d="M70 66 H100" stroke="${s.dark}" stroke-width="4"/>
<path d="M68 60 V72 M102 60 V72" stroke="${s.dark}" stroke-width="3"/>
<circle cx="75" cy="60" r="3.5" fill="${eyeC}"/><circle cx="95" cy="60" r="3.5" fill="${eyeC}"/>
<path d="M62 100 H108 L104 118 H66Z" fill="${s.dark}" opacity=".5"/>`;
} else if(s.kind==='boss'){
body = `<path d="M85 22 L120 30 L132 96 L140 170 L30 170 L38 96 L50 30Z" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
<path d="M58 60 L30 40 L24 110 L44 120Z M112 60 L140 40 L146 110 L126 120Z" fill="${s.dark}" stroke="${s.acc}" stroke-width="2"/>
<path d="M60 80 L110 80 L104 120 L66 120Z" fill="${s.dark}" opacity=".7"/>
<path d="M68 96 L84 110 L100 96" fill="none" stroke="${s.acc}" stroke-width="3"/>
<ellipse cx="72" cy="66" rx="7" ry="5" fill="${eyeC}"/><ellipse cx="98" cy="66" rx="7" ry="5" fill="${eyeC}"/>
${s.crown ? `<path d="M60 40 L66 18 L76 34 L85 12 L94 34 L104 18 L110 40Z" fill="${s.acc}" stroke="#000" stroke-width="1.5"/>` : ''}
<path d="M40 170 V182 M130 170 V182" stroke="${s.dark}" stroke-width="6"/>
${s.glow ? `<circle cx="85" cy="110" r="70" fill="${s.acc}" opacity=".15"/>` : ''}`;
} else { // man
const hood = s.hood ? `<path d="M56 60 Q85 6 114 60 L120 110 L50 110Z" fill="${s.dark}"/>` : '';
const cape = s.cape ? `<path d="M60 70 L30 170 L140 170 L110 70Z" fill="${s.dark}" opacity=".95"/>` : '';
body = `${cape}
<path d="M70 70 L100 70 L110 120 L118 170 L54 170 L60 120Z" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
<path d="M72 70 L86 130 L60 150 Z" fill="${s.dark}" opacity=".3"/>
<path d="M52 88 L38 130 L48 134 L62 96Z M118 88 L132 130 L122 134 L108 96Z" fill="${s.body}" stroke="${s.dark}" stroke-width="2"/>
<circle cx="85" cy="48" r="20" fill="${s.body}" stroke="${s.dark}" stroke-width="3"/>
${hood}
<path d="M68 42 Q85 36 102 42" fill="none" stroke="${s.dark}" stroke-width="3" opacity=".5"/>
<circle cx="78" cy="48" r="3.2" fill="${eyeC}"/><circle cx="92" cy="48" r="3.2" fill="${eyeC}"/>
${s.kind && (type==='goblin') ? `<path d="M66 44 L50 36 L66 52Z M104 44 L120 36 L104 52Z" fill="${s.body}" stroke="${s.dark}"/>`:''}
${type==='vamp' ? `<path d="M80 58 L82 66 L84 58 M88 58 L90 66 L92 58" stroke="#fff" stroke-width="1.5" fill="none"/>`:''}
${type==='void' ? `<path d="M85 22 L92 6 L99 26 M68 26 L60 10 L76 22 M104 26 L112 10 L96 22" fill="none" stroke="${s.acc}" stroke-width="3"/><circle cx="85" cy="90" r="46" fill="${s.acc}" opacity=".18"/>`:''}
${type==='cultist' ? `<path d="M74 70 L85 100 L96 70" fill="none" stroke="${s.acc}" stroke-width="3"/>`:''}
<path d="M58 170 L62 182 M112 170 L108 182" stroke="${s.dark}" stroke-width="6"/>`;
}
return `<svg xmlns="${SVGNS}" viewBox="0 0 170 180" width="170" height="180">
<defs><radialGradient id="eg" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="${s.acc}" stop-opacity=".3"/><stop offset="1" stop-color="${s.acc}" stop-opacity="0"/></radialGradient></defs>
<ellipse cx="85" cy="174" rx="60" ry="7" fill="#000" opacity=".6"/>
<ellipse cx="85" cy="100" rx="84" ry="80" fill="url(#eg)"/>
<g transform="${shift}">${body}</g>
${hurt ? `<rect x="0" y="0" width="170" height="180" fill="#ff2020" opacity=".3"/>` : ''}
</svg>`;
}
/* ================= DATA ================= */
const CLASSES = {
BASTION:{ label:'The Bastion', theme:'Immovable Object', hp:35, energy:3, desc:'High defense and block. Slow, durable, area damage.' },
WITHER: { label:'The Wither', theme:'Rot and Burn', hp:25, energy:4, desc:'Burn and poison control. Fragile, but burns everything.' },
SHROUD: { label:'The Shroud', theme:'Shadow and Speed', hp:30, energy:5, desc:'Swift strikes, evasion and assassination. Low defense.' }
};
const MAX_ENERGY = 5, HAND_SIZE = 4, MAX_COPIES = 5, SHOP_CAP = 50;
const ENEMY_TABLE = {
1:{ hp:20, dmg:3, pool:['goblin','rat'] },
2:{ hp:30, dmg:5, pool:['vamp','ogre','hound','bat'] },
3:{ hp:40, dmg:8, pool:['void','cultist'] }
};
const ENEMY_NAMES = { goblin:'Goblin', rat:'Plague Rat', vamp:'Vampire Knight', ogre:'Ogre', hound:'Dire Hound', bat:'Blood Bat', void:'Void Warrior', cultist:'Cultist', boss1:'The Gatekeeper', boss2:'The Corrupted', boss3:'The Void Lord' };
const C = (id,type,cost,atk,def,name,text,tgt=false) => ({ id,type,cost,atk,def,name,text,tgt, cls:{BA:'BASTION',WI:'WITHER',SH:'SHROUD'}[id.slice(0,2)], rar:'Common' });
const CARDS = [
// Bastion
C('BA-A01','atk',2,3,0,'Slam','Deals 3 damage. If you have 30+ HP, draw 1 card.',true),
C('BA-A02','atk',3,4,0,'Earthquake','Deals 4 damage to all enemies.'),
C('BA-A03','atk',1,2,0,'Stone Fist','Deals 2 damage.',true),
C('BA-A04','atk',4,6,0,'Boulder','Deals 6 damage. Costs 4 Energy.',true),
C('BA-D01','def',0,0,4,'Shield Wall','Gain 4 Defense for this turn.'),
C('BA-D02','def',2,0,5,'Iron Skin','Gain 5 Defense permanently. Discard 1 card.'),
C('BA-D03','def',1,0,2,'Stand Firm','Gain 2 Defense. Blocks 1 incoming damage.'),
C('BA-D04','def',2,0,3,'Taunt','Gain 3 Defense. Stun 1 enemy for 1 turn.',true),
C('BA-U01','oth',2,0,0,'Search','Draw 1 card. Discard hand if you hold more than 5.'),
C('BA-U02','oth',3,0,0,'Repair','Heal 6 HP.'),
C('BA-U03','oth',1,0,0,'Rally','Draw 1 card. Gain 1 Energy.'),
C('BA-U04','oth',2,0,0,'Loot','Gain 5 Gold.'),
// Wither
C('WI-A01','atk',2,3,0,'Fireball','Deals 3 damage. Apply 1 Burn to target.',true),
C('WI-A02','atk',3,4,0,'Inferno','Deals 4 damage. Applies 3 Burn.',true),
C('WI-A03','atk',1,2,0,'Ember','Deals 2 damage. Target burns for 3 turns.',true),
C('WI-A04','atk',4,5,0,'Pyre','Deals 5 damage. All enemies take 1 damage next turn.',true),
C('WI-D01','def',2,0,3,'Scorch Shield','Gain 3 Defense. Block 1 Burn damage.'),
C('WI-D02','def',1,0,1,'Heat Resist','Gain 1 Defense. Gain 1 Energy.'),
C('WI-D03','def',2,0,4,'Smoke','Gain 4 Defense. Block 1 Burn damage.'),
C('WI-D04','def',3,0,2,'Endure','Gain 2 Defense. Heal 2 HP.'),
C('WI-U01','oth',2,0,0,'Poison','Apply 1 Poison to target.',true),
C('WI-U02','oth',3,0,0,'Burn','Deal 3 damage + 2 Burn to all enemies.'),
C('WI-U03','oth',1,0,0,'Slow','Draw 1 card. Target cannot act next turn (Stun).',true),
C('WI-U04','oth',2,0,0,'Wither','Heal 5 HP. Reduce enemy Attack by 1.',true),
// Shroud
C('SH-A01','atk',2,4,0,'Backstab','Deals 4 damage. +1 damage if target has no Armor.',true),
C('SH-A02','atk',3,5,0,'Shadow Bolt','Deals 5 damage. Target cannot block.',true),
C('SH-A03','atk',1,3,0,'Silence','Deals 3 damage. Target loses ability to use cards.',true),
C('SH-A04','atk',3,6,0,'Vengeance','Deals 6 damage. Gain 2 Energy.',true),
C('SH-D01','def',3,0,2,'Blur','Gain 2 Defense. Ignore next attack.'),
C('SH-D02','def',2,0,3,'Step','Gain 3 Defense. Gain 1 Energy.'),
C('SH-D03','def',1,0,1,'Evasion','Gain 1 Defense. Skip enemy action.'),
C('SH-D04','def',2,0,4,'Phantom','Gain 4 Defense. Ghost form for 1 turn.'),
C('SH-U01','oth',1,0,0,'Dash','Gain 1 Energy. Skip enemy attack.'),
C('SH-U02','oth',2,0,0,'Teleport','Move to back row. Deal 2 damage.',true),
C('SH-U03','oth',2,0,0,'Hide','Gain 1 Defense. Discard 1 card. Draw 2 cards.'),
C('SH-U04','oth',3,0,0,'Assassinate','Deal 8 damage. Cannot be blocked this turn.',true)
];
const CARD = {}; CARDS.forEach(c => CARD[c.id] = c);
const FRAME_URI = {}, BACK_URI = {};
Object.keys(FRAME_CFG).forEach(k => { FRAME_URI[k] = uri(frameSVG(k)); BACK_URI[k] = uri(backSVG(k)); });
/* ================= STATE ================= */
let G = null, selectedClass = 'BASTION', UIDC = 0, shopFilter = 'ALL';
const $ = id => document.getElementById(id);
const H = () => G.hero;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const shuffle = a => { 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; };
const aliveEnemies = () => G.enemies.filter(e => !e.dead);
function newGame(cls) {
const deck = {};
CARDS.filter(c => c.cls === cls).forEach(c => deck[c.id] = 1);
return {
idx: 0, phase: 'player', over: false, pending: null,
draw: [], discard: [], hand: [], enemies: [], deck,
hero: { cls, hp: CLASSES[cls].hp, maxHp: CLASSES[cls].hp, energy: CLASSES[cls].energy, gold: 0,
def: 0, permDef: 0, flat: 0, ghost: false, skipEnemy: false, ignoreNext: 0, backRow: false }
};
}
function makeEnemy(type, name, hp, dmg, boss) {
return { uid: ++UIDC, type, name, hp, maxHp: hp, dmg, dmgMod: 0,
armor: type === 'boss1' ? 5 : 0, boss, dead: false,
burn: 0, poison: 0, stun: 0, silence: 0, delayed: 0 };
}
function buildEncounter(idx) {
const floor = Math.floor(idx / 4) + 1, pos = idx % 4;
if (pos === 3) return [makeEnemy('boss' + floor, ENEMY_NAMES['boss' + floor], 80, 15, true)];
const T = ENEMY_TABLE[floor];
const n = pos === 0 ? (Math.random() < .5 ? 1 : 2) : pos === 1 ? 2 : (Math.random() < .5 ? 2 : 3);
const out = [];
for (let i = 0; i < n; i++) { const t = T.pool[Math.floor(Math.random() * T.pool.length)]; out.push(makeEnemy(t, ENEMY_NAMES[t], T.hp, T.dmg, false)); }
return out;
}
/* ================= LOG / FX ================= */
function log(msg, cls = '') {
const p = document.createElement('p'); p.textContent = msg; if (cls) p.className = cls;
const L = $('log'); L.prepend(p); while (L.children.length > 80) L.lastChild.remove();
}
function showMsg(t) {
const m = $('msg'); m.textContent = t; m.classList.add('show');
clearTimeout(showMsg.t); showMsg.t = setTimeout(() => m.classList.remove('show'), 1400);
}
const enemyEl = e => document.querySelector(`[data-uid="${e.uid}"]`);
function pop(target, text, cls) {
let el = target === 'hero' ? $('heroSprite') : enemyEl(target);
if (!el) return;
const r = el.getBoundingClientRect();
const d = document.createElement('div');
d.className = 'pop ' + cls; d.textContent = text;
d.style.left = (r.left + r.width / 2 + (Math.random() * 40 - 20)) + 'px';
d.style.top = (r.top + r.height * 0.35) + 'px';
$('popLayer').appendChild(d); setTimeout(() => d.remove(), 1000);
}
function anim(el, cls, ms = 380) {
if (!el) return; el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls);
setTimeout(() => el.classList.remove(cls), ms);
}
let heroTimer = null;
function setHeroFrame(f, ms = 420) {
if (!G) return;
$('heroSprite').innerHTML = heroSVG(H().cls, f);
clearTimeout(heroTimer);
if (f !== 'idle') heroTimer = setTimeout(() => $('heroSprite').innerHTML = heroSVG(H().cls, 'idle'), ms);
}
function setEnemyFrame(e, f) {
const el = enemyEl(e); if (!el) return;
el.querySelector('.esprite').innerHTML = enemySVG(e.type, f);
if (f !== 'idle') setTimeout(() => { if (!e.dead) { const x = enemyEl(e); if (x) x.querySelector('.esprite').innerHTML = enemySVG(e.type, 'idle'); } }, 320);
}
/* ================= DECK / DRAW ================= */
function deckInstances() {
const out = [];
for (const id in G.deck) for (let i = 0; i < G.deck[id]; i++) out.push(id);
return out;
}
function drawCards(n) {
for (let i = 0; i < n; i++) {
if (G.draw.length === 0) {
if (G.discard.length === 0) break;
G.draw = shuffle(G.discard); G.discard = [];
}
G.hand.push(G.draw.pop());
}
}
function drawTo(n) { while (G.hand.length < n) { const b = G.hand.length; drawCards(1); if (G.hand.length === b) break; } }
function discardRandom() {
if (!G.hand.length) return;
const i = Math.floor(Math.random() * G.hand.length);
const id = G.hand.splice(i, 1)[0]; G.discard.push(id);
log(`Discarded ${CARD[id].name}.`, 'info');
}
/* ================= COMBAT ================= */
function dealDamage(e, amt, opts = {}) {
if (!e || e.dead || amt <= 0) return;
let d = amt;
if (!opts.ignoreArmor && e.armor > 0) {
const a = Math.min(e.armor, d); e.armor -= a; d -= a;
if (a > 0) pop(e, `Blocked ${a}`, 'def');
}
if (d > 0) {
e.hp = Math.max(0, e.hp - d);
pop(e, `-${d}`, 'dmg');
setEnemyFrame(e, 'hurt');
anim(enemyEl(e) && enemyEl(e).querySelector('.esprite'), 'shake', 300);
}
if (e.hp <= 0) killEnemy(e);
}
function killEnemy(e) {
e.dead = true; e.hp = 0;
const g = e.boss ? 15 : 5;
H().gold += g;
pop(e, `+${g} Gold`, 'gold');
log(`${e.name} is slain. +${g} Gold.`, 'gold');
}
function heal(n) {
const h = H(), before = h.hp;
h.hp = Math.min(h.maxHp, h.hp + n);
if (h.hp > before) pop('hero', `+${h.hp - before}`, 'heal');
}
function gainDef(n) { H().def += n; pop('hero', `+${n} Def`, 'def'); }
function applyStatus(e, key, n) { e[key] += n; pop(e, `${key} ${n}`, 'burn'); log(`${e.name} gains ${key} ${n}.`, 'burn'); }
function damageHero(raw) {
const h = H();
if (h.ignoreNext > 0) { h.ignoreNext--; pop('hero', 'Blurred', 'def'); log('The attack passes through your shadow.', 'info'); return; }
if (h.ghost) { pop('hero', 'Phantom', 'def'); log('Your ghost form slips past the blow.', 'info'); return; }
let r = raw; if (h.backRow) r -= 2; r = Math.max(0, r);
const block = h.def + h.permDef + h.flat;
const blocked = Math.min(block, r);
const dmg = Math.max(0, r - block);
h.hp = Math.max(0, h.hp - dmg);
if (blocked > 0) pop('hero', `Blocked ${blocked}`, 'def');
if (dmg > 0) pop('hero', `-${dmg}`, 'dmg');
setHeroFrame('hit', 450);
anim($('heroSprite'), 'shake', 300);
log(`Hit for ${dmg}${blocked ? ` (${blocked} blocked)` : ''}.`, dmg ? 'bad' : 'info');
}
function resolveCard(c, t) {
const h = H();
const alive = aliveEnemies();
const each = (fn) => alive.forEach(fn);
switch (c.id) {
case 'BA-A01': dealDamage(t, 3); if (h.hp >= 30) { drawCards(1); log('Slam draws a card.', 'info'); } break;
case 'BA-A02': each(e => dealDamage(e, 4)); break;
case 'BA-A03': dealDamage(t, 2); break;
case 'BA-A04': dealDamage(t, 6); break;
case 'BA-D01': gainDef(4); break;
case 'BA-D02': h.permDef += 5; pop('hero', '+5 Perm Def', 'def'); discardRandom(); break;
case 'BA-D03': gainDef(2); h.flat += 1; break;
case 'BA-D04': gainDef(3); if (t) { t.stun = 1; pop(t, 'Stunned', 'burn'); } break;
case 'BA-U01': drawCards(1); if (h.hp >= 0 && G.hand.length > 5) { G.discard.push(...G.hand); G.hand = []; log('Search: hand discarded (over 5).', 'info'); } break;
case 'BA-U02': heal(6); break;
case 'BA-U03': drawCards(1); h.energy += 1; break;
case 'BA-U04': h.gold += 5; pop('hero', '+5 Gold', 'gold'); break;
case 'WI-A01': dealDamage(t, 3); applyStatus(t, 'burn', 1); break;
case 'WI-A02': dealDamage(t, 4); applyStatus(t, 'burn', 3); break;
case 'WI-A03': dealDamage(t, 2); applyStatus(t, 'burn', 3); break;
case 'WI-A04': dealDamage(t, 5); each(e => e.delayed += 1); log('Pyre will scorch all enemies next turn.', 'burn'); break;
case 'WI-D01': gainDef(3); h.burnBlock = (h.burnBlock || 0) + 1; break;
case 'WI-D02': gainDef(1); h.energy += 1; break;
case 'WI-D03': gainDef(4); h.burnBlock = (h.burnBlock || 0) + 1; break;
case 'WI-D04': gainDef(2); heal(2); break;
case 'WI-U01': applyStatus(t, 'poison', 1); break;
case 'WI-U02': each(e => { dealDamage(e, 3); applyStatus(e, 'burn', 2); }); break;
case 'WI-U03': drawCards(1); if (t) { t.stun = 1; pop(t, 'Stunned', 'burn'); } break;
case 'WI-U04': heal(5); if (t) { t.dmgMod -= 1; pop(t, 'Weakened', 'burn'); } break;
case 'SH-A01': dealDamage(t, 4 + (t.armor === 0 ? 1 : 0)); break;
case 'SH-A02': dealDamage(t, 5, { ignoreArmor: true }); break;
case 'SH-A03': dealDamage(t, 3); if (!t.dead) { t.silence = 1; pop(t, 'Silenced', 'burn'); } break;
case 'SH-A04': dealDamage(t, 6); h.energy += 2; break;
case 'SH-D01': gainDef(2); h.ignoreNext = 1; break;
case 'SH-D02': gainDef(3); h.energy += 1; break;
case 'SH-D03': gainDef(1); h.skipEnemy = true; break;
case 'SH-D04': gainDef(4); h.ghost = true; break;
case 'SH-U01': h.energy += 1; h.skipEnemy = true; pop('hero', '+1 Energy', 'info'); break;
case 'SH-U02': h.backRow = true; dealDamage(t, 2); log('You slip to the back row.', 'info'); break;
case 'SH-U03': gainDef(1); discardRandom(); drawCards(2); break;
case 'SH-U04': dealDamage(t, 8, { ignoreArmor: true }); break;
}
}
function playCard(i, target) {
if (G.phase !== 'player' || G.over) return;
const id = G.hand[i], c = CARD[id];
if (c.cost > H().energy) { showMsg('Not enough energy'); return; }
G.hand.splice(i, 1); G.discard.push(id); H().energy -= c.cost;
if (c.type === 'atk') { setHeroFrame('attack', 420); anim($('heroSprite'), 'lunge', 350); }
log(`You play ${c.name}.`, c.type === 'atk' ? 'bad' : c.type === 'def' ? 'info' : 'gold');
resolveCard(c, target || aliveEnemies()[0]);
G.pending = null;
render();
checkClear();
}
function onCardClick(i) {
if (G.phase !== 'player' || G.over) return;
const id = G.hand[i], c = CARD[id];
if (c.cost > H().energy) { showMsg('Not enough energy'); return; }
if (c.tgt) {
const alive = aliveEnemies();
if (!alive.length) return;
if (alive.length === 1) playCard(i, alive[0]);
else { G.pending = G.pending === i ? null : i; showMsg(G.pending != null ? 'Select a target (Esc to cancel)' : ''); render(); }
} else playCard(i, null);
}
function onEnemyClick(e) {
if (G.pending != null && G.phase === 'player' && !e.dead) playCard(G.pending, e);
}
function checkClear() {
if (G.phase === 'player' && aliveEnemies().length === 0) onCleared();
}
function onCleared() {
if (G.phase === 'between') return;
G.pending = null;
G.phase = 'between';
const floor = Math.floor(G.idx / 4) + 1, boss = G.idx % 4 === 3;
if (G.idx === 11) { render(); setTimeout(victory, 700); return; }
log(boss ? `Floor ${floor} Boss defeated! Descend to Floor ${floor + 1}.` : 'Room cleared. Visit the shop or descend.', 'gold');
showMsg(boss ? 'Floor boss slain' : 'Room cleared');
render();
}
function descend() {
if (G.phase !== 'between') return;
G.idx++; startEncounter();
}
function startEncounter() {
G.enemies = buildEncounter(G.idx);
G.draw = shuffle(deckInstances()); G.discard = []; G.hand = [];
G.phase = 'player'; G.pending = null;
const h = H(); h.def = 0; h.flat = 0; h.ghost = false; h.skipEnemy = false; h.ignoreNext = 0; h.backRow = false;
drawTo(HAND_SIZE);
const floor = Math.floor(G.idx / 4) + 1, pos = G.idx % 4;
log(pos === 3 ? `— Floor ${floor} · Boss: ${ENEMY_NAMES['boss' + floor]} —` : `— Floor ${floor} · Room ${pos + 1}/3 —`, 'gold');
render();
}
function endTurn() {
if (G.phase !== 'player' || G.over) return;
enemyPhase();
}
async function enemyPhase() {
G.phase = 'enemy'; G.pending = null;
G.discard.push(...G.hand); G.hand = [];
render();
await sleep(350);
for (const e of [...G.enemies]) {
if (e.dead || G.over) continue;
if (e.type === 'boss1' && !e.silence) e.armor = e.hp >= 20 ? 5 : 0;
if (e.burn > 0) { e.burn--; log(`${e.name} burns for 1.`, 'burn'); dealDamage(e, 1, { ignoreArmor: true }); }
if (e.poison > 0) { e.poison--; log(`${e.name} takes 1 poison.`, 'burn'); dealDamage(e, 1, { ignoreArmor: true }); }
if (e.delayed > 0) { e.delayed = 0; log(`${e.name} is scorched by Pyre.`, 'burn'); dealDamage(e, 1, { ignoreArmor: true }); }
render();
await sleep(300);
if (e.dead) continue;
if (e.type === 'boss3' && !e.silence) {
const before = e.hp; e.hp = Math.min(e.maxHp, e.hp + 10);
if (e.hp > before) { pop(e, `+${e.hp - before}`, 'heal'); log(`${e.name} regenerates.`, 'info'); }
}
if (e.stun > 0) { e.stun--; log(`${e.name} is stunned and cannot act.`, 'info'); render(); await sleep(350); continue; }
if (H().skipEnemy) { log(`${e.name} finds no opening.`, 'info'); continue; }
let raw = (e.type === 'boss2' && H().energy < 3 && !e.silence) ? 20 : (e.dmg);
raw = Math.max(0, raw + e.dmgMod);
anim(enemyEl(e) && enemyEl(e).querySelector('.esprite'), 'lunge', 300);
await sleep(260);
log(`${e.name} strikes!`, 'bad');
damageHero(raw);
render();
if (H().hp <= 0) { G.over = true; render(); setTimeout(() => gameOver(), 900); return; }
await sleep(380);
}
// cleanup
const h = H(); h.def = 0; h.flat = 0; h.ghost = false; h.skipEnemy = false; h.backRow = false;
G.enemies.forEach(e => { if (e.silence > 0) e.silence--; });
if (aliveEnemies().length === 0) { onCleared(); return; }
h.energy = Math.min(MAX_ENERGY, h.energy + 1);
drawTo(HAND_SIZE);
G.phase = 'player';
log('— Your turn —', 'info');
render();
}
/* ================= SHOP / DECK ================= */
function priceOf(id) {
const c = G.deck[id] || 0;
if (c >= MAX_COPIES) return null;
return Math.min(SHOP_CAP, c >= 3 ? 10 : 1 + c);
}
function buyCard(id) {
if (G.over || G.phase === 'enemy') return;
const p = priceOf(id);
if (p === null) return;
if (H().gold < p) { showMsg('Not enough gold'); return; }
H().gold -= p;
G.deck[id] = (G.deck[id] || 0) + 1;
if (G.phase !== 'between') G.draw.push(id);
log(`Bought ${CARD[id].name} for ${p} Gold.`, 'gold');
renderShop(); render();
}
function openShop() {
if (!G || G.over) return;
if (G.phase === 'enemy') { showMsg('Wait for the enemy turn to end'); return; }
renderShop(); $('modal').classList.remove('hidden');
}
function setShopFilter(f) { shopFilter = f; renderShop(); }
function renderShop() {
const P = $('modalPanel'); P.innerHTML = '';
P.style.maxWidth = '1000px';
const head = document.createElement('div');
head.innerHTML = `<h2>Gold Shop</h2><p>Gold: <b>${H().gold}</b> · Price = 1 + copies owned (10 Gold once you own 3+). Max ${MAX_COPIES} copies per card.</p>
<div class="actions">${['ALL', 'BASTION', 'WITHER', 'SHROUD'].map(f => `<button onclick="setShopFilter('${f}')" ${shopFilter === f ? 'disabled' : ''}>${f === 'ALL' ? 'All Classes' : CLASSES[f].label}</button>`).join('')}</div>`;
P.appendChild(head);
const grid = document.createElement('div'); grid.className = 'shoprow';
CARDS.filter(c => shopFilter === 'ALL' || c.cls === shopFilter).forEach(c => {
const slot = document.createElement('div'); slot.className = 'slot';
const el = cardEl(c.id); el.style.width = '120px'; el.style.cursor = 'default'; el.classList.remove('unplayable');
const p = priceOf(c.id), owned = G.deck[c.id] || 0;
const btn = document.createElement('button');
btn.textContent = p === null ? 'Max Copies' : `Buy · ${p}g`;
btn.disabled = p === null || H().gold < p || G.phase === 'enemy';
btn.onclick = () => buyCard(c.id);
const pr = document.createElement('div'); pr.className = 'price' + (p === null ? ' maxed' : '');
pr.textContent = `Owned ${owned}/${MAX_COPIES}`;
slot.append(el, pr, btn); grid.appendChild(slot);
});
P.appendChild(grid);
const close = document.createElement('div'); close.className = 'actions';
close.innerHTML = `<button class="primary" onclick="closeModal()">Close</button>`;
P.appendChild(close);
}
function openDeck() {
if (!G) return;
const P = $('modalPanel'); P.innerHTML = ''; P.style.maxWidth = '960px';
const total = Object.values(G.deck).reduce((a, b) => a + b, 0);
P.innerHTML = `<h2>Your Deck</h2><p>${total} cards · Hand size ${HAND_SIZE} · Draw ${G.draw.length} · Discard ${G.discard.length}</p>`;
const grid = document.createElement('div'); grid.className = 'deckgrid';
Object.keys(G.deck).sort((a, b) => a.localeCompare(b)).forEach(id => {
if (!G.deck[id]) return;
const w = document.createElement('div'); w.style.position = 'relative';
const el = cardEl(id); el.style.width = '120px'; el.style.cursor = 'default'; el.classList.remove('unplayable');
const b = document.createElement('div'); b.className = 'badge'; b.textContent = '× ' + G.deck[id];
w.append(el, b); grid.appendChild(w);
});
P.appendChild(grid);
const close = document.createElement('div'); close.className = 'actions';
close.innerHTML = `<button class="primary" onclick="closeModal()">Close</button>`;
P.appendChild(close);
$('modal').classList.remove('hidden');
}
function closeModal() { $('modal').classList.add('hidden'); }
function victory() {
G.over = true;
const P = $('modalPanel'); P.style.maxWidth = '620px';
P.innerHTML = `<h2>Floor Cleared</h2><p>The Void Lord is slain. The Victory Portal opens and the light of Aethelgard returns.</p>
<p class="muted">HP ${H().hp}/${H().maxHp} · Gold ${H().gold} · Deck ${Object.values(G.deck).reduce((a, b) => a + b, 0)} cards</p>
<div class="actions"><button class="primary" onclick="toMenu()">Return to Main Menu</button></div>`;
$('modal').classList.remove('hidden');
}
function gameOver() {
const P = $('modalPanel'); P.style.maxWidth = '560px';
P.innerHTML = `<h2>Aethelgard Falls</h2><p>The Void claims another hero. Floor ${Math.floor(G.idx / 4) + 1}, encounter ${G.idx % 4 === 3 ? 'boss' : (G.idx % 4) + 1}.</p>
<div class="actions"><button class="primary" onclick="toMenu()">Return to Main Menu</button></div>`;
$('modal').classList.remove('hidden');
}
/* ================= RENDER ================= */
function cardEl(id, opts = {}) {
const c = CARD[id];
const el = document.createElement('div');
el.className = `card t-${c.type === 'atk' ? 'atk' : c.type === 'def' ? 'def' : 'oth'}`;
el.style.backgroundImage = FRAME_URI[c.cls];
const typeName = { atk: 'Attack', def: 'Defense', oth: 'Other' }[c.type];
const stat = c.type === 'atk' ? `${c.atk} ATK` : c.type === 'def' ? `${c.def} DEF` : '';
el.innerHTML = `<div class="cname">${c.name}</div>
<div class="ccost">${c.cost}</div>
<div class="ctype">${typeName}${stat ? ' · ' + stat : ''}</div>
<div class="cdesc">${c.text}</div>
<div class="crar" style="display:none">${c.id} · ${c.rar}</div>`;
el.title = `${c.name} (${c.id})\n${c.text}`;
return el;
}
function cardBack(cls) {
return `<div class="back" style="background-image:${BACK_URI[cls].replace(/"/g, '"')}"></div>`;
}
let enemyKey = '';
function renderEnemies() {
const key = G.enemies.map(e => e.uid).join(',') + '|' + G.idx;
const area = $('enemyArea');
if (key !== enemyKey) {
enemyKey = key; area.innerHTML = '';
G.enemies.forEach(e => {
const d = document.createElement('div');
d.className = 'enemy' + (e.boss ? ' boss' : '');
d.dataset.uid = e.uid;
d.innerHTML = `<div class="intent"></div><div class="esprite"></div><div class="name">${e.name}</div>
<div class="bar enemyhp"><i></i><b></b></div><div class="statuses"></div>`;
d.querySelector('.esprite').innerHTML = enemySVG(e.type, 'idle');
d.onclick = () => onEnemyClick(e);
area.appendChild(d);
});
}
G.enemies.forEach(e => {
const d = enemyEl(e); if (!d) return;
d.classList.toggle('dead', e.dead);
d.classList.toggle('target', G.pending != null && !e.dead);
const pct = Math.max(0, e.hp / e.maxHp * 100);
d.querySelector('.bar i').style.width = pct + '%';
d.querySelector('.bar b').textContent = `${e.hp} / ${e.maxHp}`;
const arm = armorOf(e);
const st = [];
if (arm > 0) st.push(`🛡 Armor ${arm}`);
if (e.burn) st.push(`🔥 Burn ${e.burn}`);
if (e.poison) st.push(`☠ Poison ${e.poison}`);
if (e.stun) st.push(`✦ Stunned`);
if (e.silence) st.push(`⊘ Silenced`);
if (e.delayed) st.push(`✹ Pyre ${e.delayed}`);
if (e.dmgMod) st.push(`▼ Atk ${e.dmgMod}`);
d.querySelector('.statuses').textContent = st.join(' ');
const intentDmg = Math.max(0, e.dmg + e.dmgMod);
let intent = e.dead ? 'Slain' : `Strikes for ${intentDmg}`;
if (!e.dead && e.type === 'boss2') intent = H().energy < 3 ? 'Strikes for 20!' : 'Strikes for 15';
if (!e.dead && e.type === 'boss3') intent += ' · Regen +10';
if (!e.dead && e.type === 'boss1' && arm > 0) intent += ' · Shields';
if (!e.dead && e.stun) intent = 'Stunned';
d.querySelector('.intent').textContent = intent;
});
if (!area.children.length) area.innerHTML = '';
}
function armorOf(e) {
if (e.dead) return 0;
if (e.type === 'boss1') return e.hp >= 20 ? 5 : 0;
if (e.type === 'boss3') return 0;
return e.armor || 0;
}
function render() {
if (!G) return;
const h = H();
$('gold').textContent = h.gold;
$('floorLbl').textContent = `Floor ${Math.floor(G.idx / 4) + 1} · ${G.idx % 4 === 3 ? 'Boss' : 'Room ' + ((G.idx % 4) + 1) + '/3'}`;
const boss = G.enemies.find(e => e.boss);
if (boss && !boss.dead) {
$('bossFill').style.width = (boss.hp / boss.maxHp * 100) + '%';
$('bossTxt').textContent = `${boss.name} ${boss.hp}/${boss.maxHp}`;
} else if (boss && boss.dead) {
$('bossFill').style.width = '0%'; $('bossTxt').textContent = 'Defeated';
} else {
const b = Math.floor(G.idx / 4) + 1;
$('bossFill').style.width = '100%'; $('bossTxt').textContent = `Boss ${b} awaits`;
}
$('heroName').textContent = CLASSES[h.cls].label;
$('hpFill').style.width = (h.hp / h.maxHp * 100) + '%';
$('hpTxt').textContent = `${h.hp} / ${h.maxHp} HP`;
const sBits = [];
sBits.push(`Defense <b>${h.def + h.permDef + h.flat}</b>${h.permDef ? ` (perm ${h.permDef})` : ''}`);
sBits.push(`Hand <b>${HAND_SIZE}</b> · Gold <b>${h.gold}</b>`);
const flags = []; if (h.ghost) flags.push('Ghost'); if (h.ignoreNext) flags.push('Shadowed'); if (h.backRow) flags.push('Back Row'); if (h.skipEnemy) flags.push('Evading');
if (flags.length) sBits.push(`<span style="color:#b48cff">${flags.join(' · ')}</span>`);
$('heroStats').innerHTML = sBits.join('<br>');
// energy orbs
$('orbs').innerHTML = Array.from({ length: MAX_ENERGY }, (_, i) => `<div class="orb ${i < h.energy ? 'on' : ''}"></div>`).join('') + `<span style="font-size:13px;margin-left:4px;align-self:center">${h.energy}/${MAX_ENERGY}</span>`;
$('piles').innerHTML = `<div class="pile">${cardBack(h.cls)}Draw ${G.draw.length}</div><div class="pile">${cardBack(h.cls)}Discard ${G.discard.length}</div>`;
// hand
const hand = $('hand'); hand.innerHTML = '';
const playable = G.phase === 'player' && !G.over;
G.hand.forEach((id, i) => {
const c = CARD[id];
const el = cardEl(id);
if (!playable || c.cost > h.energy) el.classList.add('unplayable');
if (G.pending === i) { el.style.outline = '2px solid #ff5a3c'; el.style.borderRadius = '9px'; el.style.transform = 'translateY(-14px)'; }
el.onclick = () => onCardClick(i);
hand.appendChild(el);
});
// end button
const btn = $('endBtn');
if (G.phase === 'between') { btn.textContent = 'Descend ▼'; btn.onclick = descend; btn.disabled = G.over; }
else { btn.textContent = 'End Turn'; btn.onclick = endTurn; btn.disabled = !playable; }
$('turnLbl').textContent = G.phase === 'player' ? 'Your turn' : G.phase === 'enemy' ? 'Enemy turn…' : G.phase === 'between' ? 'Room cleared — shop or descend' : '';
renderEnemies();
}
/* ================= MENU / START ================= */
function renderClassPick() {
const box = $('classPick'); box.innerHTML = '';
Object.keys(CLASSES).forEach(k => {
const c = CLASSES[k];
const d = document.createElement('div');
d.className = 'classcard' + (selectedClass === k ? ' sel' : '');
const starter = CARDS.filter(x => x.cls === k).map(x => x.name).join(' · ');
d.innerHTML = `<div class="sprite">${heroSVG(k, 'idle')}</div>
<div class="ctitle">${c.label}</div><div class="theme">${c.theme}</div>
<div class="stats">HP ${c.hp} · Energy ${c.energy}</div>
<div class="cdesc">${c.desc}</div>
<div style="margin-top:10px;display:flex;gap:6px;justify-content:center">${cardBack(k)}${cardBack(k)}</div>`;
d.onclick = () => { selectedClass = k; renderClassPick(); };
box.appendChild(d);
});
}
function startGame() {
G = newGame(selectedClass);
$('menu').classList.add('hidden');
$('game').classList.remove('hidden');
$('log').innerHTML = '';
$('heroSprite').innerHTML = heroSVG(G.hero.cls, 'idle');
log(`${CLASSES[selectedClass].label} descends into the Void.`, 'gold');
startEncounter();
}
function toMenu() {
closeModal(); G = null; enemyKey = '';
$('game').classList.add('hidden');
$('menu').classList.remove('hidden');
renderClassPick();
}
/* ================= INPUT ================= */
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { if (!$('modal').classList.contains('hidden')) closeModal(); else if (G) { G.pending = null; render(); } }
if (e.key === 'e' || e.key === 'E') { if (G && G.phase === 'player') endTurn(); else if (G && G.phase === 'between') descend(); }
if (e.key === 's' || e.key === 'S') { if (G) openShop(); }
if (e.key === 'd' || e.key === 'D') { if (G) openDeck(); }
if (G && G.phase === 'player' && /^[1-9]$/.test(e.key)) { const i = +e.key - 1; if (i < G.hand.length) onCardClick(i); }
});
$('startBtn').onclick = startGame;
$('menuBtn').onclick = () => { if (confirm('Abandon this descent?')) toMenu(); };
$('deckBtn').onclick = openDeck;
$('shopBtn').onclick = openShop;
$('modal').addEventListener('click', e => { if (e.target.id === 'modal' && G && !G.over) closeModal(); });
renderClassPick();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"a766cb42-1ab4-4d96-9daa-6527839c1785","tokensIn":1089166,"tokensOut":118312,"tokensTotal":1207478,"cost":0.08010008500000002,"turns":20,"toolCalls":30,"failedToolCalls":0,"timestamp":"2026-10-08T04:07:12.468Z"} -->