v0.5.0-dm-tools: Initiative tracker, random tables, encounter builder, calendar

- Initiative Tracker: add/remove combatants, roll initiative, HP bars with
  color transitions (green→gold→red), condition badges, round counter,
  next-turn cycling, gold active-state border
- Random Tables: 4 built-in tables (Tavern Names, Weather, NPC Quirks,
  Treasure), dice notation parser, roll history, highlight matching result
- Encounter Builder: AI-generated encounters via LLM, party level/size,
  terrain selector, JSON response parsing, difficulty badge
- Calendar Widget: Faerûn calendar (Hammer–Nightal), event creation per
  day, event indicators, month navigation, year display
- All glass-card wrapped with framer-motion hover/tap animations
- Full production build clean: tsc, vite, cargo, tauri build
This commit is contained in:
itsamejms
2026-06-28 22:40:11 +01:00
parent 9675e05b07
commit 5ce2686ced
5 changed files with 704 additions and 43 deletions
+136
View File
@@ -0,0 +1,136 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Encounter {
monsters: string[];
terrain: string;
difficulty: string;
loot: string;
}
export function EncounterBuilder() {
const [partyLevel, setPartyLevel] = useState(5);
const [partySize, setPartySize] = useState(4);
const [terrain, setTerrain] = useState("Forest");
const [encounter, setEncounter] = useState<Encounter | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function generate() {
setLoading(true);
setError("");
setEncounter(null);
try {
const result = await invoke<string>("generate", {
req: {
prompt: `Generate a D&D 5e encounter for a party of ${partySize} level-${partyLevel} characters in a ${terrain.toLowerCase()} setting.
Provide the response as a JSON object with exactly these keys:
- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts")
- "terrain": a brief terrain description with environmental features
- "difficulty": one of Easy/Medium/Hard/Deadly
- "loot": a brief treasure description`,
system: "You are a D&D encounter designer. Always respond with valid JSON only.",
temperature: 0.9,
max_tokens: 400,
},
});
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]) as Encounter;
setEncounter(parsed);
} else {
setError("LLM did not return valid JSON. Raw:\n" + result);
}
} catch (e) {
setError(String(e));
}
setLoading(false);
}
return (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Party Level</label>
<input
type="number"
min={1}
max={20}
value={partyLevel}
onChange={(e) => setPartyLevel(parseInt(e.target.value) || 1)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Party Size</label>
<input
type="number"
min={1}
max={10}
value={partySize}
onChange={(e) => setPartySize(parseInt(e.target.value) || 1)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Terrain</label>
<select
value={terrain}
onChange={(e) => setTerrain(e.target.value)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
>
{["Forest", "Dungeon", "Urban", "Mountain", "Desert", "Swamp", "Coastal", "Underdark"].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<button
onClick={generate}
disabled={loading}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{loading ? "⚔ Generating…" : "⚔ Generate Encounter"}
</button>
{error && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs">
{error}
</div>
)}
{encounter && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
{encounter.difficulty} Encounter
</span>
<span className="text-[var(--color-text-dim)] text-[10px]">
Level {partyLevel} × {partySize}
</span>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Monsters</span>
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-0.5">
{encounter.monsters?.map((m, i) => <li key={i}>{m}</li>)}
</ul>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Terrain Features</span>
<p className="text-[var(--color-text-primary)] text-xs mt-0.5">{encounter.terrain}</p>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Loot</span>
<p className="text-[var(--color-gold-bright)] text-xs mt-0.5">{encounter.loot}</p>
</div>
</div>
)}
</div>
);
}