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