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);
|
color: var(--text-tertiary);
|
||||||
text-align: right;
|
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;
|
onClick: (characterId: number) => void;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
focusSpell?: string;
|
focusSpell?: string;
|
||||||
|
isDM?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CharacterCard({
|
export default function CharacterCard({
|
||||||
|
|
@ -31,26 +32,42 @@ export default function CharacterCard({
|
||||||
onClick,
|
onClick,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
focusSpell,
|
focusSpell,
|
||||||
|
isDM = false,
|
||||||
}: CharacterCardProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
|
||||||
className={styles.card}
|
|
||||||
onClick={() => onClick(character.id)}
|
|
||||||
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
|
|
||||||
>
|
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||||
<div className={styles.nameRow}>
|
<div className={styles.nameRow}>
|
||||||
<span className={styles.name}>
|
<span className={styles.name}>
|
||||||
{character.name}
|
{isDead ? "\u{1F480} " : ""}{character.name}
|
||||||
{character.title ? ` ${character.title}` : ""}
|
{character.title ? ` ${character.title}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.level}>Lvl {character.level}</span>
|
<span className={styles.level}>Lvl {character.level}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
{character.ancestry} {character.class}
|
{character.ancestry} {character.class}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{focusSpell && (
|
{focusSpell && (
|
||||||
<div className={styles.focusIndicator}>
|
<div className={styles.focusIndicator}>
|
||||||
● Focusing: {focusSpell}
|
● Focusing: {focusSpell}
|
||||||
|
|
@ -61,13 +78,16 @@ export default function CharacterCard({
|
||||||
<HpBar
|
<HpBar
|
||||||
current={character.hp_current}
|
current={character.hp_current}
|
||||||
max={character.hp_max + getTalentHpBonus(character)}
|
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}>
|
<div className={styles.ac}>
|
||||||
<span className={styles.acLabel}>AC</span>
|
<span className={styles.acLabel}>AC</span>
|
||||||
<span className={styles.acValue}>
|
<span className={styles.acValue}>{calculateAC(character).effective}</span>
|
||||||
{calculateAC(character).effective}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={styles.luck}
|
className={styles.luck}
|
||||||
|
|
@ -86,6 +106,18 @@ export default function CharacterCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<div className={styles.modRow}>
|
||||||
{STATS.map((stat) => {
|
{STATS.map((stat) => {
|
||||||
const value = getEffectiveStat(character, stat);
|
const value = getEffectiveStat(character, stat);
|
||||||
|
|
|
||||||
|
|
@ -453,6 +453,7 @@ export default function CampaignView() {
|
||||||
onClick={setSelectedId}
|
onClick={setSelectedId}
|
||||||
canEdit={role === "dm" || char.user_id === user?.userId}
|
canEdit={role === "dm" || char.user_id === user?.userId}
|
||||||
focusSpell={focusSpells.get(char.id)}
|
focusSpell={focusSpells.get(char.id)}
|
||||||
|
isDM={role === "dm"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue