From c42cbca53ff2ad5d460c5889ab4240fcaef41b09 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 6 Dec 2024 14:39:31 -0600 Subject: [PATCH 1/4] Changes for Pathv2 support in Its A Trap et al --- Its A Trap/3.13.2/ItsATrap.js | 3057 +++++++++++++++++++++++ Its A Trap/script.json | 4 +- PathMath/1.7/PathMath.js | 1657 ++++++++++++ PathMath/script.json | 4 +- Token Collisions/1.7/TokenCollisions.js | 902 +++++++ Token Collisions/script.json | 4 +- 6 files changed, 5622 insertions(+), 6 deletions(-) create mode 100644 Its A Trap/3.13.2/ItsATrap.js create mode 100644 PathMath/1.7/PathMath.js create mode 100644 Token Collisions/1.7/TokenCollisions.js diff --git a/Its A Trap/3.13.2/ItsATrap.js b/Its A Trap/3.13.2/ItsATrap.js new file mode 100644 index 0000000000..ac5d22c018 --- /dev/null +++ b/Its A Trap/3.13.2/ItsATrap.js @@ -0,0 +1,3057 @@ +/* globals PathMath VecMath TokenCollisions HtmlBuilder CharSheetUtils KABOOM AreasOfEffect */ +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.ItsATrap={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.ItsATrap.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-4);}} +API_Meta.ItsATrap.version = '3.13.2'; +/** + * Initialize the state for the It's A Trap script. + */ +(() => { + + /** + * The ItsATrap state data. + * @typedef {object} ItsATrapState + * @property {object} noticedTraps + * The set of IDs for traps that have been noticed by passive perception. + * @property {string} theme + * The name of the TrapTheme currently being used. + */ + state.ItsATrap = state.ItsATrap || {}; + _.defaults(state.ItsATrap, { + noticedTraps: {}, + userOptions: {} + }); + _.defaults(state.ItsATrap.userOptions, { + revealTrapsToMap: false, + announcer: 'Admiral Ackbar' + }); + + // Set the theme from the useroptions. + let useroptions = globalconfig && globalconfig.itsatrap; + if(useroptions) { + state.ItsATrap.userOptions = { + revealTrapsToMap: useroptions.revealTrapsToMap === 'true' || false, + announcer: useroptions.announcer || 'Admiral Ackbar' + }; + } +})(); + +/** + * The main interface and bootstrap script for It's A Trap. + */ +const ItsATrap = (() => { + + const REMOTE_ACTIVATE_CMD = '!itsATrapRemoteActivate'; + + // The collection of registered TrapThemes keyed by name. + let trapThemes = {}; + + // The installed trap theme that is being used. + let curTheme = 'default'; + + let isJumpgate = ()=>{ + if(['jumpgate'].includes(Campaign().get('_release'))) { + isJumpgate = () => true; + } else { + isJumpgate = () => false; + } + return isJumpgate(); + }; + + const getPath = (id) => { + let path; + if(isJumpgate()){ + path = getObj('pathv2',id); + } + return path || getObj('path',id); + }; + + + /** + * Activates a trap. + * @param {Graphic} trap + * @param {Graphic} [activatingVictim] + * The victim that triggered the trap. + */ + function activateTrap(trap, activatingVictim) { + let effect = new TrapEffect(trap); + if(effect.delay) { + // Set the interdiction status on the trap so that it doesn't get + // delay-activated multiple times. + effect.trap.set('status_interdiction', true); + + // Activate the trap after the delay. + setTimeout(() => { + _activateTrap(trap); + }, 1000*effect.delay); + + // Let the GM know that the trap has been triggered. + if(activatingVictim) + ItsATrap.Chat.whisperGM(`The trap ${effect.name} has been ` + + `triggered by ${activatingVictim.get('name')}. ` + + `It will activate in ${effect.delay} seconds.`); + else + ItsATrap.Chat.whisperGM(`The trap ${effect.name} has been ` + + `triggered. It will activate in ${effect.delay} seconds.`); + } + else + _activateTrap(trap, activatingVictim); + } + + /** + * Helper for activateTrap. + * @param {Graphic} trap + * @param {Graphic} [activatingVictim] + * The victim that triggered the trap. + */ + function _activateTrap(trap, activatingVictim) { + let theme = getTheme(); + let effect = new TrapEffect(trap); + + // Apply the trap's effects to any victims in its area and to the + // activating victim, using the configured trap theme. + let victims = getTrapVictims(trap, activatingVictim); + if(victims.length > 0) + _.each(victims, victim => { + effect = new TrapEffect(trap, victim); + theme.activateEffect(effect); + }); + else { + // In the absence of any victims, activate the trap with the default + // theme, which will only display the trap's message. + let defaultTheme = trapThemes['default']; + defaultTheme.activateEffect(effect); + } + + // If the trap is destroyable, delete it after it has activated. + if(effect.destroyable) + trap.remove(); + } + + /** + * Checks if a token passively searched for any traps during its last + * movement. + * @private + * @param {TrapTheme} theme + * @param {Graphic} token + */ + function _checkPassiveSearch(theme, token) { + if(theme.passiveSearch && theme.passiveSearch !== _.noop) { + _.chain(getSearchableTraps(token)) + .filter(trap => { + // Only search for traps that are close enough to be spotted. + let effect = new TrapEffect(trap, token); + + // Check the distance to the trap itself. + let dist = getSearchDistance(token, trap); + + // Also check the distance to any path triggers. + let triggerDist = Number.POSITIVE_INFINITY; + if (_.isArray(effect.triggerPaths)) { + triggerDist = _.chain(effect.triggerPaths) + .map(pathId => { + let path = getPath(pathId); + if(path) + return getSearchDistance(token, path); + else + return Number.POSITIVE_INFINITY; + }) + .min() + .value(); + } + + let searchDist = trap.get('aura2_radius') || effect.searchDist; + return (!searchDist || Math.min(dist, triggerDist) < searchDist); + }) + .each(trap => { + theme.passiveSearch(trap, token); + }); + } + } + + /** + * Checks if a token activated or passively spotted any traps during + * its last movement. + * @private + * @param {Graphic} token + */ + function _checkTrapInteractions(token) { + if(token.iatIgnoreToken) + return; + + // Objects on the GM layer don't set off traps. + if(token.get("layer") === "objects") { + try { + let theme = getTheme(); + if(!theme) { + log('ERROR - It\'s A Trap!: TrapTheme does not exist - ' + curTheme + '. Using default TrapTheme.'); + theme = trapThemes['default']; + } + + // Did the character set off a trap? + _checkTrapActivations(theme, token); + + // If the theme has passive searching, do a passive search for traps. + _checkPassiveSearch(theme, token); + } + catch(err) { + log('ERROR - It\'s A Trap!: ' + err.message); + log(err.stack); + } + } + } + + /** + * Checks if a token activated any traps during its last movement. + * @private + * @param {TrapTheme} theme + * @param {Graphic} token + */ + function _checkTrapActivations(theme, token) { + let collisions = getTrapCollisions(token); + _.find(collisions, collision => { + let trap = collision?.other; + + let trapEffect = new TrapEffect(trap, token); + + // Skip if the trap is disabled or if it has no activation area. + if(!trap || trap.get('status_interdiction')) + return false; + + // Should this trap ignore the token? + if(trapEffect.ignores && trapEffect.ignores.includes(token.get('_id'))) + return false; + + // Figure out where to stop the token. + if(trapEffect.stopAt === 'edge' && !trapEffect.gmOnly) { + let x = collision.pt[0]; + let y = collision.pt[1]; + + token.set("lastmove",""); + token.set("left", x); + token.set("top", y); + } + else if(trapEffect.stopAt === 'center' && !trapEffect.gmOnly && + ['self', 'burst'].includes(trapEffect.effectShape)) { + let x = trap.get("left"); + let y = trap.get("top"); + + token.set("lastmove",""); + token.set("left", x); + token.set("top", y); + } + + // Apply the trap's effects to any victims in its area. + if(collision.triggeredByPath) + activateTrap(trap); + else + activateTrap(trap, token); + + // Stop activating traps if this trap stopped the token. + return (trapEffect.stopAt !== 'none'); + }); + } + + /** + * Gets the point for a token. + * @private + * @param {Graphic} token + * @return {vec3} + */ + function _getPt(token) { + return [token.get('left'), token.get('top'), 1]; + } + + /** + * Gets all the traps that a token has line-of-sight to, with no limit for + * range. Line-of-sight is blocked by paths on the dynamic lighting layer. + * @param {Graphic} charToken + * @return {Graphic[]} + * The list of traps that charToken has line-of-sight to. + */ + function getSearchableTraps(charToken) { + let pageId = charToken.get('_pageid'); + let traps = getTrapsOnPage(pageId); + return LineOfSight.filterTokens(charToken, traps); + } + + /** + * Gets the distance between two tokens in their page's units. + * @param {Graphic} token1 + * @param {(Graphic|Path)} token2 + * @return {number} + */ + function getSearchDistance(token1, token2) { + let p1 = _getPt(token1); + let page = getObj('page', token1.get('_pageid')); + let scale = page.get('scale_number'); + let pixelDist; + + if(/^path/.test(token2.get('_type'))) { + let path = token2; + pixelDist = PathMath.distanceToPoint(p1, path); + } + else { + let p2 = _getPt(token2); + let r1 = token1.get('width')/2; + let r2 = token2.get('width')/2; + pixelDist = Math.max(0, VecMath.dist(p1, p2) - r1 - r2); + } + return pixelDist/70*scale; + } + + /** + * Gets the theme currently being used to interpret TrapEffects spawned + * when a character activates a trap. + * @return {TrapTheme} + */ + function getTheme() { + return trapThemes[curTheme]; + } + + /** + * Returns the list of all traps a token would collide with during its last + * movement. The traps are sorted in the order that the token will collide + * with them. + * @param {Graphic} token + * @return {TokenCollisions.Collision[]} + */ + function getTrapCollisions(token) { + let pageId = token.get('_pageid'); + let traps = getTrapsOnPage(pageId); + + // A llambda to test if a token is flying. + let isFlying = x => { + return x.get("status_fluffy-wing"); + }; + + let pathsToTraps = {}; + + // Some traps don't affect flying tokens. + traps = _.chain(traps) + .filter(trap => { + return !isFlying(token) || isFlying(trap); + }) + + // Use paths for collisions if trigger paths are set. + .map(trap => { + let effect = new TrapEffect(trap); + + // Skip the trap if it has no trigger. + if (effect.triggerPaths === 'none') + return undefined; + + // Trigger is defined by paths. + else if(_.isArray(effect.triggerPaths)) { + return _.chain(effect.triggerPaths) + .map(id => { + if(pathsToTraps[id]) + pathsToTraps[id].push(trap); + else + pathsToTraps[id] = [trap]; + + return getPath(id); + }) + .compact() + .value(); + } + + // Trigger is the trap token itself. + else + return trap; + }) + .flatten() + .compact() + .value(); + + // Get the collisions. + return _getTrapCollisions(token, traps, pathsToTraps); + } + + /** + * Returns the list of all traps a token would collide with during its last + * movement from a list of traps. + * The traps are sorted in the order that the token will collide + * with them. + * @private + * @param {Graphic} token + * @param {(Graphic[]|Path[])} traps + * @return {TokenCollisions.Collision[]} + */ + function _getTrapCollisions(token, traps, pathsToTraps) { + return _.chain(TokenCollisions.getCollisions(token, traps, {detailed: true})) + .map(collision => { + // Convert path collisions back into trap token collisions. + if(/^path/.test(collision.other.get('_type'))) { + let pathId = collision.other.get('_id'); + return _.map(pathsToTraps[pathId], trap => { + return { + token: collision.token, + other: trap, + pt: collision.pt, + dist: collision.dist, + triggeredByPath: true + }; + }); + } + else + return collision; + }) + .flatten() + .value(); + } + + /** + * Gets the list of all the traps on the specified page. + * @param {string} pageId + * @return {Graphic[]} + */ + function getTrapsOnPage(pageId) { + return findObjs({ + _pageid: pageId, + _type: "graphic", + status_cobweb: true, + layer: "gmlayer" + }); + } + + /** + * Gets the list of victims within an activated trap's area of effect. + * @param {Graphic} trap + * @param {Graphic} triggerVictim + * @return {Graphic[]} + */ + function getTrapVictims(trap, triggerVictim) { + let pageId = trap.get('_pageid'); + + let effect = new TrapEffect(trap); + let victims = []; + let otherTokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + // Case 1: One or more closed paths define the blast areas. + if(effect.effectShape instanceof Array) { + _.each(effect.effectShape, pathId => { + let path = getPath(pathId); + if(path) { + _.each(otherTokens, token => { + if(TokenCollisions.isOverlapping(token, path)) + victims.push(token); + }); + } + }); + } + + // Case 2: The trap itself defines the blast area. + else { + victims = [triggerVictim]; + + let range = trap.get('aura1_radius'); + let squareArea = trap.get('aura1_square'); + if(range !== '') { + let pageScale = getObj('page', pageId).get('scale_number'); + range *= 70/pageScale; + } + else + range = 0; + + victims = victims.concat(LineOfSight.filterTokens(trap, otherTokens, range, squareArea)); + } + + return _.chain(victims) + .unique() + .compact() + .reject(victim => { + return effect.ignores.includes(victim.get('_id')); + }) + .value(); + } + + /** + * Marks a trap with a circle and a ping. + * @private + * @param {Graphic} trap + */ + function _markTrap(trap) { + let radius = trap.get('width')/2; + let x = trap.get('left'); + let y = trap.get('top'); + let pageId = trap.get('_pageid'); + + // Circle the trap's trigger area. + let circle = new PathMath.Circle([x, y, 1], radius); + circle.render(pageId, 'objects', { + stroke: '#ffff00', // yellow + stroke_width: 5 + }); + + let effect = new TrapEffect(trap); + + let toOrder = toFront; + let layer = 'map'; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + _revealTriggers(trap); + _revealActivationAreas(trap); + sendPing(x, y, pageId); + } + + /** + * Marks a trap as being noticed by a character's passive search. + * @param {Graphic} trap + * @param {string} noticeMessage A message to display when the trap is noticed. + * @return {boolean} + * true if the trap has not been noticed yet. + */ + function noticeTrap(trap, noticeMessage) { + let id = trap.get('_id'); + let effect = new TrapEffect(trap); + + if(!state.ItsATrap.noticedTraps[id]) { + state.ItsATrap.noticedTraps[id] = true; + ItsATrap.Chat.broadcast(noticeMessage); + + if(effect.revealWhenSpotted) + revealTrap(trap); + else + _markTrap(trap); + return true; + } + else + return false; + } + + /** + * Registers a TrapTheme. + * @param {TrapTheme} theme + */ + function registerTheme(theme) { + log('It\'s A Trap!: Registered TrapTheme - ' + theme.name + '.'); + trapThemes[theme.name] = theme; + curTheme = theme.name; + } + + /** + * Reveals the paths defining a trap's activation area, if it has any. + * @param {Graphic} trap + */ + function _revealActivationAreas(trap) { + let effect = new TrapEffect(trap); + let layer = 'map'; + let toOrder = toFront; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + + if(effect.effectShape instanceof Array) + _.each(effect.effectShape, pathId => { + let path = getPath(pathId); + if (path) { + path.set('layer', layer); + toOrder(path); + } + else { + ItsATrap.Chat.error(new Error(`Could not find activation area shape ${pathId} for trap ${effect.name}. Perhaps you deleted it? Either way, please fix it through the trap's Activation Area property.`)); + } + }); + } + + /** + * Reveals a trap to the objects or map layer. + * @param {Graphic} trap + */ + function revealTrap(trap) { + let effect = new TrapEffect(trap); + + let toOrder = toFront; + let layer = 'map'; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + + // Reveal the trap token. + trap.set('layer', layer); + toOrder(trap); + sendPing(trap.get('left'), trap.get('top'), trap.get('_pageid')); + + // Reveal its trigger paths and activation areas, if any. + _revealActivationAreas(trap); + _revealTriggers(trap); + } + + /** + * Reveals any trigger paths associated with a trap, if any. + * @param {Graphic} trap + */ + function _revealTriggers(trap) { + let effect = new TrapEffect(trap); + let layer = 'map'; + let toOrder = toFront; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + + if(_.isArray(effect.triggerPaths)) { + _.each(effect.triggerPaths, pathId => { + let path = getPath(pathId); + if (path) { + path.set('layer', layer); + toOrder(path); + } + else { + ItsATrap.Chat.error(new Error(`Could not find trigger path ${pathId} for trap ${effect.name}. Perhaps you deleted it? Either way, please fix it through the trap's Trigger Area property.`)); + } + }); + } + } + + /** + * Removes a trap from the state's collection of noticed traps. + * @private + * @param {Graphic} trap + */ + function _unNoticeTrap(trap) { + let id = trap.get('_id'); + if(state.ItsATrap.noticedTraps[id]) + delete state.ItsATrap.noticedTraps[id]; + } + + // Create macro for the remote activation command. + on('ready', () => { + let numRetries = 3; + let interval = setInterval(() => { + let theme = getTheme(); + if(theme) { + log(`--- Initialized It's A Trap! v3.13.1, using theme '${getTheme().name}' ---`); + clearInterval(interval); + } + else if(numRetries > 0) + numRetries--; + else + clearInterval(interval); + }, 1000); + }); + + // Handle macro commands. + on('chat:message', msg => { + try { + let argv = msg.content.split(' '); + if(argv[0] === REMOTE_ACTIVATE_CMD) { + let theme = getTheme(); + + let trapId = argv[1]; + let trap = getObj('graphic', trapId); + if (trap) + activateTrap(trap); + else + throw new Error(`Could not activate trap ID ${trapId}. It does not exist.`); + } + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + /** + * When a graphic on the objects layer moves, run the script to see if it + * passed through any traps. + */ + on("change:graphic:lastmove", token => { + try { + // Check for trap interactions if the token isn't also a trap. + if(!token.get('status_cobweb')) + _checkTrapInteractions(token); + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + // If a trap is moved back to the GM layer, remove it from the set of noticed traps. + on('change:graphic:layer', token => { + try { + if(token.get('layer') === 'gmlayer') + _unNoticeTrap(token); + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + // When a trap's token is destroyed, remove it from the set of noticed traps. + on('destroy:graphic', token => { + try { + _unNoticeTrap(token); + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + // When a token is added, make it temporarily unable to trigger traps. + // This is to prevent a bug related to dropping default tokens for characters + // to the VTT, which sometimes caused traps to trigger as though the dropped + // token has move. + on('add:graphic', token => { + token.iatIgnoreToken = true; + setTimeout(() => { + delete token.iatIgnoreToken; + }, 1000); + }); + + return { + activateTrap, + getSearchDistance, + getTheme, + getTrapCollisions, + getTrapsOnPage, + noticeTrap, + registerTheme, + revealTrap, + REMOTE_ACTIVATE_CMD + }; +})(); + +/** + * The configured JSON properties of a trap. This can be extended to add + * additional properties for system-specific themes. + */ +const TrapEffect = (() => { + + let isJumpgate = ()=>{ + if(['jumpgate'].includes(Campaign().get('_release'))) { + isJumpgate = () => true; + } else { + isJumpgate = () => false; + } + return isJumpgate(); + }; + + const DEFAULT_FX = { + maxParticles: 100, + emissionRate: 3, + size: 35, + sizeRandom: 15, + lifeSpan: 10, + lifeSpanRandom: 3, + speed: 3, + speedRandom: 1.5, + gravity: {x: 0.01, y: 0.01}, + angle: 0, + angleRandom: 180, + duration: -1, + startColour: [220, 35, 0, 1], + startColourRandom: [62, 0, 0, 0.25], + endColour: [220, 35, 0, 0], + endColourRandom:[60, 60, 60, 0] + }; + + return class TrapEffect { + /** + * An API chat command that will be executed when the trap is activated. + * If the constants TRAP_ID and VICTIM_ID are provided, + * they will be replaced by the IDs for the trap token and the token for + * the trap's victim, respectively in the API chat command message. + * @type {string[]} + */ + get api() { + return this._effect.api || []; + } + + /** + * Specifications for an AreasOfEffect script graphic that is spawned + * when a trap is triggered. + * @typedef {object} TrapEffect.AreaOfEffect + * @property {String} name The name of the AoE effect. + * @property {vec2} [direction] The direction of the effect. If omitted, + * it will be extended toward the triggering token. + */ + + /** + * JSON defining a graphic to spawn with the AreasOfEffect script if + * it is installed and the trap is triggered. + * @type {TrapEffect.AreaOfEffect} + */ + get areaOfEffect() { + return this._effect.areaOfEffect; + } + + /** + * The delay for the trap in seconds. If undefined or 0, the trap + * activates instantly when triggered. + * @type {uint} + */ + get delay() { + return this._effect.delay; + } + + /** + * Whether the trap should be destroyed after it activates. + * @type {boolean} + */ + get destroyable() { + return this._effect.destroyable; + } + + /** + * A custom message that is displayed when a character notices a trap via + * passive detection. + * @type {string} + */ + get detectMessage() { + return this._effect.detectMessage; + } + + /** + * The shape of the trap's activated area. This could be an area where the + * trap token itself is the center of the effect (square or circle), or + * it could be a list of path IDs which define the activated areas. + * @type {(string[]|string)} + */ + get effectShape() { + if (this._trap.get('aura1_radius')) + return 'burst'; + else if (['circle', 'rectangle', 'square'].includes(this._effect.effectShape)) + return 'self'; + else + return this._effect.effectShape || 'self'; + } + + /** + * Configuration for special FX that are created when the trap activates. + * @type {object} + * @property {(string | FxJsonDefinition)} name + * Either the name of the FX that is created + * (built-in or user-made), or a custom FX JSON defintion. + * @property {vec2} offset + * The offset of the special FX, in units from the trap's token. + * @property {vec2} direction + * For beam-like FX, this specifies the vector for the FX's + * direction. If left blank, it will fire towards the token + * that activated the trap. + */ + get fx() { + return this._effect.fx; + } + + /** + * Whether the trap should only be announced to the GM when it is activated. + * @type {boolean} + */ + get gmOnly() { + return this._effect.gmOnly; + } + + /** + * A list of IDs for tokens that this trap ignores. These tokens will neither + * trigger nor be affected by the trap. + * @type {string[]} + */ + get ignores() { + return this._effect.ignores || []; + } + + /** + * Gets a copy of the trap's JSON properties. + * @readonly + * @type {object} + */ + get json() { + return _.clone(this._effect); + } + + /** + * JSON defining options to produce an explosion/implosion effect with + * the KABOOM script. + * @type {object} + */ + get kaboom() { + return this._effect.kaboom; + } + + /** + * The flavor message displayed when the trap is activated. If left + * blank, a default message will be generated based on the name of the + * trap's token. + * @type {string} + */ + get message() { + return this._effect.message || this._createDefaultTrapMessage(); + } + + /** + * The trap's name. + * @type {string} + */ + get name() { + return this._trap.get('name'); + } + + /** + * Secret notes for the GM. + * @type {string} + */ + get notes() { + return this._effect.notes; + } + + /** + * The layer that the trap gets revealed to. + * @type {string} + */ + get revealLayer() { + return this._effect.revealLayer; + } + + /** + * Whether the trap is revealed when it is spotted. + * @type {boolean} + */ + get revealWhenSpotted() { + return this._effect.revealWhenSpotted; + } + + /** + * The name of a sound played when the trap is activated. + * @type {string} + */ + get sound() { + return this._effect.sound; + } + + /** + * This is where the trap stops the token. + * If "edge", then the token is stopped at the trap's edge. + * If "center", then the token is stopped at the trap's center. + * If "none", the token is not stopped by the trap. + * @type {string} + */ + get stopAt() { + return this._effect.stopAt || 'center'; + } + + /** + * Command arguments for integration with the TokenMod script by The Aaron. + * @type {string} + */ + get tokenMod() { + return this._effect.tokenMod; + } + + /** + * The trap this TrapEffect represents. + * @type {Graphic} + */ + get trap() { + return this._trap; + } + + /** + * The ID of the trap. + * @type {uuid} + */ + get trapId() { + return this._trap.get('_id'); + } + + /** + * A list of path IDs defining an area that triggers this trap. + * @type {string[]} + */ + get triggerPaths() { + return this._effect.triggerPaths; + } + + /** + * A list of names or IDs for traps that will also be triggered when this + * trap is activated. + * @type {string[]} + */ + get triggers() { + return this._effect.triggers; + } + + /** + * The name for the trap/secret's type displayed in automated messages. + * @type {string} + */ + get type() { + return this._effect.type; + } + + /** + * The victim who activated the trap. + * @type {Graphic} + */ + get victim() { + return this._victim; + } + + /** + * The ID of the trap's victim's token. + * @type {uuid} + */ + get victimId() { + return this._victim && this._victim.get('_id'); + } + + /** + * The name of the trap's victim's character. + * @type {uuid} + */ + get victimCharName() { + if (this._victim) { + let char = getObj('character', this._victim.get('represents')); + if (char) + return char.get('name'); + } + return undefined; + } + + /** + * @param {Graphic} trap + * The trap's token. + * @param {Graphic} [victim] + * The token for the character that activated the trap. + */ + constructor(trap, victim) { + let effect = {}; + + // URI-escape the notes and remove the HTML elements. + let notes = trap.get('gmnotes'); + try { + notes = decodeURIComponent(notes).trim(); + } + catch(err) { + notes = unescape(notes).trim(); + } + if(notes) { + try { + notes = notes.split(/<[/]?.+?>/g).join(''); + effect = JSON.parse(notes); + } + catch(err) { + effect.message = 'ERROR: invalid TrapEffect JSON.'; + } + } + this._effect = effect; + this._trap = trap; + this._victim = victim; + } + + /** + * Activates the traps that are triggered by this trap. + */ + activateTriggers() { + let triggers = this.triggers; + if(triggers) { + let otherTraps = ItsATrap.getTrapsOnPage(this._trap.get('_pageid')); + let triggeredTraps = _.filter(otherTraps, trap => { + // Skip if the trap is disabled. + if(trap.get('status_interdiction')) + return false; + + return triggers.indexOf(trap.get('name')) !== -1 || + triggers.indexOf(trap.get('_id')) !== -1; + }); + + _.each(triggeredTraps, trap => { + ItsATrap.activateTrap(trap); + }); + } + } + + /** + * Announces the activated trap. + * This should be called by TrapThemes to inform everyone about a trap + * that has been triggered and its results. Fancy HTML formatting for + * the message is encouraged. If the trap's effect has gmOnly set, + * then the message will only be shown to the GM. + * This also takes care of playing the trap's sound, FX, and API command, + * they are provided. + * @param {string} [message] + * The message for the trap results displayed in the chat. If + * omitted, then the trap's raw message will be displayed. + */ + announce(message) { + message = message || this.message; + + // Display the message to everyone, unless it's a secret. + if(this.gmOnly) { + ItsATrap.Chat.whisperGM(message); + + // Whisper any secret notes to the GM. + if(this.notes) + ItsATrap.Chat.whisperGM(`Trap Notes:
${this.notes}`); + } + else { + ItsATrap.Chat.broadcast(message); + + // Whisper any secret notes to the GM. + if(this.notes) + ItsATrap.Chat.whisperGM(`Trap Notes:
${this.notes}`); + + // Reveal the trap if it's set to become visible. + if(this.trap.get('status_bleeding-eye')) + ItsATrap.revealTrap(this.trap); + + // Produce special outputs if it has any. + this.playSound(); + this.playFX(); + this.playAreaOfEffect(); + this.playKaboom(); + this.playTokenMod(); + this.playApi(); + + // Allow traps to trigger each other using the 'triggers' property. + this.activateTriggers(); + } + } + + /** + * Creates a default message for the trap. + * @private + * @return {string} + */ + _createDefaultTrapMessage() { + if(this.victim) { + if(this.name) + return `${this.victim.get('name')} set off a trap: ${this.name}!`; + else + return `${this.victim.get('name')} set off a trap!`; + } + else { + if(this.name) + return `${this.name} was activated!`; + else + return `A trap was activated!`; + } + } + + /** + * Executes the trap's API chat command if it has one. + */ + playApi() { + let api = this.api; + if(api) { + let commands; + if(api instanceof Array) + commands = api; + else + commands = [api]; + + // Run each API command. + _.each(commands, cmd => { + cmd = cmd.replace(/TRAP_ID/g, this.trapId) + .replace(/VICTIM_ID/g, this.victimId) + .replace(/VICTIM_CHAR_NAME/g, this.victimCharName) + .replace(/\\\[/g, '[') + .replace(/\\\]/g, ']') + .replace(/\\{/g, '{') + .replace(/\\}/g, '}') + .replace(/\\@/g, '@') + .replace(/(\t|\n|\r)/g, ' ') + .replace(/\[\[ +/g, '[[') + .replace(/ +\]\]/g, ']]'); + sendChat('ItsATrap-api', `${cmd}`); + }); + } + } + + /** + * Spawns the AreasOfEffect graphic for this trap. If AreasOfEffect is + * not installed, then this has no effect. + */ + playAreaOfEffect() { + if(typeof AreasOfEffect !== 'undefined' && this.areaOfEffect) { + let direction = (this.areaOfEffect.direction && VecMath.scale(this.areaOfEffect.direction, 70)) || + (() => { + if(this._victim) + return [ + this._victim.get('left') - this._trap.get('left'), + this._victim.get('top') - this._trap.get('top') + ]; + else + return [0, 0]; + })(); + direction[2] = 0; + + let p1 = [this._trap.get('left'), this._trap.get('top'), 1]; + let p2 = VecMath.add(p1, direction); + if(VecMath.dist(p1, p2) > 0) { + let segments = [[p1, p2]]; + let pathJson = PathMath.segmentsToPath(segments); + let path = createObj( (isJumpgate() ? 'pathv2' : 'path'), _.extend(pathJson, { + _pageid: this._trap.get('_pageid'), + layer: 'objects', + stroke: '#ff0000' + })); + + // Construct a fake player object to create the effect for. + // This will correctly set the AoE's controlledby property to '' + // to denote that it is controlled by no one. + let fakePlayer = { + get: function() { + return ''; + } + }; + + // Create the AoE. + let aoeGraphic = AreasOfEffect.applyEffect(fakePlayer, this.areaOfEffect.name, path); + aoeGraphic.set('layer', 'map'); + toFront(aoeGraphic); + } + } + } + + /** + * Spawns built-in or custom FX for an activated trap. + */ + playFX() { + var pageId = this._trap.get('_pageid'); + + if(this.fx) { + var offset = this.fx.offset || [0, 0]; + var origin = [ + this._trap.get('left') + offset[0]*70, + this._trap.get('top') + offset[1]*70 + ]; + + var direction = this.fx.direction || (() => { + if(this._victim) + return [ + this._victim.get('left') - origin[0], + this._victim.get('top') - origin[1] + ]; + else + return [ 0, 1 ]; + })(); + + this._playFXNamed(this.fx.name, pageId, origin, direction); + } + } + + /** + * Play FX using a named effect. + * @private + * @param {string} name + * @param {uuid} pageId + * @param {vec2} origin + * @param {vec2} direction + */ + _playFXNamed(name, pageId, origin, direction) { + let x = origin[0]; + let y = origin[1]; + + let fx = name; + let isBeamLike = false; + + var custFx = findObjs({ _type: 'custfx', name: name })[0]; + if(custFx) { + fx = custFx.get('_id'); + isBeamLike = custFx.get('definition').angle === -1; + } + else + isBeamLike = !!_.find(['beam-', 'breath-', 'splatter-'], type => { + return name.startsWith(type); + }); + + if(isBeamLike) { + let p1 = { + x: x, + y: y + }; + let p2 = { + x: x + direction[0], + y: y + direction[1] + }; + + spawnFxBetweenPoints(p1, p2, fx, pageId); + } + else + spawnFx(x, y, fx, pageId); + } + + /** + * Produces an explosion/implosion effect with the KABOOM script. + */ + playKaboom() { + if(typeof KABOOM !== 'undefined' && this.kaboom) { + let center = [this.trap.get('left'), this.trap.get('top')]; + let options = { + effectPower: this.kaboom.power, + effectRadius: this.kaboom.radius, + type: this.kaboom.type, + scatter: this.kaboom.scatter + }; + + KABOOM.NOW(options, center); + } + } + + /** + * Plays a TrapEffect's sound, if it has one. + */ + playSound() { + if(this.sound) { + var sound = findObjs({ + _type: 'jukeboxtrack', + title: this.sound + })[0]; + if(sound) { + sound.set('playing', true); + sound.set('softstop', false); + } + else { + let msg = 'Could not find sound "' + this.sound + '".'; + sendChat('ItsATrap-api', msg); + } + } + } + + /** + * Invokes TokenMod on the victim's token. + */ + playTokenMod() { + if(typeof TokenMod !== 'undefined' && this.tokenMod && this._victim) { + let victimId = this._victim.get('id'); + let command = '!token-mod ' + this.tokenMod + ' --ids ' + victimId; + + // Since playerIsGM fails for the player ID "API", we'll need to + // temporarily switch TokenMod's playersCanUse_ids option to true. + if(!TrapEffect.tokenModTimeout) { + let temp = state.TokenMod.playersCanUse_ids; + TrapEffect.tokenModTimeout = setTimeout(() => { + state.TokenMod.playersCanUse_ids = temp; + TrapEffect.tokenModTimeout = undefined; + }, 1000); + } + + state.TokenMod.playersCanUse_ids = true; + sendChat('ItsATrap-api', command); + } + } + + /** + * Saves the current trap effect properties to the trap's token. + */ + save() { + this._trap.set('gmnotes', JSON.stringify(this.json)); + } + }; +})(); + +/** + * A small library for checking if a token has line of sight to other tokens. + */ +var LineOfSight = (() => { + + /** + * Gets the point for a token. + * @private + * @param {Graphic} token + * @return {vec3} + */ + function _getPt(token) { + return [token.get('left'), token.get('top'), 1]; + } + + return class LineOfSight { + + /** + * Gets the tokens that a token has line of sight to. + * @private + * @param {Graphic} token + * @param {Graphic[]} otherTokens + * @param {number} [range=Infinity] + * The line-of-sight range in pixels. + * @param {boolean} [isSquareRange=false] + * @return {Graphic[]} + */ + static filterTokens(token, otherTokens, range, isSquareRange) { + if(_.isUndefined(range)) + range = Infinity; + + let pageId = token.get('_pageid'); + let tokenPt = _getPt(token); + let tokenRW = token.get('width')/2-1; + let tokenRH = token.get('height')/2-1; + + let wallPaths = findObjs({ + _type: 'path', + _pageid: pageId, + layer: 'walls' + }); + let wallSegments = PathMath.toSegments(wallPaths); + + return _.filter(otherTokens, other => { + let otherPt = _getPt(other); + let otherRW = other.get('width')/2; + let otherRH = other.get('height')/2; + + // Skip tokens that are out of range. + if(isSquareRange && ( + Math.abs(tokenPt[0]-otherPt[0]) >= range + otherRW + tokenRW || + Math.abs(tokenPt[1]-otherPt[1]) >= range + otherRH + tokenRH)) + return false; + else if(!isSquareRange && VecMath.dist(tokenPt, otherPt) >= range + tokenRW + otherRW) + return false; + + let segToOther = [tokenPt, otherPt]; + return !_.find(wallSegments, wallSeg => { + return PathMath.segmentIntersection(segToOther, wallSeg); + }); + }); + } + }; +})(); + +/** + * A module that presents a wizard for setting up traps instead of + * hand-crafting the JSON for them. + */ +var ItsATrapCreationWizard = (() => { + const DISPLAY_WIZARD_CMD = '!ItsATrap_trapCreationWizard_showMenu'; + const MODIFY_CORE_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapCore'; + const MODIFY_THEME_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapTheme'; + + const MENU_CSS = { + 'optionsTable': { + 'width': '100%' + }, + 'menu': { + 'background': '#fff', + 'border': 'solid 1px #000', + 'border-radius': '5px', + 'font-weight': 'bold', + 'margin-bottom': '1em', + 'overflow': 'hidden' + }, + 'menuBody': { + 'padding': '5px', + 'text-align': 'center' + }, + 'menuHeader': { + 'background': '#000', + 'color': '#fff', + 'text-align': 'center' + } + }; + + const LPAREN = '('; + const RPAREN = ')'; + + const LBRACKET = '['; + const RBRACKET = ']'; + + const LBRACE = '{'; + const RBRACE = '}'; + + const ATSIGN = '@'; + + // The last trap that was edited in the wizard. + let curTrap; + + /** + * Displays the menu for setting up a trap. + * @param {string} who + * @param {string} playerid + * @param {Graphic} trapToken + */ + function displayWizard(who, playerId, trapToken) { + curTrap = trapToken; + let content = new HtmlBuilder('div'); + + if(!trapToken.get('status_cobweb')) { + trapToken.set('status_cobweb', true); + trapToken.set('name', 'A cunning trap'); + trapToken.set('aura1_square', true); + trapToken.set('gmnotes', getDefaultJson()); + } + + // Core properties + content.append('h4', 'Core properties'); + let coreProperties = getCoreProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, coreProperties)); + + // Trigger properties + content.append('h4', 'Trigger properties', { + style: { 'margin-top' : '2em' } + }); + let triggerProperties = getTriggerProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, triggerProperties)); + + // Activation properties + content.append('h4', 'Activation properties', { + style: { 'margin-top' : '2em' } + }); + let shapeProperties = getShapeProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, shapeProperties)); + + // Reveal properties + content.append('h4', 'Detection properties', { + style: { 'margin-top' : '2em' } + }); + let revealProperties = getRevealProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, revealProperties)); + + // Script properties + content.append('h4', 'External script properties', { + style: { 'margin-top': '2em' } + }); + let scriptProperties = getScriptProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, scriptProperties)); + + // Theme properties + let theme = ItsATrap.getTheme(); + if(theme.getThemeProperties) { + content.append('h4', 'Theme-specific properties', { + style: { 'margin-top' : '2em' } + }); + let properties = theme.getThemeProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_THEME_PROPERTY_CMD, properties)); + } + + // Remote activate button + content.append('div', `[Activate Trap](${ItsATrap.REMOTE_ACTIVATE_CMD} ${curTrap.get('_id')})`, { + style: { 'margin-top' : '2em' } + }); + + let menu = _showMenuPanel('Trap Configuration', content); + ItsATrap.Chat.whisperGM(menu.toString(MENU_CSS)); + } + + /** + * Creates the table for a list of trap properties. + * @private + */ + function _displayWizardProperties(modificationCommand, properties) { + let table = new HtmlBuilder('table'); + _.each(properties, prop => { + let row = table.append('tr', undefined, { + title: prop.desc + }); + + // Construct the list of parameter prompts. + let params = []; + let paramProperties = prop.properties || [prop]; + _.each(paramProperties, item => { + let options = ''; + if(item.options) + options = '|' + item.options.join('|'); + params.push(`?{${item.name} ${item.desc} ${options}}`); + }); + + row.append('td', `[${prop.name}](${modificationCommand} ${prop.id}&&${params.join('&&')})`, { + style: { 'font-size': '0.8em' } + }); + + row.append('td', `${prop.value || ''}`, { + style: { 'font-size': '0.8em', 'min-width': '1in' } + }); + }); + + return table; + } + + /** + * Gets a list of the core trap properties for a trap token. + * @param {Graphic} token + * @return {object[]} + */ + function getCoreProperties(trapToken) { + let trapEffect = new TrapEffect(trapToken); + + return [ + { + id: 'name', + name: 'Name', + desc: 'The name of the trap', + value: trapToken.get('name') + }, + { + id: 'type', + name: 'Type', + desc: 'Is this a trap, or some other hidden secret?', + value: trapEffect.type || 'trap' + }, + { + id: 'message', + name: 'Message', + desc: 'The message displayed when the trap is activated.', + value: trapEffect.message + }, + { + id: 'disabled', + name: 'Disabled?', + desc: 'A disabled trap will not activate when triggered, but can still be spotted with passive perception.', + value: trapToken.get('status_interdiction') ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'gmOnly', + name: 'Show GM Only?', + desc: 'When the trap is activated, should its results only be displayed to the GM?', + value: trapEffect.gmOnly ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'notes', + name: 'Secret Notes', + desc: 'Additional secret notes shown only to the GM when the trap is activated.', + value: trapEffect.notes || '-' + } + ]; + } + + /** + * Produces JSON for default trap properties. + * @return {string} + */ + function getDefaultJson() { + return JSON.stringify({ + effectShape: 'self', + stopAt: 'center' + }); + } + + /** + * Gets a list of the core trap properties for a trap token dealing + * with revealing the trap. + * @param {Graphic} token + * @return {object[]} + */ + function getRevealProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + + return [ + { + id: 'searchDist', + name: 'Max Search Distance', + desc: 'How far away can characters passively search for this trap?', + value: (() => { + let page = getObj('page', trapToken.get('_pageid')); + let units = page.get('scale_units'); + let dist = trapToken.get('aura2_radius') || trapEffect.searchDist; + + if (dist) + return `${dist} ${units}`; + else + return '-'; + })() + //value: trapToken.get('aura2_radius') || trapEffect.searchDist || '-' + }, + { + id: 'detectMessage', + name: 'Detection Message', + desc: 'What message is displayed when a character notices the trap with passive detection?', + value: trapEffect.detectMessage || '-' + }, + { + id: 'revealOpts', + name: 'Reveal the Trap?', + desc: 'Whether the trap should be revealed when the trap is activated and/or spotted, or if not, whether the trap troken is deleted after it activates.', + value: (() => { + let onActivate = trapToken.get('status_bleeding-eye'); + let onSpotted = trapEffect.revealWhenSpotted; + let layer = trapEffect.revealLayer || 'map'; + + if (onActivate && onSpotted) + return `Reveal to ${layer} layer when activated or spotted.`; + else if (onActivate) + return `Reveal to ${layer} layer when activated.`; + else if (onSpotted) + return `Reveal to ${layer} layer when spotted.`; + else + return 'Do not reveal.'; + })(), + properties: [ + { + id: 'onActivate', + name: 'Reveal when activated?', + desc: 'Should the trap be revealed when it is activated?', + options: ['yes', 'no'] + }, + { + id: 'onSpotted', + name: 'Reveal when spotted?', + desc: 'Should the trap be revealed when it is spotted?', + options: ['yes', 'no'] + }, + { + id: 'layer', + name: 'Reveal Layer', + desc: 'Which layer should the trap be moved to when it is revealed?', + options: ['map', 'objects'] + } + ] + } + ]; + } + + /** + * Gets a list of the core trap properties for a trap token defining + * the shape of the trap. + * @param {Graphic} token + * @return {object[]} + */ + function getShapeProperties(trapToken) { + let trapEffect = new TrapEffect(trapToken); + + return _.compact([ + { + id: 'effectShape', + name: 'Activation Area', + desc: `The area of the trap that actually affects tokens after it is triggered. To set paths, you must also select one or more paths defining the trap's blast area. A fill color must be set for tokens inside the path to be affected.`, + value: trapEffect.effectShape || 'self', + options: [ 'self', 'burst', 'set selected shapes'] + }, + (() => { + if (trapEffect.effectShape === 'burst') + return { + id: 'effectDistance', + name: 'Burst Radius', + desc: `The radius of the trap's burst activation area.`, + value: (() => { + let radius = trapToken.get('aura1_radius') || 0; + let page = getObj('page', trapToken.get('_pageid')); + let units = page.get('scale_units'); + return `${radius} ${units}`; + })() + }; + })(), + { + id: 'fx', + name: 'Special FX', + desc: 'What special FX are displayed when the trap is activated?', + value: (() => { + let fx = trapEffect.fx; + if(fx) { + let result = fx.name; + if(fx.offset) + result += '; Offset: ' + fx.offset; + if(fx.direction) + result += '; Direction: ' + fx.direction; + return result; + } + else + return 'None'; + })(), + properties: [ + { + id: 'name', + name: 'FX Name', + desc: 'The name of the special FX.' + }, + { + id: 'offset', + name: 'FX Offset', + desc: 'The offset ' + LPAREN + 'in units' + RPAREN + ' of the special FX from the trap\'s center. Format: ' + LBRACKET + 'X,Y' + RBRACKET + }, + { + id: 'direction', + name: 'FX Direction', + desc: 'The directional vector for the special FX ' + LPAREN + 'Leave blank to direct it towards characters' + RPAREN + '. Format: ' + LBRACKET + 'X,Y' + RBRACKET + } + ] + }, + { + id: 'sound', + name: 'Sound', + desc: 'A sound from your jukebox that will play when the trap is activated.', + value: trapEffect.sound || '-', + options: (() => { + let tracks = findObjs({ + _type: 'jukeboxtrack' + }); + let trackNames = _.map(tracks, track => { + return _htmlEncode(track.get('title')); + }); + trackNames.sort(); + return ['none', ...trackNames]; + })() + }, + { + id: 'triggers', + name: 'Chained Trap IDs', + desc: 'A list of the names or token IDs for other traps that are triggered when this trap is activated.', + value: (() => { + let triggers = trapEffect.triggers; + if(_.isString(triggers)) + triggers = [triggers]; + + if(triggers) + return triggers.join(', '); + else + return 'none'; + })(), + options: ['none', 'set selected traps'] + }, + { + id: 'destroyable', + name: 'Delete after Activation?', + desc: 'Whether to delete the trap token after it is activated.', + value: trapEffect.destroyable ? 'yes': 'no', + options: ['yes', 'no'] + } + ]); + } + + /** + * Gets a a list of the trap properties for a trap token dealing with + * supported API scripts. + */ + function getScriptProperties(trapToken) { + let trapEffect = new TrapEffect(trapToken); + + return _.compact([ + { + id: 'api', + name: 'API Command', + desc: 'An API command which the trap runs when it is activated. The constants TRAP_ID and VICTIM_ID will be replaced by the object IDs for the trap and victim. Multiple API commands are now supported by separating each command with ";;". Certain special characters must be escaped. See README section about the API Command property for details.', + value: (() => { + if (trapEffect.api.length > 0) { + let result = ''; + _.each(trapEffect.api, cmd => { + result += cmd.replace(/\\\[/g, LBRACKET) + .replace(/\\\]/g, RBRACKET) + .replace(/\\{/g, LBRACE) + .replace(/\\}/g, RBRACE) + .replace(/\\@/g, ATSIGN) + "
"; + }); + return result; + } + else + return '-'; + + + })() + }, + + // Requires AreasOfEffect script. + (() => { + if(typeof AreasOfEffect !== 'undefined') { + let effectNames = _.map(AreasOfEffect.getEffects(), effect => { + return effect.name; + }); + + return { + id: 'areaOfEffect', + name: 'Areas of Effect script', + desc: 'Specifies an AoE graphic to be spawned by the trap.', + value: (() => { + let aoe = trapEffect.areaOfEffect; + if(aoe) { + let result = aoe.name; + if(aoe.direction) + result += '; Direction: ' + aoe.direction; + return result; + } + else + return 'None'; + })(), + properties: [ + { + id: 'name', + name: 'AoE Name', + desc: 'The name of the saved AreasOfEffect effect.', + options: ['none', ...effectNames] + }, + { + id: 'direction', + name: 'AoE Direction', + desc: 'The direction of the AoE effect. Optional. If omitted, then the effect will be directed toward affected tokens. Format: ' + LBRACKET + 'X,Y' + RBRACKET + } + ] + }; + } + })(), + + // Requires KABOOM script by PaprikaCC (Bodin Punyaprateep). + (() => { + if(typeof KABOOM !== 'undefined') + return { + id: 'kaboom', + name: 'KABOOM script', + desc: 'An explosion/implosion generated by the trap with the KABOOM script by PaprikaCC.', + value: (() => { + let props = trapEffect.kaboom; + if(props) { + let result = props.power + ' ' + props.radius + ' ' + (props.type || 'default'); + if(props.scatter) + result += ' ' + 'scatter'; + return result; + } + else + return 'None'; + })(), + properties: [ + { + id: 'power', + name: 'Power', + desc: 'The power of the KABOOM effect.' + }, + { + id: 'radius', + name: 'Radius', + desc: 'The radius of the KABOOM effect.' + }, + { + id: 'type', + name: 'FX Type', + desc: 'The type of element to use for the KABOOM FX.' + }, + { + id: 'scatter', + name: 'Scatter', + desc: 'Whether to apply scattering to tokens affected by the KABOOM effect.', + options: ['no', 'yes'] + } + ] + }; + })(), + + // Requires the TokenMod script by The Aaron. + (() => { + if(typeof TokenMod !== 'undefined') + return { + id: 'tokenMod', + name: 'TokenMod script', + desc: 'Modify affected tokens with the TokenMod script by The Aaron.', + value: trapEffect.tokenMod || '-' + }; + })() + ]); + } + + /** + * Gets a list of the core trap properties for a trap token. + * @param {Graphic} token + * @return {object[]} + */ + function getTriggerProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + + return _.compact([ + { + id: 'triggerPaths', + name: 'Trigger Area', + desc: 'The trigger area for the trap. Characters that pass through this area will cause the trap to activate. To set paths, you must also select the paths that trigger the trap.', + value: (() => { + if (trapEffect.triggerPaths) + return trapEffect.triggerPaths; + else { + if (trapToken.get('aura1_square')) + return 'self - rectangle'; + else + return 'self - circle'; + } + })(), + options: ['self - rectangle', 'self - circle', 'set selected lines', 'none'] + }, + ...(() => { + if (trapEffect.triggerPaths === 'none') + return []; + else { + return [ + { + id: 'stopAt', + name: 'Trigger Collision', + desc: 'Does this trap stop tokens that pass through its trigger area?', + value: (() => { + let type = trapEffect.stopAt || 'center'; + if (type === 'center') + return 'Move to center of trap token.'; + else if (type === 'edge') + return 'Stop at edge of trigger area.'; + else + return 'None'; + })(), + options: ['center', 'edge', 'none'] + }, + { + id: 'ignores', + name: 'Ignore Token IDs', + desc: 'Select one or more tokens to be ignored by this trap.', + value: trapEffect.ignores || 'none', + options: ['none', 'set selected tokens'] + }, + { + id: 'flying', + name: 'Affects Flying Tokens?', + desc: 'Should this trap affect flying tokens ' + LPAREN + 'fluffy-wing status ' + RPAREN + '?', + value: trapToken.get('status_fluffy-wing') ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'delay', + name: 'Delay Activation', + desc: 'When the trap is triggered, its effect is delayed for the specified number of seconds. For best results, also be sure to set an area effect for the trap and set the Stops Tokens At property of the trap to None.', + value: (() => { + if (trapEffect.delay) + return trapEffect.delay + ' seconds'; + else + return '-'; + })() + } + ]; + } + })() + ]); + } + + /** + * HTML-decodes a string. + * @param {string} str + * @return {string} + */ + function _htmlDecode(str) { + return str.replace(/#(\d+);/g, (match, code) => { + return String.fromCharCode(code); + }); + } + + /** + * HTML-encodes a string, making it safe to use in chat-based action buttons. + * @param {string} str + * @return {string} + */ + function _htmlEncode(str) { + return str.replace(/[{}()\[\]<>!@#$%^&*\/\\'"+=,.?]/g, match => { + let charCode = match.charCodeAt(0); + return `#${charCode};`; + }); + } + + /** + * Changes a property for a trap. + * @param {Graphic} trapToken + * @param {Array} argv + * @param {(Graphic|Path)[]} selected + */ + function modifyTrapProperty(trapToken, argv, selected) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let prop = argv[0]; + let params = argv.slice(1); + + if(prop === 'name') + trapToken.set('name', params[0]); + if(prop === 'type') + trapEffect.type = params[0]; + if(prop === 'api') { + if(params[0]) + trapEffect.api = params[0].split(";;"); + else + trapEffect.api = []; + } + if(prop === 'areaOfEffect') { + if(params[0] && params[0] !== 'none') { + trapEffect.areaOfEffect = {}; + trapEffect.areaOfEffect.name = params[0]; + try { + trapEffect.areaOfEffect.direction = JSON.parse(params[1]); + } catch(err) {} + } + else + trapEffect.areaOfEffect = undefined; + } + if(prop === 'delay') + trapEffect.delay = params[0] || undefined; + if(prop === 'destroyable') + trapEffect.destroyable = params[0] === 'yes'; + if (prop === 'detectMessage') + trapEffect.detectMessage = params[0]; + if(prop === 'disabled') + trapToken.set('status_interdiction', params[0] === 'yes'); + + if(prop === 'effectDistance') + trapToken.set('aura1_radius', parseInt(params[0]) || ''); + + if(prop === 'effectShape') { + if (params[0] === 'self') { + trapEffect.effectShape = 'self'; + trapToken.set('aura1_radius', ''); + } + else if (params[0] === 'burst') { + trapEffect.effectShape = 'burst'; + trapToken.set('aura1_radius', 10); + } + else if(params[0] === 'set selected shapes' && selected) { + trapEffect.effectShape = _.map(selected, path => { + return path.get('_id'); + }); + trapToken.set('aura1_radius', ''); + } + else + throw Error('Unexpected effectShape value: ' + params[0]); + } + if(prop === 'flying') + trapToken.set('status_fluffy-wing', params[0] === 'yes'); + if(prop === 'fx') { + if(params[0]) { + trapEffect.fx = {}; + trapEffect.fx.name = params[0]; + try { + trapEffect.fx.offset = JSON.parse(params[1]); + } + catch(err) {} + try { + trapEffect.fx.direction = JSON.parse(params[2]); + } + catch(err) {} + } + else + trapEffect.fx = undefined; + } + if(prop === 'gmOnly') + trapEffect.gmOnly = params[0] === 'yes'; + if(prop === 'ignores') + if(params[0] === 'set selected tokens' && selected) + trapEffect.ignores = _.map(selected, token => { + return token.get('_id'); + }); + else + trapEffect.ignores = undefined; + if(prop === 'kaboom') + if(params[0]) + trapEffect.kaboom = { + power: parseInt(params[0]), + radius: parseInt(params[1]), + type: params[2] || undefined, + scatter: params[3] === 'yes' + }; + else + trapEffect.kaboom = undefined; + if(prop === 'message') + trapEffect.message = params[0]; + if(prop === 'notes') + trapEffect.notes = params[0]; + + if (prop === 'revealOpts') { + trapToken.set('status_bleeding-eye', params[0] === 'yes'); + trapEffect.revealWhenSpotted = params[1] === 'yes'; + trapEffect.revealLayer = params[2]; + } + + if(prop === 'searchDist') + trapToken.set('aura2_radius', parseInt(params[0]) || ''); + if(prop === 'sound') + trapEffect.sound = _htmlDecode(params[0]); + if(prop === 'stopAt') + trapEffect.stopAt = params[0]; + if(prop === 'tokenMod') + trapEffect.tokenMod = params[0]; + if(prop === 'triggers') { + if (params[0] === 'set selected traps' && selected) { + trapEffect.triggers = _.map(selected, token => { + let tokenId = token.get('_id'); + if (tokenId !== trapToken.get('_id')) + return token.get('_id'); + }); + } + else + trapEffect.triggers = undefined; + } + if(prop === 'triggerPaths') { + if (params[0] === 'self - circle') { + trapEffect.triggerPaths = undefined; + trapToken.set('aura1_square', false); + } + else if (params[0] === 'self - rectangle') { + trapEffect.triggerPaths = undefined; + trapToken.set('aura1_square', true); + } + else if (params[0] === 'set selected lines' && selected) { + trapEffect.triggerPaths = _.map(selected, path => { + return path.get('_id'); + }); + trapToken.set('aura1_square', false); + } + else if (params[0] === 'none') { + trapEffect.triggerPaths = 'none'; + trapToken.set('aura1_square', false); + } + else { + trapEffect.triggerPaths = undefined; + trapToken.set('aura1_square', false); + } + } + + trapToken.set('gmnotes', JSON.stringify(trapEffect)); + } + + /** + * Displays one of the script's menus. + * @param {string} header + * @param {(string|HtmlBuilder)} content + * @return {HtmlBuilder} + */ + function _showMenuPanel(header, content) { + let menu = new HtmlBuilder('.menu'); + menu.append('.menuHeader', header); + menu.append('.menuBody', content); + return menu; + } + + + + on('ready', () => { + // Delete the 3.9.4 version of the macro. + let oldMacros = findObjs({ + _type: 'macro', + name: 'ItsATrap_trapCreationWizard' + }); + if (oldMacros.length > 0) { + ItsATrap.Chat.whisperGM(`

Notice: It's A Trap v3.10

` + + `

The old It's A Trap macro has been replaced with a shorter ` + + `version named "TrapMaker". Please re-enable it on your macro ` + + `settings. By popular demand, it no longer appears as a token ` + `action.

` + + `

Please note that some of the trap menu properties have ` + + `been regrouped or condensed together in order to present a cleaner ` + + `and hopefully more intuitive interface. This should have no effect ` + + `on your existing traps. They should work just as they did before ` + + `this update.

` + + `

Please read the script's updated documentation for more ` + + `details.

`); + } + _.each(oldMacros, macro => { + macro.remove(); + }); + + // Create the 3.10 version of the macro. + let macro = findObjs({ + _type: 'macro', + name: 'TrapMaker' + })[0]; + if(!macro) { + let players = findObjs({ + _type: 'player' + }); + let gms = _.filter(players, player => { + return playerIsGM(player.get('_id')); + }); + + _.each(gms, gm => { + createObj('macro', { + _playerid: gm.get('_id'), + name: 'TrapMaker', + action: DISPLAY_WIZARD_CMD + }); + }); + } + }); + + on('chat:message', msg => { + try { + // Get the selected tokens/paths if any. + let selected; + if(msg.selected) { + selected = _.map(msg.selected, sel => { + return getObj(sel._type, sel._id); + }); + } + + if(msg.content.startsWith(DISPLAY_WIZARD_CMD)) { + if (!msg.selected || !msg.selected[0]) + throw new Error("You must have a token selected to use trap macro."); + + let trapToken = getObj('graphic', msg.selected[0]._id); + displayWizard(msg.who, msg.playerId, trapToken); + } + if(msg.content.startsWith(MODIFY_CORE_PROPERTY_CMD)) { + let params = msg.content.replace(MODIFY_CORE_PROPERTY_CMD + ' ', '').split('&&'); + modifyTrapProperty(curTrap, params, selected); + displayWizard(msg.who, msg.playerId, curTrap); + } + if(msg.content.startsWith(MODIFY_THEME_PROPERTY_CMD)) { + let params = msg.content.replace(MODIFY_THEME_PROPERTY_CMD + ' ', '').split('&&'); + let theme = ItsATrap.getTheme(); + theme.modifyTrapProperty(curTrap, params, selected); + displayWizard(msg.who, msg.playerId, curTrap); + } + } + catch (err) { + ItsATrap.Chat.error(err); + } + }); + + return { + displayWizard, + DISPLAY_WIZARD_CMD, + MODIFY_CORE_PROPERTY_CMD, + MODIFY_THEME_PROPERTY_CMD + }; +})(); + +/** + * Base class for trap themes: System-specific strategies for handling + * automation of trap activation results and passive searching. + * @abstract + */ +var TrapTheme = (() => { + + /** + * The name of the theme used to register it. + * @type {string} + */ + return class TrapTheme { + + /** + * A sample CSS object for trap HTML messages created with HTML Builder. + */ + static get css() { + return { + 'bold': { + 'font-weight': 'bold' + }, + 'critFail': { + 'border': '2px solid #B31515' + }, + 'critSuccess': { + 'border': '2px solid #3FB315' + }, + 'hit': { + 'color': '#f00', + 'font-weight': 'bold' + }, + 'miss': { + 'color': '#620', + 'font-weight': 'bold' + }, + 'paddedRow': { + 'padding': '1px 1em' + }, + 'rollResult': { + 'background-color': '#FEF68E', + 'cursor': 'help', + 'font-size': '1.1em', + 'font-weight': 'bold', + 'padding': '0 3px' + }, + 'trapMessage': { + 'background-color': '#ccc', + 'font-style': 'italic' + }, + 'trapTable': { + 'background-color': '#fff', + 'border': 'solid 1px #000', + 'border-collapse': 'separate', + 'border-radius': '10px', + 'overflow': 'hidden', + 'width': '100%' + }, + 'trapTableHead': { + 'background-color': '#000', + 'color': '#fff', + 'font-weight': 'bold' + } + }; + } + + get name() { + throw new Error('Not implemented.'); + } + + /** + * Activates a TrapEffect by displaying the trap's message and + * automating any system specific trap mechanics for it. + * @abstract + * @param {TrapEffect} effect + */ + activateEffect(effect) { + throw new Error('Not implemented.'); + } + + /** + * Gets a list of a trap's theme-specific configured properties. + * @param {Graphic} trap + * @return {TrapProperty[]} + */ + getThemeProperties(trap) { + return []; + } + + /** + * Displays the message to notice a trap. + * @param {Character} character + * @param {Graphic} trap + */ + static htmlNoticeTrap(character, trap) { + let content = new HtmlBuilder(); + let effect = new TrapEffect(trap, character); + + content.append('.paddedRow trapMessage', character.get('name') + ' notices a ' + (effect.type || 'trap') + ':'); + if (effect.detectMessage) + content.append('.paddedRow', effect.detectMessage); + else + content.append('.paddedRow', trap.get('name')); + + return TrapTheme.htmlTable(content, '#000', effect); + } + + /** + * Sends an HTML-stylized message about a noticed trap. + * @param {(HtmlBuilder|string)} content + * @param {string} borderColor + * @param {TrapEffect} [effect] + * @return {HtmlBuilder} + */ + static htmlTable(content, borderColor, effect) { + let type = (effect && effect.type) || 'trap'; + + let table = new HtmlBuilder('table.trapTable', '', { + style: { 'border-color': borderColor } + }); + table.append('thead.trapTableHead', '', { + style: { 'background-color': borderColor } + }).append('th', 'IT\'S A ' + type.toUpperCase() + '!!!'); + + table.append('tbody').append('tr').append('td', content, { + style: { 'padding': '0' } + }); + return table; + } + + /** + * Changes a theme-specific property for a trap. + * @param {Graphic} trapToken + * @param {Array} params + */ + modifyTrapProperty(trapToken, argv) { + // Default implementation: Do nothing. + } + + /** + * The system-specific behavior for a character passively noticing a trap. + * @abstract + * @param {Graphic} trap + * The trap's token. + * @param {Graphic} charToken + * The character's token. + */ + passiveSearch(trap, charToken) { + throw new Error('Not implemented.'); + } + }; +})(); + +/** + * A base class for trap themes using the D20 system (D&D 3.5, 4E, 5E, Pathfinder, etc.) + * @abstract + */ +var D20TrapTheme = (() => { + + return class D20TrapTheme extends TrapTheme { + + /** + * @inheritdoc + */ + activateEffect(effect) { + let character = getObj('character', effect.victim.get('represents')); + let effectResults = effect.json; + + // Automate trap attack/save mechanics. + Promise.resolve() + .then(() => { + effectResults.character = character; + if(character) { + if(effectResults.attack) + return this._doTrapAttack(character, effectResults); + else if(effectResults.save && effectResults.saveDC) + return this._doTrapSave(character, effectResults); + } + return effectResults; + }) + .then(effectResults => { + let html = D20TrapTheme.htmlTrapActivation(effectResults); + effect.announce(html.toString(TrapTheme.css)); + }) + .catch(err => { + ItsATrap.Chat.error(err); + }); + } + + /** + * Does a trap's attack roll. + * @private + */ + _doTrapAttack(character, effectResults) { + return Promise.all([ + this.getAC(character), + CharSheetUtils.rollAsync('1d20 + ' + effectResults.attack) + ]) + .then(tuple => { + let ac = tuple[0]; + let atkRoll = tuple[1]; + + ac = ac || 10; + effectResults.ac = ac; + effectResults.roll = atkRoll; + effectResults.trapHit = atkRoll.total >= ac; + return effectResults; + }); + } + + /** + * Does a trap's save. + * @private + */ + _doTrapSave(character, effectResults) { + return this.getSaveBonus(character, effectResults.save) + .then(saveBonus => { + saveBonus = saveBonus || 0; + effectResults.saveBonus = saveBonus; + return CharSheetUtils.rollAsync('1d20 + ' + saveBonus); + }) + .then((saveRoll) => { + effectResults.roll = saveRoll; + effectResults.trapHit = saveRoll.total < effectResults.saveDC; + return effectResults; + }); + } + + /** + * Gets a character's AC. + * @abstract + * @param {Character} character + * @return {Promise} + */ + getAC(character) { + throw new Error('Not implemented.'); + } + + /** + * Gets a character's passive wisdom (Perception). + * @abstract + * @param {Character} character + * @return {Promise} + */ + getPassivePerception(character) { + throw new Error('Not implemented.'); + } + + /** + * Gets a character's saving throw bonus. + * @abstract + * @param {Character} character + * @return {Promise} + */ + getSaveBonus(character, saveName) { + throw new Error('Not implemented.'); + } + + /** + * @inheritdoc + */ + getThemeProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let LPAREN = '('; + let RPAREN = ')'; + + let LBRACE = '['; + let RBRACE = ']'; + + return [ + { + id: 'attack', + name: 'Attack Bonus', + desc: `The trap's attack roll bonus vs AC.`, + value: trapEffect.attack || '-' + }, + { + id: 'damage', + name: 'Damage', + desc: `The dice roll expression for the trap's damage.`, + value: trapEffect.damage || '-' + }, + { + id: 'missHalf', + name: 'Miss - Half Damage', + desc: 'Does the trap deal half damage on a miss?', + value: trapEffect.missHalf ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'save', + name: 'Saving Throw', + desc: `The trap's saving throw.`, + value: (() => { + let gmOnly = trapEffect.hideSave ? '(hide results)' : ''; + if (trapEffect.save) + return `${trapEffect.save} save DC ${trapEffect.saveDC}` + + `${trapEffect.hideSave ? ' (hide results)' : ''}`; + else + return 'none'; + })(), + properties: [ + { + id: 'save', + name: 'Saving Throw', + desc: 'Which saving throw does the trap use?', + options: [ 'none', 'str', 'dex', 'con', 'int', 'wis', 'cha' ] + }, + { + id: 'dc', + name: 'Save DC', + desc: 'What is the DC for the saving throw?' + }, + { + id: 'hideSave', + name: 'Hide Save Result', + desc: 'Show the Saving Throw result only to the GM?', + options: ['no', 'yes'] + } + ] + }, + { + id: 'spotDC', + name: 'Passive Perception DC', + desc: 'The passive skill check DC to detect the trap.', + value: trapEffect.spotDC || '-' + } + ]; + } + + /** + * Produces HTML for a faked inline roll result for d20 systems. + * @param {int} result + * @param {string} tooltip + * @return {HtmlBuilder} + */ + static htmlRollResult(result, tooltip) { + let d20 = result.rolls[0].results[0].v; + + let clazzes = ['rollResult']; + if(d20 === 20) + clazzes.push('critSuccess'); + if(d20 === 1) + clazzes.push('critFail'); + return new HtmlBuilder('span.' + clazzes.join(' '), result.total, { + title: tooltip + }); + } + + /** + * Produces the HTML for a trap activation message for most d20 systems. + * @param {object} effectResults + * @return {HtmlBuilder} + */ + static htmlTrapActivation(effectResults) { + let content = new HtmlBuilder('div'); + + // Add the flavor message. + content.append('.paddedRow trapMessage', effectResults.message); + + if(effectResults.character) { + var row = content.append('.paddedRow'); + row.append('span.bold', 'Target:'); + row.append('span', effectResults.character.get('name')); + + var hasHitResult = false; + + // Add the attack roll message. + if(effectResults.attack) { + let rollResult = D20TrapTheme.htmlRollResult(effectResults.roll, '1d20 + ' + effectResults.attack); + content.append('.paddedRow') + .append('span.bold', 'Attack roll:') + .append('span', rollResult) + .append('span', ' vs AC ' + effectResults.ac); + hasHitResult = true; + } + + // Add the saving throw message. + if(effectResults.save) { + if (!effectResults.saveDC) + throw new Error(`You forgot to set the trap's save DC!`); + + let rollResult = D20TrapTheme.htmlRollResult(effectResults.roll, '1d20 + ' + effectResults.saveBonus); + let saveMsg = new HtmlBuilder('.paddedRow'); + saveMsg.append('span.bold', effectResults.save.toUpperCase() + ' save:'); + saveMsg.append('span', rollResult); + saveMsg.append('span', ' vs DC ' + effectResults.saveDC); + + // If the save result is a secret, whisper it to the GM. + if(effectResults.hideSave) + ItsATrap.Chat.whisperGM(saveMsg.toString(TrapTheme.css)); + else + content.append(saveMsg); + + hasHitResult = true; + } + + if (hasHitResult) { + // Add the hit/miss message. + if(effectResults.trapHit === 'AC unknown') { + content.append('.paddedRow', 'AC could not be determined with the current version of your character sheet. For the time being, please resolve the attack against AC manually.'); + if(effectResults.damage) + content.append('.paddedRow', 'Damage: [[' + effectResults.damage + ']]'); + } + else if(effectResults.trapHit) { + let row = content.append('.paddedRow'); + row.append('span.hit', 'HIT! '); + if(effectResults.damage) + row.append('span', 'Damage: [[' + effectResults.damage + ']]'); + else + row.append('span', 'You fall prey to the ' + (effectResults.type || 'trap') + '\'s effects!'); + } + else { + let row = content.append('.paddedRow'); + row.append('span.miss', 'MISS! '); + if(effectResults.damage && effectResults.missHalf) + row.append('span', 'Half damage: [[floor((' + effectResults.damage + ')/2)]].'); + } + } + } + + return TrapTheme.htmlTable(content, '#a22', effectResults); + } + + /** + * @inheritdoc + */ + modifyTrapProperty(trapToken, argv) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let prop = argv[0]; + let params = argv.slice(1); + + if(prop === 'attack') { + trapEffect.attack = parseInt(params[0]); + trapEffect.save = undefined; + trapEffect.saveDC = undefined; + } + if(prop === 'damage') + trapEffect.damage = params[0]; + if(prop === 'missHalf') + trapEffect.missHalf = params[0] === 'yes'; + if(prop === 'save') { + trapEffect.save = params[0] === 'none' ? undefined : params[0]; + trapEffect.saveDC = parseInt(params[1]) || 0; + trapEffect.hideSave = params[2] === 'yes'; + trapEffect.attack = undefined; + } + if(prop === 'spotDC') + trapEffect.spotDC = parseInt(params[0]); + + trapToken.set('gmnotes', JSON.stringify(trapEffect)); + } + + /** + * @inheritdoc + */ + passiveSearch(trap, charToken) { + let effect = (new TrapEffect(trap, charToken)).json; + let character = getObj('character', charToken.get('represents')); + + // Only do passive search for traps that have a spotDC. + if(effect.spotDC && character) { + + // If the character's passive perception beats the spot DC, then + // display a message and mark the trap's trigger area. + return this.getPassivePerception(character) + .then(passWis => { + if(passWis >= effect.spotDC) { + let html = TrapTheme.htmlNoticeTrap(character, trap); + ItsATrap.noticeTrap(trap, html.toString(TrapTheme.css)); + } + }) + .catch(err => { + ItsATrap.Chat.error(err); + }); + } + } + }; +})(); + +/** + * Base class for TrapThemes using D&D 4E-ish rules. + * @abstract + */ +var D20TrapTheme4E = (() => { + + return class D20TrapTheme4E extends D20TrapTheme { + + /** + * @inheritdoc + */ + activateEffect(effect) { + let character = getObj('character', effect.victim.get('represents')); + let effectResult = effect.json; + + Promise.resolve() + .then(() => { + effectResult.character = character; + + // Automate trap attack mechanics. + if(character && effectResult.defense && effectResult.attack) { + return Promise.all([ + this.getDefense(character, effectResult.defense), + CharSheetUtils.rollAsync('1d20 + ' + effectResult.attack) + ]) + .then(tuple => { + let defenseValue = tuple[0]; + let attackRoll = tuple[1]; + + defenseValue = defenseValue || 0; + effectResult.defenseValue = defenseValue; + effectResult.roll = attackRoll; + effectResult.trapHit = attackRoll.total >= defenseValue; + return effectResult; + }); + } + return effectResult; + }) + .then(effectResult => { + let html = D20TrapTheme4E.htmlTrapActivation(effectResult); + effect.announce(html.toString(TrapTheme.css)); + }) + .catch(err => { + ItsATrap.Chat.error(err); + }); + } + + /** + * Gets the value for one of a character's defenses. + * @param {Character} character + * @param {string} defenseName + * @return {Promise} + */ + getDefense(character, defenseName) { + throw new Error('Not implemented.'); + } + + /** + * @inheritdoc + */ + getThemeProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + return [ + { + id: 'attack', + name: 'Attack Roll', + desc: `The trap's attack roll bonus vs AC.`, + value: (() => { + let atkBonus = trapEffect.attack; + let atkVs = trapEffect.defense; + + if (atkVs) + return `+${atkBonus} vs ${atkVs}`; + else + return 'none'; + })(), + properties: [ + { + id: 'bonus', + name: 'Attack Bonus', + desc: 'What is the attack roll modifier?' + }, + { + id: 'vs', + name: 'Defense', + desc: 'What defense does the attack target?', + options: ['ac', 'fort', 'ref', 'will'] + } + ] + }, + { + id: 'damage', + name: 'Damage', + desc: `The dice roll expression for the trap's damage.`, + value: trapEffect.damage + }, + { + id: 'missHalf', + name: 'Miss - Half Damage', + desc: 'Does the trap deal half damage on a miss?', + value: trapEffect.missHalf ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'spotDC', + name: 'Perception DC', + desc: 'The skill check DC to spot the trap.', + value: trapEffect.spotDC + } + ]; + } + + /** + * Creates the HTML for an activated trap's result. + * @param {object} effectResult + * @return {HtmlBuilder} + */ + static htmlTrapActivation(effectResult) { + let content = new HtmlBuilder('div'); + + // Add the flavor message. + content.append('.paddedRow trapMessage', effectResult.message); + + if(effectResult.character) { + + // Add the attack roll message. + if(_.isNumber(effectResult.attack)) { + let rollHtml = D20TrapTheme.htmlRollResult(effectResult.roll, '1d20 + ' + effectResult.attack); + let row = content.append('.paddedRow'); + row.append('span.bold', 'Attack roll: '); + row.append('span', rollHtml + ' vs ' + effectResult.defense + ' ' + effectResult.defenseValue); + } + + // Add the hit/miss message. + if(effectResult.trapHit) { + let row = content.append('.paddedRow'); + row.append('span.hit', 'HIT! '); + if(effectResult.damage) + row.append('span', 'Damage: [[' + effectResult.damage + ']]'); + else + row.append('span', effectResult.character.get('name') + ' falls prey to the trap\'s effects!'); + } + else { + let row = content.append('.paddedRow'); + row.append('span.miss', 'MISS! '); + if(effectResult.damage && effectResult.missHalf) + row.append('span', 'Half damage: [[floor((' + effectResult.damage + ')/2)]].'); + } + } + + return TrapTheme.htmlTable(content, '#a22', effectResult); + } + + /** + * @inheritdoc + */ + modifyTrapProperty(trapToken, argv) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let prop = argv[0]; + let params = argv.slice(1); + + if(prop === 'attack') { + let bonus = parseInt(params[0]); + let defense = params[1]; + + if (!bonus && bonus !== 0) { + trapEffect.attack = undefined; + trapEffect.defense = undefined; + } + else { + trapEffect.attack = bonus; + trapEffect.defense = defense; + } + } + if(prop === 'damage') + trapEffect.damage = params[0]; + if(prop === 'missHalf') + trapEffect.missHalf = params[0] === 'yes'; + if(prop === 'spotDC') + trapEffect.spotDC = parseInt(params[0]); + + trapToken.set('gmnotes', JSON.stringify(trapEffect)); + } + }; +})(); + +/** + * The default system-agnostic Admiral Ackbar theme. + * @implements TrapTheme + */ +(() => { + + class DefaultTheme { + + /** + * @inheritdoc + */ + get name() { + return 'default'; + } + + /** + * @inheritdoc + */ + activateEffect(effect) { + let content = new HtmlBuilder('div'); + + var row = content.append('.paddedRow'); + if(effect.victim) { + row.append('span.bold', 'Target:'); + row.append('span', effect.victim.get('name')); + } + + content.append('.paddedRow', effect.message); + + let table = TrapTheme.htmlTable(content, '#a22', effect); + let tableView = table.toString(TrapTheme.css); + effect.announce(tableView); + } + + /** + * @inheritdoc + */ + passiveSearch(trap, charToken) { + // Do nothing. + } + } + + ItsATrap.registerTheme(new DefaultTheme()); +})(); + +ItsATrap.Chat = (() => { + + /** + * Broadcasts a message spoken by the script's configured announcer. + * This message is visible to everyone. + */ + function broadcast(msg) { + let announcer = getAnnouncer(); + sendChat(announcer, msg); + } + + /** + * Log an error and its stack trace and alert the GM about it. + * @param {Error} err The error. + */ + function error(err) { + whisperGM(err.message + "
Check API console logs for details."); + log(err.stack); + } + + /** + * Get the name of the script's announcer (for users who don't like + * Admiral Ackbar). + * @return {string} + */ + function getAnnouncer() { + return state.ItsATrap.userOptions.announcer || 'Admiral Ackbar'; + } + + /** + * Whisper a message from the API to the GM. + * @param {string} msg The message to be whispered to the GM. + */ + function whisperGM(msg) { + sendChat('Its A Trap! script', '/w gm ' + msg); + } + + return { + broadcast, + error, + getAnnouncer, + whisperGM + }; +})(); + +{try{throw new Error('');}catch(e){API_Meta.ItsATrap.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.ItsATrap.offset);}} diff --git a/Its A Trap/script.json b/Its A Trap/script.json index c58ea3ee82..188b219769 100644 --- a/Its A Trap/script.json +++ b/Its A Trap/script.json @@ -1,8 +1,8 @@ { "name": "It's a Trap!", "script": "ItsATrap.js", - "version": "3.13.1", - "previousversions": ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "3.5.2", "3.6", "3.7.4", "3.8", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10", "3.10.1", "3.11", "3.12", "3.13"], + "version": "3.13.2", + "previousversions": ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "3.5.2", "3.6", "3.7.4", "3.8", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10", "3.10.1", "3.11", "3.12", "3.13", "3.13.1"], "description": "# It's A Trap!\r\r_v3.13 Updates_\r\r* Can specify 'none' for trap trigger areas.\r* Can specify message for when a character notices a trap via passive detection using the 'Detection Message' property.\r\rThis is a script that allows GMs to quickly and very easily set up traps,\rsecret doors, and other hidden things on the GM layer, and detect when tokens\ron the objects layer move over them. This trap detection even works for tokens\rmoving by waypoints.\r\rCombined with modules called Trap Themes, this script also allows system-specific\rautomation of trap effects and passive perception used to spot them.\r\r## Trap Maker menu\rWhen this script is installed, it installs a macro called **TrapMaker**. When you\rselect a token that you want to set up as a trap and click this macro, it\rdisplays a **Trap Configuration** menu in the VTT's chat, from which you can\rmodify the trap's various properties (discussed below).\r\rWhen you use this menu on a token for the first time, it will be moved\rto the **GM layer** and it will be given the **cobweb** status marker. The script\ruses these properties to identify which tokens are active as traps.\r\rThe GM notes section of the trap's token will be used to hold the JSON data for\rthe trap's properties. Please do not edit the GM notes for a trap token\rmanually.\r\r### Enabling the menu macro\rThis macro is not added to your macro bar automatically, so you'll need to\rcheck the **In Bar** checkbox next to the **TrapMaker** macro to activate it.\r\r## Trap properties\rThe following subsections go into detail about each of the properties that can\rbe set and modified for a trap.\r\r### Core properties\rThese are the basic properties of the trap.\r\r#### Name\rThis is the name of the trap.\r\re.g. _'pit trap'_ or _'explosive runes'_\r\r#### Type\rThe It's A Trap! script, contrary to its name, can be used to automate more kinds of\rhidden objects than just traps. By default, the value of this property will just be\r_'trap'_, but you could define it to be something like _'hazard'_, _'hidden enemy'_,\ror _'concealed item'_ instead. This type will appear in the trap's header when\rit is activated.\r\rE.g., the header will read 'IT'S A TRAP!!!' if the type is 'trap', or\r'IT'S A HAZARD!!!' if the type is 'hazard'\r\rThis property otherwise has no mechanical effect.\r\r#### Message\rThis message will be displayed when the trap is activated.\r\re.g. _'You feel a floor sink under your feet as you step on a hidden pressure plate. Behind you, you hear the violent grinding of stone against stone, getting closer. A massive rolling boulder is heading this way!'_\r\r#### Disabled?\rThis sets whether the trap is currently disabled or not. A trap that is disabled\rcannot be triggered.\r\r#### Show GM Only\rThis sets whether the trap's activation will only be shared with the GM. If this\ris set to **yes**, the trap's GM Notes and its theme-based results will be\rshared only with the GM. Visible effects of a trap, such as the its message,\rsound, Areas of Effect, etc. will be ignored, and the trap will not be revealed.\r\rThis property is best used for traps, hazards, and alarms whose effects will\rnot be readily apparent when they are activated.\r\re.g. This could be set to 'yes' for a tripwire that alerts monsters in\ranother room to the party's presence.\r\r#### Secret Notes\rThese notes are whispered to the GM when the trap is activated. These notes won't be shown to any of the other players.\r\re.g. _'The tripwire sets off a silent alarm, alerting the mindflayers in the laboratory room to the party's presence.'_\r\r### Trigger properties\rThese properties all have to do with how the trap is triggered.\r\r#### Trigger Area\rThis defines the area that a character must move through in order to trigger the\rtrap. Options include:\r\r* **self - rectangle**: The trap's own token is used as the trigger area, which is treated as a rectangular shape.\r* **self - circle**: The trap's own token is used as the trigger area, which is treated as a circular shape.\r* **set selected lines**: You must have one or more lines selected on the VTT to use this option. Those lines will be used as the trigger area for the trap.\r* **none**: The trap has no trigger area, thus it cannot be triggered. Use this for things like secret doors, which shouldn't activate, but should be noticeable with passive detection.\r\r#### Trigger Collision\rThis property defines how character tokens collide with the trap's trigger area. Options include:\r\r* **center**: When a character crosses the trap's trigger area, they are moved to the trap token's center. This option only works for traps whose trigger area is *self*.\r* **edge**: When a character crosses the trap's trigger area, their movement is stopped at the trigger area's edge.\r* **none**: Character tokens are not stopped when they move through the trap's trigger area.\r\rThis property is ignored if the Delay Activation property is set.\r\r#### Ignore Token IDs\rThis property is used to select one or more creature tokens that will not be affected by a trap. Neither can these tokens trigger the trap.\r\r* **none**: No ignored tokens.\r* **set selected tokens**: To use this option, you must have one or more tokens selected. These tokens will be ignored by the trap.\r\r#### Affects Flying Tokens\rBy default, traps will only affect tokens that are not flying. Tokens are treated as 'flying' by this script if they have the **fluffy wing** status marker active.\r\rIf this property is set to **yes**, it will affect all tokens, regardless of whether\ror not they have the **fluffy wing** status marker active.\r\rLeave this set to **no** for floor-based traps. For traps that affect creatures on\rthe ground and in the air alike, set this to **yes**.\r\r#### Delay Activation\rThis property sets a delay, in **seconds**, between when the trap is triggered to\rwhen it actually activates.\r\rAs a side-effect, the trap's trigger will be deactivated once this delay is\ractivated. This is to prevent the delayed trap from triggering multiple times.\r\r### Activation properties\rThese properties all have to do with what happens when the trap activates.\r\r#### Activation Area\rThis defines the area in which characters can be affected by the trap when it activates. Options include:\r\r* **self**: The trap's token is used as the activation area.\r* **burst**: The trap affects all characters within a certain radius of it.\r* **set selected shapes**: To use this option, you must have one or more filled shapes selected. The trap affects all characters inside those shapes.\r\r#### Burst Radius\rThis property is only visible if **Activation Area** is set to **burst**. This\rsets the radius of the burst area.\r\r#### Special FX\rThis property is used to display a particle effect when the trap activates,\rusing Roll20's special FX system.\r\rThe first prompt asks for the name of the effect that will be displayed. This can\reither be the name of a custom special effect you've created, or it can be the\rname of a built in effect. Built-in special effects follow the naming convention\r**effect-color**. e.g. _explode-fire_ or _beam-acid_\r\rSee https://wiki.roll20.net/Custom_FX#Built-in_Effects for more\rinformation on supported built-in effect and color names.\r\rThe second prompt allows you to specify an offset of the effect's origin point,\rin the format **[X,Y]**. The X,Y offset, relative to the trap's token is measured\rin squares. If this is omitted, the trap's token will be used as the effect's\rorigin point.\re.g. _[3,4]_\r\rThe third prompt allows you to specify a vector for the direction of the effect,\rin the format **[X,Y]**, with each vector component measured in squares. If this\ris omitted, the effect will be directed towards the victims' tokens.\re.g. _[0,-1]_\r\r#### Sound\rThis property sets a sound from your jukebox to be played when the trap is activated.\r\r#### Chained Trap IDs\rThis property allows you to set other traps to activate when this one does. Options include:\r\r* **none**: No other traps are activated by this trap.\r* **set selected traps**: You must have one or more other trap tokens selected to use this option. When this trap activates, the selected traps will activate too.\r\r#### Delete after Activation?\rIf this property is set to **yes**, then the trap's token will be deleted after it is activated.\r\r### Detection properties\r\r#### Max Search Distance\rThis property defines the distance at which a character can attempt to notice a\rtrap passively. If this is not set, the search distance is assumed to be infinite.\r\rDynamic lighting walls will block line of sight to a trap, even if the character\ris close enough to otherwise try to passively spot it.\r\re.g. If this is set to 10 ft, then a character must be within 10 ft of the trap in order to passively notice it.\r\r#### Detection Message\r\rBy default, when a character notices a trap via passive detection (Perception/Spot/etc.),\rthe script will just announce the name of the trap that was noticed. Use this property to specify\ra custom message to be displayed when a character notices a trap.\r\re.g. 'The air feels warm and you notice holes greased with oil lining the walls.'\r\r#### Reveal the Trap?\rThis property determines whether the trap's token will be revealed (moved to a visible layer) when it is activated and/or detected.\r\rThe first prompt asks if the trap should be revealed when it is activated (yes or no).\r\rThe second prompt asks if the trap should be revealed when it is detected (yes or no).\r\rThe third prompt asks which layer the trap token is moved to when it is detected (Just click OK or press enter if you chose **no** for both of the earlier prompts).\r\r### External script properties\rThese properties are available when you have certain other API scripts installed.\r\r#### API Command\rThis property can be used to issue an API chat command when the trap activates.\rThis property supports a couple keywords to support commands involving the trap\rand its victims.\r\rThe keyword TRAP_ID will be substituted for the ID of the trap's token.\r\rThe keyword VICTIM_ID will be substituted for the ID of token for some character\rbeing affected by the trap. If there are multiple victims affected by the trap,\rthe command will be issued individually for each victim.\r\rThe keyword VICTIM_CHAR_ID will be substituted for the ID of the character being\raffected by the trap.\r\re.g. _'!someApiCommand TRAP_ID VICTIM_ID VICTIM_CHAR_NAME'_\r\rFor some API commands using special characters, you'll need to escape those\rcharacters by prefixing them with a \\ (backslash). These special characters\rinclude: [, ], {, }, and @.\r\r#### Areas of Effect script\rThis property is only available if you have the **Areas of Effect** script installed.\rIt also requires you to have at least one effect saved in that script.\rThis allows you to have the trap spawn an area of effect graphic when it is triggered.\r\rThe first prompt will ask you to choose an area of effect chosen from\rthose saved in the Areas of Effect script.\r\rThe second prompt will ask for a vector in the form **[dx,dy]**, indicating the\rdirection of the effect. Each component of this vector is measured in squares.\rIf this vector is omitted, the effect will be directed towards the victims' tokens.\r\r#### KABOOM script\rThis property is only available if you have the **KABOOM** script\r(by Bodin Punyaprateep (PaprikaCC)) installed. This allows you to create a\rKABOOM effect centered on the trap's token. This can be handy for pushing tokens\rback due to an explosive trap!\r\rThe prompts for the property are used to define the properties for the KABOOM effect,\ras defined in the KABOOM script's documentation.\r\r#### TokenMod script\rThis property is only available if you have the **TokenMod** script (by The Aaron)\rinstalled. This allows you to set properties on tokens affected by the trap, using\rthe API command parameters described in the TokenMod script's documentation.\r\re.g. _'--set statusmarkers|broken-shield'_\r\r## Trap Themes:\rTrap themes are special side-scripts used to provide support for formatting messages for traps and\rautomating system-specific trap activation and passive search mechanics.\r\rBy default the **default** theme will be used. This is a very basic,\rsystem-agnostic theme and has no special properties.\r\rIf you install a system-specific trap theme, It's A Trap will automatically\rdetect and use that theme instead. Additional system-specific themes are\ravailable as their own API scripts.\r\r### Theme-specific properties\rTrap themes come with new properties that are added to the Trap Maker menu.\rThis includes things such as modifiers for the trap's attacks, the trap's\rdamage, and the dice rolls needed to passively detect the trap.\r\rDocumentation for these properties are provided in the script documentation for\rthe respective trap theme.\r\r## Activating traps:\rIf a character token moves across a trap's trigger area at ANY point during its\rmovement, the trap will be activated! Traps are only active while they are\ron the GM layer. Moving it to another layer will disable it.\r\rA trap can also be manually activated by clicking the 'Activate Trap' button\rin the trap's configuration menu.\r\r## Help\r\rMy scripts are provided 'as-is', without warranty of any kind, expressed or implied.\r\rThat said, if you experience any issues while using this script,\rneed help using it, or if you have a neat suggestion for a new feature,\rplease shoot me a PM:\rhttps://app.roll20.net/users/46544/ada-l\r\rWhen messaging me about an issue, please be sure to include any error messages that\rappear in your API Console Log, any configurations you've got set up for the\rscript in the VTT, and any options you've got set up for the script on your\rgame's API Scripts page. The more information you provide me, the better the\rchances I'll be able to help.\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, maintaining, and providing tech support my API scripts,\rplease consider buying one of my art packs from the Roll20 marketplace:\r\rhttps://marketplace.roll20.net/browse/publisher/165/ada-lindberg\r", "authors": "Ada Lindberg", "roll20userid": 46544, diff --git a/PathMath/1.7/PathMath.js b/PathMath/1.7/PathMath.js new file mode 100644 index 0000000000..366dbe362e --- /dev/null +++ b/PathMath/1.7/PathMath.js @@ -0,0 +1,1657 @@ +/* globals VecMath MatrixMath */ +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.PathMath={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.PathMath.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-4);}} +API_Meta.PathMath.version = '1.7'; + + +/** + * PathMath script + * + * This is a library that provides mathematical operations involving Paths. + * It intended to be used by other scripts and has no stand-alone + * functionality of its own. All the library's operations are exposed by the + * PathMath object created by this script. + */ +const PathMath = (() => { + + /** The size of a single square on a page, in pixels. */ + const UNIT_SIZE_PX = 70; + + let isJumpgate = ()=>{ + if(['jumpgate'].includes(Campaign().get('_release'))) { + isJumpgate = () => true; + } else { + isJumpgate = () => false; + } + return isJumpgate(); + }; + + /** + * A vector used to define a homogeneous point or a direction. + * @typedef {number[]} Vector + */ + + /** + * A line segment defined by two homogeneous 2D points. + * @typedef {Vector[]} Segment + */ + + /** + * Information about a path's 2D transform. + * @typedef {Object} PathTransformInfo + * @property {number} angle + * The path's rotation angle in radians. + * @property {number} cx + * The x coordinate of the center of the path's bounding box. + * @property {number} cy + * The y coordinate of the center of the path's bounding box. + * @property {number} height + * The unscaled height of the path's bounding box. + * @property {number} scaleX + * The path's X-scale. + * @property {number} scaleY + * The path's Y-scale. + * @property {number} width + * The unscaled width of the path's bounding box. + */ + + /** + * Rendering information for shapes. + * @typedef {Object} RenderInfo + * @property {string} [controlledby] + * @property {string} [fill] + * @property {string} [stroke] + * @property {string} [strokeWidth] + */ + + /** + * Some shape defined by a path. + * @abstract + */ + class PathShape { + constructor(vertices) { + this.vertices = vertices || []; + } + + /** + * Gets the distance from this shape to some point. + * @abstract + * @param {vec3} pt + * @return {number} + */ + distanceToPoint(/* pt */) { + throw new Error('Must be defined by subclass.'); + } + + /** + * Gets the bounding box of this shape. + * @return {BoundingBox} + */ + getBoundingBox() { + if(!this._bbox) { + let left, right, top, bottom; + _.each(this.vertices, (v, i) => { + if(i === 0) { + left = v[0]; + right = v[0]; + top = v[1]; + bottom = v[1]; + } + else { + left = Math.min(left, v[0]); + right = Math.max(right, v[0]); + top = Math.min(top, v[1]); + bottom = Math.max(bottom, v[1]); + } + }); + let width = right - left; + let height = bottom - top; + this._bbox = new BoundingBox(left, top, width, height); + } + return this._bbox; + } + + /** + * Checks if this shape intersects another shape. + * @abstract + * @param {PathShape} other + * @return {boolean} + */ + intersects(/* other */) { + throw new Error('Must be defined by subclass.'); + } + + /** + * Renders this path. + * @param {string} pageId + * @param {string} layer + * @param {RenderInfo} renderInfo + * @return {Roll20.Path} + */ + render(pageId, layer, renderInfo) { + let segments = this.toSegments(); + let pathData = segmentsToPath(segments); + _.extend(pathData, renderInfo, { + _pageid: pageId, + layer + }); + return createObj(isJumpgate() ? 'pathv2' : 'path', pathData); + } + + /** + * Returns the segments that make up this shape. + * @abstract + * @return {Segment[]} + */ + toSegments() { + throw new Error('Must be defined by subclass.'); + } + + /** + * Produces a copy of this shape, transformed by an affine + * transformation matrix. + * @param {MatrixMath.Matrix} matrix + * @return {PathShape} + */ + transform(matrix) { + let vertices = _.map(this.vertices, v => { + return MatrixMath.multiply(matrix, v); + }); + let Clazz = this.constructor; + return new Clazz(vertices); + } + } + + /** + * An open shape defined by a path or list of vertices. + */ + class Path extends PathShape { + + /** + * @param {(Roll20Path|vec3[])} path + */ + constructor(path) { + super(); + if(_.isArray(path)) + this.vertices = path; + else { + this._segments = toSegments(path); + _.each(this._segments, (seg, i) => { + if(i === 0) + this.vertices.push(seg[0]); + this.vertices.push(seg[1]); + }); + } + + this.numVerts = this.vertices.length; + } + + /** + * Gets the distance from this path to some point. + * @param {vec3} pt + * @return {number} + */ + distanceToPoint(pt) { + let dist = _.chain(this.toSegments()) + .map(seg => { + let [ p, q ] = seg; + return VecMath.ptSegDist(pt, p, q); + }) + .min() + .value(); + return dist; + } + + /** + * Checks if this path intersects with another path. + * @param {Polygon} other + * @return {boolean} + */ + intersects(other) { + let thisBox = this.getBoundingBox(); + let otherBox = other.getBoundingBox(); + + // If the bounding boxes don't intersect, then the paths won't + // intersect. + if(!thisBox.intersects(otherBox)) + return false; + + // Naive approach: Since our shortcuts didn't return, check each + // path's segments for intersections with each of the other + // path's segments. This takes O(n^2) time. + return !!_.find(this.toSegments(), seg1 => { + return !!_.find(other.toSegments(), seg2 => { + return !!segmentIntersection(seg1, seg2); + }); + }); + } + + /** + * Produces a list of segments defining this path. + * @return {Segment[]} + */ + toSegments() { + if(!this._segments) { + if (this.numVerts <= 1) + return []; + + this._segments = _.map(_.range(this.numVerts - 1), i => { + let v = this.vertices[i]; + let vNext = this.vertices[i + 1]; + return [v, vNext]; + }); + } + return this._segments; + } + } + + /** + * A closed shape defined by a path or a list of vertices. + */ + class Polygon extends PathShape { + + /** + * @param {(Roll20Path|vec3[])} path + */ + constructor(path) { + super(); + if(_.isArray(path)) + this.vertices = path; + else { + this._segments = toSegments(path); + this.vertices = _.map(this._segments, seg => { + return seg[0]; + }); + } + + this.numVerts = this.vertices.length; + if(this.numVerts < 3) + throw new Error('A polygon must have at least 3 vertices.'); + } + + /** + * Determines whether a point lies inside the polygon using the + * winding algorithm. + * See: http://geomalgorithms.com/a03-_inclusion.html + * @param {vec3} p + * @return {boolean} + */ + containsPt(p) { + // A helper function that tests if a point is "left" of a line segment. + let _isLeft = (p0, p1, p2) => { + return (p1[0] - p0[0])*(p2[1] - p0[1]) - (p2[0]-p0[0])*(p1[1]-p0[1]); + }; + + let total = 0; + _.each(this.vertices, (v1, i) => { + let v2 = this.vertices[(i+1) % this.numVerts]; + + // Check for valid up intersect. + if(v1[1] <= p[1] && v2[1] > p[1]) { + if(_isLeft(v1, v2, p) > 0) + total++; + } + + // Check for valid down intersect. + else if(v1[1] > p[1] && v2[1] <= p[1]) { + if(_isLeft(v1, v2, p) < 0) + total--; + } + }); + return !!total; // We are inside if our total windings are non-zero. + } + + /** + * Gets the distance from this polygon to some point. + * @param {vec3} pt + * @return {number} + */ + distanceToPoint(pt) { + if(this.containsPt(pt)) + return 0; + else + return _.chain(this.toSegments()) + .map(seg => { + let [ p, q ] = seg; + return VecMath.ptSegDist(pt, p, q); + }) + .min() + .value(); + } + + /** + * Gets the area of this polygon. + * @return {number} + */ + getArea() { + let triangles = this.tessellate(); + return _.reduce(triangles, (area, tri) => { + return area + tri.getArea(); + }, 0); + } + + /** + * Determines whether each vertex along the polygon is convex (1) + * or concave (-1). A vertex lying on a straight line is assined 0. + * @return {int[]} + */ + getConvexness() { + return Polygon.getConvexness(this.vertices); + } + + /** + * Gets the convexness information about each vertex. + * @param {vec3[]} + * @return {int[]} + */ + static getConvexness(vertices) { + let totalAngle = 0; + let numVerts = vertices.length; + let vertexCurves = _.map(vertices, (v, i) => { + let vPrev = vertices[(i-1 + numVerts) % numVerts]; + let vNext = vertices[(i+1 + numVerts) % numVerts]; + + let u = VecMath.sub(v, vPrev); + let w = VecMath.sub(vNext, v); + let uHat = VecMath.normalize(u); + let wHat = VecMath.normalize(w); + + let cross = VecMath.cross(uHat, wHat); + let sign = cross[2]; + if(sign) + sign = sign/Math.abs(sign); + + let dot = VecMath.dot(uHat, wHat); + let angle = Math.acos(dot)*sign; + totalAngle += angle; + + return sign; + }); + + if(totalAngle < 0) + return _.map(vertexCurves, curve => { + return -curve; + }); + else + return vertexCurves; + } + + /** + * Checks if this polygon intersects with another polygon. + * @param {(Polygon|Path)} other + * @return {boolean} + */ + intersects(other) { + let thisBox = this.getBoundingBox(); + let otherBox = other.getBoundingBox(); + + // If the bounding boxes don't intersect, then the polygons won't + // intersect. + if(!thisBox.intersects(otherBox)) + return false; + + // If either polygon contains the first point of the other, then + // they intersect. + if(this.containsPt(other.vertices[0]) || + (other instanceof Polygon && other.containsPt(this.vertices[0]))) + return true; + + // Naive approach: Since our shortcuts didn't return, check each + // polygon's segments for intersections with each of the other + // polygon's segments. This takes O(n^2) time. + return !!_.find(this.toSegments(), seg1 => { + return !!_.find(other.toSegments(), seg2 => { + return !!segmentIntersection(seg1, seg2); + }); + }); + } + + /** + * Checks if this polygon intersects a Path. + * @param {Path} path + * @return {boolean} + */ + intersectsPath(path) { + let segments1 = this.toSegments(); + let segments2 = PathMath.toSegments(path); + + // The path intersects if any point is inside this polygon. + if(this.containsPt(segments2[0][0])) + return true; + + // Check if any of the segments intersect. + return !!_.find(segments1, seg1 => { + return _.find(segments2, seg2 => { + return PathMath.segmentIntersection(seg1, seg2); + }); + }); + } + + /** + * Tessellates a closed path representing a simple polygon + * into a bunch of triangles. + * @return {Triangle[]} + */ + tessellate() { + let triangles = []; + let vertices = _.clone(this.vertices); + + // Tessellate using ear-clipping algorithm. + while(vertices.length > 0) { + if(vertices.length === 3) { + triangles.push(new Triangle(vertices[0], vertices[1], vertices[2])); + vertices = []; + } + else { + // Determine whether each vertex is convex, concave, or linear. + let convexness = Polygon.getConvexness(vertices); + let numVerts = vertices.length; + + // Find the next ear to clip from the polygon. + let earIndex = _.find(_.range(numVerts), i => { + let v = vertices[i]; + let vPrev = vertices[(numVerts + i -1) % numVerts]; + let vNext = vertices[(numVerts + i + 1) % numVerts]; + + let vConvexness = convexness[i]; + if(vConvexness === 0) // The vertex lies on a straight line. Clip it. + return true; + else if(vConvexness < 0) // The vertex is concave. + return false; + else { // The vertex is convex and might be an ear. + let triangle = new Triangle(vPrev, v, vNext); + + // The vertex is not an ear if there is at least one other + // vertex inside its triangle. + return !_.find(vertices, (v2) => { + if(v2 === v || v2 === vPrev || v2 === vNext) + return false; + else { + return triangle.containsPt(v2); + } + }); + } + }); + + let v = vertices[earIndex]; + let vPrev = vertices[(numVerts + earIndex -1) % numVerts]; + let vNext = vertices[(numVerts + earIndex + 1) % numVerts]; + triangles.push(new Triangle(vPrev, v, vNext)); + vertices.splice(earIndex, 1); + } + } + return triangles; + } + + /** + * Produces a list of segments defining this polygon. + * @return {Segment[]} + */ + toSegments() { + if(!this._segments) { + this._segments = _.map(this.vertices, (v, i) => { + let vNext = this.vertices[(i + 1) % this.numVerts]; + return [v, vNext]; + }); + } + return this._segments; + } + } + + /** + * A 3-sided polygon that is great for tessellation! + */ + class Triangle extends Polygon { + /** + * @param {vec3} p1 + * @param {vec3} p2 + * @param {vec3} p3 + */ + constructor(p1, p2, p3) { + if(_.isArray(p1)) + [p1, p2, p3] = p1; + super([p1, p2, p3]); + + this.p1 = p1; + this.p2 = p2; + this.p3 = p3; + } + + /** + * @inheritdoc + */ + getArea() { + let base = VecMath.sub(this.p2, this.p1); + let width = VecMath.length(base); + let height = VecMath.ptLineDist(this.p3, this.p1, this.p2); + + return width*height/2; + } + } + + /** + * A circle defined by its center point and radius. + */ + class Circle extends PathShape { + + /** + * @param {vec3} pt + * @param {number} r + */ + constructor(pt, r) { + super(); + this.center = pt; + this.radius = r; + this.diameter = 2*r; + } + + /** + * Checks if a point is contained within this circle. + * @param {vec3} pt + * @return {boolean} + */ + containsPt(pt) { + let dist = VecMath.dist(this.center, pt); + return dist <= this.radius; + } + + /** + * Gets the distance from this circle to some point. + * @param {vec3} pt + * @return {number} + */ + distanceToPoint(pt) { + if(this.containsPt(pt)) + return 0; + else { + return VecMath.dist(this.center, pt) - this.radius; + } + } + + /** + * Gets this circle's area. + * @return {number} + */ + getArea() { + return Math.PI*this.radius*this.radius; + } + + /** + * Gets the circle's bounding box. + * @return {BoundingBox} + */ + getBoundingBox() { + let left = this.center[0] - this.radius; + let top = this.center[1] - this.radius; + let dia = this.radius*2; + return new BoundingBox(left, top, dia, dia); + } + + /** + * Gets this circle's circumference. + * @return {number} + */ + getCircumference() { + return Math.PI*this.diameter; + } + + /** + * Checks if this circle intersects another circle. + * @param {Circle} other + * @return {boolean} + */ + intersects(other) { + let dist = VecMath.dist(this.center, other.center); + return dist <= this.radius + other.radius; + } + + /** + * Checks if this circle intersects a polygon. + * @param {Polygon} poly + * @return {boolean} + */ + intersectsPolygon(poly) { + + // Quit early if the bounding boxes don't overlap. + let thisBox = this.getBoundingBox(); + let polyBox = poly.getBoundingBox(); + if(!thisBox.intersects(polyBox)) + return false; + + if(poly.containsPt(this.center)) + return true; + return !!_.find(poly.toSegments(), seg => { + return this.segmentIntersection(seg); + }); + } + + /** + * Renders this circle. + * @param {string} pageId + * @param {string} layer + * @param {RenderInfo} renderInfo + */ + render(pageId, layer, renderInfo) { + let data; + if(isJumpgate()){ + data = { + shape: 'eli', + x: this.center[0], + y: this.center[1], + points: `[[0,0],[${this.diameter*(renderInfo.scaleX??1)},${this.diameter*(renderInfo.scaleY??1)}]]` + }; + } else { + data = createCircleData(this.radius); + data.left = this.center[0]; + data.top = this.center[1]; + } + _.extend(data, renderInfo, { + _pageid: pageId, + layer, + left: this.center[0], + top: this.center[1] + }); + return createObj(isJumpgate() ? 'pathv2' : 'path', data); + } + + /** + * Gets the intersection coefficient between this circle and a Segment, + * if such an intersection exists. Otherwise, undefined is returned. + * @param {Segment} segment + * @return {Intersection} + */ + segmentIntersection(segment) { + if(this.containsPt(segment[0])) { + let pt = segment[0]; + let s = 0; + let t = VecMath.dist(this.center, segment[0])/this.radius; + return [pt, s, t]; + } + else { + let u = VecMath.sub(segment[1], segment[0]); + let uHat = VecMath.normalize(u); + let uLen = VecMath.length(u); + let v = VecMath.sub(this.center, segment[0]); + + let height = VecMath.ptLineDist(this.center, segment[0], segment[1]); + let base = Math.sqrt(this.radius*this.radius - height*height); + + if(isNaN(base)) + return undefined; + + let scalar = VecMath.scalarProjection(u, v)-base; + let s = scalar/uLen; + + if(s >= 0 && s <= 1) { + let t = 1; + let pt = VecMath.add(segment[0], VecMath.scale(uHat, scalar)); + return [pt, s, t]; + } + else + return undefined; + } + } + } + + /** + * The bounding box for a path/polygon. + */ + class BoundingBox { + /** + * @param {Number} left + * @param {Number} top + * @param {Number} width + * @param {Number} height + */ + constructor(left, top, width, height) { + this.left = left; + this.top = top; + this.width = width; + this.height = height; + this.right = left + width; + this.bottom = top + height; + } + + /** + * Adds two bounding boxes. + * @param {BoundingBox} a + * @param {BoundingBox} b + * @return {BoundingBox} + */ + static add(a, b) { + let left = Math.min(a.left, b.left); + let top = Math.min(a.top, b.top); + let right = Math.max(a.left + a.width, b.left + b.width); + let bottom = Math.max(a.top + a.height, b.top + b.height); + + return new BoundingBox(left, top, right - left, bottom - top); + } + + /** + * Gets the area of this bounding box. + * @return {number} + */ + getArea() { + return this.width * this.height; + } + + /** + * Checks if this bounding box intersects another bounding box. + * @param {BoundingBox} other + * @return {boolean} + */ + intersects(other) { + return !( this.left > other.right || + this.right < other.left || + this.top > other.bottom || + this.bottom < other.top); + } + + /** + * Renders the bounding box. + * @param {string} pageId + * @param {string} layer + * @param {RenderInfo} renderInfo + */ + render(pageId, layer, renderInfo) { + let verts = [ + [this.left, this.top, 1], + [this.right, this.top, 1], + [this.right, this.bottom, 1], + [this.left, this.bottom, 1] + ]; + let poly = new Polygon(verts); + poly.render(pageId, layer, renderInfo); + } + } + + /** + * Returns the partial path data for creating a circular path. + * @param {number} radius + * @param {int} [sides] + * If specified, then a polygonal path with the specified number of + * sides approximating the circle will be created instead of a true + * circle. + * @return {PathData} + */ + function createCircleData(radius, sides) { + let _path = []; + if(sides) { + let cx = radius; + let cy = radius; + let angleInc = Math.PI*2/sides; + _path.push(['M', cx + radius, cy]); + _.each(_.range(1, sides+1), function(i) { + let angle = angleInc*i; + let x = cx + radius*Math.cos(angle); + let y = cy + radius*Math.sin(angle); + _path.push(['L', x, y]); + }); + } + else { + let r = radius; + _path = [ + ['M', 0, r], + ['C', 0, r*0.5, r*0.5, 0, r, 0], + ['C', r*1.5, 0, r*2, r*0.5, r*2.0, r], + ['C', r*2.0, r*1.5, r*1.5, r*2.0, r, r*2.0], + ['C', r*0.5, r*2, 0, r*1.5, 0, r] + ]; + } + return { + height: radius*2, + _path: JSON.stringify(_path), + width: radius*2 + }; + } + + /** + * Computes the distance from a point to some path. + * @param {vec3} pt + * @param {(Roll20Path|PathShape)} path + */ + function distanceToPoint(pt, path) { + if(!(path instanceof PathShape)) + path = new Path(path); + return path.distanceToPoint(pt); + } + + /** + * Gets a point along some Bezier curve of arbitrary degree. + * @param {vec3[]} points + * The points of the Bezier curve. The points between the first and + * last point are the control points. + * @param {number} scalar + * The parametric value for the point we want along the curve. + * This value is expected to be in the range [0, 1]. + * @return {vec3} + */ + function getBezierPoint(points, scalar) { + if(points.length < 2) + throw new Error('Bezier curve cannot have less than 2 points.'); + else if(points.length === 2) { + let u = VecMath.sub(points[1], points[0]); + u = VecMath.scale(u, scalar); + return VecMath.add(points[0], u); + } + else { + let newPts = _.chain(points) + .map((cur, i) => { + if(i === 0) + return undefined; + + let prev = points[i-1]; + return getBezierPoint([prev, cur], scalar); + }) + .compact() + .value(); + + return getBezierPoint(newPts, scalar); + } + } + + + /** + * Calculates the bounding box for a list of paths. + * @param {Roll20Path | Roll20Path[]} paths + * @return {BoundingBox} + */ + function getBoundingBox(paths) { + if(!_.isArray(paths)) + paths = [paths]; + + let result; + _.each(paths, function(p) { + let pBox = _getSingleBoundingBox(p); + if(result) + result = BoundingBox.add(result, pBox); + else + result = pBox; + }); + return result; + } + + /** + * Returns the center of the bounding box countaining a path or list + * of paths. The center is returned as a 2D homongeneous point + * (It has a third component which is always 1 which is helpful for + * affine transformations). + * @param {(Roll20Path|Roll20Path[])} paths + * @return {Vector} + */ + function getCenter(paths) { + if(!_.isArray(paths)) + paths = [paths]; + + let bbox = getBoundingBox(paths); + let cx = bbox.left + bbox.width/2; + let cy = bbox.top + bbox.height/2; + + return [cx, cy, 1]; + } + + /** + * @private + * Calculates the bounding box for a single path. + * @param {Roll20Path} path + * @return {BoundingBox} + */ + function _getSingleBoundingBox(path) { + let pathData = normalizePath(path); + + let width = pathData.width; + let height = pathData.height; + let left = pathData.left - width/2; + let top = pathData.top - height/2; + + return new BoundingBox(left, top, width, height); + } + + function _pathV2Bounds(path) { + let p = JSON.parse(path.get('points'))??[]; + let {Mx,mx,My,my} = p.reduce((m,[x,y])=>({ + Mx:Math.max(m.Mx,x), + mx:Math.min(m.mx,x), + My:Math.max(m.My,y), + my:Math.min(m.my,y) + }),{Mx:-Infinity,mx:Infinity,My:-Infinity,my:Infinity}); + + return [Mx-mx,My-my]; + } + + /** + * Gets the 2D transform information about a path. + * @param {Roll20Path} path + * @return {PathTransformInfo} + */ + function getTransformInfo(path) { + let angle = path.get('rotation')/180*Math.PI; + + + if('path' === path.get('type')){ + let scaleX = path.get('scaleX'); + let scaleY = path.get('scaleY'); + + // The untransformed width and height. + let width = path.get('width'); + let height = path.get('height'); + // The transformed center of the path. + let cx = path.get('left'); + let cy = path.get('top'); + + return { + angle: angle, + cx: cx, + cy: cy, + height: height, + scaleX: scaleX, + scaleY: scaleY, + width: width + }; + } else { + // pathv2 + let [width,height] = _pathV2Bounds(path); + + return { + angle: angle, + cx: path.get('x'), + cy: path.get('y'), + scaleX: 1, + scaleY: 1, + height: height, + width: width + }; + } + } + + /** + * Checks if a path is closed, and is therefore a polygon. + * @param {(Roll20Path|Segment[])} + * @return {boolean} + */ + function isClosed(path) { // eslint-disable-line no-unused-vars + // Convert to segments. + if(!_.isArray(path)) + path = toSegments(path); + return (_.isEqual(path[0][0], path[path.length-1][1])); + } + + + /** + * Produces a merged path string from a list of path objects. + * @param {Roll20Path[]} paths + * @return {String} + */ + function mergePathStr(paths) { + let merged = []; + let bbox = getBoundingBox(paths); + + _.each(paths, function(p) { + let pbox = getBoundingBox(p); + + // Convert the path to a normalized polygonal path. + p = normalizePath(p); + let parsed = JSON.parse(p._path); + _.each(parsed, function(pathTuple) { + let dx = pbox.left - bbox.left; + let dy = pbox.top - bbox.top; + + // Move and Line tuples + let x = pathTuple[1] + dx; + let y = pathTuple[2] + dy; + merged.push([pathTuple[0], x, y]); + }); + }); + + return JSON.stringify(merged); + } + + /** + * Reproduces the data for a polygonal path such that the scales are 1 and + * its rotate is 0. + * This can also normalize freehand paths, but they will be converted to + * polygonal paths. The quatric Bezier curves used in freehand paths are + * so short though, that it doesn't make much difference though. + * @param {Roll20Path} + * @return {PathData} + */ + function normalizePath(path) { + let segments = toSegments(path); + return segmentsToPath(segments); + } + + /** + * Produces a UDL window from a Path. + * This UDL window path will be created on the walls layer + * and will have a type of transparent. + * + * @param {Roll20Path} path + * @return {Roll20Path} The Path object for the new UDL window. + */ + function pathToUDLWindow(path) { + let pathData = normalizePath(path); + + let curPage = path.get('_pageid'); + _.extend(pathData, { + stroke: '#ff0000', + barrierType: "transparent", + _pageid: curPage, + layer: 'walls' + }); + + return createObj(isJumpgate() ? 'pathv2' : 'path', pathData); + } + + /** + * Computes the intersection between the projected lines of + * two homogenous 2D line segments. + * + * Explanation of the fancy mathemagics: + * Let A be the first point in seg1 and B be the second point in seg1. + * Let C be the first point in seg2 and D be the second point in seg2. + * Let U be the vector from A to B. + * Let V be the vector from C to D. + * Let UHat be the unit vector of U. + * Let VHat be the unit vector of V. + * + * Observe that if the dot product of UHat and VHat is 1 or -1, then + * seg1 and seg2 are parallel, so they will either never intersect or they + * will overlap. We will ignore the case where seg1 and seg2 are parallel. + * + * We can represent any point P along the line projected by seg1 as + * P = A + SU, where S is some scalar value such that S = 0 yeilds A, + * S = 1 yields B, and P is on seg1 if and only if 0 <= S <= 1. + * + * We can also represent any point Q along the line projected by seg2 as + * Q = C + TV, where T is some scalar value such that T = 0 yeilds C, + * T = 1 yields D, and Q is on seg2 if and only if 0 <= T <= 1. + * + * Assume that seg1 and seg2 are not parallel and that their + * projected lines intersect at some point P. + * Therefore, we have A + SU = C + TV. + * + * We can rearrange this such that we have C - A = SU - TV. + * Let vector W = C - A, thus W = SU - TV. + * Also, let coeffs = [S, T, 1]. + * + * We can now represent this system of equations as the matrix + * multiplication problem W = M * coeffs, where in column-major + * form, M = [U, -V, [0,0,1]]. + * + * By matrix-multiplying both sides by M^-1, we get + * M^-1 * W = M^-1 * M * coeffs = coeffs, from which we can extract the + * values for S and T. + * + * We can now get the point of intersection on the projected lines of seg1 + * and seg2 by substituting S in P = A + SU or T in Q = C + TV. + * + * @param {Segment} seg1 + * @param {Segment} seg2 + * @return {Intersection} + * The point of intersection in homogenous 2D coordiantes and its + * scalar coefficients along seg1 and seg2, + * or undefined if the segments are parallel. + */ + function raycast(seg1, seg2) { + let u = VecMath.sub(seg1[1], seg1[0]); + let v = VecMath.sub(seg2[1], seg2[0]); + let w = VecMath.sub(seg2[0], seg1[0]); + + // Can't use 0-length vectors. + if(VecMath.length(u) === 0 || VecMath.length(v) === 0) + return undefined; + + // If the two segments are parallel, then either they never intersect + // or they overlap. Either way, return undefined in this case. + let uHat = VecMath.normalize(u); + let vHat = VecMath.normalize(v); + let uvDot = VecMath.dot(uHat,vHat); + if(Math.abs(uvDot) > 0.9999) + return undefined; + + // Build the inverse matrix for getting the intersection point's + // parametric coefficients along the projected segments. + let m = [[u[0], u[1], 0], [-v[0], -v[1], 0], [0, 0, 1]]; + let mInv = MatrixMath.inverse(m); + + // Get the parametric coefficients for getting the point of intersection + // on the projected semgents. + let coeffs = MatrixMath.multiply(mInv, w); + let s = coeffs[0]; + let t = coeffs[1]; + + let uPrime = VecMath.scale(u, s); + return [VecMath.add(seg1[0], uPrime), s, t]; + } + + /** + * Computes the intersection between two homogenous 2D line segments, + * if it exists. To figure out the intersection, a raycast is performed + * between the two segments. + * Seg1 and seg2 also intersect at that point if and only if 0 <= S, T <= 1. + * @param {Segment} seg1 + * @param {Segment} seg2 + * @return {Intersection} + * The point of intersection in homogenous 2D coordiantes and its + * parametric coefficients along seg1 and seg2, + * or undefined if the segments don't intersect. + */ + function segmentIntersection(seg1, seg2) { + let intersection = raycast(seg1, seg2); + if(!intersection) + return undefined; + + // Return the intersection only if it lies on both the segments. + let s = intersection[1]; + let t = intersection[2]; + if(s >= 0 && s <= 1 && t >= 0 && t <= 1) + return intersection; + else + return undefined; + } + + + /** + * Produces the data for creating a path from a list of segments forming a + * continuous path. + * @param {Segment[]} + * @return {PathData} + */ + function segmentsToPath(segments) { + let left = segments[0][0][0]; + let right = segments[0][0][0]; + let top = segments[0][0][1]; + let bottom = segments[0][0][1]; + + // Get the bounds of the segment. + let pts = []; + let isFirst = true; + _.each(segments, function(segment) { + let p1 = segment[0]; + if(isFirst) { + isFirst = false; + pts.push(p1); + } + + let p2 = segment[1]; + + left = Math.min(left, p1[0], p2[0]); + right = Math.max(right, p1[0], p2[0]); + top = Math.min(top, p1[1], p2[1]); + bottom = Math.max(bottom, p1[1], p2[1]); + + pts.push(p2); + }); + + // Get the path's left and top coordinates. + let width = right-left; + let height = bottom-top; + let cx = left + width/2; + let cy = top + height/2; + + if(isJumpgate()){ + return { + shape: 'pol', + x: cx, + y: cy, + points: JSON.stringify(pts) + }; + } else { + // Convert the points to a _path. + let _path = []; + let firstPt = true; + _.each(pts, function(pt) { + let type = 'L'; + if(firstPt) { + type = 'M'; + firstPt = false; + } + _path.push([type, pt[0]-left, pt[1]-top]); + }); + + return { + _path: JSON.stringify(_path), + left: cx, + top: cy, + width: width, + height: height + }; + } + } + + function _circlePointsFromCorners(p1,p2) { + const SPACING=20; + + // reorder points to get top left to bottom right. + if(p1[0]>p2[0]){ + let x = p1[0]; + p1[0]=p2[0]; + p2[0]=x; + } + if(p1[1]>p2[1]){ + let y = p1[1]; + p1[1]=p2[1]; + p2[1]=y; + } + + const cx = (p1[0]+p2[0])/2; + const cy = (p1[1]+p2[1])/2; + const rx = (p2[0]-p1[0])/2; + const ry = (p2[1]-p1[1])/2; + + const cir = Math.PI * ( 3* (rx+ry) - Math.sqrt((3*rx+ry)*(3*ry+rx)))/4; + // number of half subdivisions = circumference / (Spacing *2) or 1 + // +// const pn = (Math.max(Math.ceil(cir/SPACING),1)*4)-1; // guarentee odd + + let pn = Math.max(Math.ceil(cir/SPACING),1); + pn = (1===pn%2 ? pn : pn+1); // guarentee odd + + const th = Math.PI/4/pn; + + let octs = [[],[],[],[],[],[],[],[]]; + + for( let i = 1; i <= pn; ++i){ + const a = i * th; + const ct = Math.cos(a); + const st = Math.sin(a); + + const x1 = parseFloat((rx*ct).toFixed(1)); + const y1 = parseFloat((ry*st).toFixed(1)); + const x2 = parseFloat((rx*st).toFixed(1)); + const y2 = parseFloat((ry*ct).toFixed(1)); + + + // postive quad + octs[0].push([cx+x1,cy+y1]); + if(x1!==x2) { + octs[1].unshift([cx+x2,cy+y2]); + } + + octs[2].push([cx-x2,cy+y2]); + if(x1!==x2) { + octs[3].unshift([cx-x1,cy+y1]); + } + + octs[4].push([cx-x1,cy-y1]); + if(x1!==x2) { + octs[5].unshift([cx-x2,cy-y2]); + } + + octs[6].push([cx+x2,cy-y2]); + if(x1!==x2) { + octs[7].unshift([cx+x1,cy-y1]); + } + } + let points = [ + [cx+rx,cy], + ...octs[0], + ...octs[1], + [cx,cy+ry], + ...octs[2], + ...octs[3], + [cx-rx,cy], + ...octs[4], + ...octs[5], + [cx,cy-ry], + ...octs[6], + ...octs[7] + ]; + + return points; + } + + function _normalizePathV2Points(points) { + let {mX,mY} = points.reduce((m,pt)=>({ + mX: Math.min(pt[0],m.mX), + mY: Math.min(pt[1],m.mY) + }),{mX:Infinity,mY:Infinity}); + return points.map(pt=>[ pt[0]-mX, pt[1]-mY]); + } + + /** + * Converts a path into a list of line segments. + * This supports freehand paths, but not elliptical paths. + * @param {(Roll20Path|Roll20Path[])} path + * @return {Segment[]} + */ + function toSegments(path) { + if(_.isArray(path)) + return _toSegmentsMany(path); + + let _path; + try { + let page = getObj('page', path.get('_pageid')); + let pageWidth = page.get('width') * UNIT_SIZE_PX; + let pageHeight = page.get('height') * UNIT_SIZE_PX; + + if("path" === path.get('type')){ + let rawPath = path.get('_path') + .replace(/mapWidth/g, pageWidth) + .replace(/mapHeight/g, pageHeight); + _path = JSON.parse(rawPath); + } else { + // pathv2 + _path = JSON.parse(path.get('points')); + } + } + catch (err) { + log(`Error parsing Roll20 path JSON: ${path.get('_path')}`); + sendChat('Path Math', '/w gm An error was encountered while trying to parse the JSON for a path. See the API Console Log for details.'); + return []; + } + + let transformInfo = getTransformInfo(path); + + let segments = []; + + if("path" === path.get('type')){ + + let prevPt; + let prevType; + + _.each(_path, tuple => { + let type = tuple[0]; + + // Convert the previous point and tuple into segments. + let newSegs = []; + + // Cubic Bezier + if(type === 'C') { + newSegs = _toSegmentsC(prevPt, tuple, transformInfo); + if(newSegs.length > 0) + prevPt = newSegs[newSegs.length - 1][1]; + } + + // Line or two successive Moves. A curious quirk of the latter + // case is that UDL treats them as segments for windows. + // Thanks to Scott C and Aaron for letting me know about this, + // whether it's an intended feature or not. + if(type === 'L' || (type === 'M' && prevType === 'M')) { + newSegs = _toSegmentsL(prevPt, tuple, transformInfo); + if(newSegs.length > 0) + prevPt = newSegs[0][1]; + } + + // Move, not preceded by another move (not a UDL window) + if(type === 'M' && prevType !== 'M') { + prevPt = tupleToPoint(tuple, transformInfo); + } + + // Freehand (tiny Quadratic Bezier) + if(type === 'Q') { + newSegs = _toSegmentsQ(prevPt, tuple, transformInfo); + if(newSegs.length > 0) + prevPt = newSegs[0][1]; + } + + _.each(newSegs, s => { + segments.push(s); + }); + prevType = type; + }); + } else { + _path = _normalizePathV2Points(_path); + // pathv2 + switch(path.get('shape')){ + case 'rec': { + let p1 = tupleToPoint(['L',_path[0][0],_path[0][1]],transformInfo); + let p2 = tupleToPoint(['L',_path[1][0],_path[1][1]],transformInfo); + let x1 = Math.min(p1[0],p2[0]); + let x2 = Math.max(p1[0],p2[0]); + let y1 = Math.min(p1[1],p2[1]); + let y2 = Math.max(p1[1],p2[1]); + // for rec, there are only two points and you construct the other two. + segments = [ + [[x1,y1,1],[x1,y2,1]], + [[x1,y2,1],[x2,y2,1]], + [[x2,y2,1],[x2,y1,1]], + [[x2,y1,1],[x1,y1,1]] + ]; + } + break; + case 'eli': { + // approximate the segments of a circle + let p1 = tupleToPoint(['L',_path[0][0],_path[0][1]],transformInfo); + let p2 = tupleToPoint(['L',_path[1][0],_path[1][1]],transformInfo); + let x1 = Math.min(p1[0],p2[0]); + let x2 = Math.max(p1[0],p2[0]); + let y1 = Math.min(p1[1],p2[1]); + let y2 = Math.max(p1[1],p2[1]); + + let points = _circlePointsFromCorners([x1,y1],[x2,y2]); + + segments = points.reduce((m,p,i,a)=> + i + ? [...m,[ [...a[i-1],1],[...p,1]]] + : [...m,[ [...a[a.length-1],1],[...p,1]]] + ,[]); + } + break; + case 'pol': + segments = _path.reduce((m,p,i,a)=> + i + ? [...m,[ tupleToPoint(['L',...a[i-1]],transformInfo),tupleToPoint(['L',...p],transformInfo)]] + : m + ,[]); + + break; + case 'free': + // fake it as a poly line for now... + segments = _path.reduce((m,p,i,a)=> + i + ? [...m,[ tupleToPoint(['L',...a[i-1]],transformInfo),tupleToPoint(['L',...p],transformInfo)]] + : m + ,[]); + + break; + } + + } + + return _.compact(segments); + } + + /** + * Converts a 'C' type path point to a list of segments approximating the + * curve. + * @private + * @param {vec3} prevPt + * @param {PathTuple} tuple + * @param {PathTransformInfo} transformInfo + * @return {Segment[]} + */ + function _toSegmentsC(prevPt, tuple, transformInfo) { + let cPt1 = tupleToPoint(['L', tuple[1], tuple[2]], transformInfo); + let cPt2 = tupleToPoint(['L', tuple[3], tuple[4]], transformInfo); + let pt = tupleToPoint(['L', tuple[5], tuple[6]], transformInfo); + let points = [prevPt, cPt1, cPt2, pt]; + + // Choose the number of segments based on the rough approximate arc length. + // Each segment should be <= 10 pixels. + let approxArcLength = VecMath.dist(prevPt, cPt1) + VecMath.dist(cPt1, cPt2) + VecMath.dist(cPt2, pt); + let numSegs = Math.max(Math.ceil(approxArcLength/10), 1); + + let bezierPts = [prevPt]; + _.each(_.range(1, numSegs), i => { + let scalar = i/numSegs; + let bPt = getBezierPoint(points, scalar); + bezierPts.push(bPt); + }); + bezierPts.push(pt); + + return _.chain(bezierPts) + .map((cur, i) => { + if(i === 0) + return undefined; + + let prev = bezierPts[i-1]; + return [prev, cur]; + }) + .compact() + .value(); + } + + /** + * Converts an 'L' type path point to a segment. + * @private + * @param {vec3} prevPt + * @param {PathTuple} tuple + * @param {PathTransformInfo} transformInfo + * @return {Segment[]} + */ + function _toSegmentsL(prevPt, tuple, transformInfo) { + // Transform the point to 2D homogeneous map coordinates. + let pt = tupleToPoint(tuple, transformInfo); + let segments = []; + if(!(prevPt[0] == pt[0] && prevPt[1] == pt[1])) + segments.push([prevPt, pt]); + return segments; + } + + /** + * Converts a 'Q' type path point to a segment approximating + * the freehand curve. + * @private + * @param {vec3} prevPt + * @param {PathTuple} tuple + * @param {PathTransformInfo} transformInfo + * @return {Segment[]} + */ + function _toSegmentsQ(prevPt, tuple, transformInfo) { + // Freehand Bezier paths are very small, so let's just + // ignore the control point for it entirely. + tuple[1] = tuple[3]; + tuple[2] = tuple[4]; + + // Transform the point to 2D homogeneous map coordinates. + let pt = tupleToPoint(tuple, transformInfo); + + let segments = []; + if(!(prevPt[0] == pt[0] && prevPt[1] == pt[1])) + segments.push([prevPt, pt]); + return segments; + } + + /** + * Converts several paths into a single list of segments. + * @private + * @param {Roll20Path[]} paths + * @return {Segment[]} + */ + function _toSegmentsMany(paths) { + return _.chain(paths) + .reduce(function(allSegments, path) { + return allSegments.concat(toSegments(path)); + }, []) + .value(); + } + + /** + * Transforms a tuple for a point in a path into a point in + * homogeneous 2D map coordinates. + * @param {PathTuple} tuple + * @param {PathTransformInfo} transformInfo + * @return {Vector} + */ + function tupleToPoint(tuple, transformInfo) { + let width = transformInfo.width; + let height = transformInfo.height; + let scaleX = transformInfo.scaleX; + let scaleY = transformInfo.scaleY; + let angle = transformInfo.angle; + let cx = transformInfo.cx; + let cy = transformInfo.cy; + + // The point in path coordinates, relative to the path center. + let x = tuple[1] - width/2; + let y = tuple[2] - height/2; + let pt = [x,y,1]; + + // The transform of the point from path coordinates to map + // coordinates. + let scale = MatrixMath.scale([scaleX, scaleY]); + let rotate = MatrixMath.rotate(angle); + let transform = MatrixMath.translate([cx, cy]); + transform = MatrixMath.multiply(transform, rotate); + transform = MatrixMath.multiply(transform, scale); + + return MatrixMath.multiply(transform, pt); + } + + on('chat:message', function(msg) { + if(msg.type === 'api' && msg.content.indexOf('!pathInfo') === 0) { + log('!pathInfo'); + + try { + let path = findObjs({ + _type: 'path', + _id: msg.selected[0]._id + })[0]; + log(path); + log(path.get('_path')); + + let segments = toSegments(path); + log('Segments: '); + log(segments); + + let pathData = segmentsToPath(segments); + log('New path data: '); + log(pathData); + + let curPage = path.get('_pageid'); + _.extend(pathData, { + stroke: '#ff0000', + _pageid: curPage, + layer: path.get('layer') + }); + + let newPath = createObj('path', pathData); + log(newPath); + } + catch(err) { + log('!pathInfo ERROR: '); + log(err.message); + } + } + if (msg.type === 'api' && msg.content.startsWith('!pathToUDLWindow')) { + try { + let path = findObjs({ + _type: 'path', + _id: msg.selected[0]._id + })[0]; + pathToUDLWindow(path); + } + catch(err) { + log('!pathInfo ERROR: '); + log(err.message); + } + } + }); + + return { + BoundingBox, + Circle, + Path, + Polygon, + Triangle, + + createCircleData, + distanceToPoint, + getBezierPoint, + getBoundingBox, + getCenter, + getTransformInfo, + mergePathStr, + normalizePath, + pathToUDLWindow, + raycast, + segmentIntersection, + segmentsToPath, + toSegments, + tupleToPoint + }; +})(); + +{try{throw new Error('');}catch(e){API_Meta.PathMath.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PathMath.offset);}} diff --git a/PathMath/script.json b/PathMath/script.json index 15df9595fb..e538891b34 100644 --- a/PathMath/script.json +++ b/PathMath/script.json @@ -1,8 +1,8 @@ { "name": "Path Math", "script": "PathMath.js", - "version": "1.6", - "previousversions": ["1.0", "1.1", "1.2", "1.3", "1.4.1", "1.5.1", "1.5.2", "1.5.3"], + "version": "1.7", + "previousversions": ["1.0", "1.1", "1.2", "1.3", "1.4.1", "1.5.1", "1.5.2", "1.5.3","1.6"], "description": "# Path Math\r\rA library that provides some mathematical operations involving Paths.\rIt has no stand-alone functionality of its own.\r\r## API Documentation:\r\rThis script's documentation uses the following typedefs and classes:\r\r```\r/**\r * An open shape defined by a path.\r * @class Path\r */\r\r/**\r * A closed shape defined by a path.\r * @class Polygon\r */\r\r/**\r * A polygon primitive consisting of 3 vertices. Great for tessellation!\r * @class Triangle\r * @extends Polygon\r */\r\r/**\r * A circle defined by a center point and radius.\r * @class Circle\r */\r\r/**\r * A rectangle defining a path's bounding box.\r * @typedef {Object} BoundingBox\r * @property {number} left\r * @property {number} top\r * @property {number} width\r * @property {number} height\r */\r\r/**\r * JSON used to create a Path object with createObj().\r * @typedef {Object} PathData\r * This is documented by the Roll20 API wiki.\r */\r\r/**\r * Information about a path's 2D transform.\r * @typedef {Object} PathTransformInfo\r * @property {number} angle\r * The path's rotation angle in radians.\r * @property {number} cx\r * The x coordinate of the center of the path's bounding box.\r * @property {number} cy\r * The y coordinate of the center of the path's bounding box.\r * @property {number} height\r * The unscaled height of the path's bounding box.\r * @property {number} scaleX\r * The path's X-scale.\r * @property {number} scaleY\r * The path's Y-scale.\r * @property {number} width\r * The unscaled width of the path's bounding box.\r */\r\r/**\r * A line segment defined by two homogeneous 2D points.\r * @typedef {Vector[]} Segment\r */\r\r/**\r * A vector used to define a homogeneous point or a direction.\r * @typedef {number[]} Vector\r */\r```\r\rThe following functions are exposed by the ```PathMath``` object:\r\r```\r/**\r * Returns the partial path data for creating a circular path.\r * @param {number} radius\r * @param {int} [sides]\r * If specified, then a polygonal path with the specified number of\r * sides approximating the circle will be created instead of a true\r * circle.\r * @return {PathData}\r */\rfunction createCircleData(radius, sides)\r```\r\r```\r/**\r * Gets a point along some Bezier curve of arbitrary degree.\r * @param {vec3[]} points\r * The points of the Bezier curve. The points between the first and\r * last point are the control points.\r * @param {number} scalar\r * The parametric value for the point we want along the curve.\r * This value is expected to be in the range [0, 1].\r * @return {vec3}\r */\rfunction getBezierPoint(points, scalar)\r```\r\r```\r/**\r * Calculates the bounding box for a list of paths.\r * @param {(Path | Path[])} paths\r * @return {BoundingBox}\r */\rfunction getBoundingBox(paths)\r```\r\r```\r/**\r * Returns the center of the bounding box containing a path or list\r * of paths. The center is returned as a homogenous 2D point\r * (It has a third component which is always 1 which is helpful for\r * affine transformations).\r * @param {(Path|Path[])} paths\r * @return {Vector}\r */\rfunction getCenter(paths)\r```\r\r```\r/**\r * Gets the 2D transform information about a path.\r * @param {Path} path\r * @return {PathTransformInfo}\r */\rfunction getTransformInfo(path)\r```\r\r```\r/**\r * Produces a merged path string from a list of path objects.\r * @param {Path[]} paths\r * @return {String}\r */\rfunction mergePathStr(paths)\r```\r\r```\r/**\r * Reproduces the data for a polygonal path such that the scales are 1 and\r * its rotate is 0.\r * This can also normalize freehand paths, but they will be converted to\r * polygonal paths. The quatric Bezier curves used in freehand paths are\r * so short though, that it doesn't make much difference though.\r * @param {Path}\r * @return {PathData}\r */\rfunction normalizePath(path)\r```\r\r```\r/**\r * Produces a UDL window from a Path.\r * This UDL window path will be created on the walls layer\r * and its _path consists entirely of 'M' components.\r * This may have unexpected behavior for paths that have breaks in them\r * ('M' components between other components).\r * Special thanks to Scott C. and Aaron for discovering this hidden UDL\r * functionality.\r * @param {Roll20Path} path\r * @return {Roll20Path} The Path object for the new UDL window.\r */\rfunction pathToUDLWindow(path)\r```\r\r```\r/**\r * Computes the intersection between the projected lines of two homogeneous\r * 2D line segments.\r * @param {Segment} seg1\r * @param {Segment} seg2\r * @return {Array}\r * The point of intersection in homogenous 2D coordinates and its\r * parametric coefficients along seg1 and seg2,\r * or undefined if the segments are parallel.\r */\r```\r\r```\r/**\r * Computes the intersection between two homogenous 2D line segments,\r * if it exists.\r * @param {Segment} seg1\r * @param {Segment} seg2\r * @return {Array}\r * The point of intersection in homogenous 2D coordinates and its\r * parametric coefficients along seg1 and seg2,\r * or undefined if the segments don't intersect.\r */\rfunction segmentIntersection(seg1, seg2)\r```\r\r```\r/**\r * Produces the data for creating a path from a list of segments forming a\r * continuous path.\r * @param {Segment[]}\r * @return {PathData}\r */\rfunction segmentsToPath(segments)\r```\r\r```\r/**\r * Converts a path into a list of line segments.\r * This supports freehand paths, but not elliptical paths.\r * @param {(Path|Path[])} path\r * @return {Segment[]}\r */\rfunction toSegments(path)\r```\r\r```\r/**\r * Transforms a tuple for a point in a path's _path property into a point in\r * homogeneous 2D map coordinates.\r * @param {PathTuple} tuple\r * @param {PathTransformInfo} transformInfo\r * @return {Vector}\r */\rfunction tupleToPoint(tuple, transformInfo)\r```\r\r## Help\r\rMy scripts are provided 'as-is', without warranty of any kind, expressed or implied.\r\rThat said, if you experience any issues while using this script,\rneed help using it, or if you have a neat suggestion for a new feature,\rplease shoot me a PM:\rhttps://app.roll20.net/users/46544/ada-l\r\rWhen messaging me about an issue, please be sure to include any error messages that\rappear in your API Console Log, any configurations you've got set up for the\rscript in the VTT, and any options you've got set up for the script on your\rgame's API Scripts page. The more information you provide me, the better the\rchances I'll be able to help.\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, maintaining, and providing tech support my API scripts,\rplease consider buying one of my art packs from the Roll20 marketplace:\r\rhttps://marketplace.roll20.net/browse/publisher/165/ada-lindberg\r", "authors": "Ada Lindberg", "roll20userid": 46544, diff --git a/Token Collisions/1.7/TokenCollisions.js b/Token Collisions/1.7/TokenCollisions.js new file mode 100644 index 0000000000..e5cb269a9c --- /dev/null +++ b/Token Collisions/1.7/TokenCollisions.js @@ -0,0 +1,902 @@ +/* globals PathMath VecMath MatrixMath */ +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.TokenCollisions={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.TokenCollisions.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-4);}} +API_Meta.TokenCollisions.version = '1.7'; + +/** + * A small library for testing collisions between moving tokens in Roll20. + */ +const TokenCollisions = (() => { //eslint-disable-line no-unused-vars + + /** + * An object encapsulating a collision between two tokens and the point + * of collision. + * @typedef {object} Collision + * @property {Graphic} token + * The token that moved. + * @property {Graphic} other + * The token it collided with. + * @property {vec2} pt + * The point of collision. + * @property {number} dist + * The distance of the collision from the its waypoint start. + */ + + /** + * An object with optional parameters for collision functions. + * @typedef {object} CollisionOptions + * @property {boolean} detailed + * If true, the collision function will return Collision objects + * instead instead of Tokens that were collided with. + */ + + /** + * A movement waypoint defined by two points: The starting point and the + * movement's endpoint. + * @typedef {vec2[]} waypoint + */ + + /** + * For some token, this gets the list of tokens it collided with during its + * movement between two points, from some list of other tokens. + * The tokens are sorted in the order that they are collided with. + * @private + * @param {Graphic} token + * @param {Graphic[]} others + * @param {waypoint} waypoint + * @param {object} [options] + * @return {(Graphic[]|Collisions[])} + */ + function _getCollisionsInWaypoint(token, others, waypoint, options) { + options = options || {}; + let start = waypoint[0]; + let end = waypoint[1]; + + let numCollisions = 0; + + return _.chain(others) + + // Get the list of tokens that actually collide, sorted by the distance + // from the starting point at which they occur. + .map(other => { + let dist = _testCollision(token, other, waypoint); + if(dist !== undefined && dist >= 0) { + numCollisions++; + + let alpha = dist/VecMath.dist(start, end); + let vec = VecMath.sub(end, start); + vec = VecMath.scale(vec, alpha); + let pt = VecMath.add(start, vec); + + return { token, other, dist, pt }; + } + return undefined; + }) + .sortBy(collision => { + if(collision === undefined) + return undefined; + return collision.dist; + }) + + // Other tokens with undefined collision distance will be sorted to the + // high end of the list. So, we'll just drop them. + .first(numCollisions) + .map(collision => { + if(options.detailed) + return collision; + return collision.other; + }) + .value(); + } + + /** + * Gets the list of all points traversed during a token's movement, including + * its current position at the end of the movement. + * @private + * @param {Graphic} token + * @return {vec2[]} + */ + function _getLastMovePts(token) { + let move = token.get('lastmove').split(','); + let coords = _.map(move, x => { + return Math.abs(parseInt(x)); + }); + + let pts = _.map(_.range(coords.length/2), i => { + let x = coords[i*2]; + let y = coords[i*2 + 1]; + return [x, y]; + }); + pts.push([token.get('left'), token.get('top')]); + return pts; + } + + /** + * Gets the position of a token. + * @private + * @param {Graphic} token + * @return {vec2} + */ + function _getPt(token) { + return [token.get('left'), token.get('top')]; + } + + /** + * Gets the list of all points traversed during a token's movement, including + * its current position at the end of the movement. + * @private + * @param {Graphic} token + * @return {waypoint[]} + */ + function _getWaypoints(token) { + let prev; + let waypoints = []; + _.each(_getLastMovePts(token), pt => { + if(prev) + waypoints.push([prev, pt]); + prev = pt; + }); + return waypoints; + } + + /** + * Checks if a circle is overlapping a circle. + * @private + * @param {graphic} token + * @param {graphic} other + * @param {int} inset + * @return {Boolean} + */ + function _isOverlappingCircleCircle(token, other, inset) { + let circle1 = _tokenToCircle(token, inset); + let circle2 = _tokenToCircle(other, inset); + return circle1.intersects(circle2); + } + + /** + * Checks if a circle is overlapping a path/polygon. + * The path is treated as a polygon if its fill is not transparent. + * @private + * @param {graphic} token + * @param {path} path + * @param {int} inset + * @return {boolean} + */ + function _isOverlappingCirclePath(token, path, inset) { + let circle = _tokenToCircle(token, inset); + + if(path.get('fill') === 'transparent') { + let segments = PathMath.toSegments(path); + return !!_.find(segments, seg => { + return circle.segmentIntersection(seg); + }); + } + else { + let poly = new PathMath.Polygon(path); + return circle.intersectsPolygon(poly); + } + } + + /** + * Checks if a circle is overlapping a rectangle. + * @private + * @param {graphic} token + * @param {graphic} other + * @param {int} inset + * @return {Boolean} + */ + function _isOverlappingCircleRect(token, other, inset) { + let circle = _tokenToCircle(token, inset); + let rect = _tokenToRect(other, inset); + return circle.intersectsPolygon(rect); + } + + /** + * Checks if a rectangle is overlapping a path/polygon. + * The path is treated as a polygon if its fill is not transparent. + * @private + * @param {graphic} token + * @param {path} path + * @param {int} inset + * @return {boolean} + */ + function _isOverlappingRectPath(token, path, inset) { + let rect = _tokenToRect(token, inset); + + if(path.get('fill') === 'transparent') + return rect.intersectsPath(path); + else { + let poly = new PathMath.Polygon(path); + return rect.intersects(poly); + } + } + + /** + * Checks if a rectangle is overlapping a rectangle. + * @param {graphic} token + * @param {graphic} other + * @param {int} inset + * @return {Boolean} + */ + function _isOverlappingRectRect(token, other, inset) { + let rect1 = _tokenToRect(token, inset); + let rect2 = _tokenToRect(other, inset); + return rect1.intersects(rect2); + } + + /** + * Tests if a circular token collides with another circular token during + * its last movement. + * @param {Graphic} token + * @param {Graphic} other + * @param {waypoint} waypoint + * @return {number} + * The distance along the waypoint at which the collision happens, + * or undefined if there is no collision. + */ + function _testCirclesCollision(token, other, waypoint) { + let start = waypoint[0]; + start[2] = 1; + let end = waypoint[1]; + end[2] = 1; + let pt = _getPt(other); + pt[2] = 1; + let segment = [start, end]; + + let tokenR = token.get('width')/2; + let otherR = other.get('width')/2; + let totalR = tokenR + otherR; + + // Reduce the problem to an intersection between a combined circle and + // the segment representing the waypoint. + let circle = new PathMath.Circle(pt, totalR-1); // inset by 1 to avoid edges. + let intersection = circle.segmentIntersection(segment); + if(intersection) { + let intPt = intersection[0]; + let scalar = intersection[1]; + + // If our movement started in other's circle, then it doesn't count. + if(scalar <= 0) + return undefined; + + // Return the distance from the start to the point of intersection. + return VecMath.dist(start, intPt); + } + } + + /** + * Tests for a collision between a circle and a Path. + * If the path's fill is not transparent, then the Path is treated as a + * Polygon. + * @private + * @param {graphic} token + * @param {Path} path + * @param {Waypoint} waypoint + */ + function _testCirclePathCollision(token, path, waypoint) { + let shape; + if(path.get('fill') === 'transparent') + shape = new PathMath.Path(path); + else + shape = new PathMath.Polygon(path); + + return _testCirclePolyCollision(token, shape, waypoint); + } + + /** + * Tests for a collision between a circle and a Polygon. + * @private + * @param {graphic} token + * @param {(PathMath.Polygon|PathMath.Path)} poly + * @param {Waypoint} waypoint + * @return {number} The minimum distance. + */ + function _testCirclePolyCollision(token, poly, waypoint) { + let start = _.clone(waypoint[0]); + start[2] = 1; + let end = _.clone(waypoint[1]); + end[2] = 1; + let u = VecMath.sub(end, start); + let uLen = VecMath.length(u); + let uAngle = Math.atan2(u[1], u[0]); + let radius = token.get('width')/2 - 1; // Inset 1 to avoid edges. + + // Quit early if the polygon's bounding box does not intersect the + // union of the start and end circles' bounding boxes. + let startCircle = new PathMath.Circle(start, radius); + let startBox = startCircle.getBoundingBox(); + let endCircle = new PathMath.Circle(end, radius); + let endBox = endCircle.getBoundingBox(); + + let moveBox = PathMath.BoundingBox.add(startBox, endBox); + let polyBox = poly.getBoundingBox(); + + if(!moveBox.intersects(polyBox)) + return undefined; + + // Quit early if the polygon contains the start circle's center. + if(poly instanceof PathMath.Polygon && poly.containsPt(startCircle.center)) + return undefined; + + // Produce a system transformation such that our circle is centered at + // the origin and u points up. Then transform the polygon to this system. + let rotation = Math.PI/2 - uAngle; + let m = MatrixMath.multiply( + MatrixMath.rotate(rotation), + MatrixMath.translate(VecMath.sub([0,0,1] ,start)) + ); + let mPoly = poly.transform(m); + let mCircle = new PathMath.Circle([0,0,1], radius); + + // Return the minimum collision distance to a transformed segment. + let segments = mPoly.toSegments(); + let keptSegs = _testCirclePolyCollision_clipSegments(segments, mCircle); + + let minDist = _testCirclePolyCollision_minDistance(keptSegs, radius); + if(minDist === Infinity || minDist > uLen) + return undefined; + return minDist; + } + + // Clip out segments that extend beyond +/-radius. Also, get their line + // equation data. + function _testCirclePolyCollision_clipSegments(segments, circle) { + let radius = circle.radius; + return _.chain(segments) + .map(seg => { + let p = seg[0]; + let q = seg[1]; + + // Keep vertical segments that lie within the radius. + if(p[0] === q[0]) { + if(p[0] > -radius && p[0] < radius) { + seg.m = undefined; + seg.b = undefined; + return seg; + } + } + + // Let p be the leftmost point. + if(p[0] > q[0]) { + let swap = q; + q = p; + p = swap; + } + + // Get the line equation info. + let dx = q[0] - p[0]; + let dy = q[1] - p[1]; + let m = dy/dx; + let b = p[1] - m*p[0]; + + // Clip the segment if it intersects the starting circle. + if(circle.segmentIntersection(seg)) + return; + + // Clip the segment if both points are under the circle. + if(p[1] < 0 && q[1] < 0) + return; + + // Clip the segment if both points are on the same side beyond the radius. + if(p[0] < -radius && q[0] < -radius) + return; + else if(p[0] > radius && q[0] > radius) + return; + + // Clip at intersections with the left and right radius pillars. + else { + if(p[0] < -radius) + p = [-radius, -m*radius + b, 1]; + if(q[0] > radius) + q = [radius, m*radius + b, 1]; + } + + let clippedSeg = [p, q]; + clippedSeg.m = m; + clippedSeg.b = b; + return clippedSeg; + }) + .compact() + .value(); + } + + // Using the power of calculus, find the closest segment that + // wasn't clipped. + function _testCirclePolyCollision_minDistance(segments, radius) { + return _.chain(segments) + .map(seg => { + let p = seg[0]; + let q = seg[1]; + + let fofX = x => { // The line equation for the segment in y=mx + b form. + return seg.m*x + seg.b; + }; + let gofX = x => { // The line equation for the upper half of the circle. + return Math.sqrt(radius*radius - x*x); + }; + let hofX = x => { // Collision distance equation. + return fofX(x) - gofX(x); + }; + let hofXdx = x => { // first derivative. + return seg.m + x/gofX(x); + }; + let hofXddx = x => { // second derivative. + return radius*radius/Math.pow(gofX(x), 3); + }; + + if(seg.m === undefined) + return Math.min(seg[0][1], seg[1][1]) - gofX(seg[0][0]); + else { + let root1 = seg.m*radius/Math.sqrt(1 + seg.m*seg.m); + let root2 = -root1; + + // Clip roots outside of the segment, on the edge of the + // circle's movement, or whose slopes aren't valleys. + // Then get the collision distance to the closest root. + let minDist = _.chain([root1, root2, p[0], q[0]]) + .filter(root => { + let isInRadius = (root >= -radius && root <= radius); + let isInSegment = (root >= p[0] && root <= q[0]); + let isValley = (hofXddx(root) > 0); + + return isInRadius && isInSegment && isValley; + }) + .map(root => { + return hofX(root); + }) + .min() // Infinity if no valid roots. + .value(); + + if(minDist > 0) + return minDist; + return undefined; + } + }) + .min() // Get the shortest distance among the segments. + .value(); + } + + /** + * Tests if a circular token collides with a rectangular token during its + * last movement. + * @param {Graphic} token + * @param {Graphic} other + * @param {waypoint} waypoint + * @return {number} + * The distance along the waypoint at which the collision happens, + * or undefined if there is no collision. + */ + function _testCircleRectCollision(token, other, waypoint) { + let rect = _tokenToRect(other); + return _testCirclePolyCollision(token, rect, waypoint); + } + + /** + * Tests for a collision between a token and another object using a strategy + * appropriate for the shapes of the colliding objects. + * @private + * @param {Graphic} token + * @param {(Graphic|Path|PathMath.Polygon)} other + * @param {Waypoint} waypoint + */ + function _testCollision(token, other, waypoint) { + let isSquare = token.get('aura1_square'); + let otherIsPath = /^path/.test(other.get('_type')); + + let strategy; + if(isSquare) + if(other instanceof PathMath.Polygon) + strategy = _testRectPolyCollision; + else if(otherIsPath) + strategy = _testRectPathCollision; + else if(other.get('aura1_square')) + strategy = _testRectsCollision; + else + strategy = _testRectCircleCollision; + else + if(other instanceof PathMath.Polygon) + strategy = _testCirclePolyCollision; + else if(otherIsPath) + strategy = _testCirclePathCollision; + else if(other.get('aura1_square')) + strategy = _testCircleRectCollision; + else + strategy = _testCirclesCollision; + return strategy(token, other, waypoint); + } + + /** + * Tests if a token overlaps another token or path, using a strategy + * appropriate for the shapes of the objects. + * @private + * @param {graphic} token + * @param {(graphic|path)} other + * @param {int} inset + * @return {boolean} + */ + function _testOverlap(token, other, inset) { + let strategy; + if(token.get('aura1_square')) + if(/^path/.test(other.get('_type'))) + strategy = _isOverlappingRectPath; + else if(other.get('aura1_square')) + strategy = _isOverlappingRectRect; + else + strategy = _isOverlappingCircleRect; + else + if(/^test/.test(other.get('_type'))) + strategy = _isOverlappingCirclePath; + else if(other.get('aura1_square')) + strategy = _isOverlappingCircleRect; + else + strategy = _isOverlappingCircleCircle; + return strategy(token, other, inset); + } + + /** + * Tests for an in-movement collisions between a rectangular token + * and a circular token. + * @param {Graphic} token + * @param {Graphic} other + * @param {waypoint} waypoint + * @return {number} + * The distance along the waypoint at which the collision happens, + * or undefined if there is no collision. + */ + function _testRectCircleCollision(token, other, waypoint) { + // Reduce the problem to a circle-rect test in the opposite direction. + let start = waypoint[0]; + start[2] = 1; + let end = waypoint[1]; + end[2] = 1; + + let tokenPt = _getPt(token); + tokenPt[2] = 1; + let otherPt = _getPt(other); + otherPt[2] = 1; + + let u = VecMath.sub(end, start); + let uReverse = VecMath.scale(u, -1); + + let poly = _tokenToRect(token); + let offset = VecMath.sub(start, tokenPt); + poly = poly.transform(MatrixMath.translate(offset)); + + let reverseWaypoint = [otherPt, VecMath.add(otherPt, uReverse)]; + return _testCirclePolyCollision(other, poly, reverseWaypoint); + } + + /** + * Tests for an in-movement collision between a rectangular token and a + * path. The path is treated as a polygon if its fill is not transparent. + * @param {Graphic} token + * @param {Path} path + * @param {waypoint} waypoint + * @return {number} The minimum collision distance. + */ + function _testRectPathCollision(token, path, waypoint) { + let shape; + if(path.get('fill') === 'transparent') + shape = new PathMath.Path(path); + else + shape = new PathMath.Polygon(path); + return _testRectPolyCollision(token, shape, waypoint); + } + + /** + * Tests for an in-movement collision between two polygons. + * @private + * @param {graphic} token + * @param {(PathMath.Polygon|PathMath.Path)} poly + * @param {waypoint} waypoint + * @return {number} + */ + function _testRectPolyCollision(token, poly, waypoint) { + let start = waypoint[0]; + start[2] = 1; + let end = waypoint[1]; + end[2] = 1; + + let u = VecMath.sub(end, start); + let uLen = VecMath.length(u); + let uAngle = Math.atan2(u[1], u[0]); + + // Get the rectangle for the token's final position. + let rect = _tokenToRect(token, 1); // Inset by 1 to avoid edges. + let rectPt = _getPt(token); + rectPt[2] = 1; + let rectOffset = VecMath.sub(start, rectPt); + + // Get the rectangle for the waypoint's start. + let startRect = rect.transform(MatrixMath.translate(rectOffset)); + let startBox = startRect.getBoundingBox(); + + // Get the rectangle for the waypoint's end. + let endRect = startRect.transform(MatrixMath.translate(u)); + let endBox = endRect.getBoundingBox(); + + // Quit early if the polygon's bounding box does not intersect the + // union of the start and end rects' bounding boxes. + let moveBox = PathMath.BoundingBox.add(startBox, endBox); + if(!moveBox.intersects(poly.getBoundingBox())) + return undefined; + + // Quit early if the polygons intersect. + if(startRect.intersects(poly)) + return undefined; + + // Transform the system so that the token's start rect is at the origin and + // u points up. + let rotation = Math.PI/2 - uAngle; + let m = MatrixMath.multiply( + MatrixMath.rotate(rotation), + MatrixMath.translate(VecMath.sub([0,0,1], start)) + ); + let mPoly = poly.transform(m); + let mRect = startRect.transform(m); + + // Get the sets of clipped segments to test collisions between. + let mRectSegs = _testRectPolyCollision_clipRectSegs(mRect, moveBox); + let mPolySegs = _testRectPolyCollision_clipPolySegs(mPoly, mRect); + let minDist = _testRectPolyCollision_getMinDist(mPolySegs, mRectSegs); + if(minDist === Infinity || minDist > uLen) + return undefined; + return minDist; + } + + // Clip the lower segments from the rect. + function _testRectPolyCollision_clipRectSegs(mRect, moveBox) { + return _.chain(mRect.toSegments()) + .filter(seg => { + let u = VecMath.sub(seg[1], seg[0]); + let testPt = [seg[0][0], moveBox.height*2, 1]; + let v = VecMath.sub(testPt, seg[0]); + let cross = VecMath.cross(u, v); + + // Keep the segment if the test point is on its "left" side. + return cross[2] < 0; + }) + .map(seg => { + let p = seg[0]; + let q = seg[1]; + + // let p be the leftmost point. + if(p[0] > q[0]) { + let swap = q; + q = p; + p = swap; + } + + // Get the segment's line equation data. + let dx = q[0] - p[0]; + let dy = q[1] - p[1]; + let m = dy/dx; + let b = p[1] - m*p[0]; + let newSeg = [p, q]; + newSeg.m = m; + newSeg.b = b; + return newSeg; + }) + .value(); + } + + // Clip segments from the polygon that are outside the collision space. + function _testRectPolyCollision_clipPolySegs(mPoly, mRect) { + let mRectBox = mRect.getBoundingBox(); + let left = mRectBox.left; + let right = left + mRectBox.width; + + return _.chain(mPoly.toSegments()) + .map(seg => { + let p = seg[0]; + let q = seg[1]; + + // Keep vertical segments that are within the collision space. + if(p[0] === q[0] && p[0] >= left && p[0] <= right) { + seg.m = undefined; + seg.b = undefined; + return seg; + } + + // let p be the leftmost point. + if(p[0] > q[0]) { + let swap = q; + q = p; + p = swap; + } + + // Clip segments that are entirely outside the collision space. + if(p[0] < left && q[0] < left) + return undefined; + if(p[0] > right && q[0] > right) + return undefined; + if(p[1] < 0 && q[1] < 0) + return undefined; + + // Clip intersections with the left and right borders + // of the collision space. + let dx = q[0] - p[0]; + let dy = q[1] - p[1]; + let m = dy/dx; + let b = p[1] - m*p[0]; + if(p[0] < left) + p = [left, m*left + b, 1]; + if(q[0] > right) + q = [right, m*right + b, 1]; + + let clippedSeg = [p, q]; + clippedSeg.m = m; + clippedSeg.b = b; + return clippedSeg; + }) + .compact() + .value(); + } + + // Using the power of linear algebra, find the minimum distance to any of the + // polygon's segments. + function _testRectPolyCollision_getMinDist(mPolySegs, mRectSegs) { + return _.chain(mPolySegs) + .map(polySeg => { + return _.chain(mRectSegs) + .map(rectSeg => { + let fofX = x => { + return polySeg.m * x + polySeg.b; + }; + let gofX = x => { + return rectSeg.m * x + rectSeg.b; + }; + let hofX = x => { + return fofX(x) - gofX(x); + }; + let left = rectSeg[0][0]; + let right = rectSeg[1][0]; + + let p = polySeg[0]; + let q = polySeg[1]; + + // Skip if this polySeg is not directly above rectSeg. + if(p[0] < left && q[0] < left) + return undefined; + if(p[0] > right && q[0] > right) + return undefined; + + // Clip the intersections on the left and right sides of rectSeg. + if(p[0] < left) + p = [left, fofX(left), 1]; + if(q[0] > right) + q = [right, fofX(right), 1]; + + // Return the minimum distance among the clipped polySeg's endpoints. + let dist = Math.min(hofX(p[0]), hofX(q[0])); + if(dist > 0) + return dist; + return undefined; + }) + .compact() + .min() + .value(); + }) + .compact() + .min() + .value(); + } + + /** + * Tests for a collision between two rectangular tokens and returns + * the shortest distance to the collision. + * @param {Graphic} token + * @param {Graphic} other + * @param {waypoint} waypoint + * @return {number} + * The distance along the waypoint at which the collision happens, + * or undefined if there is no collision. + */ + function _testRectsCollision(token, other, waypoint) { + let poly = _tokenToRect(other); + return _testRectPolyCollision(token, poly, waypoint); + } + + /** + * Gets the circle bounding a token. + * @param {Graphic} token + * @param {number} inset + * @return {PathMath.Circle} + */ + function _tokenToCircle(token, inset) { + inset = inset || 0; + let x = token.get('left'); + let y = token.get('top'); + let r = token.get('width')/2 - inset; + return new PathMath.Circle([x, y, 1], r); + } + + /** + * Gets the rectangule bounding a token. + * @private + * @param {Graphic} token + * @param {number} inset + * @return {PathMath.Polygon} + */ + function _tokenToRect(token, inset) { + inset = inset || 0; + + let width = token.get('width') - inset; + let height = token.get('height') - inset; + let pt = _getPt(token); + let angle = token.get('rotation')*Math.PI/180; + + let m = MatrixMath.multiply( + MatrixMath.translate(pt), + MatrixMath.rotate(angle) + ); + return new PathMath.Polygon([ + MatrixMath.multiply(m, [-width/2, -height/2, 1]), + MatrixMath.multiply(m, [width/2, -height/2, 1]), + MatrixMath.multiply(m, [width/2, height/2, 1]), + MatrixMath.multiply(m, [-width/2, height/2, 1]) + ]); + } + + + // The exposed API. + return class TokenCollisions { + + /** + * Returns the list of other tokens that some token collided with during + * its last movement. + * The tokens are sorted in the order they are collided with. + * @param {Graphic} token + * @param {(Graphic|Path|PathMath.Polygon)[]} others + * @param {CollisionOptions} [options] + * @return {(Graphic[]|Collisions[])} + */ + static getCollisions(token, others, options) { + return _.chain(_getWaypoints(token)) + .map(waypoint => { + return _getCollisionsInWaypoint(token, others, waypoint, options); + }) + .flatten() + .value(); + } + + /** + * Returns the first token, from some list of tokens, that a token has + * collided with during its last movement, or undfined if there was + * no collision. + * @param {Graphic} token + * @param {(Graphic|Path|PathMath.Polygon)[]} others + * @param {CollisionOptions} [options] + * @return {(Graphic|Collision)} + */ + static getFirstCollision(token, others, options) { + return TokenCollisions.getCollisions(token, others, options)[0]; + } + + /** + * Checks if a non-moving token is currently overlapping another token. + * This supports circular and rectangular tokens. + * Tokens are considered to be rectangular if their aura1 is a square. + * @param {Graphic} token + * @param {Graphic} other + * @param {boolean} [collideOnEdge=false] + * Whether tokens should count as overlapping even if they are only + * touching on the very edge. + * @return {Boolean} + */ + static isOverlapping(token, other, collideOnEdge) { + if(token.get('_id') === other.get('_id')) + return false; + + // Inset by 1 pixel if we don't want to collide on edges. + let inset = 1; + if(collideOnEdge) + inset = 0; + + return _testOverlap(token, other, inset); + } + }; +})(); + +{try{throw new Error('');}catch(e){API_Meta.TokenCollisions.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.TokenCollisions.offset);}} diff --git a/Token Collisions/script.json b/Token Collisions/script.json index a3f194d1a2..2191ad73da 100644 --- a/Token Collisions/script.json +++ b/Token Collisions/script.json @@ -1,8 +1,8 @@ { "name": "Token Collisions", "script": "TokenCollisions.js", - "version": "1.6", - "previousversions": ["1.1", "1.2", "1.3", "1.4", "1.5"], + "version": "1.7", + "previousversions": ["1.1", "1.2", "1.3", "1.4", "1.5", "1.6"], "description": "# Token Collisions\r\r_v1.5 Updates:_\r* Collisions with Paths and arbitrary polygons are now supported. Paths with non-transparent fills are treated as polygons.\r* All collisions are now entirely pixel-perfect.\r\r_v1.4 Updates:_\r* getCollisions and getFirstCollision now accept an options object parameter. See the CollisionOptions typedef jsdoc for supported properties.\r\r_v1.3 Updates:_\r* Supports circle-to-rectangle token collisions.\r* Added isOverlapping() function.\r\rThis script provides a small library for checking for collisions between\rtokens. It provides no functionality by itself, but it is used by other\rscripts such as ```It's A Trap``` and ```World Map Discovery```.\r\r## Rectangular tokens\r\rBy default, all tokens are assumed to be circular with a diameter equal to their\rwidth. You can set a token to be rectangular for this script by setting its\rAura1 to a square.\r\r## API Documentation:\r\rThe following functions are exposed by the ```TokenCollisions``` object:\r\r```\r/**\r * Returns the list of other tokens that some token collided with during\r * its last movement.\r * The tokens are sorted in the order they are collided with.\r * @param {Graphic} token\r * @param {(Graphic|Path|PathMath.Polygon)[]} others\r * @return {Graphic[]}\r */\rfunction getCollisions(token, others)\r```\r\r```\r/**\r * Returns the first token, from some list of tokens, that a token has\r * collided with during its last movement, or undfined if there was\r * no collision.\r * @param {Graphic} token\r * @param {(Graphic|Path|PathMath.Polygon)[]} others\r * @return {Graphic}\r */\rfunction getFirstCollision(token, others)\r```\r\r```\r/**\r * Checks if a non-moving token is currently overlapping another token.\r * This supports circular and rectangular tokens.\r * Tokens are considered to be rectangular if their aura1 is a square.\r * @param {Graphic} token\r * @param {Graphic} other\r * @param {boolean} [collideOnEdge=false]\r * Whether tokens should count as overlapping even if they are only\r * touching on the very edge.\r * @return {Boolean}\r */\rfunction isOverlapping(token, other, collideOnEdge)\r```\r\r## Help\r\rMy scripts are provided 'as-is', without warranty of any kind, expressed or implied.\r\rThat said, if you experience any issues while using this script,\rneed help using it, or if you have a neat suggestion for a new feature,\rplease shoot me a PM:\rhttps://app.roll20.net/users/46544/ada-l\r\rWhen messaging me about an issue, please be sure to include any error messages that\rappear in your API Console Log, any configurations you've got set up for the\rscript in the VTT, and any options you've got set up for the script on your\rgame's API Scripts page. The more information you provide me, the better the\rchances I'll be able to help.\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, maintaining, and providing tech support my API scripts,\rplease consider buying one of my art packs from the Roll20 marketplace:\r\rhttps://marketplace.roll20.net/browse/publisher/165/ada-lindberg\r", "authors": "Ada Lindberg", "roll20userid": 46544, From 832e922104844fbc05b4dd51e40bd786750c9d96 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 6 Dec 2024 14:51:20 -0600 Subject: [PATCH 2/4] Migrated to the next version to avoid conflict --- Its A Trap/3.13.2/ItsATrap.js | 77 +- Its A Trap/3.13.3/ItsATrap.js | 3058 +++++++++++++++++++++++++++++++++ 2 files changed, 3085 insertions(+), 50 deletions(-) create mode 100644 Its A Trap/3.13.3/ItsATrap.js diff --git a/Its A Trap/3.13.2/ItsATrap.js b/Its A Trap/3.13.2/ItsATrap.js index ac5d22c018..e5d2621144 100644 --- a/Its A Trap/3.13.2/ItsATrap.js +++ b/Its A Trap/3.13.2/ItsATrap.js @@ -1,12 +1,8 @@ -/* globals PathMath VecMath TokenCollisions HtmlBuilder CharSheetUtils KABOOM AreasOfEffect */ -var API_Meta = API_Meta||{}; //eslint-disable-line no-var -API_Meta.ItsATrap={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; -{try{throw new Error('');}catch(e){API_Meta.ItsATrap.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-4);}} -API_Meta.ItsATrap.version = '3.13.2'; /** * Initialize the state for the It's A Trap script. */ (() => { + 'use strict'; /** * The ItsATrap state data. @@ -39,7 +35,8 @@ API_Meta.ItsATrap.version = '3.13.2'; /** * The main interface and bootstrap script for It's A Trap. */ -const ItsATrap = (() => { +var ItsATrap = (() => { + 'use strict'; const REMOTE_ACTIVATE_CMD = '!itsATrapRemoteActivate'; @@ -49,24 +46,6 @@ const ItsATrap = (() => { // The installed trap theme that is being used. let curTheme = 'default'; - let isJumpgate = ()=>{ - if(['jumpgate'].includes(Campaign().get('_release'))) { - isJumpgate = () => true; - } else { - isJumpgate = () => false; - } - return isJumpgate(); - }; - - const getPath = (id) => { - let path; - if(isJumpgate()){ - path = getObj('pathv2',id); - } - return path || getObj('path',id); - }; - - /** * Activates a trap. * @param {Graphic} trap @@ -150,7 +129,7 @@ const ItsATrap = (() => { if (_.isArray(effect.triggerPaths)) { triggerDist = _.chain(effect.triggerPaths) .map(pathId => { - let path = getPath(pathId); + let path = getObj('path', pathId); if(path) return getSearchDistance(token, path); else @@ -176,7 +155,7 @@ const ItsATrap = (() => { * @param {Graphic} token */ function _checkTrapInteractions(token) { - if(token.iatIgnoreToken) + if(ignoreTokens[token.id]) return; // Objects on the GM layer don't set off traps. @@ -210,12 +189,12 @@ const ItsATrap = (() => { function _checkTrapActivations(theme, token) { let collisions = getTrapCollisions(token); _.find(collisions, collision => { - let trap = collision?.other; + let trap = collision.other; let trapEffect = new TrapEffect(trap, token); // Skip if the trap is disabled or if it has no activation area. - if(!trap || trap.get('status_interdiction')) + if(trap.get('status_interdiction')) return false; // Should this trap ignore the token? @@ -287,7 +266,7 @@ const ItsATrap = (() => { let scale = page.get('scale_number'); let pixelDist; - if(/^path/.test(token2.get('_type'))) { + if(token2.get('_type') === 'path') { let path = token2; pixelDist = PathMath.distanceToPoint(p1, path); } @@ -350,7 +329,7 @@ const ItsATrap = (() => { else pathsToTraps[id] = [trap]; - return getPath(id); + return getObj('path', id); }) .compact() .value(); @@ -382,7 +361,7 @@ const ItsATrap = (() => { return _.chain(TokenCollisions.getCollisions(token, traps, {detailed: true})) .map(collision => { // Convert path collisions back into trap token collisions. - if(/^path/.test(collision.other.get('_type'))) { + if(collision.other.get('_type') === 'path') { let pathId = collision.other.get('_id'); return _.map(pathsToTraps[pathId], trap => { return { @@ -435,7 +414,7 @@ const ItsATrap = (() => { // Case 1: One or more closed paths define the blast areas. if(effect.effectShape instanceof Array) { _.each(effect.effectShape, pathId => { - let path = getPath(pathId); + let path = getObj('path', pathId); if(path) { _.each(otherTokens, token => { if(TokenCollisions.isOverlapping(token, path)) @@ -551,7 +530,7 @@ const ItsATrap = (() => { if(effect.effectShape instanceof Array) _.each(effect.effectShape, pathId => { - let path = getPath(pathId); + let path = getObj('path', pathId); if (path) { path.set('layer', layer); toOrder(path); @@ -601,7 +580,7 @@ const ItsATrap = (() => { if(_.isArray(effect.triggerPaths)) { _.each(effect.triggerPaths, pathId => { - let path = getPath(pathId); + let path = getObj('path', pathId); if (path) { path.set('layer', layer); toOrder(path); @@ -630,7 +609,7 @@ const ItsATrap = (() => { let interval = setInterval(() => { let theme = getTheme(); if(theme) { - log(`--- Initialized It's A Trap! v3.13.1, using theme '${getTheme().name}' ---`); + log(`--- Initialized It's A Trap! v3.13.2, using theme '${getTheme().name}' ---`); clearInterval(interval); } else if(numRetries > 0) @@ -704,10 +683,11 @@ const ItsATrap = (() => { // This is to prevent a bug related to dropping default tokens for characters // to the VTT, which sometimes caused traps to trigger as though the dropped // token has move. + let ignoreTokens = {}; on('add:graphic', token => { - token.iatIgnoreToken = true; + ignoreTokens[token.id] = true; setTimeout(() => { - delete token.iatIgnoreToken; + delete ignoreTokens[token.id]; }, 1000); }); @@ -728,16 +708,8 @@ const ItsATrap = (() => { * The configured JSON properties of a trap. This can be extended to add * additional properties for system-specific themes. */ -const TrapEffect = (() => { - - let isJumpgate = ()=>{ - if(['jumpgate'].includes(Campaign().get('_release'))) { - isJumpgate = () => true; - } else { - isJumpgate = () => false; - } - return isJumpgate(); - }; +var TrapEffect = (() => { + 'use strict'; const DEFAULT_FX = { maxParticles: 100, @@ -1193,7 +1165,7 @@ const TrapEffect = (() => { if(VecMath.dist(p1, p2) > 0) { let segments = [[p1, p2]]; let pathJson = PathMath.segmentsToPath(segments); - let path = createObj( (isJumpgate() ? 'pathv2' : 'path'), _.extend(pathJson, { + let path = createObj('path', _.extend(pathJson, { _pageid: this._trap.get('_pageid'), layer: 'objects', stroke: '#ff0000' @@ -1357,6 +1329,7 @@ const TrapEffect = (() => { * A small library for checking if a token has line of sight to other tokens. */ var LineOfSight = (() => { + 'use strict'; /** * Gets the point for a token. @@ -1423,6 +1396,7 @@ var LineOfSight = (() => { * hand-crafting the JSON for them. */ var ItsATrapCreationWizard = (() => { + 'use strict'; const DISPLAY_WIZARD_CMD = '!ItsATrap_trapCreationWizard_showMenu'; const MODIFY_CORE_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapCore'; const MODIFY_THEME_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapTheme'; @@ -2302,6 +2276,7 @@ var ItsATrapCreationWizard = (() => { * @abstract */ var TrapTheme = (() => { + 'use strict'; /** * The name of the theme used to register it. @@ -2453,6 +2428,7 @@ var TrapTheme = (() => { * @abstract */ var D20TrapTheme = (() => { + 'use strict'; return class D20TrapTheme extends TrapTheme { @@ -2782,6 +2758,7 @@ var D20TrapTheme = (() => { * @abstract */ var D20TrapTheme4E = (() => { + 'use strict'; return class D20TrapTheme4E extends D20TrapTheme { @@ -2969,6 +2946,7 @@ var D20TrapTheme4E = (() => { * @implements TrapTheme */ (() => { + 'use strict'; class DefaultTheme { @@ -3010,6 +2988,7 @@ var D20TrapTheme4E = (() => { })(); ItsATrap.Chat = (() => { + 'use strict'; /** * Broadcasts a message spoken by the script's configured announcer. @@ -3053,5 +3032,3 @@ ItsATrap.Chat = (() => { whisperGM }; })(); - -{try{throw new Error('');}catch(e){API_Meta.ItsATrap.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.ItsATrap.offset);}} diff --git a/Its A Trap/3.13.3/ItsATrap.js b/Its A Trap/3.13.3/ItsATrap.js new file mode 100644 index 0000000000..b6875d4366 --- /dev/null +++ b/Its A Trap/3.13.3/ItsATrap.js @@ -0,0 +1,3058 @@ +/* globals PathMath VecMath TokenCollisions HtmlBuilder CharSheetUtils KABOOM AreasOfEffect */ +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.ItsATrap={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.ItsATrap.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-4);}} +API_Meta.ItsATrap.version = '3.13.2'; +/** + * Initialize the state for the It's A Trap script. + */ +(() => { + + /** + * The ItsATrap state data. + * @typedef {object} ItsATrapState + * @property {object} noticedTraps + * The set of IDs for traps that have been noticed by passive perception. + * @property {string} theme + * The name of the TrapTheme currently being used. + */ + state.ItsATrap = state.ItsATrap || {}; + _.defaults(state.ItsATrap, { + noticedTraps: {}, + userOptions: {} + }); + _.defaults(state.ItsATrap.userOptions, { + revealTrapsToMap: false, + announcer: 'Admiral Ackbar' + }); + + // Set the theme from the useroptions. + let useroptions = globalconfig && globalconfig.itsatrap; + if(useroptions) { + state.ItsATrap.userOptions = { + revealTrapsToMap: useroptions.revealTrapsToMap === 'true' || false, + announcer: useroptions.announcer || 'Admiral Ackbar' + }; + } +})(); + +/** + * The main interface and bootstrap script for It's A Trap. + */ +const ItsATrap = (() => { + + const REMOTE_ACTIVATE_CMD = '!itsATrapRemoteActivate'; + + // The collection of registered TrapThemes keyed by name. + let trapThemes = {}; + + // The installed trap theme that is being used. + let curTheme = 'default'; + + let isJumpgate = ()=>{ + if(['jumpgate'].includes(Campaign().get('_release'))) { + isJumpgate = () => true; + } else { + isJumpgate = () => false; + } + return isJumpgate(); + }; + + const getPath = (id) => { + let path; + if(isJumpgate()){ + path = getObj('pathv2',id); + } + return path || getObj('path',id); + }; + + + /** + * Activates a trap. + * @param {Graphic} trap + * @param {Graphic} [activatingVictim] + * The victim that triggered the trap. + */ + function activateTrap(trap, activatingVictim) { + let effect = new TrapEffect(trap); + if(effect.delay) { + // Set the interdiction status on the trap so that it doesn't get + // delay-activated multiple times. + effect.trap.set('status_interdiction', true); + + // Activate the trap after the delay. + setTimeout(() => { + _activateTrap(trap); + }, 1000*effect.delay); + + // Let the GM know that the trap has been triggered. + if(activatingVictim) + ItsATrap.Chat.whisperGM(`The trap ${effect.name} has been ` + + `triggered by ${activatingVictim.get('name')}. ` + + `It will activate in ${effect.delay} seconds.`); + else + ItsATrap.Chat.whisperGM(`The trap ${effect.name} has been ` + + `triggered. It will activate in ${effect.delay} seconds.`); + } + else + _activateTrap(trap, activatingVictim); + } + + /** + * Helper for activateTrap. + * @param {Graphic} trap + * @param {Graphic} [activatingVictim] + * The victim that triggered the trap. + */ + function _activateTrap(trap, activatingVictim) { + let theme = getTheme(); + let effect = new TrapEffect(trap); + + // Apply the trap's effects to any victims in its area and to the + // activating victim, using the configured trap theme. + let victims = getTrapVictims(trap, activatingVictim); + if(victims.length > 0) + _.each(victims, victim => { + effect = new TrapEffect(trap, victim); + theme.activateEffect(effect); + }); + else { + // In the absence of any victims, activate the trap with the default + // theme, which will only display the trap's message. + let defaultTheme = trapThemes['default']; + defaultTheme.activateEffect(effect); + } + + // If the trap is destroyable, delete it after it has activated. + if(effect.destroyable) + trap.remove(); + } + + /** + * Checks if a token passively searched for any traps during its last + * movement. + * @private + * @param {TrapTheme} theme + * @param {Graphic} token + */ + function _checkPassiveSearch(theme, token) { + if(theme.passiveSearch && theme.passiveSearch !== _.noop) { + _.chain(getSearchableTraps(token)) + .filter(trap => { + // Only search for traps that are close enough to be spotted. + let effect = new TrapEffect(trap, token); + + // Check the distance to the trap itself. + let dist = getSearchDistance(token, trap); + + // Also check the distance to any path triggers. + let triggerDist = Number.POSITIVE_INFINITY; + if (_.isArray(effect.triggerPaths)) { + triggerDist = _.chain(effect.triggerPaths) + .map(pathId => { + let path = getPath(pathId); + if(path) + return getSearchDistance(token, path); + else + return Number.POSITIVE_INFINITY; + }) + .min() + .value(); + } + + let searchDist = trap.get('aura2_radius') || effect.searchDist; + return (!searchDist || Math.min(dist, triggerDist) < searchDist); + }) + .each(trap => { + theme.passiveSearch(trap, token); + }); + } + } + + /** + * Checks if a token activated or passively spotted any traps during + * its last movement. + * @private + * @param {Graphic} token + */ + function _checkTrapInteractions(token) { + if(ignoreTokens[token.id]) + return; + + // Objects on the GM layer don't set off traps. + if(token.get("layer") === "objects") { + try { + let theme = getTheme(); + if(!theme) { + log('ERROR - It\'s A Trap!: TrapTheme does not exist - ' + curTheme + '. Using default TrapTheme.'); + theme = trapThemes['default']; + } + + // Did the character set off a trap? + _checkTrapActivations(theme, token); + + // If the theme has passive searching, do a passive search for traps. + _checkPassiveSearch(theme, token); + } + catch(err) { + log('ERROR - It\'s A Trap!: ' + err.message); + log(err.stack); + } + } + } + + /** + * Checks if a token activated any traps during its last movement. + * @private + * @param {TrapTheme} theme + * @param {Graphic} token + */ + function _checkTrapActivations(theme, token) { + let collisions = getTrapCollisions(token); + _.find(collisions, collision => { + let trap = collision?.other; + + let trapEffect = new TrapEffect(trap, token); + + // Skip if the trap is disabled or if it has no activation area. + if(!trap || trap.get('status_interdiction')) + return false; + + // Should this trap ignore the token? + if(trapEffect.ignores && trapEffect.ignores.includes(token.get('_id'))) + return false; + + // Figure out where to stop the token. + if(trapEffect.stopAt === 'edge' && !trapEffect.gmOnly) { + let x = collision.pt[0]; + let y = collision.pt[1]; + + token.set("lastmove",""); + token.set("left", x); + token.set("top", y); + } + else if(trapEffect.stopAt === 'center' && !trapEffect.gmOnly && + ['self', 'burst'].includes(trapEffect.effectShape)) { + let x = trap.get("left"); + let y = trap.get("top"); + + token.set("lastmove",""); + token.set("left", x); + token.set("top", y); + } + + // Apply the trap's effects to any victims in its area. + if(collision.triggeredByPath) + activateTrap(trap); + else + activateTrap(trap, token); + + // Stop activating traps if this trap stopped the token. + return (trapEffect.stopAt !== 'none'); + }); + } + + /** + * Gets the point for a token. + * @private + * @param {Graphic} token + * @return {vec3} + */ + function _getPt(token) { + return [token.get('left'), token.get('top'), 1]; + } + + /** + * Gets all the traps that a token has line-of-sight to, with no limit for + * range. Line-of-sight is blocked by paths on the dynamic lighting layer. + * @param {Graphic} charToken + * @return {Graphic[]} + * The list of traps that charToken has line-of-sight to. + */ + function getSearchableTraps(charToken) { + let pageId = charToken.get('_pageid'); + let traps = getTrapsOnPage(pageId); + return LineOfSight.filterTokens(charToken, traps); + } + + /** + * Gets the distance between two tokens in their page's units. + * @param {Graphic} token1 + * @param {(Graphic|Path)} token2 + * @return {number} + */ + function getSearchDistance(token1, token2) { + let p1 = _getPt(token1); + let page = getObj('page', token1.get('_pageid')); + let scale = page.get('scale_number'); + let pixelDist; + + if(/^path/.test(token2.get('_type'))) { + let path = token2; + pixelDist = PathMath.distanceToPoint(p1, path); + } + else { + let p2 = _getPt(token2); + let r1 = token1.get('width')/2; + let r2 = token2.get('width')/2; + pixelDist = Math.max(0, VecMath.dist(p1, p2) - r1 - r2); + } + return pixelDist/70*scale; + } + + /** + * Gets the theme currently being used to interpret TrapEffects spawned + * when a character activates a trap. + * @return {TrapTheme} + */ + function getTheme() { + return trapThemes[curTheme]; + } + + /** + * Returns the list of all traps a token would collide with during its last + * movement. The traps are sorted in the order that the token will collide + * with them. + * @param {Graphic} token + * @return {TokenCollisions.Collision[]} + */ + function getTrapCollisions(token) { + let pageId = token.get('_pageid'); + let traps = getTrapsOnPage(pageId); + + // A llambda to test if a token is flying. + let isFlying = x => { + return x.get("status_fluffy-wing"); + }; + + let pathsToTraps = {}; + + // Some traps don't affect flying tokens. + traps = _.chain(traps) + .filter(trap => { + return !isFlying(token) || isFlying(trap); + }) + + // Use paths for collisions if trigger paths are set. + .map(trap => { + let effect = new TrapEffect(trap); + + // Skip the trap if it has no trigger. + if (effect.triggerPaths === 'none') + return undefined; + + // Trigger is defined by paths. + else if(_.isArray(effect.triggerPaths)) { + return _.chain(effect.triggerPaths) + .map(id => { + if(pathsToTraps[id]) + pathsToTraps[id].push(trap); + else + pathsToTraps[id] = [trap]; + + return getPath(id); + }) + .compact() + .value(); + } + + // Trigger is the trap token itself. + else + return trap; + }) + .flatten() + .compact() + .value(); + + // Get the collisions. + return _getTrapCollisions(token, traps, pathsToTraps); + } + + /** + * Returns the list of all traps a token would collide with during its last + * movement from a list of traps. + * The traps are sorted in the order that the token will collide + * with them. + * @private + * @param {Graphic} token + * @param {(Graphic[]|Path[])} traps + * @return {TokenCollisions.Collision[]} + */ + function _getTrapCollisions(token, traps, pathsToTraps) { + return _.chain(TokenCollisions.getCollisions(token, traps, {detailed: true})) + .map(collision => { + // Convert path collisions back into trap token collisions. + if(/^path/.test(collision.other.get('_type'))) { + let pathId = collision.other.get('_id'); + return _.map(pathsToTraps[pathId], trap => { + return { + token: collision.token, + other: trap, + pt: collision.pt, + dist: collision.dist, + triggeredByPath: true + }; + }); + } + else + return collision; + }) + .flatten() + .value(); + } + + /** + * Gets the list of all the traps on the specified page. + * @param {string} pageId + * @return {Graphic[]} + */ + function getTrapsOnPage(pageId) { + return findObjs({ + _pageid: pageId, + _type: "graphic", + status_cobweb: true, + layer: "gmlayer" + }); + } + + /** + * Gets the list of victims within an activated trap's area of effect. + * @param {Graphic} trap + * @param {Graphic} triggerVictim + * @return {Graphic[]} + */ + function getTrapVictims(trap, triggerVictim) { + let pageId = trap.get('_pageid'); + + let effect = new TrapEffect(trap); + let victims = []; + let otherTokens = findObjs({ + _pageid: pageId, + _type: 'graphic', + layer: 'objects' + }); + + // Case 1: One or more closed paths define the blast areas. + if(effect.effectShape instanceof Array) { + _.each(effect.effectShape, pathId => { + let path = getPath(pathId); + if(path) { + _.each(otherTokens, token => { + if(TokenCollisions.isOverlapping(token, path)) + victims.push(token); + }); + } + }); + } + + // Case 2: The trap itself defines the blast area. + else { + victims = [triggerVictim]; + + let range = trap.get('aura1_radius'); + let squareArea = trap.get('aura1_square'); + if(range !== '') { + let pageScale = getObj('page', pageId).get('scale_number'); + range *= 70/pageScale; + } + else + range = 0; + + victims = victims.concat(LineOfSight.filterTokens(trap, otherTokens, range, squareArea)); + } + + return _.chain(victims) + .unique() + .compact() + .reject(victim => { + return effect.ignores.includes(victim.get('_id')); + }) + .value(); + } + + /** + * Marks a trap with a circle and a ping. + * @private + * @param {Graphic} trap + */ + function _markTrap(trap) { + let radius = trap.get('width')/2; + let x = trap.get('left'); + let y = trap.get('top'); + let pageId = trap.get('_pageid'); + + // Circle the trap's trigger area. + let circle = new PathMath.Circle([x, y, 1], radius); + circle.render(pageId, 'objects', { + stroke: '#ffff00', // yellow + stroke_width: 5 + }); + + let effect = new TrapEffect(trap); + + let toOrder = toFront; + let layer = 'map'; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + _revealTriggers(trap); + _revealActivationAreas(trap); + sendPing(x, y, pageId); + } + + /** + * Marks a trap as being noticed by a character's passive search. + * @param {Graphic} trap + * @param {string} noticeMessage A message to display when the trap is noticed. + * @return {boolean} + * true if the trap has not been noticed yet. + */ + function noticeTrap(trap, noticeMessage) { + let id = trap.get('_id'); + let effect = new TrapEffect(trap); + + if(!state.ItsATrap.noticedTraps[id]) { + state.ItsATrap.noticedTraps[id] = true; + ItsATrap.Chat.broadcast(noticeMessage); + + if(effect.revealWhenSpotted) + revealTrap(trap); + else + _markTrap(trap); + return true; + } + else + return false; + } + + /** + * Registers a TrapTheme. + * @param {TrapTheme} theme + */ + function registerTheme(theme) { + log('It\'s A Trap!: Registered TrapTheme - ' + theme.name + '.'); + trapThemes[theme.name] = theme; + curTheme = theme.name; + } + + /** + * Reveals the paths defining a trap's activation area, if it has any. + * @param {Graphic} trap + */ + function _revealActivationAreas(trap) { + let effect = new TrapEffect(trap); + let layer = 'map'; + let toOrder = toFront; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + + if(effect.effectShape instanceof Array) + _.each(effect.effectShape, pathId => { + let path = getPath(pathId); + if (path) { + path.set('layer', layer); + toOrder(path); + } + else { + ItsATrap.Chat.error(new Error(`Could not find activation area shape ${pathId} for trap ${effect.name}. Perhaps you deleted it? Either way, please fix it through the trap's Activation Area property.`)); + } + }); + } + + /** + * Reveals a trap to the objects or map layer. + * @param {Graphic} trap + */ + function revealTrap(trap) { + let effect = new TrapEffect(trap); + + let toOrder = toFront; + let layer = 'map'; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + + // Reveal the trap token. + trap.set('layer', layer); + toOrder(trap); + sendPing(trap.get('left'), trap.get('top'), trap.get('_pageid')); + + // Reveal its trigger paths and activation areas, if any. + _revealActivationAreas(trap); + _revealTriggers(trap); + } + + /** + * Reveals any trigger paths associated with a trap, if any. + * @param {Graphic} trap + */ + function _revealTriggers(trap) { + let effect = new TrapEffect(trap); + let layer = 'map'; + let toOrder = toFront; + if(effect.revealLayer === 'objects') { + toOrder = toBack; + layer = 'objects'; + } + + if(_.isArray(effect.triggerPaths)) { + _.each(effect.triggerPaths, pathId => { + let path = getPath(pathId); + if (path) { + path.set('layer', layer); + toOrder(path); + } + else { + ItsATrap.Chat.error(new Error(`Could not find trigger path ${pathId} for trap ${effect.name}. Perhaps you deleted it? Either way, please fix it through the trap's Trigger Area property.`)); + } + }); + } + } + + /** + * Removes a trap from the state's collection of noticed traps. + * @private + * @param {Graphic} trap + */ + function _unNoticeTrap(trap) { + let id = trap.get('_id'); + if(state.ItsATrap.noticedTraps[id]) + delete state.ItsATrap.noticedTraps[id]; + } + + // Create macro for the remote activation command. + on('ready', () => { + let numRetries = 3; + let interval = setInterval(() => { + let theme = getTheme(); + if(theme) { + log(`--- Initialized It's A Trap! v3.13.3, using theme '${getTheme().name}' ---`); + clearInterval(interval); + } + else if(numRetries > 0) + numRetries--; + else + clearInterval(interval); + }, 1000); + }); + + // Handle macro commands. + on('chat:message', msg => { + try { + let argv = msg.content.split(' '); + if(argv[0] === REMOTE_ACTIVATE_CMD) { + let theme = getTheme(); + + let trapId = argv[1]; + let trap = getObj('graphic', trapId); + if (trap) + activateTrap(trap); + else + throw new Error(`Could not activate trap ID ${trapId}. It does not exist.`); + } + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + /** + * When a graphic on the objects layer moves, run the script to see if it + * passed through any traps. + */ + on("change:graphic:lastmove", token => { + try { + // Check for trap interactions if the token isn't also a trap. + if(!token.get('status_cobweb')) + _checkTrapInteractions(token); + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + // If a trap is moved back to the GM layer, remove it from the set of noticed traps. + on('change:graphic:layer', token => { + try { + if(token.get('layer') === 'gmlayer') + _unNoticeTrap(token); + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + // When a trap's token is destroyed, remove it from the set of noticed traps. + on('destroy:graphic', token => { + try { + _unNoticeTrap(token); + } + catch(err) { + log(`It's A Trap ERROR: ${err.msg}`); + log(err.stack); + } + }); + + // When a token is added, make it temporarily unable to trigger traps. + // This is to prevent a bug related to dropping default tokens for characters + // to the VTT, which sometimes caused traps to trigger as though the dropped + // token has move. + let ignoreTokens = {}; + on('add:graphic', token => { + ignoreTokens[token.id] = true; + setTimeout(() => { + delete ignoreTokens[token.id]; + }, 1000); + }); + + return { + activateTrap, + getSearchDistance, + getTheme, + getTrapCollisions, + getTrapsOnPage, + noticeTrap, + registerTheme, + revealTrap, + REMOTE_ACTIVATE_CMD + }; +})(); + +/** + * The configured JSON properties of a trap. This can be extended to add + * additional properties for system-specific themes. + */ +const TrapEffect = (() => { + + let isJumpgate = ()=>{ + if(['jumpgate'].includes(Campaign().get('_release'))) { + isJumpgate = () => true; + } else { + isJumpgate = () => false; + } + return isJumpgate(); + }; + + const DEFAULT_FX = { + maxParticles: 100, + emissionRate: 3, + size: 35, + sizeRandom: 15, + lifeSpan: 10, + lifeSpanRandom: 3, + speed: 3, + speedRandom: 1.5, + gravity: {x: 0.01, y: 0.01}, + angle: 0, + angleRandom: 180, + duration: -1, + startColour: [220, 35, 0, 1], + startColourRandom: [62, 0, 0, 0.25], + endColour: [220, 35, 0, 0], + endColourRandom:[60, 60, 60, 0] + }; + + return class TrapEffect { + /** + * An API chat command that will be executed when the trap is activated. + * If the constants TRAP_ID and VICTIM_ID are provided, + * they will be replaced by the IDs for the trap token and the token for + * the trap's victim, respectively in the API chat command message. + * @type {string[]} + */ + get api() { + return this._effect.api || []; + } + + /** + * Specifications for an AreasOfEffect script graphic that is spawned + * when a trap is triggered. + * @typedef {object} TrapEffect.AreaOfEffect + * @property {String} name The name of the AoE effect. + * @property {vec2} [direction] The direction of the effect. If omitted, + * it will be extended toward the triggering token. + */ + + /** + * JSON defining a graphic to spawn with the AreasOfEffect script if + * it is installed and the trap is triggered. + * @type {TrapEffect.AreaOfEffect} + */ + get areaOfEffect() { + return this._effect.areaOfEffect; + } + + /** + * The delay for the trap in seconds. If undefined or 0, the trap + * activates instantly when triggered. + * @type {uint} + */ + get delay() { + return this._effect.delay; + } + + /** + * Whether the trap should be destroyed after it activates. + * @type {boolean} + */ + get destroyable() { + return this._effect.destroyable; + } + + /** + * A custom message that is displayed when a character notices a trap via + * passive detection. + * @type {string} + */ + get detectMessage() { + return this._effect.detectMessage; + } + + /** + * The shape of the trap's activated area. This could be an area where the + * trap token itself is the center of the effect (square or circle), or + * it could be a list of path IDs which define the activated areas. + * @type {(string[]|string)} + */ + get effectShape() { + if (this._trap.get('aura1_radius')) + return 'burst'; + else if (['circle', 'rectangle', 'square'].includes(this._effect.effectShape)) + return 'self'; + else + return this._effect.effectShape || 'self'; + } + + /** + * Configuration for special FX that are created when the trap activates. + * @type {object} + * @property {(string | FxJsonDefinition)} name + * Either the name of the FX that is created + * (built-in or user-made), or a custom FX JSON defintion. + * @property {vec2} offset + * The offset of the special FX, in units from the trap's token. + * @property {vec2} direction + * For beam-like FX, this specifies the vector for the FX's + * direction. If left blank, it will fire towards the token + * that activated the trap. + */ + get fx() { + return this._effect.fx; + } + + /** + * Whether the trap should only be announced to the GM when it is activated. + * @type {boolean} + */ + get gmOnly() { + return this._effect.gmOnly; + } + + /** + * A list of IDs for tokens that this trap ignores. These tokens will neither + * trigger nor be affected by the trap. + * @type {string[]} + */ + get ignores() { + return this._effect.ignores || []; + } + + /** + * Gets a copy of the trap's JSON properties. + * @readonly + * @type {object} + */ + get json() { + return _.clone(this._effect); + } + + /** + * JSON defining options to produce an explosion/implosion effect with + * the KABOOM script. + * @type {object} + */ + get kaboom() { + return this._effect.kaboom; + } + + /** + * The flavor message displayed when the trap is activated. If left + * blank, a default message will be generated based on the name of the + * trap's token. + * @type {string} + */ + get message() { + return this._effect.message || this._createDefaultTrapMessage(); + } + + /** + * The trap's name. + * @type {string} + */ + get name() { + return this._trap.get('name'); + } + + /** + * Secret notes for the GM. + * @type {string} + */ + get notes() { + return this._effect.notes; + } + + /** + * The layer that the trap gets revealed to. + * @type {string} + */ + get revealLayer() { + return this._effect.revealLayer; + } + + /** + * Whether the trap is revealed when it is spotted. + * @type {boolean} + */ + get revealWhenSpotted() { + return this._effect.revealWhenSpotted; + } + + /** + * The name of a sound played when the trap is activated. + * @type {string} + */ + get sound() { + return this._effect.sound; + } + + /** + * This is where the trap stops the token. + * If "edge", then the token is stopped at the trap's edge. + * If "center", then the token is stopped at the trap's center. + * If "none", the token is not stopped by the trap. + * @type {string} + */ + get stopAt() { + return this._effect.stopAt || 'center'; + } + + /** + * Command arguments for integration with the TokenMod script by The Aaron. + * @type {string} + */ + get tokenMod() { + return this._effect.tokenMod; + } + + /** + * The trap this TrapEffect represents. + * @type {Graphic} + */ + get trap() { + return this._trap; + } + + /** + * The ID of the trap. + * @type {uuid} + */ + get trapId() { + return this._trap.get('_id'); + } + + /** + * A list of path IDs defining an area that triggers this trap. + * @type {string[]} + */ + get triggerPaths() { + return this._effect.triggerPaths; + } + + /** + * A list of names or IDs for traps that will also be triggered when this + * trap is activated. + * @type {string[]} + */ + get triggers() { + return this._effect.triggers; + } + + /** + * The name for the trap/secret's type displayed in automated messages. + * @type {string} + */ + get type() { + return this._effect.type; + } + + /** + * The victim who activated the trap. + * @type {Graphic} + */ + get victim() { + return this._victim; + } + + /** + * The ID of the trap's victim's token. + * @type {uuid} + */ + get victimId() { + return this._victim && this._victim.get('_id'); + } + + /** + * The name of the trap's victim's character. + * @type {uuid} + */ + get victimCharName() { + if (this._victim) { + let char = getObj('character', this._victim.get('represents')); + if (char) + return char.get('name'); + } + return undefined; + } + + /** + * @param {Graphic} trap + * The trap's token. + * @param {Graphic} [victim] + * The token for the character that activated the trap. + */ + constructor(trap, victim) { + let effect = {}; + + // URI-escape the notes and remove the HTML elements. + let notes = trap.get('gmnotes'); + try { + notes = decodeURIComponent(notes).trim(); + } + catch(err) { + notes = unescape(notes).trim(); + } + if(notes) { + try { + notes = notes.split(/<[/]?.+?>/g).join(''); + effect = JSON.parse(notes); + } + catch(err) { + effect.message = 'ERROR: invalid TrapEffect JSON.'; + } + } + this._effect = effect; + this._trap = trap; + this._victim = victim; + } + + /** + * Activates the traps that are triggered by this trap. + */ + activateTriggers() { + let triggers = this.triggers; + if(triggers) { + let otherTraps = ItsATrap.getTrapsOnPage(this._trap.get('_pageid')); + let triggeredTraps = _.filter(otherTraps, trap => { + // Skip if the trap is disabled. + if(trap.get('status_interdiction')) + return false; + + return triggers.indexOf(trap.get('name')) !== -1 || + triggers.indexOf(trap.get('_id')) !== -1; + }); + + _.each(triggeredTraps, trap => { + ItsATrap.activateTrap(trap); + }); + } + } + + /** + * Announces the activated trap. + * This should be called by TrapThemes to inform everyone about a trap + * that has been triggered and its results. Fancy HTML formatting for + * the message is encouraged. If the trap's effect has gmOnly set, + * then the message will only be shown to the GM. + * This also takes care of playing the trap's sound, FX, and API command, + * they are provided. + * @param {string} [message] + * The message for the trap results displayed in the chat. If + * omitted, then the trap's raw message will be displayed. + */ + announce(message) { + message = message || this.message; + + // Display the message to everyone, unless it's a secret. + if(this.gmOnly) { + ItsATrap.Chat.whisperGM(message); + + // Whisper any secret notes to the GM. + if(this.notes) + ItsATrap.Chat.whisperGM(`Trap Notes:
${this.notes}`); + } + else { + ItsATrap.Chat.broadcast(message); + + // Whisper any secret notes to the GM. + if(this.notes) + ItsATrap.Chat.whisperGM(`Trap Notes:
${this.notes}`); + + // Reveal the trap if it's set to become visible. + if(this.trap.get('status_bleeding-eye')) + ItsATrap.revealTrap(this.trap); + + // Produce special outputs if it has any. + this.playSound(); + this.playFX(); + this.playAreaOfEffect(); + this.playKaboom(); + this.playTokenMod(); + this.playApi(); + + // Allow traps to trigger each other using the 'triggers' property. + this.activateTriggers(); + } + } + + /** + * Creates a default message for the trap. + * @private + * @return {string} + */ + _createDefaultTrapMessage() { + if(this.victim) { + if(this.name) + return `${this.victim.get('name')} set off a trap: ${this.name}!`; + else + return `${this.victim.get('name')} set off a trap!`; + } + else { + if(this.name) + return `${this.name} was activated!`; + else + return `A trap was activated!`; + } + } + + /** + * Executes the trap's API chat command if it has one. + */ + playApi() { + let api = this.api; + if(api) { + let commands; + if(api instanceof Array) + commands = api; + else + commands = [api]; + + // Run each API command. + _.each(commands, cmd => { + cmd = cmd.replace(/TRAP_ID/g, this.trapId) + .replace(/VICTIM_ID/g, this.victimId) + .replace(/VICTIM_CHAR_NAME/g, this.victimCharName) + .replace(/\\\[/g, '[') + .replace(/\\\]/g, ']') + .replace(/\\{/g, '{') + .replace(/\\}/g, '}') + .replace(/\\@/g, '@') + .replace(/(\t|\n|\r)/g, ' ') + .replace(/\[\[ +/g, '[[') + .replace(/ +\]\]/g, ']]'); + sendChat('ItsATrap-api', `${cmd}`); + }); + } + } + + /** + * Spawns the AreasOfEffect graphic for this trap. If AreasOfEffect is + * not installed, then this has no effect. + */ + playAreaOfEffect() { + if(typeof AreasOfEffect !== 'undefined' && this.areaOfEffect) { + let direction = (this.areaOfEffect.direction && VecMath.scale(this.areaOfEffect.direction, 70)) || + (() => { + if(this._victim) + return [ + this._victim.get('left') - this._trap.get('left'), + this._victim.get('top') - this._trap.get('top') + ]; + else + return [0, 0]; + })(); + direction[2] = 0; + + let p1 = [this._trap.get('left'), this._trap.get('top'), 1]; + let p2 = VecMath.add(p1, direction); + if(VecMath.dist(p1, p2) > 0) { + let segments = [[p1, p2]]; + let pathJson = PathMath.segmentsToPath(segments); + let path = createObj( (isJumpgate() ? 'pathv2' : 'path'), _.extend(pathJson, { + _pageid: this._trap.get('_pageid'), + layer: 'objects', + stroke: '#ff0000' + })); + + // Construct a fake player object to create the effect for. + // This will correctly set the AoE's controlledby property to '' + // to denote that it is controlled by no one. + let fakePlayer = { + get: function() { + return ''; + } + }; + + // Create the AoE. + let aoeGraphic = AreasOfEffect.applyEffect(fakePlayer, this.areaOfEffect.name, path); + aoeGraphic.set('layer', 'map'); + toFront(aoeGraphic); + } + } + } + + /** + * Spawns built-in or custom FX for an activated trap. + */ + playFX() { + var pageId = this._trap.get('_pageid'); + + if(this.fx) { + var offset = this.fx.offset || [0, 0]; + var origin = [ + this._trap.get('left') + offset[0]*70, + this._trap.get('top') + offset[1]*70 + ]; + + var direction = this.fx.direction || (() => { + if(this._victim) + return [ + this._victim.get('left') - origin[0], + this._victim.get('top') - origin[1] + ]; + else + return [ 0, 1 ]; + })(); + + this._playFXNamed(this.fx.name, pageId, origin, direction); + } + } + + /** + * Play FX using a named effect. + * @private + * @param {string} name + * @param {uuid} pageId + * @param {vec2} origin + * @param {vec2} direction + */ + _playFXNamed(name, pageId, origin, direction) { + let x = origin[0]; + let y = origin[1]; + + let fx = name; + let isBeamLike = false; + + var custFx = findObjs({ _type: 'custfx', name: name })[0]; + if(custFx) { + fx = custFx.get('_id'); + isBeamLike = custFx.get('definition').angle === -1; + } + else + isBeamLike = !!_.find(['beam-', 'breath-', 'splatter-'], type => { + return name.startsWith(type); + }); + + if(isBeamLike) { + let p1 = { + x: x, + y: y + }; + let p2 = { + x: x + direction[0], + y: y + direction[1] + }; + + spawnFxBetweenPoints(p1, p2, fx, pageId); + } + else + spawnFx(x, y, fx, pageId); + } + + /** + * Produces an explosion/implosion effect with the KABOOM script. + */ + playKaboom() { + if(typeof KABOOM !== 'undefined' && this.kaboom) { + let center = [this.trap.get('left'), this.trap.get('top')]; + let options = { + effectPower: this.kaboom.power, + effectRadius: this.kaboom.radius, + type: this.kaboom.type, + scatter: this.kaboom.scatter + }; + + KABOOM.NOW(options, center); + } + } + + /** + * Plays a TrapEffect's sound, if it has one. + */ + playSound() { + if(this.sound) { + var sound = findObjs({ + _type: 'jukeboxtrack', + title: this.sound + })[0]; + if(sound) { + sound.set('playing', true); + sound.set('softstop', false); + } + else { + let msg = 'Could not find sound "' + this.sound + '".'; + sendChat('ItsATrap-api', msg); + } + } + } + + /** + * Invokes TokenMod on the victim's token. + */ + playTokenMod() { + if(typeof TokenMod !== 'undefined' && this.tokenMod && this._victim) { + let victimId = this._victim.get('id'); + let command = '!token-mod ' + this.tokenMod + ' --ids ' + victimId; + + // Since playerIsGM fails for the player ID "API", we'll need to + // temporarily switch TokenMod's playersCanUse_ids option to true. + if(!TrapEffect.tokenModTimeout) { + let temp = state.TokenMod.playersCanUse_ids; + TrapEffect.tokenModTimeout = setTimeout(() => { + state.TokenMod.playersCanUse_ids = temp; + TrapEffect.tokenModTimeout = undefined; + }, 1000); + } + + state.TokenMod.playersCanUse_ids = true; + sendChat('ItsATrap-api', command); + } + } + + /** + * Saves the current trap effect properties to the trap's token. + */ + save() { + this._trap.set('gmnotes', JSON.stringify(this.json)); + } + }; +})(); + +/** + * A small library for checking if a token has line of sight to other tokens. + */ +var LineOfSight = (() => { + + /** + * Gets the point for a token. + * @private + * @param {Graphic} token + * @return {vec3} + */ + function _getPt(token) { + return [token.get('left'), token.get('top'), 1]; + } + + return class LineOfSight { + + /** + * Gets the tokens that a token has line of sight to. + * @private + * @param {Graphic} token + * @param {Graphic[]} otherTokens + * @param {number} [range=Infinity] + * The line-of-sight range in pixels. + * @param {boolean} [isSquareRange=false] + * @return {Graphic[]} + */ + static filterTokens(token, otherTokens, range, isSquareRange) { + if(_.isUndefined(range)) + range = Infinity; + + let pageId = token.get('_pageid'); + let tokenPt = _getPt(token); + let tokenRW = token.get('width')/2-1; + let tokenRH = token.get('height')/2-1; + + let wallPaths = findObjs({ + _type: 'path', + _pageid: pageId, + layer: 'walls' + }); + let wallSegments = PathMath.toSegments(wallPaths); + + return _.filter(otherTokens, other => { + let otherPt = _getPt(other); + let otherRW = other.get('width')/2; + let otherRH = other.get('height')/2; + + // Skip tokens that are out of range. + if(isSquareRange && ( + Math.abs(tokenPt[0]-otherPt[0]) >= range + otherRW + tokenRW || + Math.abs(tokenPt[1]-otherPt[1]) >= range + otherRH + tokenRH)) + return false; + else if(!isSquareRange && VecMath.dist(tokenPt, otherPt) >= range + tokenRW + otherRW) + return false; + + let segToOther = [tokenPt, otherPt]; + return !_.find(wallSegments, wallSeg => { + return PathMath.segmentIntersection(segToOther, wallSeg); + }); + }); + } + }; +})(); + +/** + * A module that presents a wizard for setting up traps instead of + * hand-crafting the JSON for them. + */ +var ItsATrapCreationWizard = (() => { + const DISPLAY_WIZARD_CMD = '!ItsATrap_trapCreationWizard_showMenu'; + const MODIFY_CORE_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapCore'; + const MODIFY_THEME_PROPERTY_CMD = '!ItsATrap_trapCreationWizard_modifyTrapTheme'; + + const MENU_CSS = { + 'optionsTable': { + 'width': '100%' + }, + 'menu': { + 'background': '#fff', + 'border': 'solid 1px #000', + 'border-radius': '5px', + 'font-weight': 'bold', + 'margin-bottom': '1em', + 'overflow': 'hidden' + }, + 'menuBody': { + 'padding': '5px', + 'text-align': 'center' + }, + 'menuHeader': { + 'background': '#000', + 'color': '#fff', + 'text-align': 'center' + } + }; + + const LPAREN = '('; + const RPAREN = ')'; + + const LBRACKET = '['; + const RBRACKET = ']'; + + const LBRACE = '{'; + const RBRACE = '}'; + + const ATSIGN = '@'; + + // The last trap that was edited in the wizard. + let curTrap; + + /** + * Displays the menu for setting up a trap. + * @param {string} who + * @param {string} playerid + * @param {Graphic} trapToken + */ + function displayWizard(who, playerId, trapToken) { + curTrap = trapToken; + let content = new HtmlBuilder('div'); + + if(!trapToken.get('status_cobweb')) { + trapToken.set('status_cobweb', true); + trapToken.set('name', 'A cunning trap'); + trapToken.set('aura1_square', true); + trapToken.set('gmnotes', getDefaultJson()); + } + + // Core properties + content.append('h4', 'Core properties'); + let coreProperties = getCoreProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, coreProperties)); + + // Trigger properties + content.append('h4', 'Trigger properties', { + style: { 'margin-top' : '2em' } + }); + let triggerProperties = getTriggerProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, triggerProperties)); + + // Activation properties + content.append('h4', 'Activation properties', { + style: { 'margin-top' : '2em' } + }); + let shapeProperties = getShapeProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, shapeProperties)); + + // Reveal properties + content.append('h4', 'Detection properties', { + style: { 'margin-top' : '2em' } + }); + let revealProperties = getRevealProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, revealProperties)); + + // Script properties + content.append('h4', 'External script properties', { + style: { 'margin-top': '2em' } + }); + let scriptProperties = getScriptProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, scriptProperties)); + + // Theme properties + let theme = ItsATrap.getTheme(); + if(theme.getThemeProperties) { + content.append('h4', 'Theme-specific properties', { + style: { 'margin-top' : '2em' } + }); + let properties = theme.getThemeProperties(trapToken); + content.append(_displayWizardProperties(MODIFY_THEME_PROPERTY_CMD, properties)); + } + + // Remote activate button + content.append('div', `[Activate Trap](${ItsATrap.REMOTE_ACTIVATE_CMD} ${curTrap.get('_id')})`, { + style: { 'margin-top' : '2em' } + }); + + let menu = _showMenuPanel('Trap Configuration', content); + ItsATrap.Chat.whisperGM(menu.toString(MENU_CSS)); + } + + /** + * Creates the table for a list of trap properties. + * @private + */ + function _displayWizardProperties(modificationCommand, properties) { + let table = new HtmlBuilder('table'); + _.each(properties, prop => { + let row = table.append('tr', undefined, { + title: prop.desc + }); + + // Construct the list of parameter prompts. + let params = []; + let paramProperties = prop.properties || [prop]; + _.each(paramProperties, item => { + let options = ''; + if(item.options) + options = '|' + item.options.join('|'); + params.push(`?{${item.name} ${item.desc} ${options}}`); + }); + + row.append('td', `[${prop.name}](${modificationCommand} ${prop.id}&&${params.join('&&')})`, { + style: { 'font-size': '0.8em' } + }); + + row.append('td', `${prop.value || ''}`, { + style: { 'font-size': '0.8em', 'min-width': '1in' } + }); + }); + + return table; + } + + /** + * Gets a list of the core trap properties for a trap token. + * @param {Graphic} token + * @return {object[]} + */ + function getCoreProperties(trapToken) { + let trapEffect = new TrapEffect(trapToken); + + return [ + { + id: 'name', + name: 'Name', + desc: 'The name of the trap', + value: trapToken.get('name') + }, + { + id: 'type', + name: 'Type', + desc: 'Is this a trap, or some other hidden secret?', + value: trapEffect.type || 'trap' + }, + { + id: 'message', + name: 'Message', + desc: 'The message displayed when the trap is activated.', + value: trapEffect.message + }, + { + id: 'disabled', + name: 'Disabled?', + desc: 'A disabled trap will not activate when triggered, but can still be spotted with passive perception.', + value: trapToken.get('status_interdiction') ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'gmOnly', + name: 'Show GM Only?', + desc: 'When the trap is activated, should its results only be displayed to the GM?', + value: trapEffect.gmOnly ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'notes', + name: 'Secret Notes', + desc: 'Additional secret notes shown only to the GM when the trap is activated.', + value: trapEffect.notes || '-' + } + ]; + } + + /** + * Produces JSON for default trap properties. + * @return {string} + */ + function getDefaultJson() { + return JSON.stringify({ + effectShape: 'self', + stopAt: 'center' + }); + } + + /** + * Gets a list of the core trap properties for a trap token dealing + * with revealing the trap. + * @param {Graphic} token + * @return {object[]} + */ + function getRevealProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + + return [ + { + id: 'searchDist', + name: 'Max Search Distance', + desc: 'How far away can characters passively search for this trap?', + value: (() => { + let page = getObj('page', trapToken.get('_pageid')); + let units = page.get('scale_units'); + let dist = trapToken.get('aura2_radius') || trapEffect.searchDist; + + if (dist) + return `${dist} ${units}`; + else + return '-'; + })() + //value: trapToken.get('aura2_radius') || trapEffect.searchDist || '-' + }, + { + id: 'detectMessage', + name: 'Detection Message', + desc: 'What message is displayed when a character notices the trap with passive detection?', + value: trapEffect.detectMessage || '-' + }, + { + id: 'revealOpts', + name: 'Reveal the Trap?', + desc: 'Whether the trap should be revealed when the trap is activated and/or spotted, or if not, whether the trap troken is deleted after it activates.', + value: (() => { + let onActivate = trapToken.get('status_bleeding-eye'); + let onSpotted = trapEffect.revealWhenSpotted; + let layer = trapEffect.revealLayer || 'map'; + + if (onActivate && onSpotted) + return `Reveal to ${layer} layer when activated or spotted.`; + else if (onActivate) + return `Reveal to ${layer} layer when activated.`; + else if (onSpotted) + return `Reveal to ${layer} layer when spotted.`; + else + return 'Do not reveal.'; + })(), + properties: [ + { + id: 'onActivate', + name: 'Reveal when activated?', + desc: 'Should the trap be revealed when it is activated?', + options: ['yes', 'no'] + }, + { + id: 'onSpotted', + name: 'Reveal when spotted?', + desc: 'Should the trap be revealed when it is spotted?', + options: ['yes', 'no'] + }, + { + id: 'layer', + name: 'Reveal Layer', + desc: 'Which layer should the trap be moved to when it is revealed?', + options: ['map', 'objects'] + } + ] + } + ]; + } + + /** + * Gets a list of the core trap properties for a trap token defining + * the shape of the trap. + * @param {Graphic} token + * @return {object[]} + */ + function getShapeProperties(trapToken) { + let trapEffect = new TrapEffect(trapToken); + + return _.compact([ + { + id: 'effectShape', + name: 'Activation Area', + desc: `The area of the trap that actually affects tokens after it is triggered. To set paths, you must also select one or more paths defining the trap's blast area. A fill color must be set for tokens inside the path to be affected.`, + value: trapEffect.effectShape || 'self', + options: [ 'self', 'burst', 'set selected shapes'] + }, + (() => { + if (trapEffect.effectShape === 'burst') + return { + id: 'effectDistance', + name: 'Burst Radius', + desc: `The radius of the trap's burst activation area.`, + value: (() => { + let radius = trapToken.get('aura1_radius') || 0; + let page = getObj('page', trapToken.get('_pageid')); + let units = page.get('scale_units'); + return `${radius} ${units}`; + })() + }; + })(), + { + id: 'fx', + name: 'Special FX', + desc: 'What special FX are displayed when the trap is activated?', + value: (() => { + let fx = trapEffect.fx; + if(fx) { + let result = fx.name; + if(fx.offset) + result += '; Offset: ' + fx.offset; + if(fx.direction) + result += '; Direction: ' + fx.direction; + return result; + } + else + return 'None'; + })(), + properties: [ + { + id: 'name', + name: 'FX Name', + desc: 'The name of the special FX.' + }, + { + id: 'offset', + name: 'FX Offset', + desc: 'The offset ' + LPAREN + 'in units' + RPAREN + ' of the special FX from the trap\'s center. Format: ' + LBRACKET + 'X,Y' + RBRACKET + }, + { + id: 'direction', + name: 'FX Direction', + desc: 'The directional vector for the special FX ' + LPAREN + 'Leave blank to direct it towards characters' + RPAREN + '. Format: ' + LBRACKET + 'X,Y' + RBRACKET + } + ] + }, + { + id: 'sound', + name: 'Sound', + desc: 'A sound from your jukebox that will play when the trap is activated.', + value: trapEffect.sound || '-', + options: (() => { + let tracks = findObjs({ + _type: 'jukeboxtrack' + }); + let trackNames = _.map(tracks, track => { + return _htmlEncode(track.get('title')); + }); + trackNames.sort(); + return ['none', ...trackNames]; + })() + }, + { + id: 'triggers', + name: 'Chained Trap IDs', + desc: 'A list of the names or token IDs for other traps that are triggered when this trap is activated.', + value: (() => { + let triggers = trapEffect.triggers; + if(_.isString(triggers)) + triggers = [triggers]; + + if(triggers) + return triggers.join(', '); + else + return 'none'; + })(), + options: ['none', 'set selected traps'] + }, + { + id: 'destroyable', + name: 'Delete after Activation?', + desc: 'Whether to delete the trap token after it is activated.', + value: trapEffect.destroyable ? 'yes': 'no', + options: ['yes', 'no'] + } + ]); + } + + /** + * Gets a a list of the trap properties for a trap token dealing with + * supported API scripts. + */ + function getScriptProperties(trapToken) { + let trapEffect = new TrapEffect(trapToken); + + return _.compact([ + { + id: 'api', + name: 'API Command', + desc: 'An API command which the trap runs when it is activated. The constants TRAP_ID and VICTIM_ID will be replaced by the object IDs for the trap and victim. Multiple API commands are now supported by separating each command with ";;". Certain special characters must be escaped. See README section about the API Command property for details.', + value: (() => { + if (trapEffect.api.length > 0) { + let result = ''; + _.each(trapEffect.api, cmd => { + result += cmd.replace(/\\\[/g, LBRACKET) + .replace(/\\\]/g, RBRACKET) + .replace(/\\{/g, LBRACE) + .replace(/\\}/g, RBRACE) + .replace(/\\@/g, ATSIGN) + "
"; + }); + return result; + } + else + return '-'; + + + })() + }, + + // Requires AreasOfEffect script. + (() => { + if(typeof AreasOfEffect !== 'undefined') { + let effectNames = _.map(AreasOfEffect.getEffects(), effect => { + return effect.name; + }); + + return { + id: 'areaOfEffect', + name: 'Areas of Effect script', + desc: 'Specifies an AoE graphic to be spawned by the trap.', + value: (() => { + let aoe = trapEffect.areaOfEffect; + if(aoe) { + let result = aoe.name; + if(aoe.direction) + result += '; Direction: ' + aoe.direction; + return result; + } + else + return 'None'; + })(), + properties: [ + { + id: 'name', + name: 'AoE Name', + desc: 'The name of the saved AreasOfEffect effect.', + options: ['none', ...effectNames] + }, + { + id: 'direction', + name: 'AoE Direction', + desc: 'The direction of the AoE effect. Optional. If omitted, then the effect will be directed toward affected tokens. Format: ' + LBRACKET + 'X,Y' + RBRACKET + } + ] + }; + } + })(), + + // Requires KABOOM script by PaprikaCC (Bodin Punyaprateep). + (() => { + if(typeof KABOOM !== 'undefined') + return { + id: 'kaboom', + name: 'KABOOM script', + desc: 'An explosion/implosion generated by the trap with the KABOOM script by PaprikaCC.', + value: (() => { + let props = trapEffect.kaboom; + if(props) { + let result = props.power + ' ' + props.radius + ' ' + (props.type || 'default'); + if(props.scatter) + result += ' ' + 'scatter'; + return result; + } + else + return 'None'; + })(), + properties: [ + { + id: 'power', + name: 'Power', + desc: 'The power of the KABOOM effect.' + }, + { + id: 'radius', + name: 'Radius', + desc: 'The radius of the KABOOM effect.' + }, + { + id: 'type', + name: 'FX Type', + desc: 'The type of element to use for the KABOOM FX.' + }, + { + id: 'scatter', + name: 'Scatter', + desc: 'Whether to apply scattering to tokens affected by the KABOOM effect.', + options: ['no', 'yes'] + } + ] + }; + })(), + + // Requires the TokenMod script by The Aaron. + (() => { + if(typeof TokenMod !== 'undefined') + return { + id: 'tokenMod', + name: 'TokenMod script', + desc: 'Modify affected tokens with the TokenMod script by The Aaron.', + value: trapEffect.tokenMod || '-' + }; + })() + ]); + } + + /** + * Gets a list of the core trap properties for a trap token. + * @param {Graphic} token + * @return {object[]} + */ + function getTriggerProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + + return _.compact([ + { + id: 'triggerPaths', + name: 'Trigger Area', + desc: 'The trigger area for the trap. Characters that pass through this area will cause the trap to activate. To set paths, you must also select the paths that trigger the trap.', + value: (() => { + if (trapEffect.triggerPaths) + return trapEffect.triggerPaths; + else { + if (trapToken.get('aura1_square')) + return 'self - rectangle'; + else + return 'self - circle'; + } + })(), + options: ['self - rectangle', 'self - circle', 'set selected lines', 'none'] + }, + ...(() => { + if (trapEffect.triggerPaths === 'none') + return []; + else { + return [ + { + id: 'stopAt', + name: 'Trigger Collision', + desc: 'Does this trap stop tokens that pass through its trigger area?', + value: (() => { + let type = trapEffect.stopAt || 'center'; + if (type === 'center') + return 'Move to center of trap token.'; + else if (type === 'edge') + return 'Stop at edge of trigger area.'; + else + return 'None'; + })(), + options: ['center', 'edge', 'none'] + }, + { + id: 'ignores', + name: 'Ignore Token IDs', + desc: 'Select one or more tokens to be ignored by this trap.', + value: trapEffect.ignores || 'none', + options: ['none', 'set selected tokens'] + }, + { + id: 'flying', + name: 'Affects Flying Tokens?', + desc: 'Should this trap affect flying tokens ' + LPAREN + 'fluffy-wing status ' + RPAREN + '?', + value: trapToken.get('status_fluffy-wing') ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'delay', + name: 'Delay Activation', + desc: 'When the trap is triggered, its effect is delayed for the specified number of seconds. For best results, also be sure to set an area effect for the trap and set the Stops Tokens At property of the trap to None.', + value: (() => { + if (trapEffect.delay) + return trapEffect.delay + ' seconds'; + else + return '-'; + })() + } + ]; + } + })() + ]); + } + + /** + * HTML-decodes a string. + * @param {string} str + * @return {string} + */ + function _htmlDecode(str) { + return str.replace(/#(\d+);/g, (match, code) => { + return String.fromCharCode(code); + }); + } + + /** + * HTML-encodes a string, making it safe to use in chat-based action buttons. + * @param {string} str + * @return {string} + */ + function _htmlEncode(str) { + return str.replace(/[{}()\[\]<>!@#$%^&*\/\\'"+=,.?]/g, match => { + let charCode = match.charCodeAt(0); + return `#${charCode};`; + }); + } + + /** + * Changes a property for a trap. + * @param {Graphic} trapToken + * @param {Array} argv + * @param {(Graphic|Path)[]} selected + */ + function modifyTrapProperty(trapToken, argv, selected) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let prop = argv[0]; + let params = argv.slice(1); + + if(prop === 'name') + trapToken.set('name', params[0]); + if(prop === 'type') + trapEffect.type = params[0]; + if(prop === 'api') { + if(params[0]) + trapEffect.api = params[0].split(";;"); + else + trapEffect.api = []; + } + if(prop === 'areaOfEffect') { + if(params[0] && params[0] !== 'none') { + trapEffect.areaOfEffect = {}; + trapEffect.areaOfEffect.name = params[0]; + try { + trapEffect.areaOfEffect.direction = JSON.parse(params[1]); + } catch(err) {} + } + else + trapEffect.areaOfEffect = undefined; + } + if(prop === 'delay') + trapEffect.delay = params[0] || undefined; + if(prop === 'destroyable') + trapEffect.destroyable = params[0] === 'yes'; + if (prop === 'detectMessage') + trapEffect.detectMessage = params[0]; + if(prop === 'disabled') + trapToken.set('status_interdiction', params[0] === 'yes'); + + if(prop === 'effectDistance') + trapToken.set('aura1_radius', parseInt(params[0]) || ''); + + if(prop === 'effectShape') { + if (params[0] === 'self') { + trapEffect.effectShape = 'self'; + trapToken.set('aura1_radius', ''); + } + else if (params[0] === 'burst') { + trapEffect.effectShape = 'burst'; + trapToken.set('aura1_radius', 10); + } + else if(params[0] === 'set selected shapes' && selected) { + trapEffect.effectShape = _.map(selected, path => { + return path.get('_id'); + }); + trapToken.set('aura1_radius', ''); + } + else + throw Error('Unexpected effectShape value: ' + params[0]); + } + if(prop === 'flying') + trapToken.set('status_fluffy-wing', params[0] === 'yes'); + if(prop === 'fx') { + if(params[0]) { + trapEffect.fx = {}; + trapEffect.fx.name = params[0]; + try { + trapEffect.fx.offset = JSON.parse(params[1]); + } + catch(err) {} + try { + trapEffect.fx.direction = JSON.parse(params[2]); + } + catch(err) {} + } + else + trapEffect.fx = undefined; + } + if(prop === 'gmOnly') + trapEffect.gmOnly = params[0] === 'yes'; + if(prop === 'ignores') + if(params[0] === 'set selected tokens' && selected) + trapEffect.ignores = _.map(selected, token => { + return token.get('_id'); + }); + else + trapEffect.ignores = undefined; + if(prop === 'kaboom') + if(params[0]) + trapEffect.kaboom = { + power: parseInt(params[0]), + radius: parseInt(params[1]), + type: params[2] || undefined, + scatter: params[3] === 'yes' + }; + else + trapEffect.kaboom = undefined; + if(prop === 'message') + trapEffect.message = params[0]; + if(prop === 'notes') + trapEffect.notes = params[0]; + + if (prop === 'revealOpts') { + trapToken.set('status_bleeding-eye', params[0] === 'yes'); + trapEffect.revealWhenSpotted = params[1] === 'yes'; + trapEffect.revealLayer = params[2]; + } + + if(prop === 'searchDist') + trapToken.set('aura2_radius', parseInt(params[0]) || ''); + if(prop === 'sound') + trapEffect.sound = _htmlDecode(params[0]); + if(prop === 'stopAt') + trapEffect.stopAt = params[0]; + if(prop === 'tokenMod') + trapEffect.tokenMod = params[0]; + if(prop === 'triggers') { + if (params[0] === 'set selected traps' && selected) { + trapEffect.triggers = _.map(selected, token => { + let tokenId = token.get('_id'); + if (tokenId !== trapToken.get('_id')) + return token.get('_id'); + }); + } + else + trapEffect.triggers = undefined; + } + if(prop === 'triggerPaths') { + if (params[0] === 'self - circle') { + trapEffect.triggerPaths = undefined; + trapToken.set('aura1_square', false); + } + else if (params[0] === 'self - rectangle') { + trapEffect.triggerPaths = undefined; + trapToken.set('aura1_square', true); + } + else if (params[0] === 'set selected lines' && selected) { + trapEffect.triggerPaths = _.map(selected, path => { + return path.get('_id'); + }); + trapToken.set('aura1_square', false); + } + else if (params[0] === 'none') { + trapEffect.triggerPaths = 'none'; + trapToken.set('aura1_square', false); + } + else { + trapEffect.triggerPaths = undefined; + trapToken.set('aura1_square', false); + } + } + + trapToken.set('gmnotes', JSON.stringify(trapEffect)); + } + + /** + * Displays one of the script's menus. + * @param {string} header + * @param {(string|HtmlBuilder)} content + * @return {HtmlBuilder} + */ + function _showMenuPanel(header, content) { + let menu = new HtmlBuilder('.menu'); + menu.append('.menuHeader', header); + menu.append('.menuBody', content); + return menu; + } + + + + on('ready', () => { + // Delete the 3.9.4 version of the macro. + let oldMacros = findObjs({ + _type: 'macro', + name: 'ItsATrap_trapCreationWizard' + }); + if (oldMacros.length > 0) { + ItsATrap.Chat.whisperGM(`

Notice: It's A Trap v3.10

` + + `

The old It's A Trap macro has been replaced with a shorter ` + + `version named "TrapMaker". Please re-enable it on your macro ` + + `settings. By popular demand, it no longer appears as a token ` + `action.

` + + `

Please note that some of the trap menu properties have ` + + `been regrouped or condensed together in order to present a cleaner ` + + `and hopefully more intuitive interface. This should have no effect ` + + `on your existing traps. They should work just as they did before ` + + `this update.

` + + `

Please read the script's updated documentation for more ` + + `details.

`); + } + _.each(oldMacros, macro => { + macro.remove(); + }); + + // Create the 3.10 version of the macro. + let macro = findObjs({ + _type: 'macro', + name: 'TrapMaker' + })[0]; + if(!macro) { + let players = findObjs({ + _type: 'player' + }); + let gms = _.filter(players, player => { + return playerIsGM(player.get('_id')); + }); + + _.each(gms, gm => { + createObj('macro', { + _playerid: gm.get('_id'), + name: 'TrapMaker', + action: DISPLAY_WIZARD_CMD + }); + }); + } + }); + + on('chat:message', msg => { + try { + // Get the selected tokens/paths if any. + let selected; + if(msg.selected) { + selected = _.map(msg.selected, sel => { + return getObj(sel._type, sel._id); + }); + } + + if(msg.content.startsWith(DISPLAY_WIZARD_CMD)) { + if (!msg.selected || !msg.selected[0]) + throw new Error("You must have a token selected to use trap macro."); + + let trapToken = getObj('graphic', msg.selected[0]._id); + displayWizard(msg.who, msg.playerId, trapToken); + } + if(msg.content.startsWith(MODIFY_CORE_PROPERTY_CMD)) { + let params = msg.content.replace(MODIFY_CORE_PROPERTY_CMD + ' ', '').split('&&'); + modifyTrapProperty(curTrap, params, selected); + displayWizard(msg.who, msg.playerId, curTrap); + } + if(msg.content.startsWith(MODIFY_THEME_PROPERTY_CMD)) { + let params = msg.content.replace(MODIFY_THEME_PROPERTY_CMD + ' ', '').split('&&'); + let theme = ItsATrap.getTheme(); + theme.modifyTrapProperty(curTrap, params, selected); + displayWizard(msg.who, msg.playerId, curTrap); + } + } + catch (err) { + ItsATrap.Chat.error(err); + } + }); + + return { + displayWizard, + DISPLAY_WIZARD_CMD, + MODIFY_CORE_PROPERTY_CMD, + MODIFY_THEME_PROPERTY_CMD + }; +})(); + +/** + * Base class for trap themes: System-specific strategies for handling + * automation of trap activation results and passive searching. + * @abstract + */ +var TrapTheme = (() => { + + /** + * The name of the theme used to register it. + * @type {string} + */ + return class TrapTheme { + + /** + * A sample CSS object for trap HTML messages created with HTML Builder. + */ + static get css() { + return { + 'bold': { + 'font-weight': 'bold' + }, + 'critFail': { + 'border': '2px solid #B31515' + }, + 'critSuccess': { + 'border': '2px solid #3FB315' + }, + 'hit': { + 'color': '#f00', + 'font-weight': 'bold' + }, + 'miss': { + 'color': '#620', + 'font-weight': 'bold' + }, + 'paddedRow': { + 'padding': '1px 1em' + }, + 'rollResult': { + 'background-color': '#FEF68E', + 'cursor': 'help', + 'font-size': '1.1em', + 'font-weight': 'bold', + 'padding': '0 3px' + }, + 'trapMessage': { + 'background-color': '#ccc', + 'font-style': 'italic' + }, + 'trapTable': { + 'background-color': '#fff', + 'border': 'solid 1px #000', + 'border-collapse': 'separate', + 'border-radius': '10px', + 'overflow': 'hidden', + 'width': '100%' + }, + 'trapTableHead': { + 'background-color': '#000', + 'color': '#fff', + 'font-weight': 'bold' + } + }; + } + + get name() { + throw new Error('Not implemented.'); + } + + /** + * Activates a TrapEffect by displaying the trap's message and + * automating any system specific trap mechanics for it. + * @abstract + * @param {TrapEffect} effect + */ + activateEffect(effect) { + throw new Error('Not implemented.'); + } + + /** + * Gets a list of a trap's theme-specific configured properties. + * @param {Graphic} trap + * @return {TrapProperty[]} + */ + getThemeProperties(trap) { + return []; + } + + /** + * Displays the message to notice a trap. + * @param {Character} character + * @param {Graphic} trap + */ + static htmlNoticeTrap(character, trap) { + let content = new HtmlBuilder(); + let effect = new TrapEffect(trap, character); + + content.append('.paddedRow trapMessage', character.get('name') + ' notices a ' + (effect.type || 'trap') + ':'); + if (effect.detectMessage) + content.append('.paddedRow', effect.detectMessage); + else + content.append('.paddedRow', trap.get('name')); + + return TrapTheme.htmlTable(content, '#000', effect); + } + + /** + * Sends an HTML-stylized message about a noticed trap. + * @param {(HtmlBuilder|string)} content + * @param {string} borderColor + * @param {TrapEffect} [effect] + * @return {HtmlBuilder} + */ + static htmlTable(content, borderColor, effect) { + let type = (effect && effect.type) || 'trap'; + + let table = new HtmlBuilder('table.trapTable', '', { + style: { 'border-color': borderColor } + }); + table.append('thead.trapTableHead', '', { + style: { 'background-color': borderColor } + }).append('th', 'IT\'S A ' + type.toUpperCase() + '!!!'); + + table.append('tbody').append('tr').append('td', content, { + style: { 'padding': '0' } + }); + return table; + } + + /** + * Changes a theme-specific property for a trap. + * @param {Graphic} trapToken + * @param {Array} params + */ + modifyTrapProperty(trapToken, argv) { + // Default implementation: Do nothing. + } + + /** + * The system-specific behavior for a character passively noticing a trap. + * @abstract + * @param {Graphic} trap + * The trap's token. + * @param {Graphic} charToken + * The character's token. + */ + passiveSearch(trap, charToken) { + throw new Error('Not implemented.'); + } + }; +})(); + +/** + * A base class for trap themes using the D20 system (D&D 3.5, 4E, 5E, Pathfinder, etc.) + * @abstract + */ +var D20TrapTheme = (() => { + + return class D20TrapTheme extends TrapTheme { + + /** + * @inheritdoc + */ + activateEffect(effect) { + let character = getObj('character', effect.victim.get('represents')); + let effectResults = effect.json; + + // Automate trap attack/save mechanics. + Promise.resolve() + .then(() => { + effectResults.character = character; + if(character) { + if(effectResults.attack) + return this._doTrapAttack(character, effectResults); + else if(effectResults.save && effectResults.saveDC) + return this._doTrapSave(character, effectResults); + } + return effectResults; + }) + .then(effectResults => { + let html = D20TrapTheme.htmlTrapActivation(effectResults); + effect.announce(html.toString(TrapTheme.css)); + }) + .catch(err => { + ItsATrap.Chat.error(err); + }); + } + + /** + * Does a trap's attack roll. + * @private + */ + _doTrapAttack(character, effectResults) { + return Promise.all([ + this.getAC(character), + CharSheetUtils.rollAsync('1d20 + ' + effectResults.attack) + ]) + .then(tuple => { + let ac = tuple[0]; + let atkRoll = tuple[1]; + + ac = ac || 10; + effectResults.ac = ac; + effectResults.roll = atkRoll; + effectResults.trapHit = atkRoll.total >= ac; + return effectResults; + }); + } + + /** + * Does a trap's save. + * @private + */ + _doTrapSave(character, effectResults) { + return this.getSaveBonus(character, effectResults.save) + .then(saveBonus => { + saveBonus = saveBonus || 0; + effectResults.saveBonus = saveBonus; + return CharSheetUtils.rollAsync('1d20 + ' + saveBonus); + }) + .then((saveRoll) => { + effectResults.roll = saveRoll; + effectResults.trapHit = saveRoll.total < effectResults.saveDC; + return effectResults; + }); + } + + /** + * Gets a character's AC. + * @abstract + * @param {Character} character + * @return {Promise} + */ + getAC(character) { + throw new Error('Not implemented.'); + } + + /** + * Gets a character's passive wisdom (Perception). + * @abstract + * @param {Character} character + * @return {Promise} + */ + getPassivePerception(character) { + throw new Error('Not implemented.'); + } + + /** + * Gets a character's saving throw bonus. + * @abstract + * @param {Character} character + * @return {Promise} + */ + getSaveBonus(character, saveName) { + throw new Error('Not implemented.'); + } + + /** + * @inheritdoc + */ + getThemeProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let LPAREN = '('; + let RPAREN = ')'; + + let LBRACE = '['; + let RBRACE = ']'; + + return [ + { + id: 'attack', + name: 'Attack Bonus', + desc: `The trap's attack roll bonus vs AC.`, + value: trapEffect.attack || '-' + }, + { + id: 'damage', + name: 'Damage', + desc: `The dice roll expression for the trap's damage.`, + value: trapEffect.damage || '-' + }, + { + id: 'missHalf', + name: 'Miss - Half Damage', + desc: 'Does the trap deal half damage on a miss?', + value: trapEffect.missHalf ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'save', + name: 'Saving Throw', + desc: `The trap's saving throw.`, + value: (() => { + let gmOnly = trapEffect.hideSave ? '(hide results)' : ''; + if (trapEffect.save) + return `${trapEffect.save} save DC ${trapEffect.saveDC}` + + `${trapEffect.hideSave ? ' (hide results)' : ''}`; + else + return 'none'; + })(), + properties: [ + { + id: 'save', + name: 'Saving Throw', + desc: 'Which saving throw does the trap use?', + options: [ 'none', 'str', 'dex', 'con', 'int', 'wis', 'cha' ] + }, + { + id: 'dc', + name: 'Save DC', + desc: 'What is the DC for the saving throw?' + }, + { + id: 'hideSave', + name: 'Hide Save Result', + desc: 'Show the Saving Throw result only to the GM?', + options: ['no', 'yes'] + } + ] + }, + { + id: 'spotDC', + name: 'Passive Perception DC', + desc: 'The passive skill check DC to detect the trap.', + value: trapEffect.spotDC || '-' + } + ]; + } + + /** + * Produces HTML for a faked inline roll result for d20 systems. + * @param {int} result + * @param {string} tooltip + * @return {HtmlBuilder} + */ + static htmlRollResult(result, tooltip) { + let d20 = result.rolls[0].results[0].v; + + let clazzes = ['rollResult']; + if(d20 === 20) + clazzes.push('critSuccess'); + if(d20 === 1) + clazzes.push('critFail'); + return new HtmlBuilder('span.' + clazzes.join(' '), result.total, { + title: tooltip + }); + } + + /** + * Produces the HTML for a trap activation message for most d20 systems. + * @param {object} effectResults + * @return {HtmlBuilder} + */ + static htmlTrapActivation(effectResults) { + let content = new HtmlBuilder('div'); + + // Add the flavor message. + content.append('.paddedRow trapMessage', effectResults.message); + + if(effectResults.character) { + var row = content.append('.paddedRow'); + row.append('span.bold', 'Target:'); + row.append('span', effectResults.character.get('name')); + + var hasHitResult = false; + + // Add the attack roll message. + if(effectResults.attack) { + let rollResult = D20TrapTheme.htmlRollResult(effectResults.roll, '1d20 + ' + effectResults.attack); + content.append('.paddedRow') + .append('span.bold', 'Attack roll:') + .append('span', rollResult) + .append('span', ' vs AC ' + effectResults.ac); + hasHitResult = true; + } + + // Add the saving throw message. + if(effectResults.save) { + if (!effectResults.saveDC) + throw new Error(`You forgot to set the trap's save DC!`); + + let rollResult = D20TrapTheme.htmlRollResult(effectResults.roll, '1d20 + ' + effectResults.saveBonus); + let saveMsg = new HtmlBuilder('.paddedRow'); + saveMsg.append('span.bold', effectResults.save.toUpperCase() + ' save:'); + saveMsg.append('span', rollResult); + saveMsg.append('span', ' vs DC ' + effectResults.saveDC); + + // If the save result is a secret, whisper it to the GM. + if(effectResults.hideSave) + ItsATrap.Chat.whisperGM(saveMsg.toString(TrapTheme.css)); + else + content.append(saveMsg); + + hasHitResult = true; + } + + if (hasHitResult) { + // Add the hit/miss message. + if(effectResults.trapHit === 'AC unknown') { + content.append('.paddedRow', 'AC could not be determined with the current version of your character sheet. For the time being, please resolve the attack against AC manually.'); + if(effectResults.damage) + content.append('.paddedRow', 'Damage: [[' + effectResults.damage + ']]'); + } + else if(effectResults.trapHit) { + let row = content.append('.paddedRow'); + row.append('span.hit', 'HIT! '); + if(effectResults.damage) + row.append('span', 'Damage: [[' + effectResults.damage + ']]'); + else + row.append('span', 'You fall prey to the ' + (effectResults.type || 'trap') + '\'s effects!'); + } + else { + let row = content.append('.paddedRow'); + row.append('span.miss', 'MISS! '); + if(effectResults.damage && effectResults.missHalf) + row.append('span', 'Half damage: [[floor((' + effectResults.damage + ')/2)]].'); + } + } + } + + return TrapTheme.htmlTable(content, '#a22', effectResults); + } + + /** + * @inheritdoc + */ + modifyTrapProperty(trapToken, argv) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let prop = argv[0]; + let params = argv.slice(1); + + if(prop === 'attack') { + trapEffect.attack = parseInt(params[0]); + trapEffect.save = undefined; + trapEffect.saveDC = undefined; + } + if(prop === 'damage') + trapEffect.damage = params[0]; + if(prop === 'missHalf') + trapEffect.missHalf = params[0] === 'yes'; + if(prop === 'save') { + trapEffect.save = params[0] === 'none' ? undefined : params[0]; + trapEffect.saveDC = parseInt(params[1]) || 0; + trapEffect.hideSave = params[2] === 'yes'; + trapEffect.attack = undefined; + } + if(prop === 'spotDC') + trapEffect.spotDC = parseInt(params[0]); + + trapToken.set('gmnotes', JSON.stringify(trapEffect)); + } + + /** + * @inheritdoc + */ + passiveSearch(trap, charToken) { + let effect = (new TrapEffect(trap, charToken)).json; + let character = getObj('character', charToken.get('represents')); + + // Only do passive search for traps that have a spotDC. + if(effect.spotDC && character) { + + // If the character's passive perception beats the spot DC, then + // display a message and mark the trap's trigger area. + return this.getPassivePerception(character) + .then(passWis => { + if(passWis >= effect.spotDC) { + let html = TrapTheme.htmlNoticeTrap(character, trap); + ItsATrap.noticeTrap(trap, html.toString(TrapTheme.css)); + } + }) + .catch(err => { + ItsATrap.Chat.error(err); + }); + } + } + }; +})(); + +/** + * Base class for TrapThemes using D&D 4E-ish rules. + * @abstract + */ +var D20TrapTheme4E = (() => { + + return class D20TrapTheme4E extends D20TrapTheme { + + /** + * @inheritdoc + */ + activateEffect(effect) { + let character = getObj('character', effect.victim.get('represents')); + let effectResult = effect.json; + + Promise.resolve() + .then(() => { + effectResult.character = character; + + // Automate trap attack mechanics. + if(character && effectResult.defense && effectResult.attack) { + return Promise.all([ + this.getDefense(character, effectResult.defense), + CharSheetUtils.rollAsync('1d20 + ' + effectResult.attack) + ]) + .then(tuple => { + let defenseValue = tuple[0]; + let attackRoll = tuple[1]; + + defenseValue = defenseValue || 0; + effectResult.defenseValue = defenseValue; + effectResult.roll = attackRoll; + effectResult.trapHit = attackRoll.total >= defenseValue; + return effectResult; + }); + } + return effectResult; + }) + .then(effectResult => { + let html = D20TrapTheme4E.htmlTrapActivation(effectResult); + effect.announce(html.toString(TrapTheme.css)); + }) + .catch(err => { + ItsATrap.Chat.error(err); + }); + } + + /** + * Gets the value for one of a character's defenses. + * @param {Character} character + * @param {string} defenseName + * @return {Promise} + */ + getDefense(character, defenseName) { + throw new Error('Not implemented.'); + } + + /** + * @inheritdoc + */ + getThemeProperties(trapToken) { + let trapEffect = (new TrapEffect(trapToken)).json; + return [ + { + id: 'attack', + name: 'Attack Roll', + desc: `The trap's attack roll bonus vs AC.`, + value: (() => { + let atkBonus = trapEffect.attack; + let atkVs = trapEffect.defense; + + if (atkVs) + return `+${atkBonus} vs ${atkVs}`; + else + return 'none'; + })(), + properties: [ + { + id: 'bonus', + name: 'Attack Bonus', + desc: 'What is the attack roll modifier?' + }, + { + id: 'vs', + name: 'Defense', + desc: 'What defense does the attack target?', + options: ['ac', 'fort', 'ref', 'will'] + } + ] + }, + { + id: 'damage', + name: 'Damage', + desc: `The dice roll expression for the trap's damage.`, + value: trapEffect.damage + }, + { + id: 'missHalf', + name: 'Miss - Half Damage', + desc: 'Does the trap deal half damage on a miss?', + value: trapEffect.missHalf ? 'yes' : 'no', + options: ['yes', 'no'] + }, + { + id: 'spotDC', + name: 'Perception DC', + desc: 'The skill check DC to spot the trap.', + value: trapEffect.spotDC + } + ]; + } + + /** + * Creates the HTML for an activated trap's result. + * @param {object} effectResult + * @return {HtmlBuilder} + */ + static htmlTrapActivation(effectResult) { + let content = new HtmlBuilder('div'); + + // Add the flavor message. + content.append('.paddedRow trapMessage', effectResult.message); + + if(effectResult.character) { + + // Add the attack roll message. + if(_.isNumber(effectResult.attack)) { + let rollHtml = D20TrapTheme.htmlRollResult(effectResult.roll, '1d20 + ' + effectResult.attack); + let row = content.append('.paddedRow'); + row.append('span.bold', 'Attack roll: '); + row.append('span', rollHtml + ' vs ' + effectResult.defense + ' ' + effectResult.defenseValue); + } + + // Add the hit/miss message. + if(effectResult.trapHit) { + let row = content.append('.paddedRow'); + row.append('span.hit', 'HIT! '); + if(effectResult.damage) + row.append('span', 'Damage: [[' + effectResult.damage + ']]'); + else + row.append('span', effectResult.character.get('name') + ' falls prey to the trap\'s effects!'); + } + else { + let row = content.append('.paddedRow'); + row.append('span.miss', 'MISS! '); + if(effectResult.damage && effectResult.missHalf) + row.append('span', 'Half damage: [[floor((' + effectResult.damage + ')/2)]].'); + } + } + + return TrapTheme.htmlTable(content, '#a22', effectResult); + } + + /** + * @inheritdoc + */ + modifyTrapProperty(trapToken, argv) { + let trapEffect = (new TrapEffect(trapToken)).json; + + let prop = argv[0]; + let params = argv.slice(1); + + if(prop === 'attack') { + let bonus = parseInt(params[0]); + let defense = params[1]; + + if (!bonus && bonus !== 0) { + trapEffect.attack = undefined; + trapEffect.defense = undefined; + } + else { + trapEffect.attack = bonus; + trapEffect.defense = defense; + } + } + if(prop === 'damage') + trapEffect.damage = params[0]; + if(prop === 'missHalf') + trapEffect.missHalf = params[0] === 'yes'; + if(prop === 'spotDC') + trapEffect.spotDC = parseInt(params[0]); + + trapToken.set('gmnotes', JSON.stringify(trapEffect)); + } + }; +})(); + +/** + * The default system-agnostic Admiral Ackbar theme. + * @implements TrapTheme + */ +(() => { + + class DefaultTheme { + + /** + * @inheritdoc + */ + get name() { + return 'default'; + } + + /** + * @inheritdoc + */ + activateEffect(effect) { + let content = new HtmlBuilder('div'); + + var row = content.append('.paddedRow'); + if(effect.victim) { + row.append('span.bold', 'Target:'); + row.append('span', effect.victim.get('name')); + } + + content.append('.paddedRow', effect.message); + + let table = TrapTheme.htmlTable(content, '#a22', effect); + let tableView = table.toString(TrapTheme.css); + effect.announce(tableView); + } + + /** + * @inheritdoc + */ + passiveSearch(trap, charToken) { + // Do nothing. + } + } + + ItsATrap.registerTheme(new DefaultTheme()); +})(); + +ItsATrap.Chat = (() => { + + /** + * Broadcasts a message spoken by the script's configured announcer. + * This message is visible to everyone. + */ + function broadcast(msg) { + let announcer = getAnnouncer(); + sendChat(announcer, msg); + } + + /** + * Log an error and its stack trace and alert the GM about it. + * @param {Error} err The error. + */ + function error(err) { + whisperGM(err.message + "
Check API console logs for details."); + log(err.stack); + } + + /** + * Get the name of the script's announcer (for users who don't like + * Admiral Ackbar). + * @return {string} + */ + function getAnnouncer() { + return state.ItsATrap.userOptions.announcer || 'Admiral Ackbar'; + } + + /** + * Whisper a message from the API to the GM. + * @param {string} msg The message to be whispered to the GM. + */ + function whisperGM(msg) { + sendChat('Its A Trap! script', '/w gm ' + msg); + } + + return { + broadcast, + error, + getAnnouncer, + whisperGM + }; +})(); + +{try{throw new Error('');}catch(e){API_Meta.ItsATrap.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.ItsATrap.offset);}} From 996df69053861ea49cf38e44a5f07a795083fe80 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 6 Dec 2024 14:58:38 -0600 Subject: [PATCH 3/4] Script.json update --- Its A Trap/script.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Its A Trap/script.json b/Its A Trap/script.json index 188b219769..01ed2c6d8e 100644 --- a/Its A Trap/script.json +++ b/Its A Trap/script.json @@ -1,8 +1,8 @@ { "name": "It's a Trap!", "script": "ItsATrap.js", - "version": "3.13.2", - "previousversions": ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "3.5.2", "3.6", "3.7.4", "3.8", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10", "3.10.1", "3.11", "3.12", "3.13", "3.13.1"], + "version": "3.13.3", + "previousversions": ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "3.5.2", "3.6", "3.7.4", "3.8", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10", "3.10.1", "3.11", "3.12", "3.13", "3.13.1", "3,13,2"], "description": "# It's A Trap!\r\r_v3.13 Updates_\r\r* Can specify 'none' for trap trigger areas.\r* Can specify message for when a character notices a trap via passive detection using the 'Detection Message' property.\r\rThis is a script that allows GMs to quickly and very easily set up traps,\rsecret doors, and other hidden things on the GM layer, and detect when tokens\ron the objects layer move over them. This trap detection even works for tokens\rmoving by waypoints.\r\rCombined with modules called Trap Themes, this script also allows system-specific\rautomation of trap effects and passive perception used to spot them.\r\r## Trap Maker menu\rWhen this script is installed, it installs a macro called **TrapMaker**. When you\rselect a token that you want to set up as a trap and click this macro, it\rdisplays a **Trap Configuration** menu in the VTT's chat, from which you can\rmodify the trap's various properties (discussed below).\r\rWhen you use this menu on a token for the first time, it will be moved\rto the **GM layer** and it will be given the **cobweb** status marker. The script\ruses these properties to identify which tokens are active as traps.\r\rThe GM notes section of the trap's token will be used to hold the JSON data for\rthe trap's properties. Please do not edit the GM notes for a trap token\rmanually.\r\r### Enabling the menu macro\rThis macro is not added to your macro bar automatically, so you'll need to\rcheck the **In Bar** checkbox next to the **TrapMaker** macro to activate it.\r\r## Trap properties\rThe following subsections go into detail about each of the properties that can\rbe set and modified for a trap.\r\r### Core properties\rThese are the basic properties of the trap.\r\r#### Name\rThis is the name of the trap.\r\re.g. _'pit trap'_ or _'explosive runes'_\r\r#### Type\rThe It's A Trap! script, contrary to its name, can be used to automate more kinds of\rhidden objects than just traps. By default, the value of this property will just be\r_'trap'_, but you could define it to be something like _'hazard'_, _'hidden enemy'_,\ror _'concealed item'_ instead. This type will appear in the trap's header when\rit is activated.\r\rE.g., the header will read 'IT'S A TRAP!!!' if the type is 'trap', or\r'IT'S A HAZARD!!!' if the type is 'hazard'\r\rThis property otherwise has no mechanical effect.\r\r#### Message\rThis message will be displayed when the trap is activated.\r\re.g. _'You feel a floor sink under your feet as you step on a hidden pressure plate. Behind you, you hear the violent grinding of stone against stone, getting closer. A massive rolling boulder is heading this way!'_\r\r#### Disabled?\rThis sets whether the trap is currently disabled or not. A trap that is disabled\rcannot be triggered.\r\r#### Show GM Only\rThis sets whether the trap's activation will only be shared with the GM. If this\ris set to **yes**, the trap's GM Notes and its theme-based results will be\rshared only with the GM. Visible effects of a trap, such as the its message,\rsound, Areas of Effect, etc. will be ignored, and the trap will not be revealed.\r\rThis property is best used for traps, hazards, and alarms whose effects will\rnot be readily apparent when they are activated.\r\re.g. This could be set to 'yes' for a tripwire that alerts monsters in\ranother room to the party's presence.\r\r#### Secret Notes\rThese notes are whispered to the GM when the trap is activated. These notes won't be shown to any of the other players.\r\re.g. _'The tripwire sets off a silent alarm, alerting the mindflayers in the laboratory room to the party's presence.'_\r\r### Trigger properties\rThese properties all have to do with how the trap is triggered.\r\r#### Trigger Area\rThis defines the area that a character must move through in order to trigger the\rtrap. Options include:\r\r* **self - rectangle**: The trap's own token is used as the trigger area, which is treated as a rectangular shape.\r* **self - circle**: The trap's own token is used as the trigger area, which is treated as a circular shape.\r* **set selected lines**: You must have one or more lines selected on the VTT to use this option. Those lines will be used as the trigger area for the trap.\r* **none**: The trap has no trigger area, thus it cannot be triggered. Use this for things like secret doors, which shouldn't activate, but should be noticeable with passive detection.\r\r#### Trigger Collision\rThis property defines how character tokens collide with the trap's trigger area. Options include:\r\r* **center**: When a character crosses the trap's trigger area, they are moved to the trap token's center. This option only works for traps whose trigger area is *self*.\r* **edge**: When a character crosses the trap's trigger area, their movement is stopped at the trigger area's edge.\r* **none**: Character tokens are not stopped when they move through the trap's trigger area.\r\rThis property is ignored if the Delay Activation property is set.\r\r#### Ignore Token IDs\rThis property is used to select one or more creature tokens that will not be affected by a trap. Neither can these tokens trigger the trap.\r\r* **none**: No ignored tokens.\r* **set selected tokens**: To use this option, you must have one or more tokens selected. These tokens will be ignored by the trap.\r\r#### Affects Flying Tokens\rBy default, traps will only affect tokens that are not flying. Tokens are treated as 'flying' by this script if they have the **fluffy wing** status marker active.\r\rIf this property is set to **yes**, it will affect all tokens, regardless of whether\ror not they have the **fluffy wing** status marker active.\r\rLeave this set to **no** for floor-based traps. For traps that affect creatures on\rthe ground and in the air alike, set this to **yes**.\r\r#### Delay Activation\rThis property sets a delay, in **seconds**, between when the trap is triggered to\rwhen it actually activates.\r\rAs a side-effect, the trap's trigger will be deactivated once this delay is\ractivated. This is to prevent the delayed trap from triggering multiple times.\r\r### Activation properties\rThese properties all have to do with what happens when the trap activates.\r\r#### Activation Area\rThis defines the area in which characters can be affected by the trap when it activates. Options include:\r\r* **self**: The trap's token is used as the activation area.\r* **burst**: The trap affects all characters within a certain radius of it.\r* **set selected shapes**: To use this option, you must have one or more filled shapes selected. The trap affects all characters inside those shapes.\r\r#### Burst Radius\rThis property is only visible if **Activation Area** is set to **burst**. This\rsets the radius of the burst area.\r\r#### Special FX\rThis property is used to display a particle effect when the trap activates,\rusing Roll20's special FX system.\r\rThe first prompt asks for the name of the effect that will be displayed. This can\reither be the name of a custom special effect you've created, or it can be the\rname of a built in effect. Built-in special effects follow the naming convention\r**effect-color**. e.g. _explode-fire_ or _beam-acid_\r\rSee https://wiki.roll20.net/Custom_FX#Built-in_Effects for more\rinformation on supported built-in effect and color names.\r\rThe second prompt allows you to specify an offset of the effect's origin point,\rin the format **[X,Y]**. The X,Y offset, relative to the trap's token is measured\rin squares. If this is omitted, the trap's token will be used as the effect's\rorigin point.\re.g. _[3,4]_\r\rThe third prompt allows you to specify a vector for the direction of the effect,\rin the format **[X,Y]**, with each vector component measured in squares. If this\ris omitted, the effect will be directed towards the victims' tokens.\re.g. _[0,-1]_\r\r#### Sound\rThis property sets a sound from your jukebox to be played when the trap is activated.\r\r#### Chained Trap IDs\rThis property allows you to set other traps to activate when this one does. Options include:\r\r* **none**: No other traps are activated by this trap.\r* **set selected traps**: You must have one or more other trap tokens selected to use this option. When this trap activates, the selected traps will activate too.\r\r#### Delete after Activation?\rIf this property is set to **yes**, then the trap's token will be deleted after it is activated.\r\r### Detection properties\r\r#### Max Search Distance\rThis property defines the distance at which a character can attempt to notice a\rtrap passively. If this is not set, the search distance is assumed to be infinite.\r\rDynamic lighting walls will block line of sight to a trap, even if the character\ris close enough to otherwise try to passively spot it.\r\re.g. If this is set to 10 ft, then a character must be within 10 ft of the trap in order to passively notice it.\r\r#### Detection Message\r\rBy default, when a character notices a trap via passive detection (Perception/Spot/etc.),\rthe script will just announce the name of the trap that was noticed. Use this property to specify\ra custom message to be displayed when a character notices a trap.\r\re.g. 'The air feels warm and you notice holes greased with oil lining the walls.'\r\r#### Reveal the Trap?\rThis property determines whether the trap's token will be revealed (moved to a visible layer) when it is activated and/or detected.\r\rThe first prompt asks if the trap should be revealed when it is activated (yes or no).\r\rThe second prompt asks if the trap should be revealed when it is detected (yes or no).\r\rThe third prompt asks which layer the trap token is moved to when it is detected (Just click OK or press enter if you chose **no** for both of the earlier prompts).\r\r### External script properties\rThese properties are available when you have certain other API scripts installed.\r\r#### API Command\rThis property can be used to issue an API chat command when the trap activates.\rThis property supports a couple keywords to support commands involving the trap\rand its victims.\r\rThe keyword TRAP_ID will be substituted for the ID of the trap's token.\r\rThe keyword VICTIM_ID will be substituted for the ID of token for some character\rbeing affected by the trap. If there are multiple victims affected by the trap,\rthe command will be issued individually for each victim.\r\rThe keyword VICTIM_CHAR_ID will be substituted for the ID of the character being\raffected by the trap.\r\re.g. _'!someApiCommand TRAP_ID VICTIM_ID VICTIM_CHAR_NAME'_\r\rFor some API commands using special characters, you'll need to escape those\rcharacters by prefixing them with a \\ (backslash). These special characters\rinclude: [, ], {, }, and @.\r\r#### Areas of Effect script\rThis property is only available if you have the **Areas of Effect** script installed.\rIt also requires you to have at least one effect saved in that script.\rThis allows you to have the trap spawn an area of effect graphic when it is triggered.\r\rThe first prompt will ask you to choose an area of effect chosen from\rthose saved in the Areas of Effect script.\r\rThe second prompt will ask for a vector in the form **[dx,dy]**, indicating the\rdirection of the effect. Each component of this vector is measured in squares.\rIf this vector is omitted, the effect will be directed towards the victims' tokens.\r\r#### KABOOM script\rThis property is only available if you have the **KABOOM** script\r(by Bodin Punyaprateep (PaprikaCC)) installed. This allows you to create a\rKABOOM effect centered on the trap's token. This can be handy for pushing tokens\rback due to an explosive trap!\r\rThe prompts for the property are used to define the properties for the KABOOM effect,\ras defined in the KABOOM script's documentation.\r\r#### TokenMod script\rThis property is only available if you have the **TokenMod** script (by The Aaron)\rinstalled. This allows you to set properties on tokens affected by the trap, using\rthe API command parameters described in the TokenMod script's documentation.\r\re.g. _'--set statusmarkers|broken-shield'_\r\r## Trap Themes:\rTrap themes are special side-scripts used to provide support for formatting messages for traps and\rautomating system-specific trap activation and passive search mechanics.\r\rBy default the **default** theme will be used. This is a very basic,\rsystem-agnostic theme and has no special properties.\r\rIf you install a system-specific trap theme, It's A Trap will automatically\rdetect and use that theme instead. Additional system-specific themes are\ravailable as their own API scripts.\r\r### Theme-specific properties\rTrap themes come with new properties that are added to the Trap Maker menu.\rThis includes things such as modifiers for the trap's attacks, the trap's\rdamage, and the dice rolls needed to passively detect the trap.\r\rDocumentation for these properties are provided in the script documentation for\rthe respective trap theme.\r\r## Activating traps:\rIf a character token moves across a trap's trigger area at ANY point during its\rmovement, the trap will be activated! Traps are only active while they are\ron the GM layer. Moving it to another layer will disable it.\r\rA trap can also be manually activated by clicking the 'Activate Trap' button\rin the trap's configuration menu.\r\r## Help\r\rMy scripts are provided 'as-is', without warranty of any kind, expressed or implied.\r\rThat said, if you experience any issues while using this script,\rneed help using it, or if you have a neat suggestion for a new feature,\rplease shoot me a PM:\rhttps://app.roll20.net/users/46544/ada-l\r\rWhen messaging me about an issue, please be sure to include any error messages that\rappear in your API Console Log, any configurations you've got set up for the\rscript in the VTT, and any options you've got set up for the script on your\rgame's API Scripts page. The more information you provide me, the better the\rchances I'll be able to help.\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, maintaining, and providing tech support my API scripts,\rplease consider buying one of my art packs from the Roll20 marketplace:\r\rhttps://marketplace.roll20.net/browse/publisher/165/ada-lindberg\r", "authors": "Ada Lindberg", "roll20userid": 46544, From d7f2b057ec70ac312ea316dab12cb85b67c8e3a4 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Mon, 9 Dec 2024 13:50:34 -0600 Subject: [PATCH 4/4] Removed lastmove hack --- Token Collisions/1.7/TokenCollisions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Token Collisions/1.7/TokenCollisions.js b/Token Collisions/1.7/TokenCollisions.js index e5cb269a9c..efca56ea5c 100644 --- a/Token Collisions/1.7/TokenCollisions.js +++ b/Token Collisions/1.7/TokenCollisions.js @@ -100,7 +100,7 @@ const TokenCollisions = (() => { //eslint-disable-line no-unused-vars function _getLastMovePts(token) { let move = token.get('lastmove').split(','); let coords = _.map(move, x => { - return Math.abs(parseInt(x)); + return parseInt(x); }); let pts = _.map(_.range(coords.length/2), i => {