Deckbuilder
Deepseek v4.1 Flash · full

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 90,312 bytes · SHA-256 c7608c5faee3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shadows of Aethelgard</title>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
:root{
--bg:#07060b; --ink:#eae3d4; --dim:#9a8f7d;
--gold:#c8a24a; --gold2:#f2dc9e; --goldd:#6b5320;
--panel:#0f0c16; --panel2:#171225; --line:#3b3022;
--atk:#e2543a; --def:#5fa8e0; --oth:#a878e8;
--burn:#d8a520; --void:#7a4fd0;
}
body{
background:radial-gradient(125% 95% at 50% -15%, #1d1533 0%, #0b0813 45%, #040308 100%);
color:var(--ink); font-family:'EB Garamond',Georgia,serif; overflow:hidden;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3,.cz{font-family:'Cinzel',Georgia,serif;letter-spacing:.06em}
button{font-family:'Cinzel',Georgia,serif}
#fog{position:fixed;inset:-25%;pointer-events:none;z-index:0;filter:blur(26px);
background:
radial-gradient(38% 28% at 18% 72%, rgba(96,62,160,.20), transparent 70%),
radial-gradient(32% 24% at 82% 26%, rgba(158,96,42,.13), transparent 70%),
radial-gradient(28% 22% at 60% 88%, rgba(40,90,140,.12), transparent 70%);
animation:fog 46s ease-in-out infinite alternate;}
@keyframes fog{from{transform:translate(-3%,-2%) scale(1)}to{transform:translate(4%,3%) scale(1.18)}}
.grain{position:fixed;inset:0;z-index:1;pointer-events:none;opacity:.35;
background-image:repeating-linear-gradient(0deg,rgba(255,255,255,.028) 0 1px,transparent 1px 3px);}
/* ---------- screens ---------- */
.screen{position:fixed;inset:0;display:none;z-index:2;flex-direction:column}
.screen.active{display:flex}
#screen-combat.active,#screen-map.active,#screen-shop.active,#screen-menu.active{display:flex}
/* ---------- generic ui ---------- */
.btn{
position:relative;cursor:pointer;border:1px solid #6a5836;border-radius:5px;
background:linear-gradient(#3a3346,#1b1725 55%,#12101b);
color:#e6dcc4;padding:9px 16px;font-size:13px;letter-spacing:.14em;text-transform:uppercase;
box-shadow:0 2px 0 #000 inset,0 0 0 1px rgba(255,255,255,.05) inset,0 6px 14px rgba(0,0,0,.5);
transition:.16s;
}
.btn:hover{color:#fff;border-color:var(--gold);box-shadow:0 0 14px rgba(200,162,74,.35),0 2px 0 #000 inset}
.btn:active{transform:translateY(1px)}
.btn:disabled{opacity:.35;cursor:not-allowed;filter:grayscale(1)}
.btn.gold{background:linear-gradient(#5a4a1e,#2b2210 60%,#1a1509);border-color:#a8873c;color:#f6e6b4}
.btn.big{font-size:16px;padding:13px 30px;letter-spacing:.2em}
.btn.small{font-size:10.5px;padding:6px 10px;letter-spacing:.1em}
.panel{background:linear-gradient(#12101c,#0b0912);border:1px solid #2e2739;border-radius:8px;
box-shadow:0 0 0 1px rgba(0,0,0,.6),0 10px 30px rgba(0,0,0,.6)}
/* ---------- menu ---------- */
#screen-menu{align-items:center;justify-content:center;overflow:auto;padding:24px}
.menu-wrap{position:relative;z-index:3;width:min(1180px,96vw);text-align:center}
.title-block h1{
font-size:clamp(30px,5.4vw,62px);font-weight:700;letter-spacing:.14em;
background:linear-gradient(#fff4d0,#d9b459 45%,#7a5a1c);
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 40px rgba(200,162,74,.25);
}
.sub{color:#a99a86;font-style:italic;font-size:clamp(14px,1.6vw,19px);margin-top:6px}
.hint{color:#6f6555;font-size:12.5px;letter-spacing:.22em;text-transform:uppercase;margin-top:12px}
.class-picker{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;margin:26px auto 4px;max-width:960px}
.cls-card{position:relative;padding:16px 14px 18px;cursor:pointer;text-align:center;
background:linear-gradient(180deg,rgba(30,24,44,.9),rgba(10,8,16,.94));
border:1px solid #322a44;border-radius:10px;transition:.2s;overflow:hidden}
.cls-card:hover{transform:translateY(-6px);border-color:var(--gold);box-shadow:0 18px 44px rgba(0,0,0,.7),0 0 30px rgba(200,162,74,.18)}
.cls-card .back{width:132px;height:185px;margin:0 auto 12px;display:block;filter:drop-shadow(0 10px 18px rgba(0,0,0,.65))}
.cls-card .back svg{width:100%;height:100%;display:block}
.cls-card h3{font-size:19px;margin-bottom:2px}
.cls-card .theme{color:#9c8f7a;font-style:italic;font-size:13.5px;min-height:36px}
.cls-stats{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin:10px 0 12px;font-size:12px}
.chip{border:1px solid #3a3048;border-radius:20px;padding:2px 10px;background:#130f1e;color:#cbbfa8}
.chip.hp{color:#ff9a8a;border-color:#5a2a24}
.chip.en{color:#84c5ff;border-color:#24425c}
.cls-card p{font-size:12.5px;color:#8d8271;min-height:52px;margin-bottom:10px}
/* ---------- top bar ---------- */
#topbar{flex:0 0 54px;display:flex;align-items:center;gap:14px;padding:0 16px;
background:linear-gradient(#151024,#0a0811);border-bottom:1px solid #2b2438;z-index:4}
.tb{display:flex;align-items:center;gap:8px;font-size:13px;letter-spacing:.08em}
.tb .lab{color:#7d725f;font-size:10.5px;text-transform:uppercase;letter-spacing:.18em}
.tb .val{font-family:'Cinzel',serif;font-size:15px;color:#f0e3c0}
#tb-gold .val{color:var(--gold2)}
#tb-floor .val{color:#c9b7ff}
.tb-sep{width:1px;height:26px;background:#2e2739}
#tb-target{flex:1;display:flex;gap:14px;justify-content:flex-end;overflow:hidden}
.boss-hp{min-width:190px}
.bhp-name{font-size:11px;letter-spacing:.14em;color:#c3a2ff;text-transform:uppercase}
.bhp-bar{height:9px;border:1px solid #4a3a6b;border-radius:5px;background:#120c1e;overflow:hidden;margin-top:3px}
.bhp-fill{height:100%;background:linear-gradient(90deg,#7a2ea8,#c256f0);transition:width .35s}
/* ---------- combat layout ---------- */
#combat-body{flex:1;display:grid;grid-template-columns:470px 1fr 168px;min-height:0}
#hand-panel{border-right:1px solid #251f33;background:linear-gradient(180deg,rgba(19,15,30,.85),rgba(8,6,13,.92));display:flex;flex-direction:column;min-height:0}
.panel-title{flex:0 0 auto;padding:9px 12px;font-size:11px;letter-spacing:.22em;color:#8b7f6a;text-transform:uppercase;border-bottom:1px solid #221c30;display:flex;justify-content:space-between}
#hand{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px 12px;display:flex;flex-wrap:wrap;gap:12px 10px;align-content:flex-start}
#hand .card{width:140px;height:196px}
#right-panel .tb-sep{height:1px;width:70%}
#hand::-webkit-scrollbar{width:7px}#hand::-webkit-scrollbar-thumb{background:#3a3050;border-radius:4px}
#arena{position:relative;display:flex;align-items:center;justify-content:center;gap:26px;padding:14px 18px;min-height:0;
background:
radial-gradient(70% 60% at 50% 108%, rgba(80,50,140,.22), transparent 70%),
radial-gradient(60% 50% at 50% -10%, rgba(180,120,40,.08), transparent 70%);}
#arena:before{content:"";position:absolute;left:6%;right:6%;bottom:8%;height:1px;background:linear-gradient(90deg,transparent,rgba(200,162,74,.35),transparent)}
#hero-side{display:flex;flex-direction:column;align-items:center;gap:8px;flex:0 0 auto}
#enemies{display:flex;align-items:flex-end;justify-content:center;gap:16px;flex:1;min-width:0;flex-wrap:wrap}
.unit{position:relative;display:flex;flex-direction:column;align-items:center;gap:5px;transition:.18s}
.unit .sprite{position:relative;display:block;filter:drop-shadow(0 14px 16px rgba(0,0,0,.7))}
.unit .sprite svg{display:block;height:100%;width:auto}
.enemy{cursor:crosshair;padding:6px 8px 8px;border-radius:10px;border:1px solid transparent}
.enemy:hover{background:rgba(255,255,255,.045);transform:translateY(-5px)}
.enemy.targeted{border-color:rgba(226,84,58,.55);
background:radial-gradient(60% 72% at 50% 42%, rgba(226,84,58,.14), rgba(226,84,58,0) 72%);
box-shadow:0 0 34px rgba(226,84,58,.28)}
.enemy.targeted:after{content:"";position:absolute;inset:-3px;border-radius:12px;border:1px dashed rgba(226,84,58,.6);animation:spin 9s linear infinite;pointer-events:none}
@keyframes spin{to{transform:rotate(360deg)}}
.enemy.dead{opacity:.18;filter:grayscale(1);pointer-events:none}
.ename{font-size:11.5px;letter-spacing:.1em;text-transform:uppercase;color:#cbbf9f;font-family:'Cinzel',serif}
.hpbar{width:112px;height:10px;border:1px solid #4a3a2e;border-radius:5px;background:#170f14;overflow:hidden;position:relative}
.hpbar i{display:block;height:100%;background:linear-gradient(90deg,#8b1f1f,#e0473a 60%,#ff8a70);transition:width .3s}
.hpbar span{position:absolute;inset:0;text-align:center;font-size:8.5px;line-height:9px;color:#ffe;letter-spacing:.08em;text-shadow:0 1px 2px #000}
.badges{display:flex;gap:5px;align-items:center;min-height:20px;flex-wrap:wrap;justify-content:center;max-width:250px}
.badge{font-size:10.5px;border-radius:4px;padding:1px 6px;border:1px solid;letter-spacing:.06em;background:#0c0a12}
.b-dmg{color:#ff9d86;border-color:#6b2b22;background:#200d0a}
.b-blk{color:#9cd0ff;border-color:#254a68;background:#0a1620}
.b-burn{color:#ffd06a;border-color:#6a5220;background:#20180a}
.b-pois{color:#a6e07a;border-color:#3c5c22;background:#101a0a}
.b-stun{color:#ffd9a0;border-color:#6a4a20;background:#1c1408}
.b-sil{color:#d0b0ff;border-color:#4a3070;background:#160e20}
#right-panel{border-left:1px solid #251f33;background:linear-gradient(180deg,rgba(19,15,30,.85),rgba(8,6,13,.92));
display:flex;flex-direction:column;align-items:center;gap:10px;padding:14px 10px;min-height:0;overflow-y:auto}
.en-label{font-size:10px;letter-spacing:.2em;color:#8b7f6a;text-transform:uppercase}
#energy-meter{display:flex;flex-direction:column-reverse;gap:5px}
.pip{width:34px;height:16px;border-radius:3px;border:1px solid #2b5470;background:#0a1218;position:relative;transform:skewX(-12deg)}
.pip.on{background:linear-gradient(#8fe4ff,#2a7fb8 70%,#134060);border-color:#7fd8ff;box-shadow:0 0 12px rgba(90,190,255,.6)}
#pile-info{font-size:11.5px;color:#8b8069;text-align:center;line-height:1.7}
#pile-info b{color:#e6d9ba;font-family:'Cinzel',serif}
#bottombar{flex:0 0 92px;display:flex;align-items:center;gap:14px;padding:8px 16px;
background:linear-gradient(#0d0a16,#161122);border-top:1px solid #2b2438;z-index:4}
#log{flex:1;height:100%;overflow-y:auto;font-size:13px;line-height:1.55;color:#b0a48d;padding-right:8px}
#log::-webkit-scrollbar{width:6px}#log::-webkit-scrollbar-thumb{background:#3a3050;border-radius:4px}
#log .l{padding:1px 0;animation:fadeIn .3s}
#log .dmg{color:#ff9d86}#log .def{color:#9cd0ff}#log .heal{color:#a6e07a}
#log .gold{color:var(--gold2)}#log .bad{color:#ff7a7a}#log .void{color:#c9a2ff}
@keyframes fadeIn{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}}
.turnbox{display:flex;flex-direction:column;align-items:center;gap:4px;min-width:120px}
#turn-label{font-size:10.5px;letter-spacing:.18em;color:#8b7f6a;text-transform:uppercase}
/* ---------- character sheet ---------- */
#hero-side .frame-bust{width:118px;height:150px}
#hero-side .frame-bust svg{width:100%;height:100%;display:block}
.hero-hp{width:150px}
.hero-hp .hpbar{width:150px;height:13px}
.hero-hp .hpbar i{background:linear-gradient(90deg,#2f7a3a,#63c46a 60%,#b6ffb0)}
.hero-name{font-family:'Cinzel',serif;font-size:12px;letter-spacing:.12em;color:#d9cbae;text-transform:uppercase}
.shield-chip{font-size:11px;color:#9cd0ff;letter-spacing:.08em}
/* ---------- cards ---------- */
.card{position:relative;width:158px;height:221px;cursor:pointer;transition:transform .16s,filter .16s,box-shadow .16s;
transform-origin:50% 90%;flex:0 0 auto}
.card.t-attack .frame{filter:drop-shadow(0 0 7px rgba(226,84,58,.4))}
.card.t-defense .frame{filter:drop-shadow(0 0 7px rgba(95,168,224,.4))}
.card.t-other .frame{filter:drop-shadow(0 0 7px rgba(168,120,232,.4))}
.card .frame{position:absolute;inset:0}
.card .frame svg{width:100%;height:100%;display:block}
.card .ov{position:absolute;inset:0;pointer-events:none}
.c-cost{position:absolute;left:2.5%;top:2%;width:17%;height:11.6%;display:flex;align-items:center;justify-content:center;
font-family:'Cinzel',serif;font-size:17px;font-weight:700;color:#dff2ff;text-shadow:0 1px 3px #000,0 0 10px rgba(90,190,255,.85)}
.c-name{position:absolute;left:6.5%;top:53.6%;width:87%;height:8.3%;display:flex;align-items:center;justify-content:center;
font-family:'Cinzel',serif;font-size:11px;letter-spacing:.08em;color:#f0dfae;text-shadow:0 1px 2px #000;text-align:center;line-height:1}
.c-type{position:absolute;left:6.5%;top:62.6%;width:87%;height:5.3%;display:flex;align-items:center;justify-content:center;
font-size:8px;letter-spacing:.26em;text-transform:uppercase;opacity:.95}
.c-desc{position:absolute;left:8.5%;top:69.4%;width:83%;height:20%;display:flex;align-items:center;justify-content:center;
text-align:center;font-size:8.6px;line-height:1.28;color:#cdc2ad;padding:0 2px}
.c-stats{position:absolute;left:6.5%;top:90.6%;width:87%;height:7%;display:flex;align-items:center;justify-content:space-between;padding:0 6px}
.c-stats .s{font-family:'Cinzel',serif;font-size:10px;font-weight:700;text-shadow:0 1px 2px #000;white-space:nowrap}
.c-stats .sa{color:#ff9d86}.c-stats .sd{color:#9cd0ff}
.card:hover{transform:translateY(-12px) scale(1.06);z-index:20;filter:brightness(1.13)}
.card.playable{box-shadow:0 0 0 1px rgba(120,220,255,.35),0 0 22px rgba(80,180,255,.28)}
.card.unplayable{filter:grayscale(.72) brightness(.62)}
.card.selected{transform:translateY(-12px) scale(1.07);box-shadow:0 0 0 2px var(--atk),0 0 30px rgba(226,84,58,.6)}
.card.dealing{animation:deal .32s cubic-bezier(.2,.9,.3,1.4)}
@keyframes deal{from{transform:translateY(60px) rotate(-8deg) scale(.8);opacity:0}to{transform:none;opacity:1}}
.card.t-left:before,.card.t-right:after{content:"";position:absolute;top:38%;width:16px;height:16px;border-radius:50%;
background:radial-gradient(circle,#ffe9b0,#c88a20);box-shadow:0 0 12px rgba(255,200,90,.8);animation:pulse 1s infinite alternate;pointer-events:none}
.card.t-left:before{left:-13px}.card.t-right:after{right:-13px}
@keyframes pulse{to{transform:scale(1.35);opacity:.75}}
/* ---------- shop ---------- */
#screen-shop{padding:0}
#shop-head{flex:0 0 62px;display:flex;align-items:center;gap:18px;padding:0 22px;
background:linear-gradient(#151024,#0a0811);border-bottom:1px solid #2b2438}
#shop-body{flex:1;overflow-y:auto;padding:22px}
#shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(158px,1fr));gap:26px 18px;max-width:1160px;margin:0 auto}
.slot{position:relative;display:flex;flex-direction:column;align-items:center;gap:8px;padding:10px 8px 12px;border-radius:10px;
background:linear-gradient(180deg,rgba(24,19,36,.75),rgba(9,7,14,.85));border:1px solid #241e33}
.slot .price{font-family:'Cinzel',serif;color:var(--gold2);font-size:14px}
.slot .buy{width:100%}
.slot.sold{opacity:.35;filter:grayscale(.8)}
.slot.sold .buy{pointer-events:none}
.shop-note{text-align:center;color:#7d725f;font-size:12.5px;margin-top:18px;font-style:italic}
/* ---------- map ---------- */
#screen-map{align-items:center;justify-content:center}
.map-wrap{width:min(980px,94vw);position:relative;z-index:3;text-align:center}
.map-track{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;margin:30px 0 26px}
.node{width:104px;height:82px;border-radius:9px;border:1px solid #2e2739;background:linear-gradient(180deg,#171324,#0c0912);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-size:11.5px;color:#8b8069;position:relative;transition:.2s}
.node .ico{font-size:20px;filter:grayscale(.4)}
.node.done{border-color:#3f6b3a;color:#9fd39a;background:linear-gradient(180deg,#12200f,#0a1208)}
.node.done .ico{filter:none}
.node.current{border-color:var(--gold);color:#f2dc9e;box-shadow:0 0 24px rgba(200,162,74,.35);transform:scale(1.07)}
.node.boss{border-color:#5a2a6b}
.node.boss.current{border-color:#c256f0;box-shadow:0 0 28px rgba(194,86,240,.45)}
.node.locked{opacity:.42}
.link{width:26px;height:1px;background:linear-gradient(90deg,#2e2739,#5a4a2a,#2e2739)}
/* ---------- overlay / modal ---------- */
#overlay{position:fixed;inset:0;z-index:40;display:none;align-items:center;justify-content:center;background:rgba(3,2,6,.82);backdrop-filter:blur(4px)}
#overlay.on{display:flex;animation:fadeIn .25s}
.modal{width:min(760px,92vw);max-height:86vh;overflow-y:auto;padding:28px 30px 36px;border-radius:12px;
background:linear-gradient(180deg,#1a1428,#0b0812);border:1px solid #4a3a6b;box-shadow:0 0 80px rgba(120,60,200,.3),0 30px 60px #000;text-align:center}
.modal h2{font-size:30px;margin-bottom:6px;background:linear-gradient(#fff2cf,#d0a843);-webkit-background-clip:text;background-clip:text;color:transparent}
.modal p{color:#b7ab94;font-size:15px;line-height:1.7;margin:8px 0}
.modal .rules{text-align:left;color:#a99e88;font-size:13.5px;line-height:1.75;margin:14px 0}
.modal .rules b{color:#e8d9b2}
.modal .rules h3{color:#c9a2ff;font-size:13px;letter-spacing:.16em;margin:14px 0 4px;text-transform:uppercase}
.modal .row{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:20px}
.vestats{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin:14px 0 4px}
/* ---------- fx ---------- */
#fx{position:fixed;inset:0;pointer-events:none;z-index:30;overflow:hidden}
.pop{position:absolute;font-family:'Cinzel',serif;font-weight:700;font-size:26px;text-shadow:0 2px 8px #000;
animation:pop 1s ease-out forwards}
@keyframes pop{0%{transform:translate(-50%,0) scale(.6);opacity:0}18%{transform:translate(-50%,-14px) scale(1.25);opacity:1}
100%{transform:translate(-50%,-64px) scale(1);opacity:0}}
.shake{animation:shk .38s}
@keyframes shk{10%,90%{transform:translateX(-3px)}20%,80%{transform:translateX(5px)}30%,50%,70%{transform:translateX(-7px)}40%,60%{transform:translateX(7px)}}
.flash{animation:fl .4s}
@keyframes fl{0%{filter:brightness(2.6) saturate(.4) hue-rotate(-25deg)}100%{filter:none}}
.slash{position:absolute;width:130px;height:130px;pointer-events:none;animation:slash .45s ease-out forwards}
@keyframes slash{0%{transform:translate(-50%,-50%) scale(.3) rotate(-30deg);opacity:0}
30%{opacity:1}100%{transform:translate(-50%,-50%) scale(1.25) rotate(12deg);opacity:0}}
#map-hero svg{display:block;width:100%;height:100%}
@media (max-width:1080px){
#combat-body{grid-template-columns:300px 1fr 150px}
#hand .card{width:130px;height:180px}
}
</style>
</head>
<body>
<div id="fog"></div>
<div class="grain"></div>
<!-- ============ MENU ============ -->
<section id="screen-menu" class="screen active">
<div class="menu-wrap">
<div class="title-block">
<h1>SHADOWS OF AETHELGARD</h1>
<p class="sub">The Void is eating the world. Three floors stand between you and its Lords.</p>
</div>
<div class="class-picker" id="class-picker"></div>
<div class="hint">9 rooms • 3 void lords • choose your vessel</div>
<div class="row" style="margin-top:20px;display:flex;gap:12px;justify-content:center">
<button class="btn small" id="btn-rules">Rules of the Void</button>
</div>
</div>
</section>
<!-- ============ MAP ============ -->
<section id="screen-map" class="screen">
<div class="map-wrap">
<h2 id="map-floor" style="font-size:30px;background:linear-gradient(#fff2cf,#d0a843);-webkit-background-clip:text;background-clip:text;color:transparent">FLOOR I — THE SUNKEN WARD</h2>
<p id="map-sub" style="color:#9a8f7d;font-style:italic;margin-top:6px"></p>
<div class="map-track" id="map-track"></div>
<div style="display:flex;gap:12px;justify-content:center">
<button class="btn gold big" id="btn-advance">Enter Room</button>
<button class="btn" id="btn-shop-map">Gold Shop</button>
</div>
<div id="map-hero" style="margin-top:22px;display:flex;gap:20px;justify-content:center;color:#a99e88;font-size:13.5px"></div>
</div>
</section>
<!-- ============ COMBAT ============ -->
<section id="screen-combat" class="screen">
<header id="topbar">
<div class="tb" id="tb-gold"><span class="lab">Gold</span><span class="val">0</span></div>
<div class="tb-sep"></div>
<div class="tb" id="tb-floor"><span class="lab">Floor</span><span class="val">1</span></div>
<div class="tb-sep"></div>
<div class="tb" id="tb-room"><span class="lab">Room</span><span class="val">1/3</span></div>
<div id="tb-target"></div>
</header>
<div id="combat-body">
<aside id="hand-panel">
<div class="panel-title"><span>Hand</span><span id="hand-count">0 / 4</span></div>
<div id="hand"></div>
</aside>
<main id="arena">
<div id="hero-side"></div>
<div id="enemies"></div>
</main>
<aside id="right-panel">
<div class="en-label">Energy</div>
<div id="energy-meter"></div>
<div id="pile-info"></div>
<div class="tb-sep" style="width:100%"></div>
<button class="btn gold" id="btn-shop" style="width:100%">Gold Shop</button>
<button class="btn small" id="btn-concede" style="width:100%">Flee / Concede</button>
</aside>
</div>
<footer id="bottombar">
<div id="log"></div>
<div class="turnbox">
<div id="turn-label">Your Turn</div>
<button class="btn big gold" id="btn-endturn">End Turn</button>
</div>
</footer>
</section>
<!-- ============ SHOP ============ -->
<section id="screen-shop" class="screen">
<div id="shop-head">
<h2 class="cz" style="font-size:20px;color:#f2dc9e">THE GOLD SHOP</h2>
<div class="tb"><span class="lab">Gold</span><span class="val" id="shop-gold" style="color:#f2dc9e;font-family:'Cinzel',serif">0</span></div>
<div style="flex:1"></div>
<button class="btn" id="btn-leave">Leave Shop</button>
</div>
<div id="shop-body">
<div id="shop-grid"></div>
<div class="shop-note" id="shop-note"></div>
</div>
</section>
<div id="fx"></div>
<div id="overlay"><div class="modal" id="modal"></div></div>
<script>
/* =====================================================================
SHADOWS OF AETHELGARD
===================================================================== */
const PAL = {
bastion:{ metal:'#cbb27a', metalHi:'#f6e9bf', metalLo:'#786334', edge:'#7d9ec0', glow:'#8fd0ff',
deep:'#0c141d', parch:'#16202b', ink:'#cfe3f5', accent:'#9ccbe8', accent2:'#5fa8e0' },
wither:{ metal:'#d6ab52', metalHi:'#f8e4a6', metalLo:'#7c5216', edge:'#9a5a2c', glow:'#ff9b3c',
deep:'#150e0b', parch:'#221610', ink:'#f0d0a8', accent:'#e08a3a', accent2:'#d8a520' },
shroud:{ metal:'#b79ae0', metalHi:'#eae0ff', metalLo:'#5c3d90', edge:'#6a49a0', glow:'#b070ff',
deep:'#110c1b', parch:'#1b1329', ink:'#dcc9ff', accent:'#a978ff', accent2:'#c9a2ff' }
};
const P = (pts,fill,ex='') => `<polygon points="${pts}" fill="${fill}" ${ex}/>`;
const R = (x,y,w,h,f,rx=0,ex='') => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" fill="${f}" ${ex}/>`;
const C = (cx,cy,r,f,ex='') => `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${f}" ${ex}/>`;
function mix(a,b,t){
const h=x=>[1,3,5].map(i=>parseInt(x.substr(i,2),16));
const A=h(a),B=h(b);
return '#'+A.map((v,i)=>Math.round(v+(B[i]-v)*t).toString(16).padStart(2,'0')).join('');
}
function tintHit(c){
const o={}; for(const k in c) o[k]=c[k];
if(c.l)o.l=mix(c.l,'#ff7a52',.5); if(c.m)o.m=mix(c.m,'#c03a26',.5);
if(c.d)o.d=mix(c.d,'#5a1410',.5); if(c.parch)o.parch=mix(c.parch,'#5a1410',.5);
if(c.metal)o.metal=mix(c.metal,'#e0a070',.35); if(c.glow)o.glow='#ffb090';
if(c.skin)o.skin=mix(c.skin,'#ff9a80',.4);
return o;
}
/* ============ FRAME ORNAMENTS (one per deck type) ============ */
const FRAME_ORN = {
bastion(p){ let s='';
// rivets along frame
[[22,22],[218,22],[22,314],[218,314]].forEach(([x,y])=>{ s+=C(x,y,4.6,p.metal)+C(x,y,2.1,p.deep); });
for(let y=70;y<300;y+=46) s+=C(10.5,y,2.6,p.metal)+C(229.5,y,2.6,p.metal);
// top shield crest
s+=`<path d="M120 12 l16 8 v12 q0 12-16 20 q-16-8-16-20 v-12 z" fill="${p.metal}" stroke="${p.edge}" stroke-width="1"/>`;
s+=`<path d="M120 18 l9 4.5 v9 q0 7.5-9 12.5 q-9-5-9-12.5 v-9 z" fill="${p.deep}" stroke="${p.edge}" stroke-width=".8" opacity=".95"/>`;
s+=`<path d="M120 22 v18 M110 30 h20" stroke="${p.glow}" stroke-width="1.6" opacity=".95"/>`;
// riveted plate under art
s+=R(16,180,208,30,p.metal,4)+R(19.5,183.5,201,23,p.deep,3,'opacity=".9"');
for(let x=28;x<220;x+=27) s+=C(x,183,1.7,p.metal)+C(x,207,1.7,p.metal);
return s; },
wither(p){ let s='';
// rot drips from plate
for(let i=0;i<7;i++){ const x=32+i*28, h=10+((i*7)%16);
s+=`<path d="M${x-4} 210 q4 6 0 12 q-4 -6 0 -12 z" fill="${p.metalLo}" opacity=".85"/>`; }
for(let i=0;i<5;i++){ const x=46+i*36, h=9+((i*5)%14);
s+=`<path d="M${x-3} 300 q3 5 0 ${h} q-3 -${h} 0 -${h} z" fill="${p.edge}" opacity=".7"/>`; }
// burn ring crest
s+=C(120,26,13,p.metal)+C(120,26,10,p.deep);
s+=`<path d="M120 17 q7 7 0 17 q-7-10 0-17z" fill="${p.glow}" opacity=".95"/>`;
s+=`<path d="M120 21 q3.5 4 0 9 q-3.5-5 0-9z" fill="${p.metalHi}"/>`;
// ember specks
[[36,64],[206,70],[30,250],[212,254],[120,300]].forEach(([x,y])=>s+=C(x,y,2.2,p.glow,'opacity=".8"'));
// cracks
s+=`<path d="M22 178 l10 8 l-6 7 l9 9" stroke="${p.metalLo}" stroke-width="1.4" fill="none" opacity=".8"/>`;
s+=`<path d="M218 232 l-9 8 l7 7 l-10 9" stroke="${p.metalLo}" stroke-width="1.4" fill="none" opacity=".8"/>`;
// ring around name plate
s+=R(16,180,208,30,p.metal,4)+R(19.5,183.5,201,23,p.parch,3,'opacity=".75"');
return s; },
shroud(p){ let s='';
// thorn vines
const vine=(x,flip)=>{ let d=`M${x} 26 q${flip*13} 46 0 92 q${-flip*13} 46 0 92 q${flip*11} 40 0 84`;
const a=flip*11, b=-flip*7;
return `<path d="${d}" stroke="${p.metal}" stroke-width="2.2" fill="none" opacity=".9"/>`+
[46,86,126,166,206,246,286].map(y=>`<path d="M${x} ${y} l${a} -5 l${b} 8 z" fill="${p.metal}" opacity=".85"/>`).join(''); };
s+=vine(15,1)+vine(225,-1);
// moon crest
s+=`<path d="M120 10 a17 17 0 1 0 0 34 a13 13 0 1 1 0-34z" fill="${p.metal}"/>`;
s+=C(126,20,2.4,p.deep)+C(132,30,1.7,p.deep)+C(122,40,1.4,p.deep);
// star specks
[[52,56],[188,52],[40,290],[200,288]].forEach(([x,y])=>s+=`<path d="M${x} ${y-4} l1.4 3 l3 1.4 l-3 1.4 l-1.4 3 l-1.4-3 l-3-1.4 l3-1.4z" fill="${p.ink}" opacity=".7"/>`);
// dagger finial
s+=`<path d="M120 330 l5-11 h-10 z" fill="${p.metal}"/>`+R(118,300,4,16,p.metalLo,1);
// name plate
s+=R(16,180,208,30,p.metal,4)+R(19.5,183.5,201,23,p.deep,3,'opacity=".92"');
s+=`<path d="M20 195 h18 M202 195 h18" stroke="${p.metal}" stroke-width="1.6" opacity=".9"/>`;
return s; }
};
/* ============ CARD FRAME (unique per deck type) ============ */
function frameSVG(cls){
const p = PAL[cls], k = cls, orn = FRAME_ORN[cls](p);
return `<svg viewBox="0 0 240 336" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<linearGradient id="mt-${k}" x1="0" y1="0" x2=".4" y2="1">
<stop offset="0" stop-color="${p.metalHi}"/><stop offset=".26" stop-color="${p.metal}"/>
<stop offset=".5" stop-color="${p.metalLo}"/><stop offset=".74" stop-color="${p.metal}"/>
<stop offset="1" stop-color="${p.metalHi}"/></linearGradient>
<linearGradient id="pl-${k}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${p.metalHi}"/><stop offset=".5" stop-color="${p.metal}"/>
<stop offset="1" stop-color="${p.metalLo}"/></linearGradient>
<linearGradient id="pg-${k}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${p.parch}"/><stop offset="1" stop-color="${p.deep}"/></linearGradient>
<radialGradient id="gl-${k}" cx=".5" cy=".3" r=".75">
<stop offset="0" stop-color="${p.glow}" stop-opacity=".30"/>
<stop offset="1" stop-color="${p.glow}" stop-opacity="0"/></radialGradient>
<pattern id="ht-${k}" width="9" height="9" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="9" height="9" fill="#0a0a11"/>
<line x1="0" y1="0" x2="0" y2="9" stroke="${p.edge}" stroke-opacity=".28" stroke-width="2"/></pattern>
</defs>
<rect x="2" y="2" width="236" height="332" rx="13" fill="url(#mt-${k})"/>
<rect x="6" y="6" width="228" height="324" rx="10" fill="${p.deep}"/>
<rect x="9" y="9" width="222" height="318" rx="8" fill="url(#pg-${k})"/>
<rect x="12" y="12" width="216" height="312" rx="6" fill="none" stroke="${p.edge}" stroke-opacity=".4"/>
<rect x="20" y="52" width="200" height="124" rx="4" fill="url(#ht-${k})"/>
<rect x="20" y="52" width="200" height="124" rx="4" fill="url(#gl-${k})"/>
<g opacity=".5">
<circle cx="152" cy="82" r="8" fill="none" stroke="${p.ink}" stroke-width="1.6"/>
<path d="M52 152 l30-42 22 28 15-19 29 33 z" fill="none" stroke="${p.ink}" stroke-width="1.6" stroke-linejoin="round"/>
<line x1="48" y1="152" x2="192" y2="152" stroke="${p.ink}" stroke-width="1.6"/>
</g>
<text x="120" y="68" font-family="Cinzel,Georgia,serif" font-size="10" letter-spacing="2.6"
fill="${p.ink}" opacity=".55" text-anchor="middle">MAIN CARD ART</text>
<rect x="20" y="52" width="200" height="124" rx="4" fill="none" stroke="${p.metal}" stroke-width="1.6"/>
<rect x="25" y="57" width="190" height="114" rx="3" fill="none" stroke="${p.edge}"
stroke-width="1" stroke-dasharray="5 4" stroke-opacity=".85"/>
<rect x="16" y="212" width="208" height="18" rx="3" fill="url(#pl-${k})" opacity=".8"/>
<rect x="18" y="214" width="204" height="14" rx="2" fill="${p.deep}" opacity=".85"/>
<rect x="16" y="232" width="208" height="68" rx="5" fill="url(#pl-${k})"/>
<rect x="19" y="235" width="202" height="62" rx="3" fill="${p.parch}"/>
<rect x="16" y="304" width="208" height="24" rx="4" fill="url(#pl-${k})"/>
<rect x="19" y="307" width="202" height="18" rx="3" fill="${p.deep}" opacity=".9"/>
<line x1="120" y1="307" x2="120" y2="325" stroke="${p.edge}" stroke-opacity=".7"/>
<circle cx="31" cy="33" r="20" fill="url(#pl-${k})"/>
<circle cx="31" cy="33" r="16" fill="${p.deep}"/>
<circle cx="31" cy="33" r="16" fill="url(#gl-${k})"/>
${orn}
</svg>`;
}
/* ============ CARD BACK (unique per deck type) ============ */
function backSVG(cls){
const p = PAL[cls], k = cls;
const bgPat = `<pattern id="bp-${k}" width="24" height="24" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="24" height="24" fill="none"/>
<path d="M12 4 l8 8 l-8 8 l-8-8z" fill="none" stroke="${p.edge}" stroke-opacity=".22" stroke-width="1"/></pattern>`;
let emblem = '';
if(cls==='bastion'){
emblem = `
<path d="M120 92 l44 20 v34 q0 46-44 68 q-44-22-44-68 v-34 z" fill="${p.metal}" stroke="${p.metalHi}" stroke-width="1.6"/>
<path d="M120 104 l32 15 v27 q0 36-32 54 q-32-18-32-54 v-27 z" fill="${p.deep}"/>
<path d="M120 112 l6 13 h14 l-11 9 l4 14 l-13-8 l-13 8 l4-14 l-11-9 h14z" fill="${p.metal}" opacity=".9"/>
<path d="M96 236 v-42 q0-12 24-12 q24 0 24 12 v42z" fill="${p.metal}" opacity=".55"/>
<path d="M88 244 h64 v10 h-64z" fill="${p.metal}" opacity=".75"/>
<path d="M120 92 v90 M76 130 h88" stroke="${p.glow}" stroke-width="1" opacity=".35"/>`;
} else if(cls==='wither'){
emblem = `
<path d="M120 86 q40 0 40 40 q0 26-16 36 q2 16-4 26 h-40 q-6-10-4-26 q-16-10-16-36 q0-40 40-40z"
fill="${p.metal}" stroke="${p.metalHi}" stroke-width="1.6"/>
<path d="M102 126 q10-8 20 0 q-10 10-20 0z" fill="${p.deep}"/>
<path d="M138 126 q-10-8-20 0 q10 10 20 0z" fill="${p.deep}"/>
<circle cx="112" cy="127" r="4.5" fill="${p.glow}"/><circle cx="128" cy="127" r="4.5" fill="${p.glow}"/>
<path d="M120 136 l6 12 h-12z" fill="${p.deep}"/>
<path d="M104 168 h32 v10 q0 6-6 6 h-20 q-6 0-6-6z" fill="${p.deep}"/>
<path d="M100 152 l-3 12 M110 154 l-2 12 M120 155 l0 12 M130 154 l2 12 M140 152 l3 12" stroke="${p.deep}" stroke-width="3"/>
<path d="M104 196 q-6 22 4 34 q-10-8-8-34z M136 196 q6 22-4 34 q10-8 8-34z" fill="${p.glow}" opacity=".6"/>`;
} else {
emblem = `
<path d="M120 118 q34 0 40 26 q-6 26-40 26 q-34 0-40-26 q6-26 40-26z" fill="${p.metal}" stroke="${p.metalHi}" stroke-width="1.6"/>
<path d="M120 126 q24 0 29 18 q-5 18-29 18 q-24 0-29-18 q5-18 29-18z" fill="${p.deep}"/>
<ellipse cx="120" cy="144" rx="9" ry="19" fill="${p.glow}"/>
<ellipse cx="120" cy="144" rx="3.4" ry="19" fill="#0a0612"/>
<path d="M84 92 l50 96 M156 92 l-50 96" stroke="${p.metal}" stroke-width="4" stroke-linecap="round" opacity=".9"/>
<path d="M84 92 l-7 -10 M156 92 l7 -10" stroke="${p.metal}" stroke-width="3" stroke-linecap="round"/>
<path d="M120 68 l3 12 M92 76 l8 10 M148 76 l-8 10" stroke="${p.ink}" stroke-width="2" opacity=".8"/>
<path d="M96 200 q24 20 48 0 q-24 30-48 0z" fill="${p.glow}" opacity=".45"/>`;
}
return `<svg viewBox="0 0 240 336" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<linearGradient id="bm-${k}" x1="0" y1="0" x2=".5" y2="1">
<stop offset="0" stop-color="${p.metalHi}"/><stop offset=".3" stop-color="${p.metal}"/>
<stop offset=".55" stop-color="${p.metalLo}"/><stop offset="1" stop-color="${p.metal}"/></linearGradient>
<linearGradient id="bb-${k}" x1="0" y1="0" x2=".3" y2="1">
<stop offset="0" stop-color="${p.parch}"/><stop offset=".55" stop-color="${p.deep}"/>
<stop offset="1" stop-color="#05040a"/></linearGradient>
<radialGradient id="bg-${k}" cx=".5" cy=".5" r=".62">
<stop offset="0" stop-color="${p.glow}" stop-opacity=".26"/>
<stop offset="1" stop-color="${p.glow}" stop-opacity="0"/></radialGradient>
${bgPat}
</defs>
<rect x="2" y="2" width="236" height="332" rx="14" fill="url(#bm-${k})"/>
<rect x="7" y="7" width="226" height="322" rx="11" fill="${p.deep}"/>
<rect x="12" y="12" width="216" height="312" rx="8" fill="url(#bb-${k})"/>
<rect x="12" y="12" width="216" height="312" rx="8" fill="none" stroke="${p.edge}" stroke-opacity=".55"/>
<rect x="12" y="12" width="216" height="312" rx="8" fill="url(#bp-${k})"/>
<rect x="12" y="12" width="216" height="312" rx="8" fill="url(#bg-${k})"/>
<circle cx="120" cy="168" r="94" fill="none" stroke="${p.metal}" stroke-opacity=".55" stroke-width="1.4"/>
<circle cx="120" cy="168" r="88" fill="none" stroke="${p.metal}" stroke-opacity=".3" stroke-width="2.6" stroke-dasharray="3 7"/>
<g>${emblem}</g>
<path d="M22 22 h26 M22 22 v26" stroke="${p.metal}" stroke-width="2.2" fill="none" opacity=".85"/>
<path d="M218 22 h-26 M218 22 v26" stroke="${p.metal}" stroke-width="2.2" fill="none" opacity=".85"/>
<path d="M22 314 h26 M22 314 v-26" stroke="${p.metal}" stroke-width="2.2" fill="none" opacity=".85"/>
<path d="M218 314 h-26 M218 314 v-26" stroke="${p.metal}" stroke-width="2.2" fill="none" opacity=".85"/>
</svg>`;
}
/* ============ HERO SPRITES (3 frames per class) ============ */
const HERO_COL = {
bastion:{ l:'#adc9e0', m:'#5d7d9b', d:'#26374a', a:'#d9c07a', e:'#8fd0ff', skin:'#c8b49a' },
wither:{ l:'#b9a26c', m:'#6d5a30', d:'#2f2614', a:'#8fae58', e:'#ff9b3c', skin:'#c3b08e' },
shroud:{ l:'#a086dc', m:'#4e357e', d:'#1d1233', a:'#cfc6e0', e:'#c48cff', skin:'#c0acc4' }
};
function drawBastion(c,m){
const sword = (m==='attack')
? `<g transform="rotate(-32 140 112)">
${P('132,104 146,104 146,44 132,44','#d3dde6')}${P('132,44 139,26 146,44','#eef5fa')}
${P('126,102 152,98 154,114 126,116', c.a)}
</g>`
: `${P('132,96 144,96 144,152 132,152','#d3dde6')}${P('132,152 138,164 144,152','#eef5fa')}${P('126,84 150,84 150,100 126,100', c.a)}`;
return `
${P('72,166 96,166 100,210 70,210', c.d)}
${P('104,166 128,166 130,210 100,210', c.d)}
${P('60,206 98,206 100,222 54,222', c.d)}
${P('102,206 140,206 146,222 100,222', c.d)}
${P('64,94 136,94 130,170 70,170', c.m)}
${P('72,100 128,100 124,150 76,150', c.l)}
${P('100,100 108,100 108,166 100,166', c.d,'opacity=".55"')}
${P('46,88 76,82 82,120 50,128', c.m)}
${P('120,82 150,88 146,124 116,118', c.m)}
${P('82,50 118,50 124,90 76,90', c.m)}
${P('86,64 114,64 114,73 86,73','#070c11')}
${P('88,65.5 112,65.5 112,71.5 88,71.5', c.e,'opacity=".92"')}
${P('94,50 106,50 104,34 96,34', c.a)}
${P('84,86 116,92 114,105 84,101', c.a,'opacity=".8"')}
<g transform="rotate(4 52 122)">
${P('18,84 62,90 62,152 18,170', c.m)}
${P('24,91 57,96 57,146 24,162', c.l)}
${P('28,98 54,102 54,140 28,152', c.d,'opacity=".65"')}
${C(40,124,8,c.a)}${C(40,124,3.4,c.d)}
</g>
${sword}`;
}
function drawWither(c,m){
const flame = (m==='attack')
? `${P('150,36 164,72 150,102 136,72','#ffbc5e','opacity=".95"')}${P('150,50 159,72 150,92 141,72', c.e)}`
: `${P('160,56 170,80 160,100 150,80','#ffbc5e','opacity=".9"')}${P('160,66 166,80 160,94 154,80', c.e)}`;
const arm = (m==='attack') ? P('124,88 152,78 162,102 132,116', c.m) : P('124,90 146,96 152,132 132,136', c.m);
return `
${P('76,84 124,84 148,222 52,222', c.m)}
${P('84,92 116,92 136,216 64,216', c.d)}
${P('100,96 106,96 108,212 100,212','#000','opacity=".35"')}
${P('86,188 114,188 124,220 76,220', c.l,'opacity=".45"')}
${P('80,52 120,52 132,96 68,96', c.m)}
${P('84,58 116,58 124,92 76,92', c.d)}
${P('90,66 110,66 112,86 88,86','#0b0806')}
<circle cx="96" cy="75" r="3.6" fill="${c.e}"/><circle cx="105" cy="75" r="3.6" fill="${c.e}"/>
${P('58,92 78,90 84,134 62,140', c.m)}
${arm}
${P('150,58 158,62 142,214 133,212','#463a1e')}
${flame}
${C(64,150,15,c.m)}${C(64,150,10.5,c.d)}${C(64,150,4,c.e,'opacity=".9"')}
<path d="M64 135 v-12 q0-9 9-9" stroke="${c.a}" stroke-width="3" fill="none"/>`;
}
function drawShroud(c,m){
const blades = (m==='attack')
? `${P('124,88 178,58 186,68 130,100', c.a,'opacity=".35"')}
${P('150,74 196,44 204,54 158,86','#e2e9f2')}
${P('58,88 26,110 22,100 52,78','#e2e9f2')}
<path d="M152 58 q46 22 24 76" stroke="${c.e}" stroke-width="3.5" fill="none" opacity=".5" stroke-linecap="round"/>`
: `${P('148,96 164,92 176,166 160,170','#e2e9f2')}
${P('42,96 30,92 22,164 38,168','#e2e9f2')}`;
return `
${P('70,86 130,86 152,222 48,222', c.m)}
${P('78,92 122,92 140,216 60,216', c.d)}
${P('86,182 108,182 110,220 84,220', c.d,'opacity=".7"')}
${P('60,86 140,86 146,102 54,102', c.a,'opacity=".28"')}
${P('78,50 122,50 134,96 66,96', c.m)}
${P('84,58 116,58 124,92 76,92','#0a0812')}
${P('88,68 98,66 98,74 88,76', c.e)}${P('102,66 112,68 112,76 102,74', c.e)}
${P('56,92 78,90 84,136 60,142', c.m)}
${P('122,92 146,96 150,136 128,140', c.m)}
${blades}
${P('96,138 104,138 104,168 96,168', c.a,'opacity=".7"')}`;
}
function heroSVG(cls, mode){
const c = mode==='hit' ? tintHit(HERO_COL[cls]) : HERO_COL[cls];
const fn = cls==='bastion'?drawBastion:cls==='wither'?drawWither:drawShroud;
const tf = mode==='attack' ? 'rotate(-5 100 218)' : mode==='hit' ? 'rotate(7 100 218)' : '';
let fx = '';
if(mode==='attack') fx = `<path d="M40 150 q-16 -60 30 -96" stroke="${c.e}" stroke-width="4" fill="none" opacity=".4" stroke-linecap="round"/>`;
if(mode==='hit') fx = `<path d="M58 44 l-14 -16 M86 30 l-3 -20 M120 40 l12 -18 M150 70 l20 -10" stroke="#ff9a80" stroke-width="3" opacity=".85" stroke-linecap="round"/>`;
return `<svg viewBox="0 0 200 240" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="100" cy="224" rx="54" ry="9" fill="#000" opacity=".5"/>
<g transform="${tf}">${fn(c,mode)}</g>${fx}</svg>`;
}
/* ============ ENEMY SPRITES (idle + hurt frame) ============ */
const ENEMY_COL = {
goblin:{ l:'#93b25c', m:'#5c7a3a', d:'#33461f', e:'#ffe36a' },
rat:{ l:'#948478', m:'#5a4a44', d:'#31251f', e:'#ff5a4a' },
vampireKnight:{ l:'#b04a6a', m:'#6d2740', d:'#35101f', e:'#ff3a5a' },
ogre:{ l:'#9c8a5a', m:'#6a5a36', d:'#3a3018', e:'#ffcc55' },
voidWarrior:{ l:'#7a6ad0', m:'#3d2e7a', d:'#1c1240', e:'#c08aff' },
cultist:{ l:'#c9c0b0', m:'#6a6258', d:'#2c2822', e:'#ffb054' },
gatekeeper:{ l:'#9aa8c0', m:'#4c5a72', d:'#1e2634', e:'#8fd0ff' },
corrupted:{ l:'#a05ac0', m:'#5a2470', d:'#260c34', e:'#ff7ae0' },
voidLord:{ l:'#8a6ae0', m:'#3a2070', d:'#150a2e', e:'#e0b0ff' }
};
function drawEnemy(kind, c){
switch(kind){
case 'goblin': return {w:140,h:152,art:`
${P('30,52 10,28 36,40', c.m)}${P('110,52 130,28 104,40', c.m)}
${P('42,36 98,36 104,74 50,78', c.m)}
${P('48,42 92,42 96,70 54,72', c.l)}
<circle cx="64" cy="56" r="5.2" fill="${c.e}"/><circle cx="82" cy="56" r="5.2" fill="${c.e}"/>
${P('60,66 88,66 86,75 62,75','#0c0a08','opacity=".85"')}
${P('64,67 67,74 61,74','#ece4cc')}${P('84,67 87,74 81,74','#ece4cc')}
${P('46,78 96,78 104,118 38,118', c.d)}
${P('54,82 88,82 92,114 50,114', c.m)}
${P('34,82 48,84 42,114 28,110', c.m)}
${P('96,84 110,86 118,110 106,114', c.m)}
${P('118,50 126,42 130,48 122,58','#c8d2dc')}
${P('46,118 64,118 62,142 44,142', c.d)}
${P('74,118 92,118 92,142 74,142', c.d)}
${P('38,140 66,140 68,150 36,150','#2b3016')}
${P('70,140 96,140 98,150 70,150','#2b3016')}`};
case 'rat': return {w:150,h:104,art:`
<path d="M118 62 q28 8 24 34 q-2 12-14 14" stroke="${c.d}" stroke-width="6" fill="none" stroke-linecap="round"/>
<path d="M36 62 q10-22 46-22 q42 0 48 20 q-6 20-48 20 q-36 0-46-18z" fill="${c.m}"/>
<path d="M40 56 q14-14 42-14 q30 0 38 14" fill="none" stroke="${c.l}" stroke-width="5" opacity=".7"/>
${C(30,42,9,c.m)}${C(42,38,7,c.m)}
${P('16,54 48,46 52,76 20,74', c.m)}
${P('20,50 42,46 44,66 22,66', c.l)}
<circle cx="31" cy="58" r="4.4" fill="${c.e}"/>
${P('16,66 26,66 23,78 14,74','#ece4cc')}
${P('52,84 74,84 74,98 50,98', c.d)}${P('92,84 112,84 112,98 90,98', c.d)}
<path d="M0 0" />`};
case 'vampireKnight': return {w:152,h:180,art:`
${P('46,58 104,58 134,174 16,174', c.d)}
${P('54,66 98,66 118,168 34,168', c.m,'opacity=".55"')}
${P('104,66 122,64 130,168 110,168', c.d,'opacity=".7"')}
${P('58,132 76,132 74,172 56,172', c.d)}${P('82,132 100,132 104,172 84,172', c.d)}
${P('48,72 102,72 98,140 52,140', c.m)}
${P('56,78 94,78 92,122 58,122', c.l)}
${P('74,80 78,80 78,132 74,132', c.d,'opacity=".6"')}
${P('30,66 56,62 60,88 32,94', c.m)}${P('94,62 120,66 116,94 88,88', c.m)}
${P('62,56 88,56 96,72 54,72', c.d)}
${P('58,24 92,24 98,64 52,64', c.m)}
${P('62,42 88,42 88,50 62,50','#0a0810')}
${P('65,43.5 73,43.5 73,48.5 65,48.5', c.e)}${P('78,43.5 86,43.5 86,48.5 78,48.5', c.e)}
${P('66,22 70,4 78,8 74,24', c.a||c.l)}${P('84,24 80,8 88,4 92,22', c.d)}
${P('110,30 122,26 126,124 114,128','#cfd8e0')}${P('104,30 130,26 130,34 104,38', c.d)}
<path d="M60 96 q42 10 56 -6" stroke="${c.e}" stroke-width="1.5" fill="none" opacity=".5"/>`};
case 'ogre': return {w:176,h:174,art:`
${P('44,140 74,140 70,172 42,172', c.d)}${P('100,140 130,140 132,172 102,172', c.d)}
${P('40,66 138,66 148,140 30,140', c.m)}
${P('54,74 124,74 130,132 48,132', c.l)}
${P('88,74 92,74 92,136 88,136', c.d,'opacity=".5"')}
${P('34,74 52,70 56,104 36,110', c.m)}${P('126,70 144,74 142,110 122,104', c.m)}
${P('62,26 116,26 124,68 54,68', c.m)}
${P('68,32 110,32 116,62 62,62', c.l)}
${C(52,50,8,c.m)}${C(126,50,8,c.m)}
<circle cx="76" cy="46" r="5" fill="${c.e}"/><circle cx="102" cy="46" r="5" fill="${c.e}"/>
${P('70,54 78,54 78,68 68,62','#ece4cc')}${P('100,54 108,54 110,62 100,68','#ece4cc')}
${P('138,10 166,2 172,166 148,172','#6d5334')}
${P('130,60 156,54 160,66 134,74', c.m)}`};
case 'voidWarrior': return {w:154,h:182,art:`
<circle cx="76" cy="96" r="70" fill="${c.e}" opacity=".07"/>
${P('52,60 100,60 118,176 30,176', c.d)}
${P('58,70 96,70 108,170 46,170', c.m)}
${P('66,80 90,80 92,140 64,140', c.l,'opacity=".55"')}
${P('70,88 86,88 88,128 68,128', c.e,'opacity=".35"')}
${P('60,132 78,132 76,174 58,174', c.d)}${P('82,132 100,132 102,174 84,174', c.d)}
${P('36,68 60,64 64,94 38,100', c.m)}${P('92,64 116,68 114,100 88,94', c.m)}
${P('62,24 90,24 96,66 56,66', c.m)}
${P('64,44 88,44 88,52 64,52','#06050c')}
${P('67,45.5 75,45.5 75,50.5 67,50.5', c.e)}${P('77,45.5 85,45.5 85,50.5 77,50.5', c.e)}
${P('60,24 44,2 56,0 68,22', c.l)}${P('92,24 108,2 96,0 84,22', c.l)}
${P('112,26 126,22 138,168 122,172','#2a2440')}
${P('118,26 138,20 142,30 122,38', c.e,'opacity=".75"')}`};
case 'cultist': return {w:134,h:172,art:`
${P('60,62 78,62 104,172 30,172', c.m)}
${P('64,70 74,70 94,168 42,168', c.d)}
${P('46,110 92,110 100,170 38,170', c.m,'opacity=".8"')}
${P('56,40 82,40 92,72 44,72', c.m)}
${P('60,44 78,44 86,70 50,70', c.d)}
${P('62,52 78,52 80,68 60,68','#d8d2c4')}
${P('64,56 68,56 68,62 64,62','#1a1512')}${P('72,56 76,56 76,62 72,62','#1a1512')}
${P('66,64 74,64 74,67 66,67','#1a1512')}
${P('40,74 60,72 64,108 44,112', c.m)}${P('80,72 98,76 96,108 78,106', c.m)}
${P('98,34 106,38 88,166 78,164','#3c3226')}
${P('98,22 110,10 118,22 108,32','#f0e0b0','opacity=".9"')}
<circle cx="106" cy="20" r="7" fill="${c.e}" opacity=".8"/>
${P('46,86 56,86 52,116 44,114', c.a||c.l)}`};
case 'gatekeeper': return {w:250,h:246,art:`
<circle cx="125" cy="120" r="104" fill="${c.e}" opacity=".06"/>
${P('56,196 96,196 92,242 52,242', c.d)}${P('154,196 194,196 198,242 158,242', c.d)}
${P('50,140 200,140 214,200 36,200', c.m)}
${P('64,150 186,150 194,196 56,196', c.l,'opacity=".8"')}
${P('84,90 166,90 156,152 94,152', c.m)}
${P('96,100 154,100 148,144 102,144', c.d)}
${P('112,110 138,110 138,140 112,140', c.e,'opacity=".4"')}
${P('40,94 84,86 92,132 46,140', c.m)}${P('166,86 210,94 204,140 158,132', c.m)}
${P('92,32 158,32 168,92 82,92', c.m)}
${P('100,50 150,50 150,62 100,62','#05070c')}
${P('104,52.5 120,52.5 120,59.5 104,59.5', c.e)}${P('130,52.5 146,52.5 146,59.5 130,59.5', c.e)}
${P('86,32 60,4 78,0 98,28', c.l)}${P('164,32 190,4 172,0 152,28', c.l)}
${P('118,32 132,32 130,0 120,0', c.l)}
<g transform="rotate(3 26 160)">
${P('2,96 54,104 54,214 2,236', c.m)}
${P('10,108 46,114 46,204 10,220', c.l,'opacity=".8"')}
${C(28,150,16,c.e,'opacity=".35"')}${C(28,150,7,c.a||c.l)}
<path d="M28 104 v112 M2 160 h52" stroke="${c.d}" stroke-width="3" opacity=".7"/>
</g>
${P('196,60 214,56 224,200 206,206','#3a4250')}`};
case 'corrupted': return {w:240,h:238,art:`
<circle cx="120" cy="118" r="106" fill="${c.e}" opacity=".07"/>
<path d="M120 18 q62 8 78 62 q16 54-14 90 q-30 36-64 36 q-34 0-64-36 q-30-36-14-90 q16-54 78-62z" fill="${c.m}"/>
<path d="M120 34 q50 8 62 52 q12 44-12 76 q-24 30-50 30 q-26 0-50-30 q-24-32-12-76 q12-44 62-52z" fill="${c.d}"/>
<path d="M120 44 q40 8 48 44 q8 36-10 62" fill="none" stroke="${c.l}" stroke-width="4" opacity=".55"/>
${C(92,96,15,c.l)}${C(92,96,7,'#150718')}
${C(150,104,11,c.l)}${C(150,104,5,'#150718')}
${C(120,142,20,c.l,'opacity=".9"')}${C(120,142,9,'#150718')}
<circle cx="92" cy="96" r="3.6" fill="${c.e}"/><circle cx="150" cy="104" r="3.2" fill="${c.e}"/>
<circle cx="120" cy="140" r="4" fill="${c.e}"/>
<path d="M74 180 q46 30 92 0 q-14 44-46 44 q-32 0-46-44z" fill="${c.l}" opacity=".45"/>
<path d="M120 196 l4 12 l-8 0z" fill="${c.e}"/>
<path d="M40 60 q-24-16-16-42 q22 4 30 26z" fill="${c.l}" opacity=".7"/>
<path d="M200 60 q24-16 16-42 q-22 4-30 26z" fill="${c.l}" opacity=".7"/>
<path d="M28 214 q20-18 34 4 M212 214 q-20-18-34 4" stroke="${c.l}" stroke-width="6" fill="none" opacity=".55" stroke-linecap="round"/>`};
default: return {w:250,h:252,art:`
<circle cx="125" cy="112" r="112" fill="${c.e}" opacity=".08"/>
<circle cx="125" cy="112" r="84" fill="none" stroke="${c.e}" stroke-width="1.5" opacity=".35"/>
<circle cx="125" cy="112" r="96" fill="none" stroke="${c.e}" stroke-width="1" opacity=".2" stroke-dasharray="4 9"/>
<path d="M125 40 q48 2 62 56 q-6 8-24 4 q10 40-38 58 q-48-18-38-58 q-18 4-24-4 q14-54 62-56z" fill="${c.d}"/>
<path d="M125 52 q38 2 50 44 q-8 2-20-2 q8 34-30 48 q-38-14-30-48 q-12 4-20 2 q12-42 50-44z" fill="${c.m}"/>
${P('104,108 118,104 118,118 104,120', c.e)}${P('132,104 146,108 146,120 132,118', c.e)}
<path d="M112 140 q13 10 26 0 q-13 18-26 0z" fill="#0a0414"/>
${P('125 148 130 162 120 162','#e8dcff','opacity=".8"')}
<path d="M92 44 l-22 -34 l30 8z" fill="${c.l}"/>
<path d="M158 44 l22 -34 l-30 8z" fill="${c.l}"/>
<path d="M125 34 l-10 -34 l10 8 l10 -8z" fill="${c.l}"/>
<path d="M125 6 l4 12 l-8 0z" fill="${c.e}"/>
<path d="M88 196 q-18 30-8 56 M104 200 q-8 30 0 54 M146 200 q8 30 0 54 M162 196 q18 30 8 56"
stroke="${c.m}" stroke-width="9" fill="none" stroke-linecap="round" opacity=".9"/>
<path d="M60 70 q-30 14-40 46 M190 70 q30 14 40 46" stroke="${c.m}" stroke-width="10" fill="none" stroke-linecap="round" opacity=".7"/>
<circle cx="125" cy="112" r="130" fill="none" stroke="#000" stroke-opacity="0"/>`};
}
}
function enemySVG(kind, hurt){
const col = hurt ? tintHit(ENEMY_COL[kind]) : ENEMY_COL[kind];
const o = drawEnemy(kind, col);
const tf = hurt ? 'translate(3,2) rotate(3 62 100)' : '';
const fx = hurt ? `<path d="M30 40 l-14 -16 M104 30 l10 -18 M140 90 l20 -8" stroke="#ffb0a0" stroke-width="3.5" opacity=".9" stroke-linecap="round"/>` : '';
return `<svg viewBox="0 0 ${o.w} ${o.h}" xmlns="http://www.w3.org/2000/svg">
<g transform="${tf}">${o.art}</g>${fx}</svg>`;
}
/* ============ THE 36 CORE CARDS ============ */
const CARDS = [
/* ---- THE BASTION ---- */
{id:'BA-A01',cls:'bastion',name:'Slam',type:'Attack',cost:2,atk:3,def:0,target:true,
desc:'Deal 3 damage. If you have \u2264 30 HP, draw 1.',
fx:(g,t)=>{ g.dmg(t,3); if(g.hero.hp<=30) g.draw(1); }},
{id:'BA-A02',cls:'bastion',name:'Earthquake',type:'Attack',cost:3,atk:4,def:0,target:false,
desc:'Deal 4 damage to ALL enemies.',
fx:(g)=>{ g.dmgAll(4); }},
{id:'BA-A03',cls:'bastion',name:'Stone Fist',type:'Attack',cost:1,atk:2,def:0,target:true,
desc:'Deal 2 damage.', fx:(g,t)=>{ g.dmg(t,2); }},
{id:'BA-A04',cls:'bastion',name:'Boulder',type:'Attack',cost:4,atk:6,def:0,target:true,
desc:'Deal 6 damage.', fx:(g,t)=>{ g.dmg(t,6); }},
{id:'BA-D01',cls:'bastion',name:'Shield Wall',type:'Defense',cost:0,atk:0,def:4,target:false,
desc:'Gain 4 Defense this turn.', fx:(g)=>{ g.gainBlock(4); }},
{id:'BA-D02',cls:'bastion',name:'Iron Skin',type:'Defense',cost:2,atk:0,def:5,target:false,
desc:'Gain 5 Defense permanently. Discard 1 card.', fx:(g)=>{ g.gainBlock(5,true); g.discardRandom(1); }},
{id:'BA-D03',cls:'bastion',name:'Stand Firm',type:'Defense',cost:1,atk:0,def:2,target:false,
desc:'Gain 2 Defense.', fx:(g)=>{ g.gainBlock(2); }},
{id:'BA-D04',cls:'bastion',name:'Taunt',type:'Defense',cost:2,atk:0,def:3,target:true,
desc:'Gain 3 Defense. Stun 1 enemy 1 turn.', fx:(g,t)=>{ g.gainBlock(3); g.stun(t,1); }},
{id:'BA-U01',cls:'bastion',name:'Search',type:'Other',cost:2,atk:0,def:0,target:false,
desc:'Draw 1 card.', fx:(g)=>{ g.draw(1); }},
{id:'BA-U02',cls:'bastion',name:'Repair',type:'Other',cost:3,atk:0,def:0,target:false,
desc:'Heal 6 HP.', fx:(g)=>{ g.heal(6); }},
{id:'BA-U03',cls:'bastion',name:'Rally',type:'Other',cost:1,atk:0,def:0,target:false,
desc:'Draw 1 card. Gain 1 Energy.', fx:(g)=>{ g.draw(1); g.gainEnergy(1); }},
{id:'BA-U04',cls:'bastion',name:'Loot',type:'Other',cost:2,atk:0,def:0,target:false,
desc:'Gain 5 Gold.', fx:(g)=>{ g.gold(5); }},
/* ---- THE WITHER ---- */
{id:'WI-A01',cls:'wither',name:'Fireball',type:'Attack',cost:2,atk:3,def:0,target:true,
desc:'Deal 3 damage. Apply 1 Burn.', fx:(g,t)=>{ g.dmg(t,3); g.applyStatus(t,'burn',1); }},
{id:'WI-A02',cls:'wither',name:'Inferno',type:'Attack',cost:3,atk:4,def:0,target:true,
desc:'Deal 4 damage. Apply 3 Burn.', fx:(g,t)=>{ g.dmg(t,4); g.applyStatus(t,'burn',3); }},
{id:'WI-A03',cls:'wither',name:'Ember',type:'Attack',cost:1,atk:2,def:0,target:true,
desc:'Deal 2 damage. Apply 1 Burn.', fx:(g,t)=>{ g.dmg(t,2); g.applyStatus(t,'burn',1); }},
{id:'WI-A04',cls:'wither',name:'Pyre',type:'Attack',cost:4,atk:5,def:0,target:true,
desc:'Deal 5 damage. ALL enemies take 1 next turn.', fx:(g,t)=>{ g.dmg(t,5); g.dmgAll(1); }},
{id:'WI-D01',cls:'wither',name:'Scorch Shield',type:'Defense',cost:2,atk:0,def:3,target:false,
desc:'Gain 3 Defense. Block 1 Burn.', fx:(g)=>{ g.gainBlock(3); g.hero.burnWard=true; }},
{id:'WI-D02',cls:'wither',name:'Heat Resist',type:'Defense',cost:1,atk:0,def:1,target:false,
desc:'Gain 1 Defense. Gain 1 Energy.', fx:(g)=>{ g.gainBlock(1); g.gainEnergy(1); }},
{id:'WI-D03',cls:'wither',name:'Smoke',type:'Defense',cost:2,atk:0,def:4,target:false,
desc:'Gain 4 Defense. Block 1 Burn.', fx:(g)=>{ g.gainBlock(4); g.hero.burnWard=true; }},
{id:'WI-D04',cls:'wither',name:'Endure',type:'Defense',cost:3,atk:0,def:2,target:false,
desc:'Gain 2 Defense. Heal 2 HP.', fx:(g)=>{ g.gainBlock(2); g.heal(2); }},
{id:'WI-U01',cls:'wither',name:'Poison',type:'Other',cost:2,atk:0,def:0,target:true,
desc:'Apply 1 Poison to target.', fx:(g,t)=>{ g.applyStatus(t,'poison',1); }},
{id:'WI-U02',cls:'wither',name:'Burn',type:'Other',cost:3,atk:0,def:0,target:false,
desc:'Deal 3 damage + 2 Burn to ALL enemies.', fx:(g)=>{ g.dmgAll(3); g.enemies.forEach(e=>g.applyStatus(e,'burn',2)); }},
{id:'WI-U03',cls:'wither',name:'Slow',type:'Other',cost:1,atk:0,def:0,target:true,
desc:'Draw 1 card. Stun target 1 turn.', fx:(g,t)=>{ g.draw(1); g.stun(t,1); }},
{id:'WI-U04',cls:'wither',name:'Wither',type:'Other',cost:2,atk:0,def:0,target:false,
desc:'Heal 5 HP. Reduce ALL enemy Attack by 1.', fx:(g)=>{ g.heal(5); g.enemies.forEach(e=>{ if(e.alive) e.atkDown+=1; }); }},
/* ---- THE SHROUD ---- */
{id:'SH-A01',cls:'shroud',name:'Backstab',type:'Attack',cost:2,atk:4,def:0,target:true,
desc:'Deal 4 damage. +1 if target has no Armor.', fx:(g,t)=>{ g.dmg(t,4+(t.block<=0?1:0)); }},
{id:'SH-A02',cls:'shroud',name:'Shadow Bolt',type:'Attack',cost:3,atk:5,def:0,target:true,
desc:'Deal 5 damage. Target cannot block.', fx:(g,t)=>{ g.dmg(t,5,{ignoreBlock:true}); }},
{id:'SH-A03',cls:'shroud',name:'Silence',type:'Attack',cost:1,atk:3,def:0,target:true,
desc:'Deal 3 damage. Target is Silenced.', fx:(g,t)=>{ g.dmg(t,3); g.silence(t,2); }},
{id:'SH-A04',cls:'shroud',name:'Vengeance',type:'Attack',cost:3,atk:6,def:0,target:true,
desc:'Deal 6 damage. Gain 2 Energy.', fx:(g,t)=>{ g.dmg(t,6); g.gainEnergy(2); }},
{id:'SH-D01',cls:'shroud',name:'Blur',type:'Defense',cost:3,atk:0,def:2,target:false,
desc:'Gain 2 Defense. Ignore the next attack.', fx:(g)=>{ g.gainBlock(2); g.hero.evade+=1; }},
{id:'SH-D02',cls:'shroud',name:'Step',type:'Defense',cost:2,atk:0,def:3,target:false,
desc:'Gain 3 Defense. Gain 1 Energy.', fx:(g)=>{ g.gainBlock(3); g.gainEnergy(1); }},
{id:'SH-D03',cls:'shroud',name:'Evasion',type:'Defense',cost:1,atk:0,def:1,target:false,
desc:'Gain 1 Defense. Skip enemy action.', fx:(g)=>{ g.gainBlock(1); g.hero.skipEnemy=true; }},
{id:'SH-D04',cls:'shroud',name:'Phantom',type:'Defense',cost:2,atk:0,def:4,target:false,
desc:'Gain 4 Defense. Ghost form 1 turn.', fx:(g)=>{ g.gainBlock(4); g.hero.evade+=1; }},
{id:'SH-U01',cls:'shroud',name:'Dash',type:'Other',cost:1,atk:0,def:0,target:false,
desc:'Gain 1 Energy. Skip enemy attack.', fx:(g)=>{ g.gainEnergy(1); g.hero.skipEnemy=true; }},
{id:'SH-U02',cls:'shroud',name:'Teleport',type:'Other',cost:2,atk:0,def:0,target:true,
desc:'Deal 2 damage. Gain 1 Defense.', fx:(g,t)=>{ g.dmg(t,2); g.gainBlock(1); }},
{id:'SH-U03',cls:'shroud',name:'Hide',type:'Other',cost:2,atk:0,def:0,target:false,
desc:'Gain 1 Defense. Discard 1. Draw 2.', fx:(g)=>{ g.gainBlock(1); g.discardRandom(1); g.draw(2); }},
{id:'SH-U04',cls:'shroud',name:'Assassinate',type:'Other',cost:3,atk:8,def:0,target:true,
desc:'Deal 8 damage. Cannot be blocked.', fx:(g,t)=>{ g.dmg(t,8,{ignoreBlock:true}); }}
];
const CARD_BY_ID = {}; CARDS.forEach(c=>CARD_BY_ID[c.id]=c);
/* ============ CLASS DEFINITIONS ============ */
const CLASSES = {
bastion:{name:'The Bastion', theme:'Immovable Object', hp:35, maxHp:35, energy:3, maxEnergy:5, hand:4,
blurb:'High Defense, high Block, low speed. Outlast the Void and crush it with AoE stone.',
start:['BA-A01','BA-A02','BA-A03','BA-A04','BA-D01','BA-D02','BA-D03','BA-D04','BA-U01','BA-U02','BA-U03','BA-U04']},
wither:{name:'The Wither', theme:'Rot & Burn', hp:30, maxHp:30, energy:4, maxEnergy:5, hand:4,
blurb:'Burn damage, debuff control, status effects. Fragile flesh, terrible fire.',
start:['WI-A01','WI-A02','WI-A03','WI-A04','WI-D01','WI-D02','WI-D03','WI-D04','WI-U01','WI-U02','WI-U03','WI-U04']},
shroud:{name:'The Shroud', theme:'Shadow & Speed', hp:30, maxHp:30, energy:4, maxEnergy:5, hand:5,
blurb:'High speed, evasion, assassination. Blink through the dark and end things quickly.',
start:['SH-A01','SH-A02','SH-A03','SH-A04','SH-D01','SH-D02','SH-D03','SH-D04','SH-U01','SH-U02','SH-U03','SH-U04']}
};
/* ============ ENEMY ROSTER ============ */
const ENEMY_POOL = {
1:[{kind:'goblin',names:['Goblin Cutthroat','Goblin Skirmisher','Mire Goblin','Rat-Thing','Sewer Rat','Bog Lurker','Grave Goblin']},
{kind:'rat',names:['Plague Rat','Gnashing Rat','Ward Rat','Carrion Rat']}],
2:[{kind:'vampireKnight',names:['Vampire Knight','Blood Knight','Crimson Duelist','Ashen Knight']},
{kind:'ogre',names:['Ogre Brute','Rotbelly Ogre','Cave Ogre','Slab Ogre']}],
3:[{kind:'voidWarrior',names:['Void Warrior','Void Sentinel','Nullblade','Umbral Champion']},
{kind:'cultist',names:['Void Cultist','Chanting Cultist','Hollow Priest','Ashen Zealot']}]
};
const FLOOR_STAT = {1:{hp:20,dmg:3},2:{hp:30,dmg:5},3:{hp:40,dmg:8}};
const BOSSES = [
{kind:'gatekeeper',name:'The Gatekeeper',hp:80,dmg:15,mech:'Blocks 5 damage per turn until HP < 20.'},
{kind:'corrupted',name:'The Corrupted',hp:80,dmg:15,mech:'Deals 20 damage if you have < 3 Energy.'},
{kind:'voidLord',name:'The Void Lord',hp:80,dmg:15,mech:'Regenerates 10 HP per turn. Cannot block.'}
];
const FLOORS = [
{name:'FLOOR I \u2014 THE SUNKEN WARD', sub:'Goblin warrens beneath the drowned quarter.', boss:0},
{name:'FLOOR II \u2014 THE CRIMSON SPIRES', sub:'Where the nobility learned to drink blood.', boss:1},
{name:'FLOOR III \u2014 THE HOLLOW THRONE', sub:'The Void itself has grown a court.', boss:2}
];
/* =====================================================================
GAME STATE
===================================================================== */
const G = {
phase:'menu', cls:'bastion', hero:null, deck:[], inv:{}, bestiary:[], enemies:[],
draw:[], hand:[], discard:[], floor:1, nodeIdx:0, roomsCleared:0, turn:1,
sel:null, busy:false, selEnemy:0, seq:[], shopAdvance:false, shopStock:[],
turnCycle:0, stats:{dmg:0,kills:0,cardsPlayed:0,turns:0}
};
const NODE_SEQ = ['combat','shop','combat','shop','combat','boss'];
const NODE_ICON = {combat:'\u25C6', shop:'\u25C7', boss:'\u2605'};
const el = id => document.getElementById(id);
const rnd = a => a[Math.floor(Math.random()*a.length)];
const ri = (a,b) => a + Math.floor(Math.random()*(b-a+1));
/* ---------- bestiary: 20 generated enemies ---------- */
function generateBestiary(){
const out=[]; const counts={1:7,2:7,3:6};
for(const f of [1,2,3]){
const st=FLOOR_STAT[f], pool=ENEMY_POOL[f];
for(let i=0;i<counts[f];i++){
const grp = rnd(pool);
out.push({id:'EN'+out.length, kind:grp.kind, name:rnd(grp.names), floor:f,
hp:Math.round(st.hp*(0.9+Math.random()*0.25)), dmg:st.dmg + (Math.random()<.3?1:0)});
}
}
return out;
}
function makeEnemy(kind,name,hp,dmg,boss){
return {kind,name,hp,maxHp:hp,dmg,baseDmg:dmg,block:0,atkDown:0,alive:true,boss:!!boss,
status:{burn:0,poison:0,stun:0,silence:0}, hurt:0, guard:0};
}
/* =====================================================================
ACTIONS AVAILABLE TO CARDS (passed as `g`)
===================================================================== */
const E = {
get hero(){ return G.hero; },
get enemies(){ return G.enemies; },
dmg(target, amount, opt={}){
const t = target || G.enemies.find(e=>e.alive);
if(!t || !t.alive){ return; }
let dealt = amount;
if(!opt.ignoreBlock && t.block>0){
const absorbed = Math.min(t.block, dealt); t.block -= absorbed; dealt -= absorbed;
if(absorbed>0) log(`\u25C6 ${t.name} blocks ${absorbed}.`,'def');
}
if(dealt>0){
t.hp -= dealt; t.hurt = 380; G.stats.dmg += dealt;
fxPop(t, '-'+dealt, '#ff7a5a'); fxSlash(t);
log(`\u2694 ${t.name} takes ${dealt} damage.`, 'dmg');
animateEnemy(t);
if(t.hp<=0){ t.hp=0; t.alive=false; G.stats.kills++;
log(`\u2716 ${t.name} is destroyed.`,'bad'); }
}
},
dmgAll(amount, opt={}){ G.enemies.slice().forEach(e=>{ if(e.alive) E.dmg(e,amount,opt); }); },
heal(n){ G.hero.hp = Math.min(G.hero.maxHp, G.hero.hp+n);
fxPop(HERO_EL(), '+'+n, '#8ce88c'); log(`\u2665 You recover ${n} HP.`, 'heal'); },
gainBlock(n, permanent){
if(permanent){ G.hero.permBlock += n; G.hero.block += n; }
else { G.hero.block += n; }
fxPop(HERO_EL(), 'DEF '+n, '#9cd0ff'); log(`\u25A3 You gain ${n} Defense${permanent?' (permanent)':''}.`,'def');
},
draw(n){ for(let i=0;i<n;i++) drawOne(); },
gainEnergy(n){ G.hero.energy = Math.min(G.hero.maxEnergy, G.hero.energy+n);
fxPop(el('energy-meter'), '+'+n, '#8fd0ff'); log(`\u2726 You gain ${n} Energy.`,'def'); },
gold(n){ G.hero.gold += n; fxPop(el('tb-gold'), '+'+n, '#f2dc9e'); log(`\u25CF You gain ${n} Gold.`,'gold'); },
applyStatus(t, kind, n){
if(!t||!t.alive) return;
t.status[kind] += n;
log(`\u2739 ${t.name} gains ${n} ${kind==='burn'?'Burn':'Poison'}.`,'burn');
},
stun(t, n){ if(!t||!t.alive) return; t.status.stun = Math.max(t.status.stun, n);
fxPop(t,'STUN','#ffd9a0'); log(`\u2727 ${t.name} is Stunned (${n}).`,'bad'); },
silence(t, n){ if(!t||!t.alive) return; t.status.silence = Math.max(t.status.silence, n);
log(`\u2687 ${t.name} is Silenced.`,'bad'); },
discardRandom(n){ for(let i=0;i<n && G.hand.length;i++){
const k = ri(0,G.hand.length-1); const c = G.hand.splice(k,1)[0]; G.discard.push(c);
log(`\u2716 You discard ${CARD_BY_ID[c].name}.`); } }
};
/* =====================================================================
RUN SETUP
===================================================================== */
function startRun(clsKey){
const k = CLASSES[clsKey];
G.cls = clsKey;
G.hero = {cls:clsKey, hp:k.hp, maxHp:k.maxHp, block:0, permBlock:0, energy:k.energy,
maxEnergy:k.maxEnergy, handSize:k.hand, gold:0, evade:0, skipEnemy:false, burnWard:false};
G.deck = k.start.slice();
G.inv = {}; G.deck.forEach(id=>G.inv[id]=(G.inv[id]||0)+1);
G.bestiary = generateBestiary();
G.floor = 1; G.nodeIdx = 0; G.roomsCleared = 0;
G.stats = {dmg:0,kills:0,cardsPlayed:0,turns:0};
clearLog();
log(`\u2720 ${CLASSES[clsKey].name} descends into Aethelgard.`,'void');
log(`\u25CF ${G.bestiary.length} creatures stir in the dark.`);
showScreen('map'); renderMap();
}
/* =====================================================================
COMBAT SETUP
===================================================================== */
function startCombat(isBoss){
G.enemies = [];
if(isBoss){
const b = BOSSES[G.floor-1];
G.enemies.push(makeEnemy(b.kind,b.name,b.hp,b.dmg,true));
log(`\u2620 ${b.name} rises. ${b.mech}`,'void');
} else {
const pool = G.bestiary.filter(e=>e.floor===G.floor);
const n = G.floor===1 ? (Math.random()<0.45?1:2) : (Math.random()<0.5?1:2);
const used=[];
for(let i=0;i<n;i++){
let pick = rnd(pool), guard=0;
while(used.includes(pick.id) && guard++<12) pick = rnd(pool);
used.push(pick.id);
const hp = Math.round(pick.hp*(0.9+Math.random()*0.2));
G.enemies.push(makeEnemy(pick.kind, pick.name, hp, pick.dmg));
}
log(`\u2694 Room ${G.nodeIdx%2===0?((G.nodeIdx/2)|0)+1:1}: ${G.enemies.map(e=>e.name).join(' and ')} block your path.`);
}
// build draw pile
G.draw = G.deck.slice().sort(()=>Math.random()-0.5);
G.hand = []; G.discard = [];
G.hero.block = 0; G.hero.evade = 0; G.hero.skipEnemy = false; G.hero.burnWard = false;
G.hero.energy = G.hero.energyBASE = CLASSES[G.cls].energy;
G.turn = 1; G.turnCycle = 0; G.sel = null; G.selEnemy = 0; G.busy = false;
G.phase = 'combat';
showScreen('combat');
renderCombat(true);
startPlayerTurn(true);
}
function startPlayerTurn(first){
G.hero.block = G.hero.permBlock;
G.hero.evade = 0; G.hero.skipEnemy = false; G.hero.burnWard = false;
if(!first) resetHeroSprite();
const n = Math.max(0, G.hero.handSize - G.hand.length);
if(n>0) log(`\u21BB You draw ${n} card${n>1?'s':''}.`);
for(let i=0;i<n;i++) drawOne();
el('turn-label').textContent = 'Your Turn';
G.busy = false;
renderCombat();
checkVictory();
}
function drawOne(){
if(G.draw.length===0){
if(G.discard.length===0) return false;
G.draw = G.discard.slice().sort(()=>Math.random()-0.5);
G.discard = [];
log('\u267B Your discard pile is shuffled back in.');
}
G.hand.push(G.draw.pop());
return true;
}
/* =====================================================================
PLAYING CARDS
===================================================================== */
function playCard(handIdx, target){
if(G.phase!=='combat' || G.busy) return;
const id = G.hand[handIdx]; if(!id) return;
const card = CARD_BY_ID[id];
if(card.cost > G.hero.energy){
log(`\u26A0 Not enough Energy for ${card.name} (needs ${card.cost}).`,'bad');
return;
}
if(card.target && !target){
target = G.enemies.find(e=>e.alive);
}
if(card.target && !target){ log('\u26A0 No valid target.','bad'); return; }
G.hero.energy -= card.cost;
G.hand.splice(handIdx,1); G.discard.push(id);
G.stats.cardsPlayed++;
log(`\u25B6 You play ${card.name} (${card.cost} Energy).`, card.type==='Attack'?'dmg':card.type==='Defense'?'def':'void');
if(card.type==='Attack') animateHero('attack');
else if(card.type==='Defense') { fxPop(HERO_EL(), 'DEF', '#9cd0ff'); }
G.sel = null;
card.fx(E, target);
renderCombat();
checkVictory();
}
/* =====================================================================
END TURN → ENEMY PHASE
===================================================================== */
function endTurn(){
if(G.phase!=='combat' || G.busy) return;
G.busy = true;
G.sel = null;
// discard hand
G.discard.push(...G.hand); G.hand = [];
el('turn-label').textContent = 'Enemy Phase';
renderCombat();
setTimeout(enemyPhase, 320);
}
function enemyPhase(){
if(G.phase!=='combat') return;
const h = G.hero;
G.enemies.forEach(e=>e.block = 0);
enemyBlockStep();
const skipAll = h.skipEnemy;
if(skipAll) log('\u2727 You slip out of reach \u2014 all enemy attacks are skipped.','def');
G.enemies.forEach(e=>{
if(!e.alive) return;
// damage over time
['burn','poison'].forEach(k=>{
if(e.status[k]>0){
const d = e.status[k];
e.hp -= d; G.stats.dmg += d;
fxPop(e,'-'+d, k==='burn'?'#ffc46a':'#a6e07a');
log(`${k==='burn'?'\u2668 Burn':'\u2623 Poison'} sears ${e.name} for ${d}.`, k==='burn'?'burn':'heal');
e.status[k] = Math.max(0, e.status[k]-1);
if(e.hp<=0){ e.hp=0; e.alive=false; G.stats.kills++; log(`\u2716 ${e.name} succumbs.`,'bad'); }
}
});
if(!e.alive) return;
if(e.status.stun>0){ e.status.stun--; log(`\u2727 ${e.name} is stunned and cannot act.`,'def'); return; }
if(skipAll) return;
if(h.evade>0){ h.evade--; log(`\u2601 ${e.name}'s attack phases through you.`,'def'); return; }
// boss mechanics
let dmg = Math.max(0, e.dmg - e.atkDown);
if(e.kind==='corrupted' && h.energy < 3 && e.status.silence<=0){
dmg = 20; log('\u2620 The Corrupted surges \u2014 you are starved of Energy!','bad');
}
if(e.kind==='corrupted' && h.energy>=3) log('\u2620 The Corrupted hungers, but your Energy holds it back.');
if(h.burnWard){ dmg = Math.max(0,dmg-1); log('\u25A3 Your ward absorbs 1 damage.','def'); }
let dealt = dmg;
if(h.block>0){
const ab = Math.min(h.block, dealt); h.block -= ab; dealt -= ab;
if(ab>0) log(`\u25A3 Your Defense absorbs ${ab}.`,'def');
}
if(dealt>0){
h.hp -= dealt;
fxPop(HERO_EL(), '-'+dealt, '#ff6a4a');
shake(el('hero-side')); animateHero('hit'); flashScreen();
log(`\u2620 ${e.name} strikes you for ${dealt}!`,'bad');
} else {
log(`\u25A3 ${e.name} attacks, but you are unharmed.`,'def');
}
if(e.status.silence>0) e.status.silence--;
});
h.evade=0; h.skipEnemy=false; h.burnWard=false;
renderCombat();
if(h.hp<=0){ h.hp=0; renderCombat(); return gameOver('You have fallen to the Void.'); }
if(checkVictory()) return;
// enemy turn ends: recover 1 energy, new player turn
G.turn++;
h.energy = Math.min(h.maxEnergy, h.energy + 1);
log(`\u2726 You recover 1 Energy (${h.energy}/${h.maxEnergy}).`,'def');
G.busy = false;
setTimeout(()=>{ if(G.phase==='combat') startPlayerTurn(); }, 260);
}
function enemyBlockStep(){
G.enemies.forEach(e=>{
if(!e.alive) return;
if(e.kind==='gatekeeper' && e.hp >= 20 && e.status.silence<=0){
e.block += 5; log('\u25A3 The Gatekeeper raises its bulwark (+5 Defense).','def');
}
if(e.kind==='voidLord' && e.status.silence<=0 && e.hp < e.maxHp){
const heal = Math.min(10, e.maxHp-e.hp); e.hp += heal;
if(heal>0){ fxPop(e,'+'+heal,'#c48cff'); log(`\u2620 The Void Lord drinks the dark (+${heal} HP).`,'void'); }
}
if(e.block>0 && e.kind!=='gatekeeper') e.block = 0;
});
}
// hook boss steps at the start of the enemy phase
function checkVictory(){
if(G.phase!=='combat') return false;
if(G.enemies.some(e=>e.alive)) return false;
G.phase = 'won';
G.busy = true;
const boss = G.enemies.some(e=>e.boss);
const g = (boss?15:5)*G.enemies.length;
E.gold(g);
G.hero.hp = Math.min(G.hero.maxHp, G.hero.hp + (boss?5:3));
setTimeout(()=>victoryScreen(boss,g), 700);
return true;
}
function gameOver(reason){
G.phase='over';
showOverlay(`<h2 style="background:linear-gradient(#ffb0a0,#8a2018);-webkit-background-clip:text;background-clip:text;color:transparent">YOU DIE</h2>
<p>${reason}</p>
<p style="color:#c9a2ff">The Void claims another hero. Floor ${G.floor} \u00B7 ${G.roomsCleared} rooms cleared.</p>
<div class="row"><button class="btn gold big" id="btn-retry">Return to Menu</button></div>`);
el('btn-retry').onclick = ()=>{ hideOverlay(); showScreen('menu'); };
}
function victoryScreen(boss,gold){
const done = boss && G.floor===3;
const heal = boss?5:3;
showOverlay(`<h2>${done?'AETHELGARD STANDS':(boss?'FLOOR CLEARED':'ROOM CLEARED')}</h2>
<p>${done ? 'The Void Lord is unmade. Light returns to the drowned world.'
: boss ? 'The Void Lord falls. The stair descends further into the dark.'
: 'The way ahead is clear. The dark draws breath again.'}</p>
<p style="color:#f2dc9e">Gold plundered: +${gold} (total ${G.hero.gold}) · Recovered +${heal} HP</p>
<div class="vestats">
<span class="chip hp">HP ${G.hero.hp}/${G.hero.maxHp}</span>
<span class="chip">Damage Dealt ${G.stats.dmg}</span>
<span class="chip">Kills ${G.stats.kills}</span>
<span class="chip">Cards Owned ${G.deck.length}</span>
</div>
<div class="row">
<button class="btn gold big" id="btn-next">${done?'Claim Victory':'Continue'}</button>
<button class="btn" id="btn-shop2">Gold Shop</button>
</div>`);
if(done){
el('btn-next').onclick = ()=>{ hideOverlay(); showScreen('menu'); };
} else {
el('btn-next').onclick = ()=>{ hideOverlay(); advanceNode(); };
}
el('btn-shop2').onclick = ()=>{ G.shopAdvance = true; hideOverlay(); openShop(); };
G.phase = 'won';
}
function advanceNode(){
G.nodeIdx++;
if(G.nodeIdx >= NODE_SEQ.length){ G.floor++; G.nodeIdx = 0; }
if(G.floor>3){ showScreen('menu'); return; }
G.phase='map';
showScreen('map'); renderMap();
}
/* =====================================================================
UI HELPERS
===================================================================== */
const TYPE_COL = {Attack:'#ffb0a0', Defense:'#a8d8ff', Other:'#c9a2ff'};
const SPRITE_H = {gatekeeper:206,corrupted:196,voidLord:214,ogre:154,vampireKnight:152,
voidWarrior:152,cultist:146,goblin:124,rat:100};
function HERO_EL(){ return document.querySelector('#hero-side .sprite'); }
const GLYPH_FIX = {'\u2694':'\u00BB','\u25A3':'\u25C6','\u2726':'\u25C6','\u2739':'*','\u2727':'\u2605',
'\u2687':'*','\u2601':'~','\u2620':'!','\u2668':'*','\u2623':'*','\u2720':'\u2605','\u267B':'\u21BB',
'\u26A0':'!','\u25B6':'>','\u25C8':'\u2022'};
function log(msg, kind=''){
msg = String(msg).replace(/[\u2694\u25A3\u2726\u2739\u2727\u2687\u2601\u2620\u2668\u2623\u2720\u267B\u26A0\u25B6\u25C8]/g,
ch => GLYPH_FIX[ch] || ch);
const l = el('log'); const d = document.createElement('div');
d.className = 'l ' + kind; d.innerHTML = msg;
l.appendChild(d);
while(l.children.length > 140) l.removeChild(l.firstChild);
l.scrollTop = l.scrollHeight;
}
function clearLog(){ el('log').innerHTML = ''; }
function showScreen(name){
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
el('screen-'+name).classList.add('active');
}
function showOverlay(html){ el('modal').innerHTML = html; el('overlay').classList.add('on'); }
function hideOverlay(){ el('overlay').classList.remove('on'); }
function shake(node){ if(!node) return; node.classList.remove('shake'); void node.offsetWidth; node.classList.add('shake'); }
function flashScreen(){
const a = el('arena'); a.classList.remove('flash'); void a.offsetWidth; a.classList.add('flash');
}
function nodeOf(t){
if(!t) return null;
if(t.nodeType===1) return t;
return t.el || null;
}
function fxPop(t, text, color){
const n = nodeOf(t); if(!n) return;
const r = n.getBoundingClientRect();
const d = document.createElement('div');
d.className = 'pop'; d.textContent = text; d.style.color = color || '#fff';
d.style.left = (r.left + r.width/2) + 'px';
d.style.top = (r.top + r.height*0.3) + 'px';
el('fx').appendChild(d);
setTimeout(()=>d.remove(), 1000);
}
function fxSlash(t){
const n = nodeOf(t); if(!n) return;
const r = n.getBoundingClientRect();
const d = document.createElement('div');
d.className = 'slash';
d.style.left = (r.left + r.width/2) + 'px';
d.style.top = (r.top + r.height/2) + 'px';
d.innerHTML = `<svg viewBox="0 0 100 100" style="width:100%;height:100%">
<path d="M6 80 Q50 4 94 20" stroke="#ffd9c0" stroke-width="7" fill="none" stroke-linecap="round" opacity=".95"/>
<path d="M18 90 Q56 30 88 42" stroke="#fff" stroke-width="3" fill="none" opacity=".7"/></svg>`;
el('fx').appendChild(d);
setTimeout(()=>d.remove(), 470);
}
function resetHeroSprite(){
const n = HERO_EL(); if(!n) return;
clearTimeout(n._t); n.innerHTML = heroSVG(G.cls,'idle');
}
function animateHero(mode){
const n = HERO_EL(); if(!n) return;
n.innerHTML = heroSVG(G.cls, mode);
if(mode !== 'idle'){
clearTimeout(n._t);
n._t = setTimeout(()=>{ const m = HERO_EL(); if(m) m.innerHTML = heroSVG(G.cls,'idle'); }, 640);
}
}
function animateEnemy(e){
e.hurtUntil = Date.now() + 400;
renderEnemies();
clearTimeout(e._t);
e._t = setTimeout(()=>renderEnemies(), 430);
}
/* =====================================================================
CARD RENDER
===================================================================== */
function cardInner(c){
return `<div class="frame">${frameSVG(c.cls)}</div>
<div class="ov">
<div class="c-cost">${c.cost}</div>
<div class="c-name">${c.name.toUpperCase()}</div>
<div class="c-type" style="color:${TYPE_COL[c.type]}">${c.type}${c.target?' \u2022 target':''}</div>
<div class="c-desc">${c.desc}</div>
<div class="c-stats"><span class="s sa">ATK ${c.atk||'\u2013'}</span><span class="s sd">DEF ${c.def||'\u2013'}</span></div>
</div>`;
}
let _lastHand = [];
function renderHand(){
const box = el('hand');
box.innerHTML = G.hand.map((id,i)=>{
const c = CARD_BY_ID[id];
const playable = c.cost <= G.hero.energy && G.phase==='combat' && !G.busy;
const sel = G.sel === i;
const isNew = _lastHand[i] !== id;
return `<div class="card t-${c.type.toLowerCase()} cls-${c.cls}
${playable?'playable':'unplayable'} ${sel?'selected t-left t-right':''} ${isNew?'dealing':''}"
data-h="${i}" ${isNew?`style="animation-delay:${i*45}ms"`:''}>${cardInner(c)}</div>`;
}).join('');
_lastHand = G.hand.slice();
el('hand-count').textContent = `${G.hand.length} / ${G.hero.handSize}`;
}
function renderEnemies(){
const box = el('enemies');
if(!G.enemies.length){ box.innerHTML = ''; return; }
box.innerHTML = G.enemies.map((e,i)=>{
const hurt = e.hurtUntil && Date.now() < e.hurtUntil;
const h = SPRITE_H[e.kind] || 140;
let b = `<span class="badge b-dmg" title="Attack range">ATK ${Math.max(0, e.dmg - e.atkDown)}</span>`;
if(e.block>0) b += `<span class="badge b-blk">DEF ${e.block}</span>`;
if(e.status.burn>0) b += `<span class="badge b-burn">BURN ${e.status.burn}</span>`;
if(e.status.poison>0) b += `<span class="badge b-pois">PSN ${e.status.poison}</span>`;
if(e.status.stun>0) b += `<span class="badge b-stun">STUN ${e.status.stun}</span>`;
if(e.status.silence>0)b += `<span class="badge b-sil">SIL</span>`;
return `<div class="unit enemy ${i===G.selEnemy?'targeted':''} ${e.alive?'':'dead'}" data-i="${i}">
<div class="badges">${b}</div>
<div class="sprite" style="height:${h}px">${enemySVG(e.kind, hurt)}</div>
<div class="ename">${e.name}</div>
<div class="hpbar"><i style="width:${Math.max(0,e.hp/e.maxHp*100)}%"></i>
<span>${e.hp} / ${e.maxHp}</span></div>
</div>`;
}).join('');
G.enemies.forEach((e,i)=>{ e.el = box.children[i] || null; });
}
function renderEnergy(){
const m = el('energy-meter');
let s = '';
for(let i=0;i<G.hero.maxEnergy;i++) s += `<div class="pip ${i<G.hero.energy?'on':''}"></div>`;
m.innerHTML = s;
}
function renderPiles(){
el('pile-info').innerHTML =
`Deck <b>${G.draw.length}</b> · Hand <b>${G.hand.length}</b><br>
Discard <b>${G.discard.length}</b> · Turn <b>${G.turn}</b><br>
<span style="color:#7fd8ff">Energy regen +1 / turn</span>`;
}
function updateHeroSide(){
const h = G.hero;
const f = el('hero-hpfill'), t = el('hero-hptext');
if(f) f.style.width = Math.max(0,h.hp/h.maxHp*100)+'%';
if(t) t.textContent = `${h.hp} / ${h.maxHp}`;
const b = el('hero-block');
if(b) b.textContent = 'DEF ' + h.block + (h.permBlock ? ' (perm '+h.permBlock+')' : '');
const e2 = el('hero-extra');
if(e2) e2.innerHTML =
(h.evade>0?`<span class="badge b-blk">EVADE x${h.evade}</span>`:'') +
(h.skipEnemy?`<span class="badge b-stun">SKIP</span>`:'') +
(h.burnWard?`<span class="badge b-burn">WARD</span>`:'') +
`<span class="badge b-dmg">DECK ${G.deck.length}</span>`;
}
function buildHeroSide(){
const cls = G.cls;
el('hero-side').innerHTML = `
<div class="unit">
<div class="sprite" style="height:152px">${heroSVG(cls,'idle')}</div>
<div class="hero-name">${CLASSES[cls].name}</div>
<div class="hero-hp"><div class="hpbar"><i id="hero-hpfill"></i><span id="hero-hptext"></span></div></div>
<div class="badges" style="justify-content:center">
<span class="badge b-blk" id="hero-block">DEF 0</span>
</div>
<div class="badges" id="hero-extra" style="justify-content:center;flex-wrap:wrap;max-width:170px"></div>
</div>`;
}
function renderCombat(fresh){
if(fresh) buildHeroSide();
updateHeroSide();
renderEnemies();
renderHand();
renderHUD();
renderEnergy();
renderPiles();
}
function renderHUD(){
el('tb-gold').querySelector('.val').textContent = G.hero.gold;
el('tb-floor').querySelector('.val').textContent = G.floor;
const isBoss = G.enemies.some(e=>e.boss);
el('tb-room').querySelector('.val').textContent =
isBoss ? 'BOSS' : (G.nodeIdx%2===0 ? ((G.nodeIdx/2)+1) : 1) + '/3';
el('tb-target').innerHTML = G.enemies.map(e=>`
<div class="boss-hp" style="min-width:${e.boss?200:112}px">
<div class="bhp-name" style="${e.boss?'':'color:#cbbf9f'}">${e.alive?'':'\u2716 '}${e.name}</div>
<div class="bhp-bar"><div class="bhp-fill" style="width:${Math.max(0,e.hp/e.maxHp*100)}%;
${e.boss?'':'background:linear-gradient(90deg,#8b2f1f,#e06a3a)'}"></div></div>
</div>`).join('');
}
/* =====================================================================
MAP
===================================================================== */
function renderMap(){
const f = FLOORS[G.floor-1];
el('map-floor').textContent = f.name;
el('map-sub').textContent = f.sub;
el('map-track').innerHTML = NODE_SEQ.map((n,i)=>{
const state = i < G.nodeIdx ? 'done' : i === G.nodeIdx ? 'current' : 'locked';
const label = n==='combat' ? 'ROOM ' + ((i>>1)+1) : n==='shop' ? 'SHOP' : 'BOSS';
const sub = n==='combat' ? `${FLOOR_STAT[G.floor].hp} HP \u00B7 ${FLOOR_STAT[G.floor].dmg} DMG`
: n==='shop' ? 'cards for gold' : BOSSES[G.floor-1].name;
return `<div class="node ${state} ${n==='boss'?'boss':''}">
<div class="ico">${NODE_ICON[n]}</div>
<div style="letter-spacing:.1em">${label}</div>
<div style="font-size:9px;opacity:.7;text-align:center;padding:0 4px">${sub}</div>
</div>${i<NODE_SEQ.length-1?'<div class="link"></div>':''}`;
}).join('');
const n = NODE_SEQ[G.nodeIdx];
const b = el('btn-advance');
b.textContent = n==='boss' ? 'Face the Boss' : n==='shop' ? 'Enter the Shop' : 'Enter Room';
b.disabled = false;
el('map-hero').innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
<div style="height:130px;width:108px">${heroSVG(G.cls,'idle')}</div>
<div class="hero-name">${CLASSES[G.cls].name}</div>
</div>
<div style="display:flex;flex-direction:column;gap:7px;align-items:flex-start;justify-content:center">
<span class="chip hp">HP ${G.hero.hp} / ${G.hero.maxHp}</span>
<span class="chip en">Energy ${G.hero.energy} / ${G.hero.maxEnergy}</span>
<span class="chip">Gold ${G.hero.gold} \u25CF</span>
<span class="chip">Deck ${G.deck.length} cards \u00B7 Hand ${G.hero.handSize}</span>
<span class="chip">Bestiary: ${G.bestiary.length} foes \u00B7 Floor ${G.floor} of 3</span>
</div>`;
}
/* =====================================================================
SHOP
===================================================================== */
function cardPrice(c){
const owned = G.inv[c.id] || 0;
const base = c.cost<=1 ? 5 : c.cost===2 ? 7 : c.cost===3 ? 9 : 11;
let p = base + owned; // Cost = Base + Count
if(owned >= 3) p = Math.max(p, 10); // duplication fee
return Math.min(50, p);
}
function openShop(from){
G.shopFrom = from || 'map';
G.phase = 'shop';
showScreen('shop');
renderShop();
}
function renderShop(){
el('shop-gold').textContent = G.hero.gold;
el('shop-grid').innerHTML = CARDS.map(c=>{
const owned = G.inv[c.id] || 0;
const maxed = owned >= 5;
const price = cardPrice(c);
const afford = G.hero.gold >= price && !maxed;
return `<div class="slot ${maxed?'sold':''}" data-id="${c.id}">
<div class="card t-${c.type.toLowerCase()} cls-${c.cls}" style="pointer-events:none">${cardInner(c)}</div>
<div style="display:flex;gap:9px;align-items:center;font-size:12px;justify-content:center">
<span class="chip">Owned ${owned}/5</span>
<span class="price">${maxed?'\u2014':price+' gp'}</span>
</div>
<button class="btn buy ${afford?'gold':''}" data-buy="${c.id}" ${afford?'':'disabled'}>
${maxed?'Limit 5':afford?'Buy':'Need '+(price-G.hero.gold)+' gp'}</button>
</div>`;
}).join('');
el('shop-note').innerHTML = `Price = base + copies owned \u00B7 duplicate fee of 10 gp once you hold 3 copies \u00B7 limit 5 copies.
You hold <b style="color:#f2dc9e">${G.hero.gold}</b> gold. Earn 5 gold per kill, 15 per boss.`;
}
function buy(id){
const c = CARD_BY_ID[id], price = cardPrice(c), owned = G.inv[id] || 0;
if(owned >= 5){ log('\u26A0 Card limit reached.','bad'); return; }
if(G.hero.gold < price){ log('\u26A0 Not enough gold.','bad'); return; }
G.hero.gold -= price; G.inv[id] = owned+1; G.deck.push(id);
log(`\u25CF Bought ${c.name} for ${price} gold (now ${G.inv[id]}/5).`,'gold');
renderShop();
}
/* =====================================================================
MENU + RULES
===================================================================== */
function renderMenu(){
el('class-picker').innerHTML = Object.keys(CLASSES).map(k=>{
const c = CLASSES[k];
return `<div class="cls-card" data-cls="${k}">
<div class="back">${backSVG(k)}</div>
<h3>${c.name}</h3>
<div class="theme">${c.theme}</div>
<div class="cls-stats">
<span class="chip hp">HP ${c.hp}</span>
<span class="chip en">Energy ${c.energy}/${c.maxEnergy}</span>
<span class="chip">Hand ${c.hand}</span>
</div>
<p>${c.blurb}</p>
<button class="btn gold" style="width:100%">Choose</button>
</div>`;
}).join('');
}
function showRules(){
showOverlay(`<h2>RULES OF THE VOID</h2>
<div class="rules">
<h3>Turn order</h3>
<b>1.</b> Draw up to your Hand Size <b>2.</b> Play cards while Energy allows
<b>3.</b> End Turn → enemies act <b>4.</b> Recover 1 Energy, new turn.
<h3>Numbers</h3>
Damage = card Attack. Incoming damage = Attack − Defense (Block). Block is consumed and
refreshes to your permanent Defense at the start of your turn.
<h3>Status</h3>
<b>Burn</b> and <b>Poison</b> deal their stack count at the start of the enemy phase, then fade by 1.
<b>Stun</b> makes an enemy miss a turn. <b>Silence</b> shuts down a boss mechanic.
<h3>Economy</h3>
5 gold per enemy, 15 per Void Lord. Shop price = base + copies owned (min 10 gp past three copies,
limit 5 copies). Earn gold to grow your deck.
<h3>Floors</h3>
Three floors, each with three rooms and a Void Lord. Nine rooms, three bosses, then Aethelgard stands.
<b>The Gatekeeper</b> blocks 5 per turn until it is wounded. <b>The Corrupted</b> deals 20 damage if you
hold fewer than 3 Energy. <b>The Void Lord</b> regenerates 10 HP every turn and can never block.
<h3>Controls</h3>
Click a card to play it. Cards marked \u25C8 ask for a target — click an enemy.
Keys <b>1-5</b> play from hand, <b>Space</b> ends the turn.
</div>
<div class="row"><button class="btn gold" id="btn-close-modal">Close</button></div>`);
el('btn-close-modal').onclick = hideOverlay;
}
/* =====================================================================
INTERACTION
===================================================================== */
function onHandClick(i){
if(G.phase!=='combat' || G.busy) return;
const c = CARD_BY_ID[G.hand[i]];
if(!c) return;
if(c.cost > G.hero.energy){ log(`\u26A0 Not enough Energy for ${c.name} (needs ${c.cost}).`,'bad'); renderHand(); return; }
const alive = G.enemies.filter(e=>e.alive);
if(c.target && alive.length > 1){
G.sel = (G.sel === i) ? null : i;
el('turn-label').textContent = G.sel != null ? 'Choose a target' : 'Your Turn';
renderHand(); renderEnemies();
} else {
playCard(i, alive[0]);
}
}
function onEnemyClick(i){
if(G.phase!=='combat' || G.busy) return;
const e = G.enemies[i];
if(!e || !e.alive) return;
G.selEnemy = i;
if(G.sel != null){ const idx = G.sel; G.sel = null; playCard(idx, e); }
else { el('turn-label').textContent = 'Your Turn'; renderEnemies(); }
}
function enterNode(){
const n = NODE_SEQ[G.nodeIdx];
if(n === 'shop'){ G.shopAdvance = true; openShop('map'); return; }
startCombat(n === 'boss');
}
function closeShop(){
hideOverlay();
if(G.shopFrom === 'combat'){
G.phase = 'combat'; showScreen('combat'); renderCombat(); return;
}
if(G.shopAdvance){
G.shopAdvance = false; advanceNode(); return;
}
G.phase = 'map'; showScreen('map'); renderMap();
}
function onKey(ev){
if(el('overlay').classList.contains('on')) return;
if(G.phase !== 'combat') return;
if(ev.key === ' ' || ev.key === 'Enter'){ ev.preventDefault(); endTurn(); return; }
const n = parseInt(ev.key, 10);
if(n >= 1 && n <= 9 && G.hand[n-1] !== undefined) onHandClick(n-1);
}
function init(){
renderMenu();
showScreen('menu');
el('btn-rules').onclick = showRules;
el('btn-advance').onclick = enterNode;
el('btn-shop-map').onclick = ()=>{ G.shopAdvance = false; openShop('map'); };
el('btn-endturn').onclick = endTurn;
el('btn-shop').onclick = ()=>{ G.shopFrom='combat'; openShop('combat'); };
el('btn-leave').onclick = closeShop;
el('btn-concede').onclick = ()=>{
showOverlay(`<h2>FLEE THE DARK?</h2><p>Conceding ends the run.</p>
<div class="row"><button class="btn gold" id="yes-concede">Concede</button>
<button class="btn" id="no-concede">Fight on</button></div>`);
el('yes-concede').onclick = ()=>{ hideOverlay(); gameOver('You fled the dark.'); };
el('no-concede').onclick = hideOverlay;
};
el('class-picker').onclick = e=>{
const n = e.target.closest('.cls-card'); if(!n) return;
startRun(n.dataset.cls);
};
el('hand').onclick = e=>{
const n = e.target.closest('.card'); if(!n) return;
onHandClick(parseInt(n.dataset.h,10));
};
el('enemies').onclick = e=>{
const n = e.target.closest('.enemy'); if(!n) return;
onEnemyClick(parseInt(n.dataset.i,10));
};
el('shop-grid').onclick = e=>{
const b = e.target.closest('[data-buy]'); if(!b) return;
buy(b.dataset.buy);
};
document.addEventListener('keydown', onKey);
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"2d594365-9a1a-4bd1-9a84-b9c5110664f1","tokensIn":9833009,"tokensOut":106767,"tokensTotal":9939776,"cost":0.108863838,"turns":69,"toolCalls":68,"failedToolCalls":0,"timestamp":"2026-09-10T22:38:32.365Z"} -->