[Feature] Custom Resources (#1714)

* Initial

* .

* Fixed positioning

* .

* Only showing the menu if there are extra resources

* Improved resourceManager clickable

* .

* Changed variable name

* Refactor resources selection and data prep (#1721)

* Move resources select to scrolly text and accept actor object

* Convert isReversed to prepared data and add label

* Removed unused imports

---------

Co-authored-by: WBHarry <williambjrklund@gmail.com>

* Naming

* [Feature] Custom Homebrew Resources (#1718)

* Added resources to the Homebrew Menu

* Fixed translations

* .

* Inverted from isImage to isIcon. Should be more logical for users

* Removed testing resources

* Refactor resource settings to not be a method (#1723)

* Fix editing homebrew resources with a custom ResourcesField

* Fix removing homebrew resources

* Remove vestigial code

* Use custom config for module data instead of including in all (#1724)

* Use custom config for module data instead of including in all

* More simple

* base highest priority

---------

Co-authored-by: Carlos Fernandez <CarlosFdez@users.noreply.github.com>
Co-authored-by: Carlos Fernandez <cfern1990@gmail.com>
This commit is contained in:
WBHarry 2026-03-11 11:10:28 +01:00 committed by GitHub
parent af04fb33d0
commit 552c62adc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 970 additions and 189 deletions

View file

@ -233,6 +233,11 @@ Hooks.once('init', () => {
return handlebarsRegistration(); return handlebarsRegistration();
}); });
Hooks.on('i18nInit', () => {
// Setup homebrew resources
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).refreshConfig();
});
Hooks.on('setup', () => { Hooks.on('setup', () => {
CONFIG.statusEffects = [ CONFIG.statusEffects = [
...CONFIG.statusEffects.filter(x => !['dead', 'unconscious'].includes(x.id)), ...CONFIG.statusEffects.filter(x => !['dead', 'unconscious'].includes(x.id)),

View file

@ -2281,6 +2281,7 @@
"identify": "Identity", "identify": "Identity",
"imagePath": "Image Path", "imagePath": "Image Path",
"inactiveEffects": "Inactive Effects", "inactiveEffects": "Inactive Effects",
"initial": "Initial",
"inventory": "Inventory", "inventory": "Inventory",
"itemResource": "Item Resource", "itemResource": "Item Resource",
"itemQuantity": "Item Quantity", "itemQuantity": "Item Quantity",
@ -2666,6 +2667,8 @@
"resetMovesText": "Are you sure you want to reset?", "resetMovesText": "Are you sure you want to reset?",
"deleteItemTitle": "Delete Homebrew Item", "deleteItemTitle": "Delete Homebrew Item",
"deleteItemText": "Are you sure you want to delete the item?", "deleteItemText": "Are you sure you want to delete the item?",
"deleteResourceTitle": "Delete Homebrew Resource",
"deleteResourceText": "Are you sure you want to delete the resource?",
"FIELDS": { "FIELDS": {
"maxFear": { "label": "Max Fear" }, "maxFear": { "label": "Max Fear" },
"maxHope": { "label": "Max Hope" }, "maxHope": { "label": "Max Hope" },
@ -2674,6 +2677,13 @@
"label": "Max Cards in Loadout", "label": "Max Cards in Loadout",
"hint": "Set to blank or 0 for unlimited maximum" "hint": "Set to blank or 0 for unlimited maximum"
}, },
"resources": {
"resources": {
"value": { "label": "Icon" },
"isIcon": { "label": "Font Awesome Icon" },
"noColorFilter": { "label": "Disable Color Filter" }
}
},
"maxDomains": { "label": "Max Class Domains", "hint": "Max domains you can set on a class" } "maxDomains": { "label": "Max Class Domains", "hint": "Max domains you can set on a class" }
}, },
"currency": { "currency": {
@ -2702,6 +2712,13 @@
"adversaryType": { "adversaryType": {
"title": "Custom Adversary Types", "title": "Custom Adversary Types",
"newType": "Adversary Type" "newType": "Adversary Type"
},
"resources": {
"typeTitle": "{type} Resources",
"filledIcon": "Filled Icon",
"emptyIcon": "Empty Icon",
"resourceIdentifier": "Resource Identifier",
"setResourceIdentifier": "Set Resource Identifier"
} }
}, },
"Menu": { "Menu": {

View file

@ -1,4 +1,5 @@
import { DhHomebrew } from '../../data/settings/_module.mjs'; import { DhHomebrew } from '../../data/settings/_module.mjs';
import { Resource } from '../../data/settings/Homebrew.mjs';
import { slugify } from '../../helpers/utils.mjs'; import { slugify } from '../../helpers/utils.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -44,6 +45,9 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
addAdversaryType: this.addAdversaryType, addAdversaryType: this.addAdversaryType,
deleteAdversaryType: this.deleteAdversaryType, deleteAdversaryType: this.deleteAdversaryType,
selectAdversaryType: this.selectAdversaryType, selectAdversaryType: this.selectAdversaryType,
addResource: this.addResource,
removeResource: this.removeResource,
resetResourceImage: this.resetResourceImage,
save: this.save, save: this.save,
resetTokenSizes: this.resetTokenSizes, resetTokenSizes: this.resetTokenSizes,
reset: this.reset reset: this.reset
@ -56,6 +60,10 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
settings: { template: 'systems/daggerheart/templates/settings/homebrew-settings/settings.hbs' }, settings: { template: 'systems/daggerheart/templates/settings/homebrew-settings/settings.hbs' },
domains: { template: 'systems/daggerheart/templates/settings/homebrew-settings/domains.hbs' }, domains: { template: 'systems/daggerheart/templates/settings/homebrew-settings/domains.hbs' },
types: { template: 'systems/daggerheart/templates/settings/homebrew-settings/types.hbs' }, types: { template: 'systems/daggerheart/templates/settings/homebrew-settings/types.hbs' },
resources: {
template: 'systems/daggerheart/templates/settings/homebrew-settings/resources.hbs',
scrollable: ['.resource-types-container']
},
itemTypes: { template: 'systems/daggerheart/templates/settings/homebrew-settings/itemFeatures.hbs' }, itemTypes: { template: 'systems/daggerheart/templates/settings/homebrew-settings/itemFeatures.hbs' },
downtime: { template: 'systems/daggerheart/templates/settings/homebrew-settings/downtime.hbs' }, downtime: { template: 'systems/daggerheart/templates/settings/homebrew-settings/downtime.hbs' },
footer: { template: 'systems/daggerheart/templates/settings/homebrew-settings/footer.hbs' } footer: { template: 'systems/daggerheart/templates/settings/homebrew-settings/footer.hbs' }
@ -64,7 +72,14 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
/** @inheritdoc */ /** @inheritdoc */
static TABS = { static TABS = {
main: { main: {
tabs: [{ id: 'settings' }, { id: 'domains' }, { id: 'types' }, { id: 'itemFeatures' }, { id: 'downtime' }], tabs: [
{ id: 'settings' },
{ id: 'domains' },
{ id: 'types' },
{ id: 'resources' },
{ id: 'itemFeatures' },
{ id: 'downtime' }
],
initial: 'settings', initial: 'settings',
labelPrefix: 'DAGGERHEART.GENERAL.Tabs' labelPrefix: 'DAGGERHEART.GENERAL.Tabs'
} }
@ -77,9 +92,17 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
this.render(); this.render();
} }
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
for (const element of htmlElement.querySelectorAll('.path-field input'))
element.addEventListener('change', this.toggleResourceIsIcon.bind(this));
}
async _prepareContext(_options) { async _prepareContext(_options) {
const context = await super._prepareContext(_options); const context = await super._prepareContext(_options);
context.settingFields = this.settings; context.settingFields = this.settings;
context.schemaFields = context.settingFields.schema.fields;
return context; return context;
} }
@ -103,6 +126,8 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
? { id: this.selected.adversaryType, ...this.settings.adversaryTypes[this.selected.adversaryType] } ? { id: this.selected.adversaryType, ...this.settings.adversaryTypes[this.selected.adversaryType] }
: null; : null;
break; break;
case 'resources':
break;
case 'downtime': case 'downtime':
context.restOptions = { context.restOptions = {
shortRest: CONFIG.DH.GENERAL.defaultRestOptions.shortRest(), shortRest: CONFIG.DH.GENERAL.defaultRestOptions.shortRest(),
@ -124,6 +149,33 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
this.render(); this.render();
} }
async toggleResourceIsIcon(event) {
const element = event.target.closest('.resource-icon-container');
const { actorType, resourceKey, imageKey } = element.dataset;
const current = this.settings.resources[actorType].resources[resourceKey].images[imageKey];
await this.settings.updateSource({
[`resources.${actorType}.resources.${resourceKey}.images.${imageKey}`]: {
isIcon: !current.isIcon,
value: ''
}
});
this.render();
}
static async resetResourceImage(_event, button) {
const element = button.closest('.resource-icon-container');
const { actorType, resourceKey, imageKey } = element.dataset;
await this.settings.updateSource({
[`resources.${actorType}.resources.${resourceKey}.images.${imageKey}`]:
Resource.getDefaultImageData(imageKey)
});
this.render();
}
static async changeCurrencyIcon(_, target) { static async changeCurrencyIcon(_, target) {
const type = target.dataset.currency; const type = target.dataset.currency;
const currentIcon = this.settings.currency[type].icon; const currentIcon = this.settings.currency[type].icon;
@ -466,6 +518,58 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
this.render(); this.render();
} }
static async addResource(_, target) {
const { actorType } = target.dataset;
const content = new foundry.data.fields.StringField({
label: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.resources.resourceIdentifier'),
required: true
}).toFormGroup({}, { name: 'identifier', localize: true }).outerHTML;
async function callback(_, button) {
const identifier = button.form.elements.identifier.value;
if (!identifier) return;
const sluggedIdentifier = slugify(identifier);
await this.settings.updateSource({
[`resources.${actorType}.resources.${sluggedIdentifier}`]: Resource.getDefaultResourceData(identifier)
});
game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject());
this.render();
}
await foundry.applications.api.DialogV2.prompt({
content: content,
rejectClose: false,
modal: true,
ok: { callback: callback.bind(this) },
window: {
title: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.resources.setResourceIdentifier')
},
position: { width: 400 }
});
}
static async removeResource(_, target) {
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: {
title: game.i18n.localize(`DAGGERHEART.SETTINGS.Homebrew.deleteResourceTitle`)
},
content: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.deleteResourceText')
});
if (!confirmed) return;
const { actorType, resourceKey } = target.dataset;
await this.settings.updateSource({
[`resources.${actorType}.resources.-=${resourceKey}`]: null
});
game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject());
this.render();
}
static async save() { static async save() {
await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject()); await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject());
this.close(); this.close();

View file

@ -33,6 +33,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
handleResourceDice: CharacterSheet.#handleResourceDice, handleResourceDice: CharacterSheet.#handleResourceDice,
advanceResourceDie: CharacterSheet.#advanceResourceDie, advanceResourceDie: CharacterSheet.#advanceResourceDie,
cancelBeastform: CharacterSheet.#cancelBeastform, cancelBeastform: CharacterSheet.#cancelBeastform,
toggleResourceManagement: CharacterSheet.#toggleResourceManagement,
useDowntime: this.useDowntime, useDowntime: this.useDowntime,
viewParty: CharacterSheet.#viewParty viewParty: CharacterSheet.#viewParty
}, },
@ -226,6 +227,9 @@ export default class CharacterSheet extends DHBaseActorSheet {
async _preparePartContext(partId, context, options) { async _preparePartContext(partId, context, options) {
context = await super._preparePartContext(partId, context, options); context = await super._preparePartContext(partId, context, options);
switch (partId) { switch (partId) {
case 'header':
await this._prepareHeaderContext(context, options);
break;
case 'loadout': case 'loadout':
await this._prepareLoadoutContext(context, options); await this._prepareLoadoutContext(context, options);
break; break;
@ -240,6 +244,12 @@ export default class CharacterSheet extends DHBaseActorSheet {
return context; return context;
} }
async _prepareHeaderContext(context, _options) {
context.hasExtraResources = Object.keys(CONFIG.DH.RESOURCE.character.all).some(
key => !CONFIG.DH.RESOURCE.character.base[key]
);
}
/** /**
* Prepare render context for the Loadout part. * Prepare render context for the Loadout part.
* @param {ApplicationRenderContext} context * @param {ApplicationRenderContext} context
@ -942,6 +952,78 @@ export default class CharacterSheet extends DHBaseActorSheet {
}); });
} }
static async #toggleResourceManagement(event, button) {
event.stopPropagation();
const existingTooltip = document.body.querySelector('.locked-tooltip .resource-management-container');
if (existingTooltip) {
game.tooltip.dismissLockedTooltips();
return;
}
const extraResources = Object.values(CONFIG.DH.RESOURCE.character.all).reduce((acc, resource) => {
if (CONFIG.DH.RESOURCE.character.base[resource.id]) return acc;
const resourceData = this.document.system.resources[resource.id];
acc[resource.id] = {
id: resource.id,
label: game.i18n.localize(resource.label),
value: resourceData.value,
max: resourceData.max,
fullIcon: resource.images?.full ?? { value: 'fa-solid fa-circle', isIcon: true },
emptyIcon: resource.images?.empty ?? { value: 'fa-regular fa-circle', isIcon: true }
};
return acc;
}, {});
const html = document.createElement('div');
html.innerHTML = await foundry.applications.handlebars.renderTemplate(
`systems/daggerheart/templates/ui/tooltip/resourceManagement.hbs`,
{
resources: extraResources
}
);
const target = button.closest('.resource-section');
game.tooltip.dismissLockedTooltips();
game.tooltip.activate(target, {
html,
locked: true,
cssClass: 'bordered-tooltip',
direction: 'DOWN',
noOffset: true
});
const resourceManager = target.querySelector('.resource-manager');
resourceManager.classList.toggle('inverted');
Hooks.once(CONFIG.DH.HOOKS.hooksConfig.lockedTooltipDismissed, () => {
resourceManager.classList.toggle('inverted');
});
for (const element of html.querySelectorAll('.resource-value'))
element.addEventListener('click', this.onUpdateResource.bind(this));
}
async onUpdateResource(event) {
const target = event.target.closest('.resource-value');
const { resource, value: textValue } = target.dataset;
const inputValue = Number.parseInt(textValue);
const decreasing = inputValue <= this.document.system.resources[resource].value;
const value = decreasing ? inputValue - 1 : inputValue;
await this.document.update({ [`system.resources.${resource}.value`]: value }, { render: false });
/* Update resource symbols */
const section = target.closest('.resource-section');
for (const element of section.querySelectorAll('.resource-value')) {
const showFull = Number.parseInt(element.dataset.value) <= value;
element.querySelector('.full').classList.toggle('hidden', !showFull);
element.querySelector('.empty').classList.toggle('hidden', showFull);
}
}
/** /**
* Open the downtime application. * Open the downtime application.
* @type {ApplicationClickAction} * @type {ApplicationClickAction}

View file

@ -44,8 +44,32 @@ export default class DHBaseActorSettings extends DHApplicationMixin(DocumentShee
const context = await super._prepareContext(options); const context = await super._prepareContext(options);
context.isNPC = this.actor.isNPC; context.isNPC = this.actor.isNPC;
if (context.systemFields.attack) if (context.systemFields.attack) {
context.systemFields.attack.fields = this.actor.system.attack.schema.fields; context.systemFields.attack.fields = this.actor.system.attack.schema.fields;
}
// Create fake fields for actor configurable max resource value.
const resourceConfig = CONFIG.DH.RESOURCE[this.actor.type]?.all;
if (resourceConfig) {
const relevant = ['hitPoints', 'stress'].filter(r => r in resourceConfig);
context.resources = relevant.map(key => {
const data = this.actor._source.system.resources[key];
const config = resourceConfig[key];
return {
label: config.label,
name: `system.resources.${key}.max`,
value: data.max ?? config.max,
tooltip: key === 'hitPoints' ? game.i18n.localize('DAGGERHEART.UI.Tooltip.maxHPClassBound') : null,
field: new foundry.data.fields.NumberField({
initial: config.max,
integer: true,
label: game.i18n.format('DAGGERHEART.GENERAL.maxWithThing', {
thing: game.i18n.localize(config.label)
})
})
};
});
}
return context; return context;
} }

View file

@ -11,3 +11,4 @@ export * as settingsConfig from './settingsConfig.mjs';
export * as systemConfig from './system.mjs'; export * as systemConfig from './system.mjs';
export * as itemBrowserConfig from './itemBrowserConfig.mjs'; export * as itemBrowserConfig from './itemBrowserConfig.mjs';
export * as triggerConfig from './triggerConfig.mjs'; export * as triggerConfig from './triggerConfig.mjs';
export * as resourceConfig from './resourceConfig.mjs';

View file

@ -55,24 +55,6 @@ export const abilities = {
} }
}; };
export const scrollingTextResource = {
hitPoints: {
label: 'DAGGERHEART.GENERAL.HitPoints.plural',
reversed: true
},
stress: {
label: 'DAGGERHEART.GENERAL.stress',
reversed: true
},
hope: {
label: 'DAGGERHEART.GENERAL.hope'
},
armor: {
label: 'DAGGERHEART.GENERAL.armor',
reversed: true
}
};
export const featureProperties = { export const featureProperties = {
agility: { agility: {
name: 'DAGGERHEART.CONFIG.Traits.agility.name', name: 'DAGGERHEART.CONFIG.Traits.agility.name',
@ -506,8 +488,8 @@ export const subclassFeatureLabels = {
* @property {number[]} damage * @property {number[]} damage
*/ */
/** /**
* @type {Record<string, Record<2 | 3 | 4, TierData>} * @type {Record<string, Record<2 | 3 | 4, TierData>}
* Scaling data used to change an adversary's tier. Each rank is applied incrementally. * Scaling data used to change an adversary's tier. Each rank is applied incrementally.
*/ */
export const adversaryScalingData = { export const adversaryScalingData = {
@ -518,7 +500,7 @@ export const adversaryScalingData = {
severeThreshold: 10, severeThreshold: 10,
hp: 1, hp: 1,
stress: 2, stress: 2,
attack: 2, attack: 2
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -526,7 +508,7 @@ export const adversaryScalingData = {
severeThreshold: 15, severeThreshold: 15,
hp: 1, hp: 1,
stress: 0, stress: 0,
attack: 2, attack: 2
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -534,7 +516,7 @@ export const adversaryScalingData = {
severeThreshold: 25, severeThreshold: 25,
hp: 1, hp: 1,
stress: 0, stress: 0,
attack: 2, attack: 2
} }
}, },
horde: { horde: {
@ -544,7 +526,7 @@ export const adversaryScalingData = {
severeThreshold: 8, severeThreshold: 8,
hp: 2, hp: 2,
stress: 0, stress: 0,
attack: 0, attack: 0
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -552,7 +534,7 @@ export const adversaryScalingData = {
severeThreshold: 12, severeThreshold: 12,
hp: 0, hp: 0,
stress: 1, stress: 1,
attack: 1, attack: 1
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -560,7 +542,7 @@ export const adversaryScalingData = {
severeThreshold: 15, severeThreshold: 15,
hp: 2, hp: 2,
stress: 0, stress: 0,
attack: 0, attack: 0
} }
}, },
leader: { leader: {
@ -570,7 +552,7 @@ export const adversaryScalingData = {
severeThreshold: 10, severeThreshold: 10,
hp: 0, hp: 0,
stress: 0, stress: 0,
attack: 1, attack: 1
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -578,7 +560,7 @@ export const adversaryScalingData = {
severeThreshold: 15, severeThreshold: 15,
hp: 1, hp: 1,
stress: 0, stress: 0,
attack: 2, attack: 2
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -586,7 +568,7 @@ export const adversaryScalingData = {
severeThreshold: 25, severeThreshold: 25,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 3, attack: 3
} }
}, },
minion: { minion: {
@ -596,7 +578,7 @@ export const adversaryScalingData = {
severeThreshold: 0, severeThreshold: 0,
hp: 0, hp: 0,
stress: 0, stress: 0,
attack: 1, attack: 1
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -604,7 +586,7 @@ export const adversaryScalingData = {
severeThreshold: 0, severeThreshold: 0,
hp: 0, hp: 0,
stress: 1, stress: 1,
attack: 1, attack: 1
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -612,7 +594,7 @@ export const adversaryScalingData = {
severeThreshold: 0, severeThreshold: 0,
hp: 0, hp: 0,
stress: 0, stress: 0,
attack: 1, attack: 1
} }
}, },
ranged: { ranged: {
@ -622,7 +604,7 @@ export const adversaryScalingData = {
severeThreshold: 6, severeThreshold: 6,
hp: 1, hp: 1,
stress: 0, stress: 0,
attack: 1, attack: 1
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -630,7 +612,7 @@ export const adversaryScalingData = {
severeThreshold: 14, severeThreshold: 14,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 2, attack: 2
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -638,7 +620,7 @@ export const adversaryScalingData = {
severeThreshold: 10, severeThreshold: 10,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
} }
}, },
skulk: { skulk: {
@ -648,7 +630,7 @@ export const adversaryScalingData = {
severeThreshold: 8, severeThreshold: 8,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -656,7 +638,7 @@ export const adversaryScalingData = {
severeThreshold: 12, severeThreshold: 12,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -664,7 +646,7 @@ export const adversaryScalingData = {
severeThreshold: 10, severeThreshold: 10,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
} }
}, },
solo: { solo: {
@ -674,7 +656,7 @@ export const adversaryScalingData = {
severeThreshold: 10, severeThreshold: 10,
hp: 0, hp: 0,
stress: 1, stress: 1,
attack: 2, attack: 2
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -682,7 +664,7 @@ export const adversaryScalingData = {
severeThreshold: 15, severeThreshold: 15,
hp: 2, hp: 2,
stress: 1, stress: 1,
attack: 2, attack: 2
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -690,7 +672,7 @@ export const adversaryScalingData = {
severeThreshold: 25, severeThreshold: 25,
hp: 0, hp: 0,
stress: 1, stress: 1,
attack: 3, attack: 3
} }
}, },
standard: { standard: {
@ -700,7 +682,7 @@ export const adversaryScalingData = {
severeThreshold: 8, severeThreshold: 8,
hp: 0, hp: 0,
stress: 0, stress: 0,
attack: 1, attack: 1
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -708,7 +690,7 @@ export const adversaryScalingData = {
severeThreshold: 15, severeThreshold: 15,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -716,7 +698,7 @@ export const adversaryScalingData = {
severeThreshold: 15, severeThreshold: 15,
hp: 0, hp: 0,
stress: 1, stress: 1,
attack: 1, attack: 1
} }
}, },
support: { support: {
@ -726,7 +708,7 @@ export const adversaryScalingData = {
severeThreshold: 8, severeThreshold: 8,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
}, },
3: { 3: {
difficulty: 2, difficulty: 2,
@ -734,7 +716,7 @@ export const adversaryScalingData = {
severeThreshold: 12, severeThreshold: 12,
hp: 0, hp: 0,
stress: 0, stress: 0,
attack: 1, attack: 1
}, },
4: { 4: {
difficulty: 2, difficulty: 2,
@ -742,27 +724,27 @@ export const adversaryScalingData = {
severeThreshold: 10, severeThreshold: 10,
hp: 1, hp: 1,
stress: 1, stress: 1,
attack: 1, attack: 1
} }
} }
}; };
/** /**
* Scaling data used for an adversary's damage. * Scaling data used for an adversary's damage.
* Tier 4 is missing certain adversary types and therefore skews upwards. * Tier 4 is missing certain adversary types and therefore skews upwards.
* We manually set tier 4 data to hopefully lead to better results * We manually set tier 4 data to hopefully lead to better results
*/ */
export const adversaryExpectedDamage = { export const adversaryExpectedDamage = {
basic: { basic: {
1: { mean: 7.321428571428571, deviation: 1.962519002770912 }, 1: { mean: 7.321428571428571, deviation: 1.962519002770912 },
2: { mean: 12.444444444444445, deviation: 2.0631069425529676 }, 2: { mean: 12.444444444444445, deviation: 2.0631069425529676 },
3: { mean: 15.722222222222221, deviation: 2.486565208464823 }, 3: { mean: 15.722222222222221, deviation: 2.486565208464823 },
4: { mean: 26, deviation: 5.2 } 4: { mean: 26, deviation: 5.2 }
}, },
minion: { minion: {
1: { mean: 2.142857142857143, deviation: 1.0690449676496976 }, 1: { mean: 2.142857142857143, deviation: 1.0690449676496976 },
2: { mean: 5, deviation: 0.816496580927726 }, 2: { mean: 5, deviation: 0.816496580927726 },
3: { mean: 6.5, deviation: 2.1213203435596424 }, 3: { mean: 6.5, deviation: 2.1213203435596424 },
4: { mean: 11, deviation: 1 } 4: { mean: 11, deviation: 1 }
} }
}; };

View file

@ -1,3 +1,4 @@
export const hooksConfig = { export const hooksConfig = {
effectDisplayToggle: 'DHEffectDisplayToggle' effectDisplayToggle: 'DHEffectDisplayToggle',
lockedTooltipDismissed: 'DHLockedTooltipDismissed'
}; };

View file

@ -0,0 +1,88 @@
/**
* Full custom typing:
* id
* initial
* max
* reverse
* label
* images {
* full { value, isIcon, noColorFilter }
* empty { value, isIcon noColorFilter }
* }
*/
const characterBaseResources = Object.freeze({
hitPoints: {
id: 'hitPoints',
initial: 0,
max: 0,
reverse: true,
label: 'DAGGERHEART.GENERAL.HitPoints.plural',
maxLabel: 'DAGGERHEART.ACTORS.Character.maxHPBonus'
},
stress: {
id: 'stress',
initial: 0,
max: 6,
reverse: true,
label: 'DAGGERHEART.GENERAL.stress'
},
hope: {
id: 'hope',
initial: 2,
reverse: false,
label: 'DAGGERHEART.GENERAL.hope'
}
});
const adversaryBaseResources = Object.freeze({
hitPoints: {
id: 'hitPoints',
initial: 0,
max: 0,
reverse: true,
label: 'DAGGERHEART.GENERAL.HitPoints.plural',
maxLabel: 'DAGGERHEART.ACTORS.Character.maxHPBonus'
},
stress: {
id: 'stress',
initial: 0,
max: 0,
reverse: true,
label: 'DAGGERHEART.GENERAL.stress'
}
});
const companionBaseResources = Object.freeze({
stress: {
id: 'stress',
initial: 0,
max: 0,
reverse: true,
label: 'DAGGERHEART.GENERAL.stress'
},
hope: {
id: 'hope',
initial: 0,
reverse: false,
label: 'DAGGERHEART.GENERAL.hope'
}
});
export const character = {
base: characterBaseResources,
custom: {}, // module stuff goes here
all: { ...characterBaseResources },
};
export const adversary = {
base: adversaryBaseResources,
custom: {}, // module stuff goes here
all: { ...adversaryBaseResources },
};
export const companion = {
base: companionBaseResources,
custom: {}, // module stuff goes here
all: { ...companionBaseResources },
};

View file

@ -2,6 +2,7 @@ import * as GENERAL from './generalConfig.mjs';
import * as DOMAIN from './domainConfig.mjs'; import * as DOMAIN from './domainConfig.mjs';
import * as ENCOUNTER from './encounterConfig.mjs'; import * as ENCOUNTER from './encounterConfig.mjs';
import * as ACTOR from './actorConfig.mjs'; import * as ACTOR from './actorConfig.mjs';
import * as RESOURCE from './resourceConfig.mjs';
import * as ITEM from './itemConfig.mjs'; import * as ITEM from './itemConfig.mjs';
import * as SETTINGS from './settingsConfig.mjs'; import * as SETTINGS from './settingsConfig.mjs';
import * as EFFECTS from './effectConfig.mjs'; import * as EFFECTS from './effectConfig.mjs';
@ -19,6 +20,7 @@ export const SYSTEM = {
GENERAL, GENERAL,
DOMAIN, DOMAIN,
ACTOR, ACTOR,
RESOURCE,
ITEM, ITEM,
SETTINGS, SETTINGS,
EFFECTS, EFFECTS,

View file

@ -2,7 +2,7 @@ import DHAdversarySettings from '../../applications/sheets-configs/adversary-set
import { ActionField } from '../fields/actionField.mjs'; import { ActionField } from '../fields/actionField.mjs';
import { commonActorRules } from './base.mjs'; import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs'; import DhCreature from './creature.mjs';
import { resourceField, bonusField } from '../fields/actorField.mjs'; import { bonusField } from '../fields/actorField.mjs';
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
@ -65,10 +65,6 @@ export default class DhpAdversary extends DhCreature {
label: 'DAGGERHEART.GENERAL.DamageThresholds.severeThreshold' label: 'DAGGERHEART.GENERAL.DamageThresholds.severeThreshold'
}) })
}), }),
resources: new fields.SchemaField({
hitPoints: resourceField(0, 0, 'DAGGERHEART.GENERAL.HitPoints.plural', true),
stress: resourceField(0, 0, 'DAGGERHEART.GENERAL.stress', true)
}),
rules: new fields.SchemaField({ rules: new fields.SchemaField({
...commonActorRules() ...commonActorRules()
}), }),
@ -191,6 +187,7 @@ export default class DhpAdversary extends DhCreature {
} }
prepareDerivedData() { prepareDerivedData() {
super.prepareDerivedData();
this.attack.roll.isStandardAttack = true; this.attack.roll.isStandardAttack = true;
} }

View file

@ -213,7 +213,7 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel {
const textData = Object.keys(changes.system.resources).reduce((acc, key) => { const textData = Object.keys(changes.system.resources).reduce((acc, key) => {
const resource = changes.system.resources[key]; const resource = changes.system.resources[key];
if (resource.value !== undefined && resource.value !== this.resources[key].value) { if (resource.value !== undefined && resource.value !== this.resources[key].value) {
acc.push(getScrollTextData(this.resources, resource, key)); acc.push(getScrollTextData(this.parent, resource, key));
} }
return acc; return acc;

View file

@ -3,7 +3,7 @@ import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import DhLevelData from '../levelData.mjs'; import DhLevelData from '../levelData.mjs';
import { commonActorRules } from './base.mjs'; import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs'; import DhCreature from './creature.mjs';
import { attributeField, resourceField, stressDamageReductionRule, bonusField } from '../fields/actorField.mjs'; import { attributeField, stressDamageReductionRule, bonusField } from '../fields/actorField.mjs';
import { ActionField } from '../fields/actionField.mjs'; import { ActionField } from '../fields/actionField.mjs';
import DHCharacterSettings from '../../applications/sheets-configs/character-settings.mjs'; import DHCharacterSettings from '../../applications/sheets-configs/character-settings.mjs';
@ -27,28 +27,6 @@ export default class DhCharacter extends DhCreature {
return { return {
...super.defineSchema(), ...super.defineSchema(),
resources: new fields.SchemaField({
hitPoints: resourceField(
0,
0,
'DAGGERHEART.GENERAL.HitPoints.plural',
true,
'DAGGERHEART.ACTORS.Character.maxHPBonus'
),
stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true),
hope: new fields.SchemaField(
{
value: new fields.NumberField({
initial: 2,
min: 0,
integer: true,
label: 'DAGGERHEART.GENERAL.hope'
}),
isReversed: new fields.BooleanField({ initial: false })
},
{ label: 'DAGGERHEART.GENERAL.hope' }
)
}),
traits: new fields.SchemaField({ traits: new fields.SchemaField({
agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'), agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'),
strength: attributeField('DAGGERHEART.CONFIG.Traits.strength.name'), strength: attributeField('DAGGERHEART.CONFIG.Traits.strength.name'),
@ -609,6 +587,7 @@ export default class DhCharacter extends DhCreature {
} }
prepareBaseData() { prepareBaseData() {
super.prepareBaseData();
this.evasion += this.class.value?.system?.evasion ?? 0; this.evasion += this.class.value?.system?.evasion ?? 0;
const currentLevel = this.levelData.level.current; const currentLevel = this.levelData.level.current;
@ -680,6 +659,7 @@ export default class DhCharacter extends DhCreature {
} }
prepareDerivedData() { prepareDerivedData() {
super.prepareDerivedData();
let baseHope = this.resources.hope.value; let baseHope = this.resources.hope.value;
if (this.companion) { if (this.companion) {
for (let levelKey in this.companion.system.levelData.levelups) { for (let levelKey in this.companion.system.levelData.levelups) {
@ -699,6 +679,7 @@ export default class DhCharacter extends DhCreature {
this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait; this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait;
this.resources.armor = { this.resources.armor = {
label: 'DAGGERHEART.GENERAL.armor',
value: this.armor?.system?.marks?.value ?? 0, value: this.armor?.system?.marks?.value ?? 0,
max: this.armorScore, max: this.armorScore,
isReversed: true isReversed: true

View file

@ -4,7 +4,7 @@ import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import { ActionField } from '../fields/actionField.mjs'; import { ActionField } from '../fields/actionField.mjs';
import { adjustDice, adjustRange } from '../../helpers/utils.mjs'; import { adjustDice, adjustRange } from '../../helpers/utils.mjs';
import DHCompanionSettings from '../../applications/sheets-configs/companion-settings.mjs'; import DHCompanionSettings from '../../applications/sheets-configs/companion-settings.mjs';
import { resourceField, bonusField } from '../fields/actorField.mjs'; import { bonusField } from '../fields/actorField.mjs';
export default class DhCompanion extends DhCreature { export default class DhCompanion extends DhCreature {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Companion']; static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Companion'];
@ -26,10 +26,6 @@ export default class DhCompanion extends DhCreature {
return { return {
...super.defineSchema(), ...super.defineSchema(),
partner: new ForeignDocumentUUIDField({ type: 'Actor' }), partner: new ForeignDocumentUUIDField({ type: 'Actor' }),
resources: new fields.SchemaField({
stress: resourceField(3, 0, 'DAGGERHEART.GENERAL.stress', true),
hope: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.hope' })
}),
evasion: new fields.NumberField({ evasion: new fields.NumberField({
required: true, required: true,
min: 1, min: 1,
@ -127,6 +123,7 @@ export default class DhCompanion extends DhCreature {
} }
prepareBaseData() { prepareBaseData() {
super.prepareBaseData();
this.attack.roll.bonus = this.partner?.system?.spellcastModifier ?? 0; this.attack.roll.bonus = this.partner?.system?.spellcastModifier ?? 0;
for (let levelKey in this.levelData.levelups) { for (let levelKey in this.levelData.levelups) {
@ -161,6 +158,7 @@ export default class DhCompanion extends DhCreature {
} }
prepareDerivedData() { prepareDerivedData() {
super.prepareDerivedData();
/* Partner Related Setup */ /* Partner Related Setup */
if (this.partner) { if (this.partner) {
this.levelData.level.changed = this.partner.system.levelData.level.current; this.levelData.level.changed = this.partner.system.levelData.level.current;

View file

@ -1,3 +1,4 @@
import { ResourcesField } from '../fields/actorField.mjs';
import BaseDataActor from './base.mjs'; import BaseDataActor from './base.mjs';
export default class DhCreature extends BaseDataActor { export default class DhCreature extends BaseDataActor {
@ -7,6 +8,7 @@ export default class DhCreature extends BaseDataActor {
return { return {
...super.defineSchema(), ...super.defineSchema(),
resources: new ResourcesField(this.metadata.type),
advantageSources: new fields.ArrayField(new fields.StringField(), { advantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.advantageSources.label', label: 'DAGGERHEART.ACTORS.Character.advantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint' hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint'

View file

@ -6,22 +6,6 @@ const attributeField = label =>
tierMarked: new fields.BooleanField({ initial: false }) tierMarked: new fields.BooleanField({ initial: false })
}); });
const resourceField = (max = 0, initial = 0, label, reverse = false, maxLabel) =>
new fields.SchemaField(
{
value: new fields.NumberField({ initial: initial, min: 0, integer: true, label }),
max: new fields.NumberField({
initial: max,
integer: true,
label:
maxLabel ??
game.i18n.format('DAGGERHEART.GENERAL.maxWithThing', { thing: game.i18n.localize(label) })
}),
isReversed: new fields.BooleanField({ initial: reverse })
},
{ label }
);
const stressDamageReductionRule = localizationPath => const stressDamageReductionRule = localizationPath =>
new fields.SchemaField({ new fields.SchemaField({
cost: new fields.NumberField({ cost: new fields.NumberField({
@ -37,4 +21,67 @@ const bonusField = label =>
dice: new fields.ArrayField(new fields.StringField(), { label: `${game.i18n.localize(label)} Dice` }) dice: new fields.ArrayField(new fields.StringField(), { label: `${game.i18n.localize(label)} Dice` })
}); });
export { attributeField, resourceField, stressDamageReductionRule, bonusField }; /**
* Field used for actor resources. It is a resource that validates dynamically based on the config.
* Because "max" may be defined during runtime, we don't attempt to clamp the maximum value.
*/
class ResourcesField extends fields.TypedObjectField {
constructor(actorType) {
super(
new fields.SchemaField({
value: new fields.NumberField({ min: 0, initial: 0, integer: true }),
// Some resources allow changing max. A null max means its the default
max: new fields.NumberField({ initial: null, integer: true, nullable: true })
})
);
this.actorType = actorType;
}
getInitialValue() {
const resources = CONFIG.DH.RESOURCE[this.actorType].all;
return Object.values(resources).reduce((result, resource) => {
result[resource.id] = {
value: resource.initial,
max: null
};
return result;
}, {});
}
_validateKey(key) {
return key in CONFIG.DH.RESOURCE[this.actorType].all;
}
_cleanType(value, options) {
value = super._cleanType(value, options);
// If not partial, ensure all data exists
if (!options.partial) {
value = foundry.utils.mergeObject(this.getInitialValue(), value);
}
return value;
}
/** Initializes the original source data, returning prepared data */
initialize(...args) {
const data = super.initialize(...args);
const resources = CONFIG.DH.RESOURCE[this.actorType].all;
for (const [key, value] of Object.entries(data)) {
// TypedObjectField only calls _validateKey when persisting, so we also call it here
if (!this._validateKey(key)) {
delete value[key];
continue;
}
// Add basic prepared data.
const resource = resources[key];
value.label = resource.label;
value.isReversed = resources[key].reverse;
value.max = typeof resource.max === 'number' ? value.max ?? resource.max : null;
}
return data;
}
}
export { attributeField, ResourcesField, stressDamageReductionRule, bonusField };

View file

@ -224,7 +224,7 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
const armorChanged = const armorChanged =
changed.system?.marks?.value !== undefined && changed.system.marks.value !== this.marks.value; changed.system?.marks?.value !== undefined && changed.system.marks.value !== this.marks.value;
if (armorChanged && autoSettings.resourceScrollTexts && this.parent.parent?.type === 'character') { if (armorChanged && autoSettings.resourceScrollTexts && this.parent.parent?.type === 'character') {
const armorData = getScrollTextData(this.parent.parent.system.resources, changed.system.marks, 'armor'); const armorData = getScrollTextData(this.parent.parent, changed.system.marks, 'armor');
options.scrollingTextData = [armorData]; options.scrollingTextData = [armorData];
} }

View file

@ -145,6 +145,16 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
description: new fields.StringField() description: new fields.StringField()
}) })
), ),
resources: new fields.TypedObjectField(
new fields.SchemaField({
resources: new fields.TypedObjectField(new fields.EmbeddedDataField(Resource))
}),
{
initial: {
character: { resources: {} }
}
}
),
itemFeatures: new fields.SchemaField({ itemFeatures: new fields.SchemaField({
weaponFeatures: new fields.TypedObjectField( weaponFeatures: new fields.TypedObjectField(
new fields.SchemaField({ new fields.SchemaField({
@ -185,4 +195,117 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
} }
return source; return source;
} }
/** Invoked by the setting when data changes */
handleChange() {
if (this.maxFear) {
if (ui.resources) ui.resources.render({ force: true });
}
this.refreshConfig();
this.#resetActors();
}
/** Update config values based on homebrew data. Make sure the references don't change */
refreshConfig() {
for (const [actorType, actorData] of Object.entries(this.resources)) {
const config = CONFIG.DH.RESOURCE[actorType];
for (const key of Object.keys(config.all)) {
delete config.all[key];
}
Object.assign(config.all, {
...Object.entries(actorData.resources).reduce((result, [key, value]) => {
result[key] = value.toObject();
result[key].id = key;
return result;
}, {}),
...config.custom,
...config.base,
});
}
}
/**
* Triggers a reset and non-forced re-render on all given actors (if given)
* or all world actors and actors in all scenes to show immediate results for a changed setting.
*/
#resetActors() {
const actors = new Set(
[
game.actors.contents,
game.scenes.contents.flatMap(s => s.tokens.contents).flatMap(t => t.actor ?? [])
].flat()
);
for (const actor of actors) {
for (const app of Object.values(actor.apps)) {
for (const element of app.element?.querySelectorAll('prose-mirror.active')) {
element.open = false; // This triggers a save
}
}
actor.reset();
actor.render();
}
}
} }
export class Resource extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
initial: new fields.NumberField({
required: true,
integer: true,
initial: 0,
min: 0,
label: 'DAGGERHEART.GENERAL.initial'
}),
max: new fields.NumberField({
nullable: true,
initial: null,
min: 0,
label: 'DAGGERHEART.GENERAL.max'
}),
label: new fields.StringField({ label: 'DAGGERHEART.GENERAL.label' }),
images: new fields.SchemaField({
full: imageIconField('fa solid fa-circle'),
empty: imageIconField('fa-regular fa-circle')
})
};
}
static getDefaultResourceData = label => {
const images = Resource.schema.fields.images.getInitialValue();
return {
initial: 0,
max: 0,
label: label ?? '',
images
};
};
static getDefaultImageData = imageKey => {
return Resource.schema.fields.images.fields[imageKey].getInitialValue();
};
}
const imageIconField = defaultValue =>
new foundry.data.fields.SchemaField(
{
value: new foundry.data.fields.StringField({
initial: defaultValue,
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.resources.resources.value.label'
}),
isIcon: new foundry.data.fields.BooleanField({
required: true,
initial: true,
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.resources.resources.isIcon.label'
}),
noColorFilter: new foundry.data.fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.resources.resources.noColorFilter.label'
})
},
{ required: true }
);

View file

@ -3,6 +3,7 @@ import { AdversaryBPPerEncounter, BaseBPPerEncounter } from '../config/encounter
export default class DhTooltipManager extends foundry.helpers.interaction.TooltipManager { export default class DhTooltipManager extends foundry.helpers.interaction.TooltipManager {
#wide = false; #wide = false;
#bordered = false; #bordered = false;
#active = false;
async activate(element, options = {}) { async activate(element, options = {}) {
const { TextEditor } = foundry.applications.ux; const { TextEditor } = foundry.applications.ux;
@ -168,7 +169,100 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti
} }
} }
super.activate(element, { ...options, html: html }); this.baseActivate(element, { ...options, html: html });
}
/* Need to pass more options to _setAnchor, so have to copy whole foundry method >_< */
async baseActivate(element, options) {
let { text, direction, cssClass, locked = false, html, content } = options;
if (content && !html) {
foundry.utils.logCompatibilityWarning(
'The content option has been deprecated in favor of the html option',
{ since: 13, until: 15, once: true }
);
html = content;
}
if (text && html) throw new Error('Cannot provide both text and html options to TooltipManager#activate.');
// Deactivate currently active element
this.deactivate();
// Check if the element still exists in the DOM.
if (!document.body.contains(element)) return;
// Mark the new element as active
this.#active = true;
this.element = element;
element.setAttribute('aria-describedby', 'tooltip');
html ||= element.dataset.tooltipHtml;
if (html) {
if (typeof html === 'string') this.tooltip.innerHTML = foundry.utils.cleanHTML(html);
else {
this.tooltip.innerHTML = ''; // Clear existing HTML
this.tooltip.appendChild(html);
}
} else {
text ||= element.dataset.tooltipText;
if (text) this.tooltip.textContent = text;
else {
text = element.dataset.tooltip;
// Localized message should be safe
if (game.i18n.has(text)) this.tooltip.innerHTML = game.i18n.localize(text);
else this.tooltip.innerHTML = foundry.utils.cleanHTML(text);
}
}
// Activate display of the tooltip
this.tooltip.removeAttribute('class');
this.tooltip.classList.add('active', 'themed', 'theme-dark');
this.tooltip.showPopover();
cssClass ??= element.closest('[data-tooltip-class]')?.dataset.tooltipClass;
if (cssClass) this.tooltip.classList.add(...cssClass.split(' '));
// Set tooltip position
direction ??= element.closest('[data-tooltip-direction]')?.dataset.tooltipDirection;
if (!direction) direction = this._determineDirection();
this._setAnchor(direction, options);
if (locked || element.dataset.hasOwnProperty('locked')) this.lockTooltip();
}
_setAnchor(direction, options) {
const directions = this.constructor.TOOLTIP_DIRECTIONS;
const pad = this.constructor.TOOLTIP_MARGIN_PX;
const pos = this.element.getBoundingClientRect();
const { innerHeight, innerWidth } = this.tooltip.ownerDocument.defaultView;
const tooltipPadding = 16;
const horizontalOffset = options.noOffset ? tooltipPadding : this.tooltip.offsetWidth / 2 - pos.width / 2;
const verticalOffset = options.noOffset ? tooltipPadding : this.tooltip.offsetHeight / 2 - pos.height / 2;
const style = {};
switch (direction) {
case directions.DOWN:
style.textAlign = 'center';
style.left = pos.left - horizontalOffset;
style.top = pos.bottom + pad;
break;
case directions.LEFT:
style.textAlign = 'left';
style.right = innerWidth - pos.left + pad;
style.top = pos.top - verticalOffset;
break;
case directions.RIGHT:
style.textAlign = 'right';
style.left = pos.right + pad;
style.top = pos.top - verticalOffset;
break;
case directions.UP:
style.textAlign = 'center';
style.left = pos.left - horizontalOffset;
style.bottom = innerHeight - pos.top + pad;
break;
case directions.CENTER:
style.textAlign = 'center';
style.left = pos.left - horizontalOffset;
style.top = pos.top - verticalOffset;
break;
}
return this._setStyle(style);
} }
_determineItemTooltipDirection(element, prefered = this.constructor.TOOLTIP_DIRECTIONS.LEFT) { _determineItemTooltipDirection(element, prefered = this.constructor.TOOLTIP_DIRECTIONS.LEFT) {
@ -270,6 +364,12 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti
return clone; return clone;
} }
/**@inheritdoc */
dismissLockedTooltips() {
super.dismissLockedTooltips();
Hooks.callAll(CONFIG.DH.HOOKS.hooksConfig.lockedTooltipDismissed);
}
/** Get HTML for Battlepoints tooltip */ /** Get HTML for Battlepoints tooltip */
async getBattlepointHTML(combatId) { async getBattlepointHTML(combatId) {
const combat = game.combats.get(combatId); const combat = game.combats.get(combatId);

View file

@ -378,17 +378,18 @@ export const arraysEqual = (a, b) =>
export const setsEqual = (a, b) => a.size === b.size && [...a].every(value => b.has(value)); export const setsEqual = (a, b) => a.size === b.size && [...a].every(value => b.has(value));
export function getScrollTextData(resources, resource, key) { export function getScrollTextData(actor, resource, key) {
const { reversed, label } = CONFIG.DH.ACTOR.scrollingTextResource[key];
const { BOTTOM, TOP } = CONST.TEXT_ANCHOR_POINTS; const { BOTTOM, TOP } = CONST.TEXT_ANCHOR_POINTS;
const resources = actor.system.resources;
const increased = resources[key].value < resource.value; const increased = resources[key].value < resource.value;
const value = -1 * (resources[key].value - resource.value); const value = -1 * (resources[key].value - resource.value);
const { label, isReversed } = resources[key];
const text = `${game.i18n.localize(label)} ${value.signedString()}`; const text = `${game.i18n.localize(label)} ${value.signedString()}`;
const stroke = increased ? (isReversed ? 0xffffff : 0x000000) : isReversed ? 0x000000 : 0xffffff;
const stroke = increased ? (reversed ? 0xffffff : 0x000000) : reversed ? 0x000000 : 0xffffff; const fill = increased ? (isReversed ? 0x0032b1 : 0xffe760) : isReversed ? 0xffe760 : 0x0032b1;
const fill = increased ? (reversed ? 0x0032b1 : 0xffe760) : reversed ? 0xffe760 : 0x0032b1; const direction = increased ? (isReversed ? BOTTOM : TOP) : isReversed ? TOP : BOTTOM;
const direction = increased ? (reversed ? BOTTOM : TOP) : reversed ? TOP : BOTTOM;
return { text, stroke, fill, direction }; return { text, stroke, fill, direction };
} }

View file

@ -50,12 +50,7 @@ const registerMenuSettings = () => {
config: false, config: false,
type: DhHomebrew, type: DhHomebrew,
onChange: value => { onChange: value => {
if (value.maxFear) { value.handleChange();
if (ui.resources) ui.resources.render({ force: true });
}
// Some homebrew settings may change sheets in various ways, so trigger a re-render
resetActors();
} }
}); });
@ -166,25 +161,3 @@ const registerNonConfigSettings = () => {
type: CompendiumBrowserSettings type: CompendiumBrowserSettings
}); });
}; };
/**
* Triggers a reset and non-forced re-render on all given actors (if given)
* or all world actors and actors in all scenes to show immediate results for a changed setting.
*/
function resetActors(actors) {
actors ??= [
game.actors.contents,
game.scenes.contents.flatMap(s => s.tokens.contents).flatMap(t => t.actor ?? [])
].flat();
actors = new Set(actors);
for (const actor of actors) {
for (const app of Object.values(actor.apps)) {
for (const element of app.element?.querySelectorAll('prose-mirror.active')) {
element.open = false; // This triggers a save
}
}
actor.reset();
actor.render();
}
}

View file

@ -133,8 +133,19 @@
padding: 0; padding: 0;
margin-bottom: 15px; margin-bottom: 15px;
.hope-section { .resource-section {
display: flex;
align-items: center;
gap: 4px;
margin-right: 20px; margin-right: 20px;
.resource-manager {
transition: all 0.1s ease;
&.inverted {
transform: rotate(180deg);
}
}
} }
.downtime-section { .downtime-section {

View file

@ -27,6 +27,7 @@
@import './settings/settings.less'; @import './settings/settings.less';
@import './settings/homebrew-settings/domains.less'; @import './settings/homebrew-settings/domains.less';
@import './settings/homebrew-settings/types.less'; @import './settings/homebrew-settings/types.less';
@import './settings/homebrew-settings/resources.less';
@import './sidebar/tabs.less'; @import './sidebar/tabs.less';
@import './sidebar/daggerheartMenu.less'; @import './sidebar/daggerheartMenu.less';

View file

@ -0,0 +1,87 @@
.daggerheart.dh-style.setting.homebrew-settings .resources.tab {
.resource-types-container {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
max-height: 570px;
fieldset legend {
display: flex;
align-items: center;
gap: 4px;
}
.resource-type-container {
width: 100%;
.resources-container {
display: flex;
flex-direction: column;
gap: 4px;
.resource-container {
.resource-icons-container {
display: flex;
justify-content: space-between;
gap: 8px;
width: 100%;
.resource-icon-container {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
.resource-icon-title-container {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&::before,
&::after {
color: @dark-blue;
content: '';
flex: 1;
height: 2px;
}
&::before {
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, @golden 100%);
}
&::after {
background: linear-gradient(90deg, @golden 0%, rgba(0, 0, 0, 0) 100%);
}
.resource-icon-title {
font-size: var(--font-size-16);
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
color: light-dark(@dark-blue, @golden);
i {
font-size: 14px;
}
}
}
}
}
}
}
}
}
.two-columns {
width: 100%;
}
.form-group.vertical {
> * {
flex: 0 0 100%;
}
}
}

View file

@ -4,3 +4,5 @@
@import './tooltip/domain-cards.less'; @import './tooltip/domain-cards.less';
@import './autocomplete/autocomplete.less'; @import './autocomplete/autocomplete.less';
@import './tooltip/resource-management.less';

View file

@ -0,0 +1,56 @@
.bordered-tooltip.locked-tooltip .daggerheart.resource-management-container,
#tooltip .daggerheart.resource-management-container {
display: flex;
flex-direction: column;
gap: 16px;
.resource-section {
position: relative;
display: flex;
gap: 10px;
background-color: light-dark(transparent, @dark-blue);
color: light-dark(@dark-blue, @golden);
padding: 5px 10px;
border: 1px solid light-dark(@dark-blue, @golden);
border-radius: 6px;
align-items: center;
width: fit-content;
height: 30px;
h4 {
font-family: var(--dh-font-body, 'Montserrat'), sans-serif;
font-size: var(--font-size-14);
font-weight: bold;
text-transform: uppercase;
color: light-dark(@dark-blue, @golden);
margin: 0;
}
.resource-value {
display: flex;
cursor: pointer;
.hidden {
display: none;
}
img {
width: 14px;
height: 14px;
&.empty {
opacity: 0.4;
}
&.filter {
filter: @golden-filter;
}
&.non-transparent {
border-radius: 50%;
border: 1px solid @golden;
}
}
}
}
}

View file

@ -0,0 +1,78 @@
<section
class="tab {{tabs.resources.cssClass}} {{tabs.resources.id}} scrollable"
data-tab="{{tabs.resources.id}}"
data-group="{{tabs.resources.group}}"
>
<div class="resource-types-container">
{{#each settingFields.resources as |type key|}}
<fieldset>
<legend>
{{localize "DAGGERHEART.SETTINGS.Homebrew.resources.typeTitle" type=(localize (concat "TYPES.Actor." key))}}
<a data-action="addResource" data-actor-type="{{key}}"><i class="fa-solid fa-plus"></i></a>
</legend>
<div class="resource-type-container">
<div class="resources-container">
{{#each type.resources as |resource key|}}
<fieldset class="resource-container">
<legend>{{resource.label}}<a data-action="removeResource" data-actor-type="{{@../key}}" data-resource-key="{{key}}"><i class="fa-solid fa-trash"></i></a></legend>
{{formField @root.schemaFields.resources.element.fields.resources.element.fields.label value=resource.label name=(concat "resources." @../key ".resources." key ".label") classes="vertical" localize=true }}
<div class="two-columns even">
{{formField @root.schemaFields.resources.element.fields.resources.element.fields.initial value=resource.initial name=(concat "resources." @../key ".resources." key ".initial") classes="vertical" localize=true }}
{{formField @root.schemaFields.resources.element.fields.resources.element.fields.max value=resource.max name=(concat "resources." @../key ".resources." key ".max") classes="vertical" localize=true }}
</div>
<div class="resource-icons-container">
<div class="resource-icon-container" data-actor-type="{{@../key}}" data-resource-key="{{key}}" data-image-key="full">
{{#with @root.schemaFields.resources.element.fields.resources.element.fields.images.fields.full.fields}}
<div class="resource-icon-title-container">
<div class="resource-icon-title">
<span>{{localize "DAGGERHEART.SETTINGS.Homebrew.resources.filledIcon"}}</span>
<a data-action="resetResourceImage"><i class="fa-solid fa-arrow-rotate-left"></i></a>
</div>
</div>
<div class="resource-icon-content">
{{#if ../images.full.isIcon}}
{{formGroup this.value value=../images.full.value name=(concat "resources." @../key ".resources." key ".images.full.value") localize=true }}
{{else}}
<div class="form-fields">
<file-picker name="{{concat "resources." @../key ".resources." key ".images.full.value"}}" value="{{../images.full.value}}" type="image"></file-picker>
</div>
{{/if}}
{{formGroup this.isIcon value=../images.full.isIcon name="" classes="path-field" localize=true }}
{{formGroup this.noColorFilter value=../images.full.noColorFilter name=(concat "resources." @../key ".resources." key ".images.full.noColorFilter") localize=true }}
</div>
{{/with}}
</div>
<div class="resource-icon-container" data-actor-type="{{@../key}}" data-resource-key="{{key}}" data-image-key="empty">
{{#with @root.schemaFields.resources.element.fields.resources.element.fields.images.fields.empty.fields}}
<div class="resource-icon-title-container">
<div class="resource-icon-title">
<span>{{localize "DAGGERHEART.SETTINGS.Homebrew.resources.emptyIcon"}}</span>
<a data-action="resetResourceImage"><i class="fa-solid fa-arrow-rotate-left"></i></a>
</div>
</div>
<div class="resource-icon-content">
{{#if ../images.empty.isIcon}}
{{formGroup this.value value=../images.empty.value name=(concat "resources." @../key ".resources." key ".images.empty.value") localize=true }}
{{else}}
<div class="form-fields">
<file-picker name="{{concat "resources." @../key ".resources." key ".images.empty.value"}}" value="{{../images.empty.value}}" type="image"></file-picker>
</div>
{{/if}}
{{formGroup this.isIcon value=resource.images.empty.isIcon name="" classes="path-field" localize=true }}
{{formGroup this.noColorFilter value=resource.images.empty.noColorFilter name=(concat "resources." @../key ".resources." key ".images.empty.noColorFilter") localize=true }}
</div>
{{/with}}
</div>
</div>
</fieldset>
{{/each}}
</div>
</div>
</fieldset>
{{/each}}
</div>
</section>

View file

@ -20,15 +20,11 @@
<div class="fieldsets-section"> <div class="fieldsets-section">
<fieldset class="flex"> <fieldset class="flex">
<legend>{{localize "DAGGERHEART.GENERAL.HitPoints.plural"}}</legend> <legend>{{localize "DAGGERHEART.GENERAL.Resource.plural"}}</legend>
{{formGroup systemFields.resources.fields.hitPoints.fields.value value=document._source.system.resources.hitPoints.value label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.resources.hitPoints.value.label")}} {{#each resources as |resource|}}
{{formGroup systemFields.resources.fields.hitPoints.fields.max value=document._source.system.resources.hitPoints.max label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.resources.hitPoints.max.label")}} {{formGroup resource.field value=resource.value name=resource.name}}
</fieldset> {{/each}}
<fieldset class="flex"> </fieldset>
<legend>{{localize "DAGGERHEART.GENERAL.stress"}}</legend>
{{formGroup systemFields.resources.fields.stress.fields.value value=document._source.system.resources.stress.value label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.resources.stress.value.label")}}
{{formGroup systemFields.resources.fields.stress.fields.max value=document._source.system.resources.stress.max label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.resources.stress.max.label")}}
</fieldset>
</div> </div>
<fieldset class="flex"> <fieldset class="flex">
@ -36,4 +32,4 @@
{{formGroup systemFields.damageThresholds.fields.major value=document._source.system.damageThresholds.major label=(localize "DAGGERHEART.GENERAL.DamageThresholds.majorThreshold")}} {{formGroup systemFields.damageThresholds.fields.major value=document._source.system.damageThresholds.major label=(localize "DAGGERHEART.GENERAL.DamageThresholds.majorThreshold")}}
{{formGroup systemFields.damageThresholds.fields.severe value=document._source.system.damageThresholds.severe label=(localize "DAGGERHEART.GENERAL.DamageThresholds.severeThreshold")}} {{formGroup systemFields.damageThresholds.fields.severe value=document._source.system.damageThresholds.severe label=(localize "DAGGERHEART.GENERAL.DamageThresholds.severeThreshold")}}
</fieldset> </fieldset>
</section> </section>

View file

@ -22,15 +22,12 @@
<legend>{{localize 'DAGGERHEART.GENERAL.basics'}}</legend> <legend>{{localize 'DAGGERHEART.GENERAL.basics'}}</legend>
<div class="two-columns even"> <div class="two-columns even">
{{formGroup systemFields.resources.fields.hitPoints.fields.value value=document._source.system.resources.hitPoints.value localize=true}} {{#each resources as |resource|}}
<span data-tooltip-text="{{localize "DAGGERHEART.UI.Tooltip.maxHPClassBound"}}"> <span {{#if resource.tooltip}}data-tooltip-text="{{resource.tooltip}}"{{/if}}>
{{formGroup systemFields.resources.fields.hitPoints.fields.max value=document._source.system.resources.hitPoints.max localize=true}} {{formGroup resource.field value=resource.value name=resource.name}}
</span> </span>
{{/each}}
{{formGroup systemFields.resources.fields.stress.fields.value value=document._source.system.resources.stress.value localize=true}}
{{formGroup systemFields.resources.fields.stress.fields.max value=document._source.system.resources.stress.max localize=true}}
{{formGroup systemFields.resources.fields.hope.fields.value value=document._source.system.resources.hope.value localize=true}}
{{formGroup systemFields.scars value=document._source.system.scars localize=true}} {{formGroup systemFields.scars value=document._source.system.scars localize=true}}
{{formGroup systemFields.proficiency value=document._source.system.proficiency localize=true}} {{formGroup systemFields.proficiency value=document._source.system.proficiency localize=true}}
@ -39,4 +36,4 @@
</span> </span>
</div> </div>
</fieldset> </fieldset>
</section> </section>

View file

@ -7,8 +7,9 @@
<legend>{{localize 'DAGGERHEART.GENERAL.basics'}}</legend> <legend>{{localize 'DAGGERHEART.GENERAL.basics'}}</legend>
<div class="nest-inputs"> <div class="nest-inputs">
{{formGroup systemFields.evasion value=document._source.system.evasion localize=true}} {{formGroup systemFields.evasion value=document._source.system.evasion localize=true}}
{{formGroup systemFields.resources.fields.stress.fields.value value=document._source.system.resources.stress.value label='DAGGERHEART.ACTORS.Companion.FIELDS.resources.stress.currentStress.label' localize=true}} {{#each resources as |resource|}}
{{formGroup systemFields.resources.fields.stress.fields.max value=document._source.system.resources.stress.max label='DAGGERHEART.ACTORS.Companion.FIELDS.resources.stress.maxStress.label' localize=true}} {{formGroup resource.field value=resource.value name=resource.name}}
{{/each}}
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="form-fields"> <div class="form-fields">
@ -19,4 +20,4 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
</section> </section>

View file

@ -65,22 +65,25 @@
</div> </div>
<div class="character-row"> <div class="character-row">
<div class="hope-section"> <div class="resource-section">
<h4>{{localize "DAGGERHEART.GENERAL.hope"}}</h4> <div class="hope-section">
{{#times document.system.resources.hope.max}} <h4>{{localize "DAGGERHEART.GENERAL.hope"}}</h4>
<span class='hope-value' data-action='toggleHope' data-value="{{add this 1}}"> {{#times document.system.resources.hope.max}}
{{#if (gte ../document.system.resources.hope.value (add this 1))}} <span class='hope-value' data-action='toggleHope' data-value="{{add this 1}}">
<i class='fa-solid fa-diamond'></i> {{#if (gte ../document.system.resources.hope.value (add this 1))}}
{{else}} <i class='fa-solid fa-diamond'></i>
<i class='fa-regular fa-circle'></i> {{else}}
{{/if}} <i class='fa-regular fa-circle'></i>
</span> {{/if}}
{{/times}} </span>
{{#times document.system.scars}} {{/times}}
<span class='hope-value scar'> {{#times document.system.scars}}
<i class='fa-regular fa-ban'></i> <span class='hope-value scar'>
</span> <i class='fa-regular fa-ban'></i>
{{/times}} </span>
{{/times}}
{{#if hasExtraResources}}<a type="button" class="resource-manager" data-action="toggleResourceManagement"><i class="fa-solid fa-angle-down"></i></a>{{/if}}
</div>
</div> </div>
{{#if document.system.class.value}} {{#if document.system.class.value}}
<div class="domains-section"> <div class="domains-section">

View file

@ -0,0 +1,21 @@
<div class="daggerheart resource-management-container">
{{#each resources as |resource|}}
<div class="resource-section {{resource.resourceClass}}">
<h4>{{resource.label}}</h4>
{{#times resource.max}}
<span class='resource-value' data-action='toggleResource' data-value="{{add this 1}}" data-resource="{{resource.id}}">
{{#if resource.fullIcon.isIcon}}
<i class='{{resource.fullIcon.value}} full {{#unless (gte ../value (add this 1))}}hidden{{/unless}}'></i>
{{else}}
<img src="{{resource.fullIcon.value}}" class="full {{#unless resource.fullIcon.noColorFilter}}filter{{else}}non-transparent{{/unless}} {{#unless (gte ../value (add this 1))}}hidden{{/unless}}" />
{{/if}}
{{#if resource.emptyIcon.isIcon}}
<i class='{{resource.emptyIcon.value}} empty {{#if (gte ../value (add this 1))}}hidden{{/if}}'></i>
{{else}}
<img src="{{resource.emptyIcon.value}}" class="empty {{#unless resource.fullIcon.noColorFilter}}filter{{else}}non-transparent{{/unless}} {{#if (gte ../value (add this 1))}}hidden{{/if}}" />
{{/if}}
</span>
{{/times}}
</div>
{{/each}}
</div>