Implemented new AI behavior patterns #1

Open
ScarletRedMan wants to merge 17 commits from feat/ai into master
19 changed files with 1233 additions and 33 deletions
Showing only changes of commit 770fe6c496 - Show all commits

View File

@ -0,0 +1,174 @@
package ru.dragonestia.msb3.api.ai;
import lombok.Getter;
import lombok.Setter;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import ru.dragonestia.msb3.api.ai.navigator.*;
import ru.dragonestia.msb3.api.ai.navigator.follower.GroundNodeFollower;
import ru.dragonestia.msb3.api.ai.navigator.follower.NodeFollower;
import ru.dragonestia.msb3.api.ai.navigator.node.GroundNodeGenerator;
import ru.dragonestia.msb3.api.ai.navigator.node.NodeGenerator;
import ru.dragonestia.msb3.api.entity.EntityAI;
import ru.dragonestia.msb3.api.util.UncheckedRunnable;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
@Getter
public class AI {
private final EntityAI entity;
private DestinationPoint destinationData;
@Setter private NodeGenerator nodeGenerator = new GroundNodeGenerator();
@Setter private NodeFollower nodeFollower;
@Getter private final Actor actor;
public AI(EntityAI entity) {
this.entity = Objects.requireNonNull(entity, "AI is null");
nodeFollower = new GroundNodeFollower(entity);
actor = new Actor(entity);
}
public PathState getCurrentPathState() {
synchronized (this) {
if (destinationData == null) return PathState.NONE;
return destinationData.path().getState();
}
}
public CompletableFuture<PathState> setPathTo(Point point) {
var bb = entity.getBoundingBox();
var centerToCorner = Math.sqrt(bb.width() * bb.width() + bb.depth() * bb.depth()) / 2;
return setPathTo(point, centerToCorner);
}
public CompletableFuture<PathState> setPathTo(Point point, double arrivalDistance) {
return setPathTo(point, arrivalDistance, 50, 20);
}
public CompletableFuture<PathState> setPathTo(Point point, double arrivalDistance, double maxDistance, double pathVariance) {
Objects.requireNonNull(point, "Point is null");
var instance = Objects.requireNonNull(entity.getInstance(), "Entity is not spawned");
if (!instance.getWorldBorder().inBounds(point))
throw new IllegalStateException("Destination point is not in world bounds");
if (!instance.isChunkLoaded(point))
throw new IllegalStateException("Chunk is not loaded for destination point");
synchronized (this) {
// Завершаем активное следование к точке
if (destinationData != null) {
var data = destinationData;
destinationData = null;
UncheckedRunnable.runIgnoreException(() -> data.future().complete(PathState.TERMINATED));
}
// Игрок находится рядом с точкой, куда нужно прийти
if (entity.getDistanceSquared(point) <= arrivalDistance * arrivalDistance) {
return CompletableFuture.completedFuture(PathState.COMPLETED);
}
// Проверка находится ли точка слишком далеко
if (entity.getDistanceSquared(point) > Math.pow(maxDistance - arrivalDistance / 2, 2)) {
return CompletableFuture.completedFuture(PathState.TOO_FAR);
}
// Начинаем просчет пути до новой точки
var path = PathGenerator.generate(instance,
entity.getPosition(),
point,
arrivalDistance,
maxDistance,
pathVariance,
entity.getBoundingBox(),
entity.isOnGround(),
nodeGenerator);
// Если на время просчета пути уже известен результат действия
if (path.getState().isCompleted()) {
return CompletableFuture.completedFuture(path.getState());
}
return (destinationData = new DestinationPoint(Vec.fromPoint(point), arrivalDistance, path, new CompletableFuture<>()))
.future().thenApply(result -> {
resetDestinationData();
return result;
});
}
}
public void tick(long delta) {
synchronized (this) {
if (entity.isDead()) return;
var action = actor.getCurrentAction();
if (action != null) action.tick(actor, entity, delta);
if (destinationData != null) tickNavigator();
}
}
private void tickNavigator() {
if (entity.getPosition().distance(destinationData.position()) < destinationData.arrivalDistance()) {
var future = destinationData.future();
UncheckedRunnable.runIgnoreException(() -> future.complete(PathState.COMPLETED));
return;
}
var path = destinationData.path();
var currentTarget = path.getCurrent();
var nextTarget = path.getNext();
if (currentTarget == null || path.getCurrentType() == PathNode.Type.REPATH || path.getCurrentType() == null) {
recalculatePath(path);
return;
}
if (nextTarget == null) nextTarget = destinationData.position();
boolean nextIsRePath = nextTarget.sameBlock(Pos.ZERO);
nodeFollower.moveTowards(currentTarget, nodeFollower.movementSpeed(), nextIsRePath ? currentTarget : nextTarget);
// TODO: исправить баг, в данном месте, когда Entity не движется вообще. Сделать хорошую проверку на застревание
// TODO: присутствует баг, что если как-то помешать Entity следовать до точки, например поставить перед ним блок, то он перестанет идти
// TODO: хождение по неполноценным блокам - огромная проблема. Entity попросту застревает в них
if (nodeFollower.isAtPoint(currentTarget)) path.next();
else if (path.getCurrentType() == PathNode.Type.JUMP) nodeFollower.jump(currentTarget, nextTarget);
checkFinishedPath(path, destinationData.future());
}
private void recalculatePath(Path originalPath) {
var newPath = PathGenerator.generate(entity.getInstance(),
entity.getPosition(),
destinationData.position(),
destinationData.arrivalDistance(), originalPath.getMaxDistance(),
originalPath.getPathVariance(), entity.getBoundingBox(), entity.isOnGround(), nodeGenerator);
destinationData = new DestinationPoint(destinationData.position(), destinationData.arrivalDistance(), newPath, destinationData.future());
checkFinishedPath(newPath, destinationData.future());
}
private void checkFinishedPath(Path path, CompletableFuture<PathState> future) {
var state = path.getState();
if (state.isCompleted()) {
UncheckedRunnable.runIgnoreException(() -> future.complete(state));
}
}
public void resetDestinationData() {
synchronized (this) {
if (destinationData == null) return;
var future = destinationData.future();
destinationData = null;
if (!future.isDone()) future.complete(PathState.TERMINATED);
}
}
public boolean isCompleted() {
return getCurrentPathState().isCompleted();
}
}

