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.

466 lines
18 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. };
  211. result = LevelsConfig.handlers.SightHandler.performLOSTest(this.config.source.object, point, this, this.config.type);
  212. }else{
  213. result = wrapped(...args);
  214. }
  215. return result;
  216. }
  217. /**
  218. * Check whether the given wall should be tested for collisions, based on the collision type and wall configuration
  219. * @param {Object} wall - The wall being checked
  220. * @param {Integer} collisionType - The collision type being checked: 0 for sight, 1 for movement, 2 for sound, 3 for light
  221. * @returns {boolean} Whether the wall should be ignored
  222. */
  223. static shouldIgnoreWall(wall, collisionType, options) {
  224. if (collisionType === 0) {
  225. return (
  226. wall.document.sight === CONST.WALL_SENSE_TYPES.NONE ||
  227. wall.document.sight > 20 ||
  228. (wall.document.door != 0 && wall.document.ds === 1)
  229. );
  230. } else if (collisionType === 1) {
  231. return (
  232. wall.document.move === CONST.WALL_MOVEMENT_TYPES.NONE ||
  233. (wall.document.door != 0 && wall.document.ds === 1)
  234. );
  235. } else if (collisionType === 2) {
  236. return (
  237. wall.document.sound === CONST.WALL_MOVEMENT_TYPES.NONE ||
  238. wall.document.sound > 20 ||
  239. (wall.document.door != 0 && wall.document.ds === 1)
  240. );
  241. } else if (collisionType === 3) {
  242. return (
  243. wall.document.light === CONST.WALL_MOVEMENT_TYPES.NONE ||
  244. wall.document.light > 20 ||
  245. (wall.document.door != 0 && wall.document.ds === 1)
  246. );
  247. }
  248. }
  249. /**
  250. * Perform a collision test between 2 point in 3D space
  251. * @param {Object} p0 - a point in 3d space {x:x,y:y,z:z} where z is the elevation
  252. * @param {Object} p1 - a point in 3d space {x:x,y:y,z:z} where z is the elevation
  253. * @param {String} type - "sight" or "move"/"collision" or "sound" or "light" (defaults to "sight")
  254. * @returns {Boolean} returns the collision point if a collision is detected, flase if it's not
  255. **/
  256. static testCollision(p0, p1, type = "sight", options) {
  257. if (canvas?.scene?.flags['levels-3d-preview']?.object3dSight) {
  258. if (!game.Levels3DPreview?._active) return true;
  259. return game.Levels3DPreview.interactionManager.computeSightCollision(
  260. p0,
  261. p1,
  262. type
  263. );
  264. }
  265. //Declare points adjusted with token height to use in the loop
  266. const x0 = p0.x;
  267. const y0 = p0.y;
  268. const z0 = p0.z;
  269. const x1 = p1.x;
  270. const y1 = p1.y;
  271. const z1 = p1.z;
  272. const TYPE = type == "sight" ? 0 : type == "sound" ? 2 : type == "light" ? 3 : 1;
  273. const ALPHATTHRESHOLD = type == "sight" ? 0.99 : 0.1;
  274. //If the point are on the same Z axis return the 3d wall test
  275. if (z0 == z1) {
  276. return walls3dTest.bind(this)();
  277. }
  278. //Check the background for collisions
  279. const bgElevation = canvas?.scene?.flags?.levels?.backgroundElevation ?? 0
  280. const zIntersectionPointBG = getPointForPlane(bgElevation);
  281. if (((z0 < bgElevation && bgElevation < z1) || (z1 < bgElevation && bgElevation < z0))) {
  282. return {
  283. x: zIntersectionPointBG.x,
  284. y: zIntersectionPointBG.y,
  285. z: bgElevation,
  286. };
  287. }
  288. //Loop through all the planes and check for both ceiling and floor collision on each tile
  289. for (let tile of canvas.tiles.placeables) {
  290. if(tile.document.flags?.levels?.noCollision || !tile.document.overhead) continue;
  291. const bottom = tile.document.flags?.levels?.rangeBottom ?? -Infinity;
  292. const top = tile.document.flags?.levels?.rangeTop ?? Infinity;
  293. if (bottom != -Infinity) {
  294. const zIntersectionPoint = getPointForPlane(bottom);
  295. if (((z0 < bottom && bottom < z1) || (z1 < bottom && bottom < z0)) && tile.mesh?.containsPixel(zIntersectionPoint.x, zIntersectionPoint.y, ALPHATTHRESHOLD)) {
  296. return {
  297. x: zIntersectionPoint.x,
  298. y: zIntersectionPoint.y,
  299. z: bottom,
  300. };
  301. }
  302. }
  303. }
  304. //Return the 3d wall test if no collisions were detected on the Z plane
  305. return walls3dTest.bind(this)();
  306. //Get the intersection point between the ray and the Z plane
  307. function getPointForPlane(z) {
  308. const x = ((z - z0) * (x1 - x0) + x0 * z1 - x0 * z0) / (z1 - z0);
  309. const y = ((z - z0) * (y1 - y0) + z1 * y0 - z0 * y0) / (z1 - z0);
  310. const point = { x: x, y: y };
  311. return point;
  312. }
  313. //Check if a point in 2d space is betweeen 2 points
  314. function isBetween(a, b, c) {
  315. //test
  316. //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))
  317. const dotproduct = (c.x - a.x) * (b.x - a.x) + (c.y - a.y) * (b.y - a.y);
  318. if (dotproduct < 0) return false;
  319. const squaredlengthba =
  320. (b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y);
  321. if (dotproduct > squaredlengthba) return false;
  322. return true;
  323. }
  324. //Get wall heights flags, avoid infinity, use arbitrary large number instead
  325. function getWallHeightRange3Dcollision(wall) {
  326. let { top, bottom } = WallHeight.getWallBounds(wall);
  327. if (bottom == -Infinity) bottom = -1e9;
  328. if (top == Infinity) top = 1e9;
  329. let wallRange = [bottom, top];
  330. if (!wallRange[0] && !wallRange[1]) return false;
  331. else return wallRange;
  332. }
  333. //Compute 3d collision for walls
  334. function walls3dTest() {
  335. const rectX = Math.min(x0, x1);
  336. const rectY = Math.min(y0, y1);
  337. const rectW = Math.abs(x1 - x0);
  338. const rectH = Math.abs(y1 - y0);
  339. const rect = new PIXI.Rectangle(rectX, rectY, rectW, rectH);
  340. const walls = canvas.walls.quadtree.getObjects(rect);
  341. let terrainWalls = 0;
  342. for (let wall of walls) {
  343. if (this.shouldIgnoreWall(wall, TYPE, options)) continue;
  344. let isTerrain =
  345. TYPE === 0 && wall.document.sight === CONST.WALL_SENSE_TYPES.LIMITED ||
  346. TYPE === 1 && wall.document.move === CONST.WALL_SENSE_TYPES.LIMITED ||
  347. TYPE === 2 && wall.document.sound === CONST.WALL_SENSE_TYPES.LIMITED ||
  348. TYPE === 3 && wall.document.light === CONST.WALL_SENSE_TYPES.LIMITED;
  349. //declare points in 3d space of the rectangle created by the wall
  350. const wallBotTop = getWallHeightRange3Dcollision(wall);
  351. const wx1 = wall.document.c[0];
  352. const wx2 = wall.document.c[2];
  353. const wx3 = wall.document.c[2];
  354. const wy1 = wall.document.c[1];
  355. const wy2 = wall.document.c[3];
  356. const wy3 = wall.document.c[3];
  357. const wz1 = wallBotTop[0];
  358. const wz2 = wallBotTop[0];
  359. const wz3 = wallBotTop[1];
  360. //calculate the parameters for the infinite plane the rectangle defines
  361. const A = wy1 * (wz2 - wz3) + wy2 * (wz3 - wz1) + wy3 * (wz1 - wz2);
  362. const B = wz1 * (wx2 - wx3) + wz2 * (wx3 - wx1) + wz3 * (wx1 - wx2);
  363. const C = wx1 * (wy2 - wy3) + wx2 * (wy3 - wy1) + wx3 * (wy1 - wy2);
  364. const D =
  365. -wx1 * (wy2 * wz3 - wy3 * wz2) -
  366. wx2 * (wy3 * wz1 - wy1 * wz3) -
  367. wx3 * (wy1 * wz2 - wy2 * wz1);
  368. //solve for p0 p1 to check if the points are on opposite sides of the plane or not
  369. const P1 = A * x0 + B * y0 + C * z0 + D;
  370. const P2 = A * x1 + B * y1 + C * z1 + D;
  371. //don't do anything else if the points are on the same side of the plane
  372. if (P1 * P2 > 0) continue;
  373. //Check for directional walls
  374. if (wall.direction !== null) {
  375. // Directional walls where the ray angle is not in the same hemisphere
  376. const rayAngle = Math.atan2(y1 - y0, x1 - x0);
  377. const angleBounds = [rayAngle - Math.PI / 2, rayAngle + Math.PI / 2];
  378. if (!wall.isDirectionBetweenAngles(...angleBounds)) continue;
  379. }
  380. //calculate intersection point
  381. const t =
  382. -(A * x0 + B * y0 + C * z0 + D) /
  383. (A * (x1 - x0) + B * (y1 - y0) + C * (z1 - z0)); //-(A*x0 + B*y0 + C*z0 + D) / (A*x1 + B*y1 + C*z1)
  384. const ix = x0 + (x1 - x0) * t;
  385. const iy = y0 + (y1 - y0) * t;
  386. const iz = Math.round(z0 + (z1 - z0) * t);
  387. //return true if the point is inisde the rectangle
  388. const isb = isBetween(
  389. { x: wx1, y: wy1 },
  390. { x: wx2, y: wy2 },
  391. { x: ix, y: iy }
  392. );
  393. if (
  394. isTerrain &&
  395. isb &&
  396. iz <= wallBotTop[1] &&
  397. iz >= wallBotTop[0] &&
  398. terrainWalls == 0
  399. ) {
  400. terrainWalls++;
  401. continue;
  402. }
  403. if (isb && iz <= wallBotTop[1] && iz >= wallBotTop[0])
  404. return { x: ix, y: iy, z: iz };
  405. }
  406. return false;
  407. }
  408. }
  409. /**
  410. * Perform a collision test between 2 TOKENS in 3D space
  411. * @param {Token|{x:number,y:number,z:number}} token1 - a token or a point in 3d space where z is the elevation
  412. * @param {Token|{x:number,y:number,z:number}} token2 - a token or a point in 3d space where z is the elevation
  413. * @param {String} type - "sight" or "move"/"collision" or "sound" or "light" (defaults to "sight")
  414. * @returns {Boolean} returns the collision point if a collision is detected, flase if it's not
  415. **/
  416. static checkCollision(tokenOrPoint1, tokenOrPoint2, type = "sight") {
  417. const p0 = tokenOrPoint1 instanceof Token ? {
  418. x: tokenOrPoint1.vision.x,
  419. y: tokenOrPoint1.vision.y,
  420. z: tokenOrPoint1.losHeight,
  421. } : tokenOrPoint1;
  422. const p1 = tokenOrPoint2 instanceof Token ? {
  423. x: tokenOrPoint2.center.x,
  424. y: tokenOrPoint2.center.y,
  425. z: tokenOrPoint2.losHeight,
  426. } : tokenOrPoint2;
  427. return this.testCollision(p0, p1, type, {source: tokenOrPoint1, target: tokenOrPoint2});
  428. }
  429. }