Split attack/damage dice buttons, nat 20 highlighting, crit damage, character colors

This commit is contained in:
Aaron Wood 2026-04-09 12:27:25 -04:00
parent 9b1a0df8a5
commit 3e2e43ca95
15 changed files with 160 additions and 43 deletions

View file

@ -66,6 +66,11 @@
font-size: 0.75rem;
}
.rollButtons {
display: flex;
gap: 0.2rem;
}
.empty {
font-size: 0.8rem;
color: #555;

View file

@ -7,6 +7,7 @@ interface AttackBlockProps {
campaignId?: number;
characterId?: number;
characterName?: string;
characterColor?: string;
mode?: "view" | "edit";
}
@ -15,6 +16,7 @@ export default function AttackBlock({
campaignId,
characterId,
characterName,
characterColor,
mode,
}: AttackBlockProps) {
const weapons = attacks.filter((a) => !a.isTalent);
@ -41,16 +43,30 @@ export default function AttackBlock({
<span className={styles.damage}>{atk.damage}</span>
</span>
{mode === "view" && campaignId ? (
<DiceButton
campaignId={campaignId}
characterId={characterId}
characterName={characterName}
type="attack"
dice={`1d20${atk.modifier >= 0 ? "+" + atk.modifier : String(atk.modifier)}`}
label={`${atk.name} attack`}
damageDice={atk.damage}
damageLabel={`${atk.name} damage`}
/>
<span className={styles.rollButtons}>
<DiceButton
campaignId={campaignId}
characterId={characterId}
characterName={characterName}
characterColor={characterColor}
type="attack"
dice={`1d20${atk.modifier >= 0 ? "+" + atk.modifier : String(atk.modifier)}`}
label={`${atk.name} attack`}
icon="⚔"
title={`Attack roll: 1d20${atk.modifier >= 0 ? "+" + atk.modifier : atk.modifier}`}
/>
<DiceButton
campaignId={campaignId}
characterId={characterId}
characterName={characterName}
characterColor={characterColor}
type="attack"
dice={atk.damage}
label={`${atk.name} damage`}
icon="💥"
title={`Damage: ${atk.damage} (Shift: crit = double dice)`}
/>
</span>
) : (
<span className={styles.rollSpace}></span>
)}

View file

@ -20,7 +20,11 @@ export default function CharacterCard({
const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
return (
<div className={styles.card} onClick={() => onClick(character.id)}>
<div
className={styles.card}
onClick={() => onClick(character.id)}
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
>
<div className={styles.cardHeader}>
<span className={styles.name}>
{character.name}

View file

@ -77,7 +77,10 @@ export default function CharacterSheet({
return (
<>
{/* HEADER BANNER */}
<div className={styles.banner}>
<div
className={styles.banner}
style={{ borderLeft: `3px solid ${character.color}` }}
>
<div className={styles.identity}>
{mode === "edit" ? (
<div>

View file

@ -1,6 +1,6 @@
.btn {
width: 24px;
height: 24px;
width: 22px;
height: 22px;
border-radius: 4px;
border: 1px solid #444;
background: #16213e;

View file

@ -5,59 +5,66 @@ interface DiceButtonProps {
campaignId: number;
characterId?: number;
characterName?: string;
characterColor?: string;
type: "attack" | "ability-check" | "custom";
dice: string;
label: string;
damageDice?: string;
damageLabel?: string;
icon?: string;
title?: string;
}
export default function DiceButton({
campaignId,
characterId,
characterName,
characterColor,
type,
dice,
label,
damageDice,
damageLabel,
icon = "🎲",
title: titleProp,
}: DiceButtonProps) {
function handleClick(e: React.MouseEvent) {
const advantage = e.shiftKey;
const disadvantage = e.ctrlKey || e.metaKey;
let actualDice = dice;
let actualLabel = label;
let sendAdvantage = advantage && !disadvantage;
let sendDisadvantage = disadvantage && !advantage;
// For damage rolls (non-d20), shift = crit (double dice) instead of advantage
const isD20 = dice.toLowerCase().includes("d20");
if (!isD20 && advantage) {
const match = dice.match(/^(\d*)d(\d+)(.*)/i);
if (match) {
const count = match[1] ? parseInt(match[1], 10) : 1;
actualDice = `${count * 2}d${match[2]}${match[3] || ""}`;
actualLabel = label + " (CRIT)";
}
sendAdvantage = false;
}
socket.emit("roll:request", {
campaignId,
characterId,
characterName,
characterColor,
type,
dice,
label,
advantage: advantage && !disadvantage,
disadvantage: disadvantage && !advantage,
dice: actualDice,
label: actualLabel,
advantage: sendAdvantage,
disadvantage: sendDisadvantage,
});
if (damageDice && damageLabel) {
setTimeout(() => {
socket.emit("roll:request", {
campaignId,
characterId,
characterName,
type: "attack",
dice: damageDice,
label: damageLabel,
});
}, 100);
}
}
return (
<button
className={styles.btn}
onClick={handleClick}
title={`Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
title={titleProp || `Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
>
🎲
{icon}
</button>
);
}

View file

@ -17,6 +17,15 @@
}
}
.card.nat20 {
border-color: #ffd700;
background: linear-gradient(135deg, #1a1a0e, #0f1a30);
}
.card.nat20 .total {
color: #ffd700;
}
.card.fresh {
border-color: #c9a84c;
animation:
@ -104,3 +113,28 @@
font-weight: 600;
text-transform: uppercase;
}
.critBanner {
text-align: center;
font-size: 0.75rem;
font-weight: 700;
color: #ffd700;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0.15rem 0;
animation: critPulse 0.5s ease-out;
}
@keyframes critPulse {
0% {
transform: scale(1.3);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}

View file

@ -28,11 +28,19 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) {
: isDisadvantage
? Math.min(...rolls)
: null;
const isNat20 = roll.nat20;
return (
<div className={`${styles.card} ${fresh ? styles.fresh : ""}`}>
<div
className={`${styles.card} ${fresh ? styles.fresh : ""} ${isNat20 ? styles.nat20 : ""}`}
>
<div className={styles.topLine}>
<span className={styles.charName}>{roll.character_name}</span>
<span
className={styles.charName}
style={{ color: roll.character_color || "#c9a84c" }}
>
{roll.character_name}
</span>
<span className={styles.timestamp}>{timeAgo(roll.created_at)}</span>
</div>
<div className={styles.label}>
@ -40,6 +48,7 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) {
{isAdvantage && <span className={styles.advantage}> ADV</span>}
{isDisadvantage && <span className={styles.disadvantage}> DIS</span>}
</div>
{isNat20 && <div className={styles.critBanner}>NAT 20!</div>}
<div className={styles.breakdown}>
{dice_expression}: [
{rolls.map((r, i) => (

View file

@ -12,6 +12,7 @@ interface StatBlockProps {
campaignId?: number;
characterId?: number;
characterName?: string;
characterColor?: string;
}
export default function StatBlock({
@ -21,6 +22,7 @@ export default function StatBlock({
campaignId,
characterId,
characterName,
characterColor,
}: StatBlockProps) {
const statMap = new Map(stats.map((s) => [s.stat_name, s.value]));
@ -47,6 +49,7 @@ export default function StatBlock({
campaignId={campaignId}
characterId={characterId}
characterName={characterName}
characterColor={characterColor}
type="ability-check"
dice={`1d20${mod >= 0 ? "+" + mod : String(mod)}`}
label={`${name} check`}

View file

@ -31,6 +31,7 @@ export default function StatsPanel({
campaignId={campaignId}
characterId={character.id}
characterName={character.name}
characterColor={character.color}
/>
<hr className={styles.separator} />
<AttackBlock
@ -38,6 +39,7 @@ export default function StatsPanel({
campaignId={campaignId}
characterId={character.id}
characterName={character.name}
characterColor={character.color}
mode={mode}
/>
</div>

View file

@ -53,6 +53,7 @@ export interface Character {
cp: number;
gear_slots_max: number;
overrides: Record<string, unknown>;
color: string;
stats: Stat[];
gear: Gear[];
talents: Talent[];
@ -90,6 +91,7 @@ export interface RollResult {
campaign_id: number;
character_id: number | null;
character_name: string;
character_color: string;
type: "attack" | "ability-check" | "custom";
label: string;
dice_expression: string;
@ -98,5 +100,6 @@ export interface RollResult {
total: number;
advantage: boolean;
disadvantage: boolean;
nat20: boolean;
created_at: string;
}

View file

@ -91,6 +91,8 @@ db.exec(`
total INTEGER NOT NULL DEFAULT 0,
advantage INTEGER NOT NULL DEFAULT 0,
disadvantage INTEGER NOT NULL DEFAULT 0,
nat20 INTEGER NOT NULL DEFAULT 0,
character_color TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
`);
@ -108,6 +110,9 @@ const v2Columns: Array<[string, string, string]> = [
["character_gear", "game_item_id", "INTEGER"],
["character_gear", "effects", "TEXT DEFAULT '{}'"],
["character_talents", "game_talent_id", "INTEGER"],
["characters", "color", "TEXT DEFAULT ''"],
["roll_log", "nat20", "INTEGER DEFAULT 0"],
["roll_log", "character_color", "TEXT DEFAULT ''"],
];
for (const [table, column, definition] of v2Columns) {

View file

@ -10,6 +10,11 @@ const router = Router({ mergeParams: true });
const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
function generateCharacterColor(): string {
const hue = Math.floor(Math.random() * 360);
return `hsl(${hue}, 60%, 65%)`;
}
function parseJson(val: unknown): Record<string, unknown> {
if (typeof val === "string") {
try {
@ -74,8 +79,8 @@ router.post<CampaignParams>("/", (req, res) => {
}
const insertChar = db.prepare(`
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max, color)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertStat = db.prepare(
@ -89,6 +94,7 @@ router.post<CampaignParams>("/", (req, res) => {
ancestry || "Human",
hp_max || 0,
hp_max || 0,
generateCharacterColor(),
);
const characterId = result.lastInsertRowid;

View file

@ -16,6 +16,7 @@ router.get("/", (req, res) => {
rolls: JSON.parse(r.rolls as string),
advantage: r.advantage === 1,
disadvantage: r.disadvantage === 1,
nat20: r.nat20 === 1,
}));
res.json(parsed);

View file

@ -18,6 +18,7 @@ export function setupSocket(io: Server) {
campaignId: number;
characterId?: number;
characterName?: string;
characterColor?: string;
type: string;
dice: string;
label: string;
@ -35,17 +36,33 @@ export function setupSocket(io: Server) {
return;
}
// Detect nat 20
const isD20Roll = data.dice.match(/d20/i);
let nat20 = false;
if (isD20Roll && result.rolls.length > 0) {
if (data.advantage) {
// With advantage, nat20 if the chosen (higher) die is 20
nat20 = Math.max(...result.rolls) === 20;
} else if (data.disadvantage) {
// With disadvantage, nat20 if the chosen (lower) die is 20
nat20 = Math.min(...result.rolls) === 20;
} else {
nat20 = result.rolls[0] === 20;
}
}
const row = db
.prepare(
`
INSERT INTO roll_log (campaign_id, character_id, character_name, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
data.campaignId,
data.characterId ?? null,
data.characterName || "Roll",
data.characterColor || "",
data.type || "custom",
data.label,
data.dice,
@ -54,6 +71,7 @@ export function setupSocket(io: Server) {
result.total,
data.advantage ? 1 : 0,
data.disadvantage ? 1 : 0,
nat20 ? 1 : 0,
);
const saved = db
@ -65,6 +83,7 @@ export function setupSocket(io: Server) {
rolls: result.rolls,
advantage: data.advantage || false,
disadvantage: data.disadvantage || false,
nat20,
};
io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast);