Add trained toggle to attack skills (Melee, Brawl, Ranged, Finesse)

Attack skills now have trained/untrained status like regular skills:
- Untrained: difficulty = 20 - stat
- Trained: difficulty = 20 - (stat × 2)

Includes UI toggle button, data model fields, and matching styling.

🤖 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-18 10:33:35 -06:00
parent fe91764f3b
commit 0eb258d2c1
6 changed files with 142 additions and 35 deletions

View File

@ -61,14 +61,23 @@ Each skill has `trained`, `difficulty`, and `critThreshold` properties.
- `system.skills.melee.critThreshold` - Modify melee crit threshold - `system.skills.melee.critThreshold` - Modify melee crit threshold
- `system.skills.sneak.critThreshold` - Modify sneak crit threshold - `system.skills.sneak.critThreshold` - Modify sneak crit threshold
### Attack Crit Thresholds ### Attack Skills
| Key | Type | Range | Description | Each attack skill has `trained`, `difficulty`, and `critThreshold` properties.
| -------------------------------------- | ------ | ----- | ----------------------------- |
| `system.attacks.melee.critThreshold` | Number | 1-20 | Melee attack crit threshold | **Attack Skills:** `melee`, `brawl`, `ranged`, `finesse`
| `system.attacks.brawl.critThreshold` | Number | 1-20 | Brawl attack crit threshold |
| `system.attacks.ranged.critThreshold` | Number | 1-20 | Ranged attack crit threshold | | Key Pattern | Type | Range | Description |
| `system.attacks.finesse.critThreshold` | Number | 1-20 | Finesse attack crit threshold | | --------------------------------------- | ------- | ---------- | --------------------------- |
| `system.attacks.<attack>.trained` | Boolean | true/false | Is attack skill trained? |
| `system.attacks.<attack>.difficulty` | Number | 1-20 | Roll target (derived) |
| `system.attacks.<attack>.critThreshold` | Number | 1-20 | Crit on this roll or higher |
**Examples:**
- `system.attacks.melee.trained` - Train Melee attack skill
- `system.attacks.ranged.trained` - Train Ranged attack skill
- `system.attacks.melee.critThreshold` - Modify melee crit threshold
### Saves ### Saves

View File

@ -184,18 +184,26 @@ export default class CharacterData extends VagabondActorBase {
}), }),
}), }),
// Attack skills with crit thresholds // Attack skills with trained status and crit thresholds
attacks: new fields.SchemaField({ attacks: new fields.SchemaField({
melee: new fields.SchemaField({ melee: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }), critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}), }),
brawl: new fields.SchemaField({ brawl: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }), critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}), }),
ranged: new fields.SchemaField({ ranged: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }), critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}), }),
finesse: new fields.SchemaField({ finesse: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }), critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}), }),
}), }),
@ -652,8 +660,8 @@ export default class CharacterData extends VagabondActorBase {
// Calculate Skill Difficulties (also clamps skill crit thresholds) // Calculate Skill Difficulties (also clamps skill crit thresholds)
this._calculateSkillDifficulties(); this._calculateSkillDifficulties();
// Clamp attack crit thresholds after Active Effects // Calculate Attack Difficulties (also clamps attack crit thresholds)
this._clampAttackCritThresholds(); this._calculateAttackDifficulties();
} }
/** /**
@ -683,13 +691,28 @@ export default class CharacterData extends VagabondActorBase {
} }
/** /**
* Clamp attack crit thresholds after Active Effects are applied. * Calculate difficulty values for all attack skills.
* Schema constraints don't apply to effect-modified data. * Untrained: 20 - stat
* Trained: 20 - (stat × 2)
* Also clamps crit thresholds after Active Effects.
* *
* @private * @private
*/ */
_clampAttackCritThresholds() { _calculateAttackDifficulties() {
for (const attackData of Object.values(this.attacks)) { const attackStats = CONFIG.VAGABOND?.attackTypes || {};
for (const [attackId, attackData] of Object.entries(this.attacks)) {
const attackConfig = attackStats[attackId];
if (!attackConfig) continue;
const statKey = attackConfig.stat;
const statValue = this.stats[statKey]?.value || 0;
const trained = attackData.trained;
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
attackData.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
// Clamp crit threshold after Active Effects (schema min doesn't apply to effect-modified data)
attackData.critThreshold = Math.max(1, Math.min(20, attackData.critThreshold)); attackData.critThreshold = Math.max(1, Math.min(20, attackData.critThreshold));
} }
} }

View File

@ -66,6 +66,7 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor
changeTab: VagabondActorSheet.#onChangeTab, changeTab: VagabondActorSheet.#onChangeTab,
modifyResource: VagabondActorSheet.#onModifyResource, modifyResource: VagabondActorSheet.#onModifyResource,
toggleTrained: VagabondActorSheet.#onToggleTrained, toggleTrained: VagabondActorSheet.#onToggleTrained,
toggleAttackTrained: VagabondActorSheet.#onToggleAttackTrained,
removeStatus: VagabondActorSheet.#onRemoveStatus, removeStatus: VagabondActorSheet.#onRemoveStatus,
}, },
// Drag-drop configuration - use Foundry's built-in system // Drag-drop configuration - use Foundry's built-in system
@ -827,6 +828,21 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor
await this.actor.update({ [`system.skills.${skillId}.trained`]: !currentValue }); await this.actor.update({ [`system.skills.${skillId}.trained`]: !currentValue });
} }
/**
* Handle attack skill trained toggle.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onToggleAttackTrained(event, target) {
event.preventDefault();
event.stopPropagation(); // Prevent triggering the row's rollAttack action
const attackId = target.dataset.attack;
if (!attackId) return;
const currentValue = this.actor.system.attacks[attackId]?.trained ?? false;
await this.actor.update({ [`system.attacks.${attackId}.trained`]: !currentValue });
}
/** /**
* Handle status removal action. * Handle status removal action.
* @param {PointerEvent} event * @param {PointerEvent} event

View File

@ -219,29 +219,23 @@ export default class VagabondCharacterSheet extends VagabondActorSheet {
*/ */
_prepareAttackSkills() { _prepareAttackSkills() {
const system = this.actor.system; const system = this.actor.system;
const attackConfig = CONFIG.VAGABOND?.attackTypes || {};
const attackConfig = {
melee: { label: "VAGABOND.AttackMelee", stat: "might" },
brawl: { label: "VAGABOND.AttackBrawl", stat: "might" },
ranged: { label: "VAGABOND.AttackRanged", stat: "awareness" },
finesse: { label: "VAGABOND.AttackFinesse", stat: "dexterity" },
};
const attacks = {}; const attacks = {};
for (const [key, config] of Object.entries(attackConfig)) { for (const [key, config] of Object.entries(attackConfig)) {
const statValue = system.stats[config.stat]?.value || 0; const attackData = system.attacks[key];
// Attack difficulty is 20 - stat (always trained) if (!attackData) continue;
const difficulty = 20 - statValue * 2;
attacks[key] = { attacks[key] = {
id: key, id: key,
label: config.label, label: config.label,
stat: config.stat, stat: config.stat,
statAbbr: this._getStatAbbr(config.stat), statAbbr: this._getStatAbbr(config.stat),
difficulty, trained: attackData.trained,
critThreshold: system.attacks[key]?.critThreshold || 20, difficulty: attackData.difficulty,
hasCritBonus: (system.attacks[key]?.critThreshold || 20) < 20, critThreshold: attackData.critThreshold || 20,
hasCritBonus: (attackData.critThreshold || 20) < 20,
}; };
} }

View File

@ -733,38 +733,91 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-2; gap: $spacing-2;
padding: $spacing-2 $spacing-3; padding: $spacing-1 $spacing-2;
background-color: var(--color-bg-input); background-color: var(--color-bg-input);
border: 1px solid var(--color-border)-light; border: 1px solid var(--color-border)-light;
border-radius: $radius-md; border-radius: $radius-md;
cursor: pointer;
transition: all $transition-fast; transition: all $transition-fast;
&:hover { &:hover {
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
border-color: var(--color-accent-primary); border-color: var(--color-border);
}
&.trained {
background-color: rgba(45, 90, 39, 0.1);
border-color: rgba(45, 90, 39, 0.3);
.attack-trained-toggle {
color: var(--color-success);
border-color: var(--color-success);
}
}
.attack-trained-toggle {
@include flex-center;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: $radius-sm;
color: var(--color-text-muted);
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--color-accent-primary);
color: var(--color-accent-primary);
}
i {
font-size: 10px;
}
} }
.attack-name { .attack-name {
font-weight: $font-weight-semibold; font-weight: $font-weight-medium;
color: var(--color-text-primary); color: var(--color-text-primary);
flex: 1;
} }
.attack-stat { .attack-stat {
font-size: $font-size-xs; font-size: $font-size-xs;
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin-right: auto;
} }
.attack-difficulty { .attack-difficulty {
font-family: $font-family-header; font-family: $font-family-header;
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
color: var(--color-text-primary); color: var(--color-text-primary);
min-width: 24px;
text-align: center;
} }
.attack-crit { .attack-crit {
font-size: $font-size-xs; font-size: $font-size-xs;
color: var(--color-warning); color: var(--color-warning);
i {
margin-right: 2px;
}
}
.attack-roll-btn {
@include flex-center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color $transition-fast;
&:hover {
color: var(--color-accent-primary);
}
} }
} }

View File

@ -76,9 +76,16 @@
<h2 class="section-header">{{localize "VAGABOND.Attacks"}}</h2> <h2 class="section-header">{{localize "VAGABOND.Attacks"}}</h2>
<div class="attack-skills-grid"> <div class="attack-skills-grid">
{{#each attackSkills}} {{#each attackSkills}}
<div class="attack-skill-row interactive-row" role="button" tabindex="0" <div class="attack-skill-row {{#if this.trained}}trained{{/if}}" data-attack-skill="{{this.id}}">
data-action="rollAttack" data-attack-skill="{{this.id}}" <button type="button" class="attack-trained-toggle"
aria-label="{{localize this.label}} {{localize 'VAGABOND.Attack'}}: {{localize 'VAGABOND.Difficulty'}} {{this.difficulty}}"> data-action="toggleAttackTrained" data-attack="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.ToggleTrained'}}">
{{#if this.trained}}
<i class="fa-solid fa-check"></i>
{{else}}
<i class="fa-regular fa-circle"></i>
{{/if}}
</button>
<span class="attack-name">{{localize this.label}}</span> <span class="attack-name">{{localize this.label}}</span>
<span class="attack-stat">({{this.statAbbr}})</span> <span class="attack-stat">({{this.statAbbr}})</span>
<span class="attack-difficulty">{{this.difficulty}}</span> <span class="attack-difficulty">{{this.difficulty}}</span>
@ -87,6 +94,11 @@
<i class="fa-solid fa-star" aria-hidden="true"></i>{{this.critThreshold}} <i class="fa-solid fa-star" aria-hidden="true"></i>{{this.critThreshold}}
</span> </span>
{{/if}} {{/if}}
<button type="button" class="attack-roll-btn"
data-action="rollAttack" data-attack-skill="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.RollAttack'}}">
<i class="fa-solid fa-dice-d20"></i>
</button>
</div> </div>
{{/each}} {{/each}}
</div> </div>