diff --git a/lang/en.json b/lang/en.json index 2e938914..5497d5e6 100755 --- a/lang/en.json +++ b/lang/en.json @@ -389,6 +389,7 @@ "default": "Default Ownership" } }, + "CONFIG": { "ActionType": { "passive": "Passive", @@ -958,6 +959,10 @@ "stress": { "name": "Stress" } + }, + "Attachments": { + "attachHint": "Drop items here to attach them", + "transferHint": "If checked, this effect will be applied to any actor that owns this Effect's parent Item. The effect is always applied if this Item is attached to another one." } }, "GENERAL": { @@ -1093,7 +1098,8 @@ "optional": "Optional", "recovery": "Recovery", "setup": "Setup", - "equipment": "Equipment" + "equipment": "Equipment", + "attachments": "Attachments" }, "Tiers": { "singular": "Tier", diff --git a/module/applications/sheets/actors/character.mjs b/module/applications/sheets/actors/character.mjs index ae7ffbf9..5f0199f3 100644 --- a/module/applications/sheets/actors/character.mjs +++ b/module/applications/sheets/actors/character.mjs @@ -30,7 +30,12 @@ export default class CharacterSheet extends DHBaseActorSheet { window: { resizable: true }, - dragDrop: [], + dragDrop: [ + { + dragSelector: '[data-item-id][draggable="true"]', + dropSelector: null + } + ], contextMenus: [ { handler: CharacterSheet._getContextMenuOptions, @@ -665,11 +670,24 @@ export default class CharacterSheet extends DHBaseActorSheet { } } - async _onDragStart(_, event) { + async _onDragStart(event) { + const item = this.getItem(event); + + const dragData = { + type: item.documentName, + uuid: item.uuid + }; + + event.dataTransfer.setData('text/plain', JSON.stringify(dragData)); + super._onDragStart(event); } async _onDrop(event) { + // Prevent event bubbling to avoid duplicate handling + event.preventDefault(); + event.stopPropagation(); + super._onDrop(event); this._onDropItem(event, TextEditor.getDragEventData(event)); } diff --git a/module/applications/sheets/api/_modules.mjs b/module/applications/sheets/api/_modules.mjs index cb7eee62..e6caece1 100644 --- a/module/applications/sheets/api/_modules.mjs +++ b/module/applications/sheets/api/_modules.mjs @@ -1,5 +1,6 @@ export { default as DHApplicationMixin } from './application-mixin.mjs'; export { default as DHBaseItemSheet } from './base-item.mjs'; export { default as DHHeritageSheet } from './heritage-sheet.mjs'; +export { default as DHItemAttachmentSheet } from './item-attachment-sheet.mjs'; export { default as DHBaseActorSheet } from './base-actor.mjs'; export { default as DHBaseActorSettings } from './actor-setting.mjs'; diff --git a/module/applications/sheets/api/item-attachment-sheet.mjs b/module/applications/sheets/api/item-attachment-sheet.mjs new file mode 100644 index 00000000..e89c7cba --- /dev/null +++ b/module/applications/sheets/api/item-attachment-sheet.mjs @@ -0,0 +1,90 @@ + +export default function ItemAttachmentSheet(Base) { + return class extends Base { + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + dragDrop: [ + ...(super.DEFAULT_OPTIONS.dragDrop || []), + { dragSelector: null, dropSelector: '.attachments-section' } + ], + actions: { + ...super.DEFAULT_OPTIONS.actions, + removeAttachment: this.#removeAttachment + } + }; + + static PARTS = { + ...super.PARTS, + attachments: { + template: 'systems/daggerheart/templates/sheets/global/tabs/tab-attachments.hbs', + scrollable: ['.attachments'] + } + }; + + static TABS = { + ...super.TABS, + primary: { + ...super.TABS?.primary, + tabs: [ + ...(super.TABS?.primary?.tabs || []), + { id: 'attachments' } + ], + initial: super.TABS?.primary?.initial || 'description', + labelPrefix: super.TABS?.primary?.labelPrefix || 'DAGGERHEART.GENERAL.Tabs' + } + }; + + async _preparePartContext(partId, context) { + await super._preparePartContext(partId, context); + + if (partId === 'attachments') { + context.attachedItems = await prepareAttachmentContext(this.document); + } + + return context; + } + + async _onDrop(event) { + const data = TextEditor.getDragEventData(event); + + const attachmentsSection = event.target.closest('.attachments-section'); + if (!attachmentsSection) return super._onDrop(event); + + event.preventDefault(); + event.stopPropagation(); + + const item = await Item.implementation.fromDropData(data); + if (!item) return; + + // Call the data model's public method + await this.document.system.addAttachment(item); + } + + + static async #removeAttachment(event, target) { + // Call the data model's public method + await this.document.system.removeAttachment(target.dataset.uuid); +} + + async _preparePartContext(partId, context) { + await super._preparePartContext(partId, context); + + if (partId === 'attachments') { + // Keep this simple UI preparation in the mixin + const attachedUUIDs = this.document.system.attached; + context.attachedItems = await Promise.all( + attachedUUIDs.map(async uuid => { + const item = await fromUuid(uuid); + return { + uuid: uuid, + name: item?.name || 'Unknown Item', + img: item?.img || 'icons/svg/item-bag.svg' + }; + }) + ); + } + + return context; + } + }; +} \ No newline at end of file diff --git a/module/applications/sheets/items/armor.mjs b/module/applications/sheets/items/armor.mjs index 21fbfea8..a6413cf0 100644 --- a/module/applications/sheets/items/armor.mjs +++ b/module/applications/sheets/items/armor.mjs @@ -1,10 +1,10 @@ import DHBaseItemSheet from '../api/base-item.mjs'; +import ItemAttachmentSheet from '../api/item-attachment-sheet.mjs'; -export default class ArmorSheet extends DHBaseItemSheet { +export default class ArmorSheet extends ItemAttachmentSheet(DHBaseItemSheet) { /**@inheritdoc */ static DEFAULT_OPTIONS = { classes: ['armor'], - dragDrop: [{ dragSelector: null, dropSelector: null }], tagifyConfigs: [ { selector: '.features-input', @@ -26,7 +26,8 @@ export default class ArmorSheet extends DHBaseItemSheet { settings: { template: 'systems/daggerheart/templates/sheets/items/armor/settings.hbs', scrollable: ['.settings'] - } + }, + ...super.PARTS, }; /**@inheritdoc */ diff --git a/module/applications/sheets/items/weapon.mjs b/module/applications/sheets/items/weapon.mjs index 77396998..e89c3dce 100644 --- a/module/applications/sheets/items/weapon.mjs +++ b/module/applications/sheets/items/weapon.mjs @@ -1,6 +1,7 @@ import DHBaseItemSheet from '../api/base-item.mjs'; +import ItemAttachmentSheet from '../api/item-attachment-sheet.mjs'; -export default class WeaponSheet extends DHBaseItemSheet { +export default class WeaponSheet extends ItemAttachmentSheet(DHBaseItemSheet) { /**@inheritdoc */ static DEFAULT_OPTIONS = { classes: ['weapon'], @@ -25,12 +26,13 @@ export default class WeaponSheet extends DHBaseItemSheet { settings: { template: 'systems/daggerheart/templates/sheets/items/weapon/settings.hbs', scrollable: ['.settings'] - } + }, + ...super.PARTS, }; /**@inheritdoc */ async _preparePartContext(partId, context) { - super._preparePartContext(partId, context); + await super._preparePartContext(partId, context); switch (partId) { case 'settings': context.features = this.document.system.weaponFeatures.map(x => x.value); diff --git a/module/config/flagsConfig.mjs b/module/config/flagsConfig.mjs index 252863f1..0c112231 100644 --- a/module/config/flagsConfig.mjs +++ b/module/config/flagsConfig.mjs @@ -7,3 +7,5 @@ export const encounterCountdown = { simple: 'countdown-encounter-simple', position: 'countdown-encounter-position' }; + +export const itemAttachmentSource = 'attachmentSource'; \ No newline at end of file diff --git a/module/data/item/_module.mjs b/module/data/item/_module.mjs index a29d1595..bed18eb5 100644 --- a/module/data/item/_module.mjs +++ b/module/data/item/_module.mjs @@ -1,5 +1,6 @@ import DHAncestry from './ancestry.mjs'; import DHArmor from './armor.mjs'; +import DHAttachableItem from './attachableItem.mjs'; import DHClass from './class.mjs'; import DHCommunity from './community.mjs'; import DHConsumable from './consumable.mjs'; @@ -13,6 +14,7 @@ import DHBeastform from './beastform.mjs'; export { DHAncestry, DHArmor, + DHAttachableItem, DHClass, DHCommunity, DHConsumable, @@ -27,6 +29,7 @@ export { export const config = { ancestry: DHAncestry, armor: DHArmor, + attachableItem: DHAttachableItem, class: DHClass, community: DHCommunity, consumable: DHConsumable, diff --git a/module/data/item/armor.mjs b/module/data/item/armor.mjs index bf2bf73e..7a8b06c0 100644 --- a/module/data/item/armor.mjs +++ b/module/data/item/armor.mjs @@ -1,8 +1,9 @@ -import BaseDataItem from './base.mjs'; +import AttachableItem from './attachableItem.mjs'; import ActionField from '../fields/actionField.mjs'; import { armorFeatures } from '../../config/itemConfig.mjs'; +import { actionsTypes } from '../action/_module.mjs'; -export default class DHArmor extends BaseDataItem { +export default class DHArmor extends AttachableItem { /** @inheritDoc */ static get metadata() { return foundry.utils.mergeObject(super.metadata, { diff --git a/module/data/item/attachableItem.mjs b/module/data/item/attachableItem.mjs new file mode 100644 index 00000000..2b0608eb --- /dev/null +++ b/module/data/item/attachableItem.mjs @@ -0,0 +1,152 @@ +import BaseDataItem from './base.mjs'; + +export default class AttachableItem extends BaseDataItem { + static defineSchema() { + const fields = foundry.data.fields; + return { + ...super.defineSchema(), + attached: new fields.ArrayField(new fields.DocumentUUIDField({ type: "Item", nullable: true })) + }; + } + + async _preUpdate(changes, options, user) { + const allowed = await super._preUpdate(changes, options, user); + if (allowed === false) return false; + + // Handle equipped status changes for attachment effects + if (changes.system?.equipped !== undefined && changes.system.equipped !== this.equipped) { + await this.#handleAttachmentEffectsOnEquipChange(changes.system.equipped); + } + } + + async #handleAttachmentEffectsOnEquipChange(newEquippedStatus) { + const actor = this.parent.parent?.type === 'character' ? this.parent.parent : this.parent.parent?.parent; + const parentType = this.parent.type; + + if (!actor || !this.attached?.length) { + return; + } + + if (newEquippedStatus) { + // Item is being equipped - add attachment effects + for (const attachedUuid of this.attached) { + const attachedItem = await fromUuid(attachedUuid); + if (attachedItem && attachedItem.effects.size > 0) { + await this.#copyAttachmentEffectsToActor({ + attachedItem, + attachedUuid, + parentType + }); + } + } + } else { + // Item is being unequipped - remove attachment effects + await this.#removeAllAttachmentEffects(parentType); + } + } + + async #copyAttachmentEffectsToActor({ attachedItem, attachedUuid, parentType }) { + const actor = this.parent.parent; + if (!actor || !attachedItem.effects.size > 0 || !this.equipped) { + return []; + } + + const effectsToCreate = []; + for (const effect of attachedItem.effects) { + const effectData = effect.toObject(); + effectData.origin = `${this.parent.uuid}:${attachedUuid}`; + + const attachmentSource = { + itemUuid: attachedUuid, + originalEffectId: effect.id + }; + attachmentSource[`${parentType}Uuid`] = this.parent.uuid; + + effectData.flags = { + ...effectData.flags, + [CONFIG.DH.id]: { + ...effectData.flags?.[CONFIG.DH.id], + [CONFIG.DH.FLAGS.itemAttachmentSource]: attachmentSource + } + }; + effectsToCreate.push(effectData); + } + + if (effectsToCreate.length > 0) { + return await actor.createEmbeddedDocuments('ActiveEffect', effectsToCreate); + } + + return []; + } + + async #removeAllAttachmentEffects(parentType) { + const actor = this.parent.parent; + if (!actor) return; + + const parentUuidProperty = `${parentType}Uuid`; + const effectsToRemove = actor.effects.filter(effect => { + const attachmentSource = effect.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.itemAttachmentSource); + return attachmentSource && attachmentSource[parentUuidProperty] === this.parent.uuid; + }); + + if (effectsToRemove.length > 0) { + await actor.deleteEmbeddedDocuments('ActiveEffect', effectsToRemove.map(e => e.id)); + } + } + + /** + * Public method for adding an attachment + */ + async addAttachment(droppedItem) { + const newUUID = droppedItem.uuid; + + if (this.attached.includes(newUUID)) { + ui.notifications.warn(`${droppedItem.name} is already attached to this ${this.parent.type}.`); + return; + } + + const updatedAttached = [...this.attached, newUUID]; + await this.parent.update({ + 'system.attached': updatedAttached + }); + + // Copy effects if equipped + if (this.equipped && droppedItem.effects.size > 0) { + await this.#copyAttachmentEffectsToActor({ + attachedItem: droppedItem, + attachedUuid: newUUID, + parentType: this.parent.type + }); + } + } + + /** + * Public method for removing an attachment + */ + async removeAttachment(attachedUuid) { + await this.parent.update({ + 'system.attached': this.attached.filter(uuid => uuid !== attachedUuid) + }); + + // Remove effects + await this.#removeAttachmentEffects(attachedUuid); + } + + async #removeAttachmentEffects(attachedUuid) { + const actor = this.parent.parent; + if (!actor) return; + + const parentType = this.parent.type; + const parentUuidProperty = `${parentType}Uuid`; + const effectsToRemove = actor.effects.filter(effect => { + const attachmentSource = effect.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.itemAttachmentSource); + return attachmentSource && + attachmentSource[parentUuidProperty] === this.parent.uuid && + attachmentSource.itemUuid === attachedUuid; + }); + + if (effectsToRemove.length > 0) { + await actor.deleteEmbeddedDocuments('ActiveEffect', effectsToRemove.map(e => e.id)); + } + } +} \ No newline at end of file diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 8f580b6d..82b4ba80 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -1,8 +1,8 @@ -import BaseDataItem from './base.mjs'; +import AttachableItem from './attachableItem.mjs'; import { actionsTypes } from '../action/_module.mjs'; import ActionField from '../fields/actionField.mjs'; -export default class DHWeapon extends BaseDataItem { +export default class DHWeapon extends AttachableItem { /** @inheritDoc */ static get metadata() { return foundry.utils.mergeObject(super.metadata, { @@ -37,7 +37,7 @@ export default class DHWeapon extends BaseDataItem { actionIds: new fields.ArrayField(new fields.StringField({ required: true })) }) ), - attack: new ActionField({ + attack: new ActionField({ initial: { name: 'Attack', img: 'icons/skills/melee/blood-slash-foam-red.webp', diff --git a/module/documents/activeEffect.mjs b/module/documents/activeEffect.mjs index 1abc2d17..03ac73bc 100644 --- a/module/documents/activeEffect.mjs +++ b/module/documents/activeEffect.mjs @@ -1,16 +1,42 @@ export default class DhActiveEffect extends ActiveEffect { get isSuppressed() { - if (['weapon', 'armor'].includes(this.parent.type)) { + // If this is a copied effect from an attachment, never suppress it + // (These effects have attachmentSource metadata) + if (this.flags?.daggerheart?.attachmentSource) { + return false; + } + + // Then apply the standard suppression rules + if (['weapon', 'armor'].includes(this.parent?.type)) { return !this.parent.system.equipped; } - if (this.parent.type === 'domainCard') { + if (this.parent?.type === 'domainCard') { return this.parent.system.inVault; } return super.isSuppressed; } + /** + * Check if the parent item is currently attached to another item + * @returns {boolean} + */ + get isAttached() { + if (!this.parent || !this.parent.parent) return false; + + // Check if this item's UUID is in any actor's armor or weapon attachment lists + const actor = this.parent.parent; + if (!actor || !actor.items) return false; + + return actor.items.some(item => { + return (item.type === 'armor' || item.type === 'weapon') && + item.system?.attached && + Array.isArray(item.system.attached) && + item.system.attached.includes(this.parent.uuid); + }); + } + async _preCreate(data, options, user) { const update = {}; if (!data.img) { diff --git a/styles/less/global/index.less b/styles/less/global/index.less index 0559c7ff..932e48ab 100644 --- a/styles/less/global/index.less +++ b/styles/less/global/index.less @@ -12,3 +12,4 @@ @import './inventory-fieldset-items.less'; @import './prose-mirror.less'; @import './filter-menu.less'; +@import './tab-attachments.less'; diff --git a/styles/less/global/tab-attachments.less b/styles/less/global/tab-attachments.less new file mode 100644 index 00000000..c283269e --- /dev/null +++ b/styles/less/global/tab-attachments.less @@ -0,0 +1,7 @@ +.daggerheart.dh-style { + .tab.attachments { + .attached-items { + width: 100%; + } + } +} \ No newline at end of file diff --git a/templates/sheets/activeEffect/details.hbs b/templates/sheets/activeEffect/details.hbs index 8ff72b98..8a862c53 100644 --- a/templates/sheets/activeEffect/details.hbs +++ b/templates/sheets/activeEffect/details.hbs @@ -7,7 +7,7 @@ {{formGroup fields.origin value=source.origin rootId=rootId disabled=true}} {{/if}} {{#if isItemEffect}} - {{formGroup fields.transfer value=source.transfer rootId=rootId label=legacyTransfer.label hint=legacyTransfer.hint}} + {{formGroup fields.transfer value=source.transfer rootId=rootId label=legacyTransfer.label hint=(localize "DAGGERHEART.EFFECTS.Attachments.transferHint")}} {{/if}} {{formGroup fields.statuses value=source.statuses options=statuses rootId=rootId classes="statuses"}} diff --git a/templates/sheets/global/partials/inventory-item.hbs b/templates/sheets/global/partials/inventory-item.hbs index 117783a5..21b7de61 100644 --- a/templates/sheets/global/partials/inventory-item.hbs +++ b/templates/sheets/global/partials/inventory-item.hbs @@ -1,4 +1,4 @@ -
  • +
  • {{#if isCompanion}} diff --git a/templates/sheets/global/tabs/tab-attachments.hbs b/templates/sheets/global/tabs/tab-attachments.hbs new file mode 100644 index 00000000..f7f8b716 --- /dev/null +++ b/templates/sheets/global/tabs/tab-attachments.hbs @@ -0,0 +1,29 @@ +
    +
    + {{localize tabs.attachments.label}} + + {{#if attachedItems}} +
    + {{#each attachedItems as |item|}} +
    + {{item.name}} +
    +
    {{item.name}}
    +
    +
    + +
    +
    + {{/each}} +
    + {{/if}} + +
    + {{localize "DAGGERHEART.EFFECTS.Attachments.attachHint"}} +
    +
    +
    diff --git a/templates/sheets/items/weapon/attachments.hbs b/templates/sheets/items/weapon/attachments.hbs new file mode 100644 index 00000000..e69de29b