From a24377607001f30ae7fd3b13ad116d664f33455e Mon Sep 17 00:00:00 2001 From: ChatGPT Date: Thu, 4 Jun 2026 16:07:28 +0000 Subject: [PATCH] Add reaction engine and action skeletons --- scripts/actions/apply-status-action.js | 53 +++++ scripts/actions/cast-spell-action.js | 28 +++ scripts/actions/teleport-action.js | 206 +++++++++++++++++++ scripts/actions/use-inventory-item-action.js | 36 ++++ scripts/conditions/damage-condition.js | 21 ++ scripts/conditions/hp-threshold-condition.js | 21 ++ scripts/engine/action-runner.js | 44 ++++ scripts/engine/assignment-resolver.js | 19 ++ scripts/engine/condition-runner.js | 21 ++ scripts/engine/consumption-manager.js | 54 +++++ scripts/engine/reaction-engine.js | 25 +++ scripts/triggers/damage-received.js | 37 ++++ scripts/triggers/target-selected.js | 18 ++ scripts/utils/distance-utils.js | 21 ++ scripts/utils/token-utils.js | 38 ++++ 15 files changed, 642 insertions(+) create mode 100644 scripts/actions/apply-status-action.js create mode 100644 scripts/actions/cast-spell-action.js create mode 100644 scripts/actions/teleport-action.js create mode 100644 scripts/actions/use-inventory-item-action.js create mode 100644 scripts/conditions/damage-condition.js create mode 100644 scripts/conditions/hp-threshold-condition.js create mode 100644 scripts/engine/action-runner.js create mode 100644 scripts/engine/assignment-resolver.js create mode 100644 scripts/engine/condition-runner.js create mode 100644 scripts/engine/consumption-manager.js create mode 100644 scripts/engine/reaction-engine.js create mode 100644 scripts/triggers/damage-received.js create mode 100644 scripts/triggers/target-selected.js create mode 100644 scripts/utils/distance-utils.js create mode 100644 scripts/utils/token-utils.js diff --git a/scripts/actions/apply-status-action.js b/scripts/actions/apply-status-action.js new file mode 100644 index 0000000..9129153 --- /dev/null +++ b/scripts/actions/apply-status-action.js @@ -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 {}; +} diff --git a/scripts/actions/cast-spell-action.js b/scripts/actions/cast-spell-action.js new file mode 100644 index 0000000..d3b17c2 --- /dev/null +++ b/scripts/actions/cast-spell-action.js @@ -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; +} diff --git a/scripts/actions/teleport-action.js b/scripts/actions/teleport-action.js new file mode 100644 index 0000000..6ad080b --- /dev/null +++ b/scripts/actions/teleport-action.js @@ -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: `

${game.i18n.format("CONFIGURABLE_REACTIONS.Teleport.ChooseTarget", { radius: message.radius })}

`, + 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: `

${token.name} findet kein erreichbares freies Teleportziel innerhalb von ${Number(action.radius) || 30} ft.

` + }); +} diff --git a/scripts/actions/use-inventory-item-action.js b/scripts/actions/use-inventory-item-action.js new file mode 100644 index 0000000..e3b293a --- /dev/null +++ b/scripts/actions/use-inventory-item-action.js @@ -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; +} diff --git a/scripts/conditions/damage-condition.js b/scripts/conditions/damage-condition.js new file mode 100644 index 0000000..8df6f31 --- /dev/null +++ b/scripts/conditions/damage-condition.js @@ -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)); +} diff --git a/scripts/conditions/hp-threshold-condition.js b/scripts/conditions/hp-threshold-condition.js new file mode 100644 index 0000000..e972fb8 --- /dev/null +++ b/scripts/conditions/hp-threshold-condition.js @@ -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; + } +} diff --git a/scripts/engine/action-runner.js b/scripts/engine/action-runner.js new file mode 100644 index 0000000..1ecd858 --- /dev/null +++ b/scripts/engine/action-runner.js @@ -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; +} diff --git a/scripts/engine/assignment-resolver.js b/scripts/engine/assignment-resolver.js new file mode 100644 index 0000000..72f1fab --- /dev/null +++ b/scripts/engine/assignment-resolver.js @@ -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); +} diff --git a/scripts/engine/condition-runner.js b/scripts/engine/condition-runner.js new file mode 100644 index 0000000..19084cf --- /dev/null +++ b/scripts/engine/condition-runner.js @@ -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; +} diff --git a/scripts/engine/consumption-manager.js b/scripts/engine/consumption-manager.js new file mode 100644 index 0000000..a36b509 --- /dev/null +++ b/scripts/engine/consumption-manager.js @@ -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; +} diff --git a/scripts/engine/reaction-engine.js b/scripts/engine/reaction-engine.js new file mode 100644 index 0000000..f6b1dd7 --- /dev/null +++ b/scripts/engine/reaction-engine.js @@ -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); + } + } +} diff --git a/scripts/triggers/damage-received.js b/scripts/triggers/damage-received.js new file mode 100644 index 0000000..5ac415d --- /dev/null +++ b/scripts/triggers/damage-received.js @@ -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)]; +} diff --git a/scripts/triggers/target-selected.js b/scripts/triggers/target-selected.js new file mode 100644 index 0000000..0df9d38 --- /dev/null +++ b/scripts/triggers/target-selected.js @@ -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 + }); + }); +} diff --git a/scripts/utils/distance-utils.js b/scripts/utils/distance-utils.js new file mode 100644 index 0000000..7dda205 --- /dev/null +++ b/scripts/utils/distance-utils.js @@ -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; +} diff --git a/scripts/utils/token-utils.js b/scripts/utils/token-utils.js new file mode 100644 index 0000000..1ceef14 --- /dev/null +++ b/scripts/utils/token-utils.js @@ -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; +}