diff --git a/.vscode/handlebars.code-snippets b/.vscode/handlebars.code-snippets deleted file mode 100644 index e922f13..0000000 --- a/.vscode/handlebars.code-snippets +++ /dev/null @@ -1,37 +0,0 @@ -{ - // Place your foundry.dungeon workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and - // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope - // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is - // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: - // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. - // Placeholders with the same ids are connected. - // Example: - // "Print to console": { - // "scope": "javascript,typescript", - // "prefix": "log", - // "body": [ - // "console.log('$1');", - // "$2" - // ], - // "description": "Log output to console" - // } - "Localization Shortcut (concat)": { - "scope": "handlebars,html", - "prefix": "i18n", - "body": ["localize (concat \"dotdungeon.$1\" $2)"] - }, - "Localization Shortcut (no concat)": { - "scope": "handlebars,html", - "prefix": "i18n", - "body": ["localize \"dotdungeon.$1\""] - }, - "Icon": { - "scope": "handlebars,html", - "prefix": "icon", - "body": [ - "
", - "\t{{{ $2 }}}", - "
" - ] - } -} \ No newline at end of file diff --git a/langs/en-ca.json b/langs/en-ca.json index 2600103..0c99bc6 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -4,7 +4,8 @@ "player": "Player" }, "Item": { - "generic": "Generic Item" + "generic": "Item", + "attribute": "Attribute" } }, "taf": { @@ -48,7 +49,8 @@ "PlayerSheet": "Player Sheet", "SingleModePlayerSheet": "Player Sheet (Always Editing)", "AttributeOnlyPlayerSheet": "Player Sheet (Attributes Only)", - "GenericItemSheet": "System Item Sheet" + "GenericItemSheet": "System Item Sheet", + "AttributeItemSheet": "Attribute Sheet" }, "misc": { "Key": "Key", @@ -67,6 +69,15 @@ "quantity": "Quantity", "equipped": "Equipped", "group": "Group" + }, + "attribute": { + "key": { + "hint": "This is the computer-friendly identifier for the attribute. When accessing the attribute in rolls, this is the name you will need to use. Changing this WILL break any existing macros you have." + }, + "always-visible": "Always Visible", + "minimum": "Minimum", + "current-value": "Current Value", + "maximum": "Maximum" } }, "Apps": { @@ -97,6 +108,7 @@ "toggle-item-description": "Show/Hide Item Description", "tab-names": { "content": "Content", + "attributes": "Attributes", "items": "Items" } }, @@ -133,10 +145,15 @@ "missing-id": "An ID must be provided", "invalid-socket": "Invalid socket data received, this means a module or system bug is present.", "unknown-socket-event": "An unknown socket event was received: {event}", - "malformed-socket-payload": "Socket event \"{event}\" received with malformed payload. Details: {details}" + "duplicate-attribute-key": "Cannot create an Attribute with a key (\"{key}\") that already exists on the Actor", + "invalid-attribute-key": "Attribute keys must be alphanumeric (a-z, 0-9) with underscores, invalid key provided: \"{key}\"" + }, + "warn": { + "migration-in-progress": "Applying data migrations for version {version}. Please do NOT refresh the window while this warning is present." }, "success": { - "saved-default-attributes": "Successfully saved default Actor attributes" + "saved-default-attributes": "Successfully saved default Actor attributes", + "migration-successful": "Data migrations for version {version} were successful." } } } diff --git a/module/api.mjs b/module/api.mjs index d0b8c62..698f141 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -5,11 +5,11 @@ import { PlayerSheet } from "./apps/PlayerSheet.mjs"; import { QueryStatus } from "./apps/QueryStatus.mjs"; // Utils +import { isValidID, toID } from "./utils/toID.mjs"; import { attributeSorter } from "./utils/attributeSort.mjs"; import { DialogManager } from "./utils/DialogManager.mjs"; import { localizer } from "./utils/localizer.mjs"; import { QueryManager } from "./utils/QueryManager.mjs"; -import { toID } from "./utils/toID.mjs"; const { deepFreeze } = foundry.utils; @@ -26,5 +26,6 @@ export const api = deepFreeze({ attributeSorter, localizer, toID, + isValidID, }, }); diff --git a/module/apps/AttributeItemSheet.mjs b/module/apps/AttributeItemSheet.mjs new file mode 100644 index 0000000..bc33f6c --- /dev/null +++ b/module/apps/AttributeItemSheet.mjs @@ -0,0 +1,74 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { TAFDocumentSheetMixin } from "./mixins/TAFDocumentSheetMixin.mjs"; + +const { HandlebarsApplicationMixin } = foundry.applications.api; +const { ItemSheetV2 } = foundry.applications.sheets; + +export class AttributeItemSheet extends + TAFDocumentSheetMixin( + HandlebarsApplicationMixin( + ItemSheetV2, +)) { + // #region Options + static DEFAULT_OPTIONS = { + classes: [ + __ID__, + `AttributeItemSheet`, + ], + position: { + width: 350, + height: `auto`, + }, + window: { + resizable: true, + }, + form: { + submitOnChange: true, + closeOnSubmit: false, + }, + actions: {}, + }; + + static PARTS = { + header: { template: filePath(`templates/AttributeItemSheet/header.hbs`) }, + value: { template: filePath(`templates/AttributeItemSheet/value.hbs`) }, + settings: { template: filePath(`templates/AttributeItemSheet/settings.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`], + "system.value": [`value`], + "system.min": [`value`], + "system.max": [`value`], + "system.aboveTheFold": [`settings`], + "system.group": [`settings`], + "system.key": [`settings`], + }; + // #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, + }; + }; + // #endregion Lifecycle + + // #region Actions + // #endregion Actions +}; diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index 3e1be0f..2e3da97 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -1,7 +1,6 @@ import { __ID__, filePath } from "../consts.mjs"; import { deleteItemFromElement, editItemFromElement } from "./utils.mjs"; import { AttributeManager } from "./AttributeManager.mjs"; -import { attributeSorter } from "../utils/attributeSort.mjs"; import { config } from "../config.mjs"; import { Logger } from "../utils/Logger.mjs"; import { TAFDocumentSheetConfig } from "./TAFDocumentSheetConfig.mjs"; @@ -45,14 +44,21 @@ export class PlayerSheet extends static PARTS = { header: { template: filePath(`templates/PlayerSheet/header.hbs`) }, - attributes: { template: filePath(`templates/PlayerSheet/attributes.hbs`) }, + primaryAttributes: { template: filePath(`templates/PlayerSheet/primary-attributes.hbs`) }, tabs: { template: filePath(`templates/generic/tabs.hbs`) }, content: { template: filePath(`templates/PlayerSheet/content.hbs`) }, - items: { - template: filePath(`templates/PlayerSheet/item-lists.hbs`), + attributeTab: { + template: filePath(`templates/PlayerSheet/tabs/attributes/lists.hbs`), scrollable: [``], templates: [ - filePath(`templates/PlayerSheet/item.hbs`), + filePath(`templates/PlayerSheet/tabs/attributes/attribute.hbs`), + ], + }, + items: { + template: filePath(`templates/PlayerSheet/tabs/items/lists.hbs`), + scrollable: [``], + templates: [ + filePath(`templates/PlayerSheet/tabs/items/item.hbs`), ], }, }; @@ -78,6 +84,7 @@ export class PlayerSheet extends labelPrefix: `taf.Apps.PlayerSheet.tab-names`, tabs: [ { id: `content` }, + { id: `attributes` }, { id: `items` }, ], }, @@ -104,6 +111,10 @@ export class PlayerSheet extends Logger.debug(`Asserting app "${this.id}" from tab "items" to "${initial}"`); this.tabGroups.primary = initial; }; + if (this.tabGroups.primary === `attributes` && !this.hasAttributesTab) { + Logger.debug(`Asserting app "${this.id}" from tab "attributes" to "${initial}"`); + this.tabGroups.primary = initial; + }; }; /** @@ -118,6 +129,7 @@ export class PlayerSheet extends switch (tabID) { case `content`: return this.hasContentTab; case `items`: return this.hasItemsTab; + case `attributes`: return this.hasAttributesTab; }; return false; }; @@ -126,8 +138,17 @@ export class PlayerSheet extends return true; }; + get hasAttributesTab() { + return this.actor.itemTypes + .attribute + ?.filter(attr => !attr.system.aboveTheFold) + .length > 0; + }; + get hasItemsTab() { - return this.actor.items.size > 0; + return this.actor.items + .filter(item => item.type !== `attribute`) + .length > 0; }; // #endregion Instance Data @@ -207,7 +228,7 @@ export class PlayerSheet extends new ContextMenu.implementation( this.element, - `li.item`, + `[data-item-uuid]`, [ { label: _loc(`taf.misc.edit`), @@ -254,8 +275,12 @@ export class PlayerSheet extends async _preparePartContext(partID, ctx) { switch (partID) { - case `attributes`: { - await this._prepareAttributes(ctx); + case `primaryAttributes`: { + await this._preparePrimaryAttributes(ctx); + break; + }; + case `attributeTab`: { + await this._prepareAttributesTab(ctx); break; }; case `tabs`: { @@ -275,18 +300,35 @@ export class PlayerSheet extends return ctx; }; - async _prepareAttributes(ctx) { - ctx.hasAttributes = this.actor.system.hasAttributes; + async _preparePrimaryAttributes(ctx) { + const attrs = this.actor.itemTypes.attribute ?? []; + const filtered = attrs.filter(attr => attr.system.aboveTheFold); + ctx.hasAttributes = filtered.length > 0; + ctx.attrs = filtered; + }; - const attrs = []; - for (const [id, data] of Object.entries(this.actor.system.attr)) { - attrs.push({ - ...data, - id, - path: `system.attr.${id}`, - }); + async _prepareAttributesTab(ctx) { + ctx.tabActive = this.tabGroups.primary === `attributes`; + + const groups = new Map(); + const attrs = (this.actor.itemTypes.attribute ?? []) + .toSorted((a, b) => a.name.localeCompare(b.name)); + for (const attr of attrs) { + if (attr.system.aboveTheFold) { continue }; + + const groupName = attr.system.group ?? `Attributes`; + if (!groups.has(groupName)) { + groups.set(groupName, { + name: groupName.titleCase(), + attrs: [], + collapsed: false, + }); + }; + const group = groups.get(groupName); + + group.attrs.push(attr); }; - ctx.attrs = attrs.toSorted(attributeSorter); + ctx.attrGroups = [...groups.values()].toSorted((a, b) => a.name.localeCompare(b.name)); }; async _prepareTabList(ctx) { @@ -321,19 +363,24 @@ export class PlayerSheet extends ctx.itemGroups = []; for (const [groupName, items] of Object.entries(this.actor.itemTypes)) { + + // We don't care about attribute items here + if (groupName === `attribute`) { continue }; + const preparedItems = []; let summedWeight = 0; for (const item of items) { - summedWeight += item.system.quantifiedWeight; - preparedItems.push(await this._prepareItem(item)); + summedWeight += item.system.quantifiedWeight ?? 0; + const data = await this._prepareItem(item); + if (data) { preparedItems.push(data) }; }; totalWeight += summedWeight; ctx.itemGroups.push({ name: groupName.titleCase(), items: preparedItems, - weight: config.weightFormatter(totalWeight), + weight: config.weightFormatter(summedWeight), }); }; @@ -343,6 +390,7 @@ export class PlayerSheet extends }; async _prepareItem(item) { + if (item.type !== `generic`) { return }; const ctx = { uuid: item.uuid, img: item.img, diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index d0e8dbc..81a197e 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -1,4 +1,7 @@ +import { __ID__ } from "../../consts.mjs"; + export class PlayerData extends foundry.abstract.TypeDataModel { + // #region Schema static defineSchema() { const fields = foundry.data.fields; return { @@ -12,24 +15,53 @@ export class PlayerData extends foundry.abstract.TypeDataModel { nullable: true, initial: null, }), - attr: new fields.TypedObjectField( - new fields.SchemaField({ - name: new fields.StringField({ blank: false, trim: true }), - sort: new fields.NumberField({ min: 1, initial: 1, integer: true, nullable: false }), - value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }), - max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }), - isRange: new fields.BooleanField({ initial: false, nullable: false }), - }), - { - initial: {}, - nullable: false, - required: true, - }, - ), + attr: new fields.ObjectField({ persisted: false, initial: {} }), }; }; + // #endregion Schema + // #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 default items from the world setting if required + const items = this.parent._source.items; + if (items.length === 0 && !options.cloning) { + const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? []; + this.parent.updateSource({ items: defaults }); + }; + + return super._preCreate(data, options, user); + }; + + /** + * For every attribute item that the character has, we want that data + * accessible in the system data, so we create objects dynamically that + * the rest of Foundry can read in to emulate the attributes being on + * the Actor directly. + */ + prepareDerivedData() { + const attrs = this.parent.items?.filter(item => item.type === `attribute`); + for (const attr of attrs) { + if (attr.system.isRange) { + this.attr[attr.system.key] = { + value: attr.system.value, + max: attr.system.max, + }; + } else { + this.attr[attr.system.key] = attr.system.value; + }; + }; + }; + // #endregion Lifecycle + + // #region Methods get hasAttributes() { return Object.keys(this.attr).length > 0; }; + // #endregion Methods }; diff --git a/module/data/Item/attribute.mjs b/module/data/Item/attribute.mjs new file mode 100644 index 0000000..91ebcb5 --- /dev/null +++ b/module/data/Item/attribute.mjs @@ -0,0 +1,112 @@ +import { isValidID, toID } from "../../utils/toID.mjs"; +import { clamp } from "../../utils/clamp.mjs"; + +const { getProperty, hasProperty, setProperty } = foundry.utils; + +export class AttributeItemData extends foundry.abstract.TypeDataModel { + // #region Schema + static defineSchema() { + const fields = foundry.data.fields; + return { + group: new fields.StringField({ + blank: false, + trim: true, + nullable: true, + initial: null, + }), + key: new fields.StringField({ + blank: false, + trim: true, + nullable: false, + }), + aboveTheFold: new fields.BooleanField({ + initial: false, + }), + + /* The attributes current value */ + value: new fields.NumberField({ + integer: true, + }), + /* The minimum accepted value */ + min: new fields.NumberField({ + integer: true, + }), + /* The maximum accepted value */ + max: new fields.NumberField({ + integer: true, + }), + }; + }; + // #endregion Schema + + // #region Lifecycle + async _preCreate(data, options, user) { + // Assign the key as the ID'd name if isn't provided, or validate if + // it is provided. + if (!this.key) { + this.updateSource({ key: toID(this.parent.name) }); + } else if (!isValidID(this.key)) { + ui.notifications.error(_loc( + `taf.notifs.error.invalid-attribute-key`, + { key: this.key }, + )); + return false; + }; + + // Prevent duplicate Attribute keys from existing on a single Actor + if (this.parent.isEmbedded) { + const attr = this.parent.parent?.getAttribute(this.key); + if (attr) { + ui.notifications.error( + `taf.notifs.error.duplicate-attribute-key`, + { + localize: true, + format: { key: this.key }, + }, + ); + return false; + }; + }; + + return super._preCreate(data, options, user); + }; + + async _preUpdate(data, options, user) { + const allowed = await super._preUpdate(data, options, user); + if (allowed === false) { return false }; + + // Prevent invalid IDs + if (hasProperty(data, `system.key`) && !isValidID(data.system.key)) { + ui.notifications.error(_loc( + `taf.notifs.error.invalid-attribute-key`, + { key: data.system.key }, + )); + delete data.system?.key; + }; + + // Prevent value going out of the bounds of min/max + if (hasProperty(data, `system.value`)) { + const value = getProperty(data, `system.value`); + const max = getProperty(data, `system.max`) ?? this.max; + + let min = getProperty(data, `system.min`) ?? this.min; + if (max != null) { min ??= 0 }; + + setProperty(data, `system.value`, clamp(min, value, max)); + }; + }; + // #endregion Lifecycle + + // #region Methods + get isRange() { + return this.max !== null; + }; + + get inferredMinimum() { + if (this.isRange) { + return this.min ?? 0; + }; + return null; + }; + // #endregion Methods +}; diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index acbe5e0..ac58f4b 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -1,29 +1,12 @@ import { __ID__ } from "../consts.mjs"; +import { clamp } from "../utils/clamp.mjs"; const { Actor } = foundry.documents; -const { hasProperty } = foundry.utils; +const { deepClone, setProperty } = 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 - const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? {}; - if (!hasProperty(data, `system.attr`)) { - // Remove with issue: Foundry/taf#55 - const value = game.release.generation > 13 ? _replace(defaults) : defaults; - this.updateSource({ "system.==attr": value }); - }; - - 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 @@ -33,10 +16,43 @@ export class TAFActor extends Actor { super._onEmbeddedDocumentChange(...args); this.#sortedTypes = null; }; + + /** + * This override allows the _preCreate operations to see whether the actor is + * being cloned or created from nothing. This allows for easy one-time operations + * that should be performed during Actor creation but not duplication to occur. + */ + clone(data, context) { + context.cloning = true; + return super.clone(data, context); + }; // #endregion Lifecycle // #region Token Attributes + /** + * @override + * This override exists in order to support making updates to the Actor's + * embedded attribute Items from the token, or falling back to the default + * handling if it's not one of our attributes. + */ async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) { + if (attribute.startsWith(`attr.`)) { + const key = attribute.slice(5); + const attr = this.getAttribute(key); + value = isDelta ? attr.system.value + value : value; + value = clamp(attr.system.min, value, attr.system.max); + const updates = { system: { value } }; + + const allowed = Hooks.call( + `modifyTokenAttribute`, + { attribute, value, isDelta, isBar, isEmbedded: true }, + updates, + this, + ); + + return allowed !== false ? await attr.update(updates) : this; + }; + const attr = foundry.utils.getProperty(this.system, attribute); const current = isBar ? attr.value : attr; const update = isDelta ? current + value : value; @@ -44,18 +60,7 @@ export class TAFActor extends Actor { return this; }; - // Determine the updates to make to the actor data - let updates; - if (isBar) { - updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)}; - } else { - updates = {[`system.${attribute}`]: update}; - }; - - // Allow a hook to override these changes - const allowed = Hooks.call(`modifyTokenAttribute`, {attribute, value, isDelta, isBar}, updates, this); - - return allowed !== false ? this.update(updates) : this; + return super.modifyTokenAttribute(attribute, value, isDelta, isBar); }; // #endregion Token Attributes @@ -88,8 +93,15 @@ export class TAFActor extends Actor { }; // #endregion Roll Data - // #region Getters + // #region Methods #sortedTypes = null; + /** + * @override + * This override is intended to allow the "generic" item subtype to instead + * populate the Item types based on their "Group" property, for any other item + * subtype this function operates the same way that the default Foundry + * implementation does. + */ get itemTypes() { if (this.#sortedTypes) { return this.#sortedTypes }; const types = {}; @@ -105,5 +117,58 @@ export class TAFActor extends Actor { }; return this.#sortedTypes = types; }; - // #endregion Getters + + /** + * Retrieves an attribute Item from the actor, used to more easily + * + * @param {string} key The unique identifier of the attribute + * @returns The attribute's Item document, or undefined if not found + */ + getAttribute(key) { + const attrs = this.itemTypes.attribute ?? []; + return attrs.find(attr => attr.system.key === key); + }; + + /** + * Updates an embedded attribute Item with a new value. + * + * @param {string} key The unique identifier of the attribute + * @param {number} value The value to set the attribute to + */ + async setAttributeValue(key, value) { + const item = this.getAttribute(key); + await item?.update({system: { value }}); + }; + // #endregion Methods + + // #region Data Migration + /** + * This checks and performs all data migrations that the system requires, some + * of these are one-time only migrations, others of them will happen every time + * an Actor is updated. + */ + static migrateData(data, options) { + this.#migrateToAttributeItems(data, options); + return super.migrateData(data, options); + }; + + /** + * This method handles checking if the Actor has attributes within it's raw + * system data model, which was where attributes were stored originally, if + * it detects the need for a migration, it stores the existing attribute data + * into a flag so that the v3.0.0 migration script can handle creating the + * data and removing the property from the Actor. + */ + static #migrateToAttributeItems(data, options) { + if (options.partial) { return } + const attr = data.system?.attr ?? {}; + if (Object.keys(attr).length > 0) { + setProperty( + data, + `flags.${__ID__}.convertAttributesIntoItems`, + deepClone(attr), + ); + }; + }; + // #endregion Data Migration }; diff --git a/module/documents/Token.mjs b/module/documents/Token.mjs index 068c737..8b0b5bd 100644 --- a/module/documents/Token.mjs +++ b/module/documents/Token.mjs @@ -83,22 +83,12 @@ export class TAFTokenDocument extends TokenDocument { if (`value` in data && `max` in data) { let editable = hasProperty(system, `${attribute}.value`); - const isRange = getProperty(system, `${attribute}.isRange`); - if (isRange) { - return { - type: `bar`, - attribute, - value: parseInt(data.value || 0), - max: parseInt(data.max || 0), - editable, - }; - } else { - return { - type: `value`, - attribute: `${attribute}.value`, - value: Number(data.value), - editable, - }; + return { + type: `bar`, + attribute, + value: parseInt(data.value || 0), + max: parseInt(data.max || 0), + editable, }; }; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index a6f623e..54190fc 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -1,10 +1,12 @@ // Apps +import { AttributeItemSheet } from "../apps/AttributeItemSheet.mjs"; 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 { AttributeItemData } from "../data/Item/attribute.mjs"; import { GenericItemData } from "../data/Item/generic.mjs"; import { PlayerData } from "../data/Actor/player.mjs"; @@ -35,6 +37,7 @@ Hooks.on(`init`, () => { // #region Data Models CONFIG.Actor.dataModels.player = PlayerData; CONFIG.Item.dataModels.generic = GenericItemData; + CONFIG.Item.dataModels.attribute = AttributeItemData; // #endregion Data Models // #region Sheets @@ -61,10 +64,20 @@ Hooks.on(`init`, () => { __ID__, GenericItemSheet, { + types: [`generic`], makeDefault: true, label: `taf.sheet-names.GenericItemSheet`, }, ); + foundry.documents.collections.Items.registerSheet( + __ID__, + AttributeItemSheet, + { + types: [`attribute`], + makeDefault: true, + label: `taf.sheet-names.AttributeItemSheet`, + }, + ); // #endregion Sheets registerWorldSettings(); diff --git a/module/hooks/ready.mjs b/module/hooks/ready.mjs index 94c7249..2a8bf39 100644 --- a/module/hooks/ready.mjs +++ b/module/hooks/ready.mjs @@ -1,6 +1,10 @@ +import { checkMigrations } from "../migrations/checkMigrations.mjs"; + Hooks.on(`ready`, () => { // Remove with issue: Foundry/taf#52 if (game.release.generation < 14 && globalThis._loc == null) { globalThis._loc = game.i18n.format.bind(game.i18n); }; + + checkMigrations(); }); diff --git a/module/migrations/checkMigrations.mjs b/module/migrations/checkMigrations.mjs new file mode 100644 index 0000000..585526d --- /dev/null +++ b/module/migrations/checkMigrations.mjs @@ -0,0 +1,24 @@ +import { __ID__ } from "../consts.mjs"; +import { Logger } from "../utils/Logger.mjs"; +import { migrateTo3_0_0 } from "./v3.0.0.mjs"; + +const { isNewerVersion } = foundry.utils; + +export async function checkMigrations() { + if (!game.user.isActiveGM) { + Logger.debug(`User not active GM, skipping data migrations`); + return; + }; + + const migrationVersion = game.settings.get(__ID__, `migrationVersion`); + let updateVersion = !migrationVersion; + + if (isNewerVersion(`3.0.0`, migrationVersion)) { + await migrateTo3_0_0(); + updateVersion = true; + }; + + if (updateVersion) { + game.settings.set(__ID__, `migrationVersion`, game.system.version); + }; +}; diff --git a/module/migrations/utils.mjs b/module/migrations/utils.mjs new file mode 100644 index 0000000..1c03767 --- /dev/null +++ b/module/migrations/utils.mjs @@ -0,0 +1,105 @@ +import { __ID__ } from "../consts.mjs"; + +/** + * Migrate the documents within a collection based on what + * + * This function was originally reproduced from [Draw Steel's codebase](https://github.com/MetaMorphic-Digital/draw-steel/blob/82a0a050da7c0d6d28c0cd283cf3b6915f47ee2a/src/module/data/migrations.mjs#L206-L226), + * with modifications to it to work better without + * + * @param collection The Collection of documents to update. + * @param flag The flag name to reference for if the document should be migrated. + * @param convertor The function that takes the document and performs the. + * transformations to get the required update data. + * @param options Options to configure how the method behaves. + * @param options.pack The compendium pack to update. + * @param options.parent Parent of the collection for embedded collections. + * @param options.update Whether or not this method should perform the update, or pass back the array of DB operations. + * @returns An array of batch operations to perform. + */ +export async function migrateCollection( + collection, + flag, + convertor, + options = {}, +) { + const toMigrate = collection + .filter(doc => doc.getFlag(__ID__, flag)) + .map(doc => { + const update = convertor(doc, options) ?? {}; + update[`_id`] = doc._id; + + // v13/v14+ compatibility shim + if (game.release.generation > 13) { + update[`flags.${__ID__}.${flag}`] = _del; + } else { + update[`flags.${__ID__}.-=${flag}`] = null; + }; + + return update; + }) + .filter(data => !!data); + + if (!options.update) { + return [{ + action: `update`, + broadcast: true, + documentName: collection.documentName, + updates: toMigrate, + noHook: true, + pack: options.pack, + parent: options.parent, + }]; + }; + + // Modify in batches of 100 + const batches = Math.ceil(toMigrate.length / 100); + for (let i = 0; i < batches; i++) { + const updateData = toMigrate.slice(i * 100, (i + 1) * 100); + await collection.documentClass.updateDocuments( + updateData, + { + pack: options.pack, + parent: options.parent, + diff: false, + }, + ); + }; +}; + +/** + * Determine whether a compendium pack should be migrated during `migrateWorld`. + * + * This function was reproduced from [Draw Steel's codebase](https://github.com/MetaMorphic-Digital/draw-steel/blob/82a0a050da7c0d6d28c0cd283cf3b6915f47ee2a/src/module/data/migrations.mjs#L287-L302) + * + * @param pack The CompendiumPack document + * @returns {boolean} Whether or not the pack should be migrated + */ +export function shouldMigrateCompendium(pack, types = [`Actor`, `Item`]) { + // We only care about actor and item migrations + if (!types.includes(pack.documentName)) {return false} + + // World compendiums should all be migrated, system ones should never by migrated + if (pack.metadata.packageType === `world`) {return true} + if (pack.metadata.packageType === `system`) {return false} + + // Module compendiums should only be migrated if they don't have a download or manifest URL + const module = game.modules.get(pack.metadata.packageName); + return !module.download && !module.manifest; +}; + +export function finishMigrationWarning(warning, version) { + warning.update({ pct: 1 }); + setTimeout( + () => { + warning.remove(); + ui.notifications.success( + `taf.notifs.success.migration-successful`, + { + localize: true, + format: { version }, + }, + ); + }, + 3_000, + ); +}; diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs new file mode 100644 index 0000000..acedfc8 --- /dev/null +++ b/module/migrations/v3.0.0.mjs @@ -0,0 +1,120 @@ +import { + finishMigrationWarning, + migrateCollection, + shouldMigrateCompendium, +} from "./utils.mjs"; +import { __ID__ } from "../consts.mjs"; +import { Logger } from "../utils/Logger.mjs"; + +const flag = `convertAttributesIntoItems`; +const worldOperations = []; +let compendiumOperations = []; + +export async function migrateTo3_0_0() { + Logger.debug(`Starting v3.0.0 data migration`); + const packsToMigrate = game.packs.filter( + (pack) => shouldMigrateCompendium(pack, [`Actor`]), + ); + + const warning = ui.notifications.warn( + `taf.notifs.warn.migration-in-progress`, + { + localize: true, + format: { version: `3.0.0` }, + progress: true, + permanent: true, + }, + ); + + // Migrating world actors + worldOperations.push( + ...await migrateCollection( + game.actors, + flag, + handleMigratingActor, + { update: false }, + ), + ); + warning.update({ pct: 0.25 }); + + // Migrating all of the relevant compendiums + for (const pack of packsToMigrate) { + await pack.getDocuments(); + + const wasLocked = pack.config.locked; + if (wasLocked) {await pack.configure({ locked: false })} + + compendiumOperations.push( + ...await migrateCollection( + pack, + flag, + handleMigratingActor, + { pack: pack.collection, update: false }, + ), + ); + + await foundry.documents.modifyBatch(compendiumOperations); + + if (wasLocked) {await pack.configure({ locked: true })} + + compendiumOperations = []; + }; + warning.update({ pct: 0.8 }); + + // Migrating the world setting + const defaultAttrs = game.settings.get(__ID__, `actorDefaultAttributes`)?.at(0); + if (defaultAttrs) { + const itemSchemas = []; + for (const [key, attr] of Object.entries(defaultAttrs)) { + itemSchemas.push(convertToItem(key, attr)); + }; + await game.settings.set(__ID__, `actorDefaultAttributes`, itemSchemas); + }; + warning.update({ pct: 0.9 }); + + await foundry.documents.modifyBatch(worldOperations); + finishMigrationWarning(warning, `3.0.0`); +}; + +function handleMigratingActor(actor, options) { + const operation = { + action: `create`, + broadcast: true, + documentName: `Item`, + parent: actor, + pack: options.pack, + noHook: true, + data: [], + }; + + const attrs = actor.getFlag(__ID__, flag) ?? {}; + for (const [ key, attr ] of Object.entries(attrs)) { + operation.data.push(convertToItem(key, attr)); + }; + + // No items to create, don't queue the operation + if (operation.data.length > 0) { + if (actor.inCompendium) { + compendiumOperations.push(operation); + } else { + worldOperations.push(operation); + }; + }; + + return { + "system.attr": _del, + }; +}; + +function convertToItem(key, attr) { + return { + name: attr.name, + type: `attribute`, + system: { + key, + value: attr.value, + max: attr.isRange ? attr.max : null, + aboveTheFold: true, + }, + }; +}; diff --git a/module/settings/world.mjs b/module/settings/world.mjs index bb0cd06..f811d13 100644 --- a/module/settings/world.mjs +++ b/module/settings/world.mjs @@ -71,7 +71,13 @@ export function registerWorldSettings() { game.settings.register(__ID__, `actorDefaultAttributes`, { config: false, - type: Object, + type: Array, + scope: `world`, + }); + + game.settings.register(__ID__, `migrationVersion`, { + config: false, + type: String, scope: `world`, }); }; diff --git a/module/utils/clamp.mjs b/module/utils/clamp.mjs new file mode 100644 index 0000000..141185b --- /dev/null +++ b/module/utils/clamp.mjs @@ -0,0 +1,5 @@ +export function clamp(min, ideal, max) { + min ??= Number.NEGATIVE_INFINITY; + max ??= Number.POSITIVE_INFINITY; + return Math.max(min, Math.min(ideal, max)); +}; diff --git a/module/utils/toID.mjs b/module/utils/toID.mjs index 312766e..8e854b1 100644 --- a/module/utils/toID.mjs +++ b/module/utils/toID.mjs @@ -1,6 +1,6 @@ /** - * A helper method that converts an arbitrary string into a format that can be - * used as an object key easily. + * A helper method that converts an arbitrary string into a format + * that can be used as an object key easily. * * @param {string} text The text to convert * @returns The converted ID @@ -9,5 +9,26 @@ export function toID(text) { return text .toLowerCase() .replace(/\s+/g, `_`) - .replace(/\W/g, ``); + .replace(/\W/g, ``) + .replace(/(^_|_$)/); +}; + +/** + * A helper method that reports if an arbitrary string is considered a + * valid ID for use in the system + * + * @param {string} text The text to check + * @returns Whether or not the text is a valid ID + */ +export function isValidID(text) { + return !( + // any uppercase characters + text.match(/[A-Z]/) + + // any non-word characters + || text.match(/\W/) + + // any whitespace characters + || text.match(/\s/) + ); }; diff --git a/styles/Apps/AttributeItemSheet.css b/styles/Apps/AttributeItemSheet.css new file mode 100644 index 0000000..6253b6a --- /dev/null +++ b/styles/Apps/AttributeItemSheet.css @@ -0,0 +1,56 @@ +.taf.AttributeItemSheet { + min-width: 300px; + + > .window-content { + padding: 0; + color: var(--attribute-sheet-colour); + background: var(--attribute-sheet-background); + } + + .sheet-header { + padding: 0.5rem; + border-bottom: 1px solid var(--attribute-sheet-divider-colour); + } + + .value-controls { + display: grid; + align-items: center; + grid-auto-flow: column; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, auto); + gap: 2px 4px; + padding: 0 8px; + } + + .property { + display: grid; + align-items: center; + justify-items: left; + grid-template-columns: 2fr 1fr; + gap: 2px 8px; + margin: 0 8px 8px; + + .hint { + grid-column: 1 / -1; + margin: 0; + color: var(--attribute-sheet-hint-colour); + } + } + + input { + color: var(--attribute-sheet-input-colour); + background: var(--attribute-sheet-input-background); + + &:disabled { + color: var(--attribute-sheet-disabled-input-colour); + cursor: not-allowed; + } + } + + taf-toggle { + --toggle-background: var(--attribute-sheet-input-background); + --slider-checked-colour: var(--attribute-sheet-toggle-slider-enabled-colour); + --slider-unchecked-colour: var(--attribute-sheet-toggle-slider-disabled-colour); + justify-self: right; + } +} diff --git a/styles/Apps/PlayerSheet.css b/styles/Apps/PlayerSheet.css index 627932e..f83f335 100644 --- a/styles/Apps/PlayerSheet.css +++ b/styles/Apps/PlayerSheet.css @@ -37,7 +37,8 @@ } } - .items-tab.active { + .items-tab.active, + .attributes-tab.active { display: flex; flex-direction: column; gap: 8px; @@ -67,7 +68,7 @@ } } - .item-list-header { + .embedded-list-header { display: flex; flex-direction: row; align-items: center; @@ -75,8 +76,8 @@ border-radius: 6px 6px 0 0; padding: 6px 6px 4px; margin-bottom: 2px; - background: var(--item-list-header-background); - color: var(--item-list-header-colour); + background: var(--embedded-list-header-background); + color: var(--embedded-list-header-colour); button { padding: 2px; @@ -84,18 +85,23 @@ aspect-ratio: 1; height: unset; min-height: unset; - background: var(--item-list-header-input-background); - color: var(--item-list-header-input-colour); + background: var(--embedded-list-header-input-background); + color: var(--embedded-list-header-input-colour); } } - .item-list { + .embedded-list { display: flex; flex-direction: column; gap: 2px; list-style: none; margin: 0; padding: 0; + + &.two-col { + display: grid; + grid-template-columns: repeat(2, 1fr); + } } .item { @@ -172,6 +178,46 @@ } } + .attribute { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + background: var(--attribute-background); + color: var(--attribute-colour); + padding: 4px; + margin: 0; + + .name { + font-size: 1.1rem; + } + + input { + width: 50px; + } + + input, button { + background: var(--item-card-header-input-background); + color: var(--item-card-header-input-colour); + text-align: center; + + &:disabled { + color: var(--item-card-header-disabled-input-colour); + } + } + + &:last-child:nth-child(odd) { + grid-column: 1 / -1; + border-radius: 0 0 6px 6px; + } + &:nth-last-child(2):has( + &:nth-child(even)) { + border-radius: 0 0 0 6px; + } + &:last-child:nth-child(even) { + border-radius: 0 0 6px 0; + } + } + .content { flex-grow: 1; overflow: hidden; diff --git a/styles/main.css b/styles/main.css index 49e4763..be8ba70 100644 --- a/styles/main.css +++ b/styles/main.css @@ -26,6 +26,7 @@ /* Apps */ @import url("./Apps/common.css") layer(apps); @import url("./Apps/Ask.css") layer(apps); +@import url("./Apps/AttributeItemSheet.css") layer(apps); @import url("./Apps/AttributeManager.css") layer(apps); @import url("./Apps/GenericItemSheet.css") layer(apps); @import url("./Apps/PlayerSheet.css") layer(apps); diff --git a/styles/themes/dark.css b/styles/themes/dark.css index fa87c25..3d008e2 100644 --- a/styles/themes/dark.css +++ b/styles/themes/dark.css @@ -19,10 +19,16 @@ --inventory-input-colour: var(--steel-100); --inventory-input-disabled-colour: var(--steel-350); - --item-list-header-background: var(--steel-800); - --item-list-header-colour: var(--steel-100); - --item-list-header-input-background: var(--steel-650); - --item-list-header-input-colour: var(--steel-100); + --embedded-list-header-background: var(--steel-800); + --embedded-list-header-colour: var(--steel-100); + --embedded-list-header-input-background: var(--steel-650); + --embedded-list-header-input-colour: var(--steel-100); + + --attribute-background: var(--steel-700); + --attribute-colour: var(--steel-100); + --attribute-input-background: var(--steel-650); + --attribute-input-colour: var(--steel-100); + --attribute-disabled-input-colour: var(--steel-350); --item-card-background: #1d262f; --item-card-colour: var(--steel-100); @@ -44,6 +50,17 @@ --item-sheet-description-menu-background: var(--steel-700); --item-sheet-description-content-background: var(--steel-650); + /* Attribute Sheet Variables */ + --attribute-sheet-colour: var(--item-sheet-colour); + --attribute-sheet-background: var(--item-sheet-background); + --attribute-sheet-divider-colour: var(--item-sheet-divider); + --attribute-sheet-hint-colour: var(--steel-250); + --attribute-sheet-input-colour: var(--item-sheet-input-colour); + --attribute-sheet-input-background: var(--item-sheet-input-background); + --attribute-sheet-disabled-input-colour: var(--steel-350); + --attribute-sheet-toggle-slider-enabled-colour: var(--item-sheet-toggle-slider-enabled-colour); + --attribute-sheet-toggle-slider-disabled-colour: var(--item-sheet-toggle-slider-disabled-colour); + /* Chip Variables */ --chip-colour: #fff7ed; --chip-background: #2b3642; diff --git a/system.json b/system.json index 6a9bdef..a301e22 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "taf", "title": "Text-Based Actors", "description": "An intentionally minimalist system that enables you to play rules-light games without getting in your way!", - "version": "2.6.2", + "version": "3.0.0", "download": "", "manifest": "", "url": "https://git.varify.ca/Foundry/taf", @@ -45,6 +45,10 @@ "description" ], "filePathFields": {} + }, + "attribute": { + "htmlFields": [], + "filePathFields": {} } } }, diff --git a/templates/AttributeItemSheet/header.hbs b/templates/AttributeItemSheet/header.hbs new file mode 100644 index 0000000..1e540e9 --- /dev/null +++ b/templates/AttributeItemSheet/header.hbs @@ -0,0 +1,11 @@ +
+ +
diff --git a/templates/AttributeItemSheet/settings.hbs b/templates/AttributeItemSheet/settings.hbs new file mode 100644 index 0000000..2778d79 --- /dev/null +++ b/templates/AttributeItemSheet/settings.hbs @@ -0,0 +1,39 @@ +
+
+ + +

+ {{ localize "taf.misc.attribute.key.hint" }} +

+
+
+ + +
+ {{#if (not system.aboveTheFold)}} +
+ + +
+ {{/if}} +
diff --git a/templates/AttributeItemSheet/value.hbs b/templates/AttributeItemSheet/value.hbs new file mode 100644 index 0000000..8ad3f14 --- /dev/null +++ b/templates/AttributeItemSheet/value.hbs @@ -0,0 +1,37 @@ +
+ + + + + + + + +
diff --git a/templates/PlayerSheet/attributes.hbs b/templates/PlayerSheet/attributes.hbs deleted file mode 100644 index a2f0457..0000000 --- a/templates/PlayerSheet/attributes.hbs +++ /dev/null @@ -1,34 +0,0 @@ -{{#if hasAttributes}} -
- {{#each attrs as | attr |}} -
- - {{ attr.name }} - -
- - {{#if attr.isRange}} - - - {{/if}} -
-
- {{/each}} -
-{{else}} -