feat: CharacterCard dying pulse border + countdown; dead state; DM Revive button
Add dying (pulsing red border + skull countdown in vitals), dead (greyed-out, skull prefix on name, HP bar no-op), and DM-only Revive button. Wire isDM prop via role check in CampaignView. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f853fdbce3
commit
d39d6fa9b7
3 changed files with 85 additions and 10 deletions
|
|
@ -153,3 +153,45 @@
|
|||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dying {
|
||||
border: 2px solid var(--danger) !important;
|
||||
animation: dyingPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dyingPulse {
|
||||
0%, 100% { box-shadow: 0 0 6px rgba(var(--danger-rgb), 0.4); }
|
||||
50% { box-shadow: 0 0 20px rgba(var(--danger-rgb), 0.85); }
|
||||
}
|
||||
|
||||
.dead {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.75);
|
||||
}
|
||||
|
||||
.dyingLabel {
|
||||
font-size: 0.8rem;
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reviveBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: "Cinzel", Georgia, serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.reviveBtn:hover {
|
||||
background: rgba(var(--gold-rgb), 0.12);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface CharacterCardProps {
|
|||
onClick: (characterId: number) => void;
|
||||
canEdit?: boolean;
|
||||
focusSpell?: string;
|
||||
isDM?: boolean;
|
||||
}
|
||||
|
||||
export default function CharacterCard({
|
||||
|
|
@ -31,26 +32,42 @@ export default function CharacterCard({
|
|||
onClick,
|
||||
canEdit = true,
|
||||
focusSpell,
|
||||
isDM = false,
|
||||
}: CharacterCardProps) {
|
||||
const dyingCondition = character.conditions?.find((c) => c.name === "Dying");
|
||||
const isDying = !!dyingCondition;
|
||||
const isDead = !!character.is_dead;
|
||||
|
||||
const cardClass = [
|
||||
styles.card,
|
||||
isDying ? styles.dying : "",
|
||||
isDead ? styles.dead : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// When dying/dead the left-color border is replaced by the dying/dead CSS
|
||||
const cardStyle = isDying || isDead
|
||||
? {}
|
||||
: { borderLeftColor: character.color, borderLeftWidth: "3px" };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.card}
|
||||
onClick={() => onClick(character.id)}
|
||||
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
|
||||
>
|
||||
<div className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
|
||||
<div className={styles.cardHeader}>
|
||||
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||
<div className={styles.nameRow}>
|
||||
<span className={styles.name}>
|
||||
{character.name}
|
||||
{isDead ? "\u{1F480} " : ""}{character.name}
|
||||
{character.title ? ` ${character.title}` : ""}
|
||||
</span>
|
||||
<span className={styles.level}>Lvl {character.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
{character.ancestry} {character.class}
|
||||
</div>
|
||||
|
||||
{focusSpell && (
|
||||
<div className={styles.focusIndicator}>
|
||||
● Focusing: {focusSpell}
|
||||
|
|
@ -61,13 +78,16 @@ export default function CharacterCard({
|
|||
<HpBar
|
||||
current={character.hp_current}
|
||||
max={character.hp_max + getTalentHpBonus(character)}
|
||||
onChange={(hp) => onHpChange(character.id, hp)}
|
||||
onChange={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
|
||||
/>
|
||||
{isDying && dyingCondition && (
|
||||
<span className={styles.dyingLabel} title="Dying">
|
||||
{"\u{1F480}"} {dyingCondition.rounds_remaining}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.ac}>
|
||||
<span className={styles.acLabel}>AC</span>
|
||||
<span className={styles.acValue}>
|
||||
{calculateAC(character).effective}
|
||||
</span>
|
||||
<span className={styles.acValue}>{calculateAC(character).effective}</span>
|
||||
</div>
|
||||
<span
|
||||
className={styles.luck}
|
||||
|
|
@ -86,6 +106,18 @@ export default function CharacterCard({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isDead && isDM && (
|
||||
<button
|
||||
className={styles.reviveBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdate(character.id, { is_dead: false, hp_current: 1 } as Partial<Character>);
|
||||
}}
|
||||
>
|
||||
Revive
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.modRow}>
|
||||
{STATS.map((stat) => {
|
||||
const value = getEffectiveStat(character, stat);
|
||||
|
|
|
|||
|
|
@ -453,6 +453,7 @@ export default function CampaignView() {
|
|||
onClick={setSelectedId}
|
||||
canEdit={role === "dm" || char.user_id === user?.userId}
|
||||
focusSpell={focusSpells.get(char.id)}
|
||||
isDM={role === "dm"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue