451 lines
16 KiB
JavaScript
451 lines
16 KiB
JavaScript
/**
|
|
* 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 <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);
|
|
});
|
|
}
|
|
}
|