From 8f8cda75744c17f94daaa02e9ed148207a8ff3d5 Mon Sep 17 00:00:00 2001 From: cirrahn <9784519+cirrahn@users.noreply.github.com> Date: Sun, 16 Jun 2024 15:48:11 +0100 Subject: [PATCH] feat: add custom effect manager This allows a GM user to customize their FX. Features: - May define macros for items by "name" or "Lancer ID" - Macros overwrite module defaults - Extensive drag-drop support - Folder system - "Execute Macro" preview button - "Export/Import" buttons to transfer state between worlds - Effects Manger respects system theme choice --- .github/workflows/release.yml | 2 +- css/effects-manager.css | 87 +++ css/helpers.css | 165 +++++ lang/en.json | 49 +- module.json | 4 + scripts/WeaponFX.js | 2 + scripts/api.js | 28 +- scripts/consts.js | 2 + scripts/effectManager/app.js | 680 ++++++++++++++++++ scripts/effectManager/consts.js | 4 + scripts/effectManager/effectManager.js | 47 ++ scripts/effectManager/models.js | 102 +++ scripts/effectResolver/effectResolver.js | 80 ++- scripts/flow/flowListener.js | 2 +- scripts/settings.js | 1 + scripts/utils.js | 30 + templates/effectManager/effect-manager.hbs | 116 +++ .../effectManager/partial/effect-row.hbs | 124 ++++ .../effectManager/partial/folder-row.hbs | 67 ++ tours/effect-manager.json | 34 + 20 files changed, 1590 insertions(+), 36 deletions(-) create mode 100644 css/effects-manager.css create mode 100644 css/helpers.css create mode 100644 scripts/effectManager/app.js create mode 100644 scripts/effectManager/consts.js create mode 100644 scripts/effectManager/effectManager.js create mode 100644 scripts/effectManager/models.js create mode 100644 templates/effectManager/effect-manager.hbs create mode 100644 templates/effectManager/partial/effect-row.hbs create mode 100644 templates/effectManager/partial/folder-row.hbs create mode 100644 tours/effect-manager.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 101afd6..5dc66c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: run: | npm ci npm run build - zip -r ./module.zip module.json icons/ lang/ packs/ scripts/ sprites/ soundfx/ video/ + zip -r ./module.zip module.json icons/ lang/ packs/ scripts/ sprites/ soundfx/ video/ templates/ css/ tours/ # Create a release for this specific version - name: Update Release with Files diff --git a/css/effects-manager.css b/css/effects-manager.css new file mode 100644 index 0000000..f033931 --- /dev/null +++ b/css/effects-manager.css @@ -0,0 +1,87 @@ +.lwfx-effects-manager { + color: var(--dark-text); + background: var(--background-color); +} + +/* -------------------------------------------- */ + +.lwfx-effects-manager__vr { + height: 100%; + border: 1px solid var(--primary-color, fuschia); +} + +.lwfx-effects-manager__hr { + border-color: var(--primary-color, fuschia); +} + +/* -------------------------------------------- */ + +.lwfx-effects-manager__row { + margin-top: calc(0.5 * var(--lwfx-spacer)); + margin-bottom: calc(0.5 * var(--lwfx-spacer)); +} + +/* -------------------------------------------- */ + +.lwfx-effects-manager__folder, +.lwfx-effects-manager__uncategorized { + border: 2px solid var(--primary-color); + border-top: 0; +} + +.lancer.app input.lwfx-effects-manager__ipt-folder-name[type="text"] { + height: calc(2.25rem + 2px); + color: var(--light-text, fuschia); + font-size: 15px; + font-weight: 700; + text-transform: uppercase; + border: 0; +} + +.lancer.app input.lwfx-effects-manager__ipt-folder-name[type="text"]::placeholder { + color: color-mix(in srgb, var(--light-text), var(--dark-text) 27%); + font-style: italic; + font-weight: initial; +} + +/* -------------------------------------------- */ + +.lwfx-effects-manager__effects-uncategorized-empty { + /* Pad to a comfortable height for drag-drop */ + min-height: 100px; +} + +/* -------------------------------------------- */ + +.lwfx-effects-manager__effect { + padding: var(--lwfx-spacer) var(--lwfx-spacer) var(--lwfx-spacer) 0; + margin-left: var(--lwfx-spacer); + margin-right: var(--lwfx-spacer); +} + +.lwfx-effects-manager__grip { + cursor: move; + width: calc(2 * var(--lwfx-spacer)); +} + +.lwfx-effects-manager__grip-handle { + display: none; +} + +.lwfx-effects-manager__effect:hover .lwfx-effects-manager__grip-handle { + display: block; +} + +.lwfx-effects-manager__effect-ipt[type="text"], +.lwfx-effects-manager__effect-sel { + width: calc(100% - calc(4 * var(--lwfx-spacer))); + margin: calc(0.5 * var(--lwfx-spacer)) calc(2 * var(--lwfx-spacer)); + background-color: var(--tooltip-background); + color: var(--dark-text); + height: var(--form-field-height); +} + +.lwfx-effects-manager__icon-duplicate { + color: var(--error-color); + filter: contrast(2); +} diff --git a/css/helpers.css b/css/helpers.css new file mode 100644 index 0000000..6594a59 --- /dev/null +++ b/css/helpers.css @@ -0,0 +1,165 @@ +/* TODO(v12) remove some of these in favor of v2 styling */ + +:root { + --lwfx-spacer: 0.5rem; + --lwfx-top-dogear-path: polygon(var(--lwfx-spacer) 0, 100% 0, 100% 100%, 0 100%, 0 var(--lwfx-spacer)); + --lwfx-double-dogear-path: polygon(var(--lwfx-spacer) 0, 100% 0, 100% calc(100% - var(--lwfx-spacer)), calc(100% - var(--lwfx-spacer)) 100%, 0 100%, 0 var(--lwfx-spacer)); +} + +/* -------------------------------------------- */ + +.lwfx__darken-1 { + background-color: var(--darken-1); +} + +.lwfx__darken-2 { + background-color: var(--darken-2); +} + +.lwfx__clipped-top { + clip-path: var(--lwfx-top-dogear-path); +} + +.lwfx__clipped { + clip-path: var(--lwfx-double-dogear-path); +} + +.lwfx__clipped > .lancer-header, +.lwfx__clipped-top > .lancer-header { + padding-left: var(--lwfx-spacer); + padding-right: var(--lwfx-spacer); +} + +/* -------------------------------------------- */ + +.lwfx__fa { + padding-right: 0 !important; + padding-left: 0 !important; + flex: unset !important; +} + +.lwfx__fa-fw { + margin-right: 0 !important; +} + +/* -------------------------------------------- */ + +.lwfx__flexcol { + display: flex !important; + flex-direction: column !important; +} + +.lwfx__flexrow { + display: flex !important; +} + +.lwfx__flexrow-v-center { + display: flex !important; + align-items: center !important; +} + +.lwfx__flexrow-vh-center { + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.lwfx__hidden { + display: none !important; +} + +/* -------------------------------------------- */ + +.lwfx__flexshrink-0 { + flex-shrink: 0 !important; +} + +/* -------------------------------------------- */ + +.lwfx__w-initial { + width: initial !important; +} + +.lwfx__w-100 { + width: 100% !important; +} + +.lwfx__min-w-initial { + min-width: initial !important; +} + +.lwfx__min-w-0 { + min-width: 0 !important; +} + +.lwfx__max-w-100px { + max-width: 100px !important; +} + +.lwfx__h-100 { + height: 100% !important; +} + +.lwfx__min-h-0 { + min-height: 0 !important; +} + +/* -------------------------------------------- */ + +.lwfx__scrollable { + margin-right: -0.75rem !important; + padding-right: 0.75rem !important; + overflow: hidden auto !important; +} + +/* -------------------------------------------- */ + +.lwfx__ws-nowrap { + white-space: nowrap !important; +} + +/* -------------------------------------------- */ + +.lwfx__m-0 { + margin: 0 !important; +} + +.lwfx__mx-1 { + margin-left: calc(0.5 * var(--lwfx-spacer)) !important; + margin-right: calc(0.5 * var(--lwfx-spacer)) !important; +} + +.lwfx__my-1 { + margin-top: calc(0.5 * var(--lwfx-spacer)) !important; + margin-bottom: calc(0.5 * var(--lwfx-spacer)) !important; +} + +.lwfx__mr-1 { + margin-right: calc(0.5 * var(--lwfx-spacer)) !important; +} + +.lwfx__mr-2 { + margin-right: var(--lwfx-spacer) !important; +} + +.lwfx__mb-1 { + margin-bottom: calc(0.5 * var(--lwfx-spacer)) !important; +} + +.lwfx__ml-1 { + margin-left: calc(0.5 * var(--lwfx-spacer)) !important; +} + +.lwfx__pr-2 { + padding-right: var(--lwfx-spacer) !important; +} + +.lwfx__px-2 { + padding-left: var(--lwfx-spacer) !important; + padding-right: var(--lwfx-spacer) !important; +} + +.lwfx__py-1 { + padding-top: calc(0.5 * var(--lwfx-spacer)) !important; + padding-bottom: calc(0.5 * var(--lwfx-spacer)) !important; +} diff --git a/lang/en.json b/lang/en.json index f268d6e..f0f743c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -4,6 +4,53 @@ "Sound Volume Hint": "Set a base volume level for all sound effects. Note that for each user, this value is combined with the user's \"Global: Interface\" volume setting (see the \"Playlists\" sidebar tab) to produce their final volume level.", "Use Weapon Heuristic": "Use Weapon Heuristic", "Use Weapon Heuristic Hint": "Use a heuristic to determine a suitable effect to play when using an unknown (homebrew/custom) weapon.", - "Debug: Play Miss Animations by Default": "Debug: Play Miss Animations by Default" + "Debug: Play Miss Animations by Default": "Debug: Play Miss Animations by Default", + + "effectManager": { + "settings": { + "Effects Manager": "Effects Manager", + "Open Effects Manager": "Open Effects Manager" + }, + + "app": { + "Effects Manager": "Effects Manager", + "Name": "Name", + "Lancer ID": "Lancer ID", + "Import Custom Effects": "Import Custom Effects", + "Import Custom Effects Hint 1": "You may import custom effects from an exported JSON file.", + "Import Custom Effects Hint 2": "This operation will update your custom effects and cannot be un-done.", + "Import": "Import", + "Create Effect": "Create Effect", + "Create Folder": "Create Folder", + "Export Data": "Export Data", + "Import Data": "Import Data", + "Uncategorized": "Uncategorized", + "Uncategorized Hint 1": "Drop an effect here to remove it from its folder.", + "Expand Folder": "Expand Folder", + "Collapse Folder": "Collapse Folder", + "Folder Name": "Folder Name", + "SHIFT to Also Delete Contents": "SHIFT to Also Delete Contents", + "Mode Hint": "Use \"Lancer ID\" mode for items from LCPs. Use \"Name\" mode for other items.", + "Mode": "Mode", + "Item Name": "Item Name", + "Macro": "Macro", + "Macro UUID": "Macro UUID", + "Execute Macro": "Execute Macro", + "Remove Folder": "Remove Folder", + "Delete Selected": "Delete Selected", + "Select All": "Select All", + "Delete Selected Effects": "Delete Selected Effects", + "Delete Selected Effects Hint": "{count} effect(s) will be deleted. This cannot be un-done.", + "Multiple Effects Item Name Hint": "Multiple effects exist with this item name!", + "Multiple Effects Lancer ID Hint": "Multiple effects exist with this Lancer ID!", + "Start Tour": "Start Tour" + }, + + "fields": { + "macroUuid": { + "label": "Macro" + } + } + } } } diff --git a/module.json b/module.json index 3766697..1db3397 100644 --- a/module.json +++ b/module.json @@ -50,6 +50,10 @@ "esmodules": [ "./scripts/WeaponFX.js" ], + "styles": [ + "./css/effects-manager.css", + "./css/helpers.css" + ], "packs": [ { "name": "weaponfx", diff --git a/scripts/WeaponFX.js b/scripts/WeaponFX.js index 0224128..a30fe3d 100644 --- a/scripts/WeaponFX.js +++ b/scripts/WeaponFX.js @@ -2,8 +2,10 @@ import { bindHooks as bindApiHooks } from "./api.js"; import { bindHooks as bindSettingsHooks } from "./settings.js"; import { bindHooks as bindModuleCheckHooks } from "./moduleCheck.js"; import { bindHooks as bindFlowListenerHooks } from "./flow/flowListener.js"; +import { bindHooks as bindManagerHooks } from "./effectManager/effectManager.js"; bindSettingsHooks(); bindApiHooks(); bindModuleCheckHooks(); bindFlowListenerHooks(); +bindManagerHooks(); diff --git a/scripts/api.js b/scripts/api.js index 0de506d..fc14ddf 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -1,6 +1,6 @@ -import { euclideanDistance } from "./utils.js"; +import { euclideanDistance, getMacroVariables } from "./utils.js"; import LloydsAlgorithm from "./lloydsAlgorithm.js"; -import { SETTING_DEBUG_IS_DEFAULT_MISS, SETTING_VOLUME } from "./settings.js"; +import { SETTING_VOLUME } from "./settings.js"; import { MODULE_ID } from "./consts.js"; /** @@ -11,29 +11,6 @@ class ModuleApi { return volume * game.settings.get(MODULE_ID, SETTING_VOLUME); } - static getMacroVariables(macro) { - const sourceTokenFallback = canvas.tokens.controlled[0] ?? game.combat?.current?.tokenId; - const targetsFallback = [...game.user.targets]; - const flowInfo = macro?.flags?.[MODULE_ID]?.flowInfo; - - if (!flowInfo) { - return { - sourceToken: sourceTokenFallback, - targetTokens: targetsFallback, - targetsMissed: game.settings.get(MODULE_ID, SETTING_DEBUG_IS_DEFAULT_MISS) - ? new Set(targetsFallback.map(target => target.id)) - : new Set(), - }; - } - - const { sourceToken, targetTokens, targetsMissed } = flowInfo; - return { - sourceToken: sourceToken || sourceTokenFallback, - targetTokens: targetTokens || targetsFallback, - targetsMissed, - }; - } - static getTargetLocationsFromTokenGroup(targetTokens, numGroups) { const targetPoints = targetTokens.map(token => { return { x: token.center.x, y: token.center.y }; @@ -42,6 +19,7 @@ class ModuleApi { return LloydsAlgorithm.getCentroids(targetPoints, numGroups); } + static getMacroVariables = getMacroVariables; static euclideanDistance = euclideanDistance; } diff --git a/scripts/consts.js b/scripts/consts.js index 8f27cb0..c3b96a6 100644 --- a/scripts/consts.js +++ b/scripts/consts.js @@ -1 +1,3 @@ export const MODULE_ID = "lancer-weapon-fx"; + +export const PACK_ID_WEAPONFX = `${MODULE_ID}.weaponfx`; diff --git a/scripts/effectManager/app.js b/scripts/effectManager/app.js new file mode 100644 index 0000000..9e619f0 --- /dev/null +++ b/scripts/effectManager/app.js @@ -0,0 +1,680 @@ +import { MODULE_ID, PACK_ID_WEAPONFX } from "../consts.js"; +import { SETTING_EFFECTS_MANAGER_STATE } from "../settings.js"; +import { EffectManagerData } from "./models.js"; +import { getMacroVariables, getSearchString } from "../utils.js"; +import { CUSTOM_EFFECT_MODE_LID, CUSTOM_EFFECT_MODE_NAME, TOUR_ID } from "./consts.js"; + +/** + * Singleton app to manage effects. + * + * Notes on singleton implementation: + * - If two GM users both have the app open, and one GM edits the state, we want the state to sync to both apps for + * both clients. + * The world-level game setting therefore triggers `onStateChange` here, causing a re-render. + * - As a side effect of the above, changing the state in the app locally does not directly trigger a re-render for the + * app. Instead, a cascade of effects occurs: + * `GM makes change in UI -> change is saved to world state -> world state change triggers re-render` + * - The app has an `id` provided in the `defaultOptions`. This gives us some singleton behaviour for free. + * Positive: + * - Opening a new instance of the manager re-uses the existing window. The app therefore appears as a singleton to + * the user. + * Negative: + * - The new instance *steals* the window from the existing app, without closing or cleaning up the existing app. + * The old app will still be "open" so we need to avoid triggering renders for every effect manager, and instead + * render only the most recently opened (and therefore visible) one. Note that Foundry passes in the "inner" + * element to `activateListeners` and so we are safe to bind event listeners. + */ +export class EffectManagerApp extends FormApplication { + /** @override */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: `${MODULE_ID}-effects-manager`, + template: `modules/${MODULE_ID}/templates/effectManager/effect-manager.hbs`, + title: game.i18n.localize(`${MODULE_ID}.effectManager.app.Effects Manager`), + width: 800, + height: 800, + submitOnChange: true, + closeOnSubmit: false, + scrollY: [".lwfx__scrollable"], + resizable: true, + classes: ["lancer sheet"], + dragDrop: [ + { + dragSelector: "[data-drag-type]", + dropSelector: "[data-drop-target]", + }, + ], + }); + } + + /* -------------------------------------------- */ + + static _appActive = null; + + static onStateChange({ state }) { + if (!this._appActive) return; + this._appActive.setState(state); + this._appActive.render(); + } + + /* -------------------------------------------- */ + + /** @type {?Array} */ + static _macroLookup = null; + + static async _pInitMacroLookup() { + if (this._macroLookup) return; + + const pack = game.packs.get(PACK_ID_WEAPONFX); + if (!pack) { + this._macroLookup = []; + ui.notifications.error(`Lancer Weapon FX | Compendium ${PACK_ID_WEAPONFX} not found`); + return; + } + + const index = await pack.getIndex(); + + this._macroLookup = index + .map(({ name, uuid }) => ({ name, uuid })) + .sort(({ name: nameA }, { name: nameB }) => nameA.localeCompare(nameB, { sensitivity: "base" })); + } + + /* -------------------------------------------- */ + + /** @type {?EffectManagerData} */ + _datamodel; + + /** @type {?Object} */ + _iptsTransient; + + constructor(...args) { + super(...args); + EffectManagerApp._appActive = this; + this.setState( + game.settings.get(MODULE_ID, SETTING_EFFECTS_MANAGER_STATE) || new EffectManagerData().toObject(), + ); + } + + setState(state) { + if (!state) throw new Error(`Missing state!`); + this._datamodel = new EffectManagerData(state); + } + + /* -------------------------------------------- */ + + /** @override */ + async _render(force, options) { + await this.constructor._pInitMacroLookup(); + return super._render(force, options); + } + + /** @override */ + async _renderInner(...args) { + const $html = await super._renderInner(...args); + + this._iptsTransient = {}; + $html.find(`[data-name-transient]`).each((i, ipt) => { + const nameTransient = ipt.getAttribute("data-name-transient"); + foundry.utils.setProperty(this._iptsTransient, nameTransient, ipt); + }); + + return $html; + } + + /* -------------------------------------------- */ + + /** @override */ + async close(options) { + if (this.constructor._appActive === this) this.constructor._appActive = null; + return super.close(options); + } + + /* -------------------------------------------- */ + + /** @override */ + getData(options = {}) { + const dataModel = this._datamodel.toObject(); + + const { effectCountsName, effectCountsLancerId } = this._getData_getEffectCounts({ dataModel }); + + const effects = Object.entries(dataModel.effects).map(([id, effect]) => ({ + id, + ...effect, + isDuplicate: this._getData_isEffectDuplicate({ effect, effectCountsName, effectCountsLancerId }), + })); + + const folders = Object.entries(dataModel.folders).map(([id, folder]) => ({ + id, + ...folder, + effects: effects.filter(effect => effect.folderId === id), + })); + + return { + // TODO(v12) use fields to generate inputs + fields: this._datamodel.schema.fields, + + effects, + + folders, + + effectsUncategorized: effects.filter(effect => effect.folderId == null), + + rowModes: { + choices: { + [CUSTOM_EFFECT_MODE_NAME]: game.i18n.localize(`${MODULE_ID}.effectManager.app.Name`), + [CUSTOM_EFFECT_MODE_LID]: game.i18n.localize(`${MODULE_ID}.effectManager.app.Lancer ID`), + }, + CUSTOM_EFFECT_MODE_NAME, + CUSTOM_EFFECT_MODE_LID, + }, + + macros: { + choices: Object.fromEntries(this.constructor._macroLookup.map(({ name, uuid }) => [uuid, name])), + }, + }; + } + + _getData_getEffectCounts({ dataModel }) { + const effectCountsName = {}; + const effectCountsLancerId = {}; + + Object.values(dataModel.effects).forEach(effect => { + switch (effect.mode) { + case CUSTOM_EFFECT_MODE_NAME: { + const searchName = getSearchString(effect.itemName); + if (!searchName) return; + effectCountsName[searchName] = (effectCountsName[searchName] || 0) + 1; + return; + } + + case CUSTOM_EFFECT_MODE_LID: { + const searchName = getSearchString(effect.itemLid); + if (!searchName) return; + effectCountsLancerId[searchName] = (effectCountsLancerId[searchName] || 0) + 1; + return; + } + + default: + throw new Error(`Unknown mode: ${effect.mode}`); + } + }); + + return { effectCountsName, effectCountsLancerId }; + } + + _getData_isEffectDuplicate({ effect, effectCountsName, effectCountsLancerId }) { + switch (effect.mode) { + case CUSTOM_EFFECT_MODE_NAME: + return effectCountsName[getSearchString(effect.itemName)] > 1; + + case CUSTOM_EFFECT_MODE_LID: + return effectCountsLancerId[getSearchString(effect.itemLid)] > 1; + + default: + throw new Error(`Unknown mode: ${effect.mode}`); + } + } + + /* -------------------------------------------- */ + + /** @override */ + activateListeners($html) { + super.activateListeners($html); + + $html.on("click", `[name="btn-effect-create"]`, this._handleClick_createEffect.bind(this)); + $html.on("click", `[name="btn-folder-create"]`, this._handleClick_createFolder.bind(this)); + $html.on("click", `[name="btn-export"]`, this._handleClick_export.bind(this)); + $html.on("click", `[name="btn-import"]`, this._handleClick_import.bind(this)); + $html.on("click", `[name="btn-start-tour"]`, this._handleClick_startTour.bind(this)); + $html.on("click", `[name="btn-selected-delete"]`, this._handleClick_deleteSelected.bind(this)); + + $html.on("click", `[name="btn-folder-expand-collapse"]`, this._handleClick_folderExpandCollapse.bind(this)); + $html.on("click", `[name="btn-folder-create-effect"]`, this._handleClick_folderCreateEffect.bind(this)); + $html.on("click", `[name="btn-folder-delete"]`, this._handleClick_folderDelete.bind(this)); + + $html.on("click", `[name="btn-effect-play"]`, this._handleClick_playPreview.bind(this)); + + $html.on("change", `[data-name-proxy]`, this._handleChange_inputProxy.bind(this)); + + this._iptsTransient["select-all"].addEventListener("change", this._handleChange_cbSelectAll.bind(this)); + Object.entries(this._iptsTransient["effects"] || {}).forEach(([, nameTo]) => { + nameTo["isSelected"].addEventListener("change", this._handleChange_cbEffect.bind(this)); + }); + } + + /* ----- */ + + async _handleChange_cbSelectAll(evt) { + evt.stopPropagation(); + + const val = this._iptsTransient["select-all"].checked; + Object.entries(this._iptsTransient["effects"]).forEach(([, nameTo]) => (nameTo["isSelected"].checked = val)); + } + + async _handleChange_cbEffect(evt) { + evt.stopPropagation(); + + const cntSelected = Object.entries(this._iptsTransient["effects"]).reduce( + (cnt, [, nameTo]) => cnt + Number(nameTo["isSelected"].checked), + 0, + ); + + if (!cntSelected) { + this._iptsTransient["select-all"].checked = false; + this._iptsTransient["select-all"].indeterminate = false; + return; + } + + const cntEffects = Object.keys(this._datamodel.effects).length; + if (cntEffects === cntSelected) { + this._iptsTransient["select-all"].checked = true; + this._iptsTransient["select-all"].indeterminate = false; + return; + } + + this._iptsTransient["select-all"].checked = true; + this._iptsTransient["select-all"].indeterminate = true; + } + + /* ----- */ + + async _handleClick_createEffect(evt) { + await this._updateObject(null, { + [`effects.${foundry.utils.randomID()}`]: this._getNewEffect(), + }); + } + + async _handleClick_createFolder(evt) { + await this._updateObject(null, { + [`folders.${foundry.utils.randomID()}`]: this._getNewFolder(), + }); + } + + async _handleClick_export(evt) { + saveDataToFile( + JSON.stringify(this._datamodel.toObject(), null, 2), + "text/json", + `${MODULE_ID}-custom-effects.json`, + ); + } + + async _handleClick_import(evt) { + new Dialog( + { + title: game.i18n.localize(`${MODULE_ID}.effectManager.app.Import Custom Effects`), + content: await renderTemplate("templates/apps/import-data.html", { + hint1: game.i18n.localize(`${MODULE_ID}.effectManager.app.Import Custom Effects Hint 1`), + hint2: game.i18n.localize(`${MODULE_ID}.effectManager.app.Import Custom Effects Hint 2`), + }), + buttons: { + import: { + icon: ``, + label: game.i18n.localize(`${MODULE_ID}.effectManager.app.Import`), + callback: async html => { + const form = html.find("form")[0]; + if (!form.data.files.length) + return ui.notifications.error("You did not upload a data file!"); + const txt = await readTextFromFile(form.data.files[0]); + + let json; + try { + json = JSON.parse(txt); + } catch (e) { + return ui.notifications.error(`File was not valid JSON! ${e.message}`); + } + + let state; + try { + state = new EffectManagerData(json); + } catch (e) { + return ui.notifications.error(`JSON file did not contain valid state! ${e.message}`); + } + + game.settings.set(MODULE_ID, SETTING_EFFECTS_MANAGER_STATE, state.toObject()); + }, + }, + no: { + icon: ``, + label: "Cancel", + }, + }, + default: "import", + }, + { + width: 400, + }, + ).render(true); + } + + async _handleClick_startTour(evt) { + const tour = game.tours.get(`${MODULE_ID}.${TOUR_ID}`); + await tour.reset(); + if (tour?.status !== Tour.STATUS.UNSTARTED) return; + tour.start(); + } + + async _handleClick_deleteSelected(evt) { + const effectIds = Object.entries(this._iptsTransient["effects"]) + .filter(([, nameTo]) => nameTo["isSelected"].checked) + .map(([effectId]) => effectId); + if (!effectIds.length) return ui.notifications.warn(`Please select some effects first!`); + + if ( + !(await Dialog.confirm({ + title: game.i18n.localize("lancer-weapon-fx.effectManager.app.Delete Selected Effects"), + content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.format("lancer-weapon-fx.effectManager.app.Delete Selected Effects Hint", { count: effectIds.length })}

