/** * Initialize the state for the It's A Trap script. */ (() => { 'use strict'; /** * 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. */ var ItsATrap = (() => { 'use strict'; 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'; /** * Activates a trap. * @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(effect.triggerPaths) { triggerDist = _.chain(effect.triggerPaths) .map(pathId => { let path = getObj('path', pathId); return getSearchDistance(token, path); }) .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; // Skip if the trap is disabled. if(trap.get('status_interdiction')) return false; let trapEffect = (new TrapEffect(trap, token)).json; trapEffect.stopAt = trapEffect.stopAt || 'center'; // 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) { 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(token2.get('_type') === 'path') { 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); if(effect.triggerPaths) { return _.map(effect.triggerPaths, id => { if(pathsToTraps[id]) pathsToTraps[id].push(trap); else pathsToTraps[id] = [trap]; return getObj('path', id) || trap; }); } else return trap; }) .flatten() .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(collision.other.get('_type') === 'path') { 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 = getObj('path', 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() .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); let announcer = state.ItsATrap.userOptions.announcer; if(!state.ItsATrap.noticedTraps[id]) { state.ItsATrap.noticedTraps[id] = true; sendChat(announcer, 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 = getObj('path', pathId); path.set('layer', layer); toOrder(path); }); } /** * 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. _revealTriggers(trap); _revealActivationAreas(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(effect.triggerPaths) { _.each(effect.triggerPaths, pathId => { let path = getObj('path', pathId); path.set('layer', layer); toOrder(path); }); } } /** * 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! using theme '${getTheme().name}' ☒☠☒`); clearInterval(interval); } else if(numRetries > 0) numRetries--; else clearInterval(interval); }, 1000); }); // Handle macro commands. on('chat:message', msg => { try { if(msg.content === REMOTE_ACTIVATE_CMD) { let theme = getTheme(); _.each(msg.selected, item => { let trap = getObj('graphic', item._id); activateTrap(trap); }); } } 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. */ var TrapEffect = (() => { 'use strict'; 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; } /** * Whether the trap should be destroyed after it activates. * @type {boolean} */ get destroyable() { return this._effect.destroyable; } /** * 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() { return this._effect.effectShape; } /** * 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; } /** * 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; } /** * 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. * @type {uuid} */ get victimId() { return this._victim && this._victim.get('_id'); } /** * @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; let announcer = state.ItsATrap.userOptions.announcer; // Display the message to everyone, unless it's a secret. if(this.gmOnly) { message = '/w gm ' + message; sendChat(announcer, message); // Whisper any secret notes to the GM. if(this.notes) sendChat(announcer, '/w gm Trap Notes:
' + this.notes); } else { sendChat(announcer, message); // Whisper any secret notes to the GM. if(this.notes) sendChat(announcer, '/w gm 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); cmd = cmd.replace(/VICTIM_ID/g, this.victimId); 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('path', _.extend(pathJson, { _pageid: this._trap.get('_pageid'), layer: 'objects', stroke: '#ff0000' })); let aoeGraphic = AreasOfEffect.applyEffect('', 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('It\'s A Trap', command); } } }; })(); /** * 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. * @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 = (() => { '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'; 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' } }; // 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'); // Core properties content.append('h4', 'Core properties'); let coreProperties = getCoreProperties(trapToken); content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, coreProperties)); // Shape properties content.append('h4', 'Shape properties', { style: { 'margin-top' : '2em' } }); let shapeProperties = getShapeProperties(trapToken); content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, shapeProperties)); // Trigger properties content.append('h4', 'Trigger properties', { style: { 'margin-top' : '2em' } }); let triggerProperties = getTriggerProperties(trapToken); content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, triggerProperties)); // Reveal properties content.append('h4', 'Reveal properties', { style: { 'margin-top' : '2em' } }); let revealProperties = getRevealProperties(trapToken); content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, revealProperties)); // Special properties content.append('h4', 'Special properties', { style: { 'margin-top' : '2em' } }); let specialProperties = getSpecialProperties(trapToken); content.append(_displayWizardProperties(MODIFY_CORE_PROPERTY_CMD, specialProperties)); // 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})`, { style: { 'margin-top' : '2em' } }); let menu = _showMenuPanel('Trap Configuration', content); _whisper(who, menu.toString(MENU_CSS)); trapToken.set('status_cobweb', true); } /** * 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' } }); }); return table; } /** * Fixes msg.who. * @param {string} who * @return {string} */ function _fixWho(who) { return who.replace(/\(GM\)/, '').trim(); } /** * 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)).json; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; 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: '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: 'GM Notes', desc: 'Additional secret notes shown only to the GM when the trap is activated.', value: trapEffect.notes }, { id: 'destroyable', name: 'Destroyable', desc: 'Whether to delete the trap after it is activated.', value: trapEffect.destroyable ? 'yes': 'no', options: ['yes', 'no'] } ]; } /** * 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; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; return [ { id: 'searchDist', name: 'Max Search Distance', desc: 'How far away can characters passively search for this trap?', value: trapToken.get('aura2_radius') || trapEffect.searchDist }, { id: 'revealToPlayers', name: 'When Activated', desc: 'Should this trap be revealed to the players when it is activated?', value: trapToken.get('status_bleeding-eye') ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'revealWhenSpotted', name: 'When Spotted', desc: 'Should this trap be revealed to the players when a character notices it by passive searching?', value: trapEffect.revealWhenSpotted ? 'yes' : 'no', options: ['yes', 'no'] }, { id: 'revealLayer', name: 'Layer', desc: 'When this trap is revealed, which layer is it revealed on?', value: trapEffect.revealLayer || 'map', 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)).json; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; return [ { 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: 'effectDistance', name: 'Blast distance', desc: 'How far away can the trap affect other tokens?', value: trapToken.get('aura1_radius') || 0 }, { id: 'stopAt', name: 'Stops Tokens At', desc: 'Does this trap stop tokens that pass through its trigger area?', value: trapEffect.stopAt, options: ['center', 'edge', 'none'] }, { id: 'effectShape', name: 'Trap shape', desc: '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 || ' circle', options: [ 'circle', 'square', 'paths'] }, ]; } /** * Gets a list of the core trap properties for a trap token dealing * with special side effects such as FX, sound, and API commands. * @param {Graphic} token * @return {object[]} */ function getSpecialProperties(trapToken) { let trapEffect = (new TrapEffect(trapToken)).json; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; 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 ";;".', value: trapEffect.api }, // Requires AreasOfEffect script. (() => { if(typeof AreasOfEffect !== 'undefined') 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.', }, { 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: ' + LBRACE + 'X,Y' + RBRACE } ] }; })(), { 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: ' + LBRACE + 'X,Y' + RBRACE }, { id: 'direction', name: 'FX Direction', desc: 'The directional vector for the special FX ' + LPAREN + 'Leave blank to direct it towards characters' + RPAREN + '. Format: ' + LBRACE + 'X,Y' + RBRACE } ] }, // 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'] } ] }; })(), { id: 'sound', name: 'Sound', desc: 'A sound from your jukebox that will play when the trap is activated.', value: trapEffect.sound }, (() => { 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; let LPAREN = '('; let RPAREN = ')'; let LBRACE = '['; let RBRACE = ']'; return [ { id: 'triggerPaths', name: 'Set Trigger', desc: 'To set paths, you must also select the paths that trigger the trap.', value: trapEffect.triggerPaths || 'self', options: ['self', 'paths'] }, { id: 'triggers', name: 'Other Traps Triggered', 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 undefined; })() } ]; } /** * 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]) { trapEffect.areaOfEffect = {}; trapEffect.areaOfEffect.name = params[0]; try { trapEffect.areaOfEffect.direction = JSON.parse(params[1]); } catch(err) {} } else trapEffect.areaOfEffect = undefined; } if(prop === 'destroyable') trapEffect.destroyable = params[0] === 'yes'; if(prop === 'disabled') trapToken.set('status_interdiction', params[0] === 'yes'); if(prop === 'effectDistance') trapToken.set('aura1_radius', parseInt(params[0])); if(prop === 'effectShape') { if(['circle', 'square'].includes(params[0])) { trapEffect.effectShape = params[0]; trapToken.set('aura1_square', params[0].includes('square')); } else if(params[0] === 'paths' && selected) { trapEffect.effectShape = _.map(selected, path => { return path.get('_id'); }); trapToken.set('aura1_square', false); } 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 === '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 === 'revealLayer') trapEffect.revealLayer = params[0]; if(prop === 'revealToPlayers') trapToken.set('status_bleeding-eye', params[0] === 'yes'); if(prop === 'revealWhenSpotted') trapEffect.revealWhenSpotted = params[0] === 'yes'; if(prop === 'searchDist') trapToken.set('aura2_radius', parseInt(params[0])); if(prop === 'sound') trapEffect.sound = params[0]; if(prop === 'stopAt') trapEffect.stopAt = params[0]; if(prop === 'tokenMod') trapEffect.tokenMod = params[0]; if(prop === 'triggers') trapEffect.triggers = _.map(params[0].split(','), trigger => { return trigger.trim(); }); if(prop === 'triggerPaths') if(params[0] === 'paths' && selected) trapEffect.triggerPaths = _.map(selected, path => { return path.get('_id'); }); else trapEffect.triggerPaths = undefined; 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; } /** * @private * Whispers a Marching Order message to someone. */ function _whisper(who, msg) { sendChat('Its A Trap! script', '/w "' + _fixWho(who) + '" ' + msg); } on('ready', () => { let macro = findObjs({ _type: 'macro', name: 'ItsATrap_trapCreationWizard' })[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: 'ItsATrap_trapCreationWizard', action: DISPLAY_WIZARD_CMD, istokenaction: true }); }); } }); 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)) { 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) { log('ItsATrapCreationWizard: ' + err.message); log(err.stack); } }); 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 = (() => { 'use strict'; /** * 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.'); } /** * Attempts to force a calculated attribute to be corrected by * setting it. * @param {Character} character * @param {string} attr */ static forceAttrCalculation(character, attr) { // Attempt to force the calculation of the save modifier by setting it. createObj('attribute', { _characterid: character.get('_id'), name: attr, current: -9999 }); // Then try again. return TrapTheme.getSheetAttr(character, attr) .then(result => { if(_.isNumber(result)) return result; else log('Could not calculate attribute: ' + attr + ' - ' + result); }); } /** * Asynchronously gets the value of a character sheet attribute. * @param {Character} character * @param {string} attr * @return {Promise} * Contains the value of the attribute. */ static getSheetAttr(character, attr) { if(attr.includes('/')) return TrapTheme.getSheetRepeatingAttr(character, attr); else { let rollExpr = '@{' + character.get('name') + '|' + attr + '}'; return TrapTheme.rollAsync(rollExpr) .then((roll) => { if(roll) return roll.total; else throw new Error('Could not resolve roll expression: ' + rollExpr); }) .then(value => { if(_.isNumber(value)) return value; // If the attribute is autocalculated, but could its current value // could not be resolved, try to force it to calculate its value as a // last-ditch effort. else return TrapTheme.forceAttrCalculation(character, attr); }); } } /** * Asynchronously gets the value of a character sheet attribute from a * repeating row. * @param {Character} character * @param {string} attr * Here, attr has the format "sectionName/nameFieldName/nameFieldValue/valueFieldName". * For example: "skills/name/perception/total" * @return {Promise} * Contains the value of the attribute. */ static getSheetRepeatingAttr(character, attr) { let parts = attr.split('/'); let sectionName = parts[0]; let nameFieldName = parts[1]; let nameFieldValue = parts[2].toLowerCase(); let valueFieldName = parts[3]; // Find the row with the given name. return TrapTheme.getSheetRepeatingRow(character, sectionName, rowAttrs => { let nameField = rowAttrs[nameFieldName]; if(!nameField) return false; return nameField.get('current').toLowerCase().trim() === nameFieldValue; }) // Get the current value of that row. .then(rowAttrs => { if(!rowAttrs) return NaN; let valueField = rowAttrs[valueFieldName]; if(!valueField) return NaN; return valueField.get('current'); }); } /** * Gets the map of attributes inside of a repeating section row. * @param {Character} character * @param {string} section * The name of the repeating section. * @param {func} rowFilter * A filter function to find the correct row. The argument passed to it is a * map of attribute names (without the repeating section ID part - e.g. "name" * instead of "repeating_skills_-123abc_name") to their actual attributes in * the current row being filtered. The function should return true iff it is * the correct row we're looking for. * @return {Promise} * Contains the map of attributes. */ static getSheetRepeatingRow(character, section, rowFilter) { // Get all attributes in this section and group them by row. let attrs = findObjs({ _type: 'attribute', _characterid: character.get('_id') }); // Group the attributes by row. let rows = {}; _.each(attrs, attr => { let regex = new RegExp(`repeating_${section}_(-([0-9a-zA-Z\-_](?!_storage))+?|\$\d+?)_([0-9a-zA-Z\-_]+)`); let match = attr.get('name').match(regex); if(match) { let rowId = match[1]; let attrName = match[3]; if(!rows[rowId]) rows[rowId] = {}; rows[rowId][attrName] = attr; } }); // Find the row that matches our filter. return Promise.resolve(_.find(rows, rowAttrs => { return rowFilter(rowAttrs); })); } /** * 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') + ':'); 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.'); } /** * Asynchronously rolls a dice roll expression and returns the result's total in * a callback. The result is undefined if an invalid expression is given. * @param {string} expr * @return {Promise} */ static rollAsync(expr) { return new Promise((resolve, reject) => { sendChat('TrapTheme', '/w gm [[' + expr + ']]', (msg) => { try { let results = msg[0].inlinerolls[0].results; resolve(results); } catch(err) { reject(err); } }); }); } }; })(); /** * A base class for trap themes using the D20 system (D&D 3.5, 4E, 5E, Pathfinder, etc.) * @abstract */ var D20TrapTheme = (() => { 'use strict'; 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 => { sendChat('TrapTheme: ' + this.name, '/w gm ' + err.message); log(err.stack); }); } /** * Does a trap's attack roll. * @private */ _doTrapAttack(character, effectResults) { return Promise.all([ this.getAC(character), TrapTheme.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 TrapTheme.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: 'hideSave', name: 'Hide Save Result', desc: 'Show the Saving Throw result only to the GM?', value: trapEffect.hideSave ? 'yes' : 'no', options: ['yes', 'no'] }, { 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 type of saving throw used by the trap.', value: trapEffect.save, options: [ 'none', 'str', 'dex', 'con', 'int', 'wis', 'cha' ] }, { id: 'saveDC', name: 'Saving Throw DC', desc: `The DC for the trap's saving throw.`, value: trapEffect.saveDC }, { id: 'spotDC', name: 'Spot DC', desc: 'The skill check DC to spot 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')); // 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); } // Add the saving throw message. if(effectResults.save) { 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) sendChat('Admiral Ackbar', '/w gm ' + saveMsg.toString(TrapTheme.css)); else content.append(saveMsg); } // 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 === 'hideSave') trapEffect.hideSave = params[0] === 'yes'; if(prop === 'missHalf') trapEffect.missHalf = params[0] === 'yes'; if(prop === 'save') { trapEffect.save = params[0] === 'none' ? undefined : params[0]; trapEffect.attack = undefined; } if(prop === 'saveDC') { trapEffect.saveDC = parseInt(params[0]); 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 => { sendChat('Trap theme: ' + this.name, '/w gm ' + err.message); log(err.stack); }); } } }; })(); /** * Base class for TrapThemes using D&D 4E-ish rules. * @abstract */ var D20TrapTheme4E = (() => { 'use strict'; 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), TrapTheme.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 => { sendChat('Trap theme: ' + this.name, '/w gm ' + err.message); log(err.stack); }); } /** * 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 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: 'defense', name: 'Defense', desc: `The defense targeted by the trap's attack.`, value: trapEffect.defense, options: [ 'none', 'ac', 'fort', 'ref', 'will' ] }, { 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: 'Spot 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') trapEffect.attack = parseInt(params[0]); if(prop === 'damage') trapEffect.damage = params[0]; if(prop === 'defense') trapEffect.defense = params[0] === 'none' ? undefined : 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 */ (() => { 'use strict'; 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()); })();