Merge pull request #10 from Oliver-Akins/rewrite/v13

v13 Compatibility and Rewrite
This commit is contained in:
Oliver 2025-07-02 23:00:23 -06:00 committed by GitHub
commit 292f8caf92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 833 additions and 1551 deletions

View file

@ -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
View file

@ -1,2 +1,2 @@
node_modules/
.styles
deprecated

BIN
.promo/hjonk-samples.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

View file

@ -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;
```

View file

@ -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
View 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"
}
}
}

View 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
View 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
View 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
View 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;
};
};

View 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
View 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
View 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
View file

@ -0,0 +1 @@
import "./hooks/init.mjs";

12
module/settings/world.mjs Normal file
View 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`,
});
};

View file

@ -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
View 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, ``);
};

View file

@ -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()

View file

@ -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);
}
};
};
};

View file

@ -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`);
};
};

View file

@ -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();
};
};

View file

@ -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;
});
}
};
};
};

View file

@ -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 }));
};
};

View file

View file

@ -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);

View file

@ -1,5 +0,0 @@
export class Player extends Actor {
getRollData() {
return this.system;
};
};

View file

@ -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: ``,
}),
};
};
};

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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`),
};
};

View file

@ -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`);
};

View file

@ -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);
});

View file

@ -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;
});

View file

@ -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);
};
});

View file

@ -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();
};

View file

@ -1,2 +0,0 @@
export function registerClientSettings() {
};

View file

@ -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.`);
}
},
});
};

View file

@ -1,2 +0,0 @@
export function registerWorldSettings() {
};

View file

@ -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;
};
}

View file

@ -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`,
});
};

View file

@ -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(),
];
};
};
};

View file

@ -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;

View file

@ -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];
},
});
};

View file

@ -1,7 +0,0 @@
export function hideMessageText(content) {
const hideContent = taf.FEATURES.ROLL_MODE_CONTENT;
if (hideContent) {
return `-=${content}=-`;
}
return content;
};

View file

@ -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,
},
);

View file

@ -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);
},
);
};

View 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;
}
}
}

View 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
View file

@ -0,0 +1,8 @@
.taf {
> .window-content {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}

View file

@ -0,0 +1,5 @@
.taf > .window-content {
h1, h2, h3, h4, h5, h6 {
margin: 0;
}
}

View 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
View file

@ -0,0 +1,9 @@
.taf > .window-content p {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}

View 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
View 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
View file

@ -0,0 +1,5 @@
.taf > .window-content {
button, input {
all: initial;
}
}

View file

@ -1 +0,0 @@
@use "./v1/index.scss";

3
styles/themes/dark.css Normal file
View file

@ -0,0 +1,3 @@
.theme-dark {
--prosemirror-background: var(--color-cool-5);
}

3
styles/themes/light.css Normal file
View file

@ -0,0 +1,3 @@
.theme-light {
--prosemirror-background: white;
}

View file

@ -1,12 +0,0 @@
.dialog-content:not(:only-child) {
margin-bottom: 8px;
}
.dialog-content {
p {
margin: 0;
}
.prompt {
margin-top: 8px;
}
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,5 +0,0 @@
// Styling version 1
@use "./Dialog.scss";
@use "./player/root.scss";

View file

@ -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;
}
}
}

View file

@ -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
View file

@ -0,0 +1 @@
🔒

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View 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}}

View 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>

View 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>