Files
Foundry-VTT-Docker/resources/app/client/apps/forms/roll-table-config.js

451 lines
16 KiB
JavaScript
Raw Normal View History

2025-01-04 00:34:03 +01:00
/**
* The Application responsible for displaying and editing a single RollTable document.
* @param {RollTable} table The RollTable document being configured
* @param {DocumentSheetOptions} [options] Additional application configuration options
*/
class RollTableConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "roll-table-config"],
template: "templates/sheets/roll-table-config.html",
width: 720,
height: "auto",
closeOnSubmit: false,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
scrollY: ["table.table-results tbody"],
dragDrop: [{dragSelector: null, dropSelector: null}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = super.getData(options);
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {secrets: this.object.isOwner});
const results = this.document.results.map(result => {
result = result.toObject(false);
result.isText = result.type === CONST.TABLE_RESULT_TYPES.TEXT;
result.isDocument = result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT;
result.isCompendium = result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM;
result.img = result.img || CONFIG.RollTable.resultIcon;
result.text = TextEditor.decodeHTML(result.text);
return result;
});
results.sort((a, b) => a.range[0] - b.range[0]);
// Merge data and return;
return foundry.utils.mergeObject(context, {
results,
resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
obj[v[1]] = game.i18n.localize(`TABLE.RESULT_TYPES.${v[0]}.label`);
return obj;
}, {}),
documentTypes: CONST.COMPENDIUM_DOCUMENT_TYPES.map(d =>
({value: d, label: game.i18n.localize(getDocumentClass(d).metadata.label)})),
compendiumPacks: Array.from(game.packs.keys()).map(k => ({value: k, label: k}))
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// We need to disable roll button if the document is not editable AND has no formula
if ( !this.isEditable && !this.document.formula ) return;
// Roll the Table
const button = html.find("button.roll");
button.click(this._onRollTable.bind(this));
button[0].disabled = false;
// The below options require an editable sheet
if ( !this.isEditable ) return;
// Reset the Table
html.find("button.reset").click(this._onResetTable.bind(this));
// Save the sheet on checkbox change
html.find('input[type="checkbox"]').change(this._onSubmit.bind(this));
// Create a new Result
html.find("a.create-result").click(this._onCreateResult.bind(this));
// Delete a Result
html.find("a.delete-result").click(this._onDeleteResult.bind(this));
// Lock or Unlock a Result
html.find("a.lock-result").click(this._onLockResult.bind(this));
// Modify Result Type
html.find(".result-type select").change(this._onChangeResultType.bind(this));
// Re-normalize Table Entries
html.find(".normalize-results").click(this._onNormalizeResults.bind(this));
}
/* -------------------------------------------- */
/**
* Handle creating a TableResult in the RollTable document
* @param {MouseEvent} event The originating mouse event
* @param {object} [resultData] An optional object of result data to use
* @returns {Promise}
* @private
*/
async _onCreateResult(event, resultData={}) {
event.preventDefault();
// Save any pending changes
await this._onSubmit(event);
// Get existing results
const results = Array.from(this.document.results.values());
let last = results[results.length - 1];
// Get weight and range data
let weight = last ? (last.weight || 1) : 1;
let totalWeight = results.reduce((t, r) => t + r.weight, 0) || 1;
let minRoll = results.length ? Math.min(...results.map(r => r.range[0])) : 0;
let maxRoll = results.length ? Math.max(...results.map(r => r.range[1])) : 0;
// Determine new starting range
const spread = maxRoll - minRoll + 1;
const perW = Math.round(spread / totalWeight);
const range = [maxRoll + 1, maxRoll + Math.max(1, weight * perW)];
// Create the new Result
resultData = foundry.utils.mergeObject({
type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
documentCollection: last ? last.documentCollection : null,
weight: weight,
range: range,
drawn: false
}, resultData);
return this.document.createEmbeddedDocuments("TableResult", [resultData]);
}
/* -------------------------------------------- */
/**
* Submit the entire form when a table result type is changed, in case there are other active changes
* @param {Event} event
* @private
*/
_onChangeResultType(event) {
event.preventDefault();
const rt = CONST.TABLE_RESULT_TYPES;
const select = event.target;
const value = parseInt(select.value);
const resultKey = select.name.replace(".type", "");
let documentCollection = "";
if ( value === rt.DOCUMENT ) documentCollection = "Actor";
else if ( value === rt.COMPENDIUM ) documentCollection = game.packs.keys().next().value;
const updateData = {[resultKey]: {documentCollection, documentId: null}};
return this._onSubmit(event, {updateData});
}
/* -------------------------------------------- */
/**
* Handle deleting a TableResult from the RollTable document
* @param {MouseEvent} event The originating click event
* @returns {Promise<TableResult>} The deleted TableResult document
* @private
*/
async _onDeleteResult(event) {
event.preventDefault();
await this._onSubmit(event);
const li = event.currentTarget.closest(".table-result");
const result = this.object.results.get(li.dataset.resultId);
return result.delete();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const allowed = Hooks.call("dropRollTableSheetData", this.document, this, data);
if ( allowed === false ) return;
// Get the dropped document
if ( !CONST.COMPENDIUM_DOCUMENT_TYPES.includes(data.type) ) return;
const cls = getDocumentClass(data.type);
const document = await cls.fromDropData(data);
if ( !document || document.isEmbedded ) return;
// Delegate to the onCreate handler
const isCompendium = !!document.compendium;
return this._onCreateResult(event, {
type: isCompendium ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
documentCollection: isCompendium ? document.pack : document.documentName,
text: document.name,
documentId: document.id,
img: document.img || null
});
}
/* -------------------------------------------- */
/**
* Handle changing the actor profile image by opening a FilePicker
* @param {Event} event
* @private
*/
_onEditImage(event) {
const img = event.currentTarget;
const isHeader = img.dataset.edit === "img";
let current = this.document.img;
if ( !isHeader ) {
const li = img.closest(".table-result");
const result = this.document.results.get(li.dataset.resultId);
current = result.img;
}
const fp = new FilePicker({
type: "image",
current: current,
callback: path => {
img.src = path;
return this._onSubmit(event);
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
/* -------------------------------------------- */
/**
* Handle a button click to re-normalize dice result ranges across all RollTable results
* @param {Event} event
* @private
*/
async _onNormalizeResults(event) {
event.preventDefault();
if ( !this.rendered || this._submitting) return false;
// Save any pending changes
await this._onSubmit(event);
// Normalize the RollTable
return this.document.normalize();
}
/* -------------------------------------------- */
/**
* Handle toggling the drawn status of the result in the table
* @param {Event} event
* @private
*/
_onLockResult(event) {
event.preventDefault();
const tableResult = event.currentTarget.closest(".table-result");
const result = this.document.results.get(tableResult.dataset.resultId);
return result.update({drawn: !result.drawn});
}
/* -------------------------------------------- */
/**
* Reset the Table to it's original composition with all options unlocked
* @param {Event} event
* @private
*/
_onResetTable(event) {
event.preventDefault();
return this.document.resetResults();
}
/* -------------------------------------------- */
/**
* Handle drawing a result from the RollTable
* @param {Event} event
* @private
*/
async _onRollTable(event) {
event.preventDefault();
await this.submit({preventClose: true, preventRender: true});
event.currentTarget.disabled = true;
let tableRoll = await this.document.roll();
const draws = this.document.getResultsForRoll(tableRoll.roll.total);
if ( draws.length ) {
if (game.settings.get("core", "animateRollTable")) await this._animateRoll(draws);
await this.document.draw(tableRoll);
}
event.currentTarget.disabled = false;
}
/* -------------------------------------------- */
/**
* Configure the update object workflow for the Roll Table configuration sheet
* Additional logic is needed here to reconstruct the results array from the editable fields on the sheet
* @param {Event} event The form submission event
* @param {Object} formData The validated FormData translated into an Object for submission
* @returns {Promise}
* @private
*/
async _updateObject(event, formData) {
// Expand the data to update the results array
const expanded = foundry.utils.expandObject(formData);
expanded.results = expanded.hasOwnProperty("results") ? Object.values(expanded.results) : [];
for (let r of expanded.results) {
r.range = [r.rangeL, r.rangeH];
switch (r.type) {
// Document results
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
const collection = game.collections.get(r.documentCollection);
if (!collection) continue;
// Get the original document, if the name still matches - take no action
const original = r.documentId ? collection.get(r.documentId) : null;
if (original && (original.name === r.text)) continue;
// Otherwise, find the document by ID or name (ID preferred)
const doc = collection.find(e => (e.id === r.text) || (e.name === r.text)) || null;
r.documentId = doc?.id ?? null;
r.text = doc?.name ?? null;
r.img = doc?.img ?? null;
r.img = doc?.thumb || doc?.img || null;
break;
// Compendium results
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
const pack = game.packs.get(r.documentCollection);
if (pack) {
// Get the original entry, if the name still matches - take no action
const original = pack.index.get(r.documentId) || null;
if (original && (original.name === r.text)) continue;
// Otherwise, find the document by ID or name (ID preferred)
const doc = pack.index.find(i => (i._id === r.text) || (i.name === r.text)) || null;
r.documentId = doc?._id || null;
r.text = doc?.name || null;
r.img = doc?.thumb || doc?.img || null;
}
break;
// Plain text results
default:
r.type = CONST.TABLE_RESULT_TYPES.TEXT;
r.documentCollection = null;
r.documentId = null;
}
}
// Update the object
return this.document.update(expanded, {diff: false, recursive: false});
}
/* -------------------------------------------- */
/**
* Display a roulette style animation when a Roll Table result is drawn from the sheet
* @param {TableResult[]} results An Array of drawn table results to highlight
* @returns {Promise} A Promise which resolves once the animation is complete
* @protected
*/
async _animateRoll(results) {
// Get the list of results and their indices
const tableResults = this.element[0].querySelector(".table-results > tbody");
const drawnIds = new Set(results.map(r => r.id));
const drawnItems = Array.from(tableResults.children).filter(item => drawnIds.has(item.dataset.resultId));
// Set the animation timing
const nResults = this.object.results.size;
const maxTime = 2000;
let animTime = 50;
let animOffset = Math.round(tableResults.offsetHeight / (tableResults.children[0].offsetHeight * 2));
const nLoops = Math.min(Math.ceil(maxTime/(animTime * nResults)), 4);
if ( nLoops === 1 ) animTime = maxTime / nResults;
// Animate the roulette
await this._animateRoulette(tableResults, drawnIds, nLoops, animTime, animOffset);
// Flash the results
const flashes = drawnItems.map(li => this._flashResult(li));
return Promise.all(flashes);
}
/* -------------------------------------------- */
/**
* Animate a "roulette" through the table until arriving at the final loop and a drawn result
* @param {HTMLOListElement} ol The list element being iterated
* @param {Set<string>} drawnIds The result IDs which have already been drawn
* @param {number} nLoops The number of times to loop through the animation
* @param {number} animTime The desired animation time in milliseconds
* @param {number} animOffset The desired pixel offset of the result within the list
* @returns {Promise} A Promise that resolves once the animation is complete
* @protected
*/
async _animateRoulette(ol, drawnIds, nLoops, animTime, animOffset) {
let loop = 0;
let idx = 0;
let item = null;
return new Promise(resolve => {
let animId = setInterval(() => {
if (idx === 0) loop++;
if (item) item.classList.remove("roulette");
// Scroll to the next item
item = ol.children[idx];
ol.scrollTop = (idx - animOffset) * item.offsetHeight;
// If we are on the final loop
if ( (loop === nLoops) && drawnIds.has(item.dataset.resultId) ) {
clearInterval(animId);
return resolve();
}
// Continue the roulette and cycle the index
item.classList.add("roulette");
idx = idx < ol.children.length - 1 ? idx + 1 : 0;
}, animTime);
});
}
/* -------------------------------------------- */
/**
* Display a flashing animation on the selected result to emphasize the draw
* @param {HTMLElement} item The HTML &lt;li> item of the winning result
* @returns {Promise} A Promise that resolves once the animation is complete
* @protected
*/
async _flashResult(item) {
return new Promise(resolve => {
let count = 0;
let animId = setInterval(() => {
if (count % 2) item.classList.remove("roulette");
else item.classList.add("roulette");
if (count === 7) {
clearInterval(animId);
resolve();
}
count++;
}, 50);
});
}
}