Spaces:
Running
Running
| import base64 | |
| import mimetypes | |
| import threading | |
| import time | |
| from dataclasses import replace | |
| from pathlib import Path | |
| from random import Random | |
| import gradio as gr | |
| from clients import art_client_from_env, card_client_from_env, configure_mode | |
| from primitives import School | |
| from ui import ( | |
| CARD_PANEL_COUNT, | |
| HAND_PANEL_COUNT, | |
| RunState, | |
| board_html, | |
| boss_splash_uri, | |
| choose_draft_card_loading_steps, | |
| collect_ready_battle, | |
| collect_ready_pack, | |
| draft_screen_html, | |
| escape_html, | |
| log_html, | |
| new_run_shell, | |
| pass_turn_steps, | |
| play_hand_card_steps, | |
| queue_next_pack, | |
| refresh_art, | |
| ) | |
| ASSETS = Path(__file__).parent / "assets" | |
| # Encode a bundled image asset as a CSS-ready data URI (HF-Spaces safe, no static paths). | |
| def asset_data_uri(name: str, mime: str = "image/jpeg") -> str: | |
| encoded = base64.b64encode((ASSETS / name).read_bytes()).decode("ascii") | |
| return f"data:{mime};base64,{encoded}" | |
| # Encode an optional bundled image asset as a CSS-ready data URI. | |
| def optional_asset_data_uri(name: str) -> str: | |
| path = optional_asset_path(name) | |
| if path is None: | |
| return "" | |
| mime = mimetypes.guess_type(path.name)[0] or "image/jpeg" | |
| encoded = base64.b64encode(path.read_bytes()).decode("ascii") | |
| return f"data:{mime};base64,{encoded}" | |
| # Return the first matching asset path, accepting common image extensions. | |
| def optional_asset_path(name: str) -> Path | None: | |
| path = ASSETS / name | |
| if path.exists(): | |
| return path | |
| if path.suffix: | |
| for suffix in (".jpg", ".jpeg", ".png", ".webp"): | |
| candidate = path.with_suffix(suffix) | |
| if candidate.exists(): | |
| return candidate | |
| return None | |
| INN_BG = asset_data_uri("inn_bg.jpg") | |
| WOOD_BG = asset_data_uri("wood_board.jpg") | |
| HEAD = """ | |
| <script> | |
| function tabrasClick(id) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| (el.tagName === 'BUTTON' ? el : el.querySelector('button')).click(); | |
| } | |
| function tabrasPlay(id, card) { | |
| if (card.classList.contains('launching')) return; | |
| card.classList.add('launching'); | |
| tabrasClick(id); | |
| } | |
| </script> | |
| """ | |
| CSS = """ | |
| .gradio-container { | |
| background: linear-gradient(rgba(10, 6, 14, 0.82), rgba(6, 4, 9, 0.9)), url("__INN_BG__") center / cover fixed; | |
| color: #f4ead9; | |
| max-width: 100% !important; | |
| } | |
| footer { display: none !important; } | |
| .hidden-controls { display: none !important; } | |
| *, *::before, *::after { box-sizing: border-box; } | |
| gradio-app, body { | |
| background: linear-gradient(rgba(10, 6, 14, 0.82), rgba(6, 4, 9, 0.9)), url("__INN_BG__") center / cover fixed !important; | |
| overflow-x: hidden; | |
| } | |
| .gradio-container .block, .gradio-container .form, .gradio-container .gr-group, .gradio-container .styler { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| button.primary { | |
| background: linear-gradient(180deg, #f2c24d, #a96f17) !important; | |
| color: #3a2403 !important; | |
| border: 2px solid #f6dd9a !important; | |
| font-weight: 800 !important; | |
| } | |
| #play-now-btn { | |
| width: auto !important; | |
| min-width: 170px !important; | |
| max-width: 220px !important; | |
| align-self: center !important; | |
| } | |
| #play-now-btn button { | |
| width: auto !important; | |
| min-width: 170px !important; | |
| padding: 10px 28px !important; | |
| } | |
| #start-draft-btn { | |
| width: auto !important; | |
| min-width: 150px !important; | |
| max-width: 200px !important; | |
| align-self: center !important; | |
| margin: 0 auto !important; | |
| } | |
| #start-draft-btn button { | |
| width: auto !important; | |
| min-width: 150px !important; | |
| padding: 10px 24px !important; | |
| } | |
| .tabras-title { | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| gap: 26px; | |
| padding: 18vh 20px 0; | |
| } | |
| .tabras-title h1 { | |
| font-size: 92px; | |
| margin: 0; | |
| color: #f6f0ff; | |
| text-shadow: 0 0 30px rgba(160, 90, 255, 0.6), 0 3px 18px rgba(0, 0, 0, 0.65); | |
| } | |
| .tabras-sub { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| color: #efe6ff; | |
| text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8); | |
| } | |
| .tabras-sub .subline { | |
| font-size: 21px; | |
| line-height: 1.4; | |
| white-space: nowrap; | |
| } | |
| #play-now-btn { margin-top: 26px !important; } | |
| .gradio-container .setup-panel { | |
| background: rgba(26, 20, 46, 0.82) !important; | |
| border: 2px solid rgba(190, 160, 255, 0.3) !important; | |
| box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important; | |
| padding: 18px !important; | |
| border-radius: 14px !important; | |
| max-width: 560px; | |
| margin: 8vh auto 0 !important; | |
| } | |
| .step-kicker { | |
| color: #ffd35c; | |
| font-size: 13px; | |
| font-weight: 900; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| margin-bottom: 8px; | |
| } | |
| .setup-panel h2 { | |
| margin: 0 0 12px; | |
| color: #f6f0ff; | |
| font-size: 28px; | |
| } | |
| .choice-row button { | |
| min-height: 46px; | |
| } | |
| .selector-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 14px; | |
| width: min(980px, 92vw); | |
| margin: 0 auto; | |
| } | |
| .selector-panel { | |
| position: relative; | |
| min-height: 360px; | |
| border: 2px solid rgba(190, 160, 255, 0.34); | |
| border-radius: 14px; | |
| overflow: hidden; | |
| cursor: pointer; | |
| background: var(--selector-fallback); | |
| background-size: cover; | |
| background-position: center; | |
| box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42); | |
| transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease; | |
| } | |
| .selector-image { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .selector-panel::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(180deg, rgba(12, 8, 30, 0.06), rgba(12, 8, 30, 0.88)); | |
| pointer-events: none; | |
| } | |
| .selector-panel:hover { | |
| transform: translateY(-8px); | |
| border-color: #ffd35c; | |
| box-shadow: 0 0 28px rgba(255, 211, 92, 0.22), 0 24px 58px rgba(0, 0, 0, 0.52); | |
| filter: saturate(1.1); | |
| } | |
| .selector-label { | |
| position: absolute; | |
| left: 18px; | |
| right: 18px; | |
| bottom: 18px; | |
| color: #f6f0ff; | |
| font-size: 26px; | |
| font-weight: 900; | |
| text-shadow: 0 3px 14px rgba(0, 0, 0, 0.82); | |
| z-index: 2; | |
| } | |
| .selector-copy { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| align-items: flex-end; | |
| padding: 18px; | |
| color: #f6f0ff; | |
| font-size: 17px; | |
| font-weight: 700; | |
| line-height: 1.35; | |
| opacity: 0; | |
| background: linear-gradient(180deg, rgba(8, 4, 20, 0.24), rgba(8, 4, 20, 0.88)); | |
| text-shadow: 0 2px 10px rgba(0, 0, 0, 0.9); | |
| transition: opacity 0.16s ease; | |
| z-index: 3; | |
| } | |
| .selector-panel:hover .selector-copy { | |
| opacity: 1; | |
| } | |
| @media (max-width: 900px) { | |
| .selector-grid { grid-template-columns: 1fr; } | |
| .selector-panel { min-height: 240px; } | |
| } | |
| /* ---- card faces ---- */ | |
| .tabras-card { | |
| position: relative; | |
| border-radius: 10px; | |
| padding: 26px 10px 8px; | |
| background: linear-gradient(180deg, #2a2347, #151028); | |
| border: 2px solid #8d7bd6; | |
| color: #f0eaff; | |
| box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .tabras-card.empty { opacity: 0.35; } | |
| .card-cost { | |
| position: absolute; | |
| top: -9px; | |
| left: -9px; | |
| width: 34px; | |
| height: 34px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 35% 30%, #7fd4ff, #1668c9); | |
| border: 2px solid #d8f1ff; | |
| color: #fff; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 900; | |
| font-size: 17px; | |
| text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); | |
| z-index: 2; | |
| } | |
| .card-name { font-size: 13px; font-weight: 800; line-height: 1.15; min-height: 30px; } | |
| .school-fire .card-name { color: #ff9d80; } | |
| .school-ice .card-name { color: #8fdfff; } | |
| .school-earth .card-name { color: #aaf08a; } | |
| .tabras-card.school-fire { border-color: #e06a48; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35), 0 0 14px rgba(255, 110, 70, 0.22); } | |
| .tabras-card.school-ice { border-color: #4fb6e8; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35), 0 0 14px rgba(90, 200, 255, 0.22); } | |
| .tabras-card.school-earth { border-color: #6fc454; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35), 0 0 14px rgba(130, 230, 110, 0.22); } | |
| .card-art { | |
| height: 64px; | |
| border-radius: 6px; | |
| border: 1px solid rgba(200, 170, 255, 0.3); | |
| background: linear-gradient(135deg, rgba(80, 60, 140, 0.5), rgba(20, 40, 70, 0.55)); | |
| overflow: hidden; | |
| } | |
| .card-art.generated { | |
| border-style: solid; | |
| background-color: #0d0a16; | |
| background-repeat: no-repeat; | |
| background-size: cover; | |
| background-position: center; | |
| } | |
| .pending-art { | |
| position: relative; | |
| border-color: rgba(190, 160, 255, 0.28); | |
| } | |
| .pending-art::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.12) 42%, transparent 70%); | |
| transform: translateX(-120%); | |
| animation: art-sheen 1.8s ease-in-out infinite; | |
| } | |
| .school-art-fire { background: radial-gradient(circle at 40% 35%, rgba(255, 155, 80, 0.34), transparent 32%), linear-gradient(135deg, #3a1724, #180d1f 72%); } | |
| .school-art-ice { background: radial-gradient(circle at 45% 35%, rgba(125, 220, 255, 0.32), transparent 34%), linear-gradient(135deg, #172c4a, #111328 72%); } | |
| .school-art-earth { background: radial-gradient(circle at 45% 38%, rgba(170, 240, 138, 0.28), transparent 34%), linear-gradient(135deg, #20331f, #111328 72%); } | |
| @keyframes art-sheen { | |
| from { transform: translateX(-120%); } | |
| to { transform: translateX(120%); } | |
| } | |
| .card-rules { font-size: 11.5px; line-height: 1.3; min-height: 44px; color: #ece4ff; } | |
| .card-flavor { | |
| color: #b9a8e0; | |
| font-size: 10px; | |
| font-style: italic; | |
| border-top: 1px solid rgba(200, 170, 255, 0.2); | |
| padding-top: 4px; | |
| } | |
| /* ---- draft screen ---- */ | |
| .draft-board { | |
| width: min(1220px, calc(100vw - 56px)); | |
| min-height: 82vh; | |
| margin: 0 auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 24px; | |
| padding: 20px 18px; | |
| } | |
| .draft-banner { text-align: center; } | |
| .draft-banner h2 { margin: 0; color: #ffd35c; letter-spacing: 0.08em; text-transform: uppercase; text-shadow: 0 0 18px rgba(255, 211, 92, 0.45); font-size: 30px; } | |
| .draft-banner p { margin: 6px 0 0; color: #b9a8e0; font-size: 17px; } | |
| .draft-pack { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 300px)); | |
| gap: clamp(16px, 2vw, 30px); | |
| justify-content: center; | |
| align-items: stretch; | |
| width: 100%; | |
| } | |
| .draft-card { | |
| width: 100%; | |
| min-width: 0; | |
| min-height: 405px; | |
| cursor: pointer; | |
| transition: transform 0.15s ease, box-shadow 0.15s ease; | |
| animation: pack-in 0.5s ease backwards; | |
| } | |
| .draft-pack .draft-card:nth-child(2) { animation-delay: 0.1s; } | |
| .draft-pack .draft-card:nth-child(3) { animation-delay: 0.2s; } | |
| .draft-card:hover { transform: translateY(-10px) scale(1.045); box-shadow: 0 0 22px rgba(56, 232, 210, 0.5); } | |
| @keyframes pack-in { | |
| from { opacity: 0; transform: translateY(28px) scale(0.92); } | |
| to { opacity: 1; transform: none; } | |
| } | |
| .draft-pack .draft-card.fading { animation: pack-out 0.7s ease forwards; animation-delay: 0s; pointer-events: none; cursor: default; } | |
| .draft-pack .draft-card.picked { animation: picked-out 0.7s ease forwards; animation-delay: 0s; pointer-events: none; cursor: default; } | |
| @keyframes pack-out { | |
| from { opacity: 1; } | |
| to { opacity: 0; transform: translateY(-14px) scale(0.94); filter: grayscale(0.7); } | |
| } | |
| @keyframes picked-out { | |
| 30% { opacity: 1; transform: scale(1.08); box-shadow: 0 0 44px rgba(242, 194, 77, 0.95); } | |
| to { opacity: 0; transform: translateY(-34px) scale(1.02); box-shadow: 0 0 30px rgba(242, 194, 77, 0.6); } | |
| } | |
| .draft-card .card-name { font-size: 17px; min-height: 44px; } | |
| .draft-card .card-art { height: 112px; } | |
| .draft-card .card-rules { font-size: 13px; min-height: 64px; } | |
| .draft-card .card-flavor { font-size: 12px; } | |
| .starter-board { min-height: 82vh; gap: 22px; } | |
| .starter-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(190px, 1fr)); | |
| gap: 18px; | |
| width: min(920px, 92vw); | |
| } | |
| .starter-card { min-height: 260px; } | |
| .starter-card .card-name { font-size: 17px; min-height: 40px; } | |
| .starter-card .card-art { height: 70px; } | |
| .starter-card .card-rules { font-size: 13.5px; min-height: 46px; } | |
| .starter-card .card-flavor { font-size: 12px; } | |
| .loading-board { gap: 30px; } | |
| .draft-loading { | |
| width: min(520px, 86vw); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 20px 22px; | |
| border: 1px solid rgba(255, 211, 92, 0.35); | |
| border-radius: 12px; | |
| background: rgba(12, 8, 30, 0.6); | |
| box-shadow: 0 0 24px rgba(255, 211, 92, 0.12); | |
| } | |
| .loading-title { | |
| color: #ffd35c; | |
| font-size: 20px; | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .loading-subtitle { | |
| color: #b9a8e0; | |
| font-size: 13px; | |
| text-align: center; | |
| } | |
| .rules-screen { | |
| min-height: 74vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px; | |
| } | |
| .rules-card { | |
| width: min(780px, 92vw); | |
| border: 2px solid rgba(255, 211, 92, 0.32); | |
| border-radius: 16px; | |
| background: rgba(16, 10, 34, 0.82); | |
| box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45), 0 0 28px rgba(255, 211, 92, 0.12); | |
| padding: 30px; | |
| } | |
| .rules-card h1 { | |
| margin: 0 0 12px; | |
| color: #ffd35c; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .rules-card p { | |
| color: #d9d0f0; | |
| font-size: 16px; | |
| line-height: 1.45; | |
| } | |
| .reveal-screen { | |
| min-height: 64vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| padding-top: 12px; | |
| text-align: center; | |
| animation: reveal-in 0.6s ease backwards; | |
| } | |
| #reveal-next-btn { | |
| width: auto !important; | |
| min-width: 150px !important; | |
| max-width: 200px !important; | |
| align-self: center !important; | |
| margin: 4px auto 0 !important; | |
| } | |
| #reveal-next-btn button { | |
| width: auto !important; | |
| min-width: 150px !important; | |
| padding: 9px 26px !important; | |
| } | |
| @keyframes reveal-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } } | |
| .reveal-kicker { | |
| font-size: 15px; | |
| font-weight: 900; | |
| letter-spacing: 0.22em; | |
| text-transform: uppercase; | |
| color: #f06bff; | |
| text-shadow: 0 0 18px rgba(232, 91, 255, 0.55); | |
| } | |
| .reveal-art { | |
| width: min(440px, 86vw); | |
| height: min(440px, 60vh); | |
| border-radius: 16px; | |
| border: 3px solid #b14ad0; | |
| background-size: cover; | |
| background-position: center top; | |
| background-color: #160e26; | |
| box-shadow: 0 0 48px rgba(177, 74, 208, 0.45), 0 24px 60px rgba(0, 0, 0, 0.6); | |
| } | |
| .reveal-name { | |
| font-size: 40px; | |
| font-weight: 900; | |
| color: #f6f0ff; | |
| letter-spacing: 0.04em; | |
| text-shadow: 0 0 26px rgba(232, 91, 255, 0.5), 0 3px 12px rgba(0, 0, 0, 0.7); | |
| } | |
| .reveal-quote { | |
| font-size: 19px; | |
| font-style: italic; | |
| color: #d9c4ee; | |
| max-width: 540px; | |
| } | |
| .versus { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 28px; | |
| margin: 6px 0 14px; | |
| } | |
| .versus-side { display: flex; flex-direction: column; align-items: center; gap: 6px; } | |
| .versus-face { | |
| width: 132px; | |
| height: 132px; | |
| border-radius: 50% 50% 46% 46%; | |
| border: 4px solid #b08a4f; | |
| background-size: cover; | |
| background-position: center top; | |
| background-color: #1b1430; | |
| box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55); | |
| } | |
| .versus-side:last-child .versus-face { border-color: #b14ad0; box-shadow: 0 0 26px rgba(232, 91, 255, 0.4), 0 12px 30px rgba(0, 0, 0, 0.55); } | |
| .versus-face-text { display: flex; align-items: center; justify-content: center; color: #f4d69b; font-weight: 900; font-size: 22px; } | |
| .versus-name { font-weight: 800; color: #f6f0ff; font-size: 18px; } | |
| .versus-tag { font-size: 12px; color: #b9a8e0; letter-spacing: 0.04em; } | |
| .versus-vs { font-size: 30px; font-weight: 900; color: #ffd35c; text-shadow: 0 0 16px rgba(255, 211, 92, 0.5); } | |
| .villain-story { text-align: center; color: #d9d0f0; font-style: italic; max-width: 540px; margin: 0 auto; } | |
| .rules-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 12px; | |
| margin-top: 18px; | |
| } | |
| .rule-tile { | |
| border: 1px solid rgba(190, 160, 255, 0.28); | |
| border-radius: 10px; | |
| padding: 14px; | |
| background: rgba(42, 35, 71, 0.62); | |
| } | |
| .rule-tile b { | |
| display: block; | |
| color: #f6f0ff; | |
| margin-bottom: 6px; | |
| } | |
| .rule-tile span { | |
| color: #b9a8e0; | |
| font-size: 14px; | |
| line-height: 1.35; | |
| } | |
| .loading-bar { | |
| width: 100%; | |
| height: 12px; | |
| overflow: hidden; | |
| border-radius: 999px; | |
| background: rgba(20, 14, 50, 0.95); | |
| border: 1px solid rgba(95, 220, 255, 0.35); | |
| } | |
| .loading-bar span { | |
| display: block; | |
| width: 40%; | |
| height: 100%; | |
| border-radius: inherit; | |
| background: linear-gradient(90deg, #38e8d2, #ffd35c, #ff9d80); | |
| animation: loading-sweep 1.15s ease-in-out infinite; | |
| } | |
| @keyframes loading-sweep { | |
| 0% { transform: translateX(-110%); } | |
| 100% { transform: translateX(260%); } | |
| } | |
| .deck-strip { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 1100px; align-items: center; } | |
| .deck-strip-label { font-weight: 800; color: #f2c24d; margin-right: 6px; font-size: 18px; } | |
| .deck-chip { background: rgba(10, 6, 30, 0.5); border: 1px solid #5b4a8f; border-radius: 12px; padding: 3px 12px; font-size: 13px; } | |
| .deck-chip b { color: #38e8d2; } | |
| .deck-chip.anchor { border-color: #ffd35c; box-shadow: 0 0 8px rgba(255, 211, 92, 0.4); } | |
| @media (max-width: 900px) { | |
| .draft-board { min-height: auto; justify-content: flex-start; } | |
| .draft-pack { grid-template-columns: 1fr; max-width: 320px; } | |
| .draft-card { width: min(300px, 92vw); min-height: 405px; } | |
| .starter-grid { grid-template-columns: repeat(2, minmax(150px, 1fr)); } | |
| } | |
| /* ---- battle board ---- */ | |
| .board { | |
| position: relative; | |
| border: 4px solid #7a5630; | |
| border-radius: 22px; | |
| padding: 8px 14px; | |
| background: linear-gradient(rgba(24, 13, 5, 0.42), rgba(14, 7, 2, 0.6)), url("__WOOD_BG__") center / cover; | |
| box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.62), 0 0 30px rgba(150, 95, 40, 0.25), 0 24px 60px rgba(0, 0, 0, 0.55); | |
| overflow: hidden; | |
| } | |
| .zone { position: relative; display: flex; align-items: center; justify-content: center; } | |
| .zone-center { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 1; } | |
| .piles { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; } | |
| .pile { | |
| width: 64px; | |
| text-align: center; | |
| border: 2px solid #5b4a8f; | |
| border-radius: 8px; | |
| padding: 6px 0 3px; | |
| background: repeating-linear-gradient(45deg, #1c1535 0, #1c1535 6px, #251c45 6px, #251c45 12px); | |
| } | |
| .pile span { font-weight: 900; font-size: 18px; color: #ffd35c; } | |
| .pile label { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: #9d8fc7; } | |
| .fatigue-warn { color: #ff8d7a; font-size: 10px; font-weight: 800; } | |
| .hero { display: flex; flex-direction: column; align-items: center; gap: 3px; } | |
| .hero-frame { position: relative; width: 124px; height: 124px; } | |
| .hero-face { | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 50% 50% 46% 46%; | |
| border: 4px solid #b08a4f; | |
| background: radial-gradient(circle at 50% 30%, #4a3e72, #1b1430 75%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 900; | |
| font-size: 22px; | |
| color: #f4d69b; | |
| box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); | |
| } | |
| .hero-face.portrait { | |
| background-size: cover; | |
| background-position: center top; | |
| } | |
| .hp-gem, .block-gem, .ward-gem { | |
| position: absolute; | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 900; | |
| font-size: 19px; | |
| color: #fff; | |
| text-shadow: 0 1px 2px #000; | |
| border: 3px solid #f3d492; | |
| } | |
| .hp-gem { right: -10px; bottom: -6px; background: radial-gradient(circle at 35% 30%, #e0524d, #8f1612); } | |
| .block-gem { left: -10px; bottom: -6px; background: radial-gradient(circle at 35% 30%, #c9cdd4, #5a626e); border-color: #eef2f7; } | |
| .ward-gem { left: -10px; top: -6px; background: radial-gradient(circle at 35% 30%, #8fd0ff, #1d4d8f); border-color: #d8f1ff; } | |
| .hero-name { font-weight: 800; color: #f4d69b; text-shadow: 0 2px 6px #000; } | |
| .hero-frame.hit { animation: hero-shake 0.45s ease; } | |
| .hero-frame.hit .hp-gem { animation: gem-flash 0.7s ease; } | |
| @keyframes hero-shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 20% { transform: translateX(-7px) rotate(-2deg); } | |
| 40% { transform: translateX(6px) rotate(2deg); } | |
| 60% { transform: translateX(-4px); } | |
| 80% { transform: translateX(3px); } | |
| } | |
| @keyframes gem-flash { | |
| 0%, 100% { filter: none; } | |
| 30% { filter: brightness(2.2) saturate(1.6); transform: scale(1.25); } | |
| } | |
| .dmg-pop { | |
| position: absolute; | |
| left: 50%; | |
| top: 22%; | |
| transform: translateX(-50%); | |
| font-size: 34px; | |
| font-weight: 900; | |
| color: #ff5340; | |
| text-shadow: 0 0 12px rgba(255, 60, 30, 0.9), 0 2px 3px #000; | |
| pointer-events: none; | |
| z-index: 50; | |
| animation: dmg-float 1.4s ease-out forwards; | |
| } | |
| @keyframes dmg-float { | |
| from { opacity: 0; transform: translateX(-50%) translateY(16px) scale(0.5); } | |
| 25% { opacity: 1; transform: translateX(-50%) translateY(-8px) scale(1.3); } | |
| to { opacity: 0; transform: translateX(-50%) translateY(-54px) scale(1); } | |
| } | |
| .chips { display: flex; gap: 6px; min-height: 18px; } | |
| .chip { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 8px; border: 1px solid; } | |
| .chip.charge { color: #ffd98c; border-color: #c9982f; background: rgba(120, 85, 10, 0.35); } | |
| .chip.weak { color: #a8c6ff; border-color: #4a6fb5; background: rgba(30, 55, 110, 0.35); } | |
| .chip.vuln { color: #ffb3c2; border-color: #b54a64; background: rgba(110, 25, 45, 0.35); } | |
| .enemy-hand { display: flex; justify-content: center; margin-bottom: -10px; } | |
| .enemy-card-back { | |
| width: 52px; | |
| height: 72px; | |
| border-radius: 6px; | |
| border: 2px solid #7a5fc0; | |
| margin: 0 -10px; | |
| background: repeating-linear-gradient(45deg, #2a1745 0, #2a1745 7px, #371d5c 7px, #371d5c 14px); | |
| box-shadow: 0 6px 14px rgba(0, 0, 0, 0.4); | |
| transform: rotate(3deg); | |
| } | |
| .enemy-card-back:nth-child(odd) { transform: rotate(-3deg); } | |
| .battlefield { | |
| position: relative; | |
| min-height: 118px; | |
| margin: 8px 36px; | |
| border-radius: 14px; | |
| background: rgba(10, 6, 30, 0.35); | |
| box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.35); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| padding: 6px 150px 6px 16px; | |
| } | |
| .round-banner { | |
| position: absolute; | |
| left: 16px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-weight: 800; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: #cfc3f2; | |
| font-size: 13px; | |
| } | |
| .end-turn { | |
| position: absolute; | |
| right: 14px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| padding: 13px 24px; | |
| border-radius: 26px; | |
| border: 3px solid #f6dd9a; | |
| background: linear-gradient(180deg, #f2c24d, #a96f17); | |
| color: #3a2403; | |
| font-weight: 900; | |
| letter-spacing: 0.06em; | |
| cursor: pointer; | |
| box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); | |
| } | |
| .end-turn:hover { filter: brightness(1.12); } | |
| .end-turn.pulse { animation: end-pulse 1.2s ease-in-out infinite; } | |
| @keyframes end-pulse { | |
| 0%, 100% { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); } | |
| 50% { box-shadow: 0 0 26px rgba(246, 221, 154, 0.9), 0 6px 18px rgba(0, 0, 0, 0.45); } | |
| } | |
| .board.quake { animation: board-quake 0.5s ease; } | |
| @keyframes board-quake { | |
| 0%, 100% { transform: translate(0, 0); } | |
| 20% { transform: translate(-6px, 3px); } | |
| 40% { transform: translate(5px, -4px); } | |
| 60% { transform: translate(-4px, 2px); } | |
| 80% { transform: translate(3px, -2px); } | |
| } | |
| .pending-row { display: flex; gap: 8px; min-height: 34px; justify-content: center; } | |
| .showcase { display: flex; gap: 18px; justify-content: center; align-items: flex-start; min-height: 0; perspective: 900px; } | |
| .play-slot { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .play-slot.fresh { animation: play-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) backwards; } | |
| .play-label { | |
| font-size: 10px; | |
| font-weight: 800; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| padding: 1px 10px; | |
| border-radius: 10px; | |
| } | |
| .play-label.you { color: #7df5e2; background: rgba(20, 90, 80, 0.45); border: 1px solid #2aa896; } | |
| .play-label.boss { color: #f0b8ff; background: rgba(110, 30, 130, 0.45); border: 1px solid #b14ad0; } | |
| .played-card { width: 132px; } | |
| .played-card.player-play { box-shadow: 0 0 22px rgba(56, 232, 210, 0.6); } | |
| .played-card.enemy-play { box-shadow: 0 0 22px rgba(232, 91, 255, 0.6); border-color: #b14ad0; } | |
| @keyframes play-in { | |
| from { transform: translateY(120px) rotateY(90deg) scale(0.4); opacity: 0; } | |
| 60% { transform: translateY(-6px) rotateY(0deg) scale(1.12); opacity: 1; } | |
| to { transform: none; opacity: 1; } | |
| } | |
| .token { display: flex; flex-direction: column; align-items: center; padding: 2px 10px; border-radius: 10px; font-size: 11px; border: 2px solid; line-height: 1.1; } | |
| .token b { font-size: 16px; } | |
| .token.bomb { border-color: #e0524d; background: rgba(120, 20, 10, 0.5); color: #ffb9a8; } | |
| .token.burn { border-color: #f2913d; background: rgba(140, 70, 10, 0.45); color: #ffd9a8; } | |
| .mana-bar { display: flex; align-items: center; gap: 6px; } | |
| .mana { width: 19px; height: 19px; transform: rotate(45deg); border-radius: 5px; background: #1d2c3c; border: 2px solid #2f4a66; } | |
| .mana.filled { | |
| background: radial-gradient(circle at 35% 30%, #7fd4ff, #1668c9); | |
| border-color: #bfe9ff; | |
| box-shadow: 0 0 8px rgba(80, 170, 255, 0.8); | |
| } | |
| .mana-count { margin-left: 8px; font-weight: 800; color: #bfe9ff; } | |
| .hand-fan { display: flex; justify-content: center; align-items: flex-end; padding: 26px 0 6px; min-height: 240px; } | |
| .hand-card { | |
| width: 150px; | |
| margin: 0 -16px; | |
| transform: rotate(var(--rot)) translateY(var(--ty)); | |
| transform-origin: 50% 130%; | |
| transition: transform 0.15s ease, box-shadow 0.15s ease; | |
| cursor: pointer; | |
| box-shadow: 0 0 14px rgba(56, 232, 210, 0.55); | |
| border-color: #45e8c8; | |
| } | |
| .hand-card:hover { | |
| transform: rotate(0deg) translateY(-48px) scale(1.45); | |
| z-index: 99 !important; | |
| box-shadow: 0 0 24px rgba(56, 232, 210, 0.5), 0 24px 50px rgba(0, 0, 0, 0.6); | |
| } | |
| .hand-card.unplayable { filter: grayscale(0.8) brightness(0.7); cursor: default; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); border-color: #4a3f6e; } | |
| .hand-card.unplayable:hover { box-shadow: 0 24px 50px rgba(0, 0, 0, 0.6); } | |
| .hand-card.launching { | |
| transform: translateY(-170px) scale(1.08) rotate(0deg) !important; | |
| opacity: 0; | |
| transition: transform 0.3s ease-in, opacity 0.3s ease-in; | |
| pointer-events: none; | |
| } | |
| .round-splash { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 150; | |
| pointer-events: none; | |
| border-radius: 18px; | |
| animation: splash-bg 1.9s ease forwards; | |
| } | |
| @keyframes splash-bg { | |
| 0% { background: rgba(6, 3, 18, 0); } | |
| 18% { background: rgba(6, 3, 18, 0.78); } | |
| 72% { background: rgba(6, 3, 18, 0.78); } | |
| 100% { background: rgba(6, 3, 18, 0); } | |
| } | |
| .splash-round { | |
| font-size: 50px; | |
| font-weight: 900; | |
| letter-spacing: 0.3em; | |
| color: #ffd35c; | |
| text-shadow: 0 0 34px rgba(255, 211, 92, 0.9), 0 4px 8px #000; | |
| animation: splash 1.9s ease forwards; | |
| } | |
| .splash-initiative { | |
| font-size: 24px; | |
| font-weight: 900; | |
| letter-spacing: 0.26em; | |
| animation: splash 1.65s ease 0.25s both; | |
| } | |
| .splash-initiative.you { color: #38e8d2; text-shadow: 0 0 24px rgba(56, 232, 210, 0.85); } | |
| .splash-initiative.boss { color: #f06bff; text-shadow: 0 0 24px rgba(232, 91, 255, 0.85); } | |
| @keyframes splash { | |
| 0% { opacity: 0; transform: scale(2.2); } | |
| 25% { opacity: 1; transform: scale(1); } | |
| 70% { opacity: 1; } | |
| 100% { opacity: 0; transform: scale(0.92); } | |
| } | |
| .boss-thinking { | |
| position: absolute; | |
| left: 50%; | |
| bottom: -6px; | |
| transform: translateX(-50%); | |
| z-index: 60; | |
| padding: 5px 16px; | |
| border-radius: 14px; | |
| background: rgba(20, 10, 36, 0.94); | |
| border: 1px solid #b14ad0; | |
| color: #f0c4ff; | |
| font-weight: 700; | |
| font-size: 12px; | |
| letter-spacing: 0.08em; | |
| white-space: nowrap; | |
| animation: think-pulse 1.1s ease-in-out infinite; | |
| } | |
| @keyframes think-pulse { | |
| 0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); } | |
| 50% { opacity: 1; transform: translateX(-50%) scale(1.06); } | |
| } | |
| .board.thinking .hero.enemy .hero-face { box-shadow: 0 0 28px rgba(232, 91, 255, 0.6); } | |
| .winner-banner { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(8, 5, 22, 0.78); | |
| z-index: 200; | |
| border-radius: 18px; | |
| } | |
| .winner-banner h1 { font-size: 64px; letter-spacing: 0.2em; margin: 0; animation: banner-in 0.7s cubic-bezier(0.2, 1.6, 0.4, 1) backwards; } | |
| @keyframes banner-in { | |
| from { transform: scale(2.6); opacity: 0; letter-spacing: 0.6em; } | |
| to { transform: scale(1); opacity: 1; letter-spacing: 0.2em; } | |
| } | |
| .winner-banner.victory h1 { color: #ffd98c; text-shadow: 0 0 30px rgba(255, 200, 90, 0.7); } | |
| .winner-banner.defeat h1 { color: #ff7a6a; text-shadow: 0 0 30px rgba(255, 70, 40, 0.6); } | |
| .winner-banner.draw h1 { color: #b9a8e0; } | |
| .new-run { | |
| padding: 12px 30px; | |
| border-radius: 24px; | |
| border: 3px solid #f6dd9a; | |
| background: linear-gradient(180deg, #f2c24d, #a96f17); | |
| font-weight: 900; | |
| cursor: pointer; | |
| color: #3a2403; | |
| } | |
| .game-over .hand-fan, .game-over .end-turn { pointer-events: none; } | |
| .log-panel { background: rgba(18, 14, 38, 0.85) !important; border: 2px solid #5b4a8f; border-radius: 14px; padding: 8px; } | |
| .log-scroll { | |
| display: flex; | |
| flex-direction: column-reverse; | |
| gap: 4px; | |
| max-height: 600px; | |
| overflow-y: auto; | |
| font-size: 11.5px; | |
| color: #d9d0f0; | |
| } | |
| .log-line { padding: 4px 8px; border-left: 3px solid transparent; border-radius: 4px; line-height: 1.35; } | |
| .log-line b { color: #ffd35c; font-size: 12.5px; } | |
| .log-owner { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.7; } | |
| .log-rules { display: block; font-size: 10.5px; opacity: 0.85; } | |
| .log-play.log-you { border-left-color: #38e8d2; background: rgba(30, 120, 110, 0.22); } | |
| .log-play.log-boss { border-left-color: #e85bff; background: rgba(120, 40, 140, 0.22); } | |
| .log-round { | |
| text-align: center; | |
| color: #ffd35c; | |
| font-weight: 800; | |
| letter-spacing: 0.06em; | |
| margin-top: 8px; | |
| border-top: 1px solid rgba(255, 211, 92, 0.35); | |
| padding-top: 6px; | |
| } | |
| .log-winner { text-align: center; color: #ffd35c; font-weight: 900; font-size: 13px; border: 1px solid #ffd35c; background: rgba(255, 211, 92, 0.14); } | |
| .log-muted { opacity: 0.55; font-style: italic; } | |
| .log-draft { color: #aaf08a; } | |
| .log-scroll > .log-line:first-child { box-shadow: inset 0 0 0 1px rgba(255, 211, 92, 0.35); } | |
| """ | |
| CSS = CSS.replace("__INN_BG__", INN_BG).replace("__WOOD_BG__", WOOD_BG) | |
| # Build all Gradio components for the Tabras app. | |
| def build_app() -> gr.Blocks: | |
| with gr.Blocks(title="Tabras") as app: | |
| state = gr.State(None) | |
| screen_state = gr.State("title") | |
| with gr.Group(visible=True, elem_id="title-screen") as title_group: | |
| gr.HTML( | |
| "<div class='tabras-title'><h1>Tabras</h1>" | |
| "<div class='tabras-sub'>" | |
| "<div class='subline'>You're a spellcaster fighting an evil wizard.</div>" | |
| "<div class='subline'>Draft your spellbook by selecting nine cards that are authored by MiniCPM and drawn by SDXL-Turbo.</div>" | |
| "<div class='subline'>Are you powerful enough to destroy the Nemotron-enhanced wizard?</div>" | |
| "</div></div>" | |
| ) | |
| play_now = gr.Button("Play Now", variant="primary", elem_id="play-now-btn") | |
| with gr.Group(visible=False, elem_id="name-screen") as name_group: | |
| with gr.Column(elem_classes=["setup-panel"]): | |
| gr.HTML("<div class='step-kicker'>Step 1 of 3</div><h2>Name your challenger</h2>") | |
| name = gr.Textbox(label="Name", value="Vishnu") | |
| name_next = gr.Button("Continue", variant="primary") | |
| with gr.Group(visible=False, elem_id="school-screen") as school_group: | |
| with gr.Column(elem_classes=["setup-panel"]): | |
| gr.HTML("<div class='step-kicker'>Step 3 of 3</div><h2>Choose your spell school</h2>") | |
| school_view = gr.HTML(school_selector_html("Dark Fantasy")) | |
| with gr.Group(visible=False, elem_id="background-screen") as background_group: | |
| with gr.Column(elem_classes=["setup-panel"]): | |
| gr.HTML("<div class='step-kicker'>Step 2 of 3</div><h2>Choose your background</h2>") | |
| gr.HTML(background_selector_html()) | |
| with gr.Group(visible=False, elem_id="reveal-screen") as reveal_group: | |
| reveal_view = gr.HTML() | |
| reveal_next = gr.Button("Continue", variant="primary", elem_id="reveal-next-btn") | |
| with gr.Group(visible=False, elem_id="rules-screen") as rules_group: | |
| rules_view = gr.HTML() | |
| continue_rules = gr.Button("Start Draft", variant="primary", elem_id="start-draft-btn") | |
| with gr.Group(visible=False, elem_id="draft-screen") as draft_group: | |
| draft_view = gr.HTML() | |
| with gr.Group(visible=False, elem_id="battle-screen") as battle_group: | |
| with gr.Row(): | |
| with gr.Column(scale=4): | |
| board_view = gr.HTML() | |
| with gr.Column(scale=1, min_width=240, elem_classes=["log-panel"]): | |
| gr.Markdown("### Battle log") | |
| log_view = gr.HTML() | |
| with gr.Row(elem_classes=["hidden-controls"]): | |
| hand_buttons = [gr.Button("", elem_id=f"hand-btn-{index}") for index in range(HAND_PANEL_COUNT)] | |
| draft_buttons = [gr.Button("", elem_id=f"draft-btn-{index}") for index in range(CARD_PANEL_COUNT)] | |
| background_buttons = [gr.Button("", elem_id=f"background-btn-{slug}") for slug in ("dark-fantasy", "cyberpunk", "anime")] | |
| school_buttons = [gr.Button("", elem_id=f"school-btn-{slug}") for slug in ("fire", "ice", "earth")] | |
| end_turn = gr.Button("", elem_id="end-turn-btn") | |
| restart = gr.Button("", elem_id="restart-btn") | |
| world_state = gr.State("Dark Fantasy") | |
| outputs = [ | |
| state, | |
| screen_state, | |
| title_group, | |
| name_group, | |
| school_group, | |
| background_group, | |
| reveal_group, | |
| rules_group, | |
| draft_group, | |
| battle_group, | |
| reveal_view, | |
| rules_view, | |
| draft_view, | |
| board_view, | |
| log_view, | |
| school_view, | |
| world_state, | |
| ] | |
| play_now.click(show_name, outputs=outputs) | |
| name_next.click(show_background, outputs=outputs) | |
| for value, button in zip(("Dark Fantasy", "Cyberpunk", "Anime"), background_buttons): | |
| button.click(value_handler(choose_background, value), outputs=outputs) | |
| for value, button in zip(("fire", "ice", "earth"), school_buttons): | |
| button.click(value_handler(choose_school, value), inputs=[name, world_state], outputs=outputs) | |
| reveal_next.click(show_rules, inputs=[state], outputs=outputs) | |
| continue_rules.click(show_draft_from_rules, inputs=[state], outputs=outputs) | |
| for index, button in enumerate(draft_buttons): | |
| button.click(indexed_handler(draft_pick, index), inputs=[state], outputs=outputs) | |
| for index, button in enumerate(hand_buttons): | |
| button.click(indexed_handler(play_card, index), inputs=[state], outputs=outputs) | |
| end_turn.click(end_player_turn, inputs=[state], outputs=outputs) | |
| restart.click(show_name, outputs=outputs) | |
| gr.Timer(0.8).tick(refresh_screen, inputs=[state, screen_state, world_state], outputs=outputs) | |
| return app | |
| # Bind one index into a streaming handler as a true generator function. | |
| def indexed_handler(handler, index: int): | |
| def stream(run_state): | |
| yield from handler(run_state, index) | |
| return stream | |
| # Bind one string value into a handler. | |
| def value_handler(handler, value: str): | |
| def call(*args): | |
| return handler(value, *args) | |
| return call | |
| # Show the name prompt. | |
| def show_name() -> list[object]: | |
| warm_models() | |
| return render(None, "name") | |
| _warmed = False | |
| # Preload the heavy models on a background thread the moment a run begins, so the | |
| # GPU warms during setup navigation (name/world/school/rules) instead of stalling | |
| # the first draft pack and first art. Cached clients make this a one-time cost. | |
| def warm_models() -> None: | |
| global _warmed | |
| if _warmed: | |
| return | |
| _warmed = True | |
| def _load() -> None: | |
| try: | |
| card_client_from_env() # loads + caches the card model | |
| art = art_client_from_env() | |
| if art is not None: | |
| art.create_art("a single torch flame on a dark stone wall") # forces the art pipe to load | |
| except Exception: | |
| pass | |
| threading.Thread(target=_load, daemon=True).start() | |
| # Show the school prompt. | |
| def show_school() -> list[object]: | |
| return render(None, "school") | |
| # Show the background prompt. | |
| def show_background() -> list[object]: | |
| return render(None, "background") | |
| # Store one background choice and advance to school selection. | |
| def choose_background(world: str) -> list[object]: | |
| return render(None, "school", world) | |
| # Start rules after one school selector is clicked. | |
| def choose_school(school: str, name: str, world: str) -> list[object]: | |
| return start_rules(name, school, world) | |
| # Reveal the boss, then start deck generation in the background. The reveal + | |
| # rules screens give the first pack time to forge before the draft. | |
| def start_rules(name: str, school: str, world: str) -> list[object]: | |
| client = card_client_from_env() | |
| art_client = art_client_from_env() | |
| run_state = new_run_shell(name, world, school_as_literal(school), seed=Random().getrandbits(32)) | |
| run_state = queue_next_pack(run_state, client, art_client) | |
| return render(run_state, "reveal") | |
| # Move from the boss reveal to the rules screen. | |
| def show_rules(run_state: RunState | None) -> list[object]: | |
| if run_state is None: | |
| return render(None, "name") | |
| return render(refresh_art(run_state), "rules") | |
| # Move from rules to draft, showing deck loading if the first pack is not ready. | |
| def show_draft_from_rules(run_state: RunState | None) -> list[object]: | |
| if run_state is None: | |
| return render(None, "name") | |
| run_state = collect_ready_pack(refresh_art(run_state), card_client_from_env(), art_client_from_env()) | |
| if run_state is not None and not run_state.current_pack and run_state.duel is None: | |
| run_state = replace(run_state, loading="Loading your deck") | |
| return render(run_state, "draft") | |
| # Choose one draft card, streaming the paced battle opening on the final pick. | |
| def draft_pick(run_state: RunState | None, index: int): | |
| if run_state is None: | |
| yield render(None, "name") | |
| return | |
| client = card_client_from_env() | |
| art_client = art_client_from_env() | |
| yield from paced_frames(choose_draft_card_loading_steps(run_state, index, client, art_client)) | |
| # Play one hand card by index, streaming the paced boss response. | |
| def play_card(run_state: RunState | None, index: int): | |
| if run_state is None: | |
| yield render(None, "name") | |
| return | |
| yield from paced_frames(play_hand_card_steps(run_state, index)) | |
| # End the player turn, streaming the paced boss response. | |
| def end_player_turn(run_state: RunState | None): | |
| if run_state is None: | |
| yield render(None, "name") | |
| return | |
| yield from paced_frames(pass_turn_steps(run_state)) | |
| # Refresh visible generated art without advancing the game. | |
| def refresh_screen(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]: | |
| if screen not in {"title", "name", "school", "background", "reveal", "rules", "draft", "battle"}: | |
| screen = "title" | |
| if screen in {"reveal", "rules", "draft"}: | |
| client, art_client = card_client_from_env(), art_client_from_env() | |
| run_state = collect_ready_pack(refresh_art(run_state), client, art_client) | |
| run_state = collect_ready_battle(run_state, client, art_client) | |
| if run_state is not None and run_state.duel is not None: | |
| screen = "battle" | |
| return render(refresh_art(run_state), screen, world) | |
| # Yield rendered frames, letting each dramatic beat linger on screen. | |
| def paced_frames(steps): | |
| previous = None | |
| for state in steps: | |
| if previous is not None: | |
| time.sleep(frame_delay(previous)) | |
| yield render(state, "battle" if state.duel else "draft") | |
| previous = state | |
| # Return how long one frame should stay on screen before the next. | |
| def frame_delay(state: RunState) -> float: | |
| if state.round_flash: | |
| return 1.4 | |
| if state.boss_thinking: | |
| return 0.35 | |
| if state.pack_fading >= 0: | |
| return 0.45 | |
| return 0.45 | |
| # Render all app outputs. | |
| def render(run_state: RunState | None, screen: str, world: str = "Dark Fantasy") -> list[object]: | |
| run_state = refresh_art(run_state) | |
| world = run_state.world if run_state is not None else world | |
| return [ | |
| run_state, | |
| screen, | |
| gr.update(visible=screen == "title"), | |
| gr.update(visible=screen == "name"), | |
| gr.update(visible=screen == "school"), | |
| gr.update(visible=screen == "background"), | |
| gr.update(visible=screen == "reveal"), | |
| gr.update(visible=screen == "rules"), | |
| gr.update(visible=screen == "draft"), | |
| gr.update(visible=screen == "battle"), | |
| reveal_html(run_state), | |
| rules_html(run_state), | |
| draft_screen_html(run_state), | |
| board_html(run_state), | |
| log_html(run_state), | |
| school_selector_html(world), | |
| world, | |
| ] | |
| # Return HTML for image-backed background choices. | |
| def background_selector_html() -> str: | |
| cards = ( | |
| selector_card("background-btn-dark-fantasy", "Dark Fantasy", "You are a mage in a forlorn, lost, and dark world.", "darkFantasy.png", "#25152d", "#5f315f"), | |
| selector_card("background-btn-cyberpunk", "Cyberpunk", "You are a spellrunner in a neon city where old magic haunts new machines.", "cyberpunk.png", "#102438", "#2dd7d0"), | |
| selector_card("background-btn-anime", "Anime", "You are a bright prodigy in a dramatic world of rival schools and impossible magic.", "anime.png", "#1f2452", "#ff8fd8"), | |
| ) | |
| return "<div class='selector-grid'>" + "".join(cards) + "</div>" | |
| # Return HTML for image-backed school choices. | |
| def school_selector_html(world: str = "Dark Fantasy") -> str: | |
| cards = tuple(selector_card(*card) for card in school_selector_cards(world)) | |
| return "<div class='selector-grid'>" + "".join(cards) + "</div>" | |
| # Return world-specific school selector card specs. | |
| def school_selector_cards(world: str) -> tuple[tuple[str, str, str, str, str, str], ...]: | |
| slug = world_slug(world) | |
| copy = school_selector_copy(slug) | |
| return ( | |
| ("school-btn-fire", "Fire", copy["fire"], school_asset_name(slug, "fire"), "#351018", "#f06a2a"), | |
| ("school-btn-ice", "Ice", copy["ice"], school_asset_name(slug, "ice"), "#102040", "#7ed9ff"), | |
| ("school-btn-earth", "Earth", copy["earth"], school_asset_name(slug, "earth"), "#1d2d18", "#a5d46a"), | |
| ) | |
| # Return hover copy for school choices in one world. | |
| def school_selector_copy(slug: str) -> dict[str, str]: | |
| if slug == "cyberpunk": | |
| return { | |
| "fire": "Burn through the neon grid with volatile heat, overclocked rituals, and delayed detonations.", | |
| "ice": "Freeze signal, tempo, and nerve; win through clean timing and exposed weaknesses.", | |
| "earth": "Raise concrete, steel, and old stone to bank pressure before the city breaks.", | |
| } | |
| if slug == "anime": | |
| return { | |
| "fire": "Fight like a rival prodigy: explosive pressure, bold finishers, and impossible sparks.", | |
| "ice": "Move first, punish openings, and turn one perfect tempo beat into a burst.", | |
| "earth": "Stand your ground, gather force, and answer with a dramatic shield-charged strike.", | |
| } | |
| return { | |
| "fire": "Call ruinous flame in a lost kingdom: burn clocks, bombs, and violent finishers.", | |
| "ice": "Bind the dark with frost, tempo, and precise strikes through brittle openings.", | |
| "earth": "Wear the grave-stone crown: absorb the blow, bank force, and bury the boss.", | |
| } | |
| # Return a stable asset slug for one world label. | |
| def world_slug(world: str) -> str: | |
| return world.lower().replace(" ", "-") | |
| # Return the bundled image filename for a world-specific school. | |
| def school_asset_name(slug: str, school: str) -> str: | |
| prefix = {"dark-fantasy": "darkFantasy", "cyberpunk": "cyberpunk", "anime": "anime"}[slug] | |
| suffix = {"fire": "Fire", "ice": "Ice", "earth": "Earth"}[school] | |
| if slug == "cyberpunk" and school == "ice": | |
| return "cyberpunkice.png" | |
| return f"{prefix}{suffix}.png" | |
| # Return one clickable visual selector card. | |
| def selector_card(button_id: str, label: str, copy: str, image: str, dark: str, accent: str) -> str: | |
| uri = optional_asset_data_uri(image) | |
| image_tag = f"<img class='selector-image' src='{uri}' alt=''>" if uri else "" | |
| fallback = f"radial-gradient(circle at 50% 30%, {accent}, transparent 34%), linear-gradient(135deg, {dark}, #080512 82%)" | |
| return ( | |
| f"<div class='selector-panel' onclick=\"tabrasClick('{button_id}')\" " | |
| f"style='--selector-fallback:{fallback};'>" | |
| f"{image_tag}" | |
| f"<div class='selector-label'>{label}</div>" | |
| f"<div class='selector-copy'>{copy}</div>" | |
| "</div>" | |
| ) | |
| # Return the dedicated rules screen while deck generation runs. | |
| # Villain name + in-character quote per world, shown on the boss reveal screen. | |
| VILLAINS: dict[str, tuple[str, str]] = { | |
| "dark fantasy": ("The Hollow Warden", "“The keep has not opened its gates in an age. It will not open for you.”"), | |
| "cyberpunk": ("Specter-9", "“Your magic is a legacy format. I am the patch that deprecates it.”"), | |
| "anime": ("The Necrolich", "“I have waited aeons to rule this world. You will not stop me.”"), | |
| } | |
| # Return the dramatic boss reveal: full art, villain name, and a quote. | |
| def reveal_html(run_state: RunState | None) -> str: | |
| if run_state is None: | |
| return "" | |
| world = run_state.world | |
| villain_name, quote = VILLAINS.get(world.strip().lower(), VILLAINS["dark fantasy"]) | |
| splash = boss_splash_uri(world) | |
| art = ( | |
| f"<div class='reveal-art' style=\"background-image:url('{splash}')\"></div>" | |
| if splash | |
| else "<div class='reveal-art reveal-art-empty'></div>" | |
| ) | |
| return ( | |
| "<div class='reveal-screen'>" | |
| "<div class='reveal-kicker'>Your Enemy</div>" | |
| f"{art}" | |
| f"<div class='reveal-name'>{escape_html(villain_name)}</div>" | |
| f"<div class='reveal-quote'>{escape_html(quote)}</div>" | |
| "</div>" | |
| ) | |
| def rules_html(run_state: RunState | None) -> str: | |
| if run_state is None: | |
| return "" | |
| return ( | |
| "<div class='rules-screen'><div class='rules-card'>" | |
| "<h1>The Rules</h1>" | |
| "<div class='rules-grid'>" | |
| "<div class='rule-tile'><b>Each round</b><span>A coin flip decides who acts first — knowing the order is a weapon.</span></div>" | |
| "<div class='rule-tile'><b>Energy</b><span>Start at 1, ramp to 5. It refills every round and never carries over.</span></div>" | |
| "<div class='rule-tile'><b>Block</b><span>Stops incoming damage until your next turn, then fades.</span></div>" | |
| "<div class='rule-tile'><b>Ward</b><span>A persistent shield that absorbs one decisive hit, whenever it lands.</span></div>" | |
| "<div class='rule-tile'><b>Draft</b><span>Pick one card from each pack to build a 15-card deck.</span></div>" | |
| "<div class='rule-tile'><b>Win</b><span>Reduce the boss to 0 HP across the duel.</span></div>" | |
| "</div>" | |
| "</div></div>" | |
| ) | |
| # Return a typed school value. | |
| def school_as_literal(value: str) -> School: | |
| if value in {"fire", "ice", "earth"}: | |
| return value # type: ignore[return-value] | |
| return "fire" | |
| if __name__ == "__main__": | |
| configure_mode() # MODE=LOCAL by default: run the models on your own hardware | |
| build_app().launch(server_name="127.0.0.1", server_port=7860, css=CSS, head=HEAD) | |