From 3c25080666de2feab58e7b53324fb057fdf952bf Mon Sep 17 00:00:00 2001 From: ChatGPT Date: Thu, 4 Jun 2026 19:08:47 +0200 Subject: [PATCH] Migrate Options to Settings and integrate GUI Builder --- README.md | 5 +- lang/de.json | 44 ++++- lang/en.json | 44 ++++- scripts/apps/reaction-config-app.js | 247 ++++++++++++++++++++++++---- scripts/constants.js | 77 ++++++++- scripts/main.js | 19 --- scripts/settings.js | 10 ++ styles/configurable-reactions.css | 106 +++++++++++- templates/reaction-config.hbs | 87 +++++++++- 9 files changed, 571 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 383c7e0..64e5b34 100755 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ This repository is an initial module skeleton. It includes: - World-level reaction storage - World-level assignment storage -- A GM configuration app +- A GM configuration app opened through Foundry module settings +- A graphical drag-and-drop builder for trigger and action JSON blocks - Assignment of a configured reaction to selected tokens - Linked-token handling: linked tokens assign the reaction to the Actor - Unlinked-token handling: unlinked tokens assign the reaction to the TokenDocument @@ -49,7 +50,7 @@ cd /path/to/FoundryVTT/Data/modules git clone configurable-reactions ``` -Restart Foundry and enable **Configurable Reactions** in the world. +Restart Foundry and enable **Configurable Reactions** in the world. The configuration dialog is available under **Configure Settings → Module Settings → Configurable Reactions → Configure**. ## Development workflow diff --git a/lang/de.json b/lang/de.json index 8180bbb..ab69960 100755 --- a/lang/de.json +++ b/lang/de.json @@ -37,5 +37,47 @@ "CONFIGURABLE_REACTIONS.Teleport.ChooseTarget": "Wähle ein Ziel innerhalb von {radius} ft.", "CONFIGURABLE_REACTIONS.Teleport.SwitchScene": "Bitte wechsle zur betroffenen Szene.", "CONFIGURABLE_REACTIONS.Teleport.InvalidTarget": "Das gewählte Teleportziel ist ungültig.", - "CONFIGURABLE_REACTIONS.Teleport.NoValidTarget": "Kein gültiges Teleportziel für {name} innerhalb von {radius} ft gefunden." + "CONFIGURABLE_REACTIONS.Teleport.NoValidTarget": "Kein gültiges Teleportziel für {name} innerhalb von {radius} ft gefunden.", + "CONFIGURABLE_REACTIONS.Settings.Configure.Name": "Reaktionsverwaltung öffnen", + "CONFIGURABLE_REACTIONS.Settings.Configure.Label": "Konfigurieren", + "CONFIGURABLE_REACTIONS.Settings.Configure.Hint": "Öffnet den grafischen Editor für Configurable Reactions.", + "CONFIGURABLE_REACTIONS.Reactions.Name": "Name", + "CONFIGURABLE_REACTIONS.Reactions.Enabled": "Aktiv", + "CONFIGURABLE_REACTIONS.Reactions.JsonPreview": "JSON-Vorschau", + "CONFIGURABLE_REACTIONS.Common.Apply": "Übernehmen", + "CONFIGURABLE_REACTIONS.Builder.Palette": "Bausteine", + "CONFIGURABLE_REACTIONS.Builder.DragHint": "Ziehe einen Auslöser in den Auslöser-Bereich und Aktionen in den Effekt-Bereich. Die JSON-Konfiguration wird daraus erzeugt und bleibt weiterhin direkt editierbar.", + "CONFIGURABLE_REACTIONS.Builder.TriggerPalette": "Auslöser", + "CONFIGURABLE_REACTIONS.Builder.ActionPalette": "Aktionen", + "CONFIGURABLE_REACTIONS.Builder.TriggerArea": "Auslöser-Bereich", + "CONFIGURABLE_REACTIONS.Builder.ActionArea": "Effekt-Bereich", + "CONFIGURABLE_REACTIONS.Builder.DropTriggerHere": "Auslöser-Baustein hier ablegen", + "CONFIGURABLE_REACTIONS.Builder.DropActionHere": "Aktions-Bausteine hier ablegen", + "CONFIGURABLE_REACTIONS.Triggers.OnDamageReceived.Label": "Schaden erhalten", + "CONFIGURABLE_REACTIONS.Triggers.OnDamageReceived.Hint": "Reagiert, nachdem Schaden oder Heilung am betroffenen Actor verarbeitet wurde.", + "CONFIGURABLE_REACTIONS.Triggers.OnTargeted.Label": "Als Ziel gewählt", + "CONFIGURABLE_REACTIONS.Triggers.OnTargeted.Hint": "Reagiert, wenn ein Token als Ziel gewählt wird.", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellSeen.Label": "Zauber gesehen", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellSeen.Hint": "Reagiert, wenn ein Zauber im Sichtbereich erkannt werden soll. Der technische Hook ist als Platzhalter vorbereitet.", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastStart.Label": "Zauber wird gewirkt", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastStart.Hint": "Reagiert beim Start eines Zauberwirkens. Der technische Hook ist als Platzhalter vorbereitet.", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastComplete.Label": "Zauber wurde gewirkt", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastComplete.Hint": "Reagiert nach Abschluss eines Zauberwirkens. Der technische Hook ist als Platzhalter vorbereitet.", + "CONFIGURABLE_REACTIONS.Triggers.OnFeatureUsed.Label": "Feature wird benutzt", + "CONFIGURABLE_REACTIONS.Triggers.OnFeatureUsed.Hint": "Reagiert, wenn ein Feature benutzt wird. Der technische Hook ist als Platzhalter vorbereitet.", + "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.Label": "Status setzen", + "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.Hint": "Setzt einen oder mehrere Foundry-Status auf dem betroffenen Actor.", + "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.NoStatuses": "Keine Status ausgewählt", + "CONFIGURABLE_REACTIONS.Actions.Teleport.Label": "Teleport", + "CONFIGURABLE_REACTIONS.Actions.Teleport.Hint": "Teleportiert den Token in einen gültigen freien Bereich innerhalb des Radius.", + "CONFIGURABLE_REACTIONS.Actions.Teleport.Summary": "{radius} ft · {mode}", + "CONFIGURABLE_REACTIONS.Actions.Teleport.OwnerMode": "Owner wählt", + "CONFIGURABLE_REACTIONS.Actions.Teleport.RandomMode": "zufällig", + "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Label": "Zauber auslösen", + "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Hint": "Öffnet oder nutzt einen Zauber aus dem Inventar eines Tokens.", + "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Unconfigured": "Kein Zauber konfiguriert", + "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Label": "Gegenstandseffekt auslösen", + "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Hint": "Öffnet oder nutzt einen Gegenstand aus dem Inventar eines Tokens.", + "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Unconfigured": "Kein Gegenstand konfiguriert", + "CONFIGURABLE_REACTIONS.Duration.Unlimited": "Unbegrenzt" } diff --git a/lang/en.json b/lang/en.json index 746bce9..ce21e8c 100755 --- a/lang/en.json +++ b/lang/en.json @@ -37,5 +37,47 @@ "CONFIGURABLE_REACTIONS.Teleport.ChooseTarget": "Choose a target within {radius} ft.", "CONFIGURABLE_REACTIONS.Teleport.SwitchScene": "Please switch to the affected scene.", "CONFIGURABLE_REACTIONS.Teleport.InvalidTarget": "The selected teleport target is invalid.", - "CONFIGURABLE_REACTIONS.Teleport.NoValidTarget": "No valid teleport target for {name} within {radius} ft found." + "CONFIGURABLE_REACTIONS.Teleport.NoValidTarget": "No valid teleport target for {name} within {radius} ft found.", + "CONFIGURABLE_REACTIONS.Settings.Configure.Name": "Open reaction management", + "CONFIGURABLE_REACTIONS.Settings.Configure.Label": "Configure", + "CONFIGURABLE_REACTIONS.Settings.Configure.Hint": "Opens the graphical editor for Configurable Reactions.", + "CONFIGURABLE_REACTIONS.Reactions.Name": "Name", + "CONFIGURABLE_REACTIONS.Reactions.Enabled": "Enabled", + "CONFIGURABLE_REACTIONS.Reactions.JsonPreview": "JSON preview", + "CONFIGURABLE_REACTIONS.Common.Apply": "Apply", + "CONFIGURABLE_REACTIONS.Builder.Palette": "Building blocks", + "CONFIGURABLE_REACTIONS.Builder.DragHint": "Drag a trigger into the trigger area and actions into the effect area. The JSON configuration is generated from those blocks and remains directly editable.", + "CONFIGURABLE_REACTIONS.Builder.TriggerPalette": "Triggers", + "CONFIGURABLE_REACTIONS.Builder.ActionPalette": "Actions", + "CONFIGURABLE_REACTIONS.Builder.TriggerArea": "Trigger area", + "CONFIGURABLE_REACTIONS.Builder.ActionArea": "Effect area", + "CONFIGURABLE_REACTIONS.Builder.DropTriggerHere": "Drop trigger block here", + "CONFIGURABLE_REACTIONS.Builder.DropActionHere": "Drop action blocks here", + "CONFIGURABLE_REACTIONS.Triggers.OnDamageReceived.Label": "Damage received", + "CONFIGURABLE_REACTIONS.Triggers.OnDamageReceived.Hint": "Reacts after damage or healing has been processed on the affected actor.", + "CONFIGURABLE_REACTIONS.Triggers.OnTargeted.Label": "Targeted", + "CONFIGURABLE_REACTIONS.Triggers.OnTargeted.Hint": "Reacts when a token is targeted.", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellSeen.Label": "Spell seen", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellSeen.Hint": "Reacts when a spell should be detected in line of sight. The technical hook is prepared as a placeholder.", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastStart.Label": "Spell is being cast", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastStart.Hint": "Reacts when spell casting starts. The technical hook is prepared as a placeholder.", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastComplete.Label": "Spell was cast", + "CONFIGURABLE_REACTIONS.Triggers.OnSpellCastComplete.Hint": "Reacts after spell casting completes. The technical hook is prepared as a placeholder.", + "CONFIGURABLE_REACTIONS.Triggers.OnFeatureUsed.Label": "Feature is used", + "CONFIGURABLE_REACTIONS.Triggers.OnFeatureUsed.Hint": "Reacts when a feature is used. The technical hook is prepared as a placeholder.", + "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.Label": "Apply status", + "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.Hint": "Applies one or more Foundry statuses to the affected actor.", + "CONFIGURABLE_REACTIONS.Actions.ApplyStatus.NoStatuses": "No statuses selected", + "CONFIGURABLE_REACTIONS.Actions.Teleport.Label": "Teleport", + "CONFIGURABLE_REACTIONS.Actions.Teleport.Hint": "Teleports the token to a valid free space within the radius.", + "CONFIGURABLE_REACTIONS.Actions.Teleport.Summary": "{radius} ft · {mode}", + "CONFIGURABLE_REACTIONS.Actions.Teleport.OwnerMode": "owner chooses", + "CONFIGURABLE_REACTIONS.Actions.Teleport.RandomMode": "random", + "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Label": "Trigger spell", + "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Hint": "Opens or uses a spell from a token inventory.", + "CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Unconfigured": "No spell configured", + "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Label": "Trigger item effect", + "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Hint": "Opens or uses an item from a token inventory.", + "CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Unconfigured": "No item configured", + "CONFIGURABLE_REACTIONS.Duration.Unlimited": "Unlimited" } diff --git a/scripts/apps/reaction-config-app.js b/scripts/apps/reaction-config-app.js index 9bab8bd..ee1bcd8 100755 --- a/scripts/apps/reaction-config-app.js +++ b/scripts/apps/reaction-config-app.js @@ -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; + } +} diff --git a/scripts/constants.js b/scripts/constants.js index 9ae6a52..ebe6a7d 100755 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -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, diff --git a/scripts/main.js b/scripts/main.js index 803665d..626f13b 100755 --- a/scripts/main.js +++ b/scripts/main.js @@ -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; -}); diff --git a/scripts/settings.js b/scripts/settings.js index dfbd65a..cc615eb 100755 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -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"), diff --git a/styles/configurable-reactions.css b/styles/configurable-reactions.css index 9ffc540..ae88424 100755 --- a/styles/configurable-reactions.css +++ b/styles/configurable-reactions.css @@ -15,7 +15,7 @@ .configurable-reactions .cr-grid { display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: 310px 1fr; gap: 1rem; min-height: 0; height: 100%; @@ -34,6 +34,101 @@ margin: 0.75rem 0; } +.configurable-reactions .cr-palette { + display: grid; + grid-template-columns: 1fr; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.configurable-reactions .cr-palette-tile, +.configurable-reactions .cr-flow-node { + display: grid; + grid-template-columns: 2rem 1fr auto; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--color-border-light-tertiary, #888); + border-radius: 6px; + padding: 0.45rem; + background: rgb(0 0 0 / 0.08); +} + +.configurable-reactions .cr-palette-tile { + cursor: grab; + grid-template-columns: 2rem 1fr; +} + +.configurable-reactions .cr-palette-tile:active { + cursor: grabbing; +} + +.configurable-reactions .cr-palette-tile i, +.configurable-reactions .cr-flow-node > i { + text-align: center; + font-size: 1.25rem; +} + +.configurable-reactions .cr-palette-tile span, +.configurable-reactions .cr-flow-node strong, +.configurable-reactions .cr-flow-node small, +.configurable-reactions .cr-flow-node code { + display: block; +} + +.configurable-reactions .cr-palette-tile code, +.configurable-reactions .cr-flow-node code { + opacity: 0.75; + font-size: 0.75rem; + overflow-wrap: anywhere; +} + +.configurable-reactions .cr-builder-grid { + display: grid; + grid-template-columns: 1fr 1.35fr; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.configurable-reactions .cr-card, +.configurable-reactions .cr-drop-card, +.configurable-reactions .cr-reaction-summary { + border: 1px solid var(--color-border-light-tertiary, #888); + border-radius: 6px; + padding: 0.65rem; + background: rgb(0 0 0 / 0.04); +} + +.configurable-reactions .cr-basics { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: end; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.configurable-reactions .cr-drop-card { + min-height: 170px; + transition: outline-color 0.15s ease, background-color 0.15s ease; +} + +.configurable-reactions .cr-drop-active { + outline: 2px dashed var(--color-border-highlight, #ff6400); + background: rgb(255 100 0 / 0.12); +} + +.configurable-reactions .cr-empty-drop { + display: flex; + align-items: center; + justify-content: center; + min-height: 90px; + border: 1px dashed var(--color-border-light-tertiary, #888); + border-radius: 6px; + opacity: 0.8; + text-align: center; + padding: 0.75rem; +} + +.configurable-reactions .cr-action-flow, .configurable-reactions .cr-assignment-list { display: flex; flex-direction: column; @@ -43,6 +138,10 @@ list-style: none; } +.configurable-reactions .cr-action-node { + grid-template-columns: 2rem 1fr 2rem; +} + .configurable-reactions .cr-assignment-list li { display: flex; align-items: center; @@ -60,15 +159,12 @@ .configurable-reactions .cr-main textarea[name="reactionJson"] { width: 100%; - min-height: 430px; + min-height: 340px; font-family: var(--font-monospace, monospace); resize: vertical; } .configurable-reactions .cr-reaction-summary { - border: 1px solid var(--color-border-light-tertiary, #888); - border-radius: 4px; - padding: 0.5rem; margin-bottom: 0.75rem; } diff --git a/templates/reaction-config.hbs b/templates/reaction-config.hbs index e6fb638..57da803 100755 --- a/templates/reaction-config.hbs +++ b/templates/reaction-config.hbs @@ -21,17 +21,36 @@ - - +

{{localize "CONFIGURABLE_REACTIONS.Builder.Palette"}}

+

{{localize "CONFIGURABLE_REACTIONS.Builder.DragHint"}}

+ +

{{localize "CONFIGURABLE_REACTIONS.Builder.TriggerPalette"}}

+
+ {{#each triggerPalette}} +
+ + {{this.label}} + {{this.type}} +
+ {{/each}} +
+ +

{{localize "CONFIGURABLE_REACTIONS.Builder.ActionPalette"}}

+
+ {{#each actionPalette}} +
+ + {{this.label}} + {{this.type}} +
+ {{/each}} +
+

{{localize "CONFIGURABLE_REACTIONS.Assignments.Title"}}

    {{#each assignments}} @@ -50,8 +69,62 @@
    {{#if selectedReaction}} +
    +
    + + +
    +
    + + +
    + +
    + +
    +
    +

    {{localize "CONFIGURABLE_REACTIONS.Builder.TriggerArea"}}

    + {{#if activeTrigger}} +
    + +
    + {{activeTrigger.label}} + {{selectedReaction.trigger.type}} +
    +
    + {{else}} +

    {{localize "CONFIGURABLE_REACTIONS.Builder.DropTriggerHere"}}

    + {{/if}} +
    + +
    +

    {{localize "CONFIGURABLE_REACTIONS.Builder.ActionArea"}}

    + {{#if visualActions.length}} +
      + {{#each visualActions}} +
    1. + +
      + {{this.label}} + {{this.summary}} + {{this.type}} +
      + +
    2. + {{/each}} +
    + {{else}} +

    {{localize "CONFIGURABLE_REACTIONS.Builder.DropActionHere"}}

    + {{/if}} +
    +
    +
    -

    {{selectedReaction.name}}

    +

    {{localize "CONFIGURABLE_REACTIONS.Reactions.JsonPreview"}}

    ID: {{selectedReaction.id}}

    {{localize "CONFIGURABLE_REACTIONS.Trigger.Label"}}: {{selectedReaction.trigger.type}}

    {{localize "CONFIGURABLE_REACTIONS.Actions.Title"}}: {{selectedReaction.actions.length}}