diff --git a/lang/de.json b/lang/de.json index a20e0d9..1d60fde 100644 --- a/lang/de.json +++ b/lang/de.json @@ -86,5 +86,9 @@ "CONFIGURABLE_REACTIONS.Builder.DropSpellOnTrigger": "Zauber auf diesen Auslöser ziehen, um nach Zaubername zu filtern.", "CONFIGURABLE_REACTIONS.Builder.DropSpellOnAction": "Zauber hier ablegen, um ihn für diese Aktion zu speichern.", "CONFIGURABLE_REACTIONS.Builder.DropSpellCreatesAction": "Ein Zauber kann direkt in den Effekt-Bereich gezogen werden und erzeugt eine passende Zauber-Aktion.", - "CONFIGURABLE_REACTIONS.Triggers.SpellFilter": "Zauberfilter: {name}" + "CONFIGURABLE_REACTIONS.Triggers.SpellFilter": "Zauberfilter: {name}", + "CONFIGURABLE_REACTIONS.Conditions.Damage.SelectedTypes": "Gewählte Schadenstypen", + "CONFIGURABLE_REACTIONS.Conditions.Damage.NoTypes": "Keine Schadenstypen ausgewählt", + "CONFIGURABLE_REACTIONS.Conditions.Damage.SelectPlaceholder": "Schadenstyp auswählen", + "CONFIGURABLE_REACTIONS.Conditions.Damage.RemoveType": "Schadenstyp entfernen: {type}" } diff --git a/lang/en.json b/lang/en.json index d4bd718..c8d94b3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -86,5 +86,9 @@ "CONFIGURABLE_REACTIONS.Builder.DropSpellOnTrigger": "Drop a spell on this trigger to filter by spell name.", "CONFIGURABLE_REACTIONS.Builder.DropSpellOnAction": "Drop a spell here to store it for this action.", "CONFIGURABLE_REACTIONS.Builder.DropSpellCreatesAction": "A spell can be dropped directly into the effect area to create a configured spell action.", - "CONFIGURABLE_REACTIONS.Triggers.SpellFilter": "Spell filter: {name}" + "CONFIGURABLE_REACTIONS.Triggers.SpellFilter": "Spell filter: {name}", + "CONFIGURABLE_REACTIONS.Conditions.Damage.SelectedTypes": "Selected damage types", + "CONFIGURABLE_REACTIONS.Conditions.Damage.NoTypes": "No damage types selected", + "CONFIGURABLE_REACTIONS.Conditions.Damage.SelectPlaceholder": "Select damage type", + "CONFIGURABLE_REACTIONS.Conditions.Damage.RemoveType": "Remove damage type: {type}" } diff --git a/scripts/apps/reaction-config-app.js b/scripts/apps/reaction-config-app.js index 0c02381..732e8dd 100644 --- a/scripts/apps/reaction-config-app.js +++ b/scripts/apps/reaction-config-app.js @@ -18,7 +18,8 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A classes: ["configurable-reactions", "standard-form"], window: { title: "CONFIGURABLE_REACTIONS.App.Title", - icon: "fa-solid fa-bolt" + icon: "fa-solid fa-bolt", + resizable: true }, position: { width: 1040, @@ -33,6 +34,7 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A removeAction: ConfigurableReactionsConfigApp.#onRemoveAction, saveReactionBasics: ConfigurableReactionsConfigApp.#onSaveReactionBasics, clearTriggerSpell: ConfigurableReactionsConfigApp.#onClearTriggerSpell, + removeDamageType: ConfigurableReactionsConfigApp.#onRemoveDamageType, clearActionSpell: ConfigurableReactionsConfigApp.#onClearActionSpell, removeActionStatus: ConfigurableReactionsConfigApp.#onRemoveActionStatus } @@ -67,7 +69,12 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A const activeTrigger = triggerPalette.find(t => t.type === selectedReaction?.trigger?.type) ?? null; const triggerSpellName = selectedReaction?.trigger?.spell?.itemName || selectedReaction?.trigger?.spell?.itemUuid || ""; const activeTriggerAcceptsSpell = isSpellTrigger(selectedReaction?.trigger?.type); + const activeTriggerIsDamage = selectedReaction?.trigger?.type === TRIGGER_TYPES.DAMAGE_RECEIVED; const statusEffects = getStatusEffectOptions(); + const damageTypes = getDamageTypeOptions(); + const selectedDamageTypeIds = Array.from(new Set(selectedReaction?.conditions?.damage?.types ?? [])); + const selectedDamageTypes = selectedDamageTypeIds.map(typeId => resolveDamageType(typeId, damageTypes)); + const availableDamageTypes = damageTypes.filter(type => !selectedDamageTypeIds.includes(type.id)); const visualActions = (selectedReaction?.actions ?? []).map((action, index) => { const paletteEntry = actionPalette.find(p => p.type === action.type); const selectedStatusIds = Array.from(new Set(action.statuses ?? [])); @@ -101,9 +108,12 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A actionPalette, activeTrigger, activeTriggerAcceptsSpell, + activeTriggerIsDamage, triggerSpellName, visualActions, - statusEffects + statusEffects, + selectedDamageTypes, + availableDamageTypes }; } @@ -115,7 +125,10 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A this.render({ force: true }); }); + this.#activateJsonEditor(); + this.#activateBasicsSync(); this.#activateStatusSelectors(); + this.#activateDamageTypeSelector(); this.#activateDragAndDrop(); } @@ -130,6 +143,41 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A }); } } + #activateJsonEditor() { + const textarea = this.element.querySelector("[name='reactionJson']"); + if (!textarea) return; + + textarea.addEventListener("blur", async () => { + if (document.activeElement === textarea) return; + await this.#saveReactionJsonFromTextarea({ render: true }); + }); + } + + #activateBasicsSync() { + const nameInput = this.element.querySelector("[name='reactionName']"); + const enabledInput = this.element.querySelector("[name='reactionEnabled']"); + + nameInput?.addEventListener("input", () => this.#syncJsonFromDesignerForm()); + nameInput?.addEventListener("change", async () => this.#saveBasicsFromDesignerForm()); + nameInput?.addEventListener("blur", async () => this.#saveBasicsFromDesignerForm()); + + enabledInput?.addEventListener("change", async () => { + this.#syncJsonFromDesignerForm(); + await this.#saveBasicsFromDesignerForm(); + }); + } + + #activateDamageTypeSelector() { + const select = this.element.querySelector("[data-cr-damage-type-select]"); + if (!select) return; + + select.addEventListener("change", async event => { + const damageType = event.currentTarget.value; + if (!damageType) return; + await this.#addDamageType(damageType); + }); + } + #activateDragAndDrop() { for (const draggable of this.element.querySelectorAll("[data-cr-drag]")) { @@ -208,29 +256,7 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A } 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 }); + await this.#saveReactionJsonFromTextarea({ render: true }); } static async #onDeleteReaction(event, target) { @@ -308,16 +334,21 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A }); } - static async #onSaveReactionBasics(event, target) { - const name = this.element.querySelector("[name='reactionName']")?.value?.trim(); - const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true; + static async #onRemoveDamageType(event, target) { + const damageType = target.dataset.damageType; + if (!damageType) return; await this.#mutateSelectedReaction(reaction => { - if (name) reaction.name = name; - reaction.enabled = enabled; + reaction.conditions ??= {}; + reaction.conditions.damage ??= {}; + reaction.conditions.damage.types = (reaction.conditions.damage.types ?? []).filter(type => type !== damageType); }); } + static async #onSaveReactionBasics(event, target) { + await this.#saveBasicsFromDesignerForm(); + } + async #setTrigger(triggerType) { await this.#mutateSelectedReaction(reaction => { reaction.trigger ??= {}; @@ -361,12 +392,77 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A }); } + async #addDamageType(damageType) { + await this.#mutateSelectedReaction(reaction => { + reaction.conditions ??= {}; + reaction.conditions.damage ??= { enabled: true, amountMode: "damageOnly", types: [], typeMode: "any", minAmount: 1 }; + reaction.conditions.damage.types ??= []; + if (!reaction.conditions.damage.types.includes(damageType)) reaction.conditions.damage.types.push(damageType); + }); + } + + async #saveReactionJsonFromTextarea({ render = true } = {}) { + const textarea = this.element.querySelector("[name='reactionJson']"); + if (!textarea) return false; + + let reaction; + try { + reaction = JSON.parse(textarea.value); + } catch (error) { + ui.notifications.error(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.InvalidJson")); + return false; + } + + if (!reaction.id) reaction.id = foundry.utils.randomID(); + if (!reaction.name) reaction.name = game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.NewReaction"); + reaction.actions ??= []; + + const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); + const index = reactions.findIndex(r => r.id === reaction.id); + if (index >= 0) reactions[index] = reaction; + else reactions.push(reaction); + + await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); + this._selectedReactionId = reaction.id; + if (render) this.render({ force: true }); + return true; + } + + async #saveBasicsFromDesignerForm() { + const name = this.element.querySelector("[name='reactionName']")?.value?.trim(); + const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true; + + await this.#mutateSelectedReaction(reaction => { + if (name) reaction.name = name; + reaction.enabled = enabled; + }); + } + + #syncJsonFromDesignerForm() { + const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); + const reaction = reactions.find(r => r.id === this._selectedReactionId); + if (!reaction) return; + + const name = this.element.querySelector("[name='reactionName']")?.value?.trim(); + const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true; + if (name) reaction.name = name; + reaction.enabled = enabled; + this.#updateJsonTextarea(reaction); + } + + #updateJsonTextarea(reaction) { + const textarea = this.element.querySelector("[name='reactionJson']"); + if (!textarea || document.activeElement === textarea) return; + textarea.value = JSON.stringify(reaction, null, 2); + } + async #mutateSelectedReaction(mutator) { const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); const reaction = reactions.find(r => r.id === this._selectedReactionId); if (!reaction) return; mutator(reaction); + this.#updateJsonTextarea(reaction); await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); this.render({ force: true }); } @@ -495,6 +591,25 @@ function resolveStatusEffect(statusId, statusEffects = getStatusEffectOptions()) }; } +function getDamageTypeOptions() { + const damageTypes = CONFIG.DND5E?.damageTypes ?? {}; + const entries = damageTypes instanceof Map + ? Array.from(damageTypes.entries()) + : Object.entries(damageTypes); + + return entries.map(([id, value]) => ({ + id, + label: game.i18n.localize(value?.label ?? value ?? id) + })).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); +} + +function resolveDamageType(typeId, damageTypes = getDamageTypeOptions()) { + return damageTypes.find(type => type.id === typeId) ?? { + id: typeId, + label: typeId + }; +} + function summarizeAction(action, statusEffects = getStatusEffectOptions()) { switch (action.type) { case ACTION_TYPES.TELEPORT: diff --git a/styles/configurable-reactions.css b/styles/configurable-reactions.css index 0d27929..a5f3fc4 100644 --- a/styles/configurable-reactions.css +++ b/styles/configurable-reactions.css @@ -15,7 +15,7 @@ .configurable-reactions .cr-grid { display: grid; - grid-template-columns: 310px 1fr; + grid-template-columns: 33% 67%; gap: 1rem; min-height: 0; height: 100%; @@ -27,6 +27,12 @@ overflow: auto; } +.configurable-reactions .cr-main { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + .configurable-reactions .cr-button-column { display: flex; flex-direction: column; @@ -86,7 +92,9 @@ display: grid; grid-template-columns: 1fr 1.35fr; gap: 0.75rem; - margin-bottom: 0.75rem; + flex: 0 0 40%; + min-height: 220px; + overflow: auto; } .configurable-reactions .cr-card, @@ -100,7 +108,7 @@ .configurable-reactions .cr-basics { display: grid; - grid-template-columns: 1fr auto auto; + grid-template-columns: 1fr auto; align-items: end; gap: 0.75rem; margin-bottom: 0.75rem; @@ -157,15 +165,17 @@ opacity: 0.8; } -.configurable-reactions .cr-main textarea[name="reactionJson"] { - width: 100%; - min-height: 340px; - font-family: var(--font-monospace, monospace); - resize: vertical; +.configurable-reactions .cr-json-editor { + flex: 1 1 auto; + min-height: 0; } -.configurable-reactions .cr-reaction-summary { - margin-bottom: 0.75rem; +.configurable-reactions .cr-main textarea[name="reactionJson"] { + width: 100%; + height: 100%; + min-height: 180px; + font-family: var(--font-monospace, monospace); + resize: none; } .configurable-reactions .form-group.stacked { @@ -198,21 +208,23 @@ justify-content: end; } -.configurable-reactions .cr-status-builder { +.configurable-reactions .cr-status-builder, +.configurable-reactions .cr-damage-builder { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; } -.configurable-reactions .cr-status-label { +.configurable-reactions .cr-chip-label { opacity: 0.85; font-weight: 600; } -.configurable-reactions .cr-status-chip-list { +.configurable-reactions .cr-chip-list { display: flex; flex-wrap: wrap; + justify-content: flex-end; gap: 0.25rem; min-height: 1.6rem; padding: 0.25rem; @@ -221,38 +233,45 @@ background: rgb(0 0 0 / 0.08); } -.configurable-reactions .cr-status-chip { +.configurable-reactions .cr-chip { display: inline-flex; align-items: center; gap: 0.3rem; max-width: 100%; - padding: 0.15rem 0.25rem 0.15rem 0.35rem; + padding: 0.12rem 0.2rem 0.12rem 0.35rem; border: 1px solid var(--color-border-light-secondary, #777); border-radius: 999px; background: rgb(0 0 0 / 0.16); - font-size: 0.8rem; + font-size: 70%; } -.configurable-reactions .cr-status-chip img { - width: 16px; - height: 16px; +.configurable-reactions .cr-chip img { + width: 14px; + height: 14px; border: none; object-fit: contain; } -.configurable-reactions .cr-status-chip span { +.configurable-reactions .cr-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.configurable-reactions .cr-status-chip button { - width: 1.35rem; - min-height: 1.35rem; +.configurable-reactions .cr-chip button { + width: 1rem; + min-height: 1rem; + height: 1rem; padding: 0; line-height: 1; + border: none; + box-shadow: none; + background: transparent; + font-size: 0.85em; } -.configurable-reactions .cr-status-builder select { +.configurable-reactions .cr-status-builder select, +.configurable-reactions .cr-damage-builder select { width: 100%; } + diff --git a/templates/reaction-config.hbs b/templates/reaction-config.hbs index d58f2a1..f78d9ec 100644 --- a/templates/reaction-config.hbs +++ b/templates/reaction-config.hbs @@ -78,9 +78,6 @@ -
@@ -99,6 +96,32 @@ {{localize "CONFIGURABLE_REACTIONS.Builder.DropSpellOnTrigger"}} {{/if}} {{/if}} + + {{#if activeTriggerIsDamage}} +
+ {{localize "CONFIGURABLE_REACTIONS.Conditions.Damage.SelectedTypes"}} +
+ {{#if selectedDamageTypes.length}} + {{#each selectedDamageTypes}} + + {{this.label}} + + + {{/each}} + {{else}} + {{localize "CONFIGURABLE_REACTIONS.Conditions.Damage.NoTypes"}} + {{/if}} +
+ +
+ {{/if}} {{#if triggerSpellName}}
-
-

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

-

ID: {{selectedReaction.id}}

-

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

-

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

-
- -
+
- - {{else}}

{{localize "CONFIGURABLE_REACTIONS.Reactions.None"}}

{{/if}}