/** * A Detection Mode which can be associated with any kind of sense/vision/perception. * A token could have multiple detection modes. */ class DetectionMode extends foundry.abstract.DataModel { /** @inheritDoc */ static defineSchema() { const fields = foundry.data.fields; return { id: new fields.StringField({blank: false}), label: new fields.StringField({blank: false}), tokenConfig: new fields.BooleanField({initial: true}), // If this DM is available in Token Config UI walls: new fields.BooleanField({initial: true}), // If this DM is constrained by walls angle: new fields.BooleanField({initial: true}), // If this DM is constrained by the vision angle type: new fields.NumberField({ initial: this.DETECTION_TYPES.SIGHT, choices: Object.values(this.DETECTION_TYPES) }) }; } /* -------------------------------------------- */ /** * Get the detection filter pertaining to this mode. * @returns {PIXI.Filter|undefined} */ static getDetectionFilter() { return this._detectionFilter; } /** * An optional filter to apply on the target when it is detected with this mode. * @type {PIXI.Filter|undefined} */ static _detectionFilter; static { /** * The type of the detection mode. * @enum {number} */ Object.defineProperty(this, "DETECTION_TYPES", {value: Object.freeze({ SIGHT: 0, // Sight, and anything depending on light perception SOUND: 1, // What you can hear. Includes echolocation for bats per example MOVE: 2, // This is mostly a sense for touch and vibration, like tremorsense, movement detection, etc. OTHER: 3 // Can't fit in other types (smell, life sense, trans-dimensional sense, sense of humor...) })}); /** * The identifier of the basic sight detection mode. * @type {string} */ Object.defineProperty(this, "BASIC_MODE_ID", {value: "basicSight"}); } /* -------------------------------------------- */ /* Visibility Testing */ /* -------------------------------------------- */ /** * Test visibility of a target object or array of points for a specific vision source. * @param {VisionSource} visionSource The vision source being tested * @param {TokenDetectionMode} mode The detection mode configuration * @param {CanvasVisibilityTestConfig} config The visibility test configuration * @returns {boolean} Is the test target visible? */ testVisibility(visionSource, mode, {object, tests}={}) { if ( !mode.enabled ) return false; if ( !this._canDetect(visionSource, object) ) return false; return tests.some(test => this._testPoint(visionSource, mode, object, test)); } /* -------------------------------------------- */ /** * Can this VisionSource theoretically detect a certain object based on its properties? * This check should not consider the relative positions of either object, only their state. * @param {VisionSource} visionSource The vision source being tested * @param {PlaceableObject} target The target object being tested * @returns {boolean} Can the target object theoretically be detected by this vision source? * @protected */ _canDetect(visionSource, target) { const src = visionSource.object.document; const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT; // Sight-based detection fails when blinded if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; // Detection fails if burrowing unless walls are ignored if ( this.walls && src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; if ( target instanceof Token ) { const tgt = target.document; // Sight-based detection cannot see invisible tokens if ( isSight && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false; // Burrowing tokens cannot be detected unless walls are ignored if ( this.walls && tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } /* -------------------------------------------- */ /** * Evaluate a single test point to confirm whether it is visible. * Standard detection rules require that the test point be both within LOS and within range. * @param {VisionSource} visionSource The vision source being tested * @param {TokenDetectionMode} mode The detection mode configuration * @param {PlaceableObject} target The target object being tested * @param {CanvasVisibilityTest} test The test case being evaluated * @returns {boolean} * @protected */ _testPoint(visionSource, mode, target, test) { if ( !this._testRange(visionSource, mode, target, test) ) return false; return this._testLOS(visionSource, mode, target, test); } /* -------------------------------------------- */ /** * Test whether the line-of-sight requirement for detection is satisfied. * Always true if the detection mode bypasses walls, otherwise the test point must be contained by the LOS polygon. * The result of is cached for the vision source so that later checks for other detection modes do not repeat it. * @param {VisionSource} visionSource The vision source being tested * @param {TokenDetectionMode} mode The detection mode configuration * @param {PlaceableObject} target The target object being tested * @param {CanvasVisibilityTest} test The test case being evaluated * @returns {boolean} Is the LOS requirement satisfied for this test? * @protected */ _testLOS(visionSource, mode, target, test) { if ( !this.walls ) return this._testAngle(visionSource, mode, target, test); const type = visionSource.constructor.sourceType; const isSight = type === "sight"; if ( isSight && visionSource.blinded.darkness ) return false; if ( !this.angle && (visionSource.data.angle < 360) ) { // Constrained by walls but not by vision angle return !CONFIG.Canvas.polygonBackends[type].testCollision( { x: visionSource.x, y: visionSource.y }, test.point, { type, mode: "any", source: visionSource, useThreshold: true, includeDarkness: isSight } ); } // Constrained by walls and vision angle let hasLOS = test.los.get(visionSource); if ( hasLOS === undefined ) { hasLOS = visionSource.los.contains(test.point.x, test.point.y); test.los.set(visionSource, hasLOS); } return hasLOS; } /* -------------------------------------------- */ /** * Test whether the target is within the vision angle. * @param {VisionSource} visionSource The vision source being tested * @param {TokenDetectionMode} mode The detection mode configuration * @param {PlaceableObject} target The target object being tested * @param {CanvasVisibilityTest} test The test case being evaluated * @returns {boolean} Is the point within the vision angle? * @protected */ _testAngle(visionSource, mode, target, test) { if ( !this.angle ) return true; const { angle, rotation, externalRadius } = visionSource.data; if ( angle >= 360 ) return true; const point = test.point; const dx = point.x - visionSource.x; const dy = point.y - visionSource.y; if ( (dx * dx) + (dy * dy) <= (externalRadius * externalRadius) ) return true; const aMin = rotation + 90 - (angle / 2); const a = Math.toDegrees(Math.atan2(dy, dx)); return (((a - aMin) % 360) + 360) % 360 <= angle; } /* -------------------------------------------- */ /** * Verify that a target is in range of a source. * @param {VisionSource} visionSource The vision source being tested * @param {TokenDetectionMode} mode The detection mode configuration * @param {PlaceableObject} target The target object being tested * @param {CanvasVisibilityTest} test The test case being evaluated * @returns {boolean} Is the target within range? * @protected */ _testRange(visionSource, mode, target, test) { if ( mode.range === null ) return true; if ( mode.range <= 0 ) return false; const radius = visionSource.object.getLightRadius(mode.range); const dx = test.point.x - visionSource.x; const dy = test.point.y - visionSource.y; return ((dx * dx) + (dy * dy)) <= (radius * radius); } } /* -------------------------------------------- */ /** * This detection mode tests whether the target is visible due to being illuminated by a light source. * By default tokens have light perception with an infinite range if light perception isn't explicitely * configured. */ class DetectionModeLightPerception extends DetectionMode { /** @override */ _canDetect(visionSource, target) { // Cannot see while blinded or burrowing const src = visionSource.object.document; if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) || src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; // Cannot see invisible or burrowing creatures if ( target instanceof Token ) { const tgt = target.document; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } /* -------------------------------------------- */ /** @inheritDoc */ _testPoint(visionSource, mode, target, test) { if ( !super._testPoint(visionSource, mode, target, test) ) return false; return canvas.effects.testInsideLight(test.point, test.elevation); } } /* -------------------------------------------- */ /** * A special detection mode which models a form of darkvision (night vision). * This mode is the default case which is tested first when evaluating visibility of objects. */ class DetectionModeBasicSight extends DetectionMode { /** @override */ _canDetect(visionSource, target) { // Cannot see while blinded or burrowing const src = visionSource.object.document; if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) || src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; // Cannot see invisible or burrowing creatures if ( target instanceof Token ) { const tgt = target.document; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } } /* -------------------------------------------- */ /** * Detection mode that see invisible creatures. * This detection mode allows the source to: * - See/Detect the invisible target as if visible. * - The "See" version needs sight and is affected by blindness */ class DetectionModeInvisibility extends DetectionMode { /** @override */ static getDetectionFilter() { return this._detectionFilter ??= GlowOverlayFilter.create({ glowColor: [0, 0.60, 0.33, 1] }); } /** @override */ _canDetect(visionSource, target) { if ( !(target instanceof Token) ) return false; const tgt = target.document; // Only invisible tokens can be detected if ( !tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false; const src = visionSource.object.document; const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT; // Sight-based detection fails when blinded if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; // Detection fails when the source or target token is burrowing unless walls are ignored if ( this.walls ) { if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } } /* -------------------------------------------- */ /** * Detection mode that see creatures in contact with the ground. */ class DetectionModeTremor extends DetectionMode { /** @override */ static getDetectionFilter() { return this._detectionFilter ??= OutlineOverlayFilter.create({ outlineColor: [1, 0, 1, 1], knockout: true, wave: true }); } /** @override */ _canDetect(visionSource, target) { if ( !(target instanceof Token) ) return false; const tgt = target.document; // Flying and hovering tokens cannot be detected if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.FLY) ) return false; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.HOVER) ) return false; return true; } } /* -------------------------------------------- */ /** * Detection mode that see ALL creatures (no blockers). * If not constrained by walls, see everything within the range. */ class DetectionModeAll extends DetectionMode { /** @override */ static getDetectionFilter() { return this._detectionFilter ??= OutlineOverlayFilter.create({ outlineColor: [0.85, 0.85, 1.0, 1], knockout: true }); } /** @override */ _canDetect(visionSource, target) { const src = visionSource.object.document; const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT; // Sight-based detection fails when blinded if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; // Detection fails when the source or target token is burrowing unless walls are ignored if ( !this.walls ) return true; if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; if ( target instanceof Token ) { const tgt = target.document; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } }