Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>DARKRIFT — Multiplayer Dungeon RPG</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323:wght@400&display=swap'); | |
| :root { | |
| --bg: #0a0a0f; | |
| --bg2: #0f0f1a; | |
| --bg3: #141428; | |
| --panel: #0d0d1f; | |
| --border: #2a2a4a; | |
| --border2: #3a3a6a; | |
| --accent: #7c3aed; | |
| --accent2: #a855f7; | |
| --gold: #f59e0b; | |
| --gold2: #fcd34d; | |
| --red: #ef4444; | |
| --green: #22c55e; | |
| --blue: #3b82f6; | |
| --cyan: #06b6d4; | |
| --orange: #f97316; | |
| --text: #e2e8f0; | |
| --text2: #94a3b8; | |
| --text3: #64748b; | |
| --warrior: #ef4444; | |
| --mage: #8b5cf6; | |
| --archer: #22c55e; | |
| --healer: #f59e0b; | |
| --rogue: #06b6d4; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'VT323', monospace; | |
| font-size: 18px; | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| /* Scanline overlay */ | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.05) 2px, rgba(0,0,0,0.05) 4px); | |
| pointer-events: none; | |
| z-index: 9999; | |
| } | |
| canvas { display: none; } | |
| /* SCREENS */ | |
| .screen { display: none; min-height: 100vh; } | |
| .screen.active { display: flex; flex-direction: column; } | |
| /* ===== TITLE SCREEN ===== */ | |
| #title-screen { | |
| align-items: center; | |
| justify-content: center; | |
| background: radial-gradient(ellipse at center, #1a0a2e 0%, #0a0a0f 70%); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #title-screen::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background-image: | |
| radial-gradient(1px 1px at 20% 30%, rgba(124,58,237,0.4) 0%, transparent 100%), | |
| radial-gradient(1px 1px at 80% 70%, rgba(168,85,247,0.3) 0%, transparent 100%), | |
| radial-gradient(1px 1px at 50% 50%, rgba(245,158,11,0.2) 0%, transparent 100%); | |
| animation: starfield 8s ease-in-out infinite alternate; | |
| } | |
| @keyframes starfield { | |
| 0% { opacity: 0.5; transform: scale(1); } | |
| 100% { opacity: 1; transform: scale(1.05); } | |
| } | |
| .title-content { | |
| position: relative; | |
| text-align: center; | |
| z-index: 1; | |
| } | |
| .title-logo { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: clamp(32px, 6vw, 64px); | |
| color: var(--gold); | |
| text-shadow: | |
| 0 0 20px rgba(245,158,11,0.8), | |
| 0 0 40px rgba(245,158,11,0.4), | |
| 4px 4px 0 #92400e; | |
| animation: titlePulse 2s ease-in-out infinite; | |
| letter-spacing: 4px; | |
| } | |
| @keyframes titlePulse { | |
| 0%, 100% { text-shadow: 0 0 20px rgba(245,158,11,0.8), 0 0 40px rgba(245,158,11,0.4), 4px 4px 0 #92400e; } | |
| 50% { text-shadow: 0 0 30px rgba(245,158,11,1), 0 0 60px rgba(245,158,11,0.6), 4px 4px 0 #92400e; } | |
| } | |
| .title-sub { | |
| font-family: 'VT323', monospace; | |
| font-size: 22px; | |
| color: var(--accent2); | |
| margin-top: 8px; | |
| letter-spacing: 6px; | |
| text-transform: uppercase; | |
| } | |
| .title-art { | |
| font-size: 80px; | |
| margin: 30px 0; | |
| filter: drop-shadow(0 0 20px rgba(124,58,237,0.6)); | |
| animation: float 3s ease-in-out infinite; | |
| } | |
| @keyframes float { | |
| 0%, 100% { transform: translateY(0); } | |
| 50% { transform: translateY(-10px); } | |
| } | |
| .title-buttons { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| align-items: center; | |
| margin-top: 30px; | |
| } | |
| .btn { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 12px; | |
| padding: 14px 32px; | |
| border: 2px solid var(--border2); | |
| background: var(--panel); | |
| color: var(--text); | |
| cursor: pointer; | |
| position: relative; | |
| transition: all 0.15s; | |
| min-width: 220px; | |
| text-align: center; | |
| clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%); | |
| } | |
| .btn:hover { | |
| background: var(--accent); | |
| border-color: var(--accent2); | |
| color: white; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 20px rgba(124,58,237,0.5); | |
| } | |
| .btn-gold { | |
| border-color: var(--gold); | |
| color: var(--gold); | |
| } | |
| .btn-gold:hover { | |
| background: var(--gold); | |
| color: #000; | |
| box-shadow: 0 4px 20px rgba(245,158,11,0.5); | |
| } | |
| .btn-danger { | |
| border-color: var(--red); | |
| color: var(--red); | |
| } | |
| .btn-danger:hover { | |
| background: var(--red); | |
| color: white; | |
| box-shadow: 0 4px 20px rgba(239,68,68,0.5); | |
| } | |
| .btn-green { | |
| border-color: var(--green); | |
| color: var(--green); | |
| } | |
| .btn-green:hover { | |
| background: var(--green); | |
| color: #000; | |
| box-shadow: 0 4px 20px rgba(34,197,94,0.5); | |
| } | |
| .version-tag { | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| font-size: 12px; | |
| color: var(--text3); | |
| } | |
| /* ===== CLASS SELECT ===== */ | |
| #class-screen { | |
| padding: 30px 20px; | |
| background: radial-gradient(ellipse at top, #1a0a2e 0%, #0a0a0f 60%); | |
| } | |
| .screen-title { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: clamp(14px, 3vw, 22px); | |
| color: var(--gold); | |
| text-align: center; | |
| margin-bottom: 8px; | |
| text-shadow: 0 0 15px rgba(245,158,11,0.5); | |
| } | |
| .screen-subtitle { | |
| text-align: center; | |
| color: var(--text2); | |
| margin-bottom: 30px; | |
| font-size: 20px; | |
| } | |
| .classes-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 16px; | |
| max-width: 1000px; | |
| margin: 0 auto 30px; | |
| } | |
| .class-card { | |
| background: var(--panel); | |
| border: 2px solid var(--border); | |
| padding: 20px 16px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| position: relative; | |
| text-align: center; | |
| clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px)); | |
| } | |
| .class-card:hover, .class-card.selected { | |
| transform: translateY(-4px); | |
| } | |
| .class-card[data-class="warrior"]:hover, .class-card[data-class="warrior"].selected { | |
| border-color: var(--warrior); | |
| box-shadow: 0 0 20px rgba(239,68,68,0.3); | |
| background: rgba(239,68,68,0.05); | |
| } | |
| .class-card[data-class="mage"]:hover, .class-card[data-class="mage"].selected { | |
| border-color: var(--mage); | |
| box-shadow: 0 0 20px rgba(139,92,246,0.3); | |
| background: rgba(139,92,246,0.05); | |
| } | |
| .class-card[data-class="archer"]:hover, .class-card[data-class="archer"].selected { | |
| border-color: var(--archer); | |
| box-shadow: 0 0 20px rgba(34,197,94,0.3); | |
| background: rgba(34,197,94,0.05); | |
| } | |
| .class-card[data-class="healer"]:hover, .class-card[data-class="healer"].selected { | |
| border-color: var(--healer); | |
| box-shadow: 0 0 20px rgba(245,158,11,0.3); | |
| background: rgba(245,158,11,0.05); | |
| } | |
| .class-card[data-class="rogue"]:hover, .class-card[data-class="rogue"].selected { | |
| border-color: var(--rogue); | |
| box-shadow: 0 0 20px rgba(6,182,212,0.3); | |
| background: rgba(6,182,212,0.05); | |
| } | |
| .class-icon { font-size: 48px; margin-bottom: 10px; display: block; } | |
| .class-name { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .class-card[data-class="warrior"] .class-name { color: var(--warrior); } | |
| .class-card[data-class="mage"] .class-name { color: var(--mage); } | |
| .class-card[data-class="archer"] .class-name { color: var(--archer); } | |
| .class-card[data-class="healer"] .class-name { color: var(--healer); } | |
| .class-card[data-class="rogue"] .class-name { color: var(--rogue); } | |
| .class-desc { font-size: 14px; color: var(--text2); line-height: 1.4; margin-bottom: 12px; } | |
| .stat-bars { display: flex; flex-direction: column; gap: 4px; } | |
| .stat-row { display: flex; align-items: center; gap: 6px; font-size: 13px; } | |
| .stat-label { width: 40px; color: var(--text3); font-size: 12px; } | |
| .stat-bar-bg { flex: 1; height: 6px; background: var(--bg3); border-radius: 0; } | |
| .stat-bar-fill { height: 100%; transition: width 0.3s; } | |
| .name-input-area { | |
| text-align: center; | |
| margin-bottom: 20px; | |
| } | |
| .pixel-input { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 11px; | |
| background: var(--panel); | |
| border: 2px solid var(--border2); | |
| color: var(--text); | |
| padding: 12px 16px; | |
| width: 280px; | |
| outline: none; | |
| clip-path: polygon(6px 0%, 100% 0%, calc(100% - 6px) 100%, 0% 100%); | |
| } | |
| .pixel-input:focus { border-color: var(--accent2); box-shadow: 0 0 10px rgba(168,85,247,0.3); } | |
| .input-label { | |
| display: block; | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 9px; | |
| color: var(--text3); | |
| margin-bottom: 8px; | |
| } | |
| /* ===== LOBBY SCREEN ===== */ | |
| #lobby-screen { | |
| padding: 30px 20px; | |
| background: radial-gradient(ellipse at center, #0f0a1e 0%, #0a0a0f 70%); | |
| } | |
| .lobby-layout { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| @media (max-width: 600px) { .lobby-layout { grid-template-columns: 1fr; } } | |
| .panel { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| padding: 20px; | |
| clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px)); | |
| } | |
| .panel-title { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 10px; | |
| color: var(--accent2); | |
| margin-bottom: 16px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px solid var(--border); | |
| letter-spacing: 2px; | |
| } | |
| .room-code-display { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 32px; | |
| color: var(--gold); | |
| text-align: center; | |
| letter-spacing: 8px; | |
| padding: 20px; | |
| background: var(--bg3); | |
| border: 1px solid var(--gold); | |
| margin-bottom: 12px; | |
| text-shadow: 0 0 10px rgba(245,158,11,0.5); | |
| animation: codePulse 2s ease-in-out infinite; | |
| } | |
| @keyframes codePulse { | |
| 0%, 100% { box-shadow: 0 0 5px rgba(245,158,11,0.2); } | |
| 50% { box-shadow: 0 0 15px rgba(245,158,11,0.4); } | |
| } | |
| .player-slot { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 10px 14px; | |
| margin-bottom: 8px; | |
| background: var(--bg3); | |
| border: 1px solid var(--border); | |
| min-height: 54px; | |
| position: relative; | |
| } | |
| .player-slot.empty { | |
| opacity: 0.4; | |
| border-style: dashed; | |
| } | |
| .slot-icon { font-size: 28px; } | |
| .slot-info { flex: 1; } | |
| .slot-name { font-family: 'Press Start 2P', monospace; font-size: 9px; color: var(--text); } | |
| .slot-class { font-size: 13px; color: var(--text3); margin-top: 3px; } | |
| .slot-you { | |
| font-family: 'Press Start 2P', monospace; font-size: 7px; | |
| color: var(--gold); background: rgba(245,158,11,0.1); | |
| border: 1px solid var(--gold); padding: 2px 6px; | |
| } | |
| .slot-ready { | |
| font-size: 12px; | |
| color: var(--green); | |
| } | |
| .slot-host-badge { | |
| font-family: 'Press Start 2P', monospace; font-size: 7px; | |
| color: var(--accent2); background: rgba(168,85,247,0.1); | |
| border: 1px solid var(--accent2); padding: 2px 6px; | |
| margin-left: 4px; | |
| } | |
| .join-area { display: flex; flex-direction: column; gap: 10px; } | |
| .join-row { display: flex; gap: 8px; } | |
| .difficulty-selector { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 8px; | |
| margin-bottom: 16px; | |
| } | |
| .diff-btn { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 8px; | |
| padding: 10px 6px; | |
| border: 2px solid var(--border); | |
| background: var(--bg3); | |
| color: var(--text2); | |
| cursor: pointer; | |
| text-align: center; | |
| transition: all 0.15s; | |
| } | |
| .diff-btn:hover, .diff-btn.active { | |
| border-color: var(--accent2); | |
| color: var(--accent2); | |
| background: rgba(168,85,247,0.1); | |
| } | |
| .diff-btn.easy.active { border-color: var(--green); color: var(--green); background: rgba(34,197,94,0.1); } | |
| .diff-btn.hard.active { border-color: var(--red); color: var(--red); background: rgba(239,68,68,0.1); } | |
| /* ===== GAME SCREEN ===== */ | |
| #game-screen { | |
| padding: 0; | |
| background: #050508; | |
| min-height: 100vh; | |
| } | |
| .game-layout { | |
| display: grid; | |
| grid-template-rows: auto 1fr auto; | |
| height: 100vh; | |
| max-height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* TOP HUD */ | |
| .game-hud-top { | |
| background: linear-gradient(180deg, #0a0a18 0%, transparent 100%); | |
| padding: 8px 16px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| border-bottom: 1px solid var(--border); | |
| z-index: 10; | |
| } | |
| .hud-wave { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 10px; | |
| color: var(--gold); | |
| } | |
| .hud-title { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 9px; | |
| color: var(--accent2); | |
| letter-spacing: 3px; | |
| } | |
| .hud-turn-indicator { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 8px; | |
| padding: 4px 10px; | |
| border: 1px solid var(--accent2); | |
| color: var(--accent2); | |
| animation: turnBlink 1s ease-in-out infinite; | |
| } | |
| @keyframes turnBlink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .hud-turn-indicator.your-turn { | |
| border-color: var(--green); | |
| color: var(--green); | |
| animation: none; | |
| box-shadow: 0 0 10px rgba(34,197,94,0.3); | |
| } | |
| /* MAIN GAME AREA */ | |
| .game-main { | |
| display: grid; | |
| grid-template-columns: 200px 1fr 200px; | |
| overflow: hidden; | |
| } | |
| @media (max-width: 768px) { | |
| .game-main { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; } | |
| } | |
| /* PLAYER PANEL */ | |
| .players-panel { | |
| background: rgba(10,10,20,0.9); | |
| border-right: 1px solid var(--border); | |
| overflow-y: auto; | |
| padding: 10px 8px; | |
| } | |
| .panel-header { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 7px; | |
| color: var(--text3); | |
| letter-spacing: 2px; | |
| margin-bottom: 10px; | |
| padding-bottom: 6px; | |
| border-bottom: 1px solid var(--border); | |
| text-align: center; | |
| } | |
| .player-card { | |
| background: var(--bg3); | |
| border: 1px solid var(--border); | |
| padding: 10px 8px; | |
| margin-bottom: 8px; | |
| position: relative; | |
| transition: all 0.2s; | |
| } | |
| .player-card.active-turn { | |
| border-color: var(--green); | |
| box-shadow: 0 0 10px rgba(34,197,94,0.2); | |
| } | |
| .player-card.dead { | |
| opacity: 0.4; | |
| filter: grayscale(1); | |
| } | |
| .pc-header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } | |
| .pc-icon { font-size: 22px; } | |
| .pc-info { flex: 1; min-width: 0; } | |
| .pc-name { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 7px; | |
| color: var(--text); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .pc-class { font-size: 12px; color: var(--text3); } | |
| .hp-bar-container { margin-bottom: 4px; } | |
| .hp-label { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 2px; } | |
| .hp-bar-bg { height: 6px; background: #1a0a0a; border: 1px solid #3a1a1a; } | |
| .hp-bar-fill { height: 100%; background: linear-gradient(90deg, var(--red), #ff6b6b); transition: width 0.5s; } | |
| .hp-bar-fill.high { background: linear-gradient(90deg, var(--green), #86efac); } | |
| .hp-bar-fill.mid { background: linear-gradient(90deg, var(--gold), #fcd34d); } | |
| .mp-bar-bg { height: 4px; background: #0a0a1a; border: 1px solid #1a1a3a; } | |
| .mp-bar-fill { height: 100%; background: linear-gradient(90deg, var(--blue), #93c5fd); transition: width 0.5s; } | |
| .status-icons { display: flex; gap: 3px; margin-top: 4px; flex-wrap: wrap; } | |
| .status-icon { font-size: 12px; cursor: help; } | |
| /* BATTLE AREA */ | |
| .battle-area { | |
| position: relative; | |
| overflow: hidden; | |
| background: #050508; | |
| } | |
| #battle-canvas { | |
| display: block ; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .battle-overlay { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| } | |
| .damage-float { | |
| position: absolute; | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 14px; | |
| font-weight: bold; | |
| pointer-events: none; | |
| animation: floatDmg 1.2s ease-out forwards; | |
| z-index: 100; | |
| } | |
| @keyframes floatDmg { | |
| 0% { opacity: 1; transform: translateY(0) scale(1); } | |
| 50% { opacity: 1; transform: translateY(-30px) scale(1.2); } | |
| 100% { opacity: 0; transform: translateY(-60px) scale(0.8); } | |
| } | |
| /* ENEMIES PANEL */ | |
| .enemies-panel { | |
| background: rgba(10,10,20,0.9); | |
| border-left: 1px solid var(--border); | |
| overflow-y: auto; | |
| padding: 10px 8px; | |
| } | |
| .enemy-card { | |
| background: linear-gradient(135deg, #1a0a0a, #0f0808); | |
| border: 1px solid #3a1a1a; | |
| padding: 10px 8px; | |
| margin-bottom: 8px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| position: relative; | |
| } | |
| .enemy-card:hover { | |
| border-color: var(--red); | |
| box-shadow: 0 0 10px rgba(239,68,68,0.2); | |
| } | |
| .enemy-card.targeted { | |
| border-color: var(--gold); | |
| box-shadow: 0 0 12px rgba(245,158,11,0.3); | |
| animation: targetPulse 0.8s ease-in-out infinite; | |
| } | |
| @keyframes targetPulse { | |
| 0%, 100% { box-shadow: 0 0 8px rgba(245,158,11,0.3); } | |
| 50% { box-shadow: 0 0 20px rgba(245,158,11,0.6); } | |
| } | |
| .enemy-card.dead { opacity: 0.3; pointer-events: none; } | |
| .ec-header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } | |
| .ec-icon { font-size: 26px; } | |
| .ec-info { flex: 1; } | |
| .ec-name { font-family: 'Press Start 2P', monospace; font-size: 7px; color: var(--red); } | |
| .ec-type { font-size: 11px; color: var(--text3); } | |
| .target-hint { | |
| font-size: 11px; | |
| color: var(--text3); | |
| text-align: center; | |
| margin-top: 8px; | |
| padding: 4px; | |
| border: 1px dashed var(--border); | |
| } | |
| /* BOTTOM ACTIONS */ | |
| .game-actions { | |
| background: linear-gradient(0deg, #0a0a18 0%, transparent 100%); | |
| border-top: 1px solid var(--border); | |
| padding: 10px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .abilities-row { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| } | |
| .ability-btn { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 8px; | |
| padding: 10px 14px; | |
| border: 2px solid var(--border2); | |
| background: var(--panel); | |
| color: var(--text); | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| position: relative; | |
| min-width: 90px; | |
| text-align: center; | |
| clip-path: polygon(6px 0%, 100% 0%, calc(100% - 6px) 100%, 0% 100%); | |
| } | |
| .ability-btn:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| background: var(--accent); | |
| border-color: var(--accent2); | |
| box-shadow: 0 4px 15px rgba(124,58,237,0.4); | |
| } | |
| .ability-btn:disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .ability-btn .mana-cost { | |
| display: block; | |
| font-size: 7px; | |
| color: var(--blue); | |
| margin-top: 4px; | |
| } | |
| .ability-btn .cooldown-badge { | |
| position: absolute; | |
| top: -6px; | |
| right: -6px; | |
| background: var(--red); | |
| color: white; | |
| font-size: 7px; | |
| padding: 2px 4px; | |
| font-family: 'Press Start 2P', monospace; | |
| } | |
| .actions-row { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: center; | |
| } | |
| .action-btn { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 9px; | |
| padding: 8px 16px; | |
| border: 2px solid var(--border); | |
| background: var(--bg3); | |
| color: var(--text2); | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| clip-path: polygon(6px 0%, 100% 0%, calc(100% - 6px) 100%, 0% 100%); | |
| } | |
| .action-btn:hover:not(:disabled) { border-color: var(--text2); color: var(--text); background: var(--panel); } | |
| .action-btn:disabled { opacity: 0.3; cursor: not-allowed; } | |
| .action-btn.end-turn { border-color: var(--green); color: var(--green); } | |
| .action-btn.end-turn:hover:not(:disabled) { background: rgba(34,197,94,0.15); box-shadow: 0 0 10px rgba(34,197,94,0.3); } | |
| /* COMBAT LOG */ | |
| .combat-log-area { | |
| max-width: 500px; | |
| margin: 0 auto; | |
| } | |
| .combat-log { | |
| background: rgba(0,0,0,0.6); | |
| border: 1px solid var(--border); | |
| height: 70px; | |
| overflow-y: auto; | |
| padding: 6px 10px; | |
| font-size: 13px; | |
| } | |
| .log-entry { padding: 1px 0; } | |
| .log-entry.damage { color: #fca5a5; } | |
| .log-entry.heal { color: #86efac; } | |
| .log-entry.skill { color: #c4b5fd; } | |
| .log-entry.system { color: var(--text3); } | |
| .log-entry.death { color: var(--red); font-family: 'Press Start 2P', monospace; font-size: 10px; } | |
| .log-entry.boss { color: var(--gold); } | |
| /* WAVE ANNOUNCEMENT */ | |
| .wave-announcement { | |
| position: fixed; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(0,0,0,0.85); | |
| z-index: 1000; | |
| animation: waveAnnounce 2.5s ease forwards; | |
| } | |
| @keyframes waveAnnounce { | |
| 0% { opacity: 0; } | |
| 20% { opacity: 1; } | |
| 80% { opacity: 1; } | |
| 100% { opacity: 0; pointer-events: none; } | |
| } | |
| .wave-text { | |
| text-align: center; | |
| } | |
| .wave-number { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: clamp(24px, 5vw, 48px); | |
| color: var(--red); | |
| text-shadow: 0 0 30px rgba(239,68,68,0.8); | |
| display: block; | |
| margin-bottom: 10px; | |
| } | |
| .wave-subtitle { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: clamp(12px, 2.5vw, 20px); | |
| color: var(--gold); | |
| display: block; | |
| } | |
| /* GAME OVER / VICTORY */ | |
| .result-screen { | |
| position: fixed; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(0,0,0,0.9); | |
| z-index: 2000; | |
| display: none; | |
| } | |
| .result-screen.show { display: flex; } | |
| .result-content { | |
| text-align: center; | |
| padding: 40px; | |
| background: var(--panel); | |
| border: 2px solid var(--border2); | |
| clip-path: polygon(16px 0%, 100% 0%, calc(100% - 16px) 100%, 0% 100%); | |
| max-width: 500px; | |
| width: 90%; | |
| } | |
| .result-title { | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: clamp(24px, 5vw, 40px); | |
| margin-bottom: 16px; | |
| } | |
| .result-title.victory { color: var(--gold); text-shadow: 0 0 20px rgba(245,158,11,0.6); } | |
| .result-title.defeat { color: var(--red); text-shadow: 0 0 20px rgba(239,68,68,0.6); } | |
| .result-stats { | |
| margin: 20px 0; | |
| font-size: 16px; | |
| color: var(--text2); | |
| line-height: 2; | |
| } | |
| /* NOTIFICATION */ | |
| .notification { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 10px; | |
| padding: 12px 24px; | |
| background: var(--panel); | |
| border: 2px solid var(--accent2); | |
| color: var(--accent2); | |
| z-index: 3000; | |
| animation: notifSlide 3s ease forwards; | |
| max-width: 90vw; | |
| text-align: center; | |
| } | |
| @keyframes notifSlide { | |
| 0% { opacity: 0; transform: translateX(-50%) translateY(-20px); } | |
| 15% { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| 85% { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| 100% { opacity: 0; transform: translateX(-50%) translateY(-20px); } | |
| } | |
| /* WAITING SPINNER */ | |
| .waiting-overlay { | |
| position: fixed; inset: 0; | |
| background: rgba(0,0,0,0.7); | |
| display: flex; align-items: center; justify-content: center; | |
| z-index: 500; display: none; | |
| } | |
| .waiting-overlay.show { display: flex; } | |
| .waiting-box { | |
| text-align: center; | |
| font-family: 'Press Start 2P', monospace; | |
| font-size: 12px; | |
| color: var(--accent2); | |
| } | |
| .spinner { | |
| font-size: 40px; | |
| display: block; | |
| margin-bottom: 16px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } | |
| /* SCROLLBAR */ | |
| ::-webkit-scrollbar { width: 4px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--border2); } | |
| /* ROOM CODE COPY */ | |
| .copy-hint { font-size: 12px; color: var(--text3); text-align: center; cursor: pointer; } | |
| .copy-hint:hover { color: var(--gold); } | |
| /* Pixel decorations */ | |
| .pixel-deco { | |
| position: absolute; | |
| font-size: 12px; | |
| opacity: 0.15; | |
| pointer-events: none; | |
| } | |
| .flicker { animation: flicker 3s ease-in-out infinite; } | |
| @keyframes flicker { | |
| 0%, 90%, 100% { opacity: 0.15; } | |
| 92%, 96% { opacity: 0.05; } | |
| 94%, 98% { opacity: 0.2; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- TITLE SCREEN --> | |
| <div id="title-screen" class="screen active"> | |
| <div class="title-content"> | |
| <div class="title-art">⚔️</div> | |
| <div class="title-logo">DARKRIFT</div> | |
| <div class="title-sub">Dungeon Challenges — Multiplayer RPG</div> | |
| <div class="title-buttons" style="margin-top:40px"> | |
| <button class="btn btn-gold" onclick="goToClassSelect('create')">⚔ CREATE ROOM</button> | |
| <button class="btn" onclick="goToClassSelect('join')">🗡 JOIN ROOM</button> | |
| </div> | |
| </div> | |
| <div class="version-tag">v1.0 · PIXEL ART EDITION</div> | |
| </div> | |
| <!-- CLASS SELECT SCREEN --> | |
| <div id="class-screen" class="screen"> | |
| <div class="screen-title">CHOOSE YOUR CLASS</div> | |
| <div class="screen-subtitle">Select wisely — your team needs balance</div> | |
| <div class="classes-grid" id="classes-grid"> | |
| <div class="class-card" data-class="warrior" onclick="selectClass('warrior',this)"> | |
| <span class="class-icon">🛡️</span> | |
| <div class="class-name">WARRIOR</div> | |
| <div class="class-desc">Absorbs damage. Taunts enemies. Protects allies.</div> | |
| <div class="stat-bars"> | |
| <div class="stat-row"><span class="stat-label">HP</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:90%;background:var(--warrior)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">ATK</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:60%;background:var(--warrior)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">DEF</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:95%;background:var(--warrior)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">SPD</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:35%;background:var(--warrior)"></div></div></div> | |
| </div> | |
| </div> | |
| <div class="class-card" data-class="mage" onclick="selectClass('mage',this)"> | |
| <span class="class-icon">🔮</span> | |
| <div class="class-name">MAGE</div> | |
| <div class="class-desc">Devastating AoE spells. Low HP but massive damage.</div> | |
| <div class="stat-bars"> | |
| <div class="stat-row"><span class="stat-label">HP</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:35%;background:var(--mage)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">ATK</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:95%;background:var(--mage)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">DEF</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:20%;background:var(--mage)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">SPD</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:65%;background:var(--mage)"></div></div></div> | |
| </div> | |
| </div> | |
| <div class="class-card" data-class="archer" onclick="selectClass('archer',this)"> | |
| <span class="class-icon">🏹</span> | |
| <div class="class-name">ARCHER</div> | |
| <div class="class-desc">Fast attacks. Bleeding DoT. Snipe from safety.</div> | |
| <div class="stat-bars"> | |
| <div class="stat-row"><span class="stat-label">HP</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:55%;background:var(--archer)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">ATK</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:75%;background:var(--archer)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">DEF</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:40%;background:var(--archer)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">SPD</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:90%;background:var(--archer)"></div></div></div> | |
| </div> | |
| </div> | |
| <div class="class-card" data-class="healer" onclick="selectClass('healer',this)"> | |
| <span class="class-icon">✨</span> | |
| <div class="class-name">HEALER</div> | |
| <div class="class-desc">Restores HP. Removes debuffs. Resurrects allies.</div> | |
| <div class="stat-bars"> | |
| <div class="stat-row"><span class="stat-label">HP</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:60%;background:var(--healer)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">ATK</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:30%;background:var(--healer)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">DEF</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:55%;background:var(--healer)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">SPD</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:70%;background:var(--healer)"></div></div></div> | |
| </div> | |
| </div> | |
| <div class="class-card" data-class="rogue" onclick="selectClass('rogue',this)"> | |
| <span class="class-icon">🗡️</span> | |
| <div class="class-name">ROGUE</div> | |
| <div class="class-desc">Critical strikes. Stealth. Poisons and bleeds.</div> | |
| <div class="stat-bars"> | |
| <div class="stat-row"><span class="stat-label">HP</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:50%;background:var(--rogue)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">ATK</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:85%;background:var(--rogue)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">DEF</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:30%;background:var(--rogue)"></div></div></div> | |
| <div class="stat-row"><span class="stat-label">SPD</span><div class="stat-bar-bg"><div class="stat-bar-fill" style="width:95%;background:var(--rogue)"></div></div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="name-input-area"> | |
| <label class="input-label">YOUR HERO NAME</label> | |
| <input class="pixel-input" id="hero-name-input" type="text" placeholder="Enter name..." maxlength="16"> | |
| </div> | |
| <div style="text-align:center;display:flex;gap:12px;justify-content:center"> | |
| <button class="btn" onclick="showScreen('title-screen')">← BACK</button> | |
| <button class="btn btn-gold" id="confirm-class-btn" onclick="confirmClass()" disabled>CONFIRM →</button> | |
| </div> | |
| </div> | |
| <!-- LOBBY SCREEN --> | |
| <div id="lobby-screen" class="screen"> | |
| <div class="screen-title" style="margin-bottom:6px">DARKRIFT LOBBY</div> | |
| <div class="screen-subtitle">Gather your party before descending</div> | |
| <div class="lobby-layout"> | |
| <div> | |
| <div class="panel"> | |
| <div class="panel-title">⚡ ROOM CODE</div> | |
| <div class="room-code-display" id="room-code-display">----</div> | |
| <div class="copy-hint" onclick="copyRoomCode()">📋 Click to copy code</div> | |
| </div> | |
| <div class="panel" style="margin-top:16px"> | |
| <div class="panel-title">⚙ DUNGEON SETTINGS</div> | |
| <div style="font-size:14px;color:var(--text3);margin-bottom:10px">DIFFICULTY</div> | |
| <div class="difficulty-selector" id="difficulty-selector"> | |
| <div class="diff-btn easy active" data-diff="easy" onclick="setDifficulty('easy',this)">EASY</div> | |
| <div class="diff-btn" data-diff="normal" onclick="setDifficulty('normal',this)">NORMAL</div> | |
| <div class="diff-btn hard" data-diff="hard" onclick="setDifficulty('hard',this)">HARD</div> | |
| </div> | |
| <div id="host-controls" style="display:none"> | |
| <button class="btn btn-green" onclick="startGame()" id="start-btn" style="width:100%;margin-top:8px" disabled> | |
| ▶ START DUNGEON | |
| </button> | |
| </div> | |
| <div id="join-area-lobby" style="display:none"> | |
| <div class="join-area"> | |
| <label class="input-label" style="display:block">JOIN WITH CODE</label> | |
| <div class="join-row"> | |
| <input class="pixel-input" id="join-code-input" type="text" placeholder="XXXX" maxlength="4" style="width:100%;text-transform:uppercase;letter-spacing:4px"> | |
| </div> | |
| <button class="btn btn-gold" onclick="joinRoom()" style="width:100%">JOIN ROOM →</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-title">👥 PARTY — <span id="player-count">0</span>/5</div> | |
| <div id="player-slots"> | |
| <div class="player-slot empty"><span class="slot-icon">👤</span><div class="slot-info"><div class="slot-name">Waiting...</div><div class="slot-class">Empty slot</div></div></div> | |
| <div class="player-slot empty"><span class="slot-icon">👤</span><div class="slot-info"><div class="slot-name">Waiting...</div><div class="slot-class">Empty slot</div></div></div> | |
| <div class="player-slot empty"><span class="slot-icon">👤</span><div class="slot-info"><div class="slot-name">Waiting...</div><div class="slot-class">Empty slot</div></div></div> | |
| <div class="player-slot empty"><span class="slot-icon">👤</span><div class="slot-info"><div class="slot-name">Waiting...</div><div class="slot-class">Empty slot</div></div></div> | |
| <div class="player-slot empty"><span class="slot-icon">👤</span><div class="slot-info"><div class="slot-name">Waiting...</div><div class="slot-class">Empty slot</div></div></div> | |
| </div> | |
| <div style="margin-top:16px;font-size:13px;color:var(--text3);text-align:center"> | |
| Share the room code with friends<br>Min. 1 player to start | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- GAME SCREEN --> | |
| <div id="game-screen" class="screen"> | |
| <div class="game-layout"> | |
| <!-- TOP HUD --> | |
| <div class="game-hud-top"> | |
| <div class="hud-wave" id="hud-wave">WAVE 1/5</div> | |
| <div class="hud-title">⚔ DARKRIFT ⚔</div> | |
| <div class="hud-turn-indicator" id="turn-indicator">ENEMY TURN</div> | |
| </div> | |
| <!-- MAIN --> | |
| <div class="game-main"> | |
| <!-- PLAYERS LEFT --> | |
| <div class="players-panel"> | |
| <div class="panel-header">PARTY</div> | |
| <div id="player-cards-container"></div> | |
| </div> | |
| <!-- BATTLE CANVAS --> | |
| <div class="battle-area"> | |
| <canvas id="battle-canvas"></canvas> | |
| <div class="battle-overlay" id="battle-overlay"></div> | |
| </div> | |
| <!-- ENEMIES RIGHT --> | |
| <div class="enemies-panel"> | |
| <div class="panel-header">ENEMIES</div> | |
| <div id="enemy-cards-container"></div> | |
| <div class="target-hint" id="target-hint">← Select target</div> | |
| </div> | |
| </div> | |
| <!-- BOTTOM ACTIONS --> | |
| <div class="game-actions"> | |
| <div class="combat-log-area" style="width:100%"> | |
| <div class="combat-log" id="combat-log"></div> | |
| </div> | |
| <div class="abilities-row" id="abilities-row"></div> | |
| <div class="actions-row"> | |
| <button class="action-btn" id="basic-attack-btn" onclick="basicAttack()" disabled>⚔ ATTACK</button> | |
| <button class="action-btn" id="defend-btn" onclick="defend()" disabled>🛡 DEFEND</button> | |
| <button class="action-btn end-turn" id="end-turn-btn" onclick="endTurn()" disabled>NEXT →</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- RESULT SCREEN --> | |
| <div class="result-screen" id="result-screen"> | |
| <div class="result-content"> | |
| <div class="result-title" id="result-title">VICTORY!</div> | |
| <div style="font-size:60px;margin:16px 0" id="result-emoji">🏆</div> | |
| <div class="result-stats" id="result-stats"></div> | |
| <button class="btn btn-gold" onclick="location.reload()" style="margin-top:16px;width:100%">PLAY AGAIN</button> | |
| </div> | |
| </div> | |
| <!-- WAITING OVERLAY --> | |
| <div class="waiting-overlay" id="waiting-overlay"> | |
| <div class="waiting-box"> | |
| <span class="spinner">⚙</span> | |
| <div id="waiting-text">Connecting...</div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js"; | |
| import { getDatabase, ref, set, get, update, push, onValue, off, remove } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-database.js"; | |
| const firebaseConfig = { | |
| apiKey: "AIzaSyAbk1fE4rHGqZ_ULBCOJ4OnBe8aJ1AJ26Y", | |
| authDomain: "firstgame-db7be.firebaseapp.com", | |
| databaseURL: "https://firstgame-db7be-default-rtdb.europe-west1.firebasedatabase.app", | |
| projectId: "firstgame-db7be", | |
| storageBucket: "firstgame-db7be.firebasestorage.app", | |
| messagingSenderId: "778641081622", | |
| appId: "1:778641081622:web:d220de8c9135991f3d3c37", | |
| measurementId: "G-M49M84FDXS" | |
| }; | |
| const app = initializeApp(firebaseConfig); | |
| const db = getDatabase(app); | |
| // ======================== | |
| // GAME DATA DEFINITIONS | |
| // ======================== | |
| const CLASS_DATA = { | |
| warrior: { | |
| icon: '🛡️', color: '#ef4444', name: 'Warrior', | |
| hp: 180, mp: 60, atk: 28, def: 20, spd: 3, | |
| abilities: [ | |
| { id: 'shield_bash', name: 'Shield Bash', icon: '🛡', mpCost: 15, cooldown: 2, desc: 'Stun + damage', type: 'damage', power: 1.2, effect: 'stun' }, | |
| { id: 'battle_cry', name: 'Battle Cry', icon: '📣', mpCost: 20, cooldown: 3, desc: 'Boost party ATK', type: 'buff', power: 1.3, effect: 'atkUp', target: 'allies' }, | |
| { id: 'taunt', name: 'Taunt', icon: '😤', mpCost: 10, cooldown: 2, desc: 'Force enemies to attack you', type: 'taunt', power: 1, effect: 'taunt' }, | |
| ] | |
| }, | |
| mage: { | |
| icon: '🔮', color: '#8b5cf6', name: 'Mage', | |
| hp: 90, mp: 140, atk: 52, def: 8, spd: 6, | |
| abilities: [ | |
| { id: 'fireball', name: 'Fireball', icon: '🔥', mpCost: 25, cooldown: 0, desc: 'AoE fire damage', type: 'aoe', power: 1.5 }, | |
| { id: 'arcane_bolt', name: 'Arcane Bolt', icon: '⚡', mpCost: 15, cooldown: 0, desc: 'Single target + silence', type: 'damage', power: 1.8, effect: 'silence' }, | |
| { id: 'frost_nova', name: 'Frost Nova', icon: '❄️', mpCost: 30, cooldown: 3, desc: 'Freeze all enemies', type: 'aoe', power: 0.8, effect: 'freeze' }, | |
| ] | |
| }, | |
| archer: { | |
| icon: '🏹', color: '#22c55e', name: 'Archer', | |
| hp: 120, mp: 90, atk: 38, def: 12, spd: 9, | |
| abilities: [ | |
| { id: 'rapid_fire', name: 'Rapid Fire', icon: '🏹', mpCost: 20, cooldown: 1, desc: 'Hit 3 times fast', type: 'multi', power: 0.7, hits: 3 }, | |
| { id: 'poison_arrow', name: 'Poison Arrow', icon: '☠️', mpCost: 15, cooldown: 0, desc: 'Poison DoT', type: 'damage', power: 1.0, effect: 'poison' }, | |
| { id: 'snipe', name: 'Snipe', icon: '🎯', mpCost: 35, cooldown: 3, desc: 'Massive single hit', type: 'damage', power: 3.0 }, | |
| ] | |
| }, | |
| healer: { | |
| icon: '✨', color: '#f59e0b', name: 'Healer', | |
| hp: 110, mp: 160, atk: 18, def: 14, spd: 7, | |
| abilities: [ | |
| { id: 'heal', name: 'Heal', icon: '💚', mpCost: 20, cooldown: 0, desc: 'Restore ally HP', type: 'heal', power: 1.5, target: 'ally' }, | |
| { id: 'mass_heal', name: 'Mass Heal', icon: '💫', mpCost: 40, cooldown: 3, desc: 'Heal entire party', type: 'heal', power: 0.8, target: 'allies' }, | |
| { id: 'resurrect', name: 'Revive', icon: '🌟', mpCost: 60, cooldown: 5, desc: 'Resurrect fallen ally', type: 'revive', power: 0.5, target: 'deadAlly' }, | |
| ] | |
| }, | |
| rogue: { | |
| icon: '🗡️', color: '#06b6d4', name: 'Rogue', | |
| hp: 100, mp: 100, atk: 44, def: 10, spd: 11, | |
| abilities: [ | |
| { id: 'backstab', name: 'Backstab', icon: '🗡', mpCost: 15, cooldown: 0, desc: '60% crit chance', type: 'damage', power: 2.0, critChance: 0.6 }, | |
| { id: 'smoke_bomb', name: 'Smoke Bomb', icon: '💨', mpCost: 20, cooldown: 2, desc: 'Dodge next attack', type: 'buff', power: 1, effect: 'dodge' }, | |
| { id: 'fan_of_knives', name: 'Fan of Knives', icon: '🔪', mpCost: 30, cooldown: 2, desc: 'Hit all enemies', type: 'aoe', power: 0.9 }, | |
| ] | |
| } | |
| }; | |
| const WAVE_CONFIGS = [ | |
| { name: "THE DESCENT BEGINS", enemies: ['skeleton', 'goblin'], boss: false }, | |
| { name: "DARKNESS RISES", enemies: ['skeleton', 'goblin', 'skeleton'], boss: false }, | |
| { name: "THE DUNGEON ROARS", enemies: ['orc', 'goblin', 'orc'], boss: false }, | |
| { name: "ELITE GUARDS", enemies: ['orc_elite', 'skeleton_mage'], boss: false }, | |
| { name: "THE LICH KING", enemies: ['lich_king'], boss: true }, | |
| ]; | |
| const ENEMY_TYPES = { | |
| goblin: { name: 'Goblin', icon: '👺', hp: 60, atk: 15, def: 5, spd: 8, reward: 20, color: '#22c55e', size: 0.7 }, | |
| skeleton: { name: 'Skeleton', icon: '💀', hp: 80, atk: 20, def: 8, spd: 5, reward: 25, color: '#94a3b8', size: 0.8 }, | |
| orc: { name: 'Orc Brute', icon: '👹', hp: 140, atk: 30, def: 15, spd: 3, reward: 40, color: '#ef4444', size: 1.0 }, | |
| skeleton_mage: { name: 'Bone Mage', icon: '🧙', hp: 100, atk: 40, def: 5, spd: 7, reward: 55, color: '#8b5cf6', size: 0.9 }, | |
| orc_elite: { name: 'Orc Elite', icon: '👿', hp: 200, atk: 35, def: 20, spd: 4, reward: 70, color: '#f97316', size: 1.1 }, | |
| lich_king: { name: 'LICH KING', icon: '💠', hp: 500, atk: 55, def: 25, spd: 6, reward: 300, color: '#c084fc', size: 1.5, boss: true }, | |
| }; | |
| // ======================== | |
| // STATE | |
| // ======================== | |
| let myId = 'p_' + Math.random().toString(36).substr(2, 8); | |
| let myClass = null; | |
| let myName = ''; | |
| let roomCode = null; | |
| let isHost = false; | |
| let joinMode = 'create'; | |
| let selectedTarget = null; | |
| let gameState = null; | |
| let abilityCooldowns = {}; | |
| let listeners = []; | |
| let selectedDifficulty = 'easy'; | |
| let animFrame = null; | |
| let canvasCtx = null; | |
| let lastCanvas = null; | |
| // ======================== | |
| // SCREEN MANAGEMENT | |
| // ======================== | |
| window.showScreen = function(id) { | |
| document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); | |
| document.getElementById(id).classList.add('active'); | |
| }; | |
| window.goToClassSelect = function(mode) { | |
| joinMode = mode; | |
| showScreen('class-screen'); | |
| }; | |
| window.selectClass = function(cls, el) { | |
| myClass = cls; | |
| document.querySelectorAll('.class-card').forEach(c => c.classList.remove('selected')); | |
| el.classList.add('selected'); | |
| updateConfirmBtn(); | |
| }; | |
| window.updateConfirmBtn = function() { | |
| const nameVal = document.getElementById('hero-name-input').value.trim(); | |
| document.getElementById('confirm-class-btn').disabled = !myClass || !nameVal; | |
| }; | |
| document.getElementById('hero-name-input').addEventListener('input', updateConfirmBtn); | |
| window.confirmClass = async function() { | |
| myName = document.getElementById('hero-name-input').value.trim(); | |
| if (!myClass || !myName) return; | |
| showWaiting('Setting up...'); | |
| if (joinMode === 'create') { | |
| roomCode = generateRoomCode(); | |
| isHost = true; | |
| await createRoom(); | |
| hideWaiting(); | |
| setupLobby(); | |
| showScreen('lobby-screen'); | |
| } else { | |
| hideWaiting(); | |
| setupLobby(); | |
| showScreen('lobby-screen'); | |
| } | |
| }; | |
| // ======================== | |
| // LOBBY | |
| // ======================== | |
| function setupLobby() { | |
| const codeDisplay = document.getElementById('room-code-display'); | |
| const hostControls = document.getElementById('host-controls'); | |
| const joinArea = document.getElementById('join-area-lobby'); | |
| if (isHost) { | |
| codeDisplay.textContent = roomCode; | |
| hostControls.style.display = 'block'; | |
| joinArea.style.display = 'none'; | |
| } else { | |
| codeDisplay.textContent = '----'; | |
| hostControls.style.display = 'none'; | |
| joinArea.style.display = 'block'; | |
| } | |
| } | |
| window.copyRoomCode = function() { | |
| if (roomCode) { | |
| navigator.clipboard.writeText(roomCode).catch(() => {}); | |
| showNotification('Code copied: ' + roomCode); | |
| } | |
| }; | |
| window.setDifficulty = function(diff, el) { | |
| selectedDifficulty = diff; | |
| document.querySelectorAll('.diff-btn').forEach(b => b.classList.remove('active')); | |
| el.classList.add('active'); | |
| if (isHost && roomCode) { | |
| update(ref(db, `rooms/${roomCode}`), { difficulty: diff }); | |
| } | |
| }; | |
| async function createRoom() { | |
| const playerData = buildPlayerData(); | |
| await set(ref(db, `rooms/${roomCode}`), { | |
| host: myId, | |
| status: 'lobby', | |
| difficulty: selectedDifficulty, | |
| created: Date.now(), | |
| players: { [myId]: playerData } | |
| }); | |
| listenLobby(); | |
| } | |
| window.joinRoom = async function() { | |
| const code = document.getElementById('join-code-input').value.trim().toUpperCase(); | |
| if (code.length !== 4) { showNotification('Enter a 4-letter code!'); return; } | |
| showWaiting('Joining room...'); | |
| const snap = await get(ref(db, `rooms/${code}`)); | |
| if (!snap.exists()) { hideWaiting(); showNotification('Room not found!'); return; } | |
| const roomData = snap.val(); | |
| const playerCount = Object.keys(roomData.players || {}).length; | |
| if (playerCount >= 5) { hideWaiting(); showNotification('Room is full!'); return; } | |
| if (roomData.status !== 'lobby') { hideWaiting(); showNotification('Game already started!'); return; } | |
| roomCode = code; | |
| isHost = false; | |
| const playerData = buildPlayerData(); | |
| await update(ref(db, `rooms/${roomCode}/players/${myId}`), playerData); | |
| document.getElementById('room-code-display').textContent = roomCode; | |
| hideWaiting(); | |
| listenLobby(); | |
| }; | |
| function buildPlayerData() { | |
| const cls = CLASS_DATA[myClass]; | |
| return { | |
| id: myId, | |
| name: myName, | |
| class: myClass, | |
| hp: cls.hp, | |
| maxHp: cls.hp, | |
| mp: cls.mp, | |
| maxMp: cls.mp, | |
| atk: cls.atk, | |
| def: cls.def, | |
| spd: cls.spd, | |
| alive: true, | |
| statusEffects: {} | |
| }; | |
| } | |
| function listenLobby() { | |
| const roomRef = ref(db, `rooms/${roomCode}`); | |
| const unsubscribe = onValue(roomRef, (snap) => { | |
| if (!snap.exists()) return; | |
| const data = snap.val(); | |
| updateLobbyUI(data); | |
| if (data.status === 'playing') { | |
| clearListeners(); | |
| startGameClient(data); | |
| } | |
| }); | |
| listeners.push({ ref: roomRef, fn: unsubscribe }); | |
| } | |
| function updateLobbyUI(data) { | |
| const players = data.players || {}; | |
| const count = Object.keys(players).length; | |
| document.getElementById('player-count').textContent = count; | |
| const slots = document.getElementById('player-slots'); | |
| slots.innerHTML = ''; | |
| const playerArr = Object.values(players); | |
| for (let i = 0; i < 5; i++) { | |
| const p = playerArr[i]; | |
| const slot = document.createElement('div'); | |
| if (p) { | |
| slot.className = 'player-slot'; | |
| const cls = CLASS_DATA[p.class]; | |
| slot.innerHTML = ` | |
| <span class="slot-icon">${cls.icon}</span> | |
| <div class="slot-info"> | |
| <div class="slot-name">${p.name} ${p.id === myId ? '<span class="slot-you">YOU</span>' : ''} ${data.host === p.id ? '<span class="slot-host-badge">HOST</span>' : ''}</div> | |
| <div class="slot-class">${cls.name}</div> | |
| </div> | |
| <span class="slot-ready">●</span> | |
| `; | |
| } else { | |
| slot.className = 'player-slot empty'; | |
| slot.innerHTML = `<span class="slot-icon">👤</span><div class="slot-info"><div class="slot-name">Waiting...</div><div class="slot-class">Empty slot</div></div>`; | |
| } | |
| slots.appendChild(slot); | |
| } | |
| if (isHost) { | |
| document.getElementById('start-btn').disabled = count < 1; | |
| } | |
| } | |
| window.startGame = async function() { | |
| const snap = await get(ref(db, `rooms/${roomCode}`)); | |
| const roomData = snap.val(); | |
| const players = Object.values(roomData.players); | |
| // Sort players by speed | |
| const turnOrder = players.map(p => p.id).sort((a, b) => { | |
| return (roomData.players[b].spd || 0) - (roomData.players[a].spd || 0); | |
| }); | |
| const initWave = buildWave(0, roomData.difficulty); | |
| const gameData = { | |
| status: 'playing', | |
| wave: 0, | |
| totalWaves: WAVE_CONFIGS.length, | |
| turnOrder: turnOrder, | |
| currentTurnIndex: 0, | |
| currentTurn: turnOrder[0], | |
| phase: 'player', | |
| enemies: initWave, | |
| log: [], | |
| score: 0, | |
| difficulty: roomData.difficulty | |
| }; | |
| await update(ref(db, `rooms/${roomCode}`), gameData); | |
| }; | |
| function buildWave(waveIndex, difficulty) { | |
| const config = WAVE_CONFIGS[waveIndex]; | |
| const diffMult = difficulty === 'easy' ? 0.8 : difficulty === 'hard' ? 1.4 : 1.0; | |
| const enemies = {}; | |
| config.enemies.forEach((type, i) => { | |
| const base = ENEMY_TYPES[type]; | |
| const id = `enemy_${i}_${type}`; | |
| enemies[id] = { | |
| id, | |
| type, | |
| name: base.name, | |
| icon: base.icon, | |
| hp: Math.floor(base.hp * diffMult), | |
| maxHp: Math.floor(base.hp * diffMult), | |
| atk: Math.floor(base.atk * diffMult), | |
| def: base.def, | |
| spd: base.spd, | |
| alive: true, | |
| statusEffects: {}, | |
| boss: base.boss || false, | |
| x: 0, y: 0 // canvas positions set later | |
| }; | |
| }); | |
| return enemies; | |
| } | |
| // ======================== | |
| // GAME CLIENT | |
| // ======================== | |
| function startGameClient(roomData) { | |
| gameState = roomData; | |
| showScreen('game-screen'); | |
| setupCanvas(); | |
| initAbilityCooldowns(); | |
| listenGame(); | |
| renderGame(); | |
| showWaveAnnouncement(roomData.wave); | |
| } | |
| function listenGame() { | |
| const roomRef = ref(db, `rooms/${roomCode}`); | |
| const unsubscribe = onValue(roomRef, (snap) => { | |
| if (!snap.exists()) return; | |
| gameState = snap.val(); | |
| renderGame(); | |
| checkGameEnd(); | |
| }); | |
| listeners.push({ ref: roomRef, fn: unsubscribe }); | |
| } | |
| function clearListeners() { | |
| listeners.forEach(l => off(l.ref)); | |
| listeners = []; | |
| } | |
| function initAbilityCooldowns() { | |
| if (!myClass) return; | |
| CLASS_DATA[myClass].abilities.forEach(a => { | |
| abilityCooldowns[a.id] = 0; | |
| }); | |
| } | |
| // ======================== | |
| // CANVAS RENDERING | |
| // ======================== | |
| function setupCanvas() { | |
| const canvas = document.getElementById('battle-canvas'); | |
| const container = canvas.parentElement; | |
| canvas.width = container.clientWidth; | |
| canvas.height = container.clientHeight; | |
| canvasCtx = canvas.getContext('2d'); | |
| lastCanvas = canvas; | |
| drawBattleScene(); | |
| } | |
| function drawBattleScene() { | |
| if (!canvasCtx || !lastCanvas) return; | |
| const ctx = canvasCtx; | |
| const W = lastCanvas.width; | |
| const H = lastCanvas.height; | |
| // Background - dark dungeon | |
| ctx.fillStyle = '#050508'; | |
| ctx.fillRect(0, 0, W, H); | |
| // Underground layers | |
| const grad = ctx.createLinearGradient(0, 0, 0, H); | |
| grad.addColorStop(0, '#0a0810'); | |
| grad.addColorStop(0.5, '#050508'); | |
| grad.addColorStop(1, '#080510'); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(0, 0, W, H); | |
| // Floor | |
| ctx.fillStyle = '#1a1428'; | |
| ctx.fillRect(0, H * 0.72, W, H * 0.28); | |
| // Floor tiles pixel effect | |
| ctx.strokeStyle = '#0f0f20'; | |
| ctx.lineWidth = 1; | |
| const tileW = 40, tileH = 20; | |
| for (let x = 0; x < W; x += tileW) { | |
| for (let y = Math.floor(H * 0.72); y < H; y += tileH) { | |
| ctx.strokeRect(x, y, tileW, tileH); | |
| } | |
| } | |
| // Wall bricks | |
| ctx.fillStyle = '#1f1830'; | |
| for (let bx = 0; bx < W; bx += 50) { | |
| for (let by = 0; by < H * 0.72; by += 24) { | |
| const offset = (Math.floor(by / 24) % 2) * 25; | |
| ctx.fillRect(bx + offset, by, 48, 22); | |
| ctx.strokeStyle = '#0d0b18'; | |
| ctx.strokeRect(bx + offset, by, 48, 22); | |
| } | |
| } | |
| // Torches | |
| drawTorch(ctx, W * 0.2, H * 0.35); | |
| drawTorch(ctx, W * 0.5, H * 0.35); | |
| drawTorch(ctx, W * 0.8, H * 0.35); | |
| // Ambient particles | |
| drawAmbientParticles(ctx, W, H); | |
| } | |
| function drawTorch(ctx, x, y) { | |
| // Torch bracket | |
| ctx.fillStyle = '#8B7355'; | |
| ctx.fillRect(x - 3, y, 6, 16); | |
| // Flame glow | |
| const time = Date.now() / 1000; | |
| const flicker = Math.sin(time * 7 + x) * 0.3 + 0.7; | |
| const radGrad = ctx.createRadialGradient(x, y - 5, 0, x, y - 5, 25 * flicker); | |
| radGrad.addColorStop(0, `rgba(255, 200, 50, 0.6)`); | |
| radGrad.addColorStop(0.5, `rgba(255, 100, 0, 0.3)`); | |
| radGrad.addColorStop(1, 'rgba(255, 50, 0, 0)'); | |
| ctx.fillStyle = radGrad; | |
| ctx.fillRect(x - 25, y - 30, 50, 50); | |
| // Flame emoji approximation | |
| ctx.font = `${14 * flicker}px serif`; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('🔥', x, y); | |
| } | |
| function drawAmbientParticles(ctx, W, H) { | |
| // Floating dust particles | |
| const time = Date.now() / 3000; | |
| for (let i = 0; i < 15; i++) { | |
| const px = (Math.sin(i * 2.3 + time) * 0.5 + 0.5) * W; | |
| const py = (Math.cos(i * 1.7 + time * 0.5) * 0.3 + 0.3) * H; | |
| ctx.fillStyle = `rgba(124, 58, 237, ${0.1 + Math.sin(i + time) * 0.05})`; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 1.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| let animRunning = false; | |
| function startCanvasAnimation() { | |
| if (animRunning) return; | |
| animRunning = true; | |
| function loop() { | |
| if (!animRunning) return; | |
| drawBattleScene(); | |
| requestAnimationFrame(loop); | |
| } | |
| requestAnimationFrame(loop); | |
| } | |
| // ======================== | |
| // GAME RENDERING (UI) | |
| // ======================== | |
| function renderGame() { | |
| if (!gameState) return; | |
| startCanvasAnimation(); | |
| renderPlayerCards(); | |
| renderEnemyCards(); | |
| renderAbilities(); | |
| updateHUD(); | |
| updateActionButtons(); | |
| } | |
| function renderPlayerCards() { | |
| if (!gameState) return; | |
| const container = document.getElementById('player-cards-container'); | |
| const players = gameState.players || {}; | |
| container.innerHTML = ''; | |
| Object.values(players).forEach(p => { | |
| const cls = CLASS_DATA[p.class] || {}; | |
| const hpPct = Math.max(0, (p.hp / p.maxHp) * 100); | |
| const mpPct = Math.max(0, (p.mp / p.maxMp) * 100); | |
| const hpClass = hpPct > 60 ? 'high' : hpPct > 30 ? 'mid' : ''; | |
| const isActive = gameState.currentTurn === p.id; | |
| const isDead = !p.alive; | |
| const card = document.createElement('div'); | |
| card.className = `player-card${isActive ? ' active-turn' : ''}${isDead ? ' dead' : ''}`; | |
| card.innerHTML = ` | |
| <div class="pc-header"> | |
| <span class="pc-icon">${cls.icon || '👤'}</span> | |
| <div class="pc-info"> | |
| <div class="pc-name">${p.name}${p.id === myId ? ' ★' : ''}</div> | |
| <div class="pc-class">${cls.name || p.class}</div> | |
| </div> | |
| </div> | |
| <div class="hp-bar-container"> | |
| <div class="hp-label"><span>HP</span><span>${p.hp}/${p.maxHp}</span></div> | |
| <div class="hp-bar-bg"><div class="hp-bar-fill ${hpClass}" style="width:${hpPct}%"></div></div> | |
| </div> | |
| <div style="margin-top:4px"> | |
| <div class="mp-bar-bg"><div class="mp-bar-fill" style="width:${mpPct}%"></div></div> | |
| </div> | |
| <div class="status-icons">${renderStatusIcons(p.statusEffects)}</div> | |
| `; | |
| container.appendChild(card); | |
| }); | |
| } | |
| function renderStatusIcons(effects) { | |
| if (!effects) return ''; | |
| const iconMap = { stun: '😵', poison: '☠️', freeze: '❄️', dodge: '💨', atkUp: '⚔️↑', taunt: '😤', burn: '🔥', silence: '🔇' }; | |
| return Object.keys(effects).filter(e => effects[e] > 0).map(e => `<span class="status-icon" title="${e}: ${effects[e]} turns">${iconMap[e] || '?'}</span>`).join(''); | |
| } | |
| function renderEnemyCards() { | |
| if (!gameState) return; | |
| const container = document.getElementById('enemy-cards-container'); | |
| const enemies = gameState.enemies || {}; | |
| container.innerHTML = ''; | |
| Object.values(enemies).forEach(e => { | |
| const hpPct = Math.max(0, (e.hp / e.maxHp) * 100); | |
| const isTargeted = selectedTarget === e.id; | |
| const isDead = !e.alive; | |
| const card = document.createElement('div'); | |
| card.className = `enemy-card${isTargeted ? ' targeted' : ''}${isDead ? ' dead' : ''}`; | |
| card.dataset.id = e.id; | |
| card.onclick = () => targetEnemy(e.id); | |
| card.innerHTML = ` | |
| <div class="ec-header"> | |
| <span class="ec-icon">${e.icon}</span> | |
| <div class="ec-info"> | |
| <div class="ec-name">${e.name}${e.boss ? ' 👑' : ''}</div> | |
| <div class="ec-type">${e.hp}/${e.maxHp} HP</div> | |
| </div> | |
| </div> | |
| <div class="hp-bar-container"> | |
| <div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%;background:${hpPct > 50 ? '#ef4444' : hpPct > 25 ? '#f97316' : '#fcd34d'}"></div></div> | |
| </div> | |
| <div class="status-icons">${renderStatusIcons(e.statusEffects)}</div> | |
| `; | |
| container.appendChild(card); | |
| }); | |
| } | |
| function renderAbilities() { | |
| if (!myClass || !gameState) return; | |
| const row = document.getElementById('abilities-row'); | |
| const isMyTurn = gameState.currentTurn === myId; | |
| const me = gameState.players?.[myId]; | |
| row.innerHTML = ''; | |
| CLASS_DATA[myClass].abilities.forEach(ability => { | |
| const cd = abilityCooldowns[ability.id] || 0; | |
| const hasMP = (me?.mp || 0) >= ability.mpCost; | |
| const canUse = isMyTurn && cd === 0 && hasMP && me?.alive; | |
| const btn = document.createElement('button'); | |
| btn.className = 'ability-btn'; | |
| btn.disabled = !canUse; | |
| btn.onclick = () => useAbility(ability.id); | |
| btn.innerHTML = ` | |
| ${ability.icon} ${ability.name} | |
| <span class="mana-cost">💧${ability.mpCost}</span> | |
| ${cd > 0 ? `<span class="cooldown-badge">${cd}</span>` : ''} | |
| `; | |
| row.appendChild(btn); | |
| }); | |
| } | |
| function updateHUD() { | |
| if (!gameState) return; | |
| const waveEl = document.getElementById('hud-wave'); | |
| const turnEl = document.getElementById('turn-indicator'); | |
| const wave = gameState.wave || 0; | |
| waveEl.textContent = `WAVE ${wave + 1}/${WAVE_CONFIGS.length}`; | |
| const isMyTurn = gameState.currentTurn === myId; | |
| turnEl.textContent = isMyTurn ? '⚡ YOUR TURN' : `${gameState.players?.[gameState.currentTurn]?.name || 'ENEMY'}'S TURN`; | |
| turnEl.className = 'hud-turn-indicator' + (isMyTurn ? ' your-turn' : ''); | |
| } | |
| function updateActionButtons() { | |
| if (!gameState) return; | |
| const isMyTurn = gameState.currentTurn === myId; | |
| const me = gameState.players?.[myId]; | |
| const alive = me?.alive; | |
| document.getElementById('basic-attack-btn').disabled = !isMyTurn || !alive; | |
| document.getElementById('defend-btn').disabled = !isMyTurn || !alive; | |
| document.getElementById('end-turn-btn').disabled = !isMyTurn || !alive; | |
| } | |
| // ======================== | |
| // COMBAT ACTIONS | |
| // ======================== | |
| function targetEnemy(id) { | |
| if (!gameState || gameState.currentTurn !== myId) return; | |
| const enemy = gameState.enemies?.[id]; | |
| if (!enemy || !enemy.alive) return; | |
| selectedTarget = id; | |
| renderEnemyCards(); | |
| document.getElementById('target-hint').textContent = `Target: ${enemy.name}`; | |
| } | |
| window.basicAttack = async function() { | |
| if (!selectedTarget) { showNotification('Select a target first!'); return; } | |
| if (!isMyTurn()) return; | |
| const me = gameState.players[myId]; | |
| const target = gameState.enemies[selectedTarget]; | |
| if (!target || !target.alive) { showNotification('Invalid target!'); return; } | |
| const dmg = Math.max(1, me.atk - target.def + Math.floor(Math.random() * 10) - 5); | |
| await applyDamageToEnemy(selectedTarget, dmg, `${me.name} attacks ${target.name} for ${dmg} damage!`); | |
| await advanceTurn(); | |
| }; | |
| window.defend = async function() { | |
| if (!isMyTurn()) return; | |
| const me = gameState.players[myId]; | |
| const effects = me.statusEffects || {}; | |
| effects.defending = 2; | |
| await update(ref(db, `rooms/${roomCode}/players/${myId}/statusEffects`), effects); | |
| addLog(`${me.name} takes a defensive stance!`, 'skill'); | |
| await advanceTurn(); | |
| }; | |
| window.useAbility = async function(abilityId) { | |
| if (!isMyTurn()) return; | |
| const ability = CLASS_DATA[myClass].abilities.find(a => a.id === abilityId); | |
| if (!ability) return; | |
| const me = gameState.players[myId]; | |
| if (me.mp < ability.mpCost) { showNotification('Not enough MP!'); return; } | |
| if (ability.type === 'damage' || ability.type === 'multi') { | |
| if (!selectedTarget) { showNotification('Select a target first!'); return; } | |
| const target = gameState.enemies[selectedTarget]; | |
| if (!target?.alive) { showNotification('Select a valid target!'); return; } | |
| if (ability.type === 'multi') { | |
| for (let i = 0; i < (ability.hits || 1); i++) { | |
| const dmg = Math.max(1, Math.floor(me.atk * ability.power) - target.def + Math.floor(Math.random() * 8)); | |
| await applyDamageToEnemy(selectedTarget, dmg, `${me.name} hits ${target.name} for ${dmg}! (${i+1}/${ability.hits})`); | |
| } | |
| } else { | |
| let dmg = Math.max(1, Math.floor(me.atk * ability.power) - target.def + Math.floor(Math.random() * 10)); | |
| const isCrit = ability.critChance && Math.random() < ability.critChance; | |
| if (isCrit) { dmg = Math.floor(dmg * 1.8); } | |
| await applyDamageToEnemy(selectedTarget, dmg, `${me.name} uses ${ability.name}${isCrit ? ' [CRIT!]' : ''} for ${dmg} damage!`); | |
| if (ability.effect) { | |
| const effects = { ...(gameState.enemies[selectedTarget]?.statusEffects || {}) }; | |
| effects[ability.effect] = 3; | |
| await update(ref(db, `rooms/${roomCode}/enemies/${selectedTarget}/statusEffects`), effects); | |
| } | |
| } | |
| } else if (ability.type === 'aoe') { | |
| const enemies = gameState.enemies || {}; | |
| for (const [eid, enemy] of Object.entries(enemies)) { | |
| if (!enemy.alive) continue; | |
| const dmg = Math.max(1, Math.floor(me.atk * ability.power) - enemy.def + Math.floor(Math.random() * 8)); | |
| await applyDamageToEnemy(eid, dmg, `${me.name} hits ${enemy.name} for ${dmg}!`); | |
| } | |
| addLog(`${me.name} uses ${ability.name}!`, 'skill'); | |
| if (ability.effect) { | |
| for (const [eid, enemy] of Object.entries(enemies)) { | |
| if (!enemy.alive) continue; | |
| const effects = { ...(enemy.statusEffects || {}) }; | |
| effects[ability.effect] = 2; | |
| await update(ref(db, `rooms/${roomCode}/enemies/${eid}/statusEffects`), effects); | |
| } | |
| } | |
| } else if (ability.type === 'heal') { | |
| const players = gameState.players || {}; | |
| const targets = ability.target === 'allies' | |
| ? Object.values(players).filter(p => p.alive) | |
| : [Object.values(players).find(p => p.alive && p.id !== myId) || me]; | |
| for (const t of targets) { | |
| const healAmt = Math.floor(me.atk * ability.power + 20 + Math.random() * 15); | |
| const newHp = Math.min(t.maxHp, t.hp + healAmt); | |
| await update(ref(db, `rooms/${roomCode}/players/${t.id}`), { hp: newHp }); | |
| addLog(`${me.name} heals ${t.name} for ${healAmt} HP!`, 'heal'); | |
| showDamageFloat(`+${healAmt}`, '#22c55e'); | |
| } | |
| } else if (ability.type === 'revive') { | |
| const deadPlayer = Object.values(gameState.players || {}).find(p => !p.alive); | |
| if (!deadPlayer) { showNotification('No fallen allies!'); return; } | |
| const reviveHp = Math.floor(deadPlayer.maxHp * ability.power); | |
| await update(ref(db, `rooms/${roomCode}/players/${deadPlayer.id}`), { hp: reviveHp, alive: true }); | |
| addLog(`${me.name} revives ${deadPlayer.name}!`, 'heal'); | |
| } else if (ability.type === 'buff') { | |
| const effects = { ...(me.statusEffects || {}) }; | |
| effects[ability.effect] = 3; | |
| await update(ref(db, `rooms/${roomCode}/players/${myId}/statusEffects`), effects); | |
| addLog(`${me.name} uses ${ability.name}!`, 'skill'); | |
| } | |
| // Deduct MP | |
| const newMp = Math.max(0, me.mp - ability.mpCost); | |
| await update(ref(db, `rooms/${roomCode}/players/${myId}`), { mp: newMp }); | |
| // Set cooldown locally | |
| abilityCooldowns[abilityId] = ability.cooldown; | |
| await advanceTurn(); | |
| }; | |
| async function applyDamageToEnemy(enemyId, dmg, logMsg) { | |
| const enemy = (await get(ref(db, `rooms/${roomCode}/enemies/${enemyId}`))).val(); | |
| if (!enemy) return; | |
| const newHp = Math.max(0, enemy.hp - dmg); | |
| const alive = newHp > 0; | |
| await update(ref(db, `rooms/${roomCode}/enemies/${enemyId}`), { hp: newHp, alive }); | |
| addLog(logMsg, 'damage'); | |
| showDamageFloat(`-${dmg}`, '#ef4444'); | |
| if (!alive) { | |
| addLog(`${enemy.name} has been defeated!`, 'death'); | |
| const snap = await get(ref(db, `rooms/${roomCode}`)); | |
| const data = snap.val(); | |
| const score = (data.score || 0) + ENEMY_TYPES[enemy.type]?.reward || 20; | |
| await update(ref(db, `rooms/${roomCode}`), { score }); | |
| } | |
| } | |
| async function advanceTurn() { | |
| if (!gameState) return; | |
| const snap = await get(ref(db, `rooms/${roomCode}`)); | |
| const data = snap.val(); | |
| const enemies = data.enemies || {}; | |
| const allDead = Object.values(enemies).every(e => !e.alive); | |
| if (allDead) { | |
| const nextWave = (data.wave || 0) + 1; | |
| if (nextWave >= WAVE_CONFIGS.length) { | |
| await update(ref(db, `rooms/${roomCode}`), { status: 'victory' }); | |
| return; | |
| } | |
| const newEnemies = buildWave(nextWave, data.difficulty); | |
| await update(ref(db, `rooms/${roomCode}`), { | |
| wave: nextWave, | |
| enemies: newEnemies, | |
| currentTurnIndex: 0, | |
| currentTurn: data.turnOrder[0] | |
| }); | |
| showWaveAnnouncement(nextWave); | |
| return; | |
| } | |
| // Check if all players dead | |
| const players = data.players || {}; | |
| const allPlayersDead = Object.values(players).every(p => !p.alive); | |
| if (allPlayersDead) { | |
| await update(ref(db, `rooms/${roomCode}`), { status: 'defeat' }); | |
| return; | |
| } | |
| const order = data.turnOrder || []; | |
| let nextIndex = ((data.currentTurnIndex || 0) + 1) % order.length; | |
| // Skip dead players | |
| let safety = 0; | |
| while (players[order[nextIndex]] && !players[order[nextIndex]].alive && safety < order.length) { | |
| nextIndex = (nextIndex + 1) % order.length; | |
| safety++; | |
| } | |
| await update(ref(db, `rooms/${roomCode}`), { | |
| currentTurnIndex: nextIndex, | |
| currentTurn: order[nextIndex] | |
| }); | |
| // If next turn is enemy (not in players), do enemy turn | |
| if (!players[order[nextIndex]]) { | |
| // Slight delay for visual clarity | |
| setTimeout(() => doEnemyTurn(), 1200); | |
| } | |
| } | |
| async function doEnemyTurn() { | |
| const snap = await get(ref(db, `rooms/${roomCode}`)); | |
| const data = snap.val(); | |
| if (!data || data.status !== 'playing') return; | |
| const players = Object.values(data.players || {}).filter(p => p.alive); | |
| if (!players.length) return; | |
| const enemies = Object.values(data.enemies || {}).filter(e => e.alive); | |
| for (const enemy of enemies) { | |
| await enemyAct(enemy, players, data); | |
| // Tick status effects | |
| const newEffects = { ...(enemy.statusEffects || {}) }; | |
| let skip = false; | |
| if (newEffects.stun > 0) { skip = true; newEffects.stun--; } | |
| if (newEffects.freeze > 0) { skip = true; newEffects.freeze--; } | |
| if (newEffects.poison > 0) { | |
| const target = players[Math.floor(Math.random() * players.length)]; | |
| const poisonDmg = 8; | |
| const newHp = Math.max(0, target.hp - poisonDmg); | |
| // Actually enemy has poison, damage itself | |
| const selfHp = Math.max(0, enemy.hp - poisonDmg); | |
| await update(ref(db, `rooms/${roomCode}/enemies/${enemy.id}`), { hp: selfHp, alive: selfHp > 0 }); | |
| newEffects.poison--; | |
| } | |
| await update(ref(db, `rooms/${roomCode}/enemies/${enemy.id}/statusEffects`), newEffects); | |
| } | |
| // Advance to next player turn | |
| const updatedSnap = await get(ref(db, `rooms/${roomCode}`)); | |
| const updatedData = updatedSnap.val(); | |
| const order = updatedData.turnOrder || []; | |
| const players2 = updatedData.players || {}; | |
| let nextIndex = 0; // go back to first player | |
| let safety = 0; | |
| while (players2[order[nextIndex]] && !players2[order[nextIndex]].alive && safety < order.length) { | |
| nextIndex = (nextIndex + 1) % order.length; | |
| safety++; | |
| } | |
| await update(ref(db, `rooms/${roomCode}`), { | |
| currentTurnIndex: nextIndex, | |
| currentTurn: order[nextIndex] | |
| }); | |
| // Tick player status effects and regen MP | |
| for (const p of Object.values(updatedData.players || {})) { | |
| if (!p.alive) continue; | |
| const effects = { ...(p.statusEffects || {}) }; | |
| Object.keys(effects).forEach(k => { if (effects[k] > 0) effects[k]--; }); | |
| const newMp = Math.min(p.maxMp, p.mp + 10); // MP regen per round | |
| await update(ref(db, `rooms/${roomCode}/players/${p.id}`), { statusEffects: effects, mp: newMp }); | |
| } | |
| // Decrement local cooldowns | |
| Object.keys(abilityCooldowns).forEach(k => { | |
| if (abilityCooldowns[k] > 0) abilityCooldowns[k]--; | |
| }); | |
| } | |
| async function enemyAct(enemy, players, data) { | |
| // Target taunt player first, otherwise random | |
| let target = players.find(p => p.statusEffects?.taunt > 0) || players[Math.floor(Math.random() * players.length)]; | |
| // Check if target is defending | |
| const defBonus = target.statusEffects?.defending > 0 ? 15 : 0; | |
| const dmg = Math.max(1, enemy.atk - target.def - defBonus + Math.floor(Math.random() * 8) - 3); | |
| const newHp = Math.max(0, target.hp - dmg); | |
| const alive = newHp > 0; | |
| await update(ref(db, `rooms/${roomCode}/players/${target.id}`), { hp: newHp, alive }); | |
| addLog(`${enemy.name} attacks ${target.name} for ${dmg} damage!`, 'damage'); | |
| if (!alive) addLog(`${target.name} has fallen!`, 'death'); | |
| } | |
| window.endTurn = async function() { | |
| if (!isMyTurn()) return; | |
| await advanceTurn(); | |
| }; | |
| function isMyTurn() { | |
| return gameState?.currentTurn === myId && gameState?.players?.[myId]?.alive; | |
| } | |
| // ======================== | |
| // COMBAT LOG | |
| // ======================== | |
| function addLog(msg, type = 'system') { | |
| const log = document.getElementById('combat-log'); | |
| const entry = document.createElement('div'); | |
| entry.className = `log-entry ${type}`; | |
| entry.textContent = `> ${msg}`; | |
| log.appendChild(entry); | |
| log.scrollTop = log.scrollHeight; | |
| } | |
| // ======================== | |
| // DAMAGE FLOAT | |
| // ======================== | |
| function showDamageFloat(text, color) { | |
| const overlay = document.getElementById('battle-overlay'); | |
| const el = document.createElement('div'); | |
| el.className = 'damage-float'; | |
| el.style.color = color; | |
| el.style.left = (30 + Math.random() * 40) + '%'; | |
| el.style.top = (30 + Math.random() * 30) + '%'; | |
| el.textContent = text; | |
| overlay.appendChild(el); | |
| setTimeout(() => el.remove(), 1200); | |
| } | |
| // ======================== | |
| // WAVE ANNOUNCEMENT | |
| // ======================== | |
| function showWaveAnnouncement(waveIndex) { | |
| const config = WAVE_CONFIGS[waveIndex]; | |
| const el = document.createElement('div'); | |
| el.className = 'wave-announcement'; | |
| el.innerHTML = ` | |
| <div class="wave-text"> | |
| <span class="wave-number">WAVE ${waveIndex + 1}</span> | |
| <span class="wave-subtitle">${config.name}</span> | |
| </div> | |
| `; | |
| document.body.appendChild(el); | |
| setTimeout(() => el.remove(), 2500); | |
| } | |
| // ======================== | |
| // GAME END CHECK | |
| // ======================== | |
| function checkGameEnd() { | |
| if (!gameState) return; | |
| if (gameState.status === 'victory') showResult('victory'); | |
| if (gameState.status === 'defeat') showResult('defeat'); | |
| } | |
| function showResult(type) { | |
| const screen = document.getElementById('result-screen'); | |
| const title = document.getElementById('result-title'); | |
| const emoji = document.getElementById('result-emoji'); | |
| const stats = document.getElementById('result-stats'); | |
| title.textContent = type === 'victory' ? 'VICTORY!' : 'DEFEATED...'; | |
| title.className = 'result-title ' + type; | |
| emoji.textContent = type === 'victory' ? '🏆' : '💀'; | |
| stats.innerHTML = ` | |
| Score: ${gameState?.score || 0}<br> | |
| Waves cleared: ${gameState?.wave || 0}/${WAVE_CONFIGS.length}<br> | |
| Players: ${Object.values(gameState?.players || {}).filter(p => p.alive).length} survived | |
| `; | |
| screen.classList.add('show'); | |
| animRunning = false; | |
| } | |
| // ======================== | |
| // NOTIFICATIONS | |
| // ======================== | |
| window.showNotification = function(msg) { | |
| const el = document.createElement('div'); | |
| el.className = 'notification'; | |
| el.textContent = msg; | |
| document.body.appendChild(el); | |
| setTimeout(() => el.remove(), 3000); | |
| }; | |
| // ======================== | |
| // UTILS | |
| // ======================== | |
| function generateRoomCode() { | |
| const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; | |
| let code = ''; | |
| for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)]; | |
| return code; | |
| } | |
| function showWaiting(text) { | |
| document.getElementById('waiting-text').textContent = text; | |
| document.getElementById('waiting-overlay').classList.add('show'); | |
| } | |
| function hideWaiting() { | |
| document.getElementById('waiting-overlay').classList.remove('show'); | |
| } | |
| // Handle window resize for canvas | |
| window.addEventListener('resize', () => { | |
| if (document.getElementById('game-screen').classList.contains('active')) { | |
| setupCanvas(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |