diff --git a/client/src/components/AttackBlock.module.css b/client/src/components/AttackBlock.module.css index 1369fc1..ecea745 100644 --- a/client/src/components/AttackBlock.module.css +++ b/client/src/components/AttackBlock.module.css @@ -66,6 +66,11 @@ font-size: 0.75rem; } +.rollButtons { + display: flex; + gap: 0.2rem; +} + .empty { font-size: 0.8rem; color: #555; diff --git a/client/src/components/AttackBlock.tsx b/client/src/components/AttackBlock.tsx index d16e5af..425f718 100644 --- a/client/src/components/AttackBlock.tsx +++ b/client/src/components/AttackBlock.tsx @@ -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({ {atk.damage} {mode === "view" && campaignId ? ( - = 0 ? "+" + atk.modifier : String(atk.modifier)}`} - label={`${atk.name} attack`} - damageDice={atk.damage} - damageLabel={`${atk.name} damage`} - /> + + = 0 ? "+" + atk.modifier : String(atk.modifier)}`} + label={`${atk.name} attack`} + icon="⚔" + title={`Attack roll: 1d20${atk.modifier >= 0 ? "+" + atk.modifier : atk.modifier}`} + /> + + ) : ( )} diff --git a/client/src/components/CharacterCard.tsx b/client/src/components/CharacterCard.tsx index 2257c91..8cba526 100644 --- a/client/src/components/CharacterCard.tsx +++ b/client/src/components/CharacterCard.tsx @@ -20,7 +20,11 @@ export default function CharacterCard({ const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0); return ( -
onClick(character.id)}> +
onClick(character.id)} + style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }} + >
{character.name} diff --git a/client/src/components/CharacterSheet.tsx b/client/src/components/CharacterSheet.tsx index 6433042..bc2514d 100644 --- a/client/src/components/CharacterSheet.tsx +++ b/client/src/components/CharacterSheet.tsx @@ -77,7 +77,10 @@ export default function CharacterSheet({ return ( <> {/* HEADER BANNER */} -
+
{mode === "edit" ? (
diff --git a/client/src/components/DiceButton.module.css b/client/src/components/DiceButton.module.css index 75044c4..b16de0d 100644 --- a/client/src/components/DiceButton.module.css +++ b/client/src/components/DiceButton.module.css @@ -1,6 +1,6 @@ .btn { - width: 24px; - height: 24px; + width: 22px; + height: 22px; border-radius: 4px; border: 1px solid #444; background: #16213e; diff --git a/client/src/components/DiceButton.tsx b/client/src/components/DiceButton.tsx index ef0d6dc..4582fa1 100644 --- a/client/src/components/DiceButton.tsx +++ b/client/src/components/DiceButton.tsx @@ -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 ( ); } diff --git a/client/src/components/RollEntry.module.css b/client/src/components/RollEntry.module.css index cbff5e1..4e780f4 100644 --- a/client/src/components/RollEntry.module.css +++ b/client/src/components/RollEntry.module.css @@ -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; + } +} diff --git a/client/src/components/RollEntry.tsx b/client/src/components/RollEntry.tsx index 19063fa..67b9850 100644 --- a/client/src/components/RollEntry.tsx +++ b/client/src/components/RollEntry.tsx @@ -28,11 +28,19 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) { : isDisadvantage ? Math.min(...rolls) : null; + const isNat20 = roll.nat20; return ( -
+
- {roll.character_name} + + {roll.character_name} + {timeAgo(roll.created_at)}
@@ -40,6 +48,7 @@ export default function RollEntry({ roll, fresh }: RollEntryProps) { {isAdvantage && ADV} {isDisadvantage && DIS}
+ {isNat20 &&
NAT 20!
}
{dice_expression}: [ {rolls.map((r, i) => ( diff --git a/client/src/components/StatBlock.tsx b/client/src/components/StatBlock.tsx index be2b675..1e265e6 100644 --- a/client/src/components/StatBlock.tsx +++ b/client/src/components/StatBlock.tsx @@ -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`} diff --git a/client/src/components/StatsPanel.tsx b/client/src/components/StatsPanel.tsx index 889c774..a8b3dd4 100644 --- a/client/src/components/StatsPanel.tsx +++ b/client/src/components/StatsPanel.tsx @@ -31,6 +31,7 @@ export default function StatsPanel({ campaignId={campaignId} characterId={character.id} characterName={character.name} + characterColor={character.color} />
diff --git a/client/src/types.ts b/client/src/types.ts index 78faf39..16d44fc 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -53,6 +53,7 @@ export interface Character { cp: number; gear_slots_max: number; overrides: Record; + 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; } diff --git a/server/src/db.ts b/server/src/db.ts index 14c5327..7a03994 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -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) { diff --git a/server/src/routes/characters.ts b/server/src/routes/characters.ts index 6044bac..fe59c35 100644 --- a/server/src/routes/characters.ts +++ b/server/src/routes/characters.ts @@ -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 { if (typeof val === "string") { try { @@ -74,8 +79,8 @@ router.post("/", (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("/", (req, res) => { ancestry || "Human", hp_max || 0, hp_max || 0, + generateCharacterColor(), ); const characterId = result.lastInsertRowid; diff --git a/server/src/routes/rolls.ts b/server/src/routes/rolls.ts index b52f7c7..b291a71 100644 --- a/server/src/routes/rolls.ts +++ b/server/src/routes/rolls.ts @@ -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); diff --git a/server/src/socket.ts b/server/src/socket.ts index a9c1c9a..1db2b4b 100644 --- a/server/src/socket.ts +++ b/server/src/socket.ts @@ -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);