Split attack/damage dice buttons, nat 20 highlighting, crit damage, character colors
This commit is contained in:
parent
9b1a0df8a5
commit
3e2e43ca95
15 changed files with 160 additions and 43 deletions
|
|
@ -66,6 +66,11 @@
|
|||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.rollButtons {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 0.8rem;
|
||||
color: #555;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
background: #16213e;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue