Add crit threshold stepper to attack and spell roll dialogs

- Add always-visible crit threshold display with +/- adjustment buttons
- Store modifier relative to base threshold, clamped between 1 and 20
- Reset modifier when changing weapon/spell selection
- Use Math.clamp (not deprecated Math.clamped) for Foundry v13 compatibility
- Pass effective crit threshold to attackCheck() and skillCheck()
- Add SCSS styling for stepper control with hover states

🤖 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 15:42:11 -06:00
parent 0325343931
commit a30cc62957
6 changed files with 219 additions and 17 deletions

View File

@ -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)

View File

@ -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,
});

View File

@ -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<VagabondRollResult>} 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 };

View File

@ -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;

View File

@ -43,12 +43,25 @@
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
</div>
{{#if (lt rollSpecific.critThreshold 20)}}
<div class="attack-crit">
{{!-- Crit Threshold Stepper --}}
<div class="crit-threshold-section">
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
<span class="value crit">{{rollSpecific.critThreshold}}+</span>
<div class="crit-stepper">
<button type="button" class="stepper-btn decrement" data-action="crit-decrement" {{#if (eq rollSpecific.effectiveCritThreshold 1)}}disabled{{/if}}>
<i class="fa-solid fa-minus"></i>
</button>
<span class="crit-value {{#if (lt rollSpecific.effectiveCritThreshold 20)}}modified{{/if}}">{{rollSpecific.effectiveCritThreshold}}+</span>
<button type="button" class="stepper-btn increment" data-action="crit-increment" {{#if (eq rollSpecific.effectiveCritThreshold 20)}}disabled{{/if}}>
<i class="fa-solid fa-plus"></i>
</button>
</div>
{{#if rollSpecific.critThresholdModifier}}
<div class="crit-modifier-indicator">
({{#if (lt rollSpecific.critThresholdModifier 0)}}{{rollSpecific.critThresholdModifier}}{{else}}+{{rollSpecific.critThresholdModifier}}{{/if}} from base {{rollSpecific.baseCritThreshold}})
</div>
{{/if}}
</div>
{{/if}}
</div>
{{!-- Damage Preview --}}

View File

@ -67,12 +67,25 @@
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
</div>
{{#if (lt rollSpecific.critThreshold 20)}}
<div class="casting-crit">
{{!-- Crit Threshold Stepper --}}
<div class="crit-threshold-section">
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
<span class="value crit">{{rollSpecific.critThreshold}}+</span>
<div class="crit-stepper">
<button type="button" class="stepper-btn decrement" data-action="crit-decrement" {{#if (eq rollSpecific.effectiveCritThreshold 1)}}disabled{{/if}}>
<i class="fa-solid fa-minus"></i>
</button>
<span class="crit-value {{#if (lt rollSpecific.effectiveCritThreshold 20)}}modified{{/if}}">{{rollSpecific.effectiveCritThreshold}}+</span>
<button type="button" class="stepper-btn increment" data-action="crit-increment" {{#if (eq rollSpecific.effectiveCritThreshold 20)}}disabled{{/if}}>
<i class="fa-solid fa-plus"></i>
</button>
</div>
{{#if rollSpecific.critThresholdModifier}}
<div class="crit-modifier-indicator">
({{#if (lt rollSpecific.critThresholdModifier 0)}}{{rollSpecific.critThresholdModifier}}{{else}}+{{rollSpecific.critThresholdModifier}}{{/if}} from base {{rollSpecific.baseCritThreshold}})
</div>
{{/if}}
</div>
{{/if}}
</div>
{{!-- Effect Description and Toggle --}}