Ammo Tracking

- Adds the sum of all ammo into the skills sheet
- Initializes a Popover system so that we can have rerenderable popovers in the system that can be rendered like tooltips, or popped out into their own window without the need for separate templating for each method. The "tooltip"-presentation is just a frameless application.
- Utilizes the Popover system to create an ammo popover that lists each ammo independently and allows the user to "star" up to three ammos that will be visible on the sheet at all times.
- Bumps the verified from from `13`->`13.339`
This commit is contained in:
Oliver 2025-04-10 21:28:30 -06:00 committed by GitHub
commit 86ddac1aa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 831 additions and 25 deletions

View file

@ -1,6 +1,8 @@
Oliver Akins: Oliver Akins:
- geist-silhouette.v2.svg : All rights reserved. - geist-silhouette.v2.svg : All rights reserved.
- caster-silhouette.v1.svg : All rights reserved. - caster-silhouette.v1.svg : All rights reserved.
- icons/star-empty.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
- icons/star.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
Kýnan Antos (Gritsilk Games): Kýnan Antos (Gritsilk Games):
- hero-silhouette.svg : Licensed to Distribute and Modify within the bounds of the "Foundry-RipCrypt" system. - hero-silhouette.svg : Licensed to Distribute and Modify within the bounds of the "Foundry-RipCrypt" system.

View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M93.824 44.383 80.058 61.379a6.3 6.3 0 0 0-1.398 4.3l1.144 21.84c.2 3.802-3.578 6.548-7.133 5.18l-20.418-7.84a6.3 6.3 0 0 0-4.52 0L27.317 92.7c-3.551 1.364-7.332-1.382-7.133-5.18l1.145-21.84a6.3 6.3 0 0 0-1.399-4.3L6.163 44.383C3.77 41.426 5.21 36.985 8.886 36l21.125-5.66a6.3 6.3 0 0 0 3.656-2.656L45.577 9.34c2.07-3.192 6.742-3.192 8.816 0l11.91 18.344a6.3 6.3 0 0 0 3.657 2.656L91.085 36c3.675.985 5.128 5.43 2.734 8.387z" style="stroke-width: 8px; stroke: black; fill: transparent;"/></svg>

After

Width:  |  Height:  |  Size: 565 B

1
assets/icons/star.svg Normal file
View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M93.824 44.383 80.058 61.379a6.3 6.3 0 0 0-1.398 4.3l1.144 21.84c.2 3.802-3.578 6.548-7.133 5.18l-20.418-7.84a6.3 6.3 0 0 0-4.52 0L27.317 92.7c-3.551 1.364-7.332-1.382-7.133-5.18l1.145-21.84a6.3 6.3 0 0 0-1.399-4.3L6.163 44.383C3.77 41.426 5.21 36.985 8.886 36l21.125-5.66a6.3 6.3 0 0 0 3.656-2.656L45.577 9.34c2.07-3.192 6.742-3.192 8.816 0l11.91 18.344a6.3 6.3 0 0 0 3.657 2.656L91.085 36c3.675.985 5.128 5.43 2.734 8.387z"/></svg>

After

Width:  |  Height:  |  Size: 504 B

View file

@ -22,9 +22,7 @@ export default [
Hooks: `readonly`, Hooks: `readonly`,
ui: `readonly`, ui: `readonly`,
Actor: `readonly`, Actor: `readonly`,
Actors: `readonly`,
Item: `readonly`, Item: `readonly`,
Items: `readonly`,
foundry: `readonly`, foundry: `readonly`,
ChatMessage: `readonly`, ChatMessage: `readonly`,
ActiveEffect: `readonly`, ActiveEffect: `readonly`,
@ -36,6 +34,7 @@ export default [
Combatant: `readonly`, Combatant: `readonly`,
canvas: `readonly`, canvas: `readonly`,
Token: `readonly`, Token: `readonly`,
Tour: `readonly`,
}, },
}, },
}, },

View file

@ -21,6 +21,9 @@
"HeroCraftCardV1": "Hero Craft Card", "HeroCraftCardV1": "Hero Craft Card",
"HeroSkillsCardV1": "Hero Skill Card" "HeroSkillsCardV1": "Hero Skill Card"
}, },
"app-titles": {
"AmmoTracker": "Ammo Tracker"
},
"common": { "common": {
"abilities": { "abilities": {
"grit": "Grit", "grit": "Grit",
@ -172,12 +175,21 @@
"numberOfDice": "# of Dice", "numberOfDice": "# of Dice",
"rollTarget": "Target", "rollTarget": "Target",
"difficulty": "(DC: {dc})", "difficulty": "(DC: {dc})",
"RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost." "RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost.",
"starred-ammo-placeholder": "Starred Ammo Slot",
"AmmoTracker": {
"no-ammo": "You don't have any ammo!",
"star-button": "Add {name} as a starred ammo",
"star-button-tooltip": "Add Star",
"unstar-button": "Remove {name} as a starred ammo",
"unstar-button-tooltip": "Remove Star"
}
}, },
"notifs": { "notifs": {
"error": { "error": {
"cannot-equip": "Cannot equip the {itemType}, see console for more details.", "cannot-equip": "Cannot equip the {itemType}, see console for more details.",
"invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action." "invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action.",
"at-favourite-limit": "Cannot favourite more than three items, unfavourite one to make space."
}, },
"warn": { "warn": {
"cannot-go-negative": "\"{name}\" is unable to be a negative number." "cannot-go-negative": "\"{name}\" is unable to be a negative number."
@ -197,5 +209,8 @@
"heavy": "The distance of your aura when using Heavycraft" "heavy": "The distance of your aura when using Heavycraft"
} }
} }
},
"USER": {
"GM": "Keeper"
} }
} }

