All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

488 lines
19 KiB

  1. export class SightHandler {
  2. static _testRange(visionSource, mode, target, test) {
  3. if (mode.range <= 0) return false;
  4. let radius = visionSource.object.getLightRadius(mode.range);
  5. const unitsToPixel = canvas.dimensions.size / canvas.dimensions.distance;
  6. const sourceZ = visionSource.elevation * unitsToPixel;
  7. const dx = test.point.x - visionSource.x;
  8. const dy = test.point.y - visionSource.y;
  9. const dz = (test.point.z ?? sourceZ) - sourceZ;
  10. return (dx * dx + dy * dy + dz * dz) <= radius*radius;
  11. }
  12. static performLOSTest(sourceToken, tokenOrPoint, source, type = "sight") {
  13. return this.advancedLosTestVisibility(sourceToken, tokenOrPoint, source, type);
  14. }
  15. static advancedLosTestVisibility(sourceToken, tokenOrPoint, source, type = "sight") {
  16. const angleTest = this.testInAngle(sourceToken, tokenOrPoint, source);
  17. if (!angleTest) return false;
  18. return !this.advancedLosTestInLos(sourceToken, tokenOrPoint, type);
  19. const inLOS = !this.advancedLosTestInLos(sourceToken, tokenOrPoint, type);
  20. if(sourceToken.vision.los === source) return inLOS;
  21. const inRange = this.tokenInRange(sourceToken, tokenOrPoint);
  22. if (inLOS && inRange) return true;
  23. return false;
  24. }
  25. static getTestPoints(token, tol = 4) {
  26. const targetLOSH = token.losHeight;
  27. if (CONFIG.Levels.settings.get("preciseTokenVisibility") === false)
  28. return [{ x: token.center.x, y: token.center.y, z: targetLOSH }];
  29. const targetElevation =
  30. token.document.elevation + (targetLOSH - token.document.elevation) * 0.1;
  31. const tokenCorners = [
  32. { x: token.center.x, y: token.center.y, z: targetLOSH },
  33. { x: token.x + tol, y: token.y + tol, z: targetLOSH },
  34. { x: token.x + token.w - tol, y: token.y + tol, z: targetLOSH },
  35. { x: token.x + tol, y: token.y + token.h - tol, z: targetLOSH },
  36. { x: token.x + token.w - tol, y: token.y + token.h - tol, z: targetLOSH },
  37. ];
  38. if (CONFIG.Levels.settings.get("exactTokenVisibility")) {
  39. tokenCorners.push(
  40. {
  41. x: token.center.x,
  42. y: token.center.y,
  43. z: targetElevation + (targetLOSH - targetElevation) / 2,
  44. },
  45. { x: token.center.x, y: token.center.y, z: targetElevation },
  46. { x: token.x + tol, y: token.y + tol, z: targetElevation },
  47. { x: token.x + token.w - tol, y: token.y + tol, z: targetElevation },
  48. { x: token.x + tol, y: token.y + token.h - tol, z: targetElevation },
  49. {
  50. x: token.x + token.w - tol,
  51. y: token.y + token.h - tol,
  52. z: targetElevation,
  53. },
  54. );
  55. }
  56. return tokenCorners;
  57. }
  58. static advancedLosTestInLos(sourceToken, tokenOrPoint, type = "sight") {
  59. if (!(tokenOrPoint instanceof Token) || CONFIG.Levels.settings.get("preciseTokenVisibility") === false)
  60. return this.checkCollision(sourceToken, tokenOrPoint, type);
  61. const sourceCenter = {
  62. x: sourceToken.vision.x,
  63. y: sourceToken.vision.y,
  64. z: sourceToken.losHeight,
  65. };
  66. for (let point of this.getTestPoints(tokenOrPoint)) {
  67. let collision = this.testCollision(
  68. sourceCenter,
  69. point,
  70. type,
  71. {source: sourceToken, target: tokenOrPoint}
  72. );
  73. if (!collision) return collision;
  74. }
  75. return true;
  76. }
  77. static testInAngle(sourceToken, tokenOrPoint, source) {
  78. const documentAngle = source?.config?.angle ?? sourceToken.document?.sight?.angle ?? sourceToken.document?.config?.angle
  79. if (documentAngle == 360) return true;
  80. //normalize angle
  81. function normalizeAngle(angle) {
  82. let normalized = angle % (Math.PI * 2);
  83. if (normalized < 0) normalized += Math.PI * 2;
  84. return normalized;
  85. }
  86. const point = tokenOrPoint instanceof Token ? tokenOrPoint.center : tokenOrPoint;
  87. //check angled vision
  88. const angle = normalizeAngle(
  89. Math.atan2(
  90. point.y - sourceToken.vision.y,
  91. point.x - sourceToken.vision.x
  92. )
  93. );
  94. const rotation = (((sourceToken.document.rotation + 90) % 360) * Math.PI) / 180;
  95. const end = normalizeAngle(
  96. rotation + (documentAngle * Math.PI) / 180 / 2
  97. );
  98. const start = normalizeAngle(
  99. rotation - (documentAngle * Math.PI) / 180 / 2
  100. );
  101. if (start > end) return angle >= start || angle <= end;
  102. return angle >= start && angle <= end;
  103. }
  104. static tokenInRange(sourceToken, tokenOrPoint) {
  105. const range = sourceToken.vision.radius;
  106. if (range === 0) return false;
  107. if (range === Infinity) return true;
  108. const tokensSizeAdjust = tokenOrPoint instanceof Token
  109. ? (Math.min(tokenOrPoint.w, tokenOrPoint.h) || 0) / Math.SQRT2 : 0;
  110. const dist =
  111. (this.getUnitTokenDist(sourceToken, tokenOrPoint) * canvas.dimensions.size) /
  112. canvas.dimensions.distance -
  113. tokensSizeAdjust;
  114. return dist <= range;
  115. }
  116. static getUnitTokenDist(token1, tokenOrPoint2) {
  117. const unitsToPixel = canvas.dimensions.size / canvas.dimensions.distance;
  118. const x1 = token1.vision.x;
  119. const y1 = token1.vision.y;
  120. const z1 = token1.losHeight;
  121. let x2, y2, z2;
  122. if (tokenOrPoint2 instanceof Token) {
  123. x1 = tokenOrPoint2.center.x;
  124. y1 = tokenOrPoint2.center.y;
  125. z1 = tokenOrPoint2.losHeight;
  126. } else {
  127. x1 = tokenOrPoint2.x;
  128. y1 = tokenOrPoint2.y;
  129. z1 = tokenOrPoint2.z;
  130. }
  131. const d =
  132. Math.sqrt(
  133. Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2) + Math.pow((z2 - z1) * unitsToPixel, 2)
  134. ) / unitsToPixel;
  135. return d;
  136. }
  137. static testInLight(object, testTarget, source, result){
  138. const unitsToPixel = canvas.dimensions.size / canvas.dimensions.distance;
  139. const top = object.document.flags?.levels?.rangeTop ?? Infinity;
  140. const bottom = object.document.flags?.levels?.rangeBottom ?? -Infinity;
  141. let lightHeight = null;
  142. if(object instanceof Token){
  143. lightHeight = object.losHeight;
  144. }else if(top != Infinity && bottom != -Infinity){
  145. lightHeight = (top + bottom) / 2;
  146. }
  147. else if(top != Infinity){
  148. lightHeight = top;
  149. }
  150. else if(bottom != -Infinity){
  151. lightHeight = bottom;
  152. }
  153. if(lightHeight == null) return result;
  154. const lightRadius = source.config.radius/unitsToPixel;
  155. const targetLOSH = testTarget.losHeight;
  156. const targetElevation = testTarget.document.elevation;
  157. const lightTop = lightHeight + lightRadius;
  158. const lightBottom = lightHeight - lightRadius;
  159. if(targetLOSH <= lightTop && targetLOSH >= lightBottom){
  160. return result;
  161. }
  162. if(targetElevation <= lightTop && targetElevation >= lightBottom){
  163. return result;
  164. }
  165. return false;
  166. }
  167. static _testCollision(wrapped, ...args) {
  168. const visionSource = this.config?.source;
  169. const target = CONFIG?.Levels?.visibilityTestObject;
  170. if(!visionSource?.object || !target) return wrapped(...args);
  171. let targetElevation;
  172. if (target instanceof Token) {
  173. targetElevation = target.losHeight;
  174. } else if (target instanceof PlaceableObject) {
  175. targetElevation = target.document.elevation ?? target.document.flags.levels?.rangeBottom;
  176. } else if (target instanceof DoorControl) {
  177. targetElevation = visionSource.elevation;
  178. } else {
  179. targetElevation = canvas.primary.background.elevation;
  180. }
  181. const p1 = {
  182. x: args[0].A.x,
  183. y: args[0].A.y,
  184. z: visionSource.elevation,
  185. };
  186. const p2 = {
  187. x: args[0].B.x,
  188. y: args[0].B.y,
  189. z: targetElevation,
  190. };
  191. const result = CONFIG.Levels.API.testCollision(p1,p2, this.config.type, {source: visionSource, target: target});
  192. switch (args[1]) {
  193. case "any": return !!result;
  194. case "all": return result ? [PolygonVertex.fromPoint(result)] : [];
  195. default: return result ? PolygonVertex.fromPoint(result) : null;
  196. }
  197. }
  198. static containsWrapper(wrapped, ...args){
  199. const LevelsConfig = CONFIG.Levels;
  200. const testTarget = LevelsConfig.visibilityTestObject;
  201. if(!this.config?.source?.object || !(testTarget instanceof Token) || this.config.source instanceof GlobalLightSource) return wrapped(...args);
  202. let result;
  203. if(this.config.source instanceof LightSource){
  204. result = LevelsConfig.handlers.SightHandler.testInLight(this.config.source.object, testTarget, this, wrapped(...args));
  205. }else if(this.config.source.object instanceof Token){
  206. const point = {
  207. x: args[0],
  208. y: args[1],
  209. z: testTarget.losHeight,
  210. object: testTarget
  211. };
  212. result = LevelsConfig.handlers.SightHandler.performLOSTest(this.config.source.object, point, this, this.config.type);
  213. }else{
  214. result = wrapped(...args);
  215. }
  216. return result;
  217. }
  218. /**
  219. * Check whether the given wall should be tested for collisions, based on the collision type and wall configuration
  220. * @param {Object} wall - The wall being checked
  221. * @param {Integer} collisionType - The collision type being checked: 0 for sight, 1 for movement, 2 for sound, 3 for light
  222. * @returns {boolean} Whether the wall should be ignored
  223. */
  224. static shouldIgnoreWall(wall, collisionType, options) {
  225. const proximity = this.shouldIgnoreProximityWall(wall.document, options.A, options.B, options.source?.vision?.data?.externalRadius ?? 0)
  226. if (collisionType === 0) {
  227. return (
  228. wall.document.sight === CONST.WALL_SENSE_TYPES.NONE ||
  229. proximity ||
  230. //wall.document.sight > 20 ||
  231. (wall.document.door != 0 && wall.document.ds === 1)
  232. );
  233. } else if (collisionType === 1) {
  234. return (
  235. wall.document.move === CONST.WALL_MOVEMENT_TYPES.NONE ||
  236. (wall.document.door != 0 && wall.document.ds === 1)
  237. );
  238. } else if (collisionType === 2) {
  239. return (
  240. wall.document.sound === CONST.WALL_MOVEMENT_TYPES.NONE ||
  241. wall.document.sound > 20 ||
  242. (wall.document.door != 0 && wall.document.ds === 1)
  243. );
  244. } else if (collisionType === 3) {
  245. return (
  246. wall.document.light === CONST.WALL_MOVEMENT_TYPES.NONE ||
  247. wall.document.light > 20 ||
  248. (wall.document.door != 0 && wall.document.ds === 1)
  249. );
  250. }
  251. }
  252. static shouldIgnoreProximityWall(document, source, target, externalRadius = 0) {
  253. if(!source || !target) return false;
  254. const d = document.threshold?.sight
  255. if (!d || d.sight < 30) return false; // No threshold applies
  256. const proximity = document.sight === CONST.WALL_SENSE_TYPES.PROXIMITY;
  257. const pt = foundry.utils.closestPointToSegment(source, document.object.A, document.object.B);
  258. //const ptTarget = foundry.utils.closestPointToSegment(target, document.object.A, document.object.B);
  259. //const targetDistance = Math.hypot(ptTarget.x - target.x, ptTarget.y - target.y);
  260. const sourceDistance = Math.hypot(pt.x - source.x, pt.y - source.y);
  261. const sourceTargetDistance = Math.hypot(source.x - target.x, source.y - target.y);
  262. const thresholdDistance = d * document.parent.dimensions.distancePixels;
  263. return proximity ? sourceTargetDistance <= thresholdDistance || sourceDistance <= thresholdDistance / 2 : sourceDistance + externalRadius > thresholdDistance;
  264. }
  265. /**
  266. * Perform a collision test between 2 point in 3D space
  267. * @param {Object} p0 - a point in 3d space {x:x,y:y,z:z} where z is the elevation
  268. * @param {Object} p1 - a point in 3d space {x:x,y:y,z:z} where z is the elevation
  269. * @param {String} type - "sight" or "move"/"collision" or "sound" or "light" (defaults to "sight")
  270. * @returns {Boolean} returns the collision point if a collision is detected, flase if it's not
  271. **/
  272. static testCollision(p0, p1, type = "sight", options = {}) {
  273. if (canvas?.scene?.flags['levels-3d-preview']?.object3dSight) {
  274. if (!game.Levels3DPreview?._active) return true;
  275. return game.Levels3DPreview.interactionManager.computeSightCollision(
  276. p0,
  277. p1,
  278. type
  279. );
  280. }
  281. //Declare points adjusted with token height to use in the loop
  282. const x0 = p0.x;
  283. const y0 = p0.y;
  284. const z0 = p0.z;
  285. const x1 = p1.x;
  286. const y1 = p1.y;
  287. const z1 = p1.z;
  288. options.A = {...p0};
  289. options.B = {...p1};
  290. options.targetObject = p1.object ?? null;
  291. const TYPE = type == "sight" ? 0 : type == "sound" ? 2 : type == "light" ? 3 : 1;
  292. const ALPHATTHRESHOLD = type == "sight" ? 0.99 : 0.1;
  293. //If the point are on the same Z axis return the 3d wall test
  294. if (z0 == z1) {
  295. return walls3dTest.bind(this)();
  296. }
  297. //Check the background for collisions
  298. const bgElevation = canvas?.scene?.flags?.levels?.backgroundElevation ?? 0
  299. const zIntersectionPointBG = getPointForPlane(bgElevation);
  300. if (((z0 < bgElevation && bgElevation < z1) || (z1 < bgElevation && bgElevation < z0))) {
  301. return {
  302. x: zIntersectionPointBG.x,
  303. y: zIntersectionPointBG.y,
  304. z: bgElevation,
  305. };
  306. }
  307. //Loop through all the planes and check for both ceiling and floor collision on each tile
  308. for (let tile of canvas.tiles.placeables) {
  309. if(tile.document.flags?.levels?.noCollision || !tile.document.overhead) continue;
  310. const bottom = tile.document.flags?.levels?.rangeBottom ?? -Infinity;
  311. const top = tile.document.flags?.levels?.rangeTop ?? Infinity;
  312. if (bottom != -Infinity) {
  313. const zIntersectionPoint = getPointForPlane(bottom);
  314. if (((z0 < bottom && bottom < z1) || (z1 < bottom && bottom < z0)) && tile.mesh?.containsPixel(zIntersectionPoint.x, zIntersectionPoint.y, ALPHATTHRESHOLD)) {
  315. return {
  316. x: zIntersectionPoint.x,
  317. y: zIntersectionPoint.y,
  318. z: bottom,
  319. };
  320. }
  321. }
  322. }
  323. //Return the 3d wall test if no collisions were detected on the Z plane
  324. return walls3dTest.bind(this)();
  325. //Get the intersection point between the ray and the Z plane
  326. function getPointForPlane(z) {
  327. const x = ((z - z0) * (x1 - x0) + x0 * z1 - x0 * z0) / (z1 - z0);
  328. const y = ((z - z0) * (y1 - y0) + z1 * y0 - z0 * y0) / (z1 - z0);
  329. const point = { x: x, y: y };
  330. return point;
  331. }
  332. //Check if a point in 2d space is betweeen 2 points
  333. function isBetween(a, b, c) {
  334. //test
  335. //return ((a.x<=c.x && c.x<=b.x && a.y<=c.y && c.y<=b.y) || (a.x>=c.x && c.x >=b.x && a.y>=c.y && c.y >=b.y))
  336. const dotproduct = (c.x - a.x) * (b.x - a.x) + (c.y - a.y) * (b.y - a.y);
  337. if (dotproduct < 0) return false;
  338. const squaredlengthba =
  339. (b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y);
  340. if (dotproduct > squaredlengthba) return false;
  341. return true;
  342. }
  343. //Get wall heights flags, avoid infinity, use arbitrary large number instead
  344. function getWallHeightRange3Dcollision(wall) {
  345. let { top, bottom } = WallHeight.getWallBounds(wall);
  346. if (bottom == -Infinity) bottom = -1e9;
  347. if (top == Infinity) top = 1e9;
  348. let wallRange = [bottom, top];
  349. if (!wallRange[0] && !wallRange[1]) return false;
  350. else return wallRange;
  351. }
  352. //Compute 3d collision for walls
  353. function walls3dTest() {
  354. const rectX = Math.min(x0, x1);
  355. const rectY = Math.min(y0, y1);
  356. const rectW = Math.abs(x1 - x0);
  357. const rectH = Math.abs(y1 - y0);
  358. const rect = new PIXI.Rectangle(rectX, rectY, rectW, rectH);
  359. const walls = canvas.walls.quadtree.getObjects(rect);
  360. let terrainWalls = 0;
  361. for (let wall of walls) {
  362. if (this.shouldIgnoreWall(wall, TYPE, options)) continue;
  363. let isTerrain =
  364. TYPE === 0 && wall.document.sight === CONST.WALL_SENSE_TYPES.LIMITED ||
  365. TYPE === 1 && wall.document.move === CONST.WALL_SENSE_TYPES.LIMITED ||
  366. TYPE === 2 && wall.document.sound === CONST.WALL_SENSE_TYPES.LIMITED ||
  367. TYPE === 3 && wall.document.light === CONST.WALL_SENSE_TYPES.LIMITED;
  368. //declare points in 3d space of the rectangle created by the wall
  369. const wallBotTop = getWallHeightRange3Dcollision(wall);
  370. const wx1 = wall.document.c[0];
  371. const wx2 = wall.document.c[2];
  372. const wx3 = wall.document.c[2];
  373. const wy1 = wall.document.c[1];
  374. const wy2 = wall.document.c[3];
  375. const wy3 = wall.document.c[3];
  376. const wz1 = wallBotTop[0];
  377. const wz2 = wallBotTop[0];
  378. const wz3 = wallBotTop[1];
  379. //calculate the parameters for the infinite plane the rectangle defines
  380. const A = wy1 * (wz2 - wz3) + wy2 * (wz3 - wz1) + wy3 * (wz1 - wz2);
  381. const B = wz1 * (wx2 - wx3) + wz2 * (wx3 - wx1) + wz3 * (wx1 - wx2);
  382. const C = wx1 * (wy2 - wy3) + wx2 * (wy3 - wy1) + wx3 * (wy1 - wy2);
  383. const D =
  384. -wx1 * (wy2 * wz3 - wy3 * wz2) -
  385. wx2 * (wy3 * wz1 - wy1 * wz3) -
  386. wx3 * (wy1 * wz2 - wy2 * wz1);
  387. //solve for p0 p1 to check if the points are on opposite sides of the plane or not
  388. const P1 = A * x0 + B * y0 + C * z0 + D;
  389. const P2 = A * x1 + B * y1 + C * z1 + D;
  390. //don't do anything else if the points are on the same side of the plane
  391. if (P1 * P2 > 0) continue;
  392. //Check for directional walls
  393. if (wall.direction !== null) {
  394. // Directional walls where the ray angle is not in the same hemisphere
  395. const rayAngle = Math.atan2(y1 - y0, x1 - x0);
  396. const angleBounds = [rayAngle - Math.PI / 2, rayAngle + Math.PI / 2];
  397. if (!wall.isDirectionBetweenAngles(...angleBounds)) continue;
  398. }
  399. //calculate intersection point
  400. const t =
  401. -(A * x0 + B * y0 + C * z0 + D) /
  402. (A * (x1 - x0) + B * (y1 - y0) + C * (z1 - z0)); //-(A*x0 + B*y0 + C*z0 + D) / (A*x1 + B*y1 + C*z1)
  403. const ix = x0 + (x1 - x0) * t;
  404. const iy = y0 + (y1 - y0) * t;
  405. const iz = Math.round(z0 + (z1 - z0) * t);
  406. //return true if the point is inisde the rectangle
  407. const isb = isBetween(
  408. { x: wx1, y: wy1 },
  409. { x: wx2, y: wy2 },
  410. { x: ix, y: iy }
  411. );
  412. if (
  413. isTerrain &&
  414. isb &&
  415. iz <= wallBotTop[1] &&
  416. iz >= wallBotTop[0] &&
  417. terrainWalls == 0
  418. ) {
  419. terrainWalls++;
  420. continue;
  421. }
  422. if (isb && iz <= wallBotTop[1] && iz >= wallBotTop[0])
  423. return { x: ix, y: iy, z: iz };
  424. }
  425. return false;
  426. }
  427. }
  428. /**
  429. * Perform a collision test between 2 TOKENS in 3D space
  430. * @param {Token|{x:number,y:number,z:number}} token1 - a token or a point in 3d space where z is the elevation
  431. * @param {Token|{x:number,y:number,z:number}} token2 - a token or a point in 3d space where z is the elevation
  432. * @param {String} type - "sight" or "move"/"collision" or "sound" or "light" (defaults to "sight")
  433. * @returns {Boolean} returns the collision point if a collision is detected, flase if it's not
  434. **/
  435. static checkCollision(tokenOrPoint1, tokenOrPoint2, type = "sight") {
  436. const p0 = tokenOrPoint1 instanceof Token ? {
  437. x: tokenOrPoint1.vision.x,
  438. y: tokenOrPoint1.vision.y,
  439. z: tokenOrPoint1.losHeight,
  440. } : tokenOrPoint1;
  441. const p1 = tokenOrPoint2 instanceof Token ? {
  442. x: tokenOrPoint2.center.x,
  443. y: tokenOrPoint2.center.y,
  444. z: tokenOrPoint2.losHeight,
  445. } : tokenOrPoint2;
  446. return this.testCollision(p0, p1, type, {source: tokenOrPoint1, target: tokenOrPoint2});
  447. }
  448. }