Create utility class to test the line of sight between Character and target point

This commit is contained in:
2025-07-14 10:29:09 +02:00
parent ccc516ca27
commit dda12589a7
4 changed files with 234 additions and 35 deletions

View File

@@ -2,7 +2,7 @@ package com.bartlomiejpluta.base.lib.ai;
import com.bartlomiejpluta.base.api.ai.AI;
import com.bartlomiejpluta.base.api.ai.NPC;
import com.bartlomiejpluta.base.api.location.Locationable;
import com.bartlomiejpluta.base.api.entity.Entity;
import com.bartlomiejpluta.base.api.map.layer.object.ObjectLayer;
import com.bartlomiejpluta.base.api.move.MoveEvent;
import com.bartlomiejpluta.base.util.path.MovementPath;
@@ -10,8 +10,10 @@ import com.bartlomiejpluta.base.util.path.PathExecutor;
import com.bartlomiejpluta.base.util.pathfinder.PathFinder;
import lombok.NonNull;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
public abstract class FollowObjectAI<N extends NPC, T extends Locationable> implements AI {
@Slf4j
public abstract class FollowObjectAI<N extends NPC, T extends Entity> implements AI {
private final PathFinder finder;
private final PathExecutor<N> executor;
@@ -22,55 +24,87 @@ public abstract class FollowObjectAI<N extends NPC, T extends Locationable> impl
private MovementPath<N> path = null;
private boolean sees = false;
protected FollowObjectAI(@NonNull PathFinder finder, @NonNull N npc, @NonNull T target) {
this.finder = finder;
this.npc = npc;
this.target = target;
this.executor = new PathExecutor<>(npc);
npc.addEventListener(MoveEvent.TYPE, this::onMove);
}
public void recomputePath() {
path = null;
}
private void onMove(MoveEvent event) {
if (npc.getStrategy() != this) {
return;
}
public void recomputePath(@NonNull MoveEvent event) {
var movable = event.getMovable();
// Refresh only when target has been displaced
if (movable == npc) {
this.sees = sees(npc, target);
return;
}
// Recalculate path only when target has been displaced
// or another object is blocking current path
if (movable == target || (path != null && path.contains(movable))) {
path = null;
if (movable == target) {
this.path = null;
this.sees = sees(npc, target);
return;
}
// Recalculate path when another object is blocking current path
if (path != null && path.contains(movable)) {
this.path = null;
}
}
@Override
public void nextActivity(ObjectLayer layer, float dt) {
if (!npc.isMoving()) {
var distance = npc.manhattanDistance(target);
if (distance == 1) {
npc.setFaceDirection(npc.getDirectionTowards(target));
interact(npc, target, layer, dt);
} else if (sees(npc, target, layer, distance)) {
follow(npc, target, layer, dt);
if (path == null || path.isEmpty()) {
path = finder.findPath(layer, npc, target.getCoordinates());
executor.setPath(path);
}
executor.execute(layer, dt);
} else {
idle(npc, target, layer, dt);
}
if (npc.isMoving()) {
return;
}
var distance = npc.manhattanDistance(target);
if (distance == 1) {
npc.setFaceDirection(npc.getDirectionTowards(target));
interact(npc, target, layer, dt);
return;
}
if (sees) {
follow(npc, target, layer, dt);
// Calculate path
if (path == null || path.isEmpty()) {
path = finder.findPath(layer, npc, target.getCoordinates());
executor.setPath(path);
}
executor.execute(layer, dt);
return;
}
idle(npc, target, layer, dt);
}
protected abstract boolean sees(N npc, T target, ObjectLayer layer, int distance);
protected abstract void interact(N npc, T target, ObjectLayer layer, float dt);
protected abstract boolean sees(N npc, T target);
protected abstract void follow(N npc, T target, ObjectLayer layer, float dt);
protected void interact(N npc, T target, ObjectLayer layer, float dt) {
protected abstract void idle(N npc, T target, ObjectLayer layer, float dt);
}
protected void follow(N npc, T target, ObjectLayer layer, float dt) {
}
protected void idle(N npc, T target, ObjectLayer layer, float dt) {
}
}

View File

@@ -34,13 +34,15 @@ public abstract class KeepStraightDistanceAI<N extends NPC, T extends Locationab
this.maxRange = maxRange;
this.finder = finder;
this.executor = new PathExecutor<>(npc);
npc.addEventListener(MoveEvent.TYPE, this::onMove);
}
public void recomputePath() {
path = null;
}
private void onMove(MoveEvent event) {
if (npc.getStrategy() != this) {
return;
}
public void recomputePath(@NonNull MoveEvent event) {
var movable = event.getMovable();
// Refresh only when target has been displaced

View File

@@ -0,0 +1,67 @@
package com.bartlomiejpluta.base.util.math;
import org.joml.Vector2i;
import org.joml.Vector2ic;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Function;
public class BresenhamLine {
public static List<Vector2i> bresenhamLine(Vector2ic start, Vector2ic end) {
return bresenhamLine(start.x(), start.y(), end.x(), end.y());
}
public static List<Vector2i> bresenhamLine(int startX, int startY, int endX, int endY) {
var list = new LinkedList<Vector2i>();
bresenhamLine(startX, startY, endX, endY, v -> {
list.add(v);
return null;
});
return list;
}
public static <T> T bresenhamLine(Vector2ic start, Vector2ic end, Function<Vector2i, T> consumer) {
return bresenhamLine(start.x(), start.y(), end.x(), end.y(), consumer, null);
}
public static <T> T bresenhamLine(int startX, int startY, int endX, int endY, Function<Vector2i, T> consumer) {
return bresenhamLine(startX, startY, endX, endY, consumer, null);
}
public static <T> T bresenhamLine(Vector2ic start, Vector2ic end, Function<Vector2i, T> consumer, T defaultValue) {
return bresenhamLine(start.x(), start.y(), end.x(), end.y(), consumer, defaultValue);
}
public static <T> T bresenhamLine(int startX, int startY, int endX, int endY, Function<Vector2i, T> consumer, T defaultValue) {
int dx = Math.abs(endX - startX), dy = Math.abs(endY - startY);
int sx = startX < endX ? 1 : -1, sy = startY < endY ? 1 : -1;
int err = dx - dy;
while (true) {
var result = consumer.apply(new Vector2i(startX, startY));
if (result != null) {
return result;
}
if (startX == endX && startY == endY) break;
int e2 = err << 1;
if (e2 > -dy) {
err -= dy;
startX += sx;
}
if (e2 < dx) {
err += dx;
startY += sy;
}
}
return defaultValue;
}
}

View File

@@ -0,0 +1,96 @@
package com.bartlomiejpluta.base.util.visibility;
import com.bartlomiejpluta.base.api.character.Character;
import org.joml.Vector2ic;
import static com.bartlomiejpluta.base.util.math.BresenhamLine.bresenhamLine;
public class VisibilityChecker {
final int[][] DIRS = {{0, 1}, {-1, 0}, {0, -1}, {1, 0}}; // UP, LEFT, DOWN, RIGHT
public static boolean isInCone(Character observer, Vector2ic target, float angle, int maxDistance) {
var origin = observer.getCoordinates();
var direction = observer.getFaceDirection().vector;
return isInCone(origin.x(), origin.y(), direction.x(), direction.y(), target.x(), target.y(), angle, maxDistance);
}
/**
* Checks if a point (x, y) lies within a vision cone defined by an origin (x0, y0),
* a direction vector (dx, dy), a cone angle, and a maximum distance.
*
* @param x0 The x-coordinate of the origin point.
* @param y0 The y-coordinate of the origin point.
* @param dx The x-component of the direction vector (should be normalized or non-zero).
* @param dy The y-component of the direction vector (should be normalized or non-zero).
* @param x The x-coordinate of the point to check.
* @param y The y-coordinate of the point to check.
* @param angle The full angle of the cone in radians.
* @param maxDistance The maximum distance from the origin to consider (points beyond are outside the cone).
* @return true if the point is within the cone and range, false otherwise.
*/
public static boolean isInCone(int x0, int y0, int dx, int dy, int x, int y, float angle, int maxDistance) {
int vx = x - x0;
int vy = y - y0;
int lenSq = vx * vx + vy * vy;
if (lenSq == 0) return true; // point is exactly at origin
if (maxDistance > 0 && lenSq > maxDistance * maxDistance) return false; // outside max distance
// Compute dot product between direction and vector to point
int dot = dx * vx + dy * vy;
// Precompute cosine of half the cone angle
float halfAngle = angle * 0.5f;
float cosHalfAngle = (float) Math.cos(halfAngle);
// Normalize dot by length of vector to point (len)
float len = (float) Math.sqrt(lenSq);
float cosTheta = dot / len;
// Check if the point is within the cone
return cosTheta >= cosHalfAngle;
}
public static boolean canSee(Character observer, Character target, float angle, int range, boolean direction) {
var layer = observer.getLayer();
// Observer and target are on different layers
if (layer != target.getLayer()) {
return false;
}
// Target is out of observer's visibility area
if (observer.chebyshevDistance(target) > range) {
return false;
}
// Observer does not look at the target's direction
var targetCoords = target.getCoordinates();
if (direction && !isInCone(observer, targetCoords, angle, range)) {
return false;
}
var observerCoords = observer.getCoordinates();
// Checking line of sight
return bresenhamLine(observer.getCoordinates(), target.getCoordinates(), v -> {
// Don't check observer coords
if (observerCoords.equals(v)) {
return null;
}
// Target is visible
if (targetCoords.equals(v)) {
return true;
}
// Obstacle on the sight line
if (!layer.isTileReachable(v)) {
return false;
}
return null;
}, false);
}
}