Merge pull request #10 from Oliver-Akins/rewrite/v13
v13 Compatibility and Rewrite
This commit is contained in:
commit
292f8caf92
74 changed files with 833 additions and 1551 deletions
8
.github/workflows/draft-release.yaml
vendored
8
.github/workflows/draft-release.yaml
vendored
|
|
@ -29,10 +29,6 @@ jobs:
|
|||
if: ${{ steps.check-tag.outputs.exists == 'true' }}
|
||||
run: exit 1
|
||||
|
||||
- name: Ensure there are specific files to release
|
||||
if: ${{ vars.files_to_release == '' }}
|
||||
run: exit 1
|
||||
|
||||
# Compile the stuff that needs to be compiled
|
||||
- run: npm run build
|
||||
- run: node scripts/buildCompendia.mjs
|
||||
|
|
@ -43,10 +39,10 @@ jobs:
|
|||
|
||||
- name: Update the download property in the manifest
|
||||
id: manifest-update
|
||||
run: cat system.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/${{ vars.zip_name }}.zip"' > system.json
|
||||
run: cat system.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/release.zip"' > system.json
|
||||
|
||||
- name: Create the zip
|
||||
run: zip -r ${{ vars.zip_name || 'release' }}.zip ${{ vars.files_to_release }}
|
||||
run: zip -r release.zip langs module styles templates system.json README.md
|
||||
|
||||
- name: Create the draft release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,2 +1,2 @@
|
|||
node_modules/
|
||||
.styles
|
||||
deprecated
|
||||
|
|
|
|||
BIN
.promo/hjonk-samples.png
Normal file
BIN
.promo/hjonk-samples.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 705 KiB |
21
README.md
21
README.md
|
|
@ -2,24 +2,3 @@
|
|||
This is an intentionally bare-bones system that features a text-only character
|
||||
sheet, allowing the playing of games that may not otherwise have a Foundry system
|
||||
implementation.
|
||||
|
||||
## Features
|
||||
There are not too many features included in this system for things like automation
|
||||
as it's meant to be used to mostly play rules-light games. However there are some
|
||||
special features that can be enabled on a per-world basis using a world script
|
||||
to enable feature flags that you want.
|
||||
|
||||
### List of Feature Flags
|
||||
| Flag | Description
|
||||
| - | -
|
||||
| `ROLL_MODE_CONTENT` | Allows players, GMs, and macros to send "blind" chat messages where only the GM gets to see the content.
|
||||
| `STORABLE_SHEET_SIZE` | Makes it so that certain sheets are able to have their size saved, so that it resumes that size when opened.
|
||||
|
||||
### Example Feature Flag
|
||||
In order to change these flags, you must make a world script that has something
|
||||
like the below code in it:
|
||||
|
||||
```js
|
||||
// v- this is the name of the flag from the table above
|
||||
taf.FEATURES.STORABLE_SHEET_SIZE = true;
|
||||
```
|
||||
|
|
@ -20,10 +20,6 @@ export default [
|
|||
Handlebars: `readonly`,
|
||||
Hooks: `readonly`,
|
||||
ui: `readonly`,
|
||||
Actor: `readonly`,
|
||||
Actors: `readonly`,
|
||||
Item: `readonly`,
|
||||
Items: `readonly`,
|
||||
ActorSheet: `readonly`,
|
||||
ItemSheet: `readonly`,
|
||||
foundry: `readonly`,
|
||||
|
|
@ -31,7 +27,8 @@ export default [
|
|||
ActiveEffect: `readonly`,
|
||||
Dialog: `readonly`,
|
||||
renderTemplate: `readonly`,
|
||||
TextEditor: `readonly`,
|
||||
fromUuid: `readonly`,
|
||||
fromUuidSync: `readonly`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
18
langs/en-ca.json
Normal file
18
langs/en-ca.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"TYPES": {
|
||||
"Actor": {
|
||||
"player": "Player"
|
||||
}
|
||||
},
|
||||
"taf": {
|
||||
"settings": {
|
||||
"canPlayersManageAttributes": {
|
||||
"name": "Players Can Manage Attributes",
|
||||
"hint": "This allows players who have edit access to a document to be able to edit what attributes those characters have via the attribute editor"
|
||||
}
|
||||
},
|
||||
"sheet-names": {
|
||||
"PlayerSheet": "Player Sheet"
|
||||
}
|
||||
}
|
||||
}
|
||||
171
module/apps/AttributeManager.mjs
Normal file
171
module/apps/AttributeManager.mjs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { __ID__, filePath } from "../consts.mjs";
|
||||
import { Logger } from "../utils/Logger.mjs";
|
||||
import { toID } from "../utils/toID.mjs";
|
||||
|
||||
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
|
||||
const { deepClone, diffObject, randomID, setProperty } = foundry.utils;
|
||||
|
||||
export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
|
||||
// #region Options
|
||||
static DEFAULT_OPTIONS = {
|
||||
tag: `form`,
|
||||
classes: [
|
||||
__ID__,
|
||||
`AttributeManager`,
|
||||
],
|
||||
position: {
|
||||
width: 400,
|
||||
height: 350,
|
||||
},
|
||||
window: {
|
||||
resizable: true,
|
||||
},
|
||||
form: {
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: true,
|
||||
handler: this.#onSubmit,
|
||||
},
|
||||
actions: {
|
||||
addNew: this.#addNew,
|
||||
removeAttribute: this.#remove,
|
||||
},
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
attributes: {
|
||||
template: filePath(`templates/AttributeManager/attribute-list.hbs`),
|
||||
},
|
||||
controls: {
|
||||
template: filePath(`templates/AttributeManager/controls.hbs`),
|
||||
},
|
||||
};
|
||||
// #endregion Options
|
||||
|
||||
// #region Instance Data
|
||||
/** @type {string | null} */
|
||||
#doc = null;
|
||||
|
||||
#attributes;
|
||||
|
||||
constructor({ document , ...options } = {}) {
|
||||
super(options);
|
||||
this.#doc = document;
|
||||
this.#attributes = deepClone(document.system.attr);
|
||||
};
|
||||
|
||||
get title() {
|
||||
return `Attributes: ${this.#doc.name}`;
|
||||
};
|
||||
// #endregion Instance Data
|
||||
|
||||
// #region Lifecycle
|
||||
async _onRender(context, options) {
|
||||
await super._onRender(context, options);
|
||||
|
||||
const elements = this.element
|
||||
.querySelectorAll(`[data-bind]`);
|
||||
for (const input of elements) {
|
||||
input.addEventListener(`change`, this.#bindListener.bind(this));
|
||||
};
|
||||
};
|
||||
// #endregion Lifecycle
|
||||
|
||||
// #region Data Prep
|
||||
async _preparePartContext(partId) {
|
||||
const ctx = {};
|
||||
|
||||
ctx.actor = this.#doc;
|
||||
|
||||
switch (partId) {
|
||||
case `attributes`: {
|
||||
await this._prepareAttributeContext(ctx);
|
||||
};
|
||||
};
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
async _prepareAttributeContext(ctx) {
|
||||
const attrs = [];
|
||||
for (const [id, data] of Object.entries(this.#attributes)) {
|
||||
if (data == null) { continue };
|
||||
attrs.push({
|
||||
id,
|
||||
name: data.name,
|
||||
isRange: data.isRange,
|
||||
isNew: data.isNew ?? false,
|
||||
});
|
||||
};
|
||||
ctx.attrs = attrs;
|
||||
};
|
||||
// #endregion Data Prep
|
||||
|
||||
// #region Actions
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async #bindListener(event) {
|
||||
const target = event.target;
|
||||
const data = target.dataset;
|
||||
const binding = data.bind;
|
||||
|
||||
let value = target.value;
|
||||
switch (target.type) {
|
||||
case `checkbox`: {
|
||||
value = target.checked;
|
||||
};
|
||||
};
|
||||
|
||||
Logger.debug(`Updating ${binding} value to ${value}`);
|
||||
setProperty(this.#attributes, binding, value);
|
||||
await this.render();
|
||||
};
|
||||
|
||||
/** @this {AttributeManager} */
|
||||
static async #addNew() {
|
||||
const id = randomID();
|
||||
this.#attributes[id] = {
|
||||
name: ``,
|
||||
isRange: false,
|
||||
isNew: true,
|
||||
};
|
||||
await this.render({ parts: [ `attributes` ]});
|
||||
};
|
||||
|
||||
/** @this {AttributeManager} */
|
||||
static async #remove($e, element) {
|
||||
const attribute = element.closest(`[data-attribute]`)?.dataset.attribute;
|
||||
if (!attribute) { return };
|
||||
delete this.#attributes[attribute];
|
||||
this.#attributes[`-=${attribute}`] = null;
|
||||
await this.render({ parts: [ `attributes` ] });
|
||||
};
|
||||
|
||||
/** @this {AttributeManager} */
|
||||
static async #onSubmit() {
|
||||
const entries = Object.entries(this.#attributes)
|
||||
.map(([id, attr]) => {
|
||||
if (attr == null) {
|
||||
return [ id, attr ];
|
||||
};
|
||||
|
||||
if (attr.isNew) {
|
||||
delete attr.isNew;
|
||||
return [ toID(attr.name), attr ];
|
||||
};
|
||||
|
||||
return [ id, attr ];
|
||||
});
|
||||
const data = Object.fromEntries(entries);
|
||||
|
||||
const diff = diffObject(
|
||||
this.#doc.system.attr,
|
||||
data,
|
||||
{ inner: false, deletionKeys: true },
|
||||
);
|
||||
|
||||
await this.#doc.update({ "system.attr": diff });
|
||||
};
|
||||
// #endregion Actions
|
||||
};
|
||||
111
module/apps/PlayerSheet.mjs
Normal file
111
module/apps/PlayerSheet.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { __ID__, filePath } from "../consts.mjs";
|
||||
import { AttributeManager } from "./AttributeManager.mjs";
|
||||
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
const { ActorSheetV2 } = foundry.applications.sheets;
|
||||
|
||||
export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
|
||||
// #region Options
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: [
|
||||
__ID__,
|
||||
`PlayerSheet`,
|
||||
],
|
||||
position: {
|
||||
width: 575,
|
||||
height: 740,
|
||||
},
|
||||
window: {
|
||||
resizable: true,
|
||||
},
|
||||
form: {
|
||||
submitOnChange: true,
|
||||
closeOnSubmit: false,
|
||||
},
|
||||
actions: {
|
||||
manageAttributes: this.#manageAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
header: { template: filePath(`templates/PlayerSheet/header.hbs`) },
|
||||
attributes: { template: filePath(`templates/PlayerSheet/attributes.hbs`) },
|
||||
content: { template: filePath(`templates/PlayerSheet/content.hbs`) },
|
||||
};
|
||||
// #endregion Options
|
||||
|
||||
// #region Lifecycle
|
||||
_getHeaderControls() {
|
||||
const controls = super._getHeaderControls();
|
||||
|
||||
controls.push({
|
||||
icon: `fa-solid fa-at`,
|
||||
label: `Manage Attributes`,
|
||||
action: `manageAttributes`,
|
||||
visible: () => {
|
||||
const isGM = game.user.isGM;
|
||||
const allowPlayerEdits = game.settings.get(__ID__, `canPlayersManageAttributes`);
|
||||
const editable = this.isEditable;
|
||||
return isGM || (allowPlayerEdits && editable);
|
||||
},
|
||||
});
|
||||
|
||||
return controls;
|
||||
};
|
||||
// #endregion Lifecycle
|
||||
|
||||
// #region Data Prep
|
||||
async _preparePartContext(partID) {
|
||||
let ctx = {
|
||||
actor: this.actor,
|
||||
system: this.actor.system,
|
||||
editable: this.isEditable,
|
||||
};
|
||||
|
||||
switch (partID) {
|
||||
case `attributes`: {
|
||||
await this._prepareAttributes(ctx);
|
||||
break;
|
||||
};
|
||||
case `content`: {
|
||||
await this._prepareContent(ctx);
|
||||
break;
|
||||
};
|
||||
};
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
async _prepareAttributes(ctx) {
|
||||
ctx.hasAttributes = this.actor.system.hasAttributes;
|
||||
|
||||
const attrs = [];
|
||||
for (const [id, data] of Object.entries(this.actor.system.attr)) {
|
||||
attrs.push({
|
||||
...data,
|
||||
id,
|
||||
path: `system.attr.${id}`,
|
||||
});
|
||||
};
|
||||
ctx.attrs = attrs.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
async _prepareContent(ctx) {
|
||||
const TextEditor = foundry.applications.ux.TextEditor.implementation;
|
||||
ctx.enriched = {
|
||||
system: {
|
||||
content: await TextEditor.enrichHTML(this.actor.system.content),
|
||||
},
|
||||
};
|
||||
};
|
||||
// #endregion Data Prep
|
||||
|
||||
// #region Actions
|
||||
/** @this {PlayerSheet} */
|
||||
static async #manageAttributes() {
|
||||
const app = new AttributeManager({ document: this.actor });
|
||||
await app.render({ force: true });
|
||||
};
|
||||
// #endregion Actions
|
||||
};
|
||||
9
module/consts.mjs
Normal file
9
module/consts.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const __ID__ = `taf`;
|
||||
|
||||
// MARK: filePath
|
||||
export function filePath(path) {
|
||||
if (path.startsWith(`/`)) {
|
||||
path = path.slice(1);
|
||||
};
|
||||
return `systems/${__ID__}/${path}`;
|
||||
};
|
||||
29
module/data/Player.mjs
Normal file
29
module/data/Player.mjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export class PlayerData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields;
|
||||
return {
|
||||
content: new fields.HTMLField({
|
||||
blank: true,
|
||||
trim: true,
|
||||
initial: ``,
|
||||
}),
|
||||
attr: new fields.TypedObjectField(
|
||||
new fields.SchemaField({
|
||||
name: new fields.StringField({ blank: false, trim: true }),
|
||||
value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }),
|
||||
max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }),
|
||||
isRange: new fields.BooleanField({ initial: false, nullable: false }),
|
||||
}),
|
||||
{
|
||||
initial: {},
|
||||
nullable: false,
|
||||
required: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
get hasAttributes() {
|
||||
return Object.keys(this.attr).length > 0;
|
||||
};
|
||||
};
|
||||
28
module/documents/Actor.mjs
Normal file
28
module/documents/Actor.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Logger } from "../utils/Logger.mjs";
|
||||
|
||||
const { Actor } = foundry.documents;
|
||||
|
||||
export class TAFActor extends Actor {
|
||||
async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) {
|
||||
Logger.table({ attribute, value, isDelta, isBar });
|
||||
const attr = foundry.utils.getProperty(this.system, attribute);
|
||||
const current = isBar ? attr.value : attr;
|
||||
const update = isDelta ? current + value : value;
|
||||
if ( update === current ) {
|
||||
return this;
|
||||
};
|
||||
|
||||
// Determine the updates to make to the actor data
|
||||
let updates;
|
||||
if (isBar) {
|
||||
updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)};
|
||||
} else {
|
||||
updates = {[`system.${attribute}`]: update};
|
||||
};
|
||||
|
||||
// Allow a hook to override these changes
|
||||
const allowed = Hooks.call(`modifyTokenAttribute`, {attribute, value, isDelta, isBar}, updates, this);
|
||||
|
||||
return allowed !== false ? this.update(updates) : this;
|
||||
}
|
||||
};
|
||||
111
module/documents/Token.mjs
Normal file
111
module/documents/Token.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Logger } from "../utils/Logger.mjs";
|
||||
|
||||
const { TokenDocument } = foundry.documents;
|
||||
const { getProperty, getType, hasProperty, isSubclass } = foundry.utils;
|
||||
|
||||
export class TAFTokenDocument extends TokenDocument {
|
||||
|
||||
/**
|
||||
* @override
|
||||
* This override's purpose is to make it so that Token attributes and bars can
|
||||
* be accessed from the data model's values directly instead of relying on only
|
||||
* the schema, which doesn't account for my TypedObjectField of attributes.
|
||||
*/
|
||||
static getTrackedAttributes(data, _path = []) {
|
||||
|
||||
// Case 1 - Infer attributes from schema structure.
|
||||
if ( (data instanceof foundry.abstract.DataModel) || isSubclass(data, foundry.abstract.DataModel) ) {
|
||||
return this._getTrackedAttributesFromObject(data, _path);
|
||||
}
|
||||
if ( data instanceof foundry.data.fields.SchemaField ) {
|
||||
return this._getTrackedAttributesFromSchema(data, _path);
|
||||
}
|
||||
|
||||
// Case 2 - Infer attributes from object structure.
|
||||
if ( [`Object`, `Array`].includes(getType(data)) ) {
|
||||
return this._getTrackedAttributesFromObject(data, _path);
|
||||
}
|
||||
|
||||
// Case 3 - Retrieve explicitly configured attributes.
|
||||
if ( !data || (typeof data === `string`) ) {
|
||||
const config = this._getConfiguredTrackedAttributes(data);
|
||||
if ( config ) {
|
||||
return config;
|
||||
}
|
||||
data = undefined;
|
||||
}
|
||||
|
||||
// Track the path and record found attributes
|
||||
if ( data !== undefined ) {
|
||||
return {bar: [], value: []};
|
||||
}
|
||||
|
||||
// Case 4 - Infer attributes from system template.
|
||||
const bar = new Set();
|
||||
const value = new Set();
|
||||
for ( const [type, model] of Object.entries(game.model.Actor) ) {
|
||||
const dataModel = CONFIG.Actor.dataModels?.[type];
|
||||
const inner = this.getTrackedAttributes(dataModel ?? model, _path);
|
||||
inner.bar.forEach(attr => bar.add(attr.join(`.`)));
|
||||
inner.value.forEach(attr => value.add(attr.join(`.`)));
|
||||
}
|
||||
|
||||
return {
|
||||
bar: Array.from(bar).map(attr => attr.split(`.`)),
|
||||
value: Array.from(value).map(attr => attr.split(`.`)),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getBarAttribute(barName, {alternative} = {}) {
|
||||
const attribute = alternative || this[barName]?.attribute;
|
||||
Logger.log(barName, attribute);
|
||||
if (!attribute || !this.actor) {
|
||||
return null;
|
||||
};
|
||||
const system = this.actor.system;
|
||||
|
||||
// Get the current attribute value
|
||||
const data = getProperty(system, attribute);
|
||||
if (data == null) {
|
||||
return null;
|
||||
};
|
||||
|
||||
if (Number.isNumeric(data)) {
|
||||
let editable = hasProperty(system, attribute);
|
||||
return {
|
||||
type: `value`,
|
||||
attribute,
|
||||
value: Number(data),
|
||||
editable,
|
||||
};
|
||||
};
|
||||
|
||||
if (`value` in data && `max` in data) {
|
||||
let editable = hasProperty(system, `${attribute}.value`);
|
||||
const isRange = getProperty(system, `${attribute}.isRange`);
|
||||
if (isRange) {
|
||||
return {
|
||||
type: `bar`,
|
||||
attribute,
|
||||
value: parseInt(data.value || 0),
|
||||
max: parseInt(data.max || 0),
|
||||
editable,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: `value`,
|
||||
attribute: `${attribute}.value`,
|
||||
value: Number(data.value),
|
||||
editable,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Otherwise null
|
||||
return null;
|
||||
};
|
||||
|
||||
};
|
||||
36
module/hooks/init.mjs
Normal file
36
module/hooks/init.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Apps
|
||||
import { PlayerSheet } from "../apps/PlayerSheet.mjs";
|
||||
|
||||
// Data Models
|
||||
import { PlayerData } from "../data/Player.mjs";
|
||||
|
||||
// Documents
|
||||
import { TAFActor } from "../documents/Actor.mjs";
|
||||
import { TAFTokenDocument } from "../documents/Token.mjs";
|
||||
|
||||
// Settings
|
||||
import { registerWorldSettings } from "../settings/world.mjs";
|
||||
|
||||
// Utils
|
||||
import { __ID__ } from "../consts.mjs";
|
||||
import { Logger } from "../utils/Logger.mjs";
|
||||
|
||||
Hooks.on(`init`, () => {
|
||||
Logger.debug(`Initializing`);
|
||||
|
||||
CONFIG.Token.documentClass = TAFTokenDocument;
|
||||
CONFIG.Actor.documentClass = TAFActor;
|
||||
|
||||
CONFIG.Actor.dataModels.player = PlayerData;
|
||||
|
||||
foundry.documents.collections.Actors.registerSheet(
|
||||
__ID__,
|
||||
PlayerSheet,
|
||||
{
|
||||
makeDefault: true,
|
||||
label: `taf.sheet-names.PlayerSheet`,
|
||||
},
|
||||
);
|
||||
|
||||
registerWorldSettings();
|
||||
});
|
||||
1
module/main.mjs
Normal file
1
module/main.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "./hooks/init.mjs";
|
||||
12
module/settings/world.mjs
Normal file
12
module/settings/world.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { __ID__ } from "../consts.mjs";
|
||||
|
||||
export function registerWorldSettings() {
|
||||
game.settings.register(__ID__, `canPlayersManageAttributes`, {
|
||||
name: `taf.settings.canPlayersManageAttributes.name`,
|
||||
hint: `taf.settings.canPlayersManageAttributes.hint`,
|
||||
config: true,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
scope: `world`,
|
||||
});
|
||||
};
|
||||
|
|
@ -12,10 +12,10 @@ const augmentedProps = new Set([
|
|||
]);
|
||||
|
||||
/** @type {Console} */
|
||||
globalThis.Logger = new Proxy(console, {
|
||||
export const Logger = new Proxy(console, {
|
||||
get(target, prop, _receiver) {
|
||||
if (augmentedProps.has(prop)) {
|
||||
return (...args) => target[prop](game.system.id, `|`, ...args);
|
||||
return target[prop].bind(target, game.system.id, `|`);
|
||||
};
|
||||
return target[prop];
|
||||
},
|
||||
13
module/utils/toID.mjs
Normal file
13
module/utils/toID.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* A helper method that converts an arbitrary string into a format that can be
|
||||
* used as an object key easily.
|
||||
*
|
||||
* @param {string} text The text to convert
|
||||
* @returns The converted ID
|
||||
*/
|
||||
export function toID(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, `_`)
|
||||
.replace(/\W/g, ``);
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
async function rollDice() {
|
||||
const sidesOnDice = 6;
|
||||
|
||||
const answers = await DialogManager.ask({
|
||||
id: `eat-the-reich-dice-pool`,
|
||||
question: `Set up your dice pool:`,
|
||||
inputs: [
|
||||
{
|
||||
key: `statBase`,
|
||||
inputType: `number`,
|
||||
defaultValue: 2,
|
||||
label: `Number of Dice`,
|
||||
autofocus: true,
|
||||
},
|
||||
{
|
||||
key: `successThreshold`,
|
||||
inputType: `number`,
|
||||
defaultValue: 3,
|
||||
label: `Success Threshold (d${sidesOnDice} > X)`,
|
||||
},
|
||||
{
|
||||
key: `critsEnabled`,
|
||||
inputType: `checkbox`,
|
||||
defaultValue: true,
|
||||
label: `Enable Criticals`,
|
||||
},
|
||||
],
|
||||
});
|
||||
const { statBase, successThreshold, critsEnabled } = answers;
|
||||
let rollMode = game.settings.get(`core`, `rollMode`);
|
||||
|
||||
|
||||
|
||||
let successes = 0;
|
||||
let critsOnly = 0;
|
||||
const results = [];
|
||||
for (let i = statBase; i > 0; i--) {
|
||||
let r = new Roll(`1d${sidesOnDice}`);
|
||||
await r.evaluate();
|
||||
let classes = `roll die d6`;
|
||||
|
||||
// Determine the success count and class modifications for the chat
|
||||
if (r.total > successThreshold) {
|
||||
successes++;
|
||||
}
|
||||
else {
|
||||
classes += ` failure`
|
||||
}
|
||||
if (r.total === sidesOnDice && critsEnabled) {
|
||||
successes++;
|
||||
critsOnly++;
|
||||
classes += ` success`;
|
||||
}
|
||||
|
||||
results.push(`<li class="${classes}">${r.total}</li>`);
|
||||
}
|
||||
|
||||
let content = `Rolls:<div class="dice-tooltip"><ol class="dice-rolls">${results.join(``)}</ol></div><hr>Successes: ${successes}<br>Crits: ${critsOnly}`;
|
||||
|
||||
|
||||
if (rollMode === CONST.DICE_ROLL_MODES.BLIND) {
|
||||
ui.notifications.warn(`Cannot make a blind roll from the macro, rolling with mode "Private GM Roll" instead`);
|
||||
rollMode = CONST.DICE_ROLL_MODES.PRIVATE;
|
||||
}
|
||||
|
||||
const chatData = ChatMessage.applyRollMode(
|
||||
{
|
||||
title: `Dice Pool`,
|
||||
content,
|
||||
},
|
||||
rollMode,
|
||||
);
|
||||
|
||||
await ChatMessage.implementation.create(chatData);
|
||||
}
|
||||
|
||||
rollDice()
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { SystemIcon } from "./icon.mjs";
|
||||
import { SystemIncrementer } from "./incrementer.mjs";
|
||||
import { SystemRange } from "./range.mjs";
|
||||
|
||||
/**
|
||||
* A list of element classes to register, expects all of them to have a static
|
||||
* property of "elementName" that is the namespaced name that the component will
|
||||
* be registered under. Any elements that are formAssociated have their name added
|
||||
* to the "CONFIG.CACHE.componentListeners" array and should be listened to for
|
||||
* "change" events in sheets.
|
||||
*/
|
||||
const components = [
|
||||
SystemIcon,
|
||||
SystemIncrementer,
|
||||
SystemRange,
|
||||
];
|
||||
|
||||
export function registerCustomComponents() {
|
||||
(CONFIG.CACHE ??= {}).componentListeners ??= [];
|
||||
for (const component of components) {
|
||||
if (!window.customElements.get(component.elementName)) {
|
||||
console.debug(`${game.system.id} | Registering component "${component.elementName}"`);
|
||||
window.customElements.define(
|
||||
component.elementName,
|
||||
component,
|
||||
);
|
||||
if (component.formAssociated) {
|
||||
CONFIG.CACHE.componentListeners.push(component.elementName);
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { StyledShadowElement } from "./mixins/Styles.mjs";
|
||||
|
||||
/**
|
||||
Attributes:
|
||||
@property {string} name - The name of the icon, takes precedence over the path
|
||||
@property {string} path - The path of the icon file
|
||||
*/
|
||||
export class SystemIcon extends StyledShadowElement(HTMLElement) {
|
||||
static elementName = `dd-icon`;
|
||||
static formAssociated = false;
|
||||
|
||||
/* Stuff for the mixin to use */
|
||||
static _stylePath = ``;
|
||||
|
||||
|
||||
static _cache = new Map();
|
||||
#container;
|
||||
/** @type {null | string} */
|
||||
_name;
|
||||
/** @type {null | string} */
|
||||
_path;
|
||||
|
||||
/* Stored IDs for all of the hooks that are in this component */
|
||||
#svgHmr;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// this._shadow = this.attachShadow({ mode: `open`, delegatesFocus: true });
|
||||
|
||||
this.#container = document.createElement(`div`);
|
||||
this._shadow.appendChild(this.#container);
|
||||
};
|
||||
|
||||
_mounted = false;
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._mounted) { return }
|
||||
|
||||
this._name = this.getAttribute(`name`);
|
||||
this._path = this.getAttribute(`path`);
|
||||
|
||||
/*
|
||||
This converts all of the double-dash prefixed properties on the element to
|
||||
CSS variables so that they don't all need to be provided by doing style=""
|
||||
*/
|
||||
for (const attrVar of this.attributes) {
|
||||
if (attrVar.name?.startsWith(`var:`)) {
|
||||
const prop = attrVar.name.replace(`var:`, ``);
|
||||
this.style.setProperty(`--` + prop, attrVar.value);
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Try to retrieve the icon if it isn't present, try the path then default to
|
||||
the slot content, as then we can have a default per-icon usage
|
||||
*/
|
||||
let content;
|
||||
if (this._name) {
|
||||
content = await this.#getIcon(`./systems/dotdungeon/assets/${this._name}.svg`);
|
||||
};
|
||||
|
||||
if (this._path && !content) {
|
||||
content = await this.#getIcon(this._path);
|
||||
};
|
||||
|
||||
if (content) {
|
||||
this.#container.appendChild(content.cloneNode(true));
|
||||
};
|
||||
|
||||
/*
|
||||
This is so that when we get an HMR event from Foundry we can appropriately
|
||||
handle it using our logic to update the component and the icon cache.
|
||||
*/
|
||||
if (game.settings.get(game.system.id, `devMode`)) {
|
||||
this.#svgHmr = Hooks.on(`${game.system.id}-hmr:svg`, (iconName, data) => {
|
||||
if (this._name === iconName || this._path?.endsWith(data.path)) {
|
||||
const svg = this.#parseSVG(data.content);
|
||||
this.constructor._cache.set(iconName, svg);
|
||||
this.#container.replaceChildren(svg.cloneNode(true));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
this._mounted = true;
|
||||
};
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (!this._mounted) { return }
|
||||
|
||||
Hooks.off(`${game.system.id}-hmr:svg`, this.#svgHmr);
|
||||
|
||||
this._mounted = false;
|
||||
};
|
||||
|
||||
async #getIcon(path) {
|
||||
// Cache hit!
|
||||
if (this.constructor._cache.has(path)) {
|
||||
Logger.debug(`Icon ${path} cache hit`);
|
||||
return this.constructor._cache.get(path);
|
||||
};
|
||||
|
||||
const r = await fetch(path);
|
||||
switch (r.status) {
|
||||
case 200:
|
||||
case 201:
|
||||
break;
|
||||
default:
|
||||
Logger.error(`Failed to fetch icon: ${path}`);
|
||||
return;
|
||||
};
|
||||
|
||||
Logger.debug(`Adding icon ${path} to the cache`);
|
||||
const svg = this.#parseSVG(await r.text());
|
||||
this.constructor._cache.set(path, svg);
|
||||
return svg;
|
||||
};
|
||||
|
||||
/** Takes an SVG string and returns it as a DOM node */
|
||||
#parseSVG(content) {
|
||||
const temp = document.createElement(`div`);
|
||||
temp.innerHTML = content;
|
||||
return temp.querySelector(`svg`);
|
||||
};
|
||||
};
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import { StyledShadowElement } from "./mixins/Styles.mjs";
|
||||
import { SystemIcon } from "./icon.mjs";
|
||||
|
||||
/**
|
||||
Attributes:
|
||||
@property {string} name - The path to the value to update
|
||||
@property {number} value - The actual value of the input
|
||||
@property {number} min - The minimum value of the input
|
||||
@property {number} max - The maximum value of the input
|
||||
@property {number?} smallStep - The step size used for the buttons and arrow keys
|
||||
@property {number?} largeStep - The step size used for the buttons + Ctrl and page up / down
|
||||
|
||||
Styling:
|
||||
- `--height`: Controls the height of the element + the width of the buttons (default: 1.25rem)
|
||||
- `--width`: Controls the width of the number input (default 50px)
|
||||
*/
|
||||
export class SystemIncrementer extends StyledShadowElement(HTMLElement) {
|
||||
static elementName = `dd-incrementer`;
|
||||
static formAssociated = true;
|
||||
|
||||
static _stylePath = `v1/components/incrementer.scss`;
|
||||
|
||||
_internals;
|
||||
#input;
|
||||
|
||||
_min;
|
||||
_max;
|
||||
_smallStep;
|
||||
_largeStep;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Form internals
|
||||
this._internals = this.attachInternals();
|
||||
this._internals.role = `spinbutton`;
|
||||
};
|
||||
|
||||
get form() {
|
||||
return this._internals.form;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.getAttribute(`name`);
|
||||
}
|
||||
set name(value) {
|
||||
this.setAttribute(`name`, value);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.getAttribute(`value`);
|
||||
};
|
||||
set value(value) {
|
||||
this.setAttribute(`value`, value);
|
||||
};
|
||||
|
||||
get type() {
|
||||
return `number`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.replaceChildren();
|
||||
|
||||
// Attribute parsing / registration
|
||||
const value = this.getAttribute(`value`);
|
||||
this._min = parseInt(this.getAttribute(`min`) ?? 0);
|
||||
this._max = parseInt(this.getAttribute(`max`) ?? 0);
|
||||
this._smallStep = parseInt(this.getAttribute(`smallStep`) ?? 1);
|
||||
this._largeStep = parseInt(this.getAttribute(`largeStep`) ?? 5);
|
||||
|
||||
this._internals.ariaValueMin = this._min;
|
||||
this._internals.ariaValueMax = this._max;
|
||||
|
||||
const container = document.createElement(`div`);
|
||||
|
||||
// The input that the user can see / modify
|
||||
const input = document.createElement(`input`);
|
||||
this.#input = input;
|
||||
input.type = `number`;
|
||||
input.ariaHidden = true;
|
||||
input.min = this.getAttribute(`min`);
|
||||
input.max = this.getAttribute(`max`);
|
||||
input.addEventListener(`change`, this.#updateValue.bind(this));
|
||||
input.value = value;
|
||||
|
||||
// plus button
|
||||
const increment = document.createElement(SystemIcon.elementName);
|
||||
increment.setAttribute(`name`, `ui/plus`);
|
||||
increment.setAttribute(`var:size`, `0.75rem`);
|
||||
increment.setAttribute(`var:fill`, `currentColor`);
|
||||
increment.ariaHidden = true;
|
||||
increment.classList.value = `increment`;
|
||||
increment.addEventListener(`mousedown`, this.#increment.bind(this));
|
||||
|
||||
// minus button
|
||||
const decrement = document.createElement(SystemIcon.elementName);
|
||||
decrement.setAttribute(`name`, `ui/minus`);
|
||||
decrement.setAttribute(`var:size`, `0.75rem`);
|
||||
decrement.setAttribute(`var:fill`, `currentColor`);
|
||||
decrement.ariaHidden = true;
|
||||
decrement.classList.value = `decrement`;
|
||||
decrement.addEventListener(`mousedown`, this.#decrement.bind(this));
|
||||
|
||||
// Construct the DOM
|
||||
container.appendChild(decrement);
|
||||
container.appendChild(input);
|
||||
container.appendChild(increment);
|
||||
this._shadow.appendChild(container);
|
||||
|
||||
/*
|
||||
This converts all of the namespace prefixed properties on the element to
|
||||
CSS variables so that they don't all need to be provided by doing style=""
|
||||
*/
|
||||
for (const attrVar of this.attributes) {
|
||||
if (attrVar.name?.startsWith(`var:`)) {
|
||||
const prop = attrVar.name.replace(`var:`, ``);
|
||||
this.style.setProperty(`--` + prop, attrVar.value);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
#updateValue() {
|
||||
let value = parseInt(this.#input.value);
|
||||
if (this.getAttribute(`min`)) {
|
||||
value = Math.max(this._min, value);
|
||||
}
|
||||
if (this.getAttribute(`max`)) {
|
||||
value = Math.min(this._max, value);
|
||||
}
|
||||
this.#input.value = value;
|
||||
this.value = value;
|
||||
this.dispatchEvent(new Event(`change`, { bubbles: true }));
|
||||
};
|
||||
|
||||
/** @param {Event} $e */
|
||||
#increment($e) {
|
||||
$e.preventDefault();
|
||||
let value = parseInt(this.#input.value);
|
||||
value += $e.ctrlKey ? this._largeStep : this._smallStep;
|
||||
this.#input.value = value;
|
||||
this.#updateValue();
|
||||
};
|
||||
|
||||
/** @param {Event} $e */
|
||||
#decrement($e) {
|
||||
$e.preventDefault();
|
||||
let value = parseInt(this.#input.value);
|
||||
value -= $e.ctrlKey ? this._largeStep : this._smallStep;
|
||||
this.#input.value = value;
|
||||
this.#updateValue();
|
||||
};
|
||||
};
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* @param {HTMLElement} Base
|
||||
*/
|
||||
export function StyledShadowElement(Base) {
|
||||
return class extends Base {
|
||||
/**
|
||||
* The path to the CSS that is loaded
|
||||
* @type {string}
|
||||
*/
|
||||
static _stylePath;
|
||||
|
||||
/**
|
||||
* The stringified CSS to use
|
||||
* @type {string}
|
||||
*/
|
||||
static _styles;
|
||||
|
||||
/**
|
||||
* The HTML element of the stylesheet
|
||||
* @type {HTMLStyleElement}
|
||||
*/
|
||||
_style;
|
||||
|
||||
/** @type {ShadowRoot} */
|
||||
_shadow;
|
||||
|
||||
/**
|
||||
* The hook ID for this element's CSS hot reload
|
||||
* @type {number}
|
||||
*/
|
||||
#cssHmr;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._shadow = this.attachShadow({ mode: `open` });
|
||||
this._style = document.createElement(`style`);
|
||||
this._shadow.appendChild(this._style);
|
||||
};
|
||||
|
||||
#mounted = false;
|
||||
connectedCallback() {
|
||||
if (this.#mounted) { return }
|
||||
|
||||
this._getStyles();
|
||||
|
||||
if (game.settings.get(`dotdungeon`, `devMode`)) {
|
||||
this.#cssHmr = Hooks.on(`dd-hmr:css`, (data) => {
|
||||
if (data.path.endsWith(this.constructor._stylePath)) {
|
||||
this._style.innerHTML = data.content;
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
this.#mounted = true;
|
||||
};
|
||||
|
||||
disconnectedCallback() {
|
||||
if (!this.#mounted) { return }
|
||||
if (this.#cssHmr != null) {
|
||||
Hooks.off(`dd-hmr:css`, this.#cssHmr);
|
||||
this.#cssHmr = null;
|
||||
};
|
||||
this.#mounted = false;
|
||||
};
|
||||
|
||||
_getStyles() {
|
||||
if (this.constructor._styles) {
|
||||
this._style.innerHTML = this.constructor._styles;
|
||||
} else {
|
||||
fetch(`./systems/dotdungeon/.styles/${this.constructor._stylePath}`)
|
||||
.then(r => r.text())
|
||||
.then(t => {
|
||||
this.constructor._styles = t;
|
||||
this._style.innerHTML = t;
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { StyledShadowElement } from "./mixins/Styles.mjs";
|
||||
|
||||
/**
|
||||
Attributes:
|
||||
@property {string} name - The path to the value to update in the datamodel
|
||||
@property {number} value - The actual value of the input
|
||||
@property {number} max - The maximum value that this range has
|
||||
|
||||
@extends {HTMLElement}
|
||||
*/
|
||||
export class SystemRange
|
||||
extends StyledShadowElement(
|
||||
HTMLElement,
|
||||
{ mode: `open`, delegatesFocus: true },
|
||||
) {
|
||||
static elementName = `dd-range`;
|
||||
static formAssociated = true;
|
||||
|
||||
static observedAttributes = [`max`];
|
||||
|
||||
static _stylePath = `v3/components/range.css`;
|
||||
|
||||
_internals;
|
||||
#input;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Form internals
|
||||
this._internals = this.attachInternals();
|
||||
this._internals.role = `spinbutton`;
|
||||
};
|
||||
|
||||
get form() {
|
||||
return this._internals.form;
|
||||
};
|
||||
|
||||
get name() {
|
||||
return this.getAttribute(`name`);
|
||||
};
|
||||
set name(value) {
|
||||
this.setAttribute(`name`, value);
|
||||
};
|
||||
|
||||
get value() {
|
||||
try {
|
||||
return parseInt(this.getAttribute(`value`));
|
||||
} catch {
|
||||
throw new Error(`Failed to parse attribute: "value" - Make sure it's an integer`);
|
||||
};
|
||||
};
|
||||
set value(value) {
|
||||
this.setAttribute(`value`, value);
|
||||
};
|
||||
|
||||
get max() {
|
||||
try {
|
||||
return parseInt(this.getAttribute(`max`));
|
||||
} catch {
|
||||
throw new Error(`Failed to parse attribute: "max" - Make sure it's an integer`);
|
||||
};
|
||||
};
|
||||
set max(value) {
|
||||
this.setAttribute(`max`, value);
|
||||
};
|
||||
|
||||
get type() {
|
||||
return `number`;
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Attribute validation
|
||||
if (!this.hasAttribute(`max`)) {
|
||||
throw new Error(`dotdungeon | Cannot have a range without a maximum value`);
|
||||
};
|
||||
|
||||
// Keyboard accessible input for the thing
|
||||
this.#input = document.createElement(`input`);
|
||||
this.#input.type = `number`;
|
||||
this.#input.min = 0;
|
||||
this.#input.max = this.max;
|
||||
this.#input.value = this.value;
|
||||
this.#input.addEventListener(`change`, () => {
|
||||
const inputValue = parseInt(this.#input.value);
|
||||
if (inputValue === this.value) { return };
|
||||
this._updateValue.bind(this)(Math.sign(this.value - inputValue));
|
||||
this._updateValue(Math.sign(this.value - inputValue));
|
||||
});
|
||||
this._shadow.appendChild(this.#input);
|
||||
|
||||
// Shadow-DOM construction
|
||||
this._elements = new Array(this.max);
|
||||
const container = document.createElement(`div`);
|
||||
container.classList.add(`container`);
|
||||
|
||||
// Creating the node for filled content
|
||||
const filledContainer = document.createElement(`div`);
|
||||
filledContainer.classList.add(`range-increment`, `filled`);
|
||||
const filledNode = this.querySelector(`[slot="filled"]`);
|
||||
if (filledNode) { filledContainer.appendChild(filledNode) };
|
||||
|
||||
const emptyContainer = document.createElement(`div`);
|
||||
emptyContainer.classList.add(`range-increment`, `empty`);
|
||||
const emptyNode = this.querySelector(`[slot="empty"]`);
|
||||
if (emptyNode) { emptyContainer.appendChild(emptyNode) };
|
||||
|
||||
this._elements.fill(filledContainer, 0, this.value);
|
||||
this._elements.fill(emptyContainer, this.value);
|
||||
container.append(...this._elements.map((slot, i) => {
|
||||
const node = slot.cloneNode(true);
|
||||
node.setAttribute(`data-index`, i + 1);
|
||||
node.addEventListener(`click`, () => {
|
||||
const filled = node.classList.contains(`filled`);
|
||||
this._updateValue(filled ? -1 : 1);
|
||||
});
|
||||
return node;
|
||||
}));
|
||||
this._shadow.appendChild(container);
|
||||
|
||||
/*
|
||||
This converts all of the namespace prefixed properties on the element to
|
||||
CSS variables so that they don't all need to be provided by doing style=""
|
||||
*/
|
||||
for (const attrVar of this.attributes) {
|
||||
if (attrVar.name?.startsWith(`var:`)) {
|
||||
const prop = attrVar.name.replace(`var:`, ``);
|
||||
this.style.setProperty(`--` + prop, attrVar.value);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
_updateValue(delta) {
|
||||
this.value += delta;
|
||||
this.dispatchEvent(new Event(`change`, { bubbles: true }));
|
||||
};
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
|
||||
|
||||
/**
|
||||
* An object of Foundry-types to in-code Document classes.
|
||||
*/
|
||||
const classes = {};
|
||||
|
||||
/** The class that will be used if no type-specific class is defined */
|
||||
const defaultClass = ActiveEffect;
|
||||
|
||||
export const ActiveEffectProxy = createDocumentProxy(defaultClass, classes);
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export class Player extends Actor {
|
||||
getRollData() {
|
||||
return this.system;
|
||||
};
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export class PlayerData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields;
|
||||
return {
|
||||
content: new fields.HTMLField({
|
||||
blank: true,
|
||||
trim: true,
|
||||
initial: ``,
|
||||
}),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
|
||||
|
||||
/**
|
||||
* An object of Foundry-types to in-code Document classes.
|
||||
*/
|
||||
const classes = {};
|
||||
|
||||
/** The class that will be used if no type-specific class is defined */
|
||||
const defaultClass = Actor;
|
||||
|
||||
export const ActorProxy = createDocumentProxy(defaultClass, classes);
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
|
||||
|
||||
/**
|
||||
* An object of Foundry-types to in-code Document classes.
|
||||
*/
|
||||
const classes = {};
|
||||
|
||||
/** The class that will be used if no type-specific class is defined */
|
||||
const defaultClass = ChatMessage;
|
||||
|
||||
export const ChatMessageProxy = createDocumentProxy(defaultClass, classes);
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
|
||||
|
||||
/**
|
||||
* An object of Foundry-types to in-code Document classes.
|
||||
*/
|
||||
const classes = {};
|
||||
|
||||
/** The class that will be used if no type-specific class is defined */
|
||||
const defaultClass = Item;
|
||||
|
||||
export const ItemProxy = createDocumentProxy(defaultClass, classes);
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { handlebarsLocalizer, localizer } from "../utils/localizer.mjs";
|
||||
import { options } from "./options.mjs";
|
||||
|
||||
export function registerHandlebarsHelpers() {
|
||||
const helperPrefix = game.system.id;
|
||||
|
||||
return {
|
||||
// MARK: Complex helpers
|
||||
[`${helperPrefix}-i18n`]: handlebarsLocalizer,
|
||||
[`${helperPrefix}-options`]: options,
|
||||
|
||||
// MARK: Simple helpers
|
||||
[`${helperPrefix}-stringify`]: v => JSON.stringify(v, null, ` `),
|
||||
[`${helperPrefix}-empty`]: v => v.length == 0,
|
||||
[`${helperPrefix}-set-has`]: (s, k) => s.has(k),
|
||||
[`${helperPrefix}-empty-state`]: (v) => v ?? localizer(`${game.system.id}.common.empty`),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { localizer } from "../utils/localizer.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} Option
|
||||
* @property {string} [label]
|
||||
* @property {string|number} value
|
||||
* @property {boolean} [disabled]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string | number} selected
|
||||
* @param {Array<Option | string>} opts
|
||||
*/
|
||||
export function options(selected, opts, meta) {
|
||||
const { localize = false } = meta.hash;
|
||||
selected = Handlebars.escapeExpression(selected);
|
||||
const htmlOptions = [];
|
||||
|
||||
for (let opt of opts) {
|
||||
if (foundry.utils.getType(opt) === `string`) {
|
||||
opt = { label: opt, value: opt };
|
||||
};
|
||||
opt.value = Handlebars.escapeExpression(opt.value);
|
||||
htmlOptions.push(
|
||||
`<option
|
||||
value="${opt.value}"
|
||||
${selected === opt.value ? `selected` : ``}
|
||||
${opt.disabled ? `disabled` : ``}
|
||||
>
|
||||
${localize ? localizer(opt.label) : opt.label}
|
||||
</option>`,
|
||||
);
|
||||
};
|
||||
return htmlOptions.join(`\n`);
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
const loaders = {
|
||||
svg(data) {
|
||||
const iconName = data.path.split(`/`).slice(-1)[0].slice(0, -4);
|
||||
Logger.debug(`hot-reloading icon: ${iconName}`);
|
||||
Hooks.call(`${game.system.id}-hmr:svg`, iconName, data);
|
||||
},
|
||||
js() {window.location.reload()},
|
||||
mjs() {window.location.reload()},
|
||||
css(data) {
|
||||
Logger.debug(`Hot-reloading CSS: ${data.path}`);
|
||||
Hooks.call(`${game.system.id}-hmr:css`, data);
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.on(`hotReload`, async (data) => {
|
||||
if (!loaders[data.extension]) {return}
|
||||
return loaders[data.extension](data);
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
Hooks.on(`renderChatMessage`, (msg, html) => {
|
||||
|
||||
// Short-Circuit when the flag isn't set for the message
|
||||
if (msg.getFlag(`taf`, `rollModedContent`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const featureFlagEnabled = taf.FEATURES.ROLL_MODE_CONTENT;
|
||||
|
||||
const contentElement = html.find(`.message-content`)[0];
|
||||
let content = contentElement.innerHTML;
|
||||
if (featureFlagEnabled && msg.blind && !game.user.isGM) {
|
||||
content = content.replace(/-=.*?=-/gm, `??`);
|
||||
} else {
|
||||
content = content.replace(/-=|=-/gm, ``);
|
||||
}
|
||||
contentElement.innerHTML = content;
|
||||
});
|
||||
64
src/main.mjs
64
src/main.mjs
|
|
@ -1,64 +0,0 @@
|
|||
// Document Imports
|
||||
import { ActiveEffectProxy } from "./documents/ActiveEffect/_proxy.mjs";
|
||||
import { ActorProxy } from "./documents/Actor/_proxy.mjs";
|
||||
import { ChatMessageProxy } from "./documents/ChatMessage/_proxy.mjs";
|
||||
import { ItemProxy } from "./documents/Item/_proxy.mjs";
|
||||
|
||||
// DataModel Imports
|
||||
import { PlayerData } from "./documents/Actor/Player/Model.mjs";
|
||||
|
||||
// Hook Imports
|
||||
import "./hooks/renderChatMessage.mjs";
|
||||
import "./hooks/hotReload.mjs";
|
||||
|
||||
// Misc Imports
|
||||
import "./utils/globalTaf.mjs";
|
||||
import "./utils/logger.mjs";
|
||||
import "./utils/DialogManager.mjs";
|
||||
import { registerCustomComponents } from "./components/_index.mjs";
|
||||
import { registerHandlebarsHelpers } from "./helpers/_index.mjs";
|
||||
import { registerSettings } from "./settings/_index.mjs";
|
||||
import { registerSheets } from "./sheets/_index.mjs";
|
||||
|
||||
// MARK: init hook
|
||||
Hooks.once(`init`, () => {
|
||||
Logger.info(`Initializing`);
|
||||
CONFIG.ActiveEffect.legacyTransferral = false;
|
||||
|
||||
registerSettings();
|
||||
|
||||
// Data Models
|
||||
CONFIG.Actor.dataModels.player = PlayerData;
|
||||
|
||||
// Update document classes
|
||||
CONFIG.Actor.documentClass = ActorProxy;
|
||||
CONFIG.Item.documentClass = ItemProxy;
|
||||
CONFIG.ActiveEffect.documentClass = ActiveEffectProxy;
|
||||
CONFIG.ChatMessage.documentClass = ChatMessageProxy;
|
||||
registerSheets();
|
||||
|
||||
registerHandlebarsHelpers();
|
||||
|
||||
registerCustomComponents();
|
||||
});
|
||||
|
||||
|
||||
// MARK: ready hook
|
||||
Hooks.once(`ready`, () => {
|
||||
Logger.info(`Ready`);
|
||||
|
||||
let defaultTab = game.settings.get(game.system.id, `defaultTab`);
|
||||
if (defaultTab) {
|
||||
if (!ui.sidebar?.tabs?.[defaultTab]) {
|
||||
Logger.error(`Couldn't find a sidebar tab with ID:`, defaultTab);
|
||||
} else {
|
||||
Logger.debug(`Switching sidebar tab to:`, defaultTab);
|
||||
ui.sidebar.tabs[defaultTab].activate();
|
||||
};
|
||||
};
|
||||
|
||||
if (game.settings.get(game.system.id, `devMode`)) {
|
||||
console.log(`%cFeature Flags:`, `color: #00aa00; font-style: bold; font-size: 1.5rem;`);
|
||||
Logger.table(taf.FEATURES);
|
||||
};
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { registerClientSettings } from "./client_settings.mjs";
|
||||
import { registerDevSettings } from "./dev_settings.mjs";
|
||||
import { registerWorldSettings } from "./world_settings.mjs";
|
||||
|
||||
export function registerSettings() {
|
||||
Logger.debug(`Registering settings`);
|
||||
registerClientSettings();
|
||||
registerWorldSettings();
|
||||
registerDevSettings();
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export function registerClientSettings() {
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
export function registerDevSettings() {
|
||||
const isLocalhost = window.location.hostname === `localhost`;
|
||||
|
||||
game.settings.register(game.system.id, `devMode`, {
|
||||
name: `Dev Mode?`,
|
||||
scope: `client`,
|
||||
type: Boolean,
|
||||
config: isLocalhost,
|
||||
default: false,
|
||||
requiresReload: false,
|
||||
});
|
||||
|
||||
game.settings.register(game.system.id, `defaultTab`, {
|
||||
name: `Default Sidebar Tab`,
|
||||
scope: `client`,
|
||||
type: String,
|
||||
config: isLocalhost,
|
||||
requiresReload: false,
|
||||
onChange(value) {
|
||||
if (!ui.sidebar.tabs[value]) {
|
||||
ui.notifications.warn(`"${value}" cannot be found in the sidebar tabs, it may not work at reload.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export function registerWorldSettings() {
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { SizeStorable } from "../mixins/SizeStorable.mjs";
|
||||
|
||||
export class PlayerSheetv1 extends SizeStorable(ActorSheet) {
|
||||
static get defaultOptions() {
|
||||
let opts = foundry.utils.mergeObject(
|
||||
super.defaultOptions,
|
||||
{
|
||||
template: `systems/${game.system.id}/templates/Player/v1/main.hbs`,
|
||||
classes: [],
|
||||
},
|
||||
);
|
||||
opts.classes = [`actor--player`, `style-v1`];
|
||||
return opts;
|
||||
};
|
||||
|
||||
async getData() {
|
||||
const ctx = {};
|
||||
|
||||
ctx.editable = this.isEditable;
|
||||
|
||||
const actor = ctx.actor = this.actor;
|
||||
ctx.system = actor.system;
|
||||
ctx.enriched = { system: {} };
|
||||
ctx.enriched.system.content = await TextEditor.enrichHTML(actor.system.content);
|
||||
|
||||
return ctx;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { PlayerSheetv1 } from "./Player/v1.mjs";
|
||||
|
||||
export function registerSheets() {
|
||||
Logger.debug(`Registering sheets`);
|
||||
|
||||
Actors.registerSheet(game.system.id, PlayerSheetv1, {
|
||||
makeDefault: true,
|
||||
types: [`player`],
|
||||
label: `Hello`,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { DialogManager } from "../../utils/DialogManager.mjs";
|
||||
|
||||
/**
|
||||
* This mixin allows making a class so that it can store the width/height data
|
||||
* to the sheet or localhost in order to make using the text sheets a lil nicer.
|
||||
*
|
||||
* @param {ActorSheet|ItemSheet} cls The Sheet class to augment
|
||||
* @returns The augmented class
|
||||
*/
|
||||
export function SizeStorable(cls) {
|
||||
|
||||
// Don't augment class when the feature isn't enabled
|
||||
if (!taf.FEATURES.STORABLE_SHEET_SIZE) {
|
||||
return cls;
|
||||
}
|
||||
|
||||
return class SizeStorableClass extends cls {
|
||||
constructor(doc, opts) {
|
||||
|
||||
/*
|
||||
Find the saved size of the sheet, it takes the following order of precedence
|
||||
from highest to lowest:
|
||||
- Locally saved
|
||||
- Default values on actor
|
||||
- Default values from constructor
|
||||
*/
|
||||
/** @type {string|undefined} */
|
||||
let size = localStorage.getItem(`${game.system.id}.size:${doc.uuid}`);
|
||||
size ??= doc.getFlag(game.system.id, `size`);
|
||||
|
||||
// Apply the saved value to the options
|
||||
if (size) {
|
||||
const [ width, height ] = size.split(`,`);
|
||||
opts.width = width;
|
||||
opts.height = height;
|
||||
};
|
||||
|
||||
super(doc, opts);
|
||||
};
|
||||
|
||||
get hasLocalSize() {
|
||||
return localStorage.getItem(`${game.system.id}.size:${this.object.uuid}`) != null;
|
||||
};
|
||||
|
||||
get hasGlobalSize() {
|
||||
return this.object.getFlag(game.system.id, `size`) != null;
|
||||
};
|
||||
|
||||
_getHeaderButtons() {
|
||||
return [
|
||||
{
|
||||
class: `size-save`,
|
||||
icon: `fa-solid fa-floppy-disk`,
|
||||
label: `Save Size`,
|
||||
onclick: () => {
|
||||
|
||||
const buttons = {
|
||||
saveGlobal: {
|
||||
label: `Save Global Size`,
|
||||
callback: () => {
|
||||
this.object.setFlag(
|
||||
game.system.id,
|
||||
`size`,
|
||||
`${this.position.width},${this.position.height}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
saveLocal: {
|
||||
label: `Save For Me Only`,
|
||||
callback: () => {
|
||||
localStorage.setItem(
|
||||
`${game.system.id}.size:${this.object.uuid}`,
|
||||
`${this.position.width},${this.position.height}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add resets if there is a size already
|
||||
if (this.hasGlobalSize) {
|
||||
buttons.resetGlobal = {
|
||||
label: `Reset Global Size`,
|
||||
callback: () => {
|
||||
this.object.unsetFlag(game.system.id, `size`);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (this.hasLocalSize) {
|
||||
buttons.resetLocal = {
|
||||
label: `Reset Size For Me Only`,
|
||||
callback: () => {
|
||||
localStorage.removeItem(`${game.system.id}.size:${this.object.uuid}`);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// When a non-GM is using this system, we only want to save local sizes
|
||||
if (!game.user.isGM) {
|
||||
delete buttons.saveGlobal;
|
||||
delete buttons.resetGlobal;
|
||||
};
|
||||
|
||||
DialogManager.createOrFocus(
|
||||
`${this.object.uuid}:size-save`,
|
||||
{
|
||||
title: `Save size of sheet: ${this.title}`,
|
||||
content: `Saving the size of this sheet will cause it to open at the size it is when you press the save button`,
|
||||
buttons,
|
||||
render: (html) => {
|
||||
const el = html[2];
|
||||
el.style = `display: grid; grid-template-columns: 1fr 1fr; gap: 8px;`;
|
||||
},
|
||||
},
|
||||
{
|
||||
jQuery: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
...super._getHeaderButtons(),
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import { localizer } from "./localizer.mjs";
|
||||
|
||||
/**
|
||||
* A utility class that allows managing Dialogs that are created for various
|
||||
* purposes such as deleting items, help popups, etc. This is a singleton class
|
||||
* that upon instantiating after the first time will just return the first instance
|
||||
*/
|
||||
export class DialogManager {
|
||||
|
||||
/** @type {Map<string, Dialog>} */
|
||||
static #dialogs = new Map();
|
||||
|
||||
/**
|
||||
* Focuses a dialog if it already exists, or creates a new one and renders it.
|
||||
*
|
||||
* @param {string} dialogId The ID to associate with the dialog, should be unique
|
||||
* @param {object} data The data to pass to the Dialog constructor
|
||||
* @param {DialogOptions} opts The options to pass to the Dialog constructor
|
||||
* @returns {Dialog} The Dialog instance
|
||||
*/
|
||||
static async createOrFocus(dialogId, data, opts = {}) {
|
||||
if (DialogManager.#dialogs.has(dialogId)) {
|
||||
const dialog = DialogManager.#dialogs.get(dialogId);
|
||||
dialog.bringToTop();
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/*
|
||||
This makes sure that if I provide a close function as a part of the data,
|
||||
that the dialog still gets removed from the set once it's closed, otherwise
|
||||
it could lead to dangling references that I don't care to keep. Or if I don't
|
||||
provide the close function, it just sets the function as there isn't anything
|
||||
extra that's needed to be called.
|
||||
*/
|
||||
if (data?.close) {
|
||||
const provided = data.close;
|
||||
data.close = () => {
|
||||
DialogManager.#dialogs.delete(dialogId);
|
||||
provided();
|
||||
};
|
||||
} else {
|
||||
data.close = () => DialogManager.#dialogs.delete(dialogId);
|
||||
};
|
||||
|
||||
// Create the Dialog with the modified data
|
||||
const dialog = new Dialog(data, opts);
|
||||
DialogManager.#dialogs.set(dialogId, dialog);
|
||||
dialog.render(true);
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes a dialog if it is rendered
|
||||
*
|
||||
* @param {string} dialogId The ID of the dialog to close
|
||||
*/
|
||||
static async close(dialogId) {
|
||||
const dialog = DialogManager.#dialogs.get(dialogId);
|
||||
dialog?.close();
|
||||
};
|
||||
|
||||
static async helpDialog(
|
||||
helpId,
|
||||
helpContent,
|
||||
helpTitle = `dotdungeon.common.help`,
|
||||
localizationData = {},
|
||||
) {
|
||||
DialogManager.createOrFocus(
|
||||
helpId,
|
||||
{
|
||||
title: localizer(helpTitle, localizationData),
|
||||
content: localizer(helpContent, localizationData),
|
||||
buttons: {},
|
||||
},
|
||||
{ resizable: true },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the user to provide a simple piece of information, this is primarily
|
||||
* intended to be used within macros so that it can have better info gathering
|
||||
* as needed. This returns an object of input keys/labels to the value the user
|
||||
* input for that label, if there is only one input, this will return the value
|
||||
* without an object wrapper, allowing for easier access.
|
||||
*/
|
||||
static async ask(data, opts = {}) {
|
||||
if (!data.id) {
|
||||
throw new Error(`Asking the user for input must contain an ID`);
|
||||
}
|
||||
if (!data.inputs.length) {
|
||||
throw new Error(`Must include at least one input specification when prompting the user`);
|
||||
}
|
||||
|
||||
let autofocusClaimed = false;
|
||||
for (const i of data.inputs) {
|
||||
i.id ??= foundry.utils.randomID(16);
|
||||
i.inputType ??= `text`;
|
||||
|
||||
// Only ever allow one input to claim autofocus
|
||||
i.autofocus &&= !autofocusClaimed;
|
||||
autofocusClaimed ||= i.autofocus;
|
||||
|
||||
// Set the value's attribute name if it isn't specified explicitly
|
||||
if (!i.valueAttribute) {
|
||||
switch (i.inputType) {
|
||||
case `checkbox`:
|
||||
i.valueAttribute = `checked`;
|
||||
break;
|
||||
default:
|
||||
i.valueAttribute = `value`;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
opts.jQuery = true;
|
||||
data.default ??= `confirm`;
|
||||
data.title ??= `System Question`;
|
||||
|
||||
data.content = await renderTemplate(
|
||||
`systems/${game.system.id}/templates/Dialogs/ask.hbs`,
|
||||
data,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
DialogManager.createOrFocus(
|
||||
data.id,
|
||||
{
|
||||
...data,
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: `Confirm`,
|
||||
callback: (html) => {
|
||||
const answers = {};
|
||||
|
||||
/*
|
||||
Retrieve the answer for every input provided using the ID
|
||||
determined during initial data prep, and assign the value
|
||||
to the property of the label in the object.
|
||||
*/
|
||||
for (const i of data.inputs) {
|
||||
const element = html.find(`#${i.id}`)[0];
|
||||
let value = element.value;
|
||||
switch (i.inputType) {
|
||||
case `number`:
|
||||
value = parseFloat(value);
|
||||
break;
|
||||
case `checkbox`:
|
||||
value = element.checked;
|
||||
break;
|
||||
}
|
||||
Logger.debug(`Ask response: ${value} (type: ${typeof value})`);
|
||||
answers[i.key ?? i.label] = value;
|
||||
if (data.inputs.length === 1) {
|
||||
resolve(value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resolve(answers);
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: `Cancel`,
|
||||
callback: () => reject(`User cancelled the prompt`),
|
||||
},
|
||||
},
|
||||
},
|
||||
opts,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
static get size() {
|
||||
return DialogManager.#dialogs.size;
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.DialogManager = DialogManager;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
export function createDocumentProxy(defaultClass, classes) {
|
||||
// eslint-disable-next-line func-names
|
||||
return new Proxy(function () {}, {
|
||||
construct(_target, args) {
|
||||
const [data] = args;
|
||||
|
||||
if (!classes[data.type]) {
|
||||
return new defaultClass(...args);
|
||||
}
|
||||
|
||||
return new classes[data.type](...args);
|
||||
},
|
||||
get(_target, prop, _receiver) {
|
||||
|
||||
if ([`create`, `createDocuments`].includes(prop)) {
|
||||
return (data, options) => {
|
||||
if (data.constructor === Array) {
|
||||
return data.map(i => this.constructor.create(i, options));
|
||||
}
|
||||
|
||||
if (!classes[data.type]) {
|
||||
return defaultClass.create(data, options);
|
||||
}
|
||||
|
||||
return classes[data.type].create(data, options);
|
||||
};
|
||||
};
|
||||
|
||||
if (prop == Symbol.hasInstance) {
|
||||
return (instance) => {
|
||||
if (instance instanceof defaultClass) {return true}
|
||||
return Object.values(classes).some(i => instance instanceof i);
|
||||
};
|
||||
};
|
||||
|
||||
return defaultClass[prop];
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export function hideMessageText(content) {
|
||||
const hideContent = taf.FEATURES.ROLL_MODE_CONTENT;
|
||||
if (hideContent) {
|
||||
return `-=${content}=-`;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { hideMessageText } from "./feature_flags/rollModeMessageContent.mjs";
|
||||
|
||||
Object.defineProperty(
|
||||
globalThis,
|
||||
`taf`,
|
||||
{
|
||||
value: Object.freeze({
|
||||
utils: Object.freeze({
|
||||
hideMessageText,
|
||||
}),
|
||||
FEATURES: Object.preventExtensions({
|
||||
ROLL_MODE_CONTENT: false,
|
||||
STORABLE_SHEET_SIZE: false,
|
||||
}),
|
||||
}),
|
||||
writable: false,
|
||||
},
|
||||
);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/** A handlebars helper that utilizes the recursive localizer */
|
||||
export function handlebarsLocalizer(key, ...args) {
|
||||
let data = args[0];
|
||||
if (args.length === 1) { data = args[0].hash }
|
||||
if (key instanceof Handlebars.SafeString) {key = key.toString()}
|
||||
const localized = localizer(key, data);
|
||||
return localized;
|
||||
};
|
||||
|
||||
/**
|
||||
* A localizer that allows recursively localizing strings so that localized strings
|
||||
* that want to use other localized strings can.
|
||||
*
|
||||
* @param {string} key The localization key to retrieve
|
||||
* @param {object?} args The arguments provided to the localizer for replacement
|
||||
* @param {number?} depth The current depth of the localizer
|
||||
* @returns The localized string
|
||||
*/
|
||||
export function localizer(key, args = {}, depth = 0) {
|
||||
/** @type {string} */
|
||||
let localized = game.i18n.format(key, args);
|
||||
const subkeys = localized.matchAll(/@(?<key>[a-zA-Z.]+)/gm);
|
||||
|
||||
// Short-cut to help prevent infinite recursion
|
||||
if (depth > 10) {
|
||||
return localized;
|
||||
};
|
||||
|
||||
/*
|
||||
Helps prevent localization on the same key so that we aren't doing excess work.
|
||||
*/
|
||||
const localizedSubkeys = new Map();
|
||||
for (const match of subkeys) {
|
||||
const subkey = match.groups.key;
|
||||
if (localizedSubkeys.has(subkey)) {continue}
|
||||
localizedSubkeys.set(subkey, localizer(subkey, args, depth + 1));
|
||||
};
|
||||
|
||||
return localized.replace(
|
||||
/@(?<key>[a-zA-Z.]+)/gm,
|
||||
(_fullMatch, subkey) => {
|
||||
return localizedSubkeys.get(subkey);
|
||||
},
|
||||
);
|
||||
};
|
||||
33
styles/Apps/AttributeManager.css
Normal file
33
styles/Apps/AttributeManager.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
.taf.AttributeManager {
|
||||
.attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attribute {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid rebeccapurple;
|
||||
border-radius: 4px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
styles/Apps/PlayerSheet.css
Normal file
57
styles/Apps/PlayerSheet.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
.taf.PlayerSheet {
|
||||
.sheet-header, fieldset, .content {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rebeccapurple;
|
||||
}
|
||||
|
||||
.sheet-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 4px;
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.attributes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.attr-range {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100px;
|
||||
|
||||
> input {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
--table-row-color-odd: var(--table-header-bg-color);
|
||||
|
||||
&:not(:has(> prose-mirror)) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
prose-mirror {
|
||||
height: 100%;
|
||||
|
||||
menu {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
styles/Apps/common.css
Normal file
8
styles/Apps/common.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.taf {
|
||||
> .window-content {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
5
styles/elements/headers.css
Normal file
5
styles/elements/headers.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.taf > .window-content {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
6
styles/elements/input.css
Normal file
6
styles/elements/input.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.taf > .window-content input {
|
||||
&.large {
|
||||
--input-height: 2.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
9
styles/elements/p.css
Normal file
9
styles/elements/p.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.taf > .window-content p {
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
12
styles/elements/prose-mirror.css
Normal file
12
styles/elements/prose-mirror.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.taf > .window-content prose-mirror {
|
||||
background: var(--prosemirror-background);
|
||||
|
||||
.editor-content {
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.tableWrapper th,
|
||||
.tableWrapper td {
|
||||
border-color: rebeccapurple;
|
||||
}
|
||||
}
|
||||
16
styles/main.css
Normal file
16
styles/main.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
@layer resets, themes, elements, components, partials, apps, exceptions;
|
||||
|
||||
/* Themes */
|
||||
@import url("./themes/dark.css") layer(themes);
|
||||
@import url("./themes/light.css") layer(themes);
|
||||
|
||||
/* Elements */
|
||||
@import url("./elements/headers.css") layer(elements);
|
||||
@import url("./elements/input.css") layer(elements);
|
||||
@import url("./elements/p.css") layer(elements);
|
||||
@import url("./elements/prose-mirror.css") layer(elements);
|
||||
|
||||
/* Apps */
|
||||
@import url("./Apps/common.css") layer(apps);
|
||||
@import url("./Apps/PlayerSheet.css") layer(apps);
|
||||
@import url("./Apps/AttributeManager.css") layer(apps);
|
||||
5
styles/resets/inputs.css
Normal file
5
styles/resets/inputs.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.taf > .window-content {
|
||||
button, input {
|
||||
all: initial;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
@use "./v1/index.scss";
|
||||
3
styles/themes/dark.css
Normal file
3
styles/themes/dark.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.theme-dark {
|
||||
--prosemirror-background: var(--color-cool-5);
|
||||
}
|
||||
3
styles/themes/light.css
Normal file
3
styles/themes/light.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.theme-light {
|
||||
--prosemirror-background: white;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
.dialog-content:not(:only-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.prompt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// Disclaimer: This CSS is used by a custom web component and is scoped to JUST
|
||||
// the corresponding web component. This should only be imported by web component
|
||||
// style files.
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
Disclaimer: This CSS is used by a custom web component and is scoped to JUST
|
||||
the corresponding web component. Importing this into other files is forbidden
|
||||
*/
|
||||
|
||||
$default-size: 1rem;
|
||||
|
||||
@use "./common.scss";
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: var(--size, $default-size);
|
||||
height: var(--size, $default-size);
|
||||
fill: var(--fill);
|
||||
stroke: var(--stroke);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
Disclaimer: This CSS is used by a custom web component and is scoped to JUST
|
||||
the corresponding web component. Importing this into other files is forbidden
|
||||
*/
|
||||
|
||||
$default-border-radius: 4px;
|
||||
$default-height: 1.5rem;
|
||||
|
||||
@use "./common.scss";
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
grid-template-columns: var(--height, $default-height) var(--width, 50px) var(--height, $default-height);
|
||||
grid-template-rows: var(--height, 1fr);
|
||||
border-radius: var(--border-radius, $default-border-radius);
|
||||
}
|
||||
|
||||
span, input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: var(--font-family, inherit);
|
||||
text-align: center;
|
||||
font-size: var(--font-size, inherit);
|
||||
padding: 2px 4px;
|
||||
|
||||
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0
|
||||
}
|
||||
}
|
||||
|
||||
.increment, .decrement {
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.increment {
|
||||
border-radius: 0 var(--border-radius, $default-border-radius) var(--border-radius, 4px) 0;
|
||||
}
|
||||
.decrement {
|
||||
border-radius: var(--border-radius, $default-border-radius) 0 0 var(--border-radius, $default-border-radius);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Styling version 1
|
||||
|
||||
@use "./Dialog.scss";
|
||||
|
||||
@use "./player/root.scss";
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
.actor--player.style-v1 {
|
||||
--header-size: 75px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-underline-header);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
--size: var(--header-size);
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-underline-header);
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
height: var(--header-size);
|
||||
padding: 8px 1rem;
|
||||
font-size: clamp(1rem, 2rem, calc(var(--header-size) - 16px));
|
||||
border: none;
|
||||
}
|
||||
|
||||
prose-mirror {
|
||||
--menu-background: rgba(0, 0, 0, 0.1);
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--color-underline-header);
|
||||
border-radius: 4px;
|
||||
|
||||
.editor-container {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
system.json
25
system.json
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"id": "taf",
|
||||
"title": "Text-Based Actors",
|
||||
"description": "",
|
||||
"description": "An intentionally minimalist system that enables you to play rules-light games without any hassle!",
|
||||
"version": "2.0.0",
|
||||
"download": "https://github.com/Oliver-Akins/Text-Actors-Foundry/releases/latest/download/dotdungeon.zip",
|
||||
"download": "https://github.com/Oliver-Akins/Text-Actors-Foundry/releases/latest/download/release.zip",
|
||||
"manifest": "https://github.com/Oliver-Akins/Text-Actors-Foundry/releases/latest/download/system.json",
|
||||
"url": "https://github.com/Oliver-Akins/Text-Actors-Foundry",
|
||||
"compatibility": {
|
||||
"minimum": 12,
|
||||
"verified": 12,
|
||||
"maximum": 12
|
||||
"minimum": 13,
|
||||
"verified": 13,
|
||||
"maximum": 13
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
|
|
@ -18,12 +18,21 @@
|
|||
}
|
||||
],
|
||||
"esmodules": [
|
||||
"src/main.mjs"
|
||||
"./module/main.mjs"
|
||||
],
|
||||
"styles": [
|
||||
".styles/root.css"
|
||||
{
|
||||
"src": "./styles/main.css",
|
||||
"layer": "system"
|
||||
}
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"lang": "en",
|
||||
"name": "English (Canadian)",
|
||||
"path": "langs/en-ca.json"
|
||||
}
|
||||
],
|
||||
"packs": [],
|
||||
"documentTypes": {
|
||||
"Actor": {
|
||||
"player": {
|
||||
|
|
|
|||
1
taf.lock
Normal file
1
taf.lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
🔒
|
||||
35
templates/AttributeManager/attribute-list.hbs
Normal file
35
templates/AttributeManager/attribute-list.hbs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<div class="attributes">
|
||||
{{#each attrs as |attr|}}
|
||||
<div
|
||||
class="attribute"
|
||||
data-attribute="{{ attr.id }}"
|
||||
>
|
||||
{{#if attr.isNew}}
|
||||
<input
|
||||
type="text"
|
||||
data-bind="{{ attr.id }}.name"
|
||||
value="{{ attr.name }}"
|
||||
placeholder="Attribute Name..."
|
||||
>
|
||||
{{else}}
|
||||
<span>{{ attr.name }}</span>
|
||||
{{/if}}
|
||||
<label>
|
||||
Has Maximum?
|
||||
<input
|
||||
type="checkbox"
|
||||
data-bind="{{ attr.id }}.isRange"
|
||||
{{ checked attr.isRange }}
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
data-action="removeAttribute"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>No attributes yet</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
13
templates/AttributeManager/controls.hbs
Normal file
13
templates/AttributeManager/controls.hbs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<div class="controls">
|
||||
<button
|
||||
type="button"
|
||||
data-action="addNew"
|
||||
>
|
||||
Add New Attribute
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
>
|
||||
Save and Close
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<div class="ask-dialog">
|
||||
<p>
|
||||
{{ question }}
|
||||
</p>
|
||||
{{#each inputs as | i | }}
|
||||
<div class="prompt">
|
||||
<label
|
||||
for="{{i.id}}"
|
||||
class="prompt__label"
|
||||
>
|
||||
{{ i.label }}
|
||||
</label>
|
||||
<input
|
||||
type="{{i.inputType}}"
|
||||
id="{{i.id}}"
|
||||
class="patrons__input"
|
||||
{{i.valueAttribute}}="{{i.defaultValue}}"
|
||||
{{#if i.autofocus}}autofocus{{/if}}
|
||||
>
|
||||
{{#if i.details}}
|
||||
<p class="prompt__details">
|
||||
{{{ i.details }}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<form autocomplete="off">
|
||||
<div class="header-row">
|
||||
<img
|
||||
class="avatar"
|
||||
src="{{actor.img}}"
|
||||
data-edit="img"
|
||||
title="{{actor.name}}"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value="{{actor.name}}"
|
||||
class="actor-name"
|
||||
>
|
||||
</div>
|
||||
{{#if editable}}
|
||||
<prose-mirror
|
||||
class="actor-text"
|
||||
name="system.content"
|
||||
value="{{system.content}}"
|
||||
collaborate="true"
|
||||
data-document-uuid="{{actor.uuid}}"
|
||||
>
|
||||
{{{enriched.system.content}}}
|
||||
</prose-mirror>
|
||||
{{else}}
|
||||
{{{enriched.system.content}}}
|
||||
{{/if}}
|
||||
</form>
|
||||
32
templates/PlayerSheet/attributes.hbs
Normal file
32
templates/PlayerSheet/attributes.hbs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{{#if hasAttributes}}
|
||||
<div class="attributes">
|
||||
{{#each attrs as | attr |}}
|
||||
<fieldset data-attribute="{{ attr.id }}">
|
||||
<legend>
|
||||
{{ attr.name }}
|
||||
</legend>
|
||||
<div class="attr-range">
|
||||
<input
|
||||
type="number"
|
||||
class="attr-range__value"
|
||||
name="{{attr.path}}.value"
|
||||
value="{{attr.value}}"
|
||||
aria-label="Current value"
|
||||
>
|
||||
{{#if attr.isRange}}
|
||||
<span aria-hidden="true">/</span>
|
||||
<input
|
||||
type="number"
|
||||
class="attr-range__max"
|
||||
name="{{attr.path}}.max"
|
||||
value="{{attr.max}}"
|
||||
aria-label="Maximum value"
|
||||
>
|
||||
{{/if}}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<template />
|
||||
{{/if}}
|
||||
15
templates/PlayerSheet/content.hbs
Normal file
15
templates/PlayerSheet/content.hbs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<div class="content">
|
||||
{{#if editable}}
|
||||
<prose-mirror
|
||||
class="actor-text"
|
||||
name="system.content"
|
||||
value="{{system.content}}"
|
||||
collaborate="true"
|
||||
data-document-uuid="{{actor.uuid}}"
|
||||
>
|
||||
{{{ enriched.system.content }}}
|
||||
</prose-mirror>
|
||||
{{else}}
|
||||
{{{ enriched.system.content }}}
|
||||
{{/if}}
|
||||
</div>
|
||||
17
templates/PlayerSheet/header.hbs
Normal file
17
templates/PlayerSheet/header.hbs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<header class="sheet-header">
|
||||
<img
|
||||
src="{{actor.img}}"
|
||||
data-action="editImage"
|
||||
data-edit="img"
|
||||
title="{{actor.name}}"
|
||||
height="64"
|
||||
width="64"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="large"
|
||||
value="{{actor.name}}"
|
||||
placeholder="{{ localize 'Name' }}"
|
||||
/>
|
||||
</header>
|
||||
Loading…
Add table
Add a link
Reference in a new issue