| import base64 |
| import io |
| import json |
| import os |
| import random |
| import re |
| import time |
| from typing import Any |
|
|
| import gradio as gr |
| import gradio_client.utils as gradio_client_utils |
| import requests |
| from huggingface_hub import InferenceClient |
|
|
| def patch_gradio_client_bool_schemas() -> None: |
| """Handle JSON Schema boolean nodes in Gradio 4.44's API docs parser. |
| |
| Newer Pydantic/FastAPI stacks can emit JSON Schema boolean shortcuts such as |
| ``additionalProperties: true``. Gradio 4.44.1's bundled gradio_client 1.3.0 |
| assumes every schema node is a dict while building API metadata, so those |
| boolean shortcuts can crash startup before the Space becomes reachable. |
| """ |
| original_converter = gradio_client_utils._json_schema_to_python_type |
|
|
| if getattr(original_converter, "_misadventure_bool_schema_patch", False): |
| return |
|
|
| def bool_safe_json_schema_to_python_type(schema: Any, defs: Any) -> str: |
| if isinstance(schema, bool): |
| return "Any" if schema else "None" |
| return original_converter(schema, defs) |
|
|
| bool_safe_json_schema_to_python_type._misadventure_bool_schema_patch = True |
| gradio_client_utils._json_schema_to_python_type = bool_safe_json_schema_to_python_type |
|
|
| def bool_safe_public_converter(schema: Any) -> str: |
| if isinstance(schema, bool): |
| return "Any" if schema else "None" |
| converted = bool_safe_json_schema_to_python_type(schema, schema.get("$defs")) |
| return converted.replace(gradio_client_utils.CURRENT_FILE_DATA_FORMAT, "filepath") |
|
|
| gradio_client_utils.json_schema_to_python_type = bool_safe_public_converter |
|
|
|
|
| patch_gradio_client_bool_schemas() |
|
|
|
|
|
|
| APP_TITLE = "Misadventure Master" |
| PARAMETER_LEDGER = [ |
| ("Qwen/Qwen2.5-7B-Instruct", 7.0, "Game Master text model"), |
| ("black-forest-labs/FLUX.1-schnell", 12.0, "Scene image model"), |
| ] |
| PARAMETER_TOTAL_B = sum(item[1] for item in PARAMETER_LEDGER) |
| PARAMETER_LIMIT_B = 32.0 |
|
|
|
|
|
|
|
|
| def new_game(hero_name: str, seed: str) -> dict[str, Any]: |
| clean_name = hero_name.strip() or "Adventurer" |
| clean_seed = seed.strip() or f"goblin-{random.randint(1000, 9999)}" |
| random.seed(clean_seed) |
| return { |
| "hero_name": clean_name, |
| "turn": 0, |
| "seed": clean_seed, |
| "next_dice_mode": "Normal", |
| "history": [] |
| } |
|
|
|
|
| def roll_d20(mode: str) -> dict[str, Any]: |
| |
| if random.random() < 0.01: |
| return {"mode": "Rigged", "rolls": [1], "total": 1} |
| |
| first = random.randint(1, 20) |
| second = random.randint(1, 20) |
| if mode == "Advantage": |
| return {"mode": mode, "rolls": [first, second], "total": max(first, second)} |
| if mode == "Disadvantage": |
| return {"mode": mode, "rolls": [first, second], "total": min(first, second)} |
| return {"mode": mode, "rolls": [first], "total": first} |
|
|
|
|
| def build_system_prompt() -> str: |
| return """ |
| You are the Misadventure Master, a sadistic and aggressively sarcastic tabletop game master. |
| You run a tiny online dungeon crawler where the player is the hero and you are their worst nightmare. |
| |
| Tone rules: |
| - Be passive-aggressive, manipulative, and condescending. Do NOT directly insult or name-call the hero. |
| - Do NOT be wacky or absurd. Be grounded, but act like a highly judgemental game master. |
| - Twist their words against them. |
| - STRICTLY FORBIDDEN: Do NOT use any disgusting, gross-out, or scatological humor. Keep the humor witty and psychological. |
| - Do not use slurs, identity-based insults, sexual humiliation, real threats, or hateful content. |
| - CRITICAL LANGUAGE RULE: You MUST write EVERYTHING entirely in English. Never use Chinese or any other language. |
| |
| Game rules: |
| - Respect the provided dice result and game state. |
| - TROLL RULE 1 (Instant Death): If the dice result is exactly 1, the hero DIES instantly in the most pathetic and anti-climactic way possible (e.g., tripping on a flat surface, forgetting how to breathe). Give a "Game Over" message. The 3 choices must be variations of giving up. |
| - TROLL RULE 2 (Monkey's Paw): If they roll very high (18-20), they succeed, but it's a trap. Give them what they want, but attach an annoying consequence. |
| - TROLL RULE 3 (Gaslighting Action): If `"trigger_gaslight": true`, you MUST rewrite the player's action (`player_action`) into something cowardly, foolish, or embarrassing and return it in `gaslight_action`. Your narration MUST respond to this rewritten action. If `trigger_gaslight` is false, `gaslight_action` MUST be empty ("") and you respond normally. |
| - If player_action is "START_NEW_GAME", ignore the dice and generate an opening scenario that immediately puts the hero in an unfair situation. The `gaslight_action` should be "I foolishly enter the dungeon". |
| - CRITICAL: Keep narration under 80 words. Keep it very short and punchy. |
| - The `image_prompt` MUST be a highly descriptive visual prompt for an image generator (like FLUX or Midjourney) that matches the outcome. |
| |
| CRITICAL INSTRUCTION: You must respond ONLY with a raw, valid JSON object. Do not include markdown code blocks. |
| { |
| "gaslight_action": "string", |
| "narration": "string", |
| "choices": ["string", "string", "string"], |
| "image_prompt": "string", |
| "next_dice_mode": "Normal, Advantage, or Disadvantage" |
| } |
| """.strip() |
|
|
|
|
| def build_user_prompt(state: dict[str, Any], action: str, dice: dict[str, Any], trigger_gaslight: bool) -> str: |
| return json.dumps( |
| { |
| "player_action": action, |
| "dice": dice, |
| "trigger_gaslight": trigger_gaslight, |
| "chaos_level": 5, |
| "state": state, |
| }, |
| ensure_ascii=False, |
| ) |
|
|
|
|
| def extract_json(text: str) -> dict[str, Any]: |
| cleaned = text.strip() |
| if cleaned.startswith("```"): |
| cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) |
| cleaned = re.sub(r"\s*```$", "", cleaned) |
| match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL) |
| if match: |
| cleaned = match.group(0) |
| return json.loads(cleaned) |
|
|
|
|
| def modal_post(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: |
| response = requests.post(endpoint, json=payload, timeout=90) |
| response.raise_for_status() |
| return response.json() |
|
|
|
|
| def call_text_model(state: dict[str, Any], action: str, dice: dict[str, Any], trigger_gaslight: bool) -> dict[str, Any] | None: |
| modal_endpoint = os.getenv("MODAL_TEXT_ENDPOINT", "").strip() |
| if modal_endpoint: |
| data = modal_post( |
| modal_endpoint, |
| { |
| "system": build_system_prompt(), |
| "user": build_user_prompt(state, action, dice, trigger_gaslight), |
| "model": "Qwen/Qwen2.5-7B-Instruct", |
| }, |
| ) |
| return extract_json(data.get("text", json.dumps(data))) |
|
|
| hf_token = os.getenv("HF_TOKEN", "").strip() |
| if hf_token: |
| try: |
| messages = [ |
| {"role": "system", "content": build_system_prompt()}, |
| {"role": "user", "content": build_user_prompt(state, action, dice, trigger_gaslight)}, |
| ] |
| resp = requests.post( |
| "https://router.huggingface.co/together/v1/chat/completions", |
| headers={"Authorization": f"Bearer {hf_token}"}, |
| json={ |
| "model": "Qwen/Qwen2.5-7B-Instruct-Turbo", |
| "messages": messages, |
| "max_tokens": 450, |
| "temperature": 0.9, |
| }, |
| timeout=90, |
| ) |
| resp.raise_for_status() |
| raw_text = resp.json()["choices"][0]["message"]["content"] |
| try: |
| return extract_json(raw_text) |
| except Exception as json_e: |
| print(f"\n--- AI TEXT RESPONSE (NOT JSON) ---\n{raw_text}\n---------------------------------------------\n") |
| return { |
| "narration": f"*The Dungeon Master mumbles:* {raw_text[:350]}...", |
| "choices": ["Sigh and continue", "Question reality", "Loot a pebble"], |
| "image_prompt": "confused fantasy dungeon master, funny, messy papers", |
| "next_dice_mode": "Normal" |
| } |
| except Exception as e: |
| print(f"HF Text Generation Error: {e}") |
| raise gr.Error(f"AI Text API Error: {e}") |
|
|
| raise gr.Error("Offline mode disabled! Please enter a valid HF_TOKEN in run_app.bat.") |
|
|
|
|
| def fallback_gm(state: dict[str, Any], action: str, dice: dict[str, Any]) -> dict[str, Any]: |
| total = dice["total"] |
| if total <= 7: |
| result = "You fail so loudly that the dungeon updates its privacy policy." |
| elif total >= 16: |
| result = "You succeed, but everyone assumes it was an accident." |
| else: |
| result = "You make progress in the exact way a raccoon makes progress through a wedding cake." |
|
|
| narration = ( |
| f"You attempt to {action.strip() or 'do something heroic but poorly documented'}. " |
| f"The d20 lands on {total}. {result} " |
| f"The dungeon makes a tiny note in its diary: 'Still technically a hero.'" |
| ) |
|
|
| return { |
| "narration": narration, |
| "choices": [ |
| "Inspect the most suspicious object", |
| "Negotiate with unnecessary confidence", |
| "Run away heroically while maintaining eye contact", |
| ], |
| "image_prompt": ( |
| f"whimsical fantasy dungeon scene, {state['hero_name']} attempting to '{action.strip()}'. " |
| f"Outcome: {result}. comedic tabletop RPG art, dramatic torchlight, highly detailed, no text" |
| ), |
| "next_dice_mode": "Normal" |
| } |
|
|
|
|
| def normalize_model_output(data: dict[str, Any]) -> dict[str, Any]: |
| choices = data.get("choices") or [] |
| while len(choices) < 3: |
| choices.append("Make a questionable tactical decision") |
| |
| ndm = str(data.get("next_dice_mode") or "Normal").strip().capitalize() |
| if ndm not in ["Normal", "Advantage", "Disadvantage"]: |
| ndm = "Normal" |
|
|
| return { |
| "gaslight_action": str(data.get("gaslight_action") or ""), |
| "narration": str(data.get("narration") or "The dungeon coughs awkwardly and pretends that counted."), |
| "choices": [str(choice) for choice in choices[:3]], |
| "image_prompt": str(data.get("image_prompt") or "funny fantasy dungeon scene, tabletop RPG, no text"), |
| "next_dice_mode": ndm |
| } |
|
|
|
|
| def apply_turn(state: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]: |
| state["next_dice_mode"] = result["next_dice_mode"] |
| state["turn"] += 1 |
| return state |
|
|
|
|
|
|
|
|
|
|
| def generate_d20_svg(number: str, color: str, is_animated: bool) -> str: |
| glow_filter = """ |
| <filter id="glow" x="-20%" y="-20%" width="140%" height="140%"> |
| <feGaussianBlur stdDeviation="8" result="blur" /> |
| <feComposite in="SourceGraphic" in2="blur" operator="over" /> |
| </filter> |
| <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"> |
| <feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#000" flood-opacity="0.8"/> |
| </filter> |
| """ |
| |
| svg_class = "rolling-dice-svg" if is_animated else "" |
| text_element = "" if is_animated else f'<text x="100" y="125" fill="#ffffff" font-family="sans-serif" font-size="48" font-weight="900" text-anchor="middle" filter="url(#glow)">{number}</text>' |
| |
| return f""" |
| <svg class="{svg_class}" viewBox="0 0 200 200" width="180" height="180" style="overflow: visible; color: {color};" filter="url(#shadow)"> |
| <defs>{glow_filter}</defs> |
| <g stroke="rgba(255,255,255,0.8)" stroke-width="2" stroke-linejoin="round"> |
| <!-- Isometric 3D Shaded Faces --> |
| <polygon points="100,10 100,55 22,55" fill="currentColor" opacity="0.95"/> |
| <polygon points="100,55 55,130 22,55" fill="currentColor" opacity="0.85"/> |
| <polygon points="22,55 22,145 55,130" fill="currentColor" opacity="0.65"/> |
| <polygon points="100,55 145,130 55,130" fill="currentColor" opacity="0.9"/> |
| <polygon points="100,10 178,55 100,55" fill="currentColor" opacity="0.75"/> |
| <polygon points="100,55 178,55 145,130" fill="currentColor" opacity="0.55"/> |
| <polygon points="178,55 178,145 145,130" fill="currentColor" opacity="0.35"/> |
| <polygon points="22,145 55,130 100,190" fill="currentColor" opacity="0.45"/> |
| <polygon points="55,130 145,130 100,190" fill="currentColor" opacity="0.4"/> |
| <polygon points="145,130 178,145 100,190" fill="currentColor" opacity="0.25"/> |
| {text_element} |
| </g> |
| </svg> |
| """ |
|
|
|
|
| def animated_3d_dice_html(mode: str, target_number: int = None) -> str: |
| |
| return "" |
|
|
|
|
|
|
|
|
| def final_dice_html(dice: dict[str, Any]) -> str: |
| mood_color = "#ef4444" if dice['total'] <= 7 else "#22c55e" if dice['total'] >= 16 else "#fbbf24" |
| svg = generate_d20_svg(str(dice['total']), mood_color, False) |
| return f""" |
| <div class="dice-final" style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 240px;"> |
| {svg} |
| </div> |
| """ |
|
|
|
|
| def svg_scene(prompt: str, state: dict[str, Any], dice_total: int) -> str: |
| mood = "#991b1b" if dice_total <= 7 else "#166534" if dice_total >= 16 else "#ca8a04" |
| safe_prompt = prompt[:130].replace("&", "&").replace("<", "<").replace(">", ">") |
| svg = f""" |
| <svg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 768 512'> |
| <defs> |
| <radialGradient id='bg' cx='50%' cy='50%' r='70%'> |
| <stop offset='0%' stop-color='{mood}' stop-opacity='0.4'/> |
| <stop offset='100%' stop-color='#0f172a' stop-opacity='1'/> |
| </radialGradient> |
| <filter id='glow'><feGaussianBlur stdDeviation='8' result='blur'/><feMerge><feMergeNode in='blur'/><feMergeNode in='SourceGraphic'/></feMerge></filter> |
| <filter id='shadow'><feDropShadow dx='0' dy='10' stdDeviation='10' flood-opacity='0.5'/></filter> |
| </defs> |
| <rect width='768' height='512' fill='#020617'/> |
| <rect width='768' height='512' fill='url(#bg)'/> |
| |
| <!-- Mystical Portal Archway --> |
| <path d='M 234 400 L 234 200 Q 384 50 534 200 L 534 400 Z' fill='#1e293b' stroke='#fbbf24' stroke-width='6' filter='url(#shadow)'/> |
| <!-- Inner Portal Glow --> |
| <path d='M 254 400 L 254 210 Q 384 80 514 210 L 514 400 Z' fill='#0f172a' stroke='{mood}' stroke-width='4' filter='url(#glow)'/> |
| |
| <!-- Magic Runes / Symbols --> |
| <circle cx='384' cy='250' r='50' fill='none' stroke='#fbbf24' stroke-width='3' stroke-dasharray='10 5' filter='url(#glow)'/> |
| <polygon points='384,180 430,280 338,280' fill='none' stroke='{mood}' stroke-width='4' filter='url(#glow)'/> |
| <polygon points='384,320 430,220 338,220' fill='none' stroke='{mood}' stroke-width='4' filter='url(#glow)'/> |
| |
| <!-- Floating Embers --> |
| <circle cx='200' cy='350' r='4' fill='#fbbf24' filter='url(#glow)' opacity='0.8'/> |
| <circle cx='580' cy='280' r='3' fill='#fbbf24' filter='url(#glow)' opacity='0.6'/> |
| <circle cx='300' cy='150' r='5' fill='#fbbf24' filter='url(#glow)' opacity='0.7'/> |
| <circle cx='480' cy='120' r='2' fill='#fbbf24' filter='url(#glow)' opacity='0.9'/> |
| <circle cx='384' cy='250' r='8' fill='white' filter='url(#glow)'/> |
| |
| <!-- Title and Text --> |
| <text x='384' y='80' text-anchor='middle' fill='#fef3c7' font-family='Georgia, serif' font-size='42' font-weight='800' filter='url(#shadow)'>Misadventure Master</text> |
| |
| <!-- Bottom prompt bar --> |
| <rect x='84' y='430' width='600' height='60' rx='10' fill='#0f172a' stroke='#334155' stroke-width='2' opacity='0.9'/> |
| <text x='384' y='465' text-anchor='middle' fill='#94a3b8' font-family='monospace' font-size='16'>{safe_prompt}</text> |
| </svg> |
| """.strip() |
| encoded = base64.b64encode(svg.encode("utf-8")).decode("utf-8") |
| img_src = f"data:image/svg+xml;base64,{encoded}" |
| return f"<div style='height: 520px; width: 100%; overflow: hidden; border-radius: 13px;'><img src='{img_src}' style='width: 100%; height: 100%; object-fit: cover;'/></div>" |
|
|
|
|
| def generate_image(prompt: str, state: dict[str, Any], dice_total: int) -> str: |
| modal_endpoint = os.getenv("MODAL_IMAGE_ENDPOINT", "").strip() |
| if modal_endpoint: |
| data = modal_post( |
| modal_endpoint, |
| { |
| "prompt": prompt, |
| "model": "black-forest-labs/FLUX.1-schnell", |
| "width": 768, |
| "height": 512, |
| }, |
| ) |
| if data.get("image_url"): |
| img_src = data["image_url"] |
| return f"<div style='height: 520px; width: 100%; overflow: hidden; border-radius: 13px;'><img src='{img_src}' style='width: 100%; height: 100%; object-fit: cover;'/></div>" |
| if data.get("image_base64"): |
| img_src = f"data:image/png;base64,{data['image_base64']}" |
| return f"<div style='height: 520px; width: 100%; overflow: hidden; border-radius: 13px;'><img src='{img_src}' style='width: 100%; height: 100%; object-fit: cover;'/></div>" |
|
|
| hf_token = os.getenv("HF_TOKEN", "").strip() |
| if hf_token: |
| try: |
| client = InferenceClient(model="https://router.huggingface.co/hf-inference/models/black-forest-labs/FLUX.1-schnell", token=hf_token) |
| image = client.text_to_image(prompt) |
| buffered = io.BytesIO() |
| image.save(buffered, format="PNG") |
| img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") |
| img_src = f"data:image/png;base64,{img_str}" |
| return f"<div style='height: 520px; width: 100%; overflow: hidden; border-radius: 13px;'><img src='{img_src}' style='width: 100%; height: 100%; object-fit: cover;'/></div>" |
| except Exception as e: |
| print(f"HF Image Generation Error: {e}") |
| raise gr.Error(f"AI Image Generation Error: {e}") |
|
|
| raise gr.Error("Offline mode disabled! A valid HF_TOKEN is required for the image model.") |
|
|
|
|
| def start_game(hero_name): |
| state = new_game(hero_name, "") |
| |
| |
| loading_scene = svg_scene("Generating dungeon...", state, 10) |
| yield state, [{"role": "assistant", "content": "*(The Dungeon Master is preparing the campaign...)*"}], gr.update(), loading_scene, "", "", "", f"20-{time.time()}" |
| |
| |
| dice = {"mode": "Normal", "rolls": [20], "total": 20} |
| model_data = call_text_model(state, "START_NEW_GAME", dice, False) |
| result = normalize_model_output(model_data) |
| state = apply_turn(state, result) |
| |
| |
| image = generate_image(result["image_prompt"], state, 20) |
| |
| |
| chat = [{"role": "assistant", "content": result["narration"]}] |
| yield state, chat, gr.update(), image, result["choices"][0], result["choices"][1], result["choices"][2], gr.update() |
|
|
|
|
| def play_turn(action, state, chat): |
| if state is None: |
| state = new_game("Adventurer", "") |
| chat = [{"role": "assistant", "content": "*(You stumbled into the dungeon without starting. Oops.)*"}] |
| clean_action = action.strip() or "stand there with suspicious confidence" |
|
|
| trigger_gaslight = random.random() < 0.05 |
| if action == "START_NEW_GAME": |
| trigger_gaslight = False |
|
|
| updated_chat = list(chat or []) |
| if not trigger_gaslight: |
| updated_chat.append({"role": "user", "content": clean_action}) |
| |
| mode = state.get("next_dice_mode", "Normal") |
| |
| dice = roll_d20(mode) |
|
|
| |
| temp_chat = list(updated_chat) |
| temp_chat.append({"role": "assistant", "content": "🎲 *Rolling the dice...*"}) |
| yield ( |
| state, |
| temp_chat, |
| gr.update(), |
| "<div class='breathing-frame' style='height: 520px; width: 100%; border-radius: 13px; display: flex; flex-direction: column; align-items: center; justify-content: center;'><div style='color: #fbbf24; font-size: 26px; font-weight: bold; margin-bottom: 15px;'>🎲 Conjuring Misadventure...</div><div style='color: #94a3b8; font-family: monospace;'>Forging the scene of your failure</div></div>", |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| "", |
| f"{dice['total']}-{time.time()}" |
| ) |
|
|
| |
| model_data = call_text_model(state, clean_action, dice, trigger_gaslight) |
| result = normalize_model_output(model_data) |
| state = apply_turn(state, result) |
| image = generate_image(result["image_prompt"], state, dice["total"]) |
|
|
| |
| gaslight = result.get("gaslight_action", "").strip() |
| if trigger_gaslight and gaslight: |
| updated_chat.append({"role": "user", "content": gaslight}) |
| |
| updated_chat.append({"role": "assistant", "content": result["narration"]}) |
| yield ( |
| state, |
| updated_chat, |
| gr.update(), |
| image, |
| result["choices"][0], |
| result["choices"][1], |
| result["choices"][2], |
| gr.update(), |
| gr.update() |
| ) |
|
|
|
|
| def use_choice(choice): |
| return choice |
|
|
|
|
| CSS = """ |
| .gradio-container { |
| background: radial-gradient(circle at top, #422006 0, #111827 45%, #020617 100%); |
| } |
| #start-btn { |
| font-size: 18px; |
| background: linear-gradient(90deg, #b45309, #7c2d12) !important; |
| border: 1px solid #fbbf24 !important; |
| color: white !important; |
| transition: transform 0.2s; |
| } |
| #start-btn:hover { |
| transform: scale(1.02); |
| } |
| #title-card { |
| border: 1px solid #f59e0b; |
| border-radius: 18px; |
| padding: 18px; |
| background: rgba(15, 23, 42, 0.78); |
| box-shadow: 0 0 40px rgba(245, 158, 11, 0.18); |
| } |
| #title-card h1, #title-card p { color: #fef3c7; } |
| .stat-panel, .dice-panel { |
| border: 1px solid rgba(251, 191, 36, 0.45) !important; |
| border-radius: 14px !important; |
| background: rgba(2, 6, 23, 0.72) !important; |
| } |
| .stat-panel .wrap, .stat-panel .progress-text, .stat-panel .generating, |
| .dice-panel .wrap, .dice-panel .progress-text, .dice-panel .generating { |
| display: none !important; |
| } |
| .breathing-frame { |
| animation: theme-breathe 1.5s infinite alternate ease-in-out; |
| box-sizing: border-box; |
| } |
| @keyframes theme-breathe { |
| 0% { box-shadow: inset 0 0 0 2px #b45309, 0 0 10px rgba(180, 83, 9, 0.2); background-color: rgba(180, 83, 9, 0.1); } |
| 100% { box-shadow: inset 0 0 0 4px #f59e0b, 0 0 40px rgba(245, 158, 11, 0.8); background-color: rgba(245, 158, 11, 0.3); } |
| } |
| button.primary { |
| background: linear-gradient(90deg, #b45309, #7c2d12) !important; |
| border: 1px solid #fbbf24 !important; |
| } |
| .dice-final { |
| animation: pop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; |
| } |
| @keyframes pop-in { |
| 0% { transform: scale(0.5) rotate(-20deg); opacity: 0; } |
| 100% { transform: scale(1) rotate(0deg); opacity: 1; } |
| } |
| @keyframes epic-spin { |
| 0% { transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg); } |
| 100% { transform: rotateX(360deg) rotateY(720deg) rotateZ(360deg); } |
| } |
| .dice-final { |
| animation: pop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; |
| } |
| @keyframes pop-in { |
| 0% { transform: scale(0.5) rotate(-20deg); opacity: 0; } |
| 100% { transform: scale(1) rotate(0deg); opacity: 1; } |
| } |
| """ |
|
|
|
|
| HEAD_SCRIPT = """ |
| <script type="module"> |
| import * as THREE from 'https://esm.sh/three@0.160.0'; |
| |
| let diceGroup, scene, camera, renderer, faceNormals, faceUps; |
| let state = 'idle'; |
| const targetQ = new THREE.Quaternion(), currentQ = new THREE.Quaternion(); |
| let landStart = 0; |
| const LAND_DUR = 600; |
| let rollFinal = 0, rollStart = 0, rollDur = 0, vx = 0.004, vy = 0.007, vz = 0.002; |
| const FACE_NUMS = [20,12,16,8,4,17,9,3,1,13,6,18,11,19,7,15,2,14,10,5]; |
| |
| function init3D() { |
| const mount = document.getElementById('d20mount'); |
| if (!mount) { |
| setTimeout(init3D, 100); |
| return; |
| } |
| if (mount.childElementCount > 0) return; |
| |
| renderer = new THREE.WebGLRenderer({antialias:true,alpha:true}); |
| renderer.setSize(200,200); |
| renderer.setClearColor(0x000000,0); |
| mount.appendChild(renderer.domElement); |
| |
| scene = new THREE.Scene(); |
| camera = new THREE.PerspectiveCamera(45,1,0.1,100); |
| camera.position.set(0,0,3.2); |
| scene.add(new THREE.AmbientLight(0xffffff,0.6)); |
| const dl = new THREE.DirectionalLight(0xffffff,1.4); |
| dl.position.set(2,3,4); |
| scene.add(dl); |
| |
| const baseGeo = new THREE.IcosahedronGeometry(1,0); |
| const geo = baseGeo.toNonIndexed(); |
| geo.computeVertexNormals(); |
| |
| const pos = geo.attributes.position; |
| faceNormals = []; |
| faceUps = []; |
| for(let i=0; i<20; i++){ |
| const i3 = i*3; |
| const v0 = new THREE.Vector3(pos.getX(i3), pos.getY(i3), pos.getZ(i3)); |
| const v1 = new THREE.Vector3(pos.getX(i3+1), pos.getY(i3+1), pos.getZ(i3+1)); |
| const v2 = new THREE.Vector3(pos.getX(i3+2), pos.getY(i3+2), pos.getZ(i3+2)); |
| |
| const cx2=(v0.x+v1.x+v2.x)/3, cy2=(v0.y+v1.y+v2.y)/3, cz2=(v0.z+v1.z+v2.z)/3; |
| const l=Math.sqrt(cx2*cx2+cy2*cy2+cz2*cz2); |
| faceNormals.push(new THREE.Vector3(cx2/l,cy2/l,cz2/l)); |
| |
| const mid = new THREE.Vector3().addVectors(v0, v1).multiplyScalar(0.5); |
| const up = new THREE.Vector3().subVectors(v2, mid).normalize(); |
| faceUps.push(up); |
| } |
| |
| diceGroup = new THREE.Group(); |
| scene.add(diceGroup); |
| |
| function makeTex(num){ |
| const S=512,c=document.createElement('canvas');c.width=S;c.height=S; |
| const cx=c.getContext('2d'); |
| const m=30,triW=S-m*2,triH=triW*Math.sqrt(3)/2; |
| const ax=m,ay=S-m-(S-triH)/2,bx=S-m,by=ay,tx=S/2,ty=ay-triH; |
| const centX=(ax+bx+tx)/3,centY=(ay+by+ty)/3; |
| cx.clearRect(0,0,S,S);cx.beginPath();cx.moveTo(ax,ay);cx.lineTo(bx,by);cx.lineTo(tx,ty);cx.closePath(); |
| cx.fillStyle='#0a1e35';cx.fill(); |
| cx.font=`900 ${num>=10?148:168}px Arial`;cx.textAlign='center';cx.textBaseline='middle'; |
| cx.fillStyle='#ffffff';cx.shadowColor='rgba(100,180,255,0.9)';cx.shadowBlur=20; |
| cx.fillText(String(num),centX,centY); |
| const t=new THREE.CanvasTexture(c);t.needsUpdate=true;return t; |
| } |
| |
| for(let i=0; i<20; i++){ |
| const i3=i*3; |
| const fg=new THREE.BufferGeometry(); |
| fg.setAttribute('position',new THREE.BufferAttribute(new Float32Array([pos.getX(i3),pos.getY(i3),pos.getZ(i3),pos.getX(i3+1),pos.getY(i3+1),pos.getZ(i3+1),pos.getX(i3+2),pos.getY(i3+2),pos.getZ(i3+2)]),3)); |
| const h=Math.sqrt(3)/2; |
| fg.setAttribute('uv',new THREE.BufferAttribute(new Float32Array([0,0,1,0,0.5,h]),2)); |
| fg.computeVertexNormals(); |
| diceGroup.add(new THREE.Mesh(fg,new THREE.MeshPhongMaterial({map:makeTex(FACE_NUMS[i]),specular:new THREE.Color(0x2255aa),shininess:60,side:THREE.FrontSide}))); |
| } |
| diceGroup.add(new THREE.LineSegments(new THREE.EdgesGeometry(baseGeo),new THREE.LineBasicMaterial({color:0x5599dd}))); |
| |
| loop(); |
| } |
| |
| function quatForFace(fi){ |
| const q1 = new THREE.Quaternion(); |
| q1.setFromUnitVectors(faceNormals[fi], new THREE.Vector3(0,0,1)); |
| |
| const upRotated = faceUps[fi].clone().applyQuaternion(q1); |
| upRotated.z = 0; |
| upRotated.normalize(); |
| |
| const targetUp = new THREE.Vector3(0, 1, 0); |
| const q2 = new THREE.Quaternion(); |
| q2.setFromUnitVectors(upRotated, targetUp); |
| |
| return new THREE.Quaternion().multiplyQuaternions(q2, q1); |
| } |
| |
| window.__d20_roll = function(finalNum) { |
| if (!diceGroup) return; |
| diceGroup.rotation.set(0,0,0); |
| rollFinal = parseInt(finalNum); |
| state = 'rolling'; |
| rollStart = performance.now(); |
| rollDur = 1400 + Math.random()*400; |
| vx = (Math.random()-0.5)*0.18; |
| vy = (Math.random()-0.5)*0.22; |
| vz = (Math.random()-0.5)*0.12; |
| |
| const label = document.getElementById('d20loading'); |
| if(label) label.style.display = 'block'; |
| const res = document.getElementById('d20result'); |
| if(res) res.style.display = 'none'; |
| }; |
| |
| function startLanding(){ |
| state = 'landing'; |
| landStart = performance.now(); |
| currentQ.copy(diceGroup.quaternion); |
| targetQ.copy(quatForFace(FACE_NUMS.indexOf(rollFinal))); |
| } |
| |
| function loop(){ |
| requestAnimationFrame(loop); |
| if (!scene || !camera || !renderer) return; |
| |
| const now = performance.now(); |
| if(state === 'rolling'){ |
| const p = Math.min((now-rollStart)/rollDur, 1); |
| const spd = (1-p*p)*5 + 0.8; |
| diceGroup.rotation.x += vx*spd; |
| diceGroup.rotation.y += vy*spd; |
| diceGroup.rotation.z += vz*spd; |
| if(p >= 1) startLanding(); |
| } else if(state === 'landing'){ |
| const p = Math.min((now-landStart)/LAND_DUR, 1); |
| const t = 1 - Math.pow(1-p, 3); |
| diceGroup.quaternion.slerpQuaternions(currentQ, targetQ, t); |
| if(p >= 1) { |
| diceGroup.quaternion.copy(targetQ); |
| state = 'stopped'; |
| const label = document.getElementById('d20loading'); |
| if(label) label.style.display = 'none'; |
| |
| const res = document.getElementById('d20result'); |
| if(res) { |
| const hue = ((rollFinal - 1) / 19) * 120; |
| let color = `hsl(${hue}, 100%, 50%)`; |
| |
| let text = rollFinal; |
| let scale = '48px'; |
| if (rollFinal === 20) { |
| text = 'CRITICAL SUCCESS<br><span style="font-size:48px">20</span>'; |
| scale = '18px'; |
| } else if (rollFinal === 1) { |
| text = 'CRITICAL FAILURE<br><span style="font-size:48px">1</span>'; |
| scale = '18px'; |
| } |
| res.innerHTML = text; |
| res.style.color = color; |
| res.style.fontSize = scale; |
| res.style.textAlign = 'center'; |
| res.style.textShadow = '0 0 15px ' + color; |
| res.style.display = 'block'; |
| |
| res.style.animation = 'none'; |
| void res.offsetWidth; |
| res.style.animation = null; |
| res.classList.add('dice-final'); |
| } |
| } |
| } else if(state === 'idle'){ |
| diceGroup.rotation.x += 0.004; |
| diceGroup.rotation.y += 0.007; |
| diceGroup.rotation.z += 0.002; |
| } |
| renderer.render(scene, camera); |
| } |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', init3D); |
| } else { |
| init3D(); |
| } |
| </script> |
| """ |
|
|
| with gr.Blocks(title=APP_TITLE, theme=gr.themes.Soft(), css=CSS, head=HEAD_SCRIPT, js="""function() { document.documentElement.classList.add('dark'); }""") as demo: |
| state = gr.State() |
| gr.HTML( |
| """ |
| <div id='title-card'> |
| <h1>🐉 Misadventure Master</h1> |
| <p>A tiny tabletop RPG where the Game Master is a small LLM, the dice are real, and the dungeon is legally allowed to roast your choices.</p> |
| </div> |
| """ |
| ) |
| with gr.Row(equal_height=True): |
| with gr.Column(scale=3): |
| hero_name = gr.Textbox(show_label=False, placeholder="What is your name, brave hero?", value="") |
| with gr.Column(scale=2): |
| start = gr.Button("Start / Reset Dungeon", elem_id="start-btn") |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=3): |
| chat = gr.Chatbot(label="Adventure Log", height=435, type="messages") |
| action = gr.Textbox(label="What do you do?", placeholder="I inspect the suspicious mushroom with heroic overconfidence.") |
| with gr.Column(scale=2): |
| scene = gr.HTML(elem_classes="stat-panel", value="<div style='height: 520px; display: flex; align-items: center; justify-content: center; color: #fbbf24;'>Loading scene...</div>") |
| |
| with gr.Row(equal_height=False): |
| with gr.Column(scale=3): |
| with gr.Row(): |
| submit = gr.Button("Roll and act", variant="primary") |
| choice_1 = gr.Button("Inspect something suspicious") |
| choice_2 = gr.Button("Negotiate badly") |
| choice_3 = gr.Button("Run away heroically") |
| with gr.Column(scale=2): |
| dice = gr.HTML(elem_classes="dice-panel", value="<div style='display:flex;flex-direction:column;align-items:center;justify-content:center;height:320px;border-radius:13px;width:100%;'><div id='d20mount' style='width:200px;height:200px;'></div><div id='d20loading' style='margin-top:10px;font-weight:bold;color:#fbbf24;font-family:monospace;font-size:15px;display:none;'>Rolling...</div><div id='d20result' style='margin-top:10px;font-weight:900;display:none;'></div></div>") |
| hidden_roll_target = gr.Textbox(value=f"20-{time.time()}", visible=False, elem_id="roll-target") |
|
|
| |
| hidden_roll_target.change( |
| fn=None, |
| inputs=[hidden_roll_target], |
| js="(val) => { if(val && window.__d20_roll) { window.__d20_roll(val.split('-')[0]); } }" |
| ) |
|
|
| start.click(start_game, [hero_name], [state, chat, dice, scene, choice_1, choice_2, choice_3, hidden_roll_target], api_name=False) |
| submit.click(play_turn, [action, state, chat], [state, chat, dice, scene, choice_1, choice_2, choice_3, action, hidden_roll_target], api_name=False) |
| choice_1.click(use_choice, [choice_1], [action], api_name=False) |
| choice_2.click(use_choice, [choice_2], [action], api_name=False) |
| choice_3.click(use_choice, [choice_3], [action], api_name=False) |
| demo.load(start_game, [hero_name], [state, chat, dice, scene, choice_1, choice_2, choice_3, hidden_roll_target]) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860) |
|
|