diff --git a/assets/_credit.txt b/assets/_credit.txt index 39124c6..43cf3fb 100644 --- a/assets/_credit.txt +++ b/assets/_credit.txt @@ -11,6 +11,11 @@ ARISO: Abdulloh Fauzan: - icons/info-circle.svg (https://thenounproject.com/icon/information-4176576/) : Rights Purchased +QOLBIN SALIIM: + - icons/arrow-left.svg (https://thenounproject.com/icon/arrow-1933583/) : Rights Purchased + - icons/arrow-right.svg (https://thenounproject.com/icon/arrow-1933581/) : Rights Purchased + - icons/arrow-compass.svg (https://thenounproject.com/icon/arrow-2052607/) : Rights Purchased + Soetarman Atmodjo: - icons/roll.svg (https://thenounproject.com/icon/dice-5195278/) : Rights Purchased diff --git a/assets/icons/arrow-compass.svg b/assets/icons/arrow-compass.svg new file mode 100644 index 0000000..b1e8a40 --- /dev/null +++ b/assets/icons/arrow-compass.svg @@ -0,0 +1,3 @@ + diff --git a/assets/icons/arrow-left.svg b/assets/icons/arrow-left.svg new file mode 100644 index 0000000..e1a347e --- /dev/null +++ b/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + diff --git a/assets/icons/arrow-right.svg b/assets/icons/arrow-right.svg new file mode 100644 index 0000000..a477835 --- /dev/null +++ b/assets/icons/arrow-right.svg @@ -0,0 +1,3 @@ + diff --git a/langs/en-ca.json b/langs/en-ca.json index 9d24b68..adda6f2 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -67,8 +67,10 @@ "easy": "Easy", "normal": "Normal", "tough": "Tough", - "hard": "Hard" + "hard": "Hard", + "random": "Random Condition" }, + "difficulty": "Difficulty", "drag": "Drag", "edit": "Edit", "edge": "Edge", @@ -80,11 +82,23 @@ "guts": "Guts", "location": "Location", "move": "Move", - "path": { - "North": "North", - "East": "East", - "South": "South", - "West": "West" + "ordinals": { + "North": { + "full": "North", + "abbv": "N" + }, + "East": { + "full": "East", + "abbv": "E" + }, + "South": { + "full": "South", + "abbv": "S" + }, + "West": { + "full": "West", + "abbv": "W" + } }, "protection": "Protection", "quantity": "Quantity", @@ -121,6 +135,20 @@ "condensedRange": { "name": "Condense Weapon Range Input", "hint": "With this enabled, the weapon range will be displayed as \"X / Y\" when editing a weapon. While disabled it will be as displayed as two different rows, one for Short Range and one for Long Range" + }, + "sandsOfFateInitial": { + "name": "Sands of Fate Initial", + "hint": "What value should The Hourglass reset to when a Cryptic Event occurs" + }, + "onCrypticEvent": { + "name": "Cryptic Event Alert", + "hint": "What happens when a cryptic event occurs by clicking the \"Next Delve Tour\" button in the HUD", + "options": { + "notif": "Notification", + "pause": "Pause Game", + "both": "Notification and Pause Game", + "nothing": "Do Nothing" + } } }, "Apps": { @@ -153,10 +181,17 @@ }, "warn": { "cannot-go-negative": "\"{name}\" is unable to be a negative number." + }, + "info": { + "cryptic-event-alert": "A Cryptic Event Has Occured!" } }, "tooltips": { - "shield-bonus": "Shield Bonus: {value}" + "shield-bonus": "Shield Bonus: {value}", + "set-fate-to": "Set Fate to {ordinal}", + "current-tour": "Current Delve Tour", + "next-tour": "Next Delve Tour", + "prev-tour": "Previous Delve Tour" }, "region": { "difficultyDelta": { diff --git a/module/Apps/ActorSheets/HeroSummaryCardV1.mjs b/module/Apps/ActorSheets/HeroSummaryCardV1.mjs index 0dc19bd..3b7ad93 100644 --- a/module/Apps/ActorSheets/HeroSummaryCardV1.mjs +++ b/module/Apps/ActorSheets/HeroSummaryCardV1.mjs @@ -117,7 +117,7 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi ctx.fate.options = [ { label: `RipCrypt.common.empty`, v: `` }, ...Object.values(gameTerms.FatePath) - .map(v => ({ label: `RipCrypt.common.path.${v}`, value: v })), + .map(v => ({ label: `RipCrypt.common.ordinals.${v}.full`, value: v })), ]; return ctx; }; diff --git a/module/Apps/DelveDiceHUD.mjs b/module/Apps/DelveDiceHUD.mjs new file mode 100644 index 0000000..a525c3a --- /dev/null +++ b/module/Apps/DelveDiceHUD.mjs @@ -0,0 +1,251 @@ +import { distanceBetweenFates, nextFate, previousFate } from "../utils/fates.mjs"; +import { filePath } from "../consts.mjs"; +import { gameTerms } from "../gameTerms.mjs"; +import { localizer } from "../utils/Localizer.mjs"; +import { Logger } from "../utils/Logger.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +const { ContextMenu } = foundry.applications.ui; +const { FatePath } = gameTerms; + +const CompassRotations = { + [FatePath.NORTH]: -90, + [FatePath.EAST]: 0, + [FatePath.SOUTH]: 90, + [FatePath.WEST]: 180, +}; + +const conditions = [ + { label: `RipCrypt.common.difficulties.easy`, value: 4 }, + { label: `RipCrypt.common.difficulties.normal`, value: 5 }, + { label: `RipCrypt.common.difficulties.tough`, value: 6 }, + { label: `RipCrypt.common.difficulties.hard`, value: 7 }, +]; + +export class DelveDiceHUD extends HandlebarsApplicationMixin(ApplicationV2) { + // #region Options + static DEFAULT_OPTIONS = { + id: `ripcrypt-delve-dice`, + tag: `aside`, + classes: [ + `ripcrypt`, + `ripcrypt--DelveDiceHUD`, + `hud`, + ], + window: { + frame: false, + positioned: false, + }, + actions: { + tourDelta: this.#tourDelta, + setFate: this.#setFate, + }, + }; + + static PARTS = { + previousTour: { + template: filePath(`templates/Apps/DelveDiceHUD/tour/previous.hbs`), + }, + difficulty: { + template: filePath(`templates/Apps/DelveDiceHUD/difficulty.hbs`), + }, + fateCompass: { + template: filePath(`templates/Apps/DelveDiceHUD/fateCompass.hbs`), + }, + sandsOfFate: { + template: filePath(`templates/Apps/DelveDiceHUD/tour/current.hbs`), + }, + nextTour: { + template: filePath(`templates/Apps/DelveDiceHUD/tour/next.hbs`), + }, + }; + // #endregion + + // #region Instance Data + /** + * The current number of degrees the compass pointer should be rotated, this + * is not stored in the DB since we only care about the initial rotation on + * reload, which is derived from the current fate. + * @type {Number} + */ + _rotation; + + constructor(...args) { + super(...args); + this._sandsOfFate = game.settings.get(`ripcrypt`, `sandsOfFate`); + this._currentFate = game.settings.get(`ripcrypt`, `currentFate`); + this._rotation = CompassRotations[this._currentFate]; + this._difficulty = game.settings.get(`ripcrypt`, `dc`); + }; + // #endregion + + // #region Lifecycle + /** + * Injects the element into the Foundry UI in the top middle + */ + _insertElement(element) { + const existing = document.getElementById(element.id); + if (existing) { + existing.replaceWith(element); + } else { + const parent = document.getElementById(`ui-top`); + parent.prepend(element); + }; + }; + + async _onRender(context, options) { + await super._onRender(context, options); + + // Shortcut because users can't edit + if (!game.user.isGM) { return }; + + new ContextMenu( + this.element, + `#delve-difficulty`, + [ + ...conditions.map(condition => ({ + name: localizer(condition.label), + callback: DelveDiceHUD.#setDifficulty.bind(this, condition.value), + })), + { + name: localizer(`RipCrypt.common.difficulties.random`), + callback: () => { + const condition = conditions[Math.floor(Math.random() * conditions.length)]; + DelveDiceHUD.#setDifficulty.bind(this)(condition.value); + }, + }, + ], + { jQuery: false, fixed: true }, + ); + }; + + async _preparePartContext(partId, ctx, opts) { + ctx = await super._preparePartContext(partId, ctx, opts); + ctx.meta ??= {}; + + ctx.meta.editable = game.user.isGM; + + switch (partId) { + case `sandsOfFate`: { + ctx.sandsOfFate = this._sandsOfFate; + break; + }; + case `difficulty`: { + ctx.dc = this._difficulty; + break; + }; + case `fateCompass`: { + ctx.fate = this._currentFate; + ctx.rotation = `${this._rotation}deg`; + break; + }; + }; + + Logger.log(`${partId} Context`, ctx); + return ctx; + }; + + async animate({ parts = [] } = {}) { + if (parts.includes(`fateCompass`)) { + this.#animateCompassTo(); + }; + + if (parts.includes(`sandsOfFate`)) { + this.#animateSandsTo(); + }; + }; + + #animateCompassTo(newFate) { + if (newFate === this._currentFate) { return }; + + /** @type {HTMLElement|undefined} */ + const pointer = this.element.querySelector(`.compass-pointer`); + if (!pointer) { return }; + + newFate ??= game.settings.get(`ripcrypt`, `currentFate`); + + let distance = distanceBetweenFates(this._currentFate, newFate); + if (distance === 3) { distance = -1 }; + + this._rotation += distance * 90; + + pointer.style.setProperty(`transform`, `rotate(${this._rotation}deg)`); + this._currentFate = newFate; + }; + + #animateSandsTo(newSands) { + /** @type {HTMLElement|undefined} */ + const sands = this.element.querySelector(`.sands-value`); + if (!sands) { return }; + + newSands ??= game.settings.get(`ripcrypt`, `sandsOfFate`); + + sands.innerHTML = newSands; + this._sandsOfFate = newSands; + }; + // #endregion + + // #region Actions + /** @this {DelveDiceHUD} */ + static async #tourDelta(_event, element) { + const delta = parseInt(element.dataset.delta); + const initial = game.settings.get(`ripcrypt`, `sandsOfFateInitial`); + let newSands = this._sandsOfFate + delta; + + if (newSands > initial) { + Logger.info(`Cannot go to a previous Delve Tour above the initial value`); + return; + }; + + if (newSands === 0) { + newSands = initial; + await this.alertCrypticEvent(); + }; + + switch (Math.sign(delta)) { + case -1: { + game.settings.set(`ripcrypt`, `currentFate`, nextFate(this._currentFate)); + break; + } + case 1: { + game.settings.set(`ripcrypt`, `currentFate`, previousFate(this._currentFate)); + break; + } + }; + + this.#animateSandsTo(newSands); + game.settings.set(`ripcrypt`, `sandsOfFate`, newSands); + }; + + /** @this {DelveDiceHUD} */ + static async #setFate(_event, element) { + const fate = element.dataset.toFate; + this.#animateCompassTo(fate); + game.settings.set(`ripcrypt`, `currentFate`, fate); + }; + + /** @this {DelveDiceHUD} */ + static async #setDifficulty(value) { + this._difficulty = value; + game.settings.set(`ripcrypt`, `dc`, value); + }; + // #endregion + + // #region Public API + async alertCrypticEvent() { + const alertType = game.settings.get(`ripcrypt`, `onCrypticEvent`); + if (alertType === `nothing`) { return }; + + if ([`both`, `notif`].includes(alertType)) { + ui.notifications.info( + localizer(`RipCrypt.notifs.info.cryptic-event-alert`), + { console: false }, + ); + }; + + if ([`both`, `pause`].includes(alertType) && game.user.isGM) { + game.togglePause(true, { broadcast: true }); + }; + }; + // #endregion +}; diff --git a/module/Apps/DelveTourApp.mjs b/module/Apps/DelveTourApp.mjs deleted file mode 100644 index 8c213ed..0000000 --- a/module/Apps/DelveTourApp.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import { filePath } from "../consts.mjs"; -import { GenericAppMixin } from "./GenericApp.mjs"; -import { Logger } from "../utils/Logger.mjs"; - -const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; - -const conditions = [ - { label: `RipCrypt.common.difficulties.easy`, value: 4 }, - { label: `RipCrypt.common.difficulties.normal`, value: 5 }, - { label: `RipCrypt.common.difficulties.tough`, value: 6 }, - { label: `RipCrypt.common.difficulties.hard`, value: 7 }, -]; - -export class DelveTourApp extends GenericAppMixin(HandlebarsApplicationMixin(ApplicationV2)) { - // #region Options - static DEFAULT_OPTIONS = { - classes: [ - `ripcrypt--CryptApp`, - ], - window: { - title: `Delve Tour`, - frame: true, - positioned: true, - resizable: false, - minimizable: false, - }, - position: { - width: `auto`, - }, - actions: { - randomCondition: this.#randomCondition, - }, - }; - - static PARTS = { - turnCount: { - template: filePath(`templates/Apps/CryptApp/turnCount.hbs`), - }, - delveConditions: { - template: filePath(`templates/Apps/CryptApp/delveConditions.hbs`), - }, - fate: { - template: filePath(`templates/Apps/CryptApp/fate.hbs`), - }, - }; - // #endregion - - // #region Lifecycle - async _renderFrame(options) { - const frame = await super._renderFrame(options); - this.window.close.remove(); // Prevent closing - return frame; - }; - - async _onRender(context, options) { - await super._onRender(context, options); - - // Shortcut because users can't edit - if (!game.user.isGM) { return }; - - // Add event listener for the dropdown - if (options.parts.includes(`delveConditions`)) { - const select = this.element.querySelector(`#${this.id}-difficulty`); - select.addEventListener(`change`, async (ev) => { - const newDifficulty = parseInt(ev.target.value); - if (!Number.isNaN(newDifficulty)) { - await game.settings.set(`ripcrypt`, `dc`, newDifficulty); - }; - this.render({ parts: [`delveConditions`] }); - }); - }; - }; - - async _preparePartContext(partId, ctx, opts) { - ctx = await super._preparePartContext(partId, ctx, opts); - - ctx.meta.editable = game.user.isGM; - - switch (partId) { - case `delveConditions`: { - ctx = this._prepareDifficulty(ctx); - break; - }; - }; - - Logger.log(`${partId} Context`, ctx); - return ctx; - }; - - _prepareDifficulty(ctx) { - ctx.options = conditions; - ctx.difficulty = game.settings.get(`ripcrypt`, `dc`); - return ctx; - }; - // #endregion - - // #region Actions - static async #randomCondition() { - const dc = conditions[Math.floor(Math.random() * conditions.length)]; - await game.settings.set(`ripcrypt`, `dc`, dc.value); - await this.render({ parts: [`delveConditions`] }); - }; - // #endregion -}; diff --git a/module/api.mjs b/module/api.mjs index 7b04ea8..d2e50ac 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -6,6 +6,7 @@ import { HeroSummaryCardV1 } from "./Apps/ActorSheets/HeroSummaryCardV1.mjs"; import { RichEditor } from "./Apps/RichEditor.mjs"; // Util imports +import { distanceBetweenFates, nextFate, previousFate } from "./utils/fates.mjs"; import { documentSorter } from "./consts.mjs"; const { deepFreeze } = foundry.utils; @@ -24,6 +25,9 @@ Object.defineProperty( }, utils: { documentSorter, + distanceBetweenFates, + nextFate, + previousFate, }, }), writable: false, diff --git a/module/documents/combatant.mjs b/module/documents/combatant.mjs index 6de765b..0594db5 100644 --- a/module/documents/combatant.mjs +++ b/module/documents/combatant.mjs @@ -1,4 +1,4 @@ -import { distanceBetweenFates } from "../utils/distanceBetweenFates.mjs"; +import { distanceBetweenFates } from "../utils/fates.mjs"; export class RipCryptCombatant extends Combatant { diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index 70f81ff..b7dda31 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -1,7 +1,7 @@ // Applications import { AllItemSheetV1 } from "../Apps/ItemSheets/AllItemSheetV1.mjs"; import { CombinedHeroSheet } from "../Apps/ActorSheets/CombinedHeroSheet.mjs"; -import { DelveTourApp } from "../Apps/DelveTourApp.mjs"; +import { DelveDiceHUD } from "../Apps/DelveDiceHUD.mjs"; import { HeroSkillsCardV1 } from "../Apps/ActorSheets/HeroSkillsCardV1.mjs"; import { HeroSummaryCardV1 } from "../Apps/ActorSheets/HeroSummaryCardV1.mjs"; import { RipCryptCombatTracker } from "../Apps/sidebar/CombatTracker.mjs"; @@ -39,7 +39,7 @@ Hooks.once(`init`, () => { Logger.log(`Initializing`); CONFIG.Combat.initiative.decimals = 2; - CONFIG.ui.crypt = DelveTourApp; + CONFIG.ui.delveDice = DelveDiceHUD; // #region Settings registerMetaSettings(); diff --git a/module/hooks/ready.mjs b/module/hooks/ready.mjs index c71bc25..63223d7 100644 --- a/module/hooks/ready.mjs +++ b/module/hooks/ready.mjs @@ -19,9 +19,7 @@ Hooks.once(`ready`, () => { if (game.paused) { game.togglePause() }; }; - if (game.settings.get(`ripcrypt`, `showDelveTour`)) { - ui.crypt.render({ force: true }); - }; + ui.delveDice.render({ force: true }); // MARK: 1-time updates if (!game.settings.get(`ripcrypt`, `firstLoadFinished`)) { diff --git a/module/settings/metaSettings.mjs b/module/settings/metaSettings.mjs index 7694088..66fb669 100644 --- a/module/settings/metaSettings.mjs +++ b/module/settings/metaSettings.mjs @@ -1,3 +1,8 @@ +import { gameTerms } from "../gameTerms.mjs"; + +const { StringField } = foundry.data.fields; +const { FatePath } = gameTerms; + export function registerMetaSettings() { game.settings.register(`ripcrypt`, `dc`, { scope: `world`, @@ -5,19 +10,32 @@ export function registerMetaSettings() { config: false, requiresReload: false, onChange: () => { - ui.crypt.render({ parts: [ `delveConditions` ]}); + ui.delveDice.render({ parts: [`difficulty`] }); + }, + }); + + game.settings.register(`ripcrypt`, `sandsOfFate`, { + scope: `world`, + type: Number, + initial: 8, + config: false, + requiresReload: false, + onChange: async () => { + ui.delveDice.animate({ parts: [`sandsOfFate`] }); }, }); game.settings.register(`ripcrypt`, `currentFate`, { scope: `world`, - type: String, + type: new StringField({ + blank: false, + nullable: false, + initial: FatePath.NORTH, + }), config: false, requiresReload: false, onChange: async () => { - await ui.crypt.render({ parts: [ `fate` ] }); - await game.combat.setupTurns(); - await ui.combat.render({ parts: [ `tracker` ] }); + ui.delveDice.animate({ parts: [`fateCompass`] }); }, }); diff --git a/module/settings/worldSettings.mjs b/module/settings/worldSettings.mjs index d7c426a..ca095ae 100644 --- a/module/settings/worldSettings.mjs +++ b/module/settings/worldSettings.mjs @@ -1,10 +1,42 @@ +const { NumberField, StringField } = foundry.data.fields; + export function registerWorldSettings() { - game.settings.register(`ripcrypt`, `showDelveTour`, { - name: `Delve Tour Popup`, + game.settings.register(`ripcrypt`, `sandsOfFateInitial`, { + name: `RipCrypt.setting.sandsOfFateInitial.name`, + hint: `RipCrypt.setting.sandsOfFateInitial.hint`, scope: `world`, - type: Boolean, config: true, - default: true, requiresReload: false, + type: new NumberField({ + required: true, + min: 1, + step: 1, + max: 10, + initial: 8, + }), + onChange: async (newInitialSands) => { + const currentSands = game.settings.get(`ripcrypt`, `sandsOfFate`); + if (newInitialSands <= currentSands) { + game.settings.set(`ripcrypt`, `sandsOfFate`, newInitialSands); + }; + }, + }); + + game.settings.register(`ripcrypt`, `onCrypticEvent`, { + name: `RipCrypt.setting.onCrypticEvent.name`, + hint: `RipCrypt.setting.onCrypticEvent.hint`, + scope: `world`, + config: true, + requiresReload: false, + type: new StringField({ + required: true, + initial: `notif`, + choices: { + "notif": `RipCrypt.setting.onCrypticEvent.options.notif`, + "pause": `RipCrypt.setting.onCrypticEvent.options.pause`, + "both": `RipCrypt.setting.onCrypticEvent.options.both`, + "nothing": `RipCrypt.setting.onCrypticEvent.options.nothing`, + }, + }), }); }; diff --git a/module/utils/distanceBetweenFates.mjs b/module/utils/fates.mjs similarity index 50% rename from module/utils/distanceBetweenFates.mjs rename to module/utils/fates.mjs index 4eaaddd..d1fb6dd 100644 --- a/module/utils/distanceBetweenFates.mjs +++ b/module/utils/fates.mjs @@ -14,14 +14,38 @@ export function distanceBetweenFates(start, end) { return undefined; }; - if (isOppositeFates(start, end)) { + if (start === end) { + return 0; + }; + + if (isOppositeFates(start, end) || isOppositeFates(end, start)) { return 2; }; let isForward = start === FatePath.SOUTH && end === FatePath.WEST; isForward ||= start === FatePath.NORTH && end === FatePath.EAST; + isForward ||= start === FatePath.WEST && end === FatePath.NORTH; + isForward ||= start === FatePath.EAST && end === FatePath.SOUTH; if (isForward) { return 1; }; return 3; }; + +const fateOrder = [ + FatePath.WEST, // to make the .find not integer overflow + FatePath.NORTH, + FatePath.EAST, + FatePath.SOUTH, + FatePath.WEST, +]; + +export function nextFate(fate) { + const fateIndex = fateOrder.findIndex(f => f === fate); + return fateOrder[fateIndex + 1]; +}; + +export function previousFate(fate) { + const fateIndex = fateOrder.lastIndexOf(fate); + return fateOrder[fateIndex - 1]; +}; diff --git a/templates/Apps/CryptApp/delveConditions.hbs b/templates/Apps/CryptApp/delveConditions.hbs deleted file mode 100644 index 4e0f00d..0000000 --- a/templates/Apps/CryptApp/delveConditions.hbs +++ /dev/null @@ -1,29 +0,0 @@ -