459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
// SPDX-FileCopyrightText: 2021 Johannes Loher
|
|
// SPDX-FileCopyrightText: 2021 Oliver Rümpelein
|
|
// SPDX-FileCopyrightText: 2021 Gesina Schwalbe
|
|
// SPDX-FileCopyrightText: 2021 Siegfried Krug
|
|
//
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import { DS4ActiveEffect } from "../active-effect/active-effect";
|
|
import { disableOverriddenFields } from "../apps/sheet-helpers";
|
|
import { DS4 } from "../config";
|
|
import { getCanvas, getGame } from "../helpers";
|
|
import { getDS4Settings } from "../settings";
|
|
import notifications from "../ui/notifications";
|
|
import { enforce } from "../utils";
|
|
import { isCheck } from "./actor-data-properties-base";
|
|
|
|
import type { ModifiableDataBaseTotal } from "../common/common-data";
|
|
import type { DS4Settings } from "../settings";
|
|
import type { DS4Item } from "../item/item";
|
|
|
|
/**
|
|
* The base sheet class for all {@link DS4Actor}s.
|
|
*/
|
|
export class DS4ActorSheet extends ActorSheet<ActorSheet.Options, DS4ActorSheetData> {
|
|
static override get defaultOptions(): ActorSheet.Options {
|
|
return foundry.utils.mergeObject(super.defaultOptions, {
|
|
classes: ["sheet", "ds4-actor-sheet"],
|
|
height: 635,
|
|
scrollY: [".ds4-sheet-body"],
|
|
tabs: [{ navSelector: ".ds4-sheet-tab-nav", contentSelector: ".ds4-sheet-body", initial: "values" }],
|
|
dragDrop: [
|
|
{ dragSelector: ".item-list .item", dropSelector: null },
|
|
{ dragSelector: ".effect-list .effect", dropSelector: null },
|
|
{ dragSelector: ".ds4-check", dropSelector: null },
|
|
],
|
|
width: 650,
|
|
});
|
|
}
|
|
|
|
override get template(): string {
|
|
const basePath = "systems/ds4/templates/sheets/actor";
|
|
if (!getGame().user?.isGM && this.actor.limited) return `${basePath}/limited-sheet.hbs`;
|
|
return `${basePath}/${this.actor.data.type}-sheet.hbs`;
|
|
}
|
|
|
|
override async getData(): Promise<DS4ActorSheetData> {
|
|
const itemsByType = Object.fromEntries(
|
|
Object.entries(this.actor.itemTypes).map(([itemType, items]) => {
|
|
return [itemType, items.map((item) => item.data).sort((a, b) => (a.sort || 0) - (b.sort || 0))];
|
|
}),
|
|
);
|
|
|
|
const enrichedEffectPromises = this.actor.effects.map(async (effect) => {
|
|
return {
|
|
...effect.toObject(),
|
|
sourceName: await effect.getCurrentSourceName(),
|
|
factor: effect.factor,
|
|
isEffectivelyEnabled: !effect.data.disabled && !effect.isSurpressed,
|
|
};
|
|
});
|
|
const enrichedEffects = await Promise.all(enrichedEffectPromises);
|
|
|
|
const data = {
|
|
...this.addTooltipsToData(await super.getData()),
|
|
config: DS4,
|
|
itemsByType,
|
|
enrichedEffects,
|
|
settings: getDS4Settings(),
|
|
};
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Adds tooltips to the attributes, traits, and combatValues of the actor data of the given {@link ActorSheet.Data}.
|
|
*/
|
|
protected addTooltipsToData(data: ActorSheet.Data): ActorSheet.Data {
|
|
const valueGroups = [data.data.data.attributes, data.data.data.traits, data.data.data.combatValues];
|
|
|
|
valueGroups.forEach((valueGroup) => {
|
|
Object.values(valueGroup).forEach((attribute: ModifiableDataBaseTotal<number> & { tooltip?: string }) => {
|
|
attribute.tooltip = this.getTooltipForValue(attribute);
|
|
});
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Generates a tooltip for a given attribute, trait, or combatValue.
|
|
*/
|
|
protected getTooltipForValue(value: ModifiableDataBaseTotal<number>): string {
|
|
return `${value.base} (${getGame().i18n.localize("DS4.TooltipBaseValue")}) + ${
|
|
value.mod
|
|
} (${getGame().i18n.localize("DS4.TooltipModifier")}) ➞ ${getGame().i18n.localize("DS4.TooltipEffects")} ➞ ${
|
|
value.total
|
|
}`;
|
|
}
|
|
|
|
override activateListeners(html: JQuery): void {
|
|
super.activateListeners(html);
|
|
|
|
if (!this.options.editable) return;
|
|
|
|
html.find(".control-item").on("click", this.onControlItem.bind(this));
|
|
html.find(".change-item").on("change", this.onChangeItem.bind(this));
|
|
|
|
html.find(".control-effect").on("click", this.onControlEffect.bind(this));
|
|
html.find(".change-effect").on("change", this.onChangeEffect.bind(this));
|
|
|
|
html.find(".rollable-item").on("click", this.onRollItem.bind(this));
|
|
html.find(".rollable-check").on("click", this.onRollCheck.bind(this));
|
|
|
|
html.find(".sort-items").on("click", this.onSortItems.bind(this));
|
|
|
|
disableOverriddenFields(this.form, this.actor.overrides, (key) => `[name="${key}"]`);
|
|
for (const item of this.actor.items) {
|
|
disableOverriddenFields(
|
|
this.form,
|
|
item.overrides,
|
|
(key) => `[data-item-id="${item.id}"] .change-item[data-property="${key}"]`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles a click on an element of this sheet to control an embedded item of the actor corresponding to this sheet.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onControlItem(event: JQuery.ClickEvent): void {
|
|
event.preventDefault();
|
|
const a = event.currentTarget;
|
|
switch (a.dataset["action"]) {
|
|
case "create":
|
|
return this.onCreateItem(event);
|
|
case "edit":
|
|
return this.onEditItem(event);
|
|
case "delete":
|
|
return this.onDeleteItem(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new embedded item using the initial data defined in the HTML dataset of the clicked element.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onCreateItem(event: JQuery.ClickEvent): void {
|
|
const { type, ...data } = foundry.utils.deepClone(event.currentTarget.dataset);
|
|
const name = getGame().i18n.localize(`DS4.New${type.capitalize()}Name`);
|
|
const itemData = {
|
|
name: name,
|
|
type: type,
|
|
data: data,
|
|
};
|
|
Item.create(itemData, { parent: this.actor, pack: this.actor.pack ?? undefined });
|
|
}
|
|
|
|
/**
|
|
* Opens the sheet of the embedded item corresponding to the clicked element.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onEditItem(event: JQuery.ClickEvent): void {
|
|
const id = $(event.currentTarget)
|
|
.parents(embeddedDocumentListEntryProperties.Item.selector)
|
|
.data(embeddedDocumentListEntryProperties.Item.idDataAttribute);
|
|
const item = this.actor.items.get(id);
|
|
enforce(item, getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { id, actor: this.actor.name }));
|
|
enforce(item.sheet);
|
|
item.sheet.render(true);
|
|
}
|
|
|
|
/**
|
|
* Deletes the embedded item corresponding to the clicked element.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onDeleteItem(event: JQuery.ClickEvent): void {
|
|
const li = $(event.currentTarget).parents(embeddedDocumentListEntryProperties.Item.selector);
|
|
this.actor.deleteEmbeddedDocuments("Item", [li.data(embeddedDocumentListEntryProperties.Item.idDataAttribute)]);
|
|
li.slideUp(200, () => this.render(false));
|
|
}
|
|
|
|
/**
|
|
* Applies a change to a property of an embedded item depending on the `data-property` attribute of the
|
|
* {@link HTMLInputElement} that has been changed and its new value.
|
|
*
|
|
* @param event - The originating change event
|
|
*/
|
|
protected onChangeItem(event: JQuery.ChangeEvent): void {
|
|
return this.onChangeEmbeddedDocument(event, "Item");
|
|
}
|
|
|
|
/**
|
|
* Handles a click on an element of this sheet to control an embedded effect of the actor corresponding to this
|
|
* sheet.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onControlEffect(event: JQuery.ClickEvent): void {
|
|
event.preventDefault();
|
|
const a = event.currentTarget;
|
|
switch (a.dataset["action"]) {
|
|
case "create":
|
|
return this.onCreateEffect();
|
|
case "edit":
|
|
return this.onEditEffect(event);
|
|
case "delete":
|
|
return this.onDeleteEffect(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new embedded effect.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onCreateEffect(): void {
|
|
DS4ActiveEffect.createDefault(this.actor);
|
|
}
|
|
|
|
/**
|
|
* Opens the sheet of the embedded effect corresponding to the clicked element.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onEditEffect(event: JQuery.ClickEvent): void {
|
|
const id = $(event.currentTarget)
|
|
.parents(embeddedDocumentListEntryProperties.ActiveEffect.selector)
|
|
.data(embeddedDocumentListEntryProperties.ActiveEffect.idDataAttribute);
|
|
const effect = this.actor.effects.get(id);
|
|
enforce(effect, getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { id, actor: this.actor.name }));
|
|
effect.sheet?.render(true);
|
|
}
|
|
|
|
/**
|
|
* Deletes the embedded item corresponding to the clicked element.
|
|
*
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onDeleteEffect(event: JQuery.ClickEvent): void {
|
|
const li = $(event.currentTarget).parents(embeddedDocumentListEntryProperties.ActiveEffect.selector);
|
|
const id = li.data(embeddedDocumentListEntryProperties.ActiveEffect.idDataAttribute);
|
|
this.actor.deleteEmbeddedDocuments("ActiveEffect", [id]);
|
|
li.slideUp(200, () => this.render(false));
|
|
}
|
|
|
|
/**
|
|
* Applies a change to a property of an embedded effect depending on the `data-property` attribute of the
|
|
* {@link HTMLInputElement} that has been changed and its new value.
|
|
*
|
|
* @param event - The originating change event
|
|
*/
|
|
protected onChangeEffect(event: JQuery.ChangeEvent): void {
|
|
return this.onChangeEmbeddedDocument(event, "ActiveEffect");
|
|
}
|
|
|
|
/**
|
|
* Applies a change to a property of an embedded document of the actor belonging to this sheet. The change depends
|
|
* on the `data-property` attribute of the {@link HTMLInputElement} that has been changed and its new value.
|
|
*
|
|
* @param event - The originating change event
|
|
* @param documentName - The name of the embedded document to be changed.
|
|
*/
|
|
protected onChangeEmbeddedDocument(event: JQuery.ChangeEvent, documentName: "Item" | "ActiveEffect"): void {
|
|
event.preventDefault();
|
|
const element = $(event.currentTarget).get(0);
|
|
enforce(element instanceof HTMLInputElement);
|
|
if (element.disabled) return;
|
|
|
|
const documentElement = element.closest(embeddedDocumentListEntryProperties[documentName].selector);
|
|
enforce(documentElement instanceof HTMLElement);
|
|
const id = documentElement.dataset[embeddedDocumentListEntryProperties[documentName].idDataAttribute];
|
|
const property = element.dataset["property"];
|
|
enforce(property !== undefined, TypeError("HTML element does not provide 'data-property' attribute"));
|
|
|
|
const newValue = this.parseValue(element);
|
|
this.actor.updateEmbeddedDocuments(documentName, [{ _id: id, [property]: newValue }]);
|
|
}
|
|
|
|
/**
|
|
* Parses the value of the given {@link HTMLInputElement} depending on the element's type
|
|
* The value is parsed to:
|
|
* - checkbox: `boolean`, if the attribute `data-inverted` is set to a truthy value, the parsed value is inverted
|
|
* - text input: `string`
|
|
* - number: `number`
|
|
*
|
|
* @param element - The input element to parse the value from
|
|
*/
|
|
protected parseValue(element: HTMLInputElement): boolean | string | number {
|
|
switch (element.type) {
|
|
case "checkbox": {
|
|
const inverted = Boolean(element.dataset["inverted"]);
|
|
const value: boolean = element.checked;
|
|
return inverted ? !value : value;
|
|
}
|
|
case "text": {
|
|
const value: string = element.value;
|
|
return value;
|
|
}
|
|
case "number": {
|
|
const value = Number(element.value.trim());
|
|
return value;
|
|
}
|
|
default: {
|
|
throw new TypeError(
|
|
"Binding of item property to this type of HTML element not supported; given: " + element,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle clickable item rolls.
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onRollItem(event: JQuery.ClickEvent): void {
|
|
event.preventDefault();
|
|
const id = $(event.currentTarget)
|
|
.parents(embeddedDocumentListEntryProperties.Item.selector)
|
|
.data(embeddedDocumentListEntryProperties.Item.idDataAttribute);
|
|
const item = this.actor.items.get(id);
|
|
enforce(item, getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { id, actor: this.actor.name }));
|
|
item.roll().catch((e) => notifications.error(e, { log: true }));
|
|
}
|
|
|
|
/**
|
|
* Handle clickable check rolls.
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onRollCheck(event: JQuery.ClickEvent): void {
|
|
event.preventDefault();
|
|
event.currentTarget.blur();
|
|
const check = event.currentTarget.dataset["check"];
|
|
this.actor.rollCheck(check).catch((e) => notifications.error(e, { log: true }));
|
|
}
|
|
|
|
override _onDragStart(event: DragEvent): void {
|
|
const target = event.currentTarget as HTMLElement;
|
|
if (!(target instanceof HTMLElement)) return super._onDragStart(event);
|
|
|
|
const check = target.dataset.check;
|
|
if (!check) return super._onDragStart(event);
|
|
|
|
enforce(isCheck(check), getGame().i18n.format("DS4.ErrorCannotDragMissingCheck", { check }));
|
|
|
|
const dragData = {
|
|
actorId: this.actor.id,
|
|
sceneId: this.actor.isToken ? getCanvas().scene?.id : null,
|
|
tokenId: this.actor.isToken ? this.actor.token?.id : null,
|
|
type: "Check",
|
|
data: check,
|
|
};
|
|
|
|
event.dataTransfer?.setData("text/plain", JSON.stringify(dragData));
|
|
}
|
|
|
|
/**
|
|
* Sort items according to the item list header that has been clicked.
|
|
* @param event - The originating click event
|
|
*/
|
|
protected onSortItems(event: JQuery.ClickEvent<unknown, unknown, HTMLElement>): void {
|
|
event.preventDefault();
|
|
const target = event.currentTarget;
|
|
const type = target.parentElement?.dataset["type"];
|
|
enforce(type !== undefined, `Could not find property 'type' in the dataset of the parent of ${target}`);
|
|
const dataPath = target.dataset["dataPath"];
|
|
enforce(dataPath !== undefined, `Could not find property 'dataPath' in the dataset of ${target}`);
|
|
const dataPath2 = target.dataset["dataPath2"];
|
|
const items = this.actor.items.filter((item) => item.type === type);
|
|
items.sort((a, b) => a.data.sort - b.data.sort);
|
|
|
|
const sortFunction =
|
|
(invert: boolean) =>
|
|
(a: DS4Item, b: DS4Item): number => {
|
|
const propertyA = getProperty(a.data, dataPath);
|
|
const propertyB = getProperty(b.data, dataPath);
|
|
const comparison =
|
|
typeof propertyA === "string" || typeof propertyB === "string"
|
|
? compareAsStrings(propertyA, propertyB, invert)
|
|
: compareAsNumbers(propertyA, propertyB, invert);
|
|
|
|
if (comparison === 0 && dataPath2 !== undefined) {
|
|
const propertyA = getProperty(a.data, dataPath);
|
|
const propertyB = getProperty(b.data, dataPath);
|
|
return typeof propertyA === "string" || typeof propertyB === "string"
|
|
? compareAsStrings(propertyA, propertyB, invert)
|
|
: compareAsNumbers(propertyA, propertyB, invert);
|
|
}
|
|
|
|
return comparison;
|
|
};
|
|
|
|
const sortedItems = [...items].sort(sortFunction(false));
|
|
const wasSortedAlready = !sortedItems.find((item, index) => item !== items[index]);
|
|
|
|
if (wasSortedAlready) {
|
|
sortedItems.sort(sortFunction(true));
|
|
}
|
|
|
|
const updates = sortedItems.map((item, i) => ({
|
|
_id: item.id,
|
|
sort: (i + 1) * CONST.SORT_INTEGER_DENSITY,
|
|
}));
|
|
|
|
this.actor.updateEmbeddedDocuments("Item", updates);
|
|
}
|
|
|
|
protected override async _onDropItem(event: DragEvent, data: ActorSheet.DropData.Item): Promise<unknown> {
|
|
const item = await Item.fromDropData(data);
|
|
if (item && !this.actor.canOwnItemType(item.data.type)) {
|
|
notifications.warn(
|
|
getGame().i18n.format("DS4.WarningActorCannotOwnItem", {
|
|
actorName: this.actor.name,
|
|
actorType: this.actor.data.type,
|
|
itemName: item.name,
|
|
itemType: item.data.type,
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
return super._onDropItem(event, data);
|
|
}
|
|
}
|
|
|
|
interface DS4ActorSheetData extends ActorSheet.Data {
|
|
config: typeof DS4;
|
|
itemsByType: Record<string, foundry.data.ItemData[]>;
|
|
enrichedEffects: EnrichedActiveEffectDataSource[];
|
|
settings: DS4Settings;
|
|
}
|
|
|
|
type ActiveEffectDataSource = foundry.data.ActiveEffectData["_source"];
|
|
|
|
interface EnrichedActiveEffectDataSource extends ActiveEffectDataSource {
|
|
sourceName: string;
|
|
}
|
|
|
|
/**
|
|
* This object contains information about specific properties embedded document list entries for each different type.
|
|
*/
|
|
const embeddedDocumentListEntryProperties = Object.freeze({
|
|
ActiveEffect: {
|
|
selector: ".effect",
|
|
idDataAttribute: "effectId",
|
|
},
|
|
Item: {
|
|
selector: ".item",
|
|
idDataAttribute: "itemId",
|
|
},
|
|
});
|
|
|
|
const compareAsStrings = (a: { toString(): string }, b: { toString(): string }, invert: boolean): number => {
|
|
return invert ? b.toString().localeCompare(a.toString()) : a.toString().localeCompare(b.toString());
|
|
};
|
|
|
|
const compareAsNumbers = (a: number, b: number, invert: boolean): number => {
|
|
return invert ? b - a : a - b;
|
|
};
|