From c2ffa47ff256caf98a4b49604e87b507970e302f Mon Sep 17 00:00:00 2001 From: ChatGPT Date: Thu, 4 Jun 2026 21:01:54 +0200 Subject: [PATCH] add spell item dropin for spell based triggers and actions --- .gitignore | 1 + LICENSE | 0 README.md | 0 lang/de.json | 6 +- lang/en.json | 6 +- module.json | 0 scripts/actions/apply-status-action.js | 0 scripts/actions/cast-spell-action.js | 19 +++- scripts/actions/teleport-action.js | 0 scripts/actions/use-inventory-item-action.js | 0 scripts/apps/assignment-manager.js | 0 scripts/apps/reaction-config-app.js | 102 ++++++++++++++++++- scripts/conditions/damage-condition.js | 0 scripts/conditions/hp-threshold-condition.js | 0 scripts/constants.js | 0 scripts/engine/action-runner.js | 0 scripts/engine/assignment-resolver.js | 0 scripts/engine/condition-runner.js | 0 scripts/engine/consumption-manager.js | 0 scripts/engine/reaction-engine.js | 11 ++ scripts/main.js | 0 scripts/settings.js | 0 scripts/sockets.js | 0 scripts/triggers/damage-received.js | 0 scripts/triggers/target-selected.js | 0 scripts/utils/distance-utils.js | 0 scripts/utils/token-utils.js | 0 styles/configurable-reactions.css | 24 +++++ templates/reaction-config.hbs | 35 ++++++- 29 files changed, 191 insertions(+), 13 deletions(-) mode change 100755 => 100644 .gitignore mode change 100755 => 100644 LICENSE mode change 100755 => 100644 README.md mode change 100755 => 100644 lang/de.json mode change 100755 => 100644 lang/en.json mode change 100755 => 100644 module.json mode change 100755 => 100644 scripts/actions/apply-status-action.js mode change 100755 => 100644 scripts/actions/cast-spell-action.js mode change 100755 => 100644 scripts/actions/teleport-action.js mode change 100755 => 100644 scripts/actions/use-inventory-item-action.js mode change 100755 => 100644 scripts/apps/assignment-manager.js mode change 100755 => 100644 scripts/apps/reaction-config-app.js mode change 100755 => 100644 scripts/conditions/damage-condition.js mode change 100755 => 100644 scripts/conditions/hp-threshold-condition.js mode change 100755 => 100644 scripts/constants.js mode change 100755 => 100644 scripts/engine/action-runner.js mode change 100755 => 100644 scripts/engine/assignment-resolver.js mode change 100755 => 100644 scripts/engine/condition-runner.js mode change 100755 => 100644 scripts/engine/consumption-manager.js mode change 100755 => 100644 scripts/engine/reaction-engine.js mode change 100755 => 100644 scripts/main.js mode change 100755 => 100644 scripts/settings.js mode change 100755 => 100644 scripts/sockets.js mode change 100755 => 100644 scripts/triggers/damage-received.js mode change 100755 => 100644 scripts/triggers/target-selected.js mode change 100755 => 100644 scripts/utils/distance-utils.js mode change 100755 => 100644 scripts/utils/token-utils.js mode change 100755 => 100644 styles/configurable-reactions.css mode change 100755 => 100644 templates/reaction-config.hbs diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index c53d4cf..fe12736 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ Thumbs.db node_modules/ dist/ *.log +patches/ diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/lang/de.json b/lang/de.json old mode 100755 new mode 100644 index ab69960..1c02182 --- a/lang/de.json +++ b/lang/de.json @@ -79,5 +79,9 @@ "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" + "CONFIGURABLE_REACTIONS.Duration.Unlimited": "Unbegrenzt", + "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}" } diff --git a/lang/en.json b/lang/en.json old mode 100755 new mode 100644 index ce21e8c..16c740c --- a/lang/en.json +++ b/lang/en.json @@ -79,5 +79,9 @@ "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" + "CONFIGURABLE_REACTIONS.Duration.Unlimited": "Unlimited", + "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}" } diff --git a/module.json b/module.json old mode 100755 new mode 100644 diff --git a/scripts/actions/apply-status-action.js b/scripts/actions/apply-status-action.js old mode 100755 new mode 100644 diff --git a/scripts/actions/cast-spell-action.js b/scripts/actions/cast-spell-action.js old mode 100755 new mode 100644 index d3b17c2..180f70c --- a/scripts/actions/cast-spell-action.js +++ b/scripts/actions/cast-spell-action.js @@ -6,8 +6,13 @@ export async function executeCastSpellAction(action, context) { const item = await resolveItem(actor, action.spellSelection); if (!item) return { success: false, consumed: false, reason: "NO_SPELL_ITEM" }; + if (action.activationMode === "autoUse" && typeof item.use === "function") { + await item.use({}, { configureDialog: true }); + return { success: true, consumed: true }; + } + item.sheet?.render(true); - ui.notifications.info(`${item.name} wurde für ${actor.name} geöffnet. Vollautomatische Zauberauslösung folgt in einer späteren Version.`); + ui.notifications.info(`${item.name} wurde für ${actor.name} geöffnet. Der Zauber wird aus dem Inventar des Characters über dessen Spellslots genutzt.`); return { success: true, consumed: true }; } @@ -22,7 +27,15 @@ function resolveCasterToken(action, context) { } async function resolveItem(actor, selection = {}) { - if (selection.mode === "itemUuid" && selection.itemUuid) return fromUuid(selection.itemUuid); - if (selection.mode === "itemName" && selection.itemName) return actor.items.find(item => item.name === selection.itemName); + if (selection.mode === "itemUuid" && selection.itemUuid) { + const item = await fromUuid(selection.itemUuid); + return item?.type === "spell" ? item : null; + } + + if (selection.mode === "itemName" && selection.itemName) { + const expected = selection.itemName.toLocaleLowerCase(); + return actor.items.find(item => item.type === "spell" && item.name.toLocaleLowerCase() === expected) ?? null; + } + return null; } diff --git a/scripts/actions/teleport-action.js b/scripts/actions/teleport-action.js old mode 100755 new mode 100644 diff --git a/scripts/actions/use-inventory-item-action.js b/scripts/actions/use-inventory-item-action.js old mode 100755 new mode 100644 diff --git a/scripts/apps/assignment-manager.js b/scripts/apps/assignment-manager.js old mode 100755 new mode 100644 diff --git a/scripts/apps/reaction-config-app.js b/scripts/apps/reaction-config-app.js old mode 100755 new mode 100644 index ee1bcd8..c5b1545 --- a/scripts/apps/reaction-config-app.js +++ b/scripts/apps/reaction-config-app.js @@ -31,7 +31,9 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A assignSelectedTokens: ConfigurableReactionsConfigApp.#onAssignSelectedTokens, removeAssignment: ConfigurableReactionsConfigApp.#onRemoveAssignment, removeAction: ConfigurableReactionsConfigApp.#onRemoveAction, - saveReactionBasics: ConfigurableReactionsConfigApp.#onSaveReactionBasics + saveReactionBasics: ConfigurableReactionsConfigApp.#onSaveReactionBasics, + clearTriggerSpell: ConfigurableReactionsConfigApp.#onClearTriggerSpell, + clearActionSpell: ConfigurableReactionsConfigApp.#onClearActionSpell } }; @@ -62,6 +64,8 @@ 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 visualActions = (selectedReaction?.actions ?? []).map((action, index) => { const paletteEntry = actionPalette.find(p => p.type === action.type); return { @@ -69,7 +73,9 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A index, label: paletteEntry?.label ?? action.type, icon: paletteEntry?.icon ?? "fa-solid fa-puzzle-piece", - summary: summarizeAction(action) + summary: summarizeAction(action), + isSpellAction: action.type === ACTION_TYPES.CAST_SPELL_FROM_TOKEN, + spellName: action.spellSelection?.itemName || action.spellSelection?.itemUuid || "" }; }); @@ -86,6 +92,8 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A triggerPalette, actionPalette, activeTrigger, + activeTriggerAcceptsSpell, + triggerSpellName, visualActions, statusEffects: CONFIG.statusEffects.map(s => ({ id: s.id, name: game.i18n.localize(s.name ?? s.id) })) }; @@ -130,18 +138,37 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A event.preventDefault(); dropZone.classList.remove("cr-drop-active"); + event.stopPropagation(); + const payload = readDragPayload(event); if (!payload) return; + const droppedItem = await resolveDroppedItem(payload); + if (dropZone.dataset.crDrop === "trigger" && payload.kind === "trigger") { await this.#setTrigger(payload.type); return; } + if (dropZone.dataset.crDrop === "trigger" && isSpellItem(droppedItem)) { + await this.#setTriggerSpell(droppedItem); + return; + } + if (dropZone.dataset.crDrop === "actions" && payload.kind === "action") { await this.#addAction(createDefaultAction(payload.type)); return; } + + if (dropZone.dataset.crDrop === "actions" && isSpellItem(droppedItem)) { + await this.#addAction(createDefaultAction(ACTION_TYPES.CAST_SPELL_FROM_TOKEN, droppedItem)); + return; + } + + if (dropZone.dataset.crDrop === "action-spell" && isSpellItem(droppedItem)) { + await this.#setActionSpell(Number(dropZone.dataset.actionIndex), droppedItem); + return; + } }); } } @@ -230,6 +257,24 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A }); } + static async #onClearTriggerSpell(event, target) { + await this.#mutateSelectedReaction(reaction => { + reaction.trigger ??= {}; + delete reaction.trigger.spell; + }); + } + + static async #onClearActionSpell(event, target) { + const actionIndex = Number(target.dataset.actionIndex); + if (!Number.isInteger(actionIndex)) return; + + await this.#mutateSelectedReaction(reaction => { + const action = reaction.actions?.[actionIndex]; + if (!action || action.type !== ACTION_TYPES.CAST_SPELL_FROM_TOKEN) return; + action.spellSelection = { mode: "itemName", itemName: "" }; + }); + } + static async #onSaveReactionBasics(event, target) { const name = this.element.querySelector("[name='reactionName']")?.value?.trim(); const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true; @@ -244,6 +289,25 @@ export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(A await this.#mutateSelectedReaction(reaction => { reaction.trigger ??= {}; reaction.trigger.type = triggerType; + if (!isSpellTrigger(triggerType)) delete reaction.trigger.spell; + }); + } + + async #setTriggerSpell(item) { + await this.#mutateSelectedReaction(reaction => { + reaction.trigger ??= {}; + if (!isSpellTrigger(reaction.trigger.type)) reaction.trigger.type = TRIGGER_TYPES.SPELL_SEEN; + reaction.trigger.spell = buildSpellSelection(item); + }); + } + + async #setActionSpell(actionIndex, item) { + if (!Number.isInteger(actionIndex)) return; + + await this.#mutateSelectedReaction(reaction => { + const action = reaction.actions?.[actionIndex]; + if (!action || action.type !== ACTION_TYPES.CAST_SPELL_FROM_TOKEN) return; + action.spellSelection = buildSpellSelection(item); }); } @@ -277,7 +341,37 @@ function readDragPayload(event) { } } -function createDefaultAction(type) { +async function resolveDroppedItem(data) { + if (!data) return null; + + if (data.uuid) { + const document = await fromUuid(data.uuid); + if (document?.documentName === "Item") return document; + } + + if (data.type === "Item" && data.id) { + return game.items.get(data.id) ?? null; + } + + return null; +} + +function isSpellItem(item) { + return item?.documentName === "Item" && item.type === "spell"; +} + +function isSpellTrigger(type) { + return [TRIGGER_TYPES.SPELL_SEEN, TRIGGER_TYPES.SPELL_CAST_START, TRIGGER_TYPES.SPELL_CAST_COMPLETE].includes(type); +} + +function buildSpellSelection(item) { + return { + mode: "itemName", + itemName: item.name + }; +} + +function createDefaultAction(type, item = null) { switch (type) { case ACTION_TYPES.TELEPORT: return { @@ -309,7 +403,7 @@ function createDefaultAction(type) { type: ACTION_TYPES.CAST_SPELL_FROM_TOKEN, enabled: true, casterMode: "affectedToken", - spellSelection: { + spellSelection: item ? buildSpellSelection(item) : { mode: "itemName", itemName: "" }, diff --git a/scripts/conditions/damage-condition.js b/scripts/conditions/damage-condition.js old mode 100755 new mode 100644 diff --git a/scripts/conditions/hp-threshold-condition.js b/scripts/conditions/hp-threshold-condition.js old mode 100755 new mode 100644 diff --git a/scripts/constants.js b/scripts/constants.js old mode 100755 new mode 100644 diff --git a/scripts/engine/action-runner.js b/scripts/engine/action-runner.js old mode 100755 new mode 100644 diff --git a/scripts/engine/assignment-resolver.js b/scripts/engine/assignment-resolver.js old mode 100755 new mode 100644 diff --git a/scripts/engine/condition-runner.js b/scripts/engine/condition-runner.js old mode 100755 new mode 100644 diff --git a/scripts/engine/consumption-manager.js b/scripts/engine/consumption-manager.js old mode 100755 new mode 100644 diff --git a/scripts/engine/reaction-engine.js b/scripts/engine/reaction-engine.js old mode 100755 new mode 100644 index f6b1dd7..690b475 --- a/scripts/engine/reaction-engine.js +++ b/scripts/engine/reaction-engine.js @@ -13,6 +13,7 @@ export async function handleTrigger(triggerType, context) { try { if (!reaction?.enabled) continue; if (reaction.trigger?.type !== triggerType) continue; + if (!matchesTriggerSpellFilter(reaction.trigger, context)) continue; if (!await checkConsumptionAvailable(reaction, context)) continue; if (!await checkConditions(reaction, context)) continue; @@ -23,3 +24,13 @@ export async function handleTrigger(triggerType, context) { } } } + +function matchesTriggerSpellFilter(trigger, context) { + const expectedName = trigger?.spell?.itemName; + if (!expectedName) return true; + + const actualName = context?.spellItem?.name ?? context?.item?.name ?? context?.itemName ?? context?.spellName ?? null; + if (!actualName) return false; + + return actualName.toLocaleLowerCase() === expectedName.toLocaleLowerCase(); +} diff --git a/scripts/main.js b/scripts/main.js old mode 100755 new mode 100644 diff --git a/scripts/settings.js b/scripts/settings.js old mode 100755 new mode 100644 diff --git a/scripts/sockets.js b/scripts/sockets.js old mode 100755 new mode 100644 diff --git a/scripts/triggers/damage-received.js b/scripts/triggers/damage-received.js old mode 100755 new mode 100644 diff --git a/scripts/triggers/target-selected.js b/scripts/triggers/target-selected.js old mode 100755 new mode 100644 diff --git a/scripts/utils/distance-utils.js b/scripts/utils/distance-utils.js old mode 100755 new mode 100644 diff --git a/scripts/utils/token-utils.js b/scripts/utils/token-utils.js old mode 100755 new mode 100644 diff --git a/styles/configurable-reactions.css b/styles/configurable-reactions.css old mode 100755 new mode 100644 index ae88424..b4e7bf6 --- a/styles/configurable-reactions.css +++ b/styles/configurable-reactions.css @@ -173,3 +173,27 @@ flex-direction: column; align-items: stretch; } + +.configurable-reactions .cr-spell-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + width: fit-content; + margin-top: 0.25rem; + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: rgb(0 0 0 / 0.12); + font-size: 0.8rem; +} + +.configurable-reactions .cr-inline-drop { + margin-top: 0.25rem; + opacity: 0.8; + font-style: italic; +} + +.configurable-reactions .cr-action-buttons { + display: flex; + gap: 0.25rem; + justify-content: end; +} diff --git a/templates/reaction-config.hbs b/templates/reaction-config.hbs old mode 100755 new mode 100644 index 57da803..a19c302 --- a/templates/reaction-config.hbs +++ b/templates/reaction-config.hbs @@ -92,7 +92,19 @@
{{activeTrigger.label}} {{selectedReaction.trigger.type}} + {{#if triggerSpellName}} + {{triggerSpellName}} + {{else}} + {{#if activeTriggerAcceptsSpell}} + {{localize "CONFIGURABLE_REACTIONS.Builder.DropSpellOnTrigger"}} + {{/if}} + {{/if}}
+ {{#if triggerSpellName}} + + {{/if}} {{else}}

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

@@ -101,19 +113,34 @@

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

+

{{localize "CONFIGURABLE_REACTIONS.Builder.DropSpellCreatesAction"}}

{{#if visualActions.length}}
    {{#each visualActions}} -
  1. +
  2. {{this.label}} {{this.summary}} {{this.type}} + {{#if this.isSpellAction}} + {{#if this.spellName}} + {{this.spellName}} + {{else}} + {{localize "CONFIGURABLE_REACTIONS.Builder.DropSpellOnAction"}} + {{/if}} + {{/if}} +
    +
    + {{#if this.spellName}} + + {{/if}} +
    -
  3. {{/each}}