Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState } from "react"; | |
| import { newGame, castSpell } from "./api.js"; | |
| export default function App() { | |
| const [snap, setSnap] = useState(null); | |
| const [selected, setSelected] = useState([]); | |
| const [spell, setSpell] = useState(null); | |
| const [busy, setBusy] = useState(false); | |
| const [error, setError] = useState(null); | |
| const start = async () => { | |
| setError(null); | |
| setSpell(null); | |
| setSelected([]); | |
| try { | |
| setSnap(await newGame()); | |
| } catch (e) { | |
| setError(`Backend unreachable. Start it with: uv run uvicorn api.server:app --port 8000`); | |
| } | |
| }; | |
| useEffect(() => { | |
| start(); | |
| }, []); | |
| const toggleRune = (key) => { | |
| setSelected((cur) => { | |
| if (cur.includes(key)) return cur.filter((k) => k !== key); | |
| if (cur.length >= 4) return cur; | |
| return [...cur, key]; | |
| }); | |
| }; | |
| const cast = async () => { | |
| if (!snap || selected.length === 0 || busy) return; | |
| setBusy(true); | |
| setError(null); | |
| try { | |
| const res = await castSpell(snap.session_id, selected); | |
| setSpell(res.spell); | |
| setSnap(res); | |
| setSelected([]); | |
| } catch (e) { | |
| setError(String(e)); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| if (!snap) { | |
| return ( | |
| <div className="wrap"> | |
| <h1>🪄 Rune Goblin</h1> | |
| <p className="muted">{error ?? "Summoning the dungeon…"}</p> | |
| </div> | |
| ); | |
| } | |
| const { enemy, player, room, runes, log } = snap; | |
| return ( | |
| <div className="wrap"> | |
| <header> | |
| <h1>🪄 Rune Goblin</h1> | |
| <p className="tag">Draw forbidden spells. Regret efficiently.</p> | |
| </header> | |
| <div className="cards"> | |
| <section className="card enemy"> | |
| <h2>👹 {enemy.name}</h2> | |
| <Bar value={enemy.hp} max={enemy.max_hp} kind="hp" /> | |
| <p className="muted"> | |
| weak: <b>{enemy.weakness.join(", ")}</b> · resists: {enemy.resistance.join(", ")} | |
| </p> | |
| <p className="mood">mood: {enemy.mood}</p> | |
| </section> | |
| <section className="card player"> | |
| <h2> | |
| 🧙 You — Room {room.index + 1}/{room.total} | |
| </h2> | |
| <p className="room">{room.name}</p> | |
| <Bar value={player.hp} max={player.max_hp} kind="health" /> | |
| <p className="muted">score: {snap.score} · 🎒 {player.inventory.join(", ") || "empty"}</p> | |
| </section> | |
| </div> | |
| <div className="board"> | |
| {runes.map((r) => ( | |
| <button | |
| key={r.key} | |
| className={`rune ${selected.includes(r.key) ? "on" : ""}`} | |
| onClick={() => toggleRune(r.key)} | |
| title={r.label} | |
| > | |
| <span className="sym">{r.symbol}</span> | |
| <span className="lbl">{r.label}</span> | |
| </button> | |
| ))} | |
| </div> | |
| <div className="selected">{selected.join(" + ") || "pick 2–4 runes"}</div> | |
| <div className="actions"> | |
| <button className="cast" disabled={busy || snap.over} onClick={cast}> | |
| {busy ? "casting…" : "🔮 CAST SPELL"} | |
| </button> | |
| <button onClick={() => setSelected([])}>clear</button> | |
| <button onClick={start}>new run</button> | |
| </div> | |
| {spell && ( | |
| <div className="result"> | |
| <h3>✨ {spell.spell_name}</h3> | |
| <p className="flavor">{spell.flavor}</p> | |
| <p>{spell.effect}</p> | |
| {spell.side_effect && <p className="side">⚠ {spell.side_effect}</p>} | |
| <p className="muted"> | |
| enemy {spell.enemy_hp_delta} · player {spell.player_hp_delta} · chaos {spell.chaos}/10 | |
| </p> | |
| </div> | |
| )} | |
| {snap.over && ( | |
| <div className={`banner ${snap.won ? "win" : "lose"}`}> | |
| {snap.won ? "🏆 You survived the dungeon!" : "💀 You collapsed."} Score: {snap.score} | |
| </div> | |
| )} | |
| <pre className="log">{log.join("\n")}</pre> | |
| {error && <p className="error">{error}</p>} | |
| </div> | |
| ); | |
| } | |
| function Bar({ value, max, kind }) { | |
| const pct = Math.max(0, Math.round((value / max) * 100)); | |
| return ( | |
| <div className="bar"> | |
| <div className={`fill ${kind}`} style={{ width: `${pct}%` }} /> | |
| <span> | |
| {value}/{max} | |
| </span> | |
| </div> | |
| ); | |
| } | |