Add reaction engine and action skeletons
This commit is contained in:
parent
927ea528b7
commit
a243776070
53
scripts/actions/apply-status-action.js
Normal file
53
scripts/actions/apply-status-action.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { MODULE_ID } from "../constants.js";
|
||||
|
||||
export async function executeApplyStatusAction(action, context) {
|
||||
const actor = context.targetActor ?? context.actor;
|
||||
if (!actor) return { success: false, consumed: false, reason: "NO_ACTOR" };
|
||||
|
||||
const statuses = action.statuses ?? [];
|
||||
if (!statuses.length) return { success: false, consumed: false, reason: "NO_STATUSES" };
|
||||
|
||||
const effects = statuses.map(statusId => {
|
||||
const statusConfig = CONFIG.statusEffects.find(status => status.id === statusId);
|
||||
|
||||
return {
|
||||
name: statusConfig?.name ? game.i18n.localize(statusConfig.name) : statusId,
|
||||
icon: statusConfig?.img ?? "icons/svg/aura.svg",
|
||||
statuses: [statusId],
|
||||
origin: context.reactionOrigin,
|
||||
duration: buildEffectDuration(action.duration),
|
||||
changes: [],
|
||||
flags: {
|
||||
[MODULE_ID]: {
|
||||
managed: true,
|
||||
reactionId: context.reaction.id,
|
||||
actionId: action.id
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await actor.createEmbeddedDocuments("ActiveEffect", effects);
|
||||
return { success: true, consumed: true };
|
||||
}
|
||||
|
||||
function buildEffectDuration(duration) {
|
||||
if (!duration || duration.type === "unlimited") return {};
|
||||
|
||||
if (duration.type === "rounds") {
|
||||
return {
|
||||
rounds: Number(duration.value) || 1,
|
||||
startRound: game.combat?.round,
|
||||
startTurn: game.combat?.turn
|
||||
};
|
||||
}
|
||||
|
||||
if (duration.type === "minutes") {
|
||||
return {
|
||||
seconds: (Number(duration.value) || 1) * 60,
|
||||
startTime: game.time.worldTime
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
28
scripts/actions/cast-spell-action.js
Normal file
28
scripts/actions/cast-spell-action.js
Normal file
@ -0,0 +1,28 @@
|
||||
export async function executeCastSpellAction(action, context) {
|
||||
const token = resolveCasterToken(action, context);
|
||||
const actor = token?.actor;
|
||||
if (!actor) return { success: false, consumed: false, reason: "NO_CASTER" };
|
||||
|
||||
const item = await resolveItem(actor, action.spellSelection);
|
||||
if (!item) return { success: false, consumed: false, reason: "NO_SPELL_ITEM" };
|
||||
|
||||
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.`);
|
||||
|
||||
return { success: true, consumed: true };
|
||||
}
|
||||
|
||||
function resolveCasterToken(action, context) {
|
||||
switch (action.casterMode ?? "affectedToken") {
|
||||
case "sourceToken": return context.sourceToken ?? null;
|
||||
case "assignedToken": return context.assignedToken ?? context.targetToken ?? null;
|
||||
case "affectedToken":
|
||||
default: return context.targetToken ?? context.tokenDocument?.object ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
206
scripts/actions/teleport-action.js
Normal file
206
scripts/actions/teleport-action.js
Normal file
@ -0,0 +1,206 @@
|
||||
import { MODULE_ID, SETTINGS } from "../constants.js";
|
||||
import { emitSocket } from "../sockets.js";
|
||||
import { getPrimaryOwner, isTokenSpaceOccupied } from "../utils/token-utils.js";
|
||||
import { isWithinTeleportRadius, randomPointInCircle, sceneDistanceToPixels } from "../utils/distance-utils.js";
|
||||
|
||||
export async function executeTeleportAction(action, context) {
|
||||
const tokenDocument = context.targetTokenDocument ?? context.tokenDocument;
|
||||
if (!tokenDocument) return { success: false, consumed: false, reason: "NO_TOKEN" };
|
||||
|
||||
if (action.askOwner === true) {
|
||||
return requestOwnerTeleportTarget({ tokenDocument, action, context });
|
||||
}
|
||||
|
||||
return executeRandomTeleport({ tokenDocument, action });
|
||||
}
|
||||
|
||||
async function requestOwnerTeleportTarget({ tokenDocument, action }) {
|
||||
const actor = tokenDocument.actor;
|
||||
const owner = getPrimaryOwner(actor);
|
||||
|
||||
if (!owner) {
|
||||
return executeRandomTeleport({ tokenDocument, action });
|
||||
}
|
||||
|
||||
emitSocket({
|
||||
type: "requestTeleportTarget",
|
||||
userId: owner.id,
|
||||
sceneId: tokenDocument.parent.id,
|
||||
tokenId: tokenDocument.id,
|
||||
radius: Number(action.radius) || 30,
|
||||
snapToGrid: action.snapToGrid !== false
|
||||
});
|
||||
|
||||
return { success: true, consumed: true, pending: true };
|
||||
}
|
||||
|
||||
export async function handleTeleportTargetRequest(message) {
|
||||
if (message.userId !== game.user.id) return;
|
||||
|
||||
const scene = game.scenes.get(message.sceneId);
|
||||
if (!scene || canvas.scene?.id !== scene.id) {
|
||||
ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Teleport.SwitchScene"));
|
||||
return;
|
||||
}
|
||||
|
||||
const token = canvas.tokens.get(message.tokenId);
|
||||
if (!token) return;
|
||||
|
||||
const confirmed = await foundry.applications.api.DialogV2.confirm({
|
||||
window: { title: game.i18n.localize("CONFIGURABLE_REACTIONS.Teleport.Title") },
|
||||
content: `<p>${game.i18n.format("CONFIGURABLE_REACTIONS.Teleport.ChooseTarget", { radius: message.radius })}</p>`,
|
||||
yes: { label: game.i18n.localize("CONFIGURABLE_REACTIONS.Common.Choose") },
|
||||
no: { label: game.i18n.localize("CONFIGURABLE_REACTIONS.Common.Cancel") }
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const point = await waitForCanvasClick();
|
||||
|
||||
emitSocket({
|
||||
type: "teleportTargetChosen",
|
||||
sceneId: message.sceneId,
|
||||
tokenId: message.tokenId,
|
||||
userId: game.user.id,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
radius: message.radius,
|
||||
snapToGrid: message.snapToGrid !== false
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleTeleportTargetChosen(message) {
|
||||
if (!game.user.isGM) return;
|
||||
|
||||
const scene = game.scenes.get(message.sceneId);
|
||||
const tokenDocument = scene?.tokens.get(message.tokenId);
|
||||
const token = tokenDocument?.object;
|
||||
if (!tokenDocument || !token) return;
|
||||
|
||||
const user = game.users.get(message.userId);
|
||||
if (!tokenDocument.actor?.testUserPermission(user, "OWNER")) return;
|
||||
|
||||
let position = centerToTopLeft(token, { x: message.x, y: message.y });
|
||||
if (message.snapToGrid !== false) position = snapPosition(token, position);
|
||||
|
||||
const radiusPx = sceneDistanceToPixels(Number(message.radius) || 30);
|
||||
if (!isTeleportCandidateValid(token, position, radiusPx)) {
|
||||
ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Teleport.InvalidTarget"));
|
||||
return;
|
||||
}
|
||||
|
||||
await tokenDocument.update({ x: position.x, y: position.y });
|
||||
}
|
||||
|
||||
export async function executeRandomTeleport({ tokenDocument, action }) {
|
||||
const token = tokenDocument.object;
|
||||
if (!token) return { success: false, consumed: false, reason: "NO_TOKEN_OBJECT" };
|
||||
|
||||
const target = findRandomReachableTeleportTarget(token, {
|
||||
radius: Number(action.radius) || 30,
|
||||
maxAttempts: Number(action.maxAttempts) || 80,
|
||||
snapToGrid: action.snapToGrid !== false
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
ui.notifications.warn(game.i18n.format("CONFIGURABLE_REACTIONS.Teleport.NoValidTarget", {
|
||||
name: token.name,
|
||||
radius: Number(action.radius) || 30
|
||||
}));
|
||||
|
||||
await createTeleportFailureMessage(token, action);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
consumed: action.consumeOnFailure === true,
|
||||
reason: "NO_VALID_TELEPORT_TARGET"
|
||||
};
|
||||
}
|
||||
|
||||
await tokenDocument.update({ x: target.x, y: target.y });
|
||||
return { success: true, consumed: true };
|
||||
}
|
||||
|
||||
function findRandomReachableTeleportTarget(token, options) {
|
||||
const radiusPx = sceneDistanceToPixels(options.radius);
|
||||
const originCenter = token.center;
|
||||
|
||||
for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
|
||||
const candidateCenter = randomPointInCircle(originCenter, radiusPx);
|
||||
let targetPosition = centerToTopLeft(token, candidateCenter);
|
||||
|
||||
if (options.snapToGrid) targetPosition = snapPosition(token, targetPosition);
|
||||
|
||||
if (isTeleportCandidateValid(token, targetPosition, radiusPx)) return targetPosition;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTeleportCandidateValid(token, targetPosition, radiusPx) {
|
||||
const candidateCenter = token.getCenterPoint(targetPosition);
|
||||
|
||||
if (!isWithinTeleportRadius(token.center, candidateCenter, radiusPx)) return false;
|
||||
if (!isWithinSceneBounds(token, targetPosition)) return false;
|
||||
if (movementBlockedByWalls(token, candidateCenter)) return false;
|
||||
if (isTokenSpaceOccupied(token, targetPosition)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function movementBlockedByWalls(token, destinationCenter) {
|
||||
const origin = token.document.getMovementOrigin?.() ?? token.center;
|
||||
const destination = {
|
||||
x: destinationCenter.x,
|
||||
y: destinationCenter.y,
|
||||
elevation: token.document.elevation
|
||||
};
|
||||
|
||||
return token.checkCollision(destination, {
|
||||
origin,
|
||||
type: "move",
|
||||
mode: "any"
|
||||
}) === true;
|
||||
}
|
||||
|
||||
function isWithinSceneBounds(token, position) {
|
||||
const dimensions = canvas.scene.dimensions;
|
||||
return position.x >= 0 &&
|
||||
position.y >= 0 &&
|
||||
position.x + token.w <= dimensions.width &&
|
||||
position.y + token.h <= dimensions.height;
|
||||
}
|
||||
|
||||
function centerToTopLeft(token, center) {
|
||||
return {
|
||||
x: center.x - token.w / 2,
|
||||
y: center.y - token.h / 2
|
||||
};
|
||||
}
|
||||
|
||||
function snapPosition(token, position) {
|
||||
if (typeof token.getSnappedPosition === "function") return token.getSnappedPosition(position);
|
||||
const snapped = canvas.grid.getSnappedPoint(position);
|
||||
return { x: snapped.x, y: snapped.y };
|
||||
}
|
||||
|
||||
function waitForCanvasClick() {
|
||||
return new Promise(resolve => {
|
||||
const handler = event => {
|
||||
canvas.stage.off("pointerdown", handler);
|
||||
const position = event.data.getLocalPosition(canvas.stage);
|
||||
resolve({ x: position.x, y: position.y });
|
||||
};
|
||||
|
||||
canvas.stage.on("pointerdown", handler);
|
||||
});
|
||||
}
|
||||
|
||||
async function createTeleportFailureMessage(token, action) {
|
||||
if (!game.settings.get(MODULE_ID, SETTINGS.SHOW_FAILED_TELEPORT_MESSAGES)) return;
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ token }),
|
||||
content: `<p><strong>${token.name}</strong> findet kein erreichbares freies Teleportziel innerhalb von ${Number(action.radius) || 30} ft.</p>`
|
||||
});
|
||||
}
|
||||
36
scripts/actions/use-inventory-item-action.js
Normal file
36
scripts/actions/use-inventory-item-action.js
Normal file
@ -0,0 +1,36 @@
|
||||
export async function executeUseInventoryItemAction(action, context) {
|
||||
const token = resolveUserToken(action, context);
|
||||
const actor = token?.actor;
|
||||
if (!actor) return { success: false, consumed: false, reason: "NO_USER" };
|
||||
|
||||
const item = await resolveItem(actor, action.itemSelection);
|
||||
if (!item) return { success: false, consumed: false, reason: "NO_ITEM" };
|
||||
|
||||
if (action.activationMode === "openSheet" || !item.use) {
|
||||
item.sheet?.render(true);
|
||||
return { success: true, consumed: true };
|
||||
}
|
||||
|
||||
if (action.activationMode === "autoUse" && typeof item.use === "function") {
|
||||
await item.use();
|
||||
return { success: true, consumed: true };
|
||||
}
|
||||
|
||||
item.sheet?.render(true);
|
||||
return { success: true, consumed: true };
|
||||
}
|
||||
|
||||
function resolveUserToken(action, context) {
|
||||
switch (action.userMode ?? "affectedToken") {
|
||||
case "sourceToken": return context.sourceToken ?? null;
|
||||
case "assignedToken": return context.assignedToken ?? context.targetToken ?? null;
|
||||
case "affectedToken":
|
||||
default: return context.targetToken ?? context.tokenDocument?.object ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
21
scripts/conditions/damage-condition.js
Normal file
21
scripts/conditions/damage-condition.js
Normal file
@ -0,0 +1,21 @@
|
||||
export async function checkDamageCondition(config, context) {
|
||||
const amount = Number(context.amount ?? context.damageAmount ?? 0);
|
||||
const absoluteAmount = Math.abs(amount);
|
||||
|
||||
if (absoluteAmount < (Number(config.minAmount) || 0)) return false;
|
||||
|
||||
if (config.amountMode === "damageOnly" && amount <= 0) return false;
|
||||
if (config.amountMode === "healingOnly" && amount >= 0) return false;
|
||||
|
||||
if (config.amountMode === "healingOnly") return true;
|
||||
|
||||
const configuredTypes = config.types ?? [];
|
||||
if (!configuredTypes.length) return true;
|
||||
|
||||
const eventTypes = new Set(context.damageTypes ?? []);
|
||||
if (!eventTypes.size) return false;
|
||||
|
||||
if (config.typeMode === "all") return configuredTypes.every(type => eventTypes.has(type));
|
||||
if (config.typeMode === "none") return configuredTypes.every(type => !eventTypes.has(type));
|
||||
return configuredTypes.some(type => eventTypes.has(type));
|
||||
}
|
||||
21
scripts/conditions/hp-threshold-condition.js
Normal file
21
scripts/conditions/hp-threshold-condition.js
Normal file
@ -0,0 +1,21 @@
|
||||
export async function checkHpAfterDamageCondition(config, context) {
|
||||
const actor = context.targetActor ?? context.actor;
|
||||
if (!actor) return false;
|
||||
|
||||
const hp = actor.system?.attributes?.hp;
|
||||
const value = Number(hp?.value ?? 0);
|
||||
const max = Number(hp?.max ?? 0);
|
||||
if (max <= 0) return false;
|
||||
|
||||
const actual = config.mode === "percent" ? (value / max) * 100 : value;
|
||||
const threshold = Number(config.value ?? 0);
|
||||
|
||||
switch (config.operator ?? "lte") {
|
||||
case "lt": return actual < threshold;
|
||||
case "lte": return actual <= threshold;
|
||||
case "gt": return actual > threshold;
|
||||
case "gte": return actual >= threshold;
|
||||
case "eq": return actual === threshold;
|
||||
default: return actual <= threshold;
|
||||
}
|
||||
}
|
||||
44
scripts/engine/action-runner.js
Normal file
44
scripts/engine/action-runner.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { executeApplyStatusAction } from "../actions/apply-status-action.js";
|
||||
import { executeTeleportAction } from "../actions/teleport-action.js";
|
||||
import { executeCastSpellAction } from "../actions/cast-spell-action.js";
|
||||
import { executeUseInventoryItemAction } from "../actions/use-inventory-item-action.js";
|
||||
|
||||
const ACTION_HANDLERS = Object.freeze({
|
||||
applyStatus: executeApplyStatusAction,
|
||||
teleport: executeTeleportAction,
|
||||
castSpellFromToken: executeCastSpellAction,
|
||||
useInventoryItem: executeUseInventoryItemAction
|
||||
});
|
||||
|
||||
export async function executeReactionActions(reaction, context) {
|
||||
let anySuccess = false;
|
||||
let anyFailureConsumes = false;
|
||||
|
||||
for (const action of reaction.actions ?? []) {
|
||||
if (!action?.enabled) continue;
|
||||
|
||||
const handler = ACTION_HANDLERS[action.type];
|
||||
if (!handler) continue;
|
||||
|
||||
const result = await handler(action, {
|
||||
...context,
|
||||
reaction,
|
||||
reactionOrigin: `Module.configurable-reactions.Reaction.${reaction.id}`
|
||||
});
|
||||
|
||||
if (result.success) anySuccess = true;
|
||||
else if (shouldConsumeOnFailure(reaction, action)) anyFailureConsumes = true;
|
||||
|
||||
if (result.stopProcessing) break;
|
||||
}
|
||||
|
||||
return {
|
||||
success: anySuccess,
|
||||
shouldConsume: anySuccess || anyFailureConsumes
|
||||
};
|
||||
}
|
||||
|
||||
function shouldConsumeOnFailure(reaction, action) {
|
||||
if (typeof action.consumeOnFailure === "boolean") return action.consumeOnFailure;
|
||||
return reaction.consumption?.consumeOnFailure === true;
|
||||
}
|
||||
19
scripts/engine/assignment-resolver.js
Normal file
19
scripts/engine/assignment-resolver.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { MODULE_ID, SETTINGS } from "../constants.js";
|
||||
|
||||
export async function getAssignedReactionsForContext(context) {
|
||||
const reactions = game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? [];
|
||||
const assignments = game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? [];
|
||||
|
||||
const actorUuid = context.targetActor?.uuid ?? context.actor?.uuid ?? null;
|
||||
const tokenUuid = context.targetTokenDocument?.uuid ?? context.tokenDocument?.uuid ?? null;
|
||||
|
||||
const matching = assignments.filter(assignment => {
|
||||
if (assignment.mode === "actor" && assignment.actorUuid === actorUuid) return true;
|
||||
if (assignment.mode === "token" && assignment.tokenUuid === tokenUuid) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return matching
|
||||
.map(assignment => reactions.find(reaction => reaction.id === assignment.reactionId))
|
||||
.filter(Boolean);
|
||||
}
|
||||
21
scripts/engine/condition-runner.js
Normal file
21
scripts/engine/condition-runner.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { checkDamageCondition } from "../conditions/damage-condition.js";
|
||||
import { checkHpAfterDamageCondition } from "../conditions/hp-threshold-condition.js";
|
||||
|
||||
const CONDITION_HANDLERS = Object.freeze({
|
||||
damage: checkDamageCondition,
|
||||
hpAfterDamage: checkHpAfterDamageCondition
|
||||
});
|
||||
|
||||
export async function checkConditions(reaction, context) {
|
||||
for (const [conditionType, config] of Object.entries(reaction.conditions ?? {})) {
|
||||
if (!config?.enabled) continue;
|
||||
|
||||
const handler = CONDITION_HANDLERS[conditionType];
|
||||
if (!handler) return false;
|
||||
|
||||
const passed = await handler(config, context);
|
||||
if (!passed) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
54
scripts/engine/consumption-manager.js
Normal file
54
scripts/engine/consumption-manager.js
Normal file
@ -0,0 +1,54 @@
|
||||
import { MODULE_ID } from "../constants.js";
|
||||
|
||||
export async function checkConsumptionAvailable(reaction, context) {
|
||||
const consumption = reaction.consumption;
|
||||
if (!consumption?.enabled || consumption.mode === "none") return true;
|
||||
|
||||
const document = getUsageDocument(context);
|
||||
if (!document) return true;
|
||||
|
||||
const usage = document.getFlag(MODULE_ID, "usage") ?? {};
|
||||
const reactionUsage = usage[reaction.id] ?? {};
|
||||
|
||||
if (consumption.mode === "perCombat") {
|
||||
if (!game.combat?.id) return true;
|
||||
return reactionUsage.combatId !== game.combat.id || (reactionUsage.uses ?? 0) < consumption.maxUses;
|
||||
}
|
||||
|
||||
if (consumption.mode === "perRound") {
|
||||
if (!game.combat?.id) return true;
|
||||
return reactionUsage.combatId !== game.combat.id || reactionUsage.round !== game.combat.round || (reactionUsage.uses ?? 0) < consumption.maxUses;
|
||||
}
|
||||
|
||||
if (consumption.mode === "perTurn") {
|
||||
if (!game.combat?.id) return true;
|
||||
return reactionUsage.combatId !== game.combat.id || reactionUsage.round !== game.combat.round || reactionUsage.turn !== game.combat.turn || (reactionUsage.uses ?? 0) < consumption.maxUses;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function consumeReactionUse(reaction, context) {
|
||||
const consumption = reaction.consumption;
|
||||
if (!consumption?.enabled || consumption.mode === "none") return;
|
||||
|
||||
const document = getUsageDocument(context);
|
||||
if (!document) return;
|
||||
|
||||
const usage = foundry.utils.deepClone(document.getFlag(MODULE_ID, "usage") ?? {});
|
||||
const current = usage[reaction.id] ?? {};
|
||||
|
||||
usage[reaction.id] = {
|
||||
combatId: game.combat?.id ?? null,
|
||||
round: game.combat?.round ?? null,
|
||||
turn: game.combat?.turn ?? null,
|
||||
uses: (current.uses ?? 0) + 1,
|
||||
lastUsedAt: Date.now()
|
||||
};
|
||||
|
||||
await document.setFlag(MODULE_ID, "usage", usage);
|
||||
}
|
||||
|
||||
function getUsageDocument(context) {
|
||||
return context.targetTokenDocument ?? context.tokenDocument ?? context.targetActor ?? context.actor ?? null;
|
||||
}
|
||||
25
scripts/engine/reaction-engine.js
Normal file
25
scripts/engine/reaction-engine.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { MODULE_ID } from "../constants.js";
|
||||
import { getAssignedReactionsForContext } from "./assignment-resolver.js";
|
||||
import { checkConditions } from "./condition-runner.js";
|
||||
import { checkConsumptionAvailable, consumeReactionUse } from "./consumption-manager.js";
|
||||
import { executeReactionActions } from "./action-runner.js";
|
||||
|
||||
export async function handleTrigger(triggerType, context) {
|
||||
if (!game.user.isGM) return;
|
||||
|
||||
const assignedReactions = await getAssignedReactionsForContext(context);
|
||||
|
||||
for (const reaction of assignedReactions) {
|
||||
try {
|
||||
if (!reaction?.enabled) continue;
|
||||
if (reaction.trigger?.type !== triggerType) continue;
|
||||
if (!await checkConsumptionAvailable(reaction, context)) continue;
|
||||
if (!await checkConditions(reaction, context)) continue;
|
||||
|
||||
const result = await executeReactionActions(reaction, context);
|
||||
if (result.shouldConsume) await consumeReactionUse(reaction, context);
|
||||
} catch (error) {
|
||||
console.error(`${MODULE_ID} | Failed to handle reaction`, reaction, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
scripts/triggers/damage-received.js
Normal file
37
scripts/triggers/damage-received.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { TRIGGER_TYPES } from "../constants.js";
|
||||
import { handleTrigger } from "../engine/reaction-engine.js";
|
||||
import { getBestTokenDocumentForActor } from "../utils/token-utils.js";
|
||||
|
||||
export function registerDnd5eDamageTrigger() {
|
||||
Hooks.on("dnd5e.applyDamage", async (actor, amount, options = {}) => {
|
||||
if (!game.user.isGM) return;
|
||||
if (!actor) return;
|
||||
|
||||
const tokenDocument = options.token ?? options.tokenDocument ?? getBestTokenDocumentForActor(actor);
|
||||
const damageTypes = extractDamageTypes(options);
|
||||
|
||||
await handleTrigger(TRIGGER_TYPES.DAMAGE_RECEIVED, {
|
||||
actor,
|
||||
targetActor: actor,
|
||||
tokenDocument,
|
||||
targetTokenDocument: tokenDocument,
|
||||
targetToken: tokenDocument?.object ?? null,
|
||||
amount: Number(amount) || 0,
|
||||
damageAmount: Number(amount) || 0,
|
||||
damageTypes,
|
||||
options
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractDamageTypes(options) {
|
||||
const candidates = [
|
||||
options.damageTypes,
|
||||
options.types,
|
||||
options.damage?.types,
|
||||
options.workflow?.damageItem?.damageDetail?.map?.(part => part.type)
|
||||
];
|
||||
|
||||
const values = candidates.flat().filter(Boolean);
|
||||
return [...new Set(values)];
|
||||
}
|
||||
18
scripts/triggers/target-selected.js
Normal file
18
scripts/triggers/target-selected.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { TRIGGER_TYPES } from "../constants.js";
|
||||
import { handleTrigger } from "../engine/reaction-engine.js";
|
||||
|
||||
export function registerTargetSelectedTrigger() {
|
||||
Hooks.on("targetToken", async (user, token, targeted) => {
|
||||
if (!game.user.isGM) return;
|
||||
if (!targeted || !token?.actor) return;
|
||||
|
||||
await handleTrigger(TRIGGER_TYPES.TARGET_SELECTED, {
|
||||
targetUser: user,
|
||||
targetToken: token,
|
||||
targetTokenDocument: token.document,
|
||||
targetActor: token.actor,
|
||||
tokenDocument: token.document,
|
||||
actor: token.actor
|
||||
});
|
||||
});
|
||||
}
|
||||
21
scripts/utils/distance-utils.js
Normal file
21
scripts/utils/distance-utils.js
Normal file
@ -0,0 +1,21 @@
|
||||
export function randomPointInCircle(origin, radiusPx) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = Math.sqrt(Math.random()) * radiusPx;
|
||||
|
||||
return {
|
||||
x: origin.x + Math.cos(angle) * distance,
|
||||
y: origin.y + Math.sin(angle) * distance
|
||||
};
|
||||
}
|
||||
|
||||
export function isWithinTeleportRadius(originCenter, candidateCenter, radiusPx) {
|
||||
const dx = candidateCenter.x - originCenter.x;
|
||||
const dy = candidateCenter.y - originCenter.y;
|
||||
return Math.hypot(dx, dy) <= radiusPx;
|
||||
}
|
||||
|
||||
export function sceneDistanceToPixels(distance) {
|
||||
const gridSize = canvas.scene.grid.size;
|
||||
const distancePerGrid = canvas.scene.grid.distance || 5;
|
||||
return distance / distancePerGrid * gridSize;
|
||||
}
|
||||
38
scripts/utils/token-utils.js
Normal file
38
scripts/utils/token-utils.js
Normal file
@ -0,0 +1,38 @@
|
||||
export function getBestTokenDocumentForActor(actor) {
|
||||
if (!actor) return null;
|
||||
|
||||
const controlled = canvas?.tokens?.controlled?.find(token => token.actor?.uuid === actor.uuid || token.actor?.id === actor.id);
|
||||
if (controlled) return controlled.document;
|
||||
|
||||
const activeTokens = actor.getActiveTokens?.(true, true) ?? [];
|
||||
if (activeTokens.length === 1) return activeTokens[0].document;
|
||||
|
||||
const combatant = game.combat?.combatants?.find(c => c.actor?.uuid === actor.uuid || c.actor?.id === actor.id);
|
||||
if (combatant?.token) return combatant.token;
|
||||
|
||||
return activeTokens[0]?.document ?? null;
|
||||
}
|
||||
|
||||
export function getPrimaryOwner(actor) {
|
||||
if (!actor) return null;
|
||||
return game.users.find(user => user.active && !user.isGM && actor.testUserPermission(user, "OWNER")) ?? null;
|
||||
}
|
||||
|
||||
export function isTokenSpaceOccupied(token, targetPosition) {
|
||||
const targetRect = new PIXI.Rectangle(targetPosition.x, targetPosition.y, token.w, token.h);
|
||||
|
||||
return canvas.tokens.placeables.some(other => {
|
||||
if (other.id === token.id) return false;
|
||||
if (other.document.hidden) return false;
|
||||
|
||||
const otherRect = new PIXI.Rectangle(other.document.x, other.document.y, other.w, other.h);
|
||||
return rectanglesOverlap(targetRect, otherRect);
|
||||
});
|
||||
}
|
||||
|
||||
function rectanglesOverlap(a, b) {
|
||||
return a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user