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:
parent
0325343931
commit
a30cc62957
@ -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)
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 --}}
|
||||
|
||||
@ -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 --}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user