Files
fvtt-oath-hammer/module/applications/sheets/character-sheet.mjs

387 lines
17 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 OathHammerActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
import OathHammerRollDialog from "../roll-dialog.mjs"
import OathHammerWeaponDialog from "../weapon-dialog.mjs"
import OathHammerSpellDialog from "../spell-dialog.mjs"
import OathHammerMiracleDialog from "../miracle-dialog.mjs"
import OathHammerDefenseDialog from "../defense-dialog.mjs"
import OathHammerArmorDialog from "../armor-dialog.mjs"
import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense, rollInitiativeCheck } from "../../rolls.mjs"
export default class OathHammerCharacterSheet extends OathHammerActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["character"],
position: {
width: 972,
height: 780,
},
window: {
contentClasses: ["character-content"],
},
actions: {
createWeapon: OathHammerCharacterSheet.#onCreateWeapon,
createSpell: OathHammerCharacterSheet.#onCreateSpell,
createMiracle: OathHammerCharacterSheet.#onCreateMiracle,
createEquipment: OathHammerCharacterSheet.#onCreateEquipment,
createTrait: OathHammerCharacterSheet.#onCreateTrait,
createOath: OathHammerCharacterSheet.#onCreateOath,
rollSkill: OathHammerCharacterSheet.#onRollSkill,
attackWeapon: OathHammerCharacterSheet.#onAttackWeapon,
defendWeapon: OathHammerCharacterSheet.#onDefendWeapon,
damageWeapon: OathHammerCharacterSheet.#onDamageWeapon,
castSpell: OathHammerCharacterSheet.#onCastSpell,
castMiracle: OathHammerCharacterSheet.#onCastMiracle,
rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave,
resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked,
rollInitiative: OathHammerCharacterSheet.#onRollInitiative,
},
}
/** @override */
static PARTS = {
main: { template: "systems/fvtt-oath-hammer/templates/actor/character-sheet.hbs" },
tabs: { template: "templates/generic/tab-navigation.hbs" },
identity: { template: "systems/fvtt-oath-hammer/templates/actor/character-identity.hbs" },
skills: { template: "systems/fvtt-oath-hammer/templates/actor/character-skills.hbs" },
combat: { template: "systems/fvtt-oath-hammer/templates/actor/character-combat.hbs" },
magic: { template: "systems/fvtt-oath-hammer/templates/actor/character-magic.hbs" },
equipment: { template: "systems/fvtt-oath-hammer/templates/actor/character-equipment.hbs" },
notes: { template: "systems/fvtt-oath-hammer/templates/actor/character-notes.hbs" },
}
/** @override */
tabGroups = {
sheet: "identity",
}
#getTabs() {
const tabs = {
identity: { id: "identity", group: "sheet", icon: "fa-solid fa-person", label: "OATHHAMMER.Tab.Identity" },
skills: { id: "skills", group: "sheet", icon: "fa-solid fa-scroll", label: "OATHHAMMER.Tab.Skills" },
combat: { id: "combat", group: "sheet", icon: "fa-solid fa-swords", label: "OATHHAMMER.Tab.Combat" },
magic: { id: "magic", group: "sheet", icon: "fa-solid fa-wand-magic-sparkles", label: "OATHHAMMER.Tab.Magic" },
equipment: { id: "equipment", group: "sheet", icon: "fa-solid fa-backpack", label: "OATHHAMMER.Tab.Equipment" },
notes: { id: "notes", group: "sheet", icon: "fa-solid fa-book", label: "OATHHAMMER.Tab.Notes" },
}
for (const v of Object.values(tabs)) {
v.active = this.tabGroups[v.group] === v.id
v.cssClass = v.active ? "active" : ""
}
return tabs
}
/** @override */
async _prepareContext() {
const context = await super._prepareContext()
context.tabs = this.#getTabs()
// class/experience available to all parts (header + identity tab)
const doc = this.document
context.characterClass = doc.itemTypes["class"]?.[0] ?? null
return context
}
/** @override */
async _preparePartContext(partId, context) {
const doc = this.document
switch (partId) {
case "main":
break
case "identity":
context.tab = context.tabs.identity
context.traits = doc.itemTypes.trait.map(a => {
const typeKey = SYSTEM.TRAIT_TYPE_CHOICES[a.system.traitType]
const periodKey = SYSTEM.TRAIT_USAGE_PERIOD[a.system.usagePeriod]
const isPassive = a.system.usagePeriod === "none"
return {
id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system,
_typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.traitType,
_usageLabel: isPassive
? game.i18n.localize("OATHHAMMER.UsagePeriod.None")
: `${a.system.maxUses > 0 ? a.system.maxUses + " / " : ""}${game.i18n.localize(periodKey)}`,
_descTooltip: _stripHtml(a.system.description)
}
})
context.oaths = doc.itemTypes.oath.map(o => {
const typeEntry = SYSTEM.OATH_TYPES[o.system.oathType]
const parts = [o.system.tenet, o.system.boon, o.system.bane].filter(Boolean)
return {
id: o.id, uuid: o.uuid, img: o.img, name: o.name, system: o.system,
_typeLabel: typeEntry ? game.i18n.localize(typeEntry.label) : o.system.oathType,
_violated: o.system.violated,
_descTooltip: _stripHtml(parts.join(" "))
}
})
context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background ?? "", { async: true })
break
case "skills": {
context.tab = context.tabs.skills
const sys = doc.system
const skillSchemaFields = doc.system.schema.fields.skills.fields
const attrRanks = {
might: sys.attributes.might.rank,
toughness: sys.attributes.toughness.rank,
agility: sys.attributes.agility.rank,
willpower: sys.attributes.willpower.rank,
intelligence: sys.attributes.intelligence.rank,
fate: sys.attributes.fate.rank,
}
context.skillGroups = Object.entries(SYSTEM.SKILLS_BY_ATTRIBUTE).map(([attr, skillKeys]) => ({
attribute: attr,
label: `OATHHAMMER.Attribute.${attr.charAt(0).toUpperCase()}${attr.slice(1)}`,
attrRank: attrRanks[attr],
skillData: skillKeys.map(skillKey => {
const sk = sys.skills[skillKey]
return {
key: skillKey,
label: SYSTEM.SKILLS[skillKey].label,
rank: sk.rank,
modifier: sk.modifier,
colorDice: sk.colorDice,
colorDiceType: sk.colorDiceType,
rankName: `system.skills.${skillKey}.rank`,
modifierName: `system.skills.${skillKey}.modifier`,
colorDiceName: `system.skills.${skillKey}.colorDice`,
colorDiceTypeName: `system.skills.${skillKey}.colorDiceType`,
rankOptions: [0,1,2,3,4].map(v => ({ value: v, label: String(v), selected: v === sk.rank })),
total: attrRanks[attr] + sk.rank,
// legacy - kept for formInput compatibility
name: `system.skills.${skillKey}.rank`,
field: skillSchemaFields[skillKey].fields.rank,
}
})
}))
break
}
case "combat":
context.tab = context.tabs.combat
context.weapons = doc.itemTypes.weapon.map(w => {
const groupKey = SYSTEM.WEAPON_PROFICIENCY_GROUPS[w.system.proficiencyGroup]
const traitsLabel = [...w.system.traits].map(t => {
const tk = SYSTEM.WEAPON_TRAITS[t]
return tk ? game.i18n.localize(tk) : t
}).join(", ")
return {
id: w.id, uuid: w.uuid, img: w.img, name: w.name, system: w.system,
_groupLabel: groupKey ? game.i18n.localize(groupKey) : w.system.proficiencyGroup,
_traitsTooltip: traitsLabel || null,
_descTooltip: _stripHtml(w.system.description),
_isMagic: w.system.isMagic
}
})
context.armors = doc.itemTypes.armor.map(a => {
const typeKey = SYSTEM.ARMOR_TYPE_CHOICES[a.system.armorType]
const traitsLabel = [...a.system.traits].map(t => {
const tk = SYSTEM.ARMOR_TRAITS[t]
return tk ? game.i18n.localize(tk) : t
}).join(", ")
return {
id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system,
_typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.armorType,
_traitsTooltip: traitsLabel || null,
_descTooltip: _stripHtml(a.system.description),
_isMagic: a.system.isMagic
}
})
context.ammunition = doc.itemTypes.ammunition
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0)
context.slotsOver = context.slotsUsed > context.slotsMax
// Show current initiative score if actor is in an active combat
const combatant = game.combat?.combatants.find(c => c.actor?.id === doc.id)
context.combatantInitiative = combatant?.initiative ?? null
break
case "magic":
context.tab = context.tabs.magic
context.spells = doc.itemTypes.spell.map(s => ({
id: s.id, uuid: s.uuid, img: s.img, name: s.name, system: s.system,
_descTooltip: _stripHtml(s.system.effect)
}))
context.miracles = doc.itemTypes.miracle.map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.effect)
}))
break
case "equipment":
context.tab = context.tabs.equipment
context.equipment = doc.itemTypes.equipment.map(e => ({
id: e.id, uuid: e.uuid, img: e.img, name: e.name, system: e.system,
_descTooltip: _stripHtml(e.system.description)
}))
context.magicItems = doc.itemTypes["magic-item"].map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.description)
}))
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0)
context.slotsOver = context.slotsUsed > context.slotsMax
break
case "notes":
context.tab = context.tabs.notes
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.description ?? "", { async: true })
context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.notes ?? "", { async: true })
break
}
return context
}
/** Auto-fill colorDice count when color type changes */
static #COLOR_THRESHOLDS = { white: 4, red: 3, black: 2 }
_onRender(context, options) {
super._onRender?.(context, options)
// Color dice auto-fill
this.element.querySelectorAll('select.color-dice-select').forEach(select => {
select.addEventListener('change', event => {
const threshold = OathHammerCharacterSheet.#COLOR_THRESHOLDS[event.target.value] ?? 4
const countInput = event.target.closest('.skill-color-col')?.querySelector('input[type="number"]')
if (countInput) {
countInput.value = threshold
countInput.dispatchEvent(new Event('change', { bubbles: true }))
}
const dot = event.target.closest('.skill-color-col')?.querySelector('.color-dice-dot')
if (dot) dot.className = `color-dice-dot color-dice-${event.target.value}`
})
})
// Equipped checkbox — directly updates the item
this.element.querySelectorAll('input.item-equipped-cb').forEach(cb => {
cb.addEventListener('change', event => {
const itemId = event.target.dataset.itemId
const item = this.document.items.get(itemId)
if (item) item.update({ 'system.equipped': event.target.checked })
})
})
}
async _onDrop(event) {
if (!this.isEditable) return
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
if (data.type === "Item") {
const item = await fromUuid(data.uuid)
return this._onDropItem(item)
}
}
static #onCreateWeapon(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Weapon"), type: "weapon" }])
}
static #onCreateSpell(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Spell"), type: "spell" }])
}
static #onCreateMiracle(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Miracle"), type: "miracle" }])
}
static #onCreateEquipment(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Equipment"), type: "equipment" }])
}
static #onCreateTrait(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait" }])
}
static #onCreateOath(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Oath"), type: "oath" }])
}
static async #onRollSkill(event, target) {
const skillKey = target.dataset.skill
if (!skillKey) return
const result = await OathHammerRollDialog.prompt(this.document, skillKey)
if (!result) return
await rollSkillCheck(this.document, skillKey, result.dv, result)
}
static async #onAttackWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptAttack(this.document, weapon)
if (!opts) return
await rollWeaponAttack(this.document, weapon, opts)
}
static async #onDefendWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptDefense(this.document, weapon)
if (!opts) return
await rollWeaponDefense(this.document, weapon, opts)
}
static async #onDamageWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptDamage(this.document, weapon, 0)
if (!opts) return
await rollWeaponDamage(this.document, weapon, opts)
}
static async #onCastSpell(event, target) {
const spellId = target.dataset.itemId
if (!spellId) return
const spell = this.document.items.get(spellId)
if (!spell) return
const opts = await OathHammerSpellDialog.prompt(this.document, spell)
if (!opts) return
await rollSpellCast(this.document, spell, opts)
}
static async #onCastMiracle(event, target) {
const miracleId = target.dataset.itemId
if (!miracleId) return
const miracle = this.document.items.get(miracleId)
if (!miracle) return
if (this.document.system.miracleBlocked) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.MiracleBlocked"))
return
}
const opts = await OathHammerMiracleDialog.prompt(this.document, miracle)
if (!opts) return
await rollMiracleCast(this.document, miracle, opts)
}
static async #onResetMiracleBlocked() {
await this.document.update({ "system.miracleBlocked": false })
}
static async #onRollArmorSave(event, target) {
const armorId = target.dataset.itemId
if (!armorId) return
const armor = this.document.items.get(armorId)
if (!armor) return
const opts = await OathHammerArmorDialog.prompt(this.document, armor)
if (!opts) return
await rollArmorSave(this.document, armor, opts)
}
static async #onRollInitiative() {
const actor = this.document
const combatant = game.combat?.combatants.find(c => c.actor?.id === actor.id)
if (combatant) {
// Delegate to OathHammerCombat.rollInitiative — posts to chat and updates tracker
await game.combat.rollInitiative([combatant.id])
} else {
// Not in combat — roll for display only
await rollInitiativeCheck(actor)
}
}
}
/** Strip HTML tags and collapse whitespace for use in data-tooltip attributes. */
function _stripHtml(html, maxLen = 300) {
if (!html) return ""
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text
}