View file

@ -1,9 +1,12 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs"; import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { documentSorter, filePath } from "../../consts.mjs"; import { documentSorter, filePath } from "../../consts.mjs";
import { AmmoTracker } from "../popovers/AmmoTracker.mjs";
import { gameTerms } from "../../gameTerms.mjs"; import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs"; import { GenericAppMixin } from "../GenericApp.mjs";
import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs"; import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs"; import { Logger } from "../../utils/Logger.mjs";
import { PopoverEventManager } from "../../utils/PopoverEventManager.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api; const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets; const { ActorSheetV2 } = foundry.applications.sheets;
@ -43,6 +46,7 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
async _onRender(context, options) { async _onRender(context, options) {
await super._onRender(context, options); await super._onRender(context, options);
HeroSkillsCardV1._onRender.bind(this)(context, options); HeroSkillsCardV1._onRender.bind(this)(context, options);
HeroSkillsCardV1._createPopoverListeners.bind(this)();
}; };
static async _onRender(_context, options) { static async _onRender(_context, options) {
@ -75,6 +79,18 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
); );
}; };
/** @this {HeroSkillsCardV1} */
static async _createPopoverListeners() {
const ammoInfoIcon = this.element.querySelector(`.ammo-info-icon`);
const idPrefix = this.actor.uuid;
const manager = new PopoverEventManager(`${idPrefix}.ammo-info-icon`, ammoInfoIcon, AmmoTracker);
this._popoverManagers.set(`.ammo-info-icon`, manager);
this._hookIDs.set(Hooks.on(`prepare${manager.id}Context`, (ctx) => {
ctx.ammos = this.actor.itemTypes.ammo;
}), `prepare${manager.id}Context`);
};
async _preparePartContext(partId, ctx, opts) { async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts); ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document; ctx.actor = this.document;
@ -120,7 +136,24 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
}; };
static async prepareAmmo(ctx) { static async prepareAmmo(ctx) {
ctx.ammo = 0; let total = 0;
let favouriteCount = 0;
ctx.favouriteAmmo = new Array(3).fill(null);
for (const ammo of ctx.actor.itemTypes.ammo) {
total += ammo.system.quantity;
if (favouriteCount < 3 && ammo.getFlag(game.system.id, ItemFlags.FAVOURITE)) {
ctx.favouriteAmmo[favouriteCount] = {
uuid: ammo.uuid,
name: ammo.name,
quantity: ammo.system.quantity,
};
favouriteCount++;
};
};
ctx.ammo = total;
return ctx; return ctx;
}; };

View file

@ -1,4 +1,4 @@
import { createItemFromElement, deleteItemFromElement, editItemFromElement } from "./utils.mjs"; import { createItemFromElement, deleteItemFromElement, editItemFromElement, updateForeignDocumentFromEvent } from "./utils.mjs";
import { DicePool } from "./DicePool.mjs"; import { DicePool } from "./DicePool.mjs";
import { RichEditor } from "./RichEditor.mjs"; import { RichEditor } from "./RichEditor.mjs";
import { toBoolean } from "../consts.mjs"; import { toBoolean } from "../consts.mjs";
@ -31,6 +31,13 @@ export function GenericAppMixin(HandlebarsApp) {
}; };
// #endregion // #endregion
// #region Instance Data
/** @type {Map<string, PopoverEventManager>} */
_popoverManagers = new Map();
/** @type {Map<number, string>} */
_hookIDs = new Map();
// #endregion
// #region Lifecycle // #region Lifecycle
/** /**
* @override * @override
@ -38,13 +45,43 @@ export function GenericAppMixin(HandlebarsApp) {
* top after being re-rendered as normal * top after being re-rendered as normal
*/ */
async render(options = {}, _options = {}) { async render(options = {}, _options = {}) {
super.render(options, _options); await super.render(options, _options);
const instance = foundry.applications.instances.get(this.id); const instance = foundry.applications.instances.get(this.id);
if (instance !== undefined && options.orBringToFront) { if (instance !== undefined && options.orBringToFront) {
instance.bringToFront(); instance.bringToFront();
}; };
}; };
/** @override */
async _onRender(...args) {
await super._onRender(...args);
/*
Rendering each of the popover managers associated with this app allows us
to have them be dynamic and update when their parent application is rerendered,
this could eventually be something we can move into the Document's apps
collection so Foundry auto-rerenders it, but because it isn't actually
associated with the Document (as it's dependendant on the Application), I
decided that it would be best to do my own handling for it.
*/
for (const manager of this._popoverManagers.values()) {
manager.render();
};
/*
Foreign update listeners so that we can easily update items that may not
be this document itself, but are useful to be able to be edited from this
sheet. Primarily useful for editing the Actors' Item collection, or an Items'
ActiveEffect collection.
*/
this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
const events = el.dataset.foreignUpdateOn.split(`,`);
for (const event of events) {
el.addEventListener(event, updateForeignDocumentFromEvent);
};
});
};
async _preparePartContext(partId, ctx, opts) { async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts); ctx = await super._preparePartContext(partId, ctx, opts);
delete ctx.document; delete ctx.document;
@ -60,6 +97,22 @@ export function GenericAppMixin(HandlebarsApp) {
return ctx; return ctx;
}; };
_tearDown(options) {
// Clear all popovers associated with the app
for (const manager of this._popoverManagers.values()) {
manager.destroy();
};
this._popoverManagers.clear();
// Remove any hooks added for this app
for (const [id, hook] of this._hookIDs.entries()) {
Hooks.off(hook, id);
};
this._hookIDs.clear();
super._tearDown(options);
};
// #endregion // #endregion
// #region Actions // #region Actions

