This commit is contained in:
@@ -1133,6 +1133,8 @@ export default class LethalFantasyRoll extends Roll {
|
||||
options = { ...options, ...rollContext }
|
||||
options.rollName = "Ranged Defense"
|
||||
options.rollType = "weapon-defense"
|
||||
options.type = options.rollType // Required: this.type reads options.type
|
||||
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
|
||||
|
||||
const rollBase = new this(rollContext.movement, options.data, rollData)
|
||||
const rollModifier = new Roll(modifierFormula, options.data, rollData)
|
||||
@@ -1200,6 +1202,161 @@ export default class LethalFantasyRoll extends Roll {
|
||||
return rollBase
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the GM for ranged attack context (movement, range, target size, aim) when a monster
|
||||
* attacks with a ranged weapon, then evaluates an exploding D20 attack roll with the resulting modifiers.
|
||||
*
|
||||
* @param {Object} options Options for the roll.
|
||||
* @param {string} options.actorId The attacker actor ID.
|
||||
* @param {string} options.actorName The attacker actor name.
|
||||
* @param {Object} options.rollTarget The rollTarget containing attackModifier and related data.
|
||||
* @returns {Promise<LethalFantasyRoll|null>} The resulting roll, or null if cancelled.
|
||||
*/
|
||||
static async promptRangedAttack(options = {}) {
|
||||
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes)
|
||||
const fieldRollMode = new foundry.data.fields.StringField({
|
||||
choices: rollModes,
|
||||
blank: false,
|
||||
default: "public",
|
||||
})
|
||||
|
||||
let dialogContext = {
|
||||
attackerMovementChoices: SYSTEM.ATTACKER_MOVEMENT_CHOICES,
|
||||
rangeChoices: SYSTEM.RANGE_CHOICES,
|
||||
sizeChoices: SYSTEM.SIZE_CHOICES,
|
||||
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
|
||||
movement: "none",
|
||||
range: "short",
|
||||
size: "+5",
|
||||
attackerAim: "simple",
|
||||
fieldRollMode,
|
||||
rollModes
|
||||
}
|
||||
|
||||
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-attack-dialog.hbs", dialogContext)
|
||||
|
||||
const label = game.i18n.localize("LETHALFANTASY.Label.rangeAttackRoll")
|
||||
const rollContext = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: "Ranged Attack" },
|
||||
classes: ["lethalfantasy"],
|
||||
content,
|
||||
buttons: [
|
||||
{
|
||||
label,
|
||||
callback: (event, button) => {
|
||||
const output = Array.from(button.form.elements).reduce((obj, input) => {
|
||||
if (input.name) obj[input.name] = input.value
|
||||
return obj
|
||||
}, {})
|
||||
return output
|
||||
},
|
||||
},
|
||||
],
|
||||
rejectClose: false
|
||||
})
|
||||
|
||||
if (rollContext === null) return null
|
||||
|
||||
// Handle pointblank: attacker at point blank gets favor (standing still easier to aim)
|
||||
if (rollContext.range === "pointblank") {
|
||||
rollContext.movement = rollContext.movement.replace("kh", "")
|
||||
rollContext.movement = rollContext.movement.replace("kl", "")
|
||||
rollContext.movement += "kh" // Favor for attacker at point blank
|
||||
rollContext.range = "0"
|
||||
}
|
||||
// Handle beyondskill: extreme range gives disfavor to attacker
|
||||
if (rollContext.range === "beyondskill") {
|
||||
rollContext.movement = rollContext.movement.replace("kh", "")
|
||||
rollContext.movement = rollContext.movement.replace("kl", "")
|
||||
rollContext.movement += "kl" // Disfavor for attacker beyond skill range
|
||||
rollContext.range = "+11"
|
||||
}
|
||||
|
||||
// Compute contextual penalty: range + target_size, reduced by aim bonus and attack modifier
|
||||
const attackModifier = options.rollTarget?.attackModifier ?? 0
|
||||
const contextualPenalty = Number(rollContext.range) + Number(rollContext.size)
|
||||
const aimBonus = Number(rollContext.attackerAim || 0)
|
||||
const fullModifier = contextualPenalty - aimBonus - attackModifier
|
||||
|
||||
let modifierFormula
|
||||
if (fullModifier === 0) {
|
||||
modifierFormula = "0"
|
||||
} else {
|
||||
const modAbs = Math.abs(fullModifier)
|
||||
modifierFormula = `D${modAbs + 1} -1`
|
||||
}
|
||||
|
||||
const rollData = { ...rollContext }
|
||||
options = { ...options, ...rollContext }
|
||||
options.rollName = "Ranged Attack"
|
||||
options.rollType = options.rollType || "monster-attack"
|
||||
options.type = options.rollType // Required: this.type reads options.type, used to build weaponDamageOptions in toHTML
|
||||
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
|
||||
options.isRangedAttack = true
|
||||
|
||||
const rollBase = new this(rollContext.movement, options.data, rollData)
|
||||
const rollModifier = new Roll(modifierFormula, options.data, rollData)
|
||||
rollModifier.evaluate()
|
||||
await rollBase.evaluate()
|
||||
const rollD30 = await new Roll("1D30").evaluate()
|
||||
options.D30result = rollD30.total
|
||||
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, undefined, { isRanged: true })
|
||||
|
||||
// Determine favor from dice formula
|
||||
let badResult = 0
|
||||
if (rollContext.movement.includes("kh")) {
|
||||
rollData.favor = "favor"
|
||||
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
|
||||
}
|
||||
if (rollContext.movement.includes("kl")) {
|
||||
rollData.favor = "disfavor"
|
||||
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
|
||||
}
|
||||
|
||||
const dice = rollContext.movement
|
||||
const maxValue = 20
|
||||
let rollTotal = -1
|
||||
let diceResults = []
|
||||
|
||||
let diceResult = rollBase.dice[0].results[0].result
|
||||
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
|
||||
let diceSum = diceResult
|
||||
// Exploding dice
|
||||
while (diceResult === maxValue) {
|
||||
const r = await new Roll(dice).evaluate()
|
||||
diceResult = r.dice[0].results[0].result
|
||||
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
|
||||
diceSum += (diceResult - 1)
|
||||
}
|
||||
|
||||
if (fullModifier !== 0) {
|
||||
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
|
||||
if (fullModifier > 0) {
|
||||
// Net penalty: subtract from roll
|
||||
rollTotal = Math.max(diceSum - rollModifier.total, 0)
|
||||
} else {
|
||||
// Net bonus: add to roll
|
||||
rollTotal = diceSum + rollModifier.total
|
||||
}
|
||||
} else {
|
||||
rollTotal = diceSum
|
||||
}
|
||||
|
||||
rollBase.options = { ...rollBase.options, ...options }
|
||||
rollBase.options.resultType = undefined
|
||||
rollBase.options.rollTotal = rollTotal
|
||||
rollBase.options.diceResults = diceResults
|
||||
rollBase.options.rollTarget = options.rollTarget
|
||||
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
|
||||
rollBase.options.D30result = options.D30result
|
||||
rollBase.options.D30message = options.D30message
|
||||
rollBase.options.rollName = "Ranged Attack"
|
||||
rollBase.options.badResult = badResult
|
||||
rollBase.options.rollData = foundry.utils.duplicate(rollData)
|
||||
|
||||
return rollBase
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a title based on the given type.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user