Add Roll Damage button to spell chat cards

Spell damage is now rolled separately via button click instead of
automatically, matching the attack roll behavior. Includes spell chat
card styling fixes for proper header layout and damage display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-17 18:14:04 -06:00
parent a30cc62957
commit 7f06ec229a
4 changed files with 438 additions and 18 deletions

View File

@ -472,15 +472,8 @@ export default class SpellCastDialog extends VagabondRollDialog {
modifier: this.rollConfig.modifier,
});
// Roll damage if the cast succeeded and spell deals damage
let damageResult = null;
if (result.success && spell.system.isDamaging() && this.castConfig.damageDice > 0) {
const damageFormula = this._getDamageFormula();
damageResult = await damageRoll(damageFormula, {
isCrit: result.isCrit,
rollData: this.actor.getRollData(),
});
}
// Damage is rolled separately via button click (like attacks)
// Just pass null for damageResult - the button will handle it
// Spend mana (regardless of success - mana is spent on attempt)
await this.actor.update({
@ -513,19 +506,19 @@ export default class SpellCastDialog extends VagabondRollDialog {
}
}
// Send to chat
await this._sendToChat(result, damageResult);
// Send to chat (damage is rolled separately via button click)
await this._sendToChat(result);
}
/**
* Send the spell cast result to chat.
*
* @param {VagabondRollResult} result - The casting skill check result
* @param {Roll|null} damageResult - The damage roll (if applicable)
* @param {Roll|null} damageResult - The damage roll (if already rolled, e.g., for updates)
* @returns {Promise<ChatMessage>}
* @private
*/
async _sendToChat(result, damageResult) {
async _sendToChat(result, damageResult = null) {
const spell = this.spell;
const castingSkill = this._getCastingSkill();
const skillConfig = CONFIG.VAGABOND?.skills?.[castingSkill];
@ -571,11 +564,14 @@ export default class SpellCastDialog extends VagabondRollDialog {
netFavorHinder: this.netFavorHinder,
favorSources: this.rollConfig.autoFavorHinder.favorSources,
hinderSources: this.rollConfig.autoFavorHinder.hinderSources,
// Damage info
// Damage info (only present if damage was rolled)
hasDamage: !!damageResult,
damageTotal: damageResult?.total,
damageFormula: damageResult?.formula,
damageDice: this.castConfig.damageDice,
// Show damage button if cast succeeded and there's a damage formula to roll
pendingDamageFormula: this._getDamageFormula(),
showDamageButton: result.success && !!this._getDamageFormula() && !damageResult,
// Effect info
includeEffect: this.castConfig.includeEffect,
hasEffect: Boolean(spell.system.effect && spell.system.effect.trim()),
@ -591,13 +587,30 @@ export default class SpellCastDialog extends VagabondRollDialog {
const rolls = [result.roll];
if (damageResult) rolls.push(damageResult);
// Create the chat message
// Create the chat message with flags for later damage rolling
const damageFormula = this._getDamageFormula();
const chatData = {
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
content,
rolls,
sound: CONFIG.sounds.dice,
flags: {
vagabond: {
type: "spell-cast",
actorId: this.actor.id,
spellId: spell.id,
spellName: spell.name,
damageFormula,
damageType: spell.system.damageType,
damageTypeLabel: game.i18n.localize(
CONFIG.VAGABOND?.damageTypes?.[spell.system.damageType] || spell.system.damageType
),
isCrit: result.isCrit,
success: result.success,
damageRolled: !!damageResult,
},
},
};
return ChatMessage.create(chatData);

View File

@ -653,6 +653,189 @@ async function _extractAttackDataFromMessage(message) {
};
}
/* -------------------------------------------- */
/* Spell Damage Roll Handling */
/* -------------------------------------------- */
/**
* Handle clicks on "Roll Damage" buttons in spell chat cards.
* Rolls damage using the stored spell data and updates the message.
*/
Hooks.on("renderChatMessage", (message, html) => {
// Only handle spell-cast messages
const flags = message.flags?.vagabond;
if (!flags || flags.type !== "spell-cast") return;
// Find damage roll buttons in this message
html.find(".roll-damage-btn").on("click", async (event) => {
event.preventDefault();
const button = event.currentTarget;
// Verify damage hasn't been rolled yet
if (flags.damageRolled) {
ui.notifications.warn("Damage has already been rolled for this spell");
return;
}
// Disable button immediately to prevent double-clicks
button.disabled = true;
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Rolling...';
try {
// Get the actor for roll data
const actor = game.actors.get(flags.actorId);
if (!actor) {
ui.notifications.error("Could not find actor for damage roll");
return;
}
// Import damageRoll function
const { damageRoll } = await import("./dice/rolls.mjs");
// Roll damage
const roll = await damageRoll(flags.damageFormula, {
isCrit: flags.isCrit,
rollData: actor.getRollData(),
});
// Render updated content with damage
const content = await renderTemplate("systems/vagabond/templates/chat/spell-cast.hbs", {
// Original spell data from message content
...(await _extractSpellDataFromMessage(message)),
// Damage data
hasDamage: true,
damageTotal: roll.total,
damageFormula: roll.formula,
isCrit: flags.isCrit,
showDamageButton: false,
});
// Update the message with damage rolled
await message.update({
content,
rolls: [...message.rolls, roll],
"flags.vagabond.damageRolled": true,
});
// Play dice sound
AudioHelper.play({ src: CONFIG.sounds.dice }, true);
} catch (error) {
console.error("Vagabond RPG | Error rolling spell damage:", error);
ui.notifications.error("Failed to roll damage");
button.disabled = false;
button.innerHTML = '<i class="fa-solid fa-burst"></i> Roll Damage';
}
});
});
/**
* Extract spell data from an existing chat message for re-rendering.
* @param {ChatMessage} message - The chat message
* @returns {Promise<Object>} Template data extracted from the message
* @private
*/
async function _extractSpellDataFromMessage(message) {
const flags = message.flags?.vagabond || {};
const content = message.content;
// Parse values from the message HTML
const parser = new DOMParser();
const doc = parser.parseFromString(content, "text/html");
// Extract spell info
const spellImg = doc.querySelector(".spell-icon")?.getAttribute("src") || "";
const spellName = doc.querySelector(".spell-name")?.textContent || flags.spellName || "Unknown";
const castingSkillLabel = doc.querySelector(".casting-skill-badge")?.textContent || "";
// Extract cast config
const deliveryLabel =
doc.querySelector(".config-item.delivery .value")?.textContent?.trim() || "";
const durationLabel =
doc.querySelector(".config-item.duration .value")?.textContent?.trim() || "";
const manaCost = doc.querySelector(".config-item.mana-cost .value")?.textContent?.trim() || "0";
// Extract roll result
const total = doc.querySelector(".roll-total")?.textContent || "0";
const status = doc.querySelector(".roll-status .status")?.classList;
const isCrit = status?.contains("critical") || false;
const isFumble = status?.contains("fumble") || false;
const success = status?.contains("success") || status?.contains("critical") || false;
// Extract formula and breakdown
const formula = doc.querySelector(".roll-formula .value")?.textContent || "";
const d20Result = doc.querySelector(".d20-result")?.textContent?.replace(/[^\d]/g, "") || "0";
const favorDieEl = doc.querySelector(".favor-die");
const favorDie = favorDieEl?.textContent?.replace(/[^\d-]/g, "") || null;
const netFavorHinder = favorDieEl?.classList.contains("favor")
? 1
: favorDieEl?.classList.contains("hinder")
? -1
: 0;
const modifier = doc.querySelector(".modifier")?.textContent?.replace(/[^\d-]/g, "") || null;
// Extract difficulty info
const difficulty = doc.querySelector(".difficulty .value")?.textContent || "10";
const critThreshold =
doc.querySelector(".crit-threshold .value")?.textContent?.replace(/[^\d]/g, "") || "20";
// Extract spell effect
const effectText = doc.querySelector(".spell-effect-section .effect-text")?.innerHTML || "";
const critEffectText = doc.querySelector(".spell-effect-section .crit-text")?.innerHTML || "";
const hasEffect = !!effectText;
const includeEffect = hasEffect;
// Extract focus indicator
const isFocus = !!doc.querySelector(".focus-indicator");
// Extract favor/hinder sources
const favorSourcesEl = doc.querySelector(".favor-sources span");
const hinderSourcesEl = doc.querySelector(".hinder-sources span");
const favorSources =
favorSourcesEl?.textContent
?.replace(/^[^:]+:\s*/, "")
.split(", ")
.filter(Boolean) || [];
const hinderSources =
hinderSourcesEl?.textContent
?.replace(/^[^:]+:\s*/, "")
.split(", ")
.filter(Boolean) || [];
return {
spell: {
id: flags.spellId,
name: spellName,
img: spellImg,
damageType: flags.damageType,
damageTypeLabel: flags.damageTypeLabel,
effect: effectText,
critEffect: critEffectText,
isDamaging: !!flags.damageFormula,
},
castingSkillLabel,
deliveryLabel,
durationLabel,
isFocus,
manaCost,
total,
d20Result,
favorDie: favorDie ? parseInt(favorDie) : null,
modifier: modifier ? parseInt(modifier) : null,
formula,
difficulty,
critThreshold,
success,
isCrit,
isFumble,
netFavorHinder,
favorSources,
hinderSources,
hasEffect,
includeEffect,
};
}
/* -------------------------------------------- */
/* Quench Test Registration */
/* -------------------------------------------- */

View File

@ -545,8 +545,36 @@
.vagabond.chat-card.spell-card,
.vagabond.chat-card.spell-cast {
.card-header {
.spell-name {
margin: 0;
display: flex;
align-items: center;
gap: $spacing-3;
.spell-icon {
width: 32px;
height: 32px;
border-radius: $radius-sm;
border: 1px solid var(--color-border);
object-fit: cover;
}
.header-text {
flex: 1;
min-width: 0; // Prevent flex overflow
.spell-name {
margin: 0;
font-size: $font-size-base;
color: var(--color-text-primary);
}
.casting-skill-badge {
font-size: $font-size-xs;
padding: $spacing-1 $spacing-2;
background-color: rgba(112, 80, 176, 0.2);
color: var(--color-reason);
border-radius: $radius-full;
font-weight: $font-weight-medium;
}
}
.mana-cost-badge {
@ -563,6 +591,37 @@
}
}
// Cast configuration display (delivery, duration, mana)
.cast-config {
display: flex;
flex-wrap: wrap;
gap: $spacing-2;
margin: $spacing-2 $spacing-3;
padding: $spacing-2;
background-color: var(--color-bg-tertiary);
border-radius: $radius-md;
font-size: $font-size-sm;
.config-item {
display: flex;
gap: $spacing-1;
.label {
color: var(--color-text-muted);
}
.value {
font-weight: $font-weight-medium;
color: var(--color-text-primary);
i {
color: var(--color-warning);
margin-left: $spacing-1;
}
}
}
}
.spell-effect {
padding: $spacing-3;
font-style: italic;
@ -572,6 +631,109 @@
color: var(--color-text-secondary);
}
// Spell effect section in chat (different from .spell-effect above)
.spell-effect-section {
margin: $spacing-3;
padding: $spacing-3;
background-color: var(--color-bg-secondary);
border-radius: $radius-md;
border-left: 3px solid var(--color-accent-primary);
.effect-header {
@include flex-center;
justify-content: flex-start;
gap: $spacing-2;
margin-bottom: $spacing-2;
font-weight: $font-weight-semibold;
color: var(--color-accent-primary);
}
.effect-text {
font-size: $font-size-sm;
color: var(--color-text-secondary);
font-style: italic;
}
.crit-effect {
margin-top: $spacing-2;
padding-top: $spacing-2;
border-top: 1px solid var(--color-border);
.crit-label {
font-weight: $font-weight-semibold;
color: var(--color-warning);
}
.crit-text {
font-size: $font-size-sm;
color: var(--color-text-secondary);
font-style: italic;
}
}
}
// Damage section (similar to attack-roll)
.damage-section {
margin: $spacing-3;
padding: $spacing-3;
background-color: rgba(201, 68, 68, 0.1);
border: 1px solid rgba(201, 68, 68, 0.3);
border-radius: $radius-md;
&.critical {
background-color: rgba(212, 163, 44, 0.15);
border-color: var(--color-warning);
.damage-total {
color: var(--color-warning);
}
}
.damage-header {
@include flex-center;
gap: $spacing-2;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-2;
color: var(--color-text-primary);
i {
color: var(--color-danger);
}
.crit-label {
color: var(--color-warning);
font-size: $font-size-sm;
}
}
.damage-result {
@include flex-center;
gap: $spacing-2;
.damage-total {
font-family: $font-family-header;
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: var(--color-danger);
line-height: 1;
}
.damage-type {
font-size: $font-size-sm;
color: var(--color-text-secondary);
text-transform: capitalize;
}
}
.damage-formula {
@include flex-center;
margin-top: $spacing-2;
font-family: $font-family-mono;
font-size: $font-size-sm;
color: var(--color-text-muted);
}
}
.spell-meta {
@include grid(2, $spacing-2);
margin: 0 $spacing-3 $spacing-3;
@ -621,6 +783,54 @@
font-size: $font-size-sm;
color: var(--color-warning);
}
// Roll Damage button (same style as attack-roll)
.card-buttons {
.roll-damage-btn {
@include flex-center;
gap: $spacing-2;
width: 100%;
padding: $spacing-2 $spacing-3;
background-color: rgba(201, 68, 68, 0.2);
border: 1px solid var(--color-danger);
border-radius: $radius-md;
color: var(--color-danger);
font-weight: $font-weight-semibold;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: rgba(201, 68, 68, 0.3);
border-color: var(--color-danger);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&.critical {
background-color: rgba(212, 163, 44, 0.2);
border-color: var(--color-warning);
color: var(--color-warning);
&:hover:not(:disabled) {
background-color: rgba(212, 163, 44, 0.3);
}
}
.crit-label {
font-size: $font-size-sm;
color: var(--color-warning);
}
.damage-preview {
font-family: $font-family-mono;
font-size: $font-size-sm;
color: var(--color-text-secondary);
}
}
}
}
// Animations

View File

@ -106,7 +106,7 @@
{{/if}}
{{/if}}
{{!-- Damage Section (if hit and dealing damage) --}}
{{!-- Damage Section (if damage was rolled) --}}
{{#if hasDamage}}
<div class="damage-section {{#if isCrit}}critical{{/if}}">
<div class="damage-header">
@ -126,6 +126,20 @@
</div>
{{/if}}
{{!-- Roll Damage Button (if cast succeeded but damage not yet rolled) --}}
{{#if showDamageButton}}
<div class="card-buttons">
<button type="button" class="roll-damage-btn {{#if isCrit}}critical{{/if}}">
<i class="fa-solid fa-burst"></i>
{{localize "VAGABOND.RollDamage"}}
{{#if isCrit}}
<span class="crit-label">({{localize "VAGABOND.Critical"}}!)</span>
{{/if}}
<span class="damage-preview">({{pendingDamageFormula}})</span>
</button>
</div>
{{/if}}
{{!-- Focus Indicator --}}
{{#if isFocus}}
{{#if success}}