diff --git a/.vscode/components.html-data.json b/.vscode/components.html-data.json index e1c3ebd..9177004 100644 --- a/.vscode/components.html-data.json +++ b/.vscode/components.html-data.json @@ -2,24 +2,36 @@ "version": 1.1, "tags": [ { - "name": "dd-incrementer", - "description": "A number input that allows more flexible increase/decrease buttons", + "name": "taf-icon", + "description": "Loads an icon asynchronously, caching the result for future uses", "attributes": [ - { "name": "value", "description": "The initial value to put in the input" }, - { "name": "name", "description": "The form name to use when this input is used to submit data" }, - { "name": "min", "description": "The minimum value that this input can contain" }, - { "name": "max", "description": "The maximum value that this input can contain" }, - { "name": "smallStep", "description": "The value that the input is changed by when clicking a delta button or using the up/down arrow key" }, - { "name": "largeStep", "description": "The value that the input is changed by when clicking a delta button with control held or using the page up/ page down arrow key" } + { "name": "name", "description": "The name of the icon, this is relative to the assets folder of the system" }, + { "name": "path", "description": "The full path of the icon, this will only be used if `name` isn't provided or fails to fetch." } ] }, { - "name": "dd-icon", - "description": "Loads an icon asynchronously, caching the result for future uses", + "name": "taf-svg", + "description": "Loads an SVG file asynchronously, caching the result for future uses", "attributes": [ - { "name": "name", "description": "The name of the icon, this is relative to the assets folder of the dotdungeon system" }, + { "name": "name", "description": "The name of the icon, this is relative to the assets folder of the system" }, { "name": "path", "description": "The full path of the icon, this will only be used if `name` isn't provided or fails to fetch." } ] + }, + { + "name": "taf-toggle", + "description": "A conveniency component for a toggle switch", + "attributes": [ + { + "name": "type", + "description": "The type of toggle that this should be", + "values": [ + { + "name": "round", + "description": "The slider is a full circle" + } + ] + } + ] } ], "globalAttributes": [], diff --git a/assets/icons/chevron.svg b/assets/icons/chevron.svg new file mode 100644 index 0000000..40bf6e2 --- /dev/null +++ b/assets/icons/chevron.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c89266..70c0b59 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,7 @@ export default [ // v14 Additions: _loc: `readonly`, - _del: `reaonly`, + _del: `readonly`, _replace: `readonly`, }, }, @@ -72,7 +72,16 @@ export default [ "@stylistic/space-infix-ops": `warn`, "@stylistic/eol-last": `warn`, "@stylistic/operator-linebreak": [`warn`, `before`], - "@stylistic/indent": [`warn`, `tab`], + "@stylistic/indent": [ + `warn`, + `tab`, + { + SwitchCase: 1, + ignoredNodes: [ + `.superClass CallExpression`, + ], + }, + ], "@stylistic/brace-style": [`off`], "@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }], "@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }], diff --git a/langs/en-ca.json b/langs/en-ca.json index 6dee3ec..7b7a326 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -2,10 +2,18 @@ "TYPES": { "Actor": { "player": "Player" + }, + "Item": { + "generic": "Generic Item" } }, "taf": { "settings": { + "actorDefaultAttributes": { + "name": "Remove Default Attributes", + "hint": "This removes the default attributes that are applied when a new actor is created, making it so that no attributes get created alongside the actor.", + "label": "Remove Attributes" + }, "canPlayersManageAttributes": { "name": "Players Can Manage Attributes", "hint": "This allows players who have edit access to a document to be able to edit what attributes those characters have via the attribute editor" @@ -31,10 +39,9 @@ "true": "Resizable" } }, - "actorDefaultAttributes": { - "name": "Remove Default Attributes", - "hint": "This removes the default attributes that are applied when a new actor is created, making it so that no attributes get created alongside the actor.", - "label": "Remove Attributes" + "weightUnit": { + "name": "Weight Unit", + "hint": "This unit is used to display the units for the weights of items and carrying capacity of actors. This does NOTHING beyond adding the unit into the displays, it will not automatically convert between any units." } }, "sheet-names": { @@ -52,7 +59,13 @@ "save-and-close": "Save and Close", "delete": "Delete", "resizable": "Resizable", - "not-resizable": "Not Resizable" + "not-resizable": "Not Resizable", + "item": { + "weight": "Weight", + "quantity": "Quantity", + "equipped": "Equipped", + "group": "Group" + } }, "Apps": { "Ask": { @@ -70,7 +83,18 @@ "PlayerSheet": { "manage-attributes": "Manage Attributes", "current-value": "Current value", - "max-value": "Maximum value" + "max-value": "Maximum value", + "carry-capacity-used": "({percent}% Used)", + "carrying-capacity": { + "title": "Carrying Capacity:", + "label": "Maximum carrying weight" + }, + "total-weight": "Total weight", + "toggle-item-description": "Show/Hide Item Description", + "tab-names": { + "content": "Content", + "items": "Items" + } }, "QueryStatus": { "title": "Information Request Status", diff --git a/module/apps/AttributeManager.mjs b/module/apps/AttributeManager.mjs index 7ca3bf4..6b159c3 100644 --- a/module/apps/AttributeManager.mjs +++ b/module/apps/AttributeManager.mjs @@ -1,6 +1,6 @@ import { __ID__, filePath } from "../consts.mjs"; -import { attributeSorter } from "../utils/attributeSort.mjs"; import { ask } from "../utils/DialogManager.mjs"; +import { attributeSorter } from "../utils/attributeSort.mjs"; import { localizer } from "../utils/localizer.mjs"; import { toID } from "../utils/toID.mjs"; @@ -29,7 +29,7 @@ export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) label: `Save As Defaults`, visible: () => game.user.isGM, action: `saveAsDefault`, - } + }, ], }, form: { @@ -137,7 +137,7 @@ export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) const attrs = []; for (const [id, data] of Object.entries(this.#attributes)) { if (data == null) { continue }; - if (game.release.generation >= 14 && data == _del) continue; + if (game.release.generation >= 14 && data == _del) {continue} attrs.push({ id, name: data.name, @@ -150,7 +150,7 @@ export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) }; // #endregion Data Prep -// #region Actions + // #region Actions /** * @param {Event} event */ @@ -227,7 +227,7 @@ export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) }, ], }, - { type: `divider` } + { type: `divider` }, ); continue; }; @@ -254,6 +254,7 @@ export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) switch (response.state) { case `errored`: ui.notifications.error(response.error); + // eslint-disable-next-line no-fallthrough case `fronted`: return; }; diff --git a/module/apps/AttributeOnlyPlayerSheet.mjs b/module/apps/AttributeOnlyPlayerSheet.mjs index 41ee130..317bf7f 100644 --- a/module/apps/AttributeOnlyPlayerSheet.mjs +++ b/module/apps/AttributeOnlyPlayerSheet.mjs @@ -1,5 +1,7 @@ import { PlayerSheet } from "./PlayerSheet.mjs"; +const removedParts = new Set([`content`, `tabs`]); + export class AttributeOnlyPlayerSheet extends PlayerSheet { // #region Options static DEFAULT_OPTIONS = { @@ -10,6 +12,7 @@ export class AttributeOnlyPlayerSheet extends PlayerSheet { static get PARTS() { const parts = super.PARTS; + delete parts.tabs; delete parts.content; return parts; }; @@ -19,8 +22,15 @@ export class AttributeOnlyPlayerSheet extends PlayerSheet { _configureRenderOptions(options) { super._configureRenderOptions(options); - // don't attempt to rerender the content - options.parts = options.parts?.filter(partID => partID !== `content`); + // don't attempt to rerender the parts that get removed + options.parts = options.parts?.filter(partID => !removedParts.has(partID)); }; // #endregion Lifecycle + + // #region Data Prep + async _prepareItems(ctx) { + await super._prepareItems(ctx); + ctx.tabActive = true; + }; + // #endregion Data Prep }; diff --git a/module/apps/GenericItemSheet.mjs b/module/apps/GenericItemSheet.mjs new file mode 100644 index 0000000..560fc12 --- /dev/null +++ b/module/apps/GenericItemSheet.mjs @@ -0,0 +1,90 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { TAFDocumentSheetMixin } from "./mixins/TAFDocumentSheetMixin.mjs"; + +const { HandlebarsApplicationMixin } = foundry.applications.api; +const { ItemSheetV2 } = foundry.applications.sheets; +const { setProperty } = foundry.utils; + +export class GenericItemSheet extends + TAFDocumentSheetMixin( + HandlebarsApplicationMixin( + ItemSheetV2, +)) { + // #region Options + static DEFAULT_OPTIONS = { + classes: [ + __ID__, + `GenericItemSheet`, + ], + position: { + width: 400, + height: 450, + }, + window: { + resizable: true, + }, + form: { + submitOnChange: true, + closeOnSubmit: false, + }, + actions: {}, + }; + + static PARTS = { + header: { template: filePath(`templates/GenericItemSheet/header.hbs`) }, + content: { template: filePath(`templates/GenericItemSheet/content.hbs`) }, + }; + + /** + * This tells the Application's TAFDocumentSheetMixin how to rerender this app + * when specific properties get changed on the actor, so that it doesn't need + * to full-app rendering if we can do a partial rerender instead. + */ + static PROPERTY_TO_PARTIAL = { + "name": [`header`], + "img": [`header`], + "system": [`content`], + }; + // #endregion Options + + // #region Instance Data + // #endregion Instance Data + + // #region Lifecycle + async _prepareContext() { + return { + meta: { + idp: this.id, + editable: this.isEditable, + limited: this.isLimited, + }, + item: this.item, + system: this.item.system, + }; + }; + + async _preparePartContext(partID, ctx) { + switch (partID) { + case `content`: { + await this._prepareContentContext(ctx); + break; + }; + }; + + return ctx; + }; + + async _prepareContentContext(ctx) { + const TextEditor = foundry.applications.ux.TextEditor.implementation; + + setProperty( + ctx, + `enriched.system.description`, + await TextEditor.enrichHTML(this.item.system.description), + ); + }; + // #endregion Lifecycle + + // #region Actions + // #endregion Actions +}; diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index a1b8069..295fd3c 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -1,22 +1,20 @@ import { __ID__, filePath } from "../consts.mjs"; import { AttributeManager } from "./AttributeManager.mjs"; import { attributeSorter } from "../utils/attributeSort.mjs"; +import { config } from "../config.mjs"; import { TAFDocumentSheetConfig } from "./TAFDocumentSheetConfig.mjs"; +import { TAFDocumentSheetMixin } from "./mixins/TAFDocumentSheetMixin.mjs"; const { HandlebarsApplicationMixin } = foundry.applications.api; const { ActorSheetV2 } = foundry.applications.sheets; -const { getProperty, hasProperty } = foundry.utils; +const { getProperty } = foundry.utils; +const { TextEditor } = foundry.applications.ux; -const propertyToParts = { - "name": [`header`], - "img": [`header`], - "system.attr": [`attributes`], - "system.attr.value": [`attributes`, `content`], - "system.attr.max": [`attributes`, `content`], - "system.content": [`content`], -}; - -export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { +export class PlayerSheet extends + TAFDocumentSheetMixin( + HandlebarsApplicationMixin( + ActorSheetV2, +)) { // #region Options static DEFAULT_OPTIONS = { @@ -38,16 +36,60 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { actions: { manageAttributes: this.#manageAttributes, configureSheet: this.#configureSheet, + toggleExpand: this.#toggleExpand, }, }; static PARTS = { header: { template: filePath(`templates/PlayerSheet/header.hbs`) }, attributes: { template: filePath(`templates/PlayerSheet/attributes.hbs`) }, + tabs: { template: filePath(`templates/generic/tabs.hbs`) }, content: { template: filePath(`templates/PlayerSheet/content.hbs`) }, + items: { + template: filePath(`templates/PlayerSheet/item-lists.hbs`), + scrollable: [``], + templates: [ + filePath(`templates/PlayerSheet/item.hbs`), + ], + }, + }; + + /** + * This tells the Application's TAFDocumentSheetMixin how to rerender this app + * when specific properties get changed on the actor, so that it doesn't need + * to full-app rendering if we can do a partial rerender instead. + */ + static PROPERTY_TO_PARTIAL = { + "name": [`header`], + "img": [`header`], + "system.attr": [`attributes`], + "system.attr.value": [`attributes`, `content`], + "system.attr.max": [`attributes`, `content`], + "system.content": [`content`], + "system.carryCapacity": [`items`], + }; + + static TABS = { + primary: { + initial: `content`, + labelPrefix: `taf.Apps.PlayerSheet.tab-names`, + tabs: [ + { id: `content` }, + { id: `items` }, + ], + }, }; // #endregion Options + // #region Instance Data + /** + * This Set is used to keep track of which items have had their full + * details expanded so that it can be persisted across rerenders as + * they occur. + */ + #expandedItems = new Set(); + // #endregion Instance Data + // #region Lifecycle _initializeApplicationOptions(options) { const sizing = getProperty(options.document, `flags.${__ID__}.PlayerSheet.size`) ?? {}; @@ -104,21 +146,6 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { return controls; }; - _configureRenderOptions(options) { - // Only rerender the parts of the app that got changed - if (options.renderContext === `updateActor`) { - const parts = new Set(); - for (const property in propertyToParts) { - if (hasProperty(options.renderData, property)) { - propertyToParts[property].forEach(partID => parts.add(partID)); - }; - }; - options.parts = options.parts?.filter(part => !parts.has(part)) ?? Array.from(parts); - }; - - super._configureRenderOptions(options); - }; - async close() { this.#attributeManager?.close(); this.#attributeManager = null; @@ -127,22 +154,38 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { // #endregion Lifecycle // #region Data Prep - async _preparePartContext(partID) { - let ctx = { + async _prepareContext() { + return { + meta: { + idp: this.id, + editable: this.isEditable, + }, actor: this.actor, system: this.actor.system, editable: this.isEditable, }; + }; + + async _preparePartContext(partID, ctx) { switch (partID) { case `attributes`: { await this._prepareAttributes(ctx); break; }; + case `tabs`: { + ctx.hideTabs = this.actor.items.size <= 0; + ctx.tabs = await this._prepareTabs(`primary`); + break; + }; case `content`: { await this._prepareContent(ctx); break; }; + case `items`: { + await this._prepareItems(ctx); + break; + }; }; return ctx; @@ -165,19 +208,74 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { async _prepareContent(ctx) { // Whether or not the prose-mirror is toggled or always-edit ctx.toggled = true; + ctx.tabActive = this.tabGroups.primary === `content` || this.actor.items.size === 0; - const TextEditor = foundry.applications.ux.TextEditor.implementation; ctx.enriched = { system: { - content: await TextEditor.enrichHTML(this.actor.system.content), + content: await TextEditor.implementation.enrichHTML(this.actor.system.content), }, }; }; + + async _prepareItems(ctx) { + ctx.tabActive = this.tabGroups.primary === `items`; + + let totalWeight = 0; + + ctx.itemGroups = []; + for (const [groupName, items] of Object.entries(this.actor.itemTypes)) { + const preparedItems = []; + + let summedWeight = 0; + for (const item of items) { + summedWeight += item.system.quantifiedWeight; + preparedItems.push(await this._prepareItem(item)); + }; + totalWeight += summedWeight; + + ctx.itemGroups.push({ + name: groupName.titleCase(), + items: preparedItems, + weight: config.weightFormatter(totalWeight), + }); + }; + + ctx.totalWeight = config.weightFormatter(totalWeight); + ctx.carryCapacityPercent = Math.round(totalWeight / this.actor.system.carryCapacity * 100); + }; + + async _prepareItem(item) { + const ctx = { + uuid: item.uuid, + img: item.img, + name: item.name, + equipped: item.system.equipped, + quantity: item.system.quantity, + weight: config.weightFormatter(item.system.quantifiedWeight), + isExpanded: this.#expandedItems.has(item.uuid), + canExpand: item.system.description.length > 0, + }; + + ctx.description = ``; + if (item.system.description.length > 0) { + ctx.description = await TextEditor.implementation.enrichHTML(item.system.description); + }; + + return ctx; + }; // #endregion Data Prep // #region Actions #attributeManager = null; - /** @this {PlayerSheet} */ + + /** + * This action opens an instance of the AttributeManager application + * so that the user can edit and update all of the attributes for the + * actor. This persists the application instance for the duration of + * the ActorSheet's lifespan. + * + * @this {PlayerSheet} + */ static async #manageAttributes() { this.#attributeManager ??= new AttributeManager({ document: this.actor }); if (this.#attributeManager.rendered) { @@ -190,6 +288,13 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { }; }; + /** + * This action overrides the default Foundry action in order to tell + * it to open my custom DocumentSheetConfig application instead of + * opening the non-customized sheet config app. + * + * @this {PlayerSheet} + */ static async #configureSheet(event) { event.stopPropagation(); if ( event.detail > 1 ) { return } @@ -205,5 +310,27 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { window: { windowId: this.window.windowId }, }); }; + + /** + * This action is used by the item lists in order to expand/collapse + * the descriptions while maintaining that state across renders. + * + * @this {PlayerSheet} + */ + static async #toggleExpand(event, target) { + const element = target.closest(`[data-item-uuid]`); + const { itemUuid } = element?.dataset ?? {}; + if (!itemUuid) { return }; + + const expanded = this.#expandedItems.has(itemUuid); + const newExpanded = !expanded; + + this.#expandedItems[newExpanded ? `add` : `delete`]?.(itemUuid); + target.dataset.expanded = newExpanded; + const collapses = element.querySelectorAll(`[data-expanded]`); + collapses.forEach(el => { + el.dataset.expanded = newExpanded; + }); + }; // #endregion Actions }; diff --git a/module/apps/elements/StyledShadowElement.mjs b/module/apps/elements/StyledShadowElement.mjs index 4929868..24288d9 100644 --- a/module/apps/elements/StyledShadowElement.mjs +++ b/module/apps/elements/StyledShadowElement.mjs @@ -26,10 +26,13 @@ export function StyledShadowElement(Base) { /** @type {ShadowRoot} */ _shadow; - constructor() { + constructor({focusable = false} = {}) { super(); - this._shadow = this.attachShadow({ mode: `open` }); + this._shadow = this.attachShadow({ + mode: `open`, + delegatesFocus: focusable, + }); this._style = document.createElement(`style`); this._shadow.appendChild(this._style); }; diff --git a/module/apps/elements/Toggle.mjs b/module/apps/elements/Toggle.mjs new file mode 100644 index 0000000..2c106dd --- /dev/null +++ b/module/apps/elements/Toggle.mjs @@ -0,0 +1,97 @@ +import { StyledShadowElement } from "./StyledShadowElement.mjs"; + +export class TafToggle extends StyledShadowElement(HTMLElement) { + static elementName = `taf-toggle`; + + static _stylePath = `toggle.css`; + + _mounted; + _internals; + + constructor() { + super({ focusable: true }); + + this._internals = this.attachInternals(); + this._internals.role = `checkbox`; + }; + + get type() { + return `checkbox`; + }; + + get name() { + return this.getAttribute(`name`); + }; + set name(newName) { + this.setAttribute(`name`, newName); + }; + + get value() { + return this._input.value; + }; + set value(newValue) { + this._input.value = newValue; + }; + + get checked() { + return this.hasAttribute(`checked`); + }; + set checked(newValue) { + if (typeof newValue !== `boolean`) { return }; + this.toggleAttribute(`checked`, newValue); + }; + + get disabled() { + return this.matches(`:disabled`); + }; + set disabled(value) { + this.toggleAttribute(`disabled`, value); + }; + + get editable() { + return true; + }; + + connectedCallback() { + super.connectedCallback(); + if (this._mounted) { return }; + + this._internals.checked = this.checked; + + /* + This converts all of the double-dash prefixed properties on the + element to CSS variables so that they don't all need to be + provided by doing style="" + */ + for (const attrVar of this.attributes) { + if (attrVar.name?.startsWith(`var:`)) { + const prop = attrVar.name.replace(`var:`, ``); + this.style.setProperty(`--` + prop, attrVar.value); + }; + }; + + const label = document.createElement(`label`); + label.dataset.type = `round`; + + const input = document.createElement(`input`); + input.type = `checkbox`; + input.toggleAttribute(`switch`, true); + input.checked = this.checked; + + label.appendChild(input); + + const slider = document.createElement(`div`); + slider.classList = `slider`; + label.appendChild(slider); + + this._shadow.appendChild(label); + + this._mounted = true; + }; + + disconnectedCallback() { + super.disconnectedCallback(); + if (!this._mounted) { return }; + this._mounted = false; + }; +}; diff --git a/module/apps/elements/_index.mjs b/module/apps/elements/_index.mjs index dce2b24..db3ac54 100644 --- a/module/apps/elements/_index.mjs +++ b/module/apps/elements/_index.mjs @@ -1,10 +1,12 @@ import { Logger } from "../../utils/Logger.mjs"; import { TafIcon } from "./Icon.mjs"; import { TafSVGLoader } from "./svgLoader.mjs"; +import { TafToggle } from "./Toggle.mjs"; const components = [ TafSVGLoader, TafIcon, + TafToggle, ]; export function registerCustomComponents() { diff --git a/module/apps/mixins/TAFDocumentSheetMixin.mjs b/module/apps/mixins/TAFDocumentSheetMixin.mjs new file mode 100644 index 0000000..1715a6d --- /dev/null +++ b/module/apps/mixins/TAFDocumentSheetMixin.mjs @@ -0,0 +1,36 @@ +const { hasProperty } = foundry.utils; + +export function TAFDocumentSheetMixin(HandlebarsApplication) { + class TAFDocumentSheet extends HandlebarsApplication { + /** @type {Record | null} */ + static PROPERTY_TO_PARTIAL = null; + + /** + * This override is used by the mixin in order to allow for partial + * re-rendering of applications based on what properties changed. + * It requires that a static PROPERTY_TO_PARTIAL to be defined as + * an object of path keys to arrays of part IDs in order to work. + * This will not interfere with renders that are not started as + * part of the actor update lifecycle. + */ + _configureRenderOptions(options) { + + if (options.renderContext === `updateActor`) { + const propertyToParts = this.constructor.PROPERTY_TO_PARTIAL; + if (propertyToParts) { + const parts = new Set(); + for (const property in propertyToParts) { + if (hasProperty(options.renderData, property)) { + propertyToParts[property].forEach(partID => parts.add(partID)); + }; + }; + options.parts = options.parts?.filter(part => !parts.has(part)) ?? Array.from(parts); + } + }; + + super._configureRenderOptions(options); + }; + }; + + return TAFDocumentSheet; +}; diff --git a/module/config.mjs b/module/config.mjs new file mode 100644 index 0000000..f3b076d --- /dev/null +++ b/module/config.mjs @@ -0,0 +1,7 @@ +import { formatWeight } from "./utils/formatWeight.mjs"; + +const { deepSeal } = foundry.utils; + +export const config = CONFIG.TAF = deepSeal({ + weightFormatter: formatWeight, +}); diff --git a/module/data/Player.mjs b/module/data/Actor/player.mjs similarity index 89% rename from module/data/Player.mjs rename to module/data/Actor/player.mjs index b2ad529..d0e8dbc 100644 --- a/module/data/Player.mjs +++ b/module/data/Actor/player.mjs @@ -7,6 +7,11 @@ export class PlayerData extends foundry.abstract.TypeDataModel { trim: true, initial: ``, }), + carryCapacity: new fields.NumberField({ + min: 0, + nullable: true, + initial: null, + }), attr: new fields.TypedObjectField( new fields.SchemaField({ name: new fields.StringField({ blank: false, trim: true }), diff --git a/module/data/Item/generic.mjs b/module/data/Item/generic.mjs new file mode 100644 index 0000000..77021a7 --- /dev/null +++ b/module/data/Item/generic.mjs @@ -0,0 +1,41 @@ +import { toPrecision } from "../../utils/roundToPrecision.mjs"; + +export class GenericItemData extends foundry.abstract.TypeDataModel { + static defineSchema() { + const fields = foundry.data.fields; + return { + group: new fields.StringField({ + blank: false, + trim: true, + initial: null, + nullable: true, + }), + weight: new fields.NumberField({ + min: 0, + initial: 0, + nullable: false, + }), + quantity: new fields.NumberField({ + integer: true, + min: 0, + initial: 1, + }), + equipped: new fields.BooleanField({ + initial: true, + }), + description: new fields.HTMLField({ + blank: true, + trim: true, + initial: ``, + }), + }; + }; + + /** + * Calculates the total weight of the item based on the quantity of it, this + * rounds the number to the nearest 2 decimal places. + */ + get quantifiedWeight() { + return toPrecision(this.weight * this.quantity, 2); + }; +}; diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index 91c2bfa..bebc21d 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -6,6 +6,11 @@ const { hasProperty } = foundry.utils; export class TAFActor extends Actor { // #region Lifecycle + /** + * This makes sure that the actor gets created with the global attributes if + * they exist, while still allowing programmatic creation through the API with + * specific attributes. + */ async _preCreate(data, options, user) { // Assign the defaults from the world setting if they exist @@ -17,8 +22,19 @@ export class TAFActor extends Actor { return super._preCreate(data, options, user); }; + + /** + * This resets the cache of the item groupings whenever a descedant document + * gets changed (created, updated, deleted) so that we keep the cache as close + * to accurate as can be possible. + */ + _onEmbeddedDocumentChange(...args) { + super._onEmbeddedDocumentChange(...args); + this.#sortedTypes = null; + }; // #endregion Lifecycle + // #region Token Attributes async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) { const attr = foundry.utils.getProperty(this.system, attribute); const current = isBar ? attr.value : attr; @@ -40,9 +56,18 @@ export class TAFActor extends Actor { return allowed !== false ? this.update(updates) : this; }; + // #endregion Token Attributes + // #region Roll Data getRollData() { - const data = {}; + /* + All properties assigned during this phase of the roll data prep can potentially + be overridden by users creating attributes of the same key, if users shouldn't + be able to override, assign the property before the return of this function. + */ + const data = { + carryCapacity: this.system.carryCapacity ?? null, + }; if (`attr` in this.system) { for (const attrID in this.system.attr) { @@ -60,4 +85,24 @@ export class TAFActor extends Actor { return data; }; + // #endregion Roll Data + + // #region Getters + #sortedTypes = null; + get itemTypes() { + if (this.#sortedTypes) { return this.#sortedTypes }; + const types = {}; + for (const item of this.items) { + if (item.type !== `generic`) { + types[item.type] ??= []; + types[item.type].push(item); + } else { + const group = item.system.group?.toLowerCase() ?? `items`; + types[group] ??= []; + types[group].push(item); + }; + }; + return this.#sortedTypes = types; + }; + // #endregion Getters }; diff --git a/module/documents/Item.mjs b/module/documents/Item.mjs deleted file mode 100644 index 683187e..0000000 --- a/module/documents/Item.mjs +++ /dev/null @@ -1,7 +0,0 @@ -const { Item } = foundry.documents; - -export class TAFItem extends Item { - async _preCreate() { - return false; - }; -}; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index 6ff92f0..a6f623e 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -1,15 +1,16 @@ // Apps import { AttributeOnlyPlayerSheet } from "../apps/AttributeOnlyPlayerSheet.mjs"; +import { GenericItemSheet } from "../apps/GenericItemSheet.mjs"; import { PlayerSheet } from "../apps/PlayerSheet.mjs"; import { SingleModePlayerSheet } from "../apps/SingleModePlayerSheet.mjs"; // Data Models -import { PlayerData } from "../data/Player.mjs"; +import { GenericItemData } from "../data/Item/generic.mjs"; +import { PlayerData } from "../data/Actor/player.mjs"; // Documents import { TAFActor } from "../documents/Actor.mjs"; import { TAFCombatant } from "../documents/Combatant.mjs"; -import { TAFItem } from "../documents/Item.mjs"; import { TAFTokenDocument } from "../documents/Token.mjs"; // Settings @@ -25,16 +26,18 @@ import { registerSockets } from "../sockets/_index.mjs"; Hooks.on(`init`, () => { Logger.debug(`Initializing`); + // #region Documents CONFIG.Token.documentClass = TAFTokenDocument; CONFIG.Actor.documentClass = TAFActor; CONFIG.Combatant.documentClass = TAFCombatant; + // #endregion Documents + // #region Data Models CONFIG.Actor.dataModels.player = PlayerData; + CONFIG.Item.dataModels.generic = GenericItemData; + // #endregion Data Models - // We disable items in the system for now - CONFIG.Item.documentClass = TAFItem; - delete CONFIG.ui.sidebar.TABS.items; - + // #region Sheets foundry.documents.collections.Actors.registerSheet( __ID__, PlayerSheet, @@ -54,6 +57,16 @@ Hooks.on(`init`, () => { { label: `taf.sheet-names.AttributeOnlyPlayerSheet` }, ); + foundry.documents.collections.Items.registerSheet( + __ID__, + GenericItemSheet, + { + makeDefault: true, + label: `taf.sheet-names.GenericItemSheet`, + }, + ); + // #endregion Sheets + registerWorldSettings(); registerSockets(); diff --git a/module/hooks/renderSettingsConfig.mjs b/module/hooks/renderSettingsConfig.mjs index 62eb341..a75c55c 100644 --- a/module/hooks/renderSettingsConfig.mjs +++ b/module/hooks/renderSettingsConfig.mjs @@ -1,6 +1,6 @@ import { __ID__ } from "../consts.mjs"; -Hooks.on(`renderSettingsConfig`, (app, html, context, options) => { +Hooks.on(`renderSettingsConfig`, (app, html) => { /* This section is used to insert a button into the settings config that unsets a world setting when it exists but doesn't allow any other form of editing it. diff --git a/module/settings/world.mjs b/module/settings/world.mjs index 330f433..bb0cd06 100644 --- a/module/settings/world.mjs +++ b/module/settings/world.mjs @@ -13,6 +13,15 @@ export function registerWorldSettings() { scope: `world`, }); + game.settings.register(__ID__, `weightUnit`, { + name: `taf.settings.weightUnit.name`, + hint: `taf.settings.weightUnit.hint`, + config: true, + type: String, + default: ``, + scope: `world`, + }); + game.settings.register(__ID__, `canPlayersManageAttributes`, { name: `taf.settings.canPlayersManageAttributes.name`, hint: `taf.settings.canPlayersManageAttributes.hint`, diff --git a/module/utils/formatWeight.mjs b/module/utils/formatWeight.mjs new file mode 100644 index 0000000..57f945d --- /dev/null +++ b/module/utils/formatWeight.mjs @@ -0,0 +1,12 @@ +import { __ID__ } from "../consts.mjs"; +import { toPrecision } from "./roundToPrecision.mjs"; + +/** + * Formats a numerical value as a weight. + * + * @param {number} weight The numerical weight to format + */ +export function formatWeight(weight) { + const unit = game.settings.get(__ID__, `weightUnit`); + return toPrecision(weight, 2) + unit; +}; diff --git a/module/utils/roundToPrecision.mjs b/module/utils/roundToPrecision.mjs new file mode 100644 index 0000000..5365690 --- /dev/null +++ b/module/utils/roundToPrecision.mjs @@ -0,0 +1,20 @@ +/** + * Takes a possibly-decimal value and rounds after a certain precision, keeping + * only the specified amount of decimals. + * + * @param {number} value The value that is to be rounded. + * @param {number} precision The number of decimal places to round to. Must be a + * positive integer. + * @returns The rounded number + */ +export function toPrecision(value, precision = 1) { + if (!Number.isInteger(precision)) { + throw `Precision must be an integer`; + }; + if (precision < 0) { + throw `Precision must be greater than or equal to 0`; + }; + + const m = 10 ** precision; + return Math.round(value * m) / m; +}; diff --git a/styles/Apps/GenericItemSheet.css b/styles/Apps/GenericItemSheet.css new file mode 100644 index 0000000..7cd68ee --- /dev/null +++ b/styles/Apps/GenericItemSheet.css @@ -0,0 +1,53 @@ +.taf.GenericItemSheet { + .bordered { + border-radius: 8px; + border: 1px solid rebeccapurple; + } + + .sheet-header { + display: grid; + grid-template-columns: auto 1fr min-content 75px; + gap: 4px; + align-items: center; + padding: 4px; + + img { + border-radius: 4px; + } + } + + .content { + display: contents; + } + + .property { + display: grid; + grid-template-columns: 1fr 100px; + align-items: center; + justify-items: left; + gap: 8px; + + input[type="checkbox"] { + justify-self: end; + } + } + + .description { + flex-grow: 1; + overflow: hidden; + --table-row-color-odd: var(--table-header-bg-color); + + &:not(:has(> prose-mirror)) { + padding: 0.5rem; + } + } + + prose-mirror { + height: 100%; + + menu { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } +} diff --git a/styles/Apps/PlayerSheet.css b/styles/Apps/PlayerSheet.css index 525f01b..f6a9024 100644 --- a/styles/Apps/PlayerSheet.css +++ b/styles/Apps/PlayerSheet.css @@ -37,6 +37,126 @@ } } + .items-tab.active { + display: flex; + flex-direction: column; + gap: 8px; + } + + .inventory-summary { + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + background: var(--inventory-summary-background); + color: var(--inventory-summary-colour); + padding: 6px; + border-radius: 4px; + + input { + width: 75px; + text-align: center; + background: var(--inventory-input-background); + color: var(--inventory-input-colour); + text-align: center; + + &:disabled { + color: var(--inventory-input-disabled-colour); + cursor: not-allowed; + } + } + } + + .item-list-header { + display: flex; + flex-direction: row; + gap: 8px; + border-radius: 6px 6px 0 0; + padding: 6px 6px 4px; + margin-bottom: 2px; + background: var(--item-list-header-background); + color: var(--item-list-header-color); + } + + .item-list { + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; + } + + .item { + background: var(--item-card-background); + color: var(--item-card-color); + overflow: hidden; + margin-bottom: 0; + + .summary { + display: grid; + grid-template-columns: min-content auto 1fr 50px auto; + align-items: center; + gap: 8px; + background: var(--item-card-header-background); + color: var(--item-card-header-color); + padding: 4px; + + img { + --size: 35px; + width: var(--size); + height: var(--size); + border-radius: 6px; + } + + .title { + display: flex; + flex-direction: column; + gap: 4px; + } + + .name { + font-size: 1.1rem; + } + .subtitle { + font-size: 0.7rem; + opacity: 90%; + } + + input, button { + background: var(--item-card-header-input-background); + color: var(--item-card-header-input-colour); + text-align: center; + } + } + + .expand-button { + border: none; + aspect-ratio: 1; + + &:focus-visible { + filter: brightness(150%); + outline: none; + } + + &[data-expanded="true"] { + rotate: 180deg; + } + } + + .full-details { + padding: 4px; + + &[data-expanded="false"] { + display: none; + } + } + + &:last-child { + border-radius: 0 0 6px 6px; + } + } + .content { flex-grow: 1; overflow: hidden; diff --git a/styles/Apps/common.css b/styles/Apps/common.css index bbf1212..d673ac7 100644 --- a/styles/Apps/common.css +++ b/styles/Apps/common.css @@ -6,4 +6,26 @@ gap: 0.5rem; overflow: auto; } + + > .window-content nav.system-tabs { + display: flex; + flex-direction: row; + justify-content: left; + align-items: center; + gap: 8px; + + button { + border: none; + text-shadow: none; + box-shadow: none; + + &.active { + outline: 1px solid var(--tab-button-active-border); + } + + &:hover { + background: var(--tab-button-hover-bg); + } + } + } } diff --git a/styles/components/toggle.css b/styles/components/toggle.css new file mode 100644 index 0000000..ce8fc67 --- /dev/null +++ b/styles/components/toggle.css @@ -0,0 +1,55 @@ +:host { + display: block; +} + +input { + width: 0; + height: 0; + margin: 0; + padding: 0; +} + +.slider { + width: var(--size, 16px); + height: var(--size, 16px); + background: var( + --slider-colour, + var(--toggle-slider-unchecked-colour) + ); + transition: all var(--speed, 150ms) ease-in-out; + border-radius: 9999px; +} + +label { + display: flex; + padding: var(--padding, 4px); + height: calc(var(--size, 16px) + (var(--padding, 4px) * 2)); + width: calc((var(--size, 16px) * 2) + (var(--padding, 4px) * 2)); + border-radius: 9999px; + background: var( + --toggle-background, + var(--toggle-background-colour) + ); + box-sizing: border-box; + cursor: pointer; + + /* Non-checked, clicking */ + &:active .slider { + width: calc(var(--size, 16px) * 1.5); + } + + /* checked, non-clicking */ + & > :checked + .slider { + transform: translateX(var(--size, 16px)); + background: var( + --slider-checked-colour, + var(--toggle-slider-checked-colour) + ); + } + + /* checked, clicking */ + &:active > :checked + .slider { + width: calc(var(--size, 16px) * 1.5); + transform: translateX(calc(var(--size, 16px) * 0.5)); + } +} diff --git a/styles/elements/headers.css b/styles/elements/headers.css index 2f59e8c..37ebdd1 100644 --- a/styles/elements/headers.css +++ b/styles/elements/headers.css @@ -1,5 +1,14 @@ .taf > .window-content { h1, h2, h3, h4, h5, h6 { + font-family: var(--font-body); + color: currentColor; margin: 0; } + + h1 { font-size: 1.25rem; } + h2 { font-size: 1.20rem; } + h3 { font-size: 1.15rem; } + h4 { font-size: 1.1rem; } + h5 { font-size: 1.1rem; } + h6 { font-size: 1.1rem; } } diff --git a/styles/elements/input.css b/styles/elements/input.css index 4c6c747..677ab94 100644 --- a/styles/elements/input.css +++ b/styles/elements/input.css @@ -1,4 +1,6 @@ .taf > .window-content input { + border: none; + &.large { --input-height: 2.5rem; font-size: 1.75rem; diff --git a/styles/main.css b/styles/main.css index a4c4a2c..21045c4 100644 --- a/styles/main.css +++ b/styles/main.css @@ -1,9 +1,10 @@ @layer resets, themes, elements, components, partials, apps, exceptions; /* Resets */ +@import url("./resets/button.css") layer(resets); @import url("./resets/hr.css") layer(resets); @import url("./resets/inputs.css") layer(resets); -@import url("./resets/button.css") layer(resets); +@import url("./resets/tabs.css") layer(resets); /* Themes */ @import url("./themes/dark.css") layer(themes); @@ -24,6 +25,7 @@ @import url("./Apps/common.css") layer(apps); @import url("./Apps/Ask.css") layer(apps); @import url("./Apps/AttributeManager.css") layer(apps); +@import url("./Apps/GenericItemSheet.css") layer(apps); @import url("./Apps/PlayerSheet.css") layer(apps); @import url("./Apps/QueryStatus.css") layer(apps); @import url("./Apps/TAFDocumentSheetConfig.css") layer(apps); diff --git a/styles/resets/button.css b/styles/resets/button.css index d04ae6e..98ba86d 100644 --- a/styles/resets/button.css +++ b/styles/resets/button.css @@ -1,3 +1,8 @@ .taf > .window-content button { height: initial; + + &:focus { + outline: none; + box-shadow: none; + } } diff --git a/styles/resets/tabs.css b/styles/resets/tabs.css new file mode 100644 index 0000000..aefac97 --- /dev/null +++ b/styles/resets/tabs.css @@ -0,0 +1,10 @@ +.taf > .window-content { + nav.tabs.system-tabs { + all: initial; + } + + nav.sheet-tabs.top-tabs { + margin-inline: 0; + margin-top: calc(var(--spacer-8) * -1); + } +} diff --git a/styles/themes/dark.css b/styles/themes/dark.css index 53552e0..ddd01e9 100644 --- a/styles/themes/dark.css +++ b/styles/themes/dark.css @@ -4,6 +4,29 @@ --spinner-outer-colour: white; --spinner-inner-colour: #FF3D00; + --toggle-background-colour: #171e26; + --toggle-slider-unchecked-colour: maroon; + --toggle-slider-checked-colour: green; + + --tab-button-active-border: rebeccapurple; + --tab-button-hover-bg: var(--color-cool-3); + + --inventory-summary-background: #171e26; + --inventory-summary-colour: white; + --inventory-input-background: #2b3642; + --inventory-input-colour: white; + --inventory-input-disabled-colour: gray; + + --item-list-header-background: #171e26; + --item-list-header-color: white; + --item-card-background: #1d262f; + --item-card-color: white; + --item-card-header-background: #242d38; + --item-card-header-color: white; + --item-card-header-input-background: #2b3642; + --item-card-header-input-colour: white; + --item-card-header-disabled-input-colour: gray; + /* Chip Variables */ --chip-color: #fff7ed; --chip-background: #2b3642; diff --git a/styles/themes/light.css b/styles/themes/light.css index 47cf1e9..cbc2edc 100644 --- a/styles/themes/light.css +++ b/styles/themes/light.css @@ -4,6 +4,9 @@ --spinner-outer-colour: black; --spinner-inner-colour: #FF3D00; + --tab-button-active: rebeccapurple; + --tab-button-hover-bg: var(--color-light-3); + /* Chip Variables */ --chip-color: #18181b; --chip-background: #fafafa; diff --git a/styles/themes/variables.css b/styles/themes/variables.css new file mode 100644 index 0000000..62c2148 --- /dev/null +++ b/styles/themes/variables.css @@ -0,0 +1,3 @@ +:root { + --steel-650: #2b3642; +} diff --git a/system.json b/system.json index 94e2ccd..a756e80 100644 --- a/system.json +++ b/system.json @@ -39,7 +39,14 @@ "filePathFields": {} } }, - "Item": {} + "Item": { + "generic": { + "htmlFields": [ + "description" + ], + "filePathFields": {} + } + } }, "socket": true, "flags": { diff --git a/templates/GenericItemSheet/content.hbs b/templates/GenericItemSheet/content.hbs new file mode 100644 index 0000000..f20bf62 --- /dev/null +++ b/templates/GenericItemSheet/content.hbs @@ -0,0 +1,52 @@ +
+
+ + +
+
+ + +
+
+ + +
+
+ {{#if meta.editable}} + + {{{ enriched.system.description }}} + + {{else}} + {{{ enriched.system.description }}} + {{/if}} +
+
diff --git a/templates/GenericItemSheet/header.hbs b/templates/GenericItemSheet/header.hbs new file mode 100644 index 0000000..fd39d31 --- /dev/null +++ b/templates/GenericItemSheet/header.hbs @@ -0,0 +1,28 @@ +
+ + + + +
diff --git a/templates/PlayerSheet/content.hbs b/templates/PlayerSheet/content.hbs index a22f5a1..e5219f9 100644 --- a/templates/PlayerSheet/content.hbs +++ b/templates/PlayerSheet/content.hbs @@ -1,4 +1,8 @@ -
+
{{#if editable}} +
+

+ {{localize "taf.Apps.PlayerSheet.carrying-capacity.title"}} +

+
+ {{localize + "taf.Apps.PlayerSheet.carry-capacity-used" + percent=carryCapacityPercent + }} +
+ + + +
+ {{#each itemGroups as | group |}} +
+
+

+ {{ group.name }} +

+ + {{ group.weight }} + +
+
    + {{#each group.items as |item|}} + {{> (systemFilePath "templates/PlayerSheet/item.hbs") item }} + {{/each}} +
+
+ {{/each}} +
diff --git a/templates/PlayerSheet/item.hbs b/templates/PlayerSheet/item.hbs new file mode 100644 index 0000000..ce58813 --- /dev/null +++ b/templates/PlayerSheet/item.hbs @@ -0,0 +1,45 @@ +
  • +
    + + +
    + {{ name }} + {{ weight }} +
    + + +
    + {{#if canExpand}} +
    + {{{ description }}} +
    + {{/if}} +
  • diff --git a/templates/generic/tabs.hbs b/templates/generic/tabs.hbs new file mode 100644 index 0000000..947f0ae --- /dev/null +++ b/templates/generic/tabs.hbs @@ -0,0 +1,17 @@ +{{#if hideTabs}} + +{{else}} + +{{/if}}