configurable-reactions/scripts/apps/reaction-config-app.js
2026-06-04 22:52:08 +02:00

639 lines
22 KiB
JavaScript

import {
MODULE_ID,
SETTINGS,
DEFAULT_REACTION,
ACTION_TYPES,
TRIGGER_TYPES,
TRIGGER_PALETTE,
ACTION_PALETTE
} from "../constants.js";
import { assignReactionToSelectedTokens, removeAssignment } from "./assignment-manager.js";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: "configurable-reactions-config",
tag: "section",
classes: ["configurable-reactions", "standard-form"],
window: {
title: "CONFIGURABLE_REACTIONS.App.Title",
icon: "fa-solid fa-bolt",
resizable: true
},
position: {
width: 1040,
height: 820
},
actions: {
createReaction: ConfigurableReactionsConfigApp.#onCreateReaction,
saveReactionJson: ConfigurableReactionsConfigApp.#onSaveReactionJson,
deleteReaction: ConfigurableReactionsConfigApp.#onDeleteReaction,
assignSelectedTokens: ConfigurableReactionsConfigApp.#onAssignSelectedTokens,
removeAssignment: ConfigurableReactionsConfigApp.#onRemoveAssignment,
removeAction: ConfigurableReactionsConfigApp.#onRemoveAction,
saveReactionBasics: ConfigurableReactionsConfigApp.#onSaveReactionBasics,
clearTriggerSpell: ConfigurableReactionsConfigApp.#onClearTriggerSpell,
removeDamageType: ConfigurableReactionsConfigApp.#onRemoveDamageType,
clearActionSpell: ConfigurableReactionsConfigApp.#onClearActionSpell,
removeActionStatus: ConfigurableReactionsConfigApp.#onRemoveActionStatus
}
};
static PARTS = {
main: {
template: `modules/${MODULE_ID}/templates/reaction-config.hbs`
}
};
async _prepareContext(options) {
const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []);
const assignments = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? []);
const selectedReactionId = this._selectedReactionId ?? reactions[0]?.id ?? null;
const selectedReaction = reactions.find(r => r.id === selectedReactionId) ?? reactions[0] ?? null;
this._selectedReactionId = selectedReaction?.id ?? null;
const triggerPalette = TRIGGER_PALETTE.map(entry => ({
...entry,
label: game.i18n.localize(entry.labelKey),
hint: game.i18n.localize(entry.hintKey)
}));
const actionPalette = ACTION_PALETTE.map(entry => ({
...entry,
label: game.i18n.localize(entry.labelKey),
hint: game.i18n.localize(entry.hintKey)
}));
const activeTrigger = triggerPalette.find(t => t.type === selectedReaction?.trigger?.type) ?? null;
const triggerSpellName = selectedReaction?.trigger?.spell?.itemName || selectedReaction?.trigger?.spell?.itemUuid || "";
const activeTriggerAcceptsSpell = isSpellTrigger(selectedReaction?.trigger?.type);
const activeTriggerIsDamage = selectedReaction?.trigger?.type === TRIGGER_TYPES.DAMAGE_RECEIVED;
const statusEffects = getStatusEffectOptions();
const damageTypes = getDamageTypeOptions();
const selectedDamageTypeIds = Array.from(new Set(selectedReaction?.conditions?.damage?.types ?? []));
const selectedDamageTypes = selectedDamageTypeIds.map(typeId => resolveDamageType(typeId, damageTypes));
const availableDamageTypes = damageTypes.filter(type => !selectedDamageTypeIds.includes(type.id));
const visualActions = (selectedReaction?.actions ?? []).map((action, index) => {
const paletteEntry = actionPalette.find(p => p.type === action.type);
const selectedStatusIds = Array.from(new Set(action.statuses ?? []));
const selectedStatuses = selectedStatusIds.map(statusId => resolveStatusEffect(statusId, statusEffects));
return {
...action,
index,
label: paletteEntry?.label ?? action.type,
icon: paletteEntry?.icon ?? "fa-solid fa-puzzle-piece",
summary: summarizeAction(action, statusEffects),
isSpellAction: action.type === ACTION_TYPES.CAST_SPELL_FROM_TOKEN,
spellName: action.spellSelection?.itemName || action.spellSelection?.itemUuid || "",
isStatusAction: action.type === ACTION_TYPES.APPLY_STATUS,
selectedStatuses,
availableStatuses: statusEffects.filter(status => !selectedStatusIds.includes(status.id))
};
});
return {
reactions,
assignments: assignments.map(a => ({
...a,
reactionName: reactions.find(r => r.id === a.reactionId)?.name ?? a.reactionId
})),
selectedReaction,
selectedReactionJson: selectedReaction ? JSON.stringify(selectedReaction, null, 2) : "",
triggerTypes: TRIGGER_TYPES,
actionTypes: ACTION_TYPES,
triggerPalette,
actionPalette,
activeTrigger,
activeTriggerAcceptsSpell,
activeTriggerIsDamage,
triggerSpellName,
visualActions,
statusEffects,
selectedDamageTypes,
availableDamageTypes
};
}
_onRender(context, options) {
super._onRender(context, options);
this.element.querySelector("[name='selectedReactionId']")?.addEventListener("change", event => {
this._selectedReactionId = event.currentTarget.value;
this.render({ force: true });
});
this.#activateJsonEditor();
this.#activateBasicsSync();
this.#activateStatusSelectors();
this.#activateDamageTypeSelector();
this.#activateDragAndDrop();
}
#activateStatusSelectors() {
for (const select of this.element.querySelectorAll("[data-cr-status-select]")) {
select.addEventListener("change", async event => {
const actionIndex = Number(event.currentTarget.dataset.actionIndex);
const statusId = event.currentTarget.value;
if (!Number.isInteger(actionIndex) || !statusId) return;
await this.#addActionStatus(actionIndex, statusId);
});
}
}
#activateJsonEditor() {
const textarea = this.element.querySelector("[name='reactionJson']");
if (!textarea) return;
textarea.addEventListener("blur", async () => {
if (document.activeElement === textarea) return;
await this.#saveReactionJsonFromTextarea({ render: true });
});
}
#activateBasicsSync() {
const nameInput = this.element.querySelector("[name='reactionName']");
const enabledInput = this.element.querySelector("[name='reactionEnabled']");
nameInput?.addEventListener("input", () => this.#syncJsonFromDesignerForm());
nameInput?.addEventListener("change", async () => this.#saveBasicsFromDesignerForm());
nameInput?.addEventListener("blur", async () => this.#saveBasicsFromDesignerForm());
enabledInput?.addEventListener("change", async () => {
this.#syncJsonFromDesignerForm();
await this.#saveBasicsFromDesignerForm();
});
}
#activateDamageTypeSelector() {
const select = this.element.querySelector("[data-cr-damage-type-select]");
if (!select) return;
select.addEventListener("change", async event => {
const damageType = event.currentTarget.value;
if (!damageType) return;
await this.#addDamageType(damageType);
});
}
#activateDragAndDrop() {
for (const draggable of this.element.querySelectorAll("[data-cr-drag]")) {
draggable.setAttribute("draggable", "true");
draggable.addEventListener("dragstart", event => {
const payload = {
kind: draggable.dataset.crDrag,
type: draggable.dataset.crType,
actionIndex: draggable.dataset.actionIndex ? Number(draggable.dataset.actionIndex) : null
};
event.dataTransfer.setData("application/json", JSON.stringify(payload));
event.dataTransfer.setData("text/plain", JSON.stringify(payload));
event.dataTransfer.effectAllowed = "copyMove";
});
}
for (const dropZone of this.element.querySelectorAll("[data-cr-drop]")) {
dropZone.addEventListener("dragover", event => {
event.preventDefault();
dropZone.classList.add("cr-drop-active");
event.dataTransfer.dropEffect = "copy";
});
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("cr-drop-active"));
dropZone.addEventListener("drop", async event => {
event.preventDefault();
dropZone.classList.remove("cr-drop-active");
event.stopPropagation();
const payload = readDragPayload(event);
if (!payload) return;
const droppedItem = await resolveDroppedItem(payload);
if (dropZone.dataset.crDrop === "trigger" && payload.kind === "trigger") {
await this.#setTrigger(payload.type);
return;
}
if (dropZone.dataset.crDrop === "trigger" && isSpellItem(droppedItem)) {
await this.#setTriggerSpell(droppedItem);
return;
}
if (dropZone.dataset.crDrop === "actions" && payload.kind === "action") {
await this.#addAction(createDefaultAction(payload.type));
return;
}
if (dropZone.dataset.crDrop === "actions" && isSpellItem(droppedItem)) {
await this.#addAction(createDefaultAction(ACTION_TYPES.CAST_SPELL_FROM_TOKEN, droppedItem));
return;
}
if (dropZone.dataset.crDrop === "action-spell" && isSpellItem(droppedItem)) {
await this.#setActionSpell(Number(dropZone.dataset.actionIndex), droppedItem);
return;
}
});
}
}
static async #onCreateReaction(event, target) {
const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []);
const reaction = foundry.utils.deepClone(DEFAULT_REACTION);
reaction.id = foundry.utils.randomID();
reaction.name = game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.NewReaction");
reactions.push(reaction);
await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions);
this._selectedReactionId = reaction.id;
this.render({ force: true });
}
static async #onSaveReactionJson(event, target) {
await this.#saveReactionJsonFromTextarea({ render: true });
}
static async #onDeleteReaction(event, target) {
const reactionId = this.element.querySelector("[name='selectedReactionId']")?.value;
if (!reactionId) return;
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.Delete") },
content: `<p>${game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.DeleteConfirm")}</p>`
});
if (!confirmed) return;
const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? [])
.filter(r => r.id !== reactionId);
const assignments = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? [])
.filter(a => a.reactionId !== reactionId);
await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions);
await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments);
this._selectedReactionId = reactions[0]?.id ?? null;
this.render({ force: true });
}
static async #onAssignSelectedTokens(event, target) {
const reactionId = this.element.querySelector("[name='selectedReactionId']")?.value;
await assignReactionToSelectedTokens(reactionId);
this.render({ force: true });
}
static async #onRemoveAssignment(event, target) {
const assignmentId = target.dataset.assignmentId;
await removeAssignment(assignmentId);
this.render({ force: true });
}
static async #onRemoveAction(event, target) {
const actionIndex = Number(target.dataset.actionIndex);
if (!Number.isInteger(actionIndex)) return;
await this.#mutateSelectedReaction(reaction => {
reaction.actions ??= [];
reaction.actions.splice(actionIndex, 1);
});
}
static async #onClearTriggerSpell(event, target) {
await this.#mutateSelectedReaction(reaction => {
reaction.trigger ??= {};
delete reaction.trigger.spell;
});
}
static async #onClearActionSpell(event, target) {
const actionIndex = Number(target.dataset.actionIndex);
if (!Number.isInteger(actionIndex)) return;
await this.#mutateSelectedReaction(reaction => {
const action = reaction.actions?.[actionIndex];
if (!action || action.type !== ACTION_TYPES.CAST_SPELL_FROM_TOKEN) return;
action.spellSelection = { mode: "itemName", itemName: "" };
});
}
static async #onRemoveActionStatus(event, target) {
const actionIndex = Number(target.dataset.actionIndex);
const statusId = target.dataset.statusId;
if (!Number.isInteger(actionIndex) || !statusId) return;
await this.#mutateSelectedReaction(reaction => {
const action = reaction.actions?.[actionIndex];
if (!action || action.type !== ACTION_TYPES.APPLY_STATUS) return;
action.statuses = (action.statuses ?? []).filter(id => id !== statusId);
});
}
static async #onRemoveDamageType(event, target) {
const damageType = target.dataset.damageType;
if (!damageType) return;
await this.#mutateSelectedReaction(reaction => {
reaction.conditions ??= {};
reaction.conditions.damage ??= {};
reaction.conditions.damage.types = (reaction.conditions.damage.types ?? []).filter(type => type !== damageType);
});
}
static async #onSaveReactionBasics(event, target) {
await this.#saveBasicsFromDesignerForm();
}
async #setTrigger(triggerType) {
await this.#mutateSelectedReaction(reaction => {
reaction.trigger ??= {};
reaction.trigger.type = triggerType;
if (!isSpellTrigger(triggerType)) delete reaction.trigger.spell;
});
}
async #setTriggerSpell(item) {
await this.#mutateSelectedReaction(reaction => {
reaction.trigger ??= {};
if (!isSpellTrigger(reaction.trigger.type)) reaction.trigger.type = TRIGGER_TYPES.SPELL_SEEN;
reaction.trigger.spell = buildSpellSelection(item);
});
}
async #setActionSpell(actionIndex, item) {
if (!Number.isInteger(actionIndex)) return;
await this.#mutateSelectedReaction(reaction => {
const action = reaction.actions?.[actionIndex];
if (!action || action.type !== ACTION_TYPES.CAST_SPELL_FROM_TOKEN) return;
action.spellSelection = buildSpellSelection(item);
});
}
async #addAction(action) {
await this.#mutateSelectedReaction(reaction => {
reaction.actions ??= [];
reaction.actions.push(action);
});
}
async #addActionStatus(actionIndex, statusId) {
await this.#mutateSelectedReaction(reaction => {
const action = reaction.actions?.[actionIndex];
if (!action || action.type !== ACTION_TYPES.APPLY_STATUS) return;
action.statuses ??= [];
if (!action.statuses.includes(statusId)) action.statuses.push(statusId);
});
}
async #addDamageType(damageType) {
await this.#mutateSelectedReaction(reaction => {
reaction.conditions ??= {};
reaction.conditions.damage ??= { enabled: true, amountMode: "damageOnly", types: [], typeMode: "any", minAmount: 1 };
reaction.conditions.damage.types ??= [];
if (!reaction.conditions.damage.types.includes(damageType)) reaction.conditions.damage.types.push(damageType);
});
}
async #saveReactionJsonFromTextarea({ render = true } = {}) {
const textarea = this.element.querySelector("[name='reactionJson']");
if (!textarea) return false;
let reaction;
try {
reaction = JSON.parse(textarea.value);
} catch (error) {
ui.notifications.error(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.InvalidJson"));
return false;
}
if (!reaction.id) reaction.id = foundry.utils.randomID();
if (!reaction.name) reaction.name = game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.NewReaction");
reaction.actions ??= [];
const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []);
const index = reactions.findIndex(r => r.id === reaction.id);
if (index >= 0) reactions[index] = reaction;
else reactions.push(reaction);
await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions);
this._selectedReactionId = reaction.id;
if (render) this.render({ force: true });
return true;
}
async #saveBasicsFromDesignerForm() {
const name = this.element.querySelector("[name='reactionName']")?.value?.trim();
const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true;
await this.#mutateSelectedReaction(reaction => {
if (name) reaction.name = name;
reaction.enabled = enabled;
});
}
#syncJsonFromDesignerForm() {
const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []);
const reaction = reactions.find(r => r.id === this._selectedReactionId);
if (!reaction) return;
const name = this.element.querySelector("[name='reactionName']")?.value?.trim();
const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true;
if (name) reaction.name = name;
reaction.enabled = enabled;
this.#updateJsonTextarea(reaction);
}
#updateJsonTextarea(reaction) {
const textarea = this.element.querySelector("[name='reactionJson']");
if (!textarea || document.activeElement === textarea) return;
textarea.value = JSON.stringify(reaction, null, 2);
}
async #mutateSelectedReaction(mutator) {
const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []);
const reaction = reactions.find(r => r.id === this._selectedReactionId);
if (!reaction) return;
mutator(reaction);
this.#updateJsonTextarea(reaction);
await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions);
this.render({ force: true });
}
}
function readDragPayload(event) {
const raw = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain");
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (error) {
console.warn(`${MODULE_ID} | Invalid drag payload`, error);
return null;
}
}
async function resolveDroppedItem(data) {
if (!data) return null;
if (data.uuid) {
const document = await fromUuid(data.uuid);
if (document?.documentName === "Item") return document;
}
if (data.type === "Item" && data.id) {
return game.items.get(data.id) ?? null;
}
return null;
}
function isSpellItem(item) {
return item?.documentName === "Item" && item.type === "spell";
}
function isSpellTrigger(type) {
return [TRIGGER_TYPES.SPELL_SEEN, TRIGGER_TYPES.SPELL_CAST_START, TRIGGER_TYPES.SPELL_CAST_COMPLETE].includes(type);
}
function buildSpellSelection(item) {
return {
mode: "itemName",
itemName: item.name
};
}
function createDefaultAction(type, item = null) {
switch (type) {
case ACTION_TYPES.TELEPORT:
return {
id: foundry.utils.randomID(),
type: ACTION_TYPES.TELEPORT,
enabled: true,
radius: 30,
askOwner: false,
snapToGrid: true,
maxAttempts: 80,
consumeOnFailure: false
};
case ACTION_TYPES.APPLY_STATUS:
return {
id: foundry.utils.randomID(),
type: ACTION_TYPES.APPLY_STATUS,
enabled: true,
statuses: [],
duration: {
type: "rounds",
value: 1
}
};
case ACTION_TYPES.CAST_SPELL_FROM_TOKEN:
return {
id: foundry.utils.randomID(),
type: ACTION_TYPES.CAST_SPELL_FROM_TOKEN,
enabled: true,
casterMode: "affectedToken",
spellSelection: item ? buildSpellSelection(item) : {
mode: "itemName",
itemName: ""
},
targetMode: "self",
consumeSlot: true,
consumeUses: true
};
case ACTION_TYPES.USE_INVENTORY_ITEM:
return {
id: foundry.utils.randomID(),
type: ACTION_TYPES.USE_INVENTORY_ITEM,
enabled: true,
itemSelection: {
mode: "itemName",
itemName: ""
},
userMode: "affectedToken",
targetMode: "self",
activationMode: "openSheet",
consumeUses: true
};
default:
return {
id: foundry.utils.randomID(),
type,
enabled: true
};
}
}
function getStatusEffectOptions() {
return (CONFIG.statusEffects ?? []).map(status => ({
id: status.id,
label: game.i18n.localize(status.name ?? status.label ?? status.id),
icon: status.img ?? status.icon ?? "icons/svg/aura.svg"
})).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
}
function resolveStatusEffect(statusId, statusEffects = getStatusEffectOptions()) {
return statusEffects.find(status => status.id === statusId) ?? {
id: statusId,
label: statusId,
icon: "icons/svg/aura.svg"
};
}
function getDamageTypeOptions() {
const damageTypes = CONFIG.DND5E?.damageTypes ?? {};
const entries = damageTypes instanceof Map
? Array.from(damageTypes.entries())
: Object.entries(damageTypes);
return entries.map(([id, value]) => ({
id,
label: game.i18n.localize(value?.label ?? value ?? id)
})).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
}
function resolveDamageType(typeId, damageTypes = getDamageTypeOptions()) {
return damageTypes.find(type => type.id === typeId) ?? {
id: typeId,
label: typeId
};
}
function summarizeAction(action, statusEffects = getStatusEffectOptions()) {
switch (action.type) {
case ACTION_TYPES.TELEPORT:
return game.i18n.format("CONFIGURABLE_REACTIONS.Actions.Teleport.Summary", {
radius: Number(action.radius) || 30,
mode: action.askOwner ? game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.Teleport.OwnerMode") : game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.Teleport.RandomMode")
});
case ACTION_TYPES.APPLY_STATUS: {
const statuses = action.statuses?.length
? action.statuses.map(statusId => resolveStatusEffect(statusId, statusEffects).label).join(", ")
: game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.ApplyStatus.NoStatuses");
const duration = action.duration?.type === "unlimited" ? game.i18n.localize("CONFIGURABLE_REACTIONS.Duration.Unlimited") : `${action.duration?.value ?? 1} ${action.duration?.type ?? "rounds"}`;
return `${statuses} · ${duration}`;
}
case ACTION_TYPES.CAST_SPELL_FROM_TOKEN:
return action.spellSelection?.itemName || action.spellSelection?.itemUuid || game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Unconfigured");
case ACTION_TYPES.USE_INVENTORY_ITEM:
return action.itemSelection?.itemName || action.itemSelection?.itemUuid || game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Unconfigured");
default:
return action.type;
}
}