From 8c815331a43664fe25fa9c0d5976cfa2a00fa8a5 Mon Sep 17 00:00:00 2001 From: Christian Sagel Date: Mon, 13 Jan 2025 17:03:24 +0100 Subject: [PATCH] Implement buttons for spending resources for spells, skills, misc. abilities --- CONTRIBUTING.md | 2 +- lang/en.json | 17 +- module/checks/accuracy-check.mjs | 46 +-- module/checks/check-configuration.mjs | 8 - module/checks/check-hooks.mjs | 72 +++- module/checks/check-push.mjs | 2 +- module/checks/checks-v2.mjs | 5 +- module/checks/magic-check.mjs | 36 +- .../items/common/action-cost-data-model.mjs | 16 + .../items/common/targeting-data-model.mjs | 15 + module/documents/items/item.mjs | 8 +- .../items/misc/misc-ability-data-model.mjs | 6 + .../items/skill/skill-data-model.mjs | 56 ++-- .../items/spell/spell-data-model.mjs | 32 +- .../items/spell/spell-migrations.mjs | 27 ++ module/helpers/config.mjs | 11 + module/helpers/flags.mjs | 2 + module/helpers/inline-helper.mjs | 19 ++ module/helpers/target-handler.mjs | 2 +- module/helpers/targeting.mjs | 312 ++++++++++++++++++ module/helpers/templates.mjs | 4 + module/pipelines/damage-pipeline.mjs | 59 ++-- module/pipelines/pipeline.mjs | 72 ++++ module/pipelines/resource-pipeline.mjs | 171 ++++++++-- module/projectfu.mjs | 4 + module/sheets/actor-standard-sheet.mjs | 4 +- module/sheets/item-sheet.mjs | 7 +- .../actor/sections/actor-section-spells.hbs | 2 +- templates/chat/chat-apply-loss.hbs | 5 +- .../partials/chat-item-spend-resource.hbs | 5 + templates/chat/partials/chat-targets.hbs | 42 +++ templates/item/item-miscAbility-sheet.hbs | 6 + templates/item/item-skill-sheet.hbs | 6 + templates/item/item-spell-sheet.hbs | 35 +- templates/item/partials/item-action-cost.hbs | 22 ++ .../item-spell-sheet-key-attributes.hbs | 30 ++ templates/item/partials/item-targeting.hbs | 22 ++ 37 files changed, 978 insertions(+), 212 deletions(-) create mode 100644 module/documents/items/common/action-cost-data-model.mjs create mode 100644 module/documents/items/common/targeting-data-model.mjs create mode 100644 module/helpers/targeting.mjs create mode 100644 templates/chat/partials/chat-item-spend-resource.hbs create mode 100644 templates/chat/partials/chat-targets.hbs create mode 100644 templates/item/partials/item-action-cost.hbs create mode 100644 templates/item/partials/item-spell-sheet-key-attributes.hbs create mode 100644 templates/item/partials/item-targeting.hbs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2e5e750..f50b65c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ npm run watch Or for a one-time build: ```bash -npm run build +npm run push ``` Enable the _Hot-Reload Package Files_ option in your Foundry application configuration for an improved developer experience. diff --git a/lang/en.json b/lang/en.json index 643152df..1c7d182b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -606,6 +606,8 @@ "Instantaneous": "Instantaneous", "Scene": "Scene", "Self": "Self", + "Single": "Single", + "Multiple": "Multiple", "OneCreature": "One creature", "TwoCreatures": "Up to two creatures", "ThreeCreatures": "Up to three creatures", @@ -839,12 +841,14 @@ "IgnoreAbsorption": "Ignore Absorption", "IgnoreAll": "Ignore All", "IgnoreNone": "Ignore None", - "ChatApplyTargeted": "Apply Targeted", - "ChatApplySelected": "Apply Selected", + "ChatApplyTargeted": "Apply to targeted token", + "ChatApplySelected": "Apply to selected token", + "ChatApplyNoTargetSelected": "No actor selected.", "ChatApplyNoCharacterSelected": "No character selected. Unable to apply.", "ChatApplySelectedTooltip": "Click to apply effect to selected tokens.", - "ChatApplyEffectNoActorsSelected": "No actors selected. Unable to apply effect.", - "ChatApplyEffectNoActorsTargeted": "No actors targeted. Unable to apply effect.", + "ChatApplyEffectNoActorsSelected": "No actors selected. Unable to apply.", + "ChatApplyEffectNoActorsTargeted": "No actors targeted. Unable to apply.", + "ChatApplyMaxTargetWarning": "Too many actors targeted!", "ChatApplyDamageTooltip": "Click to apply damage to selected tokens.
[Ctrl + Click] to Open Damage Customizer
[Shift + Click] to ignore Resistances.
[Ctrl + Shift] + Click to ignore Resistances and Immunities.", "ChatApplyDamageNoActorsSelected": "No actors selected. Unable to apply damage.", "ChatApplyDamageVulnerable": "{actor} was vulnerable to {type} and took {damage} damage from {from}", @@ -854,7 +858,12 @@ "ChatApplyDamageImmune": "{actor} was immune to {type} and took {damage} damage from {from}", "ChatApplyDamageImmuneIgnored": "{actor} took {damage} {type} damage from {from} (Immunity ignored)", "ChatApplyDamageAbsorb": "{actor} absorbed {type} damage from {from} and was healed by {damage} HP", + "ChatApplyResourceLoss": "Spend", + "ChatApplyResourceLostsTooltip": "Click to spend the given resource on the actor who performed this action.", + "ChatApplyResourceLossSelected": "Spend Selected", + "ChatApplyResourceLossTargeted": "Spend Targeted", "ChatRevertDamage": "Revert applied damage", + "ChatRevertResourceLoss": "Revert resource loss", "ChatEvaluateAmountNoActor": "No reference to an actor provided", "ChatEvaluateAmountNoItem": "No reference to an item provided", "ChatEvaluateNoSkill": "The referenced skill was not found in the actor", diff --git a/module/checks/accuracy-check.mjs b/module/checks/accuracy-check.mjs index da4f8454..5e1f49cc 100644 --- a/module/checks/accuracy-check.mjs +++ b/module/checks/accuracy-check.mjs @@ -1,17 +1,9 @@ import { CheckHooks } from './check-hooks.mjs'; -import { CHECK_RESULT, CHECK_ROLL } from './default-section-order.mjs'; -import { FUActor } from '../documents/actors/actor.mjs'; +import { CHECK_ROLL } from './default-section-order.mjs'; import { FU, SYSTEM } from '../helpers/config.mjs'; import { CheckConfiguration } from './check-configuration.mjs'; import { Flags } from '../helpers/flags.mjs'; - -/** - * @typedef TargetData - * @property {string} name - * @property {string} uuid - * @property {string} link - * @property {number} difficulty - */ +import { Targeting } from '../helpers/targeting.mjs'; function handleGenericBonus(actor, modifiers) { if (actor.system.bonuses.accuracy.accuracyCheck) { @@ -146,7 +138,6 @@ function onRenderCheck(data, checkResult, actor, item, flags) { modifiers: damage.modifiers, }; } - const applyDamage = damageData != null; // Push combined data for accuracy and damage data.push({ @@ -160,38 +151,7 @@ function onRenderCheck(data, checkResult, actor, item, flags) { /** @type TargetData[] */ const targets = inspector.getTargets(); - const isTargeted = targets?.length > 0; - if (targets) { - data.push({ - order: CHECK_RESULT, - partial: isTargeted ? 'systems/projectfu/templates/chat/partials/chat-check-targets.hbs' : 'systems/projectfu/templates/chat/partials/chat-check-notargets.hbs', - data: { - targets: targets, - applyDamage: applyDamage, - }, - }); - } - - if (isTargeted) { - async function showFloatyText(target) { - const actor = await fromUuid(target.uuid); - if (actor instanceof FUActor) { - actor.showFloatyText(game.i18n.localize(target.result === 'hit' ? 'FU.Hit' : 'FU.Miss')); - } - } - - if (game.dice3d) { - Hooks.once('diceSoNiceRollComplete', () => { - for (const target of targets) { - showFloatyText(target); - } - }); - } else { - for (const target of targets) { - showFloatyText(target); - } - } - } + Targeting.addDamageTargetingSection(data, actor, item, targets, flags, accuracyData, damageData); (flags[SYSTEM] ??= {})[Flags.ChatMessage.Item] ??= item.toObject(); } diff --git a/module/checks/check-configuration.mjs b/module/checks/check-configuration.mjs index e894e54f..7a6406f7 100644 --- a/module/checks/check-configuration.mjs +++ b/module/checks/check-configuration.mjs @@ -40,14 +40,6 @@ const initHrZero = (hrZero) => (check) => { * @property {number} [total] */ -/** - * @typedef TargetData - * @property {string} name - * @property {string} uuid - * @property {string} link - * @property {number} [difficulty] - */ - /** * @param {CheckV2, CheckResultV2} check * @return {CheckConfigurer} check diff --git a/module/checks/check-hooks.mjs b/module/checks/check-hooks.mjs index 4dc0f270..21e6b58b 100644 --- a/module/checks/check-hooks.mjs +++ b/module/checks/check-hooks.mjs @@ -1,4 +1,5 @@ import { SYSTEM } from '../helpers/config.mjs'; +import { Pipeline } from '../pipelines/pipeline.mjs'; export const CheckHooks = Object.freeze({ prepareCheck: `${SYSTEM}.prepareCheck`, @@ -106,12 +107,13 @@ const processCheck = (check, actor, item) => {}; /** * @callback RenderCheckHook - * Hook called to determine how to render the results + * @description Hook called to determine how to render the results * @param {CheckRenderData} sections * @param {CheckResultV2} check * @param {FUActor} actor * @param {FUItem} [item] * @param {Object} additionalFlags + * @param {TargetData[]} targets */ /** @@ -119,3 +121,71 @@ const processCheck = (check, actor, item) => {}; */ // eslint-disable-next-line no-unused-vars const renderCheck = (sections, check, actor, item, additionalFlags) => {}; + +/** + * @description To be used within a {@link RenderCheckHook} + * @property {FUActor} actor + * @property {FUItem} item + * @property {Object} flags + * @property {TargetData[]} targets A snapshot of the targets at the beginning of the hook + * @property {TargetingDataModel} targeting + * @property {CheckSection} section + * @property {Boolean} executed + * @property {List>} additions + */ +export class RenderCheckSectionBuilder { + constructor(data, actor, item, targets, flags, order, partial) { + this.data = data; + this.actor = actor; + this.item = item; + this.targets = targets; + this.flags = flags; + this.executed = false; + this.additions = []; + this.section = { + order: order, + partial: partial, + data: { + name: item.name, + actor: actor.uuid, + item: item.uuid, + }, + }; + } + + toggleFlag(flag) { + Pipeline.toggleFlag(this.flags, flag); + } + + /** + * @param {Promise} onData + * @remarks Supports asynchronous operation + */ + addData(onData) { + this.additions.push(onData(this.section.data)); + } + + /** + * @description Pushes the section onto {@link CheckRenderData} + */ + push() { + if (this.executed === true) { + throw Error('Already executed.'); + } + this.data.push(async () => { + await Promise.all(this.additions); + if (this.validate()) { + return this.section; + } + return {}; + }); + this.executed = true; + } + + /** + * @returns {boolean} Whether the section should be pushed + */ + validate() { + return true; + } +} diff --git a/module/checks/check-push.mjs b/module/checks/check-push.mjs index aba6ad4c..b513c5ca 100644 --- a/module/checks/check-push.mjs +++ b/module/checks/check-push.mjs @@ -77,7 +77,7 @@ const getPushParams = async (actor) => { }); if (push === false) { - ui.notifications.error('FU.DialogPushMissingBond', { localize: true }); + ui.notifications.warn('FU.ChatEvaluateAmountNoActor', { localize: true }); return; } diff --git a/module/checks/checks-v2.mjs b/module/checks/checks-v2.mjs index 58325d9b..1a3c711d 100644 --- a/module/checks/checks-v2.mjs +++ b/module/checks/checks-v2.mjs @@ -13,6 +13,7 @@ import { OpposedCheck } from './opposed-check.mjs'; import { CheckRetarget } from './check-retarget.mjs'; import { GroupCheck } from './group-check.mjs'; import { SupportCheck } from './support-check.mjs'; +import { Targeting } from '../helpers/targeting.mjs'; /** * @typedef CheckAttributes @@ -340,8 +341,10 @@ async function renderCheck(result, actor, item, flags = {}) { */ const renderData = []; const additionalFlags = {}; + // TODO: Pass in as a parameter + const targets = Targeting.getSerializedTargetData(); - Hooks.callAll(CheckHooks.renderCheck, renderData, result, actor, item, additionalFlags); + Hooks.callAll(CheckHooks.renderCheck, renderData, result, actor, item, additionalFlags, targets); /** * @type {CheckSection[]} diff --git a/module/checks/magic-check.mjs b/module/checks/magic-check.mjs index 1c4fd1a8..4abdaf03 100644 --- a/module/checks/magic-check.mjs +++ b/module/checks/magic-check.mjs @@ -1,9 +1,9 @@ import { CheckHooks } from './check-hooks.mjs'; -import { CHECK_RESULT, CHECK_ROLL } from './default-section-order.mjs'; -import { FUActor } from '../documents/actors/actor.mjs'; +import { CHECK_ROLL } from './default-section-order.mjs'; import { FU, SYSTEM } from '../helpers/config.mjs'; import { CheckConfiguration } from './check-configuration.mjs'; import { Flags } from '../helpers/flags.mjs'; +import { Targeting } from '../helpers/targeting.mjs'; /** * @param {CheckV2} check @@ -145,37 +145,7 @@ function onRenderCheck(data, checkResult, actor, item, flags) { }); /** @type TargetData[] */ const targets = inspector.getTargets(); - const isTargeted = targets?.length > 0; - if (targets) { - data.push({ - order: CHECK_RESULT, - partial: isTargeted ? 'systems/projectfu/templates/chat/partials/chat-check-targets.hbs' : 'systems/projectfu/templates/chat/partials/chat-check-notargets.hbs', - data: { - targets: targets, - }, - }); - } - - if (isTargeted) { - async function showFloatyText(target) { - const actor = await fromUuid(target.uuid); - if (actor instanceof FUActor) { - actor.showFloatyText(game.i18n.localize(target.result === 'hit' ? 'FU.Hit' : 'FU.Miss')); - } - } - - if (game.dice3d) { - Hooks.once('diceSoNiceRollComplete', () => { - for (const target of targets) { - showFloatyText(target); - } - }); - } else { - for (const target of targets) { - showFloatyText(target); - } - } - } + Targeting.addDamageTargetingSection(data, actor, item, targets, flags, accuracyData, damageData); (flags[SYSTEM] ??= {})[Flags.ChatMessage.Item] ??= item.toObject(); } diff --git a/module/documents/items/common/action-cost-data-model.mjs b/module/documents/items/common/action-cost-data-model.mjs new file mode 100644 index 00000000..0fa9a785 --- /dev/null +++ b/module/documents/items/common/action-cost-data-model.mjs @@ -0,0 +1,16 @@ +import { FU } from '../../../helpers/config.mjs'; + +/** + * @property {FU.resources} resource.value The resource type + * @property {Number} amount.value The resource cost +s + */ +export class ActionCostDataModel extends foundry.abstract.DataModel { + static defineSchema() { + const { NumberField, StringField } = foundry.data.fields; + return { + resource: new StringField({ initial: FU.resources.mp, required: true }), + amount: new NumberField({ initial: 0, integer: true, nullable: false }), + }; + } +} diff --git a/module/documents/items/common/targeting-data-model.mjs b/module/documents/items/common/targeting-data-model.mjs new file mode 100644 index 00000000..0597e5af --- /dev/null +++ b/module/documents/items/common/targeting-data-model.mjs @@ -0,0 +1,15 @@ +import { Targeting } from '../../../helpers/targeting.mjs'; + +/** + * @property {FU.targetingRules} rule.value The type of targeting rule to use + * @property {Number} max.value The maximum number of target + */ +export class TargetingDataModel extends foundry.abstract.DataModel { + static defineSchema() { + const { NumberField, StringField } = foundry.data.fields; + return { + rule: new StringField({ initial: Targeting.rule.self, required: true }), + max: new NumberField({ initial: 0, min: 0, max: 3, integer: true, nullable: false }), + }; + } +} diff --git a/module/documents/items/item.mjs b/module/documents/items/item.mjs index 016e228f..896ac75d 100644 --- a/module/documents/items/item.mjs +++ b/module/documents/items/item.mjs @@ -153,7 +153,7 @@ export class FUItem extends Item { const qualText = this.system.quality?.value || ''; const detailString = [attackString, damageString].filter(Boolean).join('⬥'); - const qualityString = [capitalizeFirst(this.system.mpCost.value), capitalizeFirst(this.system.target.value), capitalizeFirst(this.system.duration.value), qualText].filter(Boolean).join(' ⬥ '); + const qualityString = [capitalizeFirst(this.system.cost.amount), capitalizeFirst(this.system.targeting.rule), capitalizeFirst(this.system.duration.value), qualText].filter(Boolean).join(' ⬥ '); return { attackString, @@ -883,7 +883,7 @@ export class FUItem extends Item { * @return {Promise} */ async rollSpell(hrZero) { - const { rollInfo, opportunity, description, summary, mpCost, target, duration, defense } = this.system; + const { rollInfo, opportunity, description, summary, cost, targeting, duration, defense } = this.system; let defenseAbbr = !defense ? game.i18n.localize('FU.MagicDefenseAbbr') : defenseAbbr; let checkDamage = undefined; if (rollInfo?.damage?.hasDamage?.value) { @@ -902,8 +902,8 @@ export class FUItem extends Item { img: this.img, id: this.id, duration: duration.value, - target: target.value, - mpCost: mpCost.value, + target: targeting.rule, + mpCost: cost.amount, defense: defenseAbbr, opportunity: opportunity, summary: summary.value, diff --git a/module/documents/items/misc/misc-ability-data-model.mjs b/module/documents/items/misc/misc-ability-data-model.mjs index f074960d..3a30d422 100644 --- a/module/documents/items/misc/misc-ability-data-model.mjs +++ b/module/documents/items/misc/misc-ability-data-model.mjs @@ -6,6 +6,8 @@ import { ProgressDataModel } from '../common/progress-data-model.mjs'; import { MiscAbilityMigrations } from './misc-ability-migrations.mjs'; import { FU } from '../../../helpers/config.mjs'; import { CheckHooks } from '../../../checks/check-hooks.mjs'; +import { ActionCostDataModel } from '../common/action-cost-data-model.mjs'; +import { TargetingDataModel } from '../common/targeting-data-model.mjs'; Hooks.on(CheckHooks.renderCheck, (sections, check, actor, item) => { if (item?.system instanceof MiscAbilityDataModel) { @@ -39,6 +41,8 @@ Hooks.on(CheckHooks.renderCheck, (sections, check, actor, item) => { * @property {DamageDataModel} rollInfo.damage * @property {boolean} isOffensive.value * @property {boolean} hasRoll.value + * @property {ActionCostDataModel} cost + * @property {TargetingDataModel} targeting */ export class MiscAbilityDataModel extends foundry.abstract.TypeDataModel { static defineSchema() { @@ -76,6 +80,8 @@ export class MiscAbilityDataModel extends foundry.abstract.TypeDataModel { damage: new EmbeddedDataField(DamageDataModel, {}), }), hasRoll: new SchemaField({ value: new BooleanField() }), + cost: new EmbeddedDataField(ActionCostDataModel, {}), + targeting: new EmbeddedDataField(TargetingDataModel, {}), }; } diff --git a/module/documents/items/skill/skill-data-model.mjs b/module/documents/items/skill/skill-data-model.mjs index e5adb5ed..f8b2eab4 100644 --- a/module/documents/items/skill/skill-data-model.mjs +++ b/module/documents/items/skill/skill-data-model.mjs @@ -11,6 +11,9 @@ import { CheckConfiguration } from '../../../checks/check-configuration.mjs'; import { AccuracyCheck } from '../../../checks/accuracy-check.mjs'; import { SETTINGS } from '../../../settings.js'; import { CHECK_DETAILS, CHECK_FLAVOR } from '../../../checks/default-section-order.mjs'; +import { ActionCostDataModel } from '../common/action-cost-data-model.mjs'; +import { ResourcePipeline } from '../../../pipelines/resource-pipeline.mjs'; +import { TargetingDataModel } from '../common/targeting-data-model.mjs'; const weaponUsedBySkill = 'weaponUsedBySkill'; const skillForAttributeCheck = 'skillForAttributeCheck'; @@ -85,28 +88,29 @@ Hooks.on(CheckHooks.prepareCheck, onPrepareAttributeCheck); /** * @type RenderCheckHook */ -let onRenderAccuracyCheck = (sections, check, actor, item) => { - if (check.type === 'accuracy' && item?.system instanceof SkillDataModel) { - if (check.additionalData[weaponUsedBySkill]) { - const weapon = fromUuidSync(check.additionalData[weaponUsedBySkill]); - /** @type WeaponDataModel */ - const weaponData = weapon.system; - sections.push({ - partial: 'systems/projectfu/templates/chat/partials/chat-weapon-details.hbs', - data: { - weapon: { - category: weaponData.category.value, - hands: weaponData.hands.value, - type: weaponData.type.value, - summary: item.system.summary.value, - description: item.system.description, +let onRenderAccuracyCheck = (sections, check, actor, item, flags, targets) => { + if (item?.system instanceof SkillDataModel) { + if (check.type === 'accuracy') { + if (check.additionalData[weaponUsedBySkill]) { + const weapon = fromUuidSync(check.additionalData[weaponUsedBySkill]); + /** @type WeaponDataModel */ + const weaponData = weapon.system; + sections.push({ + partial: 'systems/projectfu/templates/chat/partials/chat-weapon-details.hbs', + data: { + weapon: { + category: weaponData.category.value, + hands: weaponData.hands.value, + type: weaponData.type.value, + summary: item.system.summary.value, + description: item.system.description, + }, + collapseDescriptions: game.settings.get(SYSTEM, SETTINGS.collapseDescriptions), }, - collapseDescriptions: game.settings.get(SYSTEM, SETTINGS.collapseDescriptions), - }, - order: CHECK_DETAILS, - }); - sections.push({ - content: ` + order: CHECK_DETAILS, + }); + sections.push({ + content: `
@@ -116,9 +120,11 @@ let onRenderAccuracyCheck = (sections, check, actor, item) => {
`, - order: CHECK_DETAILS - 1, - }); + order: CHECK_DETAILS - 1, + }); + } } + ResourcePipeline.addSpendResourceChatMessageSection(sections, actor, item, targets, flags); } }; Hooks.on(CheckHooks.renderCheck, onRenderAccuracyCheck); @@ -195,6 +201,8 @@ Hooks.on(CheckHooks.renderCheck, onRenderDisplay); * @property {number} rollInfo.accuracy.value * @property {DamageDataModel} rollInfo.damage * @property {boolean} hasRoll.value + * @property {ActionCostDataModel} cost + * @property {TargetingDataModel} targeting */ export class SkillDataModel extends foundry.abstract.TypeDataModel { static defineSchema() { @@ -238,6 +246,8 @@ export class SkillDataModel extends foundry.abstract.TypeDataModel { damage: new EmbeddedDataField(DamageDataModel, {}), }), hasRoll: new SchemaField({ value: new BooleanField() }), + cost: new EmbeddedDataField(ActionCostDataModel, {}), + targeting: new EmbeddedDataField(TargetingDataModel, {}), }; } diff --git a/module/documents/items/spell/spell-data-model.mjs b/module/documents/items/spell/spell-data-model.mjs index 61e2faa7..7e5f1782 100644 --- a/module/documents/items/spell/spell-data-model.mjs +++ b/module/documents/items/spell/spell-data-model.mjs @@ -8,6 +8,10 @@ import { CheckHooks } from '../../../checks/check-hooks.mjs'; import { CHECK_DETAILS } from '../../../checks/default-section-order.mjs'; import { ChecksV2 } from '../../../checks/checks-v2.mjs'; import { CheckConfiguration } from '../../../checks/check-configuration.mjs'; +import { ResourcePipeline } from '../../../pipelines/resource-pipeline.mjs'; +import { ActionCostDataModel } from '../common/action-cost-data-model.mjs'; +import { TargetingDataModel } from '../common/targeting-data-model.mjs'; +import { TargetChatSectionBuilder } from '../../../helpers/targeting.mjs'; /** * @param {CheckV2} check @@ -43,8 +47,10 @@ Hooks.on(CheckHooks.prepareCheck, prepareCheck); * @param {CheckResultV2} result * @param {FUActor} actor * @param {FUItem} [item] + * @param {Object} flags + * @param {TargetData[]} targets */ -function onRenderCheck(data, result, actor, item) { +function onRenderCheck(data, result, actor, item, flags, targets) { if (item && item.system instanceof SpellDataModel) { data.push(async () => ({ order: CHECK_DETAILS, @@ -52,14 +58,22 @@ function onRenderCheck(data, result, actor, item) { data: { spell: { duration: item.system.duration.value, - target: item.system.target.value, - mpCost: item.system.mpCost.value, + target: item.system.targeting.rule, + mpCost: item.system.cost.value, opportunity: item.system.opportunity, summary: item.system.summary.value, description: await TextEditor.enrichHTML(item.system.description), }, }, })); + + // TODO: Find a better way to handle this, as it's needed when using a spell without accuracy + if (!item.system.hasRoll.value) { + const targetingSection = new TargetChatSectionBuilder(data, actor, item, targets, flags); + targetingSection.push(); + } + + ResourcePipeline.addSpendResourceChatMessageSection(data, actor, item, targets, flags); } } @@ -85,11 +99,14 @@ Hooks.on(CheckHooks.renderCheck, onRenderCheck); * @property {boolean} isOffensive.value * @property {string} opportunity * @property {string} source.value + * @property {Number} maxTargets.value * @property {boolean} rollInfo.useWeapon.hrZero.value * @property {ItemAttributesDataModel} rollInfo.attributes * @property {number} rollInfo.accuracy.value * @property {DamageDataModel} rollInfo.damage * @property {boolean} hasRoll.value + * @property {ActionCostDataModel} cost + * @property {TargetingDataModel} targeting */ export class SpellDataModel extends foundry.abstract.TypeDataModel { static defineSchema() { @@ -109,9 +126,10 @@ export class SpellDataModel extends foundry.abstract.TypeDataModel { impdamage: new EmbeddedDataField(ImprovisedDamageDataModel, {}), isBehavior: new SchemaField({ value: new BooleanField() }), weight: new SchemaField({ value: new NumberField({ initial: 1, min: 1, integer: true, nullable: false }) }), - mpCost: new SchemaField({ value: new StringField() }), - maxTargets: new SchemaField({ value: new NumberField({ initial: 0, integer: true, nullable: false }) }), - target: new SchemaField({ value: new StringField() }), + // Replaced by cost, targeting + //mpCost: new SchemaField({ value: new StringField() }), + //maxTargets: new SchemaField({ value: new NumberField({ initial: 0, integer: true, nullable: false }) }), + //target: new SchemaField({ value: new StringField() }), duration: new SchemaField({ value: new StringField() }), isOffensive: new SchemaField({ value: new BooleanField() }), opportunity: new StringField(), @@ -125,6 +143,8 @@ export class SpellDataModel extends foundry.abstract.TypeDataModel { damage: new EmbeddedDataField(DamageDataModel, {}), }), hasRoll: new SchemaField({ value: new BooleanField() }), + cost: new EmbeddedDataField(ActionCostDataModel, {}), + targeting: new EmbeddedDataField(TargetingDataModel, {}), }; } diff --git a/module/documents/items/spell/spell-migrations.mjs b/module/documents/items/spell/spell-migrations.mjs index acf554f8..0db1970f 100644 --- a/module/documents/items/spell/spell-migrations.mjs +++ b/module/documents/items/spell/spell-migrations.mjs @@ -1,3 +1,5 @@ +import { Targeting } from '../../../helpers/targeting.mjs'; + function migrateQualityToOpportunity(source) { if ('quality' in source && source.quality.value && !('opportunity' in source)) { source.opportunity = source.quality.value; @@ -5,8 +7,33 @@ function migrateQualityToOpportunity(source) { } } +/** + * @param {SpellDataModel} source + */ +function migrateCostAndTargets(source) { + if (source.mpCost && source.cost && source.targeting) { + source.cost.resource = 'mp'; + source.cost.amount = source.mpCost.value; + source.targeting.max = source.maxTargets.value; + + if (source.targeting.max > 1) { + source.targeting.rule = Targeting.rule.multiple; + } else if (source.targeting.max === 1) { + source.targeting.rule = Targeting.rule.single; + } else { + source.targeting.rule = Targeting.rule.self; + } + + // Delete old properties? + delete source.mpCost; + delete source.maxTargets; + delete source.target; + } +} + export class SpellMigrations { static run(source) { migrateQualityToOpportunity(source); + migrateCostAndTargets(source); } } diff --git a/module/helpers/config.mjs b/module/helpers/config.mjs index 5e6939a7..5013902e 100644 --- a/module/helpers/config.mjs +++ b/module/helpers/config.mjs @@ -559,3 +559,14 @@ FU.difficultyLevel = { hard: 'FU.Hard', veryHard: 'FU.VeryHard', }; + +/** + * @typedef {"self", "single", "multiple", "weapon", "special"} TargetingRule + */ +FU.targetingRules = { + self: 'FU.Self', + single: 'FU.Single', + multiple: 'FU.Multiple', + weapon: `FU.Weapon`, + special: `FU.Special`, +}; diff --git a/module/helpers/flags.mjs b/module/helpers/flags.mjs index 9d135e4b..509b1a8e 100644 --- a/module/helpers/flags.mjs +++ b/module/helpers/flags.mjs @@ -16,7 +16,9 @@ export const Flags = Object.freeze({ SupportCheck: 'Supporter', GroupCheckSupporters: 'GroupCheckSupporters', Item: 'Item', + ResourceLoss: 'ResourceLoss', UseMetaCurrency: 'UseMetaCurrency', + Targets: 'Targets', }), Scope: 'projectfu', Modifier: Object.freeze({ diff --git a/module/helpers/inline-helper.mjs b/module/helpers/inline-helper.mjs index 149561e6..e5890959 100644 --- a/module/helpers/inline-helper.mjs +++ b/module/helpers/inline-helper.mjs @@ -17,6 +17,15 @@ export class InlineSourceInfo { this.itemUuid = itemUuid; } + /** + * @param {FUActor} actor + * @param {FUItem} item + * @return {InlineSourceInfo} + */ + static fromInstance(actor, item) { + return new InlineSourceInfo(item.name, actor.uuid, item.uuid); + } + /** * @returns {FUActor|null} */ @@ -27,6 +36,16 @@ export class InlineSourceInfo { return null; } + /** + * @returns {FUItem|null} + */ + resolveItem() { + if (this.itemUuid) { + return fromUuidSync(this.itemUuid); + } + return null; + } + /** * @returns {FUActor|FUItem} */ diff --git a/module/helpers/target-handler.mjs b/module/helpers/target-handler.mjs index 2db7cea0..9f88c4b9 100644 --- a/module/helpers/target-handler.mjs +++ b/module/helpers/target-handler.mjs @@ -42,7 +42,7 @@ export async function getPrioritizedUserTargeted() { if (game.user.character) { targets.push(game.user.character); } else { - ui.notifications.warn('FU.ChatApplyEffectNoActorsTargeted', { localize: true }); + ui.notifications.warn('FU.ChatApplyNoTargetSelected', { localize: true }); } } return targets || []; diff --git a/module/helpers/targeting.mjs b/module/helpers/targeting.mjs new file mode 100644 index 00000000..b93463c7 --- /dev/null +++ b/module/helpers/targeting.mjs @@ -0,0 +1,312 @@ +import { ChooseWeaponDialog } from '../documents/items/skill/choose-weapon-dialog.mjs'; +import { CHECK_RESULT } from '../checks/default-section-order.mjs'; +import { Pipeline } from '../pipelines/pipeline.mjs'; +import { Flags } from './flags.mjs'; +import { SYSTEM } from './config.mjs'; +import { FUActor } from '../documents/actors/actor.mjs'; +import { RenderCheckSectionBuilder } from '../checks/check-hooks.mjs'; +import { getTargeted } from './target-handler.mjs'; + +/** + * @typedef {"self", "single", "multiple", "weapon", "special"} TargetingRule + */ + +/** + * @typedef TargetData + * @property {string} name The name of the actor + * @property {string} uuid The uuid of the actor + * @property {string} link An html link to the actor + * @property {"none", "hit", "miss"} result + * //TODO: Add a map of optional properties? + * @property {number} difficulty Additional information + */ + +// TODO: Make an option for GM +/** + * @type {boolean} + */ +const STRICT_TARGETING = false; + +/** + * @param {FUActor} actor + * @param {FUItem} item + * @param {FUActor[] | Token[] | TargetData[]} targets + * @returns {FUActor[]|FUItem} + */ +async function filterTargetsByRule(actor, item, targets) { + if (!item.system.targeting) { + throw Error(`"No targeting data model in the given item ${item.name}`); + } + + /** + * @type {TargetingDataModel} + */ + const targeting = item.system.targeting; + + switch (targeting.rule) { + case 'self': + return [actor]; + case 'single': + if (targets.length === 0) { + return []; + } else if (targets.length > 1) { + ui.notifications.warn('FU.ChatApplyMaxTargetWarning', { localize: true }); + return []; + } + return [targets[0]]; + case 'multiple': + if (targets.length === 0) { + return []; + } else if (targets.length > targeting.max.value) { + ui.notifications.warn('FU.ChatApplyMaxTargetWarning', { localize: true }); + return []; + } + return targets; + case 'weapon': { + const weapon = await ChooseWeaponDialog.prompt(actor); + return [weapon]; + } + case 'special': + return []; + } +} + +/** + * @property {String} name The name of the action to be used by jQuery + * @property {String} icon The font awesome icon + * @property {String} tooltip The localized tooltip to use + * @property {Object} fields The fields to use for the action's dataset + * @remarks Expects an action handler where dataset.id is a reference to an actor + */ +class TargetAction { + constructor(name, icon, tooltip, fields) { + this.name = name; + this.icon = icon; + this.tooltip = tooltip; + this.fields = fields ?? {}; + } +} + +/** + * @type {TargetAction} + * @description Target the token + */ +const defaultAction = new TargetAction('targetSingle', 'fa-bullseye', 'FU.ChatContextRetarget'); + +/** + * @inheritDoc + * @property {TargetAction[]} section.data.actions + * @property {TargetAction} section.data.selectedAction + */ +export class TargetChatSectionBuilder extends RenderCheckSectionBuilder { + constructor(data, actor, item, targets, flags) { + super(data, actor, item, targets, flags, CHECK_RESULT, 'systems/projectfu/templates/chat/partials/chat-targets.hbs'); + Pipeline.toggleFlag(flags, Flags.ChatMessage.Targets); + this.section.data.actions = []; + this.section.data.selectedActions = []; + if (item.system.targeting) { + this.withTargetingFromModel(); + } + this.addTargetAction(defaultAction); + } + + /** + * Adds targeting data if the {@link FUItem}'s data model has a {@link TargetingDataModel} + */ + withTargetingFromModel() { + this.targeting = this.item.system.targeting; + this.section.data.rule = this.targeting.rule ?? Targeting.rule.multiple; + this.addData(async (data) => { + data.targets = await filterTargetsByRule(this.actor, this.item, this.targets); + }); + return this; + } + + /** + * @description Adds targeting data directly + * @param {TargetData[]} targets + * + */ + withDefaultTargeting() { + this.section.data.rule = this.targets?.length > 1 ? Targeting.rule.multiple : Targeting.rule.single; + this.section.data.targets = this.targets; + return this; + } + + /** + * @param {TargetAction} action An action to be applied on a target that was snapshot when the message was created + */ + addTargetAction(action) { + this.addData(async (data) => { + data.actions.push(action); + }); + } + + /** + * @param {TargetAction} action An action to be applied on a target that is selected when invokeed + */ + addSelectedAction(action) { + this.addData(async (data) => { + data.selectedActions.push(action); + }); + } + + applyDamage(accuracyData, damageData) { + const action = new TargetAction('applyDamage', 'fa-heart-crack', 'FU.ChatApplyDamageTooltip', { + accuracy: accuracyData, + damage: damageData, + }); + + this.addTargetAction(action); + + const selectedAction = new TargetAction('applyDamageSelected', 'fa-heart-crack', 'FU.ChatApplyDamageTooltip', { + accuracy: accuracyData, + damage: damageData, + }); + + this.addSelectedAction(selectedAction); + } + + validate() { + if (!super.validate()) { + return false; + } + + if (!STRICT_TARGETING) { + return true; + } + + const rule = this.section.data.rule; + const targets = this.section.data.targets; + switch (rule) { + case Targeting.rule.multiple: + return targets.length >= 1; + case Targeting.rule.single: + case Targeting.rule.self: + return targets.length === 1; + default: + break; + } + return true; + } +} + +/** + * @returns {TargetData[]} + */ +function getSerializedTargetData() { + const targets = getTargeted(); + return serializeTargetData(targets); +} + +/** + * @param {FUActor[]} targets + * @return {TargetData[]} + */ +function serializeTargetData(targets) { + return targets.map((target) => { + return { + name: target.name, + uuid: target.uuid, + link: target.link, + result: 'none', + }; + }); +} + +/** + * @param {TargetData[]} targetData + * @return {FUActor[]} + */ +function deserializeTargetData(targetData) { + const targets = targetData.map((t) => fromUuidSync(t.uuid)); + return targets; +} + +/** + * @param {Document} document + * @param {jQuery} jQuery + */ +function onRenderChatMessage(document, jQuery) { + if (!document.getFlag(SYSTEM, Flags.ChatMessage.Targets)) { + return; + } + + jQuery.find(`a[data-action=${defaultAction.name}]`).click(function (event) { + console.debug(`Targeting ${this.dataset.id}`); + const actor = fromUuidSync(this.dataset.id); + const token = actor.token.object; + if (!validateCombatant(token)) { + return; + } + return pingCombatant(token); + }); +} + +function addDamageTargetingSection(data, actor, item, targets, flags, accuracyData, damageData) { + const isTargeted = targets?.length > 0 || !STRICT_TARGETING; + if (isTargeted) { + const targetingSection = new TargetChatSectionBuilder(data, actor, item, targets, flags); + targetingSection.withDefaultTargeting(targets); + targetingSection.applyDamage(accuracyData, damageData); + targetingSection.push(); + + async function showFloatyText(target) { + const actor = await fromUuid(target.uuid); + if (actor instanceof FUActor) { + actor.showFloatyText(game.i18n.localize(target.result === 'hit' ? 'FU.Hit' : 'FU.Miss')); + } + } + + if (game.dice3d) { + Hooks.once('diceSoNiceRollComplete', () => { + for (const target of targets) { + showFloatyText(target); + } + }); + } else { + for (const target of targets) { + showFloatyText(target); + } + } + } +} + +function validateCombatant(token) { + const canvas = game.canvas; + if (!canvas.ready || token.scene.id !== canvas.scene.id) { + return false; + } + if (!token.visible) { + return ui.notifications.warn(game.i18n.localize('COMBAT.WarnNonVisibleToken')); + } + return true; +} + +async function pingCombatant(token) { + const canvas = game.canvas; + await canvas.ping(token.center); + await panToCombatant(token); +} + +async function panToCombatant(token) { + const canvas = game.canvas; + const { x, y } = token.center; + await canvas.animatePan({ x, y, scale: Math.max(canvas.stage.scale.x, 0.5) }); +} + +export const Targeting = Object.freeze({ + rule: { + self: 'self', + single: 'single', + multiple: 'multiple', + weapon: 'weapon', + special: 'special', + }, + filterTargetsByRule, + getSerializedTargetData, + serializeTargetData, + deserializeTargetData, + onRenderChatMessage, + addDamageTargetingSection, +}); diff --git a/module/helpers/templates.mjs b/module/helpers/templates.mjs index 907a725f..425462be 100644 --- a/module/helpers/templates.mjs +++ b/module/helpers/templates.mjs @@ -48,6 +48,8 @@ export const preloadHandlebarsTemplates = async function () { // Item partials 'systems/projectfu/templates/item/partials/item-progress-clock.hbs', 'systems/projectfu/templates/item/partials/item-resource-points.hbs', + 'systems/projectfu/templates/item/partials/item-action-cost.hbs', + 'systems/projectfu/templates/item/partials/item-targeting.hbs', 'systems/projectfu/templates/item/partials/item-controls.hbs', 'systems/projectfu/templates/item/partials/item-header.hbs', 'systems/projectfu/templates/item/partials/item-progress-header.hbs', @@ -80,6 +82,7 @@ export const preloadHandlebarsTemplates = async function () { 'systems/projectfu/templates/chat/partials/chat-item-quality.hbs', 'systems/projectfu/templates/chat/partials/chat-spell-details.hbs', 'systems/projectfu/templates/chat/partials/chat-weapon-details.hbs', + 'systems/projectfu/templates/chat/partials/chat-item-spend-resource.hbs', 'systems/projectfu/templates/chat/partials/chat-check-push.hbs', 'systems/projectfu/templates/chat/partials/chat-check-reroll.hbs', 'systems/projectfu/templates/chat/partials/chat-basic-attack-details.hbs', @@ -93,6 +96,7 @@ export const preloadHandlebarsTemplates = async function () { 'systems/projectfu/templates/chat/partials/chat-opposed-check-details.hbs', 'systems/projectfu/templates/chat/partials/chat-clock-details.hbs', 'systems/projectfu/templates/chat/partials/chat-resource-details.hbs', + 'systems/projectfu/templates/chat/partials/chat-targets.hbs', // UI Components 'systems/projectfu/templates/ui/combat-tracker.hbs', diff --git a/module/pipelines/damage-pipeline.mjs b/module/pipelines/damage-pipeline.mjs index 17484520..e0dd2db5 100644 --- a/module/pipelines/damage-pipeline.mjs +++ b/module/pipelines/damage-pipeline.mjs @@ -310,7 +310,7 @@ async function process(request) { // TODO: Move elsewhere /** - * @param {?} message + * @param {Document} message * @param {jQuery} jQuery */ function onRenderChatMessage(message, jQuery) { @@ -342,33 +342,41 @@ function onRenderChatMessage(message, jQuery) { } } - const handleClick = async (event, getTargetsFunction) => { + const customizeDamage = async (event, targets) => { + DamageCustomizer( + baseDamageInfo, + targets, + async (extraDamageInfo) => { + await handleDamageApplication(event, targets, sourceUuid, sourceName, baseDamageInfo, extraDamageInfo); + disabled = false; + }, + () => { + disabled = false; + }, + ); + }; + + const applyDefaultDamage = async (event, targets) => { + return handleDamageApplication(event, targets, sourceUuid, sourceName, baseDamageInfo, {}); + }; + + const handleClick = async (event, getTargetsFunction, action, alternateAction) => { event.preventDefault(); if (!disabled) { disabled = true; const targets = await getTargetsFunction(event); if (event.ctrlKey || event.metaKey) { - DamageCustomizer( - baseDamageInfo, - targets, - (extraDamageInfo) => { - handleDamageApplication(event, targets, sourceUuid, sourceName, baseDamageInfo, extraDamageInfo); - disabled = false; - }, - () => { - disabled = false; - }, - ); + await alternateAction(event, targets); + disabled = false; } else { - handleDamageApplication(event, targets, sourceUuid, sourceName, baseDamageInfo, {}); + await action(event, targets); disabled = false; } } }; - jQuery.find(`a[data-action=applySingleDamage]`).click((event) => handleClick(event, Pipeline.getSingleTarget)); - jQuery.find(`a[data-action=applySelectedDamage]`).click((event) => handleClick(event, getSelected)); - jQuery.find(`a[data-action=applyTargetedDamage]`).click((event) => handleClick(event, getTargeted)); + jQuery.find(`a[data-action=applyDamage]`).click((event) => handleClick(event, Pipeline.getSingleTarget, applyDefaultDamage, customizeDamage)); + jQuery.find(`a[data-action=applyDamageSelected]`).click((event) => handleClick(event, getSelected, applyDefaultDamage, customizeDamage)); jQuery.find(`a[data-action=selectDamageCustomizer]`).click(async (event) => { if (!disabled) { @@ -388,25 +396,14 @@ function onRenderChatMessage(message, jQuery) { } }); - const revertDamage = jQuery.find(`a[data-action=revertDamage]`); - revertDamage.click(async (event) => { - const amount = revertDamage.data(`amount`); - const uuid = revertDamage.data(`uuid`); + Pipeline.handleClickRevert(jQuery, 'revertDamage', async (dataset) => { + const uuid = dataset.uuid; + const amount = dataset.amount; const target = fromUuidSync(uuid); const updates = []; const amountRecovered = Math.max(0, amount + (target.system.bonuses.incomingRecovery['hp'] || 0)); updates.push(target.modifyTokenAttribute('resources.hp', amountRecovered, true)); - // Disable the revert - event.preventDefault(); - revertDamage.addClass('disabled').css({ - 'pointer-events': 'none', - opacity: '0.5', - }); - jQuery.addClass('strikethrough').css({ - 'text-decoration': 'line-through', - }); - return Promise.all(updates); }); } diff --git a/module/pipelines/pipeline.mjs b/module/pipelines/pipeline.mjs index 5bb5d368..ecd1e8d1 100644 --- a/module/pipelines/pipeline.mjs +++ b/module/pipelines/pipeline.mjs @@ -5,6 +5,8 @@ * @prop {boolean} shift */ +import { SYSTEM } from '../helpers/config.mjs'; + /** * @property {InlineSourceInfo} sourceInfo * @property {FUActor[]} targets @@ -69,7 +71,77 @@ function getSingleTarget(event) { return [actor]; } +/** + * @param {Event} event + * @param {Object} dataset + * @param {Function} getTargetsFunction + * @param {Function} defaultAction + * @param {Function} alternateAction + */ +async function handleClick(event, dataset, getTargetsFunction, defaultAction, alternateAction = null) { + event.preventDefault(); + if (!dataset.disabled) { + dataset.disabled = true; + const targets = getTargetsFunction ? await getTargetsFunction(event) : []; + if (event.ctrlKey || event.metaKey) { + if (alternateAction) { + await alternateAction(event, dataset, targets); + } + dataset.disabled = false; + } else { + await defaultAction(event, dataset, targets); + dataset.disabled = false; + } + } +} + +/** + * @param {jQuery} jQuery + * @param {String} actionName The name of the data-action in the html, e.g: `a[data-action=...]` + * @param {Function} action + * @returns {Promise<*>} + */ +async function handleClickRevert(jQuery, actionName, action) { + const revert = jQuery.find(`a[data-action=${actionName}]`); + revert.click(async (event) => { + event.preventDefault(); + revert.addClass('disabled').css({ + 'pointer-events': 'none', + opacity: '0.5', + }); + jQuery.addClass('strikethrough').css({ + 'text-decoration': 'line-through', + }); + + return action(revert.data()); + }); +} + +/** + * @param {Map} flags + * @param {String} key + * @remarks Documented in {@link Flags} + */ +function toggleFlag(flags, key) { + (flags[SYSTEM] ??= {})[key] ??= true; +} + +/** + * @description Constructs an initialized flags object to be assigned in a ChatMessage + * @param {String} key + * @param {*} value + * @returns {Object} + * @remarks Documented in {@link Flags} + */ +function initializedFlags(key, value) { + return { [SYSTEM]: { [key]: value } }; +} + export const Pipeline = { getSingleTarget, process, + handleClick, + handleClickRevert, + toggleFlag, + initializedFlags, }; diff --git a/module/pipelines/resource-pipeline.mjs b/module/pipelines/resource-pipeline.mjs index 5c7c9882..50d62919 100644 --- a/module/pipelines/resource-pipeline.mjs +++ b/module/pipelines/resource-pipeline.mjs @@ -1,5 +1,9 @@ -import { PipelineRequest } from './pipeline.mjs'; -import { FU } from '../helpers/config.mjs'; +import { Pipeline, PipelineRequest } from './pipeline.mjs'; +import { FU, SYSTEM } from '../helpers/config.mjs'; +import { InlineSourceInfo } from '../helpers/inline-helper.mjs'; +import { CHECK_RESULT } from '../checks/default-section-order.mjs'; +import { Flags } from '../helpers/flags.mjs'; +import { RenderCheckSectionBuilder } from '../checks/check-hooks.mjs'; /** * @property {Number} amount @@ -16,14 +20,27 @@ export class ResourceRequest extends PipelineRequest { this.uncapped = uncapped; } - get isValue() { + /** + * @returns {boolean} True if the resource is FP, EXP or ZENIT + */ + get isMetaCurrency() { return this.resourceType === 'fp' || this.resourceType === 'exp' || this.resourceType === 'zenit'; } + /** + * @returns {string} The key to the resource within the actor's data model + */ get attributeKey() { return `resources.${this.resourceType}`; } + /** + * @returns {string} The full path to the accessor for resource in an actor's data model + */ + get attributePath() { + return `resources.${this.resourceType}.value`; + } + get resourceLabel() { return game.i18n.localize(FU.resources[this.resourceType]); } @@ -47,6 +64,32 @@ const recoveryMessages = { zenit: 'FU.ChatResourceGain', }; +/** + * @param {FUActor} actor + * @param {String} resourcePath + * @returns {number|number} + */ +function getResourcetValue(actor, resourcePath) { + return parseInt(foundry.utils.getProperty(actor.system, resourcePath), 10) || 0; +} + +/** + * @param {FUActor} actor + * @param {String} attributePath + * @param {Number} amountRecovered + * @returns {Promise<*> + */ +function createUpdateForRecovery(actor, attributePath, amountRecovered) { + const currentValue = getResourcetValue(actor, attributePath); + const newValue = Math.floor(currentValue) + Math.floor(amountRecovered); + + // Update the actor's resource directly + const updateData = { + [`system.${attributePath}`]: Math.floor(newValue), + }; + return actor.update(updateData); +} + /** * @param {ResourceRequest} request * @return {Promise[]>} @@ -62,28 +105,19 @@ async function processRecovery(request) { const updates = []; // Handle uncapped recovery logic - if (request.uncapped === true && uncappedRecoveryValue > (attr.max || 0) && !request.isValue) { + if (request.uncapped === true && uncappedRecoveryValue > (attr.max || 0) && !request.isMetaCurrency) { // Overheal recovery const newValue = Object.defineProperties({}, Object.getOwnPropertyDescriptors(attr)); // Clone attribute newValue.value = uncappedRecoveryValue; updates.push(actor.modifyTokenAttribute(request.attributeKey, newValue, false, false)); - } else if (!request.isValue) { + } else if (!request.isMetaCurrency) { // Normal recovery updates.push(actor.modifyTokenAttribute(request.attributeKey, amountRecovered, true)); } // Handle specific cases for fp and exp - if (request.isValue) { - const currentValue = parseInt(foundry.utils.getProperty(actor.system, `${request.attributeKey}.value`), 10) || 0; - const newValue = Math.floor(currentValue) + Math.floor(amountRecovered); - - // Update the actor's resource directly - const updateData = { - [`system.${request.attributeKey}.value`]: Math.floor(newValue), - }; - // TODO: Verify this was indeed not needed to be done here - //await actor.update(updateData); - updates.push(actor.update(updateData)); + if (request.isMetaCurrency) { + updates.push(createUpdateForRecovery(actor, request.attributePath, amountRecovered)); } updates.push( @@ -122,14 +156,13 @@ async function processLoss(request) { const updates = []; for (const actor of request.targets) { - if (request.isValue) { + if (request.isMetaCurrency) { const currentValue = foundry.utils.getProperty(actor.system, `${request.attributeKey}.value`) || 0; const newValue = Math.floor(currentValue) + Math.floor(amountLost); // Update the actor's resource directly const updateData = {}; updateData[`system.${request.attributeKey}.value`] = Math.floor(newValue); - //await actor.update(updateData); updates.push(actor.update(updateData)); } else { updates.push(actor.modifyTokenAttribute(`${request.attributeKey}`, amountLost, true)); @@ -139,11 +172,15 @@ async function processLoss(request) { ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: flavor, + flags: Pipeline.initializedFlags(Flags.ChatMessage.ResourceLoss, true), content: await renderTemplate('systems/projectfu/templates/chat/chat-apply-loss.hbs', { message: 'FU.ChatResourceLoss', actor: actor.name, amount: request.amount, - resource: request.resourceLabel, + uuid: actor.uuid, + resource: request.resourceType, + key: request.attributeKey, + resourceLabel: request.resourceLabel, from: request.sourceInfo.name, }), }), @@ -152,7 +189,103 @@ async function processLoss(request) { return Promise.all(updates); } +/** + * @param {CheckRenderData} data + * @param {FUActor} actor + * @param {FUItem} item + * @param {TargetData[]} targets + * @param {Object} flags + */ +function addSpendResourceChatMessageSection(data, actor, item, targets, flags) { + if (item.system.cost) { + if (item.system.cost.amount === 0) { + return; + } + + const expense = calculateExpense(item, targets); + if (expense.amount === 0) { + return; + } + + const builder = new RenderCheckSectionBuilder(data, actor, item, targets, flags, CHECK_RESULT, 'systems/projectfu/templates/chat/partials/chat-item-spend-resource.hbs'); + builder.addData(async (data) => { + data.expense = expense; + data.icon = FU.resourceIcons[item.system.cost.resource]; + }); + builder.toggleFlag(Flags.ChatMessage.ResourceLoss); + builder.push(); + } +} + +/** + * @typedef ResourceExpense + * @property {String} resource + * @property {Number} amount + */ + +/** + * @param {FUItem} item + * @param {TargetData[]} targets + * @return {ResourceExpense} + */ +function calculateExpense(item, targets) { + let amount = item.system.cost.amount; + let resource = item.system.cost.resource; + let maxTargets = item.system.targeting.max; + + if (maxTargets > 1) { + if (targets.length === 0) { + console.warn(`Wrong number of targets given (${targets.length}) for calculating resource expense. Using default of 1.`); + } else { + amount = amount * targets.length; + } + } + + return { + resource: resource, + amount: amount, + }; +} + +/** + * @param {Document} document + * @param {jQuery} jQuery + */ +function onRenderChatMessage(document, jQuery) { + if (!document.getFlag(SYSTEM, Flags.ChatMessage.ResourceLoss)) { + return; + } + + /** + * @param {Event} event + * @param dataset + * @param {FUActor[]} targets + * @returns {Promise[]>} + */ + const applyResourceLoss = async (event, dataset) => { + const sourceInfo = new InlineSourceInfo(dataset.name, dataset.actor, dataset.item); + const actor = sourceInfo.resolveActor(); + const request = new ResourceRequest(sourceInfo, [actor], dataset.resource, dataset.amount); + return ResourcePipeline.processLoss(request); + }; + + Pipeline.handleClickRevert(jQuery, 'revertResourceLoss', async (dataset) => { + const actor = fromUuidSync(dataset.uuid); + const amount = dataset.amount; + const attributeKey = dataset.key; + const updates = []; + updates.push(actor.modifyTokenAttribute(attributeKey, amount, true)); + return Promise.all(updates); + }); + + jQuery.find(`a[data-action=applyResourceLoss]`).click(function (event) { + return Pipeline.handleClick(event, this.dataset, null, applyResourceLoss); + }); +} + export const ResourcePipeline = { processRecovery, processLoss, + onRenderChatMessage, + addSpendResourceChatMessageSection, }; diff --git a/module/projectfu.mjs b/module/projectfu.mjs index fbd2661b..b9c20da8 100644 --- a/module/projectfu.mjs +++ b/module/projectfu.mjs @@ -66,7 +66,9 @@ import { StudyRollHandler } from './helpers/study-roll.mjs'; import { ItemCustomizer } from './helpers/item-customizer.mjs'; import { FUHooks } from './hooks.mjs'; import { DamagePipeline } from './pipelines/damage-pipeline.mjs'; +import { ResourcePipeline } from './pipelines/resource-pipeline.mjs'; import { InlineWeapon } from './helpers/inline-weapon.mjs'; +import { Targeting } from './helpers/targeting.mjs'; globalThis.projectfu = { ClassFeatureDataModel, @@ -230,6 +232,8 @@ Hooks.once('init', async () => { Hooks.on('getChatLogEntryContext', addRollContextMenuEntries); Hooks.on('renderChatMessage', DamagePipeline.onRenderChatMessage); + Hooks.on(`renderChatMessage`, ResourcePipeline.onRenderChatMessage); + Hooks.on(`renderChatMessage`, Targeting.onRenderChatMessage); registerClassFeatures(CONFIG.FU.classFeatureRegistry); registerOptionalFeatures(CONFIG.FU.optionalFeatureRegistry); diff --git a/module/sheets/actor-standard-sheet.mjs b/module/sheets/actor-standard-sheet.mjs index 68ce73ca..96ebde37 100644 --- a/module/sheets/actor-standard-sheet.mjs +++ b/module/sheets/actor-standard-sheet.mjs @@ -237,8 +237,8 @@ export class FUStandardActorSheet extends ActorSheet { i.equippedSlot = i.system.isEquipped && i.system.isEquipped.slot ? true : false; i.level = i.system.level?.value; i.class = i.system.class?.value; - i.mpCost = i.system.mpCost?.value; - i.target = i.system.target?.value; + i.mpCost = i.system.cost?.amount; + i.target = i.system.targeting?.rule; i.duration = i.system.duration?.value; i.dLevel = i.system.dLevel?.value; i.clock = i.system.clock?.value; diff --git a/module/sheets/item-sheet.mjs b/module/sheets/item-sheet.mjs index f9313ebb..2b0527bb 100644 --- a/module/sheets/item-sheet.mjs +++ b/module/sheets/item-sheet.mjs @@ -45,6 +45,9 @@ export class FUItemSheet extends ItemSheet { /* -------------------------------------------- */ + /** + * @remarks Used for rendering the sheets + */ /** @override */ async getData() { // Retrieve base data structure. @@ -78,7 +81,7 @@ export class FUItemSheet extends ItemSheet { effect.enrichedDescription = effect.description ? await TextEditor.enrichHTML(effect.description) : ''; } - //Add CONFIG data required + // Add CONFIG data required context.attrAbbr = CONFIG.FU.attributeAbbreviations; context.damageTypes = CONFIG.FU.damageTypes; context.wpnType = CONFIG.FU.weaponTypes; @@ -89,6 +92,8 @@ export class FUItemSheet extends ItemSheet { context.consumableType = CONFIG.FU.consumableType; context.treasureType = CONFIG.FU.treasureType; context.defenses = CONFIG.FU.defenses; + context.resAbbr = CONFIG.FU.resourcesAbbr; + context.targetingRules = CONFIG.FU.targetingRules; // Add the actor object to context for easier access context.actor = actorData; diff --git a/templates/actor/sections/actor-section-spells.hbs b/templates/actor/sections/actor-section-spells.hbs index 4947f639..bc95a9a3 100644 --- a/templates/actor/sections/actor-section-spells.hbs +++ b/templates/actor/sections/actor-section-spells.hbs @@ -83,7 +83,7 @@ {{!-- Item Name--}} {{> "systems/projectfu/templates/actor/partials/actor-item-name.hbs" item=item}}
{{ capitalize item.duration }}
-
{{ item.target }}
+
{{ item.target}}
{{ item.mpCost }}
{{!-- Item Control--}} {{> "systems/projectfu/templates/actor/partials/actor-control.hbs" item=item}} diff --git a/templates/chat/chat-apply-loss.hbs b/templates/chat/chat-apply-loss.hbs index cb3980a4..e12f0f5f 100644 --- a/templates/chat/chat-apply-loss.hbs +++ b/templates/chat/chat-apply-loss.hbs @@ -1,3 +1,6 @@
- {{{localize message actor=actor amount=amount resource=resource from=from type=type}}} + {{{localize message actor=actor amount=amount resource=resourceLabel from=from type=type}}} + + +
\ No newline at end of file diff --git a/templates/chat/partials/chat-item-spend-resource.hbs b/templates/chat/partials/chat-item-spend-resource.hbs new file mode 100644 index 00000000..38f7c40e --- /dev/null +++ b/templates/chat/partials/chat-item-spend-resource.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/templates/chat/partials/chat-targets.hbs b/templates/chat/partials/chat-targets.hbs new file mode 100644 index 00000000..e9dd1947 --- /dev/null +++ b/templates/chat/partials/chat-targets.hbs @@ -0,0 +1,42 @@ +{{#if targets}} +
+
+

+ {{localize "FU.Target"}} + {{#if (gt targets.length 1)}} + : {{localize rule}}({{targets.length}}) + {{/if}} +

+
+
+ {{#each targets as |target|}} + + + +
{{target.name}}
+ + {{#if (eq target.result "hit")}} +
+ {{localize (concat 'FU.' (capitalize "hit"))}} +
+ {{/if}} +
+ + {{#each ../actions as |action|}} + + + + + + {{/each}} +
+ {{/each}} +
+{{/if}} +{{#each selectedActions as |action|}} + +{{/each}} \ No newline at end of file diff --git a/templates/item/item-miscAbility-sheet.hbs b/templates/item/item-miscAbility-sheet.hbs index 64c3651f..73aeb4b9 100644 --- a/templates/item/item-miscAbility-sheet.hbs +++ b/templates/item/item-miscAbility-sheet.hbs @@ -159,6 +159,12 @@ {{!-- Resource Pointts --}} {{> "systems/projectfu/templates/item/partials/item-resource-points.hbs"}} + {{!-- Action Cost --}} + {{> "systems/projectfu/templates/item/partials/item-action-cost.hbs"}} + + {{!-- Targeting --}} + {{> "systems/projectfu/templates/item/partials/item-targeting.hbs"}} + {{!-- Clocks --}} {{> "systems/projectfu/templates/item/partials/item-progress-clock.hbs"}} diff --git a/templates/item/item-skill-sheet.hbs b/templates/item/item-skill-sheet.hbs index 9cb003f5..00fe44e0 100644 --- a/templates/item/item-skill-sheet.hbs +++ b/templates/item/item-skill-sheet.hbs @@ -171,6 +171,12 @@ {{!-- Resource Pointts --}} {{> "systems/projectfu/templates/item/partials/item-resource-points.hbs"}} + {{!-- Action Cost --}} + {{> "systems/projectfu/templates/item/partials/item-action-cost.hbs"}} + + {{!-- Targeting --}} + {{> "systems/projectfu/templates/item/partials/item-targeting.hbs"}} + diff --git a/templates/item/item-spell-sheet.hbs b/templates/item/item-spell-sheet.hbs index 1ea3663d..28301863 100644 --- a/templates/item/item-spell-sheet.hbs +++ b/templates/item/item-spell-sheet.hbs @@ -8,37 +8,6 @@ {{!-- Sheet Body --}}
- {{!-- Main Attributes --}} -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- {{!-- Effects Tab --}}
{{> 'systems/projectfu/templates/common/active-effects.hbs' hideHeader=true }} @@ -163,6 +132,10 @@ {{!-- Behavior Field --}} {{> "systems/projectfu/templates/item/partials/item-behavior-field.hbs"}} + {{!-- Action Cost --}} + {{> "systems/projectfu/templates/item/partials/item-action-cost.hbs"}} + {{!-- Targeting --}} + {{> "systems/projectfu/templates/item/partials/item-targeting.hbs"}}
diff --git a/templates/item/partials/item-action-cost.hbs b/templates/item/partials/item-action-cost.hbs new file mode 100644 index 00000000..4e01c515 --- /dev/null +++ b/templates/item/partials/item-action-cost.hbs @@ -0,0 +1,22 @@ +
+ + + + +
+ +
+ + +
+
+ + +
+ +
+
\ No newline at end of file diff --git a/templates/item/partials/item-spell-sheet-key-attributes.hbs b/templates/item/partials/item-spell-sheet-key-attributes.hbs new file mode 100644 index 00000000..b1e607f8 --- /dev/null +++ b/templates/item/partials/item-spell-sheet-key-attributes.hbs @@ -0,0 +1,30 @@ +{{!-- Main Attributes --}} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
\ No newline at end of file diff --git a/templates/item/partials/item-targeting.hbs b/templates/item/partials/item-targeting.hbs new file mode 100644 index 00000000..4fe2ac25 --- /dev/null +++ b/templates/item/partials/item-targeting.hbs @@ -0,0 +1,22 @@ +
+ + + + +
+
+ + +
+ {{#if (eq system.targeting.rule "multiple")}} +
+ + +
+ {{/if}} +
+
\ No newline at end of file