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.

462 lines
17 KiB

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