daggerheart/module/applications/ui/countdowns.mjs

388 lines
15 KiB
JavaScript

import { waitForDiceSoNice } from '../../helpers/utils.mjs';
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
/**
* A UI element which displays the countdowns in this world.
*
* @extends ApplicationV2
* @mixes HandlebarsApplication
*/
export default class DhCountdowns extends HandlebarsApplicationMixin(ApplicationV2) {
previousCountdownData = null;
constructor(options = {}) {
super(options);
this.previousCountdownData =
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns).countdowns;
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: 'countdowns',
tag: 'div',
classes: ['daggerheart', 'dh-style', 'countdowns'],
window: {
icon: 'fa-solid fa-clock-rotate-left',
frame: false,
title: 'DAGGERHEART.UI.Countdowns.title',
positioned: false,
resizable: false,
minimizable: false
},
actions: {
toggleViewMode: DhCountdowns.#onToggleViewMode,
toggleCountdownTypes: DhCountdowns.#onToggleCountdownTypes,
editCountdowns: DhCountdowns.#onEditCountdowns,
loopCountdown: DhCountdowns.#onLoopCountdown,
decreaseCountdown: (_, target) => this.editCountdown(false, target),
increaseCountdown: (_, target) => this.editCountdown(true, target)
},
position: {
width: 400,
height: 222,
top: 50
}
};
/** @override */
static PARTS = {
resources: {
root: true,
template: 'systems/daggerheart/templates/ui/countdowns/countdowns-view.hbs'
}
};
/**
* Returns all visible countdown types
* @returns {string[]}
*/
get visibleCountdownTypes() {
const { encounter, narrative } = CONFIG.DH.GENERAL.countdownTypes;
return game.user.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownTypeModes)
?? [encounter.id, narrative.id];
}
async _renderFrame(options) {
const frame = await super._renderFrame(options);
const iconOnly =
game.user.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode) ===
CONFIG.DH.GENERAL.countdownAppMode.iconOnly;
if (iconOnly) frame.classList.add('icon-only');
else frame.classList.remove('icon-only');
return frame;
}
/** @inheritdoc */
async _onFirstRender(context, options) {
await super._onFirstRender(context, options);
this._createContextMenu(this._getCountdownContextOptions, '.countdown-container[data-countdown]', {
parentClassHooks: false, fixed: true
});
}
/** @inheritdoc */
async _onRender(context, options) {
await super._onRender(context, options);
/* Handle rendering/hiding/positioning of the countdown UI */
this.element.hidden = !game.user.isGM && this.#getCountdowns().length === 0;
if (options?.force) {
document.getElementById('ui-right-column-1')?.appendChild(this.element);
}
this.previousCountdownData = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns)
.countdowns;
/* Handle animations to draw attention to countdown values changing */
const typesToAnimate = new Set();
for (const countdownKey of options.animate ?? []) {
const shimmerAnimation = [
{ backgroundPositionX: '98%' },
{ backgroundPositionX: '0%' }
];
const shimmerTiming = {
duration: 1000,
iterations: 1
};
const element = this.element.querySelector(`.countdown-container[data-countdown="${countdownKey}"]`);
element?.animate(shimmerAnimation, shimmerTiming);
const countdown = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns)
.countdowns[countdownKey];
if (!this.visibleCountdownTypes.includes(countdown?.type))
typesToAnimate.add(countdown.type);
}
for (const type of typesToAnimate) {
const pulseAnimation = [
{ boxShadow: '0 0 1px 1px var(--golden)' },
{ boxShadow: '0 0 2px 2px var(--golden)' }
];
const pulseTiming = {
duration: 1000,
iterations: 3
};
const element = this.element.querySelector(`.header-type-toggles .header-type[data-type="${type}"]`);
element?.animate(pulseAnimation, pulseTiming);
}
}
/** Returns countdown data filtered by ownership */
#getCountdowns() {
const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
const values = Object.entries(setting.countdowns).map(([key, countdown]) => ({
key,
countdown,
ownership: countdown.getUserLevel(game.user)
}));
return values.filter(v => v.ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE);
}
_getCountdownData() {
return this.#getCountdowns().reduce((acc, { key, countdown, ownership }) => {
const playersWithAccess = game.users.reduce((acc, user) => {
const ownership = countdown.getUserLevel(user);
if (!user.isGM && ownership && ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE) {
acc.push(user);
}
return acc;
}, []);
const nonGmPlayers = game.users.filter(x => !x.isGM);
const countdownEditable = game.user.isGM || ownership === CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
const isLooping = countdown.progress.looping !== CONFIG.DH.GENERAL.countdownLoopingTypes.noLooping;
const loopTooltip = isLooping
? countdown.progress.looping === CONFIG.DH.GENERAL.countdownLoopingTypes.increasing.id
? 'DAGGERHEART.UI.Countdowns.increasingLoop'
: countdown.progress.looping === CONFIG.DH.GENERAL.countdownLoopingTypes.decreasing.id
? 'DAGGERHEART.UI.Countdowns.decreasingLoop'
: 'DAGGERHEART.UI.Countdowns.loop'
: null;
const loopDisabled =
!countdownEditable ||
(isLooping && (countdown.progress.current > 0 || countdown.progress.start === '0'));
acc[countdown.type][key] = {
...countdown,
editable: countdownEditable,
noPlayerAccess: nonGmPlayers.length && playersWithAccess.length === 0,
shouldLoop: isLooping && countdown.progress.current === 0 && countdown.progress.start > 0,
loopDisabled: isLooping ? loopDisabled : null,
loopTooltip: isLooping && game.i18n.localize(loopTooltip)
};
return acc;
}, Object.keys(CONFIG.DH.GENERAL.countdownTypes).reduce((acc, key) => {
acc[key] = {};
return acc;
}, {}));
}
/** @override */
async _prepareContext(options) {
const context = await super._prepareContext(options);
context.isGM = game.user.isGM;
context.iconOnly =
game.user.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode)
=== CONFIG.DH.GENERAL.countdownAppMode.iconOnly;
context.userCountdownTypes = this.visibleCountdownTypes;
context.typeToggles =
Object.values(CONFIG.DH.GENERAL.countdownTypes).map(type => ({
type: type.id,
label: game.i18n.localize(type.shortLabel),
active: context.userCountdownTypes.includes(type.id)
}));
context.countdowns = this._getCountdownData();
context.countdownTypesWithVisibleEntries = this.#getCountdowns().reduce((acc, data) => {
if (context.userCountdownTypes.includes(data.countdown.type) && !acc.includes(data.countdown.type))
acc.push(data.countdown.type);
return acc;
}, []);
return context;
}
static canPerformEdit() {
if (game.user.isGM) return true;
const noGM = !game.users.find(x => x.isGM && x.active);
if (noGM) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.gmRequired'));
return false;
}
return true;
}
/** @this {DhCountdowns} */
static async #onToggleViewMode() {
const currentMode = game.user.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode);
const appMode = CONFIG.DH.GENERAL.countdownAppMode;
const newMode = currentMode === appMode.textIcon ? appMode.iconOnly : appMode.textIcon;
await game.user.setFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode, newMode);
if (newMode === appMode.iconOnly) this.element.classList.add('icon-only');
else this.element.classList.remove('icon-only');
this.render();
}
/** @this {DhCountdowns} */
static async #onToggleCountdownTypes(event, target) {
const currentTypes = this.visibleCountdownTypes;
const { type } = target.dataset;
const newTypes = event.shiftKey ?
[type] :
currentTypes.includes(type) ? currentTypes.filter(x => x !== type) : [...currentTypes, type];
await game.user.setFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownTypeModes, newTypes);
this.render();
}
/** @this {DhCountdowns} */
static async #onEditCountdowns() {
new game.system.api.applications.ui.CountdownEdit().render(true);
}
/** @this {DhCountdowns} */
static async #onLoopCountdown(_, target) {
if (!DhCountdowns.canPerformEdit()) return;
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
const countdownId = target.closest('[data-countdown]').dataset.countdown;
const countdown = settings.countdowns[countdownId];
let progressMax = countdown.progress.start;
let message = null;
if (countdown.progress.startFormula) {
const roll = await new Roll(countdown.progress.startFormula).evaluate();
progressMax = roll.total;
message = await roll.toMessage();
}
const newMax =
countdown.progress.looping === CONFIG.DH.GENERAL.countdownLoopingTypes.increasing.id
? Number(progressMax) + 1
: countdown.progress.looping === CONFIG.DH.GENERAL.countdownLoopingTypes.decreasing.id
? Math.max(Number(progressMax) - 1, 0)
: progressMax;
await waitForDiceSoNice(message);
await settings.updateSource({
[`countdowns.${countdownId}.progress`]: {
current: newMax,
start: newMax
}
});
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
refreshType: RefreshType.Countdown
});
}
static async editCountdown(increase, target) {
if (!DhCountdowns.canPerformEdit()) return;
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
const countdownId = target.closest('[data-countdown]').dataset.countdown;
const countdown = settings.countdowns[countdownId];
const newCurrent = increase
? Math.min(countdown.progress.current + 1, countdown.progress.start)
: Math.max(countdown.progress.current - 1, 0);
await settings.updateSource({ [`countdowns.${countdownId}.progress.current`]: newCurrent });
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
refreshType: RefreshType.Countdown
});
}
static async gmSetSetting(data) {
await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns, data);
game.socket.emit(`system.${CONFIG.DH.id}`, {
action: socketEvent.Refresh,
data: { refreshType: RefreshType.Countdown }
});
}
async close(options) {
/* Opt out of Foundry's standard behavior of closing all application windows marked as UI when Escape is pressed */
if (options.closeKey) return;
return super.close(options);
}
/**
* Sends updates of the countdowns to the GM player. Since this is asynchronous, be sure to
* update all the countdowns at the same time.
*
* @param {...(string | { type: string; undo?: boolean })} progressTypes Countdowns to be updated
*/
static async updateCountdowns(...progressTypes) {
progressTypes = progressTypes.map(p => typeof p === 'string' ? { type: p } : p);
const { countdownAutomation } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation);
if (!countdownAutomation) return;
const countdownSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
const updatedCountdowns = Object.keys(countdownSetting.countdowns).reduce((acc, key) => {
const countdown = countdownSetting.countdowns[key];
const progressData = progressTypes.find(x => x.type === countdown.progress.type);
if (progressData && countdown.progress.current > 0) {
acc[key] = { value: progressData.undo ? 1 : -1 };
}
return acc;
}, {});
const countdownData = countdownSetting.toObject();
const settings = {
...countdownData,
countdowns: Object.keys(countdownData.countdowns).reduce((acc, key) => {
const countdown = foundry.utils.deepClone(countdownData.countdowns[key]);
if (updatedCountdowns[key]) {
countdown.progress.current += updatedCountdowns[key].value;
}
acc[key] = countdown;
return acc;
}, {})
};
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
refreshType: RefreshType.Countdown
});
}
/**
* @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]}
*/
_getCountdownContextOptions() {
/** @param {HTMLElement} element */
const getCountdownFromElement = element => {
const id = element.closest('[data-countdown]').dataset.countdown;
if (!id) return null;
const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
return setting.countdowns[id ?? ''];
}
return [
{
label: 'CONTROLS.CommonDelete',
icon: 'fa-solid fa-trash',
visible: element => {
return getCountdownFromElement(element)?.isOwner;
},
onClick: (_, target) => {
getCountdownFromElement(target)?.delete();
}
}
];
}
}