463 lines
16 KiB
JavaScript
463 lines
16 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"
|
|
},
|
|
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,
|
|
clearActionSpell: ConfigurableReactionsConfigApp.#onClearActionSpell
|
|
}
|
|
};
|
|
|
|
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 visualActions = (selectedReaction?.actions ?? []).map((action, index) => {
|
|
const paletteEntry = actionPalette.find(p => p.type === action.type);
|
|
return {
|
|
...action,
|
|
index,
|
|
label: paletteEntry?.label ?? action.type,
|
|
icon: paletteEntry?.icon ?? "fa-solid fa-puzzle-piece",
|
|
summary: summarizeAction(action),
|
|
isSpellAction: action.type === ACTION_TYPES.CAST_SPELL_FROM_TOKEN,
|
|
spellName: action.spellSelection?.itemName || action.spellSelection?.itemUuid || ""
|
|
};
|
|
});
|
|
|
|
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,
|
|
triggerSpellName,
|
|
visualActions,
|
|
statusEffects: CONFIG.statusEffects.map(s => ({ id: s.id, name: game.i18n.localize(s.name ?? s.id) }))
|
|
};
|
|
}
|
|
|
|
_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.#activateDragAndDrop();
|
|
}
|
|
|
|
#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) {
|
|
const textarea = this.element.querySelector("[name='reactionJson']");
|
|
if (!textarea) return;
|
|
|
|
let reaction;
|
|
try {
|
|
reaction = JSON.parse(textarea.value);
|
|
} catch (error) {
|
|
ui.notifications.error(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.InvalidJson"));
|
|
return;
|
|
}
|
|
|
|
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;
|
|
this.render({ force: 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 #onSaveReactionBasics(event, target) {
|
|
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;
|
|
});
|
|
}
|
|
|
|
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 #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);
|
|
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 summarizeAction(action) {
|
|
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.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;
|
|
}
|
|
}
|