dm / commit
feat: add solo D&D web app frontend
public/index.html | 1349 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1349 insertions(+) diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..31261d6 --- /dev/null +++ b/public/index.html @@ -0,0 +1,1349 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Solo Dungeon Master</title> +<link rel="icon" type="image/png" href="/dice.png"> +<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet"> +<style> +:root { + --bg: #0d0d1a; + --bg-panel: #11111e; + --bg-sidebar: #0c1220; + --bg-card: #181826; + --bg-dm: #14142a; + --bg-player: #1a0f28; + --bg-input: #0a0a18; + --gold: #c9a84c; + --gold-light: #e8c97a; + --gold-dim: #7a5c20; + --text: #d0be98; + --text-dim: #6a5a42; + --text-bright: #eedcb8; + --border: #1e1e34; + --border-gold: #3a2a0e; + --hp-full: #2ecc71; + --hp-med: #f39c12; + --hp-low: #e74c3c; + --accent: #6b4fbb; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Crimson Text', Georgia, serif; + font-size: 17px; + line-height: 1.6; + height: 100vh; + overflow: hidden; +} + +/* ── CREATION SCREEN ─────────────────────────────── */ + +#creation-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 100vh; + overflow-y: auto; + padding: 40px 20px 60px; + background: + radial-gradient(ellipse at 50% 0%, rgba(107,79,187,0.18) 0%, transparent 60%), + radial-gradient(ellipse at 20% 80%, rgba(201,168,76,0.08) 0%, transparent 50%), + var(--bg); +} + +.creation-title { + font-family: 'Cinzel', serif; + font-size: 3rem; + font-weight: 700; + color: var(--gold-light); + text-align: center; + letter-spacing: 0.08em; + text-shadow: 0 0 40px rgba(201,168,76,0.4), 0 2px 8px rgba(0,0,0,0.8); + margin-bottom: 6px; +} + +.creation-sub { + color: var(--text-dim); + font-style: italic; + font-size: 1.1rem; + text-align: center; + margin-bottom: 40px; + letter-spacing: 0.05em; +} + +.creation-section { + width: 100%; + max-width: 700px; + margin-bottom: 30px; +} + +.section-label { + font-family: 'Cinzel', serif; + font-size: 0.8rem; + letter-spacing: 0.15em; + color: var(--gold); + text-transform: uppercase; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-gold); +} + +.selection-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.selection-grid.class-grid { + grid-template-columns: repeat(5, 1fr); +} + +.sel-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.sel-card:hover { + border-color: var(--gold-dim); + background: #1e1e30; +} + +.sel-card.selected { + border-color: var(--gold); + background: #1e1a0a; + box-shadow: 0 0 12px rgba(201,168,76,0.2); +} + +.sel-card .icon { font-size: 1.6rem; } +.sel-card .name { + font-family: 'Cinzel', serif; + font-size: 0.65rem; + letter-spacing: 0.05em; + color: var(--text-dim); + text-align: center; +} + +.sel-card.selected .name { color: var(--gold-light); } + +.text-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.field-group label { + display: block; + font-family: 'Cinzel', serif; + font-size: 0.7rem; + letter-spacing: 0.12em; + color: var(--gold); + text-transform: uppercase; + margin-bottom: 6px; +} + +.field-group input { + width: 100%; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-bright); + font-family: 'Crimson Text', serif; + font-size: 1rem; + padding: 10px 14px; + outline: none; + transition: border-color 0.15s; +} + +.field-group input:focus { border-color: var(--gold-dim); } +.field-group input::placeholder { color: var(--text-dim); font-style: italic; } + +.begin-btn { + display: block; + width: 100%; + max-width: 700px; + padding: 16px; + font-family: 'Cinzel', serif; + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.12em; + color: #0d0d1a; + background: linear-gradient(135deg, var(--gold) 0%, var(--gold-light) 50%, var(--gold) 100%); + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + box-shadow: 0 4px 20px rgba(201,168,76,0.3); + margin-top: 8px; +} + +.begin-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 28px rgba(201,168,76,0.45); +} + +.begin-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── GAME SCREEN ────────────────────────────────── */ + +#game-screen { + display: none; + height: 100vh; + flex-direction: row; +} + +#game-screen.visible { display: flex; } + +/* Chat panel */ +.chat-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-panel); + border-right: 1px solid var(--border); +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg-card); + flex-shrink: 0; +} + +.chat-header-title { + font-family: 'Cinzel', serif; + font-size: 0.85rem; + letter-spacing: 0.12em; + color: var(--gold); + text-transform: uppercase; +} + +.new-game-btn { + font-family: 'Cinzel', serif; + font-size: 0.65rem; + letter-spacing: 0.1em; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 12px; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.new-game-btn:hover { border-color: var(--gold-dim); color: var(--gold); } + +#messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +#messages::-webkit-scrollbar { width: 4px; } +#messages::-webkit-scrollbar-track { background: transparent; } +#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* Message styles */ +.msg { display: flex; flex-direction: column; gap: 4px; } + +.msg-dm { + border-left: 2px solid var(--gold-dim); + background: var(--bg-dm); + border-radius: 0 6px 6px 0; + padding: 14px 18px; +} + +.msg-player { + align-self: flex-end; + max-width: 75%; + background: var(--bg-player); + border: 1px solid #2d1a40; + border-radius: 6px 6px 0 6px; + padding: 10px 16px; + color: #c0a8e0; + font-style: italic; +} + +.msg-system { + align-self: center; + text-align: center; + color: var(--gold); + font-family: 'Cinzel', serif; + font-size: 0.78rem; + letter-spacing: 0.1em; + padding: 6px 16px; + border: 1px solid var(--border-gold); + border-radius: 20px; + background: rgba(201,168,76,0.05); +} + +.msg-dm p { margin-bottom: 10px; } +.msg-dm p:last-child { margin-bottom: 0; } +.msg-dm .options { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; } +.msg-dm .option { + display: flex; + gap: 10px; + padding: 8px 12px; + background: rgba(201,168,76,0.06); + border: 1px solid var(--border-gold); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; + align-items: flex-start; +} +.msg-dm .option:hover { background: rgba(201,168,76,0.12); border-color: var(--gold-dim); } +.msg-dm .option .opt-num { + font-family: 'Cinzel', serif; + font-size: 0.8rem; + color: var(--gold); + font-weight: 600; + min-width: 18px; + margin-top: 1px; +} +.msg-dm .option .opt-text { color: var(--text-bright); } + +.typing-dots { + display: flex; + gap: 5px; + padding: 8px 0 4px; + align-items: center; +} +.typing-dots span { + width: 6px; height: 6px; + background: var(--gold-dim); + border-radius: 50%; + animation: dot-pulse 1.4s infinite ease-in-out; +} +.typing-dots span:nth-child(2) { animation-delay: 0.2s; } +.typing-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes dot-pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1); } +} + +/* Quick actions */ +.quick-actions { + display: flex; + gap: 8px; + padding: 10px 20px; + border-top: 1px solid var(--border); + flex-wrap: wrap; + flex-shrink: 0; +} + +.quick-btn { + font-family: 'Crimson Text', serif; + font-size: 0.88rem; + color: var(--text-dim); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 12px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.quick-btn:hover:not(:disabled) { border-color: var(--gold-dim); color: var(--text); } +.quick-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* Input area */ +.input-area { + display: flex; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid var(--border); + background: var(--bg-card); + flex-shrink: 0; + align-items: flex-end; +} + +#player-input { + flex: 1; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-bright); + font-family: 'Crimson Text', serif; + font-size: 1rem; + padding: 10px 14px; + outline: none; + resize: none; + min-height: 44px; + max-height: 120px; + line-height: 1.5; + transition: border-color 0.15s; +} +#player-input:focus { border-color: var(--gold-dim); } +#player-input::placeholder { color: var(--text-dim); font-style: italic; } + +#send-btn { + font-family: 'Cinzel', serif; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.08em; + color: #0d0d1a; + background: var(--gold); + border: none; + border-radius: 6px; + padding: 10px 20px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + align-self: flex-end; +} +#send-btn:hover:not(:disabled) { background: var(--gold-light); } +#send-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── SIDEBAR ────────────────────────────────────── */ + +.sidebar { + width: 300px; + min-width: 300px; + display: flex; + flex-direction: column; + background: var(--bg-sidebar); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.sidebar::-webkit-scrollbar { width: 3px; } +.sidebar::-webkit-scrollbar-thumb { background: var(--border); } + +.sidebar-section { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-title { + font-family: 'Cinzel', serif; + font-size: 0.65rem; + letter-spacing: 0.15em; + color: var(--gold); + text-transform: uppercase; + margin-bottom: 12px; +} + +/* Character sheet */ +.char-portrait { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.char-icon { + font-size: 2.5rem; + width: 52px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + flex-shrink: 0; +} + +.char-identity { flex: 1; min-width: 0; } + +.char-name { + font-family: 'Cinzel', serif; + font-size: 0.95rem; + color: var(--text-bright); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.char-race-class { + font-size: 0.82rem; + color: var(--text-dim); + font-style: italic; +} + +/* HP bar */ +.stat-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.stat-label { + font-family: 'Cinzel', serif; + font-size: 0.65rem; + letter-spacing: 0.1em; + color: var(--text-dim); + text-transform: uppercase; +} + +.stat-value { + font-family: 'Cinzel', serif; + font-size: 0.85rem; + color: var(--text-bright); + font-weight: 600; +} + +.bar-track { + width: 100%; + height: 8px; + background: var(--bg-card); + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; +} + +.bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.4s ease, background 0.4s ease; +} + +#hp-fill { background: var(--hp-full); } +#hp-fill.warning { background: var(--hp-med); } +#hp-fill.danger { + background: var(--hp-low); + animation: hp-pulse 1.5s infinite; +} + +@keyframes hp-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +#xp-fill { background: var(--accent); } + +.gold-row { + display: flex; + align-items: center; + gap: 6px; + font-family: 'Cinzel', serif; + font-size: 0.82rem; + color: var(--gold); +} + +/* Inventory */ +#inventory-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow-y: auto; +} + +#inventory-list::-webkit-scrollbar { width: 2px; } +#inventory-list::-webkit-scrollbar-thumb { background: var(--border); } + +#inventory-list li { + font-size: 0.88rem; + color: var(--text); + padding: 3px 0; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 6px; +} + +#inventory-list li::before { + content: '◆'; + color: var(--gold-dim); + font-size: 0.5rem; + flex-shrink: 0; +} + +/* Dice roller */ +.dice-types { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 10px; +} + +.die-btn { + font-family: 'Cinzel', serif; + font-size: 0.68rem; + font-weight: 600; + color: var(--text-dim); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 2px; + cursor: pointer; + transition: all 0.15s; + text-align: center; +} + +.die-btn:hover { border-color: var(--gold-dim); color: var(--text); } +.die-btn.selected { border-color: var(--gold); color: var(--gold); background: rgba(201,168,76,0.08); } + +.dice-controls { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.dice-count-label { + font-family: 'Cinzel', serif; + font-size: 0.65rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; +} + +#dice-count { + width: 52px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-bright); + font-family: 'Cinzel', serif; + font-size: 0.9rem; + padding: 5px 8px; + text-align: center; + outline: none; +} + +#dice-count:focus { border-color: var(--gold-dim); } + +#roll-btn { + flex: 1; + font-family: 'Cinzel', serif; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + color: #0d0d1a; + background: var(--gold); + border: none; + border-radius: 4px; + padding: 7px 12px; + cursor: pointer; + transition: background 0.15s; +} + +#roll-btn:hover:not(:disabled) { background: var(--gold-light); } +#roll-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +#dice-result { + font-family: 'Cinzel', serif; + font-size: 1.1rem; + font-weight: 700; + text-align: center; + color: var(--gold-light); + min-height: 24px; + letter-spacing: 0.05em; +} + +#dice-result-detail { + font-size: 0.78rem; + text-align: center; + color: var(--text-dim); + min-height: 18px; + font-style: italic; +} + +/* Level up toast */ +#levelup-toast { + display: none; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 100; + background: linear-gradient(135deg, #1a1800, #2a2200); + border: 2px solid var(--gold); + border-radius: 12px; + padding: 32px 48px; + text-align: center; + box-shadow: 0 0 60px rgba(201,168,76,0.4), 0 20px 60px rgba(0,0,0,0.8); + animation: toast-appear 0.3s ease; +} + +@keyframes toast-appear { + from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} + +#levelup-toast .toast-icon { font-size: 3rem; margin-bottom: 8px; } +#levelup-toast .toast-title { + font-family: 'Cinzel', serif; + font-size: 1.6rem; + font-weight: 700; + color: var(--gold-light); + text-shadow: 0 0 20px rgba(201,168,76,0.6); + margin-bottom: 4px; +} +#levelup-toast .toast-sub { + font-style: italic; + color: var(--text-dim); + margin-bottom: 20px; +} +.attribution { + font-size: 0.72rem; + color: var(--text-dim); + text-align: center; +} +.attribution a { + color: var(--text-dim); + text-decoration: underline; + text-underline-offset: 2px; +} +.attribution a:hover { color: var(--gold); } + +#levelup-toast button { + font-family: 'Cinzel', serif; + font-size: 0.85rem; + letter-spacing: 0.1em; + color: #0d0d1a; + background: var(--gold); + border: none; + border-radius: 6px; + padding: 10px 28px; + cursor: pointer; +} +</style> +</head> +<body> + +<!-- ── CHARACTER CREATION ───────────────────── --> +<div id="creation-screen"> + <div class="creation-title"><img src="/dice.png" alt="" style="height:0.85em;vertical-align:middle;margin-right:0.2em;filter:invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(0.9)"> Solo Dungeon Master</div> + <div class="creation-sub">Powered by Claude — your story begins here</div> + + <div class="creation-section"> + <div class="section-label">Choose Your Race</div> + <div class="selection-grid" id="race-grid"></div> + </div> + + <div class="creation-section"> + <div class="section-label">Choose Your Class</div> + <div class="selection-grid class-grid" id="class-grid"></div> + </div> + + <div class="creation-section"> + <div class="section-label">Your Character</div> + <div class="text-fields"> + <div class="field-group"> + <label>Character Name (optional)</label> + <input type="text" id="char-name" placeholder="e.g. Lyra Dawnseeker" maxlength="40"> + </div> + <div class="field-group"> + <label>Adventure Theme (optional)</label> + <input type="text" id="adventure-theme" placeholder="e.g. haunted sea, ancient ruins" maxlength="60"> + </div> + </div> + </div> + + <button class="begin-btn" id="begin-btn" disabled>Begin Adventure</button> + + <p class="attribution"> + <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Dice icon by Icongeek26</a> from + <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a> + </p> +</div> + +<!-- ── GAME SCREEN ──────────────────────────── --> +<div id="game-screen"> + + <div class="chat-panel"> + <div class="chat-header"> + <div class="chat-header-title" id="header-title">Solo Dungeon Master</div> + <button class="new-game-btn" id="new-game-btn">✦ New Game</button> + </div> + + <div id="messages"></div> + + <div class="quick-actions" id="quick-actions"> + <button class="quick-btn" data-action="I look around carefully, taking stock of my surroundings.">👁 Look around</button> + <button class="quick-btn" data-action="I find a safe spot to rest and recover my strength.">🛌 Rest & recover</button> + <button class="quick-btn" data-action="I check my inventory and equipment.">🎒 Check inventory</button> + <button class="quick-btn" data-action="I carefully search the area for traps or hidden dangers.">🔍 Search for traps</button> + <button class="quick-btn" data-action="I approach the nearest person and try to start a conversation.">💬 Talk to NPC</button> + </div> + + <div class="input-area"> + <textarea id="player-input" placeholder="What do you do?" rows="2"></textarea> + <button id="send-btn">⚔ Send</button> + </div> + </div> + + <div class="sidebar"> + <!-- Character Sheet --> + <div class="sidebar-section"> + <div class="sidebar-title">Character Sheet</div> + <div class="char-portrait"> + <div class="char-icon" id="char-icon">⚔️</div> + <div class="char-identity"> + <div class="char-name" id="char-name-display">—</div> + <div class="char-race-class" id="char-race-class">— / —</div> + </div> + </div> + + <div class="stat-row"> + <span class="stat-label">HP</span> + <span class="stat-value" id="hp-display">—</span> + </div> + <div class="bar-track"> + <div class="bar-fill" id="hp-fill" style="width:100%"></div> + </div> + + <div class="stat-row"> + <span class="stat-label">XP <span id="level-display" style="color:var(--gold)">Lv 1</span></span> + <span class="stat-value" id="xp-display">0 / 300</span> + </div> + <div class="bar-track"> + <div class="bar-fill" id="xp-fill" style="width:0%"></div> + </div> + + <div class="gold-row"> + <span>🪙</span> + <span id="gold-display">10 GP</span> + </div> + </div> + + <!-- Inventory --> + <div class="sidebar-section"> + <div class="sidebar-title">Inventory</div> + <ul id="inventory-list"></ul> + </div> + + <!-- Dice Roller --> + <div class="sidebar-section"> + <div class="sidebar-title">Dice Roller</div> + <div class="dice-types" id="dice-types"></div> + <div class="dice-controls"> + <span class="dice-count-label">Count:</span> + <input type="number" id="dice-count" value="1" min="1" max="10"> + <button id="roll-btn">🎲 Roll</button> + </div> + <div id="dice-result"></div> + <div id="dice-result-detail"></div> + <p class="attribution" style="margin-top:10px"> + <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Dice icon by Icongeek26</a> / + <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a> + </p> + </div> + </div> +</div> + +<!-- Level up toast --> +<div id="levelup-toast"> + <div class="toast-icon">⭐</div> + <div class="toast-title" id="toast-title">LEVEL UP!</div> + <div class="toast-sub" id="toast-sub">You have grown stronger.</div> + <button onclick="document.getElementById('levelup-toast').style.display='none'">Continue</button> +</div> + +<script> +// ── DATA ────────────────────────────────────────────── + +const RACES = [ + { name: 'Human', icon: '👤' }, + { name: 'Elf', icon: '🧝' }, + { name: 'Dwarf', icon: '⛏️' }, + { name: 'Tiefling', icon: '😈' }, + { name: 'Half-Orc', icon: '💪' }, + { name: 'Gnome', icon: '🎭' }, + { name: 'Halfling', icon: '🍃' }, + { name: 'Dragonborn', icon: '🐉' }, + { name: 'Half-Elf', icon: '🌟' }, + { name: 'Aasimar', icon: '👼' }, +]; + +const CLASSES = [ + { name: 'Fighter', icon: '⚔️', hp: 10, items: ['Longsword', 'Shield', 'Chain Mail', "Explorer's Pack"] }, + { name: 'Wizard', icon: '🔮', hp: 6, items: ['Quarterstaff', 'Spellbook', 'Arcane Focus', "Scholar's Pack"] }, + { name: 'Rogue', icon: '🗡️', hp: 8, items: ['Shortsword', 'Leather Armor', "Thieves' Tools", '2x Daggers'] }, + { name: 'Paladin', icon: '⚜️', hp: 10, items: ['Longsword', 'Shield', 'Chain Mail', 'Holy Symbol'] }, + { name: 'Bard', icon: '🎵', hp: 8, items: ['Rapier', 'Leather Armor', 'Lute', "Entertainer's Pack"] }, + { name: 'Ranger', icon: '🏹', hp: 10, items: ['Longbow', '20x Arrows', 'Leather Armor', 'Shortsword'] }, + { name: 'Cleric', icon: '✨', hp: 8, items: ['Mace', 'Shield', 'Scale Mail', 'Holy Symbol'] }, + { name: 'Druid', icon: '🌿', hp: 8, items: ['Quarterstaff', 'Hide Armor', 'Herbalism Kit', 'Druidic Focus'] }, + { name: 'Barbarian', icon: '🪓', hp: 12, items: ['Greataxe', '2x Handaxes', "Explorer's Pack"] }, + { name: 'Warlock', icon: '🌑', hp: 8, items: ['Dagger', 'Leather Armor', 'Arcane Focus', "Scholar's Pack"] }, +]; + +const XP_THRESHOLDS = [300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, + 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000]; + +const DIE_TYPES = [4, 6, 8, 10, 12, 20, 100]; + +// ── STATE ───────────────────────────────────────────── + +let selectedRace = null; +let selectedClass = null; +let gameState = null; +let conversationHistory = []; +let isBusy = false; + +// ── CREATION SCREEN ─────────────────────────────────── + +function buildCreationScreen() { + const raceGrid = document.getElementById('race-grid'); + RACES.forEach(r => { + const card = document.createElement('div'); + card.className = 'sel-card'; + card.innerHTML = `<span class="icon">${r.icon}</span><span class="name">${r.name}</span>`; + card.addEventListener('click', () => { + document.querySelectorAll('#race-grid .sel-card').forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + selectedRace = r; + checkBeginReady(); + }); + raceGrid.appendChild(card); + }); + + const classGrid = document.getElementById('class-grid'); + CLASSES.forEach(c => { + const card = document.createElement('div'); + card.className = 'sel-card'; + card.innerHTML = `<span class="icon">${c.icon}</span><span class="name">${c.name}</span>`; + card.addEventListener('click', () => { + document.querySelectorAll('#class-grid .sel-card').forEach(el => el.classList.remove('selected')); + card.classList.add('selected'); + selectedClass = c; + checkBeginReady(); + }); + classGrid.appendChild(card); + }); + + document.getElementById('begin-btn').addEventListener('click', beginAdventure); +} + +function checkBeginReady() { + document.getElementById('begin-btn').disabled = !(selectedRace && selectedClass); +} + +function beginAdventure() { + const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`; + const theme = document.getElementById('adventure-theme').value.trim(); + + gameState = { + character: { + name, + race: selectedRace.name, + class: selectedClass.name, + level: 1, + hp: selectedClass.hp, + maxHp: selectedClass.hp, + xp: 0, + gold: 10, + inventory: [...selectedClass.items], + }, + theme, + icon: selectedClass.icon, + }; + + conversationHistory = []; + + document.getElementById('creation-screen').style.display = 'none'; + document.getElementById('game-screen').classList.add('visible'); + + updateSidebar(); + buildDiceRoller(); + + document.getElementById('header-title').textContent = + `${name} — ${selectedRace.name} ${selectedClass.name}`; + + // Auto-send the opening message + const opening = `The adventure begins. I stand ready, ${name} — a ${selectedRace.name} ${selectedClass.name}${theme ? ` venturing into ${theme}` : ''}. Set the scene. Bring this world to life.`; + sendMessage(opening, false); +} + +// ── SIDEBAR ─────────────────────────────────────────── + +function updateSidebar() { + const c = gameState.character; + + document.getElementById('char-icon').textContent = gameState.icon; + document.getElementById('char-name-display').textContent = c.name; + document.getElementById('char-race-class').textContent = `${c.race} · ${c.class}`; + document.getElementById('level-display').textContent = `Lv ${c.level}`; + + // HP + document.getElementById('hp-display').textContent = `${c.hp} / ${c.maxHp}`; + const hpPct = Math.max(0, (c.hp / c.maxHp) * 100); + const hpFill = document.getElementById('hp-fill'); + hpFill.style.width = hpPct + '%'; + hpFill.className = 'bar-fill'; + if (hpPct <= 25) hpFill.classList.add('danger'); + else if (hpPct <= 50) hpFill.classList.add('warning'); + + // XP + const xpNeeded = XP_THRESHOLDS[c.level - 1] || 999999; + const prevXp = c.level > 1 ? XP_THRESHOLDS[c.level - 2] : 0; + const xpPct = Math.min(100, ((c.xp - prevXp) / (xpNeeded - prevXp)) * 100); + document.getElementById('xp-display').textContent = `${c.xp} / ${xpNeeded}`; + document.getElementById('xp-fill').style.width = xpPct + '%'; + + // Gold + document.getElementById('gold-display').textContent = `${c.gold} GP`; + + // Inventory + const list = document.getElementById('inventory-list'); + list.innerHTML = ''; + c.inventory.forEach(item => { + const li = document.createElement('li'); + li.textContent = item; + list.appendChild(li); + }); +} + +function applyStatUpdate(update) { + const c = gameState.character; + let leveledUp = false; + let newLevel = c.level; + + if (update.hp_change) { + c.hp = Math.max(0, Math.min(c.maxHp, c.hp + update.hp_change)); + } + if (update.gold_change) { + c.gold = Math.max(0, c.gold + update.gold_change); + } + if (update.xp_gain) { + c.xp += update.xp_gain; + while (c.level < 20 && c.xp >= XP_THRESHOLDS[c.level - 1]) { + c.level++; + c.maxHp += Math.max(1, Math.floor(CLASSES.find(cl => cl.name === c.class)?.hp / 2) + 1); + c.hp = Math.min(c.hp + Math.max(1, Math.floor(CLASSES.find(cl => cl.name === c.class)?.hp / 2) + 1), c.maxHp); + newLevel = c.level; + leveledUp = true; + } + } + if (update.inventory_add?.length) { + c.inventory.push(...update.inventory_add); + } + if (update.inventory_remove?.length) { + update.inventory_remove.forEach(item => { + const idx = c.inventory.findIndex(i => i.toLowerCase().includes(item.toLowerCase())); + if (idx !== -1) c.inventory.splice(idx, 1); + }); + } + + updateSidebar(); + + if (update.reason) { + addSystemMessage(`⚡ ${update.reason}`); + } + + if (leveledUp) { + showLevelUp(newLevel); + } + + if (c.hp <= 0) { + addSystemMessage('💀 You have fallen. Your adventure ends here... for now.'); + setBusy(true); + } +} + +function showLevelUp(level) { + const toast = document.getElementById('levelup-toast'); + document.getElementById('toast-title').textContent = `LEVEL ${level}!`; + document.getElementById('toast-sub').textContent = `${gameState.character.name} grows stronger. The world trembles.`; + toast.style.display = 'block'; + setTimeout(() => { + if (toast.style.display === 'block') toast.style.display = 'none'; + }, 6000); +} + +// ── DICE ROLLER ─────────────────────────────────────── + +let selectedDie = 20; + +function buildDiceRoller() { + const container = document.getElementById('dice-types'); + container.innerHTML = ''; + DIE_TYPES.forEach(d => { + const btn = document.createElement('button'); + btn.className = 'die-btn' + (d === selectedDie ? ' selected' : ''); + btn.textContent = `d${d}`; + btn.addEventListener('click', () => { + document.querySelectorAll('.die-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + selectedDie = d; + }); + container.appendChild(btn); + }); + + document.getElementById('roll-btn').addEventListener('click', rollDice); +} + +function rollDice() { + if (isBusy) return; + const count = Math.min(10, Math.max(1, parseInt(document.getElementById('dice-count').value, 10) || 1)); + const rolls = Array.from({ length: count }, () => Math.floor(Math.random() * selectedDie) + 1); + const total = rolls.reduce((a, b) => a + b, 0); + + document.getElementById('dice-result').textContent = `${total}`; + document.getElementById('dice-result-detail').textContent = + count > 1 ? `${count}d${selectedDie}: [${rolls.join(', ')}]` : `d${selectedDie}`; + + const rollMsg = count > 1 + ? `🎲 I roll ${count}d${selectedDie} — [${rolls.join(', ')}] = **${total}**` + : `🎲 I roll a d${selectedDie} — **${total}**`; + + sendMessage(rollMsg, true); +} + +// ── CHAT ───────────────────────────────────────────── + +function addSystemMessage(text) { + const el = document.createElement('div'); + el.className = 'msg msg-system'; + el.textContent = text; + document.getElementById('messages').appendChild(el); + scrollToBottom(); +} + +function addPlayerMessage(text) { + const el = document.createElement('div'); + el.className = 'msg msg-player'; + el.textContent = text; + document.getElementById('messages').appendChild(el); + scrollToBottom(); + return el; +} + +function addDMMessage() { + const el = document.createElement('div'); + el.className = 'msg msg-dm'; + document.getElementById('messages').appendChild(el); + + const dots = document.createElement('div'); + dots.className = 'typing-dots'; + dots.innerHTML = '<span></span><span></span><span></span>'; + el.appendChild(dots); + + scrollToBottom(); + return el; +} + +function updateDMMessage(el, rawText) { + el.style.whiteSpace = 'pre-wrap'; + el.textContent = rawText; + scrollToBottom(); +} + +function finalizeDMMessage(el, rawText) { + el.style.whiteSpace = ''; + el.innerHTML = formatDMText(rawText); + // Make clickable options + el.querySelectorAll('.option').forEach(opt => { + opt.addEventListener('click', () => { + if (!isBusy) sendMessage(opt.dataset.text, true); + }); + }); + scrollToBottom(); +} + +function formatDMText(raw) { + const paragraphs = raw.split(/\n\n+/); + let options = []; + let mainParas = []; + + paragraphs.forEach(para => { + // Detect numbered option lists (lines like "1. ..." "2. ..." etc.) + const lines = para.split('\n'); + const isOptionBlock = lines.every(l => /^\d+\.\s/.test(l.trim()) || l.trim() === ''); + if (isOptionBlock && lines.filter(l => /^\d+\.\s/.test(l.trim())).length >= 2) { + options = lines.filter(l => /^\d+\.\s/.test(l.trim())); + } else { + mainParas.push(para); + } + }); + + let html = mainParas.map(p => { + if (!p.trim()) return ''; + let text = p + .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') + .replace(/\*(.+?)\*/g, '<em>$1</em>') + .replace(/\n/g, '<br>'); + return `<p>${text}</p>`; + }).join(''); + + if (options.length > 0) { + const optHtml = options.map(line => { + const m = line.match(/^(\d+)\.\s+(.+)$/); + if (!m) return ''; + return `<div class="option" data-text="${escHtml(line)}"> + <span class="opt-num">${m[1]}.</span> + <span class="opt-text">${m[2]}</span> + </div>`; + }).join(''); + html += `<div class="options">${optHtml}</div>`; + } + + return html; +} + +function escHtml(s) { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); +} + +function scrollToBottom() { + const msgs = document.getElementById('messages'); + msgs.scrollTop = msgs.scrollHeight; +} + +// ── MAIN SEND ───────────────────────────────────────── + +async function sendMessage(text, showAsPlayer = true) { + if (isBusy || !text.trim()) return; + + setBusy(true); + + if (showAsPlayer) addPlayerMessage(text); + + const userMsg = { role: 'user', content: text }; + conversationHistory.push(userMsg); + + const dmEl = addDMMessage(); + let rawText = ''; + + try { + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: conversationHistory, + character: gameState.character, + theme: gameState.theme, + }), + }); + + if (!resp.ok) throw new Error(`Server error: ${resp.status}`); + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let firstChunk = true; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + let data; + try { data = JSON.parse(line.slice(6)); } catch { continue; } + + if (data.type === 'text') { + if (firstChunk) { + // Remove typing indicator + dmEl.innerHTML = ''; + firstChunk = false; + } + rawText += data.chunk; + updateDMMessage(dmEl, rawText); + } else if (data.type === 'done') { + finalizeDMMessage(dmEl, rawText); + if (data.statUpdate) applyStatUpdate(data.statUpdate); + conversationHistory.push({ role: 'assistant', content: rawText }); + } else if (data.type === 'error') { + dmEl.textContent = `⚠️ Error: ${data.message}`; + conversationHistory.pop(); + } + } + } + } catch (err) { + dmEl.textContent = `⚠️ Error: ${err.message}`; + conversationHistory.pop(); + } + + setBusy(false); +} + +function setBusy(busy) { + isBusy = busy; + document.getElementById('send-btn').disabled = busy; + document.getElementById('roll-btn').disabled = busy; + document.getElementById('player-input').disabled = busy; + document.querySelectorAll('.quick-btn').forEach(b => b.disabled = busy); +} + +// ── EVENT LISTENERS ─────────────────────────────────── + +document.getElementById('send-btn').addEventListener('click', () => { + const input = document.getElementById('player-input'); + const text = input.value.trim(); + if (text) { + input.value = ''; + input.style.height = ''; + sendMessage(text, true); + } +}); + +document.getElementById('player-input').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + document.getElementById('send-btn').click(); + } +}); + +document.getElementById('player-input').addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 120) + 'px'; +}); + +document.getElementById('quick-actions').addEventListener('click', e => { + const btn = e.target.closest('.quick-btn'); + if (btn && !isBusy) sendMessage(btn.dataset.action, true); +}); + +document.getElementById('new-game-btn').addEventListener('click', () => { + if (!confirm('Start a new game? Your current adventure will be lost.')) return; + document.getElementById('game-screen').classList.remove('visible'); + document.getElementById('creation-screen').style.display = ''; + document.getElementById('messages').innerHTML = ''; + selectedRace = null; + selectedClass = null; + gameState = null; + conversationHistory = []; + isBusy = false; + document.querySelectorAll('.sel-card').forEach(c => c.classList.remove('selected')); + checkBeginReady(); +}); + +// ── INIT ────────────────────────────────────────────── + +buildCreationScreen(); +</script> +</body> +</html>