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; font-size: 0.75rem;
} }
.rollButtons {
display: flex;
gap: 0.2rem;
}
.empty { .empty {
font-size: 0.8rem; font-size: 0.8rem;
color: #555; color: #555;

View file

@ -7,6 +7,7 @@ interface AttackBlockProps {
campaignId?: number; campaignId?: number;
characterId?: number; characterId?: number;
characterName?: string; characterName?: string;
characterColor?: string;
mode?: "view" | "edit"; mode?: "view" | "edit";
} }
@ -15,6 +16,7 @@ export default function AttackBlock({
campaignId, campaignId,
characterId, characterId,
characterName, characterName,
characterColor,
mode, mode,
}: AttackBlockProps) { }: AttackBlockProps) {
const weapons = attacks.filter((a) => !a.isTalent); const weapons = attacks.filter((a) => !a.isTalent);
@ -41,16 +43,30 @@ export default function AttackBlock({
<span className={styles.damage}>{atk.damage}</span> <span className={styles.damage}>{atk.damage}</span>
</span> </span>
{mode === "view" && campaignId ? ( {mode === "view" && campaignId ? (
<DiceButton <span className={styles.rollButtons}>
campaignId={campaignId} <DiceButton
characterId={characterId} campaignId={campaignId}
characterName={characterName} characterId={characterId}
type="attack" characterName={characterName}
dice={`1d20${atk.modifier >= 0 ? "+" + atk.modifier : String(atk.modifier)}`} characterColor={characterColor}
label={`${atk.name} attack`} type="attack"
damageDice={atk.damage} dice={`1d20${atk.modifier >= 0 ? "+" + atk.modifier : String(atk.modifier)}`}
damageLabel={`${atk.name} damage`} 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> <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); const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
return ( 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}> <div className={styles.cardHeader}>
<span className={styles.name}> <span className={styles.name}>
{character.name} {character.name}

View file

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

View file

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

View file

@ -5,59 +5,66 @@ interface DiceButtonProps {
campaignId: number; campaignId: number;
characterId?: number; characterId?: number;
characterName?: string; characterName?: string;
characterColor?: string;
type: "attack" | "ability-check" | "custom"; type: "attack" | "ability-check" | "custom";
dice: string; dice: string;
label: string; label: string;
damageDice?: string; icon?: string;
damageLabel?: string; title?: string;
} }
export default function DiceButton({ export default function DiceButton({
campaignId, campaignId,
characterId, characterId,
characterName, characterName,
characterColor,
type, type,
dice, dice,
label, label,
damageDice, icon = "🎲",
damageLabel, title: titleProp,
}: DiceButtonProps) { }: DiceButtonProps) {
function handleClick(e: React.MouseEvent) { function handleClick(e: React.MouseEvent) {
const advantage = e.shiftKey; const advantage = e.shiftKey;
const disadvantage = e.ctrlKey || e.metaKey; 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", { socket.emit("roll:request", {
campaignId, campaignId,
characterId, characterId,
characterName, characterName,
characterColor,
type, type,
dice, dice: actualDice,
label, label: actualLabel,
advantage: advantage && !disadvantage, advantage: sendAdvantage,
disadvantage: disadvantage && !advantage, disadvantage: sendDisadvantage,
}); });
if (damageDice && damageLabel) {
setTimeout(() => {
socket.emit("roll:request", {
campaignId,
characterId,
characterName,
type: "attack",
dice: damageDice,
label: damageLabel,
});
}, 100);
}
} }
return ( return (
<button <button
className={styles.btn} className={styles.btn}
onClick={handleClick} onClick={handleClick}
title={`Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`} title={titleProp || `Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
> >
🎲 {icon}
</button> </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 { .card.fresh {
border-color: #c9a84c; border-color: #c9a84c;
animation: animation:
@ -104,3 +113,28 @@
font-weight: 600; font-weight: 600;
text-transform: uppercase; 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 : isDisadvantage
? Math.min(...rolls) ? Math.min(...rolls)
: null; : null;
const isNat20 = roll.nat20;
return ( return (
<div className={`${styles.card} ${fresh ? styles.fresh : ""}`}> <div
className={`${styles.card} ${fresh ? styles.fresh : ""} ${isNat20 ? styles.nat20 : ""}`}
>
<div className={styles.topLine}> <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> <span className={styles.timestamp}>{timeAgo(roll.created_at)}</span>
</div> </div>
<div className={styles.label}> <div className={styles.label}>
@ -40,6 +48,7 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) {
{isAdvantage && <span className={styles.advantage}> ADV</span>} {isAdvantage && <span className={styles.advantage}> ADV</span>}
{isDisadvantage && <span className={styles.disadvantage}> DIS</span>} {isDisadvantage && <span className={styles.disadvantage}> DIS</span>}
</div> </div>
{isNat20 && <div className={styles.critBanner}>NAT 20!</div>}
<div className={styles.breakdown}> <div className={styles.breakdown}>
{dice_expression}: [ {dice_expression}: [
{rolls.map((r, i) => ( {rolls.map((r, i) => (

View file

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

View file

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

View file

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

View file

@ -91,6 +91,8 @@ db.exec(`
total INTEGER NOT NULL DEFAULT 0, total INTEGER NOT NULL DEFAULT 0,
advantage INTEGER NOT NULL DEFAULT 0, advantage INTEGER NOT NULL DEFAULT 0,
disadvantage 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')) created_at TEXT DEFAULT (datetime('now'))
); );
`); `);
@ -108,6 +110,9 @@ const v2Columns: Array<[string, string, string]> = [
["character_gear", "game_item_id", "INTEGER"], ["character_gear", "game_item_id", "INTEGER"],
["character_gear", "effects", "TEXT DEFAULT '{}'"], ["character_gear", "effects", "TEXT DEFAULT '{}'"],
["character_talents", "game_talent_id", "INTEGER"], ["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) { 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"]; 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> { function parseJson(val: unknown): Record<string, unknown> {
if (typeof val === "string") { if (typeof val === "string") {
try { try {
@ -74,8 +79,8 @@ router.post<CampaignParams>("/", (req, res) => {
} }
const insertChar = db.prepare(` const insertChar = db.prepare(`
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max) INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max, color)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
const insertStat = db.prepare( const insertStat = db.prepare(
@ -89,6 +94,7 @@ router.post<CampaignParams>("/", (req, res) => {
ancestry || "Human", ancestry || "Human",
hp_max || 0, hp_max || 0,
hp_max || 0, hp_max || 0,
generateCharacterColor(),
); );
const characterId = result.lastInsertRowid; const characterId = result.lastInsertRowid;

View file

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

View file

@ -18,6 +18,7 @@ export function setupSocket(io: Server) {
campaignId: number; campaignId: number;
characterId?: number; characterId?: number;
characterName?: string; characterName?: string;
characterColor?: string;
type: string; type: string;
dice: string; dice: string;
label: string; label: string;
@ -35,17 +36,33 @@ export function setupSocket(io: Server) {
return; 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 const row = db
.prepare( .prepare(
` `
INSERT INTO roll_log (campaign_id, character_id, character_name, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage) INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(
data.campaignId, data.campaignId,
data.characterId ?? null, data.characterId ?? null,
data.characterName || "Roll", data.characterName || "Roll",
data.characterColor || "",
data.type || "custom", data.type || "custom",
data.label, data.label,
data.dice, data.dice,
@ -54,6 +71,7 @@ export function setupSocket(io: Server) {
result.total, result.total,
data.advantage ? 1 : 0, data.advantage ? 1 : 0,
data.disadvantage ? 1 : 0, data.disadvantage ? 1 : 0,
nat20 ? 1 : 0,
); );
const saved = db const saved = db
@ -65,6 +83,7 @@ export function setupSocket(io: Server) {
rolls: result.rolls, rolls: result.rolls,
advantage: data.advantage || false, advantage: data.advantage || false,
disadvantage: data.disadvantage || false, disadvantage: data.disadvantage || false,
nat20,
}; };
io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast);