Color bars on roll entries, auto-crit damage on nat 20, pulsing crit indicator on damage button

This commit is contained in:
Aaron Wood 2026-04-09 12:52:54 -04:00
parent 28e57a77ee
commit b0fa709767
9 changed files with 57 additions and 5 deletions

View file

@ -9,6 +9,7 @@ interface AttackBlockProps {
characterName?: string; characterName?: string;
characterColor?: string; characterColor?: string;
mode?: "view" | "edit"; mode?: "view" | "edit";
isCrit?: boolean;
} }
export default function AttackBlock({ export default function AttackBlock({
@ -18,6 +19,7 @@ export default function AttackBlock({
characterName, characterName,
characterColor, characterColor,
mode, mode,
isCrit,
}: AttackBlockProps) { }: AttackBlockProps) {
const weapons = attacks.filter((a) => !a.isTalent); const weapons = attacks.filter((a) => !a.isTalent);
const talents = attacks.filter((a) => a.isTalent); const talents = attacks.filter((a) => a.isTalent);
@ -63,8 +65,13 @@ export default function AttackBlock({
type="attack" type="attack"
dice={atk.damage} dice={atk.damage}
label={`${atk.name} damage`} label={`${atk.name} damage`}
icon="💥" icon={isCrit ? "💥" : "💥"}
title={`Damage: ${atk.damage} (Shift: crit = double dice)`} title={
isCrit
? `CRIT damage: double ${atk.damage}`
: `Damage: ${atk.damage} (Shift: crit = double dice)`
}
forceCrit={isCrit}
/> />
</span> </span>
) : ( ) : (

View file

@ -6,6 +6,7 @@ import styles from "./CharacterDetail.module.css";
interface CharacterDetailProps { interface CharacterDetailProps {
character: Character; character: Character;
campaignId: number; campaignId: number;
isCrit?: boolean;
onUpdate: (id: number, data: Partial<Character>) => void; onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void;
@ -31,6 +32,7 @@ interface CharacterDetailProps {
export default function CharacterDetail({ export default function CharacterDetail({
character, character,
campaignId, campaignId,
isCrit,
onUpdate, onUpdate,
onStatChange, onStatChange,
onAddGearFromItem, onAddGearFromItem,
@ -62,6 +64,7 @@ export default function CharacterDetail({
character={character} character={character}
mode={mode} mode={mode}
campaignId={campaignId} campaignId={campaignId}
isCrit={isCrit}
onUpdate={onUpdate} onUpdate={onUpdate}
onStatChange={onStatChange} onStatChange={onStatChange}
onAddGearFromItem={onAddGearFromItem} onAddGearFromItem={onAddGearFromItem}

View file

@ -12,6 +12,7 @@ interface CharacterSheetProps {
character: Character; character: Character;
mode: "view" | "edit"; mode: "view" | "edit";
campaignId: number; campaignId: number;
isCrit?: boolean;
onUpdate: (id: number, data: Partial<Character>) => void; onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void; onAddGearFromItem: (characterId: number, item: GameItem) => void;
@ -37,6 +38,7 @@ export default function CharacterSheet({
character, character,
mode, mode,
campaignId, campaignId,
isCrit,
onUpdate, onUpdate,
onStatChange, onStatChange,
onAddGearFromItem, onAddGearFromItem,
@ -163,6 +165,7 @@ export default function CharacterSheet({
character={character} character={character}
mode={mode} mode={mode}
campaignId={campaignId} campaignId={campaignId}
isCrit={isCrit}
onStatChange={onStatChange} onStatChange={onStatChange}
/> />
<InfoPanel <InfoPanel

View file

@ -25,3 +25,19 @@
.btn:active { .btn:active {
background: rgba(201, 168, 76, 0.25); background: rgba(201, 168, 76, 0.25);
} }
.btn.crit {
border-color: #ffd700;
color: #ffd700;
animation: critPulse 1s ease-in-out infinite;
}
@keyframes critPulse {
0%,
100% {
box-shadow: 0 0 4px rgba(255, 215, 0, 0.3);
}
50% {
box-shadow: 0 0 8px rgba(255, 215, 0, 0.6);
}
}

View file

@ -11,6 +11,7 @@ interface DiceButtonProps {
label: string; label: string;
icon?: string; icon?: string;
title?: string; title?: string;
forceCrit?: boolean;
} }
export default function DiceButton({ export default function DiceButton({
@ -23,6 +24,7 @@ export default function DiceButton({
label, label,
icon = "🎲", icon = "🎲",
title: titleProp, title: titleProp,
forceCrit,
}: DiceButtonProps) { }: DiceButtonProps) {
function handleClick(e: React.MouseEvent) { function handleClick(e: React.MouseEvent) {
const advantage = e.shiftKey; const advantage = e.shiftKey;
@ -33,9 +35,9 @@ export default function DiceButton({
let sendAdvantage = advantage && !disadvantage; let sendAdvantage = advantage && !disadvantage;
let sendDisadvantage = disadvantage && !advantage; let sendDisadvantage = disadvantage && !advantage;
// For damage rolls (non-d20), shift = crit (double dice) instead of advantage // For damage rolls (non-d20), shift or forceCrit = crit (double dice)
const isD20 = dice.toLowerCase().includes("d20"); const isD20 = dice.toLowerCase().includes("d20");
if (!isD20 && advantage) { if (!isD20 && (advantage || forceCrit)) {
const match = dice.match(/^(\d*)d(\d+)(.*)/i); const match = dice.match(/^(\d*)d(\d+)(.*)/i);
if (match) { if (match) {
const count = match[1] ? parseInt(match[1], 10) : 1; const count = match[1] ? parseInt(match[1], 10) : 1;
@ -60,7 +62,7 @@ export default function DiceButton({
return ( return (
<button <button
className={styles.btn} className={`${styles.btn} ${forceCrit ? styles.crit : ""}`}
onClick={handleClick} onClick={handleClick}
title={titleProp || `Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`} title={titleProp || `Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
> >

View file

@ -4,6 +4,8 @@
border-radius: 6px; border-radius: 6px;
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
animation: slideIn 0.3s ease-out; animation: slideIn 0.3s ease-out;
border-left-width: 3px;
border-left-style: solid;
} }
@keyframes slideIn { @keyframes slideIn {

View file

@ -33,6 +33,7 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) {
return ( return (
<div <div
className={`${styles.card} ${fresh ? styles.fresh : ""} ${isNat20 ? styles.nat20 : ""}`} className={`${styles.card} ${fresh ? styles.fresh : ""} ${isNat20 ? styles.nat20 : ""}`}
style={{ borderLeftColor: roll.character_color || "#2a2a4a" }}
> >
<div className={styles.topLine}> <div className={styles.topLine}>
<span <span

View file

@ -8,6 +8,7 @@ interface StatsPanelProps {
character: Character; character: Character;
mode: "view" | "edit"; mode: "view" | "edit";
campaignId: number; campaignId: number;
isCrit?: boolean;
onStatChange: (characterId: number, statName: string, value: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void;
} }
@ -15,6 +16,7 @@ export default function StatsPanel({
character, character,
mode, mode,
campaignId, campaignId,
isCrit,
onStatChange, onStatChange,
}: StatsPanelProps) { }: StatsPanelProps) {
const attacks = generateAttacks(character); const attacks = generateAttacks(character);
@ -41,6 +43,7 @@ export default function StatsPanel({
characterName={character.name} characterName={character.name}
characterColor={character.color} characterColor={character.color}
mode={mode} mode={mode}
isCrit={isCrit}
/> />
</div> </div>
); );

View file

@ -36,6 +36,7 @@ export default function CampaignView() {
}); });
const [rolls, setRolls] = useState<RollResult[]>([]); const [rolls, setRolls] = useState<RollResult[]>([]);
const [freshIds, setFreshIds] = useState<Set<number>>(new Set()); const [freshIds, setFreshIds] = useState<Set<number>>(new Set());
const [critCharIds, setCritCharIds] = useState<Set<number>>(new Set());
// Fetch characters and join socket room // Fetch characters and join socket room
useEffect(() => { useEffect(() => {
@ -170,6 +171,19 @@ export default function CampaignView() {
return next; return next;
}); });
}, 2000); }, 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); socket.on("character:created", onCharacterCreated);
@ -302,6 +316,7 @@ export default function CampaignView() {
<CharacterDetail <CharacterDetail
character={selectedCharacter} character={selectedCharacter}
campaignId={campaignId} campaignId={campaignId}
isCrit={critCharIds.has(selectedCharacter.id)}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onStatChange={handleStatChange} onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem} onAddGearFromItem={handleAddGearFromItem}