import DHBaseActorSheet from '../api/base-actor.mjs'; import { getDocFromElement } from '../../../helpers/utils.mjs'; import { ItemBrowser } from '../../ui/itemBrowser.mjs'; import FilterMenu from '../../ux/filter-menu.mjs'; import DaggerheartMenu from '../../sidebar/tabs/daggerheartMenu.mjs'; import { socketEvent } from '../../../systemRegistration/socket.mjs'; import GroupRollDialog from '../../dialogs/group-roll-dialog.mjs'; import DhpActor from '../../../documents/actor.mjs'; import DHItem from '../../../documents/item.mjs'; export default class Party extends DHBaseActorSheet { constructor(options) { super(options); this.refreshSelections = DaggerheartMenu.defaultRefreshSelections(); } /**@inheritdoc */ static DEFAULT_OPTIONS = { classes: ['party'], position: { width: 550 }, window: { resizable: true }, actions: { deletePartyMember: Party.#deletePartyMember, toggleHope: Party.#toggleHope, toggleHitPoints: Party.#toggleHitPoints, toggleStress: Party.#toggleStress, toggleArmorSlot: Party.#toggleArmorSlot, tempBrowser: Party.#tempBrowser, refeshActions: Party.#refeshActions, triggerRest: Party.#triggerRest, tagTeamRoll: Party.#tagTeamRoll, groupRoll: Party.#groupRoll, selectRefreshable: DaggerheartMenu.selectRefreshable, refreshActors: DaggerheartMenu.refreshActors }, dragDrop: [{ dragSelector: '.actors-section .inventory-item', dropSelector: null }] }; /**@override */ static PARTS = { header: { template: 'systems/daggerheart/templates/sheets/actors/party/header.hbs' }, tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' }, partyMembers: { template: 'systems/daggerheart/templates/sheets/actors/party/party-members.hbs' }, resources: { template: 'systems/daggerheart/templates/sheets/actors/party/resources.hbs', scrollable: [''] }, /* NOT YET IMPLEMENTED */ // projects: { // template: 'systems/daggerheart/templates/sheets/actors/party/projects.hbs', // scrollable: [''] // }, inventory: { template: 'systems/daggerheart/templates/sheets/actors/party/inventory.hbs', scrollable: ['.tab.inventory .items-section'] }, notes: { template: 'systems/daggerheart/templates/sheets/actors/party/notes.hbs' } }; /** @inheritdoc */ static TABS = { primary: { tabs: [ { id: 'partyMembers' }, { id: 'resources' }, /* NOT YET IMPLEMENTED */ // { id: 'projects' }, { id: 'inventory' }, { id: 'notes' } ], initial: 'partyMembers', labelPrefix: 'DAGGERHEART.GENERAL.Tabs' } }; async _onRender(context, options) { await super._onRender(context, options); this._createFilterMenus(); this._createSearchFilter(); } /* -------------------------------------------- */ /* Prepare Context */ /* -------------------------------------------- */ async _prepareContext(_options) { const context = await super._prepareContext(_options); context.inventory = { currency: { title: game.i18n.localize('DAGGERHEART.CONFIG.Gold.title'), coins: game.i18n.localize('DAGGERHEART.CONFIG.Gold.coins'), handfuls: game.i18n.localize('DAGGERHEART.CONFIG.Gold.handfuls'), bags: game.i18n.localize('DAGGERHEART.CONFIG.Gold.bags'), chests: game.i18n.localize('DAGGERHEART.CONFIG.Gold.chests') } }; const homebrewCurrency = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).currency; if (homebrewCurrency.enabled) { context.inventory.currency = homebrewCurrency; } if (context.inventory.length === 0) { context.inventory = Array(1).fill(Array(5).fill([])); } return context; } async _preparePartContext(partId, context, options) { context = await super._preparePartContext(partId, context, options); switch (partId) { case 'header': await this._prepareHeaderContext(context, options); break; case 'notes': await this._prepareNotesContext(context, options); break; } return context; } /** * Prepare render context for the Header part. * @param {ApplicationRenderContext} context * @param {ApplicationRenderOptions} options * @returns {Promise} * @protected */ async _prepareHeaderContext(context, _options) { const { system } = this.document; const { TextEditor } = foundry.applications.ux; context.description = await TextEditor.implementation.enrichHTML(system.description, { secrets: this.document.isOwner, relativeTo: this.document }); } /** * Prepare render context for the Biography part. * @param {ApplicationRenderContext} context * @param {ApplicationRenderOptions} options * @returns {Promise} * @protected */ async _prepareNotesContext(context, _options) { const { system } = this.document; const { TextEditor } = foundry.applications.ux; const paths = { notes: 'notes' }; for (const [key, path] of Object.entries(paths)) { const value = foundry.utils.getProperty(system, path); context[key] = { field: system.schema.getField(path), value, enriched: await TextEditor.implementation.enrichHTML(value, { secrets: this.document.isOwner, relativeTo: this.document }) }; } } /** * Toggles a hope resource value. * @type {ApplicationClickAction} */ static async #toggleHope(_, target) { const hopeValue = Number.parseInt(target.dataset.value); const actor = await foundry.utils.fromUuid(target.dataset.actorId); const newValue = actor.system.resources.hope.value >= hopeValue ? hopeValue - 1 : hopeValue; await actor.update({ 'system.resources.hope.value': newValue }); this.render(); } /** * Toggles a hp resource value. * @type {ApplicationClickAction} */ static async #toggleHitPoints(_, target) { const hitPointsValue = Number.parseInt(target.dataset.value); const actor = await foundry.utils.fromUuid(target.dataset.actorId); const newValue = actor.system.resources.hitPoints.value >= hitPointsValue ? hitPointsValue - 1 : hitPointsValue; await actor.update({ 'system.resources.hitPoints.value': newValue }); this.render(); } /** * Toggles a stress resource value. * @type {ApplicationClickAction} */ static async #toggleStress(_, target) { const stressValue = Number.parseInt(target.dataset.value); const actor = await foundry.utils.fromUuid(target.dataset.actorId); const newValue = actor.system.resources.stress.value >= stressValue ? stressValue - 1 : stressValue; await actor.update({ 'system.resources.stress.value': newValue }); this.render(); } /** * Toggles a armor slot resource value. * @type {ApplicationClickAction} */ static async #toggleArmorSlot(_, target, element) { const armorItem = await foundry.utils.fromUuid(target.dataset.itemUuid); const armorValue = Number.parseInt(target.dataset.value); const newValue = armorItem.system.marks.value >= armorValue ? armorValue - 1 : armorValue; await armorItem.update({ 'system.marks.value': newValue }); this.render(); } /** * Opens Compedium Browser */ static async #tempBrowser(_, target) { new ItemBrowser().render({ force: true }); } static async #refeshActions() { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: 'New Section', icon: 'fa-solid fa-campground' }, content: await foundry.applications.handlebars.renderTemplate( 'systems/daggerheart/templates/sidebar/daggerheart-menu/main.hbs', { refreshables: DaggerheartMenu.defaultRefreshSelections() } ), classes: ['daggerheart', 'dialog', 'dh-style', 'tab', 'sidebar-tab', 'daggerheartMenu-sidebar'] }); if (!confirmed) return; } static async #triggerRest(_, button) { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: game.i18n.localize(`DAGGERHEART.APPLICATIONS.Downtime.${button.dataset.type}.title`), icon: button.dataset.type === 'shortRest' ? 'fa-solid fa-utensils' : 'fa-solid fa-bed' }, content: 'This will trigger a dialog to players make their downtime moves, are you sure?', classes: ['daggerheart', 'dialog', 'dh-style'] }); if (!confirmed) return; this.document.system.partyMembers.forEach(actor => { game.socket.emit(`system.${CONFIG.DH.id}`, { action: socketEvent.DowntimeTrigger, data: { actorId: actor.uuid, downtimeType: button.dataset.type } }); }); } static async downtimeMoveQuery({ actorId, downtimeType }) { const actor = await foundry.utils.fromUuid(actorId); if (!actor || !actor?.isOwner) reject(); new game.system.api.applications.dialogs.Downtime(actor, downtimeType === 'shortRest').render({ force: true }); } static async #tagTeamRoll() { new game.system.api.applications.dialogs.TagTeamDialog(this.document.system.partyMembers).render({ force: true }); } static async #groupRoll(params) { new GroupRollDialog(this.document.system.partyMembers).render({ force: true }); } /** * Get the set of ContextMenu options for Consumable and Loot. * @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} - The Array of context options passed to the ContextMenu instance * @this {CharacterSheet} * @protected */ static #getItemContextOptions() { return this._getContextMenuCommonOptions.call(this, { usable: true, toChat: true }); } /* -------------------------------------------- */ /* Filter Tracking */ /* -------------------------------------------- */ /** * The currently active search filter. * @type {foundry.applications.ux.SearchFilter} */ #search = {}; /** * The currently active search filter. * @type {FilterMenu} */ #menu = {}; /** * Tracks which item IDs are currently displayed, organized by filter type and section. * @type {{ * inventory: { * search: Set, * menu: Set * }, * loadout: { * search: Set, * menu: Set * }, * }} */ #filteredItems = { inventory: { search: new Set(), menu: new Set() }, loadout: { search: new Set(), menu: new Set() } }; /* -------------------------------------------- */ /* Search Inputs */ /* -------------------------------------------- */ /** * Create and initialize search filter instances for the inventory and loadout sections. * * Sets up two {@link foundry.applications.ux.SearchFilter} instances: * - One for the inventory, which filters items in the inventory grid. * - One for the loadout, which filters items in the loadout/card grid. * @private */ _createSearchFilter() { //Filters could be a application option if needed const filters = [ { key: 'inventory', input: 'input[type="search"].search-inventory', content: '[data-application-part="inventory"] .items-section', callback: this._onSearchFilterInventory.bind(this) } ]; for (const { key, input, content, callback } of filters) { const filter = new foundry.applications.ux.SearchFilter({ inputSelector: input, contentSelector: content, callback }); filter.bind(this.element); this.#search[key] = filter; } } /** * Handle invetory items search and filtering. * @param {KeyboardEvent} event The keyboard input event. * @param {string} query The input search string. * @param {RegExp} rgx The regular expression query that should be matched against. * @param {HTMLElement} html The container to filter items from. * @protected */ async _onSearchFilterInventory(_event, query, rgx, html) { this.#filteredItems.inventory.search.clear(); for (const li of html.querySelectorAll('.inventory-item')) { const item = await getDocFromElement(li); const matchesSearch = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name); if (matchesSearch) this.#filteredItems.inventory.search.add(item.id); const { menu } = this.#filteredItems.inventory; li.hidden = !(menu.has(item.id) && matchesSearch); } } /* -------------------------------------------- */ /* Filter Menus */ /* -------------------------------------------- */ _createFilterMenus() { //Menus could be a application option if needed const menus = [ { key: 'inventory', container: '[data-application-part="inventory"]', content: '.items-section', callback: this._onMenuFilterInventory.bind(this), target: '.filter-button', filters: FilterMenu.invetoryFilters } ]; menus.forEach(m => { const container = this.element.querySelector(m.container); this.#menu[m.key] = new FilterMenu(container, m.target, m.filters, m.callback, { contentSelector: m.content }); }); } /** * Callback when filters change * @param {PointerEvent} event * @param {HTMLElement} html * @param {import('../ux/filter-menu.mjs').FilterItem[]} filters */ async _onMenuFilterInventory(_event, html, filters) { this.#filteredItems.inventory.menu.clear(); for (const li of html.querySelectorAll('.inventory-item')) { const item = await getDocFromElement(li); const matchesMenu = filters.length === 0 || filters.some(f => foundry.applications.ux.SearchFilter.evaluateFilter(item, f)); if (matchesMenu) this.#filteredItems.inventory.menu.add(item.id); const { search } = this.#filteredItems.inventory; li.hidden = !(search.has(item.id) && matchesMenu); } } /* -------------------------------------------- */ async _onDragStart(event) { const item = event.currentTarget.closest('.inventory-item'); if (item) { const adversaryData = { type: 'Actor', uuid: item.dataset.itemUuid }; event.dataTransfer.setData('text/plain', JSON.stringify(adversaryData)); event.dataTransfer.setDragImage(item, 60, 0); } } async _onDrop(event) { // Prevent event bubbling to avoid duplicate handling event.preventDefault(); event.stopPropagation(); const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event); const item = await foundry.utils.fromUuid(data.uuid); if (item instanceof DhpActor) { const currentMembers = this.document.system.partyMembers.map(x => x.uuid); if (currentMembers.includes(data.uuid)) { return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.duplicateCharacter')); } await this.document.update({ 'system.partyMembers': [...currentMembers, item.uuid] }); } else if (item instanceof DHItem) { this.document.createEmbeddedDocuments('Item', [item.toObject()]); } else { ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.onlyCharactersInPartySheet')); } } static async #deletePartyMember(_event, target) { const doc = await getDocFromElement(target.closest('.inventory-item')); const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: game.i18n.format('DAGGERHEART.APPLICATIONS.DeleteConfirmation.title', { type: game.i18n.localize('TYPES.Actor.adversary'), name: doc.name }) }, content: game.i18n.format('DAGGERHEART.APPLICATIONS.DeleteConfirmation.text', { name: doc.name }) }); if (!confirmed) return; const currentMembers = this.document.system.partyMembers.map(x => x.uuid); const newMemberdList = currentMembers.filter(uuid => uuid !== doc.uuid); await this.document.update({ 'system.partyMembers': newMemberdList }); } }