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}}