[Feature] ComboDice Modifier (#2061)

This commit is contained in:
WBHarry 2026-07-22 12:17:25 +02:00 committed by GitHub
parent 7aa4101889
commit b7f03f7a2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 208 additions and 19 deletions

View file

@ -3289,6 +3289,7 @@
"lackingItemTransferPermission": "User {user} lacks owner permission needed to transfer items to {target}", "lackingItemTransferPermission": "User {user} lacks owner permission needed to transfer items to {target}",
"noTokenTargeted": "No token is targeted", "noTokenTargeted": "No token is targeted",
"behaviorRegionRequiresGM": "Creating a Region with an attached Behavior requires an online GM", "behaviorRegionRequiresGM": "Creating a Region with an attached Behavior requires an online GM",
"comboDiceOnlyTwoDiceError": "Combo dice functionality only works on a single pair of two dice",
"reloadRequired": "The {weapon} must be reloaded to be used!" "reloadRequired": "The {weapon} must be reloaded to be used!"
}, },
"Progress": { "Progress": {

View file

@ -4,4 +4,5 @@ export { default as DamageRoll } from './damageRoll.mjs';
export { default as DHRoll } from './dhRoll.mjs'; export { default as DHRoll } from './dhRoll.mjs';
export { default as DualityRoll } from './dualityRoll.mjs'; export { default as DualityRoll } from './dualityRoll.mjs';
export { default as FateRoll } from './fateRoll.mjs'; export { default as FateRoll } from './fateRoll.mjs';
export { BaseDie } from './die/_module.mjs';
export { diceTypes } from './die/_module.mjs'; export { diceTypes } from './die/_module.mjs';

View file

@ -1,5 +1,14 @@
import { adjustDice, triggerChatRollFx } from '../../helpers/utils.mjs';
export default class BaseDie extends foundry.dice.terms.Die { export default class BaseDie extends foundry.dice.terms.Die {
async rerollResult(resultIndex) { static MODIFIERS = {
...foundry.dice.terms.Die.MODIFIERS,
cc: 'compoundComboDice',
c: 'comboDice'
};
async rerollResult(resultToReroll) {
const resultIndex = Number(resultToReroll);
const result = this.results[resultIndex]; const result = this.results[resultIndex];
result.rerolled = true; result.rerolled = true;
result.active = false; result.active = false;
@ -8,5 +17,174 @@ export default class BaseDie extends foundry.dice.terms.Die {
const rerolledResult = this.results[this.results.length - 1]; const rerolledResult = this.results[this.results.length - 1];
this.results.splice(this.results.length - 1, 1); this.results.splice(this.results.length - 1, 1);
this.results.splice(resultIndex, 0, rerolledResult); this.results.splice(resultIndex, 0, rerolledResult);
if (['c', 'cc'].some(x => this.modifiers.includes(x))) {
await this.handleComboDiceReroll(resultIndex, result);
}
}
/** @inheritDoc */
getResultCSS(result) {
// Accomodating ComboDie as a result can have a different denomination than the die as a whole
const css = super.getResultCSS(result);
const idx = css.findIndex(c => /d\d+/.test(c));
css[idx] = result.denomination ?? this.denomination;
return css;
}
/* -------------------------------------------- */
/* Modifier Logic */
/* -------------------------------------------- */
async compoundComboDice() {
return this.handleComboDice({ maxIncreasesDiceSize: true});
}
async comboDice() {
return this.handleComboDice({ maxIncreasesDiceSize: false });
}
async handleComboDice(maxIncreasesDiceSize) {
/* ComboDice only works with exactly two dice and both have to be the same denomination */
if (this.number !== 2) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.comboDiceOnlyTwoDiceError'));
return false;
}
return this.rollComboDice(maxIncreasesDiceSize);
}
async rollComboDice(options) {
const { maxIncreasesDiceSize, rerollStartIndex } = options;
const initialResultsLength = this.results.filter(x => x.active).length;
const result = await this.continueCombo(maxIncreasesDiceSize);
/* The flow of DiceSoNice has no way of knowing that some of the results of a Die should be a different denomination
We solve this by marking the results as hidden so they're not picked up by the auto roll of DiceSoNice.
The actual rolls are done here in place so every dice gets the correct denomination.
*/
if (game.modules.get('dice-so-nice')?.active) {
const resultsToRoll = this.results.filter((x, index) =>
x.active && (!rerollStartIndex || index === rerollStartIndex || index > initialResultsLength - 1));
const rolls = [];
for (const result of resultsToRoll) {
const roll = await (new Roll(`1${result.denomination ?? this.denomination}`)).evaluate();
roll.terms[0].results = [result];
roll._evaluateTotal();
rolls.push(roll);
}
/* If there are other dice that will be rolled we cannot await here. The other dice will be awaited in the normal flow */
const promises = rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true));
if (rerollStartIndex !== undefined || this._root.dice.length <= 1) {
await Promise.allSettled(promises);
}
}
for (const result of this.results)
result.hidden = true;
const modifier = maxIncreasesDiceSize ? 'cc' : 'c';
if (!this.modifiers.includes(modifier))
this.modifiers.push(modifier);
return result;
}
async continueCombo(maxIncreasesDiceSize) {
const activeResults = this.results.filter(x => x.active);
const lastIndex = activeResults.length - 1;
const lastResult = activeResults[lastIndex];
/* The Combo only continues if the latest roll was higher or equal to the previous */
if (lastResult.result < activeResults[lastIndex - 1].result) return false;
const lastFaces = lastResult.denomination ?
Number(lastResult.denomination.slice(1)) : this.faces;
const currentDenomination = `d${lastFaces}`
const increaseDiceSize =
maxIncreasesDiceSize && lastFaces < 12 &&
lastResult.result === lastFaces;
const denomination = increaseDiceSize ? adjustDice(currentDenomination, false) : currentDenomination;
const newRoll = await (new Roll(`1${denomination}`)).evaluate();
this.results.push({ result: newRoll.total, denomination: denomination, active: true });
this.number += 1;
return this.continueCombo(maxIncreasesDiceSize);
}
async handleComboDiceReroll(rerolledIndex, originalResult) {
/* Potentially sliced when correcting compound dice at (1) */
let resultGroupingIndexes = this.results.map((x, index) => ({ index, active: x.active }))
.filter(x => x.active).map(x => x.index);
if (resultGroupingIndexes.length <= 1) return;
const rerolledResult = this.results[rerolledIndex];
const compoundCombo = this.modifiers.includes('cc');
const rerollGroupingIndex = resultGroupingIndexes.indexOf(rerolledIndex);
const previousIndex = resultGroupingIndexes[rerollGroupingIndex - 1];
const previousResult = this.results[previousIndex];
const nextIndex = resultGroupingIndexes[rerollGroupingIndex + 1];
const nextResult = this.results[nextIndex];
const dropDice = preceeding => {
const cutoffIndex = preceeding ? resultGroupingIndexes[rerollGroupingIndex + 1] + 1 : rerolledIndex + 1;
this.results = this.results.slice(0, cutoffIndex);
this.number = this.results.filter(x => x.active).length;
};
/* (1) If subsequent size increases from the compound effect are now incorrect, drop subsequent dice */
const lastFaces = Number((originalResult.denomination ?? this.denomination).slice(1));
if (
compoundCombo &&
(
(originalResult.result === lastFaces &&
rerolledResult.result !== lastFaces)
||
(originalResult.result !== lastFaces &&
rerolledResult.result === lastFaces)
)
) {
dropDice(false);
resultGroupingIndexes = resultGroupingIndexes.slice(0, this.number);
}
/* (2) Rerolling any of the last two dice might introduce new results */
const isFinalHigher =
rerollGroupingIndex === resultGroupingIndexes.length - 1 && rerolledResult.result >= previousResult?.result;
const isSemifinalLower =
rerollGroupingIndex === resultGroupingIndexes.length - 2 && rerolledResult.result < nextResult?.result;
if (isFinalHigher || isSemifinalLower) {
return await this.rollComboDice({
maxIncreasesDiceSize: compoundCombo,
rerollStartIndex: rerollGroupingIndex
});
}
/* (3) Rerolling a subsequent dice might invalidate later dice which should then be dropped */
else if (rerolledResult.result < previousResult?.result){
dropDice(false);
}
/* (4) Rerolling a preceeding dice might invalidate later dice which should then be dropped */
else if (rerolledResult.result >= nextResult?.result) {
dropDice(true);
}
const fakeRollFaces =
rerolledResult.denomination ? rerolledResult.denomination.slice(1) : this.faces;
const fakeRoll = {
_evaluated: true,
dice: [new foundry.dice.terms.Die({
...this,
results: [rerolledResult],
total: rerolledResult.result,
faces: fakeRollFaces
})],
options: { appearance: {} }
};
await triggerChatRollFx([fakeRoll]);
rerolledResult.hidden = true;
} }
} }

