Merge remote-tracking branch 'origin/dev' into feature/region-behaviours
This commit is contained in:
commit
85d820a9de
37 changed files with 650 additions and 201 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
251
module/Apps/DelveDiceHUD.mjs
Normal file
251
module/Apps/DelveDiceHUD.mjs
Normal file
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { distanceBetweenFates } from "../utils/distanceBetweenFates.mjs";
|
||||
import { distanceBetweenFates } from "../utils/fates.mjs";
|
||||
|
||||
export class RipCryptCombatant extends Combatant {
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`)) {
|
||||
|
|
|
|||
|
|
@ -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`] });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue