Implement attack and save roll systems with difficulty fix
Phase 2 Tasks 2.6 & 2.7: Complete roll dialog system
- Add AttackRollDialog with weapon selection, grip toggle, attack type display
- Add SaveRollDialog with save type selection, defense options (block/dodge)
- Fix Handlebars template context resolution bug ({{this.difficulty}} pattern)
- Calculate difficulty once in dialog, pass to roll function via options
- Add difficulty/critThreshold pass-through tests for skill checks
- Fix attack check tests: use embedded items, correct damageType to "slashing"
- Add i18n strings for saves, attacks, defense types
- Add chat card and dialog styles for all roll types
- Export all roll dialogs and create system macros
Key technical fix: Handlebars was resolving {{difficulty}} through context
chain to actor.system.skills.X.difficulty (schema default 20) instead of
root template data. Using {{this.difficulty}} explicitly references root.
🤖 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
463a130c18
commit
27a5f481aa
@ -275,19 +275,21 @@
|
|||||||
"id": "2.5",
|
"id": "2.5",
|
||||||
"name": "Implement skill check system",
|
"name": "Implement skill check system",
|
||||||
"description": "Roll dialog with skill selection, favor/hinder toggles, automatic difficulty calculation, crit threshold display",
|
"description": "Roll dialog with skill selection, favor/hinder toggles, automatic difficulty calculation, crit threshold display",
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": false,
|
||||||
"priority": "critical",
|
"priority": "critical",
|
||||||
"dependencies": ["2.2", "2.4"]
|
"dependencies": ["2.2", "2.4"],
|
||||||
|
"notes": "Implemented with ApplicationV2, favor/hinder via Active Effects flags, FavorHinderDebug panel for testing"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2.6",
|
"id": "2.6",
|
||||||
"name": "Implement attack roll system",
|
"name": "Implement attack roll system",
|
||||||
"description": "Weapon attack rolls with stat selection, damage calculation, crit bonus damage, Block/Dodge prompts for targets",
|
"description": "Weapon attack rolls with stat selection, damage calculation, crit bonus damage, Block/Dodge prompts for targets",
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": false,
|
||||||
"priority": "critical",
|
"priority": "critical",
|
||||||
"dependencies": ["2.4", "2.5", "1.11"]
|
"dependencies": ["2.4", "2.5", "1.11"],
|
||||||
|
"notes": "AttackRollDialog with weapon selection, damage roll on hit, crit doubles dice. Block/Dodge prompts deferred to Task 7.8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2.7",
|
"id": "2.7",
|
||||||
|
|||||||
31
lang/en.json
31
lang/en.json
@ -228,5 +228,34 @@
|
|||||||
"VAGABOND.Formula": "Formula",
|
"VAGABOND.Formula": "Formula",
|
||||||
|
|
||||||
"VAGABOND.SelectActor": "Select Actor",
|
"VAGABOND.SelectActor": "Select Actor",
|
||||||
"VAGABOND.Save": "Save"
|
"VAGABOND.Save": "Save",
|
||||||
|
|
||||||
|
"VAGABOND.Attack": "Attack",
|
||||||
|
"VAGABOND.AttackRoll": "Attack Roll",
|
||||||
|
"VAGABOND.AttackType": "Attack Type",
|
||||||
|
"VAGABOND.Weapon": "Weapon",
|
||||||
|
"VAGABOND.SelectWeapon": "Select Weapon...",
|
||||||
|
"VAGABOND.SelectWeaponFirst": "Please select a weapon first",
|
||||||
|
"VAGABOND.NoWeaponsAvailable": "No weapons available",
|
||||||
|
"VAGABOND.Unequipped": "unequipped",
|
||||||
|
"VAGABOND.Hit": "Hit!",
|
||||||
|
"VAGABOND.Miss": "Miss",
|
||||||
|
"VAGABOND.CriticalHit": "Critical Hit!",
|
||||||
|
"VAGABOND.TwoHanded": "Two-Handed",
|
||||||
|
"VAGABOND.TwoHandedGrip": "Use Two-Handed Grip",
|
||||||
|
"VAGABOND.Unarmed": "Unarmed",
|
||||||
|
"VAGABOND.Fist": "Fist",
|
||||||
|
|
||||||
|
"VAGABOND.SaveRoll": "Save Roll",
|
||||||
|
"VAGABOND.SaveType": "Save Type",
|
||||||
|
"VAGABOND.SelectSave": "Select Save",
|
||||||
|
"VAGABOND.SelectSaveFirst": "Please select a save type first",
|
||||||
|
"VAGABOND.Stats": "Stats",
|
||||||
|
"VAGABOND.DefenseType": "Defense Type",
|
||||||
|
"VAGABOND.RequiresShield": "Requires an equipped shield",
|
||||||
|
"VAGABOND.BlockInfo": "Block uses your shield to reduce incoming damage",
|
||||||
|
"VAGABOND.DodgeInfo": "Dodge allows you to avoid the attack entirely",
|
||||||
|
"VAGABOND.BlockedWith": "Blocked with shield",
|
||||||
|
"VAGABOND.DodgedAttack": "Dodged the attack",
|
||||||
|
"VAGABOND.CriticalSuccess": "Critical Success!"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,4 +5,6 @@
|
|||||||
|
|
||||||
export { default as VagabondRollDialog } from "./base-roll-dialog.mjs";
|
export { default as VagabondRollDialog } from "./base-roll-dialog.mjs";
|
||||||
export { default as SkillCheckDialog } from "./skill-check-dialog.mjs";
|
export { default as SkillCheckDialog } from "./skill-check-dialog.mjs";
|
||||||
|
export { default as AttackRollDialog } from "./attack-roll-dialog.mjs";
|
||||||
|
export { default as SaveRollDialog } from "./save-roll-dialog.mjs";
|
||||||
export { default as FavorHinderDebug } from "./favor-hinder-debug.mjs";
|
export { default as FavorHinderDebug } from "./favor-hinder-debug.mjs";
|
||||||
|
|||||||
457
module/applications/attack-roll-dialog.mjs
Normal file
457
module/applications/attack-roll-dialog.mjs
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Attack Roll Dialog for Vagabond RPG
|
||||||
|
*
|
||||||
|
* Extends VagabondRollDialog to handle attack roll configuration:
|
||||||
|
* - Weapon selection from equipped weapons
|
||||||
|
* - Attack type display (Melee/Brawl/Ranged/Finesse)
|
||||||
|
* - Difficulty/crit threshold calculation
|
||||||
|
* - Two-handed toggle for versatile weapons
|
||||||
|
* - Damage roll on hit
|
||||||
|
*
|
||||||
|
* @extends VagabondRollDialog
|
||||||
|
*/
|
||||||
|
|
||||||
|
import VagabondRollDialog from "./base-roll-dialog.mjs";
|
||||||
|
import { attackCheck, damageRoll } from "../dice/rolls.mjs";
|
||||||
|
|
||||||
|
export default class AttackRollDialog extends VagabondRollDialog {
|
||||||
|
/**
|
||||||
|
* @param {VagabondActor} actor - The actor making the roll
|
||||||
|
* @param {Object} options - Dialog options
|
||||||
|
* @param {string} [options.weaponId] - Pre-selected weapon ID
|
||||||
|
*/
|
||||||
|
constructor(actor, options = {}) {
|
||||||
|
super(actor, options);
|
||||||
|
|
||||||
|
this.weaponId = options.weaponId || null;
|
||||||
|
this.twoHanded = false;
|
||||||
|
|
||||||
|
// Auto-select first equipped weapon if none specified, otherwise default to unarmed
|
||||||
|
if (!this.weaponId) {
|
||||||
|
const equippedWeapons = this._getEquippedWeapons();
|
||||||
|
if (equippedWeapons.length > 0) {
|
||||||
|
this.weaponId = equippedWeapons[0].id;
|
||||||
|
} else {
|
||||||
|
this.weaponId = "unarmed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load automatic favor/hinder for attacks
|
||||||
|
this.rollConfig.autoFavorHinder = actor.getNetFavorHinder({ isAttack: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Static Properties */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
static DEFAULT_OPTIONS = foundry.utils.mergeObject(
|
||||||
|
super.DEFAULT_OPTIONS,
|
||||||
|
{
|
||||||
|
id: "vagabond-attack-roll-dialog",
|
||||||
|
window: {
|
||||||
|
title: "VAGABOND.AttackRoll",
|
||||||
|
icon: "fa-solid fa-swords",
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
width: 380,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ inplace: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
static PARTS = {
|
||||||
|
form: {
|
||||||
|
template: "systems/vagabond/templates/dialog/attack-roll.hbs",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Getters */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
get title() {
|
||||||
|
if (this.weapon) {
|
||||||
|
return `${game.i18n.localize("VAGABOND.Attack")}: ${this.weapon.name}`;
|
||||||
|
}
|
||||||
|
return game.i18n.localize("VAGABOND.AttackRoll");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected weapon.
|
||||||
|
* Returns a virtual "Unarmed" weapon object if weaponId is "unarmed".
|
||||||
|
* @returns {VagabondItem|Object|null}
|
||||||
|
*/
|
||||||
|
get weapon() {
|
||||||
|
if (!this.weaponId) return null;
|
||||||
|
|
||||||
|
// Return virtual unarmed weapon
|
||||||
|
if (this.weaponId === "unarmed") {
|
||||||
|
return this._getUnarmedWeapon();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.actor.items.get(this.weaponId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the virtual unarmed strike weapon.
|
||||||
|
* All characters have access to this attack.
|
||||||
|
* @returns {Object} Virtual weapon object matching weapon item interface
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getUnarmedWeapon() {
|
||||||
|
return {
|
||||||
|
id: "unarmed",
|
||||||
|
name: game.i18n.localize("VAGABOND.Unarmed"),
|
||||||
|
img: "icons/skills/melee/unarmed-punch-fist.webp",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
damage: "1",
|
||||||
|
damageType: "blunt",
|
||||||
|
bonusDamage: 0,
|
||||||
|
grip: "fist",
|
||||||
|
attackType: "brawl",
|
||||||
|
range: { value: 0, units: "ft" },
|
||||||
|
properties: {
|
||||||
|
finesse: false,
|
||||||
|
thrown: false,
|
||||||
|
cleave: false,
|
||||||
|
reach: false,
|
||||||
|
loading: false,
|
||||||
|
brawl: true,
|
||||||
|
crude: false,
|
||||||
|
versatile: false,
|
||||||
|
},
|
||||||
|
equipped: true,
|
||||||
|
slots: 0,
|
||||||
|
value: 0,
|
||||||
|
critThreshold: null,
|
||||||
|
// Methods to match weapon item interface
|
||||||
|
getAttackStat: () => "might",
|
||||||
|
getDamageFormula: () => "1",
|
||||||
|
getActiveProperties: () => ["brawl"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attack data for the current weapon.
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
get attackData() {
|
||||||
|
const weapon = this.weapon;
|
||||||
|
if (!weapon) return null;
|
||||||
|
|
||||||
|
const attackType = weapon.system.attackType || "melee";
|
||||||
|
const attackConfig = CONFIG.VAGABOND?.attackTypes?.[attackType];
|
||||||
|
if (!attackConfig) return null;
|
||||||
|
|
||||||
|
const statKey = weapon.system.getAttackStat?.() || attackConfig.stat;
|
||||||
|
const statValue = this.actor.system.stats?.[statKey]?.value || 0;
|
||||||
|
|
||||||
|
// Attacks use trained difficulty (20 - stat × 2)
|
||||||
|
const difficulty = 20 - statValue * 2;
|
||||||
|
|
||||||
|
// Get 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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
attackType,
|
||||||
|
attackLabel: game.i18n.localize(attackConfig.label),
|
||||||
|
statKey,
|
||||||
|
statLabel: game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey] || statKey),
|
||||||
|
statValue,
|
||||||
|
difficulty,
|
||||||
|
critThreshold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Helper Methods */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all equipped weapons for this actor.
|
||||||
|
* @returns {Array<VagabondItem>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getEquippedWeapons() {
|
||||||
|
return this.actor.items.filter((item) => item.type === "weapon" && item.system.equipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all weapons (equipped or not) for this actor.
|
||||||
|
* @returns {Array<VagabondItem>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getAllWeapons() {
|
||||||
|
return this.actor.items.filter((item) => item.type === "weapon");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the damage formula for the current weapon.
|
||||||
|
* @returns {string}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getDamageFormula() {
|
||||||
|
const weapon = this.weapon;
|
||||||
|
if (!weapon) return "1d6";
|
||||||
|
|
||||||
|
return weapon.system.getDamageFormula?.(this.twoHanded) || weapon.system.damage || "1d6";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Data Preparation */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
async _prepareRollContext(_options) {
|
||||||
|
const context = {};
|
||||||
|
|
||||||
|
// Get all weapons for selection (including unarmed)
|
||||||
|
const allWeapons = this._getAllWeapons();
|
||||||
|
const unarmed = this._getUnarmedWeapon();
|
||||||
|
|
||||||
|
// Build weapons list with unarmed always first
|
||||||
|
context.weapons = [
|
||||||
|
{
|
||||||
|
id: "unarmed",
|
||||||
|
name: unarmed.name,
|
||||||
|
img: unarmed.img,
|
||||||
|
equipped: true,
|
||||||
|
attackType: unarmed.system.attackType,
|
||||||
|
damage: unarmed.system.damage,
|
||||||
|
grip: unarmed.system.grip,
|
||||||
|
isVersatile: false,
|
||||||
|
isUnarmed: true,
|
||||||
|
selected: this.weaponId === "unarmed",
|
||||||
|
},
|
||||||
|
...allWeapons.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.name,
|
||||||
|
img: w.img,
|
||||||
|
equipped: w.system.equipped,
|
||||||
|
attackType: w.system.attackType,
|
||||||
|
damage: w.system.damage,
|
||||||
|
grip: w.system.grip,
|
||||||
|
isVersatile: w.system.properties?.versatile || false,
|
||||||
|
isUnarmed: false,
|
||||||
|
selected: w.id === this.weaponId,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
context.hasWeapons = true; // Always true now since unarmed is always available
|
||||||
|
context.selectedWeaponId = this.weaponId;
|
||||||
|
context.weapon = this.weapon;
|
||||||
|
|
||||||
|
// Attack data
|
||||||
|
const attackData = this.attackData;
|
||||||
|
if (attackData) {
|
||||||
|
context.attackType = attackData.attackType;
|
||||||
|
context.attackLabel = attackData.attackLabel;
|
||||||
|
context.statLabel = attackData.statLabel;
|
||||||
|
context.statValue = attackData.statValue;
|
||||||
|
context.difficulty = attackData.difficulty;
|
||||||
|
context.critThreshold = attackData.critThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versatile weapon handling
|
||||||
|
const weapon = this.weapon;
|
||||||
|
if (weapon) {
|
||||||
|
context.isVersatile = weapon.system.properties?.versatile || false;
|
||||||
|
context.twoHanded = this.twoHanded;
|
||||||
|
context.damageFormula = this._getDamageFormula();
|
||||||
|
context.damageType = weapon.system.damageType;
|
||||||
|
context.damageTypeLabel = game.i18n.localize(
|
||||||
|
CONFIG.VAGABOND?.damageTypes?.[weapon.system.damageType] || weapon.system.damageType
|
||||||
|
);
|
||||||
|
|
||||||
|
// Weapon properties
|
||||||
|
context.properties = weapon.system.getActiveProperties?.() || [];
|
||||||
|
context.propertyLabels = context.properties.map((p) =>
|
||||||
|
game.i18n.localize(CONFIG.VAGABOND?.weaponProperties?.[p] || p)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Event Handlers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
_onRender(context, options) {
|
||||||
|
super._onRender(context, options);
|
||||||
|
|
||||||
|
// Weapon selection dropdown
|
||||||
|
const weaponSelect = this.element.querySelector('[name="weaponId"]');
|
||||||
|
weaponSelect?.addEventListener("change", (event) => {
|
||||||
|
this.weaponId = event.target.value;
|
||||||
|
this.twoHanded = false; // Reset two-handed when changing weapon
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two-handed toggle for versatile weapons
|
||||||
|
const twoHandedToggle = this.element.querySelector('[name="twoHanded"]');
|
||||||
|
twoHandedToggle?.addEventListener("change", (event) => {
|
||||||
|
this.twoHanded = event.target.checked;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
async _executeRoll() {
|
||||||
|
const weapon = this.weapon;
|
||||||
|
if (!weapon) {
|
||||||
|
ui.notifications.warn(game.i18n.localize("VAGABOND.SelectWeaponFirst"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the attack check
|
||||||
|
const result = await attackCheck(this.actor, weapon, {
|
||||||
|
favorHinder: this.netFavorHinder,
|
||||||
|
modifier: this.rollConfig.modifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roll damage if the attack hit
|
||||||
|
let damageResult = null;
|
||||||
|
if (result.success) {
|
||||||
|
const damageFormula = this._getDamageFormula();
|
||||||
|
damageResult = await damageRoll(damageFormula, {
|
||||||
|
isCrit: result.isCrit,
|
||||||
|
rollData: this.actor.getRollData(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to chat with custom template
|
||||||
|
await this._sendToChat(result, damageResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the roll result to chat.
|
||||||
|
*
|
||||||
|
* @param {VagabondRollResult} result - The attack roll result
|
||||||
|
* @param {Roll|null} damageResult - The damage roll (if hit)
|
||||||
|
* @returns {Promise<ChatMessage>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _sendToChat(result, damageResult) {
|
||||||
|
const weapon = this.weapon;
|
||||||
|
const attackData = this.attackData;
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
const templateData = {
|
||||||
|
actor: this.actor,
|
||||||
|
weapon: {
|
||||||
|
id: weapon.id,
|
||||||
|
name: weapon.name,
|
||||||
|
img: weapon.img,
|
||||||
|
attackType: weapon.system.attackType,
|
||||||
|
damageType: weapon.system.damageType,
|
||||||
|
damageTypeLabel: game.i18n.localize(
|
||||||
|
CONFIG.VAGABOND?.damageTypes?.[weapon.system.damageType] || weapon.system.damageType
|
||||||
|
),
|
||||||
|
properties: weapon.system.getActiveProperties?.() || [],
|
||||||
|
},
|
||||||
|
attackLabel: attackData?.attackLabel,
|
||||||
|
difficulty: result.difficulty,
|
||||||
|
critThreshold: result.critThreshold,
|
||||||
|
total: result.total,
|
||||||
|
d20Result: result.d20Result,
|
||||||
|
favorDie: result.favorDie,
|
||||||
|
modifier: this.rollConfig.modifier,
|
||||||
|
success: result.success,
|
||||||
|
isCrit: result.isCrit,
|
||||||
|
isFumble: result.isFumble,
|
||||||
|
formula: result.roll.formula,
|
||||||
|
netFavorHinder: this.netFavorHinder,
|
||||||
|
favorSources: this.rollConfig.autoFavorHinder.favorSources,
|
||||||
|
hinderSources: this.rollConfig.autoFavorHinder.hinderSources,
|
||||||
|
// Damage info
|
||||||
|
hasDamage: !!damageResult,
|
||||||
|
damageTotal: damageResult?.total,
|
||||||
|
damageFormula: damageResult?.formula,
|
||||||
|
twoHanded: this.twoHanded,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render the chat card template
|
||||||
|
const content = await renderTemplate(
|
||||||
|
"systems/vagabond/templates/chat/attack-roll.hbs",
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect all rolls
|
||||||
|
const rolls = [result.roll];
|
||||||
|
if (damageResult) rolls.push(damageResult);
|
||||||
|
|
||||||
|
// Create the chat message
|
||||||
|
const chatData = {
|
||||||
|
user: game.user.id,
|
||||||
|
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
|
||||||
|
content,
|
||||||
|
rolls,
|
||||||
|
sound: CONFIG.sounds.dice,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ChatMessage.create(chatData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Static Methods */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and render an attack roll dialog.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the roll
|
||||||
|
* @param {string} [weaponId] - Optional pre-selected weapon ID
|
||||||
|
* @param {Object} [options] - Additional options
|
||||||
|
* @returns {Promise<AttackRollDialog>}
|
||||||
|
*/
|
||||||
|
static async prompt(actor, weaponId = null, options = {}) {
|
||||||
|
return this.create(actor, { ...options, weaponId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a quick attack roll without showing the dialog.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the roll
|
||||||
|
* @param {VagabondItem} weapon - The weapon to attack with
|
||||||
|
* @param {Object} [options] - Roll options
|
||||||
|
* @returns {Promise<Object>} Attack and damage results
|
||||||
|
*/
|
||||||
|
static async quickRoll(actor, weapon, options = {}) {
|
||||||
|
// Get automatic favor/hinder
|
||||||
|
const autoFavorHinder = actor.getNetFavorHinder({ isAttack: true });
|
||||||
|
|
||||||
|
// Perform the attack
|
||||||
|
const result = await attackCheck(actor, weapon, {
|
||||||
|
favorHinder: options.favorHinder ?? autoFavorHinder.net,
|
||||||
|
modifier: options.modifier || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roll damage if hit
|
||||||
|
let damageResult = null;
|
||||||
|
if (result.success) {
|
||||||
|
const damageFormula =
|
||||||
|
weapon.system.getDamageFormula?.(options.twoHanded) || weapon.system.damage || "1d6";
|
||||||
|
damageResult = await damageRoll(damageFormula, {
|
||||||
|
isCrit: result.isCrit,
|
||||||
|
rollData: actor.getRollData(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary dialog for chat output
|
||||||
|
const tempDialog = new this(actor, { weaponId: weapon.id });
|
||||||
|
tempDialog.rollConfig.autoFavorHinder = autoFavorHinder;
|
||||||
|
tempDialog.twoHanded = options.twoHanded || false;
|
||||||
|
await tempDialog._sendToChat(result, damageResult);
|
||||||
|
|
||||||
|
return { attack: result, damage: damageResult };
|
||||||
|
}
|
||||||
|
}
|
||||||
336
module/applications/save-roll-dialog.mjs
Normal file
336
module/applications/save-roll-dialog.mjs
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* Save Roll Dialog for Vagabond RPG
|
||||||
|
*
|
||||||
|
* Extends VagabondRollDialog to handle saving throw configuration:
|
||||||
|
* - Save type selection (Reflex, Endure, Will)
|
||||||
|
* - Displays calculated difficulty from stats
|
||||||
|
* - Block/Dodge choice for Reflex saves (defense)
|
||||||
|
* - Favor/Hinder toggles
|
||||||
|
*
|
||||||
|
* Save Difficulties:
|
||||||
|
* - Reflex: 20 - DEX - AWR
|
||||||
|
* - Endure: 20 - MIT - MIT (MIT counts twice)
|
||||||
|
* - Will: 20 - RSN - PRS
|
||||||
|
*
|
||||||
|
* @extends VagabondRollDialog
|
||||||
|
*/
|
||||||
|
|
||||||
|
import VagabondRollDialog from "./base-roll-dialog.mjs";
|
||||||
|
import { saveRoll } from "../dice/rolls.mjs";
|
||||||
|
|
||||||
|
export default class SaveRollDialog extends VagabondRollDialog {
|
||||||
|
/**
|
||||||
|
* @param {VagabondActor} actor - The actor making the roll
|
||||||
|
* @param {Object} options - Dialog options
|
||||||
|
* @param {string} [options.saveType] - Pre-selected save type
|
||||||
|
* @param {number} [options.difficulty] - Target difficulty (if known)
|
||||||
|
* @param {boolean} [options.isDefense=false] - If true, this is a defensive save (show Block/Dodge)
|
||||||
|
*/
|
||||||
|
constructor(actor, options = {}) {
|
||||||
|
super(actor, options);
|
||||||
|
|
||||||
|
this.saveType = options.saveType || null;
|
||||||
|
this.targetDifficulty = options.difficulty || null;
|
||||||
|
this.isDefense = options.isDefense || false;
|
||||||
|
this.defenseType = null; // "block" or "dodge" for Reflex defense saves
|
||||||
|
|
||||||
|
// Load automatic favor/hinder for this save type
|
||||||
|
if (this.saveType) {
|
||||||
|
this.rollConfig.autoFavorHinder = actor.getNetFavorHinder({ saveType: this.saveType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Static Properties */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
static DEFAULT_OPTIONS = foundry.utils.mergeObject(
|
||||||
|
super.DEFAULT_OPTIONS,
|
||||||
|
{
|
||||||
|
id: "vagabond-save-roll-dialog",
|
||||||
|
window: {
|
||||||
|
title: "VAGABOND.SaveRoll",
|
||||||
|
icon: "fa-solid fa-shield-halved",
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
width: 360,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ inplace: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
static PARTS = {
|
||||||
|
form: {
|
||||||
|
template: "systems/vagabond/templates/dialog/save-roll.hbs",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Getters */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
get title() {
|
||||||
|
if (this.saveType) {
|
||||||
|
const saveLabel = CONFIG.VAGABOND?.saves?.[this.saveType]?.label || this.saveType;
|
||||||
|
return `${game.i18n.localize(saveLabel)} ${game.i18n.localize("VAGABOND.Save")}`;
|
||||||
|
}
|
||||||
|
return game.i18n.localize("VAGABOND.SaveRoll");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current save data from the actor.
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
get saveData() {
|
||||||
|
if (!this.saveType) return null;
|
||||||
|
return this.actor.system.saves?.[this.saveType] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the difficulty for this save.
|
||||||
|
* Uses targetDifficulty if provided, otherwise uses actor's calculated difficulty.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
get difficulty() {
|
||||||
|
// If a specific difficulty was provided (from an effect), use that
|
||||||
|
if (this.targetDifficulty !== null) {
|
||||||
|
return this.targetDifficulty;
|
||||||
|
}
|
||||||
|
// Otherwise use the actor's calculated save difficulty
|
||||||
|
return this.saveData?.difficulty || 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Data Preparation */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
async _prepareRollContext(_options) {
|
||||||
|
const context = {};
|
||||||
|
|
||||||
|
// Available saves for dropdown
|
||||||
|
context.saves = Object.entries(CONFIG.VAGABOND?.saves || {}).map(([id, config]) => {
|
||||||
|
const saveData = this.actor.system.saves?.[id] || {};
|
||||||
|
const stats = config.stats || [];
|
||||||
|
const statLabels = stats.map((s) => game.i18n.localize(CONFIG.VAGABOND?.statsAbbr?.[s] || s));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: game.i18n.localize(config.label),
|
||||||
|
stats: statLabels.join(" + "),
|
||||||
|
difficulty: saveData.difficulty || 10,
|
||||||
|
selected: id === this.saveType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
context.selectedSaveType = this.saveType;
|
||||||
|
context.saveData = this.saveData;
|
||||||
|
|
||||||
|
if (this.saveData) {
|
||||||
|
context.difficulty = this.difficulty;
|
||||||
|
|
||||||
|
// Get the associated stats
|
||||||
|
const saveConfig = CONFIG.VAGABOND?.saves?.[this.saveType];
|
||||||
|
if (saveConfig?.stats) {
|
||||||
|
context.statLabels = saveConfig.stats.map((s) =>
|
||||||
|
game.i18n.localize(CONFIG.VAGABOND?.stats?.[s] || s)
|
||||||
|
);
|
||||||
|
context.statValues = saveConfig.stats.map((s) => this.actor.system.stats?.[s]?.value || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense options for Reflex saves
|
||||||
|
context.isDefense = this.isDefense;
|
||||||
|
context.showDefenseOptions = this.isDefense && this.saveType === "reflex";
|
||||||
|
context.defenseType = this.defenseType;
|
||||||
|
|
||||||
|
// Check if actor has a shield equipped for Block option
|
||||||
|
context.hasShield = this._hasShieldEquipped();
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the actor has a shield equipped.
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hasShieldEquipped() {
|
||||||
|
return this.actor.items.some(
|
||||||
|
(item) => item.type === "armor" && item.system.armorType === "shield" && item.system.equipped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Event Handlers */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
_onRender(context, options) {
|
||||||
|
super._onRender(context, options);
|
||||||
|
|
||||||
|
// Save type selection dropdown
|
||||||
|
const saveSelect = this.element.querySelector('[name="saveType"]');
|
||||||
|
saveSelect?.addEventListener("change", (event) => {
|
||||||
|
this.saveType = event.target.value;
|
||||||
|
this.rollConfig.autoFavorHinder = this.actor.getNetFavorHinder({
|
||||||
|
saveType: this.saveType,
|
||||||
|
});
|
||||||
|
this.defenseType = null; // Reset defense type when save changes
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defense type selection (Block/Dodge)
|
||||||
|
const defenseButtons = this.element.querySelectorAll("[data-defense]");
|
||||||
|
for (const btn of defenseButtons) {
|
||||||
|
btn.addEventListener("click", (event) => {
|
||||||
|
this.defenseType = event.currentTarget.dataset.defense;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
async _executeRoll() {
|
||||||
|
if (!this.saveType) {
|
||||||
|
ui.notifications.warn(game.i18n.localize("VAGABOND.SelectSaveFirst"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the save roll
|
||||||
|
const result = await saveRoll(this.actor, this.saveType, this.difficulty, {
|
||||||
|
favorHinder: this.netFavorHinder,
|
||||||
|
modifier: this.rollConfig.modifier,
|
||||||
|
isBlock: this.defenseType === "block",
|
||||||
|
isDodge: this.defenseType === "dodge",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to chat with custom template
|
||||||
|
await this._sendToChat(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the roll result to chat.
|
||||||
|
*
|
||||||
|
* @param {VagabondRollResult} result - The roll result
|
||||||
|
* @returns {Promise<ChatMessage>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _sendToChat(result) {
|
||||||
|
const saveConfig = CONFIG.VAGABOND?.saves?.[this.saveType];
|
||||||
|
const saveLabel = game.i18n.localize(saveConfig?.label || this.saveType);
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
const templateData = {
|
||||||
|
actor: this.actor,
|
||||||
|
saveType: this.saveType,
|
||||||
|
saveLabel,
|
||||||
|
stats: saveConfig?.stats?.map((s) =>
|
||||||
|
game.i18n.localize(CONFIG.VAGABOND?.statsAbbr?.[s] || s)
|
||||||
|
),
|
||||||
|
difficulty: result.difficulty,
|
||||||
|
total: result.total,
|
||||||
|
d20Result: result.d20Result,
|
||||||
|
favorDie: result.favorDie,
|
||||||
|
modifier: this.rollConfig.modifier,
|
||||||
|
success: result.success,
|
||||||
|
isCrit: result.isCrit,
|
||||||
|
isFumble: result.isFumble,
|
||||||
|
formula: result.roll.formula,
|
||||||
|
netFavorHinder: this.netFavorHinder,
|
||||||
|
favorSources: this.rollConfig.autoFavorHinder.favorSources,
|
||||||
|
hinderSources: this.rollConfig.autoFavorHinder.hinderSources,
|
||||||
|
// Defense info
|
||||||
|
isDefense: this.isDefense,
|
||||||
|
defenseType: this.defenseType,
|
||||||
|
defenseLabel: this.defenseType
|
||||||
|
? game.i18n.localize(
|
||||||
|
`VAGABOND.${this.defenseType.charAt(0).toUpperCase() + this.defenseType.slice(1)}`
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render the chat card template
|
||||||
|
const content = await renderTemplate(
|
||||||
|
"systems/vagabond/templates/chat/save-roll.hbs",
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the chat message
|
||||||
|
const chatData = {
|
||||||
|
user: game.user.id,
|
||||||
|
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
|
||||||
|
content,
|
||||||
|
rolls: [result.roll],
|
||||||
|
sound: CONFIG.sounds.dice,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ChatMessage.create(chatData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
/* Static Methods */
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and render a save roll dialog.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the roll
|
||||||
|
* @param {string} [saveType] - Optional pre-selected save type
|
||||||
|
* @param {Object} [options] - Additional options
|
||||||
|
* @returns {Promise<SaveRollDialog>}
|
||||||
|
*/
|
||||||
|
static async prompt(actor, saveType = null, options = {}) {
|
||||||
|
return this.create(actor, { ...options, saveType });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for a defensive save (Block or Dodge).
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the defense
|
||||||
|
* @param {number} difficulty - The attack roll to beat
|
||||||
|
* @param {Object} [options] - Additional options
|
||||||
|
* @returns {Promise<SaveRollDialog>}
|
||||||
|
*/
|
||||||
|
static async promptDefense(actor, difficulty, options = {}) {
|
||||||
|
return this.create(actor, {
|
||||||
|
...options,
|
||||||
|
saveType: "reflex",
|
||||||
|
difficulty,
|
||||||
|
isDefense: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a quick save roll without showing the dialog.
|
||||||
|
*
|
||||||
|
* @param {VagabondActor} actor - The actor making the roll
|
||||||
|
* @param {string} saveType - The save type
|
||||||
|
* @param {number} [difficulty] - Target difficulty (uses actor's save if not provided)
|
||||||
|
* @param {Object} [options] - Roll options
|
||||||
|
* @returns {Promise<VagabondRollResult>}
|
||||||
|
*/
|
||||||
|
static async quickRoll(actor, saveType, difficulty = null, options = {}) {
|
||||||
|
// Get automatic favor/hinder
|
||||||
|
const autoFavorHinder = actor.getNetFavorHinder({ saveType });
|
||||||
|
|
||||||
|
// Use provided difficulty or actor's calculated save difficulty
|
||||||
|
const targetDifficulty = difficulty ?? actor.system.saves?.[saveType]?.difficulty ?? 10;
|
||||||
|
|
||||||
|
// Perform the roll
|
||||||
|
const result = await saveRoll(actor, saveType, targetDifficulty, {
|
||||||
|
favorHinder: options.favorHinder ?? autoFavorHinder.net,
|
||||||
|
modifier: options.modifier || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create temporary dialog for chat output
|
||||||
|
const tempDialog = new this(actor, { saveType, difficulty: targetDifficulty });
|
||||||
|
tempDialog.rollConfig.autoFavorHinder = autoFavorHinder;
|
||||||
|
await tempDialog._sendToChat(result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -87,12 +87,16 @@ export default class SkillCheckDialog extends VagabondRollDialog {
|
|||||||
// Available skills for dropdown (if no skill pre-selected)
|
// Available skills for dropdown (if no skill pre-selected)
|
||||||
context.skills = Object.entries(CONFIG.VAGABOND?.skills || {}).map(([id, config]) => {
|
context.skills = Object.entries(CONFIG.VAGABOND?.skills || {}).map(([id, config]) => {
|
||||||
const skillData = this.actor.system.skills?.[id] || {};
|
const skillData = this.actor.system.skills?.[id] || {};
|
||||||
|
const statValue = this.actor.system.stats?.[config.stat]?.value || 0;
|
||||||
|
const trained = skillData.trained || false;
|
||||||
|
// Calculate difficulty directly: 20 - stat (untrained) or 20 - stat×2 (trained)
|
||||||
|
const difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: game.i18n.localize(config.label),
|
label: game.i18n.localize(config.label),
|
||||||
stat: config.stat,
|
stat: config.stat,
|
||||||
trained: skillData.trained || false,
|
trained,
|
||||||
difficulty: skillData.difficulty || 20,
|
difficulty,
|
||||||
critThreshold: skillData.critThreshold || 20,
|
critThreshold: skillData.critThreshold || 20,
|
||||||
selected: id === this.skillId,
|
selected: id === this.skillId,
|
||||||
};
|
};
|
||||||
@ -103,15 +107,25 @@ export default class SkillCheckDialog extends VagabondRollDialog {
|
|||||||
context.skillData = this.skillData;
|
context.skillData = this.skillData;
|
||||||
|
|
||||||
if (this.skillData) {
|
if (this.skillData) {
|
||||||
context.difficulty = this.skillData.difficulty;
|
// Get the associated stat and calculate difficulty
|
||||||
context.critThreshold = this.skillData.critThreshold || 20;
|
|
||||||
context.trained = this.skillData.trained;
|
|
||||||
|
|
||||||
// Get the associated stat
|
|
||||||
const statKey = CONFIG.VAGABOND?.skills?.[this.skillId]?.stat;
|
const statKey = CONFIG.VAGABOND?.skills?.[this.skillId]?.stat;
|
||||||
|
const statValue = this.actor.system.stats?.[statKey]?.value || 0;
|
||||||
|
const trained = this.skillData.trained;
|
||||||
|
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
|
||||||
|
const difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
||||||
|
const critThreshold = this.skillData.critThreshold || 20;
|
||||||
|
|
||||||
|
// Store on instance for use in _executeRoll
|
||||||
|
this._calculatedDifficulty = difficulty;
|
||||||
|
this._calculatedCritThreshold = critThreshold;
|
||||||
|
|
||||||
|
context.difficulty = difficulty;
|
||||||
|
context.critThreshold = critThreshold;
|
||||||
|
context.trained = trained;
|
||||||
|
|
||||||
if (statKey) {
|
if (statKey) {
|
||||||
context.statLabel = game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey]?.label || statKey);
|
context.statLabel = game.i18n.localize(CONFIG.VAGABOND?.stats?.[statKey]?.label || statKey);
|
||||||
context.statValue = this.actor.system.stats?.[statKey]?.value || 0;
|
context.statValue = statValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,8 +156,10 @@ export default class SkillCheckDialog extends VagabondRollDialog {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the skill check
|
// Perform the skill check with pre-calculated difficulty
|
||||||
const result = await skillCheck(this.actor, this.skillId, {
|
const result = await skillCheck(this.actor, this.skillId, {
|
||||||
|
difficulty: this._calculatedDifficulty,
|
||||||
|
critThreshold: this._calculatedCritThreshold,
|
||||||
favorHinder: this.netFavorHinder,
|
favorHinder: this.netFavorHinder,
|
||||||
modifier: this.rollConfig.modifier,
|
modifier: this.rollConfig.modifier,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -120,54 +120,66 @@ export default class CharacterData extends VagabondActorBase {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 12 skills with training and custom crit thresholds
|
// 12 skills with training, difficulty (computed), and custom crit thresholds
|
||||||
skills: new fields.SchemaField({
|
skills: new fields.SchemaField({
|
||||||
arcana: new fields.SchemaField({
|
arcana: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
craft: new fields.SchemaField({
|
craft: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
detect: new fields.SchemaField({
|
detect: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
influence: new fields.SchemaField({
|
influence: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
leadership: new fields.SchemaField({
|
leadership: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
medicine: new fields.SchemaField({
|
medicine: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
mysticism: new fields.SchemaField({
|
mysticism: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
performance: new fields.SchemaField({
|
performance: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
sneak: new fields.SchemaField({
|
sneak: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
survival: new fields.SchemaField({
|
survival: new fields.SchemaField({
|
||||||
trained: new fields.BooleanField({ initial: false }),
|
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 }),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@ -662,9 +674,6 @@ export default class CharacterData extends VagabondActorBase {
|
|||||||
|
|
||||||
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
|
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
|
||||||
skillData.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
skillData.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
||||||
|
|
||||||
// Store the associated stat for reference
|
|
||||||
skillData.stat = statKey;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -126,9 +126,17 @@ export async function skillCheck(actor, skillId, options = {}) {
|
|||||||
throw new Error(`Actor does not have skill: ${skillId}`);
|
throw new Error(`Actor does not have skill: ${skillId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get difficulty from calculated value
|
// Use provided difficulty or calculate from stat and training
|
||||||
const difficulty = skillData.difficulty;
|
let difficulty;
|
||||||
const critThreshold = skillData.critThreshold || 20;
|
if (options.difficulty !== undefined) {
|
||||||
|
difficulty = options.difficulty;
|
||||||
|
} else {
|
||||||
|
const statKey = skillConfig.stat;
|
||||||
|
const statValue = system.stats?.[statKey]?.value || 0;
|
||||||
|
const trained = skillData.trained;
|
||||||
|
difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
|
||||||
|
}
|
||||||
|
const critThreshold = options.critThreshold ?? skillData.critThreshold ?? 20;
|
||||||
|
|
||||||
// Determine favor/hinder from Active Effect flags or override
|
// Determine favor/hinder from Active Effect flags or override
|
||||||
const favorHinderResult = actor.getNetFavorHinder?.({ skillId }) ?? { net: 0 };
|
const favorHinderResult = actor.getNetFavorHinder?.({ skillId }) ?? { net: 0 };
|
||||||
@ -154,7 +162,7 @@ export async function skillCheck(actor, skillId, options = {}) {
|
|||||||
* @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 = {}) {
|
||||||
const attackType = weapon.system.attackSkill || "melee";
|
const attackType = weapon.system.attackType || "melee";
|
||||||
const attackConfig = CONFIG.VAGABOND?.attackTypes?.[attackType];
|
const attackConfig = CONFIG.VAGABOND?.attackTypes?.[attackType];
|
||||||
|
|
||||||
if (!attackConfig) {
|
if (!attackConfig) {
|
||||||
@ -162,14 +170,18 @@ export async function attackCheck(actor, weapon, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const system = actor.system;
|
const system = actor.system;
|
||||||
const statKey = attackConfig.stat;
|
|
||||||
|
// Use weapon's getAttackStat() if available, otherwise fall back to config
|
||||||
|
const statKey = weapon.system.getAttackStat?.() || attackConfig.stat;
|
||||||
const statValue = system.stats?.[statKey]?.value || 0;
|
const statValue = system.stats?.[statKey]?.value || 0;
|
||||||
|
|
||||||
// Attack difficulty = 20 - stat (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 from attack data
|
// Get crit threshold: weapon override > actor attack data > default
|
||||||
const critThreshold = system.attacks?.[attackType]?.critThreshold || 20;
|
const actorCritThreshold = system.attacks?.[attackType]?.critThreshold || 20;
|
||||||
|
const weaponCritThreshold = weapon.system.critThreshold;
|
||||||
|
const critThreshold = weaponCritThreshold ?? actorCritThreshold;
|
||||||
|
|
||||||
// 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 };
|
||||||
|
|||||||
@ -232,6 +232,28 @@ export function registerDiceTests(quenchRunner) {
|
|||||||
expect(result.difficulty).to.equal(16);
|
expect(result.difficulty).to.equal(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses provided difficulty when passed in options", async () => {
|
||||||
|
/**
|
||||||
|
* When difficulty is passed via options (e.g., from the dialog),
|
||||||
|
* it should be used instead of calculating from stats.
|
||||||
|
* This ensures the dialog-displayed difficulty matches the roll.
|
||||||
|
*/
|
||||||
|
const result = await skillCheck(testActor, "arcana", { difficulty: 12 });
|
||||||
|
|
||||||
|
// Should use the provided difficulty, not calculate it
|
||||||
|
expect(result.difficulty).to.equal(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses provided critThreshold when passed in options", async () => {
|
||||||
|
/**
|
||||||
|
* When critThreshold is passed via options (e.g., from the dialog),
|
||||||
|
* it should override the skill's default critThreshold.
|
||||||
|
*/
|
||||||
|
const result = await skillCheck(testActor, "arcana", { critThreshold: 18 });
|
||||||
|
|
||||||
|
expect(result.critThreshold).to.equal(18);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses skill-specific crit threshold", async () => {
|
it("uses skill-specific crit threshold", async () => {
|
||||||
/**
|
/**
|
||||||
* Skills can have modified crit thresholds from class features.
|
* Skills can have modified crit thresholds from class features.
|
||||||
@ -264,7 +286,6 @@ export function registerDiceTests(quenchRunner) {
|
|||||||
const { describe, it, expect, beforeEach, afterEach } = context;
|
const { describe, it, expect, beforeEach, afterEach } = context;
|
||||||
|
|
||||||
let testActor = null;
|
let testActor = null;
|
||||||
let testWeapon = null;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testActor = await Actor.create({
|
testActor = await Actor.create({
|
||||||
@ -287,25 +308,25 @@ export function registerDiceTests(quenchRunner) {
|
|||||||
},
|
},
|
||||||
level: 1,
|
level: 1,
|
||||||
},
|
},
|
||||||
});
|
items: [
|
||||||
|
{
|
||||||
testWeapon = await Item.create({
|
name: "Test Sword",
|
||||||
name: "Test Sword",
|
type: "weapon",
|
||||||
type: "weapon",
|
system: {
|
||||||
system: {
|
damage: "1d8",
|
||||||
damage: "1d8",
|
attackType: "melee",
|
||||||
attackSkill: "melee",
|
grip: "1h",
|
||||||
gripType: "1h",
|
damageType: "slashing",
|
||||||
properties: [],
|
equipped: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (testActor) await testActor.delete();
|
if (testActor) await testActor.delete();
|
||||||
if (testWeapon) await testWeapon.delete();
|
|
||||||
testActor = null;
|
testActor = null;
|
||||||
testWeapon = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Attack Check Rolls", () => {
|
describe("Attack Check Rolls", () => {
|
||||||
@ -314,6 +335,7 @@ export function registerDiceTests(quenchRunner) {
|
|||||||
* Attack difficulty = 20 - (stat × 2) (attacks are always trained)
|
* Attack difficulty = 20 - (stat × 2) (attacks are always trained)
|
||||||
* Melee uses Might (5), so difficulty = 20 - 10 = 10
|
* Melee uses Might (5), so difficulty = 20 - 10 = 10
|
||||||
*/
|
*/
|
||||||
|
const testWeapon = testActor.items.find((i) => i.type === "weapon");
|
||||||
const result = await attackCheck(testActor, testWeapon);
|
const result = await attackCheck(testActor, testWeapon);
|
||||||
|
|
||||||
expect(result.difficulty).to.equal(10);
|
expect(result.difficulty).to.equal(10);
|
||||||
@ -324,6 +346,7 @@ export function registerDiceTests(quenchRunner) {
|
|||||||
* Attack types can have modified crit thresholds.
|
* Attack types can have modified crit thresholds.
|
||||||
* Melee attacks have critThreshold: 19 in test data.
|
* Melee attacks have critThreshold: 19 in test data.
|
||||||
*/
|
*/
|
||||||
|
const testWeapon = testActor.items.find((i) => i.type === "weapon");
|
||||||
const result = await attackCheck(testActor, testWeapon);
|
const result = await attackCheck(testActor, testWeapon);
|
||||||
|
|
||||||
expect(result.critThreshold).to.equal(19);
|
expect(result.critThreshold).to.equal(19);
|
||||||
|
|||||||
@ -23,7 +23,13 @@ import {
|
|||||||
import { VagabondActor, VagabondItem } from "./documents/_module.mjs";
|
import { VagabondActor, VagabondItem } from "./documents/_module.mjs";
|
||||||
|
|
||||||
// Import application classes
|
// Import application classes
|
||||||
import { VagabondRollDialog, SkillCheckDialog, FavorHinderDebug } from "./applications/_module.mjs";
|
import {
|
||||||
|
VagabondRollDialog,
|
||||||
|
SkillCheckDialog,
|
||||||
|
AttackRollDialog,
|
||||||
|
SaveRollDialog,
|
||||||
|
FavorHinderDebug,
|
||||||
|
} from "./applications/_module.mjs";
|
||||||
|
|
||||||
// Import sheet classes
|
// Import sheet classes
|
||||||
// import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs";
|
// import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs";
|
||||||
@ -54,6 +60,8 @@ Hooks.once("init", () => {
|
|||||||
applications: {
|
applications: {
|
||||||
VagabondRollDialog,
|
VagabondRollDialog,
|
||||||
SkillCheckDialog,
|
SkillCheckDialog,
|
||||||
|
AttackRollDialog,
|
||||||
|
SaveRollDialog,
|
||||||
FavorHinderDebug,
|
FavorHinderDebug,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -163,6 +171,54 @@ if (!actor) {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("Vagabond RPG | Created Skill Check macro");
|
console.log("Vagabond RPG | Created Skill Check macro");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attack Roll macro
|
||||||
|
const attackMacroName = "Attack Roll";
|
||||||
|
const existingAttackMacro = game.macros.find((m) => m.name === attackMacroName);
|
||||||
|
|
||||||
|
if (!existingAttackMacro) {
|
||||||
|
await Macro.create({
|
||||||
|
name: attackMacroName,
|
||||||
|
type: "script",
|
||||||
|
img: "icons/svg/sword.svg",
|
||||||
|
command: `// Opens attack roll dialog for selected token
|
||||||
|
const actor = canvas.tokens.controlled[0]?.actor
|
||||||
|
|| game.actors.find(a => a.type === "character");
|
||||||
|
|
||||||
|
if (!actor) {
|
||||||
|
ui.notifications.warn("Select a token or create a character first");
|
||||||
|
} else {
|
||||||
|
game.vagabond.applications.AttackRollDialog.prompt(actor);
|
||||||
|
}`,
|
||||||
|
flags: { vagabond: { systemMacro: true } },
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Vagabond RPG | Created Attack Roll macro");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Roll macro
|
||||||
|
const saveMacroName = "Save Roll";
|
||||||
|
const existingSaveMacro = game.macros.find((m) => m.name === saveMacroName);
|
||||||
|
|
||||||
|
if (!existingSaveMacro) {
|
||||||
|
await Macro.create({
|
||||||
|
name: saveMacroName,
|
||||||
|
type: "script",
|
||||||
|
img: "icons/svg/shield.svg",
|
||||||
|
command: `// Opens save roll dialog for selected token
|
||||||
|
const actor = canvas.tokens.controlled[0]?.actor
|
||||||
|
|| game.actors.find(a => a.type === "character");
|
||||||
|
|
||||||
|
if (!actor) {
|
||||||
|
ui.notifications.warn("Select a token or create a character first");
|
||||||
|
} else {
|
||||||
|
game.vagabond.applications.SaveRollDialog.prompt(actor);
|
||||||
|
}`,
|
||||||
|
flags: { vagabond: { systemMacro: true } },
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Vagabond RPG | Created Save Roll macro");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|||||||
@ -284,6 +284,122 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attack roll card specific
|
||||||
|
.vagabond.chat-card.attack-roll {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-3;
|
||||||
|
|
||||||
|
.weapon-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.weapon-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attack-type-badge {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
padding: $spacing-1 $spacing-2;
|
||||||
|
background-color: rgba($color-accent-primary, 0.2);
|
||||||
|
color: $color-accent-primary;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-section {
|
||||||
|
margin-top: $spacing-3;
|
||||||
|
padding: $spacing-3;
|
||||||
|
background-color: rgba($color-danger, 0.1);
|
||||||
|
border: 1px solid rgba($color-danger, 0.3);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
background-color: rgba($color-warning, 0.15);
|
||||||
|
border-color: $color-warning;
|
||||||
|
|
||||||
|
.damage-total {
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-header {
|
||||||
|
@include flex-center;
|
||||||
|
gap: $spacing-2;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
margin-bottom: $spacing-2;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crit-label {
|
||||||
|
color: $color-warning;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-result {
|
||||||
|
@include flex-center;
|
||||||
|
gap: $spacing-2;
|
||||||
|
|
||||||
|
.damage-total {
|
||||||
|
font-family: $font-family-header;
|
||||||
|
font-size: $font-size-3xl;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $color-danger;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-type {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-formula {
|
||||||
|
@include flex-center;
|
||||||
|
gap: $spacing-2;
|
||||||
|
margin-top: $spacing-2;
|
||||||
|
font-family: $font-family-mono;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
.grip-indicator {
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-properties {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-1;
|
||||||
|
margin-top: $spacing-2;
|
||||||
|
padding: $spacing-2;
|
||||||
|
|
||||||
|
.property-tag {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
padding: $spacing-1 $spacing-2;
|
||||||
|
background-color: $color-parchment-dark;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spell card specific
|
// Spell card specific
|
||||||
.vagabond.chat-card.spell-card {
|
.vagabond.chat-card.spell-card {
|
||||||
.spell-effect {
|
.spell-effect {
|
||||||
|
|||||||
@ -205,6 +205,116 @@
|
|||||||
// Additional skill-specific styles if needed
|
// Additional skill-specific styles if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attack roll dialog specific
|
||||||
|
.vagabond.attack-roll-dialog {
|
||||||
|
.weapon-selection {
|
||||||
|
@include flex-column;
|
||||||
|
gap: $spacing-2;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-weapons-message {
|
||||||
|
padding: $spacing-3;
|
||||||
|
text-align: center;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attack-info {
|
||||||
|
@include panel;
|
||||||
|
@include grid(2, $spacing-2);
|
||||||
|
padding: $spacing-3;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
@include flex-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
|
||||||
|
&.difficulty {
|
||||||
|
font-family: $font-family-header;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.crit {
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-preview {
|
||||||
|
@include panel;
|
||||||
|
padding: $spacing-3;
|
||||||
|
background-color: rgba($color-danger, 0.1);
|
||||||
|
border-color: rgba($color-danger, 0.3);
|
||||||
|
|
||||||
|
.damage-formula {
|
||||||
|
@include flex-center;
|
||||||
|
gap: $spacing-2;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: $font-family-mono;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.damage-type {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-properties {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-1;
|
||||||
|
margin-top: $spacing-2;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.property-tag {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
padding: $spacing-1 $spacing-2;
|
||||||
|
background-color: $color-parchment-dark;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.versatile-toggle {
|
||||||
|
@include flex-center;
|
||||||
|
padding: $spacing-2;
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
@include flex-center;
|
||||||
|
gap: $spacing-2;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
accent-color: $color-accent-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy dialog styles (for backward compatibility)
|
// Legacy dialog styles (for backward compatibility)
|
||||||
.vagabond.dialog.roll-dialog {
|
.vagabond.dialog.roll-dialog {
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
|
|||||||
112
templates/chat/attack-roll.hbs
Normal file
112
templates/chat/attack-roll.hbs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{{!-- Attack Roll Chat Card Template --}}
|
||||||
|
{{!-- Displays attack results with weapon info, hit/miss, and damage --}}
|
||||||
|
|
||||||
|
<div class="vagabond chat-card attack-roll">
|
||||||
|
{{!-- Header with Weapon Info --}}
|
||||||
|
<header class="card-header">
|
||||||
|
<img src="{{weapon.img}}" alt="{{weapon.name}}" class="weapon-icon">
|
||||||
|
<div class="header-text">
|
||||||
|
<h3 class="weapon-name">{{weapon.name}}</h3>
|
||||||
|
<span class="attack-type-badge">{{attackLabel}}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{!-- Roll Result --}}
|
||||||
|
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
|
||||||
|
<div class="roll-total">{{total}}</div>
|
||||||
|
<div class="roll-status">
|
||||||
|
{{#if isCrit}}
|
||||||
|
<span class="status critical">{{localize "VAGABOND.CriticalHit"}}</span>
|
||||||
|
{{else if isFumble}}
|
||||||
|
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
|
||||||
|
{{else if success}}
|
||||||
|
<span class="status success">{{localize "VAGABOND.Hit"}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="status failure">{{localize "VAGABOND.Miss"}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Roll Details --}}
|
||||||
|
<div class="roll-details">
|
||||||
|
<div class="roll-formula">
|
||||||
|
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
|
||||||
|
<span class="value">{{formula}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="roll-breakdown">
|
||||||
|
<span class="d20-result">
|
||||||
|
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
|
||||||
|
</span>
|
||||||
|
{{#if favorDie}}
|
||||||
|
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
|
||||||
|
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
{{#if modifier}}
|
||||||
|
<span class="modifier">
|
||||||
|
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Target Info --}}
|
||||||
|
<div class="target-info">
|
||||||
|
<div class="difficulty">
|
||||||
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
|
<span class="value">{{this.difficulty}}</span>
|
||||||
|
</div>
|
||||||
|
{{#if (lt this.critThreshold 20)}}
|
||||||
|
<div class="crit-threshold">
|
||||||
|
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||||
|
<span class="value">{{this.critThreshold}}+</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Damage Section (if hit) --}}
|
||||||
|
{{#if hasDamage}}
|
||||||
|
<div class="damage-section {{#if isCrit}}critical{{/if}}">
|
||||||
|
<div class="damage-header">
|
||||||
|
<i class="fa-solid fa-burst"></i>
|
||||||
|
<span>{{localize "VAGABOND.Damage"}}</span>
|
||||||
|
{{#if isCrit}}
|
||||||
|
<span class="crit-label">({{localize "VAGABOND.Critical"}}!)</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="damage-result">
|
||||||
|
<span class="damage-total">{{damageTotal}}</span>
|
||||||
|
<span class="damage-type">{{weapon.damageTypeLabel}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="damage-formula">
|
||||||
|
{{damageFormula}}
|
||||||
|
{{#if twoHanded}}
|
||||||
|
<span class="grip-indicator">({{localize "VAGABOND.TwoHanded"}})</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Weapon Properties --}}
|
||||||
|
{{#if weapon.properties.length}}
|
||||||
|
<div class="weapon-properties">
|
||||||
|
{{#each weapon.properties}}
|
||||||
|
<span class="property-tag">{{this}}</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Favor/Hinder Sources --}}
|
||||||
|
{{#if favorSources.length}}
|
||||||
|
<div class="favor-sources">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if hinderSources.length}}
|
||||||
|
<div class="hinder-sources">
|
||||||
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
|
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
97
templates/chat/save-roll.hbs
Normal file
97
templates/chat/save-roll.hbs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
{{!-- Save Roll Chat Card Template --}}
|
||||||
|
{{!-- Displays save results with stats used and success/fail --}}
|
||||||
|
|
||||||
|
<div class="vagabond chat-card save-roll">
|
||||||
|
{{!-- Header --}}
|
||||||
|
<header class="card-header">
|
||||||
|
<h3 class="save-name">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
{{saveLabel}} {{localize "VAGABOND.Save"}}
|
||||||
|
</h3>
|
||||||
|
{{#if isDefense}}
|
||||||
|
<span class="defense-badge {{defenseType}}">{{defenseLabel}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{!-- Roll Result --}}
|
||||||
|
<div class="roll-result {{#if isCrit}}critical{{else if isFumble}}fumble{{else if success}}success{{else}}failure{{/if}}">
|
||||||
|
<div class="roll-total">{{total}}</div>
|
||||||
|
<div class="roll-status">
|
||||||
|
{{#if isCrit}}
|
||||||
|
<span class="status critical">{{localize "VAGABOND.CriticalSuccess"}}</span>
|
||||||
|
{{else if isFumble}}
|
||||||
|
<span class="status fumble">{{localize "VAGABOND.Fumble"}}</span>
|
||||||
|
{{else if success}}
|
||||||
|
<span class="status success">{{localize "VAGABOND.Success"}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="status failure">{{localize "VAGABOND.Failure"}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Roll Details --}}
|
||||||
|
<div class="roll-details">
|
||||||
|
<div class="roll-formula">
|
||||||
|
<span class="label">{{localize "VAGABOND.Formula"}}:</span>
|
||||||
|
<span class="value">{{formula}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="roll-breakdown">
|
||||||
|
<span class="d20-result">
|
||||||
|
<i class="fa-solid fa-dice-d20"></i> {{d20Result}}
|
||||||
|
</span>
|
||||||
|
{{#if favorDie}}
|
||||||
|
<span class="favor-die {{#if (gt netFavorHinder 0)}}favor{{else}}hinder{{/if}}">
|
||||||
|
<i class="fa-solid fa-dice-d6"></i> {{favorDie}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
{{#if modifier}}
|
||||||
|
<span class="modifier">
|
||||||
|
{{#if (gt modifier 0)}}+{{/if}}{{modifier}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Save Info --}}
|
||||||
|
<div class="save-info">
|
||||||
|
<div class="stats-used">
|
||||||
|
<span class="label">{{localize "VAGABOND.Stats"}}:</span>
|
||||||
|
<span class="value">
|
||||||
|
{{#each stats}}
|
||||||
|
<span class="stat-abbr">{{this}}</span>{{#unless @last}} + {{/unless}}
|
||||||
|
{{/each}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="difficulty">
|
||||||
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
|
<span class="value">{{this.difficulty}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Defense Info --}}
|
||||||
|
{{#if isDefense}}
|
||||||
|
<div class="defense-info-display">
|
||||||
|
{{#if (eq defenseType 'block')}}
|
||||||
|
<i class="fa-solid fa-shield"></i>
|
||||||
|
<span>{{localize "VAGABOND.BlockedWith"}}</span>
|
||||||
|
{{else if (eq defenseType 'dodge')}}
|
||||||
|
<i class="fa-solid fa-person-running"></i>
|
||||||
|
<span>{{localize "VAGABOND.DodgedAttack"}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Favor/Hinder Sources --}}
|
||||||
|
{{#if favorSources.length}}
|
||||||
|
<div class="favor-sources">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
<span>{{localize "VAGABOND.Favor"}}: {{#each favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if hinderSources.length}}
|
||||||
|
<div class="hinder-sources">
|
||||||
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
|
<span>{{localize "VAGABOND.Hinder"}}: {{#each hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
@ -53,12 +53,12 @@
|
|||||||
<div class="target-info">
|
<div class="target-info">
|
||||||
<div class="difficulty">
|
<div class="difficulty">
|
||||||
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
<span class="value">{{difficulty}}</span>
|
<span class="value">{{this.difficulty}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{#if (lt critThreshold 20)}}
|
{{#if (lt this.critThreshold 20)}}
|
||||||
<div class="crit-threshold">
|
<div class="crit-threshold">
|
||||||
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||||
<span class="value">{{critThreshold}}+</span>
|
<span class="value">{{this.critThreshold}}+</span>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
126
templates/dialog/attack-roll.hbs
Normal file
126
templates/dialog/attack-roll.hbs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
{{!-- Attack Roll Dialog Template --}}
|
||||||
|
{{!-- Extends roll-dialog-base with attack-specific content --}}
|
||||||
|
|
||||||
|
<div class="roll-dialog-content">
|
||||||
|
{{!-- Automatic Favor/Hinder from Active Effects --}}
|
||||||
|
{{#if hasAutoFavor}}
|
||||||
|
<div class="auto-favor-hinder favor">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if hasAutoHinder}}
|
||||||
|
<div class="auto-favor-hinder hinder">
|
||||||
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
|
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Weapon Selection --}}
|
||||||
|
<div class="weapon-selection">
|
||||||
|
<label for="weaponId">{{localize "VAGABOND.Weapon"}}</label>
|
||||||
|
<select name="weaponId">
|
||||||
|
{{#each rollSpecific.weapons}}
|
||||||
|
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
|
||||||
|
{{this.name}}{{#if this.isUnarmed}} ({{localize "VAGABOND.Fist"}}){{else unless this.equipped}} ({{localize "VAGABOND.Unequipped"}}){{/if}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Attack Info (shown when weapon selected) --}}
|
||||||
|
{{#if rollSpecific.weapon}}
|
||||||
|
<div class="attack-info">
|
||||||
|
<div class="attack-type">
|
||||||
|
<span class="label">{{localize "VAGABOND.AttackType"}}:</span>
|
||||||
|
<span class="value">{{rollSpecific.attackLabel}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="attack-stat">
|
||||||
|
<span class="label">{{localize "VAGABOND.Stat"}}:</span>
|
||||||
|
<span class="value">{{rollSpecific.statLabel}} ({{rollSpecific.statValue}})</span>
|
||||||
|
</div>
|
||||||
|
<div class="attack-difficulty">
|
||||||
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
|
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||||
|
</div>
|
||||||
|
{{#if (lt rollSpecific.critThreshold 20)}}
|
||||||
|
<div class="attack-crit">
|
||||||
|
<span class="label">{{localize "VAGABOND.CritThreshold"}}:</span>
|
||||||
|
<span class="value crit">{{rollSpecific.critThreshold}}+</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Damage Preview --}}
|
||||||
|
<div class="damage-preview">
|
||||||
|
<div class="damage-formula">
|
||||||
|
<span class="label">{{localize "VAGABOND.Damage"}}:</span>
|
||||||
|
<span class="value">{{rollSpecific.damageFormula}}</span>
|
||||||
|
<span class="damage-type">({{rollSpecific.damageTypeLabel}})</span>
|
||||||
|
</div>
|
||||||
|
{{#if rollSpecific.properties.length}}
|
||||||
|
<div class="weapon-properties">
|
||||||
|
{{#each rollSpecific.propertyLabels}}
|
||||||
|
<span class="property-tag">{{this}}</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Versatile Weapon Toggle --}}
|
||||||
|
{{#if rollSpecific.isVersatile}}
|
||||||
|
<div class="versatile-toggle">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="twoHanded" {{#if rollSpecific.twoHanded}}checked{{/if}}>
|
||||||
|
<span>{{localize "VAGABOND.TwoHandedGrip"}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Favor/Hinder Toggles --}}
|
||||||
|
<div class="favor-hinder-section">
|
||||||
|
<label>{{localize "VAGABOND.FavorHinder"}}</label>
|
||||||
|
<div class="favor-hinder-toggles">
|
||||||
|
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-favor="1">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
{{localize "VAGABOND.Favor"}}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-favor="-1">
|
||||||
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
|
{{localize "VAGABOND.Hinder"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{#if (gt netFavorHinder 0)}}
|
||||||
|
<div class="net-favor-hinder favor">
|
||||||
|
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
|
||||||
|
</div>
|
||||||
|
{{else if (lt netFavorHinder 0)}}
|
||||||
|
<div class="net-favor-hinder hinder">
|
||||||
|
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Situational Modifier --}}
|
||||||
|
<div class="modifier-section">
|
||||||
|
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
|
||||||
|
<div class="modifier-presets">
|
||||||
|
<button type="button" class="modifier-preset" data-modifier="-5">-5</button>
|
||||||
|
<button type="button" class="modifier-preset" data-modifier="-1">-1</button>
|
||||||
|
<button type="button" class="modifier-preset" data-modifier="1">+1</button>
|
||||||
|
<button type="button" class="modifier-preset" data-modifier="5">+5</button>
|
||||||
|
</div>
|
||||||
|
<div class="modifier-input">
|
||||||
|
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Roll Button --}}
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button type="submit" class="roll-btn" {{#unless rollSpecific.weapon}}disabled{{/unless}}>
|
||||||
|
<i class="fa-solid fa-dice-d20"></i>
|
||||||
|
{{localize "VAGABOND.RollAttack"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
130
templates/dialog/save-roll.hbs
Normal file
130
templates/dialog/save-roll.hbs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
{{!-- Save Roll Dialog Template --}}
|
||||||
|
{{!-- Extends roll-dialog-base with save-specific content --}}
|
||||||
|
|
||||||
|
<div class="roll-dialog-content">
|
||||||
|
{{!-- Automatic Favor/Hinder from Active Effects --}}
|
||||||
|
{{#if autoFavorHinder.favorSources.length}}
|
||||||
|
<div class="auto-favor-hinder favor">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
<span>{{localize "VAGABOND.AutoFavor"}}: {{#each autoFavorHinder.favorSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if autoFavorHinder.hinderSources.length}}
|
||||||
|
<div class="auto-favor-hinder hinder">
|
||||||
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
|
<span>{{localize "VAGABOND.AutoHinder"}}: {{#each autoFavorHinder.hinderSources}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Save Type Selection --}}
|
||||||
|
<div class="save-selection">
|
||||||
|
<label for="saveType">{{localize "VAGABOND.SaveType"}}</label>
|
||||||
|
<select name="saveType">
|
||||||
|
<option value="">-- {{localize "VAGABOND.SelectSave"}} --</option>
|
||||||
|
{{#each rollSpecific.saves}}
|
||||||
|
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>
|
||||||
|
{{this.label}} ({{this.stats}})
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Save Info (shown when save type selected) --}}
|
||||||
|
{{#if rollSpecific.saveData}}
|
||||||
|
<div class="save-info">
|
||||||
|
<div class="save-stats">
|
||||||
|
<span class="label">{{localize "VAGABOND.Stats"}}:</span>
|
||||||
|
<span class="value">
|
||||||
|
{{#each rollSpecific.statLabels}}
|
||||||
|
<span class="stat-name">{{this}}</span>{{#unless @last}} + {{/unless}}
|
||||||
|
{{/each}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="save-difficulty">
|
||||||
|
<span class="label">{{localize "VAGABOND.Difficulty"}}:</span>
|
||||||
|
<span class="value difficulty">{{rollSpecific.difficulty}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Defense Options (Block/Dodge for Reflex) --}}
|
||||||
|
{{#if rollSpecific.showDefenseOptions}}
|
||||||
|
<div class="defense-options">
|
||||||
|
<label>{{localize "VAGABOND.DefenseType"}}</label>
|
||||||
|
<div class="defense-buttons">
|
||||||
|
<button type="button" class="defense-btn dodge-btn {{#if (eq rollSpecific.defenseType 'dodge')}}active{{/if}}" data-defense="dodge">
|
||||||
|
<i class="fa-solid fa-person-running"></i>
|
||||||
|
{{localize "VAGABOND.Dodge"}}
|
||||||
|
</button>
|
||||||
|
{{#if rollSpecific.hasShield}}
|
||||||
|
<button type="button" class="defense-btn block-btn {{#if (eq rollSpecific.defenseType 'block')}}active{{/if}}" data-defense="block">
|
||||||
|
<i class="fa-solid fa-shield"></i>
|
||||||
|
{{localize "VAGABOND.Block"}}
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="button" class="defense-btn block-btn" disabled title="{{localize 'VAGABOND.RequiresShield'}}">
|
||||||
|
<i class="fa-solid fa-shield"></i>
|
||||||
|
{{localize "VAGABOND.Block"}}
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{#if rollSpecific.defenseType}}
|
||||||
|
<div class="defense-info">
|
||||||
|
{{#if (eq rollSpecific.defenseType 'block')}}
|
||||||
|
<i class="fa-solid fa-info-circle"></i>
|
||||||
|
<span>{{localize "VAGABOND.BlockInfo"}}</span>
|
||||||
|
{{else}}
|
||||||
|
<i class="fa-solid fa-info-circle"></i>
|
||||||
|
<span>{{localize "VAGABOND.DodgeInfo"}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- Favor/Hinder Toggles --}}
|
||||||
|
<div class="favor-hinder-section">
|
||||||
|
<label>{{localize "VAGABOND.FavorHinder"}}</label>
|
||||||
|
<div class="favor-hinder-toggles">
|
||||||
|
<button type="button" class="favor-btn {{#if (eq config.favorHinder 1)}}active{{/if}}" data-action="toggle-favor">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
{{localize "VAGABOND.Favor"}}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hinder-btn {{#if (eq config.favorHinder -1)}}active{{/if}}" data-action="toggle-hinder">
|
||||||
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
|
{{localize "VAGABOND.Hinder"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{#if (gt netFavorHinder 0)}}
|
||||||
|
<div class="net-favor-hinder favor">
|
||||||
|
<i class="fa-solid fa-dice-d6"></i> +d6 {{localize "VAGABOND.Favor"}}
|
||||||
|
</div>
|
||||||
|
{{else if (lt netFavorHinder 0)}}
|
||||||
|
<div class="net-favor-hinder hinder">
|
||||||
|
<i class="fa-solid fa-dice-d6"></i> -d6 {{localize "VAGABOND.Hinder"}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Situational Modifier --}}
|
||||||
|
<div class="modifier-section">
|
||||||
|
<label>{{localize "VAGABOND.SituationalModifier"}}</label>
|
||||||
|
<div class="modifier-presets">
|
||||||
|
<button type="button" class="modifier-preset" data-modifier-preset="-5">-5</button>
|
||||||
|
<button type="button" class="modifier-preset" data-modifier-preset="-1">-1</button>
|
||||||
|
<button type="button" class="modifier-preset" data-modifier-preset="1">+1</button>
|
||||||
|
<button type="button" class="modifier-preset" data-modifier-preset="5">+5</button>
|
||||||
|
</div>
|
||||||
|
<div class="modifier-input">
|
||||||
|
<input type="number" name="modifier" value="{{config.modifier}}" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Roll Button --}}
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button type="submit" class="roll-btn" {{#unless rollSpecific.saveData}}disabled{{/unless}}>
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
{{localize "VAGABOND.RollSave"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Reference in New Issue
Block a user