Spaces:
Sleeping
Sleeping
| """D&D Combat Tracker — stateful, deterministic Gradio app (no AI).""" | |
| import tempfile | |
| from pathlib import Path | |
| import gradio as gr | |
| from src import content, tool | |
| from src.space_config import CONFIG | |
| from src.shared.ctas import cta_html | |
| from src.shared.page import standard_launch | |
| from src.shared.seo import ( | |
| hero_html, section_html, faq_html, related_html, | |
| ) | |
| COPY_JS = """ | |
| () => { | |
| const el = document.querySelector('#lf-copy-src textarea'); | |
| if (!el || !el.value) return; | |
| const flash = () => { | |
| const btn = document.querySelector('#lf-copy-btn button') || | |
| document.querySelector('#lf-copy-btn'); | |
| if (btn) { const t = btn.textContent; btn.textContent = 'Copied!'; | |
| setTimeout(() => (btn.textContent = t), 1500); } | |
| }; | |
| const fallback = () => { | |
| el.focus({preventScroll: true}); el.select(); | |
| try { document.execCommand('copy'); } catch (e) {} | |
| flash(); | |
| }; | |
| try { | |
| navigator.clipboard.writeText(el.value).then(flash).catch(fallback); | |
| } catch (e) { fallback(); } | |
| } | |
| """ | |
| def _sync(state): | |
| """Everything that must refresh after any state change: both state stores, | |
| the board HTML, the combatant dropdown, the copy source, and the download.""" | |
| md = tool.export_markdown(state) | |
| path = Path(tempfile.mkdtemp()) / "dnd-combat-tracker.md" | |
| path.write_text(md, encoding="utf-8") | |
| return ( | |
| state, # gr.State | |
| state, # gr.BrowserState | |
| tool.render_board(state), # board HTML | |
| gr.update(choices=tool.combatant_choices(state), # combatant dropdown | |
| value=None), | |
| md, # copy source | |
| gr.update(value=str(path)), # download button | |
| ) | |
| def on_app_load(saved): | |
| state = tool.coerce_state(saved) | |
| return _sync(state) | |
| def on_add(state, name, side, initiative, max_hp, ac): | |
| state = tool.add_combatant(state, name, side, initiative, max_hp, ac) | |
| return (*_sync(state), "") # extra output clears the name box | |
| def on_load_example(state): | |
| return _sync(tool.load_example()) | |
| def on_clear_all(state): | |
| return _sync(tool.clear_all()) | |
| def on_next(state): | |
| return _sync(tool.next_turn(state)) | |
| def on_prev(state): | |
| return _sync(tool.prev_turn(state)) | |
| def on_reset(state): | |
| return _sync(tool.reset_encounter(state)) | |
| def on_damage(state, cid, amount): | |
| if cid is None: | |
| return _sync(state) | |
| return _sync(tool.apply_hp(state, cid, -abs(int(amount or 0)))) | |
| def on_heal(state, cid, amount): | |
| if cid is None: | |
| return _sync(state) | |
| return _sync(tool.apply_hp(state, cid, abs(int(amount or 0)))) | |
| def on_toggle_condition(state, cid, condition): | |
| if cid is None or not condition: | |
| return _sync(state) | |
| return _sync(tool.toggle_condition(state, cid, condition)) | |
| def on_remove(state, cid): | |
| if cid is None: | |
| return _sync(state) | |
| return _sync(tool.remove_combatant(state, cid)) | |
| with gr.Blocks(title=CONFIG.title) as demo: | |
| gr.HTML(hero_html(CONFIG)) | |
| gr.HTML(cta_html(CONFIG, CONFIG.hero_cta)) | |
| state = gr.State(tool.new_state()) | |
| browser_state = gr.BrowserState( | |
| default_value=tool.new_state(), storage_key="lf_combat_tracker_v1", | |
| ) | |
| # ---- add-combatant form ------------------------------------------------- | |
| with gr.Group(): | |
| with gr.Row(): | |
| name_tb = gr.Textbox(label="Name", placeholder="e.g. Goblin 1 or Sera", | |
| scale=3) | |
| side_radio = gr.Radio(tool.SIDES, label="Side", value="PC", scale=2) | |
| with gr.Row(): | |
| init_num = gr.Number(label="Initiative", value=10, precision=1) | |
| hp_num = gr.Number(label="Max HP", value=10, precision=0, minimum=1) | |
| ac_num = gr.Number(label="AC (optional)", value=None, precision=0) | |
| with gr.Row(): | |
| add_btn = gr.Button("Add Combatant", variant="primary", | |
| elem_classes=["lf-generate"]) | |
| example_btn = gr.Button("Load Example Encounter") | |
| clear_btn = gr.Button("Clear All") | |
| # ---- battle board ------------------------------------------------------- | |
| board = gr.HTML(tool.render_board(tool.new_state())) | |
| # ---- turn controls ------------------------------------------------------ | |
| with gr.Row(): | |
| next_btn = gr.Button("Next Turn ▶", variant="primary", | |
| elem_classes=["lf-generate"]) | |
| prev_btn = gr.Button("◀ Back") | |
| reset_btn = gr.Button("Reset Encounter") | |
| # ---- per-combatant actions --------------------------------------------- | |
| with gr.Group(): | |
| with gr.Row(): | |
| combatant_dd = gr.Dropdown(choices=[], value=None, label="Combatant", | |
| scale=3) | |
| amount_num = gr.Number(label="Amount", value=1, precision=0, | |
| minimum=0, scale=1) | |
| damage_btn = gr.Button("Damage") | |
| heal_btn = gr.Button("Heal") | |
| with gr.Row(): | |
| condition_dd = gr.Dropdown(tool.CONDITIONS, value=None, | |
| label="Condition", scale=3) | |
| condition_btn = gr.Button("Toggle Condition") | |
| remove_btn = gr.Button("Remove") | |
| # ---- export ------------------------------------------------------------- | |
| # Kept in the DOM (offscreen) so the Copy button's JS can read it; | |
| # visible=False would remove the textarea entirely in Gradio 6. | |
| copy_src = gr.Textbox(value=tool.export_markdown(tool.new_state()), | |
| elem_id="lf-copy-src", elem_classes=["lf-offscreen"], | |
| container=False, show_label=False) | |
| with gr.Row(): | |
| copy_btn = gr.Button("Copy Summary", elem_id="lf-copy-btn") | |
| download_btn = gr.DownloadButton("Download .md") | |
| gr.HTML(cta_html(CONFIG, CONFIG.post_tool_cta)) | |
| gr.HTML(section_html("Run a Full D&D Combat", content.HOW_TO_HTML)) | |
| gr.HTML(section_html("What This DnD Combat Tracker Handles", | |
| content.HANDLES_HTML)) | |
| gr.HTML(section_html("Worked Example: A Goblin Ambush in 3 Rounds", | |
| content.EXAMPLE_HTML)) | |
| gr.HTML(section_html("Tracker Rules & Tips", content.RULES_HTML)) | |
| gr.HTML(faq_html(content.FAQS)) | |
| gr.HTML(related_html(CONFIG)) | |
| gr.HTML(cta_html(CONFIG, CONFIG.footer_cta)) | |
| gr.HTML(content.DISCLAIMER_HTML) | |
| # ---- wiring ------------------------------------------------------------- | |
| sync_outputs = [state, browser_state, board, combatant_dd, copy_src, | |
| download_btn] | |
| demo.load(on_app_load, [browser_state], sync_outputs, | |
| api_name=False, queue=False) | |
| add_btn.click(on_add, [state, name_tb, side_radio, init_num, hp_num, ac_num], | |
| sync_outputs + [name_tb], api_name=False, queue=False) | |
| name_tb.submit(on_add, [state, name_tb, side_radio, init_num, hp_num, ac_num], | |
| sync_outputs + [name_tb], api_name=False, queue=False) | |
| example_btn.click(on_load_example, [state], sync_outputs, | |
| api_name=False, queue=False) | |
| clear_btn.click(on_clear_all, [state], sync_outputs, | |
| api_name=False, queue=False) | |
| next_btn.click(on_next, [state], sync_outputs, api_name=False, queue=False) | |
| prev_btn.click(on_prev, [state], sync_outputs, api_name=False, queue=False) | |
| reset_btn.click(on_reset, [state], sync_outputs, api_name=False, queue=False) | |
| damage_btn.click(on_damage, [state, combatant_dd, amount_num], sync_outputs, | |
| api_name=False, queue=False) | |
| heal_btn.click(on_heal, [state, combatant_dd, amount_num], sync_outputs, | |
| api_name=False, queue=False) | |
| condition_btn.click(on_toggle_condition, [state, combatant_dd, condition_dd], | |
| sync_outputs, api_name=False, queue=False) | |
| remove_btn.click(on_remove, [state, combatant_dd], sync_outputs, | |
| api_name=False, queue=False) | |
| copy_btn.click(None, None, None, js=COPY_JS, api_name=False, queue=False) | |
| if __name__ == "__main__": | |
| standard_launch(demo, CONFIG) | |