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);