Deckbuilder
Qwen 3.6 35b a3b · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 82,914 bytes · SHA-256 5b57ef6febae
<!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>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; }
#app { width: 100%; height: 100%; position: relative; }
#game-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
#game-canvas { width: 100%; height: 100%; display: block; }
#ui-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#card-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5; }
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; transition: opacity 0.5s; }
.screen.hidden { display: none; }
/* ── css: start-screen ── */
#start-screen {
background: radial-gradient(ellipse at center, #1a1025 0%, #0a0a0f 70%);
font-family: 'Georgia', serif;
}
#start-screen::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.9' numOctaves='4'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
opacity: 0.5;
}
.game-title {
font-size: 3.5rem;
color: #c9a84c;
text-shadow: 0 0 30px rgba(201, 168, 76, 0.5), 0 0 60px rgba(201, 168, 76, 0.2);
letter-spacing: 0.15em;
margin-bottom: 0.5rem;
position: relative;
z-index: 1;
}
.subtitle {
font-size: 1.2rem;
color: #6b5b8a;
letter-spacing: 0.3em;
text-transform: uppercase;
margin-bottom: 3rem;
position: relative;
z-index: 1;
}
.class-select {
display: flex;
gap: 1.5rem;
margin-bottom: 2.5rem;
position: relative;
z-index: 1;
}
.class-option {
background: linear-gradient(145deg, #1a1a2e, #16162a);
border: 1px solid #2a2a4a;
border-radius: 8px;
padding: 1.5rem;
width: 200px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.class-option::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.class-option:hover {
transform: translateY(-4px);
border-color: #c9a84c;
}
.class-option:hover::before { opacity: 0.1; }
.class-option.selected {
border-color: #c9a84c;
box-shadow: 0 0 20px rgba(201, 168, 76, 0.3);
}
.class-option.selected::before { opacity: 0.1; }
.class-option.bastion-option.selected { border-color: #4a8fa8; box-shadow: 0 0 20px rgba(74, 143, 168, 0.3); }
.class-option.wither-option.selected { border-color: #c44a4a; box-shadow: 0 0 20px rgba(196, 74, 74, 0.3); }
.class-option.shroud-option.selected { border-color: #8a4ac4; box-shadow: 0 0 20px rgba(138, 74, 196, 0.3); }
.class-icon {
width: 60px;
height: 60px;
margin: 0 auto 1rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.bastion-icon {
background: radial-gradient(circle, #4a8fa8, #2a5a6a);
box-shadow: 0 0 15px rgba(74, 143, 168, 0.4);
}
.wither-icon {
background: radial-gradient(circle, #c44a4a, #6a2a2a);
box-shadow: 0 0 15px rgba(196, 74, 74, 0.4);
}
.shroud-icon {
background: radial-gradient(circle, #8a4ac4, #4a2a6a);
box-shadow: 0 0 15px rgba(138, 74, 196, 0.4);
}
.class-option h3 {
color: #c9a84c;
font-size: 1.1rem;
margin-bottom: 0.5rem;
text-align: center;
letter-spacing: 0.1em;
}
.class-desc {
color: #8a7a9a;
font-size: 0.8rem;
text-align: center;
margin-bottom: 0.8rem;
line-height: 1.4;
}
.class-stats {
color: #6b5b8a;
font-size: 0.75rem;
text-align: center;
letter-spacing: 0.05em;
}
.btn-start {
background: linear-gradient(145deg, #2a1a3a, #1a0a2a);
border: 2px solid #c9a84c;
color: #c9a84c;
font-family: 'Georgia', serif;
font-size: 1.2rem;
padding: 1rem 3rem;
cursor: pointer;
letter-spacing: 0.2em;
text-transform: uppercase;
transition: all 0.3s;
position: relative;
z-index: 1;
}
.btn-start:hover {
background: linear-gradient(145deg, #3a2a4a, #2a1a3a);
box-shadow: 0 0 30px rgba(201, 168, 76, 0.4);
transform: scale(1.05);
}
/* ── css: hud ── */
#hud {
position: absolute;
top: 0; left: 0; right: 0;
display: none;
z-index: 20;
pointer-events: none;
}
#hud.active { display: block; }
/* Top Bar */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1.5rem;
background: linear-gradient(180deg, rgba(10, 10, 15, 0.95), rgba(10, 10, 15, 0.7));
border-bottom: 1px solid #2a2a4a;
pointer-events: auto;
}
.hud-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hud-label {
color: #6b5b8a;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.hud-value {
color: #c9a84c;
font-size: 1.1rem;
font-weight: bold;
font-family: 'Georgia', serif;
}
.hud-value.hp { color: #c44a4a; }
.hud-value.energy { color: #4a8fa8; }
.hud-value.gold { color: #c9a84c; }
.hud-value.floor { color: #8a4ac4; }
/* Player Stats */
.player-stats {
position: absolute;
top: 60px;
left: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.stat-bar {
width: 200px;
height: 8px;
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 4px;
overflow: hidden;
}
.stat-bar-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 3px;
}
.stat-bar-fill.hp-fill { background: linear-gradient(90deg, #8a2a2a, #c44a4a); }
.stat-bar-fill.defense-fill { background: linear-gradient(90deg, #2a5a6a, #4a8fa8); }
.stat-bar-fill.energy-fill { background: linear-gradient(90deg, #2a4a6a, #4a8fa8); }
.stat-bar-label {
color: #6b5b8a;
font-size: 0.65rem;
margin-bottom: 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Enemy Display */
.enemy-display {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
text-align: center;
pointer-events: none;
}
.enemy-name {
color: #8a7a9a;
font-size: 0.9rem;
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.enemy-hp-bar {
width: 250px;
height: 10px;
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 5px;
overflow: hidden;
margin: 0 auto;
}
.enemy-hp-fill {
height: 100%;
background: linear-gradient(90deg, #6a1a1a, #c44a4a);
transition: width 0.3s ease;
border-radius: 4px;
}
.enemy-hp-text {
color: #c44a4a;
font-size: 0.75rem;
margin-top: 0.3rem;
font-family: 'Georgia', serif;
}
/* Hand Area */
.hand-area {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: flex-end;
padding: 0 2rem 1rem;
gap: 0;
pointer-events: none;
}
.hand-card {
width: 140px;
height: 200px;
cursor: pointer;
transition: transform 0.2s, margin 0.2s;
pointer-events: auto;
position: relative;
margin: 0 -10px;
}
.hand-card:hover {
transform: translateY(-20px) scale(1.05);
z-index: 30;
}
.hand-card.played {
transform: translateY(-60px) scale(0.9);
opacity: 0.7;
}
.hand-card.cannot-play {
filter: brightness(0.5) saturate(0.3);
cursor: not-allowed;
}
/* Energy Display */
.energy-display {
position: absolute;
top: 60px;
right: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
pointer-events: none;
}
.energy-orb {
width: 50px;
height: 50px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #6ab4e8, #2a6a8a);
box-shadow: 0 0 15px rgba(74, 143, 168, 0.4), inset 0 0 10px rgba(106, 180, 232, 0.3);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2rem;
font-weight: bold;
font-family: 'Georgia', serif;
}
/* Action Buttons */
.action-bar {
position: absolute;
bottom: 220px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
pointer-events: auto;
}
.action-btn {
background: linear-gradient(145deg, #2a1a3a, #1a0a2a);
border: 1px solid #3a2a5a;
color: #c9a84c;
font-family: 'Georgia', serif;
font-size: 0.85rem;
padding: 0.6rem 1.5rem;
cursor: pointer;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: all 0.2s;
border-radius: 4px;
}
.action-btn:hover {
border-color: #c9a84c;
box-shadow: 0 0 15px rgba(201, 168, 76, 0.3);
}
.action-btn.end-turn-btn {
background: linear-gradient(145deg, #3a1a1a, #2a0a0a);
border-color: #6a2a2a;
color: #c44a4a;
}
.action-btn.end-turn-btn:hover {
border-color: #c44a4a;
box-shadow: 0 0 15px rgba(196, 74, 74, 0.3);
}
/* Card Type Colors */
.card-attack { border-color: #8a2a2a !important; }
.card-attack .card-cost { background: #6a1a1a; }
.card-defense { border-color: #2a5a6a !important; }
.card-defense .card-cost { background: #1a4a5a; }
.card-other { border-color: #5a2a6a !important; }
.card-other .card-cost { background: #3a1a4a; }
/* ── css: cards ── */
.card-container {
width: 140px;
height: 200px;
position: relative;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(160deg, #1a1a2e, #0f0f1a);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(201, 168, 76, 0.1);
transition: box-shadow 0.3s, transform 0.2s;
cursor: pointer;
}
.card-container::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: 8px;
border: 2px solid;
pointer-events: none;
z-index: 2;
}
.card-container.card-attack::before { border-color: #8a2a2a; box-shadow: inset 0 0 20px rgba(138, 42, 42, 0.2); }
.card-container.card-defense::before { border-color: #2a5a6a; box-shadow: inset 0 0 20px rgba(42, 90, 106, 0.2); }
.card-container.card-other::before { border-color: #5a2a6a; box-shadow: inset 0 0 20px rgba(90, 42, 106, 0.2); }
.card-cost {
position: absolute;
top: 6px;
left: 6px;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.85rem;
font-weight: bold;
font-family: 'Georgia', serif;
z-index: 3;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
}
.card-attack .card-cost { background: radial-gradient(circle at 30% 30%, #c44a4a, #6a1a1a); }
.card-defense .card-cost { background: radial-gradient(circle at 30% 30%, #4a8fa8, #1a4a5a); }
.card-other .card-cost { background: radial-gradient(circle at 30% 30%, #8a4ac4, #3a1a4a); }
.card-image {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.card-image svg {
width: 100%;
height: 100%;
}
.card-name {
position: absolute;
top: 36px;
left: 0;
right: 0;
text-align: center;
color: #c9a84c;
font-size: 0.7rem;
font-weight: bold;
letter-spacing: 0.05em;
text-transform: uppercase;
z-index: 3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.card-stats {
position: absolute;
bottom: 30px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 1rem;
z-index: 3;
}
.card-stat {
display: flex;
align-items: center;
gap: 3px;
}
.card-stat-value {
color: white;
font-size: 0.85rem;
font-weight: bold;
font-family: 'Georgia', serif;
}
.card-stat.atk .card-stat-value { color: #e86a4a; }
.card-stat.def .card-stat-value { color: #6ab4e8; }
.card-description {
position: absolute;
bottom: 12px;
left: 8px;
right: 8px;
color: #8a7a9a;
font-size: 0.55rem;
text-align: center;
line-height: 1.3;
z-index: 3;
}
/* Glow effects */
.card-container.card-attack { box-shadow: 0 4px 15px rgba(138, 42, 42, 0.3); }
.card-container.card-defense { box-shadow: 0 4px 15px rgba(42, 90, 106, 0.3); }
.card-container.card-other { box-shadow: 0 4px 15px rgba(90, 42, 106, 0.3); }
/* Hand card hover */
.hand-card:hover {
transform: translateY(-25px) scale(1.08) !important;
z-index: 30 !important;
}
.hand-card:hover .card-container {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.6);
}
.hand-card.cannot-play {
filter: brightness(0.5) saturate(0.3);
cursor: not-allowed;
}
/* Shop styling */
#shop-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(5, 5, 10, 0.92);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
pointer-events: auto;
}
#shop-overlay.active { display: flex; }
.shop-container {
background: linear-gradient(145deg, #1a1a2e, #0f0f1a);
border: 2px solid #2a2a4a;
border-radius: 12px;
padding: 2rem;
max-width: 700px;
width: 90%;
box-shadow: 0 0 40px rgba(138, 74, 196, 0.2);
}
.shop-title {
color: #c9a84c;
font-size: 1.5rem;
text-align: center;
margin-bottom: 0.5rem;
letter-spacing: 0.15em;
font-family: 'Georgia', serif;
}
.shop-subtitle {
color: #6b5b8a;
font-size: 0.8rem;
text-align: center;
margin-bottom: 1.5rem;
font-style: italic;
}
.shop-gold {
color: #c9a84c;
font-size: 1.1rem;
text-align: center;
margin-bottom: 1.5rem;
font-family: 'Georgia', serif;
}
.shop-gold span { color: #c9a84c; font-weight: bold; font-size: 1.3rem; }
.shop-cards {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.shop-card {
width: 130px;
height: 185px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
border: 2px solid #2a2a4a;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(160deg, #1a1a2e, #0f0f1a);
position: relative;
}
.shop-card:hover { transform: translateY(-10px); box-shadow: 0 8px 20px rgba(0,0,0,0.5); }
.shop-card-cost {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #4a8fa8, #1a4a5a);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.75rem;
font-weight: bold;
font-family: 'Georgia', serif;
z-index: 3;
}
.shop-card-name {
position: absolute;
top: 34px;
left: 0;
right: 0;
text-align: center;
color: #c9a84c;
font-size: 0.65rem;
font-weight: bold;
letter-spacing: 0.05em;
text-transform: uppercase;
z-index: 3;
}
.shop-card-desc {
position: absolute;
bottom: 8px;
left: 6px;
right: 6px;
color: #8a7a9a;
font-size: 0.5rem;
text-align: center;
line-height: 1.3;
z-index: 3;
}
.shop-close {
display: block;
margin: 0 auto;
background: linear-gradient(145deg, #2a1a3a, #1a0a2a);
border: 1px solid #3a2a5a;
color: #c9a84c;
font-family: 'Georgia', serif;
font-size: 0.85rem;
padding: 0.5rem 2rem;
cursor: pointer;
letter-spacing: 0.1em;
text-transform: uppercase;
border-radius: 4px;
transition: all 0.2s;
}
.shop-close:hover {
border-color: #c9a84c;
box-shadow: 0 0 15px rgba(201, 168, 76, 0.3);
}
/* Victory / Game Over screens */
#victory-screen {
background: radial-gradient(ellipse at center, #1a2a1a 0%, #0a0a0f 70%);
}
.victory-title {
font-size: 3rem;
color: #4a8a4a;
text-shadow: 0 0 30px rgba(74, 138, 74, 0.5);
letter-spacing: 0.15em;
margin-bottom: 0.5rem;
font-family: 'Georgia', serif;
}
.victory-subtitle {
color: #6b8a6b;
font-size: 1.1rem;
margin-bottom: 2rem;
font-style: italic;
}
#gameover-screen {
background: radial-gradient(ellipse at center, #2a1a1a 0%, #0a0a0f 70%);
}
.gameover-title {
font-size: 3rem;
color: #c44a4a;
text-shadow: 0 0 30px rgba(196, 74, 74, 0.5);
letter-spacing: 0.15em;
margin-bottom: 0.5rem;
font-family: 'Georgia', serif;
}
.gameover-subtitle {
color: #8a6b6b;
font-size: 1.1rem;
margin-bottom: 2rem;
font-style: italic;
}
/* ── css: atmosphere ── */
/* Atmospheric background */
#game-canvas {
background: radial-gradient(ellipse at 50% 30%, #1a1025 0%, #0a0a0f 60%, #050508 100%);
}
/* Card back styles */
.card-back {
width: 140px;
height: 200px;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.card-back-bastion {
background: linear-gradient(160deg, #1a2a3a, #0a1a2a);
border: 2px solid #2a5a6a;
box-shadow: inset 0 0 20px rgba(42, 90, 106, 0.3);
}
.card-back-wither {
background: linear-gradient(160deg, #2a1a1a, #1a0a0a);
border: 2px solid #6a2a2a;
box-shadow: inset 0 0 20px rgba(106, 42, 42, 0.3);
}
.card-back-shroud {
background: linear-gradient(160deg, #1a1a2a, #0a0a1a);
border: 2px solid #3a2a5a;
box-shadow: inset 0 0 20px rgba(58, 42, 90, 0.3);
}
/* Floating particles */
.particle {
position: absolute;
pointer-events: none;
border-radius: 50%;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); opacity: 0.3; }
50% { transform: translateY(-10px); opacity: 0.7; }
}
/* Damage flash */
@keyframes damage-flash {
0% { filter: brightness(1); }
25% { filter: brightness(2) hue-rotate(20deg); }
50% { filter: brightness(0.5); }
100% { filter: brightness(1); }
}
.damage-flash { animation: damage-flash 0.4s ease-out; }
/* Card play animation */
@keyframes card-play {
0% { transform: translateY(0) scale(1); }
50% { transform: translateY(-80px) scale(1.1); }
100% { transform: translateY(-120px) scale(0.8); opacity: 0; }
}
.card-playing { animation: card-play 0.5s ease-in forwards; }
/* Energy regen */
@keyframes energy-glow {
0%, 100% { box-shadow: 0 0 15px rgba(74, 143, 168, 0.4); }
50% { box-shadow: 0 0 25px rgba(74, 143, 168, 0.7); }
}
.energy-glow { animation: energy-glow 1s ease-in-out; }
/* Status effect indicators */
.status-indicator {
position: absolute;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-weight: bold;
color: white;
z-index: 10;
}
.status-burn {
background: radial-gradient(circle, #e8a82a, #8a5a1a);
box-shadow: 0 0 8px rgba(232, 168, 42, 0.5);
}
.status-poison {
background: radial-gradient(circle, #4ae84a, #1a8a1a);
box-shadow: 0 0 8px rgba(74, 232, 74, 0.5);
}
.status-stun {
background: radial-gradient(circle, #e8e84a, #8a8a1a);
box-shadow: 0 0 8px rgba(232, 232, 74, 0.5);
}
.status-silence {
background: radial-gradient(circle, #8a8a8a, #4a4a4a);
box-shadow: 0 0 8px rgba(138, 138, 138, 0.5);
}
/* HUD improvements */
#ui-overlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 10;
}
/* Room progress indicator */
.room-progress {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
pointer-events: none;
}
.room-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2a2a4a;
border: 1px solid #3a3a5a;
}
.room-dot.completed { background: #4a8a4a; border-color: #6aaa6a; }
.room-dot.current { background: #8a4ac4; border-color: #aa6aea; box-shadow: 0 0 8px rgba(138, 74, 196, 0.5); }
.room-dot.boss { background: #c44a4a; border-color: #e46a6a; }
/* Damage number animation */
.damage-number {
position: absolute;
color: #e84a4a;
font-size: 24px;
font-weight: bold;
font-family: 'Georgia', serif;
pointer-events: none;
z-index: 100;
animation: damage-number-fly 1s ease-out forwards;
text-shadow: 0 0 10px rgba(232, 74, 74, 0.5);
}
@keyframes damage-number-fly {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-60px) scale(1.2); opacity: 0; }
}
.heal-number {
color: #4ae84a;
text-shadow: 0 0 10px rgba(74, 232, 74, 0.5);
}
.gold-number {
color: #c9a84c;
text-shadow: 0 0 10px rgba(201, 168, 76, 0.5);
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game-canvas"></canvas>
<div id="ui-overlay"></div>
<div id="card-backdrop"></div>
</div>
<div id="start-screen" class="screen">
<h1 class="game-title">Shadows of Aethelgard</h1>
<p class="subtitle">A Roguelite Deckbuilder</p>
<div class="class-select">
<div class="class-option bastion-option" data-class="bastion">
<div class="class-icon bastion-icon"></div>
<h3>The Bastion</h3>
<p class="class-desc">Immovable Object. High Defense, High Block. Focuses on durability and AoE.</p>
<div class="class-stats">HP: 35 | Energy: 3</div>
</div>
<div class="class-option wither-option" data-class="wither">
<div class="class-icon wither-icon"></div>
<h3>The Wither</h3>
<p class="class-desc">Rot and Burn. Status Effects, Debuff Control. Low HP, High AoE.</p>
<div class="class-stats">HP: 25 | Energy: 5</div>
</div>
<div class="class-option shroud-option" data-class="shroud">
<div class="class-icon shroud-icon"></div>
<h3>The Shroud</h3>
<p class="class-desc">Shadow and Speed. High Speed, Evasion. High Crit, Low Defense.</p>
<div class="class-stats">HP: 28 | Energy: 5</div>
</div>
</div>
<button id="start-btn" class="btn-start">Begin Descent</button>
</div>
<div id="victory-screen" class="screen hidden">
<h1 class="victory-title">Floor Cleared</h1>
<p class="victory-subtitle">The Void recedes... for now.</p>
<button id="menu-btn" class="btn-start">Return to Menu</button>
</div>
<div id="gameover-screen" class="screen hidden">
<h1 class="gameover-title">Consumed by the Void</h1>
<p class="gameover-subtitle">Aethelgard falls to darkness.</p>
<button id="retry-btn" class="btn-start">Try Again</button>
</div>
<script>
'use strict';
// ── const: CARD_DATA ──
// ============================================================
// CARD DATA - All 36 Core Cards
// ============================================================
const CARD_DATA = {
// --- THE BASTION ---
"BA-A01": { id: "BA-A01", name: "Slam", class: "bastion", type: "attack", cost: 2, atk: 3, def: 0, desc: "Deals 3 damage. If you have 30+ HP, draw 1 card." },
"BA-A02": { id: "BA-A02", name: "Earthquake", class: "bastion", type: "attack", cost: 3, atk: 4, def: 0, desc: "Deals 4 damage to all enemies." },
"BA-A03": { id: "BA-A03", name: "Stone Fist", class: "bastion", type: "attack", cost: 1, atk: 2, def: 0, desc: "Deals 2 damage." },
"BA-A04": { id: "BA-A04", name: "Boulder", class: "bastion", type: "attack", cost: 4, atk: 6, def: 0, desc: "Deals 6 damage. Costs 4 Energy." },
"BA-D01": { id: "BA-D01", name: "Shield Wall", class: "bastion", type: "defense", cost: 0, atk: 0, def: 4, desc: "Gain 4 Defense this turn." },
"BA-D02": { id: "BA-D02", name: "Iron Skin", class: "bastion", type: "defense", cost: 2, atk: 0, def: 5, desc: "Gain 5 Defense permanently. Discard 1 card." },
"BA-D03": { id: "BA-D03", name: "Stand Firm", class: "bastion", type: "defense", cost: 1, atk: 0, def: 2, desc: "Gain 2 Defense. Blocks 1 incoming damage." },
"BA-D04": { id: "BA-D04", name: "Taunt", class: "bastion", type: "defense", cost: 2, atk: 0, def: 3, desc: "Gain 3 Defense. Stun 1 enemy for 1 turn." },
"BA-U01": { id: "BA-U01", name: "Search", class: "bastion", type: "other", cost: 2, atk: 0, def: 0, desc: "Draw 1 card. Discard hand if > 5 cards." },
"BA-U02": { id: "BA-U02", name: "Repair", class: "bastion", type: "other", cost: 3, atk: 0, def: 0, desc: "Heal 6 HP." },
"BA-U03": { id: "BA-U03", name: "Rally", class: "bastion", type: "other", cost: 1, atk: 0, def: 0, desc: "Draw 1 card. Gain 1 Energy." },
"BA-U04": { id: "BA-U04", name: "Loot", class: "bastion", type: "other", cost: 2, atk: 0, def: 0, desc: "Gain 5 Gold." },
// --- THE WITHER ---
"WI-A01": { id: "WI-A01", name: "Fireball", class: "wither", type: "attack", cost: 2, atk: 3, def: 0, desc: "Deals 3 damage. Apply 1 Burn." },
"WI-A02": { id: "WI-A02", name: "Inferno", class: "wither", type: "attack", cost: 3, atk: 4, def: 0, desc: "Deals 4 damage. Applies 3 Burn." },
"WI-A03": { id: "WI-A03", name: "Ember", class: "wither", type: "attack", cost: 1, atk: 2, def: 0, desc: "Deals 2 damage. 1 damage/turn to target." },
"WI-A04": { id: "WI-A04", name: "Pyre", class: "wither", type: "attack", cost: 4, atk: 5, def: 0, desc: "Deals 5 damage. All enemies take 1 dmg next turn." },
"WI-D01": { id: "WI-D01", name: "Scorch Shield", class: "wither", type: "defense", cost: 2, atk: 0, def: 3, desc: "Gain 3 Defense. Block 1 Burn damage." },
"WI-D02": { id: "WI-D02", name: "Heat Resist", class: "wither", type: "defense", cost: 1, atk: 0, def: 1, desc: "Gain 1 Defense. Gain 1 Energy." },
"WI-D03": { id: "WI-D03", name: "Smoke", class: "wither", type: "defense", cost: 2, atk: 0, def: 4, desc: "Gain 4 Defense. Block 1 Burn damage." },
"WI-D04": { id: "WI-D04", name: "Endure", class: "wither", type: "defense", cost: 3, atk: 0, def: 2, desc: "Gain 2 Defense. Heal 2 HP." },
"WI-U01": { id: "WI-U01", name: "Poison", class: "wither", type: "other", cost: 2, atk: 0, def: 0, desc: "Apply 1 Poison to target." },
"WI-U02": { id: "WI-U02", name: "Burn", class: "wither", type: "other", cost: 3, atk: 0, def: 0, desc: "Deal 3 damage + 2 Burn to all enemies." },
"WI-U03": { id: "WI-U03", name: "Slow", class: "wither", type: "other", cost: 1, atk: 0, def: 0, desc: "Draw 1 card. Stun target." },
"WI-U04": { id: "WI-U04", name: "Wither", class: "wither", type: "other", cost: 2, atk: 0, def: 0, desc: "Heal 5 HP. Reduce enemy Attack by 1." },
// --- THE SHROUD ---
"SH-A01": { id: "SH-A01", name: "Backstab", class: "shroud", type: "attack", cost: 2, atk: 4, def: 0, desc: "Deals 4 damage. +1 if target has no Armor." },
"SH-A02": { id: "SH-A02", name: "Shadow Bolt", class: "shroud", type: "attack", cost: 3, atk: 5, def: 0, desc: "Deals 5 damage. Target cannot block." },
"SH-A03": { id: "SH-A03", name: "Silence", class: "shroud", type: "attack", cost: 1, atk: 3, def: 0, desc: "Deals 3 damage. Stun target." },
"SH-A04": { id: "SH-A04", name: "Vengeance", class: "shroud", type: "attack", cost: 3, atk: 6, def: 0, desc: "Deals 6 damage. Gain 2 Energy." },
"SH-D01": { id: "SH-D01", name: "Blur", class: "shroud", type: "defense", cost: 3, atk: 0, def: 2, desc: "Gain 2 Defense. Ignore next attack." },
"SH-D02": { id: "SH-D02", name: "Step", class: "shroud", type: "defense", cost: 2, atk: 0, def: 3, desc: "Gain 3 Defense. Gain 1 Energy." },
"SH-D03": { id: "SH-D03", name: "Evasion", class: "shroud", type: "defense", cost: 1, atk: 0, def: 1, desc: "Gain 1 Defense. Skip enemy action." },
"SH-D04": { id: "SH-D04", name: "Phantom", class: "shroud", type: "defense", cost: 2, atk: 0, def: 4, desc: "Gain 4 Defense. Ghost form 1 turn." },
"SH-U01": { id: "SH-U01", name: "Dash", class: "shroud", type: "other", cost: 1, atk: 0, def: 0, desc: "Gain 1 Energy. Skip enemy attack." },
"SH-U02": { id: "SH-U02", name: "Teleport", class: "shroud", type: "other", cost: 2, atk: 0, def: 0, desc: "Deal 2 damage. Move to back row." },
"SH-U03": { id: "SH-U03", name: "Hide", class: "shroud", type: "other", cost: 2, atk: 0, def: 0, desc: "Gain 1 Defense. Discard 1. Draw 2." },
"SH-U04": { id: "SH-U04", name: "Assassinate", class: "shroud", type: "other", cost: 3, atk: 0, def: 0, desc: "Deal 8 damage. Cannot be blocked." },
};
// ── const: CLASS_DECKS ──
// Class starter decks
const CLASS_DECKS = {
bastion: ["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: ["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: ["SH-A01","SH-A02","SH-A03","SH-A04","SH-D01","SH-D02","SH-D03","SH-D04","SH-U01","SH-U02","SH-U03","SH-U04"],
};
// ── const: CLASS_CONFIG ──
const CLASS_CONFIG = {
bastion: { startHP: 35, startEnergy: 3, maxEnergy: 5, color: "#4a8fa8" },
wither: { startHP: 25, startEnergy: 5, maxEnergy: 5, color: "#c44a4a" },
shroud: { startHP: 28, startEnergy: 5, maxEnergy: 5, color: "#8a4ac4" },
};
// ── const: ENEMY_TYPES ──
// Enemy types per floor
const ENEMY_TYPES = [
{ name: "Goblin", floor: 1, hp: 20, damage: 3, color: "#4a8a4a" },
{ name: "Rat", floor: 1, hp: 15, damage: 2, color: "#6a6a4a" },
{ name: "Void Warrior", floor: 3, hp: 40, damage: 8, color: "#4a2a6a" },
{ name: "Cultist", floor: 3, hp: 35, damage: 7, color: "#6a4a2a" },
];
// ── const: FLOOR_ENEMIES ──
const FLOOR_ENEMIES = {
1: [{ name: "Goblin", hp: 20, damage: 3, color: "#4a8a4a" }, { name: "Rat", hp: 15, damage: 2, color: "#6a6a4a" }, { name: "Void Rat", hp: 18, damage: 3, color: "#5a5a3a" }],
2: [{ name: "Vampire Knight", hp: 30, damage: 5, color: "#8a2a2a" }, { name: "Ogre", hp: 35, damage: 6, color: "#4a6a4a" }, { name: "Shadow Stalker", hp: 28, damage: 5, color: "#3a3a6a" }],
3: [{ name: "Void Warrior", hp: 40, damage: 8, color: "#4a2a6a" }, { name: "Cultist", hp: 35, damage: 7, color: "#6a4a2a" }, { name: "Void Fiend", hp: 45, damage: 9, color: "#2a1a4a" }],
};
// ── const: BOSSES ──
const BOSSES = {
1: { name: "The Gatekeeper", hp: 80, damage: 15, color: "#6a6a8a", desc: "Blocks 5 damage per turn until HP < 20" },
2: { name: "The Corrupted", hp: 80, damage: 15, color: "#8a2a6a", desc: "Deals 20 damage if you have < 3 Energy" },
3: { name: "The Void Lord", hp: 80, damage: 15, color: "#2a0a4a", desc: "Regenerates 10 HP per turn. Cannot block." },
};
// ── function: getCardFrameSVG ──
// ============================================================
// SVG CARD FRAMES - Unique per class
// ============================================================
function getCardFrameSVG(cardData) {
const cls = cardData.class;
const type = cardData.type;
// Base frame colors per class
const frameColors = {
bastion: { outer: "#2a5a6a", inner: "#1a3a4a", accent: "#4a8fa8", glow: "rgba(74,143,168,0.3)" },
wither: { outer: "#6a2a2a", inner: "#3a1a1a", accent: "#c44a4a", glow: "rgba(196,74,74,0.3)" },
shroud: { outer: "#3a2a5a", inner: "#1a1a3a", accent: "#8a4ac4", glow: "rgba(138,74,196,0.3)" },
};
const colors = frameColors[cls];
// Type-specific accent
let typeAccent = colors.accent;
if (type === "attack") typeAccent = "#c44a4a";
else if (type === "defense") typeAccent = "#4a8fa8";
else typeAccent = "#8a4ac4";
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 200" width="140" height="200">
<defs>
<linearGradient id="frame-bg-${cls}-${cardData.id}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${colors.inner}"/>
<stop offset="100%" style="stop-color:#0a0a1a"/>
</linearGradient>
<linearGradient id="frame-border-${cls}-${cardData.id}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${typeAccent}"/>
<stop offset="50%" style="stop-color:${colors.accent}"/>
<stop offset="100%" style="stop-color:${typeAccent}"/>
</linearGradient>
<radialGradient id="card-glow-${cls}-${cardData.id}" cx="50%" cy="30%" r="60%">
<stop offset="0%" style="stop-color:${colors.glow}"/>
<stop offset="100%" style="stop-color:transparent"/>
</radialGradient>
<filter id="inner-shadow-${cls}-${cardData.id}">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
<feOffset dx="0" dy="2" result="offsetBlur"/>
<feComposite in="SourceGraphic" in2="offsetBlur" operator="over"/>
</filter>
</defs>
<!-- Outer glow -->
<rect x="0" y="0" width="140" height="200" rx="8" fill="url(#card-glow-${cls}-${cardData.id})"/>
<!-- Card background -->
<rect x="4" y="4" width="132" height="192" rx="6" fill="url(#frame-bg-${cls}-${cardData.id})" stroke="url(#frame-border-${cls}-${cardData.id})" stroke-width="2"/>
<!-- Decorative corner flourishes -->
<path d="M12 12 L24 12 L24 16 L16 16 L16 20 L12 20 Z" fill="${colors.accent}" opacity="0.4"/>
<path d="M128 12 L120 12 L120 16 L128 16 L128 20 L132 20 Z" fill="${colors.accent}" opacity="0.4"/>
<path d="M12 188 L24 188 L24 184 L16 184 L16 180 L12 180 Z" fill="${colors.accent}" opacity="0.4"/>
<path d="M128 188 L120 188 L120 184 L128 184 L128 180 L132 180 Z" fill="${colors.accent}" opacity="0.4"/>
<!-- Image area background -->
<rect x="14" y="38" width="112" height="90" rx="4" fill="#0a0a15" stroke="${colors.accent}" stroke-width="0.5" opacity="0.8"/>
<!-- Image area decorative border -->
<rect x="18" y="42" width="104" height="82" rx="3" fill="none" stroke="${colors.accent}" stroke-width="0.3" opacity="0.3"/>
<!-- Center emblem per class -->
${getCardEmblemSVG(cls, type)}
<!-- Bottom stat area -->
<rect x="14" y="136" width="112" height="56" rx="4" fill="rgba(10,10,20,0.6)"/>
<line x1="14" y1="136" x2="126" y2="136" stroke="${colors.accent}" stroke-width="0.5" opacity="0.5"/>
<!-- Attack/Defense icons at bottom -->
${type === "attack" ? `<circle cx="30" cy="168" r="10" fill="none" stroke="#c44a4a" stroke-width="1.5" opacity="0.6"/><text x="30" y="172" text-anchor="middle" fill="#c44a4a" font-size="12" font-weight="bold" font-family="Georgia">${cardData.atk}</text>` : `<circle cx="30" cy="168" r="10" fill="none" stroke="#4a8fa8" stroke-width="1.5" opacity="0.6"/><text x="30" y="172" text-anchor="middle" fill="#4a8fa8" font-size="12" font-weight="bold" font-family="Georgia">${cardData.def}</text>`}
${type !== "other" && type !== "attack" ? `<circle cx="100" cy="168" r="10" fill="none" stroke="#4a8fa8" stroke-width="1.5" opacity="0.6"/><text x="100" y="172" text-anchor="middle" fill="#4a8fa8" font-size="12" font-weight="bold" font-family="Georgia">${cardData.def}</text>` : ''}
${type === "other" ? `<text x="70" y="172" text-anchor="middle" fill="#8a4ac4" font-size="10" font-family="Georgia">✦ EFFECT ✦</text>` : ''}
</svg>`;
}
// ── function: getCardEmblemSVG ──
function getCardEmblemSVG(cls, type) {
if (cls === "bastion") {
if (type === "attack") {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<polygon points="52,10 80,60 24,60" fill="none" stroke="#4a8fa8" stroke-width="1.5" opacity="0.5"/>
<line x1="52" y1="10" x2="52" y2="60" stroke="#4a8fa8" stroke-width="1" opacity="0.3"/>
<circle cx="52" cy="38" r="12" fill="none" stroke="#c44a4a" stroke-width="1" opacity="0.4"/>
<line x1="44" y1="38" x2="60" y2="38" stroke="#c44a4a" stroke-width="1.5" opacity="0.5"/>
<line x1="52" y1="30" x2="52" y2="46" stroke="#c44a4a" stroke-width="1.5" opacity="0.5"/>
</svg>`;
} else if (type === "defense") {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<path d="M52,15 L75,30 L75,55 Q75,70 52,78 Q29,70 29,55 L29,30 Z" fill="none" stroke="#4a8fa8" stroke-width="1.5" opacity="0.5"/>
<path d="M52,25 L65,35 L65,52 Q65,62 52,68 Q39,62 39,52 L39,35 Z" fill="none" stroke="#4a8fa8" stroke-width="1" opacity="0.3"/>
<line x1="52" y1="25" x2="52" y2="68" stroke="#4a8fa8" stroke-width="0.5" opacity="0.2"/>
</svg>`;
} else {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<circle cx="52" cy="41" r="20" fill="none" stroke="#8a4ac4" stroke-width="1" opacity="0.4"/>
<circle cx="52" cy="41" r="12" fill="none" stroke="#8a4ac4" stroke-width="0.5" opacity="0.3"/>
<circle cx="52" cy="41" r="4" fill="#8a4ac4" opacity="0.3"/>
<line x1="52" y1="15" x2="52" y2="21" stroke="#8a4ac4" stroke-width="0.5" opacity="0.3"/>
<line x1="52" y1="61" x2="52" y2="67" stroke="#8a4ac4" stroke-width="0.5" opacity="0.3"/>
<line x1="26" y1="41" x2="32" y2="41" stroke="#8a4ac4" stroke-width="0.5" opacity="0.3"/>
<line x1="72" y1="41" x2="78" y2="41" stroke="#8a4ac4" stroke-width="0.5" opacity="0.3"/>
</svg>`;
}
} else if (cls === "wither") {
if (type === "attack") {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<path d="M52,10 Q65,30 70,45 Q75,60 52,75 Q29,60 34,45 Q39,30 52,10Z" fill="none" stroke="#c44a4a" stroke-width="1.5" opacity="0.5"/>
<path d="M52,20 Q60,35 63,45 Q66,55 52,65 Q38,55 41,45 Q44,35 52,20Z" fill="none" stroke="#e8a84a" stroke-width="1" opacity="0.4"/>
<circle cx="52" cy="45" r="6" fill="#e8a84a" opacity="0.3"/>
</svg>`;
} else if (type === "defense") {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<path d="M30,20 Q52,10 74,20 L74,50 Q74,65 52,75 Q30,65 30,50Z" fill="none" stroke="#c44a4a" stroke-width="1.5" opacity="0.4"/>
<path d="M38,28 Q52,22 66,28 L66,48 Q66,58 52,66 Q38,58 38,48Z" fill="none" stroke="#e8a84a" stroke-width="0.8" opacity="0.3"/>
</svg>`;
} else {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<circle cx="52" cy="41" r="18" fill="none" stroke="#c44a4a" stroke-width="1" opacity="0.3"/>
<circle cx="52" cy="41" r="10" fill="none" stroke="#e8a84a" stroke-width="0.8" opacity="0.3"/>
<circle cx="52" cy="41" r="4" fill="#e8a84a" opacity="0.2"/>
<path d="M35,25 Q52,15 69,25" fill="none" stroke="#c44a4a" stroke-width="0.5" opacity="0.3"/>
<path d="M35,57 Q52,67 69,57" fill="none" stroke="#c44a4a" stroke-width="0.5" opacity="0.3"/>
</svg>`;
}
} else if (cls === "shroud") {
if (type === "attack") {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<path d="M52,10 L58,30 L78,35 L62,48 L66,68 L52,58 L38,68 L42,48 L26,35 L46,30Z" fill="none" stroke="#8a4ac4" stroke-width="1.5" opacity="0.5"/>
<circle cx="52" cy="41" r="8" fill="none" stroke="#c44a4a" stroke-width="1" opacity="0.4"/>
<circle cx="52" cy="41" r="3" fill="#c44a4a" opacity="0.3"/>
</svg>`;
} else if (type === "defense") {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<ellipse cx="52" cy="41" rx="22" ry="28" fill="none" stroke="#8a4ac4" stroke-width="1.5" opacity="0.4"/>
<ellipse cx="52" cy="41" rx="14" ry="18" fill="none" stroke="#8a4ac4" stroke-width="0.8" opacity="0.3"/>
<circle cx="52" cy="41" r="5" fill="none" stroke="#6ab4e8" stroke-width="0.8" opacity="0.3"/>
</svg>`;
} else {
return `<svg viewBox="0 0 104 82" width="104" height="82">
<circle cx="52" cy="41" r="18" fill="none" stroke="#8a4ac4" stroke-width="1" opacity="0.3"/>
<path d="M52,23 L56,35 L68,35 L58,42 L62,54 L52,47 L42,54 L46,42 L36,35 L48,35Z" fill="none" stroke="#6ab4e8" stroke-width="0.8" opacity="0.3"/>
</svg>`;
}
}
return `<circle cx="52" cy="41" r="15" fill="none" stroke="#c9a84c" stroke-width="1" opacity="0.3"/>`;
}
// ── function: getCardBackSVG ──
// ============================================================
// SVG CARD BACKS - Unique per class
// ============================================================
function getCardBackSVG(cls) {
const colors = {
bastion: { bg: "#1a2a3a", border: "#2a5a6a", accent: "#4a8fa8", inner: "#0a1a2a" },
wither: { bg: "#2a1a1a", border: "#6a2a2a", accent: "#c44a4a", inner: "#1a0a0a" },
shroud: { bg: "#1a1a2a", border: "#3a2a5a", accent: "#8a4ac4", inner: "#0a0a1a" },
};
const c = colors[cls];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 200" width="140" height="200">
<defs>
<linearGradient id="back-bg-${cls}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${c.bg}"/>
<stop offset="100%" style="stop-color:${c.inner}"/>
</linearGradient>
<radialGradient id="back-glow-${cls}" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:${c.accent};stop-opacity:0.15"/>
<stop offset="100%" style="stop-color:transparent"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="140" height="200" rx="8" fill="url(#back-bg-${cls})" stroke="${c.border}" stroke-width="2"/>
<rect x="0" y="0" width="140" height="200" rx="8" fill="url(#back-glow-${cls})"/>
<!-- Decorative pattern -->
<rect x="8" y="8" width="124" height="184" rx="4" fill="none" stroke="${c.accent}" stroke-width="0.5" opacity="0.3"/>
<rect x="14" y="14" width="112" height="172" rx="3" fill="none" stroke="${c.accent}" stroke-width="0.3" opacity="0.2"/>
<!-- Center emblem -->
${cls === "bastion" ? `<circle cx="70" cy="100" r="25" fill="none" stroke="${c.accent}" stroke-width="1.5" opacity="0.4"/>
<circle cx="70" cy="100" r="15" fill="none" stroke="${c.accent}" stroke-width="1" opacity="0.3"/>
<path d="M70,75 L85,100 L70,125 L55,100Z" fill="none" stroke="${c.accent}" stroke-width="1" opacity="0.4"/>
<line x1="70" y1="75" x2="70" y2="125" stroke="${c.accent}" stroke-width="0.5" opacity="0.3"/>` : ''}
${cls === "wither" ? `<circle cx="70" cy="100" r="25" fill="none" stroke="${c.accent}" stroke-width="1.5" opacity="0.4"/>
<path d="M70,78 Q82,95 82,105 Q82,120 70,125 Q58,120 58,105 Q58,95 70,78Z" fill="none" stroke="${c.accent}" stroke-width="1" opacity="0.4"/>
<circle cx="70" cy="100" r="8" fill="none" stroke="#e8a84a" stroke-width="0.8" opacity="0.3"/>` : ''}
${cls === "shroud" ? `<circle cx="70" cy="100" r="25" fill="none" stroke="${c.accent}" stroke-width="1.5" opacity="0.4"/>
<path d="M70,75 L78,90 L93,90 L81,98 L85,113 L70,105 L55,113 L59,98 L47,90 L62,90Z" fill="none" stroke="${c.accent}" stroke-width="1" opacity="0.4"/>
<circle cx="70" cy="100" r="5" fill="${c.accent}" opacity="0.2"/>` : ''}
<!-- Corner ornaments -->
<path d="M20,20 L30,20 L30,24 L24,24 L24,30 L20,30Z" fill="${c.accent}" opacity="0.2"/>
<path d="M120,20 L110,20 L110,24 L116,24 L116,30 L120,30Z" fill="${c.accent}" opacity="0.2"/>
<path d="M20,180 L30,180 L30,176 L24,176 L24,170 L20,170Z" fill="${c.accent}" opacity="0.2"/>
<path d="M120,180 L110,180 L110,176 L116,176 L116,170 L120,170Z" fill="${c.accent}" opacity="0.2"/>
</svg>`;
}
// ── let: gameState ──
// ============================================================
// GAME STATE
// ============================================================
let gameState = null;
// ── function: createGameState ──
function createGameState(className) {
const config = CLASS_CONFIG[className];
const deckIds = CLASS_DECKS[className];
// Build deck from starter cards
let deck = [];
for (let i = 0; i < 2; i++) {
for (let id of deckIds) {
deck.push({ ...CARD_DATA[id], uid: `${id}-${i}` });
}
}
gameState = {
className: className,
hero: {
hp: config.startHP,
maxHP: config.startHP,
energy: config.startEnergy,
maxEnergy: config.maxEnergy,
gold: 0,
defense: 0,
permanentDefense: 0,
deck: deck,
discardPile: [],
hand: [],
drawnThisTurn: 0,
},
inventory: {}, // cardId -> count
currentFloor: 1,
currentRoom: 1,
totalRoomsPerFloor: 3,
isBossRoom: false,
enemy: null,
enemyDefense: 0,
enemyStatusEffects: { burn: 0, poison: 0, stun: 0 },
enemyAttackReduction: 0,
playerDefenseThisTurn: 0,
playerBlockedThisTurn: 0,
cardsPlayed: [],
turnPhase: "player", // player, enemy, end
shopOpen: false,
gameOver: false,
victory: false,
statusEffects: { burn: 0, poison: 0, stun: 0, silence: 0 },
damageFlash: null,
cardAnimations: [],
};
return gameState;
}
// ── function: drawCards ──
// ============================================================
// COMBAT SYSTEM
// ============================================================
function drawCards(count) {
const gs = gameState;
const hero = gs.hero;
for (let i = 0; i < count; i++) {
if (hero.deck.length === 0) {
// Reshuffle discard into deck
hero.deck = [...hero.discardPile];
hero.discardPile = [];
// Shuffle
for (let j = hero.deck.length - 1; j > 0; j--) {
const k = Math.floor(Math.random() * (j + 1));
[hero.deck[j], hero.deck[k]] = [hero.deck[k], hero.deck[j]];
}
}
if (hero.deck.length > 0) {
const card = hero.deck.pop();
if (!hero.hand.find(h => h.uid === card.uid)) {
hero.hand.push(card);
}
}
}
hero.drawnThisTurn += count;
}
// ── function: endTurn ──
function endTurn() {
const gs = gameState;
const hero = gs.hero;
// Process status effects on enemy
if (gs.enemyStatusEffects.burn > 0) {
let burnDmg = gs.enemyStatusEffects.burn;
if (gs.burnBlock) {
burnDmg = Math.max(0, burnDmg - gs.burnBlock);
}
if (gs.enemy) {
gs.enemy.hp = Math.max(0, gs.enemy.hp - burnDmg);
}
gs.burnBlock = 0;
}
if (gs.enemyStatusEffects.poison > 0) {
if (gs.enemy) {
gs.enemy.hp = Math.max(0, gs.enemy.hp - gs.enemyStatusEffects.poison);
}
}
// Process next turn burn
if (gs._nextTurnBurn > 0 && gs.enemy) {
gs.enemy.hp = Math.max(0, gs.enemy.hp - gs._nextTurnBurn);
gs._nextTurnBurn = 0;
}
// Check enemy death
if (gs.enemy && gs.enemy.hp <= 0) {
onEnemyDefeated();
return;
}
// Enemy turn
if (gs.enemy && gs.enemyStatusEffects.stun <= 0 && !gs.skipEnemyAttack) {
performEnemyAttack();
} else {
gs.skipEnemyAttack = false;
}
// Check player death
if (hero.hp <= 0) {
gs.gameOver = true;
showGameOver();
return;
}
// Check boss regen (Boss 3)
if (gs.isBossRoom && gs.currentFloor === 3 && gs.enemy) {
gs.enemy.hp = Math.min(gs.enemy.hp + 10, 80);
}
// Discard excess hand
if (hero.hand.length > 5) {
const excess = hero.hand.splice(5);
hero.discardPile.push(...excess);
}
// Reset for next turn
hero.energy = Math.min(hero.maxEnergy, hero.energy + 1);
hero.defense = hero.permanentDefense; // Reset to permanent only
// Reduce stun
if (gs.enemyStatusEffects.stun > 0) gs.enemyStatusEffects.stun--;
if (gs.enemyStatusEffects.burn > 0) gs.enemyStatusEffects.burn--;
if (gs.enemyStatusEffects.poison > 0) gs.enemyStatusEffects.poison--;
// Boss mechanics
if (gs.isBossRoom && gs.currentFloor === 1 && gs.enemy) {
// Boss 1: Block 5 damage per turn until HP < 20
if (gs.enemy.hp >= 20) {
gs.enemyDefense = Math.max(gs.enemyDefense, 5);
}
}
if (gs.isBossRoom && gs.currentFloor === 2 && gs.enemy) {
// Boss 2: Deal 20 damage if player has < 3 energy
if (hero.energy < 3) {
let dmg = 20 - hero.defense;
hero.hp = Math.max(0, hero.hp - Math.max(0, dmg));
}
}
// Reset temporary values
gs.playerBlockedThisTurn = 0;
gs.burnBlock = 0;
gs.skipEnemyAttack = false;
// Draw cards for next turn
const drawCount = Math.min(5 - hero.hand.length, hero.deck.length);
if (drawCount > 0) {
drawCards(drawCount);
}
gs.turnPhase = "player";
gs.cardsPlayed = [];
hero.drawnThisTurn = 0;
renderGame();
}
// ── function: performEnemyAttack ──
function performEnemyAttack() {
const gs = gameState;
const hero = gs.hero;
const enemy = gs.enemy;
if (!enemy) return;
let damage = enemy.damage - (gs.enemyAttackReduction || 0);
damage = Math.max(1, damage);
// Boss 1 mechanic: extra block
if (gs.isBossRoom && gs.currentFloor === 1 && enemy.hp >= 20) {
gs.enemyDefense = Math.max(gs.enemyDefense, 5);
}
// Apply player defense
let finalDamage = Math.max(0, damage - hero.defense);
// Apply blocked damage
if (gs.playerBlockedThisTurn > 0) {
finalDamage = Math.max(0, finalDamage - gs.playerBlockedThisTurn);
}
hero.hp = Math.max(0, hero.hp - finalDamage);
gs.damageFlash = "player";
// Check player death
if (hero.hp <= 0) {
gs.gameOver = true;
showGameOver();
}
}
// ── function: onEnemyDefeated ──
function onEnemyDefeated() {
const gs = gameState;
const hero = gs.hero;
// Gold reward
const goldReward = gs.isBossRoom ? 15 : 5;
hero.gold += goldReward;
// Track inventory
if (!gs.inventory) gs.inventory = {};
// Move to next room
gs.currentRoom++;
if (gs.currentRoom > gs.totalRoomsPerFloor) {
// Floor complete
gs.currentFloor++;
gs.currentRoom = 1;
if (gs.currentFloor > 3) {
// Victory!
gs.victory = true;
showVictory();
return;
}
}
// Check if next room is boss
gs.isBossRoom = (gs.currentRoom === gs.totalRoomsPerFloor);
// Generate new enemy
generateEnemy();
// Reset turn state
gs.turnPhase = "player";
gs.cardsPlayed = [];
gs.playerBlockedThisTurn = 0;
gs.burnBlock = 0;
gs.skipEnemyAttack = false;
gs.enemyDefense = 0;
gs.enemyStatusEffects = { burn: 0, poison: 0, stun: 0 };
gs.enemyAttackReduction = 0;
gs._nextTurnBurn = 0;
// Reset player defense to permanent
hero.defense = hero.permanentDefense;
// Draw cards
hero.energy = Math.min(hero.maxEnergy, hero.energy + 1);
const drawCount = Math.min(5 - hero.hand.length, hero.deck.length);
if (drawCount > 0) drawCards(drawCount);
renderGame();
}
// ── function: generateEnemy ──
function generateEnemy() {
const gs = gameState;
if (gs.isBossRoom) {
const boss = BOSSES[gs.currentFloor];
gs.enemy = {
name: boss.name,
hp: boss.hp,
maxHP: boss.hp,
damage: boss.damage,
color: boss.color,
desc: boss.desc,
};
return;
}
const floorEnemies = FLOOR_ENEMIES[gs.currentFloor];
const template = floorEnemies[Math.floor(Math.random() * floorEnemies.length)];
// Scale slightly within floor
const hpScale = 0.9 + Math.random() * 0.2;
const dmgScale = 0.9 + Math.random() * 0.2;
gs.enemy = {
name: template.name,
hp: Math.round(template.hp * hpScale),
maxHP: Math.round(template.hp * hpScale),
damage: Math.round(template.damage * dmgScale),
color: template.color,
};
}
// ── const: canvas ──
// ============================================================
// CANVAS RENDERING
// ============================================================
const canvas = document.getElementById('game-canvas');
// ── const: ctx ──
const ctx = canvas.getContext('2d');
// ── function: resizeCanvas ──
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// ── let: particles ──
// Background particles
let particles = [];
// ── function: initParticles ──
function initParticles() {
particles = [];
for (let i = 0; i < 50; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
speed: Math.random() * 0.3 + 0.1,
opacity: Math.random() * 0.3 + 0.1,
drift: (Math.random() - 0.5) * 0.2,
});
}
}
// ── function: drawBackground ──
function drawBackground() {
const cls = gameState.className;
const classColors = {
bastion: { bg1: '#0a0a15', bg2: '#1a2a3a', accent: '#4a8fa8' },
wither: { bg1: '#0a0a15', bg2: '#2a1a1a', accent: '#c44a4a' },
shroud: { bg1: '#0a0a15', bg2: '#1a1a2a', accent: '#8a4ac4' },
};
const colors = classColors[cls];
// Dark gradient background
const grad = ctx.createRadialGradient(
canvas.width / 2, canvas.height * 0.3, 50,
canvas.width / 2, canvas.height * 0.3, canvas.width * 0.7
);
grad.addColorStop(0, colors.bg2);
grad.addColorStop(1, colors.bg1);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Atmospheric fog
ctx.fillStyle = 'rgba(10, 10, 20, 0.3)';
ctx.fillRect(0, canvas.height * 0.6, canvas.width, canvas.height * 0.4);
// Floating particles
for (let p of particles) {
p.y -= p.speed;
p.x += p.drift;
if (p.y < -10) { p.y = canvas.height + 10; p.x = Math.random() * canvas.width; }
if (p.x < -10) p.x = canvas.width + 10;
if (p.x > canvas.width + 10) p.x = -10;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${parseInt(colors.accent.slice(1,3),16)}, ${parseInt(colors.accent.slice(3,5),16)}, ${parseInt(colors.accent.slice(5,7),16)}, ${p.opacity})`;
ctx.fill();
}
}
// ── function: drawPlayerStats ──
function drawPlayerStats() {
const gs = gameState;
const hero = gs.hero;
const x = 30;
let y = 70;
// HP bar
ctx.fillStyle = '#6b5b8a';
ctx.font = '11px Georgia';
ctx.textAlign = 'left';
ctx.fillText('HEALTH', x, y);
y += 5;
const hpBarW = 200;
const hpBarH = 10;
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(x, y, hpBarW, hpBarH);
ctx.strokeStyle = '#2a2a4a';
ctx.strokeRect(x, y, hpBarW, hpBarH);
const hpRatio = Math.max(0, hero.hp / hero.maxHP);
const hpColor = hpRatio > 0.5 ? '#4a8a4a' : hpRatio > 0.25 ? '#e8a82a' : '#c44a4a';
ctx.fillStyle = hpColor;
ctx.fillRect(x, y, hpBarW * hpRatio, hpBarH);
ctx.fillStyle = '#c44a4a';
ctx.font = '10px Georgia';
ctx.fillText(`${hero.hp} / ${hero.maxHP}`, x + hpBarW + 8, y + 9);
y += 20;
// Defense bar
ctx.fillStyle = '#6b5b8a';
ctx.font = '11px Georgia';
ctx.fillText('DEFENSE', x, y);
y += 5;
const defBarW = 200;
const defBarH = 6;
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(x, y, defBarW, defBarH);
ctx.strokeStyle = '#2a2a4a';
ctx.strokeRect(x, y, defBarW, defBarH);
const defRatio = Math.min(1, hero.defense / 30);
ctx.fillStyle = '#4a8fa8';
ctx.fillRect(x, y, defBarW * defRatio, defBarH);
ctx.fillStyle = '#4a8fa8';
ctx.font = '10px Georgia';
ctx.fillText(`${hero.defense} + ${hero.permanentDefense}`, x + defBarW + 8, y + 6);
y += 20;
// Damage flash on player
if (gs.damageFlash === 'player') {
ctx.fillStyle = 'rgba(196, 74, 74, 0.2)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
gs.damageFlash = null;
}
}
// ── function: drawEnergyDisplay ──
function drawEnergyDisplay() {
const gs = gameState;
const hero = gs.hero;
const cx = canvas.width - 50;
const cy = 70;
// Energy orb
const orbGrad = ctx.createRadialGradient(cx - 5, cy - 5, 2, cx, cy, 30);
orbGrad.addColorStop(0, '#6ab4e8');
orbGrad.addColorStop(0.7, '#2a6a8a');
orbGrad.addColorStop(1, '#1a4a5a');
ctx.fillStyle = orbGrad;
ctx.beginPath();
ctx.arc(cx, cy, 28, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#4a8fa8';
ctx.lineWidth = 2;
ctx.stroke();
// Energy text
ctx.fillStyle = 'white';
ctx.font = 'bold 18px Georgia';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${hero.energy}`, cx, cy);
// Max energy
ctx.fillStyle = '#6b5b8a';
ctx.font = '10px Georgia';
ctx.fillText(`/${hero.maxEnergy}`, cx, cy + 38);
}
// ── function: drawGoldDisplay ──
function drawGoldDisplay() {
const gs = gameState;
const hero = gs.hero;
const x = canvas.width / 2;
const y = 35;
ctx.fillStyle = '#c9a84c';
ctx.font = 'bold 16px Georgia';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`⛃ ${hero.gold}`, x, y);
}
// ── function: drawFloorDisplay ──
function drawFloorDisplay() {
const gs = gameState;
const x = canvas.width / 2;
const y = canvas.height - 230;
ctx.fillStyle = '#8a4ac4';
ctx.font = '12px Georgia';
ctx.textAlign = 'center';
ctx.fillText(`Floor ${gs.currentFloor}`, x, y);
// Room indicators
for (let i = 0; i < gs.totalRoomsPerFloor; i++) {
const dotX = x - 20 + i * 20;
const isCompleted = i < gs.currentRoom - 1;
const isCurrent = i === gs.currentRoom - 1;
const isBoss = i === gs.totalRoomsPerFloor - 1;
ctx.beginPath();
ctx.arc(dotX, y + 15, 5, 0, Math.PI * 2);
if (isCompleted) {
ctx.fillStyle = '#4a8a4a';
} else if (isCurrent) {
ctx.fillStyle = isBoss ? '#c44a4a' : '#8a4ac4';
} else {
ctx.fillStyle = '#2a2a4a';
}
ctx.fill();
if (isBoss && i === gs.currentRoom - 1) {
ctx.fillStyle = '#c44a4a';
ctx.font = '8px Georgia';
ctx.fillText('☠', dotX, y + 30);
}
}
}
// ── function: renderHand ──
function renderHand() {
const gs = gameState;
const hero = gs.hero;
const overlay = document.getElementById('ui-overlay');
// Remove old hand
const oldHand = overlay.querySelector('.hand-area');
if (oldHand) oldHand.remove();
const handArea = document.createElement('div');
handArea.className = 'hand-area';
for (let i = 0; i < hero.hand.length; i++) {
const card = hero.hand[i];
const cardEl = createCardElement(card, i, hero);
handArea.appendChild(cardEl);
}
// Action buttons
const actionBar = document.createElement('div');
actionBar.className = 'action-bar';
const endTurnBtn = document.createElement('button');
endTurnBtn.className = 'action-btn end-turn-btn';
endTurnBtn.textContent = 'End Turn';
endTurnBtn.addEventListener('click', () => {
if (gs.turnPhase === 'player') endTurn();
});
const shopBtn = document.createElement('button');
shopBtn.className = 'action-btn';
shopBtn.textContent = `Shop (${hero.gold}g)`;
shopBtn.addEventListener('click', () => {
gs.shopOpen = true;
renderGame();
});
actionBar.appendChild(shopBtn);
actionBar.appendChild(endTurnBtn);
handArea.appendChild(actionBar);
overlay.appendChild(handArea);
}
// ── function: createCardElement ──
function createCardElement(card, index, hero) {
const cardEl = document.createElement('div');
cardEl.className = `hand-card card-${card.type}`;
const canPlay = card.cost <= hero.energy;
if (!canPlay) cardEl.classList.add('cannot-play');
const cardContainer = document.createElement('div');
cardContainer.className = `card-container card-${card.type}`;
// Card cost
const costEl = document.createElement('div');
costEl.className = 'card-cost';
costEl.textContent = card.cost;
cardContainer.appendChild(costEl);
// Card image (SVG frame)
const imageEl = document.createElement('div');
imageEl.className = 'card-image';
imageEl.innerHTML = getCardFrameSVG(card);
cardContainer.appendChild(imageEl);
// Card name
const nameEl = document.createElement('div');
nameEl.className = 'card-name';
nameEl.textContent = card.name;
cardContainer.appendChild(nameEl);
// Card description
const descEl = document.createElement('div');
descEl.className = 'card-description';
descEl.textContent = card.desc;
cardContainer.appendChild(descEl);
cardEl.appendChild(cardContainer);
// Click handler
cardEl.addEventListener('click', () => {
if (gs.turnPhase === 'player' && canPlay) {
playCard(card.uid);
renderGame();
}
});
return cardEl;
}
// ── function: renderShop ──
function renderShop() {
const gs = gameState;
const overlay = document.getElementById('ui-overlay');
// Remove old shop
const oldShop = overlay.querySelector('#shop-overlay');
if (oldShop) oldShop.remove();
if (!gs.shopOpen) return;
const shop = document.createElement('div');
shop.id = 'shop-overlay';
shop.className = 'active';
const container = document.createElement('div');
container.className = 'shop-container';
const title = document.createElement('h2');
title.className = 'shop-title';
title.textContent = 'Void Merchant';
container.appendChild(title);
const subtitle = document.createElement('p');
subtitle.className = 'shop-subtitle';
subtitle.textContent = 'Choose wisely - the Void hungers.';
container.appendChild(subtitle);
const goldEl = document.createElement('p');
goldEl.className = 'shop-gold';
goldEl.innerHTML = `Gold: <span>${gs.hero.gold}</span> ⛃`;
container.appendChild(goldEl);
const cardsContainer = document.createElement('div');
cardsContainer.className = 'shop-cards';
// Generate 3 random cards from any class
const allCardIds = Object.keys(CARD_DATA);
const shopCards = [];
for (let i = 0; i < 3; i++) {
const id = allCardIds[Math.floor(Math.random() * allCardIds.length)];
const card = CARD_DATA[id];
const count = gs.inventory[id] || 0;
const cost = Math.min(50, card.cost + count);
shopCards.push({ card, cost, count });
}
for (let sc of shopCards) {
const cardEl = document.createElement('div');
cardEl.className = `shop-card card-${sc.card.type}`;
const cardInner = document.createElement('div');
cardInner.style.cssText = 'width:100%;height:100%;position:relative;';
// Mini SVG
const svgEl = document.createElement('div');
svgEl.style.cssText = 'width:100%;height:100%;';
svgEl.innerHTML = getCardFrameSVG(sc.card);
cardInner.appendChild(svgEl);
// Cost badge
const costBadge = document.createElement('div');
costBadge.className = 'shop-card-cost';
costBadge.textContent = sc.cost;
cardInner.appendChild(costBadge);
// Name
const nameEl = document.createElement('div');
nameEl.className = 'shop-card-name';
nameEl.textContent = sc.card.name;
cardInner.appendChild(nameEl);
// Desc
const descEl = document.createElement('div');
descEl.className = 'shop-card-desc';
descEl.textContent = sc.card.desc;
cardInner.appendChild(descEl);
cardEl.appendChild(cardInner);
// Buy handler
cardEl.addEventListener('click', () => {
if (gs.hero.gold >= sc.cost && sc.count < 5) {
gs.hero.gold -= sc.cost;
if (!gs.inventory[sc.card.id]) gs.inventory[sc.card.id] = 0;
gs.inventory[sc.card.id]++;
// Add to deck
gs.hero.deck.push({ ...CARD_DATA[sc.card.id], uid: `${sc.card.id}-${Date.now()}-${Math.random()}` });
renderGame();
}
});
cardsContainer.appendChild(cardEl);
}
container.appendChild(cardsContainer);
const closeBtn = document.createElement('button');
closeBtn.className = 'shop-close';
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', () => {
gs.shopOpen = false;
renderGame();
});
container.appendChild(closeBtn);
shop.appendChild(container);
overlay.appendChild(shop);
}
// ── function: showVictory ──
function showVictory() {
document.getElementById('victory-screen').classList.remove('hidden');
}
// ── function: showGameOver ──
function showGameOver() {
document.getElementById('gameover-screen').classList.remove('hidden');
}
// ── function: hideAllScreens ──
function hideAllScreens() {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
}
// ── function: startGame ──
function startGame(className) {
hideAllScreens();
createGameState(className);
generateEnemy();
gameState.shopOpen = false;
// Draw initial hand
const hero = gameState.hero;
const drawCount = Math.min(4, hero.deck.length);
drawCards(drawCount);
resizeCanvas();
initParticles();
renderGame();
}
// ── function: resolveCardEffect ──
function resolveCardEffect(card) {
const gs = gameState;
const hero = gs.hero;
const enemy = gs.enemy;
if (!enemy) return;
// Debug: log the card being resolved
gs._lastResolvedCard = card.id;
gs._lastResolvedType = card.type;
switch (card.type) {
case "attack": {
let damage = card.atk;
if (card.id === "BA-A01" && hero.hp >= 30) drawCards(1);
if (card.id === "SH-A01" && gs.enemyDefense <= 0) damage += 1;
if (card.id === "SH-A02") gs.enemyDefense = 0;
if (card.id === "SH-U04") { gs.enemyDefense = 0; damage = 8; }
enemy.hp = Math.max(0, enemy.hp - damage);
if (card.id === "WI-A01") gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 1);
if (card.id === "WI-A02") gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 3);
if (card.id === "WI-A03") gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 1);
if (card.id === "WI-A04") gs._nextTurnBurn = (gs._nextTurnBurn || 0) + 1;
if (card.id === "BA-D04") gs.enemyStatusEffects.stun = Math.max(gs.enemyStatusEffects.stun, 1);
if (card.id === "SH-A03") gs.enemyStatusEffects.stun = Math.max(gs.enemyStatusEffects.stun, 1);
break;
}
case "defense": {
let defGain = card.def;
if (card.id === "BA-D02") {
hero.permanentDefense += defGain;
if (hero.hand.length > 0) { const discard = hero.hand.pop(); hero.discardPile.push(discard); }
} else {
hero.defense += defGain;
}
if (card.id === "BA-D03") gs.playerBlockedThisTurn += 1;
if (card.id === "WI-D01" || card.id === "WI-D03") gs.burnBlock = (gs.burnBlock || 0) + 1;
if (card.id === "WI-D02") hero.energy = Math.min(hero.maxEnergy, hero.energy + 1);
if (card.id === "SH-D02") hero.energy = Math.min(hero.maxEnergy, hero.energy + 1);
break;
}
case "other": {
if (card.id === "BA-U01") { drawCards(1); if (hero.hand.length > 5) { const excess = hero.hand.splice(5); hero.discardPile.push(...excess); } }
if (card.id === "BA-U02") hero.hp = Math.min(hero.maxHP, hero.hp + 6);
if (card.id === "BA-U03") { drawCards(1); hero.energy = Math.min(hero.maxEnergy, hero.energy + 1); }
if (card.id === "BA-U04") { hero.gold += 5; gs._lootApplied = true; }
if (card.id === "WI-U01") gs.enemyStatusEffects.poison = Math.max(gs.enemyStatusEffects.poison, 1);
if (card.id === "WI-U02") { enemy.hp = Math.max(0, enemy.hp - 3); gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 2); }
if (card.id === "WI-U03") { drawCards(1); gs.enemyStatusEffects.stun = Math.max(gs.enemyStatusEffects.stun, 1); }
if (card.id === "WI-U04") { hero.hp = Math.min(hero.maxHP, hero.hp + 5); gs.enemyAttackReduction = (gs.enemyAttackReduction || 0) + 1; }
if (card.id === "SH-U01") { hero.energy = Math.min(hero.maxEnergy, hero.energy + 1); gs.skipEnemyAttack = true; }
if (card.id === "SH-U02") enemy.hp = Math.max(0, enemy.hp - 2);
if (card.id === "SH-U03") { hero.defense += 1; if (hero.hand.length > 0) hero.discardPile.push(hero.hand.pop()); drawCards(2); }
if (card.id === "SH-D03") gs.skipEnemyAttack = true;
if (card.id === "SH-A04") hero.energy = Math.min(hero.maxEnergy, hero.energy + 2);
break;
}
}
}
// ── function: playCard ──
function playCard(cardUid) {
// Debug: check what gameState we see
const gs = gameState;
const hero = gs.hero;
const cardIndex = hero.hand.findIndex(h => h.uid === cardUid);
if (cardIndex === -1) return false;
const card = hero.hand[cardIndex];
if (card.cost > hero.energy) return false;
hero.energy -= card.cost;
hero.hand.splice(cardIndex, 1);
hero.discardPile.push(card);
gs._lastPlayedCard = card.id;
if (card.type === "attack") {
let damage = card.atk;
if (card.id === "BA-A01" && hero.hp >= 30) drawCards(1);
if (card.id === "SH-A01" && gs.enemyDefense <= 0) damage += 1;
if (card.id === "SH-A02") gs.enemyDefense = 0;
if (card.id === "SH-U04") { gs.enemyDefense = 0; damage = 8; }
gs.enemy.hp = Math.max(0, gs.enemy.hp - damage);
if (card.id === "WI-A01") gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 1);
if (card.id === "WI-A02") gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 3);
if (card.id === "WI-A03") gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 1);
if (card.id === "WI-A04") gs._nextTurnBurn = (gs._nextTurnBurn || 0) + 1;
if (card.id === "BA-D04") gs.enemyStatusEffects.stun = Math.max(gs.enemyStatusEffects.stun, 1);
if (card.id === "SH-A03") gs.enemyStatusEffects.stun = Math.max(gs.enemyStatusEffects.stun, 1);
} else if (card.type === "defense") {
let defGain = card.def;
if (card.id === "BA-D02") {
hero.permanentDefense += defGain;
if (hero.hand.length > 0) { const discard = hero.hand.pop(); hero.discardPile.push(discard); }
} else {
hero.defense += defGain;
}
if (card.id === "BA-D03") gs.playerBlockedThisTurn += 1;
if (card.id === "WI-D01" || card.id === "WI-D03") gs.burnBlock = (gs.burnBlock || 0) + 1;
if (card.id === "WI-D02") hero.energy = Math.min(hero.maxEnergy, hero.energy + 1);
if (card.id === "SH-D02") hero.energy = Math.min(hero.maxEnergy, hero.energy + 1);
} else if (card.type === "other") {
if (card.id === "BA-U01") { drawCards(1); if (hero.hand.length > 5) { const excess = hero.hand.splice(5); hero.discardPile.push(...excess); } }
if (card.id === "BA-U02") hero.hp = Math.min(hero.maxHP, hero.hp + 6);
if (card.id === "BA-U03") { drawCards(1); hero.energy = Math.min(hero.maxEnergy, hero.energy + 1); }
if (card.id === "BA-U04") hero.gold += 5;
if (card.id === "WI-U01") gs.enemyStatusEffects.poison = Math.max(gs.enemyStatusEffects.poison, 1);
if (card.id === "WI-U02") { gs.enemy.hp = Math.max(0, gs.enemy.hp - 3); gs.enemyStatusEffects.burn = Math.max(gs.enemyStatusEffects.burn, 2); }
if (card.id === "WI-U03") { drawCards(1); gs.enemyStatusEffects.stun = Math.max(gs.enemyStatusEffects.stun, 1); }
if (card.id === "WI-U04") { hero.hp = Math.min(hero.maxHP, hero.hp + 5); gs.enemyAttackReduction = (gs.enemyAttackReduction || 0) + 1; }
if (card.id === "SH-U01") { hero.energy = Math.min(hero.maxEnergy, hero.energy + 1); gs.skipEnemyAttack = true; }
if (card.id === "SH-U02") gs.enemy.hp = Math.max(0, gs.enemy.hp - 2);
if (card.id === "SH-U03") { hero.defense += 1; if (hero.hand.length > 0) hero.discardPile.push(hero.hand.pop()); drawCards(2); }
if (card.id === "SH-D03") gs.skipEnemyAttack = true;
if (card.id === "SH-A04") hero.energy = Math.min(hero.maxEnergy, hero.energy + 2);
}
gs.cardsPlayed.push(card);
return true;
}
// ── function: drawEnemy ──
function drawEnemy() {
const gs = gameState;
if (!gs.enemy) return;
const enemy = gs.enemy;
const cx = canvas.width / 2;
const cy = canvas.height * 0.42;
const size = gs.isBossRoom ? 110 : 75;
// Parse enemy color for glow
const r = parseInt(enemy.color.slice(1, 3), 16);
const g = parseInt(enemy.color.slice(3, 5), 16);
const b = parseInt(enemy.color.slice(5, 7), 16);
// Ambient glow
const glowGrad = ctx.createRadialGradient(cx, cy, size * 0.2, cx, cy, size * 2);
glowGrad.addColorStop(0, `rgba(${r},${g},${b},0.12)`);
glowGrad.addColorStop(0.5, `rgba(${r},${g},${b},0.04)`);
glowGrad.addColorStop(1, 'transparent');
ctx.fillStyle = glowGrad;
ctx.fillRect(cx - size * 2.5, cy - size * 2.5, size * 5, size * 5);
// Draw enemy body
ctx.save();
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(cx, cy + size * 1.1, size * 0.6, size * 0.15, 0, 0, Math.PI * 2);
ctx.fill();
// Body
const bodyGrad = ctx.createLinearGradient(cx - size * 0.5, cy - size * 0.5, cx + size * 0.5, cy + size);
bodyGrad.addColorStop(0, `rgba(${Math.min(255, r + 80)}, ${Math.min(255, g + 80)}, ${Math.min(255, b + 80)}, 0.9)`);
bodyGrad.addColorStop(0.5, `rgba(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)}, 0.8)`);
bodyGrad.addColorStop(1, `rgba(${Math.max(0, r - 20)}, ${Math.max(0, g - 20)}, ${Math.max(0, b - 20)}, 0.7)`);
ctx.fillStyle = bodyGrad;
ctx.beginPath();
ctx.ellipse(cx, cy + size * 0.15, size * 0.45, size * 0.75, 0, 0, Math.PI * 2);
ctx.fill();
// Head
const headGrad = ctx.createRadialGradient(cx - 5, cy - size * 0.7, 5, cx, cy - size * 0.6, size * 0.35);
headGrad.addColorStop(0, `rgba(${Math.min(255, r + 100)}, ${Math.min(255, g + 100)}, ${Math.min(255, b + 100)}, 0.95)`);
headGrad.addColorStop(1, `rgba(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)}, 0.8)`);
ctx.fillStyle = headGrad;
ctx.beginPath();
ctx.arc(cx, cy - size * 0.6, size * 0.32, 0, Math.PI * 2);
ctx.fill();
// Eyes
const eyeColor = gs.isBossRoom ? '#ff4a4a' : '#e8a82a';
ctx.shadowColor = eyeColor;
ctx.shadowBlur = 12;
ctx.fillStyle = eyeColor;
ctx.beginPath();
ctx.arc(cx - size * 0.1, cy - size * 0.65, size * 0.045, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(cx + size * 0.1, cy - size * 0.65, size * 0.045, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Mouth
ctx.strokeStyle = `rgba(${Math.max(0, r - 40)}, ${Math.max(0, g - 40)}, ${Math.max(0, b - 40)}, 0.6)`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy - size * 0.48, size * 0.1, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
// Damage flash
if (gs.damageFlash === 'enemy') {
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.beginPath();
ctx.ellipse(cx, cy, size * 0.55, size * 0.85, 0, 0, Math.PI * 2);
ctx.fill();
gs.damageFlash = null;
}
// Status effect indicators
let statusY = cy - size - 25;
if (gs.enemyStatusEffects.burn > 0) {
ctx.fillStyle = '#e8a82a';
ctx.font = 'bold 16px Georgia';
ctx.textAlign = 'center';
ctx.fillText('🔥', cx - 40, statusY);
ctx.font = '12px Georgia';
ctx.fillStyle = '#e8a82a';
ctx.fillText(gs.enemyStatusEffects.burn, cx - 40, statusY + 16);
statusY += 20;
}
if (gs.enemyStatusEffects.poison > 0) {
ctx.fillStyle = '#4ae84a';
ctx.font = 'bold 16px Georgia';
ctx.textAlign = 'center';
ctx.fillText('☠', cx + 40, statusY);
ctx.font = '12px Georgia';
ctx.fillStyle = '#4ae84a';
ctx.fillText(gs.enemyStatusEffects.poison, cx + 40, statusY + 16);
}
ctx.restore();
// Enemy HP bar
const barWidth = Math.min(250, canvas.width * 0.25);
const barHeight = 10;
const barX = cx - barWidth / 2;
const barY = cy + size * 1.0 + 15;
// Bar background
ctx.fillStyle = '#1a1a2e';
ctx.beginPath();
ctx.roundRect(barX, barY, barWidth, barHeight, 5);
ctx.fill();
ctx.strokeStyle = '#2a2a4a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(barX, barY, barWidth, barHeight, 5);
ctx.stroke();
// HP fill
const hpRatio = Math.max(0, enemy.hp / enemy.maxHP);
const hpColor = hpRatio > 0.5 ? '#c44a4a' : hpRatio > 0.25 ? '#e8a82a' : '#e84a4a';
ctx.fillStyle = hpColor;
ctx.beginPath();
ctx.roundRect(barX, barY, barWidth * hpRatio, barHeight, 5);
ctx.fill();
// HP text
ctx.fillStyle = '#c44a4a';
ctx.font = 'bold 11px Georgia';
ctx.textAlign = 'center';
ctx.fillText(`${enemy.hp} / ${enemy.maxHP}`, cx, barY + barHeight + 14);
// Enemy name
ctx.fillStyle = '#8a7a9a';
ctx.font = '13px Georgia';
ctx.textAlign = 'center';
ctx.fillText(enemy.name, cx, barY - 8);
// Boss description
if (gs.isBossRoom && enemy.desc) {
ctx.fillStyle = '#6b5b8a';
ctx.font = 'italic 10px Georgia';
ctx.textAlign = 'center';
ctx.fillText(enemy.desc, cx, barY + barHeight + 30);
}
}
// ── function: drawHandArea ──
function drawHandArea() {
const gs = gameState;
const hero = gs.hero;
const handY = canvas.height - 225;
const handWidth = hero.hand.length * 150;
const startX = (canvas.width - handWidth) / 2;
for (let i = 0; i < hero.hand.length; i++) {
const card = hero.hand[i];
const x = startX + i * 150 + 5;
// Card shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.roundRect(x + 3, handY + 3, 140, 200, 8);
ctx.fill();
// Card border glow
const borderColor = card.type === 'attack' ? '#8a2a2a' : card.type === 'defense' ? '#2a5a6a' : '#5a2a6a';
ctx.strokeStyle = borderColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, handY, 140, 200, 8);
ctx.stroke();
// Card background
const cardGrad = ctx.createLinearGradient(x, handY, x + 140, handY + 200);
cardGrad.addColorStop(0, '#1a1a2e');
cardGrad.addColorStop(1, '#0f0f1a');
ctx.fillStyle = cardGrad;
ctx.beginPath();
ctx.roundRect(x, handY, 140, 200, 8);
ctx.fill();
// Cost circle
const costColor = card.type === 'attack' ? '#c44a4a' : card.type === 'defense' ? '#4a8fa8' : '#8a4ac4';
const costGrad = ctx.createRadialGradient(x + 14, handY + 14, 2, x + 14, handY + 14, 14);
costGrad.addColorStop(0, costColor);
costGrad.addColorStop(1, card.type === 'attack' ? '#6a1a1a' : card.type === 'defense' ? '#1a4a5a' : '#3a1a4a');
ctx.fillStyle = costGrad;
ctx.beginPath();
ctx.arc(x + 14, handY + 14, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'white';
ctx.font = 'bold 12px Georgia';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(card.cost, x + 14, handY + 14);
// Card name
ctx.fillStyle = '#c9a84c';
ctx.font = 'bold 10px Georgia';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(card.name, x + 70, handY + 42);
// Card description
ctx.fillStyle = '#8a7a9a';
ctx.font = '8px Georgia';
const words = card.desc.split(' ');
let line = '';
let ly = handY + 175;
for (let w = 0; w < words.length; w++) {
const testLine = line + words[w] + ' ';
if (ctx.measureText(testLine).width > 120 && line !== '') {
ctx.fillText(line.trim(), x + 70, ly);
line = words[w] + ' ';
ly += 10;
} else {
line = testLine;
}
}
ctx.fillText(line.trim(), x + 70, ly);
// Type indicator
const typeColor = card.type === 'attack' ? '#e86a4a' : card.type === 'defense' ? '#6ab4e8' : '#a86ae8';
ctx.fillStyle = typeColor;
ctx.font = 'bold 9px Georgia';
ctx.fillText(card.type.toUpperCase(), x + 70, handY + 58);
}
}
// ── function: renderGame ──
function renderGame() {
const gs = gameState;
const hero = gs.hero;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background
drawBackground();
// Draw game elements
drawEnemy();
drawPlayerStats();
drawEnergyDisplay();
drawGoldDisplay();
drawFloorDisplay();
drawDeckPile();
drawHandArea();
// Render HTML UI overlay
renderHand();
renderShop();
}
// ── function: drawCardBack ──
function drawCardBack(cx, cy, size, cls) {
const colors = {
bastion: { bg1: '#1a2a3a', bg2: '#0a1a2a', border: '#2a5a6a', accent: '#4a8fa8' },
wither: { bg1: '#2a1a1a', bg2: '#1a0a0a', border: '#6a2a2a', accent: '#c44a4a' },
shroud: { bg1: '#1a1a2a', bg2: '#0a0a1a', border: '#3a2a5a', accent: '#8a4ac4' },
};
const c = colors[cls] || colors.bastion;
// Background
const grad = ctx.createLinearGradient(cx - size/2, cy - size, cx + size/2, cy + size);
grad.addColorStop(0, c.bg1);
grad.addColorStop(1, c.bg2);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(cx - size/2, cy - size, size, size * 1.43, 6);
ctx.fill();
// Border
ctx.strokeStyle = c.border;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(cx - size/2, cy - size, size, size * 1.43, 6);
ctx.stroke();
// Inner border
ctx.strokeStyle = c.accent;
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.roundRect(cx - size/2 + 4, cy - size + 4, size - 8, size * 1.43 - 8, 4);
ctx.stroke();
ctx.globalAlpha = 1;
// Center emblem
ctx.fillStyle = c.accent;
ctx.globalAlpha = 0.4;
ctx.beginPath();
ctx.arc(cx, cy, size * 0.25, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(cx, cy, size * 0.15, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
// ── function: drawDeckPile ──
function drawDeckPile() {
const gs = gameState;
const hero = gs.hero;
const x = 30;
const y = canvas.height * 0.55;
// Draw card back
drawCardBack(x + 30, y + 40, 25, gs.className);
// Deck count
ctx.fillStyle = '#6b5b8a';
ctx.font = '11px Georgia';
ctx.textAlign = 'left';
ctx.fillText(`Deck: ${hero.deck.length}`, x + 65, y + 30);
// Discard pile count
ctx.fillStyle = '#6b5b8a';
ctx.fillText(`Discard: ${hero.discardPile.length}`, x + 65, y + 50);
// Permanent defense
ctx.fillStyle = '#4a8fa8';
ctx.fillText(`Perma Def: ${hero.permanentDefense}`, x + 65, y + 70);
// Room progress
const roomX = canvas.width / 2;
const roomY = canvas.height * 0.75;
ctx.fillStyle = '#6b5b8a';
ctx.font = '11px Georgia';
ctx.textAlign = 'center';
ctx.fillText(`Room ${gs.currentRoom} of ${gs.totalRoomsPerFloor}`, roomX, roomY);
for (let i = 0; i < gs.totalRoomsPerFloor; i++) {
const dotX = roomX - 24 + i * 24;
const isCompleted = i < gs.currentRoom - 1;
const isCurrent = i === gs.currentRoom - 1;
const isBoss = i === gs.totalRoomsPerFloor - 1;
ctx.beginPath();
ctx.arc(dotX, roomY + 15, 6, 0, Math.PI * 2);
if (isCompleted) {
ctx.fillStyle = '#4a8a4a';
} else if (isCurrent) {
ctx.fillStyle = isBoss ? '#c44a4a' : '#8a4ac4';
} else {
ctx.fillStyle = '#2a2a4a';
}
ctx.fill();
ctx.strokeStyle = '#3a3a5a';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// ── main ──
// ============================================================
// MAIN - Game Initialization
// ============================================================
(function init() {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Class selection
let selectedClass = null;
document.querySelectorAll('.class-option').forEach(opt => {
opt.addEventListener('click', () => {
document.querySelectorAll('.class-option').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
selectedClass = opt.dataset.class;
});
});
// Start button
document.getElementById('start-btn').addEventListener('click', () => {
if (selectedClass) {
startGame(selectedClass);
}
});
// Menu button (victory screen)
document.getElementById('menu-btn').addEventListener('click', () => {
hideAllScreens();
gameState = null;
document.getElementById('start-screen').classList.remove('hidden');
});
// Retry button (game over screen)
document.getElementById('retry-btn').addEventListener('click', () => {
hideAllScreens();
gameState = null;
document.getElementById('start-screen').classList.remove('hidden');
});
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (!gameState || gameState.gameOver || gameState.victory) return;
if (gameState.shopOpen) return;
if (e.key === 'e' || e.key === 'E') {
endTurn();
}
if (e.key === 's' || e.key === 'S') {
gameState.shopOpen = !gameState.shopOpen;
renderGame();
}
});
// Initial render of empty canvas
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"ea21a1ea-b4b9-4901-b188-bf926910ad2b","tokensIn":4552814,"tokensOut":72126,"tokensTotal":4624940,"turns":79,"toolCalls":78,"failedToolCalls":1,"timestamp":"2026-07-20T22:57:47.025Z"} -->