Live 3-bar difficulty indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system. Automatically derived from PC levels and bestiary creature CRs. - Add optional level field (1-20) to PlayerCharacter - Add CR-to-XP and XP Budget per Character lookup tables in domain - Add calculateEncounterDifficulty pure function - Add DifficultyIndicator component with color-coded bars and tooltip - Add useDifficulty hook composing encounter, PC, and bestiary contexts - Indicator hidden when no PCs with levels or no bestiary-linked monsters - Level field in PC create/edit forms, persisted in storage Closes #18 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
203 lines
5.0 KiB
TypeScript
203 lines
5.0 KiB
TypeScript
import type { PlayerCharacter } from "@initiative/domain";
|
|
import { X } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { ColorPalette } from "./color-palette";
|
|
import { IconGrid } from "./icon-grid";
|
|
import { Button } from "./ui/button";
|
|
import { Dialog } from "./ui/dialog";
|
|
import { Input } from "./ui/input";
|
|
|
|
function parseLevel(value: string): number | undefined | "invalid" {
|
|
if (value.trim() === "") return undefined;
|
|
const n = Number.parseInt(value, 10);
|
|
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
|
return n;
|
|
}
|
|
|
|
interface CreatePlayerModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSave: (
|
|
name: string,
|
|
ac: number,
|
|
maxHp: number,
|
|
color: string | undefined,
|
|
icon: string | undefined,
|
|
level: number | undefined,
|
|
) => void;
|
|
playerCharacter?: PlayerCharacter;
|
|
}
|
|
|
|
export function CreatePlayerModal({
|
|
open,
|
|
onClose,
|
|
onSave,
|
|
playerCharacter,
|
|
}: Readonly<CreatePlayerModalProps>) {
|
|
const [name, setName] = useState("");
|
|
const [ac, setAc] = useState("10");
|
|
const [maxHp, setMaxHp] = useState("10");
|
|
const [color, setColor] = useState("blue");
|
|
const [icon, setIcon] = useState("sword");
|
|
const [level, setLevel] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
const isEdit = !!playerCharacter;
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
if (playerCharacter) {
|
|
setName(playerCharacter.name);
|
|
setAc(String(playerCharacter.ac));
|
|
setMaxHp(String(playerCharacter.maxHp));
|
|
setColor(playerCharacter.color ?? "");
|
|
setIcon(playerCharacter.icon ?? "");
|
|
setLevel(
|
|
playerCharacter.level === undefined
|
|
? ""
|
|
: String(playerCharacter.level),
|
|
);
|
|
} else {
|
|
setName("");
|
|
setAc("10");
|
|
setMaxHp("10");
|
|
setColor("");
|
|
setIcon("");
|
|
setLevel("");
|
|
}
|
|
setError("");
|
|
}
|
|
}, [open, playerCharacter]);
|
|
|
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const trimmed = name.trim();
|
|
if (trimmed === "") {
|
|
setError("Name is required");
|
|
return;
|
|
}
|
|
const acNum = Number.parseInt(ac, 10);
|
|
if (Number.isNaN(acNum) || acNum < 0) {
|
|
setError("AC must be a non-negative number");
|
|
return;
|
|
}
|
|
const hpNum = Number.parseInt(maxHp, 10);
|
|
if (Number.isNaN(hpNum) || hpNum < 1) {
|
|
setError("Max HP must be at least 1");
|
|
return;
|
|
}
|
|
const levelNum = parseLevel(level);
|
|
if (levelNum === "invalid") {
|
|
setError("Level must be between 1 and 20");
|
|
return;
|
|
}
|
|
onSave(
|
|
trimmed,
|
|
acNum,
|
|
hpNum,
|
|
color || undefined,
|
|
icon || undefined,
|
|
levelNum,
|
|
);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold text-foreground text-lg">
|
|
{isEdit ? "Edit Player" : "Create Player"}
|
|
</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className="text-muted-foreground"
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
<div>
|
|
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
|
<Input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => {
|
|
setName(e.target.value);
|
|
setError("");
|
|
}}
|
|
placeholder="Character name"
|
|
aria-label="Name"
|
|
autoFocus
|
|
/>
|
|
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<div className="flex-1">
|
|
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={ac}
|
|
onChange={(e) => setAc(e.target.value)}
|
|
placeholder="AC"
|
|
aria-label="AC"
|
|
className="text-center"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<span className="mb-1 block text-muted-foreground text-sm">
|
|
Max HP
|
|
</span>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={maxHp}
|
|
onChange={(e) => setMaxHp(e.target.value)}
|
|
placeholder="Max HP"
|
|
aria-label="Max HP"
|
|
className="text-center"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<span className="mb-1 block text-muted-foreground text-sm">
|
|
Level
|
|
</span>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={level}
|
|
onChange={(e) => setLevel(e.target.value)}
|
|
placeholder="1-20"
|
|
aria-label="Level"
|
|
className="text-center"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="mb-2 block text-muted-foreground text-sm">
|
|
Color
|
|
</span>
|
|
<ColorPalette value={color} onChange={setColor} />
|
|
</div>
|
|
|
|
<div>
|
|
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
|
<IconGrid value={icon} onChange={setIcon} />
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
);
|
|
}
|