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.weaponId = options.weaponId || null;
|
||||||
this.twoHanded = false;
|
this.twoHanded = false;
|
||||||
|
this.critThresholdModifier = 0; // Relative adjustment to base crit threshold
|
||||||
|
|
||||||
// Auto-select first equipped weapon if none specified, otherwise default to unarmed
|
// Auto-select first equipped weapon if none specified, otherwise default to unarmed
|
||||||
if (!this.weaponId) {
|
if (!this.weaponId) {
|
||||||
@ -154,10 +155,17 @@ export default class AttackRollDialog extends VagabondRollDialog {
|
|||||||
// Attacks use trained difficulty (20 - stat × 2)
|
// Attacks use trained difficulty (20 - stat × 2)
|
||||||
const difficulty = 20 - statValue * 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 actorCritThreshold = this.actor.system.attacks?.[attackType]?.critThreshold || 20;
|
||||||
const weaponCritThreshold = weapon.system.critThreshold;
|
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 {
|
return {
|
||||||
attackType,
|
attackType,
|
||||||
@ -166,7 +174,9 @@ export default class AttackRollDialog extends VagabondRollDialog {
|
|||||||
statLabel: game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey] || statKey),
|
statLabel: game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey] || statKey),
|
||||||
statValue,
|
statValue,
|
||||||
difficulty,
|
difficulty,
|
||||||
critThreshold,
|
baseCritThreshold,
|
||||||
|
effectiveCritThreshold,
|
||||||
|
critThresholdModifier: this.critThresholdModifier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,7 +289,9 @@ export default class AttackRollDialog extends VagabondRollDialog {
|
|||||||
context.statLabel = attackData.statLabel;
|
context.statLabel = attackData.statLabel;
|
||||||
context.statValue = attackData.statValue;
|
context.statValue = attackData.statValue;
|
||||||
context.difficulty = attackData.difficulty;
|
context.difficulty = attackData.difficulty;
|
||||||
context.critThreshold = attackData.critThreshold;
|
context.baseCritThreshold = attackData.baseCritThreshold;
|
||||||
|
context.effectiveCritThreshold = attackData.effectiveCritThreshold;
|
||||||
|
context.critThresholdModifier = attackData.critThresholdModifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Versatile weapon handling
|
// Versatile weapon handling
|
||||||
@ -316,6 +328,7 @@ export default class AttackRollDialog extends VagabondRollDialog {
|
|||||||
weaponSelect?.addEventListener("change", (event) => {
|
weaponSelect?.addEventListener("change", (event) => {
|
||||||
this.weaponId = event.target.value;
|
this.weaponId = event.target.value;
|
||||||
this.twoHanded = false; // Reset two-handed when changing weapon
|
this.twoHanded = false; // Reset two-handed when changing weapon
|
||||||
|
this.critThresholdModifier = 0; // Reset crit modifier when changing weapon
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -325,6 +338,30 @@ export default class AttackRollDialog extends VagabondRollDialog {
|
|||||||
this.twoHanded = event.target.checked;
|
this.twoHanded = event.target.checked;
|
||||||
this.render();
|
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 */
|
/** @override */
|
||||||
@ -335,10 +372,15 @@ export default class AttackRollDialog extends VagabondRollDialog {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get effective crit threshold from attack data
|
||||||
|
const attackData = this.attackData;
|
||||||
|
const effectiveCritThreshold = attackData?.effectiveCritThreshold ?? 20;
|
||||||
|
|
||||||
// Perform the attack check
|
// Perform the attack check
|
||||||
const result = await attackCheck(this.actor, weapon, {
|
const result = await attackCheck(this.actor, weapon, {
|
||||||
favorHinder: this.netFavorHinder,
|
favorHinder: this.netFavorHinder,
|
||||||
modifier: this.rollConfig.modifier,
|
modifier: this.rollConfig.modifier,
|
||||||
|
critThreshold: effectiveCritThreshold,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send to chat (damage is rolled separately via button click)
|
// Send to chat (damage is rolled separately via button click)
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export default class SpellCastDialog extends VagabondRollDialog {
|
|||||||
super(actor, options);
|
super(actor, options);
|
||||||
|
|
||||||
this.spellId = options.spellId || null;
|
this.spellId = options.spellId || null;
|
||||||
|
this.critThresholdModifier = 0; // Relative adjustment to base crit threshold
|
||||||
|
|
||||||
// Casting configuration
|
// Casting configuration
|
||||||
this.castConfig = {
|
this.castConfig = {
|
||||||
@ -275,7 +276,17 @@ export default class SpellCastDialog extends VagabondRollDialog {
|
|||||||
context.statValue = statValue;
|
context.statValue = statValue;
|
||||||
context.trained = trained;
|
context.trained = trained;
|
||||||
context.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
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
|
// Damage configuration
|
||||||
context.isDamaging = spell.system.isDamaging();
|
context.isDamaging = spell.system.isDamaging();
|
||||||
@ -347,6 +358,7 @@ export default class SpellCastDialog extends VagabondRollDialog {
|
|||||||
spellSelect?.addEventListener("change", (event) => {
|
spellSelect?.addEventListener("change", (event) => {
|
||||||
this.spellId = event.target.value;
|
this.spellId = event.target.value;
|
||||||
this._initializeCastConfig();
|
this._initializeCastConfig();
|
||||||
|
this.critThresholdModifier = 0; // Reset crit modifier when changing spell
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -378,6 +390,40 @@ export default class SpellCastDialog extends VagabondRollDialog {
|
|||||||
this.render();
|
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
|
// Note: Favor/hinder toggles and modifier presets are handled by parent class
|
||||||
// via super._onRender() - no need to add duplicate listeners here
|
// 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 statValue = this.actor.system.stats?.[statKey]?.value || 0;
|
||||||
const trained = skillData?.trained || false;
|
const trained = skillData?.trained || false;
|
||||||
const difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
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, {
|
const result = await skillCheck(this.actor, castingSkill, {
|
||||||
difficulty,
|
difficulty,
|
||||||
critThreshold,
|
critThreshold: effectiveCritThreshold,
|
||||||
favorHinder: this.netFavorHinder,
|
favorHinder: this.netFavorHinder,
|
||||||
modifier: this.rollConfig.modifier,
|
modifier: this.rollConfig.modifier,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -159,6 +159,7 @@ export async function skillCheck(actor, skillId, options = {}) {
|
|||||||
* @param {Object} options - Additional options
|
* @param {Object} options - Additional options
|
||||||
* @param {number} [options.modifier=0] - Situational modifier
|
* @param {number} [options.modifier=0] - Situational modifier
|
||||||
* @param {number} [options.favorHinder] - Override favor/hinder
|
* @param {number} [options.favorHinder] - Override favor/hinder
|
||||||
|
* @param {number} [options.critThreshold] - Override crit threshold
|
||||||
* @returns {Promise<VagabondRollResult>} The roll result
|
* @returns {Promise<VagabondRollResult>} The roll result
|
||||||
*/
|
*/
|
||||||
export async function attackCheck(actor, weapon, options = {}) {
|
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")
|
// Attack difficulty = 20 - stat × 2 (attacks are always "trained")
|
||||||
const difficulty = 20 - statValue * 2;
|
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 actorCritThreshold = system.attacks?.[attackType]?.critThreshold || 20;
|
||||||
const weaponCritThreshold = weapon.system.critThreshold;
|
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
|
// Determine favor/hinder from Active Effect flags or override
|
||||||
const favorHinderResult = actor.getNetFavorHinder?.({ isAttack: true }) ?? { net: 0 };
|
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)
|
// Single-column info (for spell dialog)
|
||||||
.spell-info {
|
.spell-info {
|
||||||
@include flex-column;
|
@include flex-column;
|
||||||
|
|||||||
@ -43,12 +43,25 @@
|
|||||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{!-- Damage Preview --}}
|
{{!-- Damage Preview --}}
|
||||||
|
|||||||
@ -67,12 +67,25 @@
|
|||||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{!-- Effect Description and Toggle --}}
|
{{!-- Effect Description and Toggle --}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user