View File

@ -0,0 +1,81 @@
package ru.dragonestia.msb3.api.ai;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import ru.dragonestia.msb3.api.ai.action.Action;
import ru.dragonestia.msb3.api.entity.EntityAI;
import java.util.Collection;
import java.util.LinkedList;
/**
* Класс для управления планированием расписания действий для сущности
*/
@RequiredArgsConstructor
public class Actor {
@Getter private final EntityAI entity;
@Getter @Setter private boolean active = false;
@Getter private Action currentAction;
private final LinkedList<Action> plannedActions = new LinkedList<>();
private void tryStartAction(Action action) {
if (isActive()) action.start(this, entity);
}
private void tryEndAction(Action action) {
if (isActive()) action.stop(this, entity);
}
/**
* Принудительно установить действие для исполнения сущностью. Данная установка игнорирует проверки начала действия.
* @param newAction Новое действие
* @apiNote Если передать null, то текущее действие прервется и приостановится выполнение действий из плана
*/
public void setCurrentAction(Action newAction) {
if (currentAction != null) tryEndAction(currentAction);
currentAction = newAction;
if (newAction != null) tryStartAction(newAction);
}
/**
* Принудительно установить действие для исполнения сущностью и при этом текущее действие помещается в
* план действий в самое начало. Использовать, для того чтобы прервать выполнение текущего действия,
* переключиться на другое и снова вернуться к той задаче, которую он изначально выполнял
* @param newAction Новое действие
*/
public void setCurrentActionAndRememberPrev(Action newAction) {
if (currentAction != null) plannedActions.addFirst(currentAction);
setCurrentAction(newAction);
}
/**
* Завершить текущее действие и перейти к следующему исходя из плана
*/
public void useNextAction() {
while (true) {
var action = plannedActions.poll();
if (action == null) break; // TODO: добавлять действия из генератора расписаний
if (!action.canStart(this, entity)) continue; // Действие не может начаться, так как условие не выполняется
setCurrentAction(action);
}
}
/**
* Добавить действие в план выполнения. Если у сущности нет текущей задачи, то оно приступит к задаче сразу же
* @param action Действие
*/
public void addNextAction(Action action) {
plannedActions.add(action);
if (currentAction == null) useNextAction();
}
/**
* Получить план действий для сущности
* @return План действий
*/
public Collection<Action> getPlannedActions() {
return plannedActions;
}
}

View File

@ -0,0 +1,43 @@
package ru.dragonestia.msb3.api.ai.action;
import ru.dragonestia.msb3.api.ai.Actor;
import ru.dragonestia.msb3.api.entity.EntityAI;
public interface Action {
/**
* Получить имя действия, которое примерно описывает что должна делать сущность
* @return Имя действия
*/
String getName();
/**
* Действие начало выполнение
* @param actor Действующее лицо
* @param entity Действующая сущность
*/
void start(Actor actor, EntityAI entity);
/**
* Выполнение действия раз в тик
* @param actor Действующее лицо
* @param entity Действующая сущность
* @param delta Сколько времени (в миллисекундах) прошло с предыдущего тика
*/
void tick(Actor actor, EntityAI entity, long delta);
/**
* Остановка действия
* @param actor Действующее лицо
* @param entity Действующая сущность
*/
void stop(Actor actor, EntityAI entity);
/**
* Может ли действие начаться? Данная проверка выполняется только, если действие находится в очереди действий
* @param actor Действующее лицо
* @param entity Действующая сущность
* @return Результат проверки
*/
boolean canStart(Actor actor, EntityAI entity);
}

View File