View file

@ -0,0 +1,96 @@
import { filePath } from "../../consts.mjs";
import { GenericPopoverMixin } from "./GenericPopoverMixin.mjs";
import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class AmmoTracker extends GenericPopoverMixin(HandlebarsApplicationMixin(ApplicationV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt`,
],
window: {
title: `RipCrypt.app-titles.AmmoTracker`,
contentClasses: [
`ripcrypt--AmmoTracker`,
],
},
actions: {
favourite: this.#favourite,
unfavourite: this.#unfavourite,
},
};
static PARTS = {
ammoList: {
template: filePath(`templates/Apps/popovers/AmmoTracker/ammoList.hbs`),
},
};
// #endregion
// #region Instance Data
_favouriteCount = 0;
// #endregion
// #region Lifecycle
async _preparePartContext(partId, data) {
const ctx = {
meta: { idp: this.id },
partId,
};
let favouriteCount = 0;
ctx.ammos = data.ammos.map(ammo => {
const favourite = ammo.getFlag(game.system.id, ItemFlags.FAVOURITE) ?? false;
if (favourite) { favouriteCount++ };
return {
ammo,
favourite,
};
});
this._favouriteCount = favouriteCount;
ctx.atFavouriteLimit = favouriteCount >= 3;
return ctx;
};
// #endregion
// #region Actions
static async #favourite(_, el) {
const targetEl = el.closest(`[data-item-id]`);
if (!targetEl) {
Logger.warn(`Cannot find a parent element with data-item-id`);
return;
};
if (this._favouriteCount > 3) {
ui.notifications.error(localizer(`RipCrypt.notifs.error.at-favourite-limit`));
return;
};
const data = targetEl.dataset;
const item = await fromUuid(data.itemId);
if (!item) { return };
item.setFlag(game.system.id, ItemFlags.FAVOURITE, true);
};
static async #unfavourite(_, el) {
const targetEl = el.closest(`[data-item-id]`);
if (!targetEl) {
Logger.warn(`Cannot find a parent element with data-item-id`);
return;
};
const data = targetEl.dataset;
const item = await fromUuid(data.itemId);
if (!item) { return };
item.unsetFlag(game.system.id, ItemFlags.FAVOURITE);
};
// #endregion
};

View file

@ -0,0 +1,188 @@
import { updateForeignDocumentFromEvent } from "../utils.mjs";
const { ApplicationV2 } = foundry.applications.api;
/**
* This mixin provides the ability to designate an Application as a "popover",
* which means that it will spawn near the x/y coordinates provided it won't
* overflow the bounds of the screen. This also implements a _preparePartContext
* in order to allow the parent application passing new data into the popover
* whenever it rerenders; how the popover handles this data is up to the
* specific implementation.
*/
export function GenericPopoverMixin(HandlebarsApp) {
class GenericRipCryptPopover extends HandlebarsApp {
static DEFAULT_OPTIONS = {
id: `popover-{id}`,
classes: [
`popover`,
],
window: {
frame: false,
positioned: true,
resizable: false,
minimizable: false,
},
actions: {},
};
popover = {};
constructor({ popover, ...options}) {
// For when the caller doesn't provide anything, we want this to behave
// like a normal Application instance.
popover.framed ??= true;
popover.locked ??= false;
if (popover.framed) {
options.window ??= {};
options.window.frame = true;
options.window.minimizable = true;
}
options.classes ??= [];
options.classes.push(popover.framed ? `framed` : `frameless`);
super(options);
this.popover = popover;
};
toggleLock() {
this.popover.locked = !this.popover.locked;
this.classList.toggle(`locked`, this.popover.locked);
};
/**
* This render utility is intended in order to make the popovers able to be
* used in both framed and frameless mode, making sure that the content classes
* from the framed mode get shunted onto the frameless Application's root
* element.
*/
async _onFirstRender(...args) {
await super._onFirstRender(...args);
const hasContentClasses = this.options?.window?.contentClasses?.length > 0;
if (!this.popover.framed && hasContentClasses) {
this.classList.add(...this.options.window.contentClasses);
};
};
async _onRender(...args) {
await super._onRender(...args);
/*
Foreign update listeners so that we can easily update items that may not
be this document itself, but are useful to be able to be edited from this
sheet. Primarily useful for editing the Actors' Item collection, or an Items'
ActiveEffect collection.
*/
this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
const events = el.dataset.foreignUpdateOn.split(`,`);
for (const event of events) {
el.addEventListener(event, updateForeignDocumentFromEvent);
};
});
};
async close(options = {}) {
// prevent locked popovers from being closed
if (this.popover.locked && !options.force) { return };
if (!this.popover.framed) {
options.animate = false;
};
return super.close(options);
};
/**
* @override
* Custom implementation in order to make it show up approximately where I
* want it to when being created.
*
* Most of this implementation is identical to the ApplicationV2
* implementation, the biggest difference is how targetLeft and targetTop
* are calculated.
*/
_updatePosition(position) {
if (!this.element) { return position };
if (this.popover.framed) { return super._updatePosition(position) };
const el = this.element;
let {width, height, left, top, scale} = position;
scale ??= 1.0;
const computedStyle = getComputedStyle(el);
let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0;
let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity;
let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0;
let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity;
let bounds = el.getBoundingClientRect();
const {clientWidth, clientHeight} = document.documentElement;
// Explicit width
const autoWidth = width === `auto`;
if ( !autoWidth ) {
const targetWidth = Number(width || bounds.width);
minWidth = parseInt(minWidth) || 0;
maxWidth = parseInt(maxWidth) || (clientWidth / scale);
width = Math.clamp(targetWidth, minWidth, maxWidth);
}
// Explicit height
const autoHeight = height === `auto`;
if ( !autoHeight ) {
const targetHeight = Number(height || bounds.height);
minHeight = parseInt(minHeight) || 0;
maxHeight = parseInt(maxHeight) || (clientHeight / scale);
height = Math.clamp(targetHeight, minHeight, maxHeight);
}
// Implicit height
if ( autoHeight ) {
Object.assign(el.style, {width: `${width}px`, height: ``});
bounds = el.getBoundingClientRect();
height = bounds.height;
}
// Implicit width
if ( autoWidth ) {
Object.assign(el.style, {height: `${height}px`, width: ``});
bounds = el.getBoundingClientRect();
width = bounds.width;
}
// Left Offset
const scaledWidth = width * scale;
const targetLeft = left ?? (this.popover.x - Math.floor( scaledWidth / 2 ));
const maxLeft = Math.max(clientWidth - scaledWidth, 0);
left = Math.clamp(targetLeft, 0, maxLeft);
// Top Offset
const scaledHeight = height * scale;
const targetTop = top ?? (this.popover.y - scaledHeight);
const maxTop = Math.max(clientHeight - scaledHeight, 0);
top = Math.clamp(targetTop, 0, maxTop);
// Scale
scale ??= 1.0;
return {
width: autoWidth ? `auto` : width,
height: autoHeight ? `auto` : height,
left,
top,
scale,
};
};
/**
* This is here in order allow things that are not this Application
* to provide / augment the context data for the lifecycle of the app.
*/
async _prepareContext(_partId, _context, options) {
const context = {};
Hooks.callAll(`prepare${this.constructor.name}Context`, context, options);
Hooks.callAll(`prepare${this.popover.managerId}Context`, context, options);
return context;
};
};
return GenericRipCryptPopover;
};

View file

@ -42,3 +42,24 @@ export async function deleteItemFromElement(target) {
const item = await fromUuid(itemId); const item = await fromUuid(itemId);
item.delete(); item.delete();
}; };
/**
* Updates a document using the UUID, expects there to be the following
* dataset attributes:
* - "data-foreign-uuid" : The UUID of the document to update
* - "data-foreign-name" : The dot-separated path of the value to update
*
* @param {Event} event
*/
export async function updateForeignDocumentFromEvent(event) {
const target = event.currentTarget;
const data = target.dataset;
const document = await fromUuid(data.foreignUuid);
let value = target.value;
switch (target.type) {
case `checkbox`: value = target.checked; break;
};
await document?.update({ [data.foreignName]: value });
};

View file

@ -1,4 +1,5 @@
// App imports // App imports
import { AmmoTracker } from "./Apps/popovers/AmmoTracker.mjs";
import { CombinedHeroSheet } from "./Apps/ActorSheets/CombinedHeroSheet.mjs"; import { CombinedHeroSheet } from "./Apps/ActorSheets/CombinedHeroSheet.mjs";
import { DicePool } from "./Apps/DicePool.mjs"; import { DicePool } from "./Apps/DicePool.mjs";
import { HeroSkillsCardV1 } from "./Apps/ActorSheets/HeroSkillsCardV1.mjs"; import { HeroSkillsCardV1 } from "./Apps/ActorSheets/HeroSkillsCardV1.mjs";
@ -10,6 +11,9 @@ import { distanceBetweenFates, nextFate, previousFate } from "./utils/fates.mjs"
import { documentSorter } from "./consts.mjs"; import { documentSorter } from "./consts.mjs";
import { rankToInteger } from "./utils/rank.mjs"; import { rankToInteger } from "./utils/rank.mjs";
// Misc Imports
import { ItemFlags } from "./flags/item.mjs";
const { deepFreeze } = foundry.utils; const { deepFreeze } = foundry.utils;
Object.defineProperty( Object.defineProperty(
@ -18,6 +22,7 @@ Object.defineProperty(
{ {
value: deepFreeze({ value: deepFreeze({
Apps: { Apps: {
AmmoTracker,
DicePool, DicePool,
CombinedHeroSheet, CombinedHeroSheet,
HeroSummaryCardV1, HeroSummaryCardV1,
@ -31,6 +36,7 @@ Object.defineProperty(
previousFate, previousFate,
rankToInteger, rankToInteger,
}, },
ItemFlags,
}), }),
writable: false, writable: false,
}, },

View file

@ -54,3 +54,14 @@ export function documentSorter(a, b) {
}; };
return Math.sign(a.name.localeCompare(b.name)); return Math.sign(a.name.localeCompare(b.name));
}; };
// MARK: getTooltipDelay
/**
* Retrieves the configured minimum delay between the user hovering an element
* and a tooltip showing up. Used for the pseudo-tooltip Applications that I use.
*
* @returns The number of milliseconds for the timeout
*/
export function getTooltipDelay() {
return game.tooltip.constructor.TOOLTIP_ACTIVATION_MS;
};

4
module/flags/item.mjs Normal file
View file

@ -0,0 +1,4 @@
export const ItemFlags = Object.freeze({
/** The boolean value to indicate if an item is considered favourited/starred or not */
FAVOURITE: `favourited`,
});

View file

@ -37,6 +37,7 @@ export const gameTerms = Object.preventExtensions({
}), }),
/** The types of items that contribute to the gear limit */ /** The types of items that contribute to the gear limit */
gearItemTypes: new Set([ gearItemTypes: new Set([
`ammo`,
`armour`, `armour`,
`weapon`, `weapon`,
`shield`, `shield`,

View file

@ -35,6 +35,9 @@ import { registerMetaSettings } from "../settings/metaSettings.mjs";
import { registerUserSettings } from "../settings/userSettings.mjs"; import { registerUserSettings } from "../settings/userSettings.mjs";
import { registerWorldSettings } from "../settings/worldSettings.mjs"; import { registerWorldSettings } from "../settings/worldSettings.mjs";
const { Items, Actors } = foundry.documents.collections;
const { ItemSheet, ActorSheet } = foundry.appv1.sheets;
Hooks.once(`init`, () => { Hooks.once(`init`, () => {
Logger.log(`Initializing`); Logger.log(`Initializing`);
@ -70,10 +73,8 @@ Hooks.once(`init`, () => {
// #region Sheets // #region Sheets
// Unregister core sheets // Unregister core sheets
/* eslint-disable no-undef */
Items.unregisterSheet(`core`, ItemSheet); Items.unregisterSheet(`core`, ItemSheet);
Actors.unregisterSheet(`core`, ActorSheet); Actors.unregisterSheet(`core`, ActorSheet);
/* eslint-enabled no-undef */
// #region Actors // #region Actors
Actors.registerSheet(game.system.id, CombinedHeroSheet, { Actors.registerSheet(game.system.id, CombinedHeroSheet, {

View file

@ -0,0 +1,184 @@
import { getTooltipDelay } from "../consts.mjs";
import { Logger } from "./Logger.mjs";
export class PopoverEventManager {
#options;
#id;
get id() {
return this.#id;
};
/** @type {Map<string, PopoverEventManager>} */
static #existing = new Map();
/**
* @param {HTMLElement} element The element to attach the listeners to.
* @param {GenericPopoverMixin} popoverClass The class reference that represents the popover app
*/
constructor(id, element, popoverClass, options = {}) {
id = `${id}-${popoverClass.name}`;
this.#id = id;
if (PopoverEventManager.#existing.has(id)) {
const manager = PopoverEventManager.#existing.get(id);
manager.#addListeners(element);
return manager;
};
options.managerId = id;
options.locked ??= false;
options.lockable ??= true;
this.#options = options;
this.#element = element;
this.#class = popoverClass;
this.#addListeners(element);
PopoverEventManager.#existing.set(id, this);
};
/**
* @param {HTMLElement} element
*/
#addListeners(element) {
element.addEventListener(`pointerenter`, this.#pointerEnterHandler.bind(this));
element.addEventListener(`pointerout`, this.#pointerOutHandler.bind(this));
element.addEventListener(`click`, this.#clickHandler.bind(this));
if (this.#options.lockable) {
element.addEventListener(`pointerup`, this.#pointerUpHandler.bind(this));
};
};
destroy() {
this.close();
this.#element.removeEventListener(`pointerenter`, this.#pointerEnterHandler);
this.#element.removeEventListener(`pointerout`, this.#pointerOutHandler);
this.#element.removeEventListener(`click`, this.#clickHandler);
if (this.#options.lockable) {
this.#element.removeEventListener(`pointerup`, this.#pointerUpHandler);
};
this.#stopOpen();
this.#stopClose();
};
close() {
this.#frameless?.close({ force: true });
this.#framed?.close({ force: true });
};
#stopOpen() {
if (this.#openTimeout != null) {
clearTimeout(this.#openTimeout);
this.#openTimeout = null;
};
};
#stopClose() {
if (this.#closeTimeout != null) {
clearTimeout(this.#closeTimeout);
this.#closeTimeout = null;
}
};
get rendered() {
return Boolean(this.#frameless?.rendered || this.#framed?.rendered);
};
render(options) {
if (this.#framed?.rendered) {
this.#framed.render(options);
};
if (this.#frameless?.rendered) {
this.#frameless.render(options);
};
};
#element;
#class;
#openTimeout = null;
#closeTimeout = null;
#frameless;
#framed;
#construct(options) {
options.popover ??= {};
options.popover.managerId = this.#id;
return new this.#class(options);
};
#clickHandler() {
Logger.debug(`click event handler`);
// Cleanup for the frameless lifecycle
this.#stopOpen();
this.#stopClose();
this.#frameless?.close({ force: true });
if (!this.#framed) {
this.#framed = this.#construct({ popover: { ...this.#options, framed: true } });
}
this.#framed?.render({ force: true });
};
#pointerEnterHandler(event) {
this.#stopClose();
const pos = event.target.getBoundingClientRect();
const x = pos.x + Math.floor(pos.width / 2);
const y = pos.y;
this.#openTimeout = setTimeout(
() => {
this.#openTimeout = null;
// When we have the framed version rendered, we might as well just focus
// it instead of rendering a new application
if (this.#framed?.rendered) {
this.#framed.bringToFront();
return;
};
// When the frameless is already rendered, we should just move it to the
// new location instead of spawning a new one
if (this.#frameless?.rendered) {
const { width, height } = this.#frameless.element.getBoundingClientRect();
const top = y - height;
const left = x - Math.floor(width / 2);
this.#frameless.setPosition({ left, top });
return;
}
this.#frameless = this.#construct({
popover: {
...this.#options,
framed: false,
x, y,
},
});
this.#frameless?.render({ force: true });
},
getTooltipDelay(),
);
};
#pointerOutHandler() {
this.#stopOpen();
this.#closeTimeout = setTimeout(
() => {
this.#closeTimeout = null;
this.#frameless?.close();
},
getTooltipDelay(),
);
};
#pointerUpHandler(event) {
if (event.button !== 1 || !this.#frameless?.rendered || Tour.tourInProgress) { return };
event.preventDefault();
this.#frameless.toggleLock();
};
};

View file

@ -5,7 +5,7 @@
"version": "0.0.1", "version": "0.0.1",
"compatibility": { "compatibility": {
"minimum": 13, "minimum": 13,
"verified": 13, "verified": 13.339,
"maximum": 13 "maximum": 13
}, },
"authors": [ "authors": [

View file

@ -1,7 +1,4 @@
<div> <div>
{{!-- This is here to prevent height collapsing --}}
&ZeroWidthSpace;
{{#if meta.editable}} {{#if meta.editable}}
<button <button
type="button" type="button"
@ -18,5 +15,8 @@
var:fill="currentColor" var:fill="currentColor"
></rc-icon> ></rc-icon>
</button> </button>
{{else}}
{{!-- This is here to prevent height collapsing --}}
&ZeroWidthSpace;
{{/if}} {{/if}}
</div> </div>

View file

@ -1,7 +1,4 @@
<div> <div>
{{!-- This is here to prevent height collapsing --}}
&ZeroWidthSpace;
{{#if meta.editable}} {{#if meta.editable}}
<button <button
type="button" type="button"
@ -18,6 +15,9 @@
var:fill="currentColor" var:fill="currentColor"
></rc-icon> ></rc-icon>
</button> </button>
{{else}}
{{!-- This is here to prevent height collapsing --}}
&ZeroWidthSpace;
{{/if}} {{/if}}
</div> </div>

View file

@ -104,7 +104,13 @@
{{/each}} {{/each}}
</ol> </ol>
<div class="ammo half-pill"> <div class="ammo pill with-icon">
<rc-icon
class="ammo-info-icon"
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
></rc-icon>
<div class="label"> <div class="label">
{{ rc-i18n "RipCrypt.common.ammo"}} {{ rc-i18n "RipCrypt.common.ammo"}}
</div> </div>
@ -112,10 +118,35 @@
{{ ammo }} {{ ammo }}
</div> </div>
</div> </div>
{{#each favouriteAmmo as | data |}}
{{#if data}}
<div
class="pill fav-ammo"
data-item-id="{{data.uuid}}"
>
<div>
{{data.name}}
</div>
<input
type="number"
id="{{@root.meta.idp}}-{{data.uuid}}-quantity"
value="{{data.quantity}}"
aria-label="Quantity of {{data.name}}"
data-foreign-update-on="change,blur"
data-foreign-uuid="{{data.uuid}}"
data-foreign-name="system.quantity"
>
</div>
{{else}}
<div class="pill" style="opacity: 0.5; background: var(--alt-row-background);">
{{ rc-i18n "RipCrypt.Apps.starred-ammo-placeholder" }}
</div>
{{/if}}
{{/each}}
{{!-- * Currencies --}} {{!-- * Currencies --}}
<div class="currencies"> <div class="currencies">
<div class="currency half-pill"> <div class="currency pill">
<label for="{{meta.idp}}-gold" > <label for="{{meta.idp}}-gold" >
{{ rc-i18n "RipCrypt.common.currency.gold"}} {{ rc-i18n "RipCrypt.common.currency.gold"}}
</label> </label>
@ -126,7 +157,7 @@
value="0" value="0"
> >
</div> </div>
<div class="currency half-pill"> <div class="currency pill">
<label for="{{meta.idp}}-silver" > <label for="{{meta.idp}}-silver" >
{{ rc-i18n "RipCrypt.common.currency.silver"}} {{ rc-i18n "RipCrypt.common.currency.silver"}}
</label> </label>
@ -137,7 +168,7 @@
value="0" value="0"
> >
</div> </div>
<div class="currency half-pill"> <div class="currency pill">
<label for="{{meta.idp}}-copper" > <label for="{{meta.idp}}-copper" >
{{ rc-i18n "RipCrypt.common.currency.copper"}} {{ rc-i18n "RipCrypt.common.currency.copper"}}
</label> </label>

View file

@ -8,6 +8,7 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(13, minmax(0, 1fr)); grid-template-rows: repeat(13, minmax(0, 1fr));
column-gap: var(--col-gap); column-gap: var(--col-gap);
row-gap: var(--row-gap);
background: var(--base-background); background: var(--base-background);
color: var(--base-text); color: var(--base-text);
@ -35,6 +36,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-radius: 999px;
} }
.skill-list { .skill-list {
display: grid; display: grid;
@ -106,18 +108,30 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.half-pill { .pill {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
align-items: center; align-items: center;
background: var(--section-header-background); background: var(--section-header-background);
border-radius: 0 999px 999px 0; border-radius: 999px;
color: var(--section-header-text); color: var(--section-header-text);
padding: 2px 0 2px 4px;
--input-background: var(--base-background); --input-background: var(--base-background);
--input-text: var(--base-text); --input-text: var(--base-text);
.input { &.with-icon {
margin: 2px; grid-template-columns: min-content minmax(0, 1.5fr) minmax(0, 1fr);
gap: 4px;
}
label, .label {
padding: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
input, .input {
margin: 0 2px 0 0;
border-radius: 999px; border-radius: 999px;
text-align: center; text-align: center;
} }

View file

@ -6,3 +6,5 @@
@import url("./HeroSummaryCardV1/style.css"); @import url("./HeroSummaryCardV1/style.css");
@import url("./HeroSkillsCardV1/style.css"); @import url("./HeroSkillsCardV1/style.css");
@import url("./RichEditor/style.css"); @import url("./RichEditor/style.css");
@import url("./popovers/AmmoTracker/style.css");

View file

@ -0,0 +1,53 @@
<div data-tooltip-direction="RIGHT">
{{log @root}}
{{#if ammos}}
<ul>
{{#each ammos as | data |}}
<li class="ammo" data-item-id="{{data.ammo.uuid}}">
<span class="name">{{ data.ammo.name }}</span>
<input
type="number"
id="{{@root.meta.idp}}-{{data.ammo.uuid}}-quantity"
class="value"
value="{{ data.ammo.system.quantity }}"
data-foreign-update-on="change,blur"
data-foreign-uuid="{{data.ammo.uuid}}"
data-foreign-name="system.quantity"
>
{{#if data.favourite}}
<button
type="button"
class="icon"
data-action="unfavourite"
aria-label="{{ rc-i18n "RipCrypt.Apps.AmmoTracker.unstar-button" name=data.ammo.name }}"
data-tooltip="{{ rc-i18n "RipCrypt.Apps.AmmoTracker.unstar-button-tooltip" name=data.ammo.name }}"
>
<rc-icon
name="icons/star"
var:size="1rem"
></rc-icon>
</button>
{{else}}
<button
type="button"
class="icon"
{{ disabled @root.atFavouriteLimit }}
data-action="favourite"
aria-label="{{ rc-i18n "RipCrypt.Apps.AmmoTracker.star-button" name=data.ammo.name }}"
data-tooltip="{{ rc-i18n "RipCrypt.Apps.AmmoTracker.star-button-tooltip" name=data.ammo.name }}"
>
<rc-icon
name="icons/star-empty"
var:size="1rem"
></rc-icon>
</button>
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
<span class="placeholder">
{{ rc-i18n "RipCrypt.Apps.no-ammo" }}
</span>
{{/if}}
</div>

View file

@ -0,0 +1,47 @@
.ripcrypt--AmmoTracker.ripcrypt--AmmoTracker.ripcrypt--AmmoTracker {
color: var(--popover-text);
background: var(--popover-background);
padding: 4px;
--row-gap: 4px;
--button-text: var(--header-text);
--button-background: var(--header-background);
ul {
display: flex;
flex-direction: column;
row-gap: var(--row-gap);
> li {
padding: 4px 8px;
border-radius: 999px;
&:nth-child(even) {
color: var(--popover-alt-row-text);
background: var(--popover-alt-row-background);
--input-text: var(--popover-text);
--input-background: var(--popover-background);
--button-text: unset;
--button-background: unset;
}
}
}
.ammo {
display: grid;
grid-template-columns: 130px 50px min-content;
grid-template-rows: min-content;
align-items: center;
gap: 8px;
.name {
flex-grow: 1;
justify-self: left;
}
}
input {
text-align: center;
border-radius: 999px;
}
}

View file

@ -2,6 +2,8 @@
@import url("./vars.css"); @import url("./vars.css");
@import url("./popover.css");
@import url("./elements/button.css"); @import url("./elements/button.css");
@import url("./elements/input.css"); @import url("./elements/input.css");
@import url("./elements/lists.css"); @import url("./elements/lists.css");
@ -27,6 +29,7 @@
/* height: 270px; */ /* height: 270px; */
width: 680px; width: 680px;
--col-gap: 2px; --col-gap: 2px;
--row-gap: 4px;
} }
label, input, select { label, input, select {

View file

@ -1,6 +1,9 @@
.ripcrypt.hud button, .ripcrypt:where(.popover.frameless, .hud) button,
.ripcrypt > .window-content button { .ripcrypt > .window-content button {
all: revert; all: revert;
display: flex;
justify-content: center;
align-items: center;
outline: none; outline: none;
border: none; border: none;
padding: 2px 4px; padding: 2px 4px;

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless,
.ripcrypt.hud, .ripcrypt.hud,
.ripcrypt > .window-content { .ripcrypt > .window-content {
input, .input { input, .input {

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless,
.ripcrypt.hud, .ripcrypt.hud,
.ripcrypt > .window-content { .ripcrypt > .window-content {
ol { ol {
@ -35,6 +36,9 @@
} }
ul { ul {
margin: 0;
padding: 0;
> li { > li {
margin: 0; margin: 0;
} }

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless p,
.ripcrypt.hud p, .ripcrypt.hud p,
.ripcrypt > .window-content p { .ripcrypt > .window-content p {
&.warning { &.warning {

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless .pill-bar,
.ripcrypt.hud .pill-bar, .ripcrypt.hud .pill-bar,
.ripcrypt > .window-content .pill-bar { .ripcrypt > .window-content .pill-bar {
display: flex; display: flex;

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless select,
.ripcrypt.hud select, .ripcrypt.hud select,
.ripcrypt > .window-content select { .ripcrypt > .window-content select {
all: revert; all: revert;

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless span,
.ripcrypt.hud span, .ripcrypt.hud span,
.ripcrypt > .window-content span { .ripcrypt > .window-content span {
&.small { &.small {

View file

@ -1,3 +1,4 @@
.ripcrypt.popover.frameless table,
.ripcrypt.hud table, .ripcrypt.hud table,
.ripcrypt > .window-content table { .ripcrypt > .window-content table {
all: revert; all: revert;

17
templates/css/popover.css Normal file
View file

@ -0,0 +1,17 @@
.ripcrypt.popover {
box-sizing: border-box;
&.frameless {
border-width: 2px;
border-style: solid;
border-color: transparent;
border-radius: 4px;
position: absolute;
z-index: calc(var(--z-index-tooltip) - 5);
transform-origin: top left;
&.locked {
border-color: var(--accent-3);
}
}
}

View file

@ -27,6 +27,16 @@
--col-gap: 2px; --col-gap: 2px;
--row-gap: 0px; --row-gap: 0px;
/* Popover Variables */
--popover-text: var(--base-text);
--popover-background: var(--base-background);
--popover-alt-row-text: var(--alt-row-text);
--popover-alt-row-background: var(--alt-row-background);
--popover-header-text: var(--header-text);
--popover-header-background: var(--header-background);
/* Additional Variables */ /* Additional Variables */
--string-tags-border: inherit; --string-tags-border: inherit;
--string-tags-tag-background: inherit; --string-tags-tag-background: inherit;