View file

@ -1,5 +1,5 @@
import BaseDie from './baseDie.mjs';
import { updateResourcesForDualityReroll } from '../helpers.mjs'; import { updateResourcesForDualityReroll } from '../helpers.mjs';
import BaseDie from './baseDie.mjs';
export default class DualityDie extends BaseDie { export default class DualityDie extends BaseDie {
constructor(options) { constructor(options) {

View file

@ -4,19 +4,20 @@ export default class RegisterHandlebarsHelpers {
static registerHelpers() { static registerHelpers() {
Handlebars.registerHelper({ Handlebars.registerHelper({
add: this.add, add: this.add,
includes: this.includes, coalesce: this.coalesce,
times: this.times,
damageFormula: this.damageFormula, damageFormula: this.damageFormula,
formulaValue: this.formulaValue,
damageSymbols: this.damageSymbols, damageSymbols: this.damageSymbols,
rollParsed: this.rollParsed,
hasProperty: foundry.utils.hasProperty,
getProperty: foundry.utils.getProperty,
setVar: this.setVar,
empty: this.empty, empty: this.empty,
formulaValue: this.formulaValue,
getProperty: foundry.utils.getProperty,
hasProperty: foundry.utils.hasProperty,
includes: this.includes,
isNullish: this.isNullish,
pluralize: this.pluralize, pluralize: this.pluralize,
positive: this.positive, positive: this.positive,
isNullish: this.isNullish rollParsed: this.rollParsed,
setVar: this.setVar,
times: this.times
}); });
} }
static add(a, b) { static add(a, b) {
@ -25,6 +26,10 @@ export default class RegisterHandlebarsHelpers {
return (Number.isNaN(aNum) ? 0 : aNum) + (Number.isNaN(bNum) ? 0 : bNum); return (Number.isNaN(aNum) ? 0 : aNum) + (Number.isNaN(bNum) ? 0 : bNum);
} }
static coalesce(...args) {
return args.find(a => a !== undefined && a !== null);
}
static includes(list, item) { static includes(list, item) {
return list.includes(item); return list.includes(item);
} }

View file

@ -29,7 +29,7 @@
{{> damage {{> damage
label=(ifThen ../hasHealing (localize (concat 'DAGGERHEART.CONFIG.HealingType.' index '.name')) (localize (concat 'DAGGERHEART.CONFIG.HealingType.' index '.inChatRoll'))) label=(ifThen ../hasHealing (localize (concat 'DAGGERHEART.CONFIG.HealingType.' index '.name')) (localize (concat 'DAGGERHEART.CONFIG.HealingType.' index '.inChatRoll')))
roll=roll roll=roll
isResource=true resource=index
}} }}
{{/each}} {{/each}}
</div> </div>
@ -57,13 +57,13 @@
{{/if}} {{/if}}
<div class="roll-dice"> <div class="roll-dice">
{{#if roll.dice.length}} {{#if roll.dice.length}}
{{#each roll.dice}} {{#each roll.dice as |dice diceKey|}}
{{#each results}} {{#each results as |dieResult resultKey|}}
{{#if active}} {{#if active}}
<div class="roll-die{{#unless @../first}} has-plus{{/unless}}"> <div class="roll-die{{#unless @../first}} has-plus{{/unless}}">
<div <div
class="dice reroll-button {{../denomination}}" class="dice reroll-button {{coalesce dieResult.denomination dice.denomination}}"
data-type="damage" data-damage-type="{{@../../key}}" data-dice="{{@../key}}" data-result="{{@key}}" {{#if ../../isResource}}data-is-resource="true"{{/if}} data-type="damage" data-dice="{{diceKey}}" data-result="{{resultKey}}" {{#if ../../resource}}data-damage-type="{{../../resource}}" data-is-resource="true"{{/if}}
> >
{{#if hasRerolls}}<i class="fa-solid fa-dice dice-rerolled" data-tooltip="{{localize "DAGGERHEART.GENERAL.rerolled"}}"></i>{{/if}} {{#if hasRerolls}}<i class="fa-solid fa-dice dice-rerolled" data-tooltip="{{localize "DAGGERHEART.GENERAL.rerolled"}}"></i>{{/if}}
{{result}} {{result}}

View file

@ -82,10 +82,14 @@
</div> </div>
{{/each}} {{/each}}
{{else}} {{else}}
{{#each roll.dice}} {{#each roll.dice as |dice|}}
{{#each results}} {{#each results as |dieResult|}}
<div class="roll-die {{#unless (or @../first discarded)}} has-plus{{/unless}}"> <div class="roll-die {{#unless (or @../first discarded)}} has-plus{{/unless}}">
<div class="dice {{../denomination}}{{#if discarded}} discarded{{else}}{{#if (and @../first ../../roll.advantage.type)}}{{#if (eq ../../roll.advantage.type 1)}} color-adv{{else}} color-dis{{/if}}{{/if}}{{#if success}} color-adv{{/if}}{{/if}}"> <div class="
dice
{{coalesce dieResult.denomination dice.denomination}}
{{#if discarded}} discarded{{else}}{{#if (and @../first ../../roll.advantage.type)}}{{#if (eq ../../roll.advantage.type 1)}} color-adv{{else}} color-dis{{/if}}{{/if}}{{#if success}} color-adv{{/if}}{{/if}}
">
{{result}} {{result}}
</div> </div>
</div> </div>