Migrate Options to Settings and integrate GUI Builder

This commit is contained in:
ChatGPT
2026-06-04 19:08:47 +02:00
parent 343a9eaf56
commit 3c25080666
9 changed files with 571 additions and 68 deletions
+219 -28
View File
@@ -1,4 +1,12 @@
import { MODULE_ID, SETTINGS, DEFAULT_REACTION, ACTION_TYPES, TRIGGER_TYPES } from "../constants.js";
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;
@@ -13,8 +21,8 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
icon: "fa-solid fa-bolt"
},
position: {
width: 900,
height: 760
width: 1040,
height: 820
},
actions: {
createReaction: ConfigurableReactionsConfigApp.#onCreateReaction,
@@ -22,8 +30,8 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
deleteReaction: ConfigurableReactionsConfigApp.#onDeleteReaction,
assignSelectedTokens: ConfigurableReactionsConfigApp.#onAssignSelectedTokens,
removeAssignment: ConfigurableReactionsConfigApp.#onRemoveAssignment,
addTeleportAction: ConfigurableReactionsConfigApp.#onAddTeleportAction,
addStatusAction: ConfigurableReactionsConfigApp.#onAddStatusAction
removeAction: ConfigurableReactionsConfigApp.#onRemoveAction,
saveReactionBasics: ConfigurableReactionsConfigApp.#onSaveReactionBasics
}
};
@@ -41,6 +49,30 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
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 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)
};
});
return {
reactions,
assignments: assignments.map(a => ({
@@ -51,6 +83,10 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
selectedReactionJson: selectedReaction ? JSON.stringify(selectedReaction, null, 2) : "",
triggerTypes: TRIGGER_TYPES,
actionTypes: ACTION_TYPES,
triggerPalette,
actionPalette,
activeTrigger,
visualActions,
statusEffects: CONFIG.statusEffects.map(s => ({ id: s.id, name: game.i18n.localize(s.name ?? s.id) }))
};
}
@@ -62,6 +98,52 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
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");
const payload = readDragPayload(event);
if (!payload) return;
if (dropZone.dataset.crDrop === "trigger" && payload.kind === "trigger") {
await this.#setTrigger(payload.type);
return;
}
if (dropZone.dataset.crDrop === "actions" && payload.kind === "action") {
await this.#addAction(createDefaultAction(payload.type));
return;
}
});
}
}
static async #onCreateReaction(event, target) {
@@ -91,6 +173,7 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
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);
@@ -137,41 +220,149 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A
this.render({ force: true });
}
static async #onAddTeleportAction(event, target) {
await this.#addAction({
id: foundry.utils.randomID(),
type: ACTION_TYPES.TELEPORT,
enabled: true,
radius: 30,
askOwner: false,
snapToGrid: true,
maxAttempts: 80,
consumeOnFailure: false
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 #onAddStatusAction(event, target) {
await this.#addAction({
id: foundry.utils.randomID(),
type: ACTION_TYPES.APPLY_STATUS,
enabled: true,
statuses: [],
duration: {
type: "rounds",
value: 1
}
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;
});
}
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;
reaction.actions ??= [];
reaction.actions.push(action);
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;
}
}
function createDefaultAction(type) {
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: {
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;
}
}
+72 -5
View File
@@ -9,11 +9,12 @@ export const SETTINGS = Object.freeze({
});
export const TRIGGER_TYPES = Object.freeze({
DAMAGE_RECEIVED: "damageReceived",
TARGET_SELECTED: "targetSelected",
SPELL_CAST_START: "spellCastStart",
SPELL_CAST_COMPLETE: "spellCastComplete",
FEATURE_USED: "featureUsed"
DAMAGE_RECEIVED: "onDamageReceived",
TARGET_SELECTED: "onTargeted",
SPELL_SEEN: "onSpellSeen",
SPELL_CAST_START: "onSpellCastStart",
SPELL_CAST_COMPLETE: "onSpellCastComplete",
FEATURE_USED: "onFeatureUsed"
});
export const ACTION_TYPES = Object.freeze({
@@ -23,6 +24,72 @@ export const ACTION_TYPES = Object.freeze({
USE_INVENTORY_ITEM: "useInventoryItem"
});
export const TRIGGER_PALETTE = Object.freeze([
{
type: TRIGGER_TYPES.DAMAGE_RECEIVED,
icon: "fa-solid fa-heart-crack",
labelKey: "CONFIGURABLE_REACTIONS.Triggers.OnDamageReceived.Label",
hintKey: "CONFIGURABLE_REACTIONS.Triggers.OnDamageReceived.Hint"
},
{
type: TRIGGER_TYPES.TARGET_SELECTED,
icon: "fa-solid fa-bullseye",
labelKey: "CONFIGURABLE_REACTIONS.Triggers.OnTargeted.Label",
hintKey: "CONFIGURABLE_REACTIONS.Triggers.OnTargeted.Hint"
},
{
type: TRIGGER_TYPES.SPELL_SEEN,
icon: "fa-solid fa-eye",
labelKey: "CONFIGURABLE_REACTIONS.Triggers.OnSpellSeen.Label",
hintKey: "CONFIGURABLE_REACTIONS.Triggers.OnSpellSeen.Hint"
},
{
type: TRIGGER_TYPES.SPELL_CAST_START,
icon: "fa-solid fa-wand-magic-sparkles",
labelKey: "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastStart.Label",
hintKey: "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastStart.Hint"
},
{
type: TRIGGER_TYPES.SPELL_CAST_COMPLETE,
icon: "fa-solid fa-hat-wizard",
labelKey: "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastComplete.Label",
hintKey: "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastComplete.Hint"
},
{
type: TRIGGER_TYPES.FEATURE_USED,
icon: "fa-solid fa-hand-sparkles",
labelKey: "CONFIGURABLE_REACTIONS.Triggers.OnFeatureUsed.Label",
hintKey: "CONFIGURABLE_REACTIONS.Triggers.OnFeatureUsed.Hint"
}
]);
export const ACTION_PALETTE = Object.freeze([
{
type: ACTION_TYPES.APPLY_STATUS,
icon: "fa-solid fa-certificate",
labelKey: "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.Label",
hintKey: "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.Hint"
},
{
type: ACTION_TYPES.TELEPORT,
icon: "fa-solid fa-location-arrow",
labelKey: "CONFIGURABLE_REACTIONS.Actions.Teleport.Label",
hintKey: "CONFIGURABLE_REACTIONS.Actions.Teleport.Hint"
},
{
type: ACTION_TYPES.CAST_SPELL_FROM_TOKEN,
icon: "fa-solid fa-wand-sparkles",
labelKey: "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Label",
hintKey: "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Hint"
},
{
type: ACTION_TYPES.USE_INVENTORY_ITEM,
icon: "fa-solid fa-toolbox",
labelKey: "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Label",
hintKey: "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Hint"
}
]);
export const DEFAULT_REACTION = Object.freeze({
name: "Neue Reaktion",
enabled: true,
-19
View File
@@ -1,7 +1,6 @@
import { MODULE_ID } from "./constants.js";
import { registerSettings } from "./settings.js";
import { registerSocket } from "./sockets.js";
import { ConfigurableReactionsConfigApp } from "./apps/reaction-config-app.js";
import { registerDnd5eDamageTrigger } from "./triggers/damage-received.js";
import { registerTargetSelectedTrigger } from "./triggers/target-selected.js";
@@ -16,21 +15,3 @@ Hooks.once("ready", () => {
registerTargetSelectedTrigger();
console.log(`${MODULE_ID} | Ready`);
});
Hooks.on("getSceneControlButtons", controls => {
if (!game.user.isGM) return;
const tokenControls = controls.tokens ?? controls.find?.(c => c.name === "token");
if (!tokenControls) return;
const tool = {
name: "configurable-reactions",
title: game.i18n.localize("CONFIGURABLE_REACTIONS.Controls.OpenConfig"),
icon: "fa-solid fa-bolt",
button: true,
onChange: () => new ConfigurableReactionsConfigApp().render(true)
};
if (Array.isArray(tokenControls.tools)) tokenControls.tools.push(tool);
else if (tokenControls.tools instanceof Object) tokenControls.tools[tool.name] = tool;
});
+10
View File
@@ -1,6 +1,16 @@
import { MODULE_ID, SETTINGS } from "./constants.js";
import { ConfigurableReactionsConfigApp } from "./apps/reaction-config-app.js";
export function registerSettings() {
game.settings.registerMenu(MODULE_ID, "configureReactions", {
name: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Configure.Name"),
label: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Configure.Label"),
hint: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Configure.Hint"),
icon: "fa-solid fa-bolt",
type: ConfigurableReactionsConfigApp,
restricted: true
});
game.settings.register(MODULE_ID, SETTINGS.REACTIONS, {
name: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Reactions.Name"),
hint: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Reactions.Hint"),