@ -1,43 +1,42 @@
package ru.dragonestia.msb3.api.entity.goal;
package ru.dragonestia.msb3.api.ai.action;
import lombok.Getter;
import lombok.Setter;
import net.minestom.server.entity.EntityCreature;
import net.minestom.server.entity.Player;
import net.minestom.server.entity.ai.GoalSelector;
import org.jetbrains.annotations.NotNull;
import ru.dragonestia.msb3.api.ai.Actor;
import ru.dragonestia.msb3.api.entity.EntityAI;
public class LookCloseGoal extends GoalSelector {
/**
* Entity смотрит на ближайшего игрока в радиусе 7 блоков
*/
public class LookClosePlayersAction implements Action {
private final static int MAX_DIST = 7 * 7;
@Setter @Getter private volatile boolean enabled = false;
private boolean lastSeePlayer = false;
@Getter private float defaultYaw;
@Getter private float defaultPitch;
public LookCloseGoal(@NotNull EntityCreature entityCreature) {
super(entityCreature);
defaultYaw = entityCreature.getPosition().yaw();
defaultPitch = entityCreature.getPosition().pitch();
@Override
public String getName() {
return "Смотрит на ближайших игроков";
}
@Override
public boolean shouldStart() {
return true;
public void start(Actor actor, EntityAI entity) {
defaultYaw = entity.getPosition().yaw();
defaultPitch = entity.getPosition().pitch();
}
@Override
public void start() {}
@Override
public void tick(long l) {
if ((l & 0b11) == 0) return;
public void tick(Actor actor, EntityAI entity, long delta) {
if ((delta & 0b11) == 0) return;
if (!enabled) {
if (lastSeePlayer) {
entityCreature.setView(defaultYaw, defaultPitch);
entity.setView(defaultYaw, defaultPitch);
lastSeePlayer = false;
}
return;
@ -46,8 +45,8 @@ public class LookCloseGoal extends GoalSelector {
Player closestPlayer = null;
double closestDistance = Double.MAX_VALUE;
for (var player: entityCreature.getViewers()) {
var distance = player.getPosition().distanceSquared(entityCreature.getPosition());
for (var player: entity.getViewers()) {
var distance = player.getPosition().distanceSquared(entity.getPosition());
if (distance > MAX_DIST) continue;
if (distance < closestDistance) {
@ -57,24 +56,24 @@ public class LookCloseGoal extends GoalSelector {
}
if (closestPlayer != null) {
entityCreature.lookAt(closestPlayer);
entity.lookAt(closestPlayer);
lastSeePlayer = true;
return;
}
if (lastSeePlayer) {
entityCreature.setView(defaultYaw, defaultPitch);
entity.setView(defaultYaw, defaultPitch);
lastSeePlayer = false;
}
}
@Override
public boolean shouldEnd() {
return false;
}
public void stop(Actor actor, EntityAI entity) {}
@Override
public void end() {}
public boolean canStart(Actor actor, EntityAI entity) {
return true;
}
public void setDefaultRotation(float yaw, float pitch) {
defaultYaw = yaw;

View File

@ -0,0 +1,105 @@
package ru.dragonestia.msb3.api.ai.action;
import lombok.Getter;
import lombok.Setter;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import ru.dragonestia.msb3.api.ai.Actor;
import ru.dragonestia.msb3.api.ai.navigator.PathState;
import ru.dragonestia.msb3.api.entity.EntityAI;
import ru.dragonestia.msb3.api.scheduler.Scheduler;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Entity патрулирует местность по заданному маршруту
*/
public class PatrolAction implements Action {
@Getter private final List<Vec> path = new ArrayList<>();
@Getter @Setter private double destinationRadius = 1;
@Getter private Vec currentPos;
private boolean increment = true;
private int posNumber = 0;
private EntityAI entity;
@Override
public String getName() {
return "Патрулирование территории";
}
@Override
public void start(Actor actor, EntityAI entity) {
walkToNextPos(entity);
this.entity = entity;
}
@Override
public void tick(Actor actor, EntityAI entity, long delta) {}
@Override
public void stop(Actor actor, EntityAI entity) {
entity.getAi().resetDestinationData();
this.entity = null;
}
@Override
public boolean canStart(Actor actor, EntityAI entity) {
return findNearestPos(entity) != null;
}
public void setPath(List<Vec> newPath) {
posNumber = 0;
increment = true;
path.clear();
path.addAll(newPath);
if (entity != null) walkToNextPos(entity);
}
private Vec findNearestPos(EntityAI entity) {
if (entity == null) return path.getFirst();
Vec nearest = null;
double dist = Double.MAX_VALUE;
for (var pos: path) {
var currentDist = entity.getDistance(pos);
if (currentDist < dist) nearest = pos;
}
return nearest;
}
private Vec useNextPos() {
int nextIndex = posNumber + (increment? 1 : -1);
if (nextIndex >= path.size()) increment = false;
if (nextIndex < 0) increment = true;
posNumber = posNumber + (increment? 1 : -1);
return currentPos = path.get(posNumber);
}
private void walkToNextPos(EntityAI entity) {
var nextPos = useNextPos();
var navigator = entity.getAi();
navigator.setPathTo(nextPos, destinationRadius)
.thenAccept(result -> {
if (result == PathState.TERMINATED) return;
if (result == PathState.TOO_FAR) {
Scheduler.ofEntity(entity).delayedTask(() -> {
entity.teleport(Pos.fromPoint(nextPos))
.thenRun(() -> walkToNextPos(entity));
}, Duration.ofSeconds(1));
return;
}
if (result == PathState.DEADLOCKED) {
entity.teleport(Pos.fromPoint(nextPos))
.thenRun(() -> walkToNextPos(entity));
return;
}
walkToNextPos(entity);
});
}
}

View File

@ -0,0 +1,7 @@
package ru.dragonestia.msb3.api.ai.navigator;
import net.minestom.server.coordinate.Vec;
import java.util.concurrent.CompletableFuture;
public record DestinationPoint(Vec position, double arrivalDistance, Path path, CompletableFuture<PathState> future) {}

View File

@ -0,0 +1,60 @@
package ru.dragonestia.msb3.api.ai.navigator;
import lombok.Getter;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
public class Path {
@Getter private final double maxDistance;
@Getter private final double pathVariance;
@Getter private final List<PathNode> nodes = new ArrayList<>();
private final AtomicReference<PathState> state = new AtomicReference<>(PathState.FOLLOWING);
private int index = 0;
public Path(double maxDistance, double pathVariance) {
this.maxDistance = maxDistance;
this.pathVariance = pathVariance;
}
public PathState getState() {
return state.get();
}
public void setState(PathState newState) {
state.set(newState);
}
@Override
public String toString() {
return nodes.toString();
}
public @Nullable PathNode.Type getCurrentType() {
if (index >= nodes.size()) return null;
var current = nodes.get(index);
return current.getType();
}
public @Nullable Point getCurrent() {
if (index >= nodes.size()) return null;
var current = nodes.get(index);
return new Vec(current.x(), current.y(), current.z());
}
public void next() {
if (index >= nodes.size()) return;
index++;
}
public Point getNext() {
if (index + 1 >= nodes.size()) return null;
var current = nodes.get(index + 1);
return new Vec(current.x(), current.y(), current.z());
}
}

View File

@ -0,0 +1,134 @@
package ru.dragonestia.msb3.api.ai.navigator;
import it.unimi.dsi.fastutil.objects.ObjectHeapPriorityQueue;
import it.unimi.dsi.fastutil.objects.ObjectOpenHashBigSet;
import lombok.experimental.UtilityClass;
import net.minestom.server.collision.BoundingBox;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.instance.block.Block;
import ru.dragonestia.msb3.api.ai.navigator.node.NodeGenerator;
import java.util.*;
@UtilityClass
public class PathGenerator {
private final Comparator<PathNode> pathNodeComparator = (first, second) -> (int) (((first.g() + first.h()) - (second.g() + second.h())) * 1000);
public static Path generate(Block.Getter getter, Pos orgStart, Point orgTarget, double closeDistance, double maxDistance, double pathVariance, BoundingBox boundingBox, boolean isOnGround, NodeGenerator generator) {
final Point start = (!isOnGround && generator.hasGravitySnap())
? orgStart.withY(generator.gravitySnap(getter, orgStart.x(), orgStart.y(), orgStart.z(), boundingBox, 100).orElse(orgStart.y()))
: orgStart;
final Point target = (generator.hasGravitySnap())
? orgTarget.withY(generator.gravitySnap(getter, orgTarget.x(), orgTarget.y(), orgTarget.z(), boundingBox, 100).orElse(orgTarget.y()))
: Pos.fromPoint(orgTarget);
var path = new Path(maxDistance, pathVariance);
computePath(getter, start, target, closeDistance, maxDistance, pathVariance, boundingBox, path, generator);
return path;
}
private static PathNode buildRepathNode(PathNode parent) {
return new PathNode(0, 0, 0, 0, 0, PathNode.Type.REPATH, parent);
}
private void computePath(Block.Getter getter, Point start, Point target, double closeDistance, double maxDistance, double pathVariance, BoundingBox boundingBox, Path path, NodeGenerator generator) {
double closestDistance = Double.MAX_VALUE;
double straightDistance = generator.heuristic(start, target);
int maxSize = (int) Math.floor(maxDistance * 10);
closeDistance = Math.max(0.8, closeDistance);
List<PathNode> closestFoundNodes = List.of();
var pStart = new PathNode(start, 0, generator.heuristic(start, target), PathNode.Type.WALK, null);
var open = new ObjectHeapPriorityQueue<>(pathNodeComparator);
open.enqueue(pStart);
var closed = new ObjectOpenHashBigSet<PathNode>(maxSize);
while (!open.isEmpty() && closed.size64() < maxSize) {
if (path.getState() == PathState.TERMINATED) {
return;
}
var current = open.dequeue();
if (((current.g() + current.h()) - straightDistance) > pathVariance) continue;
if (!withinDistance(current, start, maxDistance)) continue;
if (withinDistance(current, target, closeDistance)) {
open.enqueue(current);
break;
}
if (current.h() < closestDistance) {
closestDistance = current.h();
closestFoundNodes = List.of(current);
}
Collection<? extends PathNode> found = generator.getWalkable(getter, closed, current, target, boundingBox);
found.forEach(p -> {
if (getDistanceSquared(p.x(), p.y(), p.z(), start) <= (maxDistance * maxDistance)) {
open.enqueue(p);
closed.add(p);
}
});
}
PathNode current = open.isEmpty() ? null : open.dequeue();
if (current == null || !withinDistance(current, target, closeDistance)) {
if (closestFoundNodes.isEmpty()) {
path.setState(PathState.DEADLOCKED);
return;
}
current = closestFoundNodes.getFirst();
if (!open.isEmpty()) {
current = buildRepathNode(current);
}
}
while (Objects.requireNonNull(current).parent() != null) {
path.getNodes().add(current);
current = current.parent();
}
Collections.reverse(path.getNodes());
if (path.getCurrentType() == PathNode.Type.REPATH) {
path.setState(PathState.DEADLOCKED);
path.getNodes().clear();
return;
}
if (path.getNodes().isEmpty()) {
path.setState(PathState.DEADLOCKED);
return;
}
var lastNode = path.getNodes().getLast();
if (getDistanceSquared(lastNode.x(), lastNode.y(), lastNode.z(), target) > (closeDistance * closeDistance)) {
path.setState(PathState.DEADLOCKED);
return;
}
PathNode pEnd = new PathNode(target, 0, 0, PathNode.Type.WALK, null);
path.getNodes().add(pEnd);
path.setState(PathState.FOLLOWING);
}
private boolean withinDistance(PathNode point, Point target, double closeDistance) {
return getDistanceSquared(point.x(), point.y(), point.z(), target) < (closeDistance * closeDistance);
}
private double getDistanceSquared(double x, double y, double z, Point target) {
var dx = x - target.x();
var dy = y - target.y();
var dz = z - target.z();
return dx * dx + dy * dy + dz * dz;
}
}

View File

@ -0,0 +1,125 @@
package ru.dragonestia.msb3.api.ai.navigator;
import lombok.Getter;
import lombok.Setter;
import net.minestom.server.coordinate.Point;
import org.jetbrains.annotations.Nullable;
public class PathNode {
@Setter private double g;
@Setter private double h;
private PathNode parent;
private double pointX;
private double pointY;
private double pointZ;
private int hashCode;
@Setter @Getter private Type type;
public PathNode(double px, double py, double pz, double g, double h, @Nullable PathNode parent) {
this(px, py, pz, g, h, Type.WALK, parent);
}
public PathNode(double px, double py, double pz, double g, double h, Type type, @Nullable PathNode parent) {
this.g = g;
this.h = h;
this.parent = parent;
this.type = type;
this.pointX = px;
this.pointY = py;
this.pointZ = pz;
this.hashCode = cantor((int) Math.floor(px), cantor((int) Math.floor(py), (int) Math.floor(pz)));
}
public PathNode(Point point, double g, double h, Type walk, @Nullable PathNode parent) {
this(point.x(), point.y(), point.z(), g, h, walk, parent);
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (obj == null) return false;
if (obj == this) return true;
if (!(obj instanceof PathNode other)) return false;
return this.hashCode == other.hashCode;
}
@Override
public String toString() {
return "PathNode{point=(%s, %s, %s), d=%s, type=%s}".formatted(
pointX,
pointY,
pointZ,
g + h,
type
);
}
public double x() {
return pointX;
}
public double y() {
return pointY;
}
public double z() {
return pointZ;
}
public int blockX() {
return (int) Math.floor(pointX);
}
public int blockY() {
return (int) Math.floor(pointY);
}
public int blockZ() {
return (int) Math.floor(pointZ);
}
public double g() {
return g;
}
public double h() {
return h;
}
public void setPoint(double px, double py, double pz) {
this.pointX = px;
this.pointY = py;
this.pointZ = pz;
this.hashCode = cantor((int) Math.floor(px), cantor((int) Math.floor(py), (int) Math.floor(pz)));
}
public @Nullable PathNode parent() {
return parent;
}
public void setParent(@Nullable PathNode current) {
this.parent = current;
}
private static int cantor(int a, int b) {
int ca = a >= 0 ? 2 * a : -2 * a - 1;
int cb = b >= 0 ? 2 * b : -2 * b - 1;
return (ca + cb + 1) * (ca + cb) / 2 + cb;
}
public enum Type {
WALK,
JUMP,
FALL,
CLIMB,
CLIMB_WALL,
SWIM,
FLY,
REPATH
}
}

View File

@ -0,0 +1,50 @@
package ru.dragonestia.msb3.api.ai.navigator;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum PathState {
/**
* Путь не задан.
* @apiNote Можно получить только при получении текущего состояния пути Entity
*/
NONE(true, true),
/**
* Entity находится в состоянии пути к какой-то точке, либо же идет просчет пути до точки
* @apiNote Можно получить только при получении текущего состояния пути Entity
*/
FOLLOWING(false, true),
/**
* Путь успешно пройден.
* @apiNote Может являться результатом для CompletableFuture
*/
COMPLETED(true, true),
/**
* Entity зашел в тупик и не может найти путь.
* @apiNote Может являться результатом для CompletableFuture
*/
DEADLOCKED(true, false),
/**
* Путь был принудительно отклонен.
* @apiNote Может являться результатом для CompletableFuture
*/
TERMINATED(true, false),
/**
* Entity не может дойти до указанной точки так как она превышает лимит длинны пути.
* @apiNote Может являться результатом для CompletableFuture
*/
TOO_FAR(true, false),
;
private final boolean completed;
private final boolean success;
}

View File

@ -0,0 +1,66 @@
package ru.dragonestia.msb3.api.ai.navigator.follower;
import lombok.RequiredArgsConstructor;
import net.minestom.server.collision.CollisionUtils;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.attribute.Attribute;
import net.minestom.server.utils.position.PositionUtils;
import org.jetbrains.annotations.Nullable;
import ru.dragonestia.msb3.api.entity.EntityAI;
@RequiredArgsConstructor
public class GroundNodeFollower implements NodeFollower {
private final EntityAI entity;
@Override
public void moveTowards(Point direction, double speed, Point lookAt) {
var position = entity.getPosition();
var dx = direction.x() - position.x();
var dy = direction.y() - position.y();
var dz = direction.z() - position.z();
var dxLook = lookAt.x() - position.x();
var dyLook = lookAt.y() - position.y();
var dzLook = lookAt.z() - position.z();
// the purpose of these few lines is to slow down entities when they reach their destination
final double distSquared = dx * dx + dy * dy + dz * dz;
if (speed > distSquared) {
speed = distSquared;
}
var radians = Math.atan2(dz, dx);
var speedX = Math.cos(radians) * speed;
var speedZ = Math.sin(radians) * speed;
var yaw = PositionUtils.getLookYaw(dxLook, dzLook);
var pitch = PositionUtils.getLookPitch(dxLook, dyLook, dzLook);
var physicsResult = CollisionUtils.handlePhysics(entity, new Vec(speedX, 0, speedZ));
var newPosition = Pos.fromPoint(physicsResult.newPosition());
entity.refreshPosition(newPosition.withView(yaw, pitch));
}
@Override
public void jump(@Nullable Point point, @Nullable Point target) {
if (entity.isOnGround()) {
jump(4f);
}
}
@Override
public boolean isAtPoint(Point point) {
return entity.getPosition().distanceSquared(point) < 0.5 * 0.5;
}
public void jump(float height) {
entity.setVelocity(new Vec(0, height * 2.5f, 0));
}
@Override
public double movementSpeed() {
return entity.getAttribute(Attribute.MOVEMENT_SPEED).getValue();
}
}

View File

@ -0,0 +1,15 @@
package ru.dragonestia.msb3.api.ai.navigator.follower;
import net.minestom.server.coordinate.Point;
import org.jetbrains.annotations.Nullable;
public interface NodeFollower {
void moveTowards(Point target, double speed, Point lookAt);
void jump(@Nullable Point point, @Nullable Point target);
boolean isAtPoint(Point point);
double movementSpeed();
}

View File

@ -0,0 +1,128 @@
package ru.dragonestia.msb3.api.ai.navigator.node;
import net.minestom.server.collision.BoundingBox;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.instance.block.Block;
import ru.dragonestia.msb3.api.ai.navigator.PathNode;
import java.util.ArrayList;
import java.util.Collection;
import java.util.OptionalDouble;
import java.util.Set;
public class GroundNodeGenerator implements NodeGenerator {
private final static int MAX_FALL_DISTANCE = 5;
private final BoundingBox.PointIterator pointIterator = new BoundingBox.PointIterator();
private PathNode tempNode = null;
@Override
public Collection<? extends PathNode> getWalkable(Block.Getter getter, Set<PathNode> visited, PathNode current, Point goal, BoundingBox boundingBox) {
Collection<PathNode> nearby = new ArrayList<>();
tempNode = new PathNode(0, 0, 0, 0, 0, current);
int stepSize = (int) Math.max(Math.floor(boundingBox.width() / 2), 1);
if (stepSize < 1) stepSize = 1;
for (int x = -stepSize; x <= stepSize; ++x) {
for (int z = -stepSize; z <= stepSize; ++z) {
if (x == 0 && z == 0) continue;
double cost = Math.sqrt(x * x + z * z) * 0.98;
double floorPointX = current.blockX() + 0.5 + x;
double floorPointY = current.blockY();
double floorPointZ = current.blockZ() + 0.5 + z;
var optionalFloorPointY = gravitySnap(getter, floorPointX, floorPointY, floorPointZ, boundingBox, MAX_FALL_DISTANCE);
if (optionalFloorPointY.isEmpty()) continue;
floorPointY = optionalFloorPointY.getAsDouble();
var floorPoint = new Vec(floorPointX, floorPointY, floorPointZ);
var nodeWalk = createWalk(getter, floorPoint, boundingBox, cost, current, goal, visited);
if (nodeWalk != null && !visited.contains(nodeWalk)) nearby.add(nodeWalk);
for (int i = 1; i == 1; ++i) {
Point jumpPoint = new Vec(current.blockX() + 0.5 + x, current.blockY() + i, current.blockZ() + 0.5 + z);
OptionalDouble jumpPointY = gravitySnap(getter, jumpPoint.x(), jumpPoint.y(), jumpPoint.z(), boundingBox, MAX_FALL_DISTANCE);
if (jumpPointY.isEmpty()) continue;
jumpPoint = jumpPoint.withY(jumpPointY.getAsDouble());
if (!floorPoint.sameBlock(jumpPoint)) {
var nodeJump = createJump(getter, jumpPoint, boundingBox, cost + 0.2, current, goal, visited);
if (nodeJump != null && !visited.contains(nodeJump)) nearby.add(nodeJump);
}
}
}
}
return nearby;
}
private PathNode createWalk(Block.Getter getter, Point point, BoundingBox boundingBox, double cost, PathNode start, Point goal, Set<PathNode> closed) {
var n = newNode(start, cost, point, goal);
if (closed.contains(n)) return null;
if (Math.abs(point.y() - start.y()) > Vec.EPSILON && point.y() < start.y()) {
if (start.y() - point.y() > MAX_FALL_DISTANCE) return null;
if (!canMoveTowards(getter, new Vec(start.x(), start.y(), start.z()), point.withY(start.y()), boundingBox))
return null;
n.setType(PathNode.Type.FALL);
} else {
if (!canMoveTowards(getter, new Vec(start.x(), start.y(), start.z()), point, boundingBox)) return null;
}
return n;
}
private PathNode createJump(Block.Getter getter, Point point, BoundingBox boundingBox, double cost, PathNode start, Point goal, Set<PathNode> closed) {
if (Math.abs(point.y() - start.y()) < Vec.EPSILON) return null;
if (point.y() - start.y() > 2) return null;
if (point.blockX() != start.blockX() && point.blockZ() != start.blockZ()) return null;
var n = newNode(start, cost, point, goal);
if (closed.contains(n)) return null;
if (pointInvalid(getter, point, boundingBox)) return null;
if (pointInvalid(getter, new Vec(start.x(), start.y() + 1, start.z()), boundingBox)) return null;
n.setType(PathNode.Type.JUMP);
return n;
}
private PathNode newNode(PathNode current, double cost, Point point, Point goal) {
tempNode.setG(current.g() + cost);
tempNode.setH(heuristic(point, goal));
tempNode.setPoint(point.x(), point.y(), point.z());
var newNode = tempNode;
tempNode = new PathNode(0, 0, 0, 0, 0, PathNode.Type.WALK, current);
return newNode;
}
@Override
public boolean hasGravitySnap() {
return true;
}
@Override
public OptionalDouble gravitySnap(Block.Getter getter, double pointOrgX, double pointOrgY, double pointOrgZ, BoundingBox boundingBox, double maxFall) {
var pointX = (int) Math.floor(pointOrgX) + 0.5;
var pointY = (int) Math.floor(pointOrgY);
var pointZ = (int) Math.floor(pointOrgZ) + 0.5;
for (int axis = 1; axis <= maxFall; ++axis) {
pointIterator.reset(boundingBox, pointX, pointY, pointZ, BoundingBox.AxisMask.Y, -axis);
while (pointIterator.hasNext()) {
var block = pointIterator.next();
if (getter.getBlock(block.blockX(), block.blockY(), block.blockZ(), Block.Getter.Condition.TYPE).isSolid()) {
return OptionalDouble.of(block.blockY() + 1);
}
}
}
return OptionalDouble.empty();
}
}

View File

@ -0,0 +1,46 @@
package ru.dragonestia.msb3.api.ai.navigator.node;
import net.minestom.server.collision.BoundingBox;
import net.minestom.server.collision.CollisionUtils;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.instance.block.Block;
import ru.dragonestia.msb3.api.ai.navigator.PathNode;
import java.util.Collection;
import java.util.OptionalDouble;
import java.util.Set;
public interface NodeGenerator {
Collection<? extends PathNode> getWalkable(Block.Getter getter, Set<PathNode> visited, PathNode current, Point goal, BoundingBox boundingBox);
boolean hasGravitySnap();
OptionalDouble gravitySnap(Block.Getter getter, double pointX, double pointY, double pointZ, BoundingBox boundingBox, double maxFall);
default boolean canMoveTowards(Block.Getter getter, Point start, Point end, BoundingBox boundingBox) {
final Point diff = end.sub(start);
if (getter.getBlock(end) != Block.AIR) return false;
var res = CollisionUtils.handlePhysics(getter, boundingBox, Pos.fromPoint(start), Vec.fromPoint(diff), null, false);
return !res.collisionZ() && !res.collisionY() && !res.collisionX();
}
default boolean pointInvalid(Block.Getter getter, Point point, BoundingBox boundingBox) {
var iterator = boundingBox.getBlocks(point);
while (iterator.hasNext()) {
var block = iterator.next();
if (getter.getBlock(block.blockX(), block.blockY(), block.blockZ(), Block.Getter.Condition.TYPE).isSolid()) {
return true;
}
}
return false;
}
default double heuristic(Point node, Point target) {
return node.distance(target);
}
}

View File

@ -0,0 +1,50 @@
package ru.dragonestia.msb3.api.boot;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.GameMode;
import ru.dragonestia.msb3.api.module.FlatWorldModule;
import ru.dragonestia.msb3.api.module.MotdModule;
import ru.dragonestia.msb3.api.module.ResourcePackRepositoryModule;
import ru.dragonestia.msb3.api.script.ScriptRegistry;
import ru.dragonestia.msb3.api.script.defaults.SpawnTestActorScript;
import ru.dragonestia.msb3.api.util.ChunkPreloader;
import ru.dragonestia.msb3.api.world.WorldFactory;
import team.unnamed.creative.ResourcePack;
import java.io.File;
public class DebugAiBootstrap extends ServerInitializer {
public static void main(String[] args) {
ServerBootstrap.start("0.0.0.0", 25565, new DebugAiBootstrap());
}
@Override
public void onLoad() {}
@Override
public void onDefaultModulesLoaded() {
MotdModule.init("logo.png", "<gradient:#ff0059:#e06806><bold>msb3 server - AI test</bold></gradient>");
// Скачать мир можно здесь: http://files.dragonestia.ru/f/fd754add490b42929130/
FlatWorldModule.init(GameMode.CREATIVE, WorldFactory.anvil(new File("./debug_ai")).createWorldSync(), new Pos(0.5, 61, 0.5, -135, 0));
ScriptRegistry.register(SpawnTestActorScript::new);
}
@Override
public void onInitializeResources(ResourcePack resourcePack) {}
@Override
public void onResourcePackCompiled(ResourcePack resourcePack) {
ResourcePackRepositoryModule.init("0.0.0.0", 7270);
}
@Override
public void onServerStarted() {
var instance = FlatWorldModule.getWorld().getInstance();
ChunkPreloader.preload(instance, -1, -16, 18, 0);
instance.setTimeRate(0);
instance.setTime(5000);
}
}

View File

@ -0,0 +1,78 @@
package ru.dragonestia.msb3.api.entity;
import lombok.Getter;
import lombok.Setter;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Entity;
import net.minestom.server.entity.EntityType;
import net.minestom.server.entity.LivingEntity;
import net.minestom.server.entity.attribute.Attribute;
import net.minestom.server.event.EventDispatcher;
import net.minestom.server.event.entity.EntityAttackEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.utils.time.TimeUnit;
import org.jetbrains.annotations.NotNull;
import ru.dragonestia.msb3.api.ai.AI;
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Getter
public class EntityAI extends LivingEntity {
@Setter private int removalAnimationDelay = 1000;
private final AI ai = new AI(this);
public EntityAI(@NotNull EntityType entityType) {
this(entityType, UUID.randomUUID());
}
public EntityAI(@NotNull EntityType entityType, @NotNull UUID uuid) {
super(entityType, uuid);
getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.2);
heal();
}
@Override
public void update(long time) {
ai.tick(time);
super.update(time);
}
@Override
public CompletableFuture<Void> setInstance(@NotNull Instance instance, @NotNull Pos spawnPosition) {
ai.resetDestinationData();
return super.setInstance(instance, spawnPosition).thenRun(() -> {
var action = ai.getActor().getCurrentAction();
if (action != null) {
action.start(ai.getActor(), this);
}
});
}
@Override
public void kill() {
super.kill();
if (removalAnimationDelay > 0) scheduleRemove(Duration.of(removalAnimationDelay, TimeUnit.MILLISECOND));
else remove();
}
public void attack(Entity target, boolean swingHand) {
if (swingHand) swingMainHand();
EntityAttackEvent attackEvent = new EntityAttackEvent(this, Objects.requireNonNull(target));
EventDispatcher.call(attackEvent);
}
public void attack(Entity target) {
attack(target, false);
}
@Override
protected void remove(boolean permanent) {
super.remove(permanent);
ai.getActor().setActive(false);
}
}

View File

@ -3,7 +3,6 @@ package ru.dragonestia.msb3.api.entity;
import lombok.Getter;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
@ -27,7 +26,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
public class Human extends EntityCreature {
public class Human extends EntityAI {
private static final AtomicInteger freeId = new AtomicInteger(0);
private static final Map<String, Team> npcTeams = new ConcurrentHashMap<>();

View File

@ -1,10 +1,10 @@
package ru.dragonestia.msb3.api.player.defaults;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.entity.EntityCreature;
import net.minestom.server.network.packet.server.play.ParticlePacket;
import net.minestom.server.particle.Particle;
import net.minestom.server.timer.Task;
import ru.dragonestia.msb3.api.entity.EntityAI;
import ru.dragonestia.msb3.api.player.MsbPlayer;
import ru.dragonestia.msb3.api.player.PlayerContext;
import ru.dragonestia.msb3.api.scheduler.Scheduler;
@ -39,10 +39,10 @@ public class DebugParamsContext extends PlayerContext {
taskDebugRendererAiPathFinder = Scheduler.ofPlayer(getPlayer()).repeatingTask(() -> {
for (var entity: getPlayer().getInstance().getNearbyEntities(getPlayer().getPosition(), 32)) {
if (entity instanceof EntityCreature creature) {
var nodes = creature.getNavigator().getNodes();
if (nodes == null) continue;
for (var point: nodes) {
if (entity instanceof EntityAI creature) {
var destinationData = creature.getAi().getDestinationData();
if (destinationData == null) continue;
for (var point: destinationData.path().getNodes()) {
var packet = new ParticlePacket(PARTICLE_AI_PATH, point.x(), point.y() + 0.5, point.z(), 0, 0, 0, 0, 1);
entity.sendPacketToViewers(packet);
}

View File

@ -0,0 +1,40 @@
package ru.dragonestia.msb3.api.script.defaults;
import net.kyori.adventure.text.Component;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.EntityType;
import ru.dragonestia.msb3.api.ai.action.PatrolAction;
import ru.dragonestia.msb3.api.entity.EntityAI;
import ru.dragonestia.msb3.api.player.MsbPlayer;
import ru.dragonestia.msb3.api.script.InstantScript;
import ru.dragonestia.msb3.api.util.Params;
import java.util.List;
public class SpawnTestActorScript extends InstantScript {
public SpawnTestActorScript() {
super("test_actor");
}
@Override
public void start(MsbPlayer player, Params params) {
var instance = player.getInstance();
var spawnPos = player.getPosition();
var entity = new EntityAI(EntityType.SKELETON);
entity.setCustomName(Component.text("Test actor"));
entity.setCustomNameVisible(true);
var action = new PatrolAction();
action.setPath(List.of(
new Vec(10.22, 46.00, -6.13),
new Vec(12.99, 46.00, -22.02),
new Vec(11.82, 46.00, -39.27),
new Vec(47.15, 47.00, -39.14)
));
entity.getAi().getActor().setCurrentAction(action);
entity.setInstance(instance, spawnPos);
}
}