Color bars on roll entries, auto-crit damage on nat 20, pulsing crit indicator on damage button
This commit is contained in:
parent
28e57a77ee
commit
b0fa709767
9 changed files with 57 additions and 5 deletions
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue