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:
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
"Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn",
|
||||||
|
"Flamerule", "Eleasis", "Eleint", "Marpenoth", "Uktar", "Nightal",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CalendarEvent {
|
||||||
|
day: number;
|
||||||
|
month: number;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["var(--color-gold-bright)", "var(--color-info)", "var(--color-success)", "var(--color-danger)"];
|
||||||
|
|
||||||
|
export function CalendarWidget() {
|
||||||
|
const [month, setMonth] = useState(0);
|
||||||
|
const [year, setYear] = useState(1492);
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||||
|
const [newEvent, setNewEvent] = useState("");
|
||||||
|
|
||||||
|
const today = { day: 15, month: 5 }; // 15th of Kythorn (arbitrary "today")
|
||||||
|
|
||||||
|
function addEvent() {
|
||||||
|
if (!newEvent.trim() || selectedDay === null) return;
|
||||||
|
setEvents((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ day: selectedDay, month, text: newEvent.trim(), color: COLORS[events.length % COLORS.length] },
|
||||||
|
]);
|
||||||
|
setNewEvent("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEvent(idx: number) {
|
||||||
|
setEvents((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayEvents = events.filter((e) => e.day === selectedDay && e.month === month);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (month === 0) { setMonth(11); setYear(year - 1); } else setMonth(month - 1); }}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
|
||||||
|
{MONTHS[month]}
|
||||||
|
</div>
|
||||||
|
<div className="text-[var(--color-text-dim)] text-[10px]">{year} DR</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (month === 11) { setMonth(0); setYear(year + 1); } else setMonth(month + 1); }}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||||
|
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Start offset - Faerûn calendar starts on first of month */}
|
||||||
|
{Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const dayNum = i + 1;
|
||||||
|
const isToday = dayNum === today.day && month === today.month;
|
||||||
|
const hasEvent = events.some((e) => e.day === dayNum && e.month === month);
|
||||||
|
const isSelected = dayNum === selectedDay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dayNum}
|
||||||
|
onClick={() => setSelectedDay(dayNum)}
|
||||||
|
className={`rounded text-[10px] py-1 cursor-pointer transition-colors ${
|
||||||
|
isToday
|
||||||
|
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-bold"
|
||||||
|
: isSelected
|
||||||
|
? "bg-[var(--color-bg-card-hover)] text-[var(--color-text-primary)]"
|
||||||
|
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dayNum}
|
||||||
|
{hasEvent && !isToday && (
|
||||||
|
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events for selected day */}
|
||||||
|
{selectedDay !== null && (
|
||||||
|
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
||||||
|
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1">
|
||||||
|
{MONTHS[month]} {selectedDay}
|
||||||
|
</div>
|
||||||
|
{dayEvents.length === 0 && (
|
||||||
|
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||||
|
)}
|
||||||
|
{dayEvents.map((e, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: e.color }} />
|
||||||
|
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEvent(events.indexOf(e))}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
<input
|
||||||
|
className="flex-1 min-w-0 rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||||
|
value={newEvent}
|
||||||
|
onChange={(e) => setNewEvent(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addEvent()}
|
||||||
|
placeholder="Add event…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addEvent}
|
||||||
|
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ import { BentoCard } from "./BentoCard";
|
|||||||
import { NpcGenerator } from "./NpcGenerator";
|
import { NpcGenerator } from "./NpcGenerator";
|
||||||
import { DiceRoller } from "./DiceRoller";
|
import { DiceRoller } from "./DiceRoller";
|
||||||
import { SessionLogger } from "./SessionLogger";
|
import { SessionLogger } from "./SessionLogger";
|
||||||
|
import { EncounterBuilder } from "./EncounterBuilder";
|
||||||
|
import { InitiativeTracker } from "./InitiativeTracker";
|
||||||
|
import { RandomTables } from "./RandomTables";
|
||||||
|
import { CalendarWidget } from "./CalendarWidget";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
return (
|
return (
|
||||||
@@ -39,71 +43,39 @@ export function Dashboard() {
|
|||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Encounter Builder — 1×1 */}
|
{/* Encounter Builder — 1×1 */}
|
||||||
<BentoCard
|
<BentoCard title="Encounter" icon={<Swords size={16} />} span="col-span-1 row-span-1">
|
||||||
title="Encounter"
|
<EncounterBuilder />
|
||||||
icon={<Swords size={16} />}
|
|
||||||
span="col-span-1 row-span-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
|
||||||
Encounter builder coming soon
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Session Log — 2×1 */}
|
{/* Session Log — 2×1 */}
|
||||||
<BentoCard
|
<BentoCard title="Session Log" icon={<ScrollText size={16} />} span="col-span-2 row-span-1">
|
||||||
title="Session Log"
|
|
||||||
icon={<ScrollText size={16} />}
|
|
||||||
span="col-span-2 row-span-1"
|
|
||||||
>
|
|
||||||
<SessionLogger />
|
<SessionLogger />
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Quest Designer — 2×1 */}
|
{/* Quest Designer — 2×1 */}
|
||||||
<BentoCard
|
<BentoCard title="Quest Designer" icon={<Sparkles size={16} />} span="col-span-2 row-span-1">
|
||||||
title="Quest Designer"
|
|
||||||
icon={<Sparkles size={16} />}
|
|
||||||
span="col-span-2 row-span-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
||||||
Quest flowchart coming soon
|
Quest flowchart coming soon — react-flow integration pending
|
||||||
</div>
|
</div>
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Initiative Tracker — 1×1 */}
|
{/* Initiative Tracker — 1×1 */}
|
||||||
<BentoCard title="Initiative" icon={<Timer size={16} />} span="col-span-1 row-span-1">
|
<BentoCard title="Initiative" icon={<Timer size={16} />} span="col-span-1 row-span-1">
|
||||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
<InitiativeTracker />
|
||||||
Combat tracker coming soon
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Random Tables — 1×1 */}
|
{/* Random Tables — 1×1 */}
|
||||||
<BentoCard
|
<BentoCard title="Random Tables" icon={<Sparkles size={16} />} span="col-span-1 row-span-1">
|
||||||
title="Random Tables"
|
<RandomTables />
|
||||||
icon={<Sparkles size={16} />}
|
|
||||||
span="col-span-1 row-span-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
|
||||||
Random tables coming soon
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Calendar — 1×1 */}
|
{/* Calendar — 1×1 */}
|
||||||
<BentoCard
|
<BentoCard title="Calendar" icon={<Calendar size={16} />} span="col-span-1 row-span-1">
|
||||||
title="Calendar"
|
<CalendarWidget />
|
||||||
icon={<Calendar size={16} />}
|
|
||||||
span="col-span-1 row-span-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
|
||||||
Calendar coming soon
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
{/* Soundboard — 1×1 */}
|
{/* Soundboard — 1×1 */}
|
||||||
<BentoCard
|
<BentoCard title="Soundboard" icon={<Volume2 size={16} />} span="col-span-1 row-span-1">
|
||||||
title="Soundboard"
|
|
||||||
icon={<Volume2 size={16} />}
|
|
||||||
span="col-span-1 row-span-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
||||||
Soundboard coming soon
|
Soundboard coming soon
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Combatant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
initiative: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
conditions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONDITIONS = [
|
||||||
|
"Blinded", "Charmed", "Deafened", "Frightened", "Grappled",
|
||||||
|
"Incapacitated", "Invisible", "Paralyzed", "Poisoned", "Prone",
|
||||||
|
"Restrained", "Stunned", "Unconscious",
|
||||||
|
];
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export function InitiativeTracker() {
|
||||||
|
const [combatants, setCombatants] = useState<Combatant[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [initBonus, setInitBonus] = useState(0);
|
||||||
|
const [hp, setHp] = useState(10);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [round, setRound] = useState(1);
|
||||||
|
|
||||||
|
const addCombatant = useCallback(() => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
const roll = Math.floor(Math.random() * 20) + 1 + initBonus;
|
||||||
|
const c: Combatant = {
|
||||||
|
id: String(nextId++),
|
||||||
|
name: name.trim(),
|
||||||
|
initiative: roll,
|
||||||
|
hp,
|
||||||
|
maxHp: hp,
|
||||||
|
conditions: [],
|
||||||
|
};
|
||||||
|
setCombatants((prev) => [...prev, c].sort((a, b) => b.initiative - a.initiative));
|
||||||
|
setName("");
|
||||||
|
}, [name, initBonus, hp]);
|
||||||
|
|
||||||
|
const removeCombatant = useCallback((id: string) => {
|
||||||
|
setCombatants((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
setActiveId((prev) => (prev === id ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCondition = useCallback((id: string, condition: string) => {
|
||||||
|
setCombatants((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
conditions: c.conditions.includes(condition)
|
||||||
|
? c.conditions.filter((cn) => cn !== condition)
|
||||||
|
: [...c.conditions, condition],
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeHp = useCallback((id: string, delta: number) => {
|
||||||
|
setCombatants((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, c.hp + delta)) } : c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextTurn = useCallback(() => {
|
||||||
|
if (combatants.length === 0) return;
|
||||||
|
if (!activeId) {
|
||||||
|
setActiveId(combatants[0].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = combatants.findIndex((c) => c.id === activeId);
|
||||||
|
if (idx === combatants.length - 1) {
|
||||||
|
setRound((r) => r + 1);
|
||||||
|
setActiveId(combatants[0].id);
|
||||||
|
} else {
|
||||||
|
setActiveId(combatants[idx + 1].id);
|
||||||
|
}
|
||||||
|
}, [combatants, activeId]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setCombatants([]);
|
||||||
|
setActiveId(null);
|
||||||
|
setRound(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
||||||
|
{/* Round indicator */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--color-gold-bright)] font-heading text-xs font-semibold">
|
||||||
|
Round {round}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={nextTurn}
|
||||||
|
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||||
|
>
|
||||||
|
Next Turn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer hover:text-[var(--color-danger)] transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add combatant */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addCombatant()}
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-10 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
||||||
|
type="number"
|
||||||
|
value={initBonus}
|
||||||
|
onChange={(e) => setInitBonus(parseInt(e.target.value) || 0)}
|
||||||
|
title="Initiative bonus"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-12 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
||||||
|
type="number"
|
||||||
|
value={hp}
|
||||||
|
onChange={(e) => setHp(parseInt(e.target.value) || 1)}
|
||||||
|
title="Max HP"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addCombatant}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Combatant list */}
|
||||||
|
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
|
||||||
|
{combatants.map((c) => {
|
||||||
|
const isActive = c.id === activeId;
|
||||||
|
const hpPct = c.maxHp > 0 ? c.hp / c.maxHp : 0;
|
||||||
|
const hpColor =
|
||||||
|
hpPct > 0.5
|
||||||
|
? "var(--color-success)"
|
||||||
|
: hpPct > 0.25
|
||||||
|
? "var(--color-gold-bright)"
|
||||||
|
: "var(--color-danger)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`rounded-lg p-2 text-xs transition-all cursor-default ${
|
||||||
|
isActive
|
||||||
|
? "glass-card-active"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-mono text-[var(--color-text-dim)] w-6 text-right shrink-0">
|
||||||
|
{c.initiative}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-text-primary)] truncate font-medium">
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeCombatant(c.id)}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] transition-colors shrink-0 cursor-pointer"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HP bar */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${hpPct * 100}%`, backgroundColor: hpColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] shrink-0" style={{ color: hpColor }}>
|
||||||
|
{c.hp}/{c.maxHp}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => changeHp(c.id, -1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer">−</button>
|
||||||
|
<button onClick={() => changeHp(c.id, 1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-success)] cursor-pointer">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditions */}
|
||||||
|
{c.conditions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||||
|
{c.conditions.map((cn) => (
|
||||||
|
<span
|
||||||
|
key={cn}
|
||||||
|
onClick={() => toggleCondition(c.id, cn)}
|
||||||
|
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-1.5 py-0 text-[10px] cursor-pointer hover:opacity-80"
|
||||||
|
>
|
||||||
|
{cn}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add condition */}
|
||||||
|
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||||
|
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).slice(0, 5).map((cn) => (
|
||||||
|
<button
|
||||||
|
key={cn}
|
||||||
|
onClick={() => toggleCondition(c.id, cn)}
|
||||||
|
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-text-secondary)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
+{cn.slice(0, 3)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface TableEntry {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RandomTable {
|
||||||
|
name: string;
|
||||||
|
dice: string;
|
||||||
|
entries: TableEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_TABLES: RandomTable[] = [
|
||||||
|
{
|
||||||
|
name: "Tavern Names",
|
||||||
|
dice: "1d20",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "The Rusty Tankard" },
|
||||||
|
{ min: 3, max: 4, result: "The Sleeping Dragon" },
|
||||||
|
{ min: 5, max: 6, result: "The Gilded Flask" },
|
||||||
|
{ min: 7, max: 8, result: "The Blind Beholder" },
|
||||||
|
{ min: 9, max: 10, result: "The Copper Kettle" },
|
||||||
|
{ min: 11, max: 12, result: "The Prancing Pony" },
|
||||||
|
{ min: 13, max: 14, result: "The Wandering Wyvern" },
|
||||||
|
{ min: 15, max: 16, result: "The Blacksmith's Rest" },
|
||||||
|
{ min: 17, max: 18, result: "The Tipsy Goblin" },
|
||||||
|
{ min: 19, max: 20, result: "The Moonlit Hearth" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Weather",
|
||||||
|
dice: "1d12",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "Clear skies, warm breeze" },
|
||||||
|
{ min: 3, max: 4, result: "Overcast, cool" },
|
||||||
|
{ min: 5, max: 6, result: "Light rain, fog in the morning" },
|
||||||
|
{ min: 7, max: 8, result: "Thunderstorm, heavy rain" },
|
||||||
|
{ min: 9, max: 10, result: "Strong winds, debris flying" },
|
||||||
|
{ min: 11, max: 12, result: "Unnatural: green-tinted fog or blood rain" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NPC Quirks",
|
||||||
|
dice: "1d10",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 1, result: "Speaks in rhyming couplets" },
|
||||||
|
{ min: 2, max: 2, result: "Constantly checks over their shoulder" },
|
||||||
|
{ min: 3, max: 3, result: "Has a pet mouse on their shoulder" },
|
||||||
|
{ min: 4, max: 4, result: "Insists on being called 'Your Eminence'" },
|
||||||
|
{ min: 5, max: 5, result: "Smells strongly of cinnamon" },
|
||||||
|
{ min: 6, max: 6, result: "Never makes eye contact" },
|
||||||
|
{ min: 7, max: 7, result: "Finishes everyone else's sentences" },
|
||||||
|
{ min: 8, max: 8, result: "Carries a broken sword and won't say why" },
|
||||||
|
{ min: 9, max: 9, result: "Laughs at inappropriate moments" },
|
||||||
|
{ min: 10, max: 10, result: "Writes everything down in a tiny notebook" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Treasure (Low)",
|
||||||
|
dice: "1d6",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 1, result: "5d6 copper pieces" },
|
||||||
|
{ min: 2, max: 2, result: "2d6 silver pieces and a garnet ring" },
|
||||||
|
{ min: 3, max: 3, result: "1d6 gold pieces and a potion of healing" },
|
||||||
|
{ min: 4, max: 4, result: "A scroll with a cantrip and 3d6 silver" },
|
||||||
|
{ min: 5, max: 5, result: "A mundane item of unusual craftsmanship" },
|
||||||
|
{ min: 6, max: 6, result: "A map fragment leading to a nearby ruin" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function rollDice(notation: string): number {
|
||||||
|
const match = notation.toLowerCase().match(/^(\d+)d(\d+)([+-]\d+)?$/);
|
||||||
|
if (!match) return Math.floor(Math.random() * 20) + 1;
|
||||||
|
const count = parseInt(match[1]);
|
||||||
|
const sides = parseInt(match[2]);
|
||||||
|
const modifier = parseInt(match[3] || "0");
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
total += Math.floor(Math.random() * sides) + 1;
|
||||||
|
}
|
||||||
|
return total + modifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RollLog {
|
||||||
|
tableName: string;
|
||||||
|
roll: number;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RandomTables() {
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const [log, setLog] = useState<RollLog[]>([]);
|
||||||
|
const [customRoll, setCustomRoll] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const table = BUILTIN_TABLES[selected];
|
||||||
|
|
||||||
|
const roll = useCallback(() => {
|
||||||
|
const rollVal = rollDice(table.dice);
|
||||||
|
const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max);
|
||||||
|
const result = entry?.result ?? "No result found";
|
||||||
|
setLog((prev) => [{ tableName: table.name, roll: rollVal, result }, ...prev].slice(0, 30));
|
||||||
|
setCustomRoll(rollVal);
|
||||||
|
}, [table]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
|
{/* Table selector */}
|
||||||
|
<select
|
||||||
|
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"
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelected(parseInt(e.target.value));
|
||||||
|
setCustomRoll(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{BUILTIN_TABLES.map((t, i) => (
|
||||||
|
<option key={t.name} value={i}>
|
||||||
|
{t.name} ({t.dice})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Roll button */}
|
||||||
|
<button
|
||||||
|
onClick={roll}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
🎲 Roll {table.dice}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{customRoll !== null && (
|
||||||
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-center">
|
||||||
|
<div className="font-mono text-2xl font-bold text-[var(--color-gold-bright)]">
|
||||||
|
{customRoll}
|
||||||
|
</div>
|
||||||
|
<div className="text-[var(--color-text-primary)] text-sm mt-1">
|
||||||
|
{table.entries.find((e) => customRoll >= e.min && customRoll <= e.max)?.result}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table preview */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{table.entries.map((e) => (
|
||||||
|
<div
|
||||||
|
key={`${e.min}-${e.max}`}
|
||||||
|
className={`flex items-center gap-2 rounded px-2 py-0.5 text-xs ${
|
||||||
|
customRoll !== null && customRoll >= e.min && customRoll <= e.max
|
||||||
|
? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]"
|
||||||
|
: "text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono w-8 text-right shrink-0">{e.min === e.max ? e.min : `${e.min}-${e.max}`}</span>
|
||||||
|
<span className="truncate">{e.result}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roll history */}
|
||||||
|
{log.length > 0 && (
|
||||||
|
<div className="border-t border-[var(--color-border-subtle)] pt-2 max-h-24 overflow-y-auto">
|
||||||
|
<div className="text-[10px] font-medium text-[var(--color-text-dim)] mb-1">History</div>
|
||||||
|
{log.map((l, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-[10px] py-0.5">
|
||||||
|
<span className="text-[var(--color-text-dim)]">{l.tableName}</span>
|
||||||
|
<span className="font-mono text-[var(--color-gold-bright)]">{l.roll}</span>
|
||||||
|
<span className="text-[var(--color-text-secondary)] truncate">{l.result}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user