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

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});
}
}