From a42d708f15cbb5c1a688c06f47ed8c545b903afa Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:34:22 +0100 Subject: [PATCH 01/11] [Feature] Phase Transform (#1710) * Added transform action to handle phased adversaries * Added support for keeping currently marked hitPoints/stress when transforming * Minor fixes * Compendium update * Added consideration for an active combatant --- lang/en.json | 15 +++ .../sheets-configs/action-base-config.mjs | 51 ++++++++- module/config/actionConfig.mjs | 6 ++ module/data/action/_module.mjs | 4 +- module/data/action/baseAction.mjs | 6 +- module/data/action/transformAction.mjs | 5 + module/data/fields/action/_module.mjs | 1 + module/data/fields/action/transformField.mjs | 101 ++++++++++++++++++ module/systemRegistration/handlebars.mjs | 1 + ...rlord__Realm_Breaker_hxZ0sgoFJubh5aj6.json | 30 +++++- ...agon__Molten_Scourge_eArAPuB38CNR0ZIM.json | 32 +++++- ...n__Obsidian_Predator_ladm7wykhZczYzrQ.json | 32 +++++- styles/less/sheets/actions/actions.less | 92 ++++++++++------ templates/actionTypes/summon.hbs | 10 +- templates/actionTypes/transform.hbs | 62 +++++++++++ .../action-settings/effect.hbs | 1 + 16 files changed, 406 insertions(+), 43 deletions(-) create mode 100644 module/data/action/transformAction.mjs create mode 100644 module/data/fields/action/transformField.mjs create mode 100644 templates/actionTypes/transform.hbs diff --git a/lang/en.json b/lang/en.json index c9d21944..4e786e28 100755 --- a/lang/en.json +++ b/lang/en.json @@ -74,6 +74,15 @@ "invalidDrop": "You can only drop Actor entities to summon.", "chatMessageTitle": "Test2", "chatMessageHeaderTitle": "Summoning" + }, + "transform": { + "name": "Transform", + "tooltip": "Transform one actor into another", + "noTransformActor": "There is no assigned actor to transform into", + "transformActorMissing": "The assigned actor to transform into does not exist. It was probably deleted or moved in/out of a compendium", + "canvasError": "There is no active scene.", + "prototypeError": "You can only use a transform action from a Token", + "actorLinkError": "You cannot transform a token with Actor Link set to true" } }, "Config": { @@ -129,6 +138,12 @@ }, "summon": { "dropSummonsHere": "Drop Summons Here" + }, + "transform": { + "dropTransformHere": "Drop Transform Here", + "actorIsMissing": "The linked actor is missing. You should delete this link.", + "clearHitPoints": "Clear Hitpoints", + "clearStress": "Clear Stress" } } }, diff --git a/module/applications/sheets-configs/action-base-config.mjs b/module/applications/sheets-configs/action-base-config.mjs index 34543086..ce0e7e24 100644 --- a/module/applications/sheets-configs/action-base-config.mjs +++ b/module/applications/sheets-configs/action-base-config.mjs @@ -28,6 +28,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) removeEffect: this.removeEffect, addElement: this.addElement, removeElement: this.removeElement, + removeTransformActor: this.removeTransformActor, editEffect: this.editEffect, addDamage: this.addDamage, removeDamage: this.removeDamage, @@ -41,7 +42,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) submitOnChange: true, closeOnSubmit: false }, - dragDrop: [{ dragSelector: null, dropSelector: '#summon-drop-zone', handlers: ['_onDrop'] }] + dragDrop: [{ dragSelector: null, dropSelector: '[data-is-drop-zone]', handlers: ['_onDrop'] }] }; static PARTS = { @@ -120,6 +121,10 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) htmlElement.querySelectorAll('.summon-count-wrapper input').forEach(element => { element.addEventListener('change', this.updateSummonCount.bind(this)); }); + + htmlElement.querySelectorAll('.transform-resource input').forEach(element => { + element.addEventListener('change', this.updateTransformResource.bind(this)); + }); } async _prepareContext(_options) { @@ -133,6 +138,18 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) context.summons.push({ actor, count: summon.count }); } + if (context.source.transform) { + const actor = await foundry.utils.fromUuid(context.source.transform.actorUUID); + context.transform = { + ...context.source.transform, + actor: + actor ?? + (context.source.transform.actorUUID && !actor + ? { error: game.i18n.localize('DAGGERHEART.ACTIONS.Settings.transform.actorIsMissing') } + : null) + }; + } + context.openSection = this.openSection; context.tabs = this._getTabs(this.constructor.TABS); context.config = CONFIG.DH; @@ -266,6 +283,12 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) if (doc) return doc.sheet.render({ force: true }); } + static async removeTransformActor() { + const data = this.action.toObject(); + data.transform.actorUUID = null; + this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); + } + static addDamage(_event) { if (!this.action.damage.parts) return; const data = this.action.toObject(), @@ -346,6 +369,14 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); } + updateTransformResource(event) { + event.stopPropagation(); + + const data = this.action.toObject(); + data.transform.resourceRefresh[event.target.dataset.resource] = event.target.checked; + 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) {} @@ -364,6 +395,18 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) return; } + const dropZone = event.target.closest('[data-is-drop-zone]'); + if (!dropZone) return; + + switch (dropZone.id) { + case 'summon-drop-zone': + return this.onSummonDrop(data); + case 'transform-drop-zone': + return this.onTransformDrop(data); + } + } + + async onSummonDrop(data) { const actionData = this.action.toObject(); let countvalue = 1; for (const entry of actionData.summon) { @@ -380,4 +423,10 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) actionData.summon.push({ actorUUID: data.uuid, count: countvalue }); await this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(actionData) }); } + + async onTransformDrop(data) { + const actionData = this.action.toObject(); + actionData.transform.actorUUID = data.uuid; + await this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(actionData) }); + } } diff --git a/module/config/actionConfig.mjs b/module/config/actionConfig.mjs index c9b70193..0b1bf91d 100644 --- a/module/config/actionConfig.mjs +++ b/module/config/actionConfig.mjs @@ -35,6 +35,12 @@ export const actionTypes = { icon: 'fa-ghost', tooltip: 'DAGGERHEART.ACTIONS.TYPES.summon.tooltip' }, + transform: { + id: 'transform', + name: 'DAGGERHEART.ACTIONS.TYPES.transform.name', + icon: 'fa-dragon', + tooltip: 'DAGGERHEART.ACTIONS.TYPES.transform.tooltip' + }, effect: { id: 'effect', name: 'DAGGERHEART.ACTIONS.TYPES.effect.name', diff --git a/module/data/action/_module.mjs b/module/data/action/_module.mjs index 9cfc48cb..043b039c 100644 --- a/module/data/action/_module.mjs +++ b/module/data/action/_module.mjs @@ -7,6 +7,7 @@ import EffectAction from './effectAction.mjs'; import HealingAction from './healingAction.mjs'; import MacroAction from './macroAction.mjs'; import SummonAction from './summonAction.mjs'; +import TransformAction from './transformAction.mjs'; export const actionsTypes = { base: BaseAction, @@ -17,5 +18,6 @@ export const actionsTypes = { summon: SummonAction, effect: EffectAction, macro: MacroAction, - beastform: BeastformAction + beastform: BeastformAction, + transform: TransformAction }; diff --git a/module/data/action/baseAction.mjs b/module/data/action/baseAction.mjs index f6ffe75f..992e1714 100644 --- a/module/data/action/baseAction.mjs +++ b/module/data/action/baseAction.mjs @@ -197,7 +197,7 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel async executeWorkflow(config) { for (const [key, part] of this.workflow) { if (Hooks.call(`${CONFIG.DH.id}.pre${key.capitalize()}Action`, this, config) === false) return; - if ((await part.execute(config)) === false) return; + if ((await part.execute(config)) === false) return false; if (Hooks.call(`${CONFIG.DH.id}.post${key.capitalize()}Action`, this, config) === false) return; } } @@ -224,7 +224,9 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel } // Execute the Action Worflow in order based of schema fields - await this.executeWorkflow(config); + const result = await this.executeWorkflow(config); + if (result === false) return; + await config.resourceUpdates.updateResources(); if (Hooks.call(`${CONFIG.DH.id}.postUseAction`, this, config) === false) return; diff --git a/module/data/action/transformAction.mjs b/module/data/action/transformAction.mjs new file mode 100644 index 00000000..7e552902 --- /dev/null +++ b/module/data/action/transformAction.mjs @@ -0,0 +1,5 @@ +import DHBaseAction from './baseAction.mjs'; + +export default class DHTransformAction extends DHBaseAction { + static extraSchemas = [...super.extraSchemas, 'transform']; +} diff --git a/module/data/fields/action/_module.mjs b/module/data/fields/action/_module.mjs index 0bdffca2..fa3b1cd5 100644 --- a/module/data/fields/action/_module.mjs +++ b/module/data/fields/action/_module.mjs @@ -10,3 +10,4 @@ export { default as DamageField } from './damageField.mjs'; export { default as RollField } from './rollField.mjs'; export { default as MacroField } from './macroField.mjs'; export { default as SummonField } from './summonField.mjs'; +export { default as TransformField } from './transformField.mjs'; diff --git a/module/data/fields/action/transformField.mjs b/module/data/fields/action/transformField.mjs new file mode 100644 index 00000000..f6d692af --- /dev/null +++ b/module/data/fields/action/transformField.mjs @@ -0,0 +1,101 @@ +const fields = foundry.data.fields; + +export default class DHSummonField extends fields.SchemaField { + /** + * Action Workflow order + */ + static order = 130; + + constructor(options = {}, context = {}) { + const transformFields = { + actorUUID: new fields.DocumentUUIDField({ + type: 'Actor', + required: true + }), + resourceRefresh: new fields.SchemaField({ + hitPoints: new fields.BooleanField({ initial: true }), + stress: new fields.BooleanField({ initial: true }) + }) + }; + super(transformFields, options, context); + } + + static async execute() { + if (!this.transform.actorUUID) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.noTransformActor')); + return false; + } + + const baseActor = await foundry.utils.fromUuid(this.transform.actorUUID); + if (!baseActor) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.transformActorMissing')); + return false; + } + + if (!canvas.scene) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.canvasError')); + return false; + } + + if (this.actor.prototypeToken.actorLink) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.actorLinkError')); + return false; + } + + if (!this.actor.token) { + ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.prototypeError')); + return false; + } + + const actor = await DHSummonField.getWorldActor(baseActor); + const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; + const tokenSize = actor?.system.metadata.usesSize ? tokenSizes[actor.system.size] : actor.prototypeToken.width; + + await this.actor.token.update( + { ...actor.prototypeToken.toJSON(), actorId: actor.id, width: tokenSize, height: tokenSize }, + { diff: false, recursive: false, noHook: true } + ); + + if (this.actor.token.combatant) { + this.actor.token.combatant.update({ actorId: actor.id, img: actor.prototypeToken.texture.src }); + } + + const marks = { hitPoints: 0, stress: 0 }; + if (!this.transform.resourceRefresh.hitPoints) { + marks.hitPoints = Math.min( + this.actor.system.resources.hitPoints.value, + this.actor.token.actor.system.resources.hitPoints.max - 1 + ); + } + if (!this.transform.resourceRefresh.stress) { + marks.stress = Math.min( + this.actor.system.resources.stress.value, + this.actor.token.actor.system.resources.stress.max - 1 + ); + } + if (marks.hitPoints || marks.stress) { + this.actor.token.actor.update({ + 'system.resources': { + hitPoints: { value: marks.hitPoints }, + stress: { value: marks.stress } + } + }); + } + + const prevPosition = { ...this.actor.sheet.position }; + this.actor.sheet.close(); + this.actor.token.actor.sheet.render({ force: true, position: prevPosition }); + } + + /* Check for any available instances of the actor present in the world, or create a world actor based on compendium */ + static async getWorldActor(baseActor) { + const dataType = game.system.api.data.actors[`Dh${baseActor.type.capitalize()}`]; + if (baseActor.inCompendium && dataType && baseActor.img === dataType.DEFAULT_ICON) { + const worldActorCopy = game.actors.find(x => x.name === baseActor.name); + if (worldActorCopy) return worldActorCopy; + } + + const worldActor = await game.system.api.documents.DhpActor.create(baseActor.toObject()); + return worldActor; + } +} diff --git a/module/systemRegistration/handlebars.mjs b/module/systemRegistration/handlebars.mjs index ad8c741a..de085221 100644 --- a/module/systemRegistration/handlebars.mjs +++ b/module/systemRegistration/handlebars.mjs @@ -33,6 +33,7 @@ export const preloadHandlebarsTemplates = async function () { 'systems/daggerheart/templates/actionTypes/beastform.hbs', 'systems/daggerheart/templates/actionTypes/countdown.hbs', 'systems/daggerheart/templates/actionTypes/summon.hbs', + 'systems/daggerheart/templates/actionTypes/transform.hbs', 'systems/daggerheart/templates/settings/components/settings-item-line.hbs', 'systems/daggerheart/templates/ui/tooltip/parts/tooltipChips.hbs', 'systems/daggerheart/templates/ui/tooltip/parts/tooltipTags.hbs', diff --git a/src/packs/adversaries/adversary_Fallen_Warlord__Realm_Breaker_hxZ0sgoFJubh5aj6.json b/src/packs/adversaries/adversary_Fallen_Warlord__Realm_Breaker_hxZ0sgoFJubh5aj6.json index b2cdc489..8f3865e9 100644 --- a/src/packs/adversaries/adversary_Fallen_Warlord__Realm_Breaker_hxZ0sgoFJubh5aj6.json +++ b/src/packs/adversaries/adversary_Fallen_Warlord__Realm_Breaker_hxZ0sgoFJubh5aj6.json @@ -717,7 +717,35 @@ "system": { "description": "
When the @Lookup[@name] marks their last HP, replace them with the @UUID[Compendium.daggerheart.adversaries.Actor.RXkZTwBRi4dJ3JE5]{Fallen Warlord: Undefeated Champion} and immediately spotlight them.
", "resource": null, - "actions": {}, + "actions": { + "gP426WmWbtrZEWCD": { + "type": "transform", + "_id": "gP426WmWbtrZEWCD", + "systemPath": "actions", + "baseAction": false, + "description": "", + "chatDisplay": true, + "originItem": { + "type": "itemCollection" + }, + "actionType": "action", + "triggers": [], + "cost": [], + "uses": { + "value": null, + "max": null, + "recovery": null, + "consumeOnSuccess": false + }, + "transform": { + "actorUUID": "Compendium.daggerheart.adversaries.Actor.RXkZTwBRi4dJ3JE5", + "resourceRefresh": { + "hitPoints": true, + "stress": true + } + } + } + }, "originItemType": null, "originId": null, "featureForm": "reaction" diff --git a/src/packs/adversaries/adversary_Volcanic_Dragon__Molten_Scourge_eArAPuB38CNR0ZIM.json b/src/packs/adversaries/adversary_Volcanic_Dragon__Molten_Scourge_eArAPuB38CNR0ZIM.json index b23da064..7f9deb6c 100644 --- a/src/packs/adversaries/adversary_Volcanic_Dragon__Molten_Scourge_eArAPuB38CNR0ZIM.json +++ b/src/packs/adversaries/adversary_Volcanic_Dragon__Molten_Scourge_eArAPuB38CNR0ZIM.json @@ -846,7 +846,37 @@ "system": { "description": "When the @Lookup[@name] marks their last HP, replace them with the @UUID[Compendium.daggerheart.adversaries.Actor.pMuXGCSOQaxpi5tb]{Ashen Tyrant} and immediately spotlight them.
", "resource": null, - "actions": {}, + "actions": { + "cFqFjemAfAjB0OB0": { + "type": "transform", + "_id": "cFqFjemAfAjB0OB0", + "systemPath": "actions", + "baseAction": false, + "description": "", + "chatDisplay": true, + "originItem": { + "type": "itemCollection" + }, + "actionType": "action", + "triggers": [], + "cost": [], + "uses": { + "value": null, + "max": "", + "recovery": null, + "consumeOnSuccess": false + }, + "transform": { + "actorUUID": "Compendium.daggerheart.adversaries.Actor.pMuXGCSOQaxpi5tb", + "resourceRefresh": { + "hitPoints": true, + "stress": true + } + }, + "name": "Transform", + "range": "" + } + }, "originItemType": null, "originId": null, "featureForm": "reaction" diff --git a/src/packs/adversaries/adversary_Volcanic_Dragon__Obsidian_Predator_ladm7wykhZczYzrQ.json b/src/packs/adversaries/adversary_Volcanic_Dragon__Obsidian_Predator_ladm7wykhZczYzrQ.json index 2e2adbdd..5f32aae5 100644 --- a/src/packs/adversaries/adversary_Volcanic_Dragon__Obsidian_Predator_ladm7wykhZczYzrQ.json +++ b/src/packs/adversaries/adversary_Volcanic_Dragon__Obsidian_Predator_ladm7wykhZczYzrQ.json @@ -742,7 +742,37 @@ "system": { "description": "When the @Lookup[@name] marks their last HP, replace them with the @UUID[Compendium.daggerheart.adversaries.Actor.eArAPuB38CNR0ZIM]{Molten Scourge} and immediately spotlight them.
", "resource": null, - "actions": {}, + "actions": { + "OxGkCGgIl4vGFufD": { + "type": "transform", + "_id": "OxGkCGgIl4vGFufD", + "systemPath": "actions", + "baseAction": false, + "description": "", + "chatDisplay": true, + "originItem": { + "type": "itemCollection" + }, + "actionType": "action", + "triggers": [], + "cost": [], + "uses": { + "value": null, + "max": "", + "recovery": null, + "consumeOnSuccess": false + }, + "transform": { + "actorUUID": "Compendium.daggerheart.adversaries.Actor.eArAPuB38CNR0ZIM", + "resourceRefresh": { + "hitPoints": true, + "stress": true + } + }, + "name": "Transform", + "range": "" + } + }, "originItemType": null, "originId": null, "featureForm": "reaction" diff --git a/styles/less/sheets/actions/actions.less b/styles/less/sheets/actions/actions.less index 9be56c8e..5c21dc60 100644 --- a/styles/less/sheets/actions/actions.less +++ b/styles/less/sheets/actions/actions.less @@ -4,48 +4,78 @@ display: flex; flex-direction: column; gap: 10px; + } - .actor-summon-line { + .transform-container { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + + .transform-resources { + display: flex; + flex-direction: column; + + .transform-resource { + display: flex; + align-items: center; + gap: 2px; + + .resource-title { + font-size: var(--font-size-18); + } + } + } + } + + .actor-drop-line { + display: flex; + align-items: center; + gap: 5px; + border-radius: 3px; + + .actor-drop-name { + flex: 2; 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; - } + img { + height: 40px; } } - .summon-dragger { + .actor-drop-controls { + flex: 1; 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); + gap: 5px; + + &.transform { + justify-content: flex-end; + } + + .controls { + display: flex; + gap: 5px; + } } + + .actor-drop-hint { + flex: none; + } + } + + .drop-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); } .trigger-data { diff --git a/templates/actionTypes/summon.hbs b/templates/actionTypes/summon.hbs index 429977d9..18fed2e2 100644 --- a/templates/actionTypes/summon.hbs +++ b/templates/actionTypes/summon.hbs @@ -1,19 +1,19 @@ -