From 7f06ec229a4478eca607084fc6e63749482ef05b Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 17 Dec 2025 18:14:04 -0600 Subject: [PATCH] Add Roll Damage button to spell chat cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- module/applications/spell-cast-dialog.mjs | 43 +++-- module/vagabond.mjs | 183 ++++++++++++++++++ styles/scss/chat/_chat-cards.scss | 214 +++++++++++++++++++++- templates/chat/spell-cast.hbs | 16 +- 4 files changed, 438 insertions(+), 18 deletions(-) diff --git a/module/applications/spell-cast-dialog.mjs b/module/applications/spell-cast-dialog.mjs index 3da1f20..2aea9b6 100644 --- a/module/applications/spell-cast-dialog.mjs +++ b/module/applications/spell-cast-dialog.mjs @@ -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} * @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); diff --git a/module/vagabond.mjs b/module/vagabond.mjs index b409507..459f173 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -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 = ' 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 = ' Roll Damage'; + } + }); +}); + +/** + * Extract spell data from an existing chat message for re-rendering. + * @param {ChatMessage} message - The chat message + * @returns {Promise} 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 */ /* -------------------------------------------- */ diff --git a/styles/scss/chat/_chat-cards.scss b/styles/scss/chat/_chat-cards.scss index b654326..ec407b0 100644 --- a/styles/scss/chat/_chat-cards.scss +++ b/styles/scss/chat/_chat-cards.scss @@ -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 diff --git a/templates/chat/spell-cast.hbs b/templates/chat/spell-cast.hbs index 63a7804..3cc9c44 100644 --- a/templates/chat/spell-cast.hbs +++ b/templates/chat/spell-cast.hbs @@ -106,7 +106,7 @@ {{/if}} {{/if}} - {{!-- Damage Section (if hit and dealing damage) --}} + {{!-- Damage Section (if damage was rolled) --}} {{#if hasDamage}}
@@ -126,6 +126,20 @@
{{/if}} + {{!-- Roll Damage Button (if cast succeeded but damage not yet rolled) --}} + {{#if showDamageButton}} +
+ +
+ {{/if}} + {{!-- Focus Indicator --}} {{#if isFocus}} {{#if success}}