302 lines
10 KiB
JavaScript
302 lines
10 KiB
JavaScript
const { HandlebarsApplicationMixin } = foundry.applications.api
|
|
import { ARMOR_TYPE_CHOICES, CLASS_RESTRICTION_CHOICES, SYSTEM, WEAPON_PROFICIENCY_GROUPS } from "../../config/system.mjs"
|
|
import { rollRarityCheck } from "../../rolls.mjs"
|
|
|
|
export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
|
|
static SHEET_MODES = { EDIT: 0, PLAY: 1 }
|
|
|
|
constructor(options = {}) {
|
|
super(options)
|
|
this.#dragDrop = this.#createDragDropHandlers()
|
|
}
|
|
|
|
#dragDrop
|
|
|
|
/** @override */
|
|
static DEFAULT_OPTIONS = {
|
|
classes: ["oathhammer", "item"],
|
|
position: {
|
|
width: 600,
|
|
height: "auto",
|
|
},
|
|
form: {
|
|
submitOnChange: true,
|
|
},
|
|
window: {
|
|
resizable: true,
|
|
},
|
|
dragDrop: [{ dragSelector: "[data-drag]", dropSelector: null }],
|
|
actions: {
|
|
toggleSheet: OathHammerItemSheet.#onToggleSheet,
|
|
editImage: OathHammerItemSheet.#onEditImage,
|
|
rollRarity: OathHammerItemSheet.#onRollRarity,
|
|
removeRune: OathHammerItemSheet.#onRemoveRune,
|
|
openRune: OathHammerItemSheet.#onOpenRune,
|
|
},
|
|
}
|
|
|
|
_sheetMode = this.constructor.SHEET_MODES.PLAY
|
|
_lockedReadOnly = false
|
|
|
|
/** @override — prevent form submissions when this sheet is opened in read-only mode */
|
|
get isEditable() {
|
|
if (this._lockedReadOnly) return false
|
|
return super.isEditable
|
|
}
|
|
|
|
get isPlayMode() {
|
|
return this._sheetMode === this.constructor.SHEET_MODES.PLAY
|
|
}
|
|
|
|
get isEditMode() {
|
|
return this._sheetMode === this.constructor.SHEET_MODES.EDIT
|
|
}
|
|
|
|
/** @override */
|
|
async _prepareContext() {
|
|
const context = await super._prepareContext()
|
|
context.fields = this.document.schema.fields
|
|
context.systemFields = this.document.system.schema.fields
|
|
context.item = this.document
|
|
context.system = this.document.system
|
|
context.source = this.document.toObject()
|
|
context.isEditMode = this.isEditMode
|
|
context.isPlayMode = this.isPlayMode
|
|
context.isEditable = this.isEditable
|
|
if (this.document.system.description !== undefined) {
|
|
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.document.system.description ?? "", { async: true })
|
|
}
|
|
if (this.document.system.magicEffect !== undefined) {
|
|
context.enrichedMagicEffect = await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.document.system.magicEffect ?? "", { async: true })
|
|
}
|
|
context.classRestrictionChoices = CLASS_RESTRICTION_CHOICES
|
|
// Armor-specific numeric selects
|
|
context.armorValueChoices = Object.fromEntries(
|
|
Array.from({ length: 13 }, (_, i) => [i, String(i)])
|
|
)
|
|
context.penaltyChoices = Object.fromEntries(
|
|
Array.from({ length: 6 }, (_, i) => [-i, String(-i)])
|
|
)
|
|
// Weapon-specific numeric selects
|
|
context.damageModChoices = Object.fromEntries(
|
|
Array.from({ length: 10 }, (_, i) => [i - 4, i - 4 >= 0 ? `+${i - 4}` : String(i - 4)])
|
|
)
|
|
context.apChoices = Object.fromEntries(
|
|
Array.from({ length: 7 }, (_, i) => [i, String(i)])
|
|
)
|
|
// Skill choices for weapon skill override (empty = auto-detect)
|
|
context.skillChoices = {
|
|
"": `— ${game.i18n.localize("OATHHAMMER.Weapon.SkillOverrideAuto")} —`,
|
|
...Object.fromEntries(
|
|
Object.entries(SYSTEM.SKILLS).map(([k, v]) => [k, game.i18n.localize(v.label)])
|
|
)
|
|
}
|
|
// Class proficiency choices (for class-sheet checkboxes)
|
|
context.armorTypeChoices = ARMOR_TYPE_CHOICES
|
|
context.weaponGroupChoices = WEAPON_PROFICIENCY_GROUPS
|
|
|
|
// Rune context — enrich effect HTML for each attached rune
|
|
if (Array.isArray(this.document.system.runes)) {
|
|
context.enrichedRunes = await Promise.all(
|
|
this.document.system.runes.map(async (rune, idx) => ({
|
|
...rune,
|
|
idx,
|
|
enrichedEffect: await foundry.applications.ux.TextEditor.implementation.enrichHTML(rune.effect ?? "", { async: true }),
|
|
}))
|
|
)
|
|
}
|
|
context.acceptsRunes = this.#acceptsRunes()
|
|
|
|
return context
|
|
}
|
|
|
|
/** Returns true if this item type can have runic spells attached. */
|
|
#acceptsRunes() {
|
|
const type = this.document.type
|
|
if (type === "armor" || type === "weapon") return true
|
|
if (type === "magic-item" && this.document.system.itemType === "talisman") return true
|
|
return false
|
|
}
|
|
|
|
/** Map runeType expected for each item type. */
|
|
static #EXPECTED_RUNE_TYPE = {
|
|
armor: "armor",
|
|
weapon: "weapon",
|
|
"magic-item": "talisman",
|
|
}
|
|
|
|
/** @override */
|
|
_onRender(context, options) {
|
|
super._onRender(context, options)
|
|
this.#dragDrop.forEach((d) => d.bind(this.element))
|
|
if (this._lockedReadOnly) {
|
|
this.element.querySelector('[data-action="toggleSheet"]')?.remove()
|
|
for (const el of this.element.querySelectorAll(
|
|
'.window-content input, .window-content select, .window-content textarea, .window-content button'
|
|
)) {
|
|
el.disabled = true
|
|
}
|
|
}
|
|
for (const pm of this.element.querySelectorAll("prose-mirror[name]")) {
|
|
pm.addEventListener("change", async (event) => {
|
|
event.stopPropagation()
|
|
await this.document.update({ [pm.name]: pm.value ?? "" })
|
|
})
|
|
}
|
|
}
|
|
|
|
#createDragDropHandlers() {
|
|
return this.options.dragDrop.map((d) => {
|
|
d.permissions = {
|
|
dragstart: this._canDragStart.bind(this),
|
|
drop: this._canDragDrop.bind(this),
|
|
}
|
|
d.callbacks = {
|
|
dragstart: this._onDragStart.bind(this),
|
|
dragover: this._onDragOver.bind(this),
|
|
drop: this._onDrop.bind(this),
|
|
}
|
|
return new foundry.applications.ux.DragDrop.implementation(d)
|
|
})
|
|
}
|
|
|
|
_canDragStart(selector) {
|
|
return this.isEditable
|
|
}
|
|
|
|
_canDragDrop(selector) {
|
|
return this.isEditable && this.document.isOwner
|
|
}
|
|
|
|
_onDragStart(event) {
|
|
if ("link" in event.target.dataset) return
|
|
}
|
|
|
|
_onDragOver(event) {}
|
|
|
|
async _onDrop(event) {
|
|
if (!this.#acceptsRunes()) return
|
|
let dragData
|
|
try { dragData = JSON.parse(event.dataTransfer.getData("text/plain")) } catch { return }
|
|
if (dragData?.type !== "Item") return
|
|
|
|
let spell
|
|
try { spell = await fromUuid(dragData.uuid) } catch { return }
|
|
if (!spell || spell.type !== "spell") return
|
|
|
|
const sys = spell.system
|
|
if (sys.tradition !== "runic") {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.NotRunic"))
|
|
return
|
|
}
|
|
|
|
const expectedType = OathHammerItemSheet.#EXPECTED_RUNE_TYPE[this.document.type]
|
|
if (sys.runeType !== expectedType) {
|
|
ui.notifications.warn(game.i18n.format("OATHHAMMER.Rune.WrongType", {
|
|
expected: game.i18n.localize(`OATHHAMMER.RuneType.${expectedType.charAt(0).toUpperCase() + expectedType.slice(1)}`),
|
|
}))
|
|
return
|
|
}
|
|
|
|
const current = this.document.system.runes ?? []
|
|
if (current.length >= 2) {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.MaxRunes"))
|
|
return
|
|
}
|
|
if (current.some(r => r.name === spell.name)) {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.Duplicate"))
|
|
return
|
|
}
|
|
if (sys.isExalted && current.some(r => r.isExalted)) {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.MaxExalted"))
|
|
return
|
|
}
|
|
|
|
const snapshot = {
|
|
sourceUuid: spell.uuid,
|
|
name: spell.name,
|
|
img: spell.img,
|
|
runeType: sys.runeType,
|
|
isExalted: sys.isExalted,
|
|
difficultyValue: sys.difficultyValue,
|
|
effect: sys.effect ?? "",
|
|
duration: sys.duration ?? "",
|
|
range: sys.range ?? "",
|
|
spellSave: sys.spellSave ?? "",
|
|
}
|
|
await this.document.update({ "system.runes": [...current, snapshot] })
|
|
ui.notifications.info(game.i18n.format("OATHHAMMER.Rune.Attached", { name: spell.name }))
|
|
}
|
|
|
|
static async #onRemoveRune(event, target) {
|
|
const idx = parseInt(target.dataset.runeIndex, 10)
|
|
if (isNaN(idx)) return
|
|
const runes = [...(this.document.system.runes ?? [])]
|
|
runes.splice(idx, 1)
|
|
await this.document.update({ "system.runes": runes })
|
|
}
|
|
|
|
static async #onOpenRune(event, target) {
|
|
const uuid = target.dataset.sourceUuid
|
|
if (!uuid) return
|
|
try {
|
|
const spell = await fromUuid(uuid)
|
|
if (!spell) {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.SourceNotFound"))
|
|
return
|
|
}
|
|
// Use a unique ID so this read-only instance never conflicts with the
|
|
// document's normal sheet (which uses {ClassName}-{documentId} as its ID).
|
|
const SheetClass = spell.sheet.constructor
|
|
const sheet = new SheetClass({
|
|
document: spell,
|
|
id: `${SheetClass.name}-rune-view-${spell.id}`,
|
|
})
|
|
sheet._lockedReadOnly = true
|
|
sheet.render(true)
|
|
} catch {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.SourceNotFound"))
|
|
}
|
|
}
|
|
|
|
static #onToggleSheet(event, target) {
|
|
if (this._lockedReadOnly) return
|
|
const modes = this.constructor.SHEET_MODES
|
|
this._sheetMode = this.isEditMode ? modes.PLAY : modes.EDIT
|
|
this.render()
|
|
}
|
|
|
|
static async #onEditImage(event, target) {
|
|
const attr = target.dataset.edit
|
|
const current = foundry.utils.getProperty(this.document, attr)
|
|
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {}
|
|
const fp = new FilePicker({
|
|
current,
|
|
type: "image",
|
|
redirectToRoot: img ? [img] : [],
|
|
callback: (path) => {
|
|
this.document.update({ [attr]: path })
|
|
},
|
|
top: this.position.top + 40,
|
|
left: this.position.left + 10,
|
|
})
|
|
return fp.browse()
|
|
}
|
|
|
|
static async #onRollRarity(event, target) {
|
|
const rarity = this.document.system.rarity
|
|
if (!rarity) return
|
|
// Find the owning actor (embedded item) or prompt user to select a character
|
|
let actor = this.document.parent
|
|
if (!actor || actor.documentName !== "Actor") {
|
|
// Item not embedded — use the user's selected character
|
|
actor = game.user.character
|
|
if (!actor) {
|
|
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoActor"))
|
|
return
|
|
}
|
|
}
|
|
await rollRarityCheck(actor, rarity, this.document.name)
|
|
}
|
|
}
|