diff --git a/daggerheart.mjs b/daggerheart.mjs index f1d8c67a..f10d4fe7 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -2,6 +2,7 @@ import { SYSTEM } from './module/config/system.mjs'; import * as applications from './module/applications/_module.mjs'; import * as data from './module/data/_module.mjs'; import * as models from './module/data/_module.mjs'; +import * as canvas from './module/canvas/_module.mjs'; import * as documents from './module/documents/_module.mjs'; import * as dice from './module/dice/_module.mjs'; import * as fields from './module/data/fields/_module.mjs'; @@ -19,6 +20,7 @@ import { import { placeables } from './module/canvas/_module.mjs'; import './node_modules/@yaireo/tagify/dist/tagify.css'; import TemplateManager from './module/documents/templateManager.mjs'; +import TokenManager from './module/documents/tokenManager.mjs'; CONFIG.DH = SYSTEM; CONFIG.TextEditor.enrichers.push(...enricherConfig); @@ -51,6 +53,8 @@ CONFIG.ChatMessage.template = 'systems/daggerheart/templates/ui/chat/chat-messag CONFIG.Canvas.rulerClass = placeables.DhRuler; CONFIG.Canvas.layers.templates.layerClass = placeables.DhTemplateLayer; +CONFIG.Canvas.layers.tokens.layerClass = canvas.DhTokenLayer; + CONFIG.MeasuredTemplate.objectClass = placeables.DhMeasuredTemplate; CONFIG.Scene.documentClass = documents.DhScene; @@ -73,6 +77,7 @@ CONFIG.ui.countdowns = applications.ui.DhCountdowns; CONFIG.ux.ContextMenu = applications.ux.DHContextMenu; CONFIG.ux.TooltipManager = documents.DhTooltipManager; CONFIG.ux.TemplateManager = new TemplateManager(); +CONFIG.ux.TokenManager = new TokenManager(); Hooks.once('init', () => { game.system.api = { diff --git a/lang/en.json b/lang/en.json index 80091d1e..af3afc22 100755 --- a/lang/en.json +++ b/lang/en.json @@ -123,12 +123,8 @@ "cost": { "stepTooltip": "+{step} per step" }, - "summon":{ - "addSummonEntry": "Add Summon Entry", - "actorUUID": "Actor to Summon", - "actor": "Actor", - "count": "Count", - "hint": "Add Actor(s) and the quantity to summon under this action." + "summon": { + "dropSummonsHere": "Drop Summons Here" } } }, @@ -610,10 +606,6 @@ "title": "{name} Resource", "rerollDice": "Reroll Dice" }, - "Summon": { - "title": "Summon Tokens", - "hint": "Drag tokens from the list below into the scene to summon them." - }, "TagTeamSelect": { "title": "Tag Team Roll", "leaderTitle": "Initiating Character", diff --git a/module/applications/dialogs/summonDialog.mjs b/module/applications/dialogs/summonDialog.mjs deleted file mode 100644 index 9dc189fd..00000000 --- a/module/applications/dialogs/summonDialog.mjs +++ /dev/null @@ -1,50 +0,0 @@ -const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; - -export default class DHSummonDialog extends HandlebarsApplicationMixin(ApplicationV2) { - constructor(summonData) { - super(summonData); - // Initialize summons and index - this.summons = summonData.summons || []; - } - - static PARTS = { - main: { template: 'systems/daggerheart/templates/dialogs/summon/summonDialog.hbs' } - }; - - - static DEFAULT_OPTIONS= { - tag: 'form', - window: { - title: "DAGGERHEART.APPLICATIONS.Summon.title", - resizable: false - }, - position: { - width: 400, - height: 'auto' - }, - classes: ['daggerheart', 'dialog', 'summon-dialog'], - dragDrop: [{dragSelector: '.summon-token'}], - }; - - async _prepareContext() { - const context = await super._prepareContext(); - context.summons=await Promise.all(this.summons.map(async(entry)=>{ - const actor = await fromUuid(entry.actorUUID); - return { - ...entry, - name: actor?.name || game.i18n.localize("DAGGERHEART.GENERAL.Unknown"), - img: actor?.img || 'icons/svg/mystery-man.svg', - }; - })); - return context; - } - - _onDragStart(event) { - const uuid = event.currentTarget.dataset.uuid; - if(!uuid) return; - const dragData = { type: 'Actor', uuid: uuid }; - event.dataTransfer.effectAllowed = 'all'; - event.dataTransfer.setData('text/plain', JSON.stringify(dragData)); - } - -} \ No newline at end of file diff --git a/module/applications/sheets-configs/action-base-config.mjs b/module/applications/sheets-configs/action-base-config.mjs index 76965275..d30d9c08 100644 --- a/module/applications/sheets-configs/action-base-config.mjs +++ b/module/applications/sheets-configs/action-base-config.mjs @@ -15,7 +15,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) static DEFAULT_OPTIONS = { tag: 'form', - classes: ['daggerheart', 'dh-style', 'dialog', 'max-800'], + classes: ['daggerheart', 'dh-style', 'dialog', 'action-config', 'max-800'], window: { icon: 'fa-solid fa-wrench', resizable: false @@ -37,7 +37,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) submitOnChange: true, closeOnSubmit: false }, - dragDrop: [{ dragSelector: null, dropSelector: '.summon-entry', handlers: ['_onDrop'] }] + dragDrop: [{ dragSelector: null, dropSelector: '#summon-drop-zone', handlers: ['_onDrop'] }] }; static PARTS = { @@ -87,7 +87,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) } }; - static CLEAN_ARRAYS = ['damage.parts', 'cost', 'effects','summon']; + static CLEAN_ARRAYS = ['damage.parts', 'cost', 'effects', 'summon']; _getTabs(tabs) { for (const v of Object.values(tabs)) { @@ -98,23 +98,24 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) return tabs; } + _attachPartListeners(partId, htmlElement, options) { + super._attachPartListeners(partId, htmlElement, options); + + htmlElement.querySelectorAll('.summon-count-wrapper input').forEach(element => { + element.addEventListener('change', this.updateSummonCount.bind(this)); + }); + } + async _prepareContext(_options) { const context = await super._prepareContext(_options, 'action'); context.source = this.action.toObject(true); - // Resolving summon entries so actions can read entry.name / entry.img / entry.uuid - if (Array.isArray(context.source.summon)) { - context.source.summon = await Promise.all(context.source.summon.map(async entry => { - if (!entry) return entry; - const uuid = entry.actorUUID ?? entry.uuid; - entry.uuid = uuid; - try { - const doc = await foundry.utils.fromUuid(uuid); - entry.name = entry.name ?? doc?.name; - entry.img = entry.img ?? (doc?.img ?? doc?.prototypeToken?.texture?.src ?? null); - } catch (_) {} - return entry; - })); + + context.summons = []; + for (const summon of context.source.summon) { + const actor = await foundry.utils.fromUuid(summon.actorUUID); + context.summons.push({ actor, count: summon.count }); } + context.openSection = this.openSection; context.tabs = this._getTabs(this.constructor.TABS); context.config = CONFIG.DH; @@ -197,8 +198,9 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) } static async updateForm(event, _, formData) { - const submitData = this._prepareSubmitData(event, formData), - data = foundry.utils.mergeObject(this.action.toObject(), submitData); + const submitData = this._prepareSubmitData(event, formData); + + const data = foundry.utils.mergeObject(this.action.toObject(), submitData); this.action = await this.action.update(data); this.sheetUpdate?.(this.action); @@ -231,17 +233,10 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); } - static async editDoc(event, button) { - event.stopPropagation(); - const uuid = button?.dataset.itemUuid ?? button?.dataset.uuid; // Obtain uuid from dataset - if (!uuid) return; - //Try catching errors - try { - const doc = await foundry.utils.fromUuid(uuid); - if (doc?.sheet) return doc.sheet.render({ force: true }); - } catch (err) { - console.warn("editDoc action failed for", uuid, err); - } + static async editDoc(_event, target) { + const element = target.closest('[data-item-uuid]'); + const doc = (await foundry.utils.fromUuid(element.dataset.itemUuid)) ?? null; + if (doc) return doc.sheet.render({ force: true }); } static addDamage(_event) { @@ -261,6 +256,15 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); } + updateSummonCount(event) { + event.stopPropagation(); + const wrapper = event.target.closest('.summon-count-wrapper'); + const index = wrapper.dataset.index; + const data = this.action.toObject(); + data.summon[index].count = Number.parseInt(event.target.value); + this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); + } + /** Specific implementation in extending classes **/ static async addEffect(_event) {} static removeEffect(_event, _button) {} @@ -271,28 +275,28 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) await super.close(options); } - /** Implementation for dragdrop for summon actor selection **/ async _onDrop(event) { const data = foundry.applications.ux.TextEditor.getDragEventData(event); - const item=await foundry.utils.fromUuid(data.uuid); + const item = await foundry.utils.fromUuid(data.uuid); if (!(item instanceof game.system.api.documents.DhpActor)) { - ui.notifications.warn(game.i18n.localize("DAGGERHEART.ACTIONS.TYPES.summon.invalidDrop")); + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.summon.invalidDrop')); return; } - //Add to summon array - const actionData = this.action.toObject(); // Get current action data - //checking to see if actor is already in summon list add 1 to count instead of adding new entry + + const actionData = this.action.toObject(); let countvalue = 1; for (const entry of actionData.summon) { if (entry.actorUUID === data.uuid) { entry.count += 1; countvalue = entry.count; - await this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(actionData) }); + await this.constructor.updateForm.bind(this)(null, null, { + object: foundry.utils.flattenObject(actionData) + }); return; } } - actionData.summon.push({ actorUUID: data.uuid, count: countvalue });// Add new summon entry - await this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(actionData) }); // Update the form with new data + + actionData.summon.push({ actorUUID: data.uuid, count: countvalue }); + await this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(actionData) }); } - } diff --git a/module/canvas/_module.mjs b/module/canvas/_module.mjs index 6b8885f4..c211b549 100644 --- a/module/canvas/_module.mjs +++ b/module/canvas/_module.mjs @@ -1 +1,2 @@ export * as placeables from './placeables/_module.mjs'; +export { default as DhTokenLayer } from './tokens.mjs'; diff --git a/module/canvas/tokens.mjs b/module/canvas/tokens.mjs new file mode 100644 index 00000000..9813cd48 --- /dev/null +++ b/module/canvas/tokens.mjs @@ -0,0 +1,16 @@ +export default class DhTokenLayer extends foundry.canvas.layers.TokenLayer { + async _createPreview(createData, options) { + if (options.actor) { + const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; + if (options.actor?.system.metadata.usesSize) { + const tokenSize = tokenSizes[options.actor.system.size]; + if (tokenSize && options.actor.system.size !== CONFIG.DH.ACTOR.tokenSize.custom.id) { + createData.width = tokenSize; + createData.height = tokenSize; + } + } + } + + return super._createPreview(createData, options); + } +} diff --git a/module/data/fields/action/summonField.mjs b/module/data/fields/action/summonField.mjs index 6ba1f8a2..a4df927f 100644 --- a/module/data/fields/action/summonField.mjs +++ b/module/data/fields/action/summonField.mjs @@ -1,5 +1,4 @@ const fields = foundry.data.fields; -import DHSummonDialog from '../../../applications/dialogs/summonDialog.mjs'; export default class DHSummonField extends fields.ArrayField { /** @@ -24,71 +23,47 @@ export default class DHSummonField extends fields.ArrayField { } static async execute() { - if(!canvas.scene){ - ui.notifications.warn(game.i18n.localize("DAGGERHEART.ACTIONS.TYPES.summon.error")); + if (!canvas.scene) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.summon.error')); return; } - const validSummons = this.summon.filter(entry => entry.actorUUID); - if (validSummons.length === 0) { - console.log("No actors configured for this Summon action."); + + if (this.summon.length === 0) { + ui.notifications.warn('No actors configured for this Summon action.'); return; } - - for (const entry of validSummons) { - const actor = await fromUuid(entry.actorUUID); - } - // //Open Summon Dialog - // const summonData = { summons: validSummons }; - // console.log(summonData); - // const dialog = new DHSummonDialog(summonData); - // dialog.render(true); + const summonData = []; + for (const summon of this.summon) { + /* Possibly check for any available instances in the world with prepared images */ + let actor = await foundry.utils.fromUuid(summon.actorUUID); - // Create folder and add tokens to actor folder - const rootFolderName = game.i18n.localize("DAGGERHEART.APPLICATIONS.Summon.title"); - let rootFolder = game.folders.find(f => f.name === rootFolderName && f.type === 'Actor'); - if (!rootFolder) { - rootFolder = await Folder.create({ - name: rootFolderName, - type: 'Actor', - }); - } - const parentName = this.item.name ?? "Unkown Feature"; - const actionName = this.name ?? "Unkown Action"; - const subFolderName = `${parentName} - ${actionName}`; - - let subFolder = game.folders.find(f => f.name === subFolderName && f.type === 'Actor' && f.folder?.id === rootFolder.id); - if (!subFolder) { - subFolder = await Folder.create({ - name: subFolderName, - type: 'Actor', - folder: rootFolder.id - }); - const actorsToSummon = []; - for (const entry of validSummons) { - const sourceActor = await fromUuid(entry.actorUUID); - if (!sourceActor) { - console.warn('DHSummonField: Could not find actor for UUID', entry.actorUUID); - continue; - } - const actorData = sourceActor.toObject(); - delete actorData._id; // Remove _id to create a new Actor - actorData.folder = subFolder.id; - - for (let i = 0; i < entry.count; i++) { - const newActor = foundry.utils.deepClone(actorData); - if (entry.count > 1) { - newActor.name = `${actorData.name} ${i + 1}`; - } - actorsToSummon.push(newActor); - } + /* Check for any available instances of the actor present in the world if we're missing artwork in the compendium */ + const dataType = game.system.api.data.actors[`Dh${actor.type.capitalize()}`]; + if (actor.inCompendium && dataType && actor.img === dataType.DEFAULT_ICON) { + const worldActorCopy = game.actors.find(x => x.name === actor.name); + actor = worldActorCopy ?? actor; } - if (actorsToSummon.length > 0) { - await Actor.createDocuments(actorsToSummon); - ui.notifications.info(`Summoned ${actorsToSummon.length} actors successfully in folder ${subFolder.name}.`); - } - } else { - ui.notifications.info(`Summon actors already exist in folder ${subFolder.name}.`); + + summonData.push({ actor, count: summon.count }); } + + const handleSummon = async summonIndex => { + const summon = summonData[summonIndex]; + const result = await CONFIG.ux.TokenManager.createPreviewAsync(summon.actor, { + name: `${summon.actor.prototypeToken.name}${summon.count > 1 ? ` (${summon.count}x)` : ''}` + }); + if (!result) return; + + summon.count--; + if (summon.count === 0) { + summonIndex++; + if (summonIndex === summonData.length) return; + } + + handleSummon(summonIndex); + }; + + handleSummon(0); } } diff --git a/module/documents/_module.mjs b/module/documents/_module.mjs index 22718bea..8073cfe1 100644 --- a/module/documents/_module.mjs +++ b/module/documents/_module.mjs @@ -8,3 +8,4 @@ export { default as DhScene } from './scene.mjs'; export { default as DhToken } from './token.mjs'; export { default as DhTooltipManager } from './tooltipManager.mjs'; export { default as DhTemplateManager } from './templateManager.mjs'; +export { default as DhTokenManager } from './tokenManager.mjs'; diff --git a/module/documents/tokenManager.mjs b/module/documents/tokenManager.mjs new file mode 100644 index 00000000..06d78273 --- /dev/null +++ b/module/documents/tokenManager.mjs @@ -0,0 +1,103 @@ +/** + * A singleton class that handles preview tokens. + */ + +export default class DhTokenManager { + #activePreview; + #actor; + #resolve; + + /** + * Create a template preview, deactivating any existing ones. + * @param {object} data + */ + async createPreview(actor, tokenData) { + this.#actor = actor; + const token = await canvas.tokens._createPreview( + { + ...actor.prototypeToken, + displayName: 50, + ...tokenData + }, + { renderSheet: false, actor } + ); + + this.#activePreview = { + document: token.document, + object: token, + origin: { x: token.document.x, y: token.document.y } + }; + + this.#activePreview.events = { + contextmenu: this.#cancelTemplate.bind(this), + mousedown: this.#confirmTemplate.bind(this), + mousemove: this.#onDragMouseMove.bind(this) + }; + + canvas.stage.on('mousemove', this.#activePreview.events.mousemove); + canvas.stage.on('mousedown', this.#activePreview.events.mousedown); + canvas.app.view.addEventListener('contextmenu', this.#activePreview.events.contextmenu); + } + + async createPreviewAsync(actor, tokenData) { + return new Promise(resolve => { + this.#resolve = resolve; + this.createPreview(actor, tokenData); + }); + } + + /** + * Handles the movement of the token preview on mousedrag. + * @param {mousemove Event} event + */ + #onDragMouseMove(event) { + event.stopPropagation(); + const { moveTime, object } = this.#activePreview; + const update = {}; + + const now = Date.now(); + if (now - (moveTime || 0) <= 16) return; + this.#activePreview.moveTime = now; + + let cursor = event.getLocalPosition(canvas.templates); + + Object.assign(update, canvas.grid.getTopLeftPoint(cursor)); + + object.document.updateSource(update); + object.renderFlags.set({ refresh: true }); + } + + /** + * Cancels the preview token on right-click. + * @param {contextmenu Event} event + */ + #cancelTemplate(_event, resolved) { + const { mousemove, mousedown, contextmenu } = this.#activePreview.events; + this.#activePreview.object.destroy(); + + canvas.stage.off('mousemove', mousemove); + canvas.stage.off('mousedown', mousedown); + canvas.app.view.removeEventListener('contextmenu', contextmenu); + if (this.#resolve && !resolved) this.#resolve(false); + } + + /** + * Creates a real Actor and token at the preview location and cancels the preview. + * @param {click Event} event + */ + async #confirmTemplate(event) { + event.stopPropagation(); + this.#cancelTemplate(event, true); + + const actor = this.#actor.inCompendium + ? await game.system.api.documents.DhpActor.create(this.#actor.toObject()) + : this.#actor; + const tokenData = await actor.getTokenDocument(); + const token = await canvas.scene.createEmbeddedDocuments('Token', [ + { ...tokenData, x: this.#activePreview.document.x, y: this.#activePreview.document.y } + ]); + + this.#activePreview = undefined; + if (this.#resolve) this.#resolve(token); + } +} diff --git a/styles/less/global/global.less b/styles/less/global/global.less index 8abaddee..6cc63c2a 100644 --- a/styles/less/global/global.less +++ b/styles/less/global/global.less @@ -9,26 +9,6 @@ border-radius: 3px; color: light-dark(@dark-blue-50, @beige-50); font-family: @font-body; - &.summon-actor-drop { - height: fit-content; - min-height: inherit; - display: flex; - justify-content: center; - .actors-list.summon-entry .actor-summon-item .actor-summon-line { - display: flex; - justify-content: flex-end; - align-items: center; - .image{ - max-width: 10%; - } - .h4{ - width: --webkit-fill-available; - } - .controls.effect-control{ - padding:3px; - } - } - } } .daggerheart.dh-style { diff --git a/styles/less/sheets/actions/actions.less b/styles/less/sheets/actions/actions.less new file mode 100644 index 00000000..813e4416 --- /dev/null +++ b/styles/less/sheets/actions/actions.less @@ -0,0 +1,50 @@ +.application.daggerheart.dh-style.action-config { + .actor-summon-items { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + + .actor-summon-line { + display: flex; + align-items: center; + gap: 5px; + border-radius: 3px; + + .actor-summon-name { + flex: 2; + display: flex; + align-items: center; + gap: 5px; + + img { + height: 40px; + } + } + + .actor-summon-controls { + flex: 1; + display: flex; + align-items: center; + gap: 5px; + + .controls { + display: flex; + gap: 5px; + } + } + } + + .summon-dragger { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 40px; + margin-top: 10px; + border: 1px dashed light-dark(@dark-blue-50, @beige-50); + border-radius: 3px; + color: light-dark(@dark-blue-50, @beige-50); + } + } +} diff --git a/styles/less/sheets/index.less b/styles/less/sheets/index.less index 1de1b055..c339e7be 100644 --- a/styles/less/sheets/index.less +++ b/styles/less/sheets/index.less @@ -37,3 +37,5 @@ @import './items/feature.less'; @import './items/heritage.less'; @import './items/item-sheet-shared.less'; + +@import './actions/actions.less'; diff --git a/templates/actionTypes/summon.hbs b/templates/actionTypes/summon.hbs index 276045a8..429977d9 100644 --- a/templates/actionTypes/summon.hbs +++ b/templates/actionTypes/summon.hbs @@ -1,46 +1,50 @@ -