Migrate Options to Settings and integrate GUI Builder
This commit is contained in:
parent
343a9eaf56
commit
3c25080666
@ -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 <your-remote-url> 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
|
||||
|
||||
|
||||
44
lang/de.json
44
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"
|
||||
}
|
||||
|
||||
44
lang/en.json
44
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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -21,17 +21,36 @@
|
||||
<button type="button" data-action="assignSelectedTokens" {{#unless selectedReaction}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-crosshairs"></i> {{localize "CONFIGURABLE_REACTIONS.Assignments.AssignSelectedTokens"}}
|
||||
</button>
|
||||
<button type="button" data-action="addStatusAction" {{#unless selectedReaction}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-circle-plus"></i> {{localize "CONFIGURABLE_REACTIONS.Actions.AddStatus"}}
|
||||
</button>
|
||||
<button type="button" data-action="addTeleportAction" {{#unless selectedReaction}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-circle-plus"></i> {{localize "CONFIGURABLE_REACTIONS.Actions.AddTeleport"}}
|
||||
</button>
|
||||
<button type="button" data-action="deleteReaction" {{#unless selectedReaction}}disabled{{/unless}}>
|
||||
<i class="fa-solid fa-trash"></i> {{localize "CONFIGURABLE_REACTIONS.Reactions.Delete"}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>{{localize "CONFIGURABLE_REACTIONS.Builder.Palette"}}</h3>
|
||||
<p class="notes">{{localize "CONFIGURABLE_REACTIONS.Builder.DragHint"}}</p>
|
||||
|
||||
<h4>{{localize "CONFIGURABLE_REACTIONS.Builder.TriggerPalette"}}</h4>
|
||||
<div class="cr-palette">
|
||||
{{#each triggerPalette}}
|
||||
<article class="cr-palette-tile" data-cr-drag="trigger" data-cr-type="{{this.type}}" title="{{this.hint}}">
|
||||
<i class="{{this.icon}}"></i>
|
||||
<span>{{this.label}}</span>
|
||||
<code>{{this.type}}</code>
|
||||
</article>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<h4>{{localize "CONFIGURABLE_REACTIONS.Builder.ActionPalette"}}</h4>
|
||||
<div class="cr-palette">
|
||||
{{#each actionPalette}}
|
||||
<article class="cr-palette-tile" data-cr-drag="action" data-cr-type="{{this.type}}" title="{{this.hint}}">
|
||||
<i class="{{this.icon}}"></i>
|
||||
<span>{{this.label}}</span>
|
||||
<code>{{this.type}}</code>
|
||||
</article>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<h3>{{localize "CONFIGURABLE_REACTIONS.Assignments.Title"}}</h3>
|
||||
<ol class="cr-assignment-list">
|
||||
{{#each assignments}}
|
||||
@ -50,8 +69,62 @@
|
||||
|
||||
<main class="cr-main">
|
||||
{{#if selectedReaction}}
|
||||
<section class="cr-card cr-basics">
|
||||
<div class="form-group">
|
||||
<label>{{localize "CONFIGURABLE_REACTIONS.Reactions.Name"}}</label>
|
||||
<input type="text" name="reactionName" value="{{selectedReaction.name}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{localize "CONFIGURABLE_REACTIONS.Reactions.Enabled"}}</label>
|
||||
<input type="checkbox" name="reactionEnabled" {{#if selectedReaction.enabled}}checked{{/if}}>
|
||||
</div>
|
||||
<button type="button" data-action="saveReactionBasics">
|
||||
<i class="fa-solid fa-floppy-disk"></i> {{localize "CONFIGURABLE_REACTIONS.Common.Apply"}}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="cr-builder-grid">
|
||||
<div class="cr-drop-card" data-cr-drop="trigger">
|
||||
<h3><i class="fa-solid fa-bolt"></i> {{localize "CONFIGURABLE_REACTIONS.Builder.TriggerArea"}}</h3>
|
||||
{{#if activeTrigger}}
|
||||
<div class="cr-flow-node cr-trigger-node">
|
||||
<i class="{{activeTrigger.icon}}"></i>
|
||||
<div>
|
||||
<strong>{{activeTrigger.label}}</strong>
|
||||
<code>{{selectedReaction.trigger.type}}</code>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="cr-empty-drop">{{localize "CONFIGURABLE_REACTIONS.Builder.DropTriggerHere"}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="cr-drop-card" data-cr-drop="actions">
|
||||
<h3><i class="fa-solid fa-list-check"></i> {{localize "CONFIGURABLE_REACTIONS.Builder.ActionArea"}}</h3>
|
||||
{{#if visualActions.length}}
|
||||
<ol class="cr-action-flow">
|
||||
{{#each visualActions}}
|
||||
<li class="cr-flow-node cr-action-node" data-cr-drag="existingAction" data-action-index="{{this.index}}">
|
||||
<i class="{{this.icon}}"></i>
|
||||
<div>
|
||||
<strong>{{this.label}}</strong>
|
||||
<small>{{this.summary}}</small>
|
||||
<code>{{this.type}}</code>
|
||||
</div>
|
||||
<button type="button" data-action="removeAction" data-action-index="{{this.index}}" title="{{localize 'CONFIGURABLE_REACTIONS.Common.Remove'}}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{else}}
|
||||
<p class="cr-empty-drop">{{localize "CONFIGURABLE_REACTIONS.Builder.DropActionHere"}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cr-reaction-summary">
|
||||
<h3>{{selectedReaction.name}}</h3>
|
||||
<h3>{{localize "CONFIGURABLE_REACTIONS.Reactions.JsonPreview"}}</h3>
|
||||
<p><strong>ID:</strong> <code>{{selectedReaction.id}}</code></p>
|
||||
<p><strong>{{localize "CONFIGURABLE_REACTIONS.Trigger.Label"}}:</strong> {{selectedReaction.trigger.type}}</p>
|
||||
<p><strong>{{localize "CONFIGURABLE_REACTIONS.Actions.Title"}}:</strong> {{selectedReaction.actions.length}}</p>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user