diff --git a/lang/en.json b/lang/en.json index 017413dc..b75d5ccc 100755 --- a/lang/en.json +++ b/lang/en.json @@ -545,6 +545,17 @@ "ResourceDice": { "title": "{name} Resource", "rerollDice": "Reroll Dice" + }, + "TagTeamSelect": { + "title": "Tag Team Roll", + "leaderTitle": "Initiating Character", + "membersTitle": "Participants", + "hopeCost": "Hope Cost", + "linkMessageHint": "Make a roll from your character sheet to link it to the Tag Team Roll", + "damageNotRolled": "Damage not rolled in chat message yet", + "insufficientHope": "The initiating character doesn't have enough hope", + "createTagTeam": "Create TagTeam Roll", + "chatMessageRollTitle": "Roll" } }, "CLASS": { @@ -2452,8 +2463,16 @@ }, "resourceRoll": { "playerMessage": "{user} rerolled their {name}" + }, + "tagTeam": { + "title": "Tag Team", + "membersTitle": "Members" } }, + "ChatLog": { + "rerollDamage": "Reroll Damage", + "assignTagRoll": "Assign as Tag Roll" + }, "ItemBrowser": { "title": "Daggerheart Compendium Browser", "hint": "Select a Folder in sidebar to start browsing through the compendium", diff --git a/module/applications/dialogs/_module.mjs b/module/applications/dialogs/_module.mjs index 838be84d..8c9cc33c 100644 --- a/module/applications/dialogs/_module.mjs +++ b/module/applications/dialogs/_module.mjs @@ -10,4 +10,5 @@ export { default as OwnershipSelection } from './ownershipSelection.mjs'; export { default as RerollDamageDialog } from './rerollDamageDialog.mjs'; export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs'; export { default as ActionSelectionDialog } from './actionSelectionDialog.mjs'; -export { default as GroupRollDialog } from './group-roll-dialog.mjs'; \ No newline at end of file +export { default as GroupRollDialog } from './group-roll-dialog.mjs'; +export { default as TagTeamDialog } from './tagTeamDialog.mjs'; diff --git a/module/applications/dialogs/d20RollDialog.mjs b/module/applications/dialogs/d20RollDialog.mjs index 6c227152..bb5e04a1 100644 --- a/module/applications/dialogs/d20RollDialog.mjs +++ b/module/applications/dialogs/d20RollDialog.mjs @@ -34,6 +34,7 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio updateIsAdvantage: this.updateIsAdvantage, selectExperience: this.selectExperience, toggleReaction: this.toggleReaction, + toggleTagTeamRoll: this.toggleTagTeamRoll, submitRoll: this.submitRoll }, form: { @@ -120,6 +121,13 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio context.showReaction = !this.config.roll?.type && context.rollType === 'DualityRoll'; context.reactionOverride = this.reactionOverride; } + + const tagTeamSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); + if (tagTeamSetting.members[this.actor.id]) { + context.activeTagTeamRoll = true; + context.tagTeamSelected = this.config.tagTeamSelected; + } + return context; } @@ -195,6 +203,11 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio } } + static toggleTagTeamRoll() { + this.config.tagTeamSelected = !this.config.tagTeamSelected; + this.render(); + } + static async submitRoll() { await this.close({ submitted: true }); } diff --git a/module/applications/dialogs/rerollDamageDialog.mjs b/module/applications/dialogs/rerollDamageDialog.mjs index 0c2ea0e1..e1b75eb7 100644 --- a/module/applications/dialogs/rerollDamageDialog.mjs +++ b/module/applications/dialogs/rerollDamageDialog.mjs @@ -1,3 +1,5 @@ +import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs'; + const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; export default class RerollDamageDialog extends HandlebarsApplicationMixin(ApplicationV2) { @@ -122,6 +124,15 @@ export default class RerollDamageDialog extends HandlebarsApplicationMixin(Appli }, {}) }; await this.message.update(update); + + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); + await this.close(); } diff --git a/module/applications/dialogs/tagTeamDialog.mjs b/module/applications/dialogs/tagTeamDialog.mjs new file mode 100644 index 00000000..db39cc67 --- /dev/null +++ b/module/applications/dialogs/tagTeamDialog.mjs @@ -0,0 +1,279 @@ +import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs'; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; + +export default class TagTeamDialog extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(party) { + super(); + + this.data = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); + this.party = party; + + this.setupHooks = Hooks.on(socketEvent.Refresh, ({ refreshType }) => { + if (refreshType === RefreshType.TagTeamRoll) { + this.data = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); + this.render(); + } + }); + } + + get title() { + return game.i18n.localize('DAGGERHEART.APPLICATIONS.TagTeamSelect.title'); + } + + static DEFAULT_OPTIONS = { + tag: 'form', + classes: ['daggerheart', 'views', 'dh-style', 'dialog', 'tag-team-dialog'], + position: { width: 550, height: 'auto' }, + actions: { + removeMember: TagTeamDialog.#removeMember, + unlinkMessage: TagTeamDialog.#unlinkMessage, + selectMessage: TagTeamDialog.#selectMessage, + createTagTeam: TagTeamDialog.#createTagTeam + }, + form: { handler: this.updateData, submitOnChange: true, closeOnSubmit: false } + }; + + static PARTS = { + application: { + id: 'tag-team-dialog', + template: 'systems/daggerheart/templates/dialogs/tagTeamDialog.hbs' + } + }; + + async _prepareContext(_options) { + const context = await super._prepareContext(_options); + context.hopeCost = this.hopeCost; + context.data = this.data; + + context.memberOptions = this.party.filter(c => !this.data.members[c.id]); + context.selectedCharacterOptions = this.party.filter(c => this.data.members[c.id]); + + context.members = Object.keys(this.data.members).map(id => { + const roll = this.data.members[id].messageId ? game.messages.get(this.data.members[id].messageId) : null; + + context.usesDamage = + context.usesDamage === undefined + ? roll?.system.hasDamage + : context.usesDamage && roll?.system.hasDamage; + return { + character: this.party.find(x => x.id === id), + selected: this.data.members[id].selected, + roll: roll, + damageValues: roll + ? Object.keys(roll.system.damage).map(key => ({ + key: key, + name: game.i18n.localize(CONFIG.DH.GENERAL.healingTypes[key].label), + total: roll.system.damage[key].total + })) + : null + }; + }); + + const initiatorChar = this.party.find(x => x.id === this.data.initiator.id); + context.initiator = { + character: initiatorChar, + cost: this.data.initiator.cost + }; + + context.selectedData = Object.values(context.members).reduce( + (acc, member) => { + if (!member.roll) return acc; + if (member.selected) { + acc.result = `${member.roll.system.roll.total} ${member.roll.system.roll.result.label}`; + } + + if (context.usesDamage) { + if (!acc.damageValues) acc.damageValues = {}; + for (let damage of member.damageValues) { + if (acc.damageValues[damage.key]) { + acc.damageValues[damage.key].total += damage.total; + } else { + acc.damageValues[damage.key] = foundry.utils.deepClone(damage); + } + } + } + + return acc; + }, + { result: null, damageValues: null } + ); + context.showResult = Object.values(context.members).reduce((enabled, member) => { + if (!member.roll) return enabled; + if (context.usesDamage) { + enabled = enabled === null ? member.damageValues.length > 0 : enabled && member.damageValues.length > 0; + } else { + enabled = enabled === null ? Boolean(member.roll) : enabled && Boolean(member.roll); + } + + return enabled; + }, null); + + context.createDisabled = + !context.selectedData.result || + !this.data.initiator.id || + Object.keys(this.data.members).length === 0 || + Object.values(context.members).some(x => + context.usesDamage ? !x.damageValues || x.damageValues.length === 0 : !x.roll + ); + + return context; + } + + async updateSource(update) { + await this.data.updateSource(update); + await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll, this.data.toObject()); + + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); + } + + static async updateData(_event, _element, formData) { + const { selectedAddMember, initiator } = foundry.utils.expandObject(formData.object); + const update = { initiator: initiator }; + if (selectedAddMember) { + const member = await foundry.utils.fromUuid(selectedAddMember); + update[`members.${member.id}`] = { messageId: null }; + } + + await this.updateSource(update); + this.render(); + } + + static async #removeMember(_, button) { + const update = { [`members.-=${button.dataset.characterId}`]: null }; + if (this.data.initiator.id === button.dataset.characterId) { + update.iniator = { id: null }; + } + + await this.updateSource(update); + } + + static async #unlinkMessage(_, button) { + await this.updateSource({ [`members.${button.id}.messageId`]: null }); + } + + static async #selectMessage(_, button) { + const member = this.data.members[button.id]; + const currentSelected = Object.keys(this.data.members).find(key => this.data.members[key].selected); + const curretSelectedUpdate = + currentSelected && currentSelected !== button.id ? { [`${currentSelected}`]: { selected: false } } : {}; + await this.updateSource({ + members: { + [`${button.id}`]: { selected: !member.selected }, + ...curretSelectedUpdate + } + }); + } + + static async #createTagTeam() { + const mainRollId = Object.keys(this.data.members).find(key => this.data.members[key].selected); + const mainRoll = game.messages.get(this.data.members[mainRollId].messageId); + + if (this.data.initiator.cost) { + const initiator = this.party.find(x => x.id === this.data.initiator.id); + if (initiator.system.resources.hope.value < this.data.initiator.cost) { + return ui.notifications.warn( + game.i18n.localize('DAGGERHEART.APPLICATIONS.TagTeamSelect.insufficientHope') + ); + } + } + + const secondaryRolls = Object.keys(this.data.members) + .filter(key => key !== mainRollId) + .map(key => game.messages.get(this.data.members[key].messageId)); + + const systemData = foundry.utils.deepClone(mainRoll).system.toObject(); + for (let roll of secondaryRolls) { + if (roll.system.hasDamage) { + for (let key in roll.system.damage) { + var damage = roll.system.damage[key]; + if (systemData.damage[key]) { + systemData.damage[key].total += damage.total; + systemData.damage[key].parts = [...systemData.damage[key].parts, ...damage.parts]; + } else { + systemData.damage[key] = damage; + } + } + } + } + systemData.title = game.i18n.localize('DAGGERHEART.APPLICATIONS.TagTeamSelect.chatMessageRollTitle'); + + const cls = getDocumentClass('ChatMessage'), + msgData = { + type: 'dualityRoll', + user: game.user.id, + title: game.i18n.localize('DAGGERHEART.APPLICATIONS.TagTeamSelect.title'), + speaker: cls.getSpeaker({ actor: this.party.find(x => x.id === mainRollId) }), + system: systemData, + rolls: mainRoll.rolls + }; + + await cls.create(msgData); + + const fearUpdate = { key: 'fear', value: null, total: null, enabled: true }; + for (let memberId of Object.keys(this.data.members)) { + const resourceUpdates = []; + if (systemData.roll.isCritical || systemData.roll.result.duality === 1) { + const value = + memberId !== this.data.initiator.id + ? 1 + : this.data.initiator.cost + ? 1 - this.data.initiator.cost + : 1; + resourceUpdates.push({ key: 'hope', value: value, total: -value, enabled: true }); + } + if (systemData.roll.isCritical) resourceUpdates.push({ key: 'stress', value: 1, total: -1, enabled: true }); + if (systemData.roll.result.duality === -1) { + fearUpdate.value = fearUpdate.value === null ? 1 : fearUpdate.value + 1; + fearUpdate.total = fearUpdate.total === null ? -1 : fearUpdate.total - 1; + } + + this.party.find(x => x.id === memberId).modifyResource(resourceUpdates); + } + + if (fearUpdate.value) { + this.party.find(x => x.id === mainRollId).modifyResource([fearUpdate]); + } + + /* Improve by fetching default from schema */ + await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll, { + members: [], + initiator: { id: null, cost: 3 } + }); + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); + } + + static async assignRoll(char, message) { + const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); + const character = settings.members[char.id]; + if (!character) return; + + await settings.updateSource({ [`members.${char.id}.messageId`]: message.id }); + await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll, settings); + + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); + } + + async close(options = {}) { + Hooks.off(socketEvent.Refresh, this.setupHooks); + await super.close(options); + } +} diff --git a/module/applications/sheets/actors/party.mjs b/module/applications/sheets/actors/party.mjs index cd2f7e22..5b50de17 100644 --- a/module/applications/sheets/actors/party.mjs +++ b/module/applications/sheets/actors/party.mjs @@ -1,475 +1,482 @@ -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'; - -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, - 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: ['.resources'] - }, - projects: { - template: 'systems/daggerheart/templates/sheets/actors/party/projects.hbs', - scrollable: ['.projects'] - }, - inventory: { - template: 'systems/daggerheart/templates/sheets/actors/party/inventory.hbs', - scrollable: ['.inventory'] - }, - notes: { template: 'systems/daggerheart/templates/sheets/actors/party/notes.hbs' } - }; - - /** @inheritdoc */ - static TABS = { - primary: { - tabs: [ - { id: 'partyMembers' }, - { id: 'resources' }, - { 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 #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) { - const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event); - const actor = await foundry.utils.fromUuid(data.uuid); - const currentMembers = this.document.system.partyMembers.map(x => x.uuid); - - if (foundry.utils.parseUuid(data.uuid).collection instanceof CompendiumCollection) return; - - if (actor.type !== 'character') { - return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.onlyCharactersInPartySheet')); - } - - if (currentMembers.includes(data.uuid)) { - return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.duplicateCharacter')); - } - - await this.document.update({ 'system.partyMembers': [...currentMembers, actor.uuid] }); - } - - 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 }); - } -} +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'; + +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: ['.resources'] + }, + projects: { + template: 'systems/daggerheart/templates/sheets/actors/party/projects.hbs', + scrollable: ['.projects'] + }, + inventory: { + template: 'systems/daggerheart/templates/sheets/actors/party/inventory.hbs', + scrollable: ['.inventory'] + }, + notes: { template: 'systems/daggerheart/templates/sheets/actors/party/notes.hbs' } + }; + + /** @inheritdoc */ + static TABS = { + primary: { + tabs: [ + { id: 'partyMembers' }, + { id: 'resources' }, + { 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) { + const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event); + const actor = await foundry.utils.fromUuid(data.uuid); + const currentMembers = this.document.system.partyMembers.map(x => x.uuid); + + if (foundry.utils.parseUuid(data.uuid).collection instanceof CompendiumCollection) return; + + if (actor.type !== 'character') { + return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.onlyCharactersInPartySheet')); + } + + if (currentMembers.includes(data.uuid)) { + return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.duplicateCharacter')); + } + + await this.document.update({ 'system.partyMembers': [...currentMembers, actor.uuid] }); + } + + 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 }); + } +} diff --git a/module/applications/ui/chatLog.mjs b/module/applications/ui/chatLog.mjs index b95e50e1..d8e4221d 100644 --- a/module/applications/ui/chatLog.mjs +++ b/module/applications/ui/chatLog.mjs @@ -1,3 +1,5 @@ +import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs'; + export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLog { constructor(options) { super(options); @@ -35,7 +37,7 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo // } // }, { - name: 'Reroll Damage', + name: game.i18n.localize('DAGGERHEART.UI.ChatLog.rerollDamage'), icon: '', condition: li => { const message = game.messages.get(li.dataset.messageId); @@ -164,6 +166,14 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo 'system.roll': newRoll, 'rolls': [parsedRoll] }); + + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); } } } diff --git a/module/config/settingsConfig.mjs b/module/config/settingsConfig.mjs index 5232cbd9..aea9bc48 100644 --- a/module/config/settingsConfig.mjs +++ b/module/config/settingsConfig.mjs @@ -27,7 +27,8 @@ export const gameSettings = { }, LevelTiers: 'LevelTiers', Countdowns: 'Countdowns', - LastMigrationVersion: 'LastMigrationVersion' + LastMigrationVersion: 'LastMigrationVersion', + TagTeamRoll: 'TagTeamRoll' }; export const actionAutomationChoices = { diff --git a/module/data/_module.mjs b/module/data/_module.mjs index cac02a4a..eacade96 100644 --- a/module/data/_module.mjs +++ b/module/data/_module.mjs @@ -1,5 +1,6 @@ export { default as DhCombat } from './combat.mjs'; export { default as DhCombatant } from './combatant.mjs'; +export { default as DhTagTeamRoll } from './tagTeamRoll.mjs'; export * as actions from './action/_module.mjs'; export * as activeEffects from './activeEffect/_module.mjs'; diff --git a/module/data/tagTeamRoll.mjs b/module/data/tagTeamRoll.mjs new file mode 100644 index 00000000..de71a11b --- /dev/null +++ b/module/data/tagTeamRoll.mjs @@ -0,0 +1,20 @@ +import { DhCharacter } from './actor/_module.mjs'; + +export default class DhTagTeamRoll extends foundry.abstract.DataModel { + static defineSchema() { + const fields = foundry.data.fields; + + return { + initiator: new fields.SchemaField({ + id: new fields.StringField({ nullable: true, initial: null }), + cost: new fields.NumberField({ integer: true, min: 0, initial: 3 }) + }), + members: new fields.TypedObjectField( + new fields.SchemaField({ + messageId: new fields.StringField({ required: true, nullable: true, initial: null }), + selected: new fields.BooleanField({ required: true, initial: false }) + }) + ) + }; + } +} diff --git a/module/dice/damageRoll.mjs b/module/dice/damageRoll.mjs index 534867f8..c10ee6ff 100644 --- a/module/dice/damageRoll.mjs +++ b/module/dice/damageRoll.mjs @@ -1,4 +1,5 @@ import DamageDialog from '../applications/dialogs/damageDialog.mjs'; +import { RefreshType, socketEvent } from '../systemRegistration/socket.mjs'; import DHRoll from './dhRoll.mjs'; export default class DamageRoll extends DHRoll { @@ -338,5 +339,13 @@ export default class DamageRoll extends DHRoll { parts: damageParts } }); + + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); } } diff --git a/module/dice/dhRoll.mjs b/module/dice/dhRoll.mjs index 3865710a..5b998462 100644 --- a/module/dice/dhRoll.mjs +++ b/module/dice/dhRoll.mjs @@ -29,6 +29,10 @@ export default class DHRoll extends Roll { config.hooks = [...this.getHooks(), '']; config.dialog ??= {}; + const actorIdSplit = config.source.actor.split('.'); + const tagTeamSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); + config.tagTeamSelected = tagTeamSettings.members[actorIdSplit[actorIdSplit.length - 1]]; + for (const hook of config.hooks) { if (Hooks.call(`${CONFIG.DH.id}.preRoll${hook.capitalize()}`, config, message) === false) return null; } @@ -100,6 +104,10 @@ export default class DHRoll extends Roll { if (roll._evaluated) { const message = await cls.create(msgData, { rollMode: config.selectedRollMode }); + if (config.tagTeamSelected) { + game.system.api.applications.dialogs.TagTeamDialog.assignRoll(message.speakerActor, message); + } + if (game.modules.get('dice-so-nice')?.active) { await game.dice3d.waitFor3DAnimationByMessageID(message.id); } @@ -228,7 +236,8 @@ export const registerRollDiceHooks = () => { if ( !config.source?.actor || (game.user.isGM ? !hopeFearAutomation.gm : !hopeFearAutomation.players) || - config.actionType === 'reaction' + config.actionType === 'reaction' || + config.tagTeamSelected ) return; diff --git a/module/documents/chatMessage.mjs b/module/documents/chatMessage.mjs index d7476395..edf2491d 100644 --- a/module/documents/chatMessage.mjs +++ b/module/documents/chatMessage.mjs @@ -1,4 +1,4 @@ -import { emitAsGM, GMUpdateEvent } from '../systemRegistration/socket.mjs'; +import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../systemRegistration/socket.mjs'; export default class DhpChatMessage extends foundry.documents.ChatMessage { targetHook = null; @@ -149,7 +149,15 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { event.stopPropagation(); const config = foundry.utils.deepClone(this.system); config.event = event; - this.system.action?.workflow.get('damage')?.execute(config, this._id, true); + await this.system.action?.workflow.get('damage')?.execute(config, this._id, true); + + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.TagTeamRoll }); + await game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { + refreshType: RefreshType.TagTeamRoll + } + }); } async onApplyDamage(event) { diff --git a/module/helpers/handlebarsHelper.mjs b/module/helpers/handlebarsHelper.mjs index e6c1a2f0..26e86012 100644 --- a/module/helpers/handlebarsHelper.mjs +++ b/module/helpers/handlebarsHelper.mjs @@ -14,7 +14,8 @@ export default class RegisterHandlebarsHelpers { getProperty: foundry.utils.getProperty, setVar: this.setVar, empty: this.empty, - pluralize: this.pluralize + pluralize: this.pluralize, + log: this.log }); } static add(a, b) { @@ -89,4 +90,8 @@ export default class RegisterHandlebarsHelpers { const key = isSingular ? `${baseKey}.single` : `${baseKey}.plural`; return game.i18n.localize(key); } + + static log(test) { + return test; + } } diff --git a/module/systemRegistration/settings.mjs b/module/systemRegistration/settings.mjs index 565a7740..6954730f 100644 --- a/module/systemRegistration/settings.mjs +++ b/module/systemRegistration/settings.mjs @@ -7,6 +7,7 @@ import { DhHomebrewSettings, DhVariantRuleSettings } from '../applications/settings/_module.mjs'; +import { DhTagTeamRoll } from '../data/_module.mjs'; export const registerDHSettings = () => { registerMenuSettings(); @@ -122,4 +123,10 @@ const registerNonConfigSettings = () => { config: false, type: DhCountdowns }); + + game.settings.register(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll, { + scope: 'world', + config: false, + type: DhTagTeamRoll + }); }; diff --git a/module/systemRegistration/socket.mjs b/module/systemRegistration/socket.mjs index 3803c4bc..68392381 100644 --- a/module/systemRegistration/socket.mjs +++ b/module/systemRegistration/socket.mjs @@ -34,7 +34,8 @@ export const GMUpdateEvent = { }; export const RefreshType = { - Countdown: 'DhCoundownRefresh' + Countdown: 'DhCoundownRefresh', + TagTeamRoll: 'DhTagTeamRollRefresh' }; export const registerSocketHooks = () => { diff --git a/styles/less/dialog/dice-roll/roll-selection.less b/styles/less/dialog/dice-roll/roll-selection.less index a0ac42b6..0f082460 100644 --- a/styles/less/dialog/dice-roll/roll-selection.less +++ b/styles/less/dialog/dice-roll/roll-selection.less @@ -11,7 +11,14 @@ .application.daggerheart.dialog.dh-style.views.roll-selection { .dialog-header { display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + gap: 4px; + + .dialog-header-inner { + display: flex; + justify-content: center; + } h1 { width: auto; @@ -37,6 +44,29 @@ } } } + + .tag-team-controller { + display: flex; + align-items: center; + border-radius: 5px; + width: fit-content; + gap: 5px; + cursor: pointer; + padding: 5px; + background: light-dark(@dark-blue-10, @golden-10); + color: light-dark(@dark-blue, @golden); + + .label { + font-style: normal; + font-weight: 400; + font-size: var(--font-size-14); + line-height: 17px; + } + + &.selected { + background: light-dark(@dark-blue-40, @golden-40); + } + } } .roll-dialog-container { diff --git a/styles/less/dialog/index.less b/styles/less/dialog/index.less index c0ba1d11..c405b330 100644 --- a/styles/less/dialog/index.less +++ b/styles/less/dialog/index.less @@ -32,3 +32,5 @@ @import './reroll-dialog/sheet.less'; @import './group-roll/group-roll.less'; + +@import './tag-team-dialog/sheet.less'; diff --git a/styles/less/dialog/tag-team-dialog/sheet.less b/styles/less/dialog/tag-team-dialog/sheet.less new file mode 100644 index 00000000..767c66ca --- /dev/null +++ b/styles/less/dialog/tag-team-dialog/sheet.less @@ -0,0 +1,178 @@ +.daggerheart.dialog.dh-style.views.tag-team-dialog { + .tag-team-container { + display: flex; + flex-direction: column; + gap: 16px; + + .tag-team-data-container { + display: flex; + align-items: center; + gap: 8px; + + .form-group { + flex: 0; + + label { + white-space: nowrap; + } + + &.flex-group { + flex: 1; + } + } + } + + .title-row { + display: flex; + align-items: center; + gap: 8px; + + h2 { + text-align: start; + } + + select { + flex: 1; + } + } + + .participants-container { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; + + .participant-outer-container { + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; + cursor: pointer; + border-radius: 6px; + + &.selected, + &:hover { + background-color: light-dark(@golden-40, @golden-40); + } + + .participant-container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + .participant-inner-container { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + + img { + height: 48px; + width: 48px; + border-radius: 50%; + } + + .participant-labels { + display: flex; + flex-direction: column; + gap: 2px; + + .participant-label-title { + font-size: 18px; + } + + .participant-label-info { + display: flex; + gap: 4px; + + .participant-label-info-part { + border: 1px solid light-dark(white, white); + border-radius: 4px; + padding: 2px 4px; + background-color: light-dark(@beige-80, @soft-white-shadow); + color: white; + } + } + } + } + } + + .participant-empty-roll-container { + border: 1px dashed white; + padding: 8px 2px; + text-align: center; + font-style: italic; + } + + .participant-roll-outer-container { + display: flex; + flex-direction: column; + gap: 2px; + color: light-dark(@dark-blue, @golden); + + h4 { + text-align: center; + color: light-dark(@dark-blue, @golden); + } + + .participant-roll-container { + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + + .participant-roll-text-container { + padding: 0 8px; + white-space: nowrap; + display: flex; + } + } + + .damage-values-container { + display: flex; + justify-content: space-around; + gap: 8px; + + .damage-container { + border: 1px solid light-dark(white, white); + border-radius: 6px; + padding: 0 4px; + display: flex; + gap: 4px; + } + } + } + } + } + + h2 { + text-align: center; + } + + .result-container { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 8px; + + .result-damages-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + + .result-damage-container { + border: 1px solid light-dark(white, white); + border-radius: 6px; + padding: 0 4px; + } + } + } + + .roll-leader-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + } +} diff --git a/styles/less/utils/colors.less b/styles/less/utils/colors.less index 28f0b0c0..543511da 100755 --- a/styles/less/utils/colors.less +++ b/styles/less/utils/colors.less @@ -4,6 +4,7 @@ @golden: #f3c267; @golden-10: #f3c26710; @golden-40: #f3c26740; +@golden-90: #f3c26790; @golden-bg: #f3c2671a; @golden-secondary: #eaaf42; @golden-filter: brightness(0) saturate(100%) invert(89%) sepia(13%) saturate(2008%) hue-rotate(332deg) brightness(99%) diff --git a/system.json b/system.json index d8ad4089..b37c7f1e 100644 --- a/system.json +++ b/system.json @@ -5,7 +5,7 @@ "version": "1.2.0", "compatibility": { "minimum": "13", - "verified": "13.347", + "verified": "13.350", "maximum": "13" }, "authors": [ @@ -269,7 +269,8 @@ "dualityRoll": {}, "adversaryRoll": {}, "damageRoll": {}, - "abilityUse": {} + "abilityUse": {}, + "tagTeam": {} } }, "background": "systems/daggerheart/assets/logos/FoundrybornBackgroundLogo.png", diff --git a/templates/dialogs/dice-roll/header.hbs b/templates/dialogs/dice-roll/header.hbs index f61a86b3..b455462c 100644 --- a/templates/dialogs/dice-roll/header.hbs +++ b/templates/dialogs/dice-roll/header.hbs @@ -1,14 +1,22 @@
-

- {{#if reactionOverride}} - {{localize "DAGGERHEART.CONFIG.ActionType.reaction"}} - {{else}} - {{ifThen rollConfig.headerTitle rollConfig.headerTitle rollConfig.title}} - {{/if}} - {{#if showReaction}} - - {{/if}} -

+
+

+ {{#if reactionOverride}} + {{localize "DAGGERHEART.CONFIG.ActionType.reaction"}} + {{else}} + {{ifThen rollConfig.headerTitle rollConfig.headerTitle rollConfig.title}} + {{/if}} + {{#if showReaction}} + + {{/if}} +

+
+ {{#if (and @root.hasRoll @root.activeTagTeamRoll)}} +
+ + {{localize "Tag Team Roll"}} +
+ {{/if}}
\ No newline at end of file diff --git a/templates/dialogs/tagTeamDialog.hbs b/templates/dialogs/tagTeamDialog.hbs new file mode 100644 index 00000000..575d19e4 --- /dev/null +++ b/templates/dialogs/tagTeamDialog.hbs @@ -0,0 +1,110 @@ +
+
+
+ {{localize "Party Team"}} + +
+ + +
+ +
+ +
+ {{#each members as |member|}} +
+
+
+ +
+
{{member.character.name}}
+
+ {{#if member.character.system.class.value}} +
{{member.character.system.class.value.name}}
+ {{/if}} + {{#if member.system.multiclass.value}} +
{{member.character.system.multiclass.value.name}}
+ {{/if}} +
+
+
+ +
+ {{#if member.roll}} +
+
+

+ + {{member.roll.system.title}} +

+
+
+ +
+ {{member.roll.system.roll.total}} + {{localize "DAGGERHEART.GENERAL.withThing" thing=member.roll.system.roll.result.label}} +
+ +
+ {{#if member.roll.system.hasDamage}} +

{{localize "DAGGERHEART.GENERAL.damage"}}

+
+ {{#if member.damageValues}} + {{#each member.damageValues as |damage|}} +
+
{{damage.name}}
+
{{damage.total}}
+
+ {{/each}} + {{else}} + {{localize "DAGGERHEART.APPLICATIONS.TagTeamSelect.damageNotRolled"}} + {{/if}} +
+ {{/if}} +
+ {{else}} +
+ {{localize "DAGGERHEART.APPLICATIONS.TagTeamSelect.linkMessageHint"}} +
+ {{/if}} +
+ {{/each}} +
+
+ +
+

+ {{localize "Initiating Character"}} + +

+

+ {{localize "Cost"}} + +

+
+ {{#if showResult}} + {{#if selectedData.result}} +
+

Tag Team Roll: {{selectedData.result}}

+ {{#if usesDamage}} +
+ + {{#each selectedData.damageValues as |damage|}} +
+ {{damage.name}} + {{damage.total}} +
+ {{/each}} +
+ {{/if}} +
+ {{/if}} + {{/if}} + + +
+
\ No newline at end of file diff --git a/templates/sheets/actors/party/party-members.hbs b/templates/sheets/actors/party/party-members.hbs index 751ab82e..6b1f949b 100644 --- a/templates/sheets/actors/party/party-members.hbs +++ b/templates/sheets/actors/party/party-members.hbs @@ -5,7 +5,7 @@ >
-