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;
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}
/>
</span>
) : (

View file

@ -6,6 +6,7 @@ import styles from "./CharacterDetail.module.css";
interface CharacterDetailProps {
character: Character;
campaignId: number;
isCrit?: boolean;
onUpdate: (id: number, data: Partial<Character>) => 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}

View file

@ -12,6 +12,7 @@ interface CharacterSheetProps {
character: Character;
mode: "view" | "edit";
campaignId: number;
isCrit?: boolean;
onUpdate: (id: number, data: Partial<Character>) => 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}
/>
<InfoPanel

View file

@ -25,3 +25,19 @@
.btn:active {
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;
icon?: string;
title?: string;
forceCrit?: boolean;
}
export default function DiceButton({
@ -23,6 +24,7 @@ export default function DiceButton({
label,
icon = "🎲",
title: titleProp,
forceCrit,
}: DiceButtonProps) {
function handleClick(e: React.MouseEvent) {
const advantage = e.shiftKey;
@ -33,9 +35,9 @@ export default function DiceButton({
let sendAdvantage = advantage && !disadvantage;
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");
if (!isD20 && advantage) {
if (!isD20 && (advantage || forceCrit)) {
const match = dice.match(/^(\d*)d(\d+)(.*)/i);
if (match) {
const count = match[1] ? parseInt(match[1], 10) : 1;
@ -60,7 +62,7 @@ export default function DiceButton({
return (
<button
className={styles.btn}
className={`${styles.btn} ${forceCrit ? styles.crit : ""}`}
onClick={handleClick}
title={titleProp || `Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
>

View file

@ -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 {

View file

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

View file

@ -8,6 +8,7 @@ interface StatsPanelProps {
character: Character;
mode: "view" | "edit";
campaignId: number;
isCrit?: boolean;
onStatChange: (characterId: number, statName: string, value: number) => 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}
/>
</div>
);

View file

@ -36,6 +36,7 @@ export default function CampaignView() {
});
const [rolls, setRolls] = useState<RollResult[]>([]);
const [freshIds, setFreshIds] = useState<Set<number>>(new Set());
const [critCharIds, setCritCharIds] = useState<Set<number>>(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() {
<CharacterDetail
character={selectedCharacter}
campaignId={campaignId}
isCrit={critCharIds.has(selectedCharacter.id)}
onUpdate={handleUpdate}
onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem}