Files
fvtt-oath-hammer/module/applications/weapon-dialog.mjs

377 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SYSTEM } from "../config/system.mjs"
/**
* Roll dialogs for weapon attacks and damage.
*
* Attack flow:
* 1. promptAttack(actor, weapon) → options
* 2. rollWeaponAttack posts a chat card with a "Roll Damage" button
* 3. Clicking the button calls promptDamage with attackSuccesses pre-filled
* 4. rollWeaponDamage posts the damage chat card
*/
export default class OathHammerWeaponDialog {
// ------------------------------------------------------------------ //
// ATTACK DIALOG
// ------------------------------------------------------------------ //
static async promptAttack(actor, weapon) {
const sys = weapon.system
const actorSys = actor.system
const isRanged = !sys.usesMight && (sys.shortRange > 0 || sys.longRange > 0)
const skillKey = isRanged ? "shooting" : "fighting"
const skillDef = SYSTEM.SKILLS[skillKey]
const defaultAttr = skillDef.attribute
const attrRank = actorSys.attributes[defaultAttr].rank
const skillRank = actorSys.skills[skillKey].rank
const skillColor = actorSys.skills[skillKey].colorDiceType ?? "white"
const threshold = skillColor === "black" ? 2 : skillColor === "red" ? 3 : 4
const hasNimble = sys.traits.has("nimble")
// Auto-bonuses from special properties
let autoAttackBonus = 0
if (sys.specialProperties.has("master-crafted")) autoAttackBonus += 1
if (sys.specialProperties.has("accurate")) autoAttackBonus += 1 // bows
if (sys.specialProperties.has("balanced")) autoAttackBonus += 1 // grants Fast
// Damage info for reference
const hasBrutal = sys.traits.has("brutal")
const hasDeadly = sys.traits.has("deadly")
const damageColorType = hasDeadly ? "black" : hasBrutal ? "red" : "white"
const damageThreshold = hasDeadly ? 2 : hasBrutal ? 3 : 4
const damageColorLabel = hasDeadly ? "⬛" : hasBrutal ? "🔴" : "⬜"
const mightRank = actorSys.attributes.might.rank
const baseDamageDice = sys.usesMight ? Math.max(mightRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1)
const traitLabels = [...sys.traits].map(t => {
const key = SYSTEM.WEAPON_TRAITS[t]
return key ? game.i18n.localize(key) : t
})
// Option arrays
const attackBonusOptions = Array.from({ length: 13 }, (_, i) => {
const v = i - 6
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rangeOptions = [
{ value: 0, label: game.i18n.localize("OATHHAMMER.Dialog.RangeNormal") },
{ value: -1, label: game.i18n.localize("OATHHAMMER.Dialog.RangeLong") + " (1)" },
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.RangeMoving") + " (2)" },
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.RangeConcealment") + " (2)" },
{ value: -3, label: game.i18n.localize("OATHHAMMER.Dialog.RangeCover") + " (3)" },
]
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
weaponName: weapon.name,
weaponImg: weapon.img,
skillKey,
skillLabel: game.i18n.localize(skillDef.label),
attrKey: defaultAttr,
attrLabel: game.i18n.localize(`OATHHAMMER.Attribute.${_cap(defaultAttr)}`),
attrRank,
skillRank,
colorType: skillColor,
threshold,
baseAttackPool: attrRank + skillRank,
autoAttackBonus,
hasNimble,
mightLabel: game.i18n.localize("OATHHAMMER.Attribute.Might"),
mightRank,
agilityLabel: game.i18n.localize("OATHHAMMER.Attribute.Agility"),
agilityRank: actorSys.attributes.agility.rank,
isRanged,
shortRange: sys.shortRange,
longRange: sys.longRange,
damageLabel: sys.damageLabel,
damageColorType,
damageThreshold,
damageColorLabel,
baseDamageDice,
apValue: sys.ap,
traits: traitLabels,
attackBonusOptions,
rangeOptions,
colorOptions: _colorOptions(skillColor),
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/weapon-attack-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.AttackTitle", { weapon: weapon.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollAttack"),
callback: (_ev, btn) => {
const out = {}
for (const el of btn.form.elements) {
if (!el.name) continue
out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value
}
return out
},
}],
})
if (!result) return null
return {
attackBonus: parseInt(result.attackBonus) || 0,
rangeCondition: parseInt(result.rangeCondition) || 0,
attrOverride: result.attrOverride || defaultAttr,
colorOverride: result.colorOverride || skillColor,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
autoAttackBonus,
explodeOn5: result.explodeOn5 === "true",
}
}
// ------------------------------------------------------------------ //
// DEFENSE DIALOG
// ------------------------------------------------------------------ //
/**
* Show the weapon defense dialog and return resolved options.
*
* Defense pool = Agility (or Might) + Defense skill + trait bonuses + armor penalty + diminish penalty + bonus
*
* Parry trait → red dice vs melee; +1 if two Parry weapons equipped
* Block trait → red dice vs ranged; +1 bonus always
* Diminishing defense: -2 per additional defense after the first in a turn
*/
static async promptDefense(actor, weapon) {
const sys = weapon.system
const actorSys = actor.system
const agiRank = actorSys.attributes.agility.rank
const mightRank = actorSys.attributes.might.rank
const defRank = actorSys.skills.defense.rank
// Detect this weapon's defense-relevant traits
const hasParry = sys.traits.has("parry")
const hasBlock = sys.traits.has("block")
// Count all equipped parry/block weapons (for +1 with two Parry weapons)
const equipped = actor.items.filter(i => i.type === "weapon" && i.system.equipped)
const parryCount = equipped.filter(w => w.system.traits?.has?.("parry") || [...(w.system.traits ?? [])].includes("parry")).length
const blockCount = equipped.filter(w => w.system.traits?.has?.("block") || [...(w.system.traits ?? [])].includes("block")).length
// Armor penalty from all equipped armors
const armorPenalty = actor.items
.filter(i => i.type === "armor" && i.system.equipped)
.reduce((sum, a) => sum + (a.system.penalty ?? 0), 0)
// Pre-select attack type: block weapons default to ranged, parry to melee
const defaultAttackType = hasBlock && !hasParry ? "ranged" : "melee"
const traitLabels = [...sys.traits].map(t => {
const key = SYSTEM.WEAPON_TRAITS[t]
return key ? game.i18n.localize(key) : t
})
const attackTypeOptions = [
{ value: "melee", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseMelee"), selected: defaultAttackType === "melee" },
{ value: "ranged", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseRanged"), selected: defaultAttackType === "ranged" },
]
const attrOptions = [
{ value: "agility", label: `${game.i18n.localize("OATHHAMMER.Attribute.Agility")} (${agiRank})`, selected: true },
{ value: "might", label: `${game.i18n.localize("OATHHAMMER.Attribute.Might")} (${mightRank})`, selected: false },
]
const diminishOptions = [
{ value: 0, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishFirst"), selected: true },
{ value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishSecond"), selected: false },
{ value: -4, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishThird"), selected: false },
]
// Default dice color: red if parry/block trait matches the default attack type, else white
const defaultDefenseColor = (
(defaultAttackType === "melee" && hasParry) ||
(defaultAttackType === "ranged" && hasBlock)
) ? "red" : "white"
const bonusOptions = Array.from({ length: 13 }, (_, i) => {
const v = i - 6
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
weaponName: weapon.name,
weaponImg: weapon.img,
agiRank,
mightRank,
defRank,
hasParry,
hasBlock,
parryCount,
blockCount,
armorPenalty,
traits: traitLabels,
attackTypeOptions,
attrOptions,
diminishOptions,
bonusOptions,
colorOptions: _colorOptions(defaultDefenseColor),
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/weapon-defense-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.WeaponDefenseTitle", { weapon: weapon.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"),
callback: (_ev, btn) => {
const out = {}
for (const el of btn.form.elements) {
if (!el.name) continue
out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value
}
return out
},
}],
})
if (!result) return null
const attackType = result.attackType ?? defaultAttackType
const attrChoice = result.attribute ?? "agility"
const attrRank = attrChoice === "might" ? mightRank : agiRank
const diminishPenalty = parseInt(result.diminish) || 0
const bonus = parseInt(result.bonus) || 0
const colorOverride = result.colorOverride || defaultDefenseColor
// Trait bonus based on selected attack type (color is now user-controlled)
let traitBonus = 0
if (attackType === "melee" && hasParry) traitBonus = parryCount >= 2 ? 1 : 0
if (attackType === "ranged" && hasBlock) traitBonus = 1
return {
attackType,
attrRank,
attrChoice,
colorOverride,
traitBonus,
armorPenalty,
diminishPenalty,
bonus,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
explodeOn5: result.explodeOn5 === "true",
}
}
// ------------------------------------------------------------------ //
// DAMAGE DIALOG
// ------------------------------------------------------------------ //
static async promptDamage(actor, weapon, defaultSV = 0) {
const sys = weapon.system
const actorSys = actor.system
const hasBrutal = sys.traits.has("brutal")
const hasDeadly = sys.traits.has("deadly")
const damageColorType = hasDeadly ? "black" : hasBrutal ? "red" : "white"
const damageThreshold = hasDeadly ? 2 : hasBrutal ? 3 : 4
const damageColorLabel = hasDeadly ? "⬛" : hasBrutal ? "🔴" : "⬜"
const mightRank = actorSys.attributes.might.rank
const baseDamageDice = sys.usesMight ? Math.max(mightRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1)
// Auto-bonuses from special properties
let autoDamageBonus = 0
if (sys.specialProperties.has("master-crafted")) autoDamageBonus += 1
if (sys.specialProperties.has("tempered")) autoDamageBonus += 1
if (sys.specialProperties.has("heavy-draw")) autoDamageBonus += 1
const svOptions = Array.from({ length: 11 }, (_, i) => ({
value: i,
label: i === 0 ? "0" : `+${i}d`,
selected: i === defaultSV,
}))
const damageBonusOptions = Array.from({ length: 9 }, (_, i) => {
const v = i - 4
return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
})
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const context = {
actorName: actor.name,
weaponName: weapon.name,
weaponImg: weapon.img,
damageLabel: sys.damageLabel,
damageColorType,
damageThreshold,
damageColorLabel,
baseDamageDice,
autoDamageBonus,
apValue: sys.ap,
defaultSV,
svOptions,
damageBonusOptions,
rollModes,
visibility: game.settings.get("core", "rollMode"),
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-oath-hammer/templates/weapon-damage-dialog.hbs",
context
)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.format("OATHHAMMER.Dialog.DamageTitle", { weapon: weapon.name }) },
classes: ["fvtt-oath-hammer"],
content,
rejectClose: false,
buttons: [{
label: game.i18n.localize("OATHHAMMER.Dialog.RollDamage"),
callback: (_ev, btn) => Object.fromEntries(
[...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value])
),
}],
})
if (!result) return null
return {
sv: parseInt(result.sv) || 0,
damageBonus: parseInt(result.damageBonus) || 0,
visibility: result.visibility ?? game.settings.get("core", "rollMode"),
autoDamageBonus,
}
}
}
function _cap(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function _colorOptions(defaultColor = "white") {
return [
{ value: "white", label: "⬜ White (4+)", selected: defaultColor === "white" },
{ value: "red", label: "🔴 Red (3+)", selected: defaultColor === "red" },
{ value: "black", label: "⬛ Black (2+)", selected: defaultColor === "black" },
]
}