Initial
This commit is contained in:
1
resources/app/client-esm/applications/dice/_module.mjs
Normal file
1
resources/app/client-esm/applications/dice/_module.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export {default as RollResolver} from "./roll-resolver.mjs";
|
||||
321
resources/app/client-esm/applications/dice/roll-resolver.mjs
Normal file
321
resources/app/client-esm/applications/dice/roll-resolver.mjs
Normal file
@@ -0,0 +1,321 @@
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
import ApplicationV2 from "../api/application.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} DiceTermFulfillmentDescriptor
|
||||
* @property {string} id A unique identifier for the term.
|
||||
* @property {DiceTerm} term The term.
|
||||
* @property {string} method The fulfillment method.
|
||||
* @property {boolean} [isNew] Was the term newly-added to this resolver?
|
||||
*/
|
||||
|
||||
/**
|
||||
* An application responsible for handling unfulfilled dice terms in a roll.
|
||||
* @extends {ApplicationV2<ApplicationConfiguration, ApplicationRenderOptions>}
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias RollResolver
|
||||
*/
|
||||
export default class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
constructor(roll, options={}) {
|
||||
super(options);
|
||||
this.#roll = roll;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "roll-resolver-{id}",
|
||||
tag: "form",
|
||||
classes: ["roll-resolver"],
|
||||
window: {
|
||||
title: "DICE.RollResolution",
|
||||
},
|
||||
position: {
|
||||
width: 500,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: false,
|
||||
handler: this._fulfillRoll
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
form: {
|
||||
id: "form",
|
||||
template: "templates/dice/roll-resolver.hbs"
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A collection of fulfillable dice terms.
|
||||
* @type {Map<string, DiceTermFulfillmentDescriptor>}
|
||||
*/
|
||||
get fulfillable() {
|
||||
return this.#fulfillable;
|
||||
}
|
||||
|
||||
#fulfillable = new Map();
|
||||
|
||||
/**
|
||||
* A function to call when the first pass of fulfillment is complete.
|
||||
* @type {function}
|
||||
*/
|
||||
#resolve;
|
||||
|
||||
/**
|
||||
* The roll being resolved.
|
||||
* @type {Roll}
|
||||
*/
|
||||
get roll() {
|
||||
return this.#roll;
|
||||
}
|
||||
|
||||
#roll;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so.
|
||||
* @returns {Promise<void>} Returns a Promise that resolves when the first pass of fulfillment is complete.
|
||||
*/
|
||||
async awaitFulfillment() {
|
||||
const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms);
|
||||
if ( !fulfillable.length ) return;
|
||||
Roll.defaultImplementation.RESOLVERS.set(this.roll, this);
|
||||
this.render(true);
|
||||
return new Promise(resolve => this.#resolve = resolve);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a fulfilled die roll.
|
||||
* @param {string} method The method used for fulfillment.
|
||||
* @param {string} denomination The denomination of the fulfilled die.
|
||||
* @param {number} result The rolled number.
|
||||
* @returns {boolean} Whether the result was consumed.
|
||||
*/
|
||||
registerResult(method, denomination, result) {
|
||||
const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`;
|
||||
const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === "");
|
||||
if ( !term ) {
|
||||
ui.notifications.warn(`${denomination} roll was not needed by the resolver.`);
|
||||
return false;
|
||||
}
|
||||
term.value = `${result}`;
|
||||
const submitTerm = term.closest(".form-fields")?.querySelector("button");
|
||||
if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click"));
|
||||
else this._checkDone();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options={}) {
|
||||
if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element));
|
||||
Roll.defaultImplementation.RESOLVERS.delete(this.roll);
|
||||
this.#resolve?.();
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _prepareContext(_options) {
|
||||
const context = {
|
||||
formula: this.roll.formula,
|
||||
groups: {}
|
||||
};
|
||||
for ( const fulfillable of this.fulfillable.values() ) {
|
||||
const { id, term, method, isNew } = fulfillable;
|
||||
fulfillable.isNew = false;
|
||||
const config = CONFIG.Dice.fulfillment.methods[method];
|
||||
const group = context.groups[id] = {
|
||||
results: [],
|
||||
label: term.expression,
|
||||
icon: config.icon ?? '<i class="fas fa-bluetooth"></i>',
|
||||
tooltip: game.i18n.localize(config.label)
|
||||
};
|
||||
const { denomination, faces } = term;
|
||||
const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon;
|
||||
for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) {
|
||||
const result = term.results[i];
|
||||
const { result: value, exploded, rerolled } = result ?? {};
|
||||
group.results.push({
|
||||
denomination, faces, id, method, icon, exploded, rerolled, isNew,
|
||||
value: value ?? "",
|
||||
readonly: method !== "manual",
|
||||
disabled: !!result
|
||||
});
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _onSubmitForm(formConfig, event) {
|
||||
this._toggleSubmission(false);
|
||||
this.element.querySelectorAll("input").forEach(input => {
|
||||
if ( !isNaN(input.valueAsNumber) ) return;
|
||||
const { term } = this.fulfillable.get(input.name);
|
||||
input.value = `${term.randomFace()}`;
|
||||
});
|
||||
await super._onSubmitForm(formConfig, event);
|
||||
this.element?.querySelectorAll("input").forEach(input => input.disabled = true);
|
||||
this.#resolve();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle prompting for a single extra result from a term.
|
||||
* @param {DiceTerm} term The term.
|
||||
* @param {string} method The method used to obtain the result.
|
||||
* @param {object} [options]
|
||||
* @returns {Promise<number|void>}
|
||||
*/
|
||||
async resolveResult(term, method, { reroll=false, explode=false }={}) {
|
||||
const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`);
|
||||
if ( !group ) {
|
||||
console.warn("Attempted to resolve a single result for an unregistered DiceTerm.");
|
||||
return;
|
||||
}
|
||||
const fields = document.createElement("div");
|
||||
fields.classList.add("form-fields");
|
||||
fields.innerHTML = `
|
||||
<label class="icon die-input new-addition" data-denomination="${term.denomination}" data-method="${method}">
|
||||
<input type="number" min="1" max="${term.faces}" step="1" name="${term._id}"
|
||||
${method === "manual" ? "" : "readonly"} placeholder="${game.i18n.localize(term.denomination)}">
|
||||
${reroll ? '<i class="fas fa-arrow-rotate-right"></i>' : ""}
|
||||
${explode ? '<i class="fas fa-burst"></i>' : ""}
|
||||
${CONFIG.Dice.fulfillment.dice[term.denomination]?.icon ?? ""}
|
||||
</label>
|
||||
<button type="button" class="submit-result" data-tooltip="DICE.SubmitRoll"
|
||||
aria-label="${game.i18n.localize("DICE.SubmitRoll")}">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
`;
|
||||
group.appendChild(fields);
|
||||
this.setPosition({ height: "auto" });
|
||||
return new Promise(resolve => {
|
||||
const button = fields.querySelector("button");
|
||||
const input = fields.querySelector("input");
|
||||
button.addEventListener("click", () => {
|
||||
if ( !input.validity.valid ) {
|
||||
input.form.reportValidity();
|
||||
return;
|
||||
}
|
||||
let value = input.valueAsNumber;
|
||||
if ( !value ) value = term.randomFace();
|
||||
input.value = `${value}`;
|
||||
input.disabled = true;
|
||||
button.remove();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the Roll instance with the fulfilled results.
|
||||
* @this {RollResolver}
|
||||
* @param {SubmitEvent} event The originating form submission event.
|
||||
* @param {HTMLFormElement} form The form element that was submitted.
|
||||
* @param {FormDataExtended} formData Processed data for the submitted form.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
static async _fulfillRoll(event, form, formData) {
|
||||
// Update the DiceTerms with the fulfilled values.
|
||||
for ( let [id, results] of Object.entries(formData.object) ) {
|
||||
const { term } = this.fulfillable.get(id);
|
||||
if ( !Array.isArray(results) ) results = [results];
|
||||
for ( const result of results ) {
|
||||
const roll = { result: undefined, active: true };
|
||||
// A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll.
|
||||
if ( result === null ) roll.result = term.randomFace();
|
||||
else roll.result = result;
|
||||
term.results.push(roll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify any of the given terms which should be fulfilled externally.
|
||||
* @param {RollTerm[]} terms The terms.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.isNew=false] Whether this term is a new addition to the already-rendered RollResolver.
|
||||
* @returns {Promise<DiceTerm[]>}
|
||||
*/
|
||||
async #identifyFulfillableTerms(terms, { isNew=false }={}) {
|
||||
const config = game.settings.get("core", "diceConfiguration");
|
||||
const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms);
|
||||
fulfillable.forEach(term => {
|
||||
if ( term._id ) return;
|
||||
const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
|
||||
const id = foundry.utils.randomID();
|
||||
term._id = id;
|
||||
term.method = method;
|
||||
this.fulfillable.set(id, { id, term, method, isNew });
|
||||
});
|
||||
return fulfillable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new term to the resolver.
|
||||
* @param {DiceTerm} term The term.
|
||||
* @returns {Promise<void>} Returns a Promise that resolves when the term's results have been externally fulfilled.
|
||||
*/
|
||||
async addTerm(term) {
|
||||
if ( !(term instanceof foundry.dice.terms.DiceTerm) ) {
|
||||
throw new Error("Only DiceTerm instances may be added to the RollResolver.");
|
||||
}
|
||||
const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true });
|
||||
if ( !fulfillable.length ) return;
|
||||
this.render({ force: true, position: { height: "auto" } });
|
||||
return new Promise(resolve => this.#resolve = resolve);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if all rolls have been fulfilled.
|
||||
* @protected
|
||||
*/
|
||||
_checkDone() {
|
||||
// If the form has already in the submission state, we don't need to re-submit.
|
||||
const submitter = this.element.querySelector('button[type="submit"]');
|
||||
if ( submitter.disabled ) return;
|
||||
|
||||
// If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done.
|
||||
if ( this.element.querySelector("input:not([readonly], :disabled)") ) return;
|
||||
for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) {
|
||||
if ( input.value === "" ) return;
|
||||
}
|
||||
this.element.requestSubmit(submitter);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the state of the submit button.
|
||||
* @param {boolean} enabled Whether the button is enabled.
|
||||
* @protected
|
||||
*/
|
||||
_toggleSubmission(enabled) {
|
||||
const submit = this.element.querySelector('button[type="submit"]');
|
||||
const icon = submit.querySelector("i");
|
||||
icon.className = `fas ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`;
|
||||
submit.disabled = !enabled;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user