diff --git a/module/applications/attack-roll-dialog.mjs b/module/applications/attack-roll-dialog.mjs index 96ea1ef..0017e24 100644 --- a/module/applications/attack-roll-dialog.mjs +++ b/module/applications/attack-roll-dialog.mjs @@ -25,6 +25,7 @@ export default class AttackRollDialog extends VagabondRollDialog { this.weaponId = options.weaponId || null; this.twoHanded = false; + this.critThresholdModifier = 0; // Relative adjustment to base crit threshold // Auto-select first equipped weapon if none specified, otherwise default to unarmed if (!this.weaponId) { @@ -154,10 +155,17 @@ export default class AttackRollDialog extends VagabondRollDialog { // Attacks use trained difficulty (20 - stat × 2) const difficulty = 20 - statValue * 2; - // Get crit threshold from actor's attack data or weapon override + // Get base crit threshold from actor's attack data or weapon override const actorCritThreshold = this.actor.system.attacks?.[attackType]?.critThreshold || 20; const weaponCritThreshold = weapon.system.critThreshold; - const critThreshold = weaponCritThreshold ?? actorCritThreshold; + const baseCritThreshold = weaponCritThreshold ?? actorCritThreshold; + + // Calculate effective crit threshold with modifier, clamped to 1-20 + const effectiveCritThreshold = Math.clamp( + baseCritThreshold + this.critThresholdModifier, + 1, + 20 + ); return { attackType, @@ -166,7 +174,9 @@ export default class AttackRollDialog extends VagabondRollDialog { statLabel: game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey] || statKey), statValue, difficulty, - critThreshold, + baseCritThreshold, + effectiveCritThreshold, + critThresholdModifier: this.critThresholdModifier, }; } @@ -279,7 +289,9 @@ export default class AttackRollDialog extends VagabondRollDialog { context.statLabel = attackData.statLabel; context.statValue = attackData.statValue; context.difficulty = attackData.difficulty; - context.critThreshold = attackData.critThreshold; + context.baseCritThreshold = attackData.baseCritThreshold; + context.effectiveCritThreshold = attackData.effectiveCritThreshold; + context.critThresholdModifier = attackData.critThresholdModifier; } // Versatile weapon handling @@ -316,6 +328,7 @@ export default class AttackRollDialog extends VagabondRollDialog { weaponSelect?.addEventListener("change", (event) => { this.weaponId = event.target.value; this.twoHanded = false; // Reset two-handed when changing weapon + this.critThresholdModifier = 0; // Reset crit modifier when changing weapon this.render(); }); @@ -325,6 +338,30 @@ export default class AttackRollDialog extends VagabondRollDialog { this.twoHanded = event.target.checked; this.render(); }); + + // Crit threshold stepper buttons + const critIncrement = this.element.querySelector('[data-action="crit-increment"]'); + const critDecrement = this.element.querySelector('[data-action="crit-decrement"]'); + + critIncrement?.addEventListener("click", () => { + const attackData = this.attackData; + if (!attackData) return; + const newEffective = attackData.effectiveCritThreshold + 1; + if (newEffective <= 20) { + this.critThresholdModifier++; + this.render(); + } + }); + + critDecrement?.addEventListener("click", () => { + const attackData = this.attackData; + if (!attackData) return; + const newEffective = attackData.effectiveCritThreshold - 1; + if (newEffective >= 1) { + this.critThresholdModifier--; + this.render(); + } + }); } /** @override */ @@ -335,10 +372,15 @@ export default class AttackRollDialog extends VagabondRollDialog { return; } + // Get effective crit threshold from attack data + const attackData = this.attackData; + const effectiveCritThreshold = attackData?.effectiveCritThreshold ?? 20; + // Perform the attack check const result = await attackCheck(this.actor, weapon, { favorHinder: this.netFavorHinder, modifier: this.rollConfig.modifier, + critThreshold: effectiveCritThreshold, }); // Send to chat (damage is rolled separately via button click) diff --git a/module/applications/spell-cast-dialog.mjs b/module/applications/spell-cast-dialog.mjs index a480c9a..3da1f20 100644 --- a/module/applications/spell-cast-dialog.mjs +++ b/module/applications/spell-cast-dialog.mjs @@ -25,6 +25,7 @@ export default class SpellCastDialog extends VagabondRollDialog { super(actor, options); this.spellId = options.spellId || null; + this.critThresholdModifier = 0; // Relative adjustment to base crit threshold // Casting configuration this.castConfig = { @@ -275,7 +276,17 @@ export default class SpellCastDialog extends VagabondRollDialog { context.statValue = statValue; context.trained = trained; context.difficulty = trained ? 20 - statValue * 2 : 20 - statValue; - context.critThreshold = skillData?.critThreshold || 20; + + // Calculate base and effective crit threshold + const baseCritThreshold = skillData?.critThreshold || 20; + const effectiveCritThreshold = Math.clamp( + baseCritThreshold + this.critThresholdModifier, + 1, + 20 + ); + context.baseCritThreshold = baseCritThreshold; + context.effectiveCritThreshold = effectiveCritThreshold; + context.critThresholdModifier = this.critThresholdModifier; // Damage configuration context.isDamaging = spell.system.isDamaging(); @@ -347,6 +358,7 @@ export default class SpellCastDialog extends VagabondRollDialog { spellSelect?.addEventListener("change", (event) => { this.spellId = event.target.value; this._initializeCastConfig(); + this.critThresholdModifier = 0; // Reset crit modifier when changing spell this.render(); }); @@ -378,6 +390,40 @@ export default class SpellCastDialog extends VagabondRollDialog { this.render(); }); + // Crit threshold stepper buttons + const critIncrement = this.element.querySelector('[data-action="crit-increment"]'); + const critDecrement = this.element.querySelector('[data-action="crit-decrement"]'); + + critIncrement?.addEventListener("click", () => { + const castingSkill = this._getCastingSkill(); + const skillData = this.actor.system.skills?.[castingSkill]; + const baseCritThreshold = skillData?.critThreshold || 20; + const effectiveCritThreshold = Math.clamp( + baseCritThreshold + this.critThresholdModifier, + 1, + 20 + ); + if (effectiveCritThreshold < 20) { + this.critThresholdModifier++; + this.render(); + } + }); + + critDecrement?.addEventListener("click", () => { + const castingSkill = this._getCastingSkill(); + const skillData = this.actor.system.skills?.[castingSkill]; + const baseCritThreshold = skillData?.critThreshold || 20; + const effectiveCritThreshold = Math.clamp( + baseCritThreshold + this.critThresholdModifier, + 1, + 20 + ); + if (effectiveCritThreshold > 1) { + this.critThresholdModifier--; + this.render(); + } + }); + // Note: Favor/hinder toggles and modifier presets are handled by parent class // via super._onRender() - no need to add duplicate listeners here } @@ -410,11 +456,18 @@ export default class SpellCastDialog extends VagabondRollDialog { const statValue = this.actor.system.stats?.[statKey]?.value || 0; const trained = skillData?.trained || false; const difficulty = trained ? 20 - statValue * 2 : 20 - statValue; - const critThreshold = skillData?.critThreshold || 20; + + // Calculate effective crit threshold with modifier + const baseCritThreshold = skillData?.critThreshold || 20; + const effectiveCritThreshold = Math.clamp( + baseCritThreshold + this.critThresholdModifier, + 1, + 20 + ); const result = await skillCheck(this.actor, castingSkill, { difficulty, - critThreshold, + critThreshold: effectiveCritThreshold, favorHinder: this.netFavorHinder, modifier: this.rollConfig.modifier, }); diff --git a/module/dice/rolls.mjs b/module/dice/rolls.mjs index 63c4f1c..df797f1 100644 --- a/module/dice/rolls.mjs +++ b/module/dice/rolls.mjs @@ -159,6 +159,7 @@ export async function skillCheck(actor, skillId, options = {}) { * @param {Object} options - Additional options * @param {number} [options.modifier=0] - Situational modifier * @param {number} [options.favorHinder] - Override favor/hinder + * @param {number} [options.critThreshold] - Override crit threshold * @returns {Promise} The roll result */ export async function attackCheck(actor, weapon, options = {}) { @@ -178,10 +179,11 @@ export async function attackCheck(actor, weapon, options = {}) { // Attack difficulty = 20 - stat × 2 (attacks are always "trained") const difficulty = 20 - statValue * 2; - // Get crit threshold: weapon override > actor attack data > default + // Get crit threshold: option override > weapon override > actor attack data > default const actorCritThreshold = system.attacks?.[attackType]?.critThreshold || 20; const weaponCritThreshold = weapon.system.critThreshold; - const critThreshold = weaponCritThreshold ?? actorCritThreshold; + const baseCritThreshold = weaponCritThreshold ?? actorCritThreshold; + const critThreshold = options.critThreshold ?? baseCritThreshold; // Determine favor/hinder from Active Effect flags or override const favorHinderResult = actor.getNetFavorHinder?.({ isAttack: true }) ?? { net: 0 }; diff --git a/styles/scss/dialogs/_roll-dialog.scss b/styles/scss/dialogs/_roll-dialog.scss index 8ab10c9..2febfc2 100644 --- a/styles/scss/dialogs/_roll-dialog.scss +++ b/styles/scss/dialogs/_roll-dialog.scss @@ -134,6 +134,85 @@ } } + // Crit Threshold Stepper Control + .crit-threshold-section { + @include flex-center; + flex-wrap: wrap; + gap: $spacing-2; + padding: $spacing-2 $spacing-3; + background-color: var(--color-bg-secondary); + border-radius: $radius-md; + + > .label { + font-size: $font-size-sm; + color: var(--color-text-muted); + } + + .crit-stepper { + @include flex-center; + gap: $spacing-1; + + .stepper-btn { + @include flex-center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 1px solid var(--color-border); + border-radius: $radius-sm; + background-color: var(--color-bg-primary); + color: var(--color-text-secondary); + cursor: pointer; + transition: all $transition-fast; + + i { + font-size: $font-size-xs; + } + + &:hover:not(:disabled) { + background-color: var(--color-bg-tertiary); + border-color: var(--color-accent-primary); + color: var(--color-accent-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &.decrement:hover:not(:disabled) { + border-color: var(--color-success); + color: var(--color-success); + } + + &.increment:hover:not(:disabled) { + border-color: var(--color-danger); + color: var(--color-danger); + } + } + + .crit-value { + min-width: 2.5rem; + text-align: center; + font-family: $font-family-mono; + font-size: $font-size-base; + font-weight: $font-weight-bold; + color: var(--color-text-primary); + + &.modified { + color: var(--color-warning); + } + } + } + + .crit-modifier-indicator { + width: 100%; + text-align: center; + font-size: $font-size-xs; + color: var(--color-text-muted); + font-style: italic; + } + } + // Single-column info (for spell dialog) .spell-info { @include flex-column; diff --git a/templates/dialog/attack-roll.hbs b/templates/dialog/attack-roll.hbs index 379723d..105fab1 100644 --- a/templates/dialog/attack-roll.hbs +++ b/templates/dialog/attack-roll.hbs @@ -43,12 +43,25 @@ {{localize "VAGABOND.Difficulty"}}: {{rollSpecific.difficulty}} - {{#if (lt rollSpecific.critThreshold 20)}} -
+ + {{!-- Crit Threshold Stepper --}} +
{{localize "VAGABOND.CritThreshold"}}: - {{rollSpecific.critThreshold}}+ +
+ + {{rollSpecific.effectiveCritThreshold}}+ + +
+ {{#if rollSpecific.critThresholdModifier}} +
+ ({{#if (lt rollSpecific.critThresholdModifier 0)}}{{rollSpecific.critThresholdModifier}}{{else}}+{{rollSpecific.critThresholdModifier}}{{/if}} from base {{rollSpecific.baseCritThreshold}}) +
+ {{/if}}
- {{/if}}
{{!-- Damage Preview --}} diff --git a/templates/dialog/spell-cast.hbs b/templates/dialog/spell-cast.hbs index 8cb549f..10e0853 100644 --- a/templates/dialog/spell-cast.hbs +++ b/templates/dialog/spell-cast.hbs @@ -67,12 +67,25 @@ {{localize "VAGABOND.Difficulty"}}: {{rollSpecific.difficulty}} - {{#if (lt rollSpecific.critThreshold 20)}} -
+ + {{!-- Crit Threshold Stepper --}} +
{{localize "VAGABOND.CritThreshold"}}: - {{rollSpecific.critThreshold}}+ +
+ + {{rollSpecific.effectiveCritThreshold}}+ + +
+ {{#if rollSpecific.critThresholdModifier}} +
+ ({{#if (lt rollSpecific.critThresholdModifier 0)}}{{rollSpecific.critThresholdModifier}}{{else}}+{{rollSpecific.critThresholdModifier}}{{/if}} from base {{rollSpecific.baseCritThreshold}}) +
+ {{/if}}
- {{/if}}
{{!-- Effect Description and Toggle --}}