|
export class SightHandler {
|
|
|
|
static _testRange(visionSource, mode, target, test) {
|
|
if (mode.range <= 0) return false;
|
|
let radius = visionSource.object.getLightRadius(mode.range);
|
|
const unitsToPixel = canvas.dimensions.size / canvas.dimensions.distance;
|
|
const sourceZ = visionSource.elevation * unitsToPixel;
|
|
const dx = test.point.x - visionSource.x;
|
|
const dy = test.point.y - visionSource.y;
|
|
const dz = (test.point.z ?? sourceZ) - sourceZ;
|
|
return (dx * dx + dy * dy + dz * dz) <= radius*radius;
|
|
}
|
|
|
|
static performLOSTest(sourceToken, tokenOrPoint, source, type = "sight") {
|
|
return this.advancedLosTestVisibility(sourceToken, tokenOrPoint, source, type);
|
|
}
|
|
|
|
static advancedLosTestVisibility(sourceToken, tokenOrPoint, source, type = "sight") {
|
|
const angleTest = this.testInAngle(sourceToken, tokenOrPoint, source);
|
|
if (!angleTest) return false;
|
|
return !this.advancedLosTestInLos(sourceToken, tokenOrPoint, type);
|
|
const inLOS = !this.advancedLosTestInLos(sourceToken, tokenOrPoint, type);
|
|
if(sourceToken.vision.los === source) return inLOS;
|
|
const inRange = this.tokenInRange(sourceToken, tokenOrPoint);
|
|
if (inLOS && inRange) return true;
|
|
return false;
|
|
}
|
|
|
|
static getTestPoints(token, tol = 4) {
|
|
const targetLOSH = token.losHeight;
|
|
if (CONFIG.Levels.settings.get("preciseTokenVisibility") === false)
|
|
return [{ x: token.center.x, y: token.center.y, z: targetLOSH }];
|
|
const targetElevation =
|
|
token.document.elevation + (targetLOSH - token.document.elevation) * 0.1;
|
|
const tokenCorners = [
|
|
{ x: token.center.x, y: token.center.y, z: targetLOSH },
|
|
{ x: token.x + tol, y: token.y + tol, z: targetLOSH },
|
|
{ x: token.x + token.w - tol, y: token.y + tol, z: targetLOSH },
|
|
{ x: token.x + tol, y: token.y + token.h - tol, z: targetLOSH },
|
|
{ x: token.x + token.w - tol, y: token.y + token.h - tol, z: targetLOSH },
|
|
];
|
|
if (CONFIG.Levels.settings.get("exactTokenVisibility")) {
|
|
tokenCorners.push(
|
|
{
|
|
x: token.center.x,
|
|
y: token.center.y,
|
|
z: targetElevation + (targetLOSH - targetElevation) / 2,
|
|
},
|
|
{ x: token.center.x, y: token.center.y, z: targetElevation },
|
|
{ x: token.x + tol, y: token.y + tol, z: targetElevation },
|
|
{ x: token.x + token.w - tol, y: token.y + tol, z: targetElevation },
|
|
{ x: token.x + tol, y: token.y + token.h - tol, z: targetElevation },
|
|
{
|
|
x: token.x + token.w - tol,
|
|
y: token.y + token.h - tol,
|
|
z: targetElevation,
|
|
},
|
|
);
|
|
}
|
|
return tokenCorners;
|
|
}
|
|
|
|
static advancedLosTestInLos(sourceToken, tokenOrPoint, type = "sight") {
|
|
if (!(tokenOrPoint instanceof Token) || CONFIG.Levels.settings.get("preciseTokenVisibility") === false)
|
|
return this.checkCollision(sourceToken, tokenOrPoint, type);
|
|
const sourceCenter = {
|
|
x: sourceToken.vision.x,
|
|
y: sourceToken.vision.y,
|
|
z: sourceToken.losHeight,
|
|
};
|
|
for (let point of this.getTestPoints(tokenOrPoint)) {
|
|
let collision = this.testCollision(
|
|
sourceCenter,
|
|
point,
|
|
type,
|
|
{source: sourceToken, target: tokenOrPoint}
|
|
|
|
);
|
|
if (!collision) return collision;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static testInAngle(sourceToken, tokenOrPoint, source) {
|
|
const documentAngle = source?.config?.angle ?? sourceToken.document?.sight?.angle ?? sourceToken.document?.config?.angle
|
|
if (documentAngle == 360) return true;
|
|
|
|
//normalize angle
|
|
function normalizeAngle(angle) {
|
|
let normalized = angle % (Math.PI * 2);
|
|
if (normalized < 0) normalized += Math.PI * 2;
|
|
return normalized;
|
|
}
|
|
|
|
const point = tokenOrPoint instanceof Token ? tokenOrPoint.center : tokenOrPoint;
|
|
|
|
//check angled vision
|
|
const angle = normalizeAngle(
|
|
Math.atan2(
|
|
point.y - sourceToken.vision.y,
|
|
point.x - sourceToken.vision.x
|
|
)
|
|
);
|
|
const rotation = (((sourceToken.document.rotation + 90) % 360) * Math.PI) / 180;
|
|
const end = normalizeAngle(
|
|
rotation + (documentAngle * Math.PI) / 180 / 2
|
|
);
|
|
const start = normalizeAngle(
|
|
rotation - (documentAngle * Math.PI) / 180 / 2
|
|
);
|
|
if (start > end) return angle >= start || angle <= end;
|
|
return angle >= start && angle <= end;
|
|
}
|
|
|
|
static tokenInRange(sourceToken, tokenOrPoint) {
|
|
const range = sourceToken.vision.radius;
|
|
if (range === 0) return false;
|
|
if (range === Infinity) return true;
|
|
const tokensSizeAdjust = tokenOrPoint instanceof Token
|
|
? (Math.min(tokenOrPoint.w, tokenOrPoint.h) || 0) / Math.SQRT2 : 0;
|
|
const dist =
|
|
(this.getUnitTokenDist(sourceToken, tokenOrPoint) * canvas.dimensions.size) /
|
|
canvas.dimensions.distance -
|
|
tokensSizeAdjust;
|
|
return dist <= range;
|
|
}
|
|
|
|
static getUnitTokenDist(token1, tokenOrPoint2) {
|
|
const unitsToPixel = canvas.dimensions.size / canvas.dimensions.distance;
|
|
const x1 = token1.vision.x;
|
|
const y1 = token1.vision.y;
|
|
const z1 = token1.losHeight;
|
|
let x2, y2, z2;
|
|
|
|
if (tokenOrPoint2 instanceof Token) {
|
|
x1 = tokenOrPoint2.center.x;
|
|
y1 = tokenOrPoint2.center.y;
|
|
z1 = tokenOrPoint2.losHeight;
|
|
} else {
|
|
x1 = tokenOrPoint2.x;
|
|
y1 = tokenOrPoint2.y;
|
|
z1 = tokenOrPoint2.z;
|
|
}
|
|
|
|
const d =
|
|
Math.sqrt(
|
|
Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2) + Math.pow((z2 - z1) * unitsToPixel, 2)
|
|
) / unitsToPixel;
|
|
return d;
|
|
}
|
|
|
|
static testInLight(object, testTarget, source, result){
|
|
const unitsToPixel = canvas.dimensions.size / canvas.dimensions.distance;
|
|
const top = object.document.flags?.levels?.rangeTop ?? Infinity;
|
|
const bottom = object.document.flags?.levels?.rangeBottom ?? -Infinity;
|
|
let lightHeight = null;
|
|
if(object instanceof Token){
|
|
lightHeight = object.losHeight;
|
|
}else if(top != Infinity && bottom != -Infinity){
|
|
lightHeight = (top + bottom) / 2;
|
|
}
|
|
else if(top != Infinity){
|
|
lightHeight = top;
|
|
}
|
|
else if(bottom != -Infinity){
|
|
lightHeight = bottom;
|
|
}
|
|
if(lightHeight == null) return result;
|
|
const lightRadius = source.config.radius/unitsToPixel;
|
|
const targetLOSH = testTarget.losHeight;
|
|
const targetElevation = testTarget.document.elevation;
|
|
const lightTop = lightHeight + lightRadius;
|
|
const lightBottom = lightHeight - lightRadius;
|
|
if(targetLOSH <= lightTop && targetLOSH >= lightBottom){
|
|
return result;
|
|
}
|
|
if(targetElevation <= lightTop && targetElevation >= lightBottom){
|
|
return result;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static _testCollision(wrapped, ...args) {
|
|
const visionSource = this.config?.source;
|
|
const target = CONFIG?.Levels?.visibilityTestObject;
|
|
if(!visionSource?.object || !target) return wrapped(...args);
|
|
let targetElevation;
|
|
if (target instanceof Token) {
|
|
targetElevation = target.losHeight;
|
|
} else if (target instanceof PlaceableObject) {
|
|
targetElevation = target.document.elevation ?? target.document.flags.levels?.rangeBottom;
|
|
} else if (target instanceof DoorControl) {
|
|
targetElevation = visionSource.elevation;
|
|
} else {
|
|
targetElevation = canvas.primary.background.elevation;
|
|
}
|
|
const p1 = {
|
|
x: args[0].A.x,
|
|
y: args[0].A.y,
|
|
z: visionSource.elevation,
|
|
};
|
|
const p2 = {
|
|
x: args[0].B.x,
|
|
y: args[0].B.y,
|
|
z: targetElevation,
|
|
};
|
|
const result = CONFIG.Levels.API.testCollision(p1,p2, this.config.type, {source: visionSource, target: target});
|
|
switch (args[1]) {
|
|
case "any": return !!result;
|
|
case "all": return result ? [PolygonVertex.fromPoint(result)] : [];
|
|
default: return result ? PolygonVertex.fromPoint(result) : null;
|
|
}
|
|
}
|
|
|
|
static containsWrapper(wrapped, ...args){
|
|
const LevelsConfig = CONFIG.Levels;
|
|
const testTarget = LevelsConfig.visibilityTestObject;
|
|
if(!this.config?.source?.object || !(testTarget instanceof Token) || this.config.source instanceof GlobalLightSource) return wrapped(...args);
|
|
let result;
|
|
if(this.config.source instanceof LightSource){
|
|
result = LevelsConfig.handlers.SightHandler.testInLight(this.config.source.object, testTarget, this, wrapped(...args));
|
|
}else if(this.config.source.object instanceof Token){
|
|
const point = {
|
|
x: args[0],
|
|
y: args[1],
|
|
z: testTarget.losHeight,
|
|
object: testTarget
|
|
};
|
|
result = LevelsConfig.handlers.SightHandler.performLOSTest(this.config.source.object, point, this, this.config.type);
|
|
}else{
|
|
result = wrapped(...args);
|
|
}
|
|
return result;
|
|
}
|
|
/**
|
|
* Check whether the given wall should be tested for collisions, based on the collision type and wall configuration
|
|
* @param {Object} wall - The wall being checked
|
|
* @param {Integer} collisionType - The collision type being checked: 0 for sight, 1 for movement, 2 for sound, 3 for light
|
|
* @returns {boolean} Whether the wall should be ignored
|
|
*/
|
|
static shouldIgnoreWall(wall, collisionType, options) {
|
|
const proximity = this.shouldIgnoreProximityWall(wall.document, options.A, options.B, options.source?.vision?.data?.externalRadius ?? 0)
|
|
if (collisionType === 0) {
|
|
return (
|
|
wall.document.sight === CONST.WALL_SENSE_TYPES.NONE ||
|
|
proximity ||
|
|
//wall.document.sight > 20 ||
|
|
(wall.document.door != 0 && wall.document.ds === 1)
|
|
);
|
|
} else if (collisionType === 1) {
|
|
return (
|
|
wall.document.move === CONST.WALL_MOVEMENT_TYPES.NONE ||
|
|
(wall.document.door != 0 && wall.document.ds === 1)
|
|
);
|
|
} else if (collisionType === 2) {
|
|
return (
|
|
wall.document.sound === CONST.WALL_MOVEMENT_TYPES.NONE ||
|
|
wall.document.sound > 20 ||
|
|
(wall.document.door != 0 && wall.document.ds === 1)
|
|
);
|
|
} else if (collisionType === 3) {
|
|
return (
|
|
wall.document.light === CONST.WALL_MOVEMENT_TYPES.NONE ||
|
|
wall.document.light > 20 ||
|
|
(wall.document.door != 0 && wall.document.ds === 1)
|
|
);
|
|
}
|
|
}
|
|
|
|
static shouldIgnoreProximityWall(document, source, target, externalRadius = 0) {
|
|
if(!source || !target) return false;
|
|
const d = document.threshold?.sight
|
|
if (!d || d.sight < 30) return false; // No threshold applies
|
|
const proximity = document.sight === CONST.WALL_SENSE_TYPES.PROXIMITY;
|
|
const pt = foundry.utils.closestPointToSegment(source, document.object.A, document.object.B);
|
|
//const ptTarget = foundry.utils.closestPointToSegment(target, document.object.A, document.object.B);
|
|
//const targetDistance = Math.hypot(ptTarget.x - target.x, ptTarget.y - target.y);
|
|
const sourceDistance = Math.hypot(pt.x - source.x, pt.y - source.y);
|
|
const sourceTargetDistance = Math.hypot(source.x - target.x, source.y - target.y);
|
|
const thresholdDistance = d * document.parent.dimensions.distancePixels;
|
|
return proximity ? sourceTargetDistance <= thresholdDistance || sourceDistance <= thresholdDistance / 2 : sourceDistance + externalRadius > thresholdDistance;
|
|
}
|
|
|
|
/**
|
|
* Perform a collision test between 2 point in 3D space
|
|
* @param {Object} p0 - a point in 3d space {x:x,y:y,z:z} where z is the elevation
|
|
* @param {Object} p1 - a point in 3d space {x:x,y:y,z:z} where z is the elevation
|
|
* @param {String} type - "sight" or "move"/"collision" or "sound" or "light" (defaults to "sight")
|
|
* @returns {Boolean} returns the collision point if a collision is detected, flase if it's not
|
|
**/
|
|
|
|
static testCollision(p0, p1, type = "sight", options = {}) {
|
|
if (canvas?.scene?.flags['levels-3d-preview']?.object3dSight) {
|
|
if (!game.Levels3DPreview?._active) return true;
|
|
return game.Levels3DPreview.interactionManager.computeSightCollision(
|
|
p0,
|
|
p1,
|
|
type
|
|
);
|
|
}
|
|
//Declare points adjusted with token height to use in the loop
|
|
const x0 = p0.x;
|
|
const y0 = p0.y;
|
|
const z0 = p0.z;
|
|
const x1 = p1.x;
|
|
const y1 = p1.y;
|
|
const z1 = p1.z;
|
|
|
|
options.A = {...p0};
|
|
options.B = {...p1};
|
|
options.targetObject = p1.object ?? null;
|
|
|
|
const TYPE = type == "sight" ? 0 : type == "sound" ? 2 : type == "light" ? 3 : 1;
|
|
const ALPHATTHRESHOLD = type == "sight" ? 0.99 : 0.1;
|
|
//If the point are on the same Z axis return the 3d wall test
|
|
if (z0 == z1) {
|
|
return walls3dTest.bind(this)();
|
|
}
|
|
|
|
//Check the background for collisions
|
|
const bgElevation = canvas?.scene?.flags?.levels?.backgroundElevation ?? 0
|
|
const zIntersectionPointBG = getPointForPlane(bgElevation);
|
|
if (((z0 < bgElevation && bgElevation < z1) || (z1 < bgElevation && bgElevation < z0))) {
|
|
return {
|
|
x: zIntersectionPointBG.x,
|
|
y: zIntersectionPointBG.y,
|
|
z: bgElevation,
|
|
};
|
|
}
|
|
|
|
|
|
//Loop through all the planes and check for both ceiling and floor collision on each tile
|
|
for (let tile of canvas.tiles.placeables) {
|
|
if(tile.document.flags?.levels?.noCollision || !tile.document.overhead) continue;
|
|
const bottom = tile.document.flags?.levels?.rangeBottom ?? -Infinity;
|
|
const top = tile.document.flags?.levels?.rangeTop ?? Infinity;
|
|
if (bottom != -Infinity) {
|
|
const zIntersectionPoint = getPointForPlane(bottom);
|
|
if (((z0 < bottom && bottom < z1) || (z1 < bottom && bottom < z0)) && tile.mesh?.containsPixel(zIntersectionPoint.x, zIntersectionPoint.y, ALPHATTHRESHOLD)) {
|
|
return {
|
|
x: zIntersectionPoint.x,
|
|
y: zIntersectionPoint.y,
|
|
z: bottom,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
//Return the 3d wall test if no collisions were detected on the Z plane
|
|
return walls3dTest.bind(this)();
|
|
|
|
//Get the intersection point between the ray and the Z plane
|
|
function getPointForPlane(z) {
|
|
const x = ((z - z0) * (x1 - x0) + x0 * z1 - x0 * z0) / (z1 - z0);
|
|
const y = ((z - z0) * (y1 - y0) + z1 * y0 - z0 * y0) / (z1 - z0);
|
|
const point = { x: x, y: y };
|
|
return point;
|
|
}
|
|
//Check if a point in 2d space is betweeen 2 points
|
|
function isBetween(a, b, c) {
|
|
//test
|
|
//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))
|
|
|
|
const dotproduct = (c.x - a.x) * (b.x - a.x) + (c.y - a.y) * (b.y - a.y);
|
|
if (dotproduct < 0) return false;
|
|
|
|
const squaredlengthba =
|
|
(b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y);
|
|
if (dotproduct > squaredlengthba) return false;
|
|
|
|
return true;
|
|
}
|
|
//Get wall heights flags, avoid infinity, use arbitrary large number instead
|
|
function getWallHeightRange3Dcollision(wall) {
|
|
let { top, bottom } = WallHeight.getWallBounds(wall);
|
|
if (bottom == -Infinity) bottom = -1e9;
|
|
if (top == Infinity) top = 1e9;
|
|
let wallRange = [bottom, top];
|
|
if (!wallRange[0] && !wallRange[1]) return false;
|
|
else return wallRange;
|
|
}
|
|
//Compute 3d collision for walls
|
|
function walls3dTest() {
|
|
const rectX = Math.min(x0, x1);
|
|
const rectY = Math.min(y0, y1);
|
|
const rectW = Math.abs(x1 - x0);
|
|
const rectH = Math.abs(y1 - y0);
|
|
const rect = new PIXI.Rectangle(rectX, rectY, rectW, rectH);
|
|
const walls = canvas.walls.quadtree.getObjects(rect);
|
|
let terrainWalls = 0;
|
|
for (let wall of walls) {
|
|
if (this.shouldIgnoreWall(wall, TYPE, options)) continue;
|
|
|
|
let isTerrain =
|
|
TYPE === 0 && wall.document.sight === CONST.WALL_SENSE_TYPES.LIMITED ||
|
|
TYPE === 1 && wall.document.move === CONST.WALL_SENSE_TYPES.LIMITED ||
|
|
TYPE === 2 && wall.document.sound === CONST.WALL_SENSE_TYPES.LIMITED ||
|
|
TYPE === 3 && wall.document.light === CONST.WALL_SENSE_TYPES.LIMITED;
|
|
|
|
//declare points in 3d space of the rectangle created by the wall
|
|
const wallBotTop = getWallHeightRange3Dcollision(wall);
|
|
const wx1 = wall.document.c[0];
|
|
const wx2 = wall.document.c[2];
|
|
const wx3 = wall.document.c[2];
|
|
const wy1 = wall.document.c[1];
|
|
const wy2 = wall.document.c[3];
|
|
const wy3 = wall.document.c[3];
|
|
const wz1 = wallBotTop[0];
|
|
const wz2 = wallBotTop[0];
|
|
const wz3 = wallBotTop[1];
|
|
|
|
//calculate the parameters for the infinite plane the rectangle defines
|
|
const A = wy1 * (wz2 - wz3) + wy2 * (wz3 - wz1) + wy3 * (wz1 - wz2);
|
|
const B = wz1 * (wx2 - wx3) + wz2 * (wx3 - wx1) + wz3 * (wx1 - wx2);
|
|
const C = wx1 * (wy2 - wy3) + wx2 * (wy3 - wy1) + wx3 * (wy1 - wy2);
|
|
const D =
|
|
-wx1 * (wy2 * wz3 - wy3 * wz2) -
|
|
wx2 * (wy3 * wz1 - wy1 * wz3) -
|
|
wx3 * (wy1 * wz2 - wy2 * wz1);
|
|
|
|
//solve for p0 p1 to check if the points are on opposite sides of the plane or not
|
|
const P1 = A * x0 + B * y0 + C * z0 + D;
|
|
const P2 = A * x1 + B * y1 + C * z1 + D;
|
|
|
|
//don't do anything else if the points are on the same side of the plane
|
|
if (P1 * P2 > 0) continue;
|
|
|
|
//Check for directional walls
|
|
|
|
if (wall.direction !== null) {
|
|
// Directional walls where the ray angle is not in the same hemisphere
|
|
const rayAngle = Math.atan2(y1 - y0, x1 - x0);
|
|
const angleBounds = [rayAngle - Math.PI / 2, rayAngle + Math.PI / 2];
|
|
if (!wall.isDirectionBetweenAngles(...angleBounds)) continue;
|
|
}
|
|
|
|
//calculate intersection point
|
|
const t =
|
|
-(A * x0 + B * y0 + C * z0 + D) /
|
|
(A * (x1 - x0) + B * (y1 - y0) + C * (z1 - z0)); //-(A*x0 + B*y0 + C*z0 + D) / (A*x1 + B*y1 + C*z1)
|
|
const ix = x0 + (x1 - x0) * t;
|
|
const iy = y0 + (y1 - y0) * t;
|
|
const iz = Math.round(z0 + (z1 - z0) * t);
|
|
|
|
//return true if the point is inisde the rectangle
|
|
const isb = isBetween(
|
|
{ x: wx1, y: wy1 },
|
|
{ x: wx2, y: wy2 },
|
|
{ x: ix, y: iy }
|
|
);
|
|
if (
|
|
isTerrain &&
|
|
isb &&
|
|
iz <= wallBotTop[1] &&
|
|
iz >= wallBotTop[0] &&
|
|
terrainWalls == 0
|
|
) {
|
|
terrainWalls++;
|
|
continue;
|
|
}
|
|
if (isb && iz <= wallBotTop[1] && iz >= wallBotTop[0])
|
|
return { x: ix, y: iy, z: iz };
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform a collision test between 2 TOKENS in 3D space
|
|
* @param {Token|{x:number,y:number,z:number}} token1 - a token or a point in 3d space where z is the elevation
|
|
* @param {Token|{x:number,y:number,z:number}} token2 - a token or a point in 3d space where z is the elevation
|
|
* @param {String} type - "sight" or "move"/"collision" or "sound" or "light" (defaults to "sight")
|
|
* @returns {Boolean} returns the collision point if a collision is detected, flase if it's not
|
|
**/
|
|
static checkCollision(tokenOrPoint1, tokenOrPoint2, type = "sight") {
|
|
const p0 = tokenOrPoint1 instanceof Token ? {
|
|
x: tokenOrPoint1.vision.x,
|
|
y: tokenOrPoint1.vision.y,
|
|
z: tokenOrPoint1.losHeight,
|
|
} : tokenOrPoint1;
|
|
const p1 = tokenOrPoint2 instanceof Token ? {
|
|
x: tokenOrPoint2.center.x,
|
|
y: tokenOrPoint2.center.y,
|
|
z: tokenOrPoint2.losHeight,
|
|
} : tokenOrPoint2;
|
|
return this.testCollision(p0, p1, type, {source: tokenOrPoint1, target: tokenOrPoint2});
|
|
}
|
|
}
|