From f69e3841ff40acfdec078667fc2a6c80ef5ca0f9 Mon Sep 17 00:00:00 2001 From: Matt Smith Date: Mon, 28 Sep 2020 15:54:14 +0000 Subject: [PATCH] 10: Add attribute groups base implementation - Added support for attribute groups - Added rollable buttons to formula attributes - Added additional i18n translation strings --- lang/en.json | 15 +- module/actor-sheet.js | 401 ++++++++++++++++++++++++-- module/actor.js | 44 ++- module/macro.js | 47 +++ module/simple.js | 70 +++-- module/templates.js | 3 +- styles/simple.css | 90 +++++- styles/simple.less | 108 ++++++- template.json | 6 +- templates/actor-sheet.html | 64 ++-- templates/item-sheet.html | 15 +- templates/parts/sheet-attributes.html | 94 +++--- templates/parts/sheet-groups.html | 21 ++ 13 files changed, 827 insertions(+), 151 deletions(-) create mode 100644 module/macro.js create mode 100644 templates/parts/sheet-groups.html diff --git a/lang/en.json b/lang/en.json index f3cf520..e08325c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -6,6 +6,8 @@ "SIMPLE.NotifyInitFormulaUpdated": "Initiative formula was updated to:", "SIMPLE.NotifyInitFormulaInvalid": "Initiative formula was invalid:", + "SIMPLE.NotifyGroupDuplicate": "Attribute group already exists.", + "SIMPLE.NotifyGroupAlphanumeric": "Attribute group names may not contain spaces or periods.", "SIMPLE.ResourceMin": "Min", "SIMPLE.ResourceValue": "Value", @@ -13,5 +15,16 @@ "SIMPLE.DefineTemplate": "Define as Template", "SIMPLE.UnsetTemplate": "Unset Template", - "SIMPLE.NoTemplate": "No Template" + "SIMPLE.NoTemplate": "No Template", + + "SIMPLE.AttributeKey": "Attribute Key", + "SIMPLE.AttributeValue": "Value", + "SIMPLE.AttributeLabel": "Label", + "SIMPLE.AttributeDtype": "Data Type", + + "SIMPLE.DeleteGroup": "Delete group?", + "SIMPLE.DeleteGroupContent": "Do you wish to delete this group? This will delete the following group and all attributes included in it: ", + + "SIMPLE.Create": "Create", + "SIMPLE.New": "New" } \ No newline at end of file diff --git a/module/actor-sheet.js b/module/actor-sheet.js index 2a42379..831139f 100644 --- a/module/actor-sheet.js +++ b/module/actor-sheet.js @@ -7,13 +7,14 @@ import { ATTRIBUTE_TYPES } from "./constants.js"; export class SimpleActorSheet extends ActorSheet { /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["worldbuilding", "sheet", "actor"], - template: "systems/worldbuilding/templates/actor-sheet.html", + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["worldbuilding", "sheet", "actor"], + template: "systems/worldbuilding/templates/actor-sheet.html", width: 600, height: 600, tabs: [{navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description"}], + scrollY: [".biography", ".items", ".attributes"], dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}] }); } @@ -25,9 +26,46 @@ export class SimpleActorSheet extends ActorSheet { const data = super.getData(); data.dtypes = ATTRIBUTE_TYPES; for ( let attr of Object.values(data.data.attributes) ) { - attr.isCheckbox = attr.dtype === "Boolean"; - attr.isResource = attr.dtype === "Resource"; + if ( attr.dtype ) { + attr.isCheckbox = attr.dtype === "Boolean"; + attr.isResource = attr.dtype === "Resource"; + } } + + // Initialize ungrouped attributes for later. + data.data.ungroupedAttributes = {}; + + // Build an array of sorted group keys. + let groupKeys = Object.keys(data.data.groups).sort((a, b) => { + // Attempt to sort by the label, but fall back to the key. + let aSort = data.data.groups[a].label ? data.data.groups[a].label : a; + let bSort = data.data.groups[b].label ? data.data.groups[b].label : b; + return aSort.localeCompare(bSort); + }); + + // Iterate over the sorted groups to add their attributes.. + groupKeys.forEach(key => { + // Retrieve the group. + let group = data.data.attributes[key]; + + // Initialize the attributes container for this group. + if ( !data.data.groups[key]['attributes'] ) data.data.groups[key]['attributes'] = {}; + + // Sort the attributes within the group, and then iterate over them. + Object.keys(group).sort((a, b) => a.localeCompare(b)).forEach(attr => { + // For each attribute, determine whether it's a checkbox or resource, and then add it to the group's attributes list. + group[attr]['isCheckbox'] = group[attr]['dtype'] === 'Boolean'; + group[attr]['isResource'] = group[attr]['dtype'] === 'Resource'; + data.data.groups[key]['attributes'][attr] = group[attr]; + }); + }); + + // Sort the remaining attributes attributes. + Object.keys(data.data.attributes).filter(a => !groupKeys.includes(a)).sort((a, b) => a.localeCompare(b)).forEach(key => { + data.data.ungroupedAttributes[key] = data.data.attributes[key]; + }); + + // Add shorthand. data.shorthand = !!game.settings.get("worldbuilding", "macroShorthand"); return data; } @@ -35,24 +73,17 @@ export class SimpleActorSheet extends ActorSheet { /* -------------------------------------------- */ /** @override */ - activateListeners(html) { + activateListeners(html) { super.activateListeners(html); + // Handle rollable items. + html.find(".items .rollable").on("click", this._onItemRoll.bind(this)); + // Handle rollable attributes. - html.find('.items .rollable').click(ev => { - let button = $(ev.currentTarget); - let r = new Roll(button.data('roll'), this.actor.getRollData()); - const li = button.parents(".item"); - const item = this.actor.getOwnedItem(li.data("itemId")); - r.roll().toMessage({ - user: game.user._id, - speaker: ChatMessage.getSpeaker({ actor: this.actor }), - flavor: `

${item.name}

${button.text()}

` - }); - }); + html.find(".attributes").on("click", "a.attribute-roll", this._onAttributeRoll.bind(this)); // Everything below here is only needed if the sheet is editable - if (!this.options.editable) return; + if ( !this.options.editable ) return; // Update Inventory Item html.find('.item-edit').click(ev => { @@ -70,6 +101,51 @@ export class SimpleActorSheet extends ActorSheet { // Add or Remove Attribute html.find(".attributes").on("click", ".attribute-control", this._onClickAttributeControl.bind(this)); + + // Add attribute groups. + html.find(".groups").on("click", ".group-control", this._onClickAttributeGroupControl.bind(this)); + + // Add draggable for macros. + html.find(".attributes a.attribute-roll").each((i, a) => { + a.setAttribute("draggable", true); + a.addEventListener("dragstart", ev => { + let dragData = ev.currentTarget.dataset; + ev.dataTransfer.setData('text/plain', JSON.stringify(dragData)); + }, false); + }); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) { + // Exit early if this isn't a named attribute. + if ( event.currentTarget ) { + if ( event.currentTarget.tagName.toLowerCase() == 'input' && !event.currentTarget.hasAttribute('name')) { + return; + } + } + + let self = $(event.currentTarget); + let attr = null; + + // If this is the attribute key, we need to make a note of it so that we can restore focus when its recreated. + if ( self.hasClass('attribute-key') ) { + let val = self.val(); + let oldVal = self.parents('.attribute').data('attribute'); + oldVal = oldVal.includes('.') ? oldVal.split('.')[1] : oldVal; + attr = self.attr('name').replace(oldVal, val); + } + + // Submit the form. + await super._onSubmit(event, {updateData: updateData, preventClose: preventClose, preventRender: preventRender}); + + // If this was the attribute key, set a very short timeout and retrigger focus after the original element is deleted and the new one is inserted. + if ( attr ) { + setTimeout(() => { + $(`input[name="${attr}"]`).parents('.attribute').find('.attribute-value').focus(); + }, 10); + } } /* -------------------------------------------- */ @@ -85,6 +161,44 @@ export class SimpleActorSheet extends ActorSheet { /* -------------------------------------------- */ + /** + * Listen for roll buttons on items. + * @param {MouseEvent} event The originating left click event + */ + _onItemRoll(event) { + let button = $(event.currentTarget); + let r = new Roll(button.data('roll'), this.actor.getRollData()); + const li = button.parents(".item"); + const item = this.actor.getOwnedItem(li.data("itemId")); + r.roll().toMessage({ + user: game.user._id, + speaker: ChatMessage.getSpeaker({ actor: this.actor }), + flavor: `

${item.name}

${button.text()}

` + }); + } + + /** + * Listen for the roll button on attributes. + * @param {MouseEvent} event The originating left click event + */ + _onAttributeRoll(event) { + event.preventDefault(); + const button = event.currentTarget; + const formula = button.closest(".attribute").querySelector(".attribute-value")?.value; + const label = button.closest(".attribute").querySelector(".attribute-label")?.value; + const chatLabel = label ?? button.parentElement.querySelector(".attribute-key").value; + + // If there's a formula, attempt to roll it. + if ( formula ) { + let r = new Roll(formula, this.actor.getRollData()); + r.roll().toMessage({ + user: game.user._id, + speaker: ChatMessage.getSpeaker({ actor: this.actor }), + flavor: `${chatLabel}` + }); + } + } + /** * Listen for click events on an attribute control to modify the composition of attributes in the sheet * @param {MouseEvent} event The originating left click event @@ -94,12 +208,16 @@ export class SimpleActorSheet extends ActorSheet { event.preventDefault(); const a = event.currentTarget; const action = a.dataset.action; + const group = a.dataset.group; + let dtype = a.dataset.dtype; const attrs = this.object.data.data.attributes; + const groups = this.object.data.data.groups; const form = this.form; // Add new attribute if ( action === "create" ) { - const objKeys = Object.keys(attrs); + // Determine the new attribute key for ungrouped attributes. + let objKeys = Object.keys(attrs).filter(k => !Object.keys(groups).includes(k)); let nk = Object.keys(attrs).length + 1; let newValue = `attr${nk}`; let newKey = document.createElement("div"); @@ -107,7 +225,53 @@ export class SimpleActorSheet extends ActorSheet { ++nk; newValue = `attr${nk}`; }; - newKey.innerHTML = ``; + + // Build options for construction HTML inputs. + let htmlItems = { + key: { + type: "text", + value: newValue + } + }; + + // Grouped attributes. + if ( group ) { + objKeys = attrs[group] ? Object.keys(attrs[group]) : []; + nk = objKeys.length + 1; + newValue = `attr${nk}`; + while ( objKeys.includes(newValue) ) { + ++nk; + newValue = `attr${nk}`; + } + + // Update the HTML options used to build the new input. + htmlItems.key.value = newValue; + htmlItems.group = { + type: "hidden", + value: group + }; + htmlItems.dtype = { + type: "hidden", + value: dtype + }; + } + // Ungrouped attributes. + else { + // Choose a default dtype based on the last attribute, fall back to "String". + if (!dtype) { + let lastAttr = document.querySelector('.attributes > .attributes-group .attribute:last-child .attribute-dtype')?.value; + dtype = lastAttr ? lastAttr : "String"; + htmlItems.dtype = { + type: "hidden", + value: dtype + }; + } + } + + // Build the form elements used to create the new grouped attribute. + newKey.innerHTML = this._getAttributeHtml(htmlItems, nk, group); + + // Append the form element and submit the form. newKey = newKey.children[0]; form.appendChild(newKey); await this._onSubmit(event); @@ -121,18 +285,157 @@ export class SimpleActorSheet extends ActorSheet { } } + /** + * Listen for click events and modify attribute groups. + * @param {MouseEvent} event The originating left click event + */ + async _onClickAttributeGroupControl(event) { + event.preventDefault(); + const a = event.currentTarget; + const action = a.dataset.action; + const form = this.form; + + // Add new attribute group. + if ( action === "create-group" ) { + let newValue = $(a).siblings('.group-prefix').val(); + // Verify the new group key is valid, and use it to create the group. + if ( newValue.length > 0 && this._validateGroup(newValue) ) { + let newKey = document.createElement("div"); + newKey.innerHTML = ``; + // Append the form element and submit the form. + newKey = newKey.children[0]; + form.appendChild(newKey); + await this._onSubmit(event); + } + + } + + // Remove existing attribute + else if ( action === "delete-group" ) { + let groupHeader = a.closest(".group-header"); + let group = $(groupHeader).find('.group-key'); + // Create a dialog to confirm group deletion. + new Dialog({ + title: game.i18n.localize("SIMPLE.DeleteGroup"), + content: `${game.i18n.localize("SIMPLE.DeleteGroupContent")} ${group.val()}`, + buttons: { + confirm: { + icon: '', + label: game.i18n.localize("Yes"), + callback: async () => { + groupHeader.parentElement.removeChild(groupHeader); + await this._onSubmit(event); + } + }, + cancel: { + icon: '', + label: game.i18n.localize("No"), + } + } + }).render(true); + } + } + /* -------------------------------------------- */ + + /** + * Return HTML for a new attribute to be applied to the form for submission. + * + * @param {Object} items Keyed object where each item has a "type" and "value" property. + * @param {string} index Numeric index or key of the new attribute. + * @param {string|boolean} group String key of the group, or false. + * + * @returns {string} Html string. + */ + _getAttributeHtml(items, index, group = false) { + // Initialize the HTML. + let result = '
'; + // Iterate over the supplied keys and build their inputs (including whether or not they need a group key). + for (let [key, item] of Object.entries(items)) { + result = result + ``; + } + // Close the HTML and return. + return result + '
'; + } + + /* -------------------------------------------- */ + + /** + * Validate whether or not a group name can be used. + * @param {string} groupName Groupname to validate + * @returns {boolean} + */ + _validateGroup(groupName) { + let groups = Object.keys(this.actor.data.data.groups); + + // Check for duplicate group keys. + if ( groups.includes(groupName) ) { + ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupDuplicate") + ` (${groupName})`); + return false; + } + + // Check for whitespace or periods. + if ( groupName.match(/[\s|\.]/i) ) { + ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAlphanumeric")); + return false; + } + + return true; + } + /** @override */ _updateObject(event, formData) { + // Handle attribute and group updates. + formData = this._updateAttributes(formData); + formData = this._updateGroups(formData); + + // Update the Actor with the new form values. + return this.object.update(formData); + } + + /** + * Update attributes when updating an actor object. + * + * @param {Object} formData Form data object to modify keys and values for. + * @returns {Object} updated formData object. + */ + _updateAttributes(formData) { + let groupKeys = []; + // Handle the free-form attributes list const formAttrs = expandObject(formData).data.attributes || {}; const attributes = Object.values(formAttrs).reduce((obj, v) => { - let k = v["key"].trim(); - if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); - delete v["key"]; - obj[k] = v; + let attrs = []; + let group = null; + // Handle attribute keys for grouped attributes. + if ( !v["key"] ) { + attrs = Object.keys(v); + attrs.forEach(attrKey => { + group = v[attrKey]['group']; + groupKeys.push(group); + let attr = v[attrKey]; + let k = v[attrKey]["key"] ? v[attrKey]["key"].trim() : attrKey.trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); + delete attr["key"]; + // Add the new attribute if it's grouped, but we need to build the nested structure first. + if ( !obj[group] ) { + obj[group] = {}; + } + obj[group][k] = attr; + }); + } + // Handle attribute keys for ungrouped attributes. + else { + let k = v["key"].trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); + delete v["key"]; + // Add the new attribute only if it's ungrouped. + if ( !group ) { + obj[k] = v; + } + } return obj; }, {}); @@ -141,13 +444,57 @@ export class SimpleActorSheet extends ActorSheet { if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null; } + // Remove grouped attributes which are no longer used. + for ( let group of groupKeys) { + if ( this.object.data.data.attributes[group] ) { + for ( let k of Object.keys(this.object.data.data.attributes[group]) ) { + if ( !attributes[group].hasOwnProperty(k) ) attributes[group][`-=${k}`] = null; + } + } + } + // Re-combine formData formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => { obj[e[0]] = e[1]; return obj; }, {_id: this.object._id, "data.attributes": attributes}); - // Update the Actor - return this.object.update(formData); + return formData; + } + + /** + * Update attribute groups when updating an actor object. + * + * @param {Object} formData Form data object to modify keys and values for. + * @returns {Object} updated formData object. + */ + _updateGroups(formData) { + // Handle the free-form groups list + const formGroups = expandObject(formData).data.groups || {}; + const groups = Object.values(formGroups).reduce((obj, v) => { + // If there are duplicate groups, collapse them. + if ( Array.isArray(v["key"]) ) { + v["key"] = v["key"][0]; + } + // Trim and clean up. + let k = v["key"].trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Group keys may not contain spaces or periods"); + delete v["key"]; + obj[k] = v; + return obj; + }, {}); + + // Remove groups which are no longer used + for ( let k of Object.keys(this.object.data.data.groups) ) { + if ( !groups.hasOwnProperty(k) ) groups[`-=${k}`] = null; + } + + // Re-combine formData + formData = Object.entries(formData).filter(e => !e[0].startsWith("data.groups")).reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {_id: this.object._id, "data.groups": groups}); + + return formData; } } diff --git a/module/actor.js b/module/actor.js index 7621cca..abd86a4 100644 --- a/module/actor.js +++ b/module/actor.js @@ -25,6 +25,7 @@ export class SimpleActor extends Actor { delete data.attributes; delete data.attr; delete data.abil; + delete data.groups; } return data; @@ -44,7 +45,18 @@ export class SimpleActor extends Actor { // Add shortened version of the attributes. if ( !!shorthand ) { if ( !(k in data) ) { - data[k] = v.value; + // Non-grouped attributes. + if ( v.dtype ) { + data[k] = v.value; + } + // Grouped attributes. + else { + data[k] = {}; + for ( let [attrKey, attrValue] of Object.entries(v) ) { + data[k][attrKey] = attrValue.value; + if ( attrValue.dtype == "Formula" ) formulaAttributes.push(`${k}.${attrKey}`); + } + } } } } @@ -110,15 +122,43 @@ export class SimpleActor extends Actor { // Evaluate formula attributes after all other attributes have been handled, // including items. for ( let k of formulaAttributes ) { + // Grouped attributes are included as `group.attr`, so we need to split + // them into new keys. + let attr = null; + if ( k.includes('.') ) { + let attrKey = k.split('.'); + k = attrKey[0]; + attr = attrKey[1]; + } + // Non-grouped attributes. if ( data.attributes[k].value ) { data.attributes[k].value = this._replaceData(data.attributes[k].value, data, {missing: "0"}); // TODO: Replace with: // data.attributes[k].value = Roll.replaceFormulaData(data.attributes[k].value, data, {missing: "0"}); } + // Grouped attributes. + else { + if ( attr ) { + data.attributes[k][attr].value = this._replaceData(data.attributes[k][attr].value, data, {missing: "0"}); + } + } // Duplicate values to shorthand. if ( !!shorthand ) { - data[k] = data.attributes[k].value; + // Non-grouped attributes. + if ( data.attributes[k].value ) { + data[k] = data.attributes[k].value; + } + // Grouped attributes. + else { + if ( attr ) { + // Initialize a group key in case it doesn't exist. + if ( !data[k] ) { + data[k] = {}; + } + data[k][attr] = data.attributes[k][attr].value; + } + } } } } diff --git a/module/macro.js b/module/macro.js new file mode 100644 index 0000000..b194747 --- /dev/null +++ b/module/macro.js @@ -0,0 +1,47 @@ +/** + * Create a Macro from an attribute drop. + * Get an existing worldbuilding macro if one exists, otherwise create a new one. + * @param {Object} data The dropped data + * @param {number} slot The hotbar slot to use + * @returns {Promise} + */ +export async function createWorldbuildingMacro(data, slot) { + const item = data; + + // Create the macro command + const command = `game.worldbuilding.rollAttrMacro("${item.label}", "${item.roll}");`; + let macro = game.macros.entities.find(m => (m.name === item.label) && (m.command === command)); + if (!macro) { + macro = await Macro.create({ + name: item.label, + type: "script", + command: command, + flags: { "worldbuilding.attrMacro": true } + }); + } + + game.user.assignHotbarMacro(macro, slot); + return false; +} + +/** + * Create a Macro from an Item drop. + * Get an existing item macro if one exists, otherwise create a new one. + * @param {string} itemName + * @return {Promise} + */ +export function rollAttrMacro(attrName, attrFormula) { + let actor; + // Get the speaker and actor if not provided. + const speaker = ChatMessage.getSpeaker({ actor: this.actor }); + if (speaker.token) actor = game.actors.tokens[speaker.token]; + if (!actor) actor = game.actors.get(speaker.actor); + + // Create the roll. + let r = new Roll(attrFormula, actor.getRollData()); + r.roll().toMessage({ + user: game.user._id, + speaker: speaker, + flavor: attrName + }); +} \ No newline at end of file diff --git a/module/simple.js b/module/simple.js index e2a04df..4d047bb 100644 --- a/module/simple.js +++ b/module/simple.js @@ -9,11 +9,15 @@ import { SimpleActor } from "./actor.js"; import { SimpleItemSheet } from "./item-sheet.js"; import { SimpleActorSheet } from "./actor-sheet.js"; import { preloadHandlebarsTemplates } from "./templates.js"; +import { createWorldbuildingMacro, rollAttrMacro } from "./macro.js"; /* -------------------------------------------- */ /* Foundry VTT Initialization */ /* -------------------------------------------- */ +/** + * Init hook. + */ Hooks.once("init", async function() { console.log(`Initializing Simple Worldbuilding System`); @@ -26,6 +30,12 @@ Hooks.once("init", async function() { decimals: 2 }; + game.worldbuilding = { + SimpleActor, + createWorldbuildingMacro, + rollAttrMacro, + }; + // Define custom Entity classes CONFIG.Actor.entityClass = SimpleActor; @@ -94,6 +104,11 @@ Hooks.once("init", async function() { preloadHandlebarsTemplates(); }); +/** + * Macrobar hook. + */ +Hooks.on("hotbarDrop", (bar, data, slot) => createWorldbuildingMacro(data, slot)); + /** * Adds the actor template context menu. */ @@ -205,7 +220,7 @@ async function _simpleDirectoryTemplates(entityType = 'actor') { // Setup entity data. let type = entityType == 'actor' ? 'character' : 'item'; let createData = { - name: `New ${ent}`, + name: `${game.i18n.localize("SIMPLE.New")} ${ent}`, type: type, folder: event.currentTarget.dataset.folder }; @@ -231,40 +246,35 @@ async function _simpleDirectoryTemplates(entityType = 'actor') { dlg = await renderTemplate(`systems/worldbuilding/templates/sidebar/entity-create.html`, templateData); // Render the confirmation dialog window - new Dialog({ - title: `Create ${createData.name}`, + Dialog.confirm({ + title: `${game.i18n.localize("SIMPLE.Create")} ${createData.name}`, content: dlg, - buttons: { - create: { - icon: '', - label: `Create ${ent}`, - callback: html => { - // Get the form data. - const form = html[0].querySelector("form"); - mergeObject(createData, validateForm(form)); + yes: html => { + // Get the form data. + const form = html[0].querySelector("form"); + mergeObject(createData, validateForm(form)); - // Store the type and name values, and retrieve the template entity. - let templateActor = entityCollection.getName(createData.type); + // Store the type and name values, and retrieve the template entity. + let templateActor = entityCollection.getName(createData.type); - // If there's a template entity, handle the data. - if (templateActor) { - // Update the object with the existing template's values. - createData = mergeObject(templateActor.data, createData, {inplace: false}); - createData.type = templateActor.data.type; - // Clear the flag so that this doesn't become a new template. - delete createData.flags.worldbuilding.isTemplate; - } - // Otherwise, restore to a valid entity type (character/item). - else { - createData.type = type; - } - - cls.create(createData, {renderSheet: true}); - } + // If there's a template entity, handle the data. + if (templateActor) { + // Update the object with the existing template's values. + createData = mergeObject(templateActor.data, createData, {inplace: false}); + createData.type = templateActor.data.type; + // Clear the flag so that this doesn't become a new template. + delete createData.flags.worldbuilding.isTemplate; } + // Otherwise, restore to a valid entity type (character/item). + else { + createData.type = type; + } + + cls.create(createData, {renderSheet: true}); }, - default: "create" - }).render(true); + no: () => {}, + defaultYes: false + }); } // Otherwise, just create a blank entity. else { diff --git a/module/templates.js b/module/templates.js index 840f4f0..0d84c42 100644 --- a/module/templates.js +++ b/module/templates.js @@ -8,7 +8,8 @@ export const preloadHandlebarsTemplates = async function() { // Define template paths to load const templatePaths = [ // Attribute list partial. - "systems/worldbuilding/templates/parts/sheet-attributes.html" + "systems/worldbuilding/templates/parts/sheet-attributes.html", + "systems/worldbuilding/templates/parts/sheet-groups.html" ]; // Load the template parts diff --git a/styles/simple.css b/styles/simple.css index b6d6d9a..13bc5a0 100644 --- a/styles/simple.css +++ b/styles/simple.css @@ -109,10 +109,51 @@ flex: none; width: auto; } +.worldbuilding .attributes { + position: relative; +} +.worldbuilding .attributes .attribute-control, +.worldbuilding .attributes .group-control { + flex: 0 0 20px; + text-align: center; + line-height: 28px; + border: none; +} +.worldbuilding .attributes .attribute-roll { + flex: 0 0 20px; + text-align: center; + border-bottom: none; + display: flex; + align-items: center; + justify-content: center; +} +.worldbuilding .attributes .group-prefix { + height: 31px; + margin-right: 10px; +} +.worldbuilding .attributes .button { + width: 100%; + flex: 1; + margin: 0; + background: rgba(0, 0, 0, 0.1); + border: 2px groove #f0f0e0; + border-radius: 3px; + font-size: 14px; + line-height: 28px; + display: block; +} +.worldbuilding .attributes .attribute-key { + background: transparent; + border: none; +} .worldbuilding .attributes-header { + position: sticky; + top: 0; + left: 0; + right: 0; padding: 5px; margin: 5px 0; - background: rgba(0, 0, 0, 0.05); + background: #cfcdc2; border: 1px solid #AAA; border-radius: 2px; text-align: center; @@ -138,12 +179,6 @@ border-radius: 0; border-bottom: 1px solid #AAA; } -.worldbuilding .attributes-list a.attribute-control { - flex: 0 0 20px; - text-align: center; - line-height: 28px; - border: none; -} .worldbuilding .attributes-list .attribute-col label { font-size: 10px; text-align: center; @@ -155,6 +190,47 @@ line-height: 1; height: 50%; } +.worldbuilding .attribute input[type="text"]::placeholder, +.worldbuilding .group-header input[type="text"]::placeholder { + opacity: 0; + transition: opacity 0.25s ease-in-out; +} +.worldbuilding .attribute input[type="text"]:focus::placeholder, +.worldbuilding .group-header input[type="text"]:focus::placeholder { + opacity: 1; +} +.worldbuilding .groups-list { + list-style: none; + margin: 0; + padding: 0; +} +.worldbuilding .group { + margin: 20px 0; + padding: 0; +} +.worldbuilding .group-header { + background: rgba(0, 0, 0, 0.05); + border: 1px solid #AAA; + border-radius: 2px; + padding: 5px; +} +.worldbuilding .group-key, +.worldbuilding .group-label { + font-weight: bold; + border: none; + border-bottom: 1px solid #AAA; + background: transparent; + border-radius: 0; + margin-right: 6px; +} +.worldbuilding .group-key { + flex: 0 0 126px; + opacity: 0.75; +} +.worldbuilding .group-dtype { + margin: 0 5px; + flex: 0; +} .worldbuilding.sheet.actor { min-width: 560px; min-height: 420px; diff --git a/styles/simple.less b/styles/simple.less index 06402e1..592bb8d 100644 --- a/styles/simple.less +++ b/styles/simple.less @@ -125,10 +125,57 @@ } /* Attributes */ + .attributes { + position: relative; + + .attribute-control, + .group-control { + flex: 0 0 20px; + text-align: center; + line-height: 28px; + border: none; + } + + .attribute-roll { + flex: 0 0 20px; + text-align: center; + border-bottom: none; + display: flex; + align-items: center; + justify-content: center; + } + + .group-prefix { + height: 31px; + margin-right: 10px; + } + + .button { + width: 100%; + flex: 1; + margin: 0; + background: rgba(0, 0, 0, 0.1); + border: 2px groove #f0f0e0; + border-radius: 3px; + font-size: 14px; + line-height: 28px; + display: block; + } + + .attribute-key { + background: transparent; + border: none; + } + } + .attributes-header { + position: sticky; + top: 0; + left: 0; + right: 0; padding: 5px; margin: 5px 0; - background: rgba(0, 0, 0, 0.05); + background: #cfcdc2; border: 1px solid #AAA; border-radius: 2px; text-align: center; @@ -158,13 +205,6 @@ border-bottom: 1px solid #AAA; } - a.attribute-control { - flex: 0 0 20px; - text-align: center; - line-height: 28px; - border: none; - } - .attribute-col { label { font-size: 10px; @@ -180,6 +220,58 @@ } } } + + .attribute, + .group-header { + input[type="text"] { + &::placeholder { + opacity: 0; + transition: opacity 0.25s ease-in-out; + } + + &:focus::placeholder { + opacity: 1; + } + } + } + + .groups-list { + list-style: none; + margin: 0; + padding: 0; + } + + .group { + margin: 20px 0; + padding: 0; + } + + .group-header { + background: rgba(0, 0, 0, 0.05); + border: 1px solid #AAA; + border-radius: 2px; + padding: 5px; + } + + .group-key, + .group-label { + font-weight: bold; + border: none; + border-bottom: 1px solid #AAA; + background: transparent; + border-radius: 0; + margin-right: 6px; + } + + .group-key { + flex: 0 0 126px; + opacity: 0.75; + } + + .group-dtype { + margin: 0 5px; + flex: 0; + } } .worldbuilding.sheet.actor { diff --git a/template.json b/template.json index 4e108a6..dec71a7 100644 --- a/template.json +++ b/template.json @@ -13,7 +13,8 @@ "min": 0, "max": 5 }, - "attributes": {} + "attributes": {}, + "groups": {} } }, "Item": { @@ -22,7 +23,8 @@ "description": "", "quantity": 1, "weight": 0, - "attributes": {} + "attributes": {}, + "groups": {} } } } diff --git a/templates/actor-sheet.html b/templates/actor-sheet.html index 51656d8..f953b5d 100644 --- a/templates/actor-sheet.html +++ b/templates/actor-sheet.html @@ -2,18 +2,18 @@ {{!-- Sheet Header --}}
- +
-

+

- + / - +
- + / - +
@@ -36,46 +36,58 @@ {{!-- Owned Items Tab --}}
    - {{#each actor.items as |item id|}} + {{#each actor.items as |item id|}}
  1. - +

    {{item.name}}

    {{!-- Iterate through all attributes on the item and output buttons for any that are formula. --}}
    - {{#each item.data.attributes as |itemAttr key|}} + {{#each item.data.attributes as |itemAttr key|}} {{#if (eq itemAttr.dtype "Formula")}} - {{!-- Use the items.name.key format for shorthand. --}} - {{#if ../../shorthand}} - - {{!-- Use the items.name.attributes.key.value format otherwise. --}} - {{else}} - - {{/if}} + {{!-- Use the items.name.key format for shorthand. --}} + {{#if ../../shorthand}} + + {{!-- Use the items.name.attributes.key.value format otherwise. --}} + {{else}} + {{/if}} - {{/each}} + {{/if}} + {{/each}}
  2. - {{/each}} + {{/each}}
{{!-- Attributes Tab --}}
- Attribute Key - Value - Label - Data Type - + {{localize "SIMPLE.AttributeKey"}} + {{localize "SIMPLE.AttributeValue"}} + {{localize "SIMPLE.AttributeLabel"}} + {{localize "SIMPLE.AttributeDtype"}} +
{{!-- Render the attribute list partial. --}} - {{> "systems/worldbuilding/templates/parts/sheet-attributes.html" attributes=data.attributes dtypes=dtypes}} + {{> "systems/worldbuilding/templates/parts/sheet-attributes.html" attributes=data.ungroupedAttributes dtypes=dtypes}} + + {{!-- Render the grouped attributes partial and control. --}} +
+ {{> "systems/worldbuilding/templates/parts/sheet-groups.html" attributes=data.groupedAttributes groups=data.groups dtypes=dtypes}} + + +
- - + \ No newline at end of file diff --git a/templates/item-sheet.html b/templates/item-sheet.html index e02b092..9a27bf7 100644 --- a/templates/item-sheet.html +++ b/templates/item-sheet.html @@ -1,15 +1,15 @@
- +
-

+

- - + +
- - + +
@@ -31,6 +31,7 @@ {{!-- Attributes Tab --}}
+ Attribute Key Value Label @@ -42,4 +43,4 @@ {{> "systems/worldbuilding/templates/parts/sheet-attributes.html" attributes=data.attributes dtypes=dtypes}}
-
+ \ No newline at end of file diff --git a/templates/parts/sheet-attributes.html b/templates/parts/sheet-attributes.html index aa691d2..764cfdf 100644 --- a/templates/parts/sheet-attributes.html +++ b/templates/parts/sheet-attributes.html @@ -1,41 +1,55 @@ -
    -{{#each attributes as |attr key|}} -
  1. - - {{!-- Handle booleans. --}} - {{#if attr.isCheckbox}} - - {{else}} - {{!-- Handle resources. --}} - {{#if attr.isResource}} -
    - - - - - - - - - - - - +
    +
      + {{#each attributes as |attr key|}} +
    1. +
      + {{#if (eq attr.dtype "Formula")}} + + {{/if}} +
      - {{!-- Handle other input types. --}} - {{else}} - - {{/if}} - {{/if}} - - - -
    2. - {{/each}} -
    \ No newline at end of file + {{!-- Handle booleans. --}} + {{#if attr.isCheckbox}} + + {{else}} + {{!-- Handle resources. --}} + {{#if attr.isResource}} +
    + + + + + + + + + + + + +
    + {{!-- Handle other input types. --}} + {{else}} + + {{/if}} + {{/if}} + + + + +
  2. + {{/each}} +
+ \ No newline at end of file diff --git a/templates/parts/sheet-groups.html b/templates/parts/sheet-groups.html new file mode 100644 index 0000000..1075a1a --- /dev/null +++ b/templates/parts/sheet-groups.html @@ -0,0 +1,21 @@ +
    + {{#each groups as |group groupKey|}} +
  1. +
    + + + + + +
    + + {{> "systems/worldbuilding/templates/parts/sheet-attributes.html" attributes=group.attributes group=groupKey dtypes=../dtypes}} +
  2. + {{/each}} +
\ No newline at end of file