diff --git a/lang/en.json b/lang/en.json index b89f502d28..1d06b7b8c8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -160,17 +160,27 @@ } }, "Category": { + "Combat": "Combat", + "Monster": "Monster", "Standard": "Standard", "Time": "Time", - "Monster": "Monster", "Vehicle": "Vehicle" }, "Type": { + "Encounter": { + "Label": "Start of Encounter" + }, "Legendary": { "Counted": { "one": "{number} legendary action", "other": "{number} legendary actions" } + }, + "TurnEnd": { + "Label": "End of Turn" + }, + "TurnStart": { + "Label": "Start of Turn" } }, "Warning": { @@ -753,7 +763,8 @@ "DND5E.CHATMESSAGE": { "TURN": { - "NoCombat": "Combat has ended!", + "Activities": "Activities", + "NoCombatant": "Combatant no longer exists!", "Recovery": "Recovery" } }, diff --git a/less/v2/apps.less b/less/v2/apps.less index 6a3c22eb18..ab234810c0 100644 --- a/less/v2/apps.less +++ b/less/v2/apps.less @@ -292,6 +292,7 @@ } .pill-lg { + --gold-icon-size: 32px; background: var(--dnd5e-background-card); border: var(--dnd5e-border-gold); border-radius: 5px; @@ -313,11 +314,6 @@ opacity: .25; } - .gold-icon { - width: 32px; - height: 32px; - } - .name { flex: 1; } &::before { @@ -406,6 +402,10 @@ } .gold-icon { + block-size: var(--gold-icon-size); + inline-size: var(--gold-icon-size); + flex: 0 0 var(--gold-icon-size); + border: 2px solid var(--dnd5e-color-gold); box-shadow: 0 0 4px var(--dnd5e-shadow-45); border-radius: 0; diff --git a/less/v2/chat.less b/less/v2/chat.less index 59ebb4b143..7146cf2690 100644 --- a/less/v2/chat.less +++ b/less/v2/chat.less @@ -785,6 +785,16 @@ enchantment-application { display: block; margin-block-start: 1em; } + .activities ul { + margin-block-start: 8px; + + li { + --gold-icon-size: 32px; + gap: 8px; + } + + a.rollable:hover .subtitle { text-shadow: none; } + } .deltas ul { li { display: flex; diff --git a/module/applications/actor/npc-sheet.mjs b/module/applications/actor/npc-sheet.mjs index 3463b70cd4..6454214c2a 100644 --- a/module/applications/actor/npc-sheet.mjs +++ b/module/applications/actor/npc-sheet.mjs @@ -67,8 +67,9 @@ export default class ActorSheet5eNPC extends ActorSheet5e { ctx.canToggle = false; ctx.totalWeight = item.system.totalWeight?.toNearest(0.1); // Item grouping - ctx.group = item.system.properties?.has("trait") ? "passive" - : item.system.activities?.contents[0]?.activation.type || "passive"; + const isPassive = item.system.properties?.has("trait") + || CONFIG.DND5E.activityActivationTypes[item.system.activities?.contents[0]?.activation.type]?.passive; + ctx.group = isPassive ? "passive" : item.system.activities?.contents[0]?.activation.type || "passive"; ctx.ungroup = "feat"; if ( item.type === "weapon" ) ctx.ungroup = "weapon"; if ( ctx.group === "passive" ) ctx.ungroup = "passive"; diff --git a/module/config.mjs b/module/config.mjs index 4301ff0522..6d35d0b7ca 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -933,9 +933,10 @@ preLocalize("abilityActivationTypes"); /** * @typedef {ActivityActivationTypeConfig} - * @property {string} label Localized label for the activation type. - * @property {string} [group] Localized label for the presentational group. - * @property {boolean} [scalar=false] Does this activation type have a numeric value attached? + * @property {string} label Localized label for the activation type. + * @property {string} [group] Localized label for the presentational group. + * @property {boolean} [passive=false] Classify this item as a passive feature on NPC sheets. + * @property {boolean} [scalar=false] Does this activation type have a numeric value attached? */ /** @@ -970,6 +971,21 @@ DND5E.activityActivationTypes = { group: "DND5E.ACTIVATION.Category.Time", scalar: true }, + encounter: { + label: "DND5E.ACTIVATION.Type.Encounter.Label", + group: "DND5E.ACTIVATION.Category.Combat", + passive: true + }, + turnStart: { + label: "DND5E.ACTIVATION.Type.TurnStart.Label", + group: "DND5E.ACTIVATION.Category.Combat", + passive: true + }, + turnEnd: { + label: "DND5E.ACTIVATION.Type.TurnEnd.Label", + group: "DND5E.ACTIVATION.Category.Combat", + passive: true + }, legendary: { label: "DND5E.LegendaryAction.Label", group: "DND5E.ACTIVATION.Category.Monster", diff --git a/module/data/abstract.mjs b/module/data/abstract.mjs index f8c2e71ec5..654f2a5917 100644 --- a/module/data/abstract.mjs +++ b/module/data/abstract.mjs @@ -396,7 +396,7 @@ export class ActorDataModel extends SystemDataModel { /** * Reset combat-related uses. - * @param {Set} periods Which recovery periods should be considered. + * @param {string[]} periods Which recovery periods should be considered. * @param {{ actor: {}, item: [] }} updates Updates to perform on the actor and containing items. */ async recoverCombatUses(periods, updates) {} diff --git a/module/data/actor/npc.mjs b/module/data/actor/npc.mjs index 9a63e33bc4..be64b7f455 100644 --- a/module/data/actor/npc.mjs +++ b/module/data/actor/npc.mjs @@ -424,7 +424,7 @@ export default class NPCData extends CreatureTemplate { /** @override */ async recoverCombatUses(periods, updates) { // Reset legendary actions at the start of a combat encounter or at the end of the creature's turn - if ( this.resources.legact.max && (periods.has("encounter") || periods.has("turnEnd")) ) { + if ( this.resources.legact.max && (periods.includes("encounter") || periods.includes("turnEnd")) ) { updates.actor["system.resources.legact.value"] = this.resources.legact.max; } } diff --git a/module/data/chat-message/turn-message-data.mjs b/module/data/chat-message/turn-message-data.mjs index df1884b25d..6ace58f290 100644 --- a/module/data/chat-message/turn-message-data.mjs +++ b/module/data/chat-message/turn-message-data.mjs @@ -11,6 +11,7 @@ const { DocumentIdField, SchemaField, SetField, StringField } = foundry.data.fie /** * Data stored in a combat turn chat message. * + * @property {Set} activations Activities that can be used with these periods, stored as relative UUIDs. * @property {ActorDeltasData} deltas Actor/item recovery from this turn change. * @property {object} origin * @property {string} origin.combat ID of the triggering combat. @@ -26,6 +27,7 @@ export default class TurnMessageData extends ChatMessageDataModel { /** @override */ static defineSchema() { return { + activations: new SetField(new StringField()), deltas: new ActorDeltasField(), origin: new SchemaField({ combat: new DocumentIdField({ nullable: false, required: true }), @@ -39,6 +41,9 @@ export default class TurnMessageData extends ChatMessageDataModel { /** @inheritDoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { + actions: { + use: TurnMessageData.#useActivity + }, template: "systems/dnd5e/templates/chat/turn-card.hbs" }, { inplace: false })); @@ -51,7 +56,7 @@ export default class TurnMessageData extends ChatMessageDataModel { * @type {Actor5e} */ get actor() { - return this.combatant?.actor; + return this.combatant?.actor ?? this.parent.getAssociatedActor(); } /* -------------------------------------------- */ @@ -87,6 +92,11 @@ export default class TurnMessageData extends ChatMessageDataModel { }; if ( !context.actor ) return context; + if ( context.actor.isOwner ) context.activities = Array.from(this.activations) + .map(uuid => fromUuidSync(uuid, { relative: context.actor, strict: false })) + .filter(_ => _) + .sort((lhs, rhs) => (lhs.item.sort - rhs.item.sort) || (lhs.sort - rhs.sort)); + const processDelta = (doc, delta) => { const type = doc instanceof Actor ? "actor" : "item"; return { @@ -106,4 +116,55 @@ export default class TurnMessageData extends ChatMessageDataModel { ]; return context; } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @override */ + _onRender(element) { + for ( const e of element.querySelectorAll(".item-tooltip") ) { + const uuid = e.closest("[data-item-uuid]")?.dataset.itemUuid; + if ( !uuid ) continue; + Object.assign(e.dataset, { + tooltip: `
`, + tooltipClass: "dnd5e2 dnd5e-tooltip item-tooltip", + tooltipDirection: "LEFT" + }); + } + } + + /* -------------------------------------------- */ + + /** + * Handle using an activity. + * @this {TurnMessageData} + * @param {Event} event Triggering click event. + * @param {HTMLElement} target Button that was clicked. + */ + static async #useActivity(event, target) { + target.disabled = true; + try { + const activity = await fromUuid(target.closest("[data-activity-uuid]")?.dataset.activityUuid); + await activity?.use({ event }); + } finally { + target.disabled = false; + } + } + + /* -------------------------------------------- */ + /* Helpers */ + /* -------------------------------------------- */ + + /** + * Find any activity relative UUIDs on this actor that can be used during a set of combat periods. + * @param {Actor5e} actor + * @param {string[]} periods + * @returns {Set} + */ + static getActivations(actor, periods) { + return actor.items + .map(i => i.system.activities?.filter(a => periods.includes(a.activation?.type)).map(a => a.relativeUUID) ?? []) + .flat(); + } } diff --git a/module/documents/combat.mjs b/module/documents/combat.mjs index 70fb305601..00ec00cf2f 100644 --- a/module/documents/combat.mjs +++ b/module/documents/combat.mjs @@ -7,41 +7,44 @@ export default class Combat5e extends Combat { async startCombat() { await super.startCombat(); this._recoverUses({ encounter: true }); - this.combatant?.refreshDynamicRing(); return this; } + /* -------------------------------------------- */ + /* Socket Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ - async nextTurn() { - const previous = this.combatant; - await super.nextTurn(); - this._recoverUses({ turnEnd: previous, turnStart: this.combatant }); - if ( previous && (previous !== this.combatant) ) previous.refreshDynamicRing(); - this.combatant?.refreshDynamicRing(); - return this; + _onUpdate(changed, options, userId) { + super._onUpdate(changed, options, userId); + if ( this.current.combatantId !== this.previous.combatantId ) { + this.combatants.get(this.previous.combatantId)?.refreshDynamicRing(); + this.combatants.get(this.current.combatantId)?.refreshDynamicRing(); + } } /* -------------------------------------------- */ /** @inheritDoc */ - async previousTurn() { - const previous = this.combatant; - await super.previousTurn(); - if ( previous && (previous !== this.combatant) ) previous.refreshDynamicRing(); - this.combatant?.refreshDynamicRing(); - return this; + _onDelete(options, userId) { + super._onDelete(options, userId); + this.combatants.get(this.current.combatantId)?.refreshDynamicRing(); } /* -------------------------------------------- */ /** @inheritDoc */ - async endCombat() { - const previous = this.combatant; - await super.endCombat(); - previous?.refreshDynamicRing(); - return this; + async _onEndTurn(combatant) { + await super._onEndTurn(combatant); + this._recoverUses({ turnEnd: combatant }); + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _onStartTurn(combatant) { + await super._onStartTurn(combatant); + this._recoverUses({ turnStart: combatant }); } /* -------------------------------------------- */ @@ -57,7 +60,7 @@ export default class Combat5e extends Combat { async _recoverUses(types) { for ( const combatant of this.combatants ) { const periods = Object.entries(types).filter(([, v]) => (v === true) || (v === combatant)).map(([k]) => k); - if ( periods.length ) await combatant.recoverCombatUses(new Set(periods)); + if ( periods.length ) await combatant.recoverCombatUses(periods); } } } diff --git a/module/documents/combatant.mjs b/module/documents/combatant.mjs index 42af652db8..6e118a4932 100644 --- a/module/documents/combatant.mjs +++ b/module/documents/combatant.mjs @@ -1,3 +1,4 @@ +import TurnMessageData from "../data/chat-message/turn-message-data.mjs"; import { ActorDeltasField } from "../data/chat-message/fields/deltas-field.mjs"; /** @@ -22,6 +23,7 @@ export default class Combatant5e extends Combatant { speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.token }), system: { deltas, periods, + activations: TurnMessageData.getActivations(this.actor, periods), origin: { combat: this.combat.id, combatant: this.id @@ -33,9 +35,8 @@ export default class Combatant5e extends Combatant { }; if ( !foundry.utils.isEmpty(messageConfig.data.system.deltas?.actor) - || !foundry.utils.isEmpty(messageConfig.data.system.deltas?.item) ) messageConfig.create = true; - // TODO: Also create message if actor has items with relevant activation type - // when implementing https://github.com/foundryvtt/dnd5e/issues/4861 + || !foundry.utils.isEmpty(messageConfig.data.system.deltas?.item) + || !foundry.utils.isEmpty(messageConfig.data.system.activations) ) messageConfig.create = true; /** * A hook event that fires before a combat state change chat message is created. @@ -63,7 +64,7 @@ export default class Combatant5e extends Combatant { /** * Reset combat-related uses. - * @param {Set} periods Which recovery periods should be considered. + * @param {string[]} periods Which recovery periods should be considered. */ async recoverCombatUses(periods) { /** @@ -71,7 +72,7 @@ export default class Combatant5e extends Combatant { * @function dnd5e.preCombatRecovery * @memberof hookEvents * @param {Combatant5e} combatant Combatant that is being recovered. - * @param {Set} periods Periods to be recovered. + * @param {string[]} periods Periods to be recovered. * @returns {boolean} Explicitly return `false` to prevent recovery from being performed. */ if ( Hooks.call("dnd5e.preCombatRecovery", this, periods) === false ) return; @@ -88,7 +89,7 @@ export default class Combatant5e extends Combatant { * @function dnd5e.combatRecovery * @memberof hookEvents * @param {Combatant5e} combatant Combatant that is being recovered. - * @param {Set} periods Periods that were recovered. + * @param {string[]} periods Periods that were recovered. * @param {{ actor: object, item: object[] }} updates Update that will be applied to the actor and its items. * @returns {boolean} Explicitly return `false` to prevent updates from being performed. */ @@ -99,14 +100,14 @@ export default class Combatant5e extends Combatant { if ( !foundry.utils.isEmpty(updates.actor) ) await this.actor.update(updates.actor); if ( updates.item.length ) await this.actor.updateEmbeddedDocuments("Item", updates.item); - const message = await this.createTurnMessage({ deltas, periods: Array.from(periods) }); + const message = await this.createTurnMessage({ deltas, periods }); /** * A hook event that fires after combat-related recovery changes have been applied. * @function dnd5e.postCombatRecovery * @memberof hookEvents * @param {Combatant5e} combatant Combatant that is being recovered. - * @param {Set} periods Periods that were recovered. + * @param {string[]} periods Periods that were recovered. * @param {ChatMessage5e|void} message Chat message created, if any. */ Hooks.callAll("dnd5e.postCombatRecovery", this, periods, message); diff --git a/templates/chat/turn-card.hbs b/templates/chat/turn-card.hbs index 4e93972d42..06ec4ae6bb 100644 --- a/templates/chat/turn-card.hbs +++ b/templates/chat/turn-card.hbs @@ -18,9 +18,26 @@ {{/if}} {{!-- Actions --}} - + {{#if activities.length}} +
+ {{ localize "DND5E.CHATMESSAGE.TURN.Activities" }} + +
+ {{/if}} {{else}} -

{{ localize "DND5E.CHATMESSAGE.TURN.NoCombat" }}

+

{{ localize "DND5E.CHATMESSAGE.TURN.NoCombatant" }}

{{/if}}