From b0fa70976730122c7f2ce0f2b98728b755a8e99d Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Thu, 9 Apr 2026 12:52:54 -0400 Subject: [PATCH] Color bars on roll entries, auto-crit damage on nat 20, pulsing crit indicator on damage button --- client/src/components/AttackBlock.tsx | 11 +++++++++-- client/src/components/CharacterDetail.tsx | 3 +++ client/src/components/CharacterSheet.tsx | 3 +++ client/src/components/DiceButton.module.css | 16 ++++++++++++++++ client/src/components/DiceButton.tsx | 8 +++++--- client/src/components/RollEntry.module.css | 2 ++ client/src/components/RollEntry.tsx | 1 + client/src/components/StatsPanel.tsx | 3 +++ client/src/pages/CampaignView.tsx | 15 +++++++++++++++ 9 files changed, 57 insertions(+), 5 deletions(-) diff --git a/client/src/components/AttackBlock.tsx b/client/src/components/AttackBlock.tsx index 425f718..f5125c8 100644 --- a/client/src/components/AttackBlock.tsx +++ b/client/src/components/AttackBlock.tsx @@ -9,6 +9,7 @@ interface AttackBlockProps { characterName?: string; characterColor?: string; mode?: "view" | "edit"; + isCrit?: boolean; } export default function AttackBlock({ @@ -18,6 +19,7 @@ export default function AttackBlock({ characterName, characterColor, mode, + isCrit, }: AttackBlockProps) { const weapons = attacks.filter((a) => !a.isTalent); const talents = attacks.filter((a) => a.isTalent); @@ -63,8 +65,13 @@ export default function AttackBlock({ type="attack" dice={atk.damage} label={`${atk.name} damage`} - icon="💥" - title={`Damage: ${atk.damage} (Shift: crit = double dice)`} + icon={isCrit ? "💥" : "💥"} + title={ + isCrit + ? `CRIT damage: double ${atk.damage}` + : `Damage: ${atk.damage} (Shift: crit = double dice)` + } + forceCrit={isCrit} /> ) : ( diff --git a/client/src/components/CharacterDetail.tsx b/client/src/components/CharacterDetail.tsx index f178c73..366985a 100644 --- a/client/src/components/CharacterDetail.tsx +++ b/client/src/components/CharacterDetail.tsx @@ -6,6 +6,7 @@ import styles from "./CharacterDetail.module.css"; interface CharacterDetailProps { character: Character; campaignId: number; + isCrit?: boolean; onUpdate: (id: number, data: Partial) => void; onStatChange: (characterId: number, statName: string, value: number) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void; @@ -31,6 +32,7 @@ interface CharacterDetailProps { export default function CharacterDetail({ character, campaignId, + isCrit, onUpdate, onStatChange, onAddGearFromItem, @@ -62,6 +64,7 @@ export default function CharacterDetail({ character={character} mode={mode} campaignId={campaignId} + isCrit={isCrit} onUpdate={onUpdate} onStatChange={onStatChange} onAddGearFromItem={onAddGearFromItem} diff --git a/client/src/components/CharacterSheet.tsx b/client/src/components/CharacterSheet.tsx index bc2514d..b372450 100644 --- a/client/src/components/CharacterSheet.tsx +++ b/client/src/components/CharacterSheet.tsx @@ -12,6 +12,7 @@ interface CharacterSheetProps { character: Character; mode: "view" | "edit"; campaignId: number; + isCrit?: boolean; onUpdate: (id: number, data: Partial) => void; onStatChange: (characterId: number, statName: string, value: number) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void; @@ -37,6 +38,7 @@ export default function CharacterSheet({ character, mode, campaignId, + isCrit, onUpdate, onStatChange, onAddGearFromItem, @@ -163,6 +165,7 @@ export default function CharacterSheet({ character={character} mode={mode} campaignId={campaignId} + isCrit={isCrit} onStatChange={onStatChange} /> diff --git a/client/src/components/RollEntry.module.css b/client/src/components/RollEntry.module.css index 4e780f4..dde812c 100644 --- a/client/src/components/RollEntry.module.css +++ b/client/src/components/RollEntry.module.css @@ -4,6 +4,8 @@ border-radius: 6px; padding: 0.5rem 0.6rem; animation: slideIn 0.3s ease-out; + border-left-width: 3px; + border-left-style: solid; } @keyframes slideIn { diff --git a/client/src/components/RollEntry.tsx b/client/src/components/RollEntry.tsx index 67b9850..56fdca9 100644 --- a/client/src/components/RollEntry.tsx +++ b/client/src/components/RollEntry.tsx @@ -33,6 +33,7 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) { return (
void; } @@ -15,6 +16,7 @@ export default function StatsPanel({ character, mode, campaignId, + isCrit, onStatChange, }: StatsPanelProps) { const attacks = generateAttacks(character); @@ -41,6 +43,7 @@ export default function StatsPanel({ characterName={character.name} characterColor={character.color} mode={mode} + isCrit={isCrit} />
); diff --git a/client/src/pages/CampaignView.tsx b/client/src/pages/CampaignView.tsx index 1ac9e40..778c864 100644 --- a/client/src/pages/CampaignView.tsx +++ b/client/src/pages/CampaignView.tsx @@ -36,6 +36,7 @@ export default function CampaignView() { }); const [rolls, setRolls] = useState([]); const [freshIds, setFreshIds] = useState>(new Set()); + const [critCharIds, setCritCharIds] = useState>(new Set()); // Fetch characters and join socket room useEffect(() => { @@ -170,6 +171,19 @@ export default function CampaignView() { return next; }); }, 2000); + + // Track crits: if this is a nat20 attack, mark character for crit damage + if (roll.nat20 && roll.character_id && roll.label.includes("attack")) { + setCritCharIds((prev) => new Set(prev).add(roll.character_id!)); + } + // If this is a damage roll, clear the crit flag for that character + if (roll.character_id && roll.label.includes("damage")) { + setCritCharIds((prev) => { + const next = new Set(prev); + next.delete(roll.character_id!); + return next; + }); + } } socket.on("character:created", onCharacterCreated); @@ -302,6 +316,7 @@ export default function CampaignView() {