`, + })) + ) + return; + + await this._updateObject(null, Object.fromEntries(effectIds.map(effectId => [`effects.-=${effectId}`, null]))); + } + + /* ----- */ + + async _handleClick_folderExpandCollapse(evt) { + const eleFolder = evt.currentTarget.closest("[data-folder-id]"); + const folderId = eleFolder?.getAttribute("data-folder-id"); + if (!folderId) throw new Error("Should never occur!"); + + await this._updateObject(null, { + [`folders.${folderId}`]: { + isCollapsed: !this._datamodel.folders[folderId].isCollapsed, + }, + }); + } + + async _handleClick_folderCreateEffect(evt) { + const folderId = evt.currentTarget.closest("[data-folder-id]").getAttribute("data-folder-id"); + + await this._updateObject(null, { + [`effects.${foundry.utils.randomID()}`]: this._getNewEffect({ folderId }), + }); + } + + async _handleClick_folderDelete(evt) { + const folderId = evt.currentTarget.closest("[data-folder-id]").getAttribute("data-folder-id"); + + await this._updateObject(null, { + [`folders.-=${folderId}`]: null, + + ...this._handleClick_folderDelete_getEffectChanges({ evt, folderId }), + }); + } + + _handleClick_folderDelete_getEffectChanges({ evt, folderId }) { + const effectEntries = Object.entries(this._datamodel.effects).filter( + ([, effect]) => effect.folderId === folderId, + ); + + // On SHIFT-click also delete all contained effects + if (evt.shiftKey) { + return Object.fromEntries(effectEntries.map(([id]) => [`effects.-=${id}`, null])); + } + + // On regular click, move effects from the deleted folder to "uncategorized" effects + return Object.fromEntries(effectEntries.map(([id]) => [`effects.${id}.folderId`, null])); + } + + /* ----- */ + + _handleChange_inputProxy(evt) { + evt.preventDefault(); + evt.stopPropagation(); + + const ele = evt.currentTarget; + const name = ele.getAttribute("data-name-proxy"); + + const eleInput = this.form.querySelector(`[name="${name}"]`); + eleInput.value = ele.value; + eleInput.dispatchEvent( + new Event("change", { + bubbles: true, + cancelable: true, + }), + ); + } + + async _handleClick_playPreview(evt) { + evt.preventDefault(); + evt.stopPropagation(); + + // Most macros need at least a source token. Ensure the user has one selected. + const macroVariables = getMacroVariables(); + if (!macroVariables.sourceToken) return ui.notifications.warn("Please select a token first!"); + + const effectId = evt.currentTarget.closest("[data-effect-id]").getAttribute("data-effect-id"); + + const macro = await fromUuid(this._datamodel.effects[effectId].macroUuid); + + try { + await macro.execute({}); + } catch (e) { + console.error(e); + + // Many macros also require a target token. Prompt the user to select one if the macro failed. + ui.notifications.warn("Macro failed to execute! You may have to target a token first."); + } + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onDragStart(evt) { + evt.stopPropagation(); + + const effectId = evt.currentTarget.closest("[data-effect-id]")?.getAttribute("data-effect-id"); + const folderId = evt.currentTarget.closest("[data-folder-id]")?.getAttribute("data-folder-id"); + + if (!effectId) return; + + const dragData = { + type: `${MODULE_ID}.folderize`, + payload: { + effectId, + folderId, + }, + }; + + evt.dataTransfer.setData("text/plain", JSON.stringify(dragData)); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + async _onDrop(evt) { + evt.stopPropagation(); + + const data = TextEditor.getDragEventData(evt); + + if (data.lancerType) return this._onDrop_lancerFlow({ evt, data }); + + const { type, payload } = data; + + switch (type) { + case `${MODULE_ID}.folderize`: + return this._onDrop_folderize({ evt, payload }); + + case "Item": + return this._onDrop_item({ evt, data }); + + case "Macro": + return this._onDrop_macro({ evt, data }); + } + } + + async _onDrop_lancerFlow({ evt, data }) { + switch (data.lancerType) { + case "frame": { + return this._onDrop_lancerFlow_frame({ evt, data }); + } + } + } + + async _onDrop_lancerFlow_frame({ evt, data }) { + const { flowType, uuid } = data; + + if (flowType !== "core_system.activation-flow") return; + + const eleEffect = evt.currentTarget.closest("[data-effect-id]"); + const eleFolder = evt.currentTarget.closest("[data-folder-id]"); + + const item = await fromUuid(uuid); + if (!item) return; + + return this._createEffectFromDroppedItem({ + effectId: eleEffect?.getAttribute("data-effect-id"), + folderId: eleFolder?.getAttribute("data-folder-id"), + item, + }); + } + + async _onDrop_folderize({ evt, payload }) { + const dropTarget = evt.currentTarget.closest( + `[data-drop-target="folder"], [data-drop-target="effects-uncategorized"]`, + ); + if (!dropTarget) return; + + const dropTargetType = dropTarget.getAttribute("data-drop-target"); + + const folderId = dropTargetType === "effects-uncategorized" ? null : dropTarget.getAttribute("data-folder-id"); + + await this._updateObject(null, { + [`effects.${payload.effectId}.folderId`]: folderId, + }); + } + + async _onDrop_item({ evt, data }) { + const eleEffect = evt.currentTarget.closest("[data-effect-id]"); + const eleFolder = evt.currentTarget.closest("[data-folder-id]"); + const item = await fromUuid(data.uuid); + + if (!item) return; + + return this._createEffectFromDroppedItem({ + effectId: eleEffect?.getAttribute("data-effect-id"), + folderId: eleFolder?.getAttribute("data-folder-id"), + item, + }); + } + + async _onDrop_macro({ evt, data }) { + const eleEffect = evt.currentTarget.closest("[data-effect-id]"); + const eleFolder = evt.currentTarget.closest("[data-folder-id]"); + const macro = await fromUuid(data.uuid); + + // If dropped to an existing row, update that row + if (eleEffect) { + const effectId = eleEffect.getAttribute("data-effect-id"); + + return this._updateObject(null, { + [`effects.${effectId}.macroUuid`]: macro.uuid, + }); + } + + // Otherwise, create a new row + const folderId = eleFolder ? eleFolder.getAttribute("data-folder-id") : null; + return this._updateObject(null, { + [`effects.${foundry.utils.randomID()}`]: this._getNewEffect({ + macroUuid: macro.uuid, + folderId, + }), + }); + } + + /* -------------------------------------------- */ + + async _createEffectFromDroppedItem({ effectId, folderId, item }) { + // If dropped to an existing row, update that row + if (effectId) { + if (item.system?.lid) { + return this._updateObject(null, { + [`effects.${effectId}`]: { + mode: CUSTOM_EFFECT_MODE_LID, + itemLid: item.system.lid, + }, + }); + } + + return this._updateObject(null, { + [`effects.${effectId}`]: { + mode: CUSTOM_EFFECT_MODE_NAME, + itemName: item.name, + }, + }); + } + + // Otherwise, create a new row + if (item.system?.lid) { + return this._updateObject(null, { + [`effects.${foundry.utils.randomID()}`]: this._getNewEffect({ + folderId, + mode: CUSTOM_EFFECT_MODE_LID, + itemLid: item.system.lid, + }), + }); + } + + return this._updateObject(null, { + [`effects.${foundry.utils.randomID()}`]: this._getNewEffect({ + folderId, + mode: CUSTOM_EFFECT_MODE_NAME, + itemName: item.name, + }), + }); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onChangeInput(evt) { + // Do not fire change events for non-"state" inputs + if ( + evt.currentTarget?.getAttribute("data-name-transient") || + evt.currentTarget?.getAttribute("data-name-proxy") + ) { + evt.stopPropagation(); + return; + } + + return super._onChangeInput(evt); + } + + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(_ = null, formData = null) { + if (!game.user.isGM) throw new Error("Should never occur!"); + + formData ||= {}; + formData = foundry.utils.flattenObject(formData); + + // Re-type `.mode`s as integers + Object.entries(formData) + .filter(([k]) => k.endsWith(".mode")) + .forEach(([k, v]) => (formData[k] = Number(v))); + + this._datamodel.updateSource(formData); + + await game.settings.set(MODULE_ID, SETTING_EFFECTS_MANAGER_STATE, this._datamodel.toObject()); + } + + /* -------------------------------------------- */ + + _getNewEffect({ macroUuid, folderId, mode, itemName, itemLid } = {}) { + return { + macroUuid: macroUuid || null, + folderId: folderId || null, + mode: mode || CUSTOM_EFFECT_MODE_NAME, + itemName: itemName || null, + itemLid: itemLid || null, + }; + } + + _getNewFolder({ name, isCollapsed } = {}) { + return { + name: name || null, + isCollapsed: isCollapsed || false, + }; + } +} diff --git a/scripts/effectManager/consts.js b/scripts/effectManager/consts.js new file mode 100644 index 0000000..681d864 --- /dev/null +++ b/scripts/effectManager/consts.js @@ -0,0 +1,4 @@ +export const CUSTOM_EFFECT_MODE_NAME = 1; +export const CUSTOM_EFFECT_MODE_LID = 2; + +export const TOUR_ID = "effectsManagerTour"; diff --git a/scripts/effectManager/effectManager.js b/scripts/effectManager/effectManager.js new file mode 100644 index 0000000..ebcbeb9 --- /dev/null +++ b/scripts/effectManager/effectManager.js @@ -0,0 +1,47 @@ +import { MODULE_ID } from "../consts.js"; +import { EffectManagerApp } from "./app.js"; +import { SETTING_EFFECTS_MANAGER_STATE } from "../settings.js"; +import { TOUR_ID } from "./consts.js"; +import { EffectManagerData } from "./models.js"; + +export const bindHooks = () => { + Hooks.once("init", () => { + // Add the button to the module settings + game.settings.registerMenu(MODULE_ID, "effectsManagerMenu", { + name: "lancer-weapon-fx.effectManager.settings.Effects Manager", + label: "lancer-weapon-fx.effectManager.settings.Open Effects Manager", + icon: "fas fa-explosion", + type: EffectManagerApp, + restricted: true, // GM only, as we modify a game setting + }); + + game.settings.register(MODULE_ID, SETTING_EFFECTS_MANAGER_STATE, { + name: "Effects Manager State", + scope: "world", + config: false, + type: Object, + default: new EffectManagerData().toObject(), + onChange: state => { + EffectManagerApp.onStateChange({ state }); + }, + }); + + // Register Handlebars partials + loadTemplates({ + [`${MODULE_ID}.effect-manager-effect-row`]: `modules/${MODULE_ID}/templates/effectManager/partial/effect-row.hbs`, + [`${MODULE_ID}.effect-manager-folder-row`]: `modules/${MODULE_ID}/templates/effectManager/partial/folder-row.hbs`, + }).then(null); + + // Register Tour + Tour.fromJSON(`modules/${MODULE_ID}/tours/effect-manager.json`).then(tour => + game.tours.register(MODULE_ID, TOUR_ID, tour), + ); + + // Show the tour when opening the manager for the first time + Hooks.on("renderEffectManagerApp", async () => { + const tour = game.tours.get(`${MODULE_ID}.${TOUR_ID}`); + if (tour?.status !== Tour.STATUS.UNSTARTED) return; + tour.start(); + }); + }); +}; diff --git a/scripts/effectManager/models.js b/scripts/effectManager/models.js new file mode 100644 index 0000000..6f74832 --- /dev/null +++ b/scripts/effectManager/models.js @@ -0,0 +1,102 @@ +import { CUSTOM_EFFECT_MODE_LID, CUSTOM_EFFECT_MODE_NAME } from "./consts.js"; + +/* -------------------------------------------- */ +/* Schema */ +/* -------------------------------------------- */ + +export const schemaFolder = new foundry.data.fields.SchemaField({ + name: new foundry.data.fields.StringField({ + label: "Name", + }), + + isCollapsed: new foundry.data.fields.BooleanField({ + initial: false, + }), +}); + +export const schemaCustomEffect = new foundry.data.fields.SchemaField({ + // TODO(v12) switch to `foundry.data.fields.DocumentUUIDField` + macroUuid: new foundry.data.fields.StringField({ + type: "Macro", + label: "lancer-weapon-fx.effectManager.fields.macroUuid.label", + nullable: true, + }), + + // TODO(v12) switch to `foundry.data.fields.DocumentUUIDField` + folderId: new foundry.data.fields.StringField({ nullable: true }), + + mode: new foundry.data.fields.NumberField({ + integer: true, + choices: [CUSTOM_EFFECT_MODE_NAME, CUSTOM_EFFECT_MODE_LID], + nullable: true, + initial: CUSTOM_EFFECT_MODE_NAME, + }), + + // region Fields specific to "name" mode + itemName: new foundry.data.fields.StringField({ + label: "Item Name", + nullable: true, + }), + // endregion + + // region Fields specific to "LID" mode + itemLid: new foundry.data.fields.StringField({ + label: "Lancer ID", + nullable: true, + }), + // endregion +}); + +/* -------------------------------------------- */ +/* Data Model */ +/* -------------------------------------------- */ + +/** + * Notes: + * - `effects` and `folders` should ideally either be `ArrayField`s or `EmbeddedCollectionField`s. + * `ArrayField` is unsuitable as it cannot be diff-updated, as Foundry (as of v11) does not implement specific + * update logic for `ArrayField`s and so updating the fields clobbers the data. + * `EmbeddedCollectionField` is unsuitable as it can only be used with `Document` subclasses, i.e. a `DataModel` + * with DB backing, and as we are not storing our state as a document in the DB, we therefore cannot use this + * field type. + * - The above schemas (`schemaFolder`, `schemaCustomEffect`) cannot be used as part of the main `DataModel`, as there + * is no `"*" -> "datamodel"` field type. Instead, we implement validation (`validate`) using the sub-schemas to + * achieve the same effect. + */ +export class EffectManagerData extends foundry.abstract.DataModel { + constructor(data, ...rest) { + // Ensure basic data validity; allow no-args constructor + data ||= {}; + data.effects ??= EffectManagerData.schema.fields.effects.initial(); + data.folders ??= EffectManagerData.schema.fields.folders.initial(); + + super(data, ...rest); + } + + /** @override */ + static defineSchema() { + return { + effects: new foundry.data.fields.ObjectField({ + initial: () => ({}), + validate: (value, options) => { + return Object.values(value).every(obj => { + const isValid = schemaCustomEffect.validate(obj, options); + if (isValid === undefined) return true; + return isValid; + }); + }, + }), + + folders: new foundry.data.fields.ObjectField({ + initial: () => ({}), + validate: (value, options) => { + return Object.values(value).every(obj => { + const isValid = schemaFolder.validate(obj, options); + if (isValid === undefined) return true; + return isValid; + }); + }, + }), + }; + } +} diff --git a/scripts/effectResolver/effectResolver.js b/scripts/effectResolver/effectResolver.js index d86dc1e..edeb1b8 100644 --- a/scripts/effectResolver/effectResolver.js +++ b/scripts/effectResolver/effectResolver.js @@ -1,20 +1,73 @@ import { weaponEffects } from "./weaponEffects.js"; -import { MODULE_ID } from "../consts.js"; +import { MODULE_ID, PACK_ID_WEAPONFX } from "../consts.js"; +import { SETTING_EFFECTS_MANAGER_STATE } from "../settings.js"; +import { getSearchString } from "../utils.js"; -const _PACK_ID_WEAPONFX = `${MODULE_ID}.weaponfx`; +/* -------------------------------------------- */ + +const _getCustomMacroUuid_itemLid = ({ itemLid, customEffects }) => { + const itemLidSearch = getSearchString(itemLid); + if (!itemLidSearch) return null; + + const byLid = Object.values(customEffects) + // `.filter` instead of `.find` so we can warn if multiple matches + .filter(effect => getSearchString(effect.itemLid) === itemLid && getSearchString(effect.macroUuid)); + + if (!byLid.length) return null; + + const [{ macroUuid }] = byLid; + if (byLid.length === 1) return macroUuid; + + ui.notifications.warn(`Multiple custom effects found for Lancer ID "${itemLid}"!`); + + return macroUuid; +}; + +const _getCustomMacroUuid_itemName = ({ itemName, customEffects }) => { + const itemNameSearch = getSearchString(itemName); + if (!itemNameSearch) return null; + + const byName = Object.values(customEffects) + // `.filter` instead of `.find` so we can warn if multiple matches + .filter(effect => getSearchString(effect.itemName) === itemNameSearch && getSearchString(effect.macroUuid)); + + if (!byName.length) return null; + + const [{ macroUuid }] = byName; + if (byName.length === 1) return macroUuid; + + ui.notifications.warn(`Multiple custom effects found for Item Name "${itemName}"!`); + + return macroUuid; +}; + +const _getCustomMacroUuid = (itemLid, itemName) => { + const customEffects = (game.settings.get(MODULE_ID, SETTING_EFFECTS_MANAGER_STATE) || {}).effects; + if (!customEffects || !Object.keys(customEffects).length) return null; + + const byLid = _getCustomMacroUuid_itemLid({ itemLid, customEffects }); + if (byLid) return byLid; + + const byName = _getCustomMacroUuid_itemName({ itemName, customEffects }); + if (byName) return byName; + + return null; +}; + +/* -------------------------------------------- */ const _pGetLwfxMacroUuid = async macroName => { if (!macroName) return null; - const pack = game.packs.get(_PACK_ID_WEAPONFX); + const pack = game.packs.get(PACK_ID_WEAPONFX); if (!pack) { - ui.notifications.error(`Lancer Weapon FX | Compendium ${_PACK_ID_WEAPONFX} not found`); + ui.notifications.error(`Lancer Weapon FX | Compendium ${PACK_ID_WEAPONFX} not found`); return null; } // Case- and whitespace-insensitive search - const macroSearchName = macroName.toLowerCase().trim(); - const macro = (await pack.getDocuments()).find(doc => doc.name.toLowerCase().trim() === macroSearchName); + const macroSearchName = getSearchString(macroName); + const macro = (await pack.getDocuments()).find(doc => getSearchString(doc.name) === macroSearchName); if (!macro) { ui.notifications.error(`Lancer Weapon FX | Macro ${macroName} not found`); @@ -24,10 +77,21 @@ const _pGetLwfxMacroUuid = async macroName => { return macro.uuid; }; -export const pGetMacroUuid = async (itemLid, fallbackActionIdentifier) => { +/* -------------------------------------------- */ + +export const pGetMacroUuid = async (itemLid, itemName, fallbackActionIdentifier) => { + // Resolve custom macros first, to allow the user to override the module defaults + const customUuid = await _getCustomMacroUuid(itemLid, itemName); + if (customUuid) { + console.log( + `Lancer Weapon FX | Found custom macro "${customUuid}" for Lancer ID "${itemLid}"/Item Name "${itemName}"`, + ); + return customUuid; + } + const lwfxUuid = await _pGetLwfxMacroUuid(weaponEffects[itemLid]); if (lwfxUuid) { - console.log(`Lancer Weapon FX | Found macro "${lwfxUuid}" for identifier "${itemLid}"`); + console.log(`Lancer Weapon FX | Found macro "${lwfxUuid}" for Lancer ID "${itemLid}"`); return lwfxUuid; } diff --git a/scripts/flow/flowListener.js b/scripts/flow/flowListener.js index 4824e53..5349e68 100644 --- a/scripts/flow/flowListener.js +++ b/scripts/flow/flowListener.js @@ -20,7 +20,7 @@ const _pGetFlowInfo = async (state, { fallbackActionIdentifier = null } = {}) => return new FlowInfo({ sourceToken: getTokenByIdOrActorId(state.actor.token?.id || state.actor?.id), - macroUuid: await pGetMacroUuid(state.item?.system?.lid, fallbackActionIdentifier), + macroUuid: await pGetMacroUuid(state.item?.system?.lid, state.item?.name, fallbackActionIdentifier), targetTokens: zippedTargetInfo.map(({ target }) => target.target).filter(Boolean), targetsMissed: new Set( zippedTargetInfo diff --git a/scripts/settings.js b/scripts/settings.js index 475d31a..e1e2763 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -2,6 +2,7 @@ import { MODULE_ID } from "./consts.js"; export const SETTING_VOLUME = "volume"; export const SETTING_IS_WEAPON_HEURISTIC_ACTIVE = "isWeaponHeuristicActive"; +export const SETTING_EFFECTS_MANAGER_STATE = "effectsManagerState"; export const SETTING_DEBUG_IS_DEFAULT_MISS = "debug-is-default-miss"; diff --git a/scripts/utils.js b/scripts/utils.js index 54fff82..5204311 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -1,3 +1,6 @@ +import { MODULE_ID } from "./consts.js"; +import { SETTING_DEBUG_IS_DEFAULT_MISS } from "./settings.js"; + export function euclideanDistance(point1, point2) { // Calculate the Euclidean distance between two points. const dx = point1.x - point2.x; @@ -27,3 +30,30 @@ export function getUniquePoints(points) { return true; }); } + +export function getMacroVariables(macro = null) { + const sourceTokenFallback = canvas.tokens.controlled[0] ?? game.combat?.current?.tokenId; + const targetsFallback = [...game.user.targets]; + const flowInfo = macro?.flags?.[MODULE_ID]?.flowInfo; + + if (!flowInfo) { + return { + sourceToken: sourceTokenFallback, + targetTokens: targetsFallback, + targetsMissed: game.settings.get(MODULE_ID, SETTING_DEBUG_IS_DEFAULT_MISS) + ? new Set(targetsFallback.map(target => target.id)) + : new Set(), + }; + } + + const { sourceToken, targetTokens, targetsMissed } = flowInfo; + return { + sourceToken: sourceToken || sourceTokenFallback, + targetTokens: targetTokens || targetsFallback, + targetsMissed, + }; +} + +export function getSearchString(str) { + return (str || "").toLowerCase().trim(); +} diff --git a/templates/effectManager/effect-manager.hbs b/templates/effectManager/effect-manager.hbs new file mode 100644 index 0000000..2ee59c5 --- /dev/null +++ b/templates/effectManager/effect-manager.hbs @@ -0,0 +1,116 @@ +
+ {{! Header controls }} +
+
+ + + +
+ + + + +
+ + + + +
+ + +
+
+ +
+ + {{! Folder and effect list }} +
+ {{#each folders as |folder|}} + {{> + lancer-weapon-fx.effect-manager-folder-row + folder=folder + macros=@root.macros + rowModes=@root.rowModes + }} + {{/each}} + +
+
+ {{localize "lancer-weapon-fx.effectManager.app.Uncategorized"}} +
+ + {{#if effectsUncategorized}} +
+ {{#each effectsUncategorized as |effect|}} + {{> + lancer-weapon-fx.effect-manager-effect-row + effect=effect + folder=null + macros=@root.macros + rowModes=@root.rowModes + }} + {{/each}} +
+ {{else}} +
+ {{localize "lancer-weapon-fx.effectManager.app.Uncategorized Hint 1"}} +
+ {{/if}} +
+
+
diff --git a/templates/effectManager/partial/effect-row.hbs b/templates/effectManager/partial/effect-row.hbs new file mode 100644 index 0000000..f4db44a --- /dev/null +++ b/templates/effectManager/partial/effect-row.hbs @@ -0,0 +1,124 @@ +
+ + + + + + + + + {{#if (eq effect.mode rowModes.CUSTOM_EFFECT_MODE_NAME) }} + + {{else}} + + {{/if}} + + {{! region Macro dropdown and input field }} + + + + + + {{! endregion }} + + +
diff --git a/templates/effectManager/partial/folder-row.hbs b/templates/effectManager/partial/folder-row.hbs new file mode 100644 index 0000000..0e43e6c --- /dev/null +++ b/templates/effectManager/partial/folder-row.hbs @@ -0,0 +1,67 @@ +
+
+ + + + + + + +
+ +
+ {{#each folder.effects as |effect|}} + {{> + lancer-weapon-fx.effect-manager-effect-row + effect=effect + folder=@root.folder + macros=@root.macros + rowModes=@root.rowModes + }} + {{/each}} +
+
diff --git a/tours/effect-manager.json b/tours/effect-manager.json new file mode 100644 index 0000000..62be9bf --- /dev/null +++ b/tours/effect-manager.json @@ -0,0 +1,34 @@ +{ + "title": "lancer-weapon-fx.effectManager.tour.Title", + "description": "lancer-weapon-fx.effectManager.tour.Description", + "restricted": true, + "steps": [ + { + "id": "1", + "selector": "[data-tour-tag=\"lwfx-dropzone\"]", + "title": "lancer-weapon-fx.effectManager.tour.Step 1 Title", + "content": "lancer-weapon-fx.effectManager.tour.Step 1 Content" + }, + { + "id": "2", + "selector": "[data-tour-tag=\"lwfx-dropzone\"]", + "title": "lancer-weapon-fx.effectManager.tour.Step 2 Title", + "content": "lancer-weapon-fx.effectManager.tour.Step 2 Content" + }, + { + "id": "3", + "title": "lancer-weapon-fx.effectManager.tour.Step 3 Title", + "content": "lancer-weapon-fx.effectManager.tour.Step 3 Content" + } + ], + "localization": { + "lancer-weapon-fx.effectManager.tour.Title": "Effect Manager", + "lancer-weapon-fx.effectManager.tour.Description": "Manage custom effects for your world.", + "lancer-weapon-fx.effectManager.tour.Step 1 Title": "Creating Effects", + "lancer-weapon-fx.effectManager.tour.Step 1 Content": "Drag-and-drop an Item or Macro to the app to create a new effect.", + "lancer-weapon-fx.effectManager.tour.Step 2 Title": "Modifying Effects", + "lancer-weapon-fx.effectManager.tour.Step 2 Content": "Drag-and-drop an Item or Macro to an effect to update that effect.", + "lancer-weapon-fx.effectManager.tour.Step 3 Title": "Effect Precedence", + "lancer-weapon-fx.effectManager.tour.Step 3 Content": "Custom effects will take precedence over built-in effects